diff --git a/.github/release-notes/0.5.0.md b/.github/release-notes/0.5.0.md new file mode 100644 index 00000000..26b6b8f5 --- /dev/null +++ b/.github/release-notes/0.5.0.md @@ -0,0 +1,69 @@ +Flint 0.5 introduces formal visual themes: one specification can now carry +layout behavior, semantic presentation, and visual identity across an entire +chart library. + +### Define the visual system once + +Add `theme_spec` beside `chart_spec`. The chart spec continues to define what +the chart means; the theme defines how that meaning is presented. + +```json +{ + "chart_spec": { + "chartType": "Bar Chart", + "encodings": { + "x": { "field": "region" }, + "y": { "field": "revenue" } + } + }, + "theme_spec": "economist" +} +``` + +Themes participate in compilation. They can guide spacing and density, labels +and legends, axes and annotations, mark geometry, typography, color, and chart +furniture while adapting those decisions to each chart. + +### Start from nine visual themes + +Flint ships New York Times, Economist, Swiss, Nature, McKinsey, Datawrapper, +Power BI, Power BI Light, and Cartoon presets. The +[visual-theme wall](https://microsoft.github.io/flint-chart/#/themes) 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: + +```json +{ + "theme_spec": { + "extends": "economist", + "id": "our-brand", + "ink": { + "series": { + "single": "#6b3fa0" + } + } + } +} +``` + +Nested objects merge; arrays and scalar values replace inherited values. See +[Using themes](https://microsoft.github.io/flint-chart/#/documentation/theme-spec) +for the complete vocabulary and examples. + +### Use themes with agents + +The MCP server adds `list_themes` so an agent can inspect the available visual +systems and their authoring guidance. The interactive MCP App also exposes a +theme picker for Vega-Lite charts. + +ThemeSpec is currently realized by the Vega-Lite backend. Other backends +continue to accept the shared Flint input but do not yet apply `theme_spec`. + +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.1...0.5.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 50204786..17f211df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Formal visual themes for Vega-Lite through the new top-level `theme_spec` + field. Callers can select one of nine built-in presets, provide a custom + `ThemeSpec`, or inherit a preset with `extends` and override selected fields. + Nested objects merge while arrays and scalar values replace inherited values. +- A semantic theme-grounding system that applies layout behavior, presentation + rules, mark geometry, typography, color, labels, legends, axes, annotations, + and chart furniture as one visual system across chart types and data shapes. +- Public theme APIs: `ThemeSpec`, `ThemePreset`, `THEME_PRESETS`, + `listThemePresets()`, and `resolveThemeSpec()`. +- Theme discovery in the MCP server through `list_themes`, plus preset selection + in the interactive MCP App. +- A public visual-theme wall, a complete **Using themes** guide, and + preset/custom/inherited live examples on the Flint project site. + +### Changed + +- Vega-Lite assembly now grounds the selected theme before layout and realizes + its decisions throughout compilation instead of applying a post-render style + layer. Existing inputs without `theme_spec` retain Flint's default behavior. + ## [0.4.1] - 2026-07-27 ### Changed diff --git a/README.md b/README.md index 32a93f33..e3110e77 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![CI](https://github.com/microsoft/flint-chart/actions/workflows/ci.yml/badge.svg)](https://github.com/microsoft/flint-chart/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -**Please visit:** [**Flint Project Site**](https://microsoft.github.io/flint-chart/) | [**MCP Server Guide**](https://microsoft.github.io/flint-chart/#/mcp) | [**中文主页**](https://microsoft.github.io/flint-chart/#/zh) +**Please visit:** [**Flint Project Site**](https://microsoft.github.io/flint-chart/) | [**Visual Themes**](https://microsoft.github.io/flint-chart/#/themes) | [**MCP Server Guide**](https://microsoft.github.io/flint-chart/#/mcp) | [**中文主页**](https://microsoft.github.io/flint-chart/#/zh) Flint is a visualization intermediate language that lets **AI agents create expressive, polished visualizations from simple, human-editable chart specs**. @@ -38,6 +38,9 @@ This repo contains two main components: semantic types such as `Rank`, `Temperature`, `Price`, or `Country`. - **Automatic layout.** Flint adapts sizing, spacing, labels, marks, and legends to the data cardinality, chart design, and canvas constraints. +- **Formal visual themes.** Define layout behavior, semantic presentation, and + visual identity once, then apply them across a chart library with a preset, + custom `ThemeSpec`, or inherited theme. - **Multiple backends.** Compile one input to backend-native output across [Vega-Lite](https://vega.github.io/vega-lite/), [ECharts](https://echarts.apache.org/), @@ -110,6 +113,43 @@ const plotlyFigure = assemblePlotly(input); const excelArtifact = assembleExcel(input); ``` +## Apply Visual Themes + +`theme_spec` sits beside `chart_spec`: the chart spec defines what the chart +means, while the theme defines how that meaning is presented. A theme can guide +layout, labels, legends, axes, mark geometry, typography, and color as one +coherent visual system. + +Use one of Flint's nine built-in presets: + +```ts +const themedSpec = assembleVegaLite({ + ...input, + theme_spec: 'economist', +}); +``` + +Or inherit a preset and override only the decisions that belong to your brand: + +```ts +const brandedSpec = assembleVegaLite({ + ...input, + theme_spec: { + extends: 'economist', + id: 'our-brand', + ink: { + series: { single: '#6b3fa0' }, + }, + }, +}); +``` + +Nested objects merge; arrays and scalar values replace the inherited value. +ThemeSpec currently affects Vega-Lite output. Compare all presets on the +[theme wall](https://microsoft.github.io/flint-chart/#/themes) and see +[Using themes](docs/theme-spec.md) for the complete custom and inherited-theme +reference. + See the [API reference](docs/api-reference.md), backend references for [Vega-Lite](docs/reference-vegalite.md), [ECharts](docs/reference-echarts.md), [Chart.js](docs/reference-chartjs.md), [Plotly](docs/reference-plotly.md), and @@ -160,6 +200,7 @@ flint-chart/ The [project site](https://microsoft.github.io/flint-chart/) is the main entry point for examples, the live editor, and concept docs. For source-level references, start with the [API reference](docs/api-reference.md), the +[theme guide](docs/theme-spec.md), the [Flint MCP project page](https://microsoft.github.io/flint-chart/#/mcp), or the [Development guide](docs/DEVELOPMENT.md). See the [changelog](CHANGELOG.md) for notable changes in each release. diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index 3b3fbedc..daed293c 100644 --- a/agent-skills/flint-chart-author/SKILL.md +++ b/agent-skills/flint-chart-author/SKILL.md @@ -83,15 +83,19 @@ published, use the npm package or MCP server for released workflows. interface ChartAssemblyInput { // Bound by the HOST or by you, depending on the situation (see below). data: { values: any[] } | { url: string }; - semantic_types?: Record; // field → semantic type ← you write this + semantic_types?: Record; // field → type ( ← you write this) chart_spec: { // ← you write this chartType: string; // e.g. "Scatter Plot" + title?: string; // the headline — write one + subtitle?: string; // what is measured, of whom, when, in what units encodings: Record; // channel → { field, ... } (or array) baseSize?: { width: number; height: number }; // target layout size, default 400×320 canvasSize?: { width: number; height: number }; // optional hard ceiling on stretch chartProperties?: Record; // per-chart tuning (optional) }; options?: Record; // global layout options (rarely needed) + field_display_names?: Record; // field → readable axis/legend title + theme_spec?: string | { extends: string; [key: string]: any }; // preset or preset override (Vega-Lite only) } ``` @@ -167,6 +171,77 @@ For a Vega-Lite-specific style tweak: This edited Vega-Lite spec is no longer a portable Flint spec. Do not send it to `render_chart`; use `render_chart` only for Flint `ChartAssemblyInput`. +## Write a headline + +Set `chart_spec.title` to the finding, in a sentence, and `chart_spec.subtitle` +to the reading of it — what is measured, of whom, when, in what units: + +``` +title: "A pyramid that is no longer a pyramid" +subtitle: "United States population by age and sex, 2020, millions" +``` + +`Jan`, `Cairo`, `Chrome` name their own kind; `26`, `5,300`, `0.42` do not, and +the headline is where they get named. Leave it out only where the chart is not +read on its own — a sparkline in a cell, a tile under its own caption. Nothing +breaks: with no headline to lean on, the compiler keeps the axis titles instead. + +## Visual themes (`theme_spec`) + +Use one of two forms. Prefer a preset unless the user asks for a specific +brand adjustment. + +### 1. Use a preset + +Call `list_themes` to choose an id, then place it beside `chart_spec`: + +```json +{ "chart_spec": { ... }, "theme_spec": "economist" } +``` + +| id | what it is for | +| --- | --- | +| `nyt` | Newsroom graphics: headline states the finding, values on the marks, series named at their ends. | +| `economist` | Print weekly: compact, flat headline over a deck, units repeated down the ruler. | +| `swiss` | International Typographic Style: strong grid structure, black typography, and a focused red accent. | +| `nature` | Journal figure: small panel, axis titles with units, statistics beside the fit. | +| `mckinsey` | Consulting deck: wide bands, every value printed, headline states the takeaway. | +| `datawrapper` | Embedded web chart: narrow column, plain headline and deck, rule under the footer. | +| `powerbi` | Dashboard tile: compact, legend to the right, latest point emphasised. | +| `powerbi-light` | Light dashboard tile: white canvas, fine gridlines, and bright categorical color. | +| `cartoon` | Playful illustration: warm paper, rounded type, bold outlines, and bright color. | + +### 2. Override a preset + +Keep overrides narrow and state only what the user wants to change: + +```json +{ + "theme_spec": { + "extends": "economist", + "id": "our-brand", + "ink": { + "series": { + "single": "#6b3fa0" + } + } + } +} +``` + +Common simple overrides are `ink.surface.canvas`, `ink.series.single`, +`ink.series.categorical`, `type.headline.family`, and `layout.density` +(`"compact"`, `"normal"`, or `"airy"`). If replacing +`ink.series.categorical`, also replace `categoricalExtended` so charts with +many series keep the requested brand palette. + +Do not copy an entire preset or invent theme keys. A theme controls +presentation; fields, aggregation, filtering, and sorting still belong in the +chart input. ThemeSpec currently affects Vega-Lite only. + +Full reference: +https://microsoft.github.io/flint-chart/#/documentation/theme-spec + ## Step 1 — pick `chartType` Use one of the registered names **exactly**. Vega-Lite is the default and @@ -250,8 +325,8 @@ support a subset (verify if targeting a non-VL backend): `"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`, `"Parallel Coordinates"`, `"Graph"`, `"Tree"`. - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, - Combo, Line, Bump, Area, Range Area, Pie, Doughnut, Histogram, Radar, Rose, - Slope, Connected Scatter. + Lollipop, Bump, Combo, Line, Area, Range Area, Pie, Doughnut, Histogram, + Radar, Rose, Slope, Connected Scatter. You do not need to call the library or inspect its source to author the input — pick from this table. @@ -333,6 +408,29 @@ What choosing well gets you (automatically): If you don't know, use `Quantity` for numbers, `Category` for strings, `Date`/`DateTime` for date-shaped values. Do **not** invent type names. +### Saying more than the type name + +A field's entry can be an object instead of a string when the type alone +understates what you know: + +```json +"semantic_types": { + "anomaly": { "semanticType": "Quantity", "unit": "°C", "divergingMidpoint": 0 }, + "rating": { "semanticType": "Score", "intrinsicDomain": [1, 5] } +} +``` + +- `unit` — the unit or currency code: `"USD"`, `"°C"`, `"kg"`. +- `intrinsicDomain` — the field's own bounds, for bounded scales only: `[1, 5]` + for a five-star rating, `[0, 100]` for a percentage score. Not for + open-ended measures. +- `divergingMidpoint` — where the middle colour of a diverging scale sits. + Set it if you can tell what the reader is comparing against; leave it out if + you can't. +- `sortOrder` — the order the categories should appear in, when the order in + the data is not the one you want and it isn't alphabetical either: + `["Low", "Medium", "High"]`. For a handful of categories, not a long list. + ## Chart-level properties (`chartProperties`) `chartProperties` is an optional per-chart tuning map. Set a property only @@ -365,7 +463,8 @@ derived). Values are clamped to the ranges shown. | Lollipop | `dotSize` | 20–300 (80) | Circle size (px) | | Waterfall | `cornerRadius` | 0–8 (0) | Round bar corners | | Waterfall | `totals` | `auto` \| `none` \| `first` \| `last` \| `both` (`auto`) | Which bars anchor to zero as totals (only when no Type column) | -| Waterfall | `showTextLabels` | boolean (false) | Render value labels on bars | +| Waterfall | `showTextLabels` | boolean (false) | Legacy spelling of `showValueLabels`; still accepted | +| Bar / Grouped Bar / Stacked Bar / Lollipop / Pyramid / Pie / Donut / Heatmap / Waterfall | `showValueLabels` | boolean | Print the numbers on the marks. Works with or without a theme: unset, it follows the house's own habit at this density (and with no house named, stays off), so the default the compiler reports is always the honest one. Set it to overrule that for one chart. Reported inapplicable (and ignored) where the marks are too dense to carry readable numbers, or where the template already writes its own text, so it is never a control that does nothing. On a stacked bar each segment prints its own value in the middle of the segment (at the edge it would read as the running total); segments too thin to hold a line of text go unlabelled, and a normalized stack prints each segment's share rather than its raw value, since the share is what the length shows. The printed number is rounded to roughly three significant figures — with a k/M suffix once the values get long, and enough decimals that the smallest value in the series still says something — so a raw `3.14159265` lands as `3.14` and a series of `0.001` to `5000` reads at both ends. Rounding never goes so far that two marks of different size print the same number, or that a non-zero value prints as `0`; where a house asked for a coarser precision than that, the digits are raised until the labels agree with the marks. | | Regression | `regressionMethod` | `linear` \| `log` \| `exp` \| `pow` \| `quad` \| `poly` (`linear`) | Fit method | | Regression | `polyOrder` | 1–5 (3) | Polynomial order (when `poly`) | | Radar | `filled` | boolean (true) | Fill the polygon | @@ -395,6 +494,8 @@ default: - **Sort a category axis by its measure:** `encodings.x = { field: "name", sortBy: "y", sortOrder: "descending" }`. - **Pick a color scheme:** `encodings.color = { field: "region", scheme: "tableau10" }`. - **Override an inferred type:** `encodings.x = { field: "year", type: "ordinal" }` (e.g. treat a year as discrete bands). +- **Use readable field titles:** `field_display_names = { percentageOfCountries: "Percentage of countries" }`. + Keep encodings bound to the real column name; Flint uses the display name for axis titles and legend headers. - **Resize the chart:** Flint sizes from two numbers — `baseSize` (the *target* it aims for, default 400×320) and `canvasSize` (a *hard ceiling* it may never exceed). With dense data the chart stretches from base toward the ceiling. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2c52ddb6..0aa9c3de 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -68,5 +68,7 @@ Start with the guide that matches the surface you want to extend: ## Test coverage - **Smoke tests:** `packages/flint-js/tests/smoke.test.ts` -- **Visual coverage:** [Gallery](/gallery), driven by `TEST_GENERATORS` in test-data +- **Complete visual coverage:** [Full test cases](/playground/full-test-cases), driven by `TEST_GENERATORS` in `packages/flint-js/src/test-data/` +- **Curated examples:** [Gallery](/gallery) - **Shared fixtures:** `shared/test-data/`, consumed by JS and Python tests +- **Coverage matrices and authoring workflow:** [Chart engine test plan](/documentation/test-plan) diff --git a/docs/adding-a-backend.md b/docs/adding-a-backend.md index 5265869b..c0afb56d 100644 --- a/docs/adding-a-backend.md +++ b/docs/adding-a-backend.md @@ -111,9 +111,13 @@ Register in `templates/index.ts`: import defs, add them to the category map, and # §5 Site and gallery - **Gallery dev server:** `npm run site` from the repo root, then open `/gallery` +- **Full visual matrix:** open `/playground/full-test-cases`; it renders every generator registered in `packages/flint-js/src/test-data/index.ts` - **Supported backends:** update `site/src/shared/supported-backends.ts` if the new backend should appear in the UI - **Renderers:** only add a new React view (`site/src/components/`) when the spec format cannot reuse `VegaLiteView`, `EChartsView`, or `ChartjsView`. `TripleChart` currently covers VL + ECharts + Chart.js. +Use the [Chart engine test plan](/documentation/test-plan) to choose normal, +semantic, density, and edge-case coverage for backend bring-up. + Optional: wire the assembler into `agent-skills/mcp-server/` if MCP clients should be able to call it. --- @@ -134,5 +138,6 @@ A backend is ready when: # §7 Related - [Extending chart templates](/documentation/adding-a-chart-template) — `ChartTemplateDef` authoring +- [Chart engine test plan](/documentation/test-plan) — shared cases and backend bring-up coverage - [Auto Layout Algorithm](/documentation/layout-model) — what `computeLayout()` expects - [API reference](/documentation/api-reference) — `ChartAssemblyInput` and assembler entry points diff --git a/docs/api-reference.md b/docs/api-reference.md index 3ac68882..c0391951 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -116,6 +116,8 @@ interface ChartAssemblyInput { semantic_types?: Record; chart_spec: { chartType: string; + title?: string; // headline + subtitle?: string; // deck: what is measured, of whom, when, in what units encodings: Record; // string = field shorthand baseSize?: { width: number; height: number }; // target layout size, default 400×320 canvasSize?: { width: number; height: number }; // optional hard ceiling on stretch @@ -137,11 +139,30 @@ interface ChartAssemblyInput { Maps column name → semantic type. This drives encoding type, formatting, aggregation defaults, color class, and layout. See [Semantic Type](/documentation/semantic-types). +### `field_display_names` + +Maps raw column names to readable presentation labels used for axis titles and +legend headers. Keep encodings bound to the original field names: + +```ts +{ + field_display_names: { + percentageOfCountries: 'Percentage of countries' + }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'country', y: 'percentageOfCountries' } + } +} +``` + ### `chart_spec` | Field | Description | |-------|-------------| | `chartType` | Template name — must match a backend registry entry (`"Bar Chart"`, `"Heatmap"`, …) | +| `title` | The headline. Write one: `Jan` and `Cairo` name their own kind, `26` and `5,300` do not, and a theme that omits axis titles is delegating that naming to the headline. Vega-Lite only for now; where no headline is given, the compiler puts the axis titles back. | +| `subtitle` | The deck — what is measured, of whom, when, in what units. | | `encodings` | Channel → encoding map | | `baseSize` | **Target** layout size in pixels (default 400×320): the size the chart aims for with typical data. Dense data may stretch past it, up to the ceiling. | | `canvasSize` | **Hard ceiling:** the maximum size the chart may ever reach, including faceted grids. If omitted, the ceiling is `baseSize × options.maxStretch` (default 1.5×). Per-dimension caps are `βx = canvasSize.width / baseSize.width`, `βy = canvasSize.height / baseSize.height` (each ≥ 1). The base is clamped to the ceiling, so a `canvasSize` on its own acts as a fixed box the chart fills and shrinks to fit without overflowing. | diff --git a/docs/reference-chartjs.md b/docs/reference-chartjs.md index 4fece66b..20a01d99 100644 --- a/docs/reference-chartjs.md +++ b/docs/reference-chartjs.md @@ -6,7 +6,7 @@ The Chart.js backend is the lightweight embedding target for common chart famili ## What this page covers -This reference lists the 21 chart types currently supported by the Chart.js backend, grouped into 5 categories. Each chart entry shows: +This reference lists the 22 chart types currently supported by the Chart.js backend, grouped into 5 categories. Each chart entry shows: - **Encoding channels** — the visual roles accepted in `chart_spec.encodings`, such as `x`, `y`, `color`, `size`, `column`, or `row`. - **Options** — template-specific `chart_spec.chartProperties` keys, including control type, domain, default, availability, and description. @@ -85,6 +85,14 @@ _No template-specific parameters._ _No template-specific parameters._ +### ![](chart-icon-lollipop.svg) Lollipop Chart + +**Encoding channels:** `x`, `y`, `color`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `dotSize` | number | 20 – 300 (step 10) | `80` | always | Size of the dot mark. | + ### ![](chart-icon-combo.svg) Combo Chart **Encoding channels:** `x`, `y`, `column`, `row` diff --git a/docs/reference-echarts.md b/docs/reference-echarts.md index 4ee28465..cd730d55 100644 --- a/docs/reference-echarts.md +++ b/docs/reference-echarts.md @@ -65,6 +65,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `whiskerMethod` | choice | `iqr` (Tukey (1.5 × IQR)), `minmax` (Min–Max) | `iqr` | always | Whiskers | +| `showPoints` | toggle | on / off | `false` | conditional | Overlay point markers on the line. | | `showOutliers` | toggle | on / off | `true` | conditional | Outliers | | `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge | diff --git a/docs/reference-plotly.md b/docs/reference-plotly.md index 02fc7478..7c408078 100644 --- a/docs/reference-plotly.md +++ b/docs/reference-plotly.md @@ -104,7 +104,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `totals` | choice | `auto` (Auto), `none` (None), `first` (First only), `last` (Last only), `both` (First and last) | `auto` | always | Totals | -| `showTextLabels` | toggle | on / off | `false` | always | Render value labels on the marks. | +| `showTextLabels` | toggle | on / off | `false` | always | Render value labels on the marks (legacy spelling of showValueLabels). | ### ![](chart-icon-pyramid.svg) Pyramid Chart @@ -128,7 +128,8 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `showOutliers` | toggle | on / off | `true` | always | Outliers | +| `showPoints` | toggle | on / off | `false` | conditional | Overlay point markers on the line. | +| `showOutliers` | toggle | on / off | `true` | conditional | Outliers | ### ![](chart-icon-violin.svg) Violin Plot diff --git a/docs/reference-vegalite.md b/docs/reference-vegalite.md index c463217c..c7c6f7f7 100644 --- a/docs/reference-vegalite.md +++ b/docs/reference-vegalite.md @@ -106,6 +106,7 @@ The **Availability** column shows whether a parameter is `always` available or ` | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | | `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | | `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | +| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | ### ![](chart-icon-column-grouped.svg) Grouped Bar Chart @@ -115,6 +116,7 @@ The **Availability** column shows whether a parameter is `always` available or ` |---|---|---|---|---|---| | `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | +| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | ### ![](chart-icon-column-stacked.svg) Stacked Bar Chart @@ -124,6 +126,7 @@ The **Availability** column shows whether a parameter is `always` available or ` |---|---|---|---|---|---| | `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center) | — | conditional | Stacking strategy for overlapping series. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | +| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | ### ![](chart-icon-lollipop.svg) Lollipop Chart @@ -135,6 +138,7 @@ The **Availability** column shows whether a parameter is `always` available or ` | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | | `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | | `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | +| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | ### ![](chart-icon-waterfall.svg) Waterfall Chart @@ -144,7 +148,7 @@ The **Availability** column shows whether a parameter is `always` available or ` |---|---|---|---|---|---| | `cornerRadius` | number | 0 – 8 (step 1) | `0` | always | Corner radius for supported marks. | | `totals` | choice | `auto` (Auto), `none` (None), `first` (First), `last` (Last), `both` (Both) | `auto` | conditional | Totals | -| `showTextLabels` | toggle | on / off | `false` | always | Render value labels on the marks. | +| `showValueLabels` | toggle | on / off | `false` | always | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | ### ![](chart-icon-gantt.svg) Gantt Chart @@ -210,6 +214,10 @@ The **Availability** column shows whether a parameter is `always` available or ` | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `bandwidth` | number | 0.05 – 2 (step 0.05) | `0` | always | Kernel-density bandwidth (0 = auto). | +| `showPoints` | toggle | on / off | `false` | always | Overlay point markers on the line. | +| `showMedian` | toggle | on / off | `false` | always | Median rule | +| `showContour` | toggle | on / off | `false` | always | Contour | +| `medianWidth` | number | 0.2 – 1 (step 0.05) | `0.6` | conditional | Median width | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | ### ![](chart-icon-box-plot.svg) Boxplot @@ -219,6 +227,7 @@ The **Availability** column shows whether a parameter is `always` available or ` | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `whiskerMethod` | choice | `iqr` (Tukey (1.5 × IQR)), `minmax` (Min–Max) | `iqr` | always | Whiskers | +| `showPoints` | toggle | on / off | `false` | conditional | Overlay point markers on the line. | | `showOutliers` | toggle | on / off | `true` | conditional | Outliers | | `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | @@ -231,7 +240,9 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color` -_No template-specific parameters._ +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | ### ![](chart-icon-candlestick.svg) Candlestick Chart @@ -283,6 +294,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | | `logScale_x` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the x-axis. | | `logScale_y` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the y-axis. | @@ -295,6 +307,8 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| +| `showText` | toggle | on / off | `false` | always | Values | +| `showSeriesInLabel` | toggle | on / off | `false` | conditional | Name in label | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | | `logScale_x` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the x-axis. | | `logScale_y` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the y-axis. | @@ -344,6 +358,7 @@ _No template-specific parameters._ | `innerRadius` | number | 0 – 100 (step 5) | `0` | always | Inner radius as a percentage of the outer radius. | | `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | +| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | ### ![](chart-icon-doughnut.svg) Donut Chart @@ -351,9 +366,10 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `innerRadius` | number | 0 – 100 (step 5) | `0` | always | Inner radius as a percentage of the outer radius. | +| `innerRadius` | number | 0 – 100 (step 5) | `50` | always | Inner radius as a percentage of the outer radius. | | `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | +| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | ### ![](chart-icon-rose.svg) Rose Chart @@ -365,6 +381,7 @@ _No template-specific parameters._ | `alignment` | choice | `left` (Left (default)), `center` (Center) | — | always | Segment alignment for radial charts. | | `sortSlices` | choice | `none` (Data order), `descending` (Largest first), `ascending` (Smallest first) | `none` | always | Sort slices | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | +| `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | ### ![](chart-icon-radar.svg) Radar Chart @@ -389,7 +406,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `showTextLabels` | toggle | on / off | `false` | always | Render value labels on the marks. | +| `showValueLabels` | toggle | on / off | `false` | always | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | | `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | | `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | diff --git a/docs/test_plan.md b/docs/test_plan.md index 8c795dc1..122b00b2 100644 --- a/docs/test_plan.md +++ b/docs/test_plan.md @@ -2,12 +2,33 @@ ## Overview -Test data lives in `test-data/` as fixture generators (not executable test suites). -Each file exports generator functions that produce `TestCase[]` arrays. The gallery -UI (`ChartGallery.tsx`) uses `TEST_GENERATORS` and `GALLERY_SECTIONS` from -`test-data/index.ts` to render all tests interactively. - -**20 test-data files**, **~11,100 lines**, **53 named test generators**. +Visual test data lives in `packages/flint-js/src/test-data/` as fixture generators +(not executable test suites). Each case module exports generator functions that +produce `TestCase[]` arrays. The master `TEST_GENERATORS` registry in +`packages/flint-js/src/test-data/index.ts` exposes those cases to the gallery, +editor examples, documentation figures, and the dev playground. + +The current registry contains **31 case modules**, **126 generator groups**, and +**931 generated cases**. Open the site at `/playground/full-test-cases` to inspect +the complete reference set interactively. Each section renders lazily and selects +the first backend that supports its chart type. + +### Backend bring-up workflow + +When adding a backend or porting a chart template: + +1. Add or reuse cases in `packages/flint-js/src/test-data/` that cover the chart's + normal shape, semantic variants, density/cardinality limits, and edge cases. +2. Export the generator and register it in `TEST_GENERATORS` in `test-data/index.ts`. +3. Run `npm run site`, then inspect `/playground/full-test-cases` for broad visual + coverage and `/gallery` for curated product-facing examples. +4. Add focused executable assertions under `packages/flint-js/tests/` for artifact + shape and backend-specific behavior. +5. Run `npm run typecheck` and `npm run test` from the repository root. + +The playground is a visual regression surface, not a replacement for executable +tests. A backend is ready only when both the shared cases render correctly and its +focused test suite passes. ### Test categories diff --git a/docs/theme-spec.md b/docs/theme-spec.md new file mode 100644 index 00000000..fe0fd614 --- /dev/null +++ b/docs/theme-spec.md @@ -0,0 +1,119 @@ +# Using themes + +A theme in Flint is a formal specification that describes how a chart system behaves throughout creation. It works at three levels: + +- **Layout algorithm.** Controls how the compiler allocates space, relates elements, and adapts labels, legends, axes, and annotations. +- **Semantic roles.** Sets presentation rules by meaning, so field roles, order, grouping, and hierarchy drive contrast, emphasis, and representation. +- **Geometry and typography.** Defines type, color, surfaces, line weight, corners, and mark shapes to carry a consistent visual identity. + +[Explore themes](/themes) applies these three levels to the same set of charts so you can compare their effects directly. + +`theme_spec` sits beside `chart_spec` in a `ChartAssemblyInput`. The chart spec says **what the chart means**. The theme spec says **how that meaning should be presented**. + +> ThemeSpec currently affects Vega-Lite output. Other backend assemblers ignore it. + +## Three ways to use `theme_spec` + +Use the tabs below to compare the three accepted forms on a World Bank life-expectancy chart. The snippet abbreviates `data` and `semantic_types`, while keeping `chart_spec` visible for context and `theme_spec` highlighted. The chart still compiles the complete input. + +```flint-theme-spec +theme-spec +``` + +### 1. Name a preset + +Use a preset ID when one of Flint's built-in design systems fits your product. The shortest form is: + +```json +{ + "theme_spec": "economist" +} +``` + +Flint currently ships these presets: + +```flint-theme-presets +presets +``` + +Preset IDs are stable API values. Use `listThemePresets()` when a product needs to build its own picker. + +### 2. Write a custom theme + +Pass a JSON object to define a design system of your own: + +```json +{ + "theme_spec": { + "id": "our-brand", + "ink": { + "series": { + "single": "#6b3fa0" + } + }, + "layout": { + "density": "compact" + } + } +} +``` + +Every field is optional. Start with the decisions that matter to your product, then add detail as the system grows. + +| Block | What it controls | +| --- | --- | +| `ink` | Surfaces, text, structural lines, accents, and categorical or numeric color | +| `type` | Headline, axis, label, annotation, and display-number typography | +| `structure` | Axes, ticks, grids, baselines, and frames | +| `marks` | Band width, strokes, corners, outlines, separators, and point sizing | +| `labels`, `legend`, `dataLabels` | Truncation, placement, visibility, and label ink | +| `annotation` | Units, axis titles, number formats, point emphasis, and statistics | +| `layout`, `facets` | Density, title spacing, band steps, panel spacing, and shared scales | +| `chartDefaults`, `compileDefaults` | House defaults for chart controls, base size, canvas size, and layout limits | +| `furniture` | Rules, tabs, and other recurring chart chrome | +| `variants` | Semantic conditions that adapt policy to a chart's role, density, or shape | + +Theme rules are semantic. For example, `structure.grid.measure` controls the grid used to read values, whichever physical axis carries the measure. `legend.placement` gives the compiler an ordered set of acceptable positions rather than fixed coordinates. This is what lets one theme generalize across different chart types, data, and canvas sizes. + +### 3. Inherit and override + +Use `extends` when a preset is close to your brand: + +```json +{ + "theme_spec": { + "extends": "economist", + "id": "our-economist", + "ink": { + "series": { + "single": "#6b3fa0" + } + }, + "type": { + "headline": { + "family": "Aptos Display" + } + } + } +} +``` + +Flint starts with the named preset and deep-merges your object over it. Nested objects merge, so changing `ink.series.single` keeps the preset's surfaces, text colors, ramps, and other series rules. Arrays and scalar values replace the preset value in full. + +`categorical` and `categoricalExtended` are separate palettes. If your brand replaces categorical color, override both so charts with more series do not fall back to the preset's extended palette. + +Use inheritance for a durable brand variation. It keeps the preset's compiler behavior while letting you own the identity that should differ. + +## What belongs in a theme + +A theme governs presentation and compiler behavior. It may decide: + +- how tightly elements are packed; +- which labels can move outside a mark; +- whether a legend belongs inline, above, or beside the plot; +- how semantic groups receive contrast and emphasis; +- how axes, grids, marks, type, and surfaces are drawn. + +A theme does **not** choose fields, aggregation, filtering, or sorting. Those choices determine what the chart means and belong in `data`, `semantic_types`, and `chart_spec`. + +Keep that boundary and a theme can travel safely across data, chart types, canvas sizes, and products. diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index 9e5c0044..e7fdeb2b 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -102,6 +102,24 @@ That is the core workflow. The DataSpec says what the data *is*. The ChartSpec says how you want to *look at it*. Paste the JSON into the [online editor](/editor) to edit it live. +### Optional: choose a theme + +Add `theme_spec` beside `chart_spec` to apply one of Flint's design systems: + +```json +{ + "theme_spec": "economist" +} +``` + +A Flint theme is a formal specification that influences layout, semantic +presentation, and visual identity during compilation. It is more than a color +or font preset. ThemeSpec currently applies to Vega-Lite output. + +See [Using themes](/documentation/theme-spec) to browse the presets, create a +theme, or inherit one and override selected rules. [Explore themes](/themes) +shows the same charts under every preset. + ## Compile it In JavaScript or TypeScript, pass the same input to an assembler: @@ -164,6 +182,8 @@ Python support will use the same input shape and is planned for a later release. - [Example: a data story](/documentation/data-story) shows why the split matters: one DataSpec becomes five different charts by changing only the ChartSpec. +- [Using themes](/documentation/theme-spec) explains preset, custom, and + inherited ThemeSpecs. - [Set up Flint MCP](/documentation/setup-flint-mcp) shows how to connect the MCP server when you want an agent to render charts from chat or an IDE. - [Agent workflows](/documentation/agent-workflows) shows how to embed Flint's diff --git a/docs/website-design-assets/antv-g6-example-editor-three-column.png b/docs/website-design-assets/antv-g6-example-editor-three-column.png deleted file mode 100644 index 5aaad95b..00000000 Binary files a/docs/website-design-assets/antv-g6-example-editor-three-column.png and /dev/null differ diff --git a/docs/website-design-assets/antv-g6-gallery-grid.png b/docs/website-design-assets/antv-g6-gallery-grid.png deleted file mode 100644 index c94fb71b..00000000 Binary files a/docs/website-design-assets/antv-g6-gallery-grid.png and /dev/null differ diff --git a/docs/website-design-assets/echarts-example-editor-option-preview.png b/docs/website-design-assets/echarts-example-editor-option-preview.png deleted file mode 100644 index 2a4d4038..00000000 Binary files a/docs/website-design-assets/echarts-example-editor-option-preview.png and /dev/null differ diff --git a/docs/website-design-assets/echarts-examples-line-category-gallery.png b/docs/website-design-assets/echarts-examples-line-category-gallery.png deleted file mode 100644 index 1ab005a1..00000000 Binary files a/docs/website-design-assets/echarts-examples-line-category-gallery.png and /dev/null differ diff --git a/docs/website-design-assets/observable-home-hero-collage.png b/docs/website-design-assets/observable-home-hero-collage.png deleted file mode 100644 index 56ff4428..00000000 Binary files a/docs/website-design-assets/observable-home-hero-collage.png and /dev/null differ diff --git a/docs/website-design-assets/observable-plot-gallery-line-moving-average.png b/docs/website-design-assets/observable-plot-gallery-line-moving-average.png deleted file mode 100644 index afc49e03..00000000 Binary files a/docs/website-design-assets/observable-plot-gallery-line-moving-average.png and /dev/null differ diff --git a/docs/website-design-assets/vega-lite-example-gallery-index.png b/docs/website-design-assets/vega-lite-example-gallery-index.png deleted file mode 100644 index 0960b82e..00000000 Binary files a/docs/website-design-assets/vega-lite-example-gallery-index.png and /dev/null differ diff --git a/docs/website-design-assets/vega-lite-simple-bar-chart-example.png b/docs/website-design-assets/vega-lite-simple-bar-chart-example.png deleted file mode 100644 index 29d70dd8..00000000 Binary files a/docs/website-design-assets/vega-lite-simple-bar-chart-example.png and /dev/null differ diff --git a/docs/website-design-plan.md b/docs/website-design-plan.md deleted file mode 100644 index 937ca786..00000000 --- a/docs/website-design-plan.md +++ /dev/null @@ -1,320 +0,0 @@ -# Flint Chart 官网 / 示例站设计计划(Gallery + Live Editor) - -> **状态:** 设计草案(仅规划,未改代码) -> **日期:** 2026-05-14 -> **范围:** `examples/gallery`、`examples/editor` 的体验与叙事;可选与主文档站或 GitHub Pages 联动 - ---- - -## 1. 产品目标(读者应在短时间内建立何种认知) - -1. **Flint 表达意图更短**:输入为「表格数据 + 字段级 `semantic_types` + 高层 `chart_spec`」,由编译器生成各后端的完整图表配置;用户无需从零编写 Vega-Lite 的 `encoding`、`scale`、`axis`、`legend` 等冗长 JSON。 -2. **默认可读、观感专业**:Gallery 以**大图、清晰栅格、统一留白**为主,避免多枚小图并列削弱第一印象(与 AntV 示例站强调「开箱观感」的方向一致)。 -3. **与「手写 Vega-Lite」对照**:在**同一数据集与相近读图任务**下,用简短文案与(可选)编译产物并排,说明 Flint 如何把版式、格式、色板等决策前移到语义层与推荐逻辑,从而减少 spec 篇幅与决策分支。(Vega-Lite 对部分通道有类型推断与默认样式,但对业务友好的刻度、标签、色盘等,实践中仍常需显式配置;对比时表述应实事求是,见第 9 节。) -4. **Semantic types 有稳定入口**:用侧栏锚点、折叠长文或子路由之一,讲清 T0 / T1 / T2 分层与降级,并链接至 `design-semantics.md` 供深读。 - ---- - -## 2. 参考站点调研 - -下列外链指向各产品官方站点;**截图已放入本仓库** `docs/website-design-assets/`(含 AntV G6、Vega-Lite、Observable、**Apache ECharts**),在 GitHub 或本地 Markdown 预览中应相对于本文路径加载(若 IDE 预览不显示,请确认以仓库根目录打开工作区,并检查 Markdown 预览安全设置)。 - -### 2.0 AntV G6:图表示例栅格与「示例 + 编辑器」三栏工作台 - -[G6 图可视化引擎](https://g6.antv.antgroup.com/) 的「图表示例」与单示例页,在信息架构上同时承担 **检索示例** 与 **就地改代码**。 - -**图 1 — 图表示例首页:左侧分类 + 主区卡片栅格(缩略图 + 标题)** - -![AntV G6 图表示例:左侧分类导航与主区示例卡片栅格](./website-design-assets/antv-g6-gallery-grid.png) - -| 可借鉴点 | 对应本文章节 | -|----------|----------------| -| 侧栏多级分类(特性、场景案例、布局、交互等) | 第 5 节 Gallery、第 7 节导航 | -| 缩略图与短标题、主区分组标题 | 第 5.3 节(主区内视觉层级) | -| 顶栏:文档 / API / 示例 / 社区、搜索 | 第 7 节入口 | - -**图 2 — 单示例页:左导航、中大预览、右侧代码(JavaScript / Data 分 Tab)** - -![AntV G6 示例页:左导航、中画布、右侧代码与 Data Tab](./website-design-assets/antv-g6-example-editor-three-column.png) - -| 可借鉴点 | 对应本文章节 | -|----------|----------------| -| 预览占据视觉中心 | 第 4 节 Editor、第 5.3 节 | -| 代码与 **数据** 分 Tab | `data` 与 `semantic_types` 分区展示的参考 | -| 复制、运行、展开等工具条 | 第 8 节 P1 之后可增强 | - -**相关官方链接** - -- [G6 API · 数据](https://g6.antv.antgroup.com/api/data)(数据模型与 API 目录,可作「数据 / 语义」文档信息架构的参考) -- [G6 官网](https://g6.antv.antgroup.com/) -- [G2 图表示例(英文)](https://g2.antv.antgroup.com/en/examples)(通用统计图示例矩阵) - -> 上列截图为 AntV 官方界面,仅作设计与动线参考。 - -### 2.2 Vega-Lite:Example Gallery 与单示例页(含官方截图) - -[Vega-Lite](https://vega.github.io/vega-lite/) 的文档与 [Example Gallery](https://vega.github.io/vega-lite/examples/) 是 Flint 用户最熟悉、也最适合作为 **「手写 spec」对照物** 的参照;[Vega Editor](https://vega.github.io/editor/) 提供在线编辑与分享。 - -**图 3 — Example Gallery 索引区:以分级列表为主的目录页** - -![Vega-Lite Example Gallery:顶栏导航与按类目展开的示例链接列表](./website-design-assets/vega-lite-example-gallery-index.png) - -| 观察 | 对 Flint 的启示 | -|------|------------------| -| 类目完整、便于检索(Single-View、Layered、Facet、Interactive 等) | 保留「按场景 / generator 分组」的清晰度 | -| 首屏以文字目录为主,缩略图密度低于 AntV G6 图表示例首页 | Flint Gallery 若以 **缩略图 + 短标题** 为主,更易形成「一眼看到图」的差异 | -| 顶栏含 Documentation、Examples、Try Online 等 | 全局导航与「一键试玩」入口值得对齐 | - -**图 4 — 单示例页(Simple Bar Chart):图 + 说明 + 在线编辑器链接 + JSON 规格** - -![Vega-Lite 官方示例「Simple Bar Chart」:渲染结果与 Vega-Lite JSON Specification 片段](./website-design-assets/vega-lite-simple-bar-chart-example.png) - -| 观察 | 对 Flint 的启示 | -|------|------------------| -| 「View this example in the online editor」类链接 | 对应 Flint 第 5.1 / 5.4 节与第 7 节「Gallery → Editor」深链 | -| 即使简单柱状图,spec 仍包含 `$schema`、`data`、`mark`、`encoding` 等完整结构 | 对应第 4 节:在 Editor 中并排展示 **Flint 输入** 与 **编译得到的 Vega-Lite**,用篇幅对比体现「少写」 | -| 默认视觉偏文档演示风格 | Flint 若以默认观感与版式为卖点,需在 Gallery 用真实数据与统一主题证明 | - -**相关官方链接** - -- [Vega-Lite Example Gallery](https://vega.github.io/vega-lite/examples/) -- [Vega Editor](https://vega.github.io/editor/) -- [Vega-Lite 文档首页](https://vega.github.io/vega-lite/docs/) - -**设计取舍(文字摘要)** - -- Gallery 可沿用「按图表类型与复合视图能力分块」的思路,但首屏需有一句 **Flint 定位**,避免被误读为普通测试列表。 -- Editor 侧可学习 Vega Editor:**错误信息固定区域**、**示例切换不改变整体框架**。 -- 后续可在 `website-design-assets/` 增补「同一用例:Flint 输入 vs 生成 VL」的自制对比图,与上列官方截图并列说明。 - -### 2.3 Observable:首页叙事与 Observable Plot 示例(含官方截图) - -[Observable](https://observablehq.com/) 以响应式 Notebook 为核心;[Observable Plot](https://observablehq.com/plot/) 提供声明式 `Plot.plot({...})` API;示例合集以 Notebook 形式发布,例如官方 [Plot Gallery](https://observablehq.com/@observablehq/plot-gallery),将「结果图 + 简短代码」紧挨展示,与 Flint 希望的「先看到图、再看到多短能写出来」一致。Flint 仍以 **JSON 装配输入 + 多后端编译** 为主,不复制 Notebook 运行时;以下仅借鉴 **版式、首屏叙事与示例节奏**。 - -**图 5 — Observable Plot Gallery 单例:「Line with moving average」(图在上、代码在下)** - -![Observable Plot Gallery:Line with moving average 示例页,含散点、滑动平均线与 Plot.plot 代码块](./website-design-assets/observable-plot-gallery-line-moving-average.png) - -| 观察 | 对 Flint 的启示 | -|------|------------------| -| 面包屑 `OBSERVABLE PLOT > GALLERY`、标题与一段数据来源说明 | Gallery 用例页:**类目路径 + 一句话场景/数据来源**,再进入图与配置 | -| 图下方紧跟短代码(`marks`、`Plot.windowY` 等),左侧有运行/展开类控件 | Editor:**预览与输入同屏**;Flint 若用 JSON,仍可学习「图与配置垂直相邻、减少视线跳跃」 | -| 声明式 API、色板与参考线在少量行内表达 | 与第 1、4 节一致:强调 **意图层压缩**;Flint 用 `semantic_types` + `chart_spec` 而非手写 Plot 或 VL 细节 | - -**图 6 — Observable 官网首屏:主文案 + CTA + 图与代码拼贴背景** - -![Observable 官网首屏:深色网格背景、主标题与 Try it for free / Explore the docs 按钮及多图拼贴](./website-design-assets/observable-home-hero-collage.png) - -| 观察 | 对 Flint 的启示 | -|------|------------------| -| 强主标题与副文案,双按钮(试用 / 读文档) | GitHub Pages 或落地页:**一句定位 + Gallery / Editor / 文档** 主次按钮 | -| 背景拼贴多枚高质量缩略图 | Gallery 或首页:**多图氛围**与单卡大图主预览可结合(注意性能与首屏加载) | -| 中央卡片内「图 + 代码」一体化 | 与图 5 同理:强化「Flint 输入很短、图很清晰」的一体展示(不必采用 Observable 的深色品牌) | - -**相关官方链接** - -- [Observable 平台](https://observablehq.com/) -- [Observable Plot](https://observablehq.com/plot/) -- [Plot Gallery(Observable Notebook)](https://observablehq.com/@observablehq/plot-gallery) - -**设计取舍(文字摘要)** - -- **可学**:渐进式教程动线、首屏 CTA、示例页「先图后码」的信息顺序。 -- **不必照搬**:完整 Notebook 工作区、Fork/星标社区流、运行时单元格依赖;Flint MVP 保持 **静态示例站 + 本地/单页 Editor** 即可。 - -### 2.4 Apache ECharts:Examples 画廊与在线编辑器(含官方截图) - -[Apache ECharts](https://echarts.apache.org/en/index.html) 的 [Examples(英文索引)](https://echarts.apache.org/examples/en/index.html) 与单例在线编辑器,是 Flint **ECharts 后端**用户已熟悉的「配置项 `option` + 即时预览」范式;与 Vega-Lite 的声明式 spec 不同,ECharts 更偏 **命令式配置对象**,同样适合在 Flint Editor 中作为 **「编译输出对照」** 或「多引擎 Tab」心智参考(本仓库 `assembleECharts` 已存在)。 - -**图 7 — Examples:侧栏图表族(含图标)+ 主区缩略图栅格(以 Line 类目为例)** - -![Apache ECharts Examples:左侧 Line 等类目与图标,主区多列缩略图与标题](./website-design-assets/echarts-examples-line-category-gallery.png) - -| 观察 | 对 Flint 的启示 | -|------|------------------| -| 侧栏按图表类型分组并配小图标,当前类目高亮 | Gallery:**一眼可扫的分类 + 当前位置**;可与 generator / 场景树结合 | -| 主区多列缩略图 + 短标题,同类目下变体丰富 | 第 5.3 节:主区内 **图优先** 的卡片预览;侧栏「按类型」见第 7 节 | -| 主内容区提供 **Dark mode** 等主题切换 | Flint Gallery / Editor 可提供浅色 / 深色预览,验证默认可读性与导出一致性 | - -**图 8 — 单示例在线编辑器:左侧 `option` 代码、右侧实时渲染** - -![Apache ECharts 在线编辑器:左侧 option 与 Run,右侧折线图预览及工具条](./website-design-assets/echarts-example-editor-option-preview.png) - -| 观察 | 对 Flint 的启示 | -|------|------------------| -| 经典 **左码右图** 分栏,与 Gallery 点进示例后的工作台一致 | 与第 4、5.4、7 节「Gallery → Editor」及并排对照布局一致,降低学习成本 | -| Tab:Edit Code / Full Code / Option Preview;语言 JS / TS;**Run** 显式触发刷新 | 可学习:**错误与运行反馈**、全量配置与「最小片段」切换;Flint 若以自动编译为主,仍可保留「手动刷新 / 防抖」选项 | -| 基础折线亦需配置 `xAxis`、`yAxis`、`series` 等 | Editor 中展示 **Flint 输入 vs 生成的 ECharts option** 时,可并列说明「意图层」如何展开为轴与系列 | -| 预览区附带下载、截图、分享、渲染耗时等 | 第 8 节 P1 之后可选增强,利于演示与 issue 复现 | - -**相关官方链接** - -- [Apache ECharts · Examples(英文索引)](https://echarts.apache.org/examples/en/index.html) -- [Apache ECharts 官网](https://echarts.apache.org/en/index.html) -- [Handbook(入门与概念)](https://echarts.apache.org/handbook/en) -- [Option Manual(配置项手册)](https://echarts.apache.org/en/option.html) - -**设计取舍(文字摘要)** - -- **可学**:图类型侧栏 + 缩略图矩阵、在线编辑器分栏、主题切换与导出类工具。 -- **边界**:ECharts 的 `option` 体量与 VL 不同维度的「冗长」;Flint 叙事应对 **各后端编译产物** 分别诚实展示,避免只对比 VL 而忽略 ECharts 用户的体感。 - -### 2.5 行业常见模式(摘录) - -- 示例页提供「在 Playground / Editor 中打开」。 -- 深链或查询参数传递示例标识,便于分享与从 Gallery 跳入(注意 URL 长度,见第 9 节)。 -- **响应式**:Gallery 可单列堆叠即可;Editor 以桌面宽屏为主,MVP 可明确写清设备优先级。 - ---- - -## 3. 与当前仓库实现的关系 - -- **Gallery**:已有左侧 `TEST_GENERATORS` 键列表、`TripleChart` 三后端并排、`tests.slice(0, 6)` 限制展示条数;源码注释标明仍为 scaffold。**规划变更(见第 7 节)**:左侧改为 **按图表类型** 的侧栏(类 ECharts),主区 **仍保留** 当前「用例标题 + 描述 + `TripleChart`」卡片形态;需在数据层维护 **图表类型 ↔ generator / 用例** 的映射(可从 `chart_spec.chartType`、generator 名规则或单独配置表推导,实现时选定唯一来源)。 -- **Editor**:左侧为 `ChartAssemblyInput` 的 JSON(CodeMirror),右侧为后端 Tab(Vega-Lite / ECharts / Chart.js)及可选编译 spec;内置示例见 `examples/editor/src/examples.ts`。 -- **语义设计文档**:`docs/design-semantics.md` 描述 T0 / T1 / T2 与降级策略,适合作为站外或 `/docs` 的权威说明;示例站内页以摘要与链接为主,避免重复维护长文。 - ---- - -## 4. Live Editor:并排展示「Flint 输入」与「生成的 Vega-Lite」 - -### 4.1 布局建议(控制复杂度) - -在现有「左编辑 / 右预览」基础上,将 **左侧** 拆为 **上下两块**(较左右分栏更不易挤压行宽): - -| 区域 | 内容 | -|------|------| -| **上:Flint 输入** | 可编辑 JSON:`data`、`semantic_types`、`chart_spec`(与当前行为一致) | -| **下:对照** | **只读**、格式化的 **由当前输入编译得到的 Vega-Lite spec**;标题建议写作「Vega-Lite 输出(由 Flint 生成)」,避免被误解为用户手写的最简 spec | - -用户心智:**只维护上一份意图描述**;下方为同一意图在当前编译器下的 VL 展开结果,用于感受篇幅与结构复杂度。 - -**第二阶段可选** - -- 低调展示行数或嵌套层级对比。 -- 「复制 Vega-Lite」按钮,便于在 Vega Editor 中交叉验证。 - -### 4.2 错误与空状态 - -- JSON 语法错误:继续在输入区附近展示解析错误(与现状一致)。 -- 编译失败:保留 Flint 输入可编辑;对照区展示失败原因,避免整页空白。 - ---- - -## 5. Gallery - -### 5.1 布局约束 - -- **全局**:全站 **顶部导航栏**(类 Vega-Lite:Gallery、Tutorials、Documentation、Usage / Getting started、Ecosystem、GitHub、Try / Editor 等,实现时可按 MVP 裁剪条目)。 -- **Gallery 页内**:**左侧** 为 **按图表类型** 分类的侧栏(类 ECharts:类目 + 可选小图标、当前选中高亮);**右侧主区** **保持与当前 `examples/gallery` 相同** 的用例展示——即每个用例仍以 **标题、描述、`TripleChart`(或等价多后端预览)** 为主的纵向卡片流,**不**把主区改成 ECharts 官网那种纯缩略图矩阵(避免与「和现在 gallery 一样」冲突)。 -- **逐例进入 Editor**:在每个用例 **标题同一行末尾** 或 **标题下第一行**,放置与 Vega-Lite 单示例页同款的文案链:**「View this example in the online editor」**(中文站可并列或单独提供「在在线编辑器中打开」)。点击后 **进入当前 Editor 应用界面**(同仓库 `examples/editor`:开发时为另一 Vite 端口或子路径 `/editor`,上线时为统一域名下的 Editor 路由),并载入该用例对应的 `ChartAssemblyInput`(载荷方式见 §5.4)。 - -### 5.2 视觉层级(主区内) - -- **主预览**:默认突出单一后端(例如 Vega-Lite)或 Flint 首推导出效果;ECharts、Chart.js 作为 Tab 或次级折叠,避免三列长期同权导致「测试页」观感。 -- **多引擎**:以「另存为其他引擎」或「多引擎」Tab 呈现,强调能力边界而非首屏噪音。 - ---- - -## 6. Semantic types:放置位置与内容边界 - -### 6.1 放置方案比较 - -| 方案 | 优点 | 缺点 | -|------|------|------| -| **A.** Gallery 页侧栏 **图表类型区下方** 或主区顶部固定「Semantic types」入口 + 页内长折叠 | 曝光高;与第 7 节侧栏并存时需控制高度(可折叠「高级 / 语义」区) | 侧栏信息密度上升 | -| **B.** 独立路由(如 `/semantic-types`) | 内容易扩展 | 多一页导航与构建配置;需放入顶栏 **Documentation** 子链或主导航 | -| **C.** 仅链至 `docs/design-semantics.md` | 维护单点 | 离开示例站语境 | - -**MVP 建议:** **A**(与图表类型侧栏分区排版);内容膨胀后再引入 **B**,并在顶栏 **Documentation** 中链入。 - -### 6.2 示例站页内大纲(精简) - -1. 一句话:字段语义类型如何影响格式、聚合、基线、色板类别等默认决策。 -2. T0 / T1 / T2:由粗到细,**缺失细粒度类型时降级而非失败**。 -3. 一至两个静态对照(如仅标为数值与标注为 `Amount` / `Proportion` 时的轴与标签差异)。 -4. 指向 `design-semantics.md` 全文。 - ---- - -## 7. 信息架构:顶栏(类 Vega-Lite)+ Gallery(侧栏类 ECharts、主区保持现状) - -### 7.1 全站顶部导航栏 - -对齐 [Vega-Lite 文档站](https://vega.github.io/vega-lite/) 顶栏信息结构(不必逐项同名,但类目宜接近用户心智): - -| 导航项 | 用途(建议) | -|--------|----------------| -| **Gallery**(或 **Examples**) | 进入示例画廊(本页为站内核心流量入口之一)。 | -| **Tutorials** | 分步入门、常见数据集走通;MVP 可单页或外链。 | -| **Documentation** | API / 装配输入 schema / 链至仓库 `docs/` 与 `design-semantics.md`。 | -| **Usage**(或 **Getting started**) | 安装、一行代码渲染、与框架集成要点。 | -| **Ecosystem** | 相关工具、后端矩阵、Roadmap;可精简为单页。 | -| **GitHub** | 源码仓库。 | -| **Try online** / **Editor** | **直达** 在线 **Editor** 根路由(空白或默认模板);与 Gallery 内 **「View this example in the online editor」**(带用例上下文)区分。 | - -可选:**搜索**、**中 / 英文**(非 MVP 可后做)。 - -### 7.2 Gallery 页:侧栏 + 主区 - -``` -Gallery 路由(/ 或 /gallery) -├── 顶栏(见 §7.1,全站一致) -├── 左侧边栏 -│ ├── 按「图表类型」分组(参考 ECharts Examples:Line、Bar、Scatter…;类目映射见第 3 节) -│ └── [可选] Semantic types 入口(见第 6 节) -└── 主内容区(与当前实现一致) - └── 当前选中类型下的用例列表:标题 + 描述 + TripleChart(或等价) - └── 每例标题旁:「View this example in the online editor」→ Editor + 该例载荷 -``` - -- **侧栏**:负责 **「按图表类型找例」**;可保留图标、选中态、与 ECharts 类似的纵向类目列表。 -- **主区**:**不改为**缩略图-only 的矩阵;延续 **大卡片 + 多后端预览**,以满足「图表展示还是和现在 gallery 一样」的产品要求。 -- **逐例链接**:文案 **「View this example in the online editor」** 与 Vega-Lite 单例页一致,便于用户迁移习惯;点击跳转 **现有 Editor 界面**(见 §5.4)。 - -### 7.3 Editor 路由(建议) - -``` -/editor(或独立子应用 + 统一域名) -├── Flint 输入(JSON) -├── 生成的 Vega-Lite(只读对照,见第 4 节) -└── 预览(主后端 + 多引擎 Tab) -``` - -- 从 **顶栏 Try / Editor** 进入:无 `generator` / `case` 参数时加载默认空模板或内置第一个示例。 -- 从 **Gallery 逐例链接** 进入:携带 §5.4 约定参数,自动填充该用例 `ChartAssemblyInput`。 - -### 7.4 落地页与仓库入口 - -- 对外首页或 GitHub Pages:**一句定位 + 顶栏或首屏 CTA** 指向 **Gallery** 与 **Editor**;与 Observable 式双按钮可并存,但 **全局顶栏** 为信息架构主轴。 - ---- - -## 8. 分阶段交付 - -| 阶段 | 交付内容 | 验收要点 | -|------|----------|----------| -| **P0** | Editor 左栏:Flint 输入 + 只读生成 VL;Gallery 顶部对比折叠带 | 新用户能在约半分钟内理解「维护单份输入」 | -| **P1** | 全站 **顶栏**(Gallery / Tutorials / Documentation / …);Gallery **按图表类型侧栏**;主区保持现有卡片 + `TripleChart`;每例标题处 **「View this example in the online editor」** 深链至 Editor 且可复现该例 | 任意展示用例一键进入 Editor 且状态一致 | -| **P2** | Gallery 主图 + 多引擎 Tab;精选用例的补充说明 | 整体更接近产品站而非内部测试列表 | -| **P3** | Semantic types 独立短页或小册式滚动区 + 图示 | 清楚区分「统计类型 Q/N/O/T」与 Flint「业务语义类型」的角色 | - ---- - -## 9. 风险与表述原则 - -- **载荷与隐私**:大 JSON 避免直接塞进 URL;可用 `sessionStorage` 或短键服务端交换(若未来有后端)。 -- **对比诚实性**:对照区展示 **当前版本编译器真实输出**;不虚构「手写 VL 最少行数」。Vega-Lite 具备强表达力与部分默认行为,叙事重点放在 **意图层压缩与默认决策外置**,而非贬低 VL。 -- **维护成本**:卡片文案以模板与数据驱动为主,少量手写「标杆用例」即可。 - ---- - -## 10. 实现阶段可执行项(备忘) - -1. `examples/editor`:基于已有 `assembleVegaLite` 结果序列化至只读面板;解析 URL 查询参数或 `sessionStorage` token,**预填**来自 Gallery 的 `ChartAssemblyInput`。 -2. `examples/gallery`:增加 **全站顶栏** 组件;左侧 **图表类型** 侧栏(数据源:类型 ↔ generator/用例映射);主区沿用现有卡片结构;每例标题行渲染 **「View this example in the online editor」** 并指向 Editor(开发环境需处理跨端口 URL 或反向代理为同域)。 -3. `examples/gallery` 与 `examples/editor`:抽取共享的注册表与「打开 Editor」载荷编码(可考虑 `examples/shared` 等小模块)。 -4. 样式:提取有限 CSS 变量(背景、边框、主色),与 `docs/landing.html` 等品牌触点可选对齐。 - ---- - -**文档性质:** 设计与调研归档;实施顺序以第 8 节为准,**本文不替代** `design-semantics.md` 中的语义类型技术定义。 diff --git a/docs/zh-CN/reference-plotly.md b/docs/zh-CN/reference-plotly.md index ecf7032c..93c104b2 100644 --- a/docs/zh-CN/reference-plotly.md +++ b/docs/zh-CN/reference-plotly.md @@ -100,7 +100,7 @@ _无模板专用参数。_ | 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 说明 | |---|---|---|---|---|---| | `totals` | choice | `auto` (Auto), `none` (None), `first` (First only), `last` (Last only), `both` (First and last) | `auto` | always | 瀑布图总计标记。 | -| `showTextLabels` | toggle | on / off | `false` | always | 在标记上显示数值标签。 | +| `showTextLabels` | toggle | on / off | `false` | always | 在标记上显示数值标签(showValueLabels 的旧写法)。 | ### ![](chart-icon-pyramid.svg) Pyramid Chart @@ -124,7 +124,8 @@ _无模板专用参数。_ | 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 说明 | |---|---|---|---|---|---| -| `showOutliers` | toggle | on / off | `true` | always | 显示离群点。 | +| `showPoints` | toggle | on / off | `false` | conditional | 在线上叠加点标记。 | +| `showOutliers` | toggle | on / off | `true` | conditional | 显示离群点。 | ### ![](chart-icon-violin.svg) Violin Plot diff --git a/docs/zh-CN/theme-spec.md b/docs/zh-CN/theme-spec.md new file mode 100644 index 00000000..c20448ad --- /dev/null +++ b/docs/zh-CN/theme-spec.md @@ -0,0 +1,119 @@ +# 使用主题 + +Flint 主题是一套正式规范,用来描述图表系统在整个创建过程中如何运作。它不是渲染完成后再套上的外观,而是从三个层面生效: + +- **布局算法。** 控制编译器如何分配空间、组织元素,以及调整标签、图例、坐标轴和注释。 +- **语义角色。** 根据含义设置表现规则,让字段角色、顺序、分组与层级决定对比、强调和表达方式。 +- **几何与字体。** 定义字体、颜色、表面、线宽、圆角和图形形状,形成统一的视觉识别。 + +[探索主题](/themes) 会把这三个层面应用到同一组图表上,方便直接比较它们的效果。 + +`theme_spec` 与 `chart_spec` 并列放在 `ChartAssemblyInput` 中。图表规范说明**图表表达什么**,主题规范说明**这些含义如何呈现**。 + +> ThemeSpec 目前只影响 Vega-Lite 输出,其他后端的组装器会忽略它。 + +## `theme_spec` 的三种用法 + +使用下方标签页,在一张世界银行预期寿命图表上比较三种合法形式。代码片段缩略显示 `data` 和 `semantic_types`,同时保留 `chart_spec` 作为上下文,并高亮 `theme_spec`。图表仍会编译完整输入。 + +```flint-theme-spec +theme-spec +``` + +### 1. 使用预设 + +当 Flint 内置的设计系统适合你的产品时,直接使用预设 ID: + +```json +{ + "theme_spec": "economist" +} +``` + +Flint 目前提供以下预设: + +```flint-theme-presets +presets +``` + +预设 ID 是稳定的 API 值。产品需要构建自己的选择器时,可以调用 `listThemePresets()`。 + +### 2. 创建自定义主题 + +传入 JSON 对象即可定义自己的设计系统: + +```json +{ + "theme_spec": { + "id": "our-brand", + "ink": { + "series": { + "single": "#6b3fa0" + } + }, + "layout": { + "density": "compact" + } + } +} +``` + +所有字段都是可选的。可以先定义产品最重要的决策,再随着系统成长逐步补充。 + +| 区块 | 控制内容 | +| --- | --- | +| `ink` | 表面、文字、结构线、强调色,以及分类或数值颜色 | +| `type` | 标题、坐标轴、标签、注释与大数字的字体 | +| `structure` | 坐标轴、刻度、网格、基线与边框 | +| `marks` | 色带宽度、描边、圆角、轮廓、分隔与点大小 | +| `labels`, `legend`, `dataLabels` | 截断、位置、显示规则与标签颜色 | +| `annotation` | 单位、轴标题、数值格式、点强调与统计信息 | +| `layout`, `facets` | 疏密、标题间距、色带步长、面板间距与共享比例尺 | +| `chartDefaults`, `compileDefaults` | 图表控件、基础尺寸、画布尺寸与布局限制的默认值 | +| `furniture` | 分隔线、标签页及其他重复出现的图表结构 | +| `variants` | 根据语义、密度或图表形态调整规则的条件 | + +主题规则由语义驱动。例如,`structure.grid.measure` 控制用于读取数值的网格,无论度量实际位于哪个物理坐标轴。`legend.placement` 向编译器提供按优先级排列的可用位置,而不是固定坐标。因此,同一主题可以适配不同图表类型、数据与画布尺寸。 + +### 3. 继承并覆盖 + +当某个预设接近你的品牌时,可以使用 `extends`: + +```json +{ + "theme_spec": { + "extends": "economist", + "id": "our-economist", + "ink": { + "series": { + "single": "#6b3fa0" + } + }, + "type": { + "headline": { + "family": "Aptos Display" + } + } + } +} +``` + +Flint 会先读取指定预设,再将你的对象深度合并到其上。嵌套对象会合并,因此修改 `ink.series.single` 时,仍会保留预设中的表面、文字颜色、渐变和其他系列规则。数组和标量则会完整替换预设值。 + +`categorical` 与 `categoricalExtended` 是两套独立色板。如果品牌需要替换分类颜色,应同时覆盖两者,避免系列较多的图表回退到预设的扩展色板。 + +继承适合创建长期维护的品牌变体。它保留预设的编译器行为,同时允许你修改需要不同的视觉识别。 + +## 哪些内容属于主题 + +主题负责表现方式和编译器行为,例如: + +- 元素排列的疏密; +- 标签何时可以移到图形外; +- 图例应位于图形内部、上方还是侧面; +- 语义分组如何获得对比与强调; +- 坐标轴、网格、图形、字体与表面如何绘制。 + +主题**不负责**选择字段、聚合、筛选或排序。这些决策决定图表表达什么,应放在 `data`、`semantic_types` 和 `chart_spec` 中。 + +保持这条边界,主题就能安全地用于不同数据、图表类型、画布尺寸和产品。 diff --git a/docs/zh-CN/tutorials/getting-started.md b/docs/zh-CN/tutorials/getting-started.md index 65c1b043..ffc7c93e 100644 --- a/docs/zh-CN/tutorials/getting-started.md +++ b/docs/zh-CN/tutorials/getting-started.md @@ -87,6 +87,20 @@ Python 包计划在后续版本发布,不包含在首次公开发版中。目 这就是 Flint 的核心:DataSpec 说明数据*是什么*,ChartSpec 说明你想*怎么看*。将 JSON 粘贴到[在线编辑器](/editor)即可实时查看和修改。 +### 可选:选择主题 + +在 `chart_spec` 旁加入 `theme_spec`,即可使用 Flint 的内置设计系统: + +```json +{ + "theme_spec": "economist" +} +``` + +Flint 主题是一套正式规范,会在编译过程中影响布局、语义表现和视觉识别,而不只是设置颜色或字体。ThemeSpec 目前只影响 Vega-Lite 输出。 + +阅读[使用主题](/documentation/theme-spec),了解如何选择预设、创建主题,或继承主题并覆盖部分规则。[探索主题](/themes)会用同一组图表展示所有预设的效果。 + ## 编译 在 JavaScript 或 TypeScript 中,将同一份输入传给编译函数: @@ -147,6 +161,7 @@ Python 支持将使用相同的输入结构,计划在后续版本发布。 ## 接下来读什么 - [示例:数据故事](/documentation/data-story):用同一份 DataSpec 和五种 ChartSpec 生成不同图表。 +- [使用主题](/documentation/theme-spec):了解预设、自定义与继承 ThemeSpec。 - [配置 Flint MCP](/documentation/setup-flint-mcp):在聊天工具或 IDE 中连接 Flint MCP。 - [智能体工作流](/documentation/agent-workflows):将 Flint 集成到自己的智能体产品中。 - [语义类型](/documentation/semantic-types):了解 `YearMonth`、`Quantity`、`Category` 和 `Profit` 等语义标签。 diff --git a/package-lock.json b/package-lock.json index 0553d4e3..aeeab3c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9634,7 +9634,7 @@ }, "packages/flint-js": { "name": "flint-chart", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "devDependencies": { "@types/node": "^20.14.10", @@ -9679,7 +9679,7 @@ }, "packages/flint-mcp": { "name": "flint-chart-mcp", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.4", @@ -9688,7 +9688,7 @@ "@resvg/resvg-js": "^2.6.2", "chart.js": "^4.4.0", "echarts": "^6.0.0", - "flint-chart": "^0.4.1", + "flint-chart": "^0.5.0", "vega": "^6.0.0", "vega-interpreter": "^2.2.1", "vega-lite": "^6.0.0", diff --git a/packages/flint-js/README.md b/packages/flint-js/README.md index c8ffceab..fd333b07 100644 --- a/packages/flint-js/README.md +++ b/packages/flint-js/README.md @@ -42,6 +42,21 @@ const input: ChartAssemblyInput = { const vegaLiteSpec = assembleVegaLite(input); ``` +Add a formal visual theme without changing the chart's data or encodings: + +```ts +const themedSpec = assembleVegaLite({ + ...input, + theme_spec: 'economist', +}); +``` + +Flint ships ten presets and also accepts a custom `ThemeSpec`, or an object +that `extends` a preset and overrides selected fields. ThemeSpec currently +affects Vega-Lite output. See +[Using themes](https://microsoft.github.io/flint-chart/#/documentation/theme-spec) +and the [live theme wall](https://microsoft.github.io/flint-chart/#/themes). + The same `ChartAssemblyInput` compiles to any backend: ```ts @@ -84,6 +99,7 @@ The Excel backend instead produces a native-chart artifact: use ## Documentation - [Project overview & docs](https://github.com/microsoft/flint-chart#readme) +- [Using themes](https://microsoft.github.io/flint-chart/#/documentation/theme-spec) - [Semantic-type model & rationale](src/docs/design-semantics.md) - [Stretch / banking layout model](src/docs/design-stretch-model.md) - [Agent authoring skill](https://github.com/microsoft/flint-chart/blob/main/agent-skills/flint-chart-author/SKILL.md) diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index 9084022b..167841f7 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -1,6 +1,6 @@ { "name": "flint-chart", - "version": "0.4.1", + "version": "0.5.0", "description": "Semantic-level visualization library that compiles data + semantic types for Vega-Lite, ECharts, Chart.js, Plotly, and Excel.", "keywords": [ "visualization", diff --git a/packages/flint-js/src/chartjs/assemble.ts b/packages/flint-js/src/chartjs/assemble.ts index 53563145..14b12948 100644 --- a/packages/flint-js/src/chartjs/assemble.ts +++ b/packages/flint-js/src/chartjs/assemble.ts @@ -62,6 +62,17 @@ import { normalizeChartProperties } from '../core/normalize-properties'; * * @returns A Chart.js config object with optional `_warnings` and `_width`/`_height` hints */ +function applyFieldDisplayNames(config: any, names: Record | undefined): void { + if (!names) return; + const displayName = (value: unknown) => typeof value === 'string' ? names[value] ?? value : value; + for (const scale of Object.values(config.options?.scales ?? {}) as any[]) { + if (scale?.title?.text) scale.title.text = displayName(scale.title.text); + } + for (const dataset of config.data?.datasets ?? []) { + if (dataset?.label) dataset.label = displayName(dataset.label); + } +} + export function assembleChartjs(input: ChartAssemblyInput): any { const chartType = input.chart_spec.chartType; const semanticTypes = input.semantic_types ?? {}; @@ -444,6 +455,8 @@ export function assembleChartjs(input: ChartAssemblyInput): any { cjsConfig._pivot = legacyPivot.surface; } + applyFieldDisplayNames(cjsConfig, input.field_display_names); + return cjsConfig; } diff --git a/packages/flint-js/src/chartjs/templates/index.ts b/packages/flint-js/src/chartjs/templates/index.ts index 0a54c051..d35302c0 100644 --- a/packages/flint-js/src/chartjs/templates/index.ts +++ b/packages/flint-js/src/chartjs/templates/index.ts @@ -14,6 +14,7 @@ import { cjsConnectedScatterDef } from './connected-scatter'; import { cjsBubbleChartDef } from './bubble'; import { cjsStripPlotDef } from './jitter'; import { cjsBarChartDef, cjsStackedBarChartDef, cjsGroupedBarChartDef } from './bar'; +import { cjsLollipopChartDef } from './lollipop'; import { cjsComboChartDef } from './combo'; import { cjsLineChartDef } from './line'; import { cjsBumpChartDef } from './bump'; @@ -34,7 +35,7 @@ import { cjsWaterfallChartDef } from './waterfall'; */ export const cjsTemplateDefs: { [key: string]: ChartTemplateDef[] } = { 'Scatter & Point': [cjsScatterPlotDef, cjsConnectedScatterDef, cjsBubbleChartDef, cjsStripPlotDef], - 'Bar': [cjsBarChartDef, cjsGroupedBarChartDef, cjsStackedBarChartDef, cjsComboChartDef, cjsHistogramDef, cjsWaterfallChartDef, cjsGanttChartDef], + 'Bar': [cjsBarChartDef, cjsGroupedBarChartDef, cjsStackedBarChartDef, cjsLollipopChartDef, cjsComboChartDef, cjsHistogramDef, cjsWaterfallChartDef, cjsGanttChartDef], 'Line & Area': [cjsLineChartDef, cjsBumpChartDef, cjsSlopeChartDef, cjsAreaChartDef, cjsRangeAreaChartDef, cjsEcdfPlotDef], 'Part-to-Whole': [cjsPieChartDef, cjsDoughnutChartDef], 'Polar': [cjsRadarChartDef, cjsRoseChartDef], diff --git a/packages/flint-js/src/chartjs/templates/lollipop.ts b/packages/flint-js/src/chartjs/templates/lollipop.ts new file mode 100644 index 00000000..e53dded3 --- /dev/null +++ b/packages/flint-js/src/chartjs/templates/lollipop.ts @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Chart.js Lollipop Chart — thin bar stem from 0 to value + dot at the end + * (mirror of echarts/templates/lollipop.ts and vegalite/templates/lollipop.ts). + * + * Chart.js has no rule mark, so the stem is a `bar` dataset with a fixed + * `barThickness`, and the dot is a `line` dataset with `showLine: false` so it + * rides the shared category scale (a `scatter` dataset would require numeric + * `{x, y}` points instead of category labels). + */ + +import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { + extractCategories, + groupBy, + buildCategoryAlignedData, + getChartJsPalette, + getSeriesBorderColor, + detectAxes, +} from './utils'; +import { detectBandedAxisFromSemantics } from '../../core/axis-detection'; + +/** Stem styling mirrors the ECharts template: black, ~1.5px (the Vega-Lite rule look). */ +const STEM_COLOR = '#000000'; +const STEM_WIDTH_PX = 1.5; +/** Internal dataset label for the stem; filtered out of legend and tooltip. */ +const STEM_LABEL = '__stem__'; + +/** Same visual scale as the ECharts template: 6–16px dot diameter. */ +function dotRadiusFromProperty(dotSize: number): number { + const diameterPx = Math.max(6, Math.min(10 + (dotSize - 80) / 40, 16)); + return diameterPx / 2; +} + +export const cjsLollipopChartDef: ChartTemplateDef = { + chart: 'Lollipop Chart', + template: { mark: 'bar', encoding: {} }, + channels: ['x', 'y', 'color', 'column', 'row'], + markCognitiveChannel: 'length', + declareLayoutMode: (cs, table) => { + const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' }); + return { + axisFlags: result ? { [result.axis]: { banded: true } } : { x: { banded: true } }, + resolvedTypes: result?.resolvedTypes, + }; + }, + instantiate: (spec, ctx) => { + const { channelSemantics, table, chartProperties } = ctx; + const { categoryAxis, valueAxis } = detectAxes(channelSemantics); + + const catField = channelSemantics[categoryAxis]?.field; + const valField = channelSemantics[valueAxis]?.field; + if (!catField || !valField || table.length === 0) return; + + const colorField = channelSemantics.color?.field; + const categories = extractCategories( + table, catField, channelSemantics[categoryAxis]?.ordinalSortOrder, + ); + const stemData = buildCategoryAlignedData(table, catField, valField, categories); + + const isHorizontal = categoryAxis === 'y'; + const pointRadius = dotRadiusFromProperty(Number(chartProperties?.dotSize ?? 80)); + const palette = getChartJsPalette(ctx, 'color'); + + const datasets: any[] = [{ + type: 'bar' as const, + label: STEM_LABEL, + data: stemData, + barThickness: STEM_WIDTH_PX, + backgroundColor: STEM_COLOR, + borderWidth: 0, + order: 2, + }]; + + const dotDataset = (label: string, data: (number | null)[], colorIndex: number) => ({ + type: 'line' as const, + label, + data, + showLine: false, + pointRadius, + pointHoverRadius: pointRadius + 2, + borderColor: getSeriesBorderColor(palette, colorIndex), + backgroundColor: getSeriesBorderColor(palette, colorIndex), + pointBorderColor: '#fff', + pointBorderWidth: 1, + order: 1, + }); + + if (colorField) { + let i = 0; + for (const [name, rows] of groupBy(table, colorField)) { + datasets.push(dotDataset( + name, buildCategoryAlignedData(rows, catField, valField, categories), i, + )); + i++; + } + } else { + datasets.push(dotDataset(valField, stemData, 0)); + } + + const zeroDecision = channelSemantics[valueAxis]?.zero; + const config: any = { + type: 'bar', + data: { labels: categories, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + ...(isHorizontal ? { indexAxis: 'y' as const } : {}), + scales: { + [categoryAxis]: { + title: { display: true, text: catField }, + }, + [valueAxis]: { + type: 'linear' as const, + beginAtZero: zeroDecision ? zeroDecision.zero !== false : true, + title: { display: true, text: valField }, + }, + }, + plugins: { + legend: { + display: !!colorField, + labels: { filter: (item: any) => item.text !== STEM_LABEL }, + }, + tooltip: { + enabled: true, + filter: (item: any) => item.dataset?.label !== STEM_LABEL, + }, + }, + }, + }; + + Object.assign(spec, config); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { key: 'dotSize', label: 'Dot Size', type: 'continuous', min: 20, max: 300, step: 10, defaultValue: 80 }, + ] as ChartPropertyDef[], +}; diff --git a/packages/flint-js/src/core/compute-layout.ts b/packages/flint-js/src/core/compute-layout.ts index b9c6d3fa..30ef36fc 100644 --- a/packages/flint-js/src/core/compute-layout.ts +++ b/packages/flint-js/src/core/compute-layout.ts @@ -240,6 +240,19 @@ export function deriveStretchCaps( // Public API: computeLayout // --------------------------------------------------------------------------- +/** + * The size a cell in a grid wants to be, before the room has its say. Larger + * than a bar's band because a tone needs area to be compared: below about + * twenty pixels a patch reads as grout between its neighbours. + */ +const CELL_BAND_SIZE = 28; + +/** + * How much of the wider step squaring a grid may cost. At 1.5 a 30px step will + * give way to a 20px square, but not to a 15px one. + */ +const SQUARE_CELL_TOLERANCE = 1.5; + /** * Phase 1: Compute layout decisions. * @@ -819,7 +832,10 @@ export function computeLayout( const itemsPerGroup = nominalCount.group; const defaultGroupStep = itemsPerGroup * maxStepSize; const minGroupStep = Math.max(Math.ceil(MIN_GROUP_GAP_PX / stepPaddingVal), 2 * itemsPerGroup); - const groupAxis = computeAxisStep(nominalCount.x, 0, subplotWidth, elasticParamsX); + const groupElasticX = options.groupBandFillsLanes + ? { ...elasticParamsX, defaultStepSize: elasticParamsX.defaultStepSize * itemsPerGroup } + : elasticParamsX; + const groupAxis = computeAxisStep(nominalCount.x, 0, subplotWidth, groupElasticX); const groupStep = Math.max(minGroupStep, Math.min(defaultGroupStep, groupAxis.step)); xStepSize = groupStep; xStepUnit = 'group'; @@ -835,7 +851,10 @@ export function computeLayout( const itemsPerGroup = nominalCount.group; const defaultGroupStep = itemsPerGroup * maxStepSize; const minGroupStep = Math.max(Math.ceil(MIN_GROUP_GAP_PX / stepPaddingVal), 2 * itemsPerGroup); - const groupAxis = computeAxisStep(nominalCount.y, 0, subplotHeight, elasticParamsY); + const groupElasticY = options.groupBandFillsLanes + ? { ...elasticParamsY, defaultStepSize: elasticParamsY.defaultStepSize * itemsPerGroup } + : elasticParamsY; + const groupAxis = computeAxisStep(nominalCount.y, 0, subplotHeight, groupElasticY); const groupStep = Math.max(minGroupStep, Math.min(defaultGroupStep, groupAxis.step)); yStepSize = groupStep; yStepUnit = 'group'; @@ -899,6 +918,76 @@ export function computeLayout( else subplotHeight = Math.round(stepSize * (count + 1)); } + // --- Square cells --- + // Two banded axes means the marks are cells, not bars. A bar states its + // value as a length along one axis, so its thickness is free; a cell states + // its value as a tone, and the eye compares tones by area. A grid of + // squares reads as a surface; a grid of thin rectangles reads as stripes, + // and invites a comparison along the long side that the data does not + // support. + // + // So the two steps are pulled to one size. The caps used here are the ones + // the stretch budget already produced, which is what lets a grid spend a + // little extra canvas to come out square. Where the categories are too many + // for that — where squaring would cut the wider step by more than a third — + // the grid stays rectangular: a shape nobody asked for is not worth losing + // that much room over. + // + // A connected mark whose two axes are both discrete — a bump chart, ranks + // over time — is the exception: it is a line, not a grid of cells, so it is + // neither squared nor stretched to fill the height. The mark declares a + // cross-section per axis; the larger one is the run the line travels along + // (time), the smaller the stack it crosses (rank). Squaring the two, or + // letting the rank axis grow one band per competitor, only makes the panel + // taller and every crossing a near-vertical plunge — the shape the eye + // reads worst. Instead the rank axis is held to its thin cross-section and + // the run axis is stretched to a bounded multiple of it, so the panel comes + // out landscape and the slopes sit nearer 45°. Horizontal room is what + // untangles a crossing mass of lines, so the run is where the budget is + // spent. + const isConnectedMark = typeof continuousMarkCrossSection === 'object' + && !!continuousMarkCrossSection.seriesCountAxis; + const bothDiscreteConnected = isConnectedMark + && xTotalNominalCount > 0 && yTotalNominalCount > 0 + && !xHasGrouping && !yHasGrouping; + if (bothDiscreteConnected && typeof continuousMarkCrossSection === 'object') { + const csX = continuousMarkCrossSection.x ?? 0; + const csY = continuousMarkCrossSection.y ?? 0; + if (csX > 0 && csY > 0) { + // Cap the run band's advantage over the cross band: past about 2:1 + // the extra width buys little and the panel just runs off the edge. + const RUN_AR_CAP = 2; + const runIsX = csX >= csY; + const crossCS = runIsX ? csY : csX; + const runBudget = runIsX + ? Math.floor(maxSubplotW / xTotalNominalCount) + : Math.floor(maxSubplotH / yTotalNominalCount); + const cross = Math.max(minStepVal, + Math.min(runIsX ? yStepSize : xStepSize, crossCS)); + const ratio = Math.min(RUN_AR_CAP, Math.max(1, Math.max(csX, csY) / Math.min(csX, csY))); + const run = Math.max( + runIsX ? xStepSize : yStepSize, + Math.min(runBudget, Math.round(cross * ratio))); + if (runIsX) { xStepSize = run; yStepSize = cross; } + else { yStepSize = run; xStepSize = cross; } + } + } + if (xTotalNominalCount > 0 && yTotalNominalCount > 0 && !xHasGrouping && !yHasGrouping + && !bothDiscreteConnected) { + const capX = Math.floor(maxSubplotW / xTotalNominalCount); + const capY = Math.floor(maxSubplotH / yTotalNominalCount); + const generous = Math.round(CELL_BAND_SIZE * Math.max(1, sizeRatio)); + // The square is the narrower of the two steps — that one already fits — + // grown to the generous size if there is room for it on both axes. + const wanted = Math.max(generous, Math.min(xStepSize, yStepSize)); + const square = Math.min(capX, capY, wanted); + const widest = Math.max(xStepSize, yStepSize); + if (square >= minStepVal && square * SQUARE_CELL_TOLERANCE >= widest) { + xStepSize = square; + yStepSize = square; + } + } + // --- Nominal discrete subplot sizing --- // For nominal discrete axes, one backend (VL) overrides subplotWidth // with step-based sizing (width:{step:N}), so the subplot dimension diff --git a/packages/flint-js/src/core/decisions.ts b/packages/flint-js/src/core/decisions.ts index 0b23dc04..a6b9e6c4 100644 --- a/packages/flint-js/src/core/decisions.ts +++ b/packages/flint-js/src/core/decisions.ts @@ -78,12 +78,28 @@ function validateTemporalParsing( fieldName: string, fromRegistry: boolean, ): boolean { - const sampleValues = data.map(r => r[fieldName]).slice(0, 15).filter((v: any) => v != null); + // Sample distinct values, not rows. Cartesian data is commonly ordered + // outer-axis first: in a 60 × 40 heatmap the first StartDate repeats for + // 40 rows while EndDate changes immediately. Sampling rows therefore + // declared one date field ordinal and the other temporal solely because of + // loop order. Walk until we have enough distinct evidence instead. + const sampleValues: any[] = []; + const seen = new Set(); + for (const row of data) { + const value = row[fieldName]; + if (value == null) continue; + const key = value instanceof Date + ? `date:${value.getTime()}` + : `${typeof value}:${String(value)}`; + if (seen.has(key)) continue; + seen.add(key); + sampleValues.push(value); + if (sampleValues.length >= 15) break; + } if (sampleValues.length === 0) return false; // Single unique value → not useful as temporal axis (would show a single point) - const uniqueValues = new Set(sampleValues.map(String)); - if (uniqueValues.size <= 1) return false; + if (sampleValues.length <= 1) return false; const looksTemporalValue = (val: any): boolean => { if (val instanceof Date) return true; diff --git a/packages/flint-js/src/core/field-semantics.ts b/packages/flint-js/src/core/field-semantics.ts index 63a041a4..91e8b76c 100644 --- a/packages/flint-js/src/core/field-semantics.ts +++ b/packages/flint-js/src/core/field-semantics.ts @@ -61,6 +61,26 @@ export interface SemanticAnnotation { /** Unit or currency code. E.g., "USD", "°C", "kg" */ unit?: string; + /** + * The value a diverging colour scale should pivot on — what the reader is + * being asked to compare against. + * + * This is a judgement, not a fact about the field, which is why it has to + * be declared rather than inferred. A temperature in °C pivots at 0 if the + * question is whether it freezes, and at something nearer 18 if the + * question is whether a city is comfortable to live in; the numbers are + * identical either way and only the question tells them apart. Declare it + * when the chart is about the comparison — above and below an average, a + * target, a baseline period, a comfort line — and leave it out otherwise, + * in which case the pivot falls back to whatever the type and the data + * make obvious: a sign change at zero, a bounded domain's centre, or no + * pivot at all. + * + * Declaring one also *asserts* the split: the scale diverges even if every + * reading happens to land on one side of it. + */ + divergingMidpoint?: number; + /** Explicit ordinal ordering. E.g., ["Low", "Medium", "High"] */ sortOrder?: string[]; } @@ -112,7 +132,7 @@ export interface DivergingInfo { /** Whether this type is always diverging or only when data spans both sides */ inherent: boolean; /** Source of the midpoint determination */ - source: 'unit' | 'type-intrinsic' | 'domain' | 'data'; + source: 'annotation' | 'unit' | 'type-intrinsic' | 'domain' | 'data'; } /** @@ -211,7 +231,7 @@ export function normalizeAnnotation( // ============================================================================= /** Map currency codes to display symbols */ -const CURRENCY_MAP: Record = { +export const CURRENCY_MAP: Record = { USD: '$', EUR: '€', GBP: '£', JPY: '¥', CNY: '¥', KRW: '₩', INR: '₹', BRL: 'R$', CAD: 'CA$', AUD: 'A$', CHF: 'CHF', SEK: 'kr', NOK: 'kr', DKK: 'kr', @@ -854,6 +874,7 @@ export function resolveNice( * Resolve diverging midpoint information for a field. * * Priority chain: + * 0. annotation.divergingMidpoint — the author said what to compare against * 1. annotation.unit → type lookup (°C → 0, °F → 32) * 2. type-intrinsic midpoint (Sentiment → 0, Correlation → 0) * 3. annotation.intrinsicDomain midpoint (Rating [1,5] → 3) @@ -869,6 +890,13 @@ export function resolveDivergingInfo( const entry = getRegistryEntry(semanticType); // Types with diverging='none' don't get diverging treatment + // 0. Declared. Nothing below this line can know what the chart is asking, + // so a stated pivot outranks every inferred one — and it holds even when + // the data sits entirely on one side, because the comparison is the point. + if (annotation.divergingMidpoint !== undefined) { + return { midpoint: annotation.divergingMidpoint, inherent: true, source: 'annotation' }; + } + // 1. Unit-derived (Temperature) if (semanticType === 'Temperature' && annotation.unit) { const unitMidpoints: Record = { diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index 14524256..46c35f27 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -194,3 +194,18 @@ export { resolveStackable, resolveSortDirection, } from './field-semantics'; + +// ThemeSpec: public visual-system vocabulary and chart-specific grounding +export { + type ThemeSpec, + type ThemePreset, + type DesignDecisions, + type ThemeReport, + type Presence, + type GroundingContext, + groundTheme, + THEME_PRESETS, + DEFAULT_THEME_ICON, + listThemePresets, + resolveThemeSpec, +} from './theme'; diff --git a/packages/flint-js/src/core/semantic-types.ts b/packages/flint-js/src/core/semantic-types.ts index 1039972f..10aa9591 100644 --- a/packages/flint-js/src/core/semantic-types.ts +++ b/packages/flint-js/src/core/semantic-types.ts @@ -642,6 +642,7 @@ const colorSchemes = { // Diverging - good for data with meaningful center point diverging: { redBlue: 'redblue', + blueOrange: 'blueorange', redGrey: 'redgrey', redYellowBlue: 'redyellowblue', redYellowGreen: 'redyellowgreen', @@ -652,6 +653,32 @@ const colorSchemes = { }, }; +/** + * Which end of a diverging scale gets the warm arm. + * + * Every diverging scheme Vega names runs from its *first*-named colour at the + * domain minimum to its last at the maximum: `redblue` is red at the bottom + * and blue at the top, `blueorange` the other way round. So choosing a scheme + * is choosing a polarity, and the polarity belongs to the field, not to the + * palette. Only two orders are intrinsic, and they point opposite ways: + * + * - **Intensity.** Warm means *more*. Nobody reads blue as hotter, denser or + * faster. Temperature is the plain case, but so is any measure whose two + * ends are simply less and more of one thing, pivoted at a reference we + * picked. Warm belongs at the top. + * - **Valence.** Red means *loss*. Money below zero, a shrinking share, a + * poor score, a negative sentiment. Here the sign is the reading, and red + * is the side the reader is meant to wince at. Warm belongs at the bottom. + * + * Nothing else is intrinsic. Two named sides — a political lean, agree against + * disagree — carry their colour in the categories, not in the scale. And a + * field with no valence at all still leaves the reader holding "warmer is + * more", which is why intensity, not valence, is what we assume when the type + * tells us nothing. + */ +const DIVERGING_WARM_HIGH = 'blueorange'; +const DIVERGING_WARM_LOW = 'redblue'; + /** * Get recommended color scheme based on semantic type and encoding context. * @@ -707,7 +734,7 @@ export function getRecommendedColorScheme( // Temperature if (semanticType === 'Temperature') { if (colorHint?.type === 'diverging') { - return { scheme: 'redblue', type: 'diverging', reason: 'temperature diverging around freezing point' }; + return { scheme: DIVERGING_WARM_HIGH, type: 'diverging', reason: 'temperature diverging around freezing point, warm end high' }; } return { scheme: 'reds', type: 'sequential', reason: 'temperature single-direction uses sequential' }; } @@ -715,7 +742,7 @@ export function getRecommendedColorScheme( // Percentage if (semanticType === 'Percentage') { if (colorHint?.type === 'diverging') { - return { scheme: 'redblue', type: 'diverging', reason: 'percentage spans positive and negative' }; + return { scheme: DIVERGING_WARM_LOW, type: 'diverging', reason: 'percentage spans positive and negative, red is the losing side' }; } return { scheme: 'oranges', type: 'sequential', reason: 'percentage all same sign uses sequential' }; } @@ -723,7 +750,7 @@ export function getRecommendedColorScheme( // Price/Amount if (['Price', 'Amount'].includes(semanticType)) { if (colorHint?.type === 'diverging') { - return { scheme: 'redblue', type: 'diverging', reason: 'financial data spans positive and negative' }; + return { scheme: DIVERGING_WARM_LOW, type: 'diverging', reason: 'financial data spans positive and negative, red is the losing side' }; } return { scheme: 'goldgreen', type: 'sequential', reason: 'financial data uses gold-green' }; } @@ -731,7 +758,7 @@ export function getRecommendedColorScheme( // Score - evaluation metrics; diverging when hint says so (e.g., domain midpoint) if (semanticType === 'Score') { if (colorHint?.type === 'diverging') { - return { scheme: 'redblue', type: 'diverging', reason: 'score/rating diverging around midpoint' }; + return { scheme: DIVERGING_WARM_LOW, type: 'diverging', reason: 'score diverging around midpoint, red is the poor side' }; } return { scheme: 'yelloworangebrown', type: 'sequential', reason: 'scores use warm sequential' }; } @@ -754,7 +781,12 @@ export function getRecommendedColorScheme( // Geographic locations - use geographic-friendly palettes if (getRegistryEntry(semanticType ?? '').t1 === 'GeoPlace') { if (uniqueValueCount <= 10) { - return { scheme: 'set2', type: 'categorical', reason: 'geographic regions use distinct pastels' }; + // Not a pastel set. A pastel is chosen for filled area — a + // choropleth, a stacked band — where there is enough of it to read + // a faint hue. A place just as often arrives as a one-pixel line + // or as the colour of its own label, and set2's yellow on white is + // then a line the reader has to hunt for. + return { scheme: 'tableau10', type: 'categorical', reason: 'places are named categories, at a contrast that survives thin marks' }; } return { scheme: 'tableau20', type: 'categorical', reason: 'many regions use large categorical' }; } @@ -775,10 +807,10 @@ export function getRecommendedColorScheme( // Names (persons, companies, products) - use saturated schemes for readability if (semanticType === 'Name') { - return { - scheme: uniqueValueCount > 8 ? 'tableau20' : 'set2', - type: 'categorical', - reason: 'names use readable categorical' + return { + scheme: uniqueValueCount > 8 ? 'tableau20' : 'tableau10', + type: 'categorical', + reason: 'names use readable categorical' }; } @@ -792,7 +824,15 @@ export function getRecommendedColorScheme( // PercentageChange) pass through here and should honor their diverging hint. if (measureTypes.has(semanticType)) { if (colorHint?.type === 'diverging') { - return { scheme: 'redblue', type: 'diverging', reason: 'measure with diverging nature' }; + // A signed measure splits into gain and loss, so red goes to the + // bottom. Everything else that happens to straddle a pivot — a + // bare Quantity, a Count, a Distance — splits into less and more, + // and there red at the bottom would tell the reader that small is + // bad when all we meant was small. + const signed = getRegistryEntry(semanticType).t1 === 'SignedMeasure'; + return signed + ? { scheme: DIVERGING_WARM_LOW, type: 'diverging', reason: 'signed measure, red is the negative side' } + : { scheme: DIVERGING_WARM_HIGH, type: 'diverging', reason: 'measure with no valence, warm end high' }; } const sequentialSchemes = ['viridis', 'blues', 'greens', 'reds', 'yelloworangebrown', 'goldgreen']; return { diff --git a/packages/flint-js/src/core/theme/ground.ts b/packages/flint-js/src/core/theme/ground.ts new file mode 100644 index 00000000..fa52f419 --- /dev/null +++ b/packages/flint-js/src/core/theme/ground.ts @@ -0,0 +1,1898 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Level 2 — grounding. + * + * Takes a portable ThemeSpec and the signals the compiler already resolved for + * *this* chart, and returns `DesignDecisions`: every role bound to a concrete + * part of the chart, every policy resolved against the space actually + * available, and still not a single backend property name. + * + * Grounding is allowed to downgrade. It is not allowed to do so silently. + */ + +import type { + DesignDecisions, + LegendPlacement, + NumericGuard, + Presence, + Ramp, + ResolvedAxis, + ResolvedRule, + ResolvedSeriesInk, + ResolvedText, + SizeToken, + ThemeGuard, + ThemeReport, + ThemeSpec, + TypeRole, +} from './types.js'; +import { + contrastingInk, + isDarkSurface, + isPaintedSurface, + luminance, + mixHex, + parseColor, + presenceWidth, + resolvePresenceInk, + sampleRamp, +} from './presence.js'; +import { CURRENCY_MAP } from '../field-semantics.js'; +import { getRegistryEntry } from '../type-registry.js'; +import { inferValueLabelFormat, longestLabelChars } from './value-label-format.js'; +import { deepMerge } from './merge.js'; + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +/** + * Everything grounding is allowed to look at. Deliberately a flat record of + * compiler *facts* — if grounding needs something that is not here, that is a + * signal the compiler does not actually know it, and the ThemeSpec should not + * have been allowed to depend on it. + */ +export interface GroundingContext { + chartType: string; + /** Template's `markCognitiveChannel`, widened for `angle`/`text` families. */ + markChannel: string; + /** Mark families present in the instantiated chart, e.g. `['bar','text']`. */ + markTypes: string[]; + /** The resolved `showSeriesInLabel`: the chart names its series on the marks. */ + namesOnMarks?: boolean; + /** Per-channel resolved semantics (phase 0). */ + channelSemantics: Record; + /** Encoding types after template-driven conversion (e.g. Q→O for bars). */ + resolvedTypes?: Record; + axisFlags?: { x?: { banded?: boolean }; y?: { banded?: boolean } }; + /** + * What the backend spec actually put on x and y. Templates are free to + * name their semantic channels `high`/`low`/`open`/`close`, in which case + * `channelSemantics` says nothing about the axes the reader will see. This + * is a fact about the chart, not a style choice, so grounding may use it. + */ + positional?: { + x?: { type?: string; field?: string }; + y?: { type?: string; field?: string }; + /** Whatever colour-like channel a data mark carries, wherever it sits. */ + color?: { type?: string; field?: string }; + /** Whether the data marks are stacked into segments. */ + stacked?: boolean; + }; + layout: { + subplotWidth: number; + subplotHeight: number; + xStep: number; + yStep: number; + xStepUnit?: 'item' | 'group'; + yStepUnit?: 'item' | 'group'; + stepPadding: number; + titleFontSize: number; + legendFontSize: number; + facet?: { columns: number; rows: number }; + }; + table: any[]; + canvasSize: { width: number; height: number }; + /** True when the template stacks its series (sum or normalize). */ + stacked?: boolean | 'normalize'; + /** Set when the chart is a share-of-total by construction (pie, donut). */ + partToWhole?: boolean; + /** + * Whether the chart carries a headline. + * + * A house that omits axis titles is not saying the measure needs no name; + * it is saying the name is written above the chart. Where nothing is + * written there, the delegation has nowhere to go. + */ + titled?: boolean; + /** The surface the host page provides, if the theme defers to it. */ + hostSurface?: string; + /** + * The reader's own answer to "print the numbers?", from + * `chartProperties.showValueLabels`. + * + * Absent leaves the house's `dataLabels.show` policy in charge — that + * policy is what seeds the control in the first place. A present value is + * a decision someone made about *this* chart, so it outranks the standing + * preference — but `on` is a preference to print, not a licence to + * overprint: it still yields where the marks are too dense to read, + * exactly as a house's own `always` does. + */ + valueLabels?: 'on' | 'off'; +} + +// --------------------------------------------------------------------------- +// Design tokens +// --------------------------------------------------------------------------- + +/** + * Fold the house's compiler settings under whatever the caller stated. + * + * Three levels, and the order is not negotiable: a value in the chart spec is + * a decision someone made about *this* chart, the theme is a standing + * preference, and flint's default is what is left when nobody said anything. + * + * Returns the merged options; keys the caller left undefined take the house's. + */ +export function resolveCompileDefaults>( + theme: ThemeSpec | undefined, + authored: T | undefined, +): { options: T; report: ThemeReport[] } { + const house = theme?.compileDefaults as Record | undefined; + const stated = (authored ?? {}) as Record; + if (!house) return { options: stated as T, report: [] }; + const merged: Record = { ...stated }; + const report: ThemeReport[] = []; + for (const [key, value] of Object.entries(house)) { + if (value === undefined) continue; + if (stated[key] !== undefined) { + report.push({ + stage: 'ground', + path: `compileDefaults.${key}`, + message: `the house prefers \`${key}: ${JSON.stringify(value)}\`, but the chart states its own — the chart's stands`, + }); + continue; + } + merged[key] = value; + report.push({ + stage: 'ground', + path: `compileDefaults.${key}`, + message: `house preset: \`${key}\` set to ${JSON.stringify(value)}`, + }); + } + return { options: merged as T, report }; +} + +/** + * House rules for a chart type, folded into the chart properties before the + * template runs. + * + * This is the one part of theming that happens *before* the chart is built: + * whether a line carries points or a bump chart is smoothed changes the marks + * themselves, not their dress, so it cannot be done by restyling afterwards. + * + * Only keys the caller left unset are filled, and only keys the template + * actually declares — a house cannot invent a control, and it does not get to + * overrule a reader who has already chosen. + */ +export function resolveChartDefaults( + theme: ThemeSpec | undefined, + chartType: string, + declared: { key: string }[] | undefined, + authored: Record | undefined, + target: Record, +): ThemeReport[] { + const defaults = theme?.chartDefaults; + // A slopegraph reads in one of two ways, and which one is a house matter. + // An editorial house takes the value axis away — no spine, no ruler — so + // the only place a value can be read is off the mark itself: it prints the + // number at each end, with the series name beside it, and needs no colour + // key. A house that keeps its value axis (a journal panel with a measured + // spine) reads the numbers off that axis and tells the lines apart the + // ordinary way, with a legend — printing a name on every end point there + // would fight the axis for the same margin and clutter a small panel. + // + // So the end-label treatment is the default only where the house omits the + // measure axis line. Houses may still override in their own defaults, a + // caller who set the control keeps it, and baseline (no theme) is left + // alone — it keeps its legend. + const omitsMeasureAxis = theme?.structure?.axis?.measure?.line === 'omit'; + const globalDefaults: Record> = omitsMeasureAxis + ? { 'Slope Chart': { showText: true, showSeriesInLabel: true } } + : {}; + const wanted = { + ...(globalDefaults['*'] ?? {}), + ...(globalDefaults[chartType] ?? {}), + ...(defaults?.['*'] ?? {}), + ...(defaults?.[chartType] ?? {}), + }; + if (Object.keys(wanted).length === 0) return []; + const keys = new Set((declared ?? []).map((p) => p.key)); + const report: ThemeReport[] = []; + for (const [key, value] of Object.entries(wanted)) { + if (!keys.has(key)) { + report.push({ + stage: 'ground', + path: `chartDefaults.${chartType}.${key}`, + message: `the house asks for \`${key}\`, which \`${chartType}\` does not offer — dropped`, + }); + continue; + } + if (authored?.[key] !== undefined) { + report.push({ + stage: 'ground', + path: `chartDefaults.${chartType}.${key}`, + message: `the house prefers \`${key}: ${JSON.stringify(value)}\`, but the chart already states one — the chart's own setting stands`, + }); + continue; + } + target[key] = value; + report.push({ + stage: 'ground', + path: `chartDefaults.${chartType}.${key}`, + message: `house rule: \`${key}\` set to ${JSON.stringify(value)}`, + }); + } + return report; +} + +const TEXT_TOKENS: Record = { + '100': 10, '200': 12, '300': 14, '400': 16, '500': 20, '600': 24, + hero700: 28, hero800: 32, hero900: 40, hero1000: 68, +}; + +const WEIGHTS: Record = { regular: 400, medium: 500, semibold: 600, bold: 700 }; + +function tokenToPx(size: SizeToken | undefined): number | undefined { + if (size == null) return undefined; + if (typeof size === 'number') return size; + const m = /^text\.(.+)$/.exec(size); + if (m && TEXT_TOKENS[m[1]] != null) return TEXT_TOKENS[m[1]]; + const n = Number(size); + return Number.isFinite(n) ? n : undefined; +} + +// --------------------------------------------------------------------------- +// Variant resolution +// --------------------------------------------------------------------------- + +function numericGuardHolds(g: NumericGuard, value: number): boolean { + if (g.eq != null && value !== g.eq) return false; + if (g.lt != null && !(value < g.lt)) return false; + if (g.lte != null && !(value <= g.lte)) return false; + if (g.gt != null && !(value > g.gt)) return false; + if (g.gte != null && !(value >= g.gte)) return false; + return true; +} + +interface Signals { + markChannel: string; + hasBandedAxis: boolean; + seriesCount: number; + /** False when a series field exists but this stage cannot count it. */ + seriesCountKnown: boolean; + categoryCount: number; + isPartToWhole: boolean; + isSigned: boolean; + isTemporal: boolean; + isFaceted: boolean; + isSummarised: boolean; + canvasWidth: number; +} + +function guardHolds(guard: ThemeGuard, s: Signals): boolean { + for (const [key, want] of Object.entries(guard)) { + if (want == null) continue; + const got = (s as any)[key]; + if (typeof want === 'object') { + if (!numericGuardHolds(want as NumericGuard, Number(got))) return false; + } else if (got !== want) { + return false; + } + } + return true; +} + +// --------------------------------------------------------------------------- +// Signal derivation +// --------------------------------------------------------------------------- + +const SERIES_CHANNELS = ['color', 'group', 'detail', 'series', 'shape', 'stroke']; +const FACET_CHANNELS = ['column', 'row', 'facet']; +// Charts whose subject is the shape of a distribution, drawn from an area mark +// rather than a summary mark. They summarise like a box plot does, so they take +// the same label escape — a printed value names a quantity they were chosen not +// to reduce to. (A box plot itself is caught earlier by its `boxplot` mark.) +const DISTRIBUTION_SHAPE_CHARTS = new Set(['Violin Plot', 'Density Plot']); +// A table with in-row bars: every value is also printed in its own column, so +// the bar is a secondary in-cell glyph and the category axis is a row-header +// gutter, not a base the bars stand on. +const TABLE_CHARTS = new Set(['Bar Table']); +// A multi-value glyph carries several measures in one mark — a candlestick is +// open/high/low/close — so there is no single scalar per datum to print. The +// mark itself is the value; a lone number stamped on it would name one of four +// prices and mislead on the other three. These charts look labelable (a measure +// on a position, a banded axis, not a distribution summary) but must never +// print a value, and — the reason this matters — must never let a house drop +// the measure axis on the false premise that the value is printed elsewhere. +const MULTI_VALUE_GLYPH_CHARTS = new Set(['Candlestick Chart']); + +// The title block's vertical rhythm, as a multiple of the headline / deck font +// size. A house's whitespace personality reaches the title here: `tight` packs +// the chart up under the headline (a dense figure, a dashboard tile); `loose` +// gives an action title room to breathe (a slide exhibit). `normal` preserves +// the ratios the realizer used before the block was expressible. +const TITLE_GAP: Record<'tight' | 'normal' | 'loose', number> = { tight: 0.45, normal: 0.9, loose: 1.7 }; +const DECK_GAP: Record<'tight' | 'normal' | 'loose', number> = { tight: 0.25, normal: 0.55, loose: 1.05 }; + +function distinctCount(table: any[], field: string | undefined): number { + if (!field) return 0; + const seen = new Set(); + for (const row of table) { + const v = row?.[field]; + if (v !== undefined && v !== null) seen.add(v); + } + return seen.size; +} + +function channelType(ctx: GroundingContext, channel: string): string | undefined { + return ctx.resolvedTypes?.[channel] + ?? ctx.channelSemantics?.[channel]?.type + ?? (channel === 'x' || channel === 'y' ? ctx.positional?.[channel]?.type : undefined); +} + +/** Does this channel exist on screen at all, whoever named it? */ +function channelPresent(ctx: GroundingContext, channel: 'x' | 'y'): boolean { + return Boolean(ctx.channelSemantics?.[channel] ?? ctx.positional?.[channel]); +} + +/** + * What a channel actually carries, from whichever stage knows. A layered + * template states its colour field on one layer and nothing at the top, so the + * semantic layer can be silent about a distinction the reader plainly sees. + */ +function channelFact(ctx: GroundingContext, channel: string | undefined): { field?: string; type?: string } | undefined { + if (!channel) return undefined; + const sem = ctx.channelSemantics?.[channel]; + if (sem?.field) return sem; + const pos = (ctx.positional as any)?.[channel]; + return pos?.field ? pos : sem; +} + +/** + * Parts that sum to a hundred are already stated in per cent, whatever the + * field was called. That is a fact about the numbers rather than a guess about + * the name, so grounding may use it. + */ +function percentOfWhole(ctx: GroundingContext, channel: string): string | undefined { + const field = channelFact(ctx, channel)?.field; + if (!field) return undefined; + let sum = 0; + let n = 0; + for (const row of ctx.table) { + const v = row?.[field]; + if (typeof v === 'number') { sum += v; n += 1; } + } + return n >= 3 && Math.abs(sum - 100) < 0.5 ? '%' : undefined; +} + +/** + * The unit a measure is counted in, when the chart already knows it. + * + * Either the annotation says so outright, or the field names it the way a + * person does — `CO₂ (ppm)`, `Unemployment (%)`. Anything longer than a short + * tag is a phrase, not a unit, and belongs in the subtitle. + */ +const UNIT_IN_FIELD_NAME = /\(([^()]{1,6})\)\s*$/; + +function unitText(ctx: GroundingContext, channel: string): string | undefined { + const sem = ctx.channelSemantics?.[channel]; + const declared = sem?.semanticAnnotation?.unit; + const field = sem?.field ?? (ctx.positional as any)?.[channel]?.field; + const named = typeof field === 'string' ? field.match(UNIT_IN_FIELD_NAME) : null; + const raw = (typeof declared === 'string' && declared.length > 0 && declared.length <= 6) + ? declared + : named?.[1]; + if (!raw) return undefined; + // A currency is written with its sign, not its ISO code: `$8`, not `8 USD`. + return CURRENCY_MAP[raw.toUpperCase()] ?? raw; +} + +/** + * Whether the labels on a channel say what they are without being told. + * + * `Jan`, `Cairo`, `Chrome`, `2019` name themselves: a reader who sees them + * knows at once what kind of thing they are, and a title over them — + * `Month`, `City` — only repeats what is already on the page. `26`, `5300`, + * `0.42` do not: a number is an instance of nothing until someone says what it + * counts, and the title is where that is said. + * + * The registry already sorts this out. A type the reader meets as a *name* or + * a *date* carries its own kind; one they meet as a *quantity* does not, and + * neither does a rank or a bin, whose labels are numbers wearing an order. + * This is a fact about the field, so grounding may use it — it is exactly what + * a house means when it says it wants a title only `whenAmbiguous`. + */ +function labelsNameThemselves(ctx: GroundingContext, channel: string | undefined): boolean { + if (!channel) return false; + const semanticType = ctx.channelSemantics?.[channel]?.semanticAnnotation?.semanticType; + if (typeof semanticType !== 'string') { + // Nothing said about the field. Fall back to what the chart put on the + // channel: names and dates read as themselves, numbers do not. + const type = channelType(ctx, channel); + return type === 'nominal' || type === 'temporal'; + } + const entry = getRegistryEntry(semanticType); + return entry.t1 === 'DateGranule' + || entry.visEncodings.includes('nominal') + || entry.visEncodings.includes('temporal'); +} + +/** + * A field that no row carries is not a field with one value — it is a field + * this stage cannot count, usually because a backend transform will create it. + * Saying "one" would silently collapse a colour scale. + */ +function fieldPresent(table: any[], field: string | undefined): boolean { + if (!field) return false; + return table.some((row) => row != null && Object.prototype.hasOwnProperty.call(row, field)); +} + +interface Bindings { + measureChannels: Array<'x' | 'y'>; + categoricalChannel?: 'x' | 'y'; + seriesChannel?: string; + facetChannel?: string; +} + +function bindRoles(ctx: GroundingContext): Bindings { + const measureChannels: Array<'x' | 'y'> = []; + let categoricalChannel: 'x' | 'y' | undefined; + + for (const ch of ['x', 'y'] as const) { + if (!channelPresent(ctx, ch)) continue; + const t = channelType(ctx, ch); + const banded = ctx.axisFlags?.[ch]?.banded === true; + // A banded axis carries identity even when its field is quantitative + // (a binned histogram axis), so banding wins over the encoding type. + if (t === 'quantitative' && !banded) measureChannels.push(ch); + else categoricalChannel = ch; + } + // Both quantitative (scatter): there is no categorical axis, and both axes + // take the measure role. Both discrete (heatmap): neither does. + if (measureChannels.length === 2) categoricalChannel = undefined; + + const seriesChannel = SERIES_CHANNELS.find((c) => ctx.channelSemantics?.[c]?.field) + ?? (ctx.positional?.color?.field ? 'color' : undefined); + const facetChannel = FACET_CHANNELS.find((c) => ctx.channelSemantics?.[c]?.field); + return { measureChannels, categoricalChannel, seriesChannel, facetChannel }; +} + +function deriveSignals(ctx: GroundingContext, b: Bindings): Signals { + const seriesField = channelFact(ctx, b.seriesChannel)?.field; + const catField = channelFact(ctx, b.categoricalChannel)?.field; + + let isSigned = false; + // Not only the measures on the axes: a heat map counts in colour, and a + // temperature that goes below zero is signed wherever it is drawn. + const signedChannels = [...b.measureChannels, ...(b.seriesChannel ? [b.seriesChannel] : [])]; + for (const ch of signedChannels) { + const f = channelFact(ctx, ch)?.field; + if (!f) continue; + for (const row of ctx.table) { + const v = row?.[f]; + if (typeof v === 'number' && v < 0) { isSigned = true; break; } + } + if (isSigned) break; + } + + const isTemporal = (['x', 'y'] as const).some((ch) => channelType(ctx, ch) === 'temporal'); + + const seriesKnown = fieldPresent(ctx.table, seriesField); + + return { + markChannel: ctx.markChannel, + hasBandedAxis: ctx.axisFlags?.x?.banded === true || ctx.axisFlags?.y?.banded === true, + seriesCount: seriesField ? (seriesKnown ? distinctCount(ctx.table, seriesField) : 0) : 1, + seriesCountKnown: !seriesField || seriesKnown, + categoryCount: catField ? distinctCount(ctx.table, catField) : 0, + isPartToWhole: ctx.partToWhole === true + || ctx.stacked === 'normalize' + || Boolean(ctx.channelSemantics?.theta?.field), + isSigned, + isTemporal, + isFaceted: Boolean(b.facetChannel), + isSummarised: ctx.markTypes.some((m) => m === 'boxplot' || m === 'errorbar' || m === 'errorband') + // A violin or density plot draws a distribution as a *shape* built + // from an area mark — the same escape a box plot gets from its + // `boxplot` mark, these earn from what they are, not how they draw. + // Their subject is the silhouette; a single number stamped on it + // names a quantity the chart was chosen not to reduce to. + || DISTRIBUTION_SHAPE_CHARTS.has(ctx.chartType), + canvasWidth: Math.round(ctx.layout.subplotWidth || ctx.canvasSize.width), + }; +} + +// --------------------------------------------------------------------------- +// Grounding +// --------------------------------------------------------------------------- + +export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDecisions { + const report: ThemeReport[] = []; + const say = (path: string, message: string) => report.push({ stage: 'ground', path, message }); + + const bindings = bindRoles(ctx); + const signals = deriveSignals(ctx, bindings); + // A cell matrix already supplies both positional structures through its + // tiles. Axis grids add no location cue and can show through painted cell + // gaps, so they stand down while the theme's tile policy separates cells. + const gridCells = signals.markChannel === 'color' + && ctx.axisFlags?.x?.banded === true + && ctx.axisFlags?.y?.banded === true + && Boolean(ctx.positional?.x && ctx.positional?.y); + + // --- variants ----------------------------------------------------------- + let theme: ThemeSpec = themeIn; + for (const variant of themeIn.variants ?? []) { + if (!variant.when || !guardHolds(variant.when, signals)) continue; + theme = deepMerge(theme, variant.then); + say('variants', `applied variant ${JSON.stringify(variant.when)}${variant.because ? ` — ${variant.because}` : ''}`); + } + + // --- surface ------------------------------------------------------------ + const houseCanvas = theme.ink?.surface?.canvas; + const deferToHost = (theme.ink?.surface?.source ?? 'house') === 'host'; + const canvas = (deferToHost ? (ctx.hostSurface ?? houseCanvas) : houseCanvas) ?? '#ffffff'; + const plot = theme.ink?.surface?.plot ?? canvas; + const panel = theme.ink?.surface?.panel ?? plot; + const dark = isDarkSurface(plot); + + const text = { + primary: theme.ink?.text?.primary ?? contrastingInk(plot, '#f3f2f1', '#121212'), + secondary: theme.ink?.text?.secondary ?? mixHex(plot, contrastingInk(plot, '#ffffff', '#000000'), 0.72), + muted: theme.ink?.text?.muted ?? mixHex(plot, contrastingInk(plot, '#ffffff', '#000000'), 0.45), + inverse: theme.ink?.text?.inverse ?? (dark ? '#121212' : '#ffffff'), + }; + const foreground = text.primary; + + // --- typography --------------------------------------------------------- + // Grounding resolves size against the space actually available: the same + // token is a different number of pixels on a 700px chart and a 250px one. + const targetWidth = theme.layout?.targetWidth ?? 300; + const available = ctx.layout.subplotWidth || ctx.canvasSize.width || targetWidth; + const scale = clamp(Math.pow(available / targetWidth, 0.3), 0.85, 1.2); + const minSize = theme.type?.minSize ?? 8; + const bodyFamily = theme.type?.axisLabel?.family + ?? theme.type?.valueLabel?.family + ?? theme.type?.headline?.family; + + function resolveType(role: TypeRole | undefined, fallbackSize: number, fallbackColor: string): ResolvedText { + const px = tokenToPx(role?.size) ?? fallbackSize; + const sized = Math.max(minSize, Math.round(px * scale * 2) / 2); + return { + font: role?.family ?? bodyFamily, + fontSize: sized, + fontWeight: role?.weight ? WEIGHTS[role.weight] : undefined, + fontStyle: role?.style === 'italic' ? 'italic' : undefined, + color: role?.color ?? fallbackColor, + }; + } + + const axisLabelText = resolveType(theme.type?.axisLabel, 10, text.secondary); + const axisTitleText = resolveType(theme.type?.axisTitle, 10, text.secondary); + const headline = resolveType(theme.type?.headline, 14, text.primary); + const deck = resolveType(theme.type?.deck, 11, text.secondary); + const valueLabel = resolveType(theme.type?.valueLabel, 10, text.primary); + const keyLabel = resolveType(theme.type?.keyLabel, 10, text.secondary); + + // --- structure ---------------------------------------------------------- + const structure = theme.structure ?? {}; + const structureInk = theme.ink?.structure ?? {}; + + const ink = (presence: Presence | undefined, roleInk: string | undefined, fallback: Presence) => + resolvePresenceInk({ presence, surface: plot, roleInk, foreground, fallback }); + + const rule = (presence: Presence | undefined, roleInk: string | undefined, fallback: Presence, base = 1): ResolvedRule => { + const color = ink(presence, roleInk, fallback); + return { show: color !== null, color: color ?? 'transparent', width: presenceWidth(presence ?? fallback, base) }; + }; + + const gridStyle = structure.grid?.style ?? 'solid'; + const gridDash = gridStyle === 'dashed' ? [3, 3] : gridStyle === 'dotted' ? [1, 3] : undefined; + const gridWeight = structure.grid?.weight ?? 1; + + const measureGrid: ResolvedRule = { + ...rule(structure.grid?.measure, structureInk.grid, 'quiet', gridWeight), + dash: gridDash, + }; + const categoryGrid: ResolvedRule = { + ...rule(structure.grid?.category, structureInk.grid, 'omit', gridWeight), + dash: gridDash, + }; + // Zero is not one gridline among the others: it is where the measure + // changes sign, and on a chart of lengths it is the line every mark is + // measured from. A house that wants it stated says so; the default is to + // let it be an ordinary line. + const zeroRule: ResolvedRule | undefined = structure.grid?.zero + && structure.grid.zero !== 'omit' + ? rule(structure.grid.zero, structureInk.zero ?? structureInk.rule ?? structureInk.axis, 'full') + : undefined; + + const frame = rule(structure.frame, structureInk.frame ?? structureInk.axis, 'omit'); + const baseline = rule(structure.baseline, structureInk.axis, 'full'); + + const truncation = theme.labels?.truncation ?? 'ellipsis'; + const labelFlush = theme.labels?.flush === true; + const axisTitlesPolicy = theme.annotation?.axisTitles ?? 'whenAmbiguous'; + + // Which axis the chart is read *along*. Where a category sits on an axis + // that is the answer; where both axes carry quantities — a connected + // scatter, a phase plot — the horizontal one still runs the reading order. + const indexChannel: 'x' | 'y' | undefined = bindings.categoricalChannel + ?? (bindings.measureChannels.includes('x') && bindings.measureChannels.includes('y') ? 'x' : undefined); + + function buildAxis(channel: 'x' | 'y', role: 'categorical' | 'measure'): ResolvedAxis { + const spec = role === 'measure' ? structure.axis?.measure : structure.axis?.categorical; + const opposite = spec?.placement === 'opposite'; + const orient: ResolvedAxis['orient'] = channel === 'x' + ? (opposite && channel !== indexChannel ? 'top' : 'bottom') + : (opposite ? 'right' : 'left'); + + // The axis a reader indexes the chart *by* is not always the discrete + // one: a connected scatter has two quantities and still reads left to + // right. Houses that draw a rule under the categories draw it under + // that axis too — it is the base the chart stands on, not a ruler for + // reading values off. So the index axis takes the categorical line + // even when what it carries is a number. + const indexing = role === 'categorical' || channel === indexChannel; + const lineSpec = indexing ? (structure.axis?.categorical ?? spec) : spec; + + // A base rule is the line the marks stand on. Where no axis carries a + // measure at all — a grid of cells, whose quantity is in the colour — + // there is nothing standing on it, and the rule is just a line under a + // list of names. + const standsOnIt = bindings.measureChannels.length > 0; + // A bar table prints each value in its own column, so its category axis + // is a row-header gutter, not a base the bars stand on — like the + // no-measure grid above, a rule under the names is a line under a list. + const tableGutter = indexing && TABLE_CHARTS.has(ctx.chartType); + const domain = (indexing && !standsOnIt) || tableGutter + ? rule('omit', structureInk.axis, 'omit', lineSpec?.lineWeight ?? 1) + : rule(lineSpec?.line, structureInk.axis, indexing ? 'full' : 'omit', lineSpec?.lineWeight ?? 1); + if (((indexing && !standsOnIt) || tableGutter) && (lineSpec?.line ?? 'full') !== 'omit') { + say(`structure.axis.categorical.line`, tableGutter + ? 'a bar table prints its values in a column — the category axis is a row-header gutter, not a base, so a rule under the names is a line under a list' + : 'no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing'); + } + const tickLen = spec?.tickLength === 'long' ? 5 : spec?.tickLength === 'short' ? 2 : 3; + // A row-header gutter has neither spine nor ticks — dropping the line + // but keeping ticks leaves them floating against nothing. + const ticksRule = tableGutter + ? rule('omit', structureInk.axis, 'omit') + : rule(spec?.ticks, structureInk.axis, 'omit'); + const inward = spec?.tickDirection === 'inward'; + + // A title that only repeats what the labels already say is noise. + // `whenAmbiguous` is a question about the field: `Jan Feb Mar` needs + // nobody to write `Month` over it, and a column of `26 20 14` names + // nothing until someone writes `Temp (°C)` beside it. A rank or a + // binned range is in the second group even though it sits on a + // categorical axis — its labels are numbers wearing an order. + // + // A house that drops its axis titles altogether is leaning on the + // headline to name the measure, and a headline names one. Where both + // rulers carry a measure — one quantity plotted against another — the + // headline cannot say which is which, and two rows of bare numbers + // name nothing. The titles stay. + // + // And a chart with no headline at all has nothing to lean on. `omit` + // is a delegation, not a deletion: where the words it delegates to were + // never written, the labels that name nothing get their title back. + const twoMeasures = bindings.measureChannels.includes('x') + && bindings.measureChannels.includes('y'); + const selfNaming = labelsNameThemselves(ctx, channel); + const undelegated = !selfNaming && ctx.titled !== true; + const showTitle = axisTitlesPolicy === 'always' + ? true + : axisTitlesPolicy === 'omit' + ? ((twoMeasures && role === 'measure') || undelegated) + : !selfNaming; + if (axisTitlesPolicy === 'whenAmbiguous' && selfNaming !== (role !== 'measure')) { + say(`structure.axis.${role}.title`, + selfNaming + ? `the labels on ${channel} name their own kind — a title over them would repeat what is already read` + : `the labels on ${channel} are values, not names — without a title nothing on the axis says what they count`); + } + if (axisTitlesPolicy === 'omit' && undelegated) { + say('annotation.axisTitles', + `the house omits axis titles because the headline names the measure — this chart has no headline, so the title on ${channel} stays`); + } + if (axisTitlesPolicy === 'omit' && twoMeasures && role === 'measure' && channel === 'x') { + say('annotation.axisTitles', + 'both rulers carry a measure — a headline can name one of them, so the axis titles are kept'); + } + + // A measure axis is a ruler, and how finely it is graduated is a house + // matter: roughly one label every 45px reads as ordinary, one every + // 60px as quiet. Three is the floor — two gradations is not a ruler. + const density = spec?.tickDensity; + const span = channel === 'x' ? ctx.layout.subplotWidth : ctx.layout.subplotHeight; + const tickCount = role === 'measure' + ? Math.max(3, Math.round((span / (density === 'sparse' ? 60 : density === 'dense' ? 30 : 45)))) + : undefined; + + // A house that drops axis titles drops the only place the unit was + // written. Where it asks for the unit on the ticks instead, the tag is + // recovered from what the chart already knows the field to be — but + // only where the axis still counts in that unit. A normalized stack + // reads in shares, whatever the field was measured in. + const unitPolicy = theme.annotation?.unit ?? 'never'; + const inFieldUnits = ctx.stacked !== 'normalize' && !ctx.partToWhole; + const unit = role === 'measure' && inFieldUnits ? unitText(ctx, channel) : undefined; + const unitTag = unitPolicy !== 'never' ? unit : undefined; + + // Where the house keeps its axis titles, the title is the natural place + // for the unit — `Weight (lb)` — and the ticks stay bare numbers. + const titleUnit = showTitle && theme.annotation?.unitsInAxisTitle === true ? unit : undefined; + + // The gap between a label and the plot is the same gap whether or not a + // tick is drawn in it. Where there is one, the tick spans the first part + // of that distance and the padding covers the rest; where the house + // draws none — or turns them inward — the padding is the whole distance, + // and 4px leaves the label sitting against the edge of the plot. + const defaultLabelGap = labelFlush ? 2 : 4 + tickLen; + const ruleToLabelGap = spec?.labelGap ?? defaultLabelGap; + const labelPadding = ticksRule.show && !inward + ? Math.max(0, ruleToLabelGap - tickLen) + : ruleToLabelGap; + + return { + role, + orient, + domain, + ticks: { + ...ticksRule, + size: ticksRule.show ? tickLen : 0, + offset: inward ? -tickLen : 0, + }, + grid: gridCells + ? rule('omit', structureInk.grid, 'omit', gridWeight) + : indexing ? categoryGrid : measureGrid, + label: { + ...axisLabelText, + limit: truncation === 'never' ? 0 : undefined, + padding: labelPadding, + flush: labelFlush, + angle: theme.labels?.angle === 'horizontal' ? 0 : undefined, + }, + title: { + show: showTitle, + ...axisTitleText, + ...(showTitle ? { placement: theme.annotation?.axisTitlePlacement } : {}), + ...(titleUnit ? { unit: titleUnit } : {}), + }, + tickCount, + tickLabels: (indexing ? structure.axis?.categorical : spec)?.tickLabels, + indexing, + ...(indexing || !zeroRule ? {} : { zeroRule }), + unit: unitTag ? { text: unitTag, where: unitPolicy } : undefined, + }; + } + + const axes: DesignDecisions['axes'] = {}; + for (const ch of bindings.measureChannels) axes[ch] = buildAxis(ch, 'measure'); + if (bindings.categoricalChannel) { + axes[bindings.categoricalChannel] = buildAxis(bindings.categoricalChannel, 'categorical'); + } + // A heat map has *two* category axes and the bindings can only name one. + // The other is just as much a ruler for the reader, and left unbound it + // keeps whatever the template drew — its own titles, its own ink. + for (const ch of ['x', 'y'] as const) { + if (!axes[ch] && ctx.positional?.[ch]) axes[ch] = buildAxis(ch, 'categorical'); + } + if (theme.labels?.angle === 'rotated' && axes.x) { + axes.x.label.angle = -45; + } + // A house may set its category labels flat, but "flat" is a preference and + // "legible" is not. Where the band is narrower than the word standing under + // it, the angle goes back to the layout pass, which owns fit. + // + // The reverse is just as true and matters more often: the layout pass sized + // the labels in flint's own type, and a house that sets smaller labels buys + // room the layout did not know it would have. A name that now fits under + // its band should be read straight, not at forty-five degrees because of a + // measurement taken in a font the chart no longer uses. + if (axes.x && (bindings.categoricalChannel === 'x' + || ctx.positional?.x?.type === 'nominal' || ctx.positional?.x?.type === 'ordinal')) { + const field = channelFact(ctx, 'x')?.field ?? ctx.positional?.x?.field; + const type = ctx.positional?.x?.type ?? channelFact(ctx, 'x')?.type; + const banded = type === 'nominal' || type === 'ordinal'; + const longest = field + ? Math.max(0, ...ctx.table.map((r) => String(r?.[field] ?? '').length)) + : 0; + // Mixed-case words average out narrower than the widest glyph — half + // the point size a character is close enough — plus a couple of pixels + // so two names never touch. + const needed = longest * (axes.x.label.fontSize ?? 10) * 0.5 + 2; + const step = ctx.layout.xStep; + const fits = longest > 0 && step > 0 && needed <= step; + if (axes.x.label.angle === 0 && !fits && longest > 0 && step > 0) { + axes.x.label.angle = undefined; + say('axes.x.label.angle', + `the house sets category labels flat, but the widest needs ~${Math.round(needed)}px in a ${Math.round(step)}px band — the angle is left to the layout`); + } else if (axes.x.label.angle == null && fits && banded && theme.labels?.angle !== 'rotated') { + axes.x.label.angle = 0; + say('axes.x.label.angle', + `the widest name needs ~${Math.round(needed)}px and the band is ${Math.round(step)}px — at the house's label size they read straight`); + } + } + + // --- series ink --------------------------------------------------------- + const series = groundSeriesInk(theme, ctx, bindings, signals, say); + + // --- legend ------------------------------------------------------------- + const legendSpec = theme.legend ?? {}; + const ranked: LegendPlacement[] = legendSpec.placement?.length + ? legendSpec.placement + : ['right']; + + const seriesField = channelFact(ctx, bindings.seriesChannel)?.field; + const catField = channelFact(ctx, bindings.categoricalChannel)?.field; + // A legend over a quantitative series is a key to values, not to names. + const seriesIsValueKey = channelFact(ctx, bindings.seriesChannel)?.type === 'quantitative'; + + let legendShow = Boolean(seriesField) + && (!signals.seriesCountKnown || signals.seriesCount > 1) + && legendSpec.show !== 'never'; + if (legendShow && legendSpec.suppressWhenAxisNames && seriesField && seriesField === catField) { + legendShow = false; + say('legend.suppressWhenAxisNames', 'legend removed — it restated the categorical axis'); + } + + // Which placements grounding can offer at all. `inline` needs a label + // anchored to each mark's own geometry, which only line-family charts have + // room for; the ranked list exists precisely so this can fall through. + // + // A stacked band has that room too, and more of it: the name goes *inside* + // the band at its last reading, which is where a reader's eye already is + // when they ask which band this is. + const lineFamily = ctx.markTypes.some((m) => m === 'line' || m === 'trail'); + // A violin or density plot is an area too, but its band has no meaningful + // last reading to hang a name on: the shape is a distribution, and its + // right edge is an arbitrary tail near zero, not an endpoint the eye rests + // at. Grounding withholds the in-band placement from it, and the name falls + // through to the ranked list — a legend — where the shapes stay legible. + const bandFamily = ctx.markTypes.some((m) => m === 'area') && !signals.isSummarised; + const placementRealizable = (p: LegendPlacement): boolean => { + if (p === 'seriesEnd' || p === 'inline') return (lineFamily || bandFamily) && !signals.isFaceted; + return true; + }; + let placement: LegendPlacement = 'right'; + let fallbacks: LegendPlacement[] = []; + for (const [i, p] of ranked.entries()) { + if (placementRealizable(p)) { + placement = p; + fallbacks = ranked.slice(i + 1).filter(placementRealizable); + break; + } + say('legend.placement', `\`${p}\` not available for this chart — falling through`); + } + + const legendOrient = placement === 'inside' + ? 'top-right' + : placement === 'seriesEnd' || placement === 'inline' + ? 'none' + : (placement as 'top' | 'right' | 'bottom' | 'left'); + + // A chart that prints each series' name at its right-hand end (seriesEnd / + // inline placement on a left-to-right axis) has claimed the right margin. + // A house that also seats its measure axis on the right (opposite + // placement) would stack that axis's tick labels under the names — + // "Uni1ted", "Ch2na", "Ja3an" on a bump chart's rank axis. The end labels + // own the right side; the measure axis falls back to the left. + if (placement === 'seriesEnd' || placement === 'inline') { + for (const ch of bindings.measureChannels) { + const ax = axes[ch]; + if (ax && ax.orient === 'right') { + ax.orient = 'left'; + say('axes.measure.placement', + 'the series names sit at the line ends on the right — the measure axis moves to the left so its ticks do not land under the names'); + } + } + } + + // A key to a set of names needs no title: `Chrome`, `Safari`, `Firefox` + // say what kind of thing they are, and `Browser` written over them repeats + // it. A ramp of numbers says nothing of the sort — `26` is an instance of + // nothing until the key names what it counts. `whenAmbiguous` is that + // question, and it is asked of the field, not answered with a constant. + const titlePolicy = legendSpec.title ?? 'whenAmbiguous'; + const keyNamesItself = labelsNameThemselves(ctx, bindings.seriesChannel); + const legendTitle = titlePolicy === 'always' + || (titlePolicy === 'whenAmbiguous' && legendShow && !keyNamesItself); + if (legendTitle && titlePolicy !== 'always') { + say('legend.title', + 'the key is a ruler, not a list of names — without a title nothing says what its numbers count'); + } + + // --- data labels -------------------------------------------------------- + const dl = theme.dataLabels ?? {}; + let dlPlacement = dl.placement ?? 'outsideMark'; + // A label placed *on* a mark sits on the mark's fill, whatever the house + // said about ink. Contrast is a legibility floor, not a style choice. + const dlInkMode = dl.inkMode + ?? (dlPlacement === 'atMark' ? 'contrastWithMark' : 'fixed'); + if (!dl.inkMode && dlInkMode === 'contrastWithMark') { + say('dataLabels.inkMode', + 'no ink mode declared but the label sits on the mark — it contrasts with what it is printed on'); + } + // The reader's own answer outranks the house's standing preference: `off` + // is a decision that this chart carries no numbers, `on` that it does. + // Absence leaves the house in charge — and with no house named, the neutral + // default is silence, so an untheme'd chart prints numbers only when asked. + // `on` becomes `always` rather than an unconditional print, so it inherits + // the density guard below — a control that can bury a chart in unreadable + // numbers is not a control, it is a trap. + const dlShowPolicy: 'always' | 'whenTheyFit' | 'never' | undefined = + ctx.valueLabels === 'off' ? 'never' + : ctx.valueLabels === 'on' ? 'always' + : dl.show; + if (ctx.valueLabels === 'on' || ctx.valueLabels === 'off') { + if (dlShowPolicy !== dl.show) { + say('dataLabels.show', + `the chart asked for value labels \`${ctx.valueLabels}\`, overriding the house's \`${dl.show ?? 'unset'}\``); + } + } + let dlShow = dlShowPolicy === 'always'; + + // A cell in a grid *is* a position. The reader finds it by its row and its + // column, and the number goes in the middle of it — the measure being on + // colour is the reason the number is worth printing, not a reason to + // withhold it. + // A printed value has to be *keyed* to something the eye can separate — a + // band, a slice, a discrete step. On a continuous-by-continuous plot there + // is no such anchor: every datum would get its own floating number and the + // result is not a labelled chart, it is a chart with numbers spilled on it. + // This gate binds `always` too; `always` is a house habit, not a licence. + // There also has to be a value to print: a heatmap has two banded axes and + // its measure on colour, and a number can only be printed where a measure + // is on a position. And where the chart summarises a distribution, the + // marks in a band *are* the sample: its subject is the shape, and thirty + // numbers per band bury it. + // + // Stacked segments are labelled, but only in the middle of the segment. + // The objection to labelling a stack is against the *edge*, where a number + // reads as the running total; a number centred in the segment reads as the + // segment, which is the one thing a stacked bar otherwise makes hard to + // get at. Whether each segment is thick enough to hold that number is a + // separate question, settled below. + // + // A line or an area is the exception the part-to-whole test would + // otherwise let through: a normalized stacked area *is* a part of a whole, + // but it is drawn as a continuous ribbon with no slot to print into, so + // the numbers land on the vertices — which are sampling points, not marks + // a reader is meant to read off one at a time. And on a normalized chart + // they name a quantity the axis does not carry: the axis is a percentage + // and the number is a raw total. + const continuousMark = ctx.markTypes.some((m) => m === 'area' || m === 'line' || m === 'trail'); + const labelable = ((signals.hasBandedAxis && (bindings.measureChannels.length > 0 || gridCells)) + || signals.isPartToWhole) + && !signals.isSummarised + && !continuousMark + && !MULTI_VALUE_GLYPH_CHARTS.has(ctx.chartType); + if (dlShow && !labelable) { + dlShow = false; + say('dataLabels.show', signals.isSummarised + ? 'the chart summarises a distribution — each band holds a sample, not one quantity to print' + : continuousMark + ? 'the mark is a continuous line or ribbon — its vertices are sampling points, not marks to read off one at a time' + : MULTI_VALUE_GLYPH_CHARTS.has(ctx.chartType) + ? 'the mark carries several measures at once — there is no single value to print, and the measure axis stays as the only reading of them' + : signals.hasBandedAxis + ? 'the measure is not on an axis — there is no position to print a value at' + : 'no banded axis to key values to — one number per datum would be noise, not a label'); + } + + // How wide the printed number itself is. Needed before the fit checks + // below, not after them: on a dodged chart the number's own width is what + // decides whether a bar's slot can carry it, so a check that runs later + // can only veto a decision already reported — which is how the control + // came to be offered on charts that then printed nothing. + // + // What is measured is the label as it will be *printed*. Measuring + // `String(Math.round(value))` instead — as this did — is the width of a + // number nobody prints: it ignores the decimals, the separators, the sign + // and the format, so a chart of decimals measured four times narrower than + // it drew and its labels were offered straight into a pile. + let valueMaxAbs = 0; + let measureField: string | undefined; + const labelValues: number[] = []; + { + const mch = bindings.measureChannels[0]; + measureField = mch + ? (ctx.channelSemantics[mch]?.field ?? ctx.positional?.[mch]?.field) + : undefined; + if (measureField) { + for (const row of ctx.table) { + const v = row?.[measureField]; + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + valueMaxAbs = Math.max(valueMaxAbs, Math.abs(v)); + labelValues.push(v); + } + } + } + const numberFormatChoice = groundNumberFormat(theme, ctx, bindings.measureChannels[0], labelValues); + const numberFormat = numberFormatChoice.pattern; + if (numberFormatChoice.inferred && labelValues.length > 0) { + // Say it out loud: the digits a label carries are a decision, and a + // silent one would look like the number had simply been mangled. + const rawWidth = longestLabelChars(labelValues, undefined); + const shown = longestLabelChars(labelValues, numberFormat); + say('annotation.numberFormat', + `printed values use \`${numberFormat}\` — three significant figures is what a reader takes off a mark, and it holds the longest label to ${shown} characters where the raw value runs to ${rawWidth}`); + } + // A normalized stack prints each segment's share, not its value, so that + // is the string whose width has to fit — never wider than `100%`, however + // large the underlying numbers are. + const normalizedStack = (ctx.stacked ?? undefined) === 'normalize'; + const labelChars = normalizedStack + ? 4 + : longestLabelChars(labelValues, numberFormat); + const valueLabelWidthPx = (valueLabel.fontSize ?? 10) * 0.62 * labelChars + 12; + // The ink alone, without the breathing room a label wants when it has to + // sit *inside* something. Two labels floating above their own bars only + // need to clear each other. + const labelTextPx = (valueLabel.fontSize ?? 10) * 0.62 * labelChars; + + // Both policies read fit from the same two facts — room enough to stand a + // number in, and few enough marks that the numbers do not pile up — and + // differ only in where they draw the line. Computing them once also gives + // the honest answer to "could this chart carry labels at all?", which is + // what a host needs to decide whether offering the control is meaningful. + const labelBand = signals.hasBandedAxis + ? (bindings.categoricalChannel === 'y' ? ctx.layout.yStep : ctx.layout.xStep) + : Infinity; + // A dodged chart splits its band between the series with nothing between + // them, so the room a single number gets is the band over the series count + // — not the band. A single series keeps the whole band and can lean a + // number into the padding on either side. + const dodged = signals.seriesCount > 1 + && (bindings.categoricalChannel === 'y' + ? ctx.layout.yStepUnit === 'group' + : ctx.layout.xStepUnit === 'group'); + const labelSlot = dodged ? labelBand / Math.max(1, signals.seriesCount) : labelBand; + const labelMarks = Math.max(1, signals.categoryCount || ctx.table.length) + * Math.max(1, signals.seriesCount); + // Which way the number has to fit depends on which way the bars run. Across + // a vertical bar it is the number's *width* that must clear the slot; along + // a horizontal one the number sits at the bar's end, so what the slot must + // hold is the height of a line of text. + const slotHoldsLine = labelSlot >= (valueLabel.fontSize ?? 10) + 4; + // `ctx.stacked` reports only an *explicit* stack; a bar with a colour + // channel is stacked by Vega-Lite without being asked, and that shows up + // in the positional facts. It is `||`, not `??`: the explicit reading is + // `false` rather than absent when nothing was stated. + const stacked = ctx.stacked || ctx.positional?.stacked; + // On a vertical bar the number lies across the band, so the band has to be + // at least as wide as the number is — and that holds whether or not the + // bar shares its band. Moving the label above the bar buys height, not + // width: the label above bar B still runs into the label above bar C. + // `,.0f` on nine-digit revenues drew `987,654,321` across three bands and + // off both plot edges, which is the case this closes. + const widthIsBinding = bindings.categoricalChannel === 'x'; + // A label sharing its band — dodged or stacked — has to stand in the room + // it is given, gutter and all. One floating above its own bar only has to + // clear its neighbour: labels are centred on the band, so two of them + // touch exactly when the printed string is wider than the step. + const widthNeeded = (dodged || Boolean(stacked)) ? valueLabelWidthPx : labelTextPx; + const slotHoldsNumber = !widthIsBinding || labelSlot >= widthNeeded; + // A stacked bar shares its band between the segments the *other* way: the + // band is whole, but each segment's own thickness is what has to hold a + // line of text. Segments thinner than that are dropped one by one further + // down; what is settled here is the chart-level question — if not one + // segment can carry its number, there is nothing to offer the reader. + let segmentsFit = true; + let totalSegments = 0; + let thinSegments = 0; + let segmentMinShare: number | undefined; + if (stacked && measureField && signals.hasBandedAxis) { + // Along the measure axis, a segment gets the share of the plot its + // value has of the tallest stack — or, on a normalized chart, of its + // own stack, since every bar is drawn full height. + const extent = bindings.categoricalChannel === 'y' + ? ctx.layout.subplotWidth + : ctx.layout.subplotHeight; + const catField = bindings.categoricalChannel === 'y' + ? (ctx.positional?.y?.field ?? ctx.channelSemantics.y?.field) + : (ctx.positional?.x?.field ?? ctx.channelSemantics.x?.field); + const totals = new Map(); + for (const row of ctx.table) { + const v = row?.[measureField]; + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + const key = catField ? row?.[catField] : ''; + totals.set(key, (totals.get(key) ?? 0) + Math.abs(v)); + } + const tallest = Math.max(0, ...totals.values()); + const minPx = (valueLabel.fontSize ?? 10) + 4; + if (extent > 0) segmentMinShare = minPx / extent; + for (const row of ctx.table) { + const v = row?.[measureField]; + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + const key = catField ? row?.[catField] : ''; + const against = stacked === 'normalize' ? (totals.get(key) ?? 0) : tallest; + if (against <= 0) continue; + totalSegments += 1; + if ((Math.abs(v) / against) * extent < minPx) thinSegments += 1; + } + segmentsFit = totalSegments === 0 || thinSegments < totalSegments; + } + const bandHoldsNumber = slotHoldsLine && slotHoldsNumber && segmentsFit; + // The hard ceiling: past this the numbers cannot be read whoever asked for + // them, so it binds `always` and an explicit `on` alike. + const readableAtAll = bandHoldsNumber && labelMarks <= 120; + + if (dlShowPolicy === 'always' && dlShow) { + // `always` is a preference to print, not a licence to overprint. It + // holds to that preference past `whenTheyFit`'s comfort margin, onto + // the tight-but-legible charts the cautious houses leave to a legend, + // and yields only when the marks are genuinely too dense (a hundred-odd + // bars, a dozen-plus pie slices) for the numbers to be read. + if (!readableAtAll) { + const asked = ctx.valueLabels === 'on' ? 'the chart asked to print values, but' : '`always` overridden —'; + dlShow = false; + say('dataLabels.show', !segmentsFit + ? `${asked} every segment is thinner than a line of text — none can hold its number` + : !slotHoldsNumber + ? (dodged + ? `${asked} the bars group ${signals.seriesCount} to a band — each is ${Math.round(labelSlot)}px wide, too narrow to carry a ${Math.round(valueLabelWidthPx)}px number without it landing on the next bar` + : `${asked} the bars are ${Math.round(labelSlot)}px wide and the number is ${Math.round(valueLabelWidthPx)}px — it would overrun the bar it belongs to`) + : !slotHoldsLine + ? `${asked} a ${Math.round(labelSlot)}px slot cannot hold a number` + : `${asked} ${labelMarks} marks would pile the numbers past reading`); + } + } + + if (dlShowPolicy === 'whenTheyFit') { + dlShow = labelable && bandHoldsNumber && labelMarks <= 40; + if (!dlShow) { + say('dataLabels.show', labelable + ? `\`whenTheyFit\` resolved to false (slot ${Math.round(labelSlot)}px, ${labelMarks} marks)` + : '`whenTheyFit` resolved to false — no banded axis to key values to'); + } + } + // A stacked segment's number belongs in the middle of the segment and + // nowhere else. Outside the mark is the top of the *stack*, which is a + // different quantity, and the segment edge is the running total — the very + // reading a stacked label has to avoid. + if (dlShow && stacked && dlPlacement !== 'atMark') { + say('dataLabels.placement', + `\`${dlPlacement}\` printed in the segment instead — outside a stacked bar is the top of the stack, not the end of the segment`); + dlPlacement = 'atMark'; + } + // A number is printed across a bar's *width*, not up its height. A single + // bar too narrow for its own number does not lose the number — it moves it + // above the bar, where the gaps between bars give it room. (A dodged chart + // cannot do this: there are no gaps to move into, which is why that case is + // settled above, as a question of whether to label at all. Nor can a + // stacked one: above the bar means above the whole stack.) + if (dlShow && bindings.categoricalChannel === 'x' && signals.hasBandedAxis + && valueMaxAbs > 0 && !dodged && !stacked && valueLabelWidthPx > labelSlot + && dlPlacement === 'atMark') { + dlPlacement = 'outsideMark'; + say('dataLabels.placement', + `the bar is ${Math.round(labelSlot)}px wide but the number is ${Math.round(valueLabelWidthPx)}px — it moves above the bar, where the gaps between bars give it room`); + } + + if (dlShow && legendShow && legendSpec.suppressWhenValuesPrinted) { + // A printed value is not a name. It replaces a legend that was itself + // a value key — a ramp — but never one that carried series names, and + // a banded axis does not help: it names the category, not the series. + if (seriesIsValueKey) { + legendShow = false; + say('legend.suppressWhenValuesPrinted', 'legend removed — the ramp was a value key and every mark now prints its value'); + } else { + say('legend.suppressWhenValuesPrinted', + 'legend kept — the values are printed but nothing else names the series'); + } + } + + // The same argument, one axis over: once every mark states its own value, + // the measure axis is a second copy of the same information. + if (dlShow && structure.axis?.measure?.suppressWhenValuesPrinted) { + for (const ch of bindings.measureChannels) { + const ax = axes[ch]; + if (!ax) continue; + ax.label.show = false; + ax.grid = { show: false, color: 'transparent', width: 0 }; + ax.title = { ...ax.title, show: false }; + ax.ticks = { ...ax.ticks, show: false, size: 0 }; + ax.domain = { ...ax.domain, show: false }; + } + say('structure.axis.measure.suppressWhenValuesPrinted', + 'measure axis removed — every mark prints its own value'); + } + + const measureChannel = bindings.measureChannels[0]; + + // A unit has to be stated somewhere. Normally that is the ruler; a pie has + // no ruler, and a measure axis whose labels were removed is no longer one + // either. Where the house asks for a unit and nothing else can hold it, the + // printed value takes it — whoever printed it, the theme or the template. + const valueUnitChannel = measureChannel + ?? (['theta', 'size', 'radius'] as const).find((ch) => ctx.channelSemantics?.[ch]?.field); + const axisStatesUnit = (['x', 'y'] as const) + .some((ch) => axes[ch]?.unit && axes[ch]!.label.show !== false); + const houseStatesUnit = (theme.annotation?.unit ?? 'never') !== 'never' && !axisStatesUnit; + // A part-to-whole value whose slices sum to 100 *is* a percentage — the `%` + // is the number's meaning, not a house-style flourish, and a pie has no + // ruler to carry it. So it rides on the printed value whatever the house's + // axis-unit policy: a bare `65` on a slice reads as a count, not a share. + // (`percentOfWhole` only fires on values that actually total 100, so a pie + // of raw amounts keeps its bare numbers.) + const shareUnit = signals.isPartToWhole && !axisStatesUnit + ? percentOfWhole(ctx, valueUnitChannel ?? '') + : undefined; + const valueUnit = houseStatesUnit + ? (unitText(ctx, valueUnitChannel ?? '') ?? shareUnit) + : shareUnit; + + // A label placed at the mark sits *inside* it, which only works while the + // mark is longer than the label. Below that length the label has to move + // out, and above the point where the mark reaches the end of the scale an + // outside label has nowhere left to go. Grounding is the stage that can + // say where those two lines are. + let insideMinValue: number | undefined; + let outsideMaxValue: number | undefined; + if (dlShow && measureChannel) { + const span = measureChannel === 'x' ? ctx.layout.subplotWidth : ctx.layout.subplotHeight; + if (valueMaxAbs > 0 && span > 0) { + insideMinValue = (valueLabelWidthPx / span) * valueMaxAbs; + outsideMaxValue = valueMaxAbs - insideMinValue; + } + } + + // --- marks -------------------------------------------------------------- + const marksSpec = theme.marks ?? {}; + const separatorInk = marksSpec.separator?.source === 'surface' + ? plot + : structureInk.rule ?? structureInk.grid; + + const separator = marksSpec.separator + ? { + show: (marksSpec.separator.presence ?? 'omit') !== 'omit', + color: (marksSpec.separator.source === 'surface' + ? plot + : ink(marksSpec.separator.presence, separatorInk, 'hairline')) ?? plot, + width: marksSpec.separator.width ?? 1, + } + : undefined; + + // A house that says nothing about its wedges is not silent — it has + // already said how it holds adjoining marks apart, and a pie is adjoining + // marks. Only a house that wants a different answer for the circle than it + // gave for the bars has to say so twice. + const sliceGap = marksSpec.slice?.gap ?? (separator?.show ? separator.width : undefined); + + // And a grid of cells is adjoining marks too. The one thing it does not + // inherit is the ink: a bar's edge may be drawn in structure without + // anyone reading a value into it, but on a grid the fill *is* the value, + // so an edge in any ink but the surface's adds a colour the scale never + // named. A house that wants framed cells asks for them. + const tileGap = marksSpec.tile?.gap ?? (separator?.show ? separator.width : undefined); + + // A dot on a line is drawn over whatever the line passed through on its + // way there, and where several series share a plot that is another + // series' colour. A ring of the page around the dot is what holds the two + // apart: without it a crossing reads as one blob, and the reader cannot + // say which line the dot belongs to. That is a fact about lines meeting, + // not a matter of taste, so a chart that can have crossings gets the ring + // whether or not the house thought to name one — a house that wants none + // says `halo: { presence: 'omit' }`. + const crossingField = ctx.channelSemantics.color?.field + ?? ctx.channelSemantics.detail?.field + ?? ctx.positional?.color?.field; + const linesCanCross = ctx.markTypes.includes('line') + && !!crossingField + && new Set(ctx.table.map((r) => r?.[crossingField!]).filter((v) => v != null)).size > 1; + const haloDeclared = marksSpec.point?.halo?.presence !== undefined; + const halo = haloDeclared + ? marksSpec.point!.halo!.presence !== 'omit' + : linesCanCross; + if (halo && !haloDeclared) { + say('marks.point.halo', + 'the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on'); + } + + const marks: DesignDecisions['marks'] = { + bandFraction: marksSpec.bandFraction ?? (1 - (ctx.layout.stepPadding ?? 0.1)), + strokeWidth: marksSpec.strokeWeight ?? 2, + strokeCap: marksSpec.strokeCap, + strokeJoin: marksSpec.strokeJoin, + interpolate: marksSpec.interpolation === 'monotone' + ? 'monotone' + : marksSpec.interpolation === 'step' ? 'step' : undefined, + fillOpacity: marksSpec.fillOpacity, + cornerRadius: marksSpec.cornerRadius, + outline: marksSpec.outline && (marksSpec.outline.presence ?? 'omit') !== 'omit' + ? { + color: marksSpec.outline.source === 'surface' + ? plot + : (ink('full', structureInk.axis ?? structureInk.rule, 'full') ?? foreground), + width: marksSpec.outline.weight ?? 1.5, + } + : undefined, + point: marksSpec.point || halo + ? { + show: (marksSpec.point?.presence ?? 'omit') !== 'omit', + size: marksSpec.point?.size, + // Only a house that spoke about its dots' fill decides how + // they are filled; inventing an answer here would re-fill + // every scatter it has for the sake of a line chart's + // vertices — or, for a house that named only a size, turn + // the hollow glyphs of a shape-encoded scatter solid. + filled: marksSpec.point?.fill != null + ? marksSpec.point.fill !== 'hollow' + : undefined, + haloColor: halo ? plot : undefined, + haloWidth: marksSpec.point?.halo?.width ?? (halo ? 1.5 : undefined), + } + : undefined, + separator, + slice: sliceGap + ? { + gap: sliceGap, + style: marksSpec.slice?.gapStyle ?? 'rule', + color: separator?.color ?? plot, + } + : undefined, + tile: tileGap + ? { + gap: tileGap, + color: marksSpec.tile?.source === 'structure' + ? (ink('hairline', structureInk.rule, 'hairline') ?? plot) + : plot, + } + : undefined, + connector: { + // A house that names no connector has still not asked for its + // dumbbell's bridge to be drawn in a series colour. `show` says + // whether the house styles connectors at all; stage 3 uses it to + // separate the roles it may restyle freely from the one it must + // correct either way. The ink is resolved for both cases, so an + // undeclared house gets the same quiet structural grey a declared + // one would have got by saying nothing about its ink. + show: marksSpec.connector ? (marksSpec.connector.presence ?? 'omit') !== 'omit' : false, + // A connector is not a gridline. It borrows the rule's ink + // where the house declares none of its own, but it is read as + // part of the mark, not through it — so it is scaled at the + // step the house named against whichever of the two inks it + // gave, and a house whose rules are already pale states a + // `connector` ink rather than fading a faint grey further. + // + // Where the house declares no connector at all there is no step + // to scale and no ink to borrow that is not the grid's, and grid + // ink is too faint: a bridge carries the reading, so it has to + // sit clearly above the lines drawn *through* the plot even while + // it stays below the marks. The three houses that do state a + // connector ink put it at very nearly the same place — about + // 45% of the way from the axis-label ink toward the plot surface + // (mckinsey 0.48, powerbi 0.47, powerbi-light 0.26) — so a silent + // house is given the same relationship against its own two inks. + color: marksSpec.connector + ? ink( + marksSpec.connector.presence, + structureInk.connector ?? structureInk.rule, + 'quiet', + ) ?? undefined + : mixHex(axisLabelText.color ?? foreground, plot, 0.45, foreground), + width: marksSpec.connector?.weight ?? 1, + // A stem and a bridge are one setting only in the sense that + // both are drawn in structure's ink. What they are worth + // differs: a stem repeats a position already plotted, a bridge + // draws a distance that is plotted nowhere else. So a house + // that says nothing about the bridge is not silent about it + // either — it has already said what a mark of its own weighs. + spanWidth: marksSpec.connector?.spanWeight ?? (marksSpec.strokeWeight ?? 2), + ...(marksSpec.connector?.style && marksSpec.connector.style !== 'solid' + ? { dash: marksSpec.connector.style === 'dotted' ? [1, 2] : [4, 3] } + : {}), + }, + interval: marksSpec.interval + ? { + fillOpacity: marksSpec.interval.fillOpacity, + edge: (marksSpec.interval.edge ?? 'omit') !== 'omit', + } + : undefined, + summary: marksSpec.summary + ? { + fill: (marksSpec.summary.fill ?? 'full') !== 'omit', + outline: (marksSpec.summary.outline ?? 'full') !== 'omit', + centralRule: (marksSpec.summary.centralRule ?? 'full') !== 'omit', + widthFraction: marksSpec.summary.widthFraction, + } + : undefined, + reference: marksSpec.reference + ? { + show: (marksSpec.reference.presence ?? 'omit') !== 'omit', + width: marksSpec.reference.weight ?? 1, + style: marksSpec.reference.style, + label: marksSpec.reference.label === true, + } + : undefined, + zOrder: marksSpec.zOrder ?? 'summaryOverData', + sizeRange: marksSpec.sizeRange, + minSize: marksSpec.minSize, + observations: marksSpec.observations + ? { + expose: marksSpec.observations.expose ?? 'never', + maxRows: marksSpec.observations.maxRows ?? 500, + } + : undefined, + redundantChannels: marksSpec.redundantChannels ?? [], + redundantEncoding: marksSpec.redundantEncoding ?? 'never', + redundant: groundRedundancy(marksSpec, series, signals, + legendShow && (placement === 'seriesEnd' || placement === 'inline'), say), + }; + + // --- facets ------------------------------------------------------------- + const facetSpec = theme.facets ?? {}; + const headerPresence = facetSpec.header?.presence ?? 'full'; + const facets: DesignDecisions['facets'] = { + header: { + show: headerPresence !== 'omit', + fieldTitle: (facetSpec.header?.fieldTitle ?? 'omit') === 'always', + ...keyLabel, + color: headerPresence === 'emphasised' ? text.primary : keyLabel.color, + }, + panelFrame: (facetSpec.panelFrame ?? 'omit') !== 'omit', + axisRepetition: facetSpec.axisRepetition ?? 'everyPanel', + spacing: facetSpec.spacing === 'compact' ? 8 : facetSpec.spacing === 'airy' ? 24 : undefined, + preferredColumns: facetSpec.preferredColumns, + }; + + // --- layout ------------------------------------------------------------- + const density = theme.layout?.density ?? 'normal'; + const densityPadding = density === 'compact' ? 8 : density === 'airy' ? 20 : 12; + + // A house that paints its canvas has drawn a rectangle, and the padding + // stops being empty space: it becomes that rectangle's margin, a visible + // edge with the ink measured against it. On plain white the same number is + // invisible — the page's whitespace runs straight through it, so ink + // sitting 8px from the boundary still looks like it has all the room in + // the world, because there is no boundary to see. + // + // Against a painted edge it does not. The nearest ink to the boundary is + // almost always an axis tick label, and a margin narrower than the type it + // surrounds reads as a crop rather than a frame. That is exactly what the + // dark house was doing: `compact` density gave it 8px, its tick labels are + // 10px, and the numbers looked shaved off the bottom of the panel. + // + // So a painted canvas is held to a floor of one and a half label heights + // on all four sides — the usual margin for framed type, and derived from + // the type rather than picked, because it is that type the margin has to + // clear. A house already breathing wider than the floor keeps its own + // number: density is still the house's voice, and this only stops that + // voice from cropping itself. + const padding = isPaintedSurface(canvas) + ? Math.max(densityPadding, Math.round((axisLabelText.fontSize ?? 10) * 1.5)) + : densityPadding; + + return { + themeId: theme.id ?? 'flint', + surface: { canvas, plot, panel }, + text, + font: bodyFamily, + title: { + anchor: theme.layout?.titleBlock?.anchor ?? 'start', + position: theme.layout?.titleBlock?.position ?? 'top', + headline, + deck, + offset: Math.round((headline.fontSize ?? 14) * TITLE_GAP[theme.layout?.titleBlock?.gap ?? 'normal']), + deckPadding: Math.round((deck.fontSize ?? 11) * DECK_GAP[theme.layout?.titleBlock?.deckGap ?? 'normal']), + }, + axes, + frame, + baseline, + series, + legend: { + show: legendShow, + placement, + ...(fallbacks.length ? { fallbacks } : {}), + orient: legendOrient, + direction: theme.legend?.direction + ?? (legendOrient === 'top' || legendOrient === 'bottom' ? 'horizontal' : 'vertical'), + title: legendTitle, + label: keyLabel, + gradientLength: legendSpec.gradientLength, + maxSwatches: legendSpec.maxSwatches, + }, + dataLabels: { + show: dlShow, + possible: labelable && readableAtAll, + placement: dlPlacement, + inkMode: dlInkMode, + text: valueLabel, + format: numberFormat, + ...(valueUnit ? { unit: valueUnit } : {}), + insideMinValue, + outsideMaxValue, + ...(segmentMinShare !== undefined ? { segmentMinShare } : {}), + }, + // A house that dots the end of a line is saying where the story stops. + // Only the policy is decided here: whether the chart *has* a line to + // dot is a question about the backend spec, and stage 3 answers it. + pointEmphasis: (theme.annotation?.pointEmphasis ?? 'never') !== 'never' + ? { + where: theme.annotation!.pointEmphasis as 'endpoints' | 'latest' | 'extremes', + labels: theme.annotation?.pointLabels ?? 'never', + size: (marks.strokeWidth || 2) * 14, + } + : undefined, + marks, + facets, + layout: { padding, density }, + statistics: theme.annotation?.statistics?.show?.length + ? { + show: theme.annotation.statistics.show, + placement: theme.annotation.statistics.placement ?? 'panel', + ...axisLabelText, + } + : undefined, + furniture: theme.furniture ?? [], + bound: { + measureChannels: bindings.measureChannels, + categoricalChannel: bindings.categoricalChannel, + seriesChannel: bindings.seriesChannel, + seriesField, + categoryField: catField, + seriesCount: signals.seriesCount, + categoryCount: signals.categoryCount, + isFaceted: signals.isFaceted, + isPartToWhole: signals.isPartToWhole, + isSigned: signals.isSigned, + markChannel: signals.markChannel, + }, + report, + }; +} + +// --------------------------------------------------------------------------- +// Series ink +// --------------------------------------------------------------------------- + +/** WCAG contrast between two colours, `1` for identical. */ +function contrastRatio(a: string, b: string): number { + const ca = parseColor(a); + const cb = parseColor(b); + if (!ca || !cb) return 21; + const la = luminance(ca); + const lb = luminance(cb); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +} + +/** The least contrast at which a filled cell still reads as a cell. */ +const ENDPOINT_CONTRAST = 1.2; + +/** + * Keep a ramp's ends off the page. + * + * A ramp that starts a shade away from the surface makes its smallest values + * disappear, and “least” then looks like “no data”. Where the house asks for + * endpoints that stand against the surface, an end too close to it is pulled + * away until it can be seen — the hue is the house's, only its distance from + * the page is not. + */ +function offSurface( + ramp: Ramp | undefined, + surface: string, + say: (path: string, message: string) => void, +): Ramp | undefined { + if (!ramp?.stops?.length || ramp.endpointsAgainstSurface !== true) return ramp; + const away = isDarkSurface(surface) ? '#ffffff' : '#000000'; + const stops = ramp.stops.slice(); + let moved = false; + for (const i of [0, stops.length - 1]) { + if (contrastRatio(stops[i], surface) >= ENDPOINT_CONTRAST) continue; + for (let t = 0.05; t <= 0.6; t += 0.05) { + const candidate = mixHex(stops[i], away, t, stops[i]); + if (contrastRatio(candidate, surface) >= ENDPOINT_CONTRAST) { + stops[i] = candidate; + moved = true; + break; + } + } + } + if (!moved) return ramp; + say('ink.series.endpointsAgainstSurface', + 'a ramp end sat too close to the surface to be seen as a value — it was pulled away from the page'); + return { ...ramp, stops }; +} + +function groundSeriesInk( + theme: ThemeSpec, + ctx: GroundingContext, + bindings: Bindings, + signals: Signals, + say: (path: string, message: string) => void, +): ResolvedSeriesInk { + const s = theme.ink?.series ?? {}; + const selection = s.selection ?? {}; + const categorical = s.categorical ?? []; + const extended = s.categoricalExtended ?? []; + const single = s.single ?? categorical[0] ?? theme.ink?.accent ?? '#4c78a8'; + const surfaceColour = theme.ink?.surface?.plot ?? theme.ink?.surface?.canvas ?? '#ffffff'; + + // The house may name a larger indexed set for higher cardinality (Tableau + // 10→20). Rank the tiers by capacity so we can reach for the smallest one + // that still gives every series its own ink. + const tiers = [categorical, extended] + .filter((t) => t.length > 0) + .sort((a, b) => a.length - b.length); + const largestTier = tiers.length ? tiers[tiers.length - 1] : categorical; + + const seriesChannel = bindings.seriesChannel; + const seriesField = channelFact(ctx, seriesChannel)?.field; + const seriesType = channelFact(ctx, seriesChannel)?.type; + const count = signals.seriesCount; + + const base: ResolvedSeriesInk = { + mode: 'single', + single, + categorical, + overflow: s.overflow, + status: s.status, + }; + + if (!seriesField || (signals.seriesCountKnown && count <= 1)) { + // A single-series chart still needs an ink, and there is exactly one + // right answer: the house's single-series colour. + return base; + } + if (!signals.seriesCountKnown) { + say('ink.series', + `\`${seriesField}\` is created by a backend transform — the whole categorical set is offered rather than guessing a count`); + } + + const facetField = bindings.facetChannel ? ctx.channelSemantics[bindings.facetChannel]?.field : undefined; + if (selection.redundantWithFacet === 'single' && facetField && facetField === seriesField) { + say('ink.series.selection.redundantWithFacet', + 'series colour collapsed to single — the facet already names the series'); + return base; + } + + // Continuous series: a ramp, not an indexed set. + if (seriesType === 'quantitative') { + const diverging = signals.isSigned && Boolean(s.diverging); + const ramp: Ramp | undefined = offSurface(diverging ? s.diverging : s.sequential, surfaceColour, say); + if (ramp?.stops?.length) { + const consumption = ramp.consumption ?? 'interpolate'; + const quantize = consumption === 'quantize' ? (ramp.quantizeCount ?? 5) : undefined; + return { + ...base, + mode: diverging ? 'diverging' : 'sequential', + ramp, + quantize, + range: quantize ? sampleRamp(ramp.stops, quantize) : ramp.stops.slice(), + }; + } + say('ink.series', 'no ramp declared for a continuous series — one is built from the house ink'); + // An indexed set is never the right answer for a continuous field: it + // says "different" where the data says "more". If the house has not + // declared a ramp, the honest fallback is a ramp of its own colour, + // from a tint of it to the colour itself. + const surface = theme.ink?.surface?.canvas ?? '#ffffff'; + const stops = [mixHex(single, surface, 0.85), single]; + return { + ...base, + mode: 'sequential', + ramp: { stops }, + range: stops, + }; + } + + if (signals.isPartToWhole && selection.partToWhole === 'sequentialRamp' && s.sequential?.stops?.length) { + // A single-hue ramp names a part-to-whole cleanly only while the slices + // stay as few as the ramp has control points. Sampled past that, the + // adjacent shades blur into one another and the wheel reads as a smear + // of near-identical tints — worse than distinct hues, and it also skips + // the "Others" tail a large pie needs. Up to the ramp's resolution the + // house keeps its monochrome part-to-whole look; beyond it, fall through + // to the indexed set (distinct hues + an Others fold) so every slice + // stays nameable. + const rampResolution = s.sequential.stops.length; + if (!signals.seriesCountKnown || count <= rampResolution) { + // One ramp, consumed as an indexed set: the largest share takes the + // darkest end, so the ramp is sampled in reverse. + const ramp = offSurface(s.sequential, surfaceColour, say)!; + const range = sampleRamp(ramp.stops, Math.max(2, count)).reverse(); + return { ...base, mode: 'sequential', ramp, range }; + } + say('ink.series.selection.partToWhole', + `${count} slices exceed the ${rampResolution}-stop ramp's resolution — distinct hues name them better than shades, so the indexed set stands`); + } + + if (signals.isSigned && s.status && selection.signed === 'status' && selection.statusUse !== 'never') { + if (selection.statusUse === 'thresholdOnly') { + say('ink.series.selection.statusUse', + 'status ink withheld — `thresholdOnly` and no threshold was declared'); + } else { + return { ...base, mode: 'status' }; + } + } + + if (signals.isSigned && selection.signed === 'diverging' && s.diverging?.stops?.length) { + const ramp = offSurface(s.diverging, surfaceColour, say)!; + return { + ...base, + mode: 'diverging', + ramp, + range: sampleRamp(ramp.stops, Math.max(2, count)), + }; + } + + // An *ordered* series is not an indexed set. Categories that run from + // "a great deal" to "none at all" have a direction, and an unordered + // palette throws it away. One ramp, sampled to the number of steps. + if (seriesType === 'ordinal') { + const ramp: Ramp | undefined = offSurface( + s.sequential?.stops?.length ? s.sequential : s.diverging, surfaceColour, say); + if (ramp?.stops?.length && signals.seriesCountKnown) { + say('ink.series', 'the series is ordered — the house ramp is sampled across it rather than an unordered set'); + return { + ...base, + mode: 'sequential', + ramp, + range: sampleRamp(ramp.stops, Math.max(2, count)), + }; + } + } + + if (signals.seriesCountKnown && count > categorical.length && categorical.length > 0) { + // Auto-upsize: an extended tier that still names every series is the + // right answer — reach for the smallest one that covers the count. + const fittingTier = tiers.find((t) => count <= t.length); + if (fittingTier && fittingTier.length > categorical.length) { + say('ink.series.categorical', + `${count} series past the core ${categorical.length} inks — the house's extended ${fittingTier.length}-colour set is used so each stays distinct`); + return { ...base, categorical: fittingTier, mode: 'categorical' }; + } + + if (s.overflow) { + // Past even the extended set. The top inks by prominence name the + // largest series; every remaining ("other") series folds into the + // one overflow ink. Realization orders the domain by share so it is + // the smallest series that go grey, read as a single tail. + say('ink.series.categorical', + `${count} series past the house's ${largestTier.length} inks — the largest ${largestTier.length} keep a colour, the rest fold into one "other" ink`); + return { ...base, categorical: largestTier, mode: 'categorical', overflowTail: true }; + } else if (ctx.namesOnMarks === true) { + // The chart prints the series name on the mark (a slopegraph's end + // labels, a house that asked for it), so the names are already on + // the page in words. Colour was never the key here; keeping a + // foreign palette only spreads seven hues across seven lines and + // seven labels that say the same thing the words do. One ink, and + // the reader reads the names. + say('ink.series.categorical', + `${count} series against ${largestTier.length} house inks, but the house names them on the mark — colour stops naming and takes the single ink`); + return { ...base, mode: 'single' }; + } else { + // An indexed set has a capacity, and past it the colours stop + // being names: two different series come out the same ink and the + // key lies. A house that declares six and no overflow ink has not + // said what the twenty-fifth thing looks like, and cycling is not + // an answer — it is the same answer twice. + say('ink.series.categorical', + `${count} series and the house declares ${largestTier.length} with no overflow ink — colour cannot name them all, so the scale already on the chart stands`); + return { ...base, categorical: largestTier, mode: 'categorical', exhausted: true }; + } + } + return { ...base, mode: 'categorical' }; +} + +// --------------------------------------------------------------------------- +// Redundant encoding +// --------------------------------------------------------------------------- + +/** + * Shape and dash exist to carry the series identity when colour cannot: in + * mono print, for a colour-blind reader, or simply when there are more series + * than the house has inks. They are only meaningful for an indexed set — a + * ramp is read as a quantity, and doubling it with shapes says nothing. + */ +function groundRedundancy( + marksSpec: NonNullable, + series: ResolvedSeriesInk, + signals: Signals, + directlyLabeled: boolean, + say: (path: string, message: string) => void, +): { shape: boolean; dash: boolean } { + const off = { shape: false, dash: false }; + const policy = marksSpec.redundantEncoding ?? 'never'; + const channels = marksSpec.redundantChannels ?? []; + if (policy === 'never' || channels.length === 0) return off; + if (series.mode !== 'categorical') return off; + const effectiveCount = signals.seriesCountKnown ? signals.seriesCount : series.categorical.length; + if (effectiveCount <= 1) return off; + + if (policy === 'whenNeeded') { + const strained = effectiveCount > series.categorical.length; + if (!strained) { + say('marks.redundantEncoding', + '`whenNeeded` withheld — the house has a distinct ink for every series'); + return off; + } + // Even with more series than inks, a redundant channel earns its noise + // only if the reader needs it to tell the series apart. When each series + // is named at its own mark — a line labelled at its end, a band at its + // last reading — that identity is already carried, and a dash spread + // over a dense path degrades into texture rather than a distinguishing + // mark. The name does the work `whenNeeded` was reaching for. + if (directlyLabeled) { + say('marks.redundantEncoding', + '`whenNeeded` withheld — each series is named at its own mark, so nothing else need tell them apart'); + return off; + } + } + const unsupported = channels.filter((c) => c === 'texture' || c === 'lightness'); + if (unsupported.length) { + say('marks.redundantChannels', `${unsupported.join(', ')} not realizable — ignored`); + } + return { shape: channels.includes('shape'), dash: channels.includes('dash') }; +} + +// --------------------------------------------------------------------------- +// Number format +// --------------------------------------------------------------------------- + +/** + * The format a printed value is rendered with. + * + * The house states a *style* — group the thousands, use a k/M suffix, always + * show the sign — and a style says nothing about how many digits follow. Left + * open, `~s` prints `1.23457M` and a bare `,` prints `3.14159265`: the mark + * gets a number longer than itself and the reader gets precision they cannot + * use. So a house's stated precision is honoured, and a precision the house + * left open is inferred from the data. + * + * With no house at all there is still a format, which is the change of + * substance here: the alternative is Vega-Lite's raw rendering, and that is + * how a tidy chart ends up captioned `0.00123456`. + */ +function groundNumberFormat( + theme: ThemeSpec, + ctx: GroundingContext, + measureChannel: 'x' | 'y' | undefined, + values: number[], +): { pattern: string | undefined; inferred: boolean } { + const nf = theme.annotation?.numberFormat; + const sem = measureChannel ? ctx.channelSemantics[measureChannel] : undefined; + const isPercent = typeof sem?.format?.suffix === 'string' && sem.format.suffix.includes('%'); + + let house: string | undefined; + if (nf) { + const sign = nf.signed ? '+' : ''; + if (nf.thousands === 'suffix') house = `${sign}~s`; + else { + const group = nf.thousands === 'separator' ? ',' : ''; + const precision = nf.precision === 'integer' ? '.0' + : nf.precision === 'one' ? '.1' + : nf.precision === 'two' ? '.2' + : undefined; + house = precision === undefined + ? (group ? `${sign}${group}` : (sign || undefined)) + : `${sign}${group}${precision}${isPercent ? 'f' : 'f'}`; + } + } + // A field already carrying its own percent formatting is left alone: the + // semantics decided how that number reads, and re-deriving it here would + // print a share of a share. + if (isPercent) return { pattern: house, inferred: false }; + const pattern = inferValueLabelFormat(values, house); + return { pattern, inferred: pattern !== house }; +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} diff --git a/packages/flint-js/src/core/theme/index.ts b/packages/flint-js/src/core/theme/index.ts new file mode 100644 index 00000000..7b9b0b07 --- /dev/null +++ b/packages/flint-js/src/core/theme/index.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './types.js'; +export * from './presence.js'; +export { groundTheme } from './ground.js'; +export type { GroundingContext } from './ground.js'; +export { THEME_PRESETS, DEFAULT_THEME_ICON, listThemePresets, resolveThemeSpec } from './presets.js'; diff --git a/packages/flint-js/src/core/theme/merge.ts b/packages/flint-js/src/core/theme/merge.ts new file mode 100644 index 00000000..81240b39 --- /dev/null +++ b/packages/flint-js/src/core/theme/merge.ts @@ -0,0 +1,21 @@ +/** True for JSON-style records, but not arrays. */ +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Merge authored policy objects. + * + * Objects merge recursively. Arrays and scalar values are complete authored + * decisions and replace the base. `undefined` means the patch did not state a + * decision, which matters to TypeScript callers even though JSON cannot carry + * it. + */ +export function deepMerge(base: T, patch: unknown): T { + if (!isPlainObject(patch)) return (patch === undefined ? base : patch) as T; + const out: Record = isPlainObject(base) ? { ...base } : {}; + for (const [key, value] of Object.entries(patch)) { + out[key] = isPlainObject(value) ? deepMerge(out[key], value) : (value === undefined ? out[key] : value); + } + return out as T; +} diff --git a/packages/flint-js/src/core/theme/presence.ts b/packages/flint-js/src/core/theme/presence.ts new file mode 100644 index 00000000..dd0fd733 --- /dev/null +++ b/packages/flint-js/src/core/theme/presence.ts @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The presence ordinal, resolved. + * + * `omit < hairline < quiet < full < emphasised` is a statement about *contrast + * against the surface the element sits on*, not about opacity or colour. That + * is the whole reason one ThemeSpec works on white and on Power BI's #1b1a19 + * with no branch: the ordinal is resolved late, against the surface that + * grounding actually chose. + */ + +import type { Presence } from './types.js'; + +export interface RGB { r: number; g: number; b: number; a: number } + +const HEX3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i; +const HEX6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i; +const HEX8 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i; + +export function parseColor(input: string | undefined | null): RGB | null { + if (!input) return null; + const s = String(input).trim(); + let m = HEX8.exec(s); + if (m) { + return { + r: parseInt(m[1], 16), + g: parseInt(m[2], 16), + b: parseInt(m[3], 16), + a: parseInt(m[4], 16) / 255, + }; + } + m = HEX6.exec(s); + if (m) { + return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16), a: 1 }; + } + m = HEX3.exec(s); + if (m) { + return { + r: parseInt(m[1] + m[1], 16), + g: parseInt(m[2] + m[2], 16), + b: parseInt(m[3] + m[3], 16), + a: 1, + }; + } + return null; +} + +export function toHex(c: RGB): string { + const h = (n: number) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); + return `#${h(c.r)}${h(c.g)}${h(c.b)}`; +} + +/** Composite a possibly-transparent ink over an opaque surface. */ +export function flatten(ink: RGB, surface: RGB): RGB { + if (ink.a >= 1) return { ...ink, a: 1 }; + return { + r: ink.r * ink.a + surface.r * (1 - ink.a), + g: ink.g * ink.a + surface.g * (1 - ink.a), + b: ink.b * ink.a + surface.b * (1 - ink.a), + a: 1, + }; +} + +/** Linear blend from `a` (t=0) to `b` (t=1). */ +export function mix(a: RGB, b: RGB, t: number): RGB { + const k = Math.max(0, Math.min(1, t)); + return { + r: a.r + (b.r - a.r) * k, + g: a.g + (b.g - a.g) * k, + b: a.b + (b.b - a.b) * k, + a: 1, + }; +} + +export function mixHex(a: string, b: string, t: number, fallback = '#000000'): string { + const ca = parseColor(a); + const cb = parseColor(b); + if (!ca || !cb) return fallback; + return toHex(mix(ca, cb, t)); +} + +/** WCAG relative luminance. */ +export function luminance(c: RGB): number { + const f = (v: number) => { + const x = v / 255; + return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); + }; + return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b); +} + +export function isDarkSurface(surface: string): boolean { + const c = parseColor(surface); + return c ? luminance(c) < 0.45 : false; +} + +/** + * Whether a surface is a colour the reader can see, as opposed to the absence + * of one. + * + * Plain white is how a house says "no surface": the chart is ink on the page, + * and the page's own whitespace runs straight through the chart's margin, so + * there is no boundary anywhere. Any other value — the dark house's near + * black, the cream of a print house — is a rectangle that has been painted, + * with an edge, and everything inside it is now measured against that edge. + * + * The test is deliberately exact rather than a luminance threshold. A cream at + * #fffdf5 is a hair off white and would pass any "is it light?" test, but a + * house that went to the trouble of naming a colour other than white meant to + * paint something, and the reader can see it against the page. + */ +export function isPaintedSurface(surface: string | undefined): boolean { + if (!surface) return false; + const c = parseColor(surface); + if (!c) return false; + return !(c.r === 255 && c.g === 255 && c.b === 255); +} + +/** + * How far up the ordinal an element sits, expressed as the fraction of the + * distance from the surface to full-strength role ink. + * + * These are the numbers in `fields.md` §presence, restated as a blend factor: + * `full` means "the ink the house declared", and everything below it is that + * same ink pulled back toward the surface. Nothing here invents a hue — the + * hue always comes from `ink.structure.*`. + */ +const PRESENCE_STRENGTH: Record = { + omit: 0, + hairline: 0.42, + quiet: 0.72, + full: 1, + emphasised: 1, +}; + +/** + * Contrast targets used only when the house declared no ink for the role. + * Expressed against the foreground, so a dark surface flips automatically. + */ +const PRESENCE_FALLBACK_CONTRAST: Record = { + omit: 0, + hairline: 0.06, + quiet: 0.12, + full: 0.4, + emphasised: 1, +}; + +export const PRESENCE_ORDER: Presence[] = ['omit', 'hairline', 'quiet', 'full', 'emphasised']; + +export function presenceRank(p: Presence | undefined, dflt: Presence = 'full'): number { + return PRESENCE_ORDER.indexOf(p ?? dflt); +} + +export interface ResolveInkArgs { + presence: Presence | undefined; + /** The surface this element sits on, already resolved. */ + surface: string; + /** `ink.structure.*` for this role, if the house declared one. */ + roleInk?: string; + /** Full-strength foreground, used for `emphasised` and as ink fallback. */ + foreground: string; + fallback?: Presence; +} + +/** + * Resolve a presence to a concrete colour, or `null` for `omit`. + * + * `emphasised` deliberately ignores the role ink: it means "as strong as text", + * and text ink is the only thing that knows what that is on this surface. + */ +export function resolvePresenceInk(args: ResolveInkArgs): string | null { + const p = args.presence ?? args.fallback ?? 'full'; + if (p === 'omit') return null; + + const surface = parseColor(args.surface) ?? { r: 255, g: 255, b: 255, a: 1 }; + const fg = parseColor(args.foreground) ?? { r: 0, g: 0, b: 0, a: 1 }; + + if (p === 'emphasised') return toHex(fg); + + const declared = parseColor(args.roleInk ?? undefined); + if (declared) { + // A fully transparent declared ink is the house saying "not here". + if (declared.a === 0) return null; + const target = flatten(declared, surface); + return toHex(mix(surface, target, PRESENCE_STRENGTH[p])); + } + return toHex(mix(surface, fg, PRESENCE_FALLBACK_CONTRAST[p])); +} + +/** Stroke width implied by the ordinal, in px. */ +export function presenceWidth(p: Presence | undefined, base = 1): number { + switch (p ?? 'full') { + case 'omit': return 0; + case 'hairline': return Math.min(base, 0.5); + case 'quiet': return base; + case 'full': return base; + case 'emphasised': return base * 1.5; + default: return base; + } +} + +/** Pick whichever of two inks reads better on `background`. */ +export function contrastingInk(background: string, light: string, dark: string): string { + const bg = parseColor(background); + if (!bg) return dark; + return luminance(bg) < 0.5 ? light : dark; +} + +/** Sample `n` evenly spaced colours from a ramp's control points. */ +export function sampleRamp(stops: string[], n: number): string[] { + if (stops.length === 0) return []; + if (n <= 1) return [stops[stops.length - 1]]; + const parsed = stops.map((s) => parseColor(s)).filter(Boolean) as RGB[]; + if (parsed.length === 0) return []; + if (parsed.length === 1) return new Array(n).fill(toHex(parsed[0])); + const out: string[] = []; + for (let i = 0; i < n; i++) { + const t = (i / (n - 1)) * (parsed.length - 1); + const lo = Math.floor(t); + const hi = Math.min(parsed.length - 1, lo + 1); + out.push(toHex(mix(parsed[lo], parsed[hi], t - lo))); + } + return out; +} diff --git a/packages/flint-js/src/core/theme/presets.ts b/packages/flint-js/src/core/theme/presets.ts new file mode 100644 index 00000000..9a1a24c8 --- /dev/null +++ b/packages/flint-js/src/core/theme/presets.ts @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The houses Flint ships. + * + * Each is measured from a hand-authored redesign of real charts, so naming one + * is a claim that can be checked against the original rather than a mood. + * A caller who wants their own house passes a `ThemeSpec` object instead. + */ + +import type { ThemePreset, ThemeSpec } from './types'; +import { FLINT_ICON } from './presets/icons'; +import { nyt } from './presets/nyt'; +import { economist } from './presets/economist'; +import { nature } from './presets/nature'; +import { mckinsey } from './presets/mckinsey'; +import { datawrapper } from './presets/datawrapper'; +import { powerbi } from './presets/powerbi'; +import { powerbiLight } from './presets/powerbi-light'; +import { swiss } from './presets/swiss'; +import { pop } from './presets/pop'; +import { cartoon } from './presets/cartoon'; +import { deepMerge } from './merge.js'; + +export const THEME_PRESETS: Record = { + nyt, + economist, + swiss, + nature, + mckinsey, + datawrapper, + powerbi, + 'powerbi-light': powerbiLight, + pop, + cartoon, +}; + +/** + * The icon for "no house" — flint's own defaults, so a picker can offer + * *not* theming as a visible choice rather than an empty slot. + */ +export const DEFAULT_THEME_ICON = FLINT_ICON; + +/** + * The catalogue, without the specs — enough to choose by. + * + * Without the icons either: this is what an agent reads to pick a house, and a + * picture it cannot see costs it context it could have spent on the chart. A + * picker that wants icons reads them off {@link THEME_PRESETS}. + */ +export function listThemePresets(): Array> { + return Object.values(THEME_PRESETS).map(({ id, label, description }) => ({ id, label, description })); +} + +/** + * Take what the caller put in `theme_spec` and hand back a ThemeSpec. + * + * A string names a house Flint ships; an object is the caller's own. An object + * may also `extend` one of those houses and state only its overrides. Nested + * policy objects merge, while arrays and scalar values replace the preset. + * + * An unknown name is an error rather than a silent fallback to no theme: a + * chart that quietly ignores the house it was asked for looks like a bug in + * the house. + */ +export function resolveThemeSpec(theme: ThemeSpec | string | undefined): ThemeSpec | undefined { + if (theme === undefined) return undefined; + if (typeof theme === 'string') return resolveThemeSpec(presetSpec(theme)); + if (theme.extends === undefined) return theme; + + const { extends: presetId, ...overrides } = theme; + return deepMerge(resolveThemeSpec(presetSpec(presetId))!, overrides); +} + +function presetSpec(id: string): ThemeSpec { + const preset = THEME_PRESETS[id]; + if (!preset) { + throw new Error( + `Unknown theme \`${id}\`. Flint ships: ${Object.keys(THEME_PRESETS).join(', ')}.`, + ); + } + return preset.spec; +} diff --git a/packages/flint-js/src/core/theme/presets/cartoon.ts b/packages/flint-js/src/core/theme/presets/cartoon.ts new file mode 100644 index 00000000..c558582a --- /dev/null +++ b/packages/flint-js/src/core/theme/presets/cartoon.ts @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ThemePreset } from '../types'; +import { CARTOON_ICON } from './icons'; + +/** + * Cartoon — a playful, friendly house in the spirit of xkcd and modern + * flat-cartoon illustration. + * + * Modelled on the hand-authored mockups in the Cartoon lab (see + * `site/src/playground/cartoon-lab-data.ts`). Flint cannot draw the + * hand-wobbled "last mile" of a true xkcd plot — that is a per-pixel filter on + * the rendered SVG, not a chart decision — so the character is carried by the + * parts a theme owns and by three levers that read as *fun*: + * + * - a rounded comic typeface (Comic Sans / Comic Neue / Chalkboard fallbacks); + * - `marks.cornerRadius` — rounded bar tops and wedge corners (balloon/sticker + * shapes, not spreadsheet rectangles); + * - `marks.outline` — a fat dark border around every filled shape, including + * dots (the sticker edge that makes a mark look drawn, not printed); + * + * over a warm cream-paper canvas, a soft dashed grid, round-capped chunky + * strokes, and a bright six-crayon palette. + */ +export const cartoon: ThemePreset = { + id: 'cartoon', + label: 'Cartoon', + description: + 'A playful comic house: warm cream paper, a rounded comic typeface, fat dark "sticker" outlines around bright crayon-coloured bars, wedges and dots, rounded corners, chunky round-capped lines, and a soft dashed grid.', + guidance: [ + '- `title` carries the naming in a bold rounded comic block; `subtitle` names the measure in a friendly aside.', + '- Annotate the measure with `unit` in `semantic_types`.', + '- Colour is a bright crayon set; the key tells 6 series apart.', + ].join('\n'), + icon: CARTOON_ICON, + spec: { + id: 'cartoon', + label: 'Cartoon', + ink: { + surface: { + source: 'house', + canvas: '#fffdf5', + plot: '#fffdf5', + }, + text: { + primary: '#2e2b28', + secondary: '#8a837a', + muted: '#b3aa9c', + }, + structure: { + grid: '#ece5d6', + axis: '#2e2b28', + rule: '#2e2b28', + // The lollipop stem / dumbbell bridge in a soft pencil grey so + // the emoji-ish chunky marks stay the loud part. + connector: '#c9c1b2', + }, + series: { + // Sky blue reads as the friendly default single. + single: '#3aa9ff', + // Bright crayon: sky, coral, sunflower, grass, grape, tangerine. + categorical: ['#3aa9ff', '#ff5d5d', '#ffc23c', '#4cc76a', '#9b6cff', '#ff8a3d'], + categoricalExtended: [ + '#3aa9ff', + '#ff5d5d', + '#ffc23c', + '#4cc76a', + '#9b6cff', + '#ff8a3d', + '#2ec4c4', + '#ff77b7', + '#7bd23a', + '#ffd84a', + '#6c8cff', + '#c96a2a', + ], + // Sequential: a warm cream-to-coral crayon ramp, binned so the + // reader can name a bin, not read a wash. + sequential: { + stops: ['#fff2cc', '#ffd98a', '#ffb14a', '#ff8a3d', '#ff5d5d'], + space: 'lab', + endpointsAgainstSurface: true, + consumption: 'quantize', + quantizeCount: 5, + }, + // Diverging: sky to coral, through the warm paper neutral. The + // warm end is the high end — a ramp that runs the other way + // paints a hot July blue and a cold January red, and no reader + // checks the key before believing that. + diverging: { + stops: ['#3aa9ff', '#8fc9ff', '#f2ead8', '#ffb0a0', '#ff5d5d'], + neutral: '#f2ead8', + space: 'lab', + endpointsAgainstSurface: true, + consumption: 'quantize', + quantizeCount: 5, + }, + // Signed data: grass up, coral down, a soft pencil grey total. + status: { + positive: '#4cc76a', + negative: '#ff5d5d', + neutral: '#b3aa9c', + }, + overflow: '#b3aa9c', + selection: { + signed: 'status', + statusUse: 'anySigned', + }, + }, + accent: '#ff5d5d', + }, + type: { + minSize: 9, + // One rounded comic face carries every role: `bodyFamily` falls back + // to the headline family, so axis and value labels inherit it. + headline: { + family: "'Comic Sans MS', 'Comic Neue', 'Chalkboard SE', 'Marker Felt', cursive", + size: 'text.400', + weight: 'bold', + }, + deck: { + size: 'text.200', + color: '#8a837a', + }, + axisLabel: { + size: 'text.100', + }, + axisTitle: { + size: 'text.100', + weight: 'bold', + color: '#2e2b28', + }, + }, + structure: { + axis: { + categorical: { + line: 'full', + lineWeight: 2.5, + ticks: 'omit', + labelGap: 7, + }, + measure: { + line: 'full', + lineWeight: 2.5, + ticks: 'omit', + labelGap: 7, + }, + }, + // A soft dashed grid the reader reads values off, only across the + // value axis — the category side stays clean. + grid: { + measure: 'quiet', + category: 'omit', + style: 'dashed', + weight: 1.5, + }, + frame: 'omit', + baseline: 'full', + }, + marks: { + // Chunky bars with a friendly gap between them. + bandFraction: 0.62, + // Fat round-capped, round-joined strokes and bouncy curves. + strokeWeight: 5, + strokeCap: 'round', + strokeJoin: 'round', + interpolation: 'monotone', + // Rounded bar tops and wedge corners — the balloon/gumball tell. + cornerRadius: 10, + // The sticker edge: a fat dark outline around every filled shape. + outline: { presence: 'full', weight: 2.5, source: 'ink' }, + point: { + presence: 'full', + fill: 'solid', + size: 170, + // The dark sticker edge is the identity here; a pale halo would + // replace it because Vega-Lite gives a point only one stroke. + halo: { presence: 'omit' }, + }, + // Wedges swing apart (keeping their dark ring) rather than being cut + // by a rule that would paint over the outline. + slice: { + gap: 5, + gapStyle: 'pad', + }, + sizeRange: [120, 2600], + }, + labels: { + truncation: 'never', + flush: true, + angle: 'auto', + }, + legend: { + show: 'always', + placement: ['top'], + direction: 'horizontal', + title: 'omit', + suppressWhenAxisNames: true, + }, + dataLabels: { + show: 'whenTheyFit', + placement: 'outsideMark', + inkMode: 'fixed', + }, + annotation: { + axisTitles: 'whenAmbiguous', + unit: 'lastTick', + numberFormat: { + precision: 'auto', + }, + }, + layout: { + density: 'normal', + targetWidth: 300, + titleBlock: { + anchor: 'start', + gap: 'normal', + }, + }, + compileDefaults: { + baseSize: { width: 380, height: 320 }, + }, + }, +}; diff --git a/packages/flint-js/src/core/theme/presets/datawrapper.ts b/packages/flint-js/src/core/theme/presets/datawrapper.ts new file mode 100644 index 00000000..b8697c58 --- /dev/null +++ b/packages/flint-js/src/core/theme/presets/datawrapper.ts @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ThemePreset } from '../types'; +import { DATAWRAPPER_ICON } from './icons'; + +/** + * Datawrapper. + * + * Measured from hand-authored redesigns, not invented — see the Theme Lab. + */ +export const datawrapper: ThemePreset = { + id: 'datawrapper', + label: "Datawrapper", + description: "Embedded web chart: narrow column, plain headline and deck, a rule under the footer.", + guidance: [ + "- `title` and `subtitle` do all the naming; annotate the measure with `unit`.", + "- Sized for a narrow column, so it reads tall rather than wide.", + "- Colour can tell 5 categories apart.", + ].join('\n'), + icon: DATAWRAPPER_ICON, + spec: { + "id": "datawrapper", + "label": "Datawrapper", + "ink": { + "surface": { + "source": "host" + }, + "text": { + "primary": "#333333", + "secondary": "#666666", + "muted": "#999999" + }, + "structure": { + "grid": "#dcdcdc", + "rule": "#dcdcdc", + "connector": "#c8c8c8", + "axis": "#333333" + }, + "series": { + "single": "#18a1cd", + "categorical": [ + "#18a1cd", + "#e2a233", + "#c04a4a", + "#2d8659", + "#7e5aa2" + ], + "categoricalExtended": [ + "#18a1cd", + "#e2a233", + "#c04a4a", + "#2d8659", + "#7e5aa2", + "#d97b4f", + "#5b8fb0", + "#b5546a", + "#8c9a3f", + "#c98ac0", + "#6b8e8a", + "#a67c52" + ], + "sequential": { + "stops": [ + "#dceef6", + "#a9d3e6", + "#6aabcc", + "#2f7fa8", + "#0b5c82" + ], + "space": "lab", + "consumption": "quantize", + "quantizeCount": 5 + }, + "diverging": { + "stops": [ + "#2f7fa8", + "#a9d3e6", + "#f0ece4", + "#e8ac70", + "#c04a4a" + ], + "neutral": "#f0ece4", + "space": "lab", + "endpointsAgainstSurface": true, + "consumption": "quantize", + "quantizeCount": 5 + }, + "selection": {}, + "overflow": "#b9bcbe" + }, + "accent": "#18a1cd" + }, + "type": { + "minSize": 11, + "headline": { + "family": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "size": "text.300", + "weight": "bold" + }, + "axisLabel": { + "size": "text.200" + }, + "keyLabel": { + "size": "text.200" + } + }, + "structure": { + "axis": { + "categorical": { + "line": "full", + "ticks": "omit", + "tickLabels": "sparse" + }, + "measure": { + "line": "omit", + "ticks": "omit" + } + }, + "grid": { + "measure": "quiet", + "category": "omit", + "style": "dashed" + }, + "frame": "omit", + "baseline": "quiet" + }, + "marks": { + "bandFraction": 0.66, + "separator": { + "presence": "hairline", + "source": "surface", + "width": 1.5 + }, + "point": { + "size": 48 + }, + "connector": { + "presence": "full", + "weight": 1 + } + }, + "labels": { + "truncation": "never" + }, + "legend": { + "show": "always", + "placement": [ + "top" + ], + "direction": "horizontal", + "title": "omit" + }, + "dataLabels": { + "show": "whenTheyFit", + "placement": "outsideMark" + }, + "annotation": { + "axisTitles": "omit", + "unit": "lastTick", + "numberFormat": { + "precision": "auto" + } + }, + "furniture": [ + { + "kind": "footerRule", + "anchor": "bottomLeft", + "color": "#dcdcdc", + "height": 1 + } + ], + "interaction": { + "tooltipFormat": "matchKey" + }, + "layout": { + "density": "normal", + "targetWidth": 300, + "titleBlock": { + "anchor": "start" + } + }, + "compileDefaults": { + "baseSize": { "width": 420, "height": 340 } + } + }, +}; diff --git a/packages/flint-js/src/core/theme/presets/economist.ts b/packages/flint-js/src/core/theme/presets/economist.ts new file mode 100644 index 00000000..f1634bcd --- /dev/null +++ b/packages/flint-js/src/core/theme/presets/economist.ts @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ThemePreset } from '../types'; +import { ECONOMIST_ICON } from './icons'; + +/** + * The Economist. + * + * Measured from hand-authored redesigns, not invented — see the Theme Lab. + */ +export const economist: ThemePreset = { + id: 'economist', + label: "The Economist", + description: "Print weekly: compact, flat headline over a deck that names the measure, units repeated down the ruler.", + guidance: [ + "- `subtitle` names the measure, the place and the period — \"% of GDP, 2023\".", + "- Annotate each measure with `unit` in `semantic_types`.", + "- The key holds 3 colours.", + ].join('\n'), + icon: ECONOMIST_ICON, + spec: { + "id": "economist", + "label": "The Economist", + "ink": { + "surface": { + "source": "host" + }, + "text": { + "primary": "#121317", + "secondary": "#54585a", + "muted": "#8b9196" + }, + "structure": { + "grid": "#c9d3da", + "axis": "#121317", + "rule": "#c9d3da", + "zero": "#121317" + }, + "series": { + "single": "#006ba2", + "categorical": [ + "#006ba2", + "#3ebcd2", + "#ebb434", + "#379a8b", + "#9a3d5b", + "#a17ba5" + ], + // Continuous measure: the Economist blue, light to deep. + "sequential": { + "stops": [ + "#dcebf2", + "#a7ccdd", + "#6ba7c6", + "#2f88ae", + "#006ba2", + "#003f5c" + ], + "space": "lab", + "endpointsAgainstSurface": true, + "consumption": "interpolate" + }, + "diverging": { + "stops": [ + "#006ba2", + "#7ba7b8", + "#e9e5dc", + "#c8967a", + "#a1655a" + ], + "neutral": "#e9e5dc", + "space": "lab", + "endpointsAgainstSurface": true, + "consumption": "quantize", + "quantizeCount": 5 + }, + "status": { + "positive": "#006ba2", + "negative": "#e3120b", + "neutral": "#b8c4cc" + }, + "overflow": "#b0aca1", + "selection": { + "signed": "status", + "statusUse": "anySigned" + } + }, + "accent": "#e3120b" + }, + "type": { + "minSize": 8, + "headline": { + "family": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "size": "text.300", + "weight": "bold" + }, + "deck": { + "size": "text.200", + "color": "#54585a" + }, + "axisLabel": { + "size": "text.100" + } + }, + "structure": { + "axis": { + "categorical": { + "line": "full", + "ticks": "omit" + }, + "measure": { + "line": "omit", + "ticks": "omit", + "placement": "opposite" + } + }, + "grid": { + "measure": "quiet", + "category": "omit", + "style": "solid", + "zero": "full" + }, + "frame": "omit", + "baseline": "full" + }, + "marks": { + "bandFraction": 0.68, + "strokeWeight": 1.6, + "slice": { + "gap": 1.5 + }, + "interval": { + "fillOpacity": 0.22, + "edge": "quiet", + "inkSource": "sameAsCentral" + }, + "point": { + "size": 66 + }, + "sizeRange": [ + 10, + 450 + ] + }, + "labels": { + "truncation": "never", + "angle": "auto" + }, + "legend": { + "show": "always", + "placement": [ + "seriesEnd", + "top" + ], + "direction": "horizontal", + "title": "omit", + "maxSwatches": 3 + }, + "dataLabels": { + "show": "whenTheyFit", + "placement": "atMark" + }, + "annotation": { + "axisTitles": "omit", + "unit": "everyTick" + }, + "furniture": [ + { + // The Economist "red tab" is a chunky rectangle, not a thin + // rule: the style guide draws it ~15pt wide × 5pt tall (≈3:1) + // on a 160pt chart — about 1/10 of the width. On this ~460px + // plot that is ≈44px wide; a ~12px height keeps the 3–4:1 block + // proportion so it reads as the masthead tag, not a hairline. + "kind": "mastheadTab", + "anchor": "topLeft", + "color": "#e3120b", + "width": 44, + "height": 12 + } + ], + "layout": { + "density": "compact", + "titleBlock": { + "anchor": "start", + "gap": "tight" + } + }, + "compileDefaults": { + "baseSize": { "width": 460, "height": 300 } + }, + "variants": [ + { + "when": { + "markChannel": "area", + "isPartToWhole": false + }, + "then": { + "structure": { + "axis": { + "measure": { + "placement": "default" + } + } + } + }, + "because": "Right-hand measure axis is the house default (ggthemes theme_economist; measured opposite on 3/3 bar charts and the electricity-mix part-to-whole area). The one measured exception is a non-part-to-whole range/area band (seattle-range), which keeps y on the left." + } + ], + "chartDefaults": { + "Slope Chart": { + "showText": true, + "showSeriesInLabel": true + } + } + }, +}; diff --git a/packages/flint-js/src/core/theme/presets/icons.ts b/packages/flint-js/src/core/theme/presets/icons.ts new file mode 100644 index 00000000..480953d4 --- /dev/null +++ b/packages/flint-js/src/core/theme/presets/icons.ts @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The houses at 16 pixels. + * + * A theme picker has to say what a house *looks like* before the reader has + * seen a chart in it, and a word cannot: "Economist" and "Datawrapper" are both + * blue bars on white to anyone who has not met them. So each icon is a tiny + * chart drawn in the house's own decisions — its canvas, the first three of its + * categorical set, the weight of its rules — rather than an invented glyph. + * Nothing here is a colour the house does not already use. + * + * They are authored in one file on purpose. An icon set is only legible as a + * set: what makes each one recognisable is the thing it does that its + * neighbours do not, and that can only be judged side by side. At this size + * there is room for exactly one such difference each, so every house gets one + * and only one: + * + * flint no house at all — one flat grey trio, no signature + * nyt a black headline bar over the plot, the way its charts lead + * economist the red flag, top left + * nature a bare black L of axis and thin journal bars, no grid + * mckinsey bars laid horizontally, the deck-chart posture + * datawrapper horizontal grid ruling read through the bars + * powerbi the dark canvas + * powerbi-light the same bright series on white, with a faint grid + * swiss heavy structural black rules on warm paper + * pop process-colour quadrants divided by heavy black ink + * cartoon rounded bar tops and a thick soft outline + * + * Each is a complete SVG document so a caller can put it straight in an `` + * or inline it. 16×16 with a half-pixel inset frame: the frame is what lets a + * white house read as a *tile* rather than as marks floating on the toolbar. + */ + +/** Shared geometry, so the set lines up when the icons sit next to each other. */ +const BASELINE = 12.5; + +function tile(canvas: string, frame: string, body: string, radius = 2): string { + return [ + '', + ``, + body, + '', + ].join(''); +} + +/** Three upright bars on the shared baseline, at the heights the set uses. */ +function bars( + colors: [string, string, string], + heights: [number, number, number], + width = 2.6, + radius = 0, +): string { + const x = [3, 6.7, 10.4]; + return colors + .map((fill, i) => { + const h = heights[i]; + const rx = radius ? ` rx="${radius}"` : ''; + return ``; + }) + .join(''); +} + +/** A horizontal rule — a baseline, a gridline, the pale ruling behind bars. */ +function rule(x1: number, y: number, x2: number, stroke: string, width = 1): string { + return ``; +} + +/** No house: flint's own defaults, stated plainly so "none" is a visible choice. */ +export const FLINT_ICON = tile( + '#ffffff', + '#dcdcdc', + bars(['#4c78a8', '#f58518', '#e45756'], [6.5, 8.5, 5]) + + rule(2.6, BASELINE, 13.4, '#bdbdbd'), +); + +/** A black headline bar, then the plot — the order an NYT chart is read in. */ +export const NYT_ICON = tile( + '#ffffff', + '#dcdcdc', + '' + + bars(['#2f6b9a', '#c2352b', '#4a8b6f'], [6, 7.6, 4.6]) + + rule(2.6, BASELINE, 13.4, '#121212'), +); + +/** The red flag in the corner, and the house blues under one pale rule. */ +export const ECONOMIST_ICON = tile( + '#ffffff', + '#dcdcdc', + '' + + bars(['#006ba2', '#3ebcd2', '#ebb434'], [6, 7.6, 4.6]) + + rule(2.6, BASELINE, 13.4, '#121317'), +); + +/** A bare black L and thin bars: a journal figure, no grid, no ornament. */ +export const NATURE_ICON = tile( + '#ffffff', + '#dcdcdc', + bars(['#0072b2', '#e69f00', '#009e73'], [6.2, 8, 4.6], 2) + + ``, +); + +/** Bars laid on their side against a single spine — the deck-chart posture. */ +export const MCKINSEY_ICON = tile( + '#ffffff', + '#dcdcdc', + '' + + '' + + '' + + '', +); + +/** Grid ruling read straight through the bars, the way its charts are gridded. */ +export const DATAWRAPPER_ICON = tile( + '#ffffff', + '#dcdcdc', + rule(2.6, 5.5, 13.4, '#b3b3b3') + + rule(2.6, 8, 13.4, '#b3b3b3') + + rule(2.6, 10.5, 13.4, '#b3b3b3') + + bars(['#18a1cd', '#e2a233', '#c04a4a'], [6.5, 8.5, 5], 2.2) + + rule(2.6, BASELINE, 13.4, '#333333'), +); + +/** The dark canvas, which is the whole point of the house. */ +export const POWERBI_ICON = tile( + '#1b1a19', + '#3b3a39', + rule(2.6, 7.6, 13.4, '#3b3a39') + + bars(['#118dff', '#e66c37', '#3bd1c7'], [6.5, 8.5, 5]) + + rule(2.6, BASELINE, 13.4, '#3b3a39'), +); + +/** The same series, on white — the pair reads as one house in two surfaces. */ +export const POWERBI_LIGHT_ICON = tile( + '#ffffff', + '#d2d0ce', + rule(2.6, 7.6, 13.4, '#dcdcdc') + + bars(['#118dff', '#12239e', '#e66c37'], [6.5, 8.5, 5]) + + rule(2.6, BASELINE, 13.4, '#d2d0ce'), +); + +/** Warm paper, square marks, and axes drawn as structure rather than hinted. */ +export const SWISS_ICON = tile( + '#f4f1ea', + '#d9d5cc', + bars(['#e2231a', '#1a1a1a', '#0067a5'], [6.2, 8.2, 4.8]) + + ``, + 0, +); + +/** Process-colour blocks and heavy black divisions: Swiss turned up to eleven. */ +export const POP_ICON = tile( + '#fff200', + '#111111', + '' + + '' + + '' + + '', +); + +/** Rounded tops and a thick soft rule: the drawn-by-hand register. */ +export const CARTOON_ICON = tile( + '#fffdf5', + '#ece5d6', + bars(['#3aa9ff', '#ff5d5d', '#ffc23c'], [6.4, 8.4, 5], 2.8, 1.3) + + rule(2.6, BASELINE, 13.4, '#2e2b28', 1.7), + 3.5, +); diff --git a/packages/flint-js/src/core/theme/presets/mckinsey.ts b/packages/flint-js/src/core/theme/presets/mckinsey.ts new file mode 100644 index 00000000..b6df40e7 --- /dev/null +++ b/packages/flint-js/src/core/theme/presets/mckinsey.ts @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ThemePreset } from '../types'; +import { MCKINSEY_ICON } from './icons'; + +/** + * McKinsey. + * + * Measured from hand-authored redesigns, not invented — see the Theme Lab. + */ +export const mckinsey: ThemePreset = { + id: 'mckinsey', + label: "McKinsey", + description: "Consulting deck: wide bands, every value printed in a column, a headline that states the takeaway.", + guidance: [ + "- `title` states the takeaway; `subtitle` names the measure and unit.", + "- Colour can tell 5 categories apart.", + ].join('\n'), + icon: MCKINSEY_ICON, + spec: { + "id": "mckinsey", + "label": "McKinsey", + "ink": { + "surface": { + "source": "host" + }, + "text": { + "primary": "#051c2c", + "secondary": "#5a6872", + "muted": "#8a969d" + }, + "structure": { + "axis": "#051c2c", + "rule": "#d3dce1", + // A lollipop stem borrows the rule where no connector ink is + // named, but McKinsey's rule is a near-white gridline (#d3dce1) + // — far too faint for a stem that must carry the eye up to the + // dot. Their exhibits draw the stem a solid medium-light grey, + // clearly present yet subordinate to the near-black dot. So the + // connector states its own, more definite ink. + "connector": "#a7b1bc" + }, + "series": { + // McKinsey's 2020 "Deep Blue" (#051c2c) is deliberately + // *almost black* — the firm's own brand refresh describes it + // as near-black to project authority against white, and real + // exhibits/reports use it as the primary data-bar colour. + // So a lone series reads near-black by design; electric blue + // (#2251ff) is the house's highlight, not its default. Kept + // authentic — do not "fix" it to a lighter blue. + "single": "#051c2c", + "categorical": [ + "#051c2c", + "#2251ff", + "#00a9f4", + "#00cfb4", + "#8c9ba5" + ], + // The house is blue at heart, so the extended set does not + // reach for a rainbow — it walks the cool wheel the way + // McKinsey's own decks do: blue → cyan → teal, then a + // restrained turn into violet before the slate. The core five + // stay a prefix so a chart's colours don't reshuffle as it + // grows past the handful the identity is built on. + "categoricalExtended": [ + "#051c2c", + "#2251ff", + "#00a9f4", + "#00cfb4", + "#8c9ba5", + "#7c5cff", + "#0e7c8b", + "#6fb7e8", + "#b39ddb", + "#3d4f66" + ], + "sequential": { + "stops": [ + "#eef3f8", + "#cfdcea", + "#9db8d2", + "#5b82ab", + "#051c2c" + ], + "space": "lab", + "endpointsAgainstSurface": true, + // Stated light-to-dark. One ramp, two consumptions: a + // part-to-whole pie samples it in reverse, a heat map + // interpolates it. + "consumption": "interpolate" + }, + // A signed measure crosses zero, and a single-hue ramp cannot + // say which side of it a value sits on — dark reads as "more", + // not "positive". The house is otherwise all blue, so the one + // place it must reach for a second hue is here: cool blue below + // zero, a restrained warm above, the light surface tint at the + // break. Cool-below / warm-above matches the other houses. + "diverging": { + "stops": [ + "#2251ff", + "#9db8d2", + "#eef3f8", + "#d98f6a", + "#b4472e" + ], + "neutral": "#eef3f8", + "space": "lab", + "endpointsAgainstSurface": true, + "consumption": "interpolate" + }, + "selection": { + "partToWhole": "categorical", + "signed": "diverging" + }, + "overflow": "#b6bfc7" + }, + "accent": "#2251ff" + }, + "type": { + "minSize": 9, + "headline": { + "family": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "size": "text.300", + "weight": "bold" + }, + "axisLabel": { + "size": "text.200" + }, + "valueLabel": { + "size": "text.200", + "weight": "semibold", + "color": "#051c2c" + } + }, + "structure": { + "axis": { + "categorical": { + "line": "omit", + "ticks": "omit" + }, + "measure": { + "line": "omit", + "ticks": "omit", + "suppressWhenValuesPrinted": true + } + }, + "grid": { + "measure": "omit", + "category": "omit" + }, + "frame": "omit", + "baseline": "full" + }, + "marks": { + "bandFraction": 0.6, + "strokeWeight": 2, + "connector": { + "presence": "full", + "weight": 0.8, + "spanWeight": 3 + }, + "point": { + "size": 72 + }, + "separator": { + "presence": "hairline", + "source": "surface", + "width": 0.6 + }, + "slice": { + "gap": 1 + } + }, + "labels": { + "truncation": "never", + "angle": "horizontal" + }, + "legend": { + "show": "always", + "placement": [ + "seriesEnd", + "inline", + "top" + ], + "direction": "horizontal", + "title": "omit", + "suppressWhenValuesPrinted": true + }, + "dataLabels": { + "show": "always", + "placement": "column", + "inkMode": "contrastWithMark" + }, + "annotation": { + "axisTitles": "omit", + "numberFormat": { + "precision": "integer", + "thousands": "separator" + } + }, + "layout": { + "density": "airy", + "titleBlock": { + "anchor": "start", + "gap": "loose", + "deckGap": "loose" + }, + "bandStep": 80 + }, + "compileDefaults": { + "baseSize": { "width": 440, "height": 300 } + } + }, +}; diff --git a/packages/flint-js/src/core/theme/presets/nature.ts b/packages/flint-js/src/core/theme/presets/nature.ts new file mode 100644 index 00000000..47827238 --- /dev/null +++ b/packages/flint-js/src/core/theme/presets/nature.ts @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ThemePreset } from '../types'; +import { NATURE_ICON } from './icons'; + +/** + * Nature. + * + * Measured from hand-authored redesigns, not invented — see the Theme Lab. + */ +export const nature: ThemePreset = { + id: 'nature', + label: "Nature", + description: "Journal figure: small panel, axis titles kept with units, statistics printed beside the fit.", + guidance: [ + "- Annotate every measure with `unit` in `semantic_types`; `subtitle` names the sample, not the unit.", + "- Colour can tell 6 categories apart; past that they share a grey.", + ].join('\n'), + icon: NATURE_ICON, + spec: { + "id": "nature", + "label": "Nature", + "ink": { + "surface": { + "source": "host" + }, + "text": { + "primary": "#000000", + "secondary": "#000000" + }, + "structure": { + "axis": "#000000", + "grid": "#00000000", + "frame": "#000000" + }, + "series": { + "single": "#0072b2", + "categorical": [ + "#0072b2", + "#e69f00", + "#009e73", + "#cc79a7", + "#56b4e9", + "#d55e00" + ], + "categoricalExtended": [ + "#0072b2", + "#e69f00", + "#009e73", + "#cc79a7", + "#56b4e9", + "#d55e00", + "#f0e442", + "#332288", + "#117733", + "#882255", + "#88ccee", + "#999933" + ], + // Continuous measure: the house blue (Wong), light to deep. + "sequential": { + "stops": [ + "#e6f0f7", + "#b3d3e8", + "#79b0d5", + "#3a8fc4", + "#0072b2", + "#00436a" + ], + "space": "lab", + "endpointsAgainstSurface": true, + "consumption": "interpolate" + }, + "diverging": { + "stops": [ + "#0072b2", + "#83b9db", + "#ffffff", + "#eba06a", + "#d55e00" + ], + "neutral": "#ffffff", + "space": "lab", + "endpointsAgainstSurface": false, + "consumption": "interpolate" + }, + "overflow": "#999999", + "selection": { + "partToWhole": "categorical" + } + }, + "accent": "#000000" + }, + "type": { + "minSize": 8.5, + "headline": { + "family": "Arial, Helvetica, sans-serif", + "size": "text.200", + "weight": "bold" + }, + "deck": { + "size": "text.100", + "style": "italic", + "case": "asIs" + }, + "axisLabel": { + "family": "Arial, Helvetica, sans-serif", + "size": "text.100" + }, + "axisTitle": { + "family": "Arial, Helvetica, sans-serif", + "size": "text.100" + } + }, + "structure": { + "axis": { + "categorical": { + "line": "full", + "ticks": "full", + "tickLength": "long", + "tickDirection": "outward" + }, + "measure": { + "line": "full", + "ticks": "full", + "tickLength": "long", + "tickDirection": "outward" + } + }, + "grid": { + "measure": "omit", + "category": "omit" + }, + "frame": "omit", + "baseline": "quiet" + }, + "marks": { + "bandFraction": 0.55, + "strokeWeight": 1.2, + "separator": { + "presence": "hairline", + "source": "surface", + "width": 0.5 + }, + "slice": { + "gap": 1.5 + }, + "point": { + "presence": "full", + "size": 45, + "fill": "solid", + "halo": { + "presence": "hairline", + "width": 0.6 + } + }, + "interval": { + "fillOpacity": 0.25, + "edge": "omit", + "inkSource": "sameAsCentral" + }, + "summary": { + "fill": "omit", + "outline": "full", + "centralRule": "emphasised", + "widthFraction": 0.4 + }, + "observations": { + "expose": "always", + "maxRows": 400 + }, + "zOrder": "summaryUnderData", + "redundantEncoding": "always", + "redundantChannels": [ + "shape" + ] + }, + "labels": { + "truncation": "never" + }, + "legend": { + "show": "always", + "placement": [ + "right", + "inside" + ], + "title": "whenAmbiguous", + "suppressWhenAxisNames": true + }, + "dataLabels": { + "show": "whenTheyFit", + "placement": "outsideMark" + }, + "annotation": { + "axisTitles": "always", + "axisTitlePlacement": "rotated", + "unitsInAxisTitle": true, + "statistics": { + "show": [ + "n", + "r2", + "slope" + ], + "placement": "panel" + } + }, + "layout": { + "density": "compact", + "targetWidth": 252, + "titleBlock": { + "anchor": "middle", + "position": "bottom", + "gap": "tight", + "deckGap": "tight" + }, + "bandStep": 46 + }, + "compileDefaults": { + "baseSize": { "width": 300, "height": 250 } + }, + "chartDefaults": { + "Boxplot": { + "showPoints": true + }, + "Violin Plot": { + "showPoints": true, + "showMedian": true, + "showContour": true + } + } + }, +}; diff --git a/packages/flint-js/src/core/theme/presets/nyt.ts b/packages/flint-js/src/core/theme/presets/nyt.ts new file mode 100644 index 00000000..a759871f --- /dev/null +++ b/packages/flint-js/src/core/theme/presets/nyt.ts @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ThemePreset } from '../types'; +import { NYT_ICON } from './icons'; + +/** + * New York Times. + * + * Measured from hand-authored redesigns, not invented — see the Theme Lab. + */ +export const nyt: ThemePreset = { + id: 'nyt', + label: "New York Times", + description: "Newsroom graphics: a headline that states the finding, values printed on the marks, series named at their own ends.", + guidance: [ + "- `title` is the finding, in a sentence; `subtitle` names the measure, the population and the unit.", + "- Colour can tell 5 categories apart; past that they share a grey.", + ].join('\n'), + icon: NYT_ICON, + spec: { + "id": "nyt", + "label": "New York Times", + "ink": { + "surface": { + "source": "host" + }, + "text": { + "primary": "#121212", + "secondary": "#6b6b6b", + "muted": "#8a8a8a", + "inverse": "#ffffff" + }, + "structure": { + "grid": "#e4e4e4", + "axis": "#121212", + "rule": "#121212" + }, + "series": { + "single": "#2f6b9a", + "categorical": [ + "#2f6b9a", + "#c2352b", + "#4a8b6f", + "#7f6a9e", + "#d9a441" + ], + "categoricalExtended": [ + "#2f6b9a", + "#c2352b", + "#4a8b6f", + "#7f6a9e", + "#d9a441", + "#e27ea6", + "#3fae9e", + "#9ca13a", + "#8c6d31", + "#6b8fb3", + "#d07b3a", + "#b5546a" + ], + // Continuous measure: the house blue, light tint to deep, so + // a heat map or choropleth reads as one hue growing, not a set. + "sequential": { + "stops": [ + "#eef4f8", + "#c2d8e7", + "#8bb0cf", + "#5187b3", + "#2f6b9a", + "#1c4363" + ], + "space": "lab", + "endpointsAgainstSurface": true, + "consumption": "interpolate" + }, + "diverging": { + "stops": [ + "#2f6b9a", + "#8fb4cc", + "#efece5", + "#dd9a86", + "#c2352b" + ], + "neutral": "#efece5", + "space": "lab", + "endpointsAgainstSurface": true, + "consumption": "interpolate" + }, + "overflow": "#9e9e9e", + "status": { + "positive": "#2f6b9a", + "negative": "#c2352b", + "neutral": "#9e9e9e" + }, + "selection": { + "signed": "status", + "statusUse": "anySigned" + } + }, + "accent": "#c2352b" + }, + "type": { + "minSize": 8, + "headline": { + "family": "Georgia, serif", + "size": "text.400", + "weight": "bold", + "color": "#121212" + }, + "deck": { + "family": "Georgia, serif", + "size": "text.200", + "color": "#6b6b6b" + }, + "axisLabel": { + "family": "Helvetica, Arial, sans-serif", + "size": "text.100" + }, + "valueLabel": { + "family": "Helvetica, Arial, sans-serif", + "size": "text.100", + "weight": "bold" + } + }, + "structure": { + "axis": { + "categorical": { + "line": "full", + "ticks": "omit", + "tickLabels": "sparse" + }, + "measure": { + "line": "omit", + "ticks": "omit", + "tickDensity": "sparse", + "suppressWhenValuesPrinted": true + } + }, + "grid": { + "measure": "quiet", + "category": "omit", + "style": "solid", + "zero": "full" + }, + "frame": "omit", + "baseline": "full" + }, + "marks": { + "bandFraction": 0.72, + "strokeWeight": 2.4, + "strokeCap": "round", + "strokeJoin": "round", + "slice": { + "gap": 1.5 + }, + "tile": { + "gap": 1 + }, + "point": { + "size": 58 + }, + "zOrder": "summaryOverData", + "redundantEncoding": "whenNeeded", + "redundantChannels": [ + "dash" + ] + }, + "labels": { + "truncation": "never", + "flush": true + }, + "legend": { + "show": "always", + "placement": [ + "seriesEnd", + "top" + ], + "title": "omit", + "suppressWhenValuesPrinted": false + }, + "dataLabels": { + "show": "always", + "placement": "atMark", + "inkMode": "contrastWithMark" + }, + "annotation": { + "axisTitles": "omit", + "axisTitlePlacement": "flatAboveAxis", + "unit": "lastTick", + "pointEmphasis": "endpoints", + "numberFormat": { + "precision": "auto", + "thousands": "suffix" + } + }, + "layout": { + "density": "normal", + "titleBlock": { + "anchor": "start", + "deckGap": "tight" + } + }, + "compileDefaults": { + "baseSize": { "width": 380, "height": 340 } + }, + "chartDefaults": { + "Line Chart": { + "showPoints": true + }, + "Bump Chart": { + "interpolate": "linear" + } + } + }, +}; diff --git a/packages/flint-js/src/core/theme/presets/pop.ts b/packages/flint-js/src/core/theme/presets/pop.ts new file mode 100644 index 00000000..e29dcc29 --- /dev/null +++ b/packages/flint-js/src/core/theme/presets/pop.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ThemePreset } from '../types'; +import { POP_ICON } from './icons'; + +/** A loud pop-art remix that demonstrates how little a derived house needs to say. */ +export const pop: ThemePreset = { + id: 'pop', + label: 'Pop', + description: + 'A pop-art remix of Swiss: electric process colours, heavy black structure, oversized marks, and punchy display type.', + guidance: [ + '- Use a short title that can carry the poster-like display treatment.', + '- Strong categorical or binned quantitative data makes best use of the 6-colour process key.', + '- Keep annotations concise; the heavy structure and high-contrast marks already speak loudly.', + ].join('\n'), + icon: POP_ICON, + spec: { + extends: 'swiss', + id: 'pop', + label: 'Pop', + ink: { + surface: { source: 'house', canvas: '#fff200', plot: '#fff200' }, + text: { primary: '#111111', secondary: '#5f0047', muted: '#8c0068', inverse: '#fff200' }, + structure: { axis: '#111111', grid: '#111111', rule: '#111111', connector: '#111111' }, + series: { + single: '#ff1493', + categorical: ['#ff1493', '#00d9ff', '#ff5a1f', '#7a3cff', '#00c853', '#111111'], + sequential: { + stops: ['#00d9ff', '#7a3cff', '#ff1493', '#ff5a1f', '#fff200'], + space: 'rgb', + endpointsAgainstSurface: true, + consumption: 'quantize', + quantizeCount: 5, + }, + diverging: { + stops: ['#00d9ff', '#7a3cff', '#fff200', '#ff5a1f', '#ff1493'], + neutral: '#fff200', + space: 'rgb', + endpointsAgainstSurface: true, + consumption: 'quantize', + quantizeCount: 5, + }, + status: { positive: '#00c853', negative: '#ff1493', neutral: '#111111' }, + overflow: '#111111', + }, + accent: '#ff1493', + }, + type: { + minSize: 10, + headline: { + family: "'Arial Black', 'Helvetica Neue', Arial, sans-serif", + size: 'text.500', + weight: 'bold', + case: 'upper', + }, + deck: { size: 'text.200', weight: 'bold', color: '#5f0047' }, + axisLabel: { family: "'Arial Black', Arial, sans-serif", size: 'text.100' }, + axisTitle: { family: "'Arial Black', Arial, sans-serif", size: 'text.100', weight: 'bold' }, + valueLabel: { family: "'Arial Black', Arial, sans-serif", weight: 'bold' }, + }, + structure: { + axis: { + categorical: { line: 'emphasised', lineWeight: 3, ticks: 'omit' }, + measure: { line: 'emphasised', lineWeight: 3, ticks: 'full', tickLength: 'long' }, + }, + grid: { measure: 'quiet', category: 'hairline', style: 'solid', weight: 1, zero: 'emphasised' }, + baseline: 'emphasised', + }, + marks: { + bandFraction: 0.84, + strokeWeight: 6, + strokeCap: 'square', + strokeJoin: 'miter', + fillOpacity: 1, + outline: { presence: 'emphasised', weight: 3, source: 'ink' }, + tile: { gap: 1, source: 'structure' }, + point: { presence: 'full', size: 180, fill: 'solid', halo: { presence: 'omit' } }, + separator: { presence: 'emphasised', width: 3, source: 'structure' }, + }, + dataLabels: { show: 'whenTheyFit', placement: 'atMark', inkMode: 'contrastWithMark' }, + layout: { density: 'normal', titleBlock: { anchor: 'start', gap: 'tight' } }, + }, +}; \ No newline at end of file diff --git a/packages/flint-js/src/core/theme/presets/powerbi-light.ts b/packages/flint-js/src/core/theme/presets/powerbi-light.ts new file mode 100644 index 00000000..16de90e3 --- /dev/null +++ b/packages/flint-js/src/core/theme/presets/powerbi-light.ts @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ThemePreset } from '../types'; +import { POWERBI_LIGHT_ICON } from './icons'; + +/** + * Power BI — light. + * + * The default polished Power BI report look: a white tile, Segoe UI, the + * standard #118dff-led data palette, hairline light-grey gridlines, legend to + * the right, the latest point emphasised. The dark `powerbi` house flipped to + * a light surface — same furniture, same proportions, different ink — so a + * reader recognises the same product in either mode. + */ +export const powerbiLight: ThemePreset = { + id: 'powerbi-light', + label: "Power BI (light)", + description: "Light dashboard tile: white canvas, Segoe UI, hairline grid, legend to the right.", + guidance: [ + "- Leave `title` out where the tile sits under its own caption — the axis titles come back to name the measure.", + "- Colour can tell 6 categories apart.", + ].join('\n'), + icon: POWERBI_LIGHT_ICON, + spec: { + "id": "powerbi-light", + "label": "Power BI (light)", + "ink": { + "surface": { + "source": "house", + "canvas": "#ffffff", + "plot": "#ffffff", + "panel": "#faf9f8" + }, + "text": { + "primary": "#252423", + "secondary": "#605e5c", + "muted": "#a19f9d", + "inverse": "#ffffff" + }, + "structure": { + "grid": "#ededed", + "axis": "#d2d0ce", + "rule": "#d2d0ce", + "connector": "#8a8886" + }, + "series": { + "single": "#118dff", + "categorical": [ + "#118dff", + "#12239e", + "#e66c37", + "#6b007b", + "#e044a7", + "#744ec2" + ], + "categoricalExtended": [ + "#118dff", + "#12239e", + "#e66c37", + "#6b007b", + "#e044a7", + "#744ec2", + "#d9b300", + "#d64550", + "#197278", + "#5c2e91", + "#ff9d3b", + "#4a9c2d" + ], + // Continuous measure: Power BI azure, light to deep. + "sequential": { + "stops": [ + "#e5f1ff", + "#b3d7ff", + "#7bbcff", + "#3f9dff", + "#118dff", + "#0a5cb5" + ], + "space": "lab", + "endpointsAgainstSurface": true, + "consumption": "interpolate" + }, + "diverging": { + "stops": [ + "#118dff", + "#7bb8f5", + "#e1dfdd", + "#e59866", + "#d64550" + ], + "neutral": "#e1dfdd", + "space": "lab", + "endpointsAgainstSurface": true, + "consumption": "interpolate" + }, + "status": { + "positive": "#107c10", + "negative": "#d13438", + "neutral": "#a19f9d" + }, + "selection": { + "signed": "diverging", + "statusUse": "thresholdOnly", + "redundantWithFacet": "single" + }, + "overflow": "#bcbcbc" + }, + "accent": "#118dff" + }, + "type": { + "minSize": 8, + "headline": { + "family": "'Segoe UI', system-ui, sans-serif", + "size": "text.200", + "weight": "semibold", + "color": "#252423" + }, + "display": { + "family": "'Segoe UI', system-ui, sans-serif", + "size": "text.hero900", + "weight": "semibold" + }, + "axisLabel": { + "family": "'Segoe UI', system-ui, sans-serif", + "size": "text.100", + "color": "#605e5c" + }, + "keyLabel": { + "size": "text.100", + "color": "#605e5c" + } + }, + "structure": { + "axis": { + "categorical": { + "line": "omit", + "ticks": "omit", + "tickLabels": "sparse" + }, + "measure": { + "line": "omit", + "ticks": "omit", + "tickDensity": "sparse" + } + }, + "grid": { + "measure": "quiet", + "category": "omit", + "style": "solid" + }, + "frame": "omit", + "baseline": "quiet" + }, + "marks": { + "strokeWeight": 2.2, + "strokeCap": "square", + "minSize": 1.5, + "point": { + "size": 62 + }, + "separator": { + "presence": "hairline", + "source": "surface", + "width": 1 + }, + "slice": { + "gap": 1.5 + }, + "connector": { + "presence": "full", + "weight": 1.5, + "spanWeight": 2 + }, + "trailingFill": { + "presence": "quiet", + "opacity": 0.18 + }, + "reference": { + "presence": "full", + "style": "tick", + "label": true, + "weight": 2 + } + }, + "labels": { + "truncation": "never" + }, + "legend": { + "show": "always", + "placement": [ + "right", + "bottom" + ], + "title": "omit", + "gradientLength": 90, + "suppressWhenValuesPrinted": false + }, + "dataLabels": { + "show": "whenTheyFit", + "placement": "atMark", + "inkMode": "contrastWithMark" + }, + "annotation": { + "axisTitles": "omit", + "unit": "everyTick", + "pointEmphasis": "latest", + "numberFormat": { + "precision": "auto" + } + }, + "facets": { + "header": { + "presence": "full", + "style": "flushLabel", + "fieldTitle": "omit" + }, + "panelFrame": "omit", + "axisRepetition": "edgeOnly", + "preferredColumns": 4, + "sharedScale": "whenComparable" + }, + "layout": { + "density": "compact", + "titleBlock": { + "anchor": "start", + "gap": "tight", + "deckGap": "tight" + } + }, + "compileDefaults": { + "baseSize": { "width": 480, "height": 280 } + } + }, +}; diff --git a/packages/flint-js/src/core/theme/presets/powerbi.ts b/packages/flint-js/src/core/theme/presets/powerbi.ts new file mode 100644 index 00000000..ecb52865 --- /dev/null +++ b/packages/flint-js/src/core/theme/presets/powerbi.ts @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ThemePreset } from '../types'; +import { POWERBI_ICON } from './icons'; + +/** + * Power BI. + * + * Measured from hand-authored redesigns, not invented — see the Theme Lab. + */ +export const powerbi: ThemePreset = { + id: 'powerbi', + label: "Power BI", + description: "Dashboard tile: compact, legend to the right, the latest point emphasised.", + guidance: [ + "- Leave `title` out where the tile sits under its own caption — the axis titles come back to name the measure.", + "- Colour can tell 6 categories apart.", + ].join('\n'), + icon: POWERBI_ICON, + spec: { + "id": "powerbi", + "label": "Power BI", + "ink": { + "surface": { + "source": "house", + "canvas": "#1b1a19", + "plot": "#1b1a19", + "panel": "#252423" + }, + "text": { + "primary": "#f3f2f1", + "secondary": "#c8c6c4", + "muted": "#a19f9d", + "inverse": "#1b1a19" + }, + "structure": { + "grid": "#3b3a39", + "axis": "#3b3a39", + "rule": "#3b3a39", + "connector": "#797775" + }, + "series": { + "single": "#118dff", + // Power BI themes name dataColors explicitly; the classic + // defaults are for a light report canvas. This dark set keeps + // the product's azure/orange/magenta character while every + // swatch clears 3:1 against both the plot and panel. + "categorical": [ + "#118dff", + "#e66c37", + "#3bd1c7", + "#e044a7", + "#d9b300", + "#8764b8" + ], + "categoricalExtended": [ + "#118dff", + "#e66c37", + "#3bd1c7", + "#e044a7", + "#d9b300", + "#8764b8", + "#d64550", + "#4a9c2d", + "#6677d9", + "#b146c2", + "#ff9d3b", + "#25797f" + ], + // Continuous measure on a dark canvas: dim blue at the low end + // (never the background) rising to bright azure, so "more" + // reads as brighter — a light-to-dark ramp would sink the high + // values into the near-black plot. + "sequential": { + "stops": [ + "#123049", + "#0f4c86", + "#1170c9", + "#3f9dff", + "#7bbcff", + "#c9e3ff" + ], + "space": "lab", + "endpointsAgainstSurface": true, + "consumption": "interpolate" + }, + "diverging": { + "stops": [ + "#118dff", + "#5aa9f0", + "#4a4948", + "#e08a4a", + "#d64550" + ], + "neutral": "#4a4948", + "space": "lab", + "endpointsAgainstSurface": true, + "consumption": "interpolate" + }, + "status": { + "positive": "#22b14c", + "negative": "#e66c37", + "neutral": "#a19f9d" + }, + "selection": { + "signed": "diverging", + "statusUse": "thresholdOnly", + "redundantWithFacet": "single" + }, + "overflow": "#8a8886" + }, + "accent": "#118dff" + }, + "type": { + "minSize": 8, + "headline": { + "family": "'Segoe UI', system-ui, sans-serif", + "size": "text.200", + "weight": "semibold", + "color": "#f3f2f1" + }, + "display": { + "family": "'Segoe UI', system-ui, sans-serif", + "size": "text.hero900", + "weight": "semibold" + }, + "axisLabel": { + "family": "'Segoe UI', system-ui, sans-serif", + "size": "text.100", + "color": "#c8c6c4" + }, + "keyLabel": { + "size": "text.100", + "color": "#c8c6c4" + } + }, + "structure": { + "axis": { + "categorical": { + "line": "omit", + "ticks": "omit", + "tickLabels": "sparse" + }, + "measure": { + "line": "omit", + "ticks": "omit", + "tickDensity": "sparse" + } + }, + "grid": { + "measure": "quiet", + "category": "omit", + "style": "solid" + }, + "frame": "omit", + "baseline": "quiet" + }, + "marks": { + "strokeWeight": 2.2, + "strokeCap": "square", + "minSize": 1.5, + "point": { + "size": 62 + }, + "separator": { + "presence": "hairline", + "source": "surface", + "width": 1 + }, + "slice": { + "gap": 1.5 + }, + "connector": { + "presence": "full", + "weight": 1.5, + "spanWeight": 2 + }, + "trailingFill": { + "presence": "quiet", + "opacity": 0.18 + }, + "reference": { + "presence": "full", + "style": "tick", + "label": true, + "weight": 2 + } + }, + "labels": { + "truncation": "never" + }, + "legend": { + "show": "always", + "placement": [ + "right", + "bottom" + ], + "title": "omit", + "gradientLength": 90, + "suppressWhenValuesPrinted": false + }, + "dataLabels": { + "show": "whenTheyFit", + "placement": "atMark", + "inkMode": "contrastWithMark" + }, + "annotation": { + "axisTitles": "omit", + "unit": "everyTick", + "pointEmphasis": "latest", + "numberFormat": { + "precision": "auto" + } + }, + "facets": { + "header": { + "presence": "full", + "style": "flushLabel", + "fieldTitle": "omit" + }, + "panelFrame": "omit", + "axisRepetition": "edgeOnly", + "preferredColumns": 4, + "sharedScale": "whenComparable" + }, + "layout": { + "density": "compact", + "titleBlock": { + "anchor": "start", + "gap": "tight", + "deckGap": "tight" + } + }, + "compileDefaults": { + "baseSize": { "width": 480, "height": 280 } + } + }, +}; diff --git a/packages/flint-js/src/core/theme/presets/swiss.ts b/packages/flint-js/src/core/theme/presets/swiss.ts new file mode 100644 index 00000000..01f12182 --- /dev/null +++ b/packages/flint-js/src/core/theme/presets/swiss.ts @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ThemePreset } from '../types'; +import { SWISS_ICON } from './icons'; + +/** + * Swiss / International Typographic Style. + * + * Modelled on the hand-authored mockups in the Swiss lab (see + * `site/src/playground/swiss-lab-data.ts`), which were in turn measured against + * the poster/diagram canon — swissted.com, Poster House "The Swiss Grid", + * Müller-Brockmann's Tonhalle posters, Vignelli's 1972 NYC subway diagram, and + * Otl Aicher's 1972 Munich colour system. + * + * What makes it a different design *language* rather than a palette reskin of + * the editorial houses: the modular grid is drawn, not hidden (NYT hides it); + * the axes are structural black rules; the headline is a bold flush-left + * Helvetica block; and colour is a single saturated signal red against warm + * paper, with flat primaries for extra categories. No gradients on the marks, + * no rounded corners, no ornament. + */ +export const swiss: ThemePreset = { + id: 'swiss', + label: 'Swiss', + description: + 'International Typographic Style: warm paper, a visible modular grid, black structural axes, a bold flush-left Helvetica headline over a rule, and a single signal-red accent.', + guidance: [ + '- `title` carries the naming in a bold flush-left block; `subtitle` names the measure and period.', + '- Annotate the measure with `unit` in `semantic_types`.', + '- Colour is a single signal red; the categorical key tells 5 series apart.', + ].join('\n'), + icon: SWISS_ICON, + spec: { + id: 'swiss', + label: 'Swiss', + ink: { + surface: { + source: 'house', + canvas: '#f4f1ea', + plot: '#f4f1ea', + }, + text: { + primary: '#1a1a1a', + secondary: '#555555', + muted: '#8a8a8a', + }, + structure: { + grid: '#d9d5cc', + axis: '#1a1a1a', + rule: '#1a1a1a', + connector: '#8a8a8a', + }, + series: { + single: '#e2231a', + categorical: ['#e2231a', '#1a1a1a', '#0067a5', '#f2b705', '#2a7f4f'], + categoricalExtended: [ + '#e2231a', + '#1a1a1a', + '#0067a5', + '#f2b705', + '#2a7f4f', + '#e06d1f', + '#5b4b8a', + '#1f8a8a', + '#b5195f', + '#8a8a2a', + '#5a6b78', + '#8a5a2a', + ], + // Sequential: a single-hue red ramp, binned into steps — a scale + // the reader can name a bin off, not a wash. + sequential: { + stops: ['#fbe3df', '#f4a19a', '#eb6a5f', '#e2231a', '#9e120c'], + space: 'lab', + endpointsAgainstSurface: true, + consumption: 'quantize', + quantizeCount: 5, + }, + // Diverging: cobalt to signal red, through the paper neutral. + diverging: { + stops: ['#0067a5', '#7fb2d6', '#efeae0', '#ef9a90', '#e2231a'], + neutral: '#efeae0', + space: 'lab', + endpointsAgainstSurface: true, + consumption: 'quantize', + quantizeCount: 5, + }, + // Signed data reads as the two Swiss primaries: cobalt up, + // signal red down, a neutral grey for the anchoring total. + status: { + positive: '#0067a5', + negative: '#e2231a', + neutral: '#9a9a9a', + }, + overflow: '#9a9a9a', + selection: { + signed: 'status', + statusUse: 'anySigned', + }, + }, + accent: '#e2231a', + }, + type: { + minSize: 9, + headline: { + family: "'Helvetica Neue', Helvetica, Arial, sans-serif", + size: 'text.400', + weight: 'bold', + }, + deck: { + size: 'text.200', + color: '#555555', + }, + axisLabel: { + size: 'text.100', + }, + axisTitle: { + size: 'text.100', + weight: 'bold', + color: '#1a1a1a', + }, + }, + structure: { + axis: { + categorical: { + line: 'full', + ticks: 'omit', + }, + measure: { + line: 'full', + ticks: 'full', + tickLength: 'short', + tickDirection: 'outward', + }, + }, + grid: { + measure: 'quiet', + category: 'omit', + style: 'solid', + zero: 'full', + }, + frame: 'omit', + baseline: 'full', + }, + marks: { + bandFraction: 0.7, + strokeWeight: 3, + strokeCap: 'butt', + strokeJoin: 'miter', + interpolation: 'linear', + point: { + presence: 'omit', + fill: 'solid', + // Small and exact. The grid does the work of placing a + // reading here, so the dot only has to mark the spot. + size: 52, + }, + separator: { + presence: 'hairline', + source: 'surface', + width: 1.5, + }, + slice: { + gap: 2, + gapStyle: 'rule', + }, + sizeRange: [12, 400], + }, + labels: { + truncation: 'never', + flush: true, + angle: 'auto', + }, + legend: { + show: 'always', + placement: ['top'], + direction: 'horizontal', + title: 'omit', + suppressWhenAxisNames: true, + }, + dataLabels: { + show: 'whenTheyFit', + placement: 'outsideMark', + inkMode: 'fixed', + }, + annotation: { + axisTitles: 'whenAmbiguous', + unit: 'lastTick', + numberFormat: { + precision: 'auto', + }, + }, + layout: { + density: 'normal', + targetWidth: 300, + titleBlock: { + anchor: 'start', + gap: 'normal', + }, + }, + compileDefaults: { + baseSize: { width: 420, height: 320 }, + }, + }, +}; diff --git a/packages/flint-js/src/core/theme/types.ts b/packages/flint-js/src/core/theme/types.ts new file mode 100644 index 00000000..895d453c --- /dev/null +++ b/packages/flint-js/src/core/theme/types.ts @@ -0,0 +1,817 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * ThemeSpec — level 1 (authored) and level 2 (grounded) types. + * + * See `design-docs/03-themeSpec-abstract-design.md` and + * `design-docs/themespec/fields.md` for the argument behind every field. + * + * The invariant this file encodes: + * + * LEVEL 1 (`ThemeSpec`) never names a chart type, a positional channel, + * a mark type, a field, or a backend property. + * + * LEVEL 2 (`DesignDecisions`) has bound every role to a concrete part of + * *this* chart and resolved every policy against the space actually + * available — but is still backend-neutral. + * + * Level 3 (realization) lives in each backend, e.g. `vegalite/theme.ts`. + */ + +import type { AssembleOptions } from '../types.js'; + +// --------------------------------------------------------------------------- +// Level 1 — the authored ThemeSpec +// --------------------------------------------------------------------------- + +/** The presence ordinal. Grounding may step DOWN it, and must report. */ +export type Presence = 'omit' | 'hairline' | 'quiet' | 'full' | 'emphasised'; + +export type Frequency = 'never' | 'whenNeeded' | 'always'; + +/** A design-token size reference (`text.100`, `text.hero900`) or a raw px number. */ +export type SizeToken = string | number; + +export interface TypeRole { + family?: string; + size?: SizeToken; + weight?: 'regular' | 'medium' | 'semibold' | 'bold'; + /** Journals set decks and captions in italic; it is a role, not an accident. */ + style?: 'normal' | 'italic'; + case?: 'asIs' | 'upper' | 'lower' | 'title'; + color?: string; +} + +export interface AxisRole { + line?: Presence; + /** Stroke width of the axis rule in px. Presence still decides whether it is drawn. */ + lineWeight?: number; + ticks?: Presence; + tickLength?: 'short' | 'medium' | 'long'; + tickDirection?: 'outward' | 'inward'; + /** + * Distance from the axis rule to its labels in px. For outward ticks, the + * tick occupies the first part of this distance. + */ + labelGap?: number; + /** `opposite` = the far side of the plot (top for x, right for y). */ + placement?: 'default' | 'opposite'; + tickLabels?: 'all' | 'observed' | 'endpoints' | 'sparse'; + tickDensity?: 'sparse' | 'normal' | 'dense'; + /** + * Drop the axis entirely when every mark already prints its own value. + * The scale is then carried by the numbers, not by a ruler beside them. + * Mirrors `legend.suppressWhenValuesPrinted`. + */ + suppressWhenValuesPrinted?: boolean; +} + +/** + * Control points of an interpolator — NOT an indexed set. + * Length is resolution, not capacity, so `overflow` does not apply. + */ +export interface Ramp { + stops: string[]; + /** Diverging only: the ink at the pivot. */ + neutral?: string; + space?: 'rgb' | 'lab' | 'hcl'; + /** Forbid the ramp endpoints colliding with the canvas. */ + endpointsAgainstSurface?: boolean; + consumption?: 'interpolate' | 'quantize' | 'sampleCategorical'; + quantizeCount?: number; +} + +export interface ThemeInk { + surface?: { + source?: 'host' | 'house'; + canvas?: string; + plot?: string; + panel?: string; + }; + text?: { + primary?: string; + secondary?: string; + muted?: string; + inverse?: string; + }; + /** The ink the presence ordinal scales against. */ + structure?: { + axis?: string; + grid?: string; + frame?: string; + rule?: string; + /** + * The zero line, when the house makes it more than one gridline among + * the rest. The Economist strokes zero in its signature red so every + * length is read from a line the eye cannot miss. It borrows `rule` + * (then `axis`) where the house says nothing, so a house that draws an + * ordinary zero needs to state nothing. + */ + zero?: string; + /** + * The stem of a lollipop, the bridge of a dumbbell. It borrows `rule` + * where the house says nothing, but the two are not the same job: a + * gridline is read *through*, so it sits at the bottom of the ordinal, + * while a connector is part of the mark and has to hold its shape at + * a hairline's width. A house whose gridlines are already pale has no + * room left below them, and states this ink instead. + */ + connector?: string; + }; + series?: { + single?: string; + categorical?: string[]; + /** + * A larger indexed set the house reaches for when a chart has more + * series than its core {@link categorical} palette can name, in the + * spirit of Tableau's 10→20 step. The core set carries the house's + * identity at low cardinality (a handful of well-known inks); the + * extended set trades a little of that identity for the capacity to + * keep every series distinct up to its length. Grounding picks the + * smallest set whose length covers the series count; past the extended + * set's length the {@link overflow} ink takes the tail. + * + * Must contain the core set's inks as a prefix is *not* required — but + * ordering the shared hues first keeps a chart's colours stable as it + * grows. Unset ⇒ the house has only its core palette. + */ + categoricalExtended?: string[]; + overflow?: string; + sequential?: Ramp; + diverging?: Ramp; + status?: { positive?: string; negative?: string; neutral?: string }; + selection?: { + partToWhole?: 'categorical' | 'sequentialRamp'; + signed?: 'categorical' | 'status' | 'diverging' | 'sequential'; + redundantWithFacet?: 'single' | 'categorical'; + statusUse?: 'anySigned' | 'thresholdOnly' | 'never'; + }; + }; + accent?: string; +} + +export interface ThemeType { + minSize?: number; + headline?: TypeRole; + deck?: TypeRole; + axisLabel?: TypeRole; + axisTitle?: TypeRole; + valueLabel?: TypeRole; + keyLabel?: TypeRole; + annotation?: TypeRole; + footnote?: TypeRole; + /** The KPI big-number role — data, not chrome. */ + display?: TypeRole; +} + +export interface ThemeStructure { + axis?: { + categorical?: AxisRole; + measure?: AxisRole; + }; + /** + * Gridlines, bound by what the axis *does*, not by what it holds. + * + * `measure` is the grid the reader reads values off — the lines that run + * across the value axis. `category` is the grid across the axis the chart + * is indexed by, which on a scatter is the horizontal one even though it + * carries a number. Houses that rule only horizontally are asking for + * `measure` on and `category` off, whatever the two axes happen to hold. + */ + grid?: { + measure?: Presence; + category?: Presence; + style?: 'solid' | 'dashed' | 'dotted'; + /** Stroke width of visible gridlines in px. */ + weight?: number; + /** + * A separate rule where the value axis crosses zero. `omit` leaves + * zero as an ordinary gridline; anything else draws it in its own + * weight. Only ever drawn when zero is inside the domain. + */ + zero?: Presence; + }; + frame?: Presence; + baseline?: Presence; +} + +export interface ThemeMarks { + bandFraction?: number; + strokeWeight?: number; + strokeCap?: 'butt' | 'round' | 'square'; + strokeJoin?: 'miter' | 'round' | 'bevel'; + interpolation?: 'linear' | 'monotone' | 'step'; + fillOpacity?: number; + /** + * How far the *value* end of a bar is rounded, in px — the top of a + * column, the right of a horizontal bar — and, on a wedge, its corners. + * Only the value end of a bar moves; the baseline stays a clean edge, so a + * stack still reads as one column. A house that says nothing keeps square + * corners; a friendlier, less clinical house rounds them. + */ + cornerRadius?: number; + /** + * A stroke drawn around every filled mark — a bar, wedge, or point: the + * "sticker" / flat-illustration edge. It is not a `separator` (which cuts + * *between* adjacent pieces) nor a `frame` (which bounds the plot): it + * bounds each mark on its own, so a lone bar carries it too. A bar's + * outline stands down where the bar is too thin to hold it (so a dense bar + * chart keeps its fill); a grid cell is a field, held apart by a `tile` + * gap, not an outline. Large points keep the outline while dense point + * clouds may shrink the whole dot so the border does not turn the plot + * into a solid field. `ink` draws it in the house's dark structural ink; + * `surface` draws it in the page. A house that says nothing leaves its + * marks unbordered. + */ + outline?: { presence?: Presence; weight?: number; source?: 'ink' | 'surface' }; + sizeRange?: [number, number]; + minSize?: number; + zOrder?: 'summaryOverData' | 'summaryUnderData'; + separator?: { presence?: Presence; width?: number; source?: 'surface' | 'structure' }; + /** + * A wedge sits in no band, so how far apart neighbouring wedges stand is + * its own question. A house may rule its stacked bars with a half-pixel + * hairline and still want a clean cut between the pieces of a pie: two + * arcs of the same size at different orientations are hard enough to + * compare without also having to find where one ends. Where the house + * says nothing, wedges are held apart the way its bars are. + * + * `rule` paints the shared edge in the separator's ink, which reads as a + * gap of constant width. `pad` swings the wedges apart instead, so the + * gap opens at the rim and closes to nothing at the centre. + */ + slice?: { gap?: number; gapStyle?: 'rule' | 'pad' }; + /** + * How far apart the cells of a grid stand — a heatmap, a calendar, a + * matrix. A cell is not a bar: it has no band to give back, its two + * neighbours are on two axes, and its colour is the reading, so a gap + * between cells has to be cut out of the shape the way a wedge's is. + * + * Whether to cut at all is a real difference between houses and not a + * detail. Flush cells read as a continuous field — the eye follows the + * gradient across a row and sees a season. Cut cells read as a table of + * separate readings, which is what a house wants when it prints the number + * inside each one. Where the house says nothing, cells are held apart the + * way its bars are. + * + * The gap is painted, not spaced, and it takes the surface unless the + * house asks for structure: on a grid whose colour *is* the value, an edge + * in any other ink reads as data. + */ + tile?: { gap?: number; source?: 'surface' | 'structure' }; + point?: { + /** + * Whether a *line* carries a dot at each of its vertices. This is a + * question about lines, not about dots: a scatter's dots are drawn + * because the chart is a scatter, and no house presence turns them off. + */ + presence?: Presence; + /** + * How big a dot this house draws, as an area in px² — the way the + * renderer states a point's size and the way the size channel is read. + * One number, wherever a dot appears: at a line's vertex, on a scatter, + * at the ends of a dumbbell. A house that wanted its scatter dots and + * its vertex dots at different sizes would be saying that the same ink + * means two things, and the eye does not read them that way. + * + * Where the house says nothing the renderer's default stands. The + * layout remains free to shrink it when the plot runs short of room. + */ + size?: number; + fill?: 'solid' | 'hollow'; + halo?: { presence?: Presence; width?: number }; + }; + connector?: { + presence?: Presence; + /** + * The weight of a connector that runs from a mark to the baseline — a + * lollipop's stem. It restates nothing: the dot's position already + * carries the value and the stem only leads the eye down to the axis, + * so it is drawn as structure. + */ + weight?: number; + /** + * The weight of a connector that runs between two marks — a dumbbell's + * bridge. This one is not redundant: the *distance* it draws is the + * reading, and a hairline asks the eye to measure a gap it can barely + * see. It keeps structure's ink and takes a mark's weight. + * + * Where the house says nothing, a bridge is drawn at the weight the + * house gives its lines: it is a mark, so it takes a mark's weight. + */ + spanWeight?: number; + style?: 'solid' | 'dashed' | 'dotted'; + }; + trailingFill?: { presence?: Presence; opacity?: number }; + interval?: { fillOpacity?: number; edge?: Presence; inkSource?: 'sameAsCentral' | 'structure' }; + summary?: { + fill?: Presence; + outline?: Presence; + centralRule?: Presence; + widthFraction?: number; + }; + observations?: { expose?: 'never' | 'whenSparse' | 'always'; maxRows?: number }; + reference?: { presence?: Presence; style?: 'tick' | 'line' | 'dashed'; weight?: number; label?: boolean }; + redundantEncoding?: Frequency; + redundantChannels?: Array<'shape' | 'dash' | 'texture' | 'lightness'>; +} + +export interface ThemeLabels { + truncation?: 'never' | 'ellipsis' | 'wrap'; + flush?: boolean; + angle?: 'auto' | 'horizontal' | 'rotated'; +} + +export type LegendPlacement = + | 'seriesEnd' | 'inline' | 'top' | 'right' | 'bottom' | 'left' | 'inside'; + +export interface ThemeLegend { + show?: 'always' | 'never'; + placement?: LegendPlacement[]; + direction?: 'horizontal' | 'vertical'; + /** + * `whenAmbiguous` asks whether the key's labels say what they are: a list + * of names (`Chrome`, `Cairo`) does, and a ramp of numbers does not. + */ + title?: 'omit' | 'whenAmbiguous' | 'always'; + gradientLength?: number; + /** + * The most entries a key to *values* may spend. A legend that names ten + * bubble sizes is a table, not a key: three well-chosen sizes tell the + * reader the scale and leave the chart the room. + */ + maxSwatches?: number; + swatch?: 'auto'; + /** The legend restates the categorical axis — delete it. */ + suppressWhenAxisNames?: boolean; + /** The legend restates a number already printed in every mark — delete it. */ + suppressWhenValuesPrinted?: boolean; +} + +export interface ThemeDataLabels { + show?: 'always' | 'whenTheyFit' | 'never'; + placement?: 'atMark' | 'outsideMark' | 'column'; + inkMode?: 'fixed' | 'matchSeries' | 'contrastWithMark'; +} + +export interface ThemeAnnotation { + unit?: 'never' | 'firstTick' | 'lastTick' | 'firstAndLast' | 'everyTick'; + /** + * `whenAmbiguous` asks the same question of each axis: `Jan Feb Mar` names + * its own kind and needs no title over it, `26 20 14` names nothing until + * one is written. Ranks and binned ranges count as numbers. + */ + axisTitles?: 'omit' | 'whenAmbiguous' | 'always'; + axisTitlePlacement?: 'rotated' | 'flatAboveAxis' | 'inline'; + unitsInAxisTitle?: boolean; + numberFormat?: { + precision?: 'auto' | 'integer' | 'one' | 'two'; + signed?: boolean; + thousands?: 'none' | 'separator' | 'suffix'; + ordinal?: boolean; + }; + pointEmphasis?: 'never' | 'endpoints' | 'latest' | 'extremes'; + pointLabels?: 'never' | 'endpoints' | 'all'; + statistics?: { show?: string[]; placement?: 'panel' | 'caption' }; +} + +export interface ThemeFurniture { + kind: 'mastheadTab' | 'footerRule' | 'headerRule'; + anchor?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; + color?: string; + width?: number; + height?: number; +} + +export interface ThemeFacets { + header?: { presence?: Presence; style?: 'flushLabel' | 'boxedLabel'; fieldTitle?: 'omit' | 'always' }; + panelFrame?: Presence; + axisRepetition?: 'everyPanel' | 'edgeOnly'; + spacing?: 'compact' | 'normal' | 'airy'; + preferredColumns?: number; + sharedScale?: 'always' | 'whenComparable' | 'never'; +} + +export interface ThemeLayout { + density?: 'compact' | 'normal' | 'airy'; + targetWidth?: number; + titleBlock?: { + anchor?: 'start' | 'middle' | 'end'; + /** Place the semantic title above the chart or as a caption below it. */ + position?: 'top' | 'bottom'; + /** + * The vertical gap between the title block and the chart below it — a + * house's whitespace personality reaching the headline. `tight` packs + * the chart up under the title (a dense figure, a dashboard tile); + * `loose` gives an action title room to breathe (a slide exhibit). + */ + gap?: 'tight' | 'normal' | 'loose'; + /** The vertical gap between the headline and its deck (subtitle). */ + deckGap?: 'tight' | 'normal' | 'loose'; + }; + bandStep?: number; +} + +/** A predicate over signals the compiler already resolves. Deliberately closed. */ +export interface ThemeGuard { + markChannel?: 'length' | 'position' | 'area' | 'angle' | 'color' | 'text'; + hasBandedAxis?: boolean; + seriesCount?: NumericGuard; + categoryCount?: NumericGuard; + isPartToWhole?: boolean; + isSigned?: boolean; + isTemporal?: boolean; + isFaceted?: boolean; + isSummarised?: boolean; + canvasWidth?: NumericGuard; +} + +export interface NumericGuard { + lt?: number; lte?: number; gt?: number; gte?: number; eq?: number; +} + +export interface ThemeVariant { + when: ThemeGuard; + /** Policy blocks only — `ink` and `type` may not vary. */ + then: Partial>; + /** Required: a variant without a stated reason is an inconsistency. */ + because?: string; +} + +/** + * The one block that is allowed to name a chart type. + * + * Everything else at level 1 is a policy the compiler binds to whatever chart + * it is handed. This is different in kind: it is the house's list of settings + * for charts it has an opinion about — the Times puts a dot on every reading + * of a line, and a bump chart it prints is never smoothed. Those are not + * consequences of a design language, they are house rules, and there is no + * honest way to derive them from ink and type. + * + * Keyed by chart type id, or `*` for every chart. Values are chart-property + * keys the template already declares. It is a *default*: anything the caller + * set explicitly wins, and a key the template does not offer is reported and + * dropped. + */ +export interface ThemeChartDefaults { + [chartType: string]: Record; +} + +/** + * Compiler settings the house prefers — the size it draws at, how far a chart + * may stretch, how much air a facet gets. These are not style: they decide how + * much room the chart has before a single colour is chosen, which is why they + * cannot be applied after the fact like ink. + * + * Three levels, in order: what the caller put in the chart spec, then this, + * then flint's own defaults. A house sets the middle one. + */ +export interface ThemeCompileDefaults extends Partial { + baseSize?: { width: number; height: number }; + canvasSize?: { width: number; height: number }; +} + +/** + * Level 1. One JSON document per design language. + * + * Every field is optional, including the ink and the type. A house that states + * nothing is not an error — it is the neutral house, and grounding it yields + * Flint's own defaults. That matters beyond tidiness: it is what lets the + * compiler reason about a chart's design (can it carry value labels? at this + * density?) when the caller named no house at all, without having to invent a + * second, parallel set of rules for the untheme'd case. + */ +export interface ThemeSpec { + /** + * Start from a theme Flint ships, then override only the fields this + * specification states. Nested objects merge; arrays and scalar values + * replace the preset value. + */ + extends?: string; + id?: string; + label?: string; + ink?: ThemeInk; + type?: ThemeType; + structure?: ThemeStructure; + marks?: ThemeMarks; + labels?: ThemeLabels; + legend?: ThemeLegend; + dataLabels?: ThemeDataLabels; + annotation?: ThemeAnnotation; + furniture?: ThemeFurniture[]; + facets?: ThemeFacets; + layout?: ThemeLayout; + chartDefaults?: ThemeChartDefaults; + compileDefaults?: ThemeCompileDefaults; + interaction?: { tooltipFormat?: string }; + variants?: ThemeVariant[]; +} + +/** + * A house Flint ships, ready to name by id: `theme_spec: 'economist'`. + * + * Three parts, and they answer different questions. `spec` is what the + * compiler reads. `description` is how a caller chooses between houses. + * `guidance` is the house talking upstream. + * + * That last one needs saying, because the boundary is easy to blur. A theme + * governs the visual: ink, type, furniture, spacing. It does not choose the + * fields, the aggregation or the sort — the chart spec does, and it is written + * first. So where a house depends on something only the spec can give, it says + * so: which words it needs written, which annotations it reads, how many + * categories its colour can name. Facts and requests, not instructions — what + * to do about a tail of thirty categories is the author's call, and a house + * that starts prescribing transformations is overreaching. Hence a few lines. + */ +export interface ThemePreset { + id: string; + label: string; + /** One line: what this house is for. */ + description: string; + /** A few markdown bullets: what this house needs the chart spec to do. */ + guidance: string; + /** + * A 16px SVG standing in for the house in a picker, as a complete document + * so a caller can drop it straight into an `` or inline it. + * + * It is drawn from the house's own decisions rather than invented: the tile + * is its canvas, the bars are the first three of its categorical set, and + * the one thing left over says what the house does that the others do not — + * the Economist's red tab, Swiss's structural black rules, McKinsey's + * horizontal bars, Nature's bare axis, cartoon's rounded tops. At this size + * that is all a reader can take in, and it is enough to recognise the house + * once they have seen one chart in it. + */ + icon: string; + spec: ThemeSpec; +} + +// --------------------------------------------------------------------------- +// Level 2 — grounded DesignDecisions +// --------------------------------------------------------------------------- + +/** + * A downgrade or approximation. Silent fallbacks are indistinguishable from + * bugs, so every one of these is surfaced on `spec._theme.report`. + */ +export interface ThemeReport { + stage: 'ground' | 'realize'; + /** Dotted ThemeSpec path this concerns, e.g. `legend.placement`. */ + path: string; + message: string; +} + +export interface ResolvedText { + font?: string; + fontSize?: number; + fontWeight?: 'normal' | 'bold' | number; + fontStyle?: 'normal' | 'italic'; + color?: string; +} + +export interface ResolvedRule { + show: boolean; + color: string; + width: number; + dash?: number[]; +} + +/** One axis, already bound to a screen channel. */ +export interface ResolvedAxis { + role: 'categorical' | 'measure'; + /** `top`/`bottom` for x, `left`/`right` for y. */ + orient: 'top' | 'bottom' | 'left' | 'right'; + domain: ResolvedRule; + ticks: ResolvedRule & { size: number; offset: number }; + grid: ResolvedRule; + label: ResolvedText & { show?: boolean; limit?: number; padding: number; flush?: boolean; angle?: number }; + title: { show: boolean; placement?: 'rotated' | 'flatAboveAxis' | 'inline'; unit?: string } & ResolvedText; + /** Preferred tick count; undefined = let the renderer choose. */ + tickCount?: number; + /** + * Which ticks carry a label. `all` leaves the choice to the renderer's own + * scale; the rest ask for the values the data actually holds, thinned or + * cut to the two ends. + */ + tickLabels?: 'all' | 'observed' | 'endpoints' | 'sparse'; + /** True when this axis carries what the reader indexes the chart by. */ + indexing?: boolean; + /** A rule at zero, drawn only where the value axis crosses it. */ + zeroRule?: ResolvedRule; + /** Suffix/prefix policy for the measure this axis carries. */ + unit?: { text: string; where: 'never' | 'firstTick' | 'lastTick' | 'firstAndLast' | 'everyTick' }; +} + +export interface ResolvedSeriesInk { + mode: 'single' | 'categorical' | 'sequential' | 'diverging' | 'status'; + single: string; + categorical: string[]; + overflow?: string; + /** + * The data needs more inks than the house named, and the house named no + * overflow ink either. Colour can no longer tell the series apart, so the + * house set is not imposed — what is on the chart already was chosen for + * the count. + */ + exhausted?: boolean; + /** + * More series than even the extended palette holds, but the house *does* + * name an {@link overflow} ink. The top {@link categorical}.length series + * by prominence take the indexed inks; every remaining ("other") series + * takes the one overflow ink. Realization orders the colour domain by + * share so it is the *smallest* series that fold into the overflow tail, + * not an arbitrary slice of the domain. + */ + overflowTail?: boolean; + ramp?: Ramp; + status?: { positive?: string; negative?: string; neutral?: string }; + /** Concrete range to hand a continuous colour scale (already sampled). */ + range?: string[]; + /** Set when the ramp is consumed as discrete bands. */ + quantize?: number; +} + +export interface ResolvedLegend { + show: boolean; + /** The placement that actually survived grounding. */ + placement: LegendPlacement; + /** + * The rest of the house's ranked list, after the one that survived. + * + * Grounding cannot see everything: whether a name fits inside the band it + * names is a question of pixels and text, and it is answered in realize. + * When the answer comes back no, the house has already said what it would + * rather have — so the fallback is read from here rather than invented. + */ + fallbacks?: LegendPlacement[]; + orient?: 'top' | 'right' | 'bottom' | 'left' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'none'; + direction?: 'horizontal' | 'vertical'; + title: boolean; + label: ResolvedText; + gradientLength?: number; + /** The most entries a key to values may spend. */ + maxSwatches?: number; +} + +export interface ResolvedDataLabels { + show: boolean; + placement: 'atMark' | 'outsideMark' | 'column'; + inkMode: 'fixed' | 'matchSeries' | 'contrastWithMark'; + text: ResolvedText; + /** d3-format string derived from `annotation.numberFormat` + channel semantics. */ + format?: string; + /** + * The unit each printed value carries. Set only where the house asks for + * a unit and no axis is left to state it — a pie has no ruler at all. + */ + unit?: string; + /** + * Below this magnitude the mark is shorter than its own label, so an + * inside label would overrun it. Grounding owns this because it is a + * question about space, not about style. + */ + insideMinValue?: number; + /** + * Above this magnitude the mark reaches the end of the scale, so an + * outside label would fall off the plot. The mirror of `insideMinValue`. + */ + outsideMaxValue?: number; + /** + * The smallest share of the measure axis a stacked segment may occupy and + * still be labelled — a line of text over the plot's extent along that + * axis. Segments below it get no number: it would not fit between the + * segment's edges and would read as its neighbour's. + * + * A share rather than a value because the two stack modes divide by + * different totals — the tallest stack when the bars are summed, each + * bar's own total when they are normalized. Grounding owns it because + * only grounding knows the plot's size; by the time a spec is assembled + * the height may be a step or a container, not a number. + */ + segmentMinShare?: number; + /** + * Whether this chart could carry value labels *at all* — structurally + * labelable, and not so dense that the numbers would be unreadable however + * firmly they were asked for. + * + * `show` is what the house decided; this is what the chart permits. A host + * reads it to know whether offering the reader a labels control is + * meaningful: where it is false the control can do nothing, so it is not + * shown rather than shown broken. + */ + possible: boolean; +} + +export interface ResolvedMarks { + bandFraction: number; + strokeWidth: number; + strokeCap?: string; + strokeJoin?: string; + interpolate?: string; + fillOpacity?: number; + /** Corner radius for the value end of a bar, and a wedge's corners, in px. */ + cornerRadius?: number; + /** A stroke around each filled bar/wedge/point: the sticker edge (thin bars skip it). */ + outline?: { color: string; width: number }; + point?: { show: boolean; size?: number; filled?: boolean; haloColor?: string; haloWidth?: number }; + /** The area a sized mark may take, smallest to largest, in px². */ + sizeRange?: [number, number]; + /** The area below which a sized mark stops being a mark. */ + minSize?: number; + /** Whether the rows behind a summary mark are drawn alongside it. */ + observations?: { expose: 'never' | 'whenSparse' | 'always'; maxRows: number }; + separator?: { show: boolean; color: string; width: number }; + /** How far apart neighbouring wedges of a pie or donut stand, in px. */ + slice?: { gap: number; style: 'rule' | 'pad'; color: string }; + /** How far apart the cells of a heatmap or matrix stand, in px. */ + tile?: { gap: number; color: string }; + /** + * `width` is the stem — a connector to the baseline. `spanWidth` is the + * bridge — a connector between two marks, which draws a distance that is + * itself the reading. Both are painted in `color`. + */ + connector?: { show: boolean; color?: string; width: number; spanWidth: number; dash?: number[] }; + interval?: { fillOpacity?: number; edge: boolean }; + summary?: { fill: boolean; outline: boolean; centralRule: boolean; widthFraction?: number }; + reference?: { show: boolean; width: number; style?: string; label: boolean }; + zOrder: 'summaryOverData' | 'summaryUnderData'; + redundantChannels: Array<'shape' | 'dash' | 'texture' | 'lightness'>; + redundantEncoding: Frequency; + /** + * The redundant channels grounding decided this chart actually gets, after + * weighing `redundantEncoding` against how hard the series are to tell + * apart by colour alone. + */ + redundant: { shape: boolean; dash: boolean }; +} + +/** Level 2 output. Backend-neutral, but every role is bound to this chart. */ +export interface DesignDecisions { + themeId: string; + surface: { canvas: string; plot?: string; panel?: string }; + /** Default text ink, for anything not otherwise specified. */ + text: { primary: string; secondary: string; muted: string; inverse: string }; + /** Base font family for the chart body. */ + font?: string; + title: { + anchor: 'start' | 'middle' | 'end'; + position: 'top' | 'bottom'; + headline: ResolvedText; + deck: ResolvedText; + /** Gap from the title block to the chart, in px. */ + offset: number; + /** Gap between the headline and its deck, in px. */ + deckPadding: number; + }; + /** Bound axes, keyed by screen channel. */ + axes: { x?: ResolvedAxis; y?: ResolvedAxis }; + frame: ResolvedRule; + baseline: ResolvedRule; + series: ResolvedSeriesInk; + legend: ResolvedLegend; + dataLabels: ResolvedDataLabels; + /** + * Which points on a line the house dots, and whether it writes the value + * there. Only meaningful where the chart draws a line through its data — + * stage 3 knows that, stage 2 does not. + */ + pointEmphasis?: { + where: 'endpoints' | 'latest' | 'extremes'; + labels: 'never' | 'endpoints' | 'all'; + size: number; + }; + marks: ResolvedMarks; + facets: { + header: { show: boolean; fieldTitle: boolean } & ResolvedText; + panelFrame: boolean; + axisRepetition: 'everyPanel' | 'edgeOnly'; + spacing?: number; + preferredColumns?: number; + }; + layout: { padding: number; density: 'compact' | 'normal' | 'airy' }; + /** + * What the house prints alongside a fit: the quantities it expects to see + * stated, and where. Only meaningful where the chart actually fits + * something — stage 3 knows that, stage 2 does not. + */ + statistics?: { show: string[]; placement: 'panel' | 'caption' } & ResolvedText; + furniture: ThemeFurniture[]; + /** Chart facts stage 3 is allowed to consult (it may not re-derive them). */ + bound: { + measureChannels: Array<'x' | 'y'>; + categoricalChannel?: 'x' | 'y'; + seriesChannel?: string; + /** The field the series is keyed on, and the one the categorical axis names. */ + seriesField?: string; + categoryField?: string; + seriesCount: number; + categoryCount: number; + isFaceted: boolean; + isPartToWhole: boolean; + isSigned: boolean; + /** The mark family, for realizers that must fake a missing primitive. */ + markChannel: string; + }; + report: ThemeReport[]; +} diff --git a/packages/flint-js/src/core/theme/value-label-format.ts b/packages/flint-js/src/core/theme/value-label-format.ts new file mode 100644 index 00000000..bcdb7e51 --- /dev/null +++ b/packages/flint-js/src/core/theme/value-label-format.ts @@ -0,0 +1,283 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * How many digits a value printed *on a mark* should carry, and how wide the + * result will be. + * + * A value label is a reading aid, not a table cell. It is read in place, next + * to its neighbours, in whatever room the mark leaves — so it wants the digits + * that let a reader take the value and compare it, and no more. Left to + * itself Vega-Lite prints the number as JavaScript renders it, which is how a + * tidy bar chart ends up captioned `3.14159265`. + * + * Two jobs live here, and they belong together because neither is right + * without the other: choosing the digits, and measuring what those digits will + * take up. Flint's fit tests — is the slot wide enough, is the segment thick + * enough — used to measure `String(Math.round(value))`, which is the width of + * a number nobody prints: it ignores decimals, separators, signs and the + * format itself. A chart of decimals was measured four times narrower than it + * drew, so the labels were offered and then overlapped. + */ + +/** Significant digits a printed value carries at the top of its scale. */ +const SIGNIFICANT_DIGITS = 3; + +/** + * Past this, digits stop being information and start being magnitude: a + * reader takes `1.23M` off a chart faster than `1,234,567`, and the mark + * rarely has room for the latter anyway. + */ +const SUFFIX_ABOVE = 10_000; + +/** The SI suffixes d3-format uses, smallest to largest. */ +const SI_SUFFIX = ['y', 'z', 'a', 'f', 'p', 'n', 'µ', 'm', '', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']; + +/** + * The decimals the data itself carries. + * + * Read off `toFixed(10)`, which already discards floating-point noise + * (0.1 + 0.2 comes to 0.30000000000000004 but fixes to 0.3000000000), and + * capped at 6 — past that a value label is no longer being read, it is being + * transcribed. + */ +function dataDecimals(values: number[]): number { + let most = 0; + for (const v of values) { + if (!Number.isFinite(v)) continue; + const s = v.toFixed(10); + const dot = s.indexOf('.'); + if (dot === -1) continue; + let end = s.length - 1; + while (end > dot && s[end] === '0') end -= 1; + const decimals = end > dot ? end - dot : 0; + if (decimals > most) most = decimals; + } + return Math.min(most, 6); +} + +/** The largest magnitude in the data — the number that sets the width. */ +function maxMagnitude(values: number[]): number { + let max = 0; + for (const v of values) { + if (typeof v === 'number' && Number.isFinite(v)) max = Math.max(max, Math.abs(v)); + } + return max; +} + +/** + * The decimals it takes for the printed labels to keep the distinctions the + * marks are already showing. + * + * Precision chosen from magnitude alone answers "how big is this number", but + * a value label is read *against its neighbours*, and what a reader wants from + * it is often the difference. Eight bars of visibly different height captioned + * `100` eight times, or a row of fractions all captioned `0`, is a caption the + * chart itself contradicts — the worst thing a label can be, because the + * reader trusts the number over the pixels. + * + * So the series gets whatever decimals it takes to keep two values that differ + * printing differently, and to keep a value that is not zero from printing as + * zero. Never more than the data carries, and never more than six: past that + * the distinction is too fine to have been the point of the chart. + */ +function decimalsToDistinguish(values: number[], cap: number): number { + const distinct = new Set(); + for (const v of values) if (Number.isFinite(v)) distinct.add(v); + if (distinct.size === 0) return 0; + const list = [...distinct]; + for (let d = 0; d <= cap; d += 1) { + const scale = 10 ** d; + const printed = new Set(); + let zeroedOut = false; + for (const v of list) { + const r = Math.round(v * scale) / scale; + if (r === 0 && v !== 0) zeroedOut = true; + printed.add(r); + } + if (!zeroedOut && printed.size === list.length) return d; + } + return cap; +} + +/** Whether a d3 format pattern already states a precision (`.2f`, `.3~s`). */ +function statesPrecision(pattern: string): boolean { + return /\.\d/.test(pattern); +} + +/** + * Choose the format a value label is printed with. + * + * The house's own pattern outranks this: a stated format is a decision + * someone made. But a house states a *style* — "use a k/M suffix", "group the + * thousands" — and a style says nothing about precision, which is why + * `~s` prints `1.23457M`. Where the house left the precision open, it is + * filled in from the data. + * + * Where the house stated one it is kept, with a single exception: a stated + * precision that would print two different values the same, or a value that + * is not zero as zero, is raised until it does not. `precision: 'integer'` is + * a house saying how numbers should read, not a house asking for eight bars + * of different heights all captioned `100`. + * + * With no house at all the whole pattern is inferred, because the alternative + * is Vega-Lite's raw rendering, and that is the case this exists to fix. + */ +export function inferValueLabelFormat(values: number[], house: string | undefined): string | undefined { + const nums = values.filter((v): v is number => typeof v === 'number' && Number.isFinite(v)); + if (nums.length === 0) return house; + + const max = maxMagnitude(nums); + const sign = house?.startsWith('+') ? '+' : ''; + // What it would take for the labels to stay as distinct as the marks. + // This is a floor on precision, not a target: it is consulted both when + // the house left the precision open and when it stated one, because a + // stated precision is a preference about how numbers should read, not a + // licence to print one the chart contradicts. + const needed = decimalsToDistinguish(nums, dataDecimals(nums)); + if (house && statesPrecision(house)) { + // A suffix format states *significant* digits, not decimals, and + // shortens by design; the two rules do not compose, so it is left as + // the house wrote it. + if (/[se]$/.test(house)) return house; + const stated = Number(/\.(\d+)/.exec(house)?.[1] ?? 0); + if (stated >= needed) return house; + // Keep everything the house said — sign, grouping, suffix-free `f` — + // and raise only the digits, with `~` so the values that did not need + // them are not padded out with zeros. + return house.replace(/\.\d+~?f$/, `.${needed}~f`); + } + // A house that asked for a suffix keeps its suffix; it is only the number + // of digits in front of it that was left open. But a suffix means `k` and + // `M` — the house is asking to shorten large numbers, not to reach for SI + // in the other direction. d3 applies `s` both ways, and `0.00123` comes + // out as `1.23m`, which on a chart reads as millions. So the suffix is + // used only where every value is at least 1, and so cannot pick up a + // negative-exponent prefix. + let smallest = Infinity; + for (const v of nums) { + const a = Math.abs(v); + if (a > 0) smallest = Math.min(smallest, a); + } + const suffixIsSafe = max >= 1000 && (smallest === Infinity || smallest >= 1); + // A suffix is a deliberate shortening, but not to the point of printing + // two different bars the same. Three significant figures separate + // 123M from 988M; they do not separate 1,000,000 from 1,000,400. + const sigFigsHold = (() => { + const distinct = new Set(nums); + const rounded = new Set(); + for (const v of distinct) rounded.add(Number(v.toPrecision(SIGNIFICANT_DIGITS))); + return rounded.size === distinct.size; + })(); + const wantsSuffix = (house ? /s$/.test(house) : max >= SUFFIX_ABOVE) && suffixIsSafe && sigFigsHold; + if (wantsSuffix) return `${sign}.${SIGNIFICANT_DIGITS}~s`; + + // Three significant digits at the top of the scale. `1999.9` keeps none of + // its decimals, `3.14159` keeps two — in each case the digits that + // separate one value from the next, and no more. + const grouping = house === undefined || house.includes(',') ? ',' : ''; + const magnitude = max > 0 ? Math.floor(Math.log10(max)) : 0; + const wanted = SIGNIFICANT_DIGITS - 1 - magnitude; + + // But a series is not all one size. Sizing the decimals off the largest + // value alone prints 0.001 and 0.05 both as `0` when a 5000 shares the + // axis — the small values are rounded out of existence, and a label that + // reads `0` on a bar that plainly is not zero is worse than no label. So + // the smallest value gets to claim the decimals it needs to say anything + // at all, and `~` trims the trailing zeros this leaves on the large ones, + // so `5000` is still printed `5,000` and not `5,000.000`. + const floor = smallest === Infinity ? 0 : Math.max(0, -Math.floor(Math.log10(smallest))); + + // Below this even the exponent is shorter than the zeros in front of it. + if (max > 0 && max < 1e-4) return `${sign}.2~e`; + + // Never invent precision the data does not have: whole numbers stay whole. + // `needed` is the exception that is not an exception — it is already + // bounded by what the data carries, so honouring it never invents a digit. + const decimals = Math.max(0, Math.min(Math.max(wanted, floor), dataDecimals(nums), 6), needed); + return decimals === 0 ? `${sign}${grouping}d` : `${sign}${grouping}.${decimals}~f`; +} + +/** + * Render a value approximately as d3-format would. + * + * Approximately, and deliberately: flint-js carries no runtime dependencies, + * and what the fit tests need is the *width* of the label, not the label. This + * covers the patterns Flint itself produces and the ones a house can state; + * anything else falls back to the raw rendering, which is what Vega-Lite would + * print if the pattern were dropped, and is never narrower than the truth. + */ +export function formatValueApprox(value: number, pattern: string | undefined): string { + if (!Number.isFinite(value)) return ''; + if (!pattern) return String(value); + + const match = /^([+\-( ])?(,)?(?:\.(\d+))?(~)?([a-z%])?$/i.exec(pattern) + ?? /^([+\-( ])?(?:\.(\d+))?(~)?([a-z%])?(,)?$/i.exec(pattern); + if (!match) return String(value); + const forceSign = pattern.startsWith('+'); + const group = pattern.includes(','); + const precisionText = /\.(\d+)/.exec(pattern)?.[1]; + const precision = precisionText === undefined ? undefined : Number(precisionText); + const trim = pattern.includes('~'); + const type = /([a-z%])\s*$/i.exec(pattern.replace(/,$/, ''))?.[1]; + + const negative = value < 0; + const abs = Math.abs(value); + let body: string; + let suffix = ''; + + if (type === 's') { + // SI: bring the mantissa into 1–999 and name the exponent. + const exponent = abs === 0 ? 0 : Math.floor(Math.log10(abs) / 3) * 3; + const clamped = Math.max(-24, Math.min(24, exponent)); + const mantissa = abs / 10 ** clamped; + body = mantissa.toPrecision(precision ?? 6); + if (body.includes('e')) body = String(mantissa); + suffix = SI_SUFFIX[clamped / 3 + 8] ?? ''; + } else if (type === '%') { + body = (abs * 100).toFixed(precision ?? 0); + suffix = '%'; + } else if (type === 'd') { + body = String(Math.round(abs)); + } else if (type === 'f') { + body = abs.toFixed(precision ?? 6); + } else if (type === 'e') { + body = abs.toExponential(precision ?? 6); + } else { + // No type: d3 renders the shortest form that keeps the precision, so + // the raw rendering is the honest estimate. + body = precision === undefined ? String(abs) : String(Number(abs.toPrecision(precision))); + } + + // `~` drops trailing zeros — and the point along with them. + if (trim && body.includes('.')) { + const [mantissa, exponent] = body.split(/e/i); + const trimmed = mantissa.replace(/0+$/, '').replace(/\.$/, ''); + body = exponent === undefined ? trimmed : `${trimmed}e${exponent}`; + } + + if (group) { + const dot = body.indexOf('.'); + const whole = dot === -1 ? body : body.slice(0, dot); + const rest = dot === -1 ? '' : body.slice(dot); + body = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + rest; + } + + const signText = negative ? '-' : forceSign ? '+' : ''; + return `${signText}${body}${suffix}`; +} + +/** + * The longest label this data will print, in characters. + * + * This is what the fit tests must measure: a slot has to hold the widest + * number that will land in it, not the widest number in some other notation. + */ +export function longestLabelChars(values: number[], pattern: string | undefined): number { + let longest = 1; + for (const v of values) { + if (typeof v !== 'number' || !Number.isFinite(v)) continue; + longest = Math.max(longest, formatValueApprox(v, pattern).length); + } + return longest; +} diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index 4e6db304..409f9fdd 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -5,6 +5,7 @@ import type { ZeroDecision, ColorSchemeRecommendation } from './semantic-types'; import type { LabelSizingDecision } from './decisions'; import type { SemanticAnnotation, FormatSpec, DomainConstraint, TickConstraint } from './field-semantics'; import type { ColorDecisionResult } from './color-decisions'; +import type { ThemeSpec } from './theme/types'; /** * Core types for the chart engine library. @@ -942,6 +943,13 @@ export interface ChartTemplateDef { /** Optional configurable properties for the chart type */ properties?: ChartPropertyDef[]; + /** + * This template draws its own value text instead of using the generic + * theme label layer. The public control is still `showValueLabels`; + * templates may retain older internal/input spellings for compatibility. + */ + ownsValueLabels?: boolean; + /** * Opt out of a backend's *generic* column/row facet-splitting pass, even * though the template declares `x`/`y` (so the axis-less `hasAxes` gate @@ -1061,6 +1069,19 @@ export interface ChartAssemblyInput { chart_spec: { /** Template name, e.g. `"Scatter Plot"`, `"Bar Chart"` */ chartType: string; + /** + * The headline — what this chart says, in words. + * + * Not decoration. A chart of bare numbers names nothing on its own, and + * the headline is where the measure gets named: `Male` and `75+` say + * what they are, `35 30 25` does not. Design languages that drop axis + * titles are leaning on this line to carry the subject, so a chart + * authored without one loses the naming altogether — the compiler + * notices, and puts the axis titles back. + */ + title?: string; + /** The deck: the reading of the headline — what is measured, of whom, when, in what units. */ + subtitle?: string; /** Channel → encoding map (e.g., `{ x: { field: 'weight' }, y: { field: 'mpg' } }`). * A bare string is shorthand for `{ field: }` (e.g. `{ x: 'weight' }`). */ encodings: Record; @@ -1089,6 +1110,26 @@ export interface ChartAssemblyInput { chartProperties?: Record; }; + /** + * Theme — describes *how it should look*. + * + * Either the name of a house Flint ships (`'economist'`, `'nature'`, …see + * `listThemePresets()`), a `ThemeSpec` of your own, or a `ThemeSpec` that + * `extends` a shipped house and overrides selected fields. A ThemeSpec is a + * portable design language (ink, type, structure, marks, chrome policy), + * stated without ever naming a channel, field, or backend property. The + * compiler grounds it against this chart and then realizes it in the + * target backend. + * + * Sits beside `chart_spec` rather than inside it because the same theme + * applies to every chart and the same chart accepts any theme — nesting it + * would make that independence unstatable. + * + * Currently realized by the Vega-Lite assembler only. Other assemblers + * accept the shared input field but do not apply it. + */ + theme_spec?: ThemeSpec | string; + /** * Options for the assembler — layout tuning, tooltips, etc. * All fields are optional and have sensible defaults. @@ -1204,6 +1245,20 @@ export interface AssembleOptions { * Default: 20. */ defaultBandSize?: number; + /** + * Chart-specific **floor** on the per-category band step (at a 300px + * baseline canvas), which a house's `layout.bandStep` may grow but not + * undercut. Ordinary charts have no such floor: a compact house is right + * to print thin bars. But a few chart types are only legible above a + * minimum band width regardless of house — a slopegraph draws its whole + * meaning from the angle of two columns, and a house that packs them 46px + * apart turns every slope near-vertical and leaves no room for the end + * labels. Such a template states the width its read needs here, and the + * house is held to it as a minimum while still free to spread wider. + * + * Unset for most templates (no floor). Set via paramOverrides. + */ + minBandStep?: number; /** * Maximum pixels per discrete category at a 300px baseline canvas, * scaled proportionally with canvas size (like {@link defaultBandSize}). @@ -1222,6 +1277,22 @@ export interface AssembleOptions { * Defaults to {@link defaultBandSize} (no expansion beyond the base band). */ maxBandSize?: number; + /** + * When a discrete axis is **grouped** (dodged lanes within each category + * band), by default the band's elastic stretch target is the *per-item* + * step — so a category holding N lanes is only sized as if it held one. + * For thin marks (grouped bars) that's fine: 4 lanes share a ~66px band + * comfortably. But wide marks — a box-and-whisker glyph — need real room + * per lane, and the per-item target leaves grouped boxplots compressed on + * an otherwise roomy canvas. + * + * When set, the grouped band instead targets `itemsPerGroup × step`, so + * the category band stretches to give each lane its full width (still + * bounded by `maxBandSize × itemsPerGroup` and the canvas budget). Scoped + * to templates whose grouped glyph is wide (boxplot); left off for bars so + * their tuned grouped spacing does not change. + */ + groupBandFillsLanes?: boolean; /** * Backend-native base font size (px) for axis **tick labels**, at a 300px * reference canvas. The core scales it subtly with canvas size and uses it diff --git a/packages/flint-js/src/docs/test_plan.md b/packages/flint-js/src/docs/test_plan.md deleted file mode 100644 index 6777d8df..00000000 --- a/packages/flint-js/src/docs/test_plan.md +++ /dev/null @@ -1,369 +0,0 @@ -# Chart Engine Test Plan - -## Overview - -Test data lives in `test-data/` as fixture generators (not executable test suites). -Each file exports generator functions that produce `TestCase[]` arrays. The gallery -UI (`ChartGallery.tsx`) uses `TEST_GENERATORS` and `GALLERY_SECTIONS` from -`test-data/index.ts` to render all tests interactively. - -**20 test-data files**, **~11,100 lines**, **53 named test generators**. - -### Test categories - -| Category | Files | Description | -|----------|-------|-------------| -| **VL chart matrices** | scatter-tests, line-tests, bar-tests, area-tests | Matrix-driven tests for core VL chart types | -| **Distribution charts** | distribution-tests | Histogram, Boxplot, Density, Strip Plot | -| **Specialized charts** | specialized-tests | Pie, Heatmap, Lollipop, Candlestick, Waterfall, Ranged Dot, Bump, Radar, Pyramid, Rose, Custom | -| **Semantic context** | semantic-tests | 39 tests validating semantic type → ChannelSemantics resolution | -| **ECharts backend** | echarts-tests | All ECharts chart types (reuses VL inputs + ECharts-only types) | -| **Chart.js backend** | chartjs-tests | Chart.js chart types | -| **Facets** | facet-tests | Column, row, col+row, wrap, clip, overflow faceting | -| **Stress/sizing** | stress-tests, gas-pressure-tests, line-area-stretch-tests, discrete-axis-tests | Overflow, elasticity, pressure model, discrete axis sizing | -| **Temporal** | date-tests | Year, Month, YearMonth, Decade, DateTime, Hours parsing/formatting | -| **Line/area variants** | line-area-tests | Bump Chart | - ---- - -## Scatter Plot - -A scatter plot places marks (points/bubbles) in a 2D space. The core axes are continuous (quantitative), but one or both axes can be discrete (nominal), which changes how the engine computes layout, step sizing, and overflow. - -Temporal axes are omitted from scatter tests because T behaves identically to Q in scatter layout — no special handling. Temporal is still tested as a color channel (`color: 'T'`). - -Default test canvas: 300 × 300 px. - -### Matrix-driven approach - -Tests are generated from a **declarative matrix** (`SCATTER_MATRIX` in `scatter-tests.ts`). Each row describes one test via its axis types, optional third channels, cardinality, and special flags. A generator function converts each matrix entry into a full `TestCase`. - -### Matrix dimensions - -| Dimension | Values | Notes | -|-----------|--------|-------| -| **x axis type** | Q, N | Quantitative, Nominal | -| **y axis type** | Q, N | Same | -| **color channel** | —, Q, T, N | Optional 3rd encoding | -| **size channel** | —, Q, N | Optional 4th encoding | -| **n (density)** | 10–500 | Or 0 for N×N grid mode | -| **cardinality** | xCard, yCard, colorCard, sizeCard | Cardinality of nominal dims | -| **flags** | hugeRange | Special data distributions | - -### Full test matrix (25 tests) - -#### Q × Q — 15 tests - -| # | color | size | n | flags | what it tests | -|---|-------|------|---|-------|---------------| -| 1 | — | — | 20 | | Baseline scatter | -| 2 | N(3) | — | 20 | | Nominal color groups | -| 3 | Q | — | 20 | | Continuous color gradient | -| 4 | T | — | 30 | | Temporal color gradient | -| 5 | — | Q | 50 | | Bubble chart | -| 6 | — | N(4) | 20 | | Ordinal size — 4 ranked levels | -| 7 | N(3) | Q | 15 | | Gapminder-style | -| 8 | Q | Q | 30 | | Dual continuous (4D) | -| 9 | N(20) | Q | 20 | hugeRange | Size 1K–1B, sqrt scale | -| 10 | — | — | 100 | | Moderate density | -| 11 | — | — | 500 | | High density | -| 12 | N(20) | — | 200 | | Dense, many groups | -| 13 | N(50) | — | 100 | | Legend overflow | -| 14 | — | Q | 10 | | Sparse bubbles | -| 15 | — | Q | 200 | | Dense bubbles | - -#### N × Q — 4 tests - -| # | xCard | color | size | n | what it tests | -|---|-------|-------|------|---|---------------| -| 1 | 5 | Q | — | 25 | Strip + continuous color | -| 2 | 5 | — | Q | 25 | Bubble strip | -| 3 | 2 | — | — | 30 | Binary category strip (edge) | -| 4 | 60 | — | — | 60 | 60 cats — overflow | - -#### Q × N — 3 tests (mirrors N×Q with flipped orientation) - -| # | yCard | color | size | n | what it tests | -|---|-------|-------|------|---|---------------| -| 1 | 5 | Q | — | 25 | Horizontal strip + continuous color | -| 2 | 5 | — | Q | 25 | Horizontal bubble strip | -| 3 | 60 | — | — | 60 | Horizontal 60-cat overflow | - -#### N × N — 3 tests - -| # | xCard | yCard | color | size | what it tests | -|---|-------|-------|-------|------|---------------| -| 1 | 5 | 6 | — | Q | Bubble grid | -| 2 | 5 | 4 | Q | — | Heatmap-like grid | -| 3 | 15 | 12 | — | Q | Large grid — overflow | - -### Coverage summary - -Axis combos: Q×Q, N×Q, Q×N, N×N. Third-channel variants (color and size, typed Q/T/N) crossed with Q×Q. Density from 10 to 500. Edge cases: binary categories, legend overflow, huge value ranges. - -### How to add a test - -Add one row to `SCATTER_MATRIX` in `scatter-tests.ts`: - -```typescript -{ x: 'N', y: 'Q', n: 40, xCard: 8, color: 'Q', desc: 'Strip + continuous color, moderate density' }, -``` - -The generator handles field naming, data synthesis, metadata, tags, and title automatically. - ---- - -## Line Chart - -A line chart connects data points with lines. Lines imply sequential progression, so axes use T (temporal), O (ordinal), or Q (quantitative) — never purely nominal. N (nominal) is used only for color groups. - -Channels: `x, y, color, opacity, column, row` - -### Matrix-driven approach - -Tests are generated from `LINE_MATRIX` in `line-tests.ts`. Each row specifies axis types, optional color channel, point count, and flags like `sparse` (20% dropout). - -### Full test matrix (16 tests) - -#### T × Q — 6 tests (core time series) - -| # | color | n | flags | what it tests | -|---|-------|---|-------|---------------| -| 1 | — | 30 | | Simple time series | -| 2 | N(4) | 200 | | 4 series × 50 dates | -| 3 | N(8) | 800 | | 8 series crowded | -| 4 | N(20) | 4000 | stress | 20 series spaghetti | -| 5 | N(3) | 180 | sparse | 3 series, ~20% missing | -| 6 | Q | 30 | | Continuous color gradient | - -#### O × Q — 4 tests (ordinal x) - -| # | xCard | color | n | what it tests | -|---|-------|-------|---|---------------| -| 7 | 5 | — | 5 | Ordinal line | -| 8 | 12 | N(4) | 48 | 12 ordinal × 4 series | -| 9 | 30 | — | 30 | Label overflow | -| 10 | 5 | Q | 5 | Ordinal + gradient | - -#### Q × Q — 3 tests - -| # | color | n | what it tests | -|---|-------|---|---------------| -| 11 | — | 30 | Quantitative x line | -| 12 | N(3) | 150 | 3 parametric curves | -| 13 | — | 200 | Dense single curve | - -#### Q × O — 3 tests (mirror) - -| # | yCard | color | n | what it tests | -|---|-------|-------|---|---------------| -| 14 | 5 | — | 5 | Horizontal ordinal | -| 15 | 12 | N(4) | 48 | Horizontal 12 ordinal × 4 | -| 16 | 30 | — | 30 | Horizontal 30 ordinal overflow | - -#### Excluded combos - -- **T×T, Q×T** — date-pair data (start vs end date) doesn't suit line charts. Each row is an independent event, not a sequential series; lines connect points in data order producing random zig-zags. Better served by scatter or dumbbell charts. -- **O×O** — ordinal×ordinal lines are degenerate. -- **N×N, T×N, N×T** — purely nominal axes don't suit line charts. Lines imply sequence/progression; connecting unordered categories is misleading. - -### Coverage summary - -Axis combos: T×Q, O×Q, Q×Q, Q×O. Color variants (N, Q) crossed with primary combos. Density from 5 to 4000 (stress). Sparse dropout tests irregular gaps. Total **16 tests**. - ---- - -## Bar Chart / Stacked Bar Chart / Grouped Bar Chart - -Bar charts encode values as rectangular bars. Three variants share a common matrix format in `bar-tests.ts`: -- **Bar Chart**: `x, y, color, opacity` — basic bars with optional color -- **Stacked Bar Chart**: `x, y, color` — bars stacked by color dimension -- **Grouped Bar Chart**: `x, y, group` — bars side-by-side by group dimension - -### Matrix-driven approach - -Three matrices (`BAR_MATRIX`, `STACKED_BAR_MATRIX`, `GROUPED_BAR_MATRIX`) share one generator function `barMatrixToTestCase`. The third channel key is `'color'` for bar/stacked and `'group'` for grouped. - -### Bar Chart matrix (19 tests) - -#### N × Q — 6 tests (classic vertical) - -| # | xCard | color | n | what it tests | -|---|-------|-------|---|---------------| -| 1 | 5 | — | 5 | Basic 5 bars | -| 2 | 20 | — | 20 | Label rotation | -| 3 | 30 | — | 30 | Thin bar handling | -| 4 | 100 | — | 100 | Discrete cutoff | -| 5 | 5 | N(3) | 15 | 5 cats × 3 colors | -| 6 | 5 | N(20) | 100 | Color saturation | - -#### Q × N — 3 tests (horizontal) - -| # | yCard | color | n | what it tests | -|---|-------|-------|---|---------------| -| 7 | 10 | — | 10 | Horizontal 10 bars | -| 8 | 100 | — | 100 | Horizontal cutoff | -| 9 | 10 | N(3) | 30 | Horizontal + 3 colors | - -#### T × Q — 3 tests (temporal) - -| # | color | n | what it tests | -|---|-------|---|---------------| -| 10 | — | 24 | Temporal bars | -| 11 | — | 100 | 100 dates — dynamic sizing | -| 12 | N(3) | 72 | Temporal + 3 colors | - -#### Q × T — 2 tests (horizontal temporal) - -| # | color | n | what it tests | -|---|-------|---|---------------| -| 13 | — | 18 | Horizontal temporal | -| 14 | N(3) | 54 | Horizontal temporal + color | - -#### Q × Q — 2 tests (continuous banded) - -| # | n | what it tests | -|---|---|---------------| -| 15 | 20 | Both quant — dynamic resizing | -| 16 | 30 | Equally spaced 1..30 | - -#### Edge combos — 3 tests - -| # | x | y | n | what it tests | -|---|---|---|---|---------------| -| 17 | N | N | grid | Cat × cat (degenerate) | -| 18 | T | T | 20 | Date × date (degenerate) | -| 19 | T | N | 25 | Temporal × categorical | - -### Stacked Bar Chart matrix (12 tests) - -| # | x | y | color | n | what it tests | -|---|---|---|-------|---|---------------| -| 1 | N | Q | N(3) | 12 | Basic stack 4×3 | -| 2 | N | Q | N(5) | 75 | Large 15×5 | -| 3 | N | Q | N(3) | 240 | Very large 80×3 (cutoff) | -| 4 | N | Q | Q(4) | 24 | Numeric color (1–4) | -| 5 | N | Q | Q(30) | 150 | Numeric color (1–30) | -| 6 | T | Q | N(3) | 30 | Temporal stack | -| 7 | T | Q | N(4) | 80 | 20 dates × 4 | -| 8 | Q | Q | N(3) | 30 | Both quant stacked | -| 9 | Q | N | N(3) | 24 | Horizontal stack | -| 10 | Q | T | N(3) | 45 | Horizontal temporal stack | -| 11 | N | N | N(3) | grid | Cat×cat stacked (edge) | -| 12 | T | T | N(3) | 30 | Date×date stacked (edge) | - -### Grouped Bar Chart matrix (12 tests) - -| # | x | y | group | n | what it tests | -|---|---|---|-------|---|---------------| -| 1 | N | Q | N(3) | 12 | Basic grouped 4×3 | -| 2 | N | Q | — | 8 | No group — fallback | -| 3 | N | Q | N(3) | 270 | Very large 90×3 (cutoff) | -| 4 | N | Q | Q(5) | 30 | Numeric group (1–5) | -| 5 | T | Q | N(3) | 36 | Temporal grouped | -| 6 | Q | Q | N(4) | 20 | Both quant + group | -| 7 | Q | N | N(4) | 24 | Horizontal grouped | -| 8 | Q | T | N(3) | 30 | Horizontal temporal grouped | -| 9 | N | Q | Q(50) | 400 | Numeric group (1–50) | -| 10 | N | Q | Q | 50 | Continuous float on group | -| 11 | N | N | N(3) | grid | Cat×cat grouped (edge) | -| 12 | T | T | N(3) | 30 | Date×date grouped (edge) | - -### Coverage summary - -All three bar variants cover common xy-type combinations. Bar Chart has 19 tests, Stacked Bar has 12, Grouped Bar has 12 — total **43 bar tests**. Covers horizontal/vertical orientation, discrete cutoff, numeric/continuous color, edge combos. - -## Area Chart & Streamgraph - -**File:** `area-tests.ts` -**Approach:** Matrix-driven — `AREA_MATRIX` (17 entries) + `STREAMGRAPH_MATRIX` (6 entries). -**Shared generator:** `areaMatrixToTestCase(entry, chartType, rand)` — same infrastructure for both chart types. -**Data characteristic:** Uses `genAreaTrend()` with upward drift (natural for cumulative / stacked-area metrics). - -Area charts use O (ordinal) for categorical axes (like line charts) — area fills imply continuity. N (nominal) is used only for color groups. Purely nominal axis combos are excluded. - -### Area Chart matrix (17 tests) - -#### T × Q — 7 tests (core stacked / layered area) - -| # | color | n | flags | what it tests | -|---|-------|---|-------|---------------| -| 1 | — | 30 | | Simple time-series area | -| 2 | N(4) | 96 | | 4 stacked series | -| 3 | N(8) | 480 | | 8 series large stacked | -| 4 | N(15) | 1800 | stress | 15 series stress | -| 5 | N(3) | 120 | | 3 layered/overlapping | -| 6 | N(3) | 180 | sparse | 3 series, ~20% missing | -| 7 | Q | 30 | | Continuous color gradient | - -#### O × Q — 4 tests (ordinal x) - -| # | xCard | color | n | what it tests | -|---|-------|-------|---|---------------| -| 8 | 5 | — | 5 | Ordinal area 5 cats | -| 9 | 12 | N(4) | 48 | 12 ordinal × 4 stacked | -| 10 | 30 | — | 30 | 30 ordinal overflow | -| 11 | 5 | Q | 5 | Ordinal + continuous color | - -#### Q × O — 3 tests (mirror) - -| # | yCard | color | n | what it tests | -|---|-------|-------|---|---------------| -| 12 | 5 | — | 5 | Horizontal ordinal 5 cats | -| 13 | 12 | N(4) | 48 | Horizontal 12 ordinal × 4 | -| 14 | 30 | — | 30 | Horizontal 30 ordinal overflow | - -#### Q × Q — 3 tests - -| # | color | n | what it tests | -|---|-------|---|---------------| -| 15 | — | 30 | Quantitative x area | -| 16 | N(3) | 150 | 3 stacked curves | -| 17 | — | 200 | Dense single-series | - -#### Excluded combos - -- **T×T, Q×T** — date-pair data doesn't suit area charts. Area fills imply sequential progression; T×T/Q×T lack monotonic relationships. -- **N×N, T×N, N×T** — purely nominal axes don't suit area charts. Area fills imply continuity/progression; nominal axes lack this. - -### Streamgraph matrix (6 tests) - -| # | x | y | color | n | what it tests | -|---|---|---|-------|---|---------------| -| 1 | T | Q | N(5) | 200 | 5 genres basic streamgraph | -| 2 | T | Q | N(10) | 800 | 10 industries large | -| 3 | T | Q | N(20) | 3000 | 20 series stress | -| 4 | T | Q | N(5) | 200 | 5 series ~20% sparse | -| 5 | O | Q | N(5) | 60 | Ordinal streamgraph | -| 6 | Q | Q | N(3) | 150 | Quant-x streamgraph | - -### Coverage summary - -Area Chart covers T×Q, O×Q, Q×O, Q×Q axis combos (17 tests). Streamgraph adds 6 tests exercising T×Q, O×Q, and Q×Q with multi-series color. Total **23 area/streamgraph tests**. - ---- - -## Grand total (matrix-driven chart tests) - -| Chart type | Tests | -|------------|-------| -| Scatter | 25 | -| Line | 16 | -| Bar | 19 | -| Stacked Bar | 12 | -| Grouped Bar | 12 | -| Area | 17 | -| Streamgraph | 6 | -| **Matrix subtotal** | **107** | - -Plus additional non-matrix test generators: -- Distribution charts (Histogram, Boxplot, Density, Strip) -- Specialized charts (Pie, Heatmap, Lollipop, Candlestick, Waterfall, etc.) -- Semantic context (39 tests) -- Facets (9 generators) -- Stress/sizing (4 generators) -- Temporal (7 generators) -- ECharts backend (24 generators) -- Chart.js backend (11 generators) - -**43 named test generators** total across all categories. diff --git a/packages/flint-js/src/echarts/assemble.ts b/packages/flint-js/src/echarts/assemble.ts index 638c081c..64e36bc4 100644 --- a/packages/flint-js/src/echarts/assemble.ts +++ b/packages/flint-js/src/echarts/assemble.ts @@ -94,6 +94,24 @@ import { normalizeChartProperties } from '../core/normalize-properties'; * * @returns An ECharts option object with optional `_warnings` and `_width`/`_height` hints */ +function applyFieldDisplayNames(option: any, names: Record | undefined): void { + if (!names) return; + const displayName = (value: unknown) => typeof value === 'string' ? names[value] ?? value : value; + for (const axisKey of ['xAxis', 'yAxis', 'singleAxis']) { + const axes = Array.isArray(option[axisKey]) ? option[axisKey] : [option[axisKey]]; + for (const axis of axes) { + if (axis?.name) axis.name = displayName(axis.name); + } + } + for (const series of option.series ?? []) { + if (series?.name) series.name = displayName(series.name); + } + const graphics = Array.isArray(option.graphic) ? option.graphic : option.graphic ? [option.graphic] : []; + for (const graphic of graphics) { + if (graphic?.style?.text) graphic.style.text = displayName(graphic.style.text); + } +} + export function assembleECharts(input: ChartAssemblyInput): any { const chartType = input.chart_spec.chartType; const semanticTypes = input.semantic_types ?? {}; @@ -534,6 +552,8 @@ export function assembleECharts(input: ChartAssemblyInput): any { // Clean internal-only props delete ecOption._legendWidth; + applyFieldDisplayNames(ecOption, input.field_display_names); + return ecOption; } diff --git a/packages/flint-js/src/echarts/instantiate-spec.ts b/packages/flint-js/src/echarts/instantiate-spec.ts index 3d588e63..b4474d4c 100644 --- a/packages/flint-js/src/echarts/instantiate-spec.ts +++ b/packages/flint-js/src/echarts/instantiate-spec.ts @@ -257,6 +257,15 @@ function pyramidNiceTickStep(niceMax: number): number { return niceMax / 4; } +/** + * Overlay companions: the scatter series a boxplot draws alongside itself for + * its outliers or for the full raw sample. They are separate series only + * because ECharts needs a different series type to draw them, so they must + * take the colour of the box they sit on rather than the next palette slot. + */ +const COMPANION_SUFFIX = / \((?:outliers|points)\)$/; +const isCompanionSeries = (s: any): boolean => s?._companion === true; + /** * Grouped boxplot needs enough per-category horizontal room; otherwise boxes overlap. * Return a conservative minimum plot width for category x-axis grouped boxplots. @@ -300,7 +309,7 @@ function placePyramidChannelHeaders(option: any): void { const gw = Math.max(0, cw - gl - gr); const centerX = gl + gw / 2; const dx = gw / 4; - const topY = Math.max(4, gt - 10); + const topY = Math.max(4, gt - 24); const L = estimatePyramidYCategoryInsetPx(option, gw); const innerW = Math.max(gw - L, 1); @@ -1008,8 +1017,7 @@ export function ecApplyLayoutToSpec( // 当存在调色板时,覆盖模板中的硬编码 itemStyle.color, // 让最终颜色真正由 colorDecisions / colormap 注册表驱动。 - if (effectivePalette && effectivePalette.length > 0 && Array.isArray(option.series)) { - const palette_ = effectivePalette; // local const so TS narrows inside closures + if (effectivePalette && effectivePalette.length > 0 && Array.isArray(option.series)) { const palette_ = effectivePalette; // local const so TS narrows inside closures const n = palette_.length; const schemeType = colorDecision?.schemeType; @@ -1082,13 +1090,13 @@ export function ecApplyLayoutToSpec( option.series.forEach((s: any, idx: number) => { if (!s) return; const rawName: string = typeof s.name === 'string' ? s.name : (s.name != null ? String(s.name) : ''); - const baseName = rawName.endsWith(' (outliers)') - ? rawName.slice(0, -' (outliers)'.length) - : rawName; + const baseName = rawName.replace(COMPANION_SUFFIX, ''); const mappedColor = baseName && categoryToColor.has(baseName) ? categoryToColor.get(baseName) : palette_[idx % n]; s.itemStyle = s.itemStyle || {}; + // A hollow boxplot (raw sample overlaid) declares a per-datum + // transparent fill; the palette still owns its outline. s.itemStyle.color = mappedColor!; if (s.type === 'boxplot') { s.itemStyle.borderColor = mappedColor!; @@ -1098,7 +1106,7 @@ export function ecApplyLayoutToSpec( // ECharts default palette: first = blue (#5470c6), fourth = red (#ee6666) — not adjacent greens. const pal = effectivePalette && effectivePalette.length > 0 ? effectivePalette : DEFAULT_COLORS; const cLeft = pal[0]; - const cRight = pal.length > 3 ? pal[3] : pal[Math.min(1, pal.length - 1)]; + const cRight = pal.length > 3 ? pal[3] : pal[Math.max(0, pal.length - 1)]; let barIdx = 0; for (const s of option.series) { if (!s || s.type !== 'bar') continue; @@ -1225,7 +1233,9 @@ export function ecApplyLayoutToSpec( : new Map(); // 只对「需要上色」的 series 从 palette 取色,已设 color 的(如连接线、参考线)不占下标,使 Min/Max 等得到第 1、2 个颜色 - const colorableCount = option.series.filter((s: any) => s && s.itemStyle?.color == null).length; + const colorableCount = option.series.filter( + (s: any) => s && s.itemStyle?.color == null && !isCompanionSeries(s), + ).length; const spacedIndices = useEvenSpacing && colorableCount > 0 ? pickEvenlySpacedColorIndices(n, colorableCount) : null; @@ -1236,6 +1246,17 @@ export function ecApplyLayoutToSpec( s.itemStyle = s.itemStyle || {}; if (s.itemStyle.color != null) return; + // An overlay companion (outliers / the raw sample) belongs to + // the series it sits on, so it inherits that colour instead + // of consuming a palette slot and landing a different hue. + if (isCompanionSeries(s)) { + const owner = option.series[idx - 1]; + if (owner?.itemStyle?.color != null) { + s.itemStyle.color = owner.itemStyle.color; + return; + } + } + // Rank / Index 颜色映射:根据 rank 数值在连续色带上取色 if (isRankLikeColor && rankLegendColorMap.size > 0) { const rawName: string = typeof s.name === 'string' diff --git a/packages/flint-js/src/echarts/templates/boxplot.ts b/packages/flint-js/src/echarts/templates/boxplot.ts index 91e3a438..3a2cee21 100644 --- a/packages/flint-js/src/echarts/templates/boxplot.ts +++ b/packages/flint-js/src/echarts/templates/boxplot.ts @@ -79,19 +79,44 @@ function findOutliers(values: number[]): number[] { return values.filter(v => v < lo || v > hi); } -function boxplotLaneOffset(bandWidth: number, laneCount: number, laneIndex: number): number { +/** Lane centre offset and box width, in pixels, within one category band. */ +function boxplotLaneGeometry(bandWidth: number, laneCount: number, laneIndex: number) { const availableWidth = bandWidth * 0.8 - 2; const boxGap = availableWidth / laneCount * 0.3; const boxWidth = (availableWidth - boxGap * (laneCount - 1)) / laneCount; - return boxWidth / 2 - availableWidth / 2 + laneIndex * (boxGap + boxWidth); + return { + offset: boxWidth / 2 - availableWidth / 2 + laneIndex * (boxGap + boxWidth), + boxWidth, + }; +} + +// Half-width of the raw-observation jitter cloud, as a fraction of one box. +const POINT_JITTER_FRACTION = 0.35; + +/** + * Deterministic jitter in [-1, 1]. A golden-ratio sequence spreads successive + * points evenly instead of clumping the way independent random draws do, and + * being deterministic keeps a re-render pixel-identical. + */ +function jitterAt(index: number): number { + return ((index * 0.6180339887498949) % 1) * 2 - 1; } -function makeOutlierSeries( +/** + * Scatter overlay drawn on top of the boxes — either just the outliers, or + * every raw observation when `showPoints` is on. + * + * ECharts has no per-point band offset, so this is a `custom` series that + * resolves the lane (and the jitter carried as the datum's third element) + * against the live band width at render time. + */ +function makePointSeries( name: string, data: any[], laneIndex: number, laneCount: number, horizontal: boolean, + fullSample = false, ): any { return { name, @@ -100,23 +125,35 @@ function makeOutlierSeries( data, encode: { tooltip: [0, 1] }, z: 3, + // Marks this as an overlay belonging to the boxplot series before it, so + // palette assignment gives it that box's colour instead of a new slot. + _companion: true, renderItem: (_params: any, api: any) => { const category = Number(api.value(0)); const value = Number(api.value(1)); + const jitter = Number(api.value(2)) || 0; const point = horizontal ? api.coord([value, category]) : api.coord([category, value]); const size = api.size(horizontal ? [0, 1] : [1, 0]); const bandWidth = Math.abs(horizontal ? size[1] : size[0]); - const offset = boxplotLaneOffset(bandWidth, laneCount, laneIndex); + const lane = boxplotLaneGeometry(bandWidth, laneCount, laneIndex); + const offset = lane.offset + jitter * lane.boxWidth * POINT_JITTER_FRACTION; + const color = api.visual('color'); return { type: 'circle', shape: { cx: point[0] + (horizontal ? 0 : offset), cy: point[1] + (horizontal ? offset : 0), - r: 2, + r: fullSample ? Math.max(1.6, Math.min(3.2, lane.boxWidth * 0.08)) : 2, }, - style: { fill: api.visual('color') }, + // With the whole sample drawn the box goes hollow and the points + // become the mark that spends saturated ink, so they carry the + // group colour. Slight transparency lets dense regions read as + // density; the hairline halo keeps overlaps countable. + style: fullSample + ? { fill: color, opacity: 0.7, stroke: '#ffffff', lineWidth: 0.5 } + : { fill: color }, }; }, }; @@ -187,8 +224,11 @@ export const ecBoxplotDef: ChartTemplateDef = { // whiskers, outliers are drawn as a scatter overlay unless suppressed. const whiskerMethod: 'iqr' | 'minmax' = ctx.chartProperties?.whiskerMethod === 'minmax' ? 'minmax' : 'iqr'; + // `showPoints` overlays every raw observation instead — which makes the + // separate outlier marks redundant, since they are drawn too. + const showPoints = ctx.chartProperties?.showPoints === true; const showOutliers = - whiskerMethod === 'iqr' && ctx.chartProperties?.showOutliers !== false; + !showPoints && whiskerMethod === 'iqr' && ctx.chartProperties?.showOutliers !== false; // Determine which axis is categorical and which is quantitative const xIsDiscrete = isDiscrete(xCS.type); @@ -274,7 +314,7 @@ export const ecBoxplotDef: ChartTemplateDef = { const catGroups = groupBy(table, catField); for (let lane = 0; lane < maxPerBand; lane++) { const boxData: ({ value: [number, number, number, number, number]; itemStyle: any } | '-')[] = []; - const outlierData: any[] = []; + const pointData: any[] = []; for (let i = 0; i < categories.length; i++) { const cat = categories[i]; const g = perBand.get(cat)?.[lane]; @@ -283,15 +323,24 @@ export const ecBoxplotDef: ChartTemplateDef = { const values = rows.map((r: any) => Number(r[valField])).filter((v: number) => isFinite(v)); if (!values.length) { boxData.push('-'); continue; } const c = colorFor(g); - boxData.push({ value: fiveNumberSummary(values, whiskerMethod), itemStyle: { color: c, borderColor: c } }); - if (showOutliers) { - for (const o of findOutliers(values)) outlierData.push({ value: [i, o], itemStyle: { color: c } }); + boxData.push({ + value: fiveNumberSummary(values, whiskerMethod), + // Hollow box when the sample is drawn (see makePointSeries). + itemStyle: { color: showPoints ? 'transparent' : c, borderColor: c }, + }); + if (showPoints) { + values.forEach((v, k) => pointData.push([i, v, jitterAt(k)])); + } else if (showOutliers) { + for (const o of findOutliers(values)) pointData.push({ value: [i, o], itemStyle: { color: c } }); } } - option.series.push({ name: `__lane${lane}`, type: 'boxplot', data: boxData }); - if (outlierData.length > 0) { - option.series.push(makeOutlierSeries( - `__lane${lane} (outliers)`, outlierData, lane, maxPerBand, isHorizontal, + option.series.push({ + name: `__lane${lane}`, type: 'boxplot', data: boxData, + ...(showPoints ? { itemStyle: { borderWidth: 1.5 } } : {}), + }); + if (pointData.length > 0) { + option.series.push(makePointSeries( + `__lane${lane} (points)`, pointData, lane, maxPerBand, isHorizontal, showPoints, )); } } @@ -305,8 +354,8 @@ export const ecBoxplotDef: ChartTemplateDef = { for (let cIdx = 0; cIdx < colorCategories.length; cIdx++) { const colorName = colorCategories[cIdx]; - const boxData: ([number, number, number, number, number] | '-')[] = []; - const outlierData: [number, number][] = []; + const boxData: any[] = []; + const pointData: number[][] = []; for (let i = 0; i < categories.length; i++) { const cat = categories[i]; @@ -319,11 +368,21 @@ export const ecBoxplotDef: ChartTemplateDef = { // flat box at 0 in every unoccupied lane (the sparse-dodge // zero-box bug). ECharts boxplot does not accept `null` // data items; `'-'` is its missing-value sentinel. - boxData.push(values.length ? fiveNumberSummary(values, whiskerMethod) : '-'); + // A per-datum transparent fill hollows the box when the raw + // sample is drawn; the palette still owns the outline, which + // `instantiate-spec` assigns at series level. + boxData.push( + !values.length ? '-' + : showPoints + ? { value: fiveNumberSummary(values, whiskerMethod), itemStyle: { color: 'transparent' } } + : fiveNumberSummary(values, whiskerMethod), + ); - if (showOutliers) { + if (showPoints) { + values.forEach((v, k) => pointData.push([i, v, jitterAt(k)])); + } else if (showOutliers) { for (const o of findOutliers(values)) { - outlierData.push([i, o]); + pointData.push([i, o]); } } } @@ -332,11 +391,12 @@ export const ecBoxplotDef: ChartTemplateDef = { name: colorName, type: 'boxplot', data: boxData, + ...(showPoints ? { itemStyle: { borderWidth: 1.5 } } : {}), // itemStyle 由 ecApplyLayoutToSpec 按 colorDecisions 填充 }); - if (outlierData.length > 0) { - option.series.push(makeOutlierSeries( - colorName + ' (outliers)', outlierData, cIdx, colorCategories.length, isHorizontal, + if (pointData.length > 0) { + option.series.push(makePointSeries( + colorName + ' (points)', pointData, cIdx, colorCategories.length, isHorizontal, showPoints, )); } } @@ -346,18 +406,21 @@ export const ecBoxplotDef: ChartTemplateDef = { } else { // Single boxplot series (no color grouping) const catGroups = groupBy(table, catField); - const boxData: [number, number, number, number, number][] = []; - const outlierData: [number, number][] = []; + const boxData: any[] = []; + const pointData: number[][] = []; for (let i = 0; i < categories.length; i++) { const cat = categories[i]; const rows = catGroups.get(cat) || []; const values = rows.map((r: any) => Number(r[valField])).filter((v: number) => isFinite(v)); - boxData.push(fiveNumberSummary(values, whiskerMethod)); + const summary = fiveNumberSummary(values, whiskerMethod); + boxData.push(showPoints ? { value: summary, itemStyle: { color: 'transparent' } } : summary); - if (showOutliers) { + if (showPoints) { + values.forEach((v, k) => pointData.push([i, v, jitterAt(k)])); + } else if (showOutliers) { for (const o of findOutliers(values)) { - outlierData.push([i, o]); + pointData.push([i, o]); } } } @@ -365,10 +428,11 @@ export const ecBoxplotDef: ChartTemplateDef = { option.series.push({ type: 'boxplot', data: boxData, + ...(showPoints ? { itemStyle: { borderWidth: 1.5 } } : {}), // 单系列颜色由 ecApplyLayoutToSpec 使用 cat10[0] 等统一默认 }); - if (outlierData.length > 0) { - option.series.push(makeOutlierSeries('Outliers', outlierData, 0, 1, isHorizontal)); + if (pointData.length > 0) { + option.series.push(makePointSeries('Points', pointData, 0, 1, isHorizontal, showPoints)); } } @@ -385,9 +449,24 @@ export const ecBoxplotDef: ChartTemplateDef = { ], defaultValue: 'iqr', } as ChartPropertyDef, + { + key: 'showPoints', label: 'Points', type: 'binary', defaultValue: false, + // Jitter needs a band to scatter within, so this is only meaningful + // once one position axis is discrete. + check: (ctx) => ({ + applicable: isDiscrete(ctx.channelSemantics?.x?.type) + || isDiscrete(ctx.channelSemantics?.y?.type), + }), + } as ChartPropertyDef, { key: 'showOutliers', label: 'Outliers', type: 'binary', defaultValue: true, - check: (ctx) => ({ applicable: ctx.chartProperties?.whiskerMethod !== 'minmax' }), + // Outliers exist only with Tukey whiskers; min–max whiskers absorb + // every point. And once every observation is drawn, the outlier + // marks are just duplicates. + check: (ctx) => ({ + applicable: ctx.chartProperties?.whiskerMethod !== 'minmax' + && ctx.chartProperties?.showPoints !== true, + }), } as ChartPropertyDef, { key: 'dodge', label: 'Dodge', type: 'discrete', diff --git a/packages/flint-js/src/excel/artifact.ts b/packages/flint-js/src/excel/artifact.ts index 728cf0ed..cb6ed958 100644 --- a/packages/flint-js/src/excel/artifact.ts +++ b/packages/flint-js/src/excel/artifact.ts @@ -95,7 +95,7 @@ export function prepareExcelArtifact(value: unknown): PreparedExcelArtifact { rangeA1: `A1:${excelColumnLetter(columns - 1)}${rows}`, chartType, numericAxis: /XYScatter|Bubble/i.test(chartType), - dateAxis: /Stock/i.test(chartType), + dateAxis: /Stock/i.test(chartType) || spec.categoryAxis?.categoryType === 'DateAxis', hasAxes: !/Pie|Doughnut|Treemap|Sunburst|Funnel/i.test(chartType), isBar: /Bar|Column/i.test(chartType), isLine: /Line/i.test(chartType), diff --git a/packages/flint-js/src/excel/assemble.ts b/packages/flint-js/src/excel/assemble.ts index de1b538d..0b26b049 100644 --- a/packages/flint-js/src/excel/assemble.ts +++ b/packages/flint-js/src/excel/assemble.ts @@ -38,6 +38,7 @@ import type { ExcelNativeSeriesSpec, ExcelSeriesBy, } from './types'; +import { excelDateAxis, excelDateSerial } from './date-axis'; type Cell = string | number | null; @@ -84,11 +85,6 @@ function focusedNumericAxis(values: number[]): Partial labelBudget ? Math.ceil(categoryCount / labelBudget) : undefined; -} - /** Normalize shorthand (`"x": "field"`) to `{ field }`. */ function normalizeEncodings( raw: Record, @@ -101,6 +97,22 @@ function normalizeEncodings( return out; } +function applyFieldDisplayNames( + spec: ExcelChartSpec, + fieldDisplayNames: Record | undefined, +): ExcelChartSpec { + if (!fieldDisplayNames) return spec; + const displayName = (value: string | number | null) => + typeof value === 'string' ? fieldDisplayNames[value] ?? value : value; + if (spec.categoryAxis?.title) spec.categoryAxis.title = String(displayName(spec.categoryAxis.title)); + if (spec.valueAxis?.title) spec.valueAxis.title = String(displayName(spec.valueAxis.title)); + if (spec.data.length > 0) spec.data[0] = spec.data[0].map(displayName); + if (spec.series) { + for (const series of spec.series) series.name = String(displayName(series.name)); + } + return spec; +} + /** Distinct values of a field, first-seen order. */ function distinct(rows: any[], field: string): Cell[] { const seen = new Set(); @@ -204,7 +216,10 @@ export function assembleExcel(input: ChartAssemblyInput): ExcelChartSpec { } if (chartTemplate.instantiate) { - return chartTemplate.instantiate(templateContext); + return applyFieldDisplayNames( + chartTemplate.instantiate(templateContext), + input.field_display_names, + ); } if (flintType === 'Bar Chart' || flintType === 'Grouped Bar Chart' || flintType === 'Stacked Bar Chart') { @@ -486,7 +501,9 @@ export function assembleExcel(input: ChartAssemblyInput): ExcelChartSpec { data.push([catField, ...seriesDescriptors.map((descriptor) => descriptor.label)]); for (let categoryIndex = 0; categoryIndex < categories.length; categoryIndex += 1) { data.push([ - String(categories[categoryIndex]), + typeOf(catCh!) === 'temporal' + ? excelDateSerial(categories[categoryIndex]) + : String(categories[categoryIndex]), ...seriesValues.map((values) => values[categoryIndex]), ]); } @@ -544,11 +561,11 @@ export function assembleExcel(input: ChartAssemblyInput): ExcelChartSpec { : {}; spec.categoryAxis = { title: catField, + ...(orientation === 'vertical' && typeOf(catCh!) === 'temporal' + ? excelDateAxis(convertedData.map((row) => row[catField]), base.width) + : {}), labelFontSize: orientation === 'horizontal' && data.length > 25 - ? Math.max(5, Math.min(10, ((base.height - 80) / (data.length - 1)) * 0.72)) - : undefined, - tickLabelSpacing: orientation === 'vertical' && typeOf(catCh!) === 'temporal' - ? temporalLabelSpacing(data.length - 1, base.width) + ? Math.max(8, Math.min(13, ((base.height - 80) / (data.length - 1)) * 0.9)) : undefined, reversePlotOrder: orientation === 'horizontal' && typeOf(catCh!) !== 'temporal', ...numericXScale, @@ -572,5 +589,5 @@ export function assembleExcel(input: ChartAssemblyInput): ExcelChartSpec { spec.overlap = 0; } - return spec; + return applyFieldDisplayNames(spec, input.field_display_names); } diff --git a/packages/flint-js/src/excel/codegen.ts b/packages/flint-js/src/excel/codegen.ts index 099f549c..0b8e0ec4 100644 --- a/packages/flint-js/src/excel/codegen.ts +++ b/packages/flint-js/src/excel/codegen.ts @@ -1,5 +1,12 @@ import { prepareExcelArtifact } from './artifact'; import type { ExcelAxisSpec, ExcelNativeChartSpec } from './types'; +import { + EXCEL_AXIS_TITLE_FONT_SIZE, + EXCEL_CHART_TITLE_FONT_SIZE, + EXCEL_DATA_LABEL_FONT_SIZE, + EXCEL_LABEL_FONT_SIZE, + EXCEL_LEGEND_FONT_SIZE, +} from './typography'; export interface OfficeJsCodegenOptions { scale?: number; @@ -21,12 +28,16 @@ export interface GeneratedOfficeJs { function axisCode(axisName: 'categoryAxis' | 'valueAxis', axis: ExcelAxisSpec): string[] { const target = `chart.axes.${axisName}`; const lines: string[] = []; + if (axis.categoryType) lines.push(` ${target}.categoryType = ${JSON.stringify(axis.categoryType)};`); + if (axis.baseTimeUnit) lines.push(` ${target}.baseTimeUnit = ${JSON.stringify(axis.baseTimeUnit)};`); + if (axis.majorTimeUnitScale) lines.push(` ${target}.majorTimeUnitScale = ${JSON.stringify(axis.majorTimeUnitScale)};`); if (axis.title) { lines.push(` ${target}.title.text = ${JSON.stringify(axis.title)};`); lines.push(` ${target}.title.visible = true;`); + lines.push(` ${target}.title.format.font.size = ${EXCEL_AXIS_TITLE_FONT_SIZE};`); } if (axis.numberFormat) lines.push(` ${target}.numberFormat = ${JSON.stringify(axis.numberFormat)};`); - if (axis.labelFontSize !== undefined) lines.push(` ${target}.format.font.size = ${axis.labelFontSize};`); + lines.push(` ${target}.format.font.size = ${axis.labelFontSize ?? EXCEL_LABEL_FONT_SIZE};`); if (axis.tickLabelSpacing !== undefined) lines.push(` ${target}.tickLabelSpacing = ${axis.tickLabelSpacing};`); if (axis.reversePlotOrder !== undefined) lines.push(` ${target}.reversePlotOrder = ${axis.reversePlotOrder};`); if (axis.minimumScale !== undefined) lines.push(` ${target}.minimum = ${axis.minimumScale};`); @@ -79,42 +90,47 @@ export function generateOfficeJs(value: unknown, options: OfficeJsCodegenOptions ); if (spec.series?.length) { - lines.push(" chart.series.load('items');", ' await context.sync();'); - lines.push(` if (chart.series.items.length < ${spec.series.length}) throw new Error('Excel inferred too few series.');`); + lines.push( + " chart.series.load('items');", + ' await context.sync();', + ' for (let index = chart.series.items.length - 1; index >= 0; index -= 1) {', + ' chart.series.getItemAt(index).delete();', + ' }', + ' await context.sync();', + ); spec.series.forEach((binding, index) => { lines.push( - ` chart.series.items[${index}].name = ${JSON.stringify(binding.name)};`, - ` chart.series.items[${index}].setXAxisValues(sheet.getRangeByIndexes(1, ${binding.xColumn}, ${binding.rowCount}, 1));`, - ` chart.series.items[${index}].setValues(sheet.getRangeByIndexes(1, ${binding.yColumn}, ${binding.rowCount}, 1));`, + ` const boundSeries${index} = chart.series.add(${JSON.stringify(binding.name)}, ${index});`, + ` boundSeries${index}.setXAxisValues(sheet.getRangeByIndexes(1, ${binding.xColumn}, ${binding.rowCount}, 1));`, + ` boundSeries${index}.setValues(sheet.getRangeByIndexes(1, ${binding.yColumn}, ${binding.rowCount}, 1));`, ); if (binding.bubbleSizeColumn !== undefined) { - lines.push(` chart.series.items[${index}].setBubbleSizes(sheet.getRangeByIndexes(1, ${binding.bubbleSizeColumn}, ${binding.rowCount}, 1));`); + lines.push(` boundSeries${index}.setBubbleSizes(sheet.getRangeByIndexes(1, ${binding.bubbleSizeColumn}, ${binding.rowCount}, 1));`); } }); - lines.push( - ` for (let index = chart.series.items.length - 1; index >= ${spec.series.length}; index -= 1) {`, - ' chart.series.getItemAt(index).delete();', - ' await context.sync();', - ' }', - ); } lines.push(` chart.width = ${width};`, ` chart.height = ${height};`); if (spec.title) { - lines.push(` chart.title.text = ${JSON.stringify(spec.title)};`, ' chart.title.visible = true;'); + lines.push( + ` chart.title.text = ${JSON.stringify(spec.title)};`, + ' chart.title.visible = true;', + ` chart.title.format.font.size = ${EXCEL_CHART_TITLE_FONT_SIZE};`, + ); } if (spec.legend) { lines.push(` chart.legend.visible = ${spec.legend.visible};`); if (spec.legend.visible && spec.legend.position) { lines.push(` chart.legend.position = ${JSON.stringify(spec.legend.position)};`); } + if (spec.legend.visible) lines.push(` chart.legend.format.font.size = ${EXCEL_LEGEND_FONT_SIZE};`); } if (spec.dataLabels) { lines.push(` chart.dataLabels.visible = ${spec.dataLabels.visible};`); if (spec.dataLabels.position) lines.push(` chart.dataLabels.position = ${JSON.stringify(spec.dataLabels.position)};`); if (spec.dataLabels.numberFormat) lines.push(` chart.dataLabels.numberFormat = ${JSON.stringify(spec.dataLabels.numberFormat)};`); if (spec.dataLabels.fontColor) lines.push(` chart.dataLabels.format.font.color = ${JSON.stringify(spec.dataLabels.fontColor)};`); - if (spec.dataLabels.fontSize !== undefined) lines.push(` chart.dataLabels.format.font.size = ${spec.dataLabels.fontSize};`); + lines.push(` chart.dataLabels.format.font.size = ${spec.dataLabels.fontSize ?? EXCEL_DATA_LABEL_FONT_SIZE};`); } if (prepared.hasAxes) { if (spec.categoryAxis) lines.push(...axisCode('categoryAxis', spec.categoryAxis)); diff --git a/packages/flint-js/src/excel/date-axis.ts b/packages/flint-js/src/excel/date-axis.ts new file mode 100644 index 00000000..3b4d3336 --- /dev/null +++ b/packages/flint-js/src/excel/date-axis.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ExcelAxisSpec } from './types'; + +const EXCEL_EPOCH = Date.UTC(1899, 11, 30); +const DAY_MILLISECONDS = 24 * 60 * 60 * 1000; + +export function excelDateSerial(value: unknown): number { + return (new Date(value as string | number | Date).getTime() - EXCEL_EPOCH) / DAY_MILLISECONDS; +} + +function niceInterval(value: number): number { + const power = 10 ** Math.floor(Math.log10(value)); + const fraction = value / power; + return (fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10) * power; +} + +export function excelDateAxis(values: unknown[], width: number): Pick< + ExcelAxisSpec, + 'categoryType' | 'baseTimeUnit' | 'majorUnit' | 'majorTimeUnitScale' | 'numberFormat' +> { + const axis: ExcelAxisSpec = { + categoryType: 'DateAxis', + baseTimeUnit: 'Days', + numberFormat: 'yyyy-mm-dd', + }; + const labelBudget = Math.max(2, Math.floor((width - 90) / 75)); + if (values.length <= labelBudget) return axis; + + const times = values + .map((value) => new Date(value as string | number | Date).getTime()) + .filter(Number.isFinite); + if (times.length < 2) return axis; + const roughDays = Math.max(1, Math.max(...times) - Math.min(...times)) / DAY_MILLISECONDS / labelBudget; + if (roughDays >= 365) { + return { ...axis, majorUnit: niceInterval(roughDays / 365), majorTimeUnitScale: 'Years' }; + } + if (roughDays >= 28) { + return { ...axis, majorUnit: niceInterval(roughDays / (365 / 12)), majorTimeUnitScale: 'Months' }; + } + return { ...axis, majorUnit: niceInterval(roughDays), majorTimeUnitScale: 'Days' }; +} diff --git a/packages/flint-js/src/excel/runtime.ts b/packages/flint-js/src/excel/runtime.ts index 82603ca2..6b7e4ed2 100644 --- a/packages/flint-js/src/excel/runtime.ts +++ b/packages/flint-js/src/excel/runtime.ts @@ -1,4 +1,11 @@ import { prepareExcelArtifact, type PreparedExcelArtifact } from './artifact'; +import { + EXCEL_AXIS_TITLE_FONT_SIZE, + EXCEL_CHART_TITLE_FONT_SIZE, + EXCEL_DATA_LABEL_FONT_SIZE, + EXCEL_LABEL_FONT_SIZE, + EXCEL_LEGEND_FONT_SIZE, +} from './typography'; export interface OfficeJsExcelApi { run(callback: (context: any) => Promise): Promise; @@ -16,11 +23,12 @@ export interface ExcelRenderResult { inspection: unknown | null; } -function applyChartFormat(chart: any, prepared: PreparedExcelArtifact): void { +function applyChartFormat(chart: any, prepared: PreparedExcelArtifact, nativeSeriesCount: number): void { const { spec } = prepared; if (spec.legend) { chart.legend.visible = spec.legend.visible; if (spec.legend.visible && spec.legend.position) chart.legend.position = spec.legend.position; + if (spec.legend.visible) chart.legend.format.font.size = EXCEL_LEGEND_FONT_SIZE; } if (prepared.hasAxes) { const axes = [ @@ -29,12 +37,16 @@ function applyChartFormat(chart: any, prepared: PreparedExcelArtifact): void { ] as const; for (const [axis, format] of axes) { if (!format) continue; + if (format.categoryType) axis.categoryType = format.categoryType; + if (format.baseTimeUnit) axis.baseTimeUnit = format.baseTimeUnit; + if (format.majorTimeUnitScale) axis.majorTimeUnitScale = format.majorTimeUnitScale; if (format.title) { axis.title.text = format.title; axis.title.visible = true; + axis.title.format.font.size = EXCEL_AXIS_TITLE_FONT_SIZE; } if (format.numberFormat) axis.numberFormat = format.numberFormat; - if (format.labelFontSize !== undefined) axis.format.font.size = format.labelFontSize; + axis.format.font.size = format.labelFontSize ?? EXCEL_LABEL_FONT_SIZE; if (format.tickLabelSpacing !== undefined) axis.tickLabelSpacing = format.tickLabelSpacing; if (format.reversePlotOrder !== undefined) axis.reversePlotOrder = format.reversePlotOrder; if (format.minimumScale !== undefined) axis.minimum = format.minimumScale; @@ -47,9 +59,9 @@ function applyChartFormat(chart: any, prepared: PreparedExcelArtifact): void { if (spec.dataLabels.position) chart.dataLabels.position = spec.dataLabels.position; if (spec.dataLabels.numberFormat) chart.dataLabels.numberFormat = spec.dataLabels.numberFormat; if (spec.dataLabels.fontColor) chart.dataLabels.format.font.color = spec.dataLabels.fontColor; - if (spec.dataLabels.fontSize !== undefined) chart.dataLabels.format.font.size = spec.dataLabels.fontSize; + chart.dataLabels.format.font.size = spec.dataLabels.fontSize ?? EXCEL_DATA_LABEL_FONT_SIZE; } - for (let index = 0; index < prepared.seriesCount; index += 1) { + for (let index = 0; index < Math.min(prepared.seriesCount, nativeSeriesCount); index += 1) { const series = chart.series.getItemAt(index); const format = spec.seriesFormats?.[index]; if (format?.color) { @@ -77,15 +89,14 @@ async function inspectChart(context: any, sheet: any, dataRange: any, chart: any for (const series of chart.series.items) { series.binOptions.load('type,count,width,allowOverflow,overflowValue,allowUnderflow,underflowValue'); } - const primaryCategoryAxis = chart.axes.getItemOrNullObject('Category', 'Primary'); - const primaryValueAxis = chart.axes.getItemOrNullObject('Value', 'Primary'); - const secondaryValueAxis = chart.axes.getItemOrNullObject('Value', 'Secondary'); - for (const axis of [primaryCategoryAxis, primaryValueAxis, secondaryValueAxis]) { - axis.load('isNullObject,axisType,axisGroup,visible,minimum,maximum,numberFormat'); + const primaryCategoryAxis = chart.axes.categoryAxis; + const primaryValueAxis = chart.axes.valueAxis; + for (const axis of [primaryCategoryAxis, primaryValueAxis]) { + axis.load('axisType,axisGroup,visible,minimum,maximum,numberFormat'); axis.title.load('text,visible'); } await context.sync(); - const describeAxis = (axis: any) => axis.isNullObject ? null : { ...axis.toJSON(), title: axis.title.toJSON() }; + const describeAxis = (axis: any) => ({ ...axis.toJSON(), title: axis.title.toJSON() }); return { sourceRange: { address: dataRange.address, @@ -106,7 +117,6 @@ async function inspectChart(context: any, sheet: any, dataRange: any, chart: any axes: { primaryCategory: describeAxis(primaryCategoryAxis), primaryValue: describeAxis(primaryValueAxis), - secondaryValue: describeAxis(secondaryValueAxis), }, }, }; @@ -153,22 +163,18 @@ export async function renderExcelChart( if (spec.series?.length) { chart.series.load('items'); await context.sync(); - if (chart.series.items.length < spec.series.length) { - throw new Error(`Excel inferred ${chart.series.items.length} series; ${spec.series.length} required.`); + for (let index = chart.series.items.length - 1; index >= 0; index -= 1) { + chart.series.getItemAt(index).delete(); } + await context.sync(); for (const [index, binding] of spec.series.entries()) { - const series = chart.series.items[index]; - series.name = binding.name; + const series = chart.series.add(binding.name, index); series.setXAxisValues(sheet.getRangeByIndexes(1, binding.xColumn, binding.rowCount, 1)); series.setValues(sheet.getRangeByIndexes(1, binding.yColumn, binding.rowCount, 1)); if (binding.bubbleSizeColumn !== undefined) { series.setBubbleSizes(sheet.getRangeByIndexes(1, binding.bubbleSizeColumn, binding.rowCount, 1)); } } - for (let index = chart.series.items.length - 1; index >= spec.series.length; index -= 1) { - chart.series.getItemAt(index).delete(); - await context.sync(); - } } const width = spec.width ?? 400; @@ -178,8 +184,11 @@ export async function renderExcelChart( if (spec.title) { chart.title.text = spec.title; chart.title.visible = true; + chart.title.format.font.size = EXCEL_CHART_TITLE_FONT_SIZE; } - applyChartFormat(chart, prepared); + chart.series.load?.('items'); + await context.sync(); + applyChartFormat(chart, prepared, chart.series.items?.length ?? prepared.seriesCount); await context.sync(); const image = chart.getImage( Math.round(width * (96 / 72) * scale), diff --git a/packages/flint-js/src/excel/templates/candlestick.ts b/packages/flint-js/src/excel/templates/candlestick.ts index fada1375..9d8e67a4 100644 --- a/packages/flint-js/src/excel/templates/candlestick.ts +++ b/packages/flint-js/src/excel/templates/candlestick.ts @@ -2,15 +2,10 @@ // Licensed under the MIT License. import { formatSpecToExcel } from '../chart-types'; +import { excelDateAxis, excelDateSerial } from '../date-axis'; import type { ExcelTemplateDef } from './types'; const PRICE_CHANNELS = ['open', 'high', 'low', 'close'] as const; -const EXCEL_EPOCH = Date.UTC(1899, 11, 30); -const DAY_MILLISECONDS = 24 * 60 * 60 * 1000; - -function excelDateSerial(value: unknown): number { - return (new Date(value as string | number | Date).getTime() - EXCEL_EPOCH) / DAY_MILLISECONDS; -} function niceStep(span: number): number { const rough = span / 5; @@ -66,8 +61,6 @@ export const excelCandlestickDef: ExcelTemplateDef = { const xField = fieldOf('x')!; const fields = PRICE_CHANNELS.map((channel) => fieldOf(channel)!); const base = input.chart_spec.baseSize ?? { width: 480, height: 320 }; - const labelBudget = Math.max(12, Math.floor((base.width - 90) / 14)); - const tickLabelSpacing = table.length > labelBudget ? Math.ceil(table.length / labelBudget) : undefined; const lows = table.map((row) => Number(row[fields[2]])); const highs = table.map((row) => Number(row[fields[1]])); const minimum = Math.min(...lows); @@ -89,7 +82,10 @@ export const excelCandlestickDef: ExcelTemplateDef = { ...fields.map((field) => Number(row[field])), ]), ], - categoryAxis: { title: xField, numberFormat: 'yyyy-mm-dd', tickLabelSpacing }, + categoryAxis: { + title: xField, + ...excelDateAxis(table.map((row) => row[xField]), base.width), + }, valueAxis: { title: 'Price', numberFormat: formatSpecToExcel(semantics.close?.format), diff --git a/packages/flint-js/src/excel/templates/funnel.ts b/packages/flint-js/src/excel/templates/funnel.ts index 401037de..34cabd5e 100644 --- a/packages/flint-js/src/excel/templates/funnel.ts +++ b/packages/flint-js/src/excel/templates/funnel.ts @@ -51,7 +51,7 @@ export const excelFunnelChartDef: ExcelTemplateDef = { visible: true, numberFormat: formatSpecToExcel(semantics.size?.format), fontColor: '#FFFFFF', - fontSize: 11, + fontSize: 13, }, seriesFormats: [{ color: '#4472C4' }], width: base.width, diff --git a/packages/flint-js/src/excel/templates/histogram.ts b/packages/flint-js/src/excel/templates/histogram.ts index bf6964eb..8515ead2 100644 --- a/packages/flint-js/src/excel/templates/histogram.ts +++ b/packages/flint-js/src/excel/templates/histogram.ts @@ -57,20 +57,31 @@ export const excelHistogramDef: ExcelTemplateDef = { if (seriesIndex >= 0) seriesCounts[seriesIndex][binIndex] += 1; } const base = input.chart_spec.baseSize ?? { width: 480, height: 320 }; + const data = colorField + ? [ + [valueField, labels[0], '', ...labels.slice(1)], + ...seriesKeys.map((name, seriesIndex) => [ + name, + seriesCounts[seriesIndex][0], + 0, + ...seriesCounts[seriesIndex].slice(1), + ]), + ] + : [ + [valueField, ...seriesKeys], + ...labels.map((label, index) => [label, seriesCounts[0][index]]), + ]; return { schema: 'flint.excel.chart/v1', kind: 'chart', chartType: colorField ? 'ColumnStacked' : 'ColumnClustered', title: `Distribution of ${valueField}`, - seriesBy: 'Columns', - data: [ - [valueField, ...seriesKeys], - ...labels.map((label, index) => [label, ...seriesCounts.map((series) => series[index])]), - ], + seriesBy: colorField ? 'Rows' : 'Columns', + data, categoryAxis: { title: valueField }, valueAxis: { title: 'Count', numberFormat: '0' }, legend: { visible: Boolean(colorField), position: 'Bottom' }, - gapWidth: 0, + gapWidth: 20, width: base.width, height: base.height, warnings: [], diff --git a/packages/flint-js/src/excel/types.ts b/packages/flint-js/src/excel/types.ts index 8d236eb5..42ef4779 100644 --- a/packages/flint-js/src/excel/types.ts +++ b/packages/flint-js/src/excel/types.ts @@ -24,6 +24,12 @@ export type ExcelLegendPosition = 'Top' | 'Bottom' | 'Left' | 'Right'; export interface ExcelAxisSpec { /** Axis title text (from the field display name). */ title?: string; + /** Native Excel category-axis interpretation. */ + categoryType?: 'DateAxis' | 'TextAxis'; + /** Base unit used by a native Excel date axis. */ + baseTimeUnit?: 'Days' | 'Months' | 'Years'; + /** Unit associated with `majorUnit` on a native Excel date axis. */ + majorTimeUnitScale?: 'Days' | 'Months' | 'Years'; /** Axis-label font size in points, used when dense categories need explicit sizing. */ labelFontSize?: number; /** Number of categories between displayed labels; underlying data stays intact. */ diff --git a/packages/flint-js/src/excel/typography.ts b/packages/flint-js/src/excel/typography.ts new file mode 100644 index 00000000..314cb72f --- /dev/null +++ b/packages/flint-js/src/excel/typography.ts @@ -0,0 +1,5 @@ +export const EXCEL_CHART_TITLE_FONT_SIZE = 18; +export const EXCEL_AXIS_TITLE_FONT_SIZE = 15; +export const EXCEL_LABEL_FONT_SIZE = 13; +export const EXCEL_LEGEND_FONT_SIZE = 13; +export const EXCEL_DATA_LABEL_FONT_SIZE = 13; \ No newline at end of file diff --git a/packages/flint-js/src/plotly/assemble.ts b/packages/flint-js/src/plotly/assemble.ts index 0f18d537..1a1c804a 100644 --- a/packages/flint-js/src/plotly/assemble.ts +++ b/packages/flint-js/src/plotly/assemble.ts @@ -65,6 +65,28 @@ import { normalizeChartProperties } from '../core/normalize-properties'; * * @returns A Plotly figure with optional `_warnings` and `_width`/`_height` hints */ +function applyFieldDisplayNames(figure: any, names: Record | undefined): void { + if (!names) return; + const displayName = (value: unknown) => typeof value === 'string' ? names[value] ?? value : value; + for (const [key, axis] of Object.entries(figure.layout ?? {}) as Array<[string, any]>) { + if (/^[xy]axis\d*$/.test(key) && axis?.title?.text) { + axis.title.text = displayName(axis.title.text); + } + } + if (figure.layout?.legend?.title?.text) { + figure.layout.legend.title.text = displayName(figure.layout.legend.title.text); + } + for (const trace of figure.data ?? []) { + if (trace?.name) trace.name = displayName(trace.name); + if (trace?.marker?.colorbar?.title?.text) { + trace.marker.colorbar.title.text = displayName(trace.marker.colorbar.title.text); + } + if (trace?.colorbar?.title?.text) { + trace.colorbar.title.text = displayName(trace.colorbar.title.text); + } + } +} + export function assemblePlotly(input: ChartAssemblyInput): any { const chartType = input.chart_spec.chartType; const semanticTypes = input.semantic_types ?? {}; @@ -416,6 +438,8 @@ export function assemblePlotly(input: ChartAssemblyInput): any { figure._pivot = legacyPivot.surface; } + applyFieldDisplayNames(figure, input.field_display_names); + return figure; } diff --git a/packages/flint-js/src/plotly/templates/boxplot.ts b/packages/flint-js/src/plotly/templates/boxplot.ts index dbd5904e..d070799f 100644 --- a/packages/flint-js/src/plotly/templates/boxplot.ts +++ b/packages/flint-js/src/plotly/templates/boxplot.ts @@ -55,20 +55,35 @@ export const plBoxplotDef: ChartTemplateDef = { const isHorizontal = catAxis === 'y'; const categories = extractCategories(table, catField, channelSemantics[catAxis]?.ordinalSortOrder); + // `showPoints` overlays every raw observation on the box; that makes the + // separate outlier marks redundant, since those points are drawn too. + const showPoints = chartProperties?.showPoints === true; const showOutliers = chartProperties?.showOutliers !== false; - const boxpoints = showOutliers ? 'outliers' : false; + const boxpoints = showPoints ? 'all' : (showOutliers ? 'outliers' : false); const palette = getPlotlyPalette(ctx, 'color'); const makeTrace = (name: string | undefined, rows: any[], colorIdx: number) => { const cats = rows.map((r: any) => String(r[catField] ?? '')); const vals = rows.map((r: any) => Number(r[valField])); + const seriesColor = getSeriesColor(palette, colorIdx); return { type: 'box', ...(name != null ? { name } : {}), ...(isHorizontal ? { y: cats, x: vals } : { x: cats, y: vals }), boxpoints, - marker: { color: getSeriesColor(palette, colorIdx), size: 3 }, - line: { color: getSeriesColor(palette, colorIdx) }, + // Drawing the sample inverts the visual hierarchy: the sample + // becomes the figure and the box demotes to scaffolding over it. + // So the box goes hollow and keeps only its outline, and the + // group colour lives on the points — fill *and* point colour + // would encode the group twice. `pointpos: 0` centres the cloud + // on the box instead of parking it alongside. + ...(showPoints + ? { jitter: 0.6, pointpos: 0, fillcolor: 'rgba(0,0,0,0)' } + : {}), + marker: showPoints + ? { color: seriesColor, size: 4, opacity: 0.7, line: { color: '#ffffff', width: 0.5 } } + : { color: seriesColor, size: 3 }, + line: { color: seriesColor, ...(showPoints ? { width: 1.5 } : {}) }, }; }; @@ -108,6 +123,19 @@ export const plBoxplotDef: ChartTemplateDef = { delete spec.encoding; }, properties: [ - { key: 'showOutliers', label: 'Outliers', type: 'binary', defaultValue: true } as ChartPropertyDef, + { + key: 'showPoints', label: 'Points', type: 'binary', defaultValue: false, + // Jitter needs a band to scatter within, so this is only meaningful + // once one position axis is discrete. + check: (ctx) => ({ + applicable: isDiscreteType(ctx.channelSemantics?.x?.type) + || isDiscreteType(ctx.channelSemantics?.y?.type), + }), + } as ChartPropertyDef, + { + key: 'showOutliers', label: 'Outliers', type: 'binary', defaultValue: true, + // Once every observation is drawn, the outlier marks are duplicates. + check: (ctx) => ({ applicable: ctx.chartProperties?.showPoints !== true }), + } as ChartPropertyDef, ], }; diff --git a/packages/flint-js/src/test-data/gantt-bullet-tests.ts b/packages/flint-js/src/test-data/gantt-bullet-tests.ts index 34d9a210..950aadbe 100644 --- a/packages/flint-js/src/test-data/gantt-bullet-tests.ts +++ b/packages/flint-js/src/test-data/gantt-bullet-tests.ts @@ -190,3 +190,66 @@ export function genBulletTests(): TestCase[] { ...realBulletCases(), ]; } + +// --------------------------------------------------------------------------- +// KPI Card — big-number tiles, each measured against its own goal +// --------------------------------------------------------------------------- + +/** metric, value, goal — chosen to land one tile in each verdict state. */ +const QUARTER_KPIS: Array<[string, number, number]> = [ + ['Revenue ($k)', 1284, 1200], // exceeded — at or above goal + ['New customers', 372, 500], // on track — between the two + ['Churn saves', 41, 120], // behind — well short of goal + ['NPS', 54, 50], // exceeded +]; + +const ADOPTION_KPIS: Array<[string, number, number]> = [ + ['Weekly actives (k)', 88, 120], + ['Seats licensed (k)', 143, 140], +]; + +export function genKpiCardTests(): TestCase[] { + const quarter = QUARTER_KPIS.map(([metric, value, goal]) => ({ metric, value, goal })); + const adoption = ADOPTION_KPIS.map(([metric, value, goal]) => ({ metric, value, goal })); + const meta = { + metric: { type: Type.String, semanticType: 'Category', levels: [] }, + value: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + goal: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + }; + const encodingMap = { + metric: makeEncodingItem('metric'), + value: makeEncodingItem('value'), + goal: makeEncodingItem('goal'), + }; + const fields = [makeField('metric'), makeField('value'), makeField('goal')]; + return [ + { + title: 'Quarterly KPIs vs goal', + description: + 'Four big-number tiles, each with a progress bar against its own ' + + 'goal. The four deliberately span every verdict the card can ' + + 'reach — two that beat their goal, one still in progress and one ' + + 'well short — so a theme\u2019s accent and its status inks all ' + + 'appear on a single sheet and can be told apart.', + tags: ['kpi', 'card', 'big-number', 'target', 'gallery'], + chartType: 'KPI Card', + data: quarter, + fields, + metadata: meta, + encodingMap, + }, + { + title: 'Adoption against plan', + description: + 'A two-tile card: one metric short of plan and one just past it, ' + + 'at the width where the tiles are widest and the big number has ' + + 'the most room.', + tags: ['kpi', 'card', 'big-number', 'target', 'gallery'], + chartType: 'KPI Card', + data: adoption, + fields, + metadata: meta, + encodingMap, + }, + ]; +} diff --git a/packages/flint-js/src/test-data/index.ts b/packages/flint-js/src/test-data/index.ts index 841f0f6f..e59fc3ad 100644 --- a/packages/flint-js/src/test-data/index.ts +++ b/packages/flint-js/src/test-data/index.ts @@ -45,7 +45,7 @@ export { genDiscreteAxisTests } from './discrete-axis-tests'; export { genDateTests, genDateYearTests, genDateMonthTests, genDateYearMonthTests, genDateDecadeTests, genDateDateTimeTests, genDateHoursTests } from './date-tests'; export { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests'; export { genMapTests, genChoroplethTests } from './map-tests'; -export { genGanttTests, genBulletTests } from './gantt-bullet-tests'; +export { genGanttTests, genBulletTests, genKpiCardTests } from './gantt-bullet-tests'; export { OMNI_VIZ_ROWS, OMNI_VIZ_LEVELS, @@ -92,7 +92,7 @@ import { genHistogramTests, genBoxplotTests, genDensityTests, genStripPlotTests import { genDensityContourTests } from './density-2d-tests'; import { genViolinTests } from './violin-tests'; import { genMapTests, genChoroplethTests } from './map-tests'; -import { genGanttTests, genBulletTests } from './gantt-bullet-tests'; +import { genGanttTests, genBulletTests, genKpiCardTests } from './gantt-bullet-tests'; import { genLineTests } from './line-tests'; import { genSparklineTests } from './sparkline-tests'; import { genBumpChartTests } from './line-area-tests'; @@ -180,6 +180,7 @@ export const TEST_GENERATORS: Record TestCase[]> = { 'Choropleth': genChoroplethTests, 'Gantt Chart': genGanttTests, 'Bullet Chart': genBulletTests, + 'KPI Card': genKpiCardTests, 'Facet: Columns': genFacetColumnTests, 'Facet: Rows': genFacetRowTests, 'Facet: Cols+Rows': genFacetColRowTests, diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index 69e6ac0c..eb5a1c70 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -66,6 +66,9 @@ import { computeLayout, computeChannelBudgets, computeMinSubplotDimensions, deri import { vlApplyLayoutToSpec, vlApplyTooltips } from './instantiate-spec'; import { normalizeStaticSeries } from '../core/static-series'; import { normalizeChartProperties } from '../core/normalize-properties'; +import { groundTheme, resolveChartDefaults, resolveCompileDefaults } from '../core/theme/ground'; +import { resolveThemeSpec } from '../core/theme/presets'; +import { realizeThemeVegaLite, realizeValueLabelsVegaLite, collectMarkTypes, collectPositional } from './theme'; // --------------------------------------------------------------------------- // Helpers @@ -109,14 +112,21 @@ const escapeVlFieldName = (name: string): string => export function assembleVegaLite(input: ChartAssemblyInput): any { const chartType = input.chart_spec.chartType; const semanticTypes = input.semantic_types ?? {}; + // `theme_spec` may name a house Flint ships rather than spell one out. + const themeSpec = resolveThemeSpec(input.theme_spec); + // A house may prefer a size, a stretch budget, a facet gap. Those settle + // before anything is measured, and in a fixed order: the chart spec first, + // the theme's presets under it, flint's own defaults under that. + const themePresets = resolveCompileDefaults(themeSpec, input.options); // Internal layout targets the base (target) size; the optional canvasSize // ceiling is applied as per-dimension stretch caps once options resolve. // The base is clamped to the ceiling so a smaller canvasSize shrinks the // chart to fit rather than overflowing it. - const sizeCeiling = input.chart_spec.canvasSize; - const baseSize = resolveBaseSize(input.chart_spec.baseSize, sizeCeiling); + const housePresets = themeSpec?.compileDefaults; + const sizeCeiling = input.chart_spec.canvasSize ?? housePresets?.canvasSize; + const baseSize = resolveBaseSize(input.chart_spec.baseSize ?? housePresets?.baseSize, sizeCeiling); const canvasSize = baseSize; - const options = input.options ?? {}; + const options = themePresets.options ?? {}; let chartTemplate = vlGetTemplateDef(chartType) as ChartTemplateDef; if (!chartTemplate) { throw new Error(`Unknown chart type: ${chartType}`); @@ -131,9 +141,32 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { const normalizedProps = normalizeChartProperties( chartTemplate.properties, input.chart_spec.chartProperties, ); - const chartProperties = normalizedProps.chartProperties; + let chartProperties = normalizedProps.chartProperties; warnings.push(...normalizedProps.warnings); + // A house's rules about the chart itself — points on a line, a bump chart + // left unsmoothed — change what is drawn, not how it is dressed, so they + // are folded in here, before the pipeline reads the properties. Anything + // the caller stated already is left alone. + if (themeSpec && !chartProperties) chartProperties = {}; + const chartDefaultsReport = chartProperties + ? resolveChartDefaults( + themeSpec, chartType, chartTemplate.properties, + input.chart_spec.chartProperties, chartProperties, + ) + : []; + + // One toggle, whichever way the template draws the numbers. A few charts + // print their own value labels instead of going through the theme's + // data-label layer. They expose the same `showValueLabels` control as every + // other chart; translating it to the older internal `showTextLabels` + // boolean keeps saved inputs compatible without publishing two controls. + // Silence stays silent: with no answer the template keeps its own default. + if (chartProperties && templateOwnsValueLabels(chartTemplate)) { + const choice = resolveValueLabelChoice(chartProperties); + if (choice) chartProperties.showTextLabels = choice === 'on'; + } + // ═══════════════════════════════════════════════════════════════════════ // PRE-PHASE: Static Series Normalization // ═══════════════════════════════════════════════════════════════════════ @@ -347,6 +380,42 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { ...(declaration.paramOverrides || {}), }; + // How much room one category gets is a house decision — a journal that + // prints three wide boxes and a dashboard that prints thirty thin bars are + // both right about their own page. A template's band size is a guess made + // without knowing the house, so the house's number replaces it; a caller + // who states one outranks both. + // + // But a band step is a statement about bar thickness, and where *both* axes + // are banded there are no bars: the marks are cells, and a cell's size is + // fixed by the two counts and the room they share. A house that asks for + // 80px categories would print a grid of stripes. That one the layout keeps. + const houseBandStep = themeSpec?.layout?.bandStep; + const cellGrid = declaration.axisFlags?.x?.banded === true + && declaration.axisFlags?.y?.banded === true; + // A template may state a floor its read cannot go below (a slopegraph needs + // its two columns spread wide however compact the house). The house sets + // the band step, but not below that floor. + const minBandStep = (declaration.paramOverrides as AssembleOptions | undefined)?.minBandStep; + if (houseBandStep && options.defaultBandSize == null && !cellGrid) { + const step = minBandStep ? Math.max(houseBandStep, minBandStep) : houseBandStep; + effectiveOptions.defaultBandSize = step; + effectiveOptions.maxBandSize = Math.max(step, effectiveOptions.maxBandSize ?? 0); + chartDefaultsReport.push({ + stage: 'ground', + path: 'layout.bandStep', + message: minBandStep && step > houseBandStep + ? `the house gives each category ${houseBandStep}px, but this chart reads only above ${minBandStep}px — held to ${step}px` + : `the house gives each category ${houseBandStep}px`, + }); + } else if (houseBandStep && cellGrid) { + chartDefaultsReport.push({ + stage: 'ground', + path: 'layout.bandStep', + message: `the house asks for ${houseBandStep}px categories, but both axes are banded — the marks are cells, whose size the grid settles, not the house`, + }); + } + const { addTooltips: addTooltipsOpt = false, minSubplotSize: minSubplotVal = 60, @@ -658,11 +727,88 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { vlApplyTooltips(vgObj); } + // ═══════════════════════════════════════════════════════════════════════ + // HEADLINE + // ═══════════════════════════════════════════════════════════════════════ + // + // Written before theming, because whether the chart has a headline is a + // fact the theme reasons about: a house that omits axis titles is leaning + // on this line to name the measure. + const headline = input.chart_spec.title?.trim(); + const deck = input.chart_spec.subtitle?.trim(); + if (headline || deck) { + vgObj.title = { + text: headline ?? '', + ...(deck ? { subtitle: [deck] } : {}), + }; + } + + // ═══════════════════════════════════════════════════════════════════════ + // THEME (level 2 grounding → level 3 realization) + // ═══════════════════════════════════════════════════════════════════════ + // + // Runs last, deliberately. `vlApplyLayoutToSpec` builds `config` wholesale + // from fit decisions; the theme is a style layer over a chart that already + // fits, so it must see the finished spec. + // + // Grounding runs whether or not a house was named — with the neutral house + // when it was not. Some design questions are Flint's own and want answering + // either way: whether this chart can carry its values, and at this density + // whether it should. A house enhances that answer (it may prefer values on, + // or tolerate a tighter chart); it does not own it. Only *realization* is + // gated, so an untheme'd chart gets its numbers without also getting a + // house's ink, type and furniture. + const markTypes = collectMarkTypes(vgObj); + // Some templates write their own text on the marks. Where they do, the + // label layer stands down (it will not print a second number beside the + // template's), so the toggle would be a control that changes nothing. + // Asked before realization, because realization is what adds the label + // layer — asked after, every labelled chart would look like this. + const templateDrawsOwnText = markTypes.includes('text'); + const stackedChannel = (vgObj.spec?.encoding ?? vgObj.encoding ?? {}); + const stacked = stackedChannel.y?.stack ?? stackedChannel.x?.stack; + const design = groundTheme(themeSpec ?? {}, { + chartType, + markChannel: chartTemplate.markCognitiveChannel, + markTypes, + namesOnMarks: (chartProperties as any)?.showSeriesInLabel === true, + channelSemantics, + resolvedTypes: declaration.resolvedTypes as Record | undefined, + axisFlags: declaration.axisFlags, + positional: collectPositional(vgObj), + layout: layoutResult, + table: values, + canvasSize, + stacked: stacked === 'normalize' ? 'normalize' : Boolean(stacked), + partToWhole: markTypes.includes('arc'), + titled: Boolean(vgObj.title), + hostSurface: (input.options as any)?.background, + valueLabels: resolveValueLabelChoice(chartProperties), + }); + + let themeDecisions: any; + if (themeSpec) { + const realizeReport = realizeThemeVegaLite(vgObj, design, values); + themeDecisions = { + ...design, + report: [...themePresets.report, ...chartDefaultsReport, ...design.report, ...realizeReport], + }; + } else { + realizeValueLabelsVegaLite(vgObj, design, values); + } + // ═══════════════════════════════════════════════════════════════════════ // RESULT // ═══════════════════════════════════════════════════════════════════════ const result: any = { ...vgObj, data: vgObj.data ?? { values } }; + if (themeDecisions) { + result._theme = { + id: themeDecisions.themeId, + report: themeDecisions.report, + decisions: themeDecisions, + }; + } if (warnings.length > 0) { result._warnings = warnings; } @@ -688,13 +834,40 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { data, chartProperties, }; + const ownsLabels = templateOwnsValueLabels(chartTemplate); + const valueLabelChoice = resolveValueLabelChoice(chartProperties); + const explicitValueLabels = valueLabelChoice == null + ? undefined + : valueLabelChoice === 'on'; const layoutCoupledRecommendation: Record = { independentYAxis: computedIndependentYAxis, + // Seed the labels toggle from what the house and the density actually + // decided, so an untouched control shows the theme's own habit and + // re-seeds when the reader switches theme. Where the template owns its + // labels, its own boolean is the honest answer. + showValueLabels: ownsLabels + ? explicitValueLabels ?? design?.dataLabels?.show + : design?.dataLabels?.show, + }; + // Whether offering a labels control means anything is a question about the + // resolved layout — is there anything to key a number to, and is there room + // to print it — so only the grounded design can answer it. A chart too + // dense to read numbers on cannot be argued into it, and neither can one + // whose template already writes its own text. Templates that print labels + // *on request* are the exception: they answer to the toggle themselves. + const designCoupledApplicability: Record = { + showValueLabels: ownsLabels + || (design?.dataLabels?.possible === true && !templateDrawsOwnText), + // The older spelling stays an accepted *input* for compatibility, but a + // host should be shown one switch, not two that fight. + showTextLabels: false, }; result._options = (chartTemplate.properties ?? []).map((def): ChartOption => { const ev = def.check?.(evalCtx); - const applicable = ev ? ev.applicable : true; + const applicable = def.key in designCoupledApplicability + ? designCoupledApplicability[def.key] + : ev ? ev.applicable : true; const recommended = layoutCoupledRecommendation[def.key] ?? ev?.recommendedValue; const value = chartProperties?.[def.key] ?? recommended ?? def.defaultValue; // Strip the `check` rule — a ChartOption is the resolved, serializable @@ -742,6 +915,34 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { return result; } +/** + * What the caller asked for on value labels, in one word. + * + * `showValueLabels` is the control; `showTextLabels` is the older boolean some + * templates and hosts still pass, and it keeps working — `true` means print, + * `false` means the caller never touched it (it was the key's default), so it + * reads as `auto` rather than as a demand for silence. Only the tri-state can + * say "off". + */ +/** + * Does this template print its own value labels, rather than leaving them to + * the theme's data-label layer? + */ +function templateOwnsValueLabels(template: ChartTemplateDef): boolean { + return template.ownsValueLabels === true; +} + +function resolveValueLabelChoice( + chartProperties: Record | undefined, +): 'on' | 'off' | undefined { + const choice = chartProperties?.showValueLabels; + if (typeof choice === 'boolean') return choice ? 'on' : 'off'; + // `showTextLabels` is the older, template-owned spelling of the same wish. + // Only `true` is meaningful: it was the opt-in for charts that print their + // own numbers, so `false` means "never asked", not "asked for silence". + return chartProperties?.showTextLabels === true ? 'on' : undefined; +} + /** * Inspect a chart spec + dataset and report the configurable options Flint * exposes for it, each annotated with whether it is *applicable* and the *value* @@ -887,6 +1088,10 @@ function buildVLEncodings( // Legend sizing for high-cardinality nominal color/group if (encodingObj.type === "nominal" && (channel === 'color' || channel === 'group')) { const actualDomain = [...new Set(data.map(r => r[fieldName]))]; + // Threshold kept in sync with HIGH_CARDINALITY_LEGEND_MIN in + // vegalite/theme.ts: when a theme later folds the key to a short + // top-K + Others list, that pass recomputes this shrink against + // the folded count so short legends are not squeezed to 8px. if (actualDomain.length >= 16) { if (!encodingObj.legend) encodingObj.legend = {}; encodingObj.legend.symbolSize = 12; diff --git a/packages/flint-js/src/vegalite/canvas-furniture.ts b/packages/flint-js/src/vegalite/canvas-furniture.ts new file mode 100644 index 00000000..676aeaea --- /dev/null +++ b/packages/flint-js/src/vegalite/canvas-furniture.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @module flint-chart/vegalite/canvas-furniture + * + * Canvas-anchored furniture: branding marks that belong to the *graphic frame*, + * not the plot. The Economist red masthead tab is the archetype — it sits at + * the graphic's top-left, flush with the title, and has nothing to do with the + * data rectangle. + * + * Vega-Lite has no way to express this. Every mark it draws lives in the plot's + * coordinate space, and a concat child pins to the plot's *data* rectangle — so + * on a horizontal bar (a wide left-axis gutter) the tab drifts right, away from + * the title, instead of holding graphic-left. There is no `align`/`bounds` + * combination that lifts a child into the axis gutter; the wall is structural. + * + * The remedy is to draw the tab *after* Vega-Lite is done, straight onto the + * rendered SVG at absolute canvas coordinates. The grounding stage records + * where each piece goes (in `usermeta`, which Vega-Lite passes through to the + * compiled Vega spec untouched) and reserves a top band so nothing overlaps the + * title. The renderer then injects a plain `` at those coordinates. The + * result is a real element inside the exported `` — it travels with the + * file and rasterises identically in any standalone SVG renderer. + */ + +/** A branding rectangle anchored to the graphic frame, in canvas pixels. */ +export interface CanvasFurnitureItem { + kind: string; + /** Distance from the canvas left edge — the same margin the title anchors to. */ + x: number; + /** Distance from the canvas top edge, inside the reserved top band. */ + y: number; + width: number; + height: number; + color: string; +} + +/** The `usermeta` key the grounding stage stamps canvas furniture under. */ +export const CANVAS_FURNITURE_KEY = 'flintCanvasFurniture'; + +/** + * Read canvas furniture off a spec's `usermeta`. Works on either a Vega-Lite + * spec (before compile) or the compiled Vega spec (`usermeta` survives compile). + */ +export function readCanvasFurniture(spec: any): CanvasFurnitureItem[] { + const items = spec?.usermeta?.[CANVAS_FURNITURE_KEY]; + return Array.isArray(items) ? items : []; +} + +function escapeAttr(value: string): string { + return String(value).replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} + +/** Build the `` markup for a set of canvas-furniture items. */ +export function canvasFurnitureMarkup(items: CanvasFurnitureItem[]): string { + return items + .map( + (it) => + ``, + ) + .join(''); +} + +/** + * Inject canvas furniture into a rendered SVG string, drawn last so it paints + * over the background rect. A no-op when there is nothing to draw. + */ +export function injectCanvasFurnitureSVG(svg: string, items: CanvasFurnitureItem[]): string { + if (!items?.length) return svg; + const idx = svg.lastIndexOf(''); + if (idx === -1) return svg; + return svg.slice(0, idx) + canvasFurnitureMarkup(items) + svg.slice(idx); +} diff --git a/packages/flint-js/src/vegalite/index.ts b/packages/flint-js/src/vegalite/index.ts index c6a5d62e..e837543f 100644 --- a/packages/flint-js/src/vegalite/index.ts +++ b/packages/flint-js/src/vegalite/index.ts @@ -16,6 +16,15 @@ export { assembleVegaLite, getChartOptions, getChartPivot, getChartTransform } f // VL spec instantiation (Phase 2) export { vlApplyLayoutToSpec, vlApplyTooltips } from './instantiate-spec'; +// Canvas-anchored furniture (branding marks drawn onto the rendered SVG) +export { + type CanvasFurnitureItem, + CANVAS_FURNITURE_KEY, + readCanvasFurniture, + canvasFurnitureMarkup, + injectCanvasFurnitureSVG, +} from './canvas-furniture'; + // VL template registry export { vlTemplateDefs, diff --git a/packages/flint-js/src/vegalite/instantiate-spec.ts b/packages/flint-js/src/vegalite/instantiate-spec.ts index 1b381f6f..a755b87d 100644 --- a/packages/flint-js/src/vegalite/instantiate-spec.ts +++ b/packages/flint-js/src/vegalite/instantiate-spec.ts @@ -173,43 +173,47 @@ export function vlApplyLayoutToSpec( const bandedCount = axis === 'x' ? layout.xContinuousAsDiscrete : layout.yContinuousAsDiscrete; if (bandedCount <= 1) continue; - const enc = vgObj.encoding?.[axis] || vgObj.spec?.encoding?.[axis]; - if (!enc) continue; - - // Skip binned encodings — VL handles bin domain automatically - if (enc.bin) continue; - - const isTemporal = enc.type === 'temporal'; - const isContinuous = enc.type === 'quantitative' || isTemporal; - if (!isContinuous) continue; - if (enc.scale?.domain) continue; - - const numericVals = context.table - .map((r: any) => { - const raw = r[enc.field]; - if (raw == null) return NaN; - if (isTemporal) return +new Date(raw); - return +raw; - }) - .filter((v: number) => !isNaN(v)); - if (numericVals.length <= 1) continue; - - const minVal = Math.min(...numericVals); - const maxVal = Math.max(...numericVals); - const dataRange = maxVal - minVal; - if (dataRange === 0) continue; - - const pad = dataRange / (bandedCount - 1) / 2; - if (!enc.scale) enc.scale = {}; - enc.scale.nice = false; - - if (isTemporal) { - enc.scale.domain = [ - new Date(minVal - pad).toISOString(), - new Date(maxVal + pad).toISOString(), - ]; - } else { - enc.scale.domain = [minVal - pad, maxVal + pad]; + // Labelled heatmaps move X/Y onto rect and text layers. Looking only at + // the top-level encoding skips both, so temporal edge cells lose their + // half-step domain and are clipped against the axis. Apply the same + // domain to every matching layer target; shared scales then resolve + // consistently and neither layer introduces a competing boundary. + for (const enc of collectEncodingTargets(axis)) { + // Skip binned encodings — VL handles bin domain automatically + if (enc.bin) continue; + + const isTemporal = enc.type === 'temporal'; + const isContinuous = enc.type === 'quantitative' || isTemporal; + if (!isContinuous) continue; + if (enc.scale?.domain) continue; + + const numericVals = context.table + .map((r: any) => { + const raw = r[enc.field]; + if (raw == null) return NaN; + if (isTemporal) return +new Date(raw); + return +raw; + }) + .filter((v: number) => !isNaN(v)); + if (numericVals.length <= 1) continue; + + const minVal = Math.min(...numericVals); + const maxVal = Math.max(...numericVals); + const dataRange = maxVal - minVal; + if (dataRange === 0) continue; + + const pad = dataRange / (bandedCount - 1) / 2; + if (!enc.scale) enc.scale = {}; + enc.scale.nice = false; + + if (isTemporal) { + enc.scale.domain = [ + new Date(minVal - pad).toISOString(), + new Date(maxVal + pad).toISOString(), + ]; + } else { + enc.scale.domain = [minVal - pad, maxVal + pad]; + } } } @@ -228,6 +232,14 @@ export function vlApplyLayoutToSpec( labelFontSize: layout.yLabel.fontSize, titleFontSize: layout.titleFontSize, }; + // Vega drops a tick label only once its box *overlaps* its neighbour's, so + // two numbers whose boxes merely abut both survive and are read as one: + // `20,000` beside `30,000` prints `20,00030,000`. Numbers need a + // character's worth of air between them before they read as two. Bands are + // exempt — their labels are spaced by the scale, and thinning them drops a + // category rather than a tick. + if (!xIsDiscrete) axisXConfig.labelSeparation = Math.round(layout.xLabel.fontSize * 0.6); + if (!yIsDiscrete) axisYConfig.labelSeparation = Math.round(layout.yLabel.fontSize * 0.6); vgObj.config = { view: { diff --git a/packages/flint-js/src/vegalite/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts index 7bef3b80..38721a11 100644 --- a/packages/flint-js/src/vegalite/templates/area.ts +++ b/packages/flint-js/src/vegalite/templates/area.ts @@ -3,7 +3,7 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { makeCartesianPivot } from '../../core/pivot'; -import { defaultBuildEncodings, setMarkProp } from './utils'; +import { defaultBuildEncodings, setMarkProp, alignStackOrderToColorOrder } from './utils'; const interpolateConfigProperty: ChartPropertyDef = { key: "interpolate", label: "Curve", type: "discrete", options: [ @@ -147,6 +147,7 @@ export const areaChartDef: ChartTemplateDef = { } else if (config?.stackMode !== 'layered') { interpolateSparseStack(spec, ctx); } + alignStackOrderToColorOrder(spec, ctx); }, properties: [ interpolateConfigProperty, @@ -196,6 +197,7 @@ export const streamgraphDef: ChartTemplateDef = { // A streamgraph is always centre-stacked → interpolate sparse gaps so the // stack stays continuous (see interpolateSparseStack). interpolateSparseStack(spec, ctx); + alignStackOrderToColorOrder(spec, ctx); }, properties: [interpolateConfigProperty] as ChartPropertyDef[], }; diff --git a/packages/flint-js/src/vegalite/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts index 35216767..c89a64aa 100644 --- a/packages/flint-js/src/vegalite/templates/bar.ts +++ b/packages/flint-js/src/vegalite/templates/bar.ts @@ -11,9 +11,17 @@ import { } from '../../core/axis-detection'; import { defaultBuildEncodings, setMarkProp, adjustBarMarks, adjustRectTiling, - resolveAsDiscrete, + resolveAsDiscrete, alignStackOrderToColorOrder, } from './utils'; +/** + * Fraction of a lane's pitch a locally-dodged bar fills, leaving a small gap + * between the bars inside one band. A house that states its own + * `marks.bandFraction` re-cuts against this baseline (see theme.ts `bandWalk`), + * so the two must agree on the number. + */ +export const LOCAL_DODGE_LANE_FILL = 0.85; + const HEATMAP_SCHEME_COLORS: Record = { viridis: ['#440154', '#fde725'], inferno: ['#000004', '#fcffa4'], @@ -293,11 +301,12 @@ export const groupedBarChartDef: ChartTemplateDef = { { joinaggregate: [{ op: 'distinct', field: groupField, as: '__localCount' }], groupby: [axisField] }, { calculate: `((datum.__laneIdx - 1) - (datum.__localCount - 1) / 2) / ${maxPB}`, as: '__off' }, ]; - // Constant bar width ≈ 85% of a lane. VL's band reserves ~20% - // padding, so the usable per-lane pitch is (band·0.8 / maxPerBand). + // Constant bar width ≈ LOCAL_DODGE_LANE_FILL of a lane. VL's + // band reserves ~20% padding, so the usable per-lane pitch is + // (band·0.8 / maxPerBand). const band = offsetCh === 'xOffset' ? ctx.layout?.xStep : ctx.layout?.yStep; if (band) { - spec.mark = setMarkProp(spec.mark, 'size', Math.max(2, Math.round((band * 0.8 / maxPB) * 0.85))); + spec.mark = setMarkProp(spec.mark, 'size', Math.max(2, Math.round((band * 0.8 / maxPB) * LOCAL_DODGE_LANE_FILL))); } } } @@ -366,6 +375,7 @@ export const stackedBarChartDef: ChartTemplateDef = { } } } + alignStackOrderToColorOrder(spec, ctx); adjustBarMarks(spec, ctx); }, properties: [ @@ -405,6 +415,12 @@ export const histogramDef: ChartTemplateDef = { }, channels: ["x", "color", "column", "row"], markCognitiveChannel: 'length', + // A binned x is an index axis, not a measure: the reader keys counts off + // its intervals, and its identity comes from banding even though the field + // is quantitative. Declaring it banded keeps the count off it and stops a + // house that seats its *measure* axis opposite (economist's right/top) from + // flipping the bins to the top of the plot. + declareLayoutMode: () => ({ axisFlags: { x: { banded: true } } }), instantiate: (spec, ctx) => { defaultBuildEncodings(spec, ctx.resolvedEncodings); // `binCount` is the maxbins cap; 0 (auto) leaves the template's `bin: true` @@ -437,9 +453,14 @@ export const heatmapDef: ChartTemplateDef = { template: { mark: "rect", encoding: {} }, channels: ["x", "y", "color", "column", "row"], markCognitiveChannel: 'color', - declareLayoutMode: (_cs, _table, chartProperties) => { + ownsValueLabels: true, + declareLayoutMode: (_channelSemantics, _table, chartProperties) => { const showTextLabels = !!chartProperties?.showTextLabels; return { + // Heatmap positions are cells, regardless of whether their labels + // are categories, numbers, or dates. Temporal axes keep a temporal + // scale for tick semantics while the dynamic layout budgets one + // discrete slot per observed value (continuous-as-discrete). axisFlags: { x: { banded: true }, y: { banded: true } }, // Labels need slightly larger cells so the value text isn't crushed, // but we keep this close to the unlabeled defaults (minStep 6 / @@ -458,9 +479,17 @@ export const heatmapDef: ChartTemplateDef = { const colorField = spec.encoding?.color?.field; const colorVals = colorField ? ctx.table - .map((r: any) => Number(r[colorField])) + .map((r: any) => r[colorField]) + .filter((v: any) => v != null && v !== '') + .map((v: any) => Number(v)) .filter((v: number) => Number.isFinite(v)) : []; + const hasMissingValues = colorField + ? ctx.table.some((r: any) => { + const value = r[colorField]; + return value == null || value === '' || !Number.isFinite(Number(value)); + }) + : false; const observedMin = colorVals.length > 0 ? Math.min(...colorVals) : 0; const observedMax = colorVals.length > 0 ? Math.max(...colorVals) : 1; const existingScheme = spec.encoding?.color?.scale?.scheme; @@ -478,8 +507,14 @@ export const heatmapDef: ChartTemplateDef = { && !semanticIsDiverging && !isDivergingHeatmapScheme(existingScheme) && colorEncodingType !== 'nominal'; + // A diverging heatmap has a polarity, and the polarity is a reading of + // the field: warm at the top for an intensity, red at the bottom for a + // loss (see the diverging note in semantic-types). That call has + // already been made upstream, so take the scheme it named rather than + // pinning one here — a hard-coded default lands cold-red on every + // temperature grid we draw. const schemeName = userScheme - || (semanticIsDiverging ? (existingScheme || 'redblue') : undefined) + || (semanticIsDiverging ? (existingScheme || semanticScheme?.scheme || 'redblue') : undefined) || (shouldUseHeatmapDefault ? DEFAULT_HEATMAP_SCHEME : existingScheme); const isDiverging = isDivergingHeatmapScheme(schemeName); const intrinsicDomain = getSafeHeatmapIntrinsicDomain(ctx, colorField); @@ -492,12 +527,20 @@ export const heatmapDef: ChartTemplateDef = { if (schemeName) { spec.encoding.color.scale.scheme = schemeName; } - if (isDiverging && effectiveMin < 0 && effectiveMax > 0) { - const sym = Math.max(Math.abs(effectiveMin), Math.abs(effectiveMax)); - effectiveMin = -sym; - effectiveMax = sym; - spec.encoding.color.scale.domain = [-sym, sym]; - spec.encoding.color.scale.domainMid = 0; + // A diverging grid has to be symmetric about its pivot, or one arm + // of the ramp reaches further than the other and equal distances + // from the pivot read as unequal. The pivot is not always zero — + // an author can say what the reader is comparing against — so + // centre on whatever was resolved rather than on the origin. + const pivot = spec.encoding.color.scale.domainMid + ?? semanticScheme?.domainMid + ?? 0; + if (isDiverging && effectiveMin < pivot && effectiveMax > pivot) { + const sym = Math.max(pivot - effectiveMin, effectiveMax - pivot); + effectiveMin = pivot - sym; + effectiveMax = pivot + sym; + spec.encoding.color.scale.domain = [effectiveMin, effectiveMax]; + spec.encoding.color.scale.domainMid = pivot; } else if (intrinsicDomain) { // Sequential color with a known intrinsic domain (e.g. a // Percentage field with [0, 100]). Don't force the full @@ -516,10 +559,13 @@ export const heatmapDef: ChartTemplateDef = { adjustBarMarks(spec, ctx); adjustRectTiling(spec, ctx); - if (showTextLabels && spec.encoding?.color?.field) { + if ((showTextLabels || hasMissingValues) && spec.encoding?.color?.field) { const baseEncoding = spec.encoding || {}; const xEncoding = baseEncoding.x; const yEncoding = baseEncoding.y; + const colorValue = `datum[${JSON.stringify(colorField)}]`; + const validValue = `isValid(${colorValue}) && ${colorValue} !== ''`; + const missingValue = `!(${validValue})`; const span = effectiveMax - effectiveMin; const cellMinDim = Math.min(ctx.layout.xStep || 50, ctx.layout.yStep || 50); @@ -537,53 +583,89 @@ export const heatmapDef: ChartTemplateDef = { : effectiveMin + span * 0.6) : undefined; - spec.layer = [ - { + if (hasMissingValues) { + // Keep no-data styling on the original rect encoding. A + // separate missing-value layer owns its own X/Y definitions; + // even with shared scales, that layer then participates in + // axis inference and can disturb a transposed temporal axis. + spec.encoding.color = { + ...spec.encoding.color, + condition: { test: missingValue, value: '#8c8c8c' }, + }; + spec.encoding.opacity = { + condition: { test: missingValue, value: 0.32 }, + value: 1, + }; + } + + if (showTextLabels) { + const defaultTextColor = isDiverging + ? 'black' + : (highIsLight ? 'white' : 'black'); + const textColorConditions: any[] = [ + ...(hasMissingValues + ? [{ test: missingValue, value: '#8c8c8c' }] + : []), + ...(strongThreshold == null + ? [] + : [{ + test: isDiverging + ? `${colorValue} > ${strongThreshold} || ${colorValue} < ${-strongThreshold}` + : `${colorValue} >= ${strongThreshold}`, + value: isDiverging + ? 'white' + : (highIsLight ? 'black' : 'white'), + }]), + ]; + const layers: any[] = [{ mark: spec.mark, encoding: { ...(xEncoding ? { x: xEncoding } : {}), ...(yEncoding ? { y: yEncoding } : {}), - ...(baseEncoding.color ? { color: baseEncoding.color } : {}), + ...(baseEncoding.color ? { color: spec.encoding.color } : {}), + ...(spec.encoding.opacity ? { opacity: spec.encoding.opacity } : {}), }, - }, - { + }, { mark: { type: 'text', align: 'center', baseline: 'middle', fontSize: labelFontSize, + clip: true, }, encoding: { ...(xEncoding ? { x: xEncoding } : {}), ...(yEncoding ? { y: yEncoding } : {}), text: { + ...(hasMissingValues + ? { condition: { test: missingValue, value: '—' } } + : {}), field: colorField, type: 'quantitative', format: labelFormat, }, - color: strongThreshold == null - ? { value: 'black' } - : { - condition: { - test: isDiverging - ? `datum.${colorField} > ${strongThreshold} || datum.${colorField} < ${-strongThreshold}` - : `datum.${colorField} >= ${strongThreshold}`, - value: isDiverging - ? 'white' - : (highIsLight ? 'black' : 'white'), - }, - value: isDiverging - ? 'black' - : (highIsLight ? 'white' : 'black'), - }, + color: textColorConditions.length > 0 + ? { condition: textColorConditions, value: defaultTextColor } + : { value: defaultTextColor }, }, - }, - ]; - delete spec.mark; + }]; + + spec.layer = layers; + delete spec.mark; + + // Facets remain shared by the layered unit, but X/Y/color now + // live on the individual layers. + const sharedEncoding = { + ...(baseEncoding.column ? { column: baseEncoding.column } : {}), + ...(baseEncoding.row ? { row: baseEncoding.row } : {}), + }; + if (Object.keys(sharedEncoding).length > 0) spec.encoding = sharedEncoding; + else delete spec.encoding; + } } }, properties: [ - { key: 'showTextLabels', label: 'Labels', type: 'binary', defaultValue: false }, + { key: 'showValueLabels', label: 'Values', type: 'binary', defaultValue: false }, ] as ChartPropertyDef[], // Color scheme is an encoding-level edit (writes encoding.scheme on the // color channel), so it is exposed as a Category-B encoding action rather diff --git a/packages/flint-js/src/vegalite/templates/bump.ts b/packages/flint-js/src/vegalite/templates/bump.ts index f84cd9ae..351dfe09 100644 --- a/packages/flint-js/src/vegalite/templates/bump.ts +++ b/packages/flint-js/src/vegalite/templates/bump.ts @@ -3,6 +3,7 @@ import { ChartTemplateDef } from '../../core/types'; import { defaultBuildEncodings } from './utils'; +import { interpolateConfigProperty, applyInterpolate } from './line'; /** Semantic types that indicate a rank-like field */ const RANK_SEMANTIC_TYPES = new Set(['Rank', 'Score', 'Level']); @@ -13,17 +14,25 @@ const isDiscrete = (type: string | undefined) => export const bumpChartDef: ChartTemplateDef = { chart: "Bump Chart", template: { - mark: { type: "line", point: true, interpolate: "monotone", strokeWidth: 2 }, + mark: { type: "line", point: true, interpolate: "linear", strokeWidth: 2 }, encoding: {}, }, channels: ["x", "y", "color", "detail", "column", "row"], markCognitiveChannel: 'position', + properties: [interpolateConfigProperty], declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 80, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.4 }, }), instantiate: (spec, ctx) => { defaultBuildEncodings(spec, ctx.resolvedEncodings); + // Straight segments between one standing and the next. A curve would + // draw a rank the reader can point at halfway between two Games, and + // there was no such rank — nothing was measured between them. A caller + // or a house that wants the softer read says so through the curve + // option. + spec.mark = applyInterpolate(spec.mark, ctx.chartProperties); + const xEnc = spec.encoding?.x; const yEnc = spec.encoding?.y; if (!xEnc || !yEnc) return; @@ -62,5 +71,79 @@ export const bumpChartDef: ChartTemplateDef = { type: yEnc.type || "quantitative", }; } + + applyRankScale(spec.encoding[rankAxis], ctx, rankAxis); + padSequenceEnds(spec.encoding[rankAxis === 'y' ? 'x' : 'y']); }, }; + +/** + * A rank runs from first to last, and there is no zeroth place. + * + * The axis a bump chart is read against is a standing, whatever the field was + * tagged as — the template picked it out for exactly that reason. So it is + * fitted to the standings that exist: an author's declared bounds if there are + * any, otherwise the ranks in the data. A zero baseline here is not a + * conservative choice, it is a tick for a position nobody can finish in, and + * it costs the chart a fifth of its height. + * + * Both ends then get a little air. First place drawn on the frame reads as + * clipped rather than as first, and the label riding on it has nowhere to sit. + */ +function applyRankScale(enc: any, ctx: any, rankAxis: 'x' | 'y'): void { + if (!enc?.field || enc.type !== 'quantitative') return; + + const declared = ctx.channelSemantics?.[rankAxis]?.semanticAnnotation?.intrinsicDomain; + let domain: [number, number] | undefined = Array.isArray(declared) && declared.length === 2 + ? [declared[0], declared[1]] + : undefined; + + if (!domain) { + let min = Infinity; + let max = -Infinity; + for (const row of ctx.table ?? []) { + const v = Number(row?.[enc.field]); + if (!Number.isFinite(v)) continue; + if (v < min) min = v; + if (v > max) max = v; + } + if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return; + domain = [min, max]; + } + + enc.scale = { ...enc.scale, domain, zero: false, nice: false, padding: RANK_END_PAD }; + + // Every place gets its own tick while there are few enough of them to + // name. A generic tick count on a rank axis lands on 2, 4, 6 and leaves + // first place — the one line the reader came for — with no label at all. + const [lo, hi] = domain; + if (Number.isInteger(lo) && Number.isInteger(hi) && hi - lo <= MAX_NAMED_RANKS) { + const values: number[] = []; + for (let v = lo; v <= hi; v++) values.push(v); + // The count travels with the values: a house that asks for four ticks + // on its value axes means four on a measured axis, but here the ticks + // are the places themselves, and Vega thins a named list down to the + // house count unless it is told how many names there are. + enc.axis = { ...enc.axis, values, tickCount: values.length }; + } +} + +/** Ranks we will label one by one before falling back to the axis's own count. */ +const MAX_NAMED_RANKS = 11; + +/** Room at the ends of the scale, in pixels. */ +const RANK_END_PAD = 14; +const SEQUENCE_END_PAD = 10; + +/** + * Air at the start and the end of the sequence axis. + * + * The first reading sits on the value axis and the last sits on the frame, + * which puts a point and its name in the same pixels as the axis labels. The + * padding is what the reader would leave if they were drawing it by hand. + */ +function padSequenceEnds(enc: any): void { + if (!enc?.field || enc.type === 'nominal' || enc.type === 'ordinal') return; + if (enc.scale?.padding != null) return; + enc.scale = { ...enc.scale, padding: SEQUENCE_END_PAD }; +} diff --git a/packages/flint-js/src/vegalite/templates/candlestick.ts b/packages/flint-js/src/vegalite/templates/candlestick.ts index 10493a5e..0fdb598d 100644 --- a/packages/flint-js/src/vegalite/templates/candlestick.ts +++ b/packages/flint-js/src/vegalite/templates/candlestick.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { ChartTemplateDef } from '../../core/types'; +import { adjustBarMarks } from './utils'; export const candlestickChartDef: ChartTemplateDef = { chart: "Candlestick Chart", @@ -44,28 +45,55 @@ export const candlestickChartDef: ChartTemplateDef = { if (close) spec.layer[1].encoding.y2 = { field: close.field }; if (open?.field && close?.field) { + // `<=`, not `<`: a session that closes exactly where it opened has + // not fallen, and colouring it as a decline is a false statement. spec.encoding.color = { condition: { - test: `datum['${open.field}'] < datum['${close.field}']`, + test: `datum['${open.field}'] <= datum['${close.field}']`, value: "#06982d", }, value: "#ae1325", }; } - // Compute bar width from x-axis cardinality - const table = ctx.table; - const plotWidth = ctx.canvasSize?.width || 400; - const xField = spec.encoding?.x?.field; - let barSize: number; - - if (xField && table?.length > 0) { - const cardinality = new Set(table.map((r: any) => r[xField])).size; - barSize = Math.max(2, Math.min(20, Math.round(plotWidth * 0.6 / cardinality))); + // Body width. + // + // On a banded *continuous* x — the usual case, dates — the slot width + // is set by the smallest gap between observations, not by the row + // count: nine trading days spanning eleven calendar days occupy eleven + // slots, two of which are the weekend. Sizing on cardinality makes + // every body wider than its own slot and adjacent candles fuse into a + // single polygon. adjustBarMarks() already performs that min-gap + // analysis for bar marks, so use it rather than keep a second, wrong + // copy of the arithmetic here. + // + // It returns the largest *non-overlapping* size, and bodies that + // merely touch still read as one shape when consecutive sessions move + // the same way. A candlestick needs a visible gutter, so take a + // fraction of the fitted width. + const BODY_FILL = 0.8; + if ((ctx.layout?.xContinuousAsDiscrete ?? 0) > 0) { + adjustBarMarks(spec, ctx); + const fitted = (spec.layer[1].mark as { size?: number })?.size ?? 14; + spec.layer[1].mark = { ...spec.layer[1].mark, size: Math.max(2, Math.floor(fitted * BODY_FILL)) }; } else { - barSize = 14; + const step = ctx.layout?.xStep ?? 20; + spec.layer[1].mark = { ...spec.layer[1].mark, size: Math.max(2, Math.round(step * BODY_FILL)) }; } - spec.layer[1].mark = { ...spec.layer[1].mark, size: barSize }; + // Doji sessions. + // + // When open === close the open→close bar has zero height and vanishes, + // so a flat session renders as a bare wick with no candle on it. Draw + // it as a horizontal tick at the shared price, which is the convention + // and is exactly what the bar degenerates to. + if (open?.field && close?.field) { + const bodySize = (spec.layer[1].mark as { size?: number })?.size ?? 14; + spec.layer.push({ + transform: [{ filter: `datum['${open.field}'] === datum['${close.field}']` }], + mark: { type: "tick", size: bodySize, thickness: 2 }, + encoding: { y: { field: close.field } }, + }); + } }, }; diff --git a/packages/flint-js/src/vegalite/templates/connected-scatter.ts b/packages/flint-js/src/vegalite/templates/connected-scatter.ts index 151a7ff4..cf17b03d 100644 --- a/packages/flint-js/src/vegalite/templates/connected-scatter.ts +++ b/packages/flint-js/src/vegalite/templates/connected-scatter.ts @@ -59,7 +59,7 @@ function resolveOrderType( export const connectedScatterDef: ChartTemplateDef = { chart: "Connected Scatter Plot", template: { - mark: { type: "line", point: true, interpolate: "linear", strokeWidth: 2 }, + mark: { type: "line", point: true, interpolate: "linear" }, encoding: {}, }, channels: ["x", "y", "order", "color", "detail", "column", "row"], diff --git a/packages/flint-js/src/vegalite/templates/index.ts b/packages/flint-js/src/vegalite/templates/index.ts index 2fe6ace4..d364e567 100644 --- a/packages/flint-js/src/vegalite/templates/index.ts +++ b/packages/flint-js/src/vegalite/templates/index.ts @@ -210,6 +210,42 @@ const AXIS_DTYPE_PROPERTIES: ChartPropertyDef[] = [ }, ]; +/** + * The reader's answer to "print the numbers on the marks?". + * + * A plain switch, seeded from what the house and the density already decided: + * the compiler supplies the recommended default at assembly time (same shape as + * `independentYAxis`), so an untouched control shows the theme's own habit and + * flipping it is an explicit decision about *this* chart. + * + * Switching it on is still not a licence to overprint — it inherits the same + * hard density ceiling the houses obey. And where that ceiling is already + * breached the control is reported inapplicable rather than offered inert: the + * compiler answers this from the grounded `dataLabels.possible`, so what the + * host shows can never drift from what the compiler would do. + */ +const VALUE_LABEL_PROPERTIES: ChartPropertyDef[] = [ + { + key: 'showValueLabels', label: 'Values', type: 'binary', + defaultValue: false, + // Both applicability and the recommended default are measured, not + // guessed: they need the resolved layout (band width) and the house's + // policy, neither of which exists this early. + check: () => ({ applicable: false }), + }, +]; + +/** + * Charts that can print one number per mark: a banded axis to key values to, + * or wedges of a whole. Stacked bars are absent on purpose — a number at a + * segment edge reads as the running total, and the compiler refuses to print + * them for the same reason. + */ +const VALUE_LABEL_CHARTS = new Set([ + 'Bar Chart', 'Grouped Bar Chart', 'Stacked Bar Chart', 'Lollipop Chart', 'Pyramid Chart', + 'Pie Chart', 'Donut Chart', 'Rose Chart', 'Heatmap', 'Waterfall Chart', +]); + /** * Attach the cross-cutting properties (faceting, log scale, axis dtype) a * template qualifies for, based on its channels and mark-cognitive role. Keeps @@ -227,6 +263,7 @@ function withInjectedProperties(def: ChartTemplateDef): ChartTemplateDef { ...(isPosition ? LOG_SCALE_PROPERTIES : []), ...(isPosition ? ZERO_BASELINE_PROPERTIES : []), ...(wantsAxisDtype ? AXIS_DTYPE_PROPERTIES : []), + ...(VALUE_LABEL_CHARTS.has(def.chart) ? VALUE_LABEL_PROPERTIES : []), ]; if (extra.length === 0) return def; const ownKeys = new Set((def.properties ?? []).map(p => p.key)); diff --git a/packages/flint-js/src/vegalite/templates/kpi-card.ts b/packages/flint-js/src/vegalite/templates/kpi-card.ts index be2ffb2a..e68df82e 100644 --- a/packages/flint-js/src/vegalite/templates/kpi-card.ts +++ b/packages/flint-js/src/vegalite/templates/kpi-card.ts @@ -372,6 +372,10 @@ export const kpiCardDef: ChartTemplateDef = { : PROGRESS_ON_TRACK; layers.push({ + // Only the exceeded state paints this line a status hue; + // otherwise it is ordinary caption grey and re-tones with + // the rest of the card's text. + ...(isExceeded ? { __themeRole: 'positive' } : {}), data: { values: [{}] }, mark: { type: 'text', @@ -410,6 +414,11 @@ export const kpiCardDef: ChartTemplateDef = { // that the goal was exceeded. const fillEnd = barLeft + Math.min(1, pct) * barWidth; layers.push({ + // The bar is the only part of the card that carries a + // measurement, so it takes the house's ink: its accent + // where the reading is simply in progress, and the + // house's status inks where the reading has a verdict. + __themeRole: isExceeded ? 'positive' : isBehind ? 'negative' : 'accent', data: { values: [{}] }, mark: { type: 'rect', diff --git a/packages/flint-js/src/vegalite/templates/line.ts b/packages/flint-js/src/vegalite/templates/line.ts index 484d1a5a..c61a34ca 100644 --- a/packages/flint-js/src/vegalite/templates/line.ts +++ b/packages/flint-js/src/vegalite/templates/line.ts @@ -28,11 +28,38 @@ export function applyInterpolate(mark: any, config?: Record): any { return setMarkProp(mark, 'interpolate', config.interpolate); } -function applyShowPoints(mark: any, config?: Record): any { - if (!config?.showPoints) return mark; +function applyShowPoints(mark: any, ctx: InstantiateContext): any { + if (!ctx.chartProperties?.showPoints) return mark; + // Points on a line name where a value was measured. Past the density at + // which they touch, they stop being points and become a texture that buries + // the line under them — a house habit meeting a fact about fit. Yield the + // overlay when the readings pack tighter than a small dot can sit apart. + if (pointsTooDense(ctx)) return mark; return setMarkProp(mark, 'point', true); } +/** + * True when a line carries more readings per series than can be drawn as + * separate dots: the per-series point spacing falls below the width a small + * dot needs to read as one. Measured at the base width (the honest floor before + * any stretch), against the densest reasonable series estimate (rows ÷ series). + */ +function pointsTooDense(ctx: InstantiateContext): boolean { + const rows = ctx.table?.length ?? 0; + if (rows === 0) return false; + const seriesField = ctx.resolvedEncodings?.color?.field ?? ctx.resolvedEncodings?.detail?.field; + let series = 1; + if (seriesField) { + const seen = new Set(); + for (const r of ctx.table) seen.add(r[seriesField]); + series = Math.max(1, seen.size); + } + const pointsPerSeries = rows / series; + const width = ctx.canvasSize?.width ?? 300; + const spacing = width / Math.max(1, pointsPerSeries); + return spacing < 8; +} + function isContinuousColor(ctx: InstantiateContext): boolean { const color = ctx.resolvedEncodings.color; if (!color?.field) return false; @@ -101,7 +128,7 @@ export const lineChartDef: ChartTemplateDef = { } defaultBuildEncodings(spec, ctx.resolvedEncodings); spec.mark = applyInterpolate(spec.mark, ctx.chartProperties); - spec.mark = applyShowPoints(spec.mark, ctx.chartProperties); + spec.mark = applyShowPoints(spec.mark, ctx); }, properties: [interpolateConfigProperty, showPointsProperty], // No `transpose`: a line pins its domain to `x` (never a vertical line, for any diff --git a/packages/flint-js/src/vegalite/templates/pie.ts b/packages/flint-js/src/vegalite/templates/pie.ts index d8eb4c3b..d611efe7 100644 --- a/packages/flint-js/src/vegalite/templates/pie.ts +++ b/packages/flint-js/src/vegalite/templates/pie.ts @@ -108,7 +108,26 @@ export const pieChartDef: ChartTemplateDef = { ] as ChartPropertyDef[], }; +/** The hole a Donut Chart gets when the caller sets no `innerRadius`. */ +const DONUT_DEFAULT_INNER_RADIUS = 50; + export const donutChartDef: ChartTemplateDef = { ...pieChartDef, chart: "Donut Chart", + // A donut is a pie with a hole. Property defaults are not merged into + // `chartProperties` at assemble time (only discrete options are coerced), + // so a Donut Chart authored without an explicit `innerRadius` would inherit + // the pie's hole-less 0 and render as a full pie. Carry a non-zero default + // and apply it here before delegating to the pie's instantiate. + properties: (pieChartDef.properties ?? []).map((p) => + p.key === 'innerRadius' ? { ...p, defaultValue: DONUT_DEFAULT_INNER_RADIUS } : p, + ) as ChartPropertyDef[], + instantiate: (spec, ctx) => { + const innerRadius = ctx.chartProperties?.innerRadius; + const withHole = + innerRadius == null + ? { ...ctx, chartProperties: { ...(ctx.chartProperties ?? {}), innerRadius: DONUT_DEFAULT_INNER_RADIUS } } + : ctx; + pieChartDef.instantiate(spec, withHole); + }, }; diff --git a/packages/flint-js/src/vegalite/templates/radar.ts b/packages/flint-js/src/vegalite/templates/radar.ts index ef491dc1..31682cbf 100644 --- a/packages/flint-js/src/vegalite/templates/radar.ts +++ b/packages/flint-js/src/vegalite/templates/radar.ts @@ -230,9 +230,14 @@ function buildRadarLayers( }, }; if (groups.length > 1 && groupField) { + // Stroke, fill and point colour all carry `__group`; Vega-Lite merges + // them into a single legend for the field. Leaving the legend off any + // one of them (a `legend: null`) removes the key from *all* of them, + // because the layers share the colour scale — so the series would be + // unidentifiable. Let all three share the one merged legend. lineLayer.encoding.stroke = { field: "__group", type: "nominal", title: groupField }; if (filled) { - lineLayer.encoding.fill = { field: "__group", type: "nominal", title: groupField, legend: null }; + lineLayer.encoding.fill = { field: "__group", type: "nominal", title: groupField }; } } else if (filled) { lineLayer.mark.fill = "#4c78a8"; @@ -254,7 +259,7 @@ function buildRadarLayers( }, }; if (groups.length > 1 && groupField) { - pointLayer.encoding.color = { field: "__group", type: "nominal", title: groupField, legend: null }; + pointLayer.encoding.color = { field: "__group", type: "nominal", title: groupField }; } layers.push(pointLayer); diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index 0f8d3fe1..29a21f43 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -15,7 +15,17 @@ const isDiscreteType = (t: string | undefined) => t === 'nominal' || t === 'ordi // fills most of its category band; a grouped (dodged) box fills most of its // per-subgroup lane. The remainder becomes the gap between adjacent boxes. const BOXPLOT_BAND_FILL = 0.7; -const GROUPED_BOXPLOT_LANE_FILL = 0.85; +// A dodged box should leave a legible gap between adjacent lanes, otherwise a +// quartet of boxes reads as one solid multi-colour block. 0.7 keeps the box +// substantial while opening a clear ~30%-of-lane channel between neighbours. +const GROUPED_BOXPLOT_LANE_FILL = 0.7; +// Half-width of the raw-observation jitter cloud, as a fraction of one lane. +// 0.3 spreads the points across the middle ~60% of the lane, so the cloud sits +// inside its box rather than spilling over the neighbouring one. +const POINT_JITTER_FRACTION = 0.3; +// Ink for the box skeleton once the box is hollow — dark and neutral, so the +// median reads as a summary statistic rather than as another category. +const SILHOUETTE_INK = '#2b2f36'; // Vega-Lite's default discrete position band scale reserves ~20% of each step as // inter-band padding, so only ~80% of the step is usable drawing width. Grouped // box sizing must use this usable width when splitting a band into sub-lanes, @@ -206,7 +216,7 @@ export const boxplotDef: ChartTemplateDef = { return { axisFlags: { [result.axis]: { banded: true } }, resolvedTypes: result.resolvedTypes, - paramOverrides: { defaultBandSize: 28 }, // box+whisker needs wider bands + paramOverrides: { defaultBandSize: 28, groupBandFillsLanes: true }, // box+whisker needs wider bands; grouped lanes each get full width colorActsAsGroup, // dodge-by-color → budget band per category, shrink lanes ...(groupLaneCount ? { groupLaneCount } : {}), }; @@ -214,25 +224,52 @@ export const boxplotDef: ChartTemplateDef = { instantiate: (spec, ctx) => { defaultBuildEncodings(spec, ctx.resolvedEncodings); + const props = ctx.chartProperties; + const layout = ctx.layout; + const hasDiscreteX = layout.xNominalCount > 0; + const hasDiscreteAxis = hasDiscreteX || layout.yNominalCount > 0; + + // `showPoints` overlays every raw observation on top of the box. A box + // summarises a sample; at small n it can summarise almost nothing, so + // being able to show the sample is what makes the summary honest. + // Jitter needs a band to scatter within, hence the discrete-axis gate. + const showPoints = props?.showPoints === true && hasDiscreteAxis; + // Whisker convention + outlier visibility (design choices, not styling). // whiskerMethod 'minmax' → whiskers span the full data range; VL draws // no outlier points (they are inside the whiskers by definition). // whiskerMethod 'iqr' (default) → Tukey 1.5×IQR whiskers; points beyond // the fences render as outliers unless suppressed. - const props = ctx.chartProperties; const useMinMax = props?.whiskerMethod === 'minmax'; if (useMinMax) { spec.mark = setMarkProp(spec.mark, 'extent', 'min-max'); } // `showOutliers` defaults to true. With min-max whiskers there are no - // outliers anyway, so hiding them is implicit. - if (useMinMax || props?.showOutliers === false) { + // outliers anyway, so hiding them is implicit — and when every point is + // already drawn, the outlier marks would be duplicates. + if (useMinMax || props?.showOutliers === false || showPoints) { spec.mark = setMarkProp(spec.mark, 'outliers', false); } - const layout = ctx.layout; - const hasDiscreteX = layout.xNominalCount > 0; - const hasDiscreteAxis = hasDiscreteX || layout.yNominalCount > 0; + // Drawing the sample inverts the visual hierarchy. Normally the box is + // the figure and there is nothing behind it; once every observation is + // on the page the sample becomes the figure and the box demotes to + // scaffolding over it. So the box gives up its fill and keeps only an + // outline, and the colour encoding moves to the points — encoding the + // group twice, in fill and in point colour, would just be redundant ink. + // `filled: false` is the switch that redirects the colour encoding from + // the box's fill to its stroke. + if (showPoints) { + spec.mark = setMarkProp(spec.mark, 'box', { filled: false, strokeWidth: 1.5 }); + // The median rule is white by default so it reads against a filled + // box; over a hollow one it would disappear. It is the single + // most-read feature of a boxplot, so it gets the darkest ink at + // full strength (the boxplot theme dims marks to 0.7, which would + // let the point cloud show through it). + spec.mark = setMarkProp(spec.mark, 'median', { + color: SILHOUETTE_INK, strokeWidth: 2, opacity: 1, + }); + } // Grouped boxplots: a color field subdividing a categorical axis must // dodge the boxes side-by-side (xOffset/yOffset), not overlay them at the @@ -246,6 +283,13 @@ export const boxplotDef: ChartTemplateDef = { let subgroups = 1; let localSeparatorAxis: 'x' | 'y' | undefined; let localSeparatorValues: Record[] = []; + // How far a box is pushed off its band centre, as an expression in + // offset-scale domain units (the [-0.5, 0.5] domain spans one band, so + // one lane is `1 / subgroups` wide). '0' = undodged, sits dead centre. + // The point overlay reuses this so a point always lands on its own box. + let laneOffsetExpr = '0'; + // Transforms the lane expression depends on, replayed on the point layer. + let laneTransforms: Record[] = []; const colorField = ctx.channelSemantics?.color?.field; const axisField = hasDiscreteX ? ctx.channelSemantics?.x?.field @@ -267,19 +311,44 @@ export const boxplotDef: ChartTemplateDef = { // [-0.5, 0.5] range places each band's boxes centered, using // only maxPerBand lanes. Native axis labels stay centered. const maxPB = Math.max(1, plan.maxPerBand); + laneTransforms = [ + { window: [{ op: 'dense_rank', as: '__laneIdx' }], groupby: [axisField], sort: [{ field: colorField, order: 'ascending' }] }, + { joinaggregate: [{ op: 'distinct', field: colorField, as: '__localCount' }], groupby: [axisField] }, + ]; + laneOffsetExpr = `((datum.__laneIdx - 1) - (datum.__localCount - 1) / 2) / ${maxPB}`; spec.encoding[offsetChannel] = { field: '__off', type: 'quantitative', scale: { domain: [-0.5, 0.5] }, axis: null, }; spec.transform = [ ...(spec.transform ?? []), - { window: [{ op: 'dense_rank', as: '__laneIdx' }], groupby: [axisField], sort: [{ field: colorField, order: 'ascending' }] }, - { joinaggregate: [{ op: 'distinct', field: colorField, as: '__localCount' }], groupby: [axisField] }, - { calculate: `((datum.__laneIdx - 1) - (datum.__localCount - 1) / 2) / ${maxPB}`, as: '__off' }, + ...laneTransforms, + { calculate: laneOffsetExpr, as: '__off' }, ]; localSeparatorAxis = hasDiscreteX ? 'x' : 'y'; const categories = [...new Set((ctx.fullTable ?? ctx.table).map((row) => row[axisField]))]; localSeparatorValues = categories.slice(0, -1).map((category) => ({ [axisField]: category })); + } else if (showPoints) { + // Global lanes, with a point overlay. A *nominal* offset can + // carry a lane but not the extra jitter the points need, and + // one channel admits one scale — so resolve the lane index in + // the spec instead and let both layers share one quantitative + // offset. Lane order follows the declared colour sort, so the + // lanes still match the legend. + const laneOrder = (Array.isArray(colorEnc.sort) && colorEnc.sort.length > 0 + ? colorEnc.sort + : [...new Set((ctx.fullTable ?? ctx.table).map((row) => row[colorField]))].sort() + ).map((value: unknown) => String(value)); + subgroups = Math.max(1, laneOrder.length); + laneOffsetExpr = `(indexof(${JSON.stringify(laneOrder)}, toString(datum[${JSON.stringify(colorField)}])) - ${(subgroups - 1) / 2}) / ${subgroups}`; + spec.encoding[offsetChannel] = { + field: '__off', type: 'quantitative', + scale: { domain: [-0.5, 0.5] }, axis: null, + }; + spec.transform = [ + ...(spec.transform ?? []), + { calculate: laneOffsetExpr, as: '__off' }, + ]; } else { // Global: a fixed lane per distinct color across all bands. const offsetEnc: Record = { field: colorEnc.field, type: 'nominal' }; @@ -306,22 +375,73 @@ export const boxplotDef: ChartTemplateDef = { } } + let separatorLayer: Record | undefined; if (localSeparatorAxis && localSeparatorValues.length > 0) { - const boxLayer = { mark: spec.mark, encoding: spec.encoding, transform: spec.transform }; const axisEncoding = spec.encoding[localSeparatorAxis]; - spec.layer = [ - { - data: { values: localSeparatorValues }, - mark: { type: 'rule', stroke: '#c9ced6', strokeDash: [4, 4], strokeWidth: 1, opacity: 0.75 }, - encoding: { - [localSeparatorAxis]: { - field: axisField, - type: 'nominal', - sort: axisEncoding.sort, - bandPosition: 1, - }, + separatorLayer = { + data: { values: localSeparatorValues }, + mark: { type: 'rule', stroke: '#c9ced6', strokeDash: [4, 4], strokeWidth: 1, opacity: 0.75 }, + encoding: { + [localSeparatorAxis]: { + field: axisField, + type: 'nominal', + sort: axisEncoding.sort, + bandPosition: 1, + }, + }, + }; + } + + // The raw-observation overlay. Points ride on the same offset channel as + // the boxes: lane position (so a point sits on its own box) plus jitter + // (so coincident values do not stack into one dot). The offset scale's + // [-0.5, 0.5] domain maps onto the band, which keeps the cloud centred + // whatever the band width works out to — measuring jitter in pixels + // silently drifts off-centre when the layout changes the step. + let pointLayer: Record | undefined; + if (showPoints) { + const offsetChannel = hasDiscreteX ? 'xOffset' : 'yOffset'; + const lanePitch = ((hasDiscreteX ? layout.xStep : layout.yStep) * USABLE_BAND_FRACTION) / subgroups; + // `size` is point AREA in px²; keep the glyph well inside its lane. + const pointSize = Math.max(8, Math.min(30, Math.round(lanePitch * 0.6))); + const jitter = `(random() * 2 - 1) * ${(POINT_JITTER_FRACTION / subgroups).toFixed(5)}`; + pointLayer = { + transform: [ + ...laneTransforms, + { calculate: laneOffsetExpr === '0' ? jitter : `${laneOffsetExpr} + ${jitter}`, as: '__off' }, + ], + mark: { + // Points now carry the colour encoding (see the silhouette + // note above), so they are the one mark spending saturated + // ink. Slight transparency lets dense regions read as + // density; the hairline white halo keeps individual points + // countable where they overlap. + type: 'point', filled: true, size: pointSize, + opacity: 0.7, stroke: '#ffffff', strokeWidth: 0.5, + }, + encoding: { + ...(spec.encoding.x ? { x: JSON.parse(JSON.stringify(spec.encoding.x)) } : {}), + ...(spec.encoding.y ? { y: JSON.parse(JSON.stringify(spec.encoding.y)) } : {}), + ...(spec.encoding.color + ? { color: JSON.parse(JSON.stringify(spec.encoding.color)) } + : {}), + [offsetChannel]: { + field: '__off', type: 'quantitative', + scale: { domain: [-0.5, 0.5] }, axis: null, }, }, + }; + } + + if (separatorLayer || pointLayer) { + const boxLayer: Record = { mark: spec.mark, encoding: spec.encoding }; + if (spec.transform) boxLayer.transform = spec.transform; + // The hollow box goes on top of the cloud: it costs almost no ink to + // occlude, and the quartile edges and median have to stay crisp + // exactly where the points are densest. + spec.layer = [ + ...(separatorLayer ? [separatorLayer] : []), + ...(pointLayer ? [pointLayer] : []), boxLayer, ]; delete spec.mark; @@ -338,11 +458,24 @@ export const boxplotDef: ChartTemplateDef = { ], defaultValue: 'iqr', }, + { + key: 'showPoints', label: 'Points', type: 'binary', defaultValue: false, + // Jitter needs a band to scatter within, so this is only meaningful + // once one position axis is discrete. + check: (ctx) => ({ + applicable: isDiscreteType(ctx.channelSemantics?.x?.type) + || isDiscreteType(ctx.channelSemantics?.y?.type), + }), + }, { key: 'showOutliers', label: 'Outliers', type: 'binary', defaultValue: true, // Outliers exist only with Tukey whiskers; min–max whiskers absorb - // every point, so the toggle is irrelevant there. - check: (ctx) => ({ applicable: ctx.chartProperties?.whiskerMethod !== 'minmax' }), + // every point, so the toggle is irrelevant there. And once every + // observation is drawn, the outlier marks are just duplicates. + check: (ctx) => ({ + applicable: ctx.chartProperties?.whiskerMethod !== 'minmax' + && ctx.chartProperties?.showPoints !== true, + }), }, { key: 'dodge', label: 'Dodge', type: 'discrete', diff --git a/packages/flint-js/src/vegalite/templates/slope.ts b/packages/flint-js/src/vegalite/templates/slope.ts index bfd714ac..2530cf9c 100644 --- a/packages/flint-js/src/vegalite/templates/slope.ts +++ b/packages/flint-js/src/vegalite/templates/slope.ts @@ -25,7 +25,7 @@ * shows points at both ends. */ -import { ChartTemplateDef } from '../../core/types'; +import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { resolveDiscreteType } from '../../core/axis-detection'; import { defaultBuildEncodings } from './utils'; @@ -82,8 +82,12 @@ export const slopeChartDef: ChartTemplateDef = { paramOverrides: { // Spread the two periods well apart and keep the plot from being // squeezed tall: a wide band step + no series-count vertical - // stretch yields the classic balanced slopegraph framing. + // stretch yields the classic balanced slopegraph framing. The + // band step is also a floor (`minBandStep`): a compact house may + // spread the two columns wider, but not pack them so close the + // slopes go near-vertical and the end labels have no room. defaultBandSize: 120, + minBandStep: 120, continuousMarkCrossSection: { x: 0, y: 0, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.4, }, @@ -114,7 +118,9 @@ export const slopeChartDef: ChartTemplateDef = { // Inset the two period bands from the plot edges so the end points and // their value labels are not clipped (classic slopegraph framing). - xEnc.scale = { ...xEnc.scale, padding: 0.4 }; + const labelled = ctx.chartProperties?.showText === true; + const named = labelled && ctx.chartProperties?.showSeriesInLabel === true; + xEnc.scale = { ...xEnc.scale, padding: named ? 0.75 : labelled ? 0.55 : 0.4 }; // Give the value axis a little breathing room (in pixels) so the // extreme top / bottom end-point markers — common with zero-crossing @@ -123,5 +129,75 @@ export const slopeChartDef: ChartTemplateDef = { // data rather than anchoring at zero — matching the ECharts / Chart.js // slope templates and classic slopegraph convention. yEnc.scale = { ...yEnc.scale, zero: false, nice: true, padding: 12 }; + + // A slopegraph is a table that happens to be drawn. Its two columns of + // numbers are the point of it — the line only says which way the pair + // moved — so printing the values at both ends is the chart's own job, + // not an annotation added over it. And a value with no name attached is + // half a row: `showSeriesInLabel` puts the two back together and makes + // the colour legend redundant. + const props = ctx.chartProperties; + if (props?.showText === true) { + const periods = orderedDistinct(ctx.table, xEnc.field); + if (periods.length >= 2) { + const first = periods[0]; + const last = periods[periods.length - 1]; + const seriesField = ctx.channelSemantics?.color?.field + ?? ctx.channelSemantics?.detail?.field; + const withSeries = props.showSeriesInLabel === true && !!seriesField; + // Names on the marks make the colour legend redundant: it would + // only repeat, in a second place, the words already printed at + // each line's ends. Drop it so the plot is the whole story. + if (withSeries && (spec.encoding as any)?.color) { + (spec.encoding as any).color = { + ...(spec.encoding as any).color, legend: null, + }; + } + const fmt = props.labelFormat ?? '.3~s'; + const valueExpr = `format(datum[${JSON.stringify(yEnc.field)}], ${JSON.stringify(fmt)})`; + const labelExpr = withSeries + ? `datum[${JSON.stringify(seriesField)}] + ' ' + ${valueExpr}` + : valueExpr; + const endLayer = (period: unknown, align: 'left' | 'right') => ({ + transform: [ + { filter: { field: xEnc.field, equal: period as any } }, + { calculate: labelExpr, as: '__slopeLabel' }, + ], + mark: { + type: 'text', + align, + baseline: 'middle', + dx: align === 'right' ? -8 : 8, + fontSize: 11, + }, + encoding: { + ...JSON.parse(JSON.stringify(spec.encoding)), + text: { field: '__slopeLabel', type: 'nominal' }, + }, + }); + const lineLayer: Record = { mark: spec.mark, encoding: spec.encoding }; + if (spec.transform) lineLayer.transform = spec.transform; + spec.layer = [ + lineLayer, + endLayer(first, 'right'), + endLayer(last, 'left'), + ]; + delete spec.mark; + delete spec.encoding; + delete spec.transform; + } + } }, + properties: [ + { + key: 'showText', label: 'Values', type: 'binary', defaultValue: false, + }, + { + key: 'showSeriesInLabel', label: 'Name in label', type: 'binary', defaultValue: false, + // A name only goes in the label when there is a name to put there. + check: (ctx) => ({ + applicable: Boolean(ctx.encodings?.color?.field || ctx.encodings?.detail?.field), + }), + }, + ] as ChartPropertyDef[], }; diff --git a/packages/flint-js/src/vegalite/templates/utils.ts b/packages/flint-js/src/vegalite/templates/utils.ts index 4f9a7095..8e461699 100644 --- a/packages/flint-js/src/vegalite/templates/utils.ts +++ b/packages/flint-js/src/vegalite/templates/utils.ts @@ -79,6 +79,16 @@ export function setMarkProp(mark: any, key: string, value: any): any { return { ...mark, [key]: value }; } +/** + * Marks whose size came from the coarse coverage estimate in + * `applyPointSizeScaling`, which runs at build time against an assumed plot + * and the whole table. A theme knows the plot it actually got and how many + * panels the rows are spread over, so where both have an opinion the theme's + * is the better-informed one — but only for the marks this rule sized, never + * for a mark a template fitted to a lane. + */ +export const coverageSizedMarks = new WeakSet(); + /** * Coverage-based point sizing. */ @@ -107,6 +117,7 @@ export const applyPointSizeScaling = ( const size = Math.round(Math.max(minSize, (targetCoverage * plotArea) / n)); vgSpec.mark = setMarkProp(vgSpec.mark, 'size', size); + coverageSizedMarks.add(vgSpec.mark); return vgSpec; }; @@ -148,10 +159,71 @@ function maxNonOverlapSize( return Math.max(minSize, maxWidth); } +/** + * Make a stacked mark's segment order match its colour order. + * + * The assembler expresses "keep the order the data arrived in" as + * `color.sort: null`. That governs the legend, but NOT the stack: Vega-Lite + * derives the stack's sort from the colour *scale*, and a scale with no + * explicit domain falls back to sorting the field ascending — i.e. + * alphabetically. The result is a chart whose legend reads + * `A great deal, Some, Not much, None at all` while its bars stack + * `A great deal, None at all, Not much, Some`. For any ordered series + * (a Likert scale, an age band, a size class) that silently destroys the + * meaning of the stack. + * + * Pinning `scale.domain` to the same order makes Vega-Lite emit a + * `__sort_index` and sort the stack by it, so legend and stack agree. + * An explicit `sort` array does NOT achieve this — Vega-Lite applies it to + * the legend only — which is why the domain is what gets set here. + */ +export function alignStackOrderToColorOrder(spec: any, ctx: InstantiateContext): void { + const color = spec.encoding?.color; + // Only meaningful for a discrete colour series. + if (!color?.field || (color.type !== 'nominal' && color.type !== 'ordinal')) return; + // Called from stacked templates, where stacking is implicit unless it has + // been explicitly switched off (`stackMode: 'layered'` → `stack: null`). + const unstacked = (['x', 'y'] as const).some( + (axis) => spec.encoding?.[axis] && 'stack' in spec.encoding[axis] && + (spec.encoding[axis].stack === null || spec.encoding[axis].stack === false), + ); + if (unstacked) return; + // An explicit domain already pins the order; don't override the caller. + if (color.scale?.domain) return; + + // `sort: null` means data order; an array means that array. Anything else + // (a field-driven sort spec, "ascending"/"descending") is left alone. + let order: any[]; + if (color.sort === null) { + const seen = new Set(); + order = []; + for (const row of ctx.table ?? []) { + const v = row?.[color.field]; + if (v === undefined || v === null || seen.has(v)) continue; + seen.add(v); + order.push(v); + } + } else if (Array.isArray(color.sort)) { + order = color.sort; + } else { + return; + } + if (order.length < 2) return; + + color.scale = { ...(color.scale ?? {}), domain: order }; +} + /** * Adjust bar/rect marks for continuous-as-discrete axes. * v2 version: reads layout info from InstantiateContext. */ +/** + * Fraction of a continuous-banded step a bar fills by default, leaving a 10% + * gap. A house that states its own `marks.bandFraction` re-cuts against this + * baseline (see theme.ts `bandWalk`), so the two must agree on the number. + */ +export const CONTINUOUS_BAR_STEP_FILL = 0.9; + export function adjustBarMarks(spec: any, ctx: InstantiateContext): void { const layout = ctx.layout; for (const axis of ['x', 'y'] as const) { @@ -180,7 +252,7 @@ export function adjustBarMarks(spec: any, ctx: InstantiateContext): void { const maxSize = enc?.field ? maxNonOverlapSize(enc.field, ctx.table, isTemporal, subplotDim, count) : Infinity; - const cellSize = Math.max(2, Math.min(Math.round(effStep * 0.9), maxSize)); + const cellSize = Math.max(2, Math.min(Math.round(effStep * CONTINUOUS_BAR_STEP_FILL), maxSize)); if (Array.isArray(spec.layer)) { for (const layer of spec.layer) { diff --git a/packages/flint-js/src/vegalite/templates/violin.ts b/packages/flint-js/src/vegalite/templates/violin.ts index 8c1d49b2..dad02846 100644 --- a/packages/flint-js/src/vegalite/templates/violin.ts +++ b/packages/flint-js/src/vegalite/templates/violin.ts @@ -302,8 +302,175 @@ export const violinPlotDef: ChartTemplateDef = { spec.width = panelW; spec.height = panelH; } + + // --- What the smoothing hides ------------------------------------ + // A kernel density is an *inference*: it draws a curve where there + // were points, and with twelve birds per species the curve says more + // than the sample can support. Houses that publish distributions in + // print answer that by drawing the evidence next to the estimate — + // every observation, and the one summary the eye cannot read off a + // smooth shape, the median. + const wantPoints = config?.showPoints === true; + const wantMedian = config?.showMedian === true; + // A density estimate has an edge, and whether that edge is drawn is a + // real choice: a wash with no contour reads as a cloud, a contour reads + // as a measured shape. Houses that print distributions draw the line. + const wantContour = config?.showContour === true; + const medianWidth = typeof config?.medianWidth === 'number' && config.medianWidth > 0 + ? Math.min(1, config.medianWidth) + : 0.6; + if (wantPoints || wantMedian || wantContour) { + // A centre stack does not centre on zero — it centres every shape + // on half the widest density in the panel set, so zero sits at the + // left edge and anything drawn at zero misses the violin. The + // mirror is therefore cut by hand, half the density either side of + // zero, which puts the centre line where the jitter expects it. + const baseX = { ...(spec.encoding.x || {}) }; + delete baseX.stack; + delete baseX.impute; + delete baseX.field; + const layers: any[] = [{ + transform: [ + ...(spec.transform || []), + { calculate: 'datum.density / 2', as: '__violinHalf' }, + { calculate: '-datum.density / 2', as: '__violinNegHalf' }, + ], + // A solid shape drawn over the evidence hides it: the estimate + // steps back to a wash so the observations read through it. + mark: wantPoints + ? { ...(typeof spec.mark === 'string' ? { type: spec.mark } : spec.mark), fillOpacity: 0.35 } + : spec.mark, + encoding: { + y: spec.encoding.y, + x: { ...baseX, field: '__violinHalf', type: 'quantitative', stack: null }, + x2: { field: '__violinNegHalf' }, + }, + }]; + if (wantContour) { + // An area mark fills a shape; it does not draw one, and its + // `line` overlay follows only the leading edge — half a + // silhouette on a mirrored density. So the contour is two + // lines, one down each side, both riding the same colour scale + // as the wash they enclose. `point: false` is not decoration: + // a house that puts a dot on every line vertex would otherwise + // bead two hundred kernel samples along the outline. + for (const edge of ['__violinHalf', '__violinNegHalf']) { + layers.push({ + transform: [ + ...(spec.transform || []), + { calculate: 'datum.density / 2', as: '__violinHalf' }, + { calculate: '-datum.density / 2', as: '__violinNegHalf' }, + ], + mark: { + type: 'line', orient: 'horizontal', strokeWidth: 1, + opacity: 0.9, point: false, + }, + encoding: { + y: spec.encoding.y, + x: { ...baseX, field: edge, type: 'quantitative', stack: null }, + }, + }); + } + } + if (wantPoints) { + // A normal kernel peaks at ~0.4 / bandwidth, and the mirror + // seats half of that on each side of the centre line, so a + // quarter of the peak is a strip of jitter that stays well + // inside the shape it belongs to — and it is measured in the + // same density units, so it rides the violin's own scale. + const peak = effectiveBw > 0 ? 0.4 / effectiveBw : 0; + const jitter = peak * 0.25; + layers.push({ + transform: [{ + calculate: jitter > 0 ? `(random() - 0.5) * ${jitter}` : '0', + as: '__violinJitter', + }], + mark: { type: 'point', filled: true, size: 16, opacity: 0.9 }, + encoding: { + y: { + field: measureField, type: 'quantitative', title: measureField, + // The violin is a window on the distribution, not a + // length measured from nothing — a point mark would + // otherwise drag the axis down to zero. + scale: { zero: false }, + }, + x: { + field: '__violinJitter', type: 'quantitative', title: null, + axis: null, stack: null, + }, + }, + }); + } + if (wantMedian) { + // A rule drawn the full width of the panel is not a summary of + // *this* shape, it is a line across the page that happens to + // pass through the median. The mirror puts the widest a violin + // can ever get at half the kernel peak, so the rule is cut to a + // fraction of that — the same width in every panel, always + // inside the widest shape, and visibly a mark on the violin + // rather than a graticule behind it. + const peakHalf = effectiveBw > 0 ? (0.4 / effectiveBw) / 2 : 0; + const half = peakHalf * medianWidth; + layers.push({ + transform: [{ + aggregate: [{ op: 'median', field: measureField, as: '__violinMedian' }], + groupby, + }], + mark: { type: 'rule', strokeWidth: 1.5 }, + encoding: { + y: { + field: '__violinMedian', type: 'quantitative', title: measureField, + scale: { zero: false }, + }, + ...(half > 0 + ? { x: { datum: -half, type: 'quantitative' }, x2: { datum: half } } + : {}), + }, + }); + } + // Vega-Lite ignores a facet *channel* on a layered spec, so the + // per-category panels move to the facet operator: the panels wrap + // the layers instead of sitting beside them. + const enc = spec.encoding; + const wrap = enc.facet; + const shared = { ...enc }; + delete shared.facet; + delete shared.column; + delete shared.row; + // Position belongs to each layer — a shared x would push its stack + // onto the observations and its field onto the median rule. + delete shared.x; + delete shared.y; + const inner: any = { layer: layers, encoding: shared }; + if (spec.width != null) inner.width = spec.width; + if (spec.height != null) inner.height = spec.height; + if (wrap) { + const { columns, ...def } = wrap; + spec.facet = def; + if (columns != null) spec.columns = columns; + } else { + spec.facet = { + ...(enc.column ? { column: enc.column } : {}), + ...(enc.row ? { row: enc.row } : {}), + }; + } + spec.spec = inner; + delete spec.mark; + delete spec.transform; + delete spec.encoding; + delete spec.width; + delete spec.height; + } }, properties: [ { key: 'bandwidth', label: 'Bandwidth', type: 'continuous', min: 0.05, max: 2, step: 0.05, defaultValue: 0 }, + { key: 'showPoints', label: 'Observations', type: 'binary', defaultValue: false }, + { key: 'showMedian', label: 'Median rule', type: 'binary', defaultValue: false }, + { key: 'showContour', label: 'Contour', type: 'binary', defaultValue: false }, + { + key: 'medianWidth', label: 'Median width', type: 'continuous', + min: 0.2, max: 1, step: 0.05, defaultValue: 0.6, + check: (ctx: any) => ({ applicable: ctx.chartProperties?.showMedian === true }), + }, ] as ChartPropertyDef[], }; diff --git a/packages/flint-js/src/vegalite/templates/waterfall.ts b/packages/flint-js/src/vegalite/templates/waterfall.ts index 292eb820..9cac8d9b 100644 --- a/packages/flint-js/src/vegalite/templates/waterfall.ts +++ b/packages/flint-js/src/vegalite/templates/waterfall.ts @@ -20,6 +20,7 @@ export const waterfallChartDef: ChartTemplateDef = { template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], markCognitiveChannel: 'length', + ownsValueLabels: true, declareLayoutMode: () => ({ axisFlags: { x: { banded: true } }, }), @@ -31,6 +32,13 @@ export const waterfallChartDef: ChartTemplateDef = { const yField: string = y?.field || 'Amount'; const colorField: string | undefined = color?.field; + // The assembler has already resolved the axis titles (including any + // `field_display_names` override) onto the encodings; the layers below + // rebuild their own encoding objects, so carry those titles across + // rather than falling back to the raw field names. + const xTitle = x?.title ?? xField; + const yTitle = y?.title ?? yField; + if (!spec.encoding) spec.encoding = {}; if (column) spec.encoding.column = column; if (row) spec.encoding.row = row; @@ -115,11 +123,24 @@ export const waterfallChartDef: ChartTemplateDef = { spec.transform = transforms; // ── Shared x encoding ──────────────────────────────────────── + // Vega-Lite derives an axis title from the field name of every untitled + // encoding on a shared scale and concatenates them, so leaving this + // untitled surfaced the internal connector column in the rendered axis + // ("week, __wf_lead"). An explicit title settles the whole shared scale. + // It has to be an explicit title rather than `title: null` on the + // internal bindings: a null on a primary channel resolves the merged + // axis title to nothing, blanking the axis for every layer. const xEnc = { field: xField, type: "ordinal" as const, sort: null, + // x2 binds the synthetic `__wf_lead` field for the connector span; + // without an explicit title Vega-Lite folds that helper's name into + // 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, }; // ── Preserve facet encodings ───────────────────────────────── @@ -176,7 +197,7 @@ export const waterfallChartDef: ChartTemplateDef = { y: { field: "__wf_prev_sum", type: "quantitative", - title: yField, + title: yTitle, ...(yDomain ? { scale: { domain: yDomain } } : {}), }, y2: { field: "__wf_sum" }, @@ -207,7 +228,7 @@ export const waterfallChartDef: ChartTemplateDef = { encoding: { x: { field: xField, type: "ordinal", sort: null, bandPosition: 0 }, x2: { field: "__wf_lead", bandPosition: 1 }, - y: { field: "__wf_connector_y", type: "quantitative" }, + y: { field: "__wf_connector_y", type: "quantitative", title: yTitle }, }, }, ]; @@ -235,7 +256,7 @@ export const waterfallChartDef: ChartTemplateDef = { fill: "#374151", }, encoding: { - y: { field: "__wf_sum", type: "quantitative" }, + y: { field: "__wf_sum", type: "quantitative", title: yTitle }, text: { field: "__wf_sum", type: "quantitative", format: labelFormat }, }, }, @@ -250,7 +271,7 @@ export const waterfallChartDef: ChartTemplateDef = { fontSize: labelFontSize, }, encoding: { - y: { field: "__wf_center", type: "quantitative" }, + y: { field: "__wf_center", type: "quantitative", title: yTitle }, text: { field: "__wf_delta_text", type: "nominal" }, color: { condition: { test: "datum.__wf_color === 'total'", value: "#725a30" }, @@ -282,6 +303,6 @@ export const waterfallChartDef: ChartTemplateDef = { // template (see chart-types/waterfall.ts resolveTotalsMode). check: (ctx) => ({ applicable: !ctx.encodings?.color?.field }), }, - { key: 'showTextLabels', label: 'Labels', type: 'binary', defaultValue: false }, + { key: 'showValueLabels', label: 'Values', type: 'binary', defaultValue: false }, ] as ChartPropertyDef[], }; diff --git a/packages/flint-js/src/vegalite/theme.ts b/packages/flint-js/src/vegalite/theme.ts new file mode 100644 index 00000000..cf2eadbb --- /dev/null +++ b/packages/flint-js/src/vegalite/theme.ts @@ -0,0 +1,5307 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Level 3 — realization, Vega-Lite. + * + * Takes backend-neutral `DesignDecisions` and writes Vega-Lite. This file is + * allowed to *approximate* — to fake a primitive Vega-Lite does not have, such + * as line-end series labels — and is required to report when it does. + * + * It is NOT allowed to decide anything. Any `if (chartType === …)` here is a + * bug in grounding, not a shortcut. + * + * Runs LAST, after `vlApplyLayoutToSpec`, which builds `spec.config` wholesale. + */ + +import type { DesignDecisions, ThemeReport } from '../core/theme/types.js'; +import { contrastingInk, parseColor, luminance, 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'; + +/** Mark families that carry data values (as opposed to chrome). */ +const DATA_MARKS = new Set([ + 'bar', 'line', 'area', 'point', 'circle', 'square', 'arc', 'rect', + 'trail', 'tick', 'boxplot', 'errorbar', 'errorband', 'geoshape', +]); + +const LINE_MARKS = new Set(['line', 'trail']); + +/** Axis properties this realizer owns; anything a template left is overwritten. */ +const OWNED_AXIS_KEYS = [ + 'grid', 'gridColor', 'gridWidth', 'gridDash', 'gridOpacity', + 'domain', 'domainColor', 'domainWidth', + 'ticks', 'tickColor', 'tickWidth', 'tickSize', 'tickOffset', + 'labelColor', 'labelFont', 'labelFontSize', 'labelFontWeight', 'labelPadding', + 'titleColor', 'titleFont', 'titleFontSize', 'titleFontWeight', +]; + +export function markTypeOf(mark: any): string | undefined { + if (!mark) return undefined; + return typeof mark === 'string' ? mark : mark.type; +} + +/** Every mark family present anywhere in the spec. Used to ground before realizing. */ +export function collectMarkTypes(spec: any): string[] { + const found = new Set(); + walk(spec, (node) => { + const t = markTypeOf(node.mark); + if (t) found.add(t); + }); + return [...found]; +} + +/** Series marks that draw a connected path — folding several onto one colour + * key would thread a single line through unrelated readings. */ +const CONNECTED_MARKS = new Set(['line', 'area', 'trail']); + +/** True when any mark at or under this node draws a connected series path. */ +function subtreeHasConnectedMark(node: any): boolean { + let found = false; + walk(node, (n) => { + const t = markTypeOf(n.mark); + if (t && CONNECTED_MARKS.has(t)) found = true; + }); + return found; +} + +/** Part-to-whole marks whose slices are a share of a summed measure — the + * overflow tail can be summed into a single "Others" slice at the data level. */ +const PART_TO_WHOLE_MARKS = new Set(['arc']); + +/** + * The distinct-count at which `assemble` shrinks a colour key's labels to fit + * the field's full cardinality (see `assemble.ts`, "Legend sizing for + * high-cardinality nominal color/group"). When the overflow fold collapses the + * key to a short top-K + Others list, only a handful of rows render, so that + * shrink is recomputed against the folded count and short lists are handed back + * to the house's own size. Kept in sync with the literal in `assemble.ts`. + */ +const HIGH_CARDINALITY_LEGEND_MIN = 16; + +/** True when the node's own mark is a part-to-whole (share) mark. */ +function isPartToWholeMark(node: any): boolean { + const t = markTypeOf(node.mark); + return !!t && PART_TO_WHOLE_MARKS.has(t); +} + +/** + * Every field the node's encodings read, minus the colour and angle fields the + * overflow fold already accounts for. If anything is left, a sum-aggregate + * would drop it, so the tail is kept as separate wedges rather than merged. + */ +function otherEncodedFields(encoding: any, keep: Set): string[] { + const out: string[] = []; + for (const ch of Object.keys(encoding ?? {})) { + const e = (encoding as any)[ch]; + const list = Array.isArray(e) ? e : [e]; + for (const one of list) { + const f = one?.field; + if (typeof f === 'string' && !keep.has(f)) out.push(f); + } + } + return out; +} + +/** + * Sum the angle measure across each colour key. On a part-to-whole chart the + * top categories keep their own key (one row each, unchanged) and the folded + * tail — all sharing the "Others (N)" key — collapse into a single summed + * slice. Prepended after the key `calculate` so the key exists to group on. + */ +function addTailAggregate(node: any, thetaField: string, keyField: string): void { + const transform = Array.isArray(node.transform) ? node.transform.slice() : []; + if (transform.some((t: any) => Array.isArray(t?.aggregate) && (t?.groupby ?? []).includes(keyField))) return; + transform.push({ aggregate: [{ op: 'sum', field: thetaField, as: thetaField }], groupby: [keyField] }); + node.transform = transform; +} + +/** + * What the spec actually put on the two screen axes. A template is free to + * name its semantic channels `high`/`low`/`open`/`close`; the reader still + * sees an x and a y, and grounding has to be able to bind them. + */ +export function collectPositional(spec: any): { x?: { type?: string; field?: string }; y?: { type?: string; field?: string }; color?: { type?: string; field?: string } } { + const out: any = {}; + // The type and the field are collected independently: a layered template + // states the type once on the shared encoding and the field on each layer. + walk(spec, (node) => { + for (const ch of ['x', 'y'] as const) { + const enc = node.encoding?.[ch]; + if (!enc || typeof enc !== 'object') continue; + const cur = out[ch] ?? (out[ch] = {}); + if (cur.type == null && enc.type) cur.type = enc.type; + if (cur.type == null && enc.aggregate) cur.type = 'quantitative'; + if (cur.field == null && enc.field) cur.field = enc.field; + } + // The same for colour, which a layered template states on the one + // layer that needs it. Grounding otherwise concludes "no series" for a + // chart the reader plainly sees two series in. + for (const ch of ['color', 'fill', 'stroke'] as const) { + const enc = node.encoding?.[ch]; + if (!enc?.field || out.color) continue; + out.color = { field: enc.field, type: enc.type }; + } + }); + for (const ch of ['x', 'y'] as const) { + if (out[ch] && out[ch].type == null && out[ch].field == null) delete out[ch]; + } + // Whether the marks are stacked is a fact about the chart too, and one + // grounding cannot get from the semantics: Vega-Lite stacks a bar with a + // colour channel without being asked. + for (const body of plotBodies(spec)) { + const units = body.layer ? body.layer : [body]; + for (const unit of units) { + if (!DATA_MARKS.has(markTypeOf(unit.mark) ?? '')) continue; + const merged = { ...(body.encoding ?? {}), ...(unit.encoding ?? {}) }; + const probe = { mark: unit.mark, encoding: merged }; + if (isStacked(probe, 'x') || isStacked(probe, 'y')) out.stacked = true; + } + } + return out; +} + +function walk(node: any, visit: (n: any) => void): void { + if (!node || typeof node !== 'object') return; + visit(node); + for (const key of ['layer', 'vconcat', 'hconcat', 'concat']) { + if (Array.isArray(node[key])) node[key].forEach((c: any) => walk(c, visit)); + } + if (node.spec) walk(node.spec, visit); + if (node.facet && node.facet.spec) walk(node.facet.spec, visit); +} + +/** + * As {@link walk}, but carrying the size of the view each node is drawn in. + * + * A composed spec has no single plot: a concat gives each panel its own width, + * and a sparkline's line panel is a third of the chart it sits in. Anything + * that budgets a mark against the space available to it — how much room a dot + * has before it touches its neighbour — has to ask the view the mark is + * actually drawn in, not the outermost one, or a panel is measured against a + * plot several times its size. The nearest declared numeric `width`/`height` + * wins; children inherit until one of them overrides it. + */ +function walkScoped( + node: any, + outer: { width?: number; height?: number }, + visit: (n: any, view: { width?: number; height?: number }) => void, +): void { + if (!node || typeof node !== 'object') return; + const view = { + width: typeof node.width === 'number' ? node.width : outer.width, + height: typeof node.height === 'number' ? node.height : outer.height, + }; + visit(node, view); + for (const key of ['layer', 'vconcat', 'hconcat', 'concat']) { + if (Array.isArray(node[key])) node[key].forEach((c: any) => walkScoped(c, view, visit)); + } + if (node.spec) walkScoped(node.spec, view, visit); + if (node.facet && node.facet.spec) walkScoped(node.facet.spec, view, visit); +} + +/** + * The node that owns the plot body — where layers must be added and where the + * positional encodings live. For a facet spec that is `spec`, for a concat it + * is the first child that has marks, otherwise the spec itself. + */ +function plotBody(spec: any): any { + let node = spec; + for (let depth = 0; depth < 6; depth++) { + if (node.mark || node.layer) return node; + if (node.spec) { node = node.spec; continue; } + for (const key of ['vconcat', 'hconcat', 'concat']) { + if (Array.isArray(node[key]) && node[key].length) { node = node[key][0]; break; } + } + if (node === spec) break; + } + return node.mark || node.layer ? node : spec; +} + +/** + * Every plot body in the spec. A concatenation has one per panel, and each of + * them is a chart in its own right — the mark-level passes have to visit all + * of them, not just the first. + */ +function plotBodies(spec: any): any[] { + const out: any[] = []; + const visit = (node: any, depth: number): void => { + if (!node || typeof node !== 'object' || depth > 6) return; + if (node.mark || node.layer) { out.push(node); return; } + if (node.spec) return visit(node.spec, depth + 1); + for (const key of ['vconcat', 'hconcat', 'concat'] as const) { + if (Array.isArray(node[key])) node[key].forEach((c: any) => visit(c, depth + 1)); + } + }; + visit(spec, 0); + return out.length ? out : [plotBody(spec)]; +} + +/** Merged encoding visible to a unit: node-level plus its own. */ +function mergedEncoding(node: any, inherited: any): any { + return { ...(inherited ?? {}), ...(node.encoding ?? {}) }; +} + +/** The words Vega-Lite will write on this axis if nobody says otherwise. */ +function titleOf(enc: any): string | undefined { + // An explicit `title: null` is the author saying "this layer carries no + // title" — a shared-scale helper layer (a waterfall connector, a label + // series) that must not contribute its field name to the merged axis title. + if (enc?.title === null) return undefined; + if (typeof enc?.title === 'string') return enc.title; + // Internal helper fields (`__wf_connector_y`, …) are plumbing, never a + // reader-facing title. + if (typeof enc?.field === 'string' && !enc.field.startsWith('__')) return enc.field; + return undefined; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +export function realizeThemeVegaLite(spec: any, d: DesignDecisions, table: any[] = []): ThemeReport[] { + const report: ThemeReport[] = []; + const say = (path: string, message: string) => report.push({ stage: 'realize', path, message }); + + const config = (spec.config ??= {}); + + applySurface(spec, config, d); + applyTypography(config, d); + applyAxes(spec, config, d, table, say); + applyZeroRule(spec, d, table, say); + applyMarks(spec, d, table, say); + applySeriesInk(spec, d, table, say); + harmonizeLinePoints(spec, d, table, say); + applyConnectors(spec, d, say); + applyRedundantChannels(spec, d, say); + demoteSeriesEnd(spec, d, say); + applyLegend(spec, config, d, table, say); + applyFacetChrome(config, d); + applyPanelTitles(spec, d, say); + const valueLayer = applyDataLabels(spec, d, table, say); + applySeriesEndLabels(spec, d, valueLayer, table, say); + applyPointEmphasis(spec, d, say); + applyPrintedUnits(spec, d, say); + applyStatistics(spec, d, table, say); + const wrapped = applyFurniture(spec, d, table, say); + + return wrapped ? report : report; +} + +/** + * Print the value labels, and nothing else. + * + * Value labels belong to Flint, not to a house: a bar chart can carry its own + * numbers with no theme in sight. What a house adds is a *preference* — whether + * it likes them on, and how crowded a chart it will still print them in. So + * when the caller named no house, this runs alone over the neutral grounding, + * rather than dragging the entire visual layer in behind one label. + */ +export function realizeValueLabelsVegaLite(spec: any, d: DesignDecisions, table: any[] = []): ThemeReport[] { + const report: ThemeReport[] = []; + const say = (path: string, message: string) => report.push({ stage: 'realize', path, message }); + applyDataLabels(spec, d, table, say); + return report; +} + +// --------------------------------------------------------------------------- +// Surface & typography +// --------------------------------------------------------------------------- + +function applySurface(spec: any, config: any, d: DesignDecisions): void { + spec.background = d.surface.canvas; + config.background = d.surface.canvas; + + config.view = { ...(config.view ?? {}) }; + config.view.stroke = d.frame.show ? d.frame.color : null; + if (d.frame.show) config.view.strokeWidth = d.frame.width; + if (d.surface.plot && d.surface.plot !== d.surface.canvas) config.view.fill = d.surface.plot; + + if (spec.padding == null) spec.padding = d.layout.padding; +} + +function applyTypography(config: any, d: DesignDecisions): void { + if (d.font) config.font = d.font; + + const h = d.title.headline; + const deck = d.title.deck; + config.title = { + ...(config.title ?? {}), + font: h.font, + fontSize: h.fontSize, + fontWeight: h.fontWeight ?? 700, + ...(h.fontStyle ? { fontStyle: h.fontStyle } : {}), + color: h.color, + anchor: d.title.anchor, + orient: d.title.position, + offset: d.title.offset, + subtitleFont: deck.font, + subtitleFontSize: deck.fontSize, + ...(deck.fontStyle ? { subtitleFontStyle: deck.fontStyle } : {}), + subtitleColor: deck.color, + subtitlePadding: d.title.deckPadding, + }; + // A start-anchored headline belongs at the edge of the *graphic*, not at + // the left edge of the plotting rectangle — otherwise the width of the + // category labels decides where the title starts. + if (d.title.anchor !== 'middle') config.title.frame = 'bounds'; +} + +// --------------------------------------------------------------------------- +// Axes +// --------------------------------------------------------------------------- + +const NONLINEAR_SCALES = new Set(['log', 'symlog', 'pow', 'sqrt']); + +/** + * Flint's preferred tick-label size for this backend (`baseLabelFontSize` in + * the assembler). Anything under it is the layout pass reporting that the + * labels did not fit at the size it wanted. + */ +const BASE_LABEL_FONT_SIZE = 10; + +function applyAxes(spec: any, config: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): void { + for (const channel of ['x', 'y'] as const) { + const axis = d.axes[channel]; + if (!axis) continue; + + const key = channel === 'x' ? 'axisX' : 'axisY'; + // A rule under the categories is the line the marks stand on, and a + // chart whose value scale floats does not stand on anything: the + // slopegraph's 40 is not a base, it is wherever the window happened to + // start. Drawing a heavy rule there asserts a zero that is not on the + // page. But this is an argument about *bands*: where the index axis is + // itself a measure — a scatter's horizontal — its rule is the edge of + // the window, not a base anything is measured from, and taking it away + // leaves the plot hanging off a single wall. + // + // It is also an argument about *lengths*. The rule lies only if + // someone reads a distance from it, and only bars and areas are read + // that way. A strip plot's dots, a dumbbell's dots and a bump chart's + // lines are read against the tick labels; none of them reaches the + // bottom of the plot, so the line there is doing what the left-hand + // spine does — bounding the window and giving the category labels + // something to hang from. A house that draws one wall and not the + // other has not made a point about zero, it has lost a wall. + let domainShow = axis.domain.show; + if (domainShow && axis.indexing && bandedAxis(spec, channel) + && floatingValueScale(spec, channel) && lengthsFromIndexAxis(spec, channel)) { + domainShow = false; + say(`axes.${channel}.domain`, + 'the value scale floats — a rule under the categories would claim a base the chart does not have'); + } + const themed: any = { + grid: axis.grid.show, + gridColor: axis.grid.color, + gridWidth: axis.grid.width, + domain: domainShow, + domainColor: axis.domain.color, + domainWidth: axis.domain.width, + ticks: axis.ticks.show, + tickColor: axis.ticks.color, + tickWidth: axis.ticks.width, + tickSize: axis.ticks.size, + labelFont: axis.label.font, + labelFontSize: axis.label.fontSize, + labelColor: axis.label.color, + labelPadding: axis.label.padding, + titleFont: axis.title.font, + titleFontSize: axis.title.fontSize, + titleColor: axis.title.color, + titleFontWeight: axis.title.fontWeight ?? 'normal', + }; + if (axis.grid.show && twoPositionLineAxis(spec, channel, table)) { + themed.grid = false; + themed.gridWidth = 0; + say(`axes.${channel}.grid`, + '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; + 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; + if (axis.tickCount != null) themed.tickCount = axis.tickCount; + + // Vega drops a tick label only once its box *overlaps* its neighbour's, + // so two numbers whose boxes merely abut both survive and are read as + // one: `20,000` beside `30,000` prints `20,00030,000`. Numbers need a + // character's worth of air between them before they read as two, and + // that is what a separation states. Bands are exempt — their labels are + // spaced by the scale, and thinning them drops categories. + if (!bandedAxis(spec, channel)) { + const size = typeof themed.labelFontSize === 'number' ? themed.labelFontSize : BASE_LABEL_FONT_SIZE; + themed.labelSeparation = Math.round(size * 0.6); + } + + // Flint's layout pass owns label rotation/anchoring when the theme has + // no opinion — those are fit decisions, not style ones. + const existing = config[key] ?? {}; + + // How big the tick labels are is a house matter; whether they still + // read is not. Flint's layout shrinks them below its own base only + // when the categories crowd — a hundred and twenty bands in a plot + // six pixels wide apiece — and setting a larger house face on top of + // that decision does not restyle the axis, it smears it. So the house + // size holds up to the size fit allows. + const fitted = existing.labelFontSize; + if (typeof fitted === 'number' && fitted < BASE_LABEL_FONT_SIZE + && typeof themed.labelFontSize === 'number' && themed.labelFontSize > fitted) { + say(`axes.${channel}.label.fontSize`, + `the axis is crowded — the layout fitted its labels at ${fitted}px and the house's ${themed.labelFontSize}px would not stand in the band`); + themed.labelFontSize = fitted; + } + + config[key] = { ...existing, ...themed }; + if (axis.label.angle == null && existing.labelAngle != null) { + config[key].labelAngle = existing.labelAngle; + } + + // Encoding-level axis objects outrank config, so anything a template + // left behind in the keys this file owns has to go. + let saidGrid = false; + let saidTicks = false; + let saidGutter = false; + let saidUnit = false; + let flatTitle = false; + walk(spec, (node) => { + const enc = node.encoding?.[channel]; + if (!enc || enc.axis === null) return; + const ax = enc.axis; + 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 + // reserved a gutter and set the labels flush into it, and + // trimming it back to four drops the names onto the marks. + const gutter = typeof ax.labelPadding === 'number' + && ax.labelPadding >= (axis.label.padding ?? 4) + 16 + ? ax.labelPadding + : undefined; + for (const k of OWNED_AXIS_KEYS) delete ax[k]; + if (gutter != null) { + ax.labelPadding = gutter; + if (!saidGutter) { + say(`axes.${channel}.label.padding`, + `the template holds a ${gutter}px gutter for its labels — that is layout, not padding, so it stands`); + saidGutter = true; + } + } + } + if (!axis.title.show) { + enc.axis = { ...(ax ?? {}), title: null }; + } else { + // The unit belongs in the title where the house keeps titles — + // `Weight (lb)`, not `1500 lb`, `2000 lb`, `2500 lb` — unless + // the field already says it. + if (axis.title.unit) { + const current = enc.axis?.title ?? titleOf(enc); + if (typeof current === 'string' && !current.includes(`(${axis.title.unit})`)) { + enc.axis = { ...(enc.axis ?? {}), title: `${current} (${axis.title.unit})` }; + if (!saidUnit) { + say(`axes.${channel}.title`, + `the unit \`${axis.title.unit}\` is stated once in the axis title, which this house keeps`); + saidUnit = 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 + && (placement === 'flatAboveAxis' || placement === 'inline')) { + enc.axis = { + ...(enc.axis ?? {}), + titleAngle: 0, + titleAlign: 'left', + titleAnchor: placement === 'inline' ? 'end' : 'start', + titleX: 0, + titleY: -(axis.title.fontSize ?? 11) - 6, + titleBaseline: 'bottom', + }; + growPadding(spec, 'top', (axis.title.fontSize ?? 11) + 8); + flatTitle = true; + 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'); + } + } + const defaultOrient = channel === 'x' ? 'bottom' : 'left'; + if (axis.orient !== defaultOrient) { + enc.axis = { ...(enc.axis ?? {}), orient: axis.orient }; + } + // A grid line the reader cannot name is not a grid, it is a fence. + // A log scale offers a line at 2, 3, 4… of every decade, and Vega + // prunes the *labels* that collide, not the lines under them. Where + // the house draws a grid on such a scale, the ticks are cut back to + // the decades — the ones that will still carry a number. + if (themed.grid && NONLINEAR_SCALES.has(enc.scale?.type) + && (enc.axis?.tickCount ?? enc.axis?.values) == null) { + const size = channel === 'x' ? (node.width ?? spec.width) : (node.height ?? spec.height); + const span = typeof size === 'number' ? size : (channel === 'x' ? 600 : 300); + const ticks = enc.scale?.type === 'log' + ? logTicks(table, enc.field, span) + : decadeTicks(table, enc.field); + if (ticks) { + enc.axis = { ...(enc.axis ?? {}), values: ticks }; + if (!saidGrid) { + say(`axes.${channel}.grid`, + enc.scale.type === 'log' + ? `the log scale is ticked at ${ticks.length} readable 1/2/5 steps across its decades, chosen from the transformed pixel spacing` + : `the ${enc.scale.type} scale offers a line at every step of every decade — the grid is cut back to the ${ticks.length} it can label`); + saidGrid = true; + } + } + } + + // A tick between two observations names a value the chart does not + // hold: an axis of Olympic years does not have a 2014. Where the + // house asks for the values the data carries, the axis is stepped + // by what the data is actually spaced by. + if (axis.indexing && axis.tickLabels && axis.tickLabels !== 'all' + && enc.axis?.values == null && enc.axis?.tickCount == null) { + const size = channel === 'x' ? (node.width ?? spec.width) : (node.height ?? spec.height); + const span = typeof size === 'number' ? size : (channel === 'x' ? 600 : 300); + const fontSize = axis.label.fontSize ?? 10; + if (enc.type === 'temporal') { + // Stating dates outright costs the renderer the format it + // would have inferred — "1960" becomes "04 PM" — so a + // format comes with them. Where the dates are not plain + // calendar dates, an interval and a step say the same + // thing without risking a timezone. + const dates = observedDates(table, enc.field, axis.tickLabels, span, fontSize); + if (dates) { + enc.axis = { ...(enc.axis ?? {}), values: dates.values, format: dates.format }; + // The dates were read as UTC; the scale has to agree, + // or a tick lands hours off its own observation and + // the last one falls outside the domain entirely. + enc.scale = { ...(enc.scale ?? {}), type: 'utc' }; + if (!saidTicks) { + say(`axes.${channel}.tickLabels`, + `${axis.tickLabels} labels — the axis is ticked at ${dates.values.length} of the dates the data holds, not at round numbers between them`); + saidTicks = true; + } + } else { + const every = temporalStep(table, enc.field, span, fontSize); + if (every) { + enc.axis = { ...(enc.axis ?? {}), tickCount: every }; + if (!saidTicks) { + say(`axes.${channel}.tickLabels`, + `${axis.tickLabels} labels — the axis is stepped every ${every.step} ${every.interval}${every.step === 1 ? '' : 's'}, which is how the data is spaced`); + saidTicks = true; + } + } + } + } else if (enc.type === 'quantitative') { + const values = observedTicks(table, enc.field, axis.tickLabels, span, fontSize); + if (values) { + enc.axis = { ...(enc.axis ?? {}), values }; + if (!saidTicks) { + say(`axes.${channel}.tickLabels`, + `${axis.tickLabels} labels — the axis is ticked at the ${values.length} values the data holds, not at round numbers between them`); + saidTicks = true; + } + } + } + } + + // 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 + && (enc.type === 'quantitative' || enc.axis?.values != null)) { + const tagged = tagWithUnit(enc.axis?.labelExpr, unit.text, unit.where); + enc.axis = { ...(enc.axis ?? {}), labelExpr: tagged }; + if (!saidUnit) { + say(`axes.${channel}.unit`, + unit.where === 'everyTick' + ? `every label carries its unit — \`${unit.text}\` — because the house prints no axis title to hold it` + : `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; + } + } + }); + } + + dropGridUnderSpine(spec, config, say); +} + +/** + * True when the axis holds discrete positions — names, not numbers. Only such + * an axis is a floor the marks stand on; a continuous one is a ruler, and its + * rule is the edge of the window. + */ +function bandedAxis(spec: any, channel: 'x' | 'y'): boolean { + let banded = false; + walk(spec, (node) => { + const enc = node.encoding?.[channel]; + if (!enc?.field) return; + if (enc.type === 'nominal' || enc.type === 'ordinal') banded = true; + }); + return banded; +} + +/** 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; + let field: string | undefined; + let line = false; + walk(spec, (node) => { + if (isLiteralMark(node) || markTypeOf(node.mark) !== 'line') return; + const enc = node.encoding?.[channel]; + if (!enc?.field || (enc.type !== 'nominal' && enc.type !== 'ordinal')) return; + field ??= enc.field; + line = true; + }); + if (!line || !field) return false; + return new Set(table.map((row) => row?.[field!]).filter((value) => value != null)).size === 2; +} + +/** + * True when the *other* axis is a window on the data rather than a scale from + * zero — the template said `zero: false`, or pinned a domain that does not + * reach it. Nothing on such a chart is measured from the axis line. + * + * `zero: false` is not the last word, though: a lollipop turns it off so the + * dots are not squashed against the top, then hangs every stem from + * `datum: 0`. A mark that reaches the base *puts* the base on the page, + * whatever the scale asked for, and the rule under it is the floor those stems + * land on. + */ +/** + * True when something on the chart is read as a *length* measured from the + * index axis, so a rule drawn there would be the line that length starts at. + * + * Bars and areas are the marks that work this way: their reading is extent, + * and the base they extend from is wherever the plot ends. Everything else on + * a banded axis — dots, ticks, series lines — is read by position against the + * labels, and stops well short of the edge; the edge is then just the edge. + * + * A bar or area given a second value channel is a *span*, not a length from a + * base: a gantt task runs between two dates and a range band between two + * values, and neither is measured from the bottom of the plot either. + */ +function lengthsFromIndexAxis(spec: any, indexChannel: 'x' | 'y'): boolean { + const other = indexChannel === 'x' ? 'y' : 'x'; + let found = false; + walk(spec, (node) => { + if (found || isLiteralMark(node)) return; + const type = markTypeOf(node.mark); + if (type !== 'bar' && type !== 'area') return; + if (node.encoding?.[`${other}2`]) return; + found = true; + }); + return found; +} + +function floatingValueScale(spec: any, indexChannel: 'x' | 'y'): boolean { + const other = indexChannel === 'x' ? 'y' : 'x'; + let floating = false; + let seen = false; + let anchored = false; + walk(spec, (node) => { + const enc = node.encoding?.[other]; + const end = node.encoding?.[`${other}2`]; + if (enc?.datum === 0 || end?.datum === 0) anchored = true; + if (!enc?.field || enc.type === 'nominal' || enc.type === 'ordinal') return; + seen = true; + const scale = enc.scale; + if (scale?.zero === false) floating = true; + if (Array.isArray(scale?.domain) && typeof scale.domain[0] === 'number' && scale.domain[0] > 0) floating = true; + }); + return seen && floating && !anchored; +} + +/** + * A rule where the value axis crosses zero. + * + * Zero only earns its own line when it is *inside* the plot: on a chart of + * lengths it sits at the edge and the axis rule already states it, and on a + * scale that never changes sign it is not a crossing but a corner. So the rule + * is drawn only where the field runs both ways. + */ +function applyZeroRule(spec: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): void { + let said = false; + for (const channel of ['x', 'y'] as const) { + const axis = d.axes[channel]; + const zero = axis?.zeroRule; + if (!zero?.show) continue; + for (const body of plotBodies(spec)) { + const enc = (body.encoding?.[channel]) ?? body.layer?.[0]?.encoding?.[channel]; + const field = enc?.field; + if (!field || enc.type === 'nominal' || enc.type === 'ordinal') continue; + let min = Infinity; + let max = -Infinity; + for (const row of table ?? []) { + const v = Number(row?.[field]); + if (!Number.isFinite(v)) continue; + if (v < min) min = v; + if (v > max) max = v; + } + if (!(min < 0 && max > 0)) continue; + const rule = { + data: { values: [{}] }, + mark: { + type: 'rule', + color: zero.color, + strokeWidth: zero.width ?? 1, + ...(zero.dash ? { strokeDash: zero.dash } : {}), + }, + encoding: { [channel]: { datum: 0 } }, + }; + // `appendLayer` turns a unit into a layer *and* promotes a + // `facet`/`row`/`column` split to a real operator wrapping the + // layer — Vega-Lite drops that split if it is left inside a layer's + // encoding, which silently un-facets the chart. + appendLayer(body, rule); + if (!said) { + say('structure.grid.zero', 'the measure changes sign inside the plot — zero is drawn as its own rule, not as one gridline among the rest'); + said = true; + } + } + } +} + +/** + * The decades a field spans, for a log axis whose grid must be nameable. + * Returns `undefined` when the data is not readable from the spec or the span + * is too short to be worth thinning. + */ +function decadeTicks(table: any[], field: string | undefined): number[] | undefined { + if (!field || !Array.isArray(table) || table.length === 0) return undefined; + let min = Infinity; + let max = -Infinity; + for (const row of table) { + const v = Number(row?.[field]); + if (!Number.isFinite(v) || v <= 0) continue; + if (v < min) min = v; + if (v > max) max = v; + } + if (!Number.isFinite(min) || !Number.isFinite(max)) return undefined; + const lo = Math.floor(Math.log10(min)); + const hi = Math.ceil(Math.log10(max)); + if (hi - lo < 2) return undefined; + const out: number[] = []; + for (let k = lo; k <= hi; k++) out.push(10 ** k); + return out; +} + +/** + * Readable ticks for a base-10 log scale. + * + * Powers of ten always stand. The 2× and 5× positions join them only when the + * narrowest transformed interval is at least 32px, so a wide log plot gains + * useful interpolation without a compact one turning into a picket fence. + */ +function logTicks(table: any[], field: string | undefined, span: number): number[] | undefined { + if (!field || !Array.isArray(table) || table.length === 0 || span <= 0) return undefined; + let min = Infinity; + let max = -Infinity; + for (const row of table) { + const value = Number(row?.[field]); + if (!Number.isFinite(value) || value <= 0) continue; + if (value < min) min = value; + if (value > max) max = value; + } + if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return undefined; + + const logSpan = Math.log10(max) - Math.log10(min); + const decadePixels = span / logSpan; + const multipliers = decadePixels * Math.log10(2) >= 32 ? [1, 2, 5] : [1]; + const lo = Math.floor(Math.log10(min)); + const hi = Math.ceil(Math.log10(max)); + const ticks: number[] = []; + for (let exponent = lo; exponent <= hi; exponent++) { + for (const multiplier of multipliers) ticks.push(multiplier * 10 ** exponent); + } + return ticks; +} + +/** + * The values a field actually holds, for an index axis that should be ticked + * at observations rather than at round numbers between them. + * + * Returns `undefined` when there is nothing to improve on: too few values to + * matter, or so many that stating them all would be a worse fence than the + * renderer's own choice. + */ +const MAX_OBSERVED_TICKS = 30; + +/** + * Whether a sorted run of values is a *step* rather than a scatter of readings. + * + * A little slack is allowed, because real steps are not exact: months are 28 to + * 31 days long and a survey run "every year" lands a fortnight late. Values + * that are not numbers at all are ordinal — they are already a step, one + * category at a time. A run that steps geometrically counts too: a log axis of + * 1, 10, 100 is as much a ruler as 2012, 2016, 2020. + */ +function evenlySpaced(values: any[]): boolean { + const nums = values.map(Number); + if (!nums.every((n) => Number.isFinite(n))) return true; + if (nums.length < 3) return true; + const regular = (xs: number[]) => { + const gaps: number[] = []; + for (let i = 1; i < xs.length; i++) gaps.push(xs[i] - xs[i - 1]); + const lo = Math.min(...gaps); + const hi = Math.max(...gaps); + return lo > 0 && hi / lo <= 1.5; + }; + if (regular(nums)) return true; + return nums.every((n) => n > 0) && regular(nums.map(Math.log)); +} + +function observedTicks( + table: any[], + field: string | undefined, + mode: 'observed' | 'endpoints' | 'sparse', + span: number, + fontSize: number, +): any[] | undefined { + if (!field || !Array.isArray(table) || table.length === 0) return undefined; + const seen = new Map(); + for (const row of table) { + const v = row?.[field]; + if (v == null) continue; + const k = String(v); + if (!seen.has(k)) seen.set(k, v); + } + const values = [...seen.values()]; + if (values.length < 2 || values.length > MAX_OBSERVED_TICKS) return undefined; + values.sort((a, b) => { + const na = Number(a); + const nb = Number(b); + if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb; + return String(a) < String(b) ? -1 : 1; + }); + if (mode === 'endpoints') return [values[0], values[values.length - 1]]; + + // Ticking at observations is a claim that the data is *spaced* by + // something — Olympic years every four, quarters every three months — so + // that a tick between two of them would name a year there was no Games in. + // Fifteen countries' incomes are not spaced by anything: 5,300 is Nigeria, + // not a mark on a ruler, and a fence built from them is jagged, unroundable + // and says nothing a reader can carry to the next chart. So the axis is + // only stepped by its observations where they are actually a step. + if (!evenlySpaced(values)) return undefined; + + // Every label needs its own width. Where there is not room for all of + // them, take every k-th — and keep the last, which is where a reader + // looks for "now". + const widest = Math.max(...values.map((v) => String(v).length)); + const room = Math.max(2, Math.floor(span / (widest * fontSize * 0.6 + 10))); + if (values.length <= room) return values; + const step = Math.ceil((values.length - 1) / (room - 1)); + if (step <= 1) return values; + const out: any[] = []; + for (let i = 0; i < values.length; i += step) out.push(values[i]); + if (out[out.length - 1] !== values[values.length - 1]) out.push(values[values.length - 1]); + return out; +} + +const DAY_MS = 86400000; +const MONTH_MS = 30.44 * DAY_MS; +const YEAR_MS = 365.25 * DAY_MS; + +/** Signs that go before the number rather than after it. */ +const PREFIX_UNITS = new Set(['$', '£', '€', '¥', '₹', '₩', 'R$', 'US$', 'A$', 'CA$', 'CHF']); + +/** + * A label expression that writes the unit onto the ticks the house asked for. + * + * Builds on whatever expression is already there — flint's own number format + * may have written one — because that expression *is* the label, and the unit + * goes outside it. + */ +function tagWithUnit( + existing: string | undefined, + unit: string, + where: 'firstTick' | 'lastTick' | 'firstAndLast' | 'everyTick', +): string { + const label = existing ? `(${existing})` : 'datum.label'; + // `10%` but `420 ppm` — a symbol sits against the number, a word does not. + const quoted = JSON.stringify(/^[A-Za-z]/.test(unit) ? ` ${unit}` : unit); + const tagged = PREFIX_UNITS.has(unit) + ? `${quoted} + ${label}` + : `${label} + ${quoted}`; + if (where === 'everyTick') return tagged; + const at = where === 'firstTick' ? 'datum.index === 0' + : where === 'lastTick' ? 'datum.index === 1' + : 'datum.index === 0 || datum.index === 1'; + return `${at} ? ${tagged} : ${label}`; +} + +/** The field a unit-bearing label reads from. */ +const UNIT_LABEL_FIELD = '__flintValueWithUnit'; + +/** + * Print a value with its unit — `65%`, `$4.2bn`. + * + * Vega-Lite can format a number or write a literal, never both, and it takes no + * expression on a `text` channel. So the label is computed once for the whole + * plot body and the text layer simply reads the new field. + */ +function printWithUnit(body: any, node: any, field: string, format: string | undefined, unit: string): void { + const value = `datum[${JSON.stringify(field)}]`; + const shown = format ? `format(${value}, ${JSON.stringify(format)})` : `${value} + ''`; + const quoted = JSON.stringify(/^[A-Za-z]/.test(unit) ? ` ${unit}` : unit); + const calculate = PREFIX_UNITS.has(unit) ? `${quoted} + ${shown}` : `${shown} + ${quoted}`; + const transform = (body.transform ??= []); + if (!transform.some((t: any) => t.as === UNIT_LABEL_FIELD)) transform.push({ calculate, as: UNIT_LABEL_FIELD }); + node.encoding.text = { field: UNIT_LABEL_FIELD, type: 'nominal' }; +} + +/** + * The unit on values the *template* printed. + * + * Where a template already writes a number beside every mark, the theme leaves + * the label alone — but the house's unit still has nowhere else to go, and a + * bare `65` beside a slice is not the same statement as `65%`. + */ +function applyPrintedUnits(spec: any, d: DesignDecisions, say: (p: string, m: string) => void): void { + const unit = d.dataLabels.unit; + if (!unit) return; + let said = false; + for (const body of plotBodies(spec)) { + for (const node of (body.layer ?? [body])) { + if (markTypeOf(node.mark) !== 'text') continue; + const text = node.encoding?.text; + // Only numbers take a unit: a name printed at the end of a line is + // already a word, and a label already reading the computed field + // says what it means. + if (!text?.field || text.type !== 'quantitative') continue; + printWithUnit(body, node, text.field, text.format ?? d.dataLabels.format, unit); + if (!said) { + say('annotation.unit', + `each printed value carries its unit \`${unit}\` — there is no axis left to state it on`); + said = true; + } + } + } +} + +/** `2012`, `2012-07`, `2012-07-27` — a date with no time and no zone in it. */ +const CALENDAR_DATE = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/; + +/** + * The dates the data actually holds, as Vega-Lite `DateTime` objects. + * + * Only for fields written as plain calendar dates: those are read as UTC, so + * the calendar fields can be handed back untouched and cannot drift by an + * offset — provided the scale is told to keep UTC too, which is why the caller + * also sets `scale.type`. A format comes with them, because an axis given + * explicit values loses the one the renderer would have inferred and falls + * back to clock time. + */ +function observedDates( + table: any[], + field: string | undefined, + mode: 'observed' | 'endpoints' | 'sparse', + span: number, + fontSize: number, +): { values: Record[]; format: string } | undefined { + if (!field || !Array.isArray(table) || table.length === 0) return undefined; + const seen = new Set(); + for (const row of table) { + const v = row?.[field]; + if (typeof v !== 'string' || !CALENDAR_DATE.test(v)) return undefined; + seen.add(v); + } + const dates = [...seen].sort(); + if (dates.length < 2 || dates.length > MAX_OBSERVED_TICKS) return undefined; + + const parts = dates.map((s) => s.match(CALENDAR_DATE)!); + const grain = parts.every((p) => p[2] == null) ? 'year' + : parts.every((p) => p[3] == null) ? 'month' : 'day'; + const format = grain === 'year' ? '%Y' : grain === 'month' ? '%b %Y' : '%b %-d'; + const width = (grain === 'year' ? 4 : grain === 'month' ? 8 : 6) * fontSize * 0.6 + 10; + + let kept = parts; + if (mode === 'endpoints') { + kept = [parts[0], parts[parts.length - 1]]; + } else { + const room = Math.max(2, Math.floor(span / width)); + if (parts.length > room) { + const step = Math.ceil((parts.length - 1) / (room - 1)); + kept = parts.filter((_, i) => i % step === 0); + if (kept[kept.length - 1] !== parts[parts.length - 1]) kept.push(parts[parts.length - 1]); + } + } + const values = kept.map((p) => { + const dt: Record = { year: Number(p[1]), utc: true }; + if (grain !== 'year') dt.month = Number(p[2]); + if (grain === 'day') dt.date = Number(p[3]); + return dt; + }); + return { values, format }; +} + +/** + * How a temporal index axis is spaced, as an interval and a step — Olympic + * years are every 4 years, a monthly series is every month. Thinned to what + * the span can label. `undefined` when the dates are not evenly spaced enough + * for a step to be honest. + */ +function temporalStep( + table: any[], + field: string | undefined, + span: number, + fontSize: number, +): { interval: 'year' | 'month' | 'day'; step: number } | undefined { + if (!field || !Array.isArray(table) || table.length === 0) return undefined; + const stamps = new Set(); + for (const row of table) { + const v = row?.[field]; + if (v == null) continue; + const t = v instanceof Date ? v.getTime() : new Date(v as any).getTime(); + if (Number.isFinite(t)) stamps.add(t); + } + const sorted = [...stamps].sort((a, b) => a - b); + if (sorted.length < 3 || sorted.length > MAX_OBSERVED_TICKS) return undefined; + + let gap = Infinity; + for (let i = 1; i < sorted.length; i++) gap = Math.min(gap, sorted[i] - sorted[i - 1]); + let interval: 'year' | 'month' | 'day'; + let unit: number; + if (gap >= YEAR_MS * 0.9) { interval = 'year'; unit = YEAR_MS; } + else if (gap >= MONTH_MS * 0.9) { interval = 'month'; unit = MONTH_MS; } + else if (gap >= DAY_MS * 0.9) { interval = 'day'; unit = DAY_MS; } + else return undefined; + let step = Math.max(1, Math.round(gap / unit)); + + // Labels need their own width; where there is not room for one per + // observation the step multiplies. + const width = (interval === 'year' ? 4 : 8) * fontSize * 0.6 + 10; + const room = Math.max(2, Math.floor(span / width)); + const count = Math.round((sorted[sorted.length - 1] - sorted[0]) / (unit * step)) + 1; + if (count > room) step *= Math.ceil(count / room); + return { interval, step }; +} + +// --------------------------------------------------------------------------- +// Marks +// --------------------------------------------------------------------------- + +/** + * A dash that carries a *distinction* — observed against projected — has to + * survive the house's line style. A rounded cap extends every segment by half + * the stroke width at each end, so at the widths a display theme likes, the + * default dash pattern closes up into a solid line. Where `strokeDash` is + * encoded, the cap goes square and the pattern is scaled to the stroke. + */ +function protectDashEncoding(spec: any, config: any, strokeWidth: number): void { + let dashed = false; + walk(spec, (node) => { + if (node.encoding?.strokeDash?.field) dashed = true; + }); + if (!dashed) return; + config.line = { ...config.line, strokeCap: 'butt' }; + const w = Math.max(1, strokeWidth); + const range = [[1, 0], [w * 3, w * 2], [w * 1.2, w * 1.2], [w * 5, w * 2, w * 1.2, w * 2]]; + walk(spec, (node) => { + const enc = node.encoding?.strokeDash; + if (!enc?.field || enc.scale?.range) return; + enc.scale = { ...(enc.scale ?? {}), range }; + }); +} + +/** + * The area below which a dot stops reading as a dot — about 5px across. The + * crowding budget may shrink a house's dots this far and no further; past + * here the answer is no longer a smaller dot but a different chart. + */ +const MIN_DENSE_POINT_SIZE = 20; + +/** + * How many panels the table is spread across. A facet draws each panel from + * its own slice of the rows, so anything budgeting panel space against the + * row count has to divide by this first. + */ +function panelCount(spec: any, table: any[]): number { + const fields = new Set(); + const note = (def: any) => { if (def?.field) fields.add(def.field); }; + walk(spec, (node) => { + note(node.encoding?.facet); + note(node.encoding?.row); + note(node.encoding?.column); + note(node.facet); + note(node.facet?.row); + note(node.facet?.column); + }); + note(spec.facet); + note(spec.facet?.row); + note(spec.facet?.column); + if (fields.size === 0) return 1; + let panels = 1; + for (const field of fields) { + const values = new Set(table.map((r) => r?.[field]).filter((v) => v != null)); + panels *= Math.max(1, values.size); + } + return panels; +} + +/** + * Whether the spec draws a dot per row — a scatter, a strip, a dot plot. Only + * then does the crowding budget below have a claim on the plot's area: a + * boxplot's dots are its outliers and a line's are its vertices, and neither + * is one-per-row. + */ +function drawsPointCloud(spec: any): boolean { + let found = false; + walk(spec, (node) => { + if (found) return; + const type = markTypeOf(node.mark); + if (type === 'point' || type === 'circle' || type === 'square') found = true; + }); + return found; +} + +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; + + 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.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 + // and a stack still reads as one column; `cornerRadiusEnd` rounds the + // top of a vertical bar and the right of a horizontal one. A wedge has + // no baseline, so it rounds all its corners. + config.bar = { ...(config.bar ?? {}), cornerRadiusEnd: m.cornerRadius }; + config.arc = { ...(config.arc ?? {}), cornerRadius: m.cornerRadius }; + } + if (m.outline) { + // A wedge's outline follows its radial edges and never swallows it, so + // it rides the arc config. A bar's outline is width-sensitive — a fat + // border eats a thin bar whole — so it is drawn per bar below, only on + // 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 }; + } + protectDashEncoding(spec, config, m.strokeWidth); + + // Dots whose size this house deliberately cut to fit the room its panel + // gives them. The ring-writing passes below hand a bare `point: true` the + // house's full-size ring, which would put back exactly the ink the fit + // just took out, so they leave these alone. + const fittedDots = new Set(); + if (m.point?.show) { + config.line.point = { + filled: m.point.filled !== false, + size: m.point.size ?? 24, + ...(m.outline + ? { stroke: m.outline.color, strokeWidth: m.outline.width } + : m.point.haloColor + ? { stroke: m.point.haloColor, strokeWidth: m.point.haloWidth ?? 1 } + : {}), + }; // A point on a line marks a reading. A fitted line has no readings — + // it is sampled wherever the fit needs sampling, which for a straight + // one is the two ends — so a point at each vertex claims two + // observations that were never made, sitting exactly on the model. + let saidFit = false; + walk(spec, (node) => { + if (!LINE_MARKS.has(markTypeOf(node.mark) ?? '')) return; + if (!(node.transform ?? []).some((t: any) => t?.regression || t?.loess)) return; + node.mark = { ...normalizeMark(node.mark), point: false }; + if (!saidFit) { + say('marks.point', 'the fitted line keeps no vertex points — its vertices are where the fit was sampled, not where anything was measured'); + saidFit = true; + } + }); + + // Dots are worth drawing while they are still countable. A house that + // marks its readings means the handful a reader could point at one by + // one; past that the dots touch, fuse into a beaded rope, and hide the + // shape of the very line they sit on — a step curve with one vertex + // per observation is thirty dots along a curve nobody can now follow. + // A spec that asked for points itself keeps them; this only decides + // whether the *house* adds them where the chart never asked. + let saidCrowd = false; + let saidShrink = false; + walkScoped(spec, { width: plotWidth, height: plotHeight }, (node, view) => { + if (!LINE_MARKS.has(markTypeOf(node.mark) ?? '')) return; + const mark = normalizeMark(node.mark); + if (mark.point !== undefined) return; + const enc = mergedEncoding(node, spec.encoding); + const readings = maxReadingsPerSeries(enc, table); + if (readings > MAX_DOTTED_READINGS) { + node.mark = { ...mark, point: false }; + if (!saidCrowd) { + say('marks.point', + `${readings} readings on one line is past the ${MAX_DOTTED_READINGS} a reader can take one at a time, so the house's dots stand down and the line keeps its shape`); + saidCrowd = true; + } + return; + } + // Countable is not the same as roomy. A dozen readings are easy to + // take one at a time across a full plot and impossible across a + // sparkline a third as wide, where the house's dot is wider than + // the gap between two of them. Measure the dot against the space + // this view actually gives it and shrink it until it fits. + const dot = config.line.point; + const spacing = readingSpacing(view.width, readings); + if (spacing == null) return; + const outer = dotOuterDiameter(dot.size, dot.strokeWidth ?? 0); + const needed = (spacing * DOT_SPACING_FIT) / outer; + if (needed >= 1) return; + const shrink = Math.max(MIN_DOT_SHRINK, needed); + if (outer * shrink > spacing) { + // Even at the smallest a bead may be drawn it still runs into + // its neighbour, so the line is better read without them. + node.mark = { ...mark, point: false }; + if (!saidCrowd) { + say('marks.point', + `${readings} readings across ${Math.round(spacing)}px apart leave no room for the house's ${Math.round(outer)}px dots, so they stand down and the line keeps its shape`); + saidCrowd = true; + } + return; + } + const size = Math.max(4, Math.round(dot.size * shrink * shrink)); + const point: any = { ...dot, size }; + if (dot.strokeWidth) { + point.strokeWidth = Math.max(0.5, Number((dot.strokeWidth * shrink).toFixed(1))); + } + node.mark = { ...mark, point }; + fittedDots.add(node); + if (!saidShrink) { + say('marks.point.size', + `this panel gives each of its ${readings} readings ${Math.round(spacing)}px, so the house's dots shrink to ${size}px² to sit apart on their line`); + saidShrink = true; + } + }); + } + + // The ring of page around a dot is settled in `config.line.point` for the + // lines the house dotted itself. A chart that asked for its own vertices + // — a bump, a slope — carries `point: true` on the mark, and a bare + // `true` takes the renderer's dot and none of the house's, so the ring + // has to be written onto those marks by hand. + if (m.point?.haloColor) { + const dot = m.point; + walk(spec, (node) => { + if (!LINE_MARKS.has(markTypeOf(node.mark) ?? '')) return; + if (fittedDots.has(node)) return; + const mark = normalizeMark(node.mark); + if (!mark.point) return; + mark.point = { + ...(typeof mark.point === 'object' ? mark.point : {}), + ...(dot.filled != null ? { filled: dot.filled } : {}), + ...(dot.size != null ? { size: dot.size } : {}), + stroke: m.outline?.color ?? dot.haloColor, + strokeWidth: m.outline?.width ?? dot.haloWidth ?? 1, + }; + node.mark = mark; + }); + } + if (m.outline && !m.point?.haloColor) { + walk(spec, (node) => { + if (!LINE_MARKS.has(markTypeOf(node.mark) ?? '')) return; + if (fittedDots.has(node)) return; + const mark = normalizeMark(node.mark); + if (!mark.point) return; + mark.point = { + ...(typeof mark.point === 'object' ? mark.point : {}), + stroke: m.outline!.color, + strokeWidth: m.outline!.width, + }; + node.mark = mark; + }); + } + // The same size wherever a dot is drawn — and `config.point` alone does + // not reach them all: `circle` and `square` are their own mark types with + // their own config blocks, and most scatters are drawn as one of those, so + // a house that sized only `point` silently missed every scatter it had. + if (m.point?.size != null || m.point?.filled != null) { + // This config reaches only standalone point-family marks; line + // vertices use `config.line.point` above. + // + // The house's size is the size of a dot that has room. Crowd the plot + // and the dots have to give ground, or the reading stops being a + // cloud of observations and becomes one solid field. The budget is + // the share of the plot the dots may collectively ink — 12%, the + // middle of the 10-15% band the practitioner literature converges on + // — divided among the dots that have to share it. + // + // `plotWidth`/`plotHeight` are one panel, so the count has to be one + // panel's worth too: a six-panel facet draws a sixth of the table in + // each panel, and budgeting all of it against one panel would shrink + // every dot to the floor for rows that are not even drawn there. + // + // And the budget only means anything where the dots *are* the + // reading. A boxplot draws a dot per outlier, a line chart draws none + // at all: charging those few dots for every row in the table would + // shrink an outlier to a speck to make room for marks that were never + // drawn. + const perPanel = table.length / Math.max(1, panelCount(spec, table)); + const densitySize = table.length > 0 && drawsPointCloud(spec) + ? Math.max(MIN_DENSE_POINT_SIZE, Math.floor((plotWidth * plotHeight * 0.12) / perPanel)) + : undefined; + const pointSize = m.point.size != null && densitySize != null + ? Math.min(m.point.size, densitySize) + : m.point.size; + const pointOutlineWidth = m.outline && pointSize != null + ? Math.min(m.outline.width, Math.max(1.2, Math.sqrt(pointSize) * 0.22)) + : m.outline?.width; + for (const family of ['point', 'circle', 'square'] as const) { + config[family] = { + ...(config[family] ?? {}), + // `config.point` is not only the point *mark*: Vega-Lite + // normalizes a line's `point: true` overlay into a symbol + // styled `point`, so a size written here would also resize + // every line vertex in the chart — dots whose spacing along + // the line, not the plot's area, is what decides their size. + // Only `circle` and `square`, which no overlay borrows, can + // take the size from the config; the point marks that really + // are a data cloud are sized on the mark below. + ...(pointSize != null && family !== 'point' ? { size: pointSize } : {}), + // Only a house that spoke about its dots decides how they are + // filled. Vega-Lite draws a bare `point` hollow, which is how + // a shape-encoded scatter tells its glyphs apart, and a house + // that merely named a size has not asked for that to change. + ...(m.point.filled != null ? { filled: m.point.filled } : {}), + ...(m.outline ? { stroke: m.outline.color, strokeWidth: pointOutlineWidth } : {}), + }; + } + if (pointSize != null) { + walk(spec, (node) => { + const type = markTypeOf(node.mark); + if (type !== 'point' && type !== 'circle' && type !== 'square') return; + const mark = normalizeMark(node.mark); + // A template that fitted its dot to a lane measured something + // this pass cannot see, so its size stands. A size left by + // the build-time coverage estimate does not: it assumed a + // plot rather than being given one, and knew nothing of + // facets, so the budget above supersedes it. + if (mark.size != null && !coverageSizedMarks.has(node.mark)) return; + node.mark = { ...mark, size: pointSize }; + }); + } + if (m.point.size != null) { + if (pointSize !== m.point.size) { + say('marks.point.size', + `${Math.round(perPanel)} points would cover too much of the ${Math.round(plotWidth)}×${Math.round(plotHeight)}px plot at ${m.point.size}px² each, so the dots shrink to ${pointSize}px²`); + } else { + say('marks.point.size', + `a dot is drawn at ${m.point.size}px² wherever one is drawn — the house's size, not the renderer's`); + } + } + } + + // Every house's dots need the room, not only the houses that size them: + // where none is stated the renderer still draws a 30px² dot with whatever + // outline the house asked for, and that dot has the same radius problem. + padScaleForDots( + spec, + config.point?.size ?? config.circle?.size ?? 30, + config.point?.strokeWidth ?? m.outline?.width ?? 0, + say, + ); + + // Band occupancy is a scale decision, not a mark size: expressing it as + // padding keeps grouped and simple bars consistent and leaves the layout + // engine's step untouched. + // + // The positional encoding a mark reads is not always its own — a layered + // template states the category once on the parent and lets every layer + // inherit it. The scale lives wherever the encoding was written, so the + // walk has to carry what it inherited or a house's bar width silently + // misses every layered chart. + const paddingInner = clamp(1 - m.bandFraction, 0, 0.9); + let saidCells = false; + let saidBand = false; + const paddedBandFields = new Set(); + const bandWalk = (node: any, inherited: any): void => { + if (!node || typeof node !== 'object') return; + const enc = mergedEncoding(node, inherited); + const mark = markTypeOf(node.mark); + if (mark === 'bar' || mark === 'rect' || mark === 'boxplot') { + const discrete = (['x', 'y'] as const).filter((channel) => { + const t = enc[channel]?.type; + return enc[channel]?.field && (t === 'nominal' || t === 'ordinal'); + }); + // A rect with a category on *both* axes is a cell in a grid, not a + // bar in a row: the cell is the band, and a house's bar thickness + // says nothing about it. Thinning it here opens gaps in what should + // read as a continuous surface, and how big the cell is was settled + // by the layout, which is the only pass that knows the room. + if (mark === 'rect' && discrete.length === 2) { + if (!saidCells) { + say('marks.bandFraction', + 'the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply'); + saidCells = true; + } + } else if (mark === 'bar' && discrete.length === 0 + && typeof (node.mark as any)?.size === 'number' + && continuousBandedBar(enc)) { + // A bar on a continuous-banded axis (a year, a date) has no band + // scale to carry `paddingInner`, so the pixel width the layout + // already cut — `step × CONTINUOUS_BAR_STEP_FILL`, capped so the + // closest pair never touches — is the only handle. Re-scale it + // to the house's fill: dividing out the baseline recovers the + // step, and every house asks for a thinner bar than the + // baseline, so re-cutting can only widen the gap, never collide. + const current = (node.mark as any).size as number; + const resized = Math.max(1, Math.round(current * m.bandFraction / CONTINUOUS_BAR_STEP_FILL)); + if (resized !== current) { + node.mark = { ...normalizeMark(node.mark), size: resized }; + if (!saidBand) { + say('marks.bandFraction', + `bars on a continuous axis are re-cut to ${Math.round(m.bandFraction * 100)}% of the step`); + saidBand = true; + } + } + } else { + for (const channel of discrete) { + const target = node.encoding?.[channel] ?? inherited?.[channel]; + if (!target) continue; + target.scale = { ...(target.scale ?? {}), paddingInner }; + if (typeof target.field === 'string') paddedBandFields.add(target.field); + } + // Dodged bars carry a second band inside each group — the + // offset. A house gap that only narrows the group leaves the + // lanes touching, so the offset has to take the same fill. + const offset = enc.xOffset ?? enc.yOffset; + const offsetCh = enc.xOffset?.field ? 'xOffset' : enc.yOffset?.field ? 'yOffset' : undefined; + const offsetDiscrete = !!offsetCh && (offset?.type === 'nominal' || offset?.type === 'ordinal'); + const offsetLocal = !!offsetCh && offset?.type === 'quantitative'; + const sizedMark = typeof (node.mark as any)?.size === 'number' && mark !== 'boxplot'; + if (offsetDiscrete && mark !== 'boxplot') { + // Native dodge: the lanes are a band scale of their own, so + // the gap lives on the offset scale, not on any pixel size. + const offTarget = node.encoding?.[offsetCh!] ?? inherited?.[offsetCh!]; + if (offTarget) { + offTarget.scale = { ...(offTarget.scale ?? {}), paddingInner }; + if (!saidBand) { + say('marks.bandFraction', + `dodged lanes fill ${Math.round(m.bandFraction * 100)}% of their slot`); + saidBand = true; + } + } + } else if (sizedMark && offsetLocal) { + // Local dodge: the template pinned each lane to + // LOCAL_DODGE_LANE_FILL of its pitch. Re-scale to the house's + // fill — recomputing from the band step would spread one + // bar across the whole band and overlap the lanes. + const current = (node.mark as any).size as number; + const resized = Math.max(1, Math.round(current * m.bandFraction / LOCAL_DODGE_LANE_FILL)); + if (resized !== current) { + node.mark = { ...normalizeMark(node.mark), size: resized }; + if (!saidBand) { + say('marks.bandFraction', + `dodged lanes are re-cut to ${Math.round(m.bandFraction * 100)}% of their pitch`); + saidBand = true; + } + } + } else if (sizedMark && discrete[0]) { + // A simple bar pinned to a pixel width fixed the gap along + // with it. The house sets the gap, so the width moves too. + const step = bandStep(spec, node, enc, discrete[0], table); + if (step) { + node.mark = { ...normalizeMark(node.mark), size: Math.max(1, Math.round(step * m.bandFraction)) }; + if (!saidBand) { + say('marks.bandFraction', + `the template pinned its bars to a pixel width — they are re-cut to ${Math.round(m.bandFraction * 100)}% of the band`); + saidBand = true; + } + } + } + } + } + // A box is a summary, and how much of its band it fills is a house + // matter of its own: a wide box reads as a distribution, a narrow one + // as a marker with error bars. + if (mark === 'boxplot' && m.summary?.widthFraction != null) { + const channel = (['x', 'y'] as const) + .find((c) => enc[c]?.field && (enc[c]?.type === 'nominal' || enc[c]?.type === 'ordinal')); + const lanes = laneCount(node, enc, table); + const step = channel ? bandStep(spec, node, enc, channel, table) : undefined; + if (step) { + const size = Math.max(3, Math.round((step * m.summary.widthFraction) / lanes)); + node.mark = { ...normalizeMark(node.mark), size }; + say('marks.summary.widthFraction', + `the house fills ${Math.round(m.summary.widthFraction * 100)}% of the band with the box — ${size}px of a ${Math.round(step)}px band`); + } + } + for (const key of ['layer', 'vconcat', 'hconcat', 'concat']) { + if (Array.isArray(node[key])) node[key].forEach((c: any) => bandWalk(c, enc)); + } + if (node.spec) bandWalk(node.spec, enc); + if (node.facet?.spec) bandWalk(node.facet.spec, enc); + }; + bandWalk(spec, undefined); + centerBandDividers(spec, paddedBandFields, paddingInner, say); + + // A sized mark reads by area, and how much area the largest circle may take + // is a house matter — a page of small multiples cannot spend what a full + // page can. + if (m.sizeRange || m.minSize != null) { + let saidSize = false; + walk(spec, (node) => { + const enc = node.encoding?.size; + if (!enc?.field || enc.type !== 'quantitative') return; + enc.scale = { + ...(enc.scale ?? {}), + ...(m.sizeRange ? { range: m.sizeRange } : {}), + ...(m.minSize != null && !m.sizeRange ? { rangeMin: m.minSize } : {}), + }; + if (!saidSize && m.sizeRange) { + say('marks.sizeRange', + `sized marks run from ${m.sizeRange[0]} to ${m.sizeRange[1]}px² — the house's range, not the renderer's`); + saidSize = true; + } + }); + } + + if (m.separator?.show) { + const plotW = spec.config?.view?.continuousWidth ?? spec._width ?? 300; + const plotH = spec.config?.view?.continuousHeight ?? spec._height ?? 300; + let saidThin = false; + let sawStroke = false; + let bandCh: 'x' | 'y' | undefined; + walk(spec, (node) => { + const mark = markTypeOf(node.mark); + 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 + // and its fill is the reading, so how it is held apart is its own + // decision, taken below. + if (isGridCell(node, node.encoding ?? {})) return; + // A separator is drawn on the bar's edge, so half its width is taken + // out of the fill on each side. Once the bars are thinner than the + // stroke — a ramp dodged into fifty lanes, say — the stroke paints + // over the whole bar and the series vanishes. Leave those alone; the + // density itself already reads as separation. + const barW = estimateBarExtent(node, node.encoding ?? {}, table, plotW, plotH); + if (barW < 2 * m.separator!.width) { + if (!saidThin) { + say('marks.separator', + `bars are ${barW.toFixed(1)}px — too thin to hold a ${m.separator!.width}px separator, which would paint over them; left flush`); + saidThin = true; + } + return; + } + node.mark = { ...normalizeMark(node.mark), stroke: m.separator!.color, strokeWidth: m.separator!.width }; + sawStroke = true; + // Which axis carries the bands is read off the bar itself: a + // waterfall keeps its category on the parent layer and only the + // measure on the bar. The band is whatever is not the measure — + // the measure being the quantitative channel, or the one that + // spans (x2/y2). + const enc = node.encoding ?? {}; + if (enc.y?.type === 'quantitative' || enc.y2) bandCh = 'x'; + else if (enc.x?.type === 'quantitative' || enc.x2) bandCh = 'y'; + else if (enc.x?.field && enc.x.type !== 'quantitative') bandCh = 'x'; + else if (enc.y?.field && enc.y.type !== 'quantitative') bandCh = 'y'; + }); + if (sawStroke && bandCh) liftBandAxis(spec, bandCh, say); + } + + if (m.tile) applyTileGap(spec, m.tile, say); + + if (m.slice) applySliceGap(spec, m.slice, table, say); + + // The sticker edge on bars: a dark border around each column, drawn per + // bar so a fat outline can stand down where a bar is too thin to hold it + // (the same guard the separator uses). A bar the separator already stroked + // keeps that stroke; grid cells are a field, held apart by a tile gap. + if (m.outline) { + let saidThinOutline = false; + walk(spec, (node) => { + if (markTypeOf(node.mark) !== 'bar') return; + if (isLiteralMark(node)) return; + const enc = node.encoding ?? {}; + if (isGridCell(node, enc)) return; + const mark = normalizeMark(node.mark); + if (mark.stroke) return; + const barW = estimateBarExtent(node, enc, table, plotWidth, plotHeight); + if (barW < 2 * m.outline!.width) { + if (!saidThinOutline) { + say('marks.outline', + `bars are ${barW.toFixed(1)}px — too thin to hold a ${m.outline!.width}px outline, which would paint over them; left unbordered`); + saidThinOutline = true; + } + return; + } + node.mark = { ...mark, stroke: m.outline!.color, strokeWidth: m.outline!.width }; + }); + } + + // A corner radius is authored in pixels, but what it has to stay + // proportional to is the bar it rounds. The radius that reads as a + // friendly sticker corner on a wide bar swallows a narrow one: once it + // passes half the bar's thickness the shape stops being a bar at all and + // becomes a lozenge, and a ranking of lozenges is no longer a ranking of + // lengths. The house keeps its full roundness wherever the bar has room + // for it, and is held to the same *fraction* of the bar where it does not. + if (m.cornerRadius != null) { + let saidRound = false; + walk(spec, (node) => { + if (markTypeOf(node.mark) !== 'bar') return; + if (isLiteralMark(node)) return; + const enc = node.encoding ?? {}; + if (isGridCell(node, enc)) return; + const barW = estimateBarExtent(node, enc, table, plotWidth, plotHeight); + const capped = Math.round(barW * MAX_CORNER_FRACTION * 10) / 10; + if (capped >= m.cornerRadius!) return; + node.mark = { ...normalizeMark(node.mark), cornerRadiusEnd: capped }; + if (!saidRound) { + say('marks.cornerRadius', + `bars are ${barW.toFixed(1)}px — a ${m.cornerRadius}px corner would round the bar away, so it is held to ${capped}px, the same share of the bar the house rounds off a wide one`); + saidRound = true; + } + }); + } +} + +/** + * A house that strokes its bars in the surface colour (datawrapper's white + * hairline between stacked segments, swiss's paper gap) draws that stroke on + * every edge of the bar — including the edge that sits *on* the band axis. + * Painted in the surface, it chops that axis domain into a dash under each bar. + * + * Vega-Lite drops `zindex` from a *config* axis, but honours it on an axis + * declared in the encoding, so the band axis is simply lifted over the marks. + * Nothing is drawn twice and no geometry is invented — the one line Vega + * already draws is just drawn last. Only the band axis is lifted, and only + * while it carries no grid, so no gridline is ever raised over the data. + */ +function liftBandAxis(spec: any, bandCh: 'x' | 'y', say: (p: string, m: string) => void): void { + const axisCfg = spec.config?.[bandCh === 'x' ? 'axisX' : 'axisY'] ?? {}; + const color = axisCfg.domainColor; + const width = axisCfg.domainWidth ?? 1; + if (axisCfg.domain === false || !color || color === 'transparent' || width <= 0) return; + // Lifting an axis lifts its grid with it. A band axis that rules its own + // grid would paint those lines over the bars, which is a worse fault than + // the one being fixed, so it keeps its place. + if (axisCfg.grid && (axisCfg.gridWidth ?? 1) > 0 && + axisCfg.gridColor && axisCfg.gridColor !== 'transparent') return; + + let lifted = false; + walk(spec, (node) => { + const enc = node.encoding?.[bandCh]; + // `axis: null` is the chart saying this band carries no ruler at all. + if (!enc?.field || enc.axis === null) return; + enc.axis = { ...(enc.axis ?? {}), zindex: 1 }; + lifted = true; + }); + if (lifted) { + say('marks.separator', + 'the band axis is drawn over the bars — their surface-coloured edge strokes would otherwise chop its domain line into a dash'); + } +} + +/** + * A grid puts a line at every tick of the axis it belongs to, and the tick at + * the end of a scale sits exactly where the *other* axis draws its domain. The + * two lines are then one line drawn twice, in two different inks — and the + * grid, being part of the panel, is drawn second: a 2.5px black spine comes + * out as a pale dashed rule in grid ink. + * + * Z-order is not the answer here. Vega-Lite hoists a shared axis out of a + * faceted plot's panels, so the panel's own grid is always laid down after it + * however the axis is ranked, and drawing a second spine to cover the damage + * would be inventing geometry. The honest fix is that the redundant line + * should not be there: the spine already states that edge, so the grid gives + * up its outermost line. Vega-Lite's conditional axis properties can say this + * exactly — `datum.index` is the tick's position along the axis as a fraction, + * so the line the domain lies on is the one at 0 or at 1. + */ +function dropGridUnderSpine(spec: any, config: any, say: (p: string, m: string) => void): void { + const drawsGrid = (cfg: any): boolean => + !!cfg?.grid && (cfg.gridWidth ?? 1) > 0 && !!cfg.gridColor && cfg.gridColor !== 'transparent'; + const drawsDomain = (cfg: any): boolean => + cfg?.domain !== false && !!cfg?.domainColor && cfg.domainColor !== 'transparent' + && (cfg.domainWidth ?? 1) > 0; + + let said = false; + for (const gridCh of ['x', 'y'] as const) { + const spineCh = gridCh === 'x' ? 'y' : 'x'; + const gridCfg = config[gridCh === 'x' ? 'axisX' : 'axisY']; + const spineCfg = config[spineCh === 'x' ? 'axisX' : 'axisY']; + if (!drawsGrid(gridCfg) || !drawsDomain(spineCfg)) continue; + // `index` runs from the start of the range to its end. A y range runs + // top-to-bottom, so its 0 is the bottom of the plot, where an x axis + // sits by default; an x range runs left-to-right, so its 0 is the left, + // where a y axis sits. + const far = spineCh === 'x' + ? (spineCfg.orient === 'top' ? 1 : 0) + : (spineCfg.orient === 'right' ? 1 : 0); + walk(spec, (node) => { + const enc = node.encoding?.[gridCh]; + if (!enc?.field || enc.axis === null) return; + if (enc.axis?.gridColor && typeof enc.axis.gridColor === 'object') return; + enc.axis = { + ...(enc.axis ?? {}), + gridColor: { + condition: { test: `datum.index === ${far}`, value: 'transparent' }, + value: gridCfg.gridColor, + }, + }; + if (!said) { + say(`axes.${gridCh}.grid`, + `the ${gridCh} grid drops its outermost line — the ${spineCh} axis already rules that edge, and a grid line over a spine repaints it in grid ink`); + said = true; + } + }); + } +} + +/** + * Vega-Lite's nested `line.point` mark does not inherit a literal colour from + * its parent line, so a single-series themed line can become one colour with + * renderer-default blue dots. Keep the two pieces of the same trajectory in + * the same ink. Where a house deliberately draws outlined sticker dots, also + * scale both dot and line down together once a trajectory has too many + * observations for every vertex to remain full-size. + */ +function harmonizeLinePoints( + spec: any, + d: DesignDecisions, + table: any[], + say: (p: string, m: string) => void, +): void { + let saidColor = false; + let saidDensity = false; + walk(spec, (node) => { + if (!LINE_MARKS.has(markTypeOf(node.mark) ?? '')) return; + const mark = normalizeMark(node.mark); + if (!mark.point) return; + + const point = typeof mark.point === 'object' ? { ...mark.point } : {}; + if (mark.color && point.color == null && point.fill == null) { + point.color = mark.color; + if (!saidColor) { + say('marks.point.color', + 'the vertex dots inherit the line ink — both pieces belong to the same trajectory'); + saidColor = true; + } + } + + if (d.marks.point?.size != null && d.marks.outline && point.size == null) { + const enc = mergedEncoding(node, spec.encoding); + const readings = maxReadingsPerSeries(enc, table); + // A dot and the line under it are one object — a bead on a string + // — and the house sized them against each other. Crowding shrinks + // the *diameter*, and the stroke follows it by the same factor, so + // the proportion the house authored survives at every density. + // Area goes as the square of the diameter, so the size follows the + // factor squared: scaling the area directly would fatten the dots + // against their own line every time the plot got busier. + const shrink = readings > MAX_DOTTED_READINGS + ? Math.max(MIN_DOT_SHRINK, Math.sqrt(MAX_DOTTED_READINGS / readings)) + : 1; + const size = Math.round(d.marks.point.size * shrink * shrink); + point.size = size; + if (point.stroke === d.marks.outline.color) { + point.strokeWidth = Math.max(1, Number((d.marks.outline.width * shrink).toFixed(1))); + } + if (shrink < 1) { + mark.strokeWidth = Math.max(1, Number((d.marks.strokeWidth * shrink).toFixed(1))); + if (!saidDensity) { + say('marks.point.size', + `${readings} connected readings shrink the house's dots to ${size}px² and its line to ${mark.strokeWidth}px together, so the dot stays the same bead on the same string`); + saidDensity = true; + } + } + } + + mark.point = point; + node.mark = mark; + }); +} + +/** + * A cell of a grid — a heatmap, a calendar, a matrix. Both of its axes are + * spent on position and its colour carries the reading, so unlike a bar it has + * no free axis to be thinned along: the gap has to be cut out of the shape. + * A heatmap template may keep a temporal or quantitative scale for tick + * semantics while budgeting one band per observed value, so semantic axis + * types alone cannot identify cells. + */ +function isGridCell(node: any, enc: any): boolean { + if (markTypeOf(node.mark) !== 'rect') return false; + return Boolean(enc.x?.field && enc.y?.field && enc.color?.field); +} + +/** + * Cells are cut apart rather than spaced apart, for the same reason wedges + * are: spacing them would mean shrinking the mark, and on a grid the mark's + * extent is what says which month and which city the reading belongs to. + * + * A stroke straddles the edge it is drawn on, so a shared edge painted at + * `gap` px opens a `gap`-wide channel between the two cells — half taken from + * each, which keeps the grid's centres where the scale put them. + */ +function applyTileGap( + spec: any, + tile: NonNullable, + say: (p: string, m: string) => void, +): void { + let said = false; + const gridWalk = (node: any, inherited: any): void => { + if (!node || typeof node !== 'object') return; + const enc = mergedEncoding(node, inherited); + if (!isLiteralMark(node) && isGridCell(node, enc)) { + node.mark = { ...normalizeMark(node.mark), stroke: tile.color, strokeWidth: tile.gap }; + if (!said) { + say('marks.tile', + `the cells are cut apart by ${tile.gap}px — the grid reads as a table of separate readings rather than one continuous field`); + said = true; + } + } + for (const key of ['layer', 'vconcat', 'hconcat', 'concat']) { + if (Array.isArray(node[key])) node[key].forEach((child: any) => gridWalk(child, enc)); + } + if (node.spec) gridWalk(node.spec, enc); + if (node.facet?.spec) gridWalk(node.facet.spec, enc); + }; + gridWalk(spec, undefined); +} + +/** + * A connector draws the *distance* between two positions of one datum, and + * both positions are already on the page. It is structure, not a series — so + * it takes the house's structural ink, and it runs after the series ink has + * been laid down so that the ink it takes is the one that stands. + * + * What it is worth, though, depends on what it joins, and that is a question + * the spec answers without anyone naming a chart type: + * + * - a **stem** ends at a constant — the baseline. The dot at its other end + * already carries the value, so the stem is only a path for the eye and + * is drawn at a hairline. + * - a **bridge** ends at another mark across the *measured* axis. Nothing + * else on the plot states the gap between men and women, before and + * after: the bridge *is* that reading, and a hairline asks the eye to + * measure something it can hardly see. It is drawn at a mark's weight. + * - a **lead** ends at another mark across the *categorical* axis, holding + * one value the whole way — a waterfall's step from one bar's top to + * where the next one starts. It carries no distance of its own; the two + * bar tops it touches already say where the level is. At a mark's weight + * it would be the heaviest line on a chart that is asking about the bars, + * so it is drawn at a hairline like the stem. + */ +function applyConnectors(spec: any, d: DesignDecisions, say: (p: string, m: string) => void): void { + const c = d.marks.connector; + if (!c) return; + // A house that never styles connectors still gets its bridges corrected. + // A bridge joins two marks belonging to *different* series, so whichever + // series ink it inherits by default belongs to one of its two ends — it + // misattributes the span to one endpoint and, worse, makes that endpoint's + // dot indistinguishable from the line leaving it. A stem and a lead have + // no such ambiguity (a stem falls to the baseline within one series; a + // lead runs between two bars at one level), so where the house is silent + // they keep whatever the series pass gave them. + const bridgeOnly = !c.show; + + const said = new Set(); + let sawStem = false; + const paint = (node: any, role: 'stem' | 'bridge' | 'lead', enc: any): void => { + if (bridgeOnly && role !== 'bridge') return; + // A mark whose colour is encoded takes its ink from the data — a + // candlestick's wick is a rule that spans low to high but is coloured + // by whether the session closed up, whether that is said with a field + // or with a test and two values. Vega-Lite would discard a mark-level + // colour set beneath it. Structure has nothing to add to a line that + // is already saying something. + if ((node.encoding?.color ?? enc?.color) !== undefined) return; + const mark = normalizeMark(node.mark); + // A mark that names its own stroke has already been given its ink by + // whoever drew it — a radar's spokes and rings are `rule`s that reach + // from centre to rim and so read as spans, but they are the chart's + // furniture, not a connector between two readings. Vega-Lite drops a + // `color` set alongside an explicit `stroke` anyway; skipping keeps + // that from being said twice and warned about once. + if (mark.stroke !== undefined) return; + if (role === 'stem') sawStem = true; + if (c.color) mark.color = c.color; + // Correcting an undeclared bridge is a correction of colour, not of + // weight: the house said nothing about how heavy its spans should be, + // and the series pass already sized this line like every other line. + if (!bridgeOnly) mark.strokeWidth = role === 'bridge' ? c.spanWidth : c.width; + if (c.dash && !bridgeOnly) mark.strokeDash = c.dash; + node.mark = mark; + if (said.has(role)) return; + said.add(role); + const why = bridgeOnly + ? `the bridge is drawn in structural ink — it joins two series' marks, so any series colour it took would credit the span to one of its two ends` + : { + bridge: `the bridge is drawn at ${c.spanWidth}px in structural ink — the distance it spans is the reading, so it carries a mark's weight and none of a series' colour`, + stem: `the stem is drawn at ${c.width}px in structural ink — it leads the eye to the axis and states nothing the dot's position has not`, + lead: `the lead line is drawn at ${c.width}px in structural ink — it runs across the categories at one level, and the two mark ends it touches already state that level`, + }[role]; + say('marks.connector', why); + }; + + const bandWalk = (node: any, inherited: any): void => { + if (!node || typeof node !== 'object') return; + const enc = mergedEncoding(node, inherited); + const type = markTypeOf(node.mark); + const own = node.encoding ?? {}; + if (!isLiteralMark(node)) { + if (type === 'rule') { + // One end is written on the mark; the other is where it stops. + // If it stops at a number, it stops at the baseline. If it + // stops at another mark, which axis it crossed to get there + // decides whether the crossing was the reading. + const along = (own.x2 ?? enc.x2) ? 'x' : (own.y2 ?? enc.y2) ? 'y' : undefined; + const end = along === 'x' ? (own.x2 ?? enc.x2) : along === 'y' ? (own.y2 ?? enc.y2) : undefined; + if (end) { + const start = along === 'x' ? (own.x ?? enc.x) : (own.y ?? enc.y); + const acrossCategories = start?.type === 'nominal' || start?.type === 'ordinal'; + paint(node, !end.field ? 'stem' : acrossCategories ? 'lead' : 'bridge', enc); + } + } else if (type === 'line') { + // A line grouped by the field the categorical axis already + // names draws one segment inside each band, not one series + // across them: it joins that band's own marks. + const key = own.detail?.field ?? enc.detail?.field; + const band = (['x', 'y'] as const) + .map((ch) => enc[ch]) + .find((e: any) => e?.field && (e.type === 'nominal' || e.type === 'ordinal')); + if (key && band?.field === key) paint(node, 'bridge', enc); + } + } + for (const k of ['layer', 'vconcat', 'hconcat', 'concat']) { + if (Array.isArray(node[k])) node[k].forEach((child: any) => bandWalk(child, enc)); + } + if (node.spec) bandWalk(node.spec, enc); + if (node.facet?.spec) bandWalk(node.facet.spec, enc); + }; + bandWalk(spec, undefined); + + // A lollipop's category axis is a point scale — its dots have no width, so + // Vega-Lite's default `pointPadding` of 0.5 leaves the first and last dot + // only half a step from the axis while every neighbour sits a full step + // apart. On a bar that half-step is right (a bar fills toward the edge); + // on a zero-width dot it reads as cramped against the spine. Widen the + // outer padding so the end dots stand off the axis by the same gap they + // keep from each other. Scoped to a chart that actually hangs stems, and + // to the point scale only (the measured axis is linear), so nothing else + // moves; a house or template that pinned its own padding keeps it. + if (sawStem) { + const config = (spec.config ??= {}); + const scale = (config.scale ??= {}); + if (scale.pointPadding == null) { + scale.pointPadding = LOLLIPOP_CATEGORY_PADDING; + say('config.scale.pointPadding', + `the lollipop's end dots are stood off the category axis by a full step (pointPadding ${LOLLIPOP_CATEGORY_PADDING}) so they are not cramped against the spine`); + } + } +} + +/** Outer padding for a lollipop's category point scale: end dots stand a full + * inter-item gap off the axis instead of Vega-Lite's default half-step. */ +const LOLLIPOP_CATEGORY_PADDING = 1; + +/** + * How far a mark reaches from the point it is anchored at. Only the round + * marks have one; a bar, an area or a line is drawn *to* its anchor, so the + * anchor is already the edge. + * + * Vega-Lite states a point's `size` as an area in px², which is how the size + * channel is read as well — so the radius is the same conversion in both. + */ +const POINT_MARKS = new Set(['point', 'circle', 'square']); +function markRadius(node: any, spec: any, d: DesignDecisions): number { + const type = markTypeOf(node.mark) ?? ''; + if (!POINT_MARKS.has(type)) return 0; + const area = normalizeMark(node.mark)?.size + ?? node.encoding?.size?.scale?.range?.[1] + ?? spec.config?.[type]?.size + ?? spec.config?.point?.size + ?? d.marks.point?.size + ?? 30; + return typeof area === 'number' ? Math.round(Math.sqrt(area / Math.PI)) : 0; +} + +/** + * Wedges are cut apart rather than spaced apart: a pie has no band to give + * back, so the gap has to come out of the shape itself — either painted over + * the shared edge or opened by swinging the two arcs away from each other. + */ +function applySliceGap( + spec: any, + slice: NonNullable, + table: any[], + say: (p: string, m: string) => void, +): void { + let said = false; + walk(spec, (node) => { + if (markTypeOf(node.mark) !== 'arc') return; + if (isLiteralMark(node)) return; + const mark = normalizeMark(node.mark); + + if (slice.style === 'pad') { + const radius = arcRadius(node, spec, mark); + if (!radius) return; + // A gap is a gap only while there is a wedge left on either side of + // it. Five slices at 2px cost the circle almost nothing; forty cost + // it the pie. The house's width is honoured until the gaps would + // take a sixth of the circumference between them, and held there. + const enc = node.encoding ?? {}; + const slices = distinctCount(table, enc.color?.field ?? enc.theta?.field); + const wanted = slice.gap / radius; + const room = slices ? (2 * Math.PI * 0.15) / slices : wanted; + mark.padAngle = Math.min(wanted, room); + if (!said) { + say('marks.slice.gap', wanted > room + ? `${slices} wedges cannot each give up ${slice.gap}px and still be wedges — the gap is held at ${(room * radius).toFixed(1)}px` + : `the wedges stand ${slice.gap}px apart at the rim`); + said = true; + } + } else { + mark.stroke = slice.color; + mark.strokeWidth = slice.gap; + // Wedges taper to a point at the hub; a mitred stroke on those acute + // tips shoots a spike past the centre (worse the thinner the slice). + // Round the join so the rule stays a rule all the way to the middle. + mark.strokeJoin = 'round'; + if (!said) { + say('marks.slice.gap', + `a ${slice.gap}px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one`); + said = true; + } + } + node.mark = mark; + }); +} + +/** + * How far the wedge reaches. Stated on the mark where a template pinned it; + * otherwise Vega-Lite fills the smaller side of the plot. + */ +function arcRadius(node: any, spec: any, mark: any): number | undefined { + if (typeof mark.outerRadius === 'number') return mark.outerRadius; + const w = typeof node.width === 'number' ? node.width : spec.width; + const h = typeof node.height === 'number' ? node.height : spec.height; + if (typeof w !== 'number' || typeof h !== 'number') return undefined; + return Math.min(w, h) / 2; +} + +/** + * A mark placed entirely in pixels — every channel a literal value, no field + * and no datum — is not a series. It is furniture the template drew itself: a + * card, a track, a badge. It carries no value, so a separator between "its + * segments" is a line across nothing, and the house's series ink says nothing + * about it either. + */ +function isLiteralMark(node: any): boolean { + const enc = node?.encoding; + if (!enc || typeof enc !== 'object') return false; + const channels = Object.values(enc) as any[]; + if (channels.length === 0) return false; + return channels.every((e) => e && typeof e === 'object' && e.field == null && e.datum === undefined); +} + +function normalizeMark(mark: any): any { + return typeof mark === 'string' ? { type: mark } : { ...mark }; +} + +/** Distinct values a field takes in the data behind the chart. */ +function distinctCount(table: any[], field: string | undefined): number { + if (!field || !Array.isArray(table)) return 0; + const seen = new Set(); + for (const row of table) { + const v = row?.[field]; + if (v != null) seen.add(String(v)); + } + return seen.size; +} + +/** + * Paint the marks a template drew in pixels but flagged as carrying meaning. + * + * Most template furniture is scenery — a card, a track, a caption — and the + * pass above only re-tones it against the surface. A few of those pixel marks + * are not scenery at all: a KPI card's progress bar is the one part of the + * tile that states a measurement, and left literal it stays flint's blue in + * every house on the wall. A template cannot read the theme, so it names the + * *role* its colour plays with `__themeRole` and the house supplies the ink. + * + * accent the reading itself, with no verdict attached — the house's ink + * positive a reading that met or beat its target + * negative a reading that fell short + * + * A house that never named status inks keeps the template's own red and green: + * those hues are conventional rather than decorative, and inventing a verdict + * colour from the palette would say something the house did not. + * + * A verdict also has to stay *legible as a verdict*. Several houses draw their + * status ink from the same short palette as their series ink — the Economist's + * positive is its blue, Swiss's negative is its red — so taking it here would + * paint "beat the target" and "still going" in one colour and quietly delete + * the distinction the bar exists to make. Where the house's verdict ink is the + * ink already on the bar, the template's conventional hue stays. + */ +function paintRoleMarks(spec: any, d: DesignDecisions, say: (p: string, m: string) => void): void { + const status = d.series.status; + const accent = d.series.single; + const same = (a?: string, b?: string) => !!a && !!b && a.toLowerCase() === b.toLowerCase(); + const verdict = (ink?: string) => (ink && !same(ink, accent) ? ink : undefined); + let said = false; + walk(spec, (node) => { + const role = node.__themeRole; + if (!role || !markTypeOf(node.mark)) return; + const ink = role === 'accent' + ? accent + : role === 'positive' + ? verdict(status?.positive) + : role === 'negative' + ? verdict(status?.negative) + : undefined; + if (!ink) return; + const mark = normalizeMark(node.mark); + for (const key of ['fill', 'stroke', 'color'] as const) { + if (typeof mark[key] === 'string') mark[key] = ink; + } + node.mark = mark; + if (!said) { + say('ink.series', + 'the card\'s progress bar states a measurement, not scenery, so it takes the house ink rather than the template\'s own blue'); + said = true; + } + }); +} + + + +/** How many dots one line can carry before they stop being countable. */ +const MAX_DOTTED_READINGS = 12; + +/** + * The share of the gap between two readings a dot may occupy. + * + * At 1 the beads on a line touch exactly; a little under that leaves a thread + * of page between them, which is what makes them read as separate readings + * rather than as one rope. + */ +const DOT_SPACING_FIT = 0.9; + +/** + * The room one reading gets along a line, in px. + * + * Returns `undefined` when the view has no settled width (a step-sized or + * responsive panel), where there is nothing to measure the dot against and the + * house's size is left alone. + */ +function readingSpacing(width: number | undefined, readings: number): number | undefined { + if (typeof width !== 'number' || !isFinite(width) || width <= 0) return undefined; + if (readings <= 1) return undefined; + return width / readings; +} + +/** + * How much page a dot covers, edge to edge. + * + * A mark's `size` is its area in px², and its stroke straddles the edge it is + * drawn on, so half the stroke falls outside the disc on each side — the ink a + * neighbouring dot has to clear is the diameter plus one whole stroke width. + */ +function dotOuterDiameter(size: number, strokeWidth: number): number { + return 2 * Math.sqrt(size / Math.PI) + strokeWidth; +} + +/** + * A dot has a radius; a scale fitted to the data does not know that. + * + * Vega-Lite sizes a continuous scale to the extremes of the field, which puts + * the *centre* of the outermost dot exactly on the edge of the plot and throws + * away the half of it that falls outside — the lowest value in a dumbbell ends + * up as a half-moon glued to the y axis, sitting on its own row label. A bar + * never has this trouble because its end *is* the edge; only a mark with ink + * either side of its position does. + * + * So the range is opened by the dot's own radius. `scale.padding` is measured + * in pixels, which is the unit the problem is in: it costs the same sliver of + * plot whatever the data happens to span, and it moves no value. + * + * Held back wherever something on the chart is measured *from* the edge — a + * 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 { + 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 + // layer only its colour, so the scale to open is not always written on the + // node that carries the mark. Walk down remembering where each channel was + // last declared, and pad that. + let anchored = false; + const dots: Record[] = []; + const visit = (node: any, declared: Record): void => { + if (!node || typeof node !== 'object') return; + const next = { ...declared }; + for (const ch of ['x', 'y'] as const) { + if (node.encoding?.[ch]?.field) next[ch] = node.encoding[ch]; + } + if (node.mark && !isLiteralMark(node)) { + const type = markTypeOf(node.mark); + if (type === 'bar' || type === 'area' || type === 'rect' || type === 'rule' || type === 'arc') anchored = true; + else if (type && POINT_MARKS.has(type)) dots.push(next); + } + for (const k of ['layer', 'vconcat', 'hconcat', 'concat']) { + if (Array.isArray(node[k])) node[k].forEach((child: any) => visit(child, next)); + } + if (node.spec) visit(node.spec, next); + if (node.facet?.spec) visit(node.facet.spec, next); + }; + visit(spec, {}); + if (anchored || !dots.length) return; + + let padded = false; + for (const declared of dots) { + for (const ch of ['x', 'y'] as const) { + const enc = declared[ch]; + if (!enc?.field) continue; + if (enc.type !== 'quantitative' && enc.type !== 'temporal') continue; + // 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 }; + padded = true; + } + } + if (padded) { + 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`); + } +} + +/** + * The most of a bar's own thickness a rounded end may eat. + * + * Read off the hand-drawn cartoon reference, whose 14px corner sits on a 46px + * bar. Past roughly a third the corner starts reading as the shape rather than + * as a finish on it, and at a half the bar is a capsule. + */ +const MAX_CORNER_FRACTION = 0.3; + +/** + * How far a crowded trajectory may shrink its dots and line. + * + * Below about half the authored size the bead stops being a reading a finger + * could land on, and the trajectory is better served by the line alone. + */ +const MIN_DOT_SHRINK = 0.5; + +/** + * The longest single line in the plot, counted in readings. + * + * A reading is one position along the line, so it is the distinct values of + * the field on the continuous axis, taken within one series — the series + * being whatever splits the line into more than one: colour, detail, a dash. + * Rows would be the wrong count, since an aggregated line draws one vertex + * from many rows. + */ +function maxReadingsPerSeries(enc: any, table: any[]): number { + if (!Array.isArray(table) || table.length === 0) return 0; + + const along = (['x', 'y'] as const) + .map((ch) => enc?.[ch]) + .find((e: any) => e?.field && e.type !== 'nominal' && e.type !== 'ordinal') + ?? enc?.x; + if (!along?.field) return 0; + + const keys = ['color', 'detail', 'strokeDash', 'shape'] + .map((ch) => enc?.[ch]?.field) + .filter((f: any): f is string => typeof f === 'string' && f !== along.field); + + const perSeries = new Map>(); + for (const row of table) { + const v = row?.[along.field]; + if (v == null) continue; + const key = keys.map((f) => String(row?.[f])).join('\u0000'); + let seen = perSeries.get(key); + if (!seen) perSeries.set(key, seen = new Set()); + seen.add(String(v)); + } + + let max = 0; + for (const seen of perSeries.values()) max = Math.max(max, seen.size); + return max; +} + +/** + * How many pixels one category gets. The renderer works this out for itself, + * but a pass that wants to re-cut a mark pinned in pixels has to arrive at the + * same number independently. + * + * A band scale usually states it outright — `width: {step: 46}` *is* the step, + * and dividing a plot width by the categories would answer a question nobody + * asked. Where neither the step nor a pixel size is stated the answer is that + * it is not known: a guess here re-cuts every mark on the chart against a + * number that came from nowhere. + */ +function bandStep(spec: any, node: any, enc: any, channel: 'x' | 'y', table: any[]): number | undefined { + const size = channel === 'x' ? (node.width ?? spec.width) : (node.height ?? spec.height); + if (size && typeof size === 'object' && typeof size.step === 'number') return size.step; + if (typeof size !== 'number') return undefined; + const count = distinctCount(table, enc?.[channel]?.field); + if (!count) return undefined; + return size / count; +} + +/** + * A group divider — the dashed rule a grouped box or violin plot draws between + * one department and the next — is written by the template as `bandPosition: 1`, + * the end of the band. That is the middle of the gap only while the gap is + * nothing: `bandPosition` is measured in band *widths*, so once a house asks for + * `paddingInner` the band stops short of the step and the divider lands hard + * against the right shoulder of the group on its left, reading as that group's + * edge rather than as a boundary between two. + * + * The gap runs from the band's end to the next band's start and is + * `step - width` wide; in band widths that is `paddingInner / (1 - paddingInner)`. + * Half of it puts the rule in the middle, where it belongs — equidistant from + * the two groups it separates, which is the whole claim a divider makes. + */ +function centerBandDividers(spec: any, paddedFields: Set, paddingInner: number, say: (p: string, m: string) => void): void { + if (paddingInner <= 0 || paddedFields.size === 0) return; + const centered = 1 + paddingInner / (2 * (1 - paddingInner)); + let said = false; + walk(spec, (node: any) => { + if (markTypeOf(node.mark) !== 'rule') return; + for (const channel of ['x', 'y'] as const) { + const e = node.encoding?.[channel]; + // A rule with a second end on the same axis spans a range and is + // anchored at both ends on purpose — a waterfall's connector reaches + // from one bar's edge to the next and must keep touching them. + if (!e || node.encoding?.[`${channel}2`]) continue; + if (e.bandPosition !== 1 || !paddedFields.has(e.field)) continue; + e.bandPosition = centered; + if (!said) { + say('marks.bandFraction', + 'group dividers move to the middle of the gap the house opened between bands'); + said = true; + } + } + }); +} + +/** + * A bar sitting on a continuous positional axis the layout has banded (a year, + * a date): one axis carries a temporal or quantitative field that is not + * binned. There is no band scale to take `paddingInner`, so the only handle on + * the gap is the pixel width the layout already cut. + */ +function continuousBandedBar(enc: any): boolean { + return (['x', 'y'] as const).some((c) => { + const e = enc?.[c]; + return e?.field && (e.type === 'temporal' || e.type === 'quantitative') && !e.bin; + }); +} + +/** Side-by-side lanes inside one band, from the offset channel if there is one. */ +function laneCount(node: any, enc: any, table: any[]): number { + const offset = enc?.xOffset ?? enc?.yOffset; + if (!offset?.field) return 1; + if (offset.type === 'quantitative') { + return Math.max(1, distinctCount(table, enc?.color?.field) || 1); + } + return Math.max(1, distinctCount(table, offset.field) || 1); +} + +/** + * Roughly how many pixels wide a single bar ends up. A pinned pixel size is the + * answer outright; otherwise the band the layout gives each category is split + * between the dodge lanes inside it, and the padding the house holds between + * bands is taken back off — a house that spends a third of every band on air + * draws a bar a third narrower than its step, which is the difference between + * a stroke that fits and one that paints the bar out. + */ +function estimateBarExtent(node: any, enc: any, table: any[], plotW: number, plotH: number): number { + const size = (node.mark as any)?.size; + if (typeof size === 'number') return size; + const discreteX = enc.x?.field && (enc.x.type === 'nominal' || enc.x.type === 'ordinal'); + const discreteY = enc.y?.field && (enc.y.type === 'nominal' || enc.y.type === 'ordinal'); + const channel = discreteX ? 'x' : discreteY ? 'y' : undefined; + if (!channel) return Infinity; + const span = channel === 'x' ? plotW : plotH; + const cats = distinctCount(table, enc[channel].field) || 1; + const lanes = laneCount(node, enc, table); + const step = span / (cats * lanes); + // Vega-Lite's own default gap for a banded bar, used when the house has + // not said how much of the band it wants the bar to fill. + const padInner = enc[channel].scale?.paddingInner; + const fill = 1 - (typeof padInner === 'number' ? padInner : 0.1); + return step * Math.max(0.05, fill); +} + +// --------------------------------------------------------------------------- +// Series ink +// --------------------------------------------------------------------------- + +/** + * Properties that describe *another* colour decision. Once the theme supplies + * an explicit range they are not merely redundant, they conflict — a `scheme` + * silently wins over `range`, and a `domainMid` on a quantize scale is a + * runtime error. + */ +function clearColorScale(scale: any): any { + const out = { ...scale }; + delete out.scheme; + delete out.interpolate; + delete out.domainMid; + delete out.reverse; + return out; +} + +/** A colour channel whose scale is continuous, and so cannot take a one-stop range. */ +function isContinuousColor(enc: any): boolean { + return enc?.type === 'quantitative' || enc?.type === 'temporal'; +} + +/** + * Write an explicit colour range, clearing whatever colour decision was there + * before. A continuous scale interpolates between range entries, so a single + * ink has to be stated twice — one stop is a runtime error in Vega, not a + * constant colour. + */ +function setColorRange(enc: any, range: string[], extra?: any): void { + const r = isContinuousColor(enc) && range.length < 2 ? [range[0], range[0]] : range; + enc.scale = { ...clearColorScale(enc.scale ?? {}), ...(extra ?? {}), range: r }; +} + +/** + * A near-neutral ink baked into a template was chosen against flint's own + * surface, which is white. What it means is its *distance* from that surface — + * `#f5f5f5` is a faint tint, `#1a1a1a` is a hard marker — and carried onto a + * dark surface both readings invert. Re-place each at the same distance from + * the surface it now sits on. Anything with real hue is left alone: that was a + * choice about meaning, not about contrast. + */ +function reToneNeutral(ink: string, surface: string): string { + const c = parseColor(ink); + const bg = parseColor(surface); + if (!c || !bg) return ink; + if (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) > 24) return ink; + const vb = (bg.r + bg.g + bg.b) / 3; + if (vb > 128) return ink; + const v = (c.r + c.g + c.b) / 3; + const t = Math.round(Math.min(255, vb + (255 - v))); + return toHex({ r: t, g: t, b: t, a: 1 }); +} + +const NEGATIVE_WORDS = /\b(below|decrease|decline|loss|losses|negative|down|fall|deficit|shrink|worse)\b/i; +const POSITIVE_WORDS = /\b(above|increase|growth|gain|gains|positive|up|rise|surplus|grow|better)\b/i; + +/** + * The status inks name roles — positive, negative, neutral — and a role is a + * property of the *category*, not of its place in the domain. Handing the + * three colours to the scale in a fixed order paints whichever series happens + * to sort first as the negative one, which is how "above average" ends up red. + * So pair each domain value with a role: by what it calls itself where the + * label says so, and otherwise by the sign of the quantity it carries. + */ +/** + * The synthetic field name a colour-overflow chart keys off: the top-share + * categories keep their own name, the tail collapses onto one "Others (N)" + * value so the legend can name it. + */ +const OVERFLOW_KEY_FIELD = '__flintColorKey'; + +/** + * Fold a colour field's tail categories onto one key via a Vega-Lite + * `calculate` transform: `topK` values pass through, everything else becomes + * `othersLabel`. The transform is prepended so the derived field exists before + * any the template added. Marks keep their own rows — only the colour key each + * row carries is remapped. + */ +function addColorKeyTransform( + node: any, + field: string, + topK: any[], + othersLabel: string, + keyField: string, +): void { + const f = JSON.stringify(field); + const calc = `indexof(${JSON.stringify(topK)}, datum[${f}]) >= 0 ? datum[${f}] : ${JSON.stringify(othersLabel)}`; + const transform = Array.isArray(node.transform) ? node.transform.slice() : []; + if (transform.some((t: any) => t?.as === keyField)) return; + transform.push({ calculate: calc, as: keyField }); + node.transform = transform; +} + +/** + * Order a colour domain by each category's share of the measure, largest + * first. Used when there are more series than the house's indexed set: the top + * inks go to the biggest categories and the small ones fold into the overflow + * tail, so the grey is the long tail rather than an arbitrary slice. Returns + * `undefined` for a continuous colour field or when no measure can be found. + */ +function orderDomainByShare(enc: any, node: any, table: any[]): any[] | undefined { + if (isContinuousColor(enc)) return undefined; + const field = enc.field; + if (!field) return undefined; + const domain: any[] = Array.isArray(enc.scale?.domain) && enc.scale.domain.length + ? enc.scale.domain.slice() + : [...new Set((table ?? []).map((r) => r?.[field]).filter((v) => v != null))]; + if (domain.length < 2) return undefined; + + // The measure whose share ranks the categories: theta for a pie, else the + // quantitative position (size before the axes for a bubble legend). + let measure: string | undefined; + for (const ch of ['theta', 'size', 'y', 'x'] as const) { + const e = node?.encoding?.[ch]; + if (e?.field && (e.type === 'quantitative' || e.type == null)) { measure = e.field; break; } + } + if (!measure) return undefined; + + const total = new Map(); + for (const row of table ?? []) { + const key = row?.[field]; + if (key == null) continue; + const v = Number(row?.[measure]); + total.set(key, (total.get(key) ?? 0) + (Number.isFinite(v) ? Math.abs(v) : 0)); + } + if (total.size === 0) return undefined; + // Stable descending sort, preserving the original domain order among ties. + return domain + .map((value, i) => ({ value, i, w: total.get(value) ?? 0 })) + .sort((a, b) => (b.w - a.w) || (a.i - b.i)) + .map((d) => d.value); +} + +function statusRange( + enc: any, + node: any, + status: { positive?: string; negative?: string; neutral?: string }, + table: any[], +): { domain: any[]; range: string[]; roles: string[] } | undefined { + if (isContinuousColor(enc)) return undefined; + const field = enc.field; + const domain: any[] = Array.isArray(enc.scale?.domain) && enc.scale.domain.length + ? enc.scale.domain + : [...new Set((table ?? []).map((r) => r?.[field]).filter((v) => v != null))]; + if (domain.length < 2) return undefined; + + // The measure the categories are signing: the quantitative position on the + // same node, y before x, since a signed bar is usually vertical. + let measure: string | undefined; + for (const ch of ['y', 'x', 'theta', 'size'] as const) { + const e = node?.encoding?.[ch]; + if (e?.field && (e.type === 'quantitative' || e.type == null)) { measure = e.field; break; } + } + + const roles = domain.map((value) => { + const label = String(value); + if (NEGATIVE_WORDS.test(label)) return 'negative'; + if (POSITIVE_WORDS.test(label)) return 'positive'; + if (!measure) return 'neutral'; + let sum = 0; let n = 0; + for (const row of table ?? []) { + if (row?.[field] !== value) continue; + const v = Number(row?.[measure]); + if (Number.isFinite(v)) { sum += v; n++; } + } + if (n === 0) return 'neutral'; + return sum < 0 ? 'negative' : sum > 0 ? 'positive' : 'neutral'; + }); + + // No sign to speak of — every category reads the same way, so the status + // set says nothing the categorical set would not say better. + if (new Set(roles).size < 2) return undefined; + const range = roles.map((role) => (status as any)[role] ?? status.neutral ?? status.positive); + if (range.some((c) => !c)) return undefined; + return { domain, range: range as string[], roles }; +} + +function applySeriesInk(spec: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): void { + const s = d.series; + let sawColorField = false; + let saidCollapse = false; + let saidStatus = false; + let saidExhausted = false; + // Merging the overflow tail into one summed slice rewrites the data feeding + // this node. Safe for a lone arc view; inside a layer/facet/concat the + // sibling layers (a value-label text mark, say) still read the full rows, + // so summing one layer's data would desync them. Only merge when the spec + // is a single view. + const composed = !!(spec?.layer || spec?.concat || spec?.hconcat || spec?.vconcat || spec?.facet || spec?.spec); + /** The house's indexed set, extended to `n` positions with the overflow ink. */ + const palette = (n: number): string[] => { + const out = s.categorical.slice(0, n); + while (out.length < n) { + out.push(s.overflow ?? s.categorical[out.length % Math.max(1, s.categorical.length)] ?? s.single); + } + return out.length ? out : [s.single]; + }; + + walk(spec, (node) => { + for (const channel of ['color', 'fill', 'stroke'] as const) { + const enc = node.encoding?.[channel]; + if (!enc || !enc.field) continue; + sawColorField = true; + + // A scale that already names its domain has told us how many inks + // it needs. Writing fewer would not restyle the chart, it would + // erase a distinction the chart was built to make. + const declared = Array.isArray(enc.scale?.domain) ? enc.scale.domain.length : 0; + + if (s.mode === 'single') { + if (declared > 1 && !isContinuousColor(enc)) { + if (!saidCollapse) { + say('ink.series', + `grounding saw one series but \`${enc.field}\` declares ${declared} colour values — the house's categorical set is used rather than flattening them`); + saidCollapse = true; + } + setColorRange(enc, palette(declared)); + } else { + setColorRange(enc, [s.single]); + } + continue; + } + if (s.mode === 'status' && s.status) { + const signed = statusRange(enc, node, s.status, table); + if (signed) { + setColorRange(enc, signed.range, { domain: signed.domain }); + if (!saidStatus) { + say('ink.series.status', + `the categories carry a sign — ${signed.domain.map((v, i) => `${v} is ${signed.roles[i]}`).join(', ')}`); + saidStatus = true; + } + } else { + setColorRange(enc, [s.status.negative, s.status.neutral, s.status.positive].filter(Boolean) as string[]); + } + continue; + } + if ((s.mode === 'sequential' || s.mode === 'diverging') && s.range?.length) { + const quantize = s.quantize && enc.type === 'quantitative'; + // A diverging/sequential ramp sampled onto a *discrete* colour + // domain (a waterfall's total/increase/decrease, say) can hand + // a near-neutral midpoint to a bar fill. That neutral was + // placed against a light surface; on a dark canvas it sinks + // into the background. Re-tone the neutral entries to the same + // distance from this surface, leaving the hued endpoints — and + // every continuous heat ramp — untouched. + const surface = d.surface.plot ?? d.surface.canvas; + const range = isContinuousColor(enc) + ? s.range + : s.range.map((c) => reToneNeutral(c, surface)); + setColorRange(enc, range, quantize ? { type: 'quantize' } : undefined); + continue; + } + // Categorical: an ordered set consumed by index, with the overflow + // ink taking every position past the end. A count of zero means + // grounding could not count the series, so the whole set is + // offered and the scale takes what it needs. + const need = Math.max(d.bound.seriesCount || s.categorical.length || 1, declared); + if (s.exhausted && need > s.categorical.length) { + // Grounding found the house short of inks and short of an + // answer for the remainder. Whatever scheme is on the chart + // was picked for this many categories; a shorter set repainted + // over it would only make two things look like one. + if (!saidExhausted) { + say('ink.series.categorical', + `${need} series against ${s.categorical.length} house inks — the scale is left as it is so no two series share a colour`); + saidExhausted = true; + } + continue; + } + if (s.overflowTail && s.overflow && need > s.categorical.length && !subtreeHasConnectedMark(node)) { + // Order the colour domain by share so the largest series keep + // a named ink and the small ones fold into the single overflow + // tail — a chart with a handful of headline categories and a + // grey remainder, not a wheel of near-identical hues. + // + // Only for marks that stand on their own — a pie's wedges, a + // scatter's points, a bar's bars. A line or an area is a + // *connected* series: fold two of them onto one colour key and + // Vega-Lite threads a single path through both, a grey scribble + // where two readings should be. Those keep the palette (top + // inks, the rest sharing the overflow ink but each still its + // own line) via the fall-through below. + const ordered = orderDomainByShare(enc, node, table); + if (ordered && ordered.length > s.categorical.length) { + const k = s.categorical.length; + const topK = ordered.slice(0, k); + const restCount = ordered.length - k; + // One legend row stands in for the grey tail — an explicit + // "Others (N)" swatch in the overflow ink, so the reader is + // told how many categories share it rather than being left + // to infer that the grey wedges are "everything else". + const othersLabel = `Others (${restCount})`; + const field = enc.field; + const keyField = OVERFLOW_KEY_FIELD; + // A derived key folds the tail categories onto one value. + // The marks keep their own rows (a pie still draws N wedges, + // a bar N bars) — only which colour-key each row shows is + // remapped, so the legend dedupes to top-K plus one Others. + addColorKeyTransform(node, field, topK, othersLabel, keyField); + enc.field = keyField; + enc.type = 'nominal'; + const domain = [...topK, othersLabel]; + const range = [...s.categorical.slice(0, k), s.overflow!]; + setColorRange(enc, range, { domain }); + if (enc.legend !== null) { + // The K+1 domain is short enough to list in full; drop + // any values pin the template left so Others shows too. + const legend = { ...(enc.legend ?? {}) }; + delete legend.values; + // assemble shrank these labels (and their swatches) to + // fit the field's *full* cardinality; the fold now shows + // only the K largest plus one Others row, so that shrink + // is measured against the wrong count. Recompute it here + // against what actually renders — a short list stands at + // the house's own size, not squeezed to 8px for names it + // no longer draws. + if (topK.length + 1 < HIGH_CARDINALITY_LEGEND_MIN) { + delete legend.labelFontSize; + delete legend.symbolSize; + } + enc.legend = legend; + } + // On a part-to-whole chart the tail is a *share*, so summing + // it is meaningful: merge the "Others" categories into one + // slice at the data level rather than fanning out N same-grey + // wedges. Only when the angle is a plain summed field (a + // count already groups by the colour key) and nothing else is + // encoded off a field the aggregate would drop. + const thetaEnc = node.encoding?.theta; + const thetaField = thetaEnc?.field; + let merged = false; + if ( + isPartToWholeMark(node) && + !composed && + typeof thetaField === 'string' && + !thetaEnc.aggregate && + otherEncodedFields(node.encoding, new Set([thetaField, keyField])).length === 0 + ) { + addTailAggregate(node, thetaField, keyField); + merged = true; + } + if (!saidExhausted) { + say('ink.series.categorical', + merged + ? `${need} series past ${k} inks — the ${k} largest keep a colour, the rest sum into one "${othersLabel}" slice` + : `${need} series past ${k} inks — the ${k} largest keep a colour, the rest fold into one "${othersLabel}" ink named in the legend`); + saidExhausted = true; + } + continue; + } + } + setColorRange(enc, palette(need)); + } + }); + + // No colour channel at all: every data mark takes the single-series ink. + // Unless it already states one. A layer that hard-codes its own colour + // beside a colour-encoded layer is *context* — a bullet chart's qualitative + // bands, a target tick, a reference band. Those are roles, not series, and + // painting them the series ink erases the distinction they exist to make. + // They do still have to be re-toned: a neutral chosen against white is + // invisible on black. + let saidRole = false; + let saidChrome = false; + let saidHollow = false; + const surface = d.surface.plot ?? d.surface.canvas; + + // Furniture the template drew in pixels — a card, a track, a caption — is + // painted with literal `fill`s chosen against flint's white. Those are + // roles, not series: they keep their hues and only move to the same + // distance from the surface they now sit on. Left alone, a white card + // stays a white island on a dark canvas. + // A label the template wrote itself — a radar's axis names, drawn as text + // marks because the plot has no axis to hang them on — is chrome the house + // owns. It is positioned by fields, so the furniture pass below does not + // see it, and its literal grey was picked against flint's white: on a dark + // canvas it sinks into the surface. The text is a constant, so nothing here + // is a reading; it takes the house's secondary ink like any other label. + let saidLabels = false; + walk(spec, (node) => { + if (markTypeOf(node.mark) !== 'text' || node.__themeSynthetic || node.__themeRole) return; + if (node.encoding?.text?.value === undefined) return; + const mark = normalizeMark(node.mark); + let changed = false; + for (const key of ['fill', 'color'] as const) { + if (typeof mark[key] === 'string' && mark[key] !== d.text.secondary) { + mark[key] = d.text.secondary; + changed = true; + } + } + if (!changed) return; + node.mark = mark; + if (!saidLabels) { + say('type.axisLabel', + 'the template wrote its own labels as text marks — those are chrome and take the house label ink, not the literal grey they were drawn in'); + saidLabels = true; + } + }); + + // Vega-Lite paints a box plot's whiskers and caps from the mark's colour, + // but only when the mark states one: with the colour *encoded*, the box + // takes the series ink and the rule falls back to a literal black. On a + // dark canvas the extremes then disappear entirely. The whisker is + // structure holding a reading, so it takes the house's text ink — which on + // a light house is the black it already was. + let saidWhisker = false; + walk(spec, (node) => { + if (markTypeOf(node.mark) !== 'boxplot') return; + const encoded = node.encoding?.color?.field ?? node.encoding?.fill?.field; + if (!encoded) return; + const mark = normalizeMark(node.mark); + if (mark.color != null) return; + let changed = false; + for (const part of ['rule', 'ticks'] as const) { + const own = mark[part]; + // `ticks` is off unless the mark asks for it, and an object *is* + // asking — so only an existing tick gets recoloured. A rule is + // always drawn, so it can be given one. + if (part === 'ticks' && !(own && typeof own === 'object')) continue; + if (own && typeof own === 'object' && own.color != null) continue; + mark[part] = { ...(typeof own === 'object' ? own : {}), color: d.text.primary }; + changed = true; + } + if (!changed) return; + node.mark = mark; + if (!saidWhisker) { + say('ink.series', + 'the box takes its ink from the colour channel, which leaves the whiskers literal black — they take the text ink so the extremes stay legible on any surface'); + saidWhisker = true; + } + }); + + walk(spec, (node) => { + if (!markTypeOf(node.mark) || node.__themeSynthetic || !isLiteralMark(node)) return; + if (node.__themeRole) return; + const mark = normalizeMark(node.mark); + let changed = false; + for (const key of ['fill', 'stroke', 'color'] as const) { + const own = mark[key]; + if (typeof own !== 'string') continue; + const toned = reToneNeutral(own, surface); + if (toned !== own) { mark[key] = toned; changed = true; } + } + if (!changed) return; + node.mark = mark; + if (!saidChrome) { + say('ink.series', + 'the template drew its own furniture in literal colours — those keep their role and are re-toned against the surface'); + saidChrome = true; + } + }); + + paintRoleMarks(spec, d, say); + + walk(spec, (node) => { + const mark = markTypeOf(node.mark); + if (!mark || !DATA_MARKS.has(mark)) return; + if (node.encoding?.color?.field || node.encoding?.fill?.field || node.encoding?.stroke?.field) return; + if (node.encoding?.color?.value != null || node.__themeSynthetic) return; + if (isLiteralMark(node)) return; + // A box drawn hollow is a box drawn over its own observations: the + // sample is the figure and the summary has demoted to scaffolding + // around it. Series ink on scaffolding puts the house colour on the + // part of the mark that carries no value, and leaves the box and the + // median it holds in two different colours. Both take the structural + // ink instead, and the series ink stays where the data is. + const box = normalizeMark(node.mark).box; + if (mark === 'boxplot' && box && typeof box === 'object' && box.filled === false) { + const structural = d.text.primary; + const current = normalizeMark(node.mark); + node.mark = { + ...current, + color: structural, + ...(current.median ? { median: { ...current.median, color: structural } } : {}), + }; + if (!saidHollow) { + say('ink.series', + 'the box is hollow because the observations are drawn through it — the outline is scaffolding and takes the text ink, not the series ink'); + saidHollow = true; + } + return; + } + const own = normalizeMark(node.mark).color; + if (typeof own === 'string' && sawColorField) { + const toned = reToneNeutral(own, surface); + if (toned !== own && !saidRole) { + say('ink.series', + 'the layer states its own colour beside a colour-encoded layer — it is context, not series, so it keeps its role and is only re-toned against the surface'); + saidRole = true; + } + node.mark = { ...normalizeMark(node.mark), color: toned }; + return; + } + node.mark = { ...normalizeMark(node.mark), color: s.single }; + }); + + if (!sawColorField && s.mode === 'categorical' && paintPanelSeries(spec, palette)) { + say('ink.series', + 'the series is carried by the panels of a concatenation rather than a colour channel — the house set is assigned across the panels'); + return; + } + + if (!sawColorField && s.mode !== 'single') { + say('ink.series', `grounded as \`${s.mode}\` but the chart has no colour channel — single ink used`); + } +} + +/** + * A population pyramid encodes sex by *panel*, not by a colour channel: each + * side of the concatenation hard-codes its own ink. That is still a series, + * and the house's set should land on it. Only concatenations qualify — a + * literal colour inside a layered chart is usually annotation, not series. + * + * @returns whether anything was repainted. + */ +function paintPanelSeries(spec: any, palette: (n: number) => string[]): boolean { + if (!spec.hconcat && !spec.vconcat && !spec.concat) return false; + const bodies = plotBodies(spec); + const literal = (node: any): string | undefined => { + const v = node.encoding?.color?.value ?? node.encoding?.fill?.value; + if (typeof v === 'string') return v; + const m = normalizeMark(node.mark); + return typeof m?.color === 'string' ? m.color : undefined; + }; + const targets: { node: any; ink: string }[] = []; + for (const body of bodies) { + const units = body.layer ? body.layer : [body]; + for (const unit of units) { + if (unit.__themeSynthetic) continue; + if (!DATA_MARKS.has(markTypeOf(unit.mark) ?? '')) continue; + const ink = literal(unit); + if (ink) targets.push({ node: unit, ink }); + } + } + const distinct = [...new Set(targets.map((t) => t.ink))]; + if (distinct.length < 2) return false; + const inks = palette(distinct.length); + for (const { node, ink } of targets) { + const next = inks[distinct.indexOf(ink)]; + if (node.encoding?.color?.value != null) node.encoding.color = { value: next }; + else if (node.encoding?.fill?.value != null) node.encoding.fill = { value: next }; + else node.mark = { ...normalizeMark(node.mark), color: next }; + } + return true; +} + +// --------------------------------------------------------------------------- +// Redundant channels +// --------------------------------------------------------------------------- + +/** + * Repeat the series identity on a second, non-colour channel. Vega-Lite merges + * the legends automatically as long as the field and type match, so the key + * stays one block rather than two. + */ +function applyRedundantChannels(spec: any, d: DesignDecisions, say: (p: string, m: string) => void): void { + const r = d.marks.redundant; + if (!r.shape && !r.dash) return; + + let placed = false; + walk(spec, (node) => { + const mark = markTypeOf(node.mark); + if (!mark || node.__themeSynthetic) return; + const series = node.encoding?.color ?? node.encoding?.stroke; + if (!series?.field) return; + const key = { field: series.field, type: series.type ?? 'nominal' }; + + if (r.shape && (mark === 'point' || mark === 'circle' || mark === 'square')) { + if (!node.encoding.shape) node.encoding.shape = { ...key }; + // `circle` and `square` hard-code their own shape; only `point` + // can take one from a scale. + if (mark !== 'point') node.mark = { ...normalizeMark(node.mark), type: 'point' }; + placed = true; + } + if (r.dash && LINE_MARKS.has(mark)) { + if (!node.encoding.strokeDash) node.encoding.strokeDash = { ...key }; + placed = true; + } + if (r.shape && LINE_MARKS.has(mark) && d.marks.point?.show) { + placed = true; + } + }); + + if (!placed) { + say('marks.redundantEncoding', + 'no mark in this chart can carry a redundant channel — colour is on its own'); + } +} + +// --------------------------------------------------------------------------- +// Legend +// --------------------------------------------------------------------------- + +function applyLegend(spec: any, config: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): void { + const l = d.legend; + + if (!l.show || l.placement === 'seriesEnd' || l.placement === 'inline') { + // The legend "show" decision (and seriesEnd/inline placement) is about + // *naming series* — the colour/shape key. A quantitative size or + // opacity encoding is a different kind of legend: a value key, like a + // bubble map's size scale. It has to survive even when there is no + // series legend, or the reader cannot decode how big is how much. + let keptValueKey = false; + walk(spec, (node) => { + for (const channel of ['color', 'fill', 'stroke', 'shape', 'size', 'opacity'] as const) { + const enc = node.encoding?.[channel]; + if (!enc?.field) continue; + if ((channel === 'size' || channel === 'opacity') && enc.type === 'quantitative') { + keptValueKey = true; + continue; + } + enc.legend = null; + } + }); + if (!keptValueKey) return; + // A value key survives; the series-naming placement ('none' for + // seriesEnd, or no legend at all) does not govern it. Style it in the + // house's legend type and seat it to the side. + config.legend = { + ...(config.legend ?? {}), + orient: !l.orient || l.orient === 'none' ? 'right' : l.orient, + labelFont: l.label.font, + labelFontSize: l.label.fontSize, + labelColor: l.label.color, + titleFont: l.label.font, + titleFontSize: l.label.fontSize, + titleColor: d.text.muted, + }; + say('legend.valueKey', + 'no series legend, but the size key is a value scale — it stays so the bubble sizes can be read'); + return; + } + + config.legend = { + ...(config.legend ?? {}), + orient: l.orient, + direction: l.direction, + labelFont: l.label.font, + labelFontSize: l.label.fontSize, + labelColor: l.label.color, + titleFont: l.label.font, + titleFontSize: l.label.fontSize, + titleColor: d.text.muted, + ...(l.gradientLength ? { gradientLength: l.gradientLength } : {}), + }; + // A colour ramp is a caption, not an exhibit. Left alone the renderer draws + // it at a fixed 200px, which on a small chart is most of the plot's width + // and reads as a second graphic above the first. Where the house has not + // said how long, it is cut to a share of the block it labels. + if (!l.gradientLength && (l.direction ?? 'horizontal') === 'horizontal') { + const block = blockWidth(spec, table); + if (block) { + const length = Math.round(clamp(block * 0.45, 80, 200)); + config.legend.gradientLength = length; + say('legend.gradientLength', + `the ramp runs ${length}px — under half the ${block}px block, so the key stays a caption to the chart`); + } + } + // Two kinds of key can share one chart, and "does this key need a title?" + // has different answers for them. + // + // `whenAmbiguous` asks whether the labels say what they are. `Europe`, + // `Asia`, `Africa` plainly do, so writing `Continent` over them repeats + // what the reader can already see. `200`, `600`, `1,000` do not — a number + // is an instance of nothing until the key names what it counts, and a + // bubble key with no title leaves the reader asking "200 what?". + // + // The question was being asked once, of the series channel, and its answer + // applied to every key on the chart. On the Gapminder shape — a continent + // colour and a population size on the same plot — the colour key answered + // "my labels name themselves" and the size key lost its title on the + // strength of it. Both came out bare, in all nine houses. + // + // So the suppression is now aimed at the key it was reasoned about. A + // value key keeps its title whatever the series key concluded. That is + // 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. + if (!l.title) { + let valueKeys = 0; + walk(spec, (node) => { + for (const channel of ['color', 'fill', 'stroke', 'shape', 'size', 'opacity'] as const) { + const enc = node.encoding?.[channel]; + if (!enc?.field || enc.legend === null) continue; + // A value key is the ruler kind: size and opacity carrying a + // number. Everything else on this list is naming series. + const isValueKey = (channel === 'size' || channel === 'opacity') + && enc.type === 'quantitative'; + if (isValueKey) { valueKeys++; continue; } + enc.legend = { ...(enc.legend ?? {}), title: null }; + } + }); + if (valueKeys === 0) config.legend.title = null; + else { + say('legend.title', + `the series key names its own labels and drops its title; ${valueKeys === 1 ? 'the value key keeps' : `${valueKeys} value keys keep`} ` + + 'theirs, because a number names nothing until the key says what it counts'); + } + } + // A vertical-axis title laid flat sits at the plot's top-left corner; a key + // riding along the top of that same plot lands its last row in the very + // strip the title occupies, and on a multi-row key the two collide. Push + // the key up off the plot far enough to clear the flat title. + const yTitleForOffset = d.axes?.y?.title; + if (l.orient === 'top' && yTitleForOffset?.show + && (yTitleForOffset.placement === 'flatAboveAxis' || yTitleForOffset.placement === 'inline')) { + const clearance = (yTitleForOffset.fontSize ?? 11) + 12; + config.legend.offset = (config.legend.offset ?? 18) + clearance; + 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] ?? {}), + }, + }; + } + if (l.orient === 'top-right' || l.orient === 'top-left') { + config.legend.fillColor = d.surface.plot; + config.legend.padding = 4; + config.legend.strokeColor = null; + } + + // Two keys, and only one of them is about colour. A size key inherits the + // mark's ink, which on a chart that also keys colour means its swatches + // come out the same slate as the first category's — a fifth continent, in + // effect, sitting beside the four real ones. Neutral ink says the row is + // measuring, not naming. + const namesColour = (() => { + let found = false; + walk(spec, (node) => { + const enc = node.encoding?.color ?? node.encoding?.fill; + if (enc?.field && enc.legend !== null && enc.type !== 'quantitative') found = true; + }); + return found; + })(); + if (namesColour) { + let neutralised = false; + walk(spec, (node) => { + const enc = node.encoding?.size; + if (!enc?.field || enc.legend === null) return; + enc.legend = { ...(enc.legend ?? {}), symbolFillColor: d.text.muted }; + neutralised = true; + }); + if (neutralised) { + say('legend.placement', + 'the size key is drawn in neutral ink — beside a colour key, swatches in series ink read as another category'); + } + } + + // A key to *values* is sampled, not enumerated: the renderer will happily + // print every tick it can fit, and a row of nine bubbles reads as data. The + // values are chosen round so the reader can interpolate between them. + // + // A size key is capped whether or not the house asked for it. How many + // anchors an area scale needs is a fact about reading areas, not a house + // style: a size key is a ruler calibration, not a lookup table. A nominal + // colour key has to enumerate, because nothing lets you interpolate a hue + // into a category; a size key is read by comparing a bubble to its nearest + // anchors, so it only needs enough of them to fix the mapping and show its + // curvature. Two anchors fix the ends but sqrt-area is not linear, so + // mid-range reads skew; three show the curve. Past three, every extra + // symbol is horizontal space taken from the plot — on a small canvas the + // size key is the widest thing in the chart. + // + // Colour and opacity ramps stay opt-in. `roundSample` spaces its samples + // quadratically and drops anything non-positive, which is right for an area + // scale and wrong for a ramp that crosses zero. + { + let capped = false; + let cappedAt = 0; + walk(spec, (node) => { + for (const channel of ['size', 'color', 'opacity'] as const) { + const enc = node.encoding?.[channel]; + if (!enc?.field || enc.type !== 'quantitative' || enc.legend === null) continue; + const cap = channel === 'size' ? (l.maxSwatches ?? SIZE_KEY_SWATCHES) : l.maxSwatches; + if (!cap) continue; + if (channel === 'color' && !enc.scale?.type) continue; + // Discretizing colour scales (quantize/quantile/threshold) already + // render one swatch per bin — sampling round tick values collapses + // that stepped key into a couple of end-labels over a gradient bar. + if (channel === 'color' + && ['quantize', 'quantile', 'threshold', 'bin-ordinal'].includes(enc.scale?.type)) continue; + if (enc.legend?.values) continue; + const values = roundSample(table, enc.field, cap); + if (!values) continue; + enc.legend = { ...(enc.legend ?? {}), values }; + capped = true; + cappedAt = cap; + } + }); + if (capped) { + say('legend.maxSwatches', + `the key to values is sampled at ${cappedAt} round sizes — a swatch for every tick reads as data, not as a key`); + } + } + + // Two keys, one row. Laid side by side above the plot a colour key and a + // size key eat the block between them, and past a point the second is + // pushed hard against the first with nothing to separate them. Vega will + // stack them given the word, so the word is given by measurement: they + // share a row while they fit across the block, and take one each when they + // do not. + if (l.orient === 'top' || l.orient === 'bottom') { + 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) { + config.legend.layout = { + ...(config.legend.layout ?? {}), + [l.orient]: { + ...(config.legend.layout?.[l.orient] ?? {}), + direction: 'vertical', + anchor: 'start', + }, + }; + say('legend.placement', + `${widths.length} keys want ${Math.round(total)}px across a ${block}px block — 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. + 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'); + } + } + + // A single key with many entries laid horizontally is the same overrun seen + // one key at a time: the house asked for one row, and one row of fifty names + // runs off the side of the canvas. A row has a width — the block the chart + // occupies — so the entries are wrapped into as many columns as fit and no + // more, and where even a wrapped grid would tower over the plot the count + // is capped. Vega-Lite draws a top or bottom legend in a single row unless + // told how many columns to fill, so it is told. + if (l.orient === 'top' || l.orient === 'bottom') { + wrapWideKeys(spec, l, blockWidth(spec, table), table, say); + } + void say; +} + +/** + * Wrap a high-cardinality horizontal key into a bounded grid. + * + * A top or bottom legend flows its entries along a single row until told + * otherwise. One key with more names than fit across the block overruns the + * canvas, so the row is broken into as many columns as the block holds; and + * because a very long list would then grow downward without end, the number + * of entries drawn is capped to a few rows' worth. + */ +function wrapWideKeys( + spec: any, l: DesignDecisions['legend'], block: number | undefined, table: any[], + say: (p: string, m: string) => void, +): void { + if (!block) return; + const MAX_ROWS = 4; + // A top/bottom legend has the whole chart width to flow across; the base + // display is 300px wide, so a narrow plot's key still has at least this + // much room before it has to stack into one column. + const MIN_HORIZONTAL_LEGEND_BLOCK = 280; + // A full categorical palette's worth of names always fits — the point past + // which high cardinality has already been folded into "Others (N)". + const MIN_LEGEND_ENTRIES = 12; + walk(spec, (node) => { + for (const channel of ['color', 'fill', 'stroke'] as const) { + const enc = node.encoding?.[channel]; + if (!enc?.field || enc.legend === null || enc.type === 'quantitative') continue; + const entries: string[] = Array.isArray(enc.legend?.values) + ? enc.legend.values.map(String) + : Array.isArray(enc.scale?.domain) + ? enc.scale.domain.map(String) + : orderedValues(table, enc.field).map(String); + if (entries.length < 2) continue; + const labelFS = enc.legend?.labelFontSize ?? l.label.fontSize ?? 10; + const symbolArea = enc.legend?.symbolSize; + const symbol = symbolArea ? 2 * Math.sqrt(symbolArea / Math.PI) : 10; + // Vega-Lite packs a legend row: each entry takes the width of its + // own name, not the width of the longest one. Measuring the row as + // `count × widest` therefore charges every short name the price of + // the longest, and a four-key Likert scale — one long name and + // three short ones — was wrapped to three columns with a third of + // the block still empty. (Verified by rendering: forced to one + // row, the entries sit tight and the row clears the block.) + const widthOf = (e: string) => symbol + 4 + e.length * labelFS * 0.55 + 10; + // A top or bottom legend flows across the whole chart, not just the + // plot the marks occupy. A five-bar stacked column is ~90px wide + // yet its key has the full canvas to spread over, so a narrow plot + // must not force the key into one column (and, below, into a cap + // that would then hide entries). + const usableBlock = Math.max(block, MIN_HORIZONTAL_LEGEND_BLOCK); + // The widest run of `n` consecutive entries is what has to clear + // the block, since that is the row Vega-Lite will actually draw. + const widths = entries.map(widthOf); + const rowFits = (n: number) => { + for (let i = 0; i < widths.length; i += n) { + let sum = 0; + for (let j = i; j < Math.min(i + n, widths.length); j += 1) sum += widths[j]; + if (sum > usableBlock) return false; + } + return true; + }; + let columns = 1; + for (let n = entries.length; n >= 1; n -= 1) { + if (rowFits(n)) { columns = n; break; } + } + if (entries.length <= columns) continue; + enc.legend = { ...(enc.legend ?? {}), columns }; + // A folded key is already the bounded top-K + "Others (N)" list the + // overflow pass built to be listable in full; capping it would hide + // the very Others row that stands in for the tail. Wrap it into + // columns, but never truncate it. + const folded = enc.field === OVERFLOW_KEY_FIELD; + // The cap bounds a genuine *tower* of names — dozens of entries no + // grid can hold. It must never hide the handful of series a normal + // key names: on a stacked bar or an area the colour key is the only + // thing telling those series apart, so truncating it to "…2 entries" + // leaves two bands unidentifiable. Never cap below a full + // categorical palette's worth (past which the "Others (N)" fold + // would already have engaged upstream). + const cap = Math.max(columns * MAX_ROWS, MIN_LEGEND_ENTRIES); + if (!folded && entries.length > cap) enc.legend.symbolLimit = cap; + say('legend.columns', + `${entries.length} keys in one row overrun the ${Math.round(block)}px block — wrapped to ${columns} columns`); + } + }); +} + +/** + * Roughly how wide each key drawn on this chart wants to be. + * + * Rough is the point: the question is whether two keys fit on one row, and that + * is answered by tens of pixels, not by ones. A swatch, a gap, the label, the + * space to the next entry — summed over the entries the key will show. + */ +function keyWidths(spec: any, table: any[], fontSize: number): number[] { + const seen = new Map(); + 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; + // One field is one key. Vega-Lite merges the guides for a field + // however many channels and layers carry it — a scatter that fills + // and strokes by the same column draws one legend, not two — so + // counting them separately would stack a legend against itself. + const entries: string[] = enc.legend?.values + ? enc.legend.values.map((v: any) => (typeof v === 'number' ? v.toLocaleString('en-US') : String(v))) + : Array.isArray(enc.scale?.domain) + ? enc.scale.domain.map(String) + : orderedValues(table, enc.field).map(String); + if (!entries.length) continue; + // A size key's swatch is as wide as the biggest bubble it shows, and + // that is the whole reason it is there. + const area = channel === 'size' ? (enc.scale?.range?.[1] ?? 100) : 0; + const symbol = area ? 2 * Math.sqrt(area / Math.PI) : 10; + const width = entries.reduce((w, e) => w + symbol + 4 + e.length * fontSize * 0.55 + 10, 0); + seen.set(enc.field, Math.max(seen.get(enc.field) ?? 0, width)); + } + }); + return [...seen.values()]; +} + +/** A few round numbers spanning what a field holds, largest last. */ +/** + * How many anchors a size key spends when the house has not said. + * + * Three: enough to fix the mapping and show that area, not radius, carries the + * value; few enough that the key stays a key. See the call site for why this is + * the engine's default rather than a house preference. + */ +const SIZE_KEY_SWATCHES = 3; + +function roundSample(table: any[], field: string, count: number): number[] | undefined { + let max = -Infinity; + let min = Infinity; + for (const row of table ?? []) { + const v = Number(row?.[field]); + if (!Number.isFinite(v)) continue; + if (v > max) max = v; + if (v < min) min = v; + } + if (!Number.isFinite(max) || max <= 0) return undefined; + const round1 = (v: number) => { + const mag = 10 ** Math.floor(Math.log10(Math.abs(v))); + return Math.round(v / mag) * mag; + }; + const out: number[] = []; + for (let i = 0; i < count; i++) { + const t = count === 1 ? 1 : (i + 1) / count; + const v = round1(min + (max - min) * t * t); + if (v > 0 && !out.includes(v)) out.push(v); + } + return out.length > 1 ? out : undefined; +} + +// --------------------------------------------------------------------------- +// Facet chrome +// --------------------------------------------------------------------------- + +function applyFacetChrome(config: any, d: DesignDecisions): void { + const f = d.facets; + config.header = { + ...(config.header ?? {}), + labelFont: f.header.font, + labelFontSize: f.header.fontSize, + labelColor: f.header.color, + labelFontWeight: f.header.fontWeight ?? 'normal', + ...(f.header.show ? {} : { labels: false }), + ...(f.header.fieldTitle ? {} : { title: null }), + }; + // A header is drawn *in* the gap above its panel. A house may set its + // panels tight — or inherit a tight layout — but the gap still has to hold + // the name, or the name lands on the panel above it. + const current = config.facet?.spacing; + const base = f.spacing ?? (typeof current === 'number' ? current : undefined); + const needed = f.header.show === false ? 0 : Math.round((f.header.fontSize ?? 11) * 1.7); + if (base != null && needed > base) { + config.facet = { ...(config.facet ?? {}), spacing: { row: needed, column: base } }; + } else if (f.spacing != null) { + config.facet = { ...(config.facet ?? {}), spacing: f.spacing }; + } +} + +/** + * A concat child may carry a title of its own. It is not the chart's headline + * — it names one panel of several — but Vega-Lite gives every title in the + * spec the same config, so it arrives in the headline's weight and the + * headline's anchor, competing with the sentence above it. + * + * A panel of a concat and a panel of a facet are the same thing to the reader, + * so the name is set in the voice the house already chose for facet headers, + * over the middle of the panel it names. + * + * And where that name is a value of the field the panels are coloured by, it + * is written in that value's ink. The name and the swatch become one object, + * which is the reason there is no key beside it. + */ +function applyPanelTitles(spec: any, d: DesignDecisions, say: (p: string, m: string) => void): void { + const f = d.facets; + let said = false; + const visit = (node: any): void => { + if (!node || typeof node !== 'object') return; + for (const key of ['hconcat', 'vconcat', 'concat']) { + const children = (node[key] ?? []).filter((c: any) => typeof c?.title === 'string'); + // The ink says which panel is which only when the panels differ in + // it. Where every panel is drawn in the same colour, that colour + // distinguishes nothing, and a name written in it is decoration. + const inks = children.map((c: any) => panelInk(c, c.title)); + const distinct = new Set(inks.filter(Boolean)); + const naming = distinct.size > 1 && distinct.size === children.length; + for (const [i, child] of children.entries()) { + const ink = naming ? inks[i] : undefined; + child.title = { + text: child.title, + anchor: 'middle', + font: f.header.font, + fontSize: f.header.fontSize, + fontWeight: f.header.fontWeight ?? 'normal', + color: ink ?? f.header.color, + }; + if (!said) { + say('facets.header', ink + ? 'the panel names are set in their own panel\'s ink — the name is the swatch, so no key is drawn beside it' + : 'the panel names are set in the header voice, not the headline\'s — they name a panel, not the chart'); + said = true; + } + } + for (const child of node[key] ?? []) visit(child); + } + if (node.spec) visit(node.spec); + }; + visit(spec); +} + +/** + * The ink a panel is drawn in: the colour its own scale gives the value it is + * named for, or — where the panel holds one series and the colour was painted + * straight onto the marks — that colour. Read off the chart rather than + * recomputed, since whatever ink ended up on the marks is the ink the reader + * will match the name to. + */ +function panelInk(node: any, value: string): string | undefined { + let scaled: string | undefined; + const painted = new Set(); + walk(node, (n) => { + for (const channel of ['color', 'fill', 'stroke'] as const) { + const enc = n.encoding?.[channel]; + if (!enc) continue; + const domain = enc.scale?.domain; + const range = enc.scale?.range; + if (Array.isArray(domain) && Array.isArray(range)) { + const i = domain.indexOf(value); + if (i >= 0 && typeof range[i] === 'string') scaled ??= range[i]; + } + if (typeof enc.value === 'string' && DATA_MARKS.has(markTypeOf(n.mark) ?? '')) painted.add(enc.value); + } + if (!DATA_MARKS.has(markTypeOf(n.mark) ?? '')) return; + const mark = normalizeMark(n.mark); + for (const key of ['color', 'fill'] as const) { + if (typeof mark[key] === 'string') painted.add(mark[key]); + } + }); + return scaled ?? (painted.size === 1 ? [...painted][0] : undefined); +} + +// --------------------------------------------------------------------------- +// Data labels — the first thing Vega-Lite has no primitive for +// --------------------------------------------------------------------------- + +function isStacked(node: any, measureChannel: 'x' | 'y'): boolean { + const mark = markTypeOf(node.mark); + if (mark !== 'bar' && mark !== 'area') return false; + const hasSeries = Boolean(node.encoding?.color?.field); + const dodged = Boolean(node.encoding?.xOffset || node.encoding?.yOffset); + const explicit = node.encoding?.[measureChannel]?.stack; + if (explicit === null || explicit === false) return false; + return hasSeries && !dodged; +} + +function applyDataLabels(spec: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): any { + if (!d.dataLabels.show) return undefined; + + // A concatenation is several charts side by side. Labelling only the first + // panel is worse than labelling none. + const said = new Set(); + const once = (path: string, message: string) => { + const k = `${path}\u0000${message}`; + if (said.has(k)) return; + said.add(k); + say(path, message); + }; + let first: any; + for (const body of plotBodies(spec)) { + const layer = labelOneBody(spec, body, d, table, once); + if (layer && !first) first = layer; + } + return first; +} + +/** + * 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. + */ +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 }; + const STEPS = 32; + const runs: Array<[number, number]> = []; + for (let i = 0; i <= STEPS; i++) { + const t = (i / 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; + 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]); + } + 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 }; +} + +function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): any { + const units = body.layer + ? body.layer.filter((n: any) => DATA_MARKS.has(markTypeOf(n.mark) ?? '')) + : (markTypeOf(body.mark) ? [body] : []); + if (units.length === 0) { + say('dataLabels', 'no data mark found to label'); + return; + } + // A pie has no axis, but it does have a measure: the angle. That is where + // a value label matters most, since an angle is the hardest quantity to + // read off a chart. + const radial = units.some((n: any) => markTypeOf(n.mark) === 'arc'); + // A heat map keeps its quantity in colour and its position in the grid. + // The cell is the position, and the number belongs in the middle of it. + const cells = !radial + && d.bound.measureChannels.length === 0 + && units.every((n: any) => markTypeOf(n.mark) === 'rect'); + const measureChannel: 'x' | 'y' | 'theta' | 'color' = radial ? 'theta' + : cells ? 'color' + : d.bound.measureChannels[0]; + if (!measureChannel) { + say('dataLabels', 'no measure axis to label'); + return undefined; + } + if (body.layer && body.layer.some((n: any) => markTypeOf(n.mark) === 'text')) { + say('dataLabels', 'template already prints its own labels — left alone'); + return; + } + // A composite mark spends several layers saying one thing about each datum + // — open, high, low, close. There is no single number to print beside it, + // and printing one of the four would be a lie by selection. + const measureFields = new Set( + units.map((n: any) => mergedEncoding(n, body.encoding)[measureChannel]?.field).filter(Boolean), + ); + if (measureFields.size > 1) { + say('dataLabels', + `the mark carries ${measureFields.size} measures (${[...measureFields].join(', ')}) — no single value to print`); + return; + } + + const primary = units[0]; + const enc = mergedEncoding(primary, body.encoding); + const measure = enc[measureChannel]; + if (!measure?.field) { + say('dataLabels', `measure channel \`${measureChannel}\` has no field`); + 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 + // one quantity a stacked bar otherwise makes hard to get at. + const stackedSegments = measureChannel !== 'theta' && measureChannel !== 'color' + && (isStacked(primary, measureChannel) + || (Boolean(enc.color?.field) && !enc.xOffset && !enc.yOffset + && measure.stack !== null && measure.stack !== false + && (markTypeOf(primary.mark) === 'bar' || markTypeOf(primary.mark) === 'area'))); + + const horizontal = measureChannel === 'x'; + const outside = d.dataLabels.placement !== 'atMark'; + const t = d.dataLabels.text; + // `atMark` means "inside the mark" only when the mark has an inside. A bar + // or a slice has a body a label can sit on; a point or a line vertex does + // not, and a label there is over bare surface. + const BODY_MARKS = new Set(['bar', 'arc', 'rect', 'area']); + const onMarkBody = BODY_MARKS.has(markTypeOf(primary.mark) ?? ''); + + const markDef: any = { + type: 'text', + font: t.font, + fontSize: t.fontSize, + ...(t.fontWeight ? { fontWeight: t.fontWeight } : {}), + ...(t.fontStyle ? { fontStyle: t.fontStyle } : {}), + }; + const inside = !outside && onMarkBody; + // A reversed scale turns the mark round: the end of the bar is on the side + // the axis started from, so "outside" is the other way. + const reversed = measure.scale?.reverse === true; + // The offset is measured from the anchor, and the anchor is not always the + // edge of the mark. A bar's anchor is the end of it, so 4px of offset is + // 4px of air; a dot's anchor is its centre, and 4px from the centre of a + // 10px dot lands the text on the dot. Where a mark has a radius, the gap + // is measured from the rim by adding it. + const radius = Math.max(0, ...units.map((unit: any) => markRadius(unit, spec, d))); + // A slice too thin to stand a line of text across gets no label: its share + // of the circle, swung out to the label radius, is a shorter arc than the + // label is tall, so the number would land on its neighbours. Hiding it by + // opacity (not by dropping the row) keeps every slice in the stack, so the + // surviving labels stay on the angles their arcs actually occupy. + let radialLabelKeepTest: string | undefined; + // Which side of the mark's end a label sits on depends on which way the + // mark grew. A bar that runs down from zero has its end at the bottom, so + // "outside" is *below* it — placing the number above puts it on top of the + // bar it labels. The direction is a property of the datum, not of the + // layer, so where the series has negatives it is stated as an expression + // and Vega-Lite resolves it per mark. + let hasNegative = false; + for (const row of table ?? []) { + const v = row?.[measure.field]; + if (typeof v === 'number' && Number.isFinite(v) && v < 0) { hasNegative = true; break; } + } + const geometry = (within: boolean): any => { + const w = reversed ? !within : within; + const gap = horizontal ? (within ? 5 + radius : 4 + radius) : 4 + radius; + if (!hasNegative) { + return horizontal + ? { align: w ? 'right' : 'left', baseline: 'middle', dx: w ? -gap : gap } + : { align: 'center', baseline: w ? 'top' : 'bottom', dy: w ? gap : -gap }; + } + const down = `datum[${JSON.stringify(measure.field)}] < 0`; + const pick = (positive: string | number, negative: string | number) => ({ + expr: typeof positive === 'string' + ? `${down} ? '${negative}' : '${positive}'` + : `${down} ? ${negative} : ${positive}`, + }); + return horizontal + ? { + align: pick(w ? 'right' : 'left', w ? 'left' : 'right'), + baseline: 'middle', + dx: pick(w ? -gap : gap, w ? gap : -gap), + } + : { + align: 'center', + baseline: pick(w ? 'top' : 'bottom', w ? 'bottom' : 'top'), + dy: pick(w ? gap : -gap, w ? -gap : gap), + }; + }; + if (cells) { + // A cell has no outside: the grid is continuous, and a number beside a + // cell belongs to no cell in particular. + Object.assign(markDef, { align: 'center', baseline: 'middle' }); + if (d.dataLabels.placement !== 'atMark') { + say('dataLabels.placement', + `\`${d.dataLabels.placement}\` printed in the cell instead — a grid is continuous and has no outside`); + } + } else if (radial) { + // On an arc the label rides the same angle as its slice; the only + // choice left is how far out along the radius it sits. Vega-Lite does + // not give a text mark the arc's radius, so it has to be stated — + // from the arc if it declares one, otherwise from the plot box, which + // is where Vega-Lite's own default comes from. + const arc = units.find((n: any) => markTypeOf(n.mark) === 'arc'); + const declared = normalizeMark(arc?.mark)?.outerRadius; + const w = body.width ?? spec.width; + const h = body.height ?? spec.height; + let r = typeof declared === 'number' + ? declared + : (typeof w === 'number' && typeof h === 'number' ? Math.min(w, h) / 2 : undefined); + // Vega-Lite grows the wedge to fill the plot box, then hangs an + // outside label just past the rim. With nothing declared the wedge + // already touches the box edge, so a label — centred on the radius + // and reaching out half its own width — runs off the canvas (the + // widest numbers on the 3/9-o'clock slices lose a digit). When the + // house prints its numbers outside, pull the wedge in far enough to + // seat the labels with air around them: reserve half the widest + // label plus the gap, and write that radius back onto the arc so the + // drawn wedge and the label ring agree. A snug 14px gap reads as the + // number stuck to the rim; McKinsey and its peers sit the annotation + // clear of the arc, so the gap is generous. An arc that states its + // own radius is left alone. + const radialOutsideGap = 22; + if (r !== undefined && !inside && typeof declared !== 'number' + && typeof w === 'number' && typeof h === 'number' && arc) { + const labelChars = table.reduce((m, row) => { + const v = row?.[measure.field]; + if (typeof v !== 'number' || !Number.isFinite(v)) return m; + return Math.max(m, Math.round(Math.abs(v)).toLocaleString('en-US').length); + }, 1); + const estHalfWidth = (labelChars * (t.fontSize ?? 10) * 0.62) / 2; + const halfMin = Math.min(w, h) / 2; + const arcOuter = Math.max(halfMin * 0.5, halfMin - (estHalfWidth + radialOutsideGap + 4)); + if (arcOuter < r) { + const norm = normalizeMark(arc.mark) ?? { type: 'arc' }; + arc.mark = { ...norm, outerRadius: Math.round(arcOuter) }; + r = arcOuter; + } + } + if (r) { + const labelRadius = inside ? r * 0.72 : r + radialOutsideGap; + Object.assign(markDef, { radius: labelRadius }); + // The slice's share of the circle is its value over the total; swung + // out to the label radius that share becomes an arc of + // `labelRadius · share · 2π`. A label needs at least its own height + // of arc to sit in without touching its neighbours, so the smallest + // labelled value is the one whose arc clears a line of text. + const total = table.reduce((s, row) => { + const v = row?.[measure.field]; + return typeof v === 'number' && Number.isFinite(v) ? s + Math.abs(v) : s; + }, 0); + const minArc = (t.fontSize ?? 10) + 2; + if (total > 0 && labelRadius > 0) { + const minValue = (minArc * total) / (labelRadius * 2 * Math.PI); + if (minValue > 0) { + radialLabelKeepTest = `abs(datum[${JSON.stringify(measure.field)}]) >= ${minValue}`; + const dropped = table.filter((row) => { + const v = row?.[measure.field]; + return typeof v === 'number' && Math.abs(v) < minValue; + }).length; + if (dropped > 0) { + say('dataLabels.show', + `${dropped} slice${dropped === 1 ? '' : 's'} narrower than a line of text go unlabelled — their arc would not hold the number`); + } + } + } + } else say('dataLabels', 'the arc has no radius to hang a label from'); + } else if (stackedSegments) { + // The middle of the segment, whichever way the bars run. + Object.assign(markDef, { align: 'center', baseline: 'middle' }); + } else { + Object.assign(markDef, geometry(inside)); + } + if (d.dataLabels.placement === 'column' && !cells) { + say('dataLabels.placement', '`column` approximated as `outsideMark` — Vega-Lite has no label gutter'); + } + + const labelEncoding: any = { + text: { field: measure.field, type: 'quantitative', ...(d.dataLabels.format ? { format: d.dataLabels.format } : {}) }, + }; + if (d.dataLabels.unit) { + printWithUnit(body, { encoding: labelEncoding }, measure.field, d.dataLabels.format, d.dataLabels.unit); + say('annotation.unit', + `each printed value carries its unit \`${d.dataLabels.unit}\` — there is no axis left to state it on`); + } + // `facet`, `row` and `column` split the data; Vega-Lite refuses them inside + // a layer and `appendLayer` promotes them to a real facet operator that + // covers every layer, the label included. Copied onto the label they are a + // second, conflicting split — so they are left to the operator. + for (const ch of ['x', 'y', 'xOffset', 'yOffset', 'detail', 'theta', 'radius'] as const) { + if (enc[ch]) labelEncoding[ch] = stripAxis(enc[ch]); + } + // Vega-Lite stacks `theta` for arcs but not for text, so a copied angle + // would measure from zero instead of from the start of the slice. + if (radial && labelEncoding.theta) labelEncoding.theta = { ...labelEncoding.theta, stack: true }; + + // A stacked label rides the same stack as its segment, sat in the middle + // of it rather than at its end. + let stackedKeepTest: string | undefined; + let stackedTransform: any[] | undefined; + let stackOrderTransform: any | undefined; + if (stackedSegments) { + labelEncoding[measureChannel] = { + ...labelEncoding[measureChannel], + stack: measure.stack ?? 'zero', + bandPosition: 0.5, + }; + if (enc.color?.field) { + const key = { field: enc.color.field, type: enc.color.type ?? 'nominal' }; + labelEncoding.detail = labelEncoding.detail + ? [].concat(labelEncoding.detail as any, key as any) + : key; + // Vega-Lite reads the stacking order off the colour field. The + // label layer carries no colour, so left to itself it stacks the + // segments in a different order from the bars and every number + // lands on a neighbour's segment. Stating the order the bars + // already use puts them back — and stating it only here leaves + // the drawn bars exactly as they were. + // + // Which order that is cannot be assumed. Sorting the field + // alphabetically matches only when the colour scale takes its + // domain from the field's natural order; a template that pins an + // explicit domain — as the stacked-bar template does, to keep a + // Likert scale in its own order — stacks in *that* order instead, + // and an alphabetical label order then lands every number on the + // wrong segment. So the domain is read off the scale where it is + // stated, and the position of each row within it is what the + // labels are ordered by: one rule that follows the bars in both + // cases. The two axes run opposite ways — a stack grows up the y + // axis but rightward along the x — so the direction still flips + // with the orientation. + const stated = enc.color.scale?.domain; + const domain = Array.isArray(stated) && stated.length > 0 + ? stated + : Array.from(new Set(table.map((r) => r?.[enc.color.field]) + .filter((v) => v !== undefined && v !== null))) + .sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)); + stackOrderTransform = { + calculate: `indexof(${JSON.stringify(domain)}, datum[${JSON.stringify(enc.color.field)}])`, + as: '__flintStackOrder', + }; + labelEncoding.order = { + field: '__flintStackOrder', + type: 'quantitative', + sort: horizontal ? 'ascending' : 'descending', + }; + } + // A segment shorter than a line of text cannot hold its number. How + // much of the plot a segment occupies is its value over the tallest + // stack — or, where the chart is normalized, over its own stack, since + // every bar is drawn full height. Hiding by opacity rather than + // dropping the row keeps the stack intact, so the surviving labels + // stay on the segments they belong to. + const minShare = d.dataLabels.segmentMinShare; + const catField = horizontal ? enc.y?.field : enc.x?.field; + if (minShare !== undefined && minShare > 0) { + const normalized = measure.stack === 'normalize'; + const v = `abs(datum[${JSON.stringify(measure.field)}])`; + if (normalized && catField) { + // Each bar is measured against its own total, which only + // exists once the rows are grouped — so Vega-Lite computes it. + stackedTransform = [{ + joinaggregate: [{ op: 'sum', field: measure.field, as: '__flintStackTotal' }], + groupby: [catField], + }, { + calculate: `datum.__flintStackTotal ? datum[${JSON.stringify(measure.field)}] / datum.__flintStackTotal : 0`, + as: '__flintStackShare', + }]; + // On a normalized bar the length of a segment *is* its share, + // and the axis is a percentage. Printing the raw value there + // would name a different quantity from the one drawn, so the + // number that goes in the segment is the share itself. + labelEncoding.text = { field: '__flintStackShare', type: 'quantitative', format: '.0%' }; + say('dataLabels', 'each segment prints its share — on a normalized bar the segment\'s length is its share, not its value'); + stackedKeepTest = `datum.__flintStackTotal > 0 && ${v} / datum.__flintStackTotal >= ${minShare}`; + } else { + const totals = new Map(); + for (const row of table) { + const val = row?.[measure.field]; + if (typeof val !== 'number' || !Number.isFinite(val)) continue; + const k = catField ? row?.[catField] : ''; + totals.set(k, (totals.get(k) ?? 0) + Math.abs(val)); + } + const tallest = Math.max(0, ...totals.values()); + if (tallest > 0) { + const minValue = minShare * tallest; + stackedKeepTest = `${v} >= ${minValue}`; + const dropped = table.filter((row) => { + const val = row?.[measure.field]; + return typeof val === 'number' && Math.abs(val) < minValue; + }).length; + if (dropped > 0) { + say('dataLabels.show', + `${dropped} segment${dropped === 1 ? '' : 's'} thinner than a line of text go unlabelled — the number would not fit between the segment's edges`); + } + } + } + } + } + + // The label sits on the mark body and the mark's fill comes from a + // categorical scale: each entry in that scale is a different background, + // so each needs its own ink. Mirroring the fill encoding — same field, + // same sort, no legend — makes Vega-Lite derive the same domain, so range + // entry *i* is the ink for fill *i* without the domain ever being named. + const fillRange: unknown = enc.color?.scale?.range; + const perCategoryInk = (!outside && onMarkBody && enc.color?.field + && Array.isArray(fillRange) && fillRange.length > 0 + && fillRange.every((c) => typeof c === 'string')) + ? { + field: enc.color.field, + type: enc.color.type ?? 'nominal', + ...(enc.color.sort !== undefined ? { sort: enc.color.sort } : {}), + scale: { range: (fillRange as string[]).map((fill) => readableOn(fill, d.text.inverse, d.text.primary)) }, + legend: null, + } + : undefined; + + 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); + } else if (d.dataLabels.inkMode === 'matchSeries' && enc.color?.field) { + labelEncoding.color = { ...enc.color, legend: null }; + } else if (perCategoryInk) { + // A number sitting *inside* a mark that draws from a categorical + // palette has as many backgrounds as the palette has entries, so one + // ink cannot serve them all: Swiss prints a near-black label, and on + // its near-black series the number simply vanishes. The ink is a fact + // about the segment it lands on, so it is resolved per segment — + // by giving the label its own colour scale over the same field, with + // the same sort, so Vega-Lite derives the identical domain and the + // two ranges line up entry for entry. + labelEncoding.color = perCategoryInk; + } else if (d.dataLabels.inkMode === 'contrastWithMark' && !outside && onMarkBody) { + const seriesInk = d.series.mode === 'single' ? d.series.single : d.series.categorical[0] ?? d.series.single; + markDef.color = readableOn(seriesInk, d.text.inverse, d.text.primary); + } else if (d.dataLabels.inkMode === 'contrastWithMark') { + // The label is floating over the surface, not sitting on the mark, so + // the thing it has to be readable against is the surface. + markDef.color = readableOn(d.surface.plot ?? d.surface.canvas, d.text.inverse, d.text.primary); + } else { + markDef.color = t.color ?? d.text.primary; + } + + const layer: any = { __themeSynthetic: true, mark: markDef, encoding: labelEncoding }; + if (radialLabelKeepTest) { + labelEncoding.opacity = { condition: { test: radialLabelKeepTest, value: 1 }, value: 0 }; + } + if (stackedKeepTest) { + labelEncoding.opacity = { condition: { test: stackedKeepTest, value: 1 }, value: 0 }; + } + // The order index has to exist before the stack is computed, so it goes + // in front of whatever else the label layer derives. + const labelTransforms = [ + ...(stackOrderTransform ? [stackOrderTransform] : []), + ...(stackedTransform ?? []), + ]; + if (labelTransforms.length > 0) layer.transform = labelTransforms; + if (perCategoryInk) { + // 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 + // background. Resolving colour independently lets the two coexist. + // The label's scale carries `legend: null`, so the legend still comes + // from the marks alone. + body.resolve = { + ...(body.resolve ?? {}), + scale: { ...(body.resolve?.scale ?? {}), color: 'independent' }, + }; + } + appendLayer(body, layer); + + if (radial) { + // Vega-Lite gives a text mark the middle of a slice only when the + // angle is *shared* by the layers and declared stacked. The colour has + // to move up with it, because the stacking order is read off the + // colour field — left behind, the labels stack in a different order + // from the slices they belong to. The label then overrides the + // inherited colour with its own ink. + // `appendLayer` may have promoted an encoding-level facet to a real + // operator, in which case the arc and the label are layers of + // `body.spec`, not `body`. Operate on whichever node now owns them. + const host = body.layer ? body : body.spec; + const arc = host.layer.find((n: any) => markTypeOf(n.mark) === 'arc'); + const shared: any = { ...(host.encoding ?? {}) }; + const theta = arc?.encoding?.theta ?? shared.theta; + if (theta) { + shared.theta = { ...theta, stack: true }; + if (arc?.encoding) delete arc.encoding.theta; + } + const colour = arc?.encoding?.color ?? shared.color; + if (colour?.field) { + shared.color = colour; + if (arc?.encoding) delete arc.encoding.color; + if (!labelEncoding.color) labelEncoding.color = { value: markDef.color ?? d.text.primary }; + // Vega-Lite derives the stacking order from the colour field. The + // label overrides that colour with its own ink, which would leave + // it stacking by value instead, so the order is stated outright + // and both layers read the same one. + shared.order = { field: colour.field, type: colour.type ?? 'nominal', sort: 'ascending' }; + } + host.encoding = shared; + delete labelEncoding.theta; + } + + // A label goes where there is room. A mark shorter than its own label + // cannot hold it, and a mark that reaches the end of the scale has no room + // past its end — so each case sends those few labels the other way. + // Vega-Lite has no conditional `align`, so this is two layers with + // complementary filters. + const flipInk = (within: boolean): string | undefined => { + if (d.dataLabels.inkMode === 'matchSeries' && enc.color?.field) return undefined; + if (!within) return t.color ?? d.text.primary; + const seriesInk = d.series.mode === 'single' ? d.series.single : d.series.categorical[0] ?? d.series.single; + return readableOn(seriesInk, d.text.inverse, d.text.primary); + }; + const split = (threshold: number, comparison: '<' | '>', message: string) => { + const v = `abs(datum[${JSON.stringify(measure.field)}])`; + const flipped = !inside; + layer.transform = [{ filter: `${v} ${comparison === '<' ? '>=' : '<='} ${threshold}` }]; + const other: any = { + __themeSynthetic: true, + transform: [{ filter: `${v} ${comparison} ${threshold}` }], + mark: { ...markDef, ...geometry(flipped), color: flipInk(flipped) }, + encoding: labelEncoding, + }; + if (other.mark.color === undefined) delete other.mark.color; + appendLayer(body, other); + say('dataLabels.placement', message); + }; + + // A vertical bar's outside label is cleared by giving the measure scale + // headroom (below); a horizontal one by reserving right margin. The + // scale-end flip — printing the tallest bars' labels inside instead — + // solves the same "no room past the end" problem, so it is only needed + // where headroom is not the remedy: on horizontal bars. + const headroomClears = !inside && onMarkBody && !horizontal && !radial && !cells; + + // A stacked segment is exempt: "outside" a segment is the top of the + // stack, a different quantity. Segments too short for their number drop it + // instead, which the keep test above already arranges. + if (!radial && !cells && !stackedSegments && onMarkBody) { + if (inside && d.dataLabels.insideMinValue != null) { + split(d.dataLabels.insideMinValue, '<', 'marks shorter than their own label print it outside instead'); + growPadding(spec, horizontal ? 'right' : 'top', (t.fontSize ?? 10) * 2); + } else if (!inside && d.dataLabels.outsideMaxValue != null && !headroomClears) { + split(d.dataLabels.outsideMaxValue, '>', 'marks that reach the end of the scale print their label inside instead'); + } + } + + // Outside labels sit past the end of the mark, so the plot needs room — + // to the side the bar grows toward. A horizontal bar's label sits past its + // right end, in clean margin the padding reserves. A vertical bar's sits + // above its top, where the title already is, so reserving outer padding + // does not clear it; instead the measure scale is given headroom so the + // tallest bar stops short of the plot ceiling and its label lands in + // whitespace inside the plot. + if (!inside && !radial && !cells) { + if (horizontal) { + growPadding(spec, reversed ? 'left' : 'right', (t.fontSize ?? 10) * 3); + } else if (onMarkBody) { + addMeasureHeadroom(body, measureChannel, measure.field, table); + } + } + return layer; +} + +function stripAxis(enc: any): any { + const out = { ...enc }; + delete out.axis; + delete out.scale; + delete out.legend; + return out; +} + +function appendLayer(body: any, layer: any): void { + if (body.layer) { body.layer.push(layer); return; } + const base: any = { mark: body.mark }; + if (body.encoding) base.encoding = body.encoding; + delete body.mark; + delete body.encoding; + + // A unit spec with a `facet`, `row` or `column` channel is an operator in + // a unit's clothes: those channels split the data, and Vega-Lite refuses + // them inside a layer — it drops the split and draws every panel's marks + // on top of each other. So the split is promoted to a real facet operator + // and the layers go inside it. + const split = ['facet', 'row', 'column'].filter((c) => base.encoding?.[c]); + if (split.length === 0) { body.layer = [base, layer]; return; } + + const facet: any = {}; + for (const channel of split) { + const def = { ...base.encoding[channel] }; + delete base.encoding[channel]; + if (channel === 'facet') Object.assign(facet, def); + else facet[channel] = def; + } + const columns = body.columns ?? facet.columns; + delete facet.columns; + delete body.columns; + + const inner: any = { layer: [base, layer] }; + for (const key of ['width', 'height', 'view'] as const) { + if (body[key] !== undefined) { inner[key] = body[key]; delete body[key]; } + } + body.facet = facet; + if (columns != null) body.columns = columns; + body.spec = inner; +} + +function readableOn(background: string, light: string, dark: string): string { + // `light` and `dark` are the theme's two ink candidates. Which one reads on + // a surface is a fact about their contrast with it, not about which the + // theme calls primary — a dark house's "primary" ink is itself light, so a + // fixed light/dark mapping inverts on it. Pick whichever candidate sits + // furthest from the background in luminance; fall back to the first when a + // colour will not parse. + const bg = parseColor(background); + if (!bg) return light; + const bgLum = luminance(bg); + const lightC = parseColor(light); + const darkC = parseColor(dark); + if (!lightC || !darkC) return luminance(bg) < 0.5 ? light : dark; + const lightGap = Math.abs(luminance(lightC) - bgLum); + const darkGap = Math.abs(luminance(darkC) - bgLum); + return lightGap >= darkGap ? light : dark; +} + +function growPadding(spec: any, side: 'left' | 'right' | 'top' | 'bottom', amount: number): void { + const base = typeof spec.padding === 'number' + ? { left: spec.padding, right: spec.padding, top: spec.padding, bottom: spec.padding } + : { left: 8, right: 8, top: 8, bottom: 8, ...(spec.padding ?? {}) }; + base[side] = (base[side] ?? 8) + Math.round(amount); + spec.padding = base; +} + +/** + * A value label printed above a bar needs a strip of plot above the bar to sit + * in. The bar that defines the scale's maximum reaches the top of the plot and + * leaves none, so the scale is given a margin: the domain is pushed past the + * data by a fraction of its span, on whichever end carries labelled bar ends. + * A stack of bars rising from zero keeps its zero; only the outward end moves. + */ +function addMeasureHeadroom( + body: any, channel: 'x' | 'y' | 'theta' | 'color', field: string, table: any[], +): void { + const HEADROOM = 0.15; + const nums = table.map((r) => r?.[field]).filter((v: any) => typeof v === 'number' && Number.isFinite(v)) as number[]; + if (nums.length === 0) return; + const dataMax = Math.max(...nums); + const dataMin = Math.min(...nums); + // Bars stand on zero, so the span the labels have to clear is measured from + // zero, not from the smallest bar. + const span = Math.max(dataMax, 0) - Math.min(dataMin, 0); + if (!(span > 0)) return; + const pad = span * HEADROOM; + // `appendLayer` has already run: the mark that carried the measure is no + // longer `body` but a layer inside it (and may have been pushed under a + // hoisted facet operator). Follow the same path to the node that now owns + // the encodings, and set the scale on every place the measure appears — + // the shared encoding and each layer that repeats the field — so the + // shared scale Vega-Lite resolves across the layers picks the wider domain. + const host = body.spec?.layer ? body.spec : body; + const encodings: any[] = []; + if (host.encoding?.[channel]?.field === field) encodings.push(host.encoding[channel]); + for (const u of (host.layer ?? [])) { + if (u?.encoding?.[channel]?.field === field) encodings.push(u.encoding[channel]); + } + for (const encChannel of encodings) { + const scale = { ...(encChannel.scale ?? {}) }; + // An explicit domain is a deliberate choice; headroom does not override it. + if (scale.domain != null) continue; + // The label sits past the end of the bar — above a positive bar, below + // a negative one — so each populated end is given room. + if (dataMax > 0 && scale.domainMax == null) scale.domainMax = dataMax + pad; + if (dataMin < 0 && scale.domainMin == null) scale.domainMin = dataMin - pad; + encChannel.scale = scale; + } +} + +// --------------------------------------------------------------------------- +// seriesEnd — the second thing Vega-Lite has no primitive for +// --------------------------------------------------------------------------- + +/** + * `seriesEnd` is a placement, and a placement can be unavailable. Decide that + * *before* the legend is drawn — once the colour legends have been suppressed + * in favour of end labels there is nothing to fall back to. + */ +function demoteSeriesEnd(spec: any, d: DesignDecisions, say: (p: string, m: string) => void): void { + if (d.legend.placement !== 'seriesEnd' && d.legend.placement !== 'inline') return; + if (!d.legend.show) return; + const body = plotBody(spec); + // A band carries its own end label inside itself, so it counts as a run + // with an end just as much as a line does. + const endable = (mark: string) => LINE_MARKS.has(mark) || mark === 'area'; + const units = body.layer + ? body.layer.filter((n: any) => endable(markTypeOf(n.mark) ?? '')) + : (endable(markTypeOf(body.mark) ?? '') ? [body] : []); + const enc = units.length ? mergedEncoding(units[0], body.encoding) : {}; + const field = enc.color?.field ?? d.bound.seriesField ?? enc.detail?.field; + // A name that will not fit inside its band goes in the margin past the last + // reading — and where the house has stood the value axis in that margin, as + // it does on a part-to-whole chart, the name would be printed over the + // ruler. A band is thin more often than not, so this is not a risk worth + // running: the naming goes back to the house's next choice. + const bands = units.some((n: any) => markTypeOf(n.mark) === 'area'); + const runsAlongX = runChannel(d) === 'x'; + const marginTaken = bands + && (runsAlongX ? d.axes.y?.orient === 'right' : d.axes.x?.orient === 'top'); + // A closed run (a radar/spider polygon, `interpolate: *-closed`) has no last + // point: every vertex is an axis, not an end, so an end label would land on + // an arbitrary spoke. The series can only be named by a key. + const closedRun = units.some((n: any) => { + const interp = normalizeMark(n.mark)?.interpolate; + return typeof interp === 'string' && interp.endsWith('-closed'); + }); + const reason = units.length === 0 + ? '`seriesEnd` needs a line mark' + : (!field ? '`seriesEnd` needs a series field to name' + : (field === d.bound.categoryField + ? '`seriesEnd` would restate the categorical axis' + : closedRun + ? '`seriesEnd` needs an open run — a closed polygon has no last point' + : marginTaken + ? `the ${runsAlongX ? 'right' : 'top'} margin holds the value axis, so a name too big for its band has nowhere to stand` + : null)); + if (!reason) return; + // The house ranked its placements; a demotion should land on the next one + // it named, not on whatever this function happens to prefer. + const next = d.legend.fallbacks?.find((p) => p !== 'seriesEnd' && p !== 'inline') ?? 'right'; + say('legend.placement', `${reason} — the key is drawn \`${next}\` instead`); + d.legend.placement = next; + d.legend.orient = next === 'inside' ? 'top-right' : next as any; + d.legend.direction = next === 'top' || next === 'bottom' ? 'horizontal' : 'vertical'; +} + +/** + * The channel a series runs *along*. A measure on `y` means the run is `x`, + * a measure on `x` means the run is `y` — and when neither channel carries a + * measure the run is still the horizontal one, because a chart whose value is + * an *order* rather than a quantity (a bump chart ranks its rows) still reads + * left to right. Calling `y` the run in that case puts every series' label on + * the same row: at the top, on top of each other. + */ +function runChannel(d: DesignDecisions): 'x' | 'y' { + if (d.bound.measureChannels.includes('y')) return 'x'; + if (d.bound.measureChannels.includes('x')) return 'y'; + return 'x'; +} + +function applySeriesEndLabels( + spec: any, + d: DesignDecisions, + valueLayer: any, + table: any[], + say: (p: string, m: string) => void, +): void { + if (d.legend.placement !== 'seriesEnd' && d.legend.placement !== 'inline') return; + if (!d.legend.show) return; + + const body = plotBody(spec); + // A template that already prints a name at the end of each run has said it. + // A second label in the same place is not a legend, it is a stutter. + if ((body.layer ?? []).some((n: any) => markTypeOf(n.mark) === 'text' && n.encoding?.text?.field)) { + say('legend.placement', 'the chart already prints its own end labels — no second set drawn'); + return; + } + const units = body.layer + ? body.layer.filter((n: any) => LINE_MARKS.has(markTypeOf(n.mark) ?? '')) + : (LINE_MARKS.has(markTypeOf(body.mark) ?? '') ? [body] : []); + if (units.length === 0) { + // A band is not a line, but it has the same last reading — and more + // room to say it in, because the name can go *inside* the band where + // the reader is already looking. + if (bandEndLabels(spec, body, d, table, say)) return; + say('legend.placement', '`seriesEnd` needs a line mark — no legend drawn'); + return; + } + + const primary = units[0]; + const enc = mergedEncoding(primary, body.encoding); + // `detail` is a grouping key, not a series: on a dumbbell it is the very + // field the categorical axis already names, and printing it at the end of + // each connector says the same word twice. + const seriesField = enc.color?.field ?? d.bound.seriesField ?? enc.detail?.field; + const domainChannel = runChannel(d); + const valueChannel = domainChannel === 'x' ? 'y' : 'x'; + const domain = enc[domainChannel]; + const value = enc[valueChannel]; + if (!seriesField || !domain?.field || !value?.field) { + say('legend.placement', '`seriesEnd` needs a series field on a positional line — no legend drawn'); + return; + } + + const t = d.legend.label; + // The line that carries the shape need not be the layer that carries the + // colour — a dumbbell draws its connector plain and colours the dots. + let colourEnc = enc.color?.field ? enc.color : undefined; + if (!colourEnc) { + for (const unit of (body.layer ?? [body])) { + const c = unit.encoding?.color ?? unit.encoding?.fill ?? unit.encoding?.stroke; + if (c?.field === seriesField) { colourEnc = c; break; } + } + } + // "The end of the series" only means something when the domain is ordered — + // a time axis, a scale, or ordered categories. An unordered domain still + // carries one order, the one its rows arrived in, and where the run is + // horizontal that is enough: the reader finishes at the right-hand column + // and there is margin there to write the name in. A run down the page is a + // list, and a list is labelled at its head, inside the plot. + const runs = domain.type === 'quantitative' || domain.type === 'temporal' || domain.type === 'ordinal'; + const atEnd = runs || domainChannel === 'x'; + // "The end" is the last band in the order the domain is *drawn*, which for + // an ordinal axis is the order the house declared (Jan…Dec), not the order + // the values happen to sort in as strings. Ranking by the raw field would + // end each series at its alphabetically-last reading — "Sep", stranded + // mid-plot under the crossing lines — instead of at the right-hand edge. So + // where the domain carries an explicit order, rank by each row's position + // in it; otherwise a descending sort of an ordered field finds the last. + let runsRank: any[] = []; + if (runs) { + if (Array.isArray(domain.sort)) { + const arr = JSON.stringify(domain.sort); + const fld = JSON.stringify(domain.field); + runsRank = [ + { calculate: `indexof(${arr}, datum[${fld}])`, as: '__domainOrder' }, + { + window: [{ op: 'row_number', as: '__seriesEndRank' }], + sort: [{ field: '__domainOrder', order: 'descending' }], + groupby: [seriesField], + }, + ]; + } else { + const order = domain.sort === 'descending' ? 'ascending' : 'descending'; + runsRank = [{ + window: [{ op: 'row_number', as: '__seriesEndRank' }], + sort: [{ field: domain.field, order }], + groupby: [seriesField], + }]; + } + } + const rank = runs + ? runsRank + : atEnd + ? [ + { window: [{ op: 'row_number', as: '__dataOrder' }] }, + { + window: [{ op: 'row_number', as: '__seriesEndRank' }], + sort: [{ field: '__dataOrder', order: 'descending' }], + groupby: [seriesField], + }, + ] + : [{ window: [{ op: 'row_number', as: '__seriesEndRank' }], groupby: [seriesField] }]; + + // Both the series name and its final value want the same few pixels. The + // house answer is not to stack them but to say them once: "Japan 84.5". + const merged = Boolean(valueLayer) && valueLayer.encoding?.text?.field === value.field; + const transform: any[] = [...rank, { filter: 'datum.__seriesEndRank === 1' }]; + let textField = seriesField; + if (merged) { + const fmt = d.dataLabels.format; + const v = `datum[${JSON.stringify(value.field)}]`; + const shown = fmt ? `format(${v}, ${JSON.stringify(fmt)})` : `${v} + ''`; + transform.push({ + calculate: `datum[${JSON.stringify(seriesField)}] + ' ' + ${shown}`, + as: '__seriesEndLabel', + }); + textField = '__seriesEndLabel'; + // …and the value layer must stop printing the point that is now named. + valueLayer.transform = [...rank, { filter: 'datum.__seriesEndRank !== 1' }]; + say('legend.placement', + 'series name and final value merged into one label — they compete for the same space'); + } + + const labelLayer: any = { + __themeSynthetic: true, + transform, + mark: { + type: 'text', + align: domainChannel === 'x' ? 'left' : 'center', + baseline: domainChannel === 'x' ? 'middle' : 'bottom', + dx: domainChannel === 'x' ? 5 : 0, + dy: domainChannel === 'x' ? 0 : -5, + font: t.font, + fontSize: t.fontSize, + ...(t.fontWeight ? { fontWeight: t.fontWeight } : {}), + ...(t.fontStyle ? { fontStyle: t.fontStyle } : {}), + }, + encoding: { + [domainChannel]: stripAxis(domain), + [valueChannel]: stripAxis(value), + text: { field: textField, type: 'nominal' }, + ...(colourEnc?.field ? { color: { ...colourEnc, legend: null } } : {}), + }, + }; + 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); + } + // 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 + // labels now are. The two collide into an unreadable pile. The end labels + // own that edge now, so the surviving key steps down to the foot of the + // plot, where it has the width to itself. + relocateSecondaryLegends(spec, 'bottom', say); + say('legend.placement', '`seriesEnd` realized as a synthesized text layer at each series\' last point'); +} + +/** + * When the series are named at the line end, a second, non-colour encoding + * (`strokeDash` for actual/forecast, `shape`, `size`, `opacity`) still draws a + * key, and its default corner is the top-right the end labels have just taken. + * Move each such surviving key to the foot of the plot, clear of the labels. + * Colour/fill/stroke are the series itself and are already spoken for. + */ +function relocateSecondaryLegends(spec: any, orient: 'bottom' | 'top', say: (p: string, m: string) => void): void { + const channels = ['strokeDash', 'shape', 'size', 'opacity'] as const; + let moved = 0; + walk(spec, (node) => { + if (!node.encoding) return; + for (const ch of channels) { + const enc = node.encoding[ch]; + if (enc?.field && enc.legend !== null) { + enc.legend = { ...(typeof enc.legend === 'object' && enc.legend ? enc.legend : {}), orient }; + moved++; + } + } + }); + if (moved) { + say('legend.orient', + `a second key sat where the end labels now are — moved to the ${orient}, clear of them`); + } +} + +/** + * The same idea as a series-end label, for a chart made of bands. + * + * A stacked area has no end point to hang a name off, but it has something + * better: at its last reading the band is a shape with a middle, and a name + * knocked out of that middle is unambiguous in a way a legend swatch never is. + * Returns true when it drew them. + */ +function bandEndLabels( + spec: any, + body: any, + d: DesignDecisions, + table: any[], + say: (p: string, m: string) => void, +): boolean { + const units = body.layer + ? body.layer.filter((n: any) => markTypeOf(n.mark) === 'area') + : (markTypeOf(body.mark) === 'area' ? [body] : []); + if (units.length === 0) return false; + + const enc = mergedEncoding(units[0], body.encoding); + const seriesField = enc.color?.field ?? d.bound.seriesField; + const domainChannel = runChannel(d); + const valueChannel = domainChannel === 'x' ? 'y' : 'x'; + const domain = enc[domainChannel]; + const value = enc[valueChannel]; + if (!seriesField || !domain?.field || !value?.field) return false; + + // Where the middle of a band sits depends on everything stacked under it, + // and Vega-Lite already works that sum out for the band itself. Asking it + // for the same sum on the label layer — same series field, same stack — + // keeps label and band in step; a second, cleverer calculation here would + // only find new ways to disagree. The label's colour scale is the plot + // surface throughout, so the name is knocked out of the band it names + // rather than tinted by it. + const seriesCount = Math.max( + Array.isArray(enc.color?.scale?.domain) + ? enc.color.scale.domain.length + : orderedValues(table, seriesField).length, + 1, + ); + const knockedOut = enc.color?.field + ? { + color: { + ...enc.color, + scale: { ...(enc.color.scale ?? {}), range: Array(seriesCount).fill(d.surface.plot) }, + legend: null, + }, + } + : { detail: { field: seriesField, type: 'nominal' } }; + const inSeriesInk = enc.color?.field + ? { color: { ...enc.color, legend: null } } + : { detail: { field: seriesField, type: 'nominal' } }; + + // A name can only be knocked out of a band wide and thick enough to hold + // it. Where the band ends as a sliver — Oceania under four other + // continents — or climbs away from under its own label, the name goes + // outside the plot in its own ink instead. + // + // `seriesEnd` is a preference for the *inset* position, not a per-series + // choice, so it is honoured all or nothing. A chart that knocks four names + // out of their bands and hangs the fifth in the margin reads as a mistake: + // the two positions carry different ink and sit on different sides of the + // plot edge, so the odd one out looks like a different kind of label rather + // than the same label with less room. One band that cannot hold its name is + // therefore enough to send every name outset, where they line up as a + // single list and the set stays comparable. + const t = d.legend.label; + const along = domainChannel === 'x' ? (body.width ?? spec.width) : (body.height ?? spec.height); + const across = valueChannel === 'y' ? (body.height ?? spec.height) : (body.width ?? spec.width); + const order: string[] = Array.isArray(enc.color?.scale?.domain) + ? enc.color.scale.domain.map((s: any) => String(s)) + : orderedValues(table, seriesField); + const homeless = namesTheBandCannotHold({ + table, + domainField: domain.field, + domainType: domain.type, + seriesField, + valueField: value.field, + order, + stacked: value.stack !== null && value.stack !== false && value.stack !== 'none', + centred: value.stack === 'center', + alongPx: typeof along === 'number' ? along : 600, + acrossPx: typeof across === 'number' ? across : 254, + fontSize: t.fontSize ?? 10, + }); + const outside = homeless.length ? order : []; + const outsideList = JSON.stringify(outside); + const belongs = (inside: boolean) => + `indexof(${outsideList}, datum[${JSON.stringify(seriesField)}] + '') ${inside ? '<' : '>='} 0`; + + const fmt = d.dataLabels.format; + const v = `datum[${JSON.stringify(value.field)}]`; + const shown = fmt ? `format(${v}, ${JSON.stringify(fmt)})` : `${v} + ''`; + // A normalized stack redraws every band as a share of its column, and the + // axis is read in per cent. The field still holds what it always held — + // 9,420 TWh of coal — so printing it beside the name puts a number on the + // chart that the chart does not draw anywhere. + // + // The reading is also dropped once the names are pushed outset. Inset, the + // name lies on the band at the reading it quotes, so the number annotates + // something: this band, here, is 4,641. Outset it is a list in the margin — + // a legend, naming which colour is which — and a legend that quotes numbers + // is quoting them about a place the reader cannot see. It would also widen + // the margin by the length of the longest *number* for no reading gained. + // + // Either way, the name goes alone. + const normalized = value.stack === 'normalize'; + const withValue = !normalized && !outside.length; + const name = withValue + ? `datum[${JSON.stringify(seriesField)}] + ' ' + ${shown}` + : `datum[${JSON.stringify(seriesField)}] + ''`; + // The band is an `area` mark, which Vega-Lite stacks on its own; a `text` + // mark is not stacked unless told to. Left implicit, every label lands at + // its series' *raw* reading and the whole set piles up at the foot of the + // plot as if nothing were stacked. Naming the band's own offset on the + // label layer lifts each name to the middle of the band it belongs to. + const stackOffset = + value.stack === null || value.stack === false || value.stack === 'none' + ? undefined + : (value.stack ?? 'zero'); + // Both layers keep every series, because a stacked layer stacks only the + // rows it is given: drop one and the rest slide off their own bands. What + // changes between them is whether the text says anything. + // + // "The end" is the last reading in the order the domain is *drawn*. A time + // or number line sorts itself; an explicit category order is read off the + // house's list; but a bare nominal domain ("Stage 1"…"Stage 12") has no + // order but the one its rows arrived in — and sorting those as *strings* + // ends every series at "Stage 9", stranded mid-plot, not at "Stage 12". So + // rank by declared order where there is one, by the field where it is a + // scale, and by arrival order otherwise. + const rankTf: any[] = Array.isArray(domain.sort) + ? [ + { calculate: `indexof(${JSON.stringify(domain.sort)}, datum[${JSON.stringify(domain.field)}])`, as: '__bandDomainOrder' }, + { window: [{ op: 'row_number', as: '__bandEndRank' }], sort: [{ field: '__bandDomainOrder', order: 'descending' }], groupby: [seriesField] }, + ] + : (domain.type === 'quantitative' || domain.type === 'temporal') + ? [{ window: [{ op: 'row_number', as: '__bandEndRank' }], sort: [{ field: domain.field, order: domain.sort === 'descending' ? 'ascending' : 'descending' }], groupby: [seriesField] }] + : [ + { window: [{ op: 'row_number', as: '__bandDataOrder' }] }, + { window: [{ op: 'row_number', as: '__bandEndRank' }], sort: [{ field: '__bandDataOrder', order: 'descending' }], groupby: [seriesField] }, + ]; + const endLayer = (inside: boolean): any => ({ + __themeSynthetic: true, + transform: [ + ...rankTf, + { filter: 'datum.__bandEndRank === 1' }, + { + calculate: outside.length ? `${belongs(inside)} ? ${name} : ''` : name, + as: '__bandEndLabel', + }, + ], + mark: { + type: 'text', + align: domainChannel !== 'x' ? 'center' : inside ? 'right' : 'left', + baseline: 'middle', + dx: domainChannel !== 'x' ? 0 : inside ? -6 : 6, + font: t.font, + fontSize: t.fontSize, + fontWeight: 'bold', + ...(inside ? { color: d.surface.plot } : {}), + }, + encoding: { + [domainChannel]: stripAxis(domain), + [valueChannel]: { ...stripAxis(value), bandPosition: 0.5, ...(stackOffset ? { stack: stackOffset } : {}) }, + text: { field: '__bandEndLabel', type: 'nominal' }, + ...(inside ? knockedOut : inSeriesInk), + }, + }); + // Exactly one of the two layers is drawn: `outside` is now all of the + // series or none of them, never a subset. + 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); + 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' + : `${homeless.length === 1 ? 'one band is' : `${homeless.length} bands are`} too thin at the end to hold a name — the inset position is all or nothing, so every name sits outside the plot rather than splitting the set between two positions`); + } + // Layers share their scales, so without this the label's colour scale is + // the band's and the name comes out tinted the colour of the shape it is + // sitting on. The label keeps the same *domain*, which is what puts it in + // the right band; only the ink is its own. + body.resolve = { + ...(body.resolve ?? {}), + scale: { ...(body.resolve?.scale ?? {}), color: 'independent' }, + }; + say('legend.placement', + '`seriesEnd` realized inside each band at its last reading — a name in the band beats a swatch beside the chart'); + return true; +} + +/** + * Which names cannot be knocked out of the band they belong to. + * + * A label is set in the middle of the band's last reading and written + * backwards from there, so the band has to be thick enough to hold the letters + * *and* still be under them a label's width back. On a stack that narrows + * towards its end — or one drawn about a centre line, where the whole pile + * rises as it grows — a name set in the last band can run clean off the shape + * it names and onto the page. + */ +function namesTheBandCannotHold(args: { + table: any[]; + domainField: string; + domainType?: string; + seriesField: string; + valueField: string; + order: string[]; + stacked: boolean; + centred: boolean; + alongPx: number; + acrossPx: number; + fontSize: number; +}): string[] { + const { table, domainField, domainType, seriesField, valueField, order } = args; + + const byKey = new Map }>(); + const keys: string[] = []; + let index = 0; + for (const row of table ?? []) { + const raw = row?.[domainField]; + index++; + if (raw == null) continue; + const key = String(raw); + let rec = byKey.get(key); + if (!rec) { + const n = raw instanceof Date ? raw.getTime() + : typeof raw === 'number' ? raw + : domainType === 'temporal' ? Date.parse(key) : index; + rec = { at: Number.isFinite(n) ? n : index, vals: new Map() }; + byKey.set(key, rec); + keys.push(key); + } + const v = Number(row?.[valueField]); + if (!Number.isFinite(v)) continue; + const s = String(row?.[seriesField]); + rec.vals.set(s, (rec.vals.get(s) ?? 0) + v); + } + const points = keys.map((k) => byKey.get(k)!).sort((a, b) => a.at - b.at); + if (points.length === 0) return []; + + // Segment bounds in value space, top-first, the way the stack is drawn. + const bounds = (vals: Map): Map => { + const out = new Map(); + if (!args.stacked) { + for (const s of order) out.set(s, [0, vals.get(s) ?? 0]); + return out; + } + const total = order.reduce((sum, s) => sum + (vals.get(s) ?? 0), 0); + let running = args.centred ? total / 2 : total; + for (const s of order) { + const v = vals.get(s) ?? 0; + out.set(s, [running - v, running]); + running -= v; + } + return out; + }; + + const biggest = Math.max(...points.map((p) => order.reduce((sum, s) => sum + (p.vals.get(s) ?? 0), 0))); + if (!(biggest > 0)) return []; + const perPx = biggest / args.acrossPx; + const stepPx = args.alongPx / Math.max(1, points.length - 1); + const margin = (args.fontSize / 2 + 1) * perPx; + + const last = bounds(points[points.length - 1].vals); + const homeless: string[] = []; + for (const s of order) { + const here = last.get(s); + if (!here) continue; + const centre = (here[0] + here[1]) / 2; + // Roughly what the label measures: the name, a space, and a number. + const width = (s.length + 6) * args.fontSize * 0.55; + const back = Math.min(points.length - 1, Math.ceil(width / Math.max(stepPx, 1))); + for (let i = points.length - 1; i >= points.length - 1 - back; i--) { + const seg = bounds(points[i].vals).get(s); + if (!seg || centre < seg[0] + margin || centre > seg[1] - margin) { + homeless.push(s); + break; + } + } + } + return homeless; +} + +/** Distinct values of a field, in the order the table first shows them. */ +function orderedValues(table: any[], field: string): string[] { + const seen: string[] = []; + const set = new Set(); + for (const row of table ?? []) { + const v = row?.[field]; + if (v == null) continue; + const s = String(v); + if (!set.has(s)) { set.add(s); seen.push(s); } + } + 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 +// --------------------------------------------------------------------------- + +/** + * A dot on a line is a full stop: it says *this* is the value the sentence was + * building to. Houses differ on where it goes — both ends of the run, the + * latest reading, the high and the low — but they agree that it goes on a + * line, and only where the line does not already show every observation. + */ +function applyPointEmphasis(spec: any, d: DesignDecisions, say: (p: string, m: string) => void): void { + const policy = d.pointEmphasis; + if (!policy) return; + + const body = plotBody(spec); + const units = (body.layer ?? [body]).filter((n: any) => LINE_MARKS.has(markTypeOf(n.mark) ?? '')); + if (units.length === 0) return; + + // A line drawn with a point at every observation has already said it — + // whether the house asked for that or the chart spec did. + const drawsEveryPoint = d.marks.point?.show === true + || units.some((n: any) => n.mark && typeof n.mark === 'object' && n.mark.point); + if (drawsEveryPoint) { + say('annotation.pointEmphasis', + 'the line already shows every observation — a second dot at the end would say nothing new'); + return; + } + + const enc = mergedEncoding(units[0], body.encoding); + const domainChannel = runChannel(d); + const valueChannel = domainChannel === 'x' ? 'y' : 'x'; + const domain = enc[domainChannel]; + const value = enc[valueChannel]; + if (!domain?.field || !value?.field) return; + + const seriesField = enc.color?.field ?? d.bound.seriesField ?? enc.detail?.field; + const groupby = seriesField ? [seriesField] : []; + const v = `datum[${JSON.stringify(value.field)}]`; + + let transform: any[]; + let what: string; + if (policy.where === 'extremes') { + transform = [ + { + joinaggregate: [ + { op: 'min', field: value.field, as: '__peLow' }, + { op: 'max', field: value.field, as: '__peHigh' }, + ], + groupby, + }, + { filter: `${v} === datum.__peLow || ${v} === datum.__peHigh` }, + ]; + what = 'the high and the low of each run'; + } else { + // Row numbers, not values: the domain may be a date, and two dates are + // never `===` each other in a Vega expression even when they name the + // same instant. + const rank = (order: 'ascending' | 'descending', as: string) => ({ + window: [{ op: 'row_number', as }], + sort: [{ field: domain.field, order }], + groupby, + }); + transform = policy.where === 'latest' + ? [rank('descending', '__peLast'), { filter: 'datum.__peLast === 1' }] + : [ + rank('ascending', '__peFirst'), + rank('descending', '__peLast'), + { filter: 'datum.__peFirst === 1 || datum.__peLast === 1' }, + ]; + what = policy.where === 'latest' ? 'the latest reading' : 'the first and last reading of each run'; + } + + // The colour encoding is repeated, not re-declared: in a layered spec the + // scales are shared, and a `legend: null` on any one layer takes the key + // away from all of them. + const colourEnc = enc.color?.field ? { ...enc.color } : undefined; + appendLayer(body, { + __themeSynthetic: true, + transform, + mark: { + type: 'point', + filled: true, + size: policy.size, + ...(d.marks.point?.haloColor + ? { stroke: d.marks.point.haloColor, strokeWidth: d.marks.point.haloWidth ?? 1 } + : {}), + ...(colourEnc ? {} : { color: d.series.single }), + }, + encoding: { + [domainChannel]: stripAxis(domain), + [valueChannel]: stripAxis(value), + ...(colourEnc ? { color: colourEnc } : {}), + }, + }); + say('annotation.pointEmphasis', `${what} carries a dot — the house marks where the line lands`); + + // Vega-Lite sizes a legend swatch from the largest mark on the scale, so + // the dot just added would swell every swatch in the key. The key samples + // the line, not the full stop at the end of it. + if (colourEnc && d.legend.show) { + const legendConfig = ((spec.config ??= {}).legend ??= {}); + if (legendConfig.symbolSize == null) { + legendConfig.symbolSize = ((d.legend.label.fontSize ?? 10) * 0.8) ** 2; + } + } + + if (policy.labels === 'never') return; + const t = d.dataLabels.text; + appendLayer(body, { + __themeSynthetic: true, + ...(policy.labels === 'endpoints' ? { transform } : {}), + mark: { + type: 'text', + align: domainChannel === 'x' ? 'center' : 'left', + baseline: domainChannel === 'x' ? 'bottom' : 'middle', + dy: domainChannel === 'x' ? -8 : 0, + dx: domainChannel === 'x' ? 0 : 8, + font: t.font, + fontSize: t.fontSize, + ...(t.fontWeight ? { fontWeight: t.fontWeight } : {}), + fill: t.color, + }, + encoding: { + [domainChannel]: stripAxis(domain), + [valueChannel]: stripAxis(value), + text: { + field: value.field, + type: 'quantitative', + ...(d.dataLabels.format ? { format: d.dataLabels.format } : {}), + }, + }, + }); + say('annotation.pointLabels', + `${policy.labels === 'all' ? 'every point' : 'the dotted points'} print their value`); +} + +// --------------------------------------------------------------------------- +// Statistics +// --------------------------------------------------------------------------- + +/** The fit a template asked the renderer for, if it asked for one. */ +function findFit(spec: any): { y: string; x: string } | undefined { + let found: { y: string; x: string } | undefined; + walk(spec, (node) => { + if (found || !Array.isArray(node.transform)) return; + for (const t of node.transform) { + const on = t?.on; + const y = t?.regression ?? t?.loess; + // A fit per group states several slopes; one caption cannot. + if (typeof y === 'string' && typeof on === 'string' && !t.groupby?.length) { + found = { y, x: on }; + return; + } + } + }); + return found; +} + +function formatStat(v: number): string { + const abs = Math.abs(v); + const text = abs >= 100 ? v.toFixed(0) : abs >= 1 ? v.toFixed(2) : v.toFixed(3); + return text.replace('-', '\u2212'); +} + +/** + * Print what the fit is worth. + * + * A house that draws a regression line and says nothing about it is asking the + * reader to take the line on trust. Where the house asks for the numbers, they + * are computed from the same rows the line was fitted to. + */ +function applyStatistics(spec: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): void { + const policy = d.statistics; + if (!policy || !Array.isArray(spec.layer) || !Array.isArray(table) || table.length === 0) return; + const fit = findFit(spec); + if (!fit) return; + + const pairs = table + .map((row) => [Number(row?.[fit.x]), Number(row?.[fit.y])]) + .filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y)); + if (pairs.length < 3) return; + + const n = pairs.length; + const mx = pairs.reduce((s, p) => s + p[0], 0) / n; + const my = pairs.reduce((s, p) => s + p[1], 0) / n; + let sxy = 0; let sxx = 0; let syy = 0; + for (const [x, y] of pairs) { + sxy += (x - mx) * (y - my); + sxx += (x - mx) ** 2; + syy += (y - my) ** 2; + } + if (sxx === 0 || syy === 0) return; + const slope = sxy / sxx; + const stats: Record = { + slope: `slope = ${formatStat(slope)}`, + intercept: `intercept = ${formatStat(my - slope * mx)}`, + r2: `R² = ${((sxy ** 2) / (sxx * syy)).toFixed(2)}`, + n: `n = ${n}`, + }; + const parts = policy.show.map((k) => stats[k]).filter(Boolean); + if (!parts.length) return; + + const caption = policy.placement === 'caption'; + spec.layer.push({ + __themeSynthetic: true, + data: { values: [{}] }, + mark: { + type: 'text', + text: parts.join(' · '), + align: caption ? 'left' : 'right', + baseline: 'bottom', + x: { expr: caption ? '0' : 'width' }, + y: { expr: caption ? 'height + 34' : '-4' }, + font: policy.font, + fontSize: policy.fontSize, + fontStyle: policy.fontStyle, + fill: policy.color, + }, + }); + say('annotation.statistics', + `the fit is stated as well as drawn — ${parts.join(', ')} — computed from the ${n} rows the line was fitted to`); +} + +// --------------------------------------------------------------------------- +// Furniture +// --------------------------------------------------------------------------- + +/** + * A chart whose radial mark cannot survive being wrapped in a concatenation to + * hang furniture beneath it. A pie is a disc of angles and rides inside a + * `vconcat` unharmed; but a *rose* sizes its petals by radius, and a *faceted* + * disc splits into panels, and in either case Vega-Lite reads the panel's width + * signal to lay the mark out — a signal the concat wrapper either rescopes out + * of the mark's reach (the facet: an unresolved `child_width`) or leaves the + * radius scale to collapse against (the rose: petals shrunk to a compass). The + * chart is already its own block; a rule across it has no single width to take. + */ +function radialResistsFurniture(spec: any): boolean { + let faceted = false; + let arc = false; + let radiusArc = false; + walk(spec, (node) => { + if (node.facet) faceted = true; + for (const ch of ['facet', 'row', 'column'] as const) { + if (node.encoding?.[ch]) faceted = true; + } + if (markTypeOf(node.mark) === 'arc') { + arc = true; + if (node.encoding?.radius) radiusArc = true; + } + }); + return radiusArc || (arc && faceted); +} + +/** + * A faceted *table* — a `facet` operator whose panel is itself a concatenation + * (a bar-table's label / bar / value columns laid side by side) — meets the + * same wall as the faceted disc in `radialResistsFurniture`, one level deeper. + * Wrapping `facet > concat` in an outer furniture `vconcat` yields + * `concat > facet > concat`, and Vega-Lite rescopes the inner panels' width + * signals out of the concat's reach: every column collapses and the whole + * block renders empty. A plain faceted unit (line-facet, area-facet) has no + * such nested signal and rides the wrapper unharmed, so this stays narrow — + * only the facet-of-concat is refused. Like the disc, the table is already its + * own stack of blocks, with no single width for a rule to run across. + */ +function facetedTableResistsFurniture(spec: any): boolean { + if (!spec.facet) return false; + const panel = spec.spec; + return !!(panel && (panel.hconcat || panel.vconcat || panel.concat)); +} + +function applyFurniture(spec: any, d: DesignDecisions, table: any[], say: (p: string, m: string) => void): boolean { + if (!d.furniture.length) return false; + + // Two kinds of furniture with different homes. A masthead tab is *branding* + // anchored to the graphic frame: it draws onto the rendered canvas at + // graphic-left, flush with the title, and does not care what the plot is or + // how wide its axis gutter runs (see canvas-furniture.ts). A header/footer + // rule *closes a block*: it wraps the plot in a concatenation and runs the + // block's width. + const canvas = d.furniture.filter((f) => f.kind === 'mastheadTab'); + const rules = d.furniture.filter((f) => f.kind !== 'mastheadTab'); + + // Canvas furniture is drawn after render, so — unlike a block rule — it + // applies to every chart, including the concats, radials and faceted tables + // a rule cannot wrap. + let handled = false; + if (canvas.length) handled = applyCanvasFurniture(spec, canvas, d, say) || handled; + + if (!rules.length) return handled; + + if (spec.vconcat || spec.hconcat || spec.concat) { + say('furniture', 'the closing rule is not drawn — the chart is already a concatenation'); + return handled; + } + // A rose or a faceted disc cannot be wrapped in a block to hang a rule + // beneath it — see `radialResistsFurniture`. It is already its own block. + if (radialResistsFurniture(spec)) { + say('furniture', 'the closing rule is not drawn — a radial chart is already its own block, with no edge to close'); + return handled; + } + // A faceted table (facet of concat) collapses when wrapped — see + // `facetedTableResistsFurniture`. It is already its own stack of blocks. + if (facetedTableResistsFurniture(spec)) { + say('furniture', 'the closing rule is not drawn — a faceted table is already its own stack of blocks, with no single edge to close'); + return handled; + } + + // A rule closes a block, so its length is the block's, and a house that + // draws one does not state a number for it. Falling back to a stub leaves a + // dash in the corner that looks like a mistake rather than an edge. + const before: any[] = []; + const after: any[] = []; + const block = blockWidth(spec, table); + for (const item of rules) { + const width = item.width ?? block; + if (width == null) { + say('furniture', `the house draws a ${item.kind} across the block, but the chart states no width to draw it across — left out`); + continue; + } + if (item.width == null) { + say('furniture', `the ${item.kind} runs the width of the block — ${width}px — not a fixed stub`); + } + const isTop = (item.anchor ?? 'topLeft').startsWith('top'); + const rect = { + __themeSynthetic: true, + mark: { type: 'rect', color: item.color ?? d.text.primary }, + width, + height: item.height ?? 2, + data: { values: [{}] }, + }; + (isTop ? before : after).push(rect); + } + if (!before.length && !after.length) return handled; + + const inner: any = { ...spec }; + for (const key of ['$schema', 'background', 'padding', 'title', 'config', 'autosize', 'usermeta']) delete inner[key]; + + // A concatenated child's legends are hoisted to the outer view and drawn + // above every child in it. Resolved independently they stay with the plot + // they key, and the house's rule sits back under the headline where it was + // drawn. + const keyed = new Set(); + walk(inner, (node) => { + for (const channel of ['color', 'fill', 'stroke', 'size', 'shape', 'opacity'] as const) { + const enc = node.encoding?.[channel]; + if (enc?.field && enc.legend !== null) keyed.add(channel); + } + }); + + const outer: any = { + ...(spec.$schema ? { $schema: spec.$schema } : {}), + background: spec.background, + padding: spec.padding, + ...(spec.usermeta ? { usermeta: spec.usermeta } : {}), + ...(spec.title ? { title: spec.title } : {}), + spacing: 6, + vconcat: [...before, inner, ...after], + ...(keyed.size + ? { resolve: { legend: Object.fromEntries([...keyed].map((c) => [c, 'independent'])) } } + : {}), + config: spec.config, + }; + for (const key of Object.keys(spec)) delete spec[key]; + Object.assign(spec, outer); + return true; +} + +/** Normalise Vega-Lite's number|object padding into a full-sided object. */ +function normalizePadding(p: any): { left: number; right: number; top: number; bottom: number } { + if (typeof p === 'number') return { left: p, right: p, top: p, bottom: p }; + return { left: 8, right: 8, top: 8, bottom: 8, ...(p ?? {}) }; +} + +/** + * Canvas-anchored branding (the Economist red tab). Records where each piece + * draws — graphic-left, flush with the title's own margin — and reserves a + * strip of top padding so the tab opens the graphic above the headline rather + * than colliding with it. The renderer draws the recorded rects onto the SVG; + * Vega-Lite carries the record through compile in `usermeta`. + */ +function applyCanvasFurniture( + spec: any, + items: DesignDecisions['furniture'], + d: DesignDecisions, + say: (p: string, m: string) => void, +): boolean { + const base = normalizePadding(spec.padding); + const marginLeft = base.left; // the same left margin the title anchors to + const topBefore = base.top; + const gapBelow = 8; + const built: CanvasFurnitureItem[] = []; + let band = 0; + for (const item of items) { + const width = item.width ?? 40; + const height = item.height ?? 2; + built.push({ kind: item.kind, x: marginLeft, y: topBefore, width, height, color: item.color ?? d.text.primary }); + band = Math.max(band, height); + } + if (!built.length) return false; + growPadding(spec, 'top', band + gapBelow); + const prev = readCanvasFurniture(spec); + spec.usermeta = { ...(spec.usermeta ?? {}), [CANVAS_FURNITURE_KEY]: [...prev, ...built] }; + say( + 'furniture', + `the masthead tab rides the canvas at graphic-left (${marginLeft}px), in a ${band}px band above the title — clear of the plot's axis gutter`, + ); + return true; +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)); +} + +/** + * How wide the drawn block is, in the units furniture is measured in. The + * plotting rectangle is the only width the spec states; the labels down its + * left sit outside it, so a rule that closes the block is drawn a little past + * the plot rather than flush with it. + * + * A banded chart states no total at all — it states a step and lets the + * categories decide — so the total is read back the same way the layout wrote + * it, one band per name. + */ +function widestWidth(spec: any, table: any[]): number | undefined { + let widest: number | undefined; + const consider = (w: number | undefined) => { + if (w != null && Number.isFinite(w) && (widest == null || w > widest)) widest = w; + }; + // The step may be stated on a node whose children carry the encoding, so + // the band field is looked for down the branch, not only on the node. + const bandField = (node: any): string | undefined => { + let found: string | undefined; + walk(node, (n) => { + const enc = n.encoding?.x; + if (!found && enc?.field && (enc.type === 'nominal' || enc.type === 'ordinal')) found = enc.field; + }); + return found; + }; + walk(spec, (node) => { + const w = node.width; + if (typeof w === 'number') consider(w); + else if (w && typeof w === 'object' && typeof w.step === 'number') { + const count = distinctCount(table, bandField(node)); + if (count) consider(w.step * count); + } + }); + 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); + return widest; +} + +function blockWidth(spec: any, table: any[]): number | undefined { + const widest = widestWidth(spec, table); + if (widest == null) return undefined; + // A row of columns laid side by side — a bar-table's bars and its printed + // value column — is as wide as the columns *summed*, not as wide as the + // widest one. Anything that flows across the whole graphic (a top or bottom + // legend) has that whole width to spread over, so the key is not crowded + // into the few columns the bar plot alone would allow while the space above + // the value column sits empty. + if (Array.isArray(spec.hconcat) && spec.hconcat.length > 1) { + const spacing = typeof spec.spacing === 'number' ? spec.spacing : 8; + let sum = 0; + let ok = true; + for (const child of spec.hconcat) { + const cw = widestWidth(child, table); + if (cw == null) { ok = false; break; } + sum += cw; + } + if (ok && sum > widest) return Math.round(sum * 1.07 + spacing * (spec.hconcat.length - 1)); + } + return Math.round(widest * 1.07); +} + +void contrastingInk; diff --git a/packages/flint-js/tests/boxplot-grouped-dodge.test.ts b/packages/flint-js/tests/boxplot-grouped-dodge.test.ts index 0025a93c..35150590 100644 --- a/packages/flint-js/tests/boxplot-grouped-dodge.test.ts +++ b/packages/flint-js/tests/boxplot-grouped-dodge.test.ts @@ -73,7 +73,9 @@ describe('grouped boxplot dodging', () => { makeGroupedBoxplotInput(['Electronics', 'Clothing', 'Food'], ['Male', 'Female']), ) as any; const pitch = lanePitch(grouped, subgroups); - expect(sizeOf(grouped) / pitch).toBeGreaterThanOrEqual(0.75); + // The box fills most of its lane but leaves a legible gap (~30%) between + // neighbours so a quartet of boxes does not read as one solid block. + expect(sizeOf(grouped) / pitch).toBeGreaterThanOrEqual(0.6); expect(sizeOf(grouped)).toBeLessThan(pitch); }); @@ -94,11 +96,11 @@ describe('grouped boxplot dodging', () => { // Each sub-lane (and thus each box) shrinks as subgroups are added. expect(stepOf(four) / 4).toBeLessThan(stepOf(two) / 2); // Boxes never exceed their lane pitch (no within-group overlap) yet still - // fill most of it at both subgroup counts. + // fill most of it (leaving a legible gap) at both subgroup counts. expect(sizeOf(two)).toBeLessThan(lanePitch(two, 2)); expect(sizeOf(four)).toBeLessThan(lanePitch(four, 4)); - expect(sizeOf(two) / lanePitch(two, 2)).toBeGreaterThanOrEqual(0.75); - expect(sizeOf(four) / lanePitch(four, 4)).toBeGreaterThanOrEqual(0.75); + expect(sizeOf(two) / lanePitch(two, 2)).toBeGreaterThanOrEqual(0.6); + expect(sizeOf(four) / lanePitch(four, 4)).toBeGreaterThanOrEqual(0.6); }); it('uses yOffset when the categorical axis is y (horizontal boxplot)', () => { @@ -283,7 +285,7 @@ describe('ECharts sparse grouped boxplot', () => { it('offsets global outliers onto their boxplot lane', () => { const option = assembleECharts(sparseInput('global')) as any; - const outliers = option.series.find((series: any) => series.name === 'G1 (outliers)'); + const outliers = option.series.find((series: any) => series.name === 'G1 (points)'); expect(outliers?.type).toBe('custom'); const api = { diff --git a/packages/flint-js/tests/excel-codegen.test.ts b/packages/flint-js/tests/excel-codegen.test.ts index 36206977..0d82ba87 100644 --- a/packages/flint-js/tests/excel-codegen.test.ts +++ b/packages/flint-js/tests/excel-codegen.test.ts @@ -48,6 +48,7 @@ describe('Excel Office.js artifacts', () => { }); expect(generated.code).toContain('sheet.charts.add("Funnel", dataRange, "Columns")'); expect(generated.code).toContain('chart.dataLabels.format.font.color = "#FFFFFF"'); + expect(generated.code).toContain('chart.title.format.font.size = 18'); expect(generated.code).toContain('chart.series.getItemAt(index)'); expect(generated.code).toContain('chart.getImage(1280, 853, Excel.ImageFittingMode.fit)'); expect(() => new Function('Excel', `${generated.code}\nreturn main;`)).not.toThrow(); @@ -58,4 +59,24 @@ describe('Excel Office.js artifacts', () => { expect(funnelArtifact).not.toHaveProperty('scale'); expect(funnelArtifact).not.toHaveProperty('cleanWorksheet'); }); + + it('generates deterministic explicit series bindings', () => { + const generated = generateOfficeJs({ + ...funnelArtifact, + chartType: 'ColumnStacked', + data: [['Bin', 'Male', 'Female'], ['150-160', 7, 40]], + series: [ + { name: 'Male', xColumn: 0, yColumn: 1, rowCount: 1 }, + { name: 'Female', xColumn: 0, yColumn: 2, rowCount: 1 }, + ], + }); + + expect(generated.code).toContain('for (let index = chart.series.items.length - 1; index >= 0; index -= 1)'); + expect(generated.code).toContain('chart.series.getItemAt(index).delete()'); + expect(generated.code).toContain('chart.series.add("Male", 0)'); + expect(generated.code).toContain('chart.series.add("Female", 1)'); + expect(generated.code).toContain('boundSeries1.setXAxisValues(sheet.getRangeByIndexes(1, 0, 1, 1))'); + expect(generated.code).toContain('boundSeries1.setValues(sheet.getRangeByIndexes(1, 2, 1, 1))'); + expect(() => new Function('Excel', `${generated.code}\nreturn main;`)).not.toThrow(); + }); }); diff --git a/packages/flint-js/tests/excel-runtime.test.ts b/packages/flint-js/tests/excel-runtime.test.ts index 70b4efb4..746a40d0 100644 --- a/packages/flint-js/tests/excel-runtime.test.ts +++ b/packages/flint-js/tests/excel-runtime.test.ts @@ -11,20 +11,40 @@ function createMockExcel() { numberFormat: null as unknown, image: null as unknown, labels: {} as Record, + typography: {} as Record, fillColor: '', + deletedSeries: 0, + addedSeries: [] as Array<{ name: string | undefined; index: number | undefined }>, + xBindings: [] as unknown[], + valueBindings: [] as unknown[], clears: 0, syncs: 0, }; const series = { + delete: () => { calls.deletedSeries += 1; }, + setXAxisValues: (value: unknown) => { calls.xBindings.push(value); }, + setValues: (value: unknown) => { calls.valueBindings.push(value); }, format: { fill: { setSolidColor: (color: string) => { calls.fillColor = color; } }, line: {}, }, }; const chart = { - series: { getItemAt: () => series }, - title: {}, - legend: {}, + series: { + items: [series], + load: vi.fn(), + add: (name?: string, index?: number) => { + calls.addedSeries.push({ name, index }); + return series; + }, + getItemAt: () => series, + }, + title: { format: { font: { + set size(value: number) { calls.typography.chartTitle = value; }, + } } }, + legend: { format: { font: { + set size(value: number) { calls.typography.legend = value; }, + } } }, dataLabels: { format: { font: {} }, set visible(value: boolean) { calls.labels.visible = value; }, @@ -63,6 +83,8 @@ function createMockExcel() { calls.rangeAddress = address; return range; }, + getRangeByIndexes: (row: number, column: number, rowCount: number, columnCount: number) => + ({ row, column, rowCount, columnCount }), }; const run = vi.fn(async (callback: (context: unknown) => Promise) => callback({ workbook: { worksheets: { getActiveWorksheet: () => sheet } }, @@ -109,6 +131,7 @@ describe('renderExcelChart', () => { fontSize: 11, }); expect(calls.fillColor).toBe('#4472C4'); + expect(calls.typography).toEqual({ chartTitle: 18 }); expect(calls.image).toEqual({ width: 1280, height: 853, mode: 'fit' }); expect(calls.clears).toBe(1); expect(result).toEqual({ pngBase64: 'iVBORw0KGgoMOCK', inspection: null }); @@ -121,4 +144,31 @@ describe('renderExcelChart', () => { ); expect(run).not.toHaveBeenCalled(); }); + + it('rebuilds explicitly bound series instead of relying on Excel inference', async () => { + const { excel, calls } = createMockExcel(); + await renderExcelChart(excel, { + ...artifact, + chartType: 'ColumnStacked', + data: [['Bin', 'Male', 'Female'], ['150-160', 7, 40]], + series: [ + { name: 'Male', xColumn: 0, yColumn: 1, rowCount: 1 }, + { name: 'Female', xColumn: 0, yColumn: 2, rowCount: 1 }, + ], + }); + + expect(calls.deletedSeries).toBe(1); + expect(calls.addedSeries).toEqual([ + { name: 'Male', index: 0 }, + { name: 'Female', index: 1 }, + ]); + expect(calls.xBindings).toEqual([ + { row: 1, column: 0, rowCount: 1, columnCount: 1 }, + { row: 1, column: 0, rowCount: 1, columnCount: 1 }, + ]); + expect(calls.valueBindings).toEqual([ + { row: 1, column: 1, rowCount: 1, columnCount: 1 }, + { row: 1, column: 2, rowCount: 1, columnCount: 1 }, + ]); + }); }); diff --git a/packages/flint-js/tests/heatmap-colors.test.ts b/packages/flint-js/tests/heatmap-colors.test.ts index 0c29a623..f260135c 100644 --- a/packages/flint-js/tests/heatmap-colors.test.ts +++ b/packages/flint-js/tests/heatmap-colors.test.ts @@ -81,6 +81,89 @@ describe('heatmap color defaults', () => { expect(spec.encoding.color.scale.domainMid).toBe(0); }); + it('renders null values as intentional no-data cells', () => { + const input = heatmapInput({ showValueLabels: true }) as any; + input.data.values[0].value = null; + + const spec = assembleVegaLite(input) as any; + expect(spec.encoding).toBeUndefined(); + expect(spec.layer).toHaveLength(2); + expect(spec.layer[0].encoding.color.condition).toMatchObject({ + value: '#8c8c8c', + }); + expect(spec.layer[0].encoding.opacity.condition).toMatchObject({ + value: 0.32, + }); + expect(spec.layer[1].mark.clip).toBe(true); + expect(spec.layer[1].encoding.text.condition).toMatchObject({ value: '—' }); + }); + + it('keeps temporal heatmap axes continuous after transposing them', () => { + const input = { + data: { + values: [ + { month: '2025-01-01', food: 'Apples', value: 1 }, + { month: '2025-02-01', food: 'Apples', value: 2 }, + { month: '2025-01-01', food: 'Eggs', value: 3 }, + { month: '2025-02-01', food: 'Eggs', value: null }, + ], + }, + semantic_types: { month: 'YearMonth', food: 'Category', value: 'Count' }, + chart_spec: { + chartType: 'Heatmap', + encodings: { x: 'month', y: 'food', color: 'value' }, + chartProperties: { + arrange: 'flip:x-y', + showValueLabels: true, + }, + }, + } as any; + + const spec = assembleVegaLite(input) as any; + expect(spec.layer[0].encoding.x).toMatchObject({ field: 'food', type: 'nominal' }); + expect(spec.layer[0].encoding.y).toMatchObject({ field: 'month', type: 'temporal' }); + expect(spec.layer[0].encoding.y.scale.domain).toHaveLength(2); + expect(spec.layer[1].encoding.y.scale.domain) + .toEqual(spec.layer[0].encoding.y.scale.domain); + }); + + it('retains two true temporal axes for a dense 2,400-cell time heatmap', () => { + const values = []; + for (let x = 0; x < 60; x += 1) { + for (let y = 0; y < 40; y += 1) { + values.push({ + xDate: new Date(Date.UTC(2018, 0, 1 + x * 27)).toISOString().slice(0, 10), + yDate: new Date(Date.UTC(2020, 0, 1 + y * 27)).toISOString().slice(0, 10), + value: (x + y) % 100, + }); + } + } + + const spec = assembleVegaLite({ + data: { values }, + semantic_types: { xDate: 'Date', yDate: 'Date', value: 'Quantity' }, + chart_spec: { + chartType: 'Heatmap', + encodings: { + x: { field: 'xDate' }, + y: { field: 'yDate' }, + color: 'value', + }, + baseSize: { width: 400, height: 300 }, + }, + } as any) as any; + + expect(spec.encoding.x.type).toBe('temporal'); + expect(spec.encoding.y.type).toBe('temporal'); + expect(spec.mark).toMatchObject({ type: 'rect' }); + expect(spec.mark.width).toBeGreaterThan(0); + expect(spec.mark.height).toBeGreaterThan(0); + expect(spec._width).toBeLessThanOrEqual(500); + expect(spec._height).toBeLessThanOrEqual(400); + expect(spec.config.axisX.labelFontSize).toBeLessThanOrEqual(8); + expect(spec.config.axisY.labelFontSize).toBeLessThanOrEqual(8); + }); + it('uses light-to-dark blues for ECharts heatmaps by default', () => { const option = assembleECharts(heatmapInput()) as any; const colors = option.visualMap.inRange.color; @@ -90,4 +173,69 @@ describe('heatmap color defaults', () => { expect(option.color).toBeUndefined(); expect(option.series[0].itemStyle?.color).toBeUndefined(); }); +}); + +/** + * A pivot is a question, not a fact about the numbers. The same temperatures + * split at freezing if we are asking what ices over, and somewhere near room + * temperature if we are asking where is pleasant to live; nothing in the data + * distinguishes the two, so the author says which. + */ +const CITY_TEMPS = [ + { city: 'Singapore', month: 'Jan', temp: 26 }, + { city: 'Singapore', month: 'Jul', temp: 27 }, + { city: 'Moscow', month: 'Jan', temp: -9 }, + { city: 'Moscow', month: 'Jul', temp: 19 }, +]; + +function tempHeatmapInput(annotation: Record) { + return { + data: { values: CITY_TEMPS }, + semantic_types: { + city: 'Category', + month: 'Month', + temp: { semanticType: 'Quantity', ...annotation }, + }, + chart_spec: { + chartType: 'Heatmap', + encodings: { + x: { field: 'month' }, + y: { field: 'city' }, + color: { field: 'temp' }, + }, + }, + } as any; +} + +describe('what a diverging colour scale pivots on', () => { + it('pivots on the value the author names, not on zero', () => { + const spec = assembleVegaLite(tempHeatmapInput({ divergingMidpoint: 18 })) as any; + + expect(spec.encoding.color.scale.domainMid).toBe(18); + // Symmetric about the pivot, or a degree above reads differently from a + // degree below: 18 - (-9) = 27 is the longer reach, so both arms take it. + expect(spec.encoding.color.scale.domain).toEqual([-9, 45]); + }); + + it('splits on the named value even when every reading is on one side of it', () => { + const warm = CITY_TEMPS.filter(r => r.temp > 0); + const spec = assembleVegaLite({ + ...tempHeatmapInput({ divergingMidpoint: 18 }), + data: { values: warm }, + }) as any; + + expect(spec.encoding.color.scale.domainMid).toBe(18); + }); + + it('falls back to zero when the author names nothing', () => { + const spec = assembleVegaLite(tempHeatmapInput({})) as any; + + expect(spec.encoding.color.scale.domainMid).toBe(0); + }); + + it('warms the high end of a measure whose sign carries no loss', () => { + const spec = assembleVegaLite(tempHeatmapInput({})) as any; + + expect(spec.encoding.color.scale.scheme).toBe('blueorange'); + }); }); \ No newline at end of file diff --git a/packages/flint-js/tests/legend-series-end.test.ts b/packages/flint-js/tests/legend-series-end.test.ts new file mode 100644 index 00000000..7b242fd0 --- /dev/null +++ b/packages/flint-js/tests/legend-series-end.test.ts @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite } from '../src'; +import type { ThemeSpec } from '../src/core/theme/types'; + +/** + * `seriesEnd` names each band at its own last reading. The name can be knocked + * out *inside* the band, or hung *outside* the plot in the series' own ink — + * and that is a preference about the whole set, not a per-series decision. + * + * The two positions carry different ink and sit on opposite sides of the plot + * edge, so a set split between them reads as two kinds of label rather than one + * label with less room. One band that cannot hold its name sends every name out. + */ + +const YEARS = [1950, 1970, 1990, 2010, 2020]; + +/** A stacked area whose bands are `sizes`, each grown across the years. */ +function stack(sizes: Record): any[] { + const out: any[] = []; + YEARS.forEach((Year, i) => { + const growth = 0.6 + (i / (YEARS.length - 1)) * 0.4; + for (const [Region, top] of Object.entries(sizes)) { + out.push({ Year, Region, Population: Math.round(top * growth) }); + } + }); + return out; +} + +const house: ThemeSpec = { + id: 'house', + label: 'House', + ink: { + surface: { canvas: '#ffffff', plot: '#ffffff' }, + text: { primary: '#111111' }, + series: { + single: '#333333', + categorical: ['#111111', '#2251ff', '#00a9f4', '#00d7b9', '#b3b8bd'], + }, + }, + legend: { show: 'always', placement: ['seriesEnd', 'right'] }, +} as ThemeSpec; + +function build(sizes: Record): any { + return assembleVegaLite({ + data: { values: stack(sizes) }, + semantic_types: { Year: 'Year', Region: 'Category', Population: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + title: 'World population by region', + encodings: { x: 'Year', y: 'Population', color: 'Region' }, + baseSize: { width: 480, height: 320 }, + chartProperties: { stackMode: 'stack' }, + }, + theme_spec: house, + } as any) as any; +} + +/** + * Every band-end name layer, and whether it is the inset one. The inset layer + * paints its text in the plot surface to knock the name out of the band; the + * outset layer leaves the text in the series ink. + */ +function bandNameLayers(node: any, acc: Array<{ inset: boolean; align?: string; calc: string }> = []): typeof acc { + if (!node || typeof node !== 'object') return acc; + if (Array.isArray(node)) { + node.forEach((n) => bandNameLayers(n, acc)); + return acc; + } + const mark = typeof node.mark === 'string' ? { type: node.mark } : node.mark; + if (mark?.type === 'text' && node.encoding?.text?.field === '__bandEndLabel') { + const calc = (node.transform ?? []).find((tf: any) => tf.as === '__bandEndLabel')?.calculate ?? ''; + acc.push({ inset: typeof mark.color === 'string', align: mark.align, calc }); + } + for (const key of ['layer', 'vconcat', 'hconcat', 'concat']) { + if (Array.isArray(node[key])) node[key].forEach((n: any) => bandNameLayers(n, acc)); + } + if (node.spec) bandNameLayers(node.spec, acc); + if (node.facet?.spec) bandNameLayers(node.facet.spec, acc); + return acc; +} + +const messages = (spec: any): string[] => + (spec._theme?.report ?? []) + .filter((r: any) => r.path === 'legend.placement') + .map((r: any) => r.message); + +describe('band-end names are inset or outset as a set', () => { + it('knocks every name into its band when each band can hold one', () => { + const layers = bandNameLayers(build({ Asia: 3000, Africa: 1400, Europe: 1200 })); + expect(layers.length).toBe(1); + expect(layers[0].inset).toBe(true); + }); + + it('sends every name outside when a single band is too thin to hold one', () => { + // Oceania under four other continents: one sliver, four comfortable bands. + const spec = build({ Asia: 4641, Africa: 1361, Europe: 748, Americas: 1023, Oceania: 45 }); + const layers = bandNameLayers(spec); + + // The whole point: one layer, and it is the outset one. Never a mix. + expect(layers.length).toBe(1); + expect(layers[0].inset).toBe(false); + expect(messages(spec).join(' ')).toMatch(/all or nothing/); + }); + + it('never draws the inset and outset layers together', () => { + const cases: Record[] = [ + { Asia: 3000, Africa: 1400, Europe: 1200 }, + { Asia: 4641, Africa: 1361, Europe: 748, Americas: 1023, Oceania: 45 }, + { Asia: 4641, Africa: 40, Europe: 30, Americas: 1023, Oceania: 45 }, + ]; + for (const sizes of cases) { + const layers = bandNameLayers(build(sizes)); + expect(new Set(layers.map((l) => l.inset)).size).toBe(1); + } + }); +}); + +describe('the reading rides with the name only where it annotates something', () => { + it('quotes the last reading beside a name lying on its own band', () => { + const [label] = bandNameLayers(build({ Asia: 3000, Africa: 1400, Europe: 1200 })); + expect(label.inset).toBe(true); + expect(label.calc).toContain('Population'); + }); + + it('drops the reading once the names are a list in the margin', () => { + const [label] = bandNameLayers( + build({ Asia: 4641, Africa: 1361, Europe: 748, Americas: 1023, Oceania: 45 }), + ); + expect(label.inset).toBe(false); + // Out here the name is a legend entry, not an annotation: no number. + expect(label.calc).not.toContain('Population'); + expect(label.calc).toContain('Region'); + }); +}); diff --git a/packages/flint-js/tests/lollipop-chartjs.test.ts b/packages/flint-js/tests/lollipop-chartjs.test.ts new file mode 100644 index 00000000..b9d4ee4c --- /dev/null +++ b/packages/flint-js/tests/lollipop-chartjs.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleChartjs, assembleECharts, assembleVegaLite, cjsGetTemplateDef } from '../src'; + +const DATA = [ + { region: 'East', revenue: 168 }, + { region: 'South', revenue: 167 }, + { region: 'North', revenue: 145 }, +]; + +const GROUPED_DATA = [ + { region: 'East', revenue: 168, year: '2024' }, + { region: 'South', revenue: 167, year: '2024' }, + { region: 'East', revenue: 120, year: '2025' }, + { region: 'South', revenue: 131, year: '2025' }, +]; + +function verticalInput(chartProperties?: Record) { + return { + data: { values: DATA }, + semantic_types: { region: 'Region', revenue: 'Amount' }, + chart_spec: { + chartType: 'Lollipop Chart', + encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + baseSize: { width: 400, height: 300 }, + ...(chartProperties ? { chartProperties } : {}), + }, + }; +} + +describe('Chart.js Lollipop Chart', () => { + it('is registered in the Chart.js template registry', () => { + expect(cjsGetTemplateDef('Lollipop Chart')).toBeDefined(); + }); + + it('builds a thin bar stem plus a point-only line dataset', () => { + const config = assembleChartjs(verticalInput()) as any; + expect(config.type).toBe('bar'); + expect(config.data.labels).toEqual(['East', 'South', 'North']); + + const [stem, dots] = config.data.datasets; + expect(stem.type).toBe('bar'); + expect(stem.barThickness).toBe(1.5); + expect(stem.data).toEqual([168, 167, 145]); + + expect(dots.type).toBe('line'); + expect(dots.showLine).toBe(false); + expect(dots.pointRadius).toBe(5); // dotSize default 80 → 10px diameter + expect(dots.data).toEqual([168, 167, 145]); + }); + + it('anchors the value axis at zero (stems grow from 0)', () => { + const config = assembleChartjs(verticalInput()) as any; + expect(config.options.scales.y.beginAtZero).toBe(true); + }); + + it('transposes to indexAxis "y" when the category is on y', () => { + const config = assembleChartjs({ + data: { values: DATA }, + semantic_types: { region: 'Region', revenue: 'Amount' }, + chart_spec: { + chartType: 'Lollipop Chart', + encodings: { x: { field: 'revenue' }, y: { field: 'region' } }, + baseSize: { width: 400, height: 300 }, + }, + }) as any; + expect(config.options.indexAxis).toBe('y'); + expect(config.options.scales.x.type).toBe('linear'); + expect(config.data.labels).toEqual(['East', 'South', 'North']); + }); + + it('adds one dot dataset per color group and shows the legend', () => { + const config = assembleChartjs({ + data: { values: GROUPED_DATA }, + semantic_types: { region: 'Region', revenue: 'Amount', year: 'Year' }, + chart_spec: { + chartType: 'Lollipop Chart', + encodings: { x: { field: 'region' }, y: { field: 'revenue' }, color: { field: 'year' } }, + baseSize: { width: 400, height: 300 }, + }, + }) as any; + + expect(config.data.datasets).toHaveLength(3); // stem + 2 groups + expect(config.data.datasets.slice(1).map((d: any) => d.label)).toEqual(['2024', '2025']); + expect(config.options.plugins.legend.display).toBe(true); + }); + + it('keeps the stem out of legend and tooltip', () => { + const config = assembleChartjs(verticalInput()) as any; + const stem = config.data.datasets[0]; + const { legend, tooltip } = config.options.plugins; + expect(legend.labels.filter({ text: stem.label })).toBe(false); + expect(tooltip.filter({ dataset: { label: stem.label } })).toBe(false); + expect(tooltip.filter({ dataset: { label: 'revenue' } })).toBe(true); + }); + + it('maps the dotSize property onto the point radius', () => { + const small = assembleChartjs(verticalInput({ dotSize: 20 })) as any; + const large = assembleChartjs(verticalInput({ dotSize: 300 })) as any; + expect(small.data.datasets[1].pointRadius).toBeLessThan(5); + expect(large.data.datasets[1].pointRadius).toBeGreaterThan(5); + expect(large.data.datasets[1].pointRadius).toBeLessThanOrEqual(8); // 16px diameter cap + }); + + it('compiles the same input on all three backends', () => { + const input = verticalInput(); + expect(() => assembleVegaLite(input)).not.toThrow(); + expect(() => assembleECharts(input)).not.toThrow(); + expect(() => assembleChartjs(input)).not.toThrow(); + }); +}); diff --git a/packages/flint-js/tests/point-size.test.ts b/packages/flint-js/tests/point-size.test.ts new file mode 100644 index 00000000..92bc36d2 --- /dev/null +++ b/packages/flint-js/tests/point-size.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite } from '../src'; +import { THEME_PRESETS } from '../src/core/theme/presets'; + +/** + * How big a dot is. + * + * A house's dot size is the size of a dot that has room. Crowd the plot and + * the dots have to give ground, or a cloud of observations turns into one + * solid field — but they may only give so much before a dot stops being a + * dot, and they must not give ground for marks that were never drawn. + */ + +const scatter = (n: number, house: string, extra: Record = {}, groups = 6) => { + let seed = 7; + const rnd = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648; + const values = Array.from({ length: n }, (_, i) => ({ + Weight: 1 + rnd() * 3, + Economy: 12 + rnd() * 30, + Group: `G${i % groups}`, + })); + return assembleVegaLite({ + data: { values }, + semantic_types: { Weight: 'Quantity', Economy: 'Quantity', Group: 'Category' }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: { field: 'Weight' }, y: { field: 'Economy' }, ...extra }, + }, + theme_spec: THEME_PRESETS[house].spec, + } as never) as never as { mark?: unknown; config?: Record }; +}; + +const drawnSize = (spec: { mark?: unknown; config?: Record }) => { + // A mark-level size beats the config block, so reading the config alone + // can report a size that nothing is actually drawn at. + const mark = (typeof spec.mark === 'string' ? { type: spec.mark } : spec.mark ?? {}) as { type?: string; size?: number }; + return mark.size ?? (mark.type ? spec.config?.[mark.type]?.size : undefined); +}; + +const declaredSize = (house: string) => + (THEME_PRESETS[house].spec as { marks?: { point?: { size?: number } } }).marks?.point?.size; + +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 + // the renderer's own default, which is nobody's design decision, and + // — because the crowding budget can only cut a size that exists — + // it also opts out of ever giving ground when the plot fills up. + for (const id of Object.keys(THEME_PRESETS)) { + expect(declaredSize(id), `${id} declares no point size`).toBeGreaterThan(0); + } + }); + + it('a sparse scatter draws the house size', () => { + for (const id of ['economist', 'swiss', 'nyt', 'mckinsey']) { + const drawn = drawnSize(scatter(30, id)); + expect(drawn, `${id} draws no size of its own`).toBeGreaterThan(0); + expect(drawn, id).toBe(declaredSize(id)); + } + }); + + it('a crowded scatter gives ground, but never past the floor', () => { + for (const id of ['economist', 'swiss', 'nyt', 'mckinsey']) { + const dense = drawnSize(scatter(1500, id))!; + expect(dense, `${id} did not shrink`).toBeLessThan(declaredSize(id)!); + expect(dense, `${id} shrank past the floor`).toBeGreaterThanOrEqual(20); + } + }); + + it('a facet budgets each panel against its own rows', () => { + // Six panels draw a sixth of the table each. Charging one panel for + // the whole table would shrink every dot to the floor to make room + // for five panels' worth of marks that are not drawn there. + const rows = 300; + const panels = 3; + const spec = scatter(rows, 'mckinsey', { column: { field: 'Group' } }, panels); + const view = (spec.config as unknown as { view: { continuousWidth: number; continuousHeight: number } }).view; + const budget = (share: number) => Math.floor((view.continuousWidth * view.continuousHeight * 0.12) / share); + const perPanel = budget(rows / panels); + // The dots are charged for the rows their own panel draws, not for + // the whole table — which would be three times as harsh. + expect(budget(rows)).toBeLessThan(perPanel); + expect(drawnSize(spec)).toBe(Math.min(declaredSize('mckinsey')!, perPanel)); + }); + + it('a chart that draws no dot cloud is not charged for the crowd', () => { + // A line chart's dots are its vertices and a boxplot's are its + // outliers — neither is one per row, so neither pays the crowd's + // rent for rows it never drew. The same row count in a scatter, + // where every row *is* a dot, does have to give ground. + const rows = 900; + const values = Array.from({ length: rows }, (_, i) => ({ + Month: `2024-${String((i % 12) + 1).padStart(2, '0')}-01`, + Revenue: 100 + (i % 37), + Series: `S${i % 3}`, + })); + const line = assembleVegaLite({ + data: { values }, + semantic_types: { Month: 'Time', Revenue: 'Quantity', Series: 'Category' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: { field: 'Month' }, y: { field: 'Revenue' }, color: { field: 'Series' } }, + }, + theme_spec: THEME_PRESETS['mckinsey'].spec, + } as never) as never as { config?: Record }; + expect(line.config?.circle?.size).toBe(declaredSize('mckinsey')); + expect(drawnSize(scatter(rows, 'mckinsey'))).toBeLessThan(declaredSize('mckinsey')!); + }); +}); diff --git a/packages/flint-js/tests/smoke.test.ts b/packages/flint-js/tests/smoke.test.ts index 4e7977b8..0b0bdb5f 100644 --- a/packages/flint-js/tests/smoke.test.ts +++ b/packages/flint-js/tests/smoke.test.ts @@ -49,6 +49,36 @@ describe('public API smoke', () => { expect(config.type ?? config.data ?? config.options).toBeDefined(); }); + it('uses field display names across native backend axis and series titles', () => { + const input = { + data: { values: [ + { country: 'France', percentageOfCountries: 42 }, + { country: 'Japan', percentageOfCountries: 47 }, + ] }, + semantic_types: { country: 'Country', percentageOfCountries: 'Percent' }, + field_display_names: { + country: 'Country', + percentageOfCountries: 'Percentage of countries', + }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'country', y: 'percentageOfCountries' }, + }, + }; + + const echarts = assembleECharts(input) as any; + expect(echarts.xAxis.name).toBe('Country'); + expect(echarts.yAxis.name).toBe('Percentage of countries'); + + const chartjs = assembleChartjs(input) as any; + expect(chartjs.options.scales.x.title.text).toBe('Country'); + expect(chartjs.options.scales.y.title.text).toBe('Percentage of countries'); + + const plotly = assemblePlotly(input) as any; + expect(plotly.layout.xaxis.title.text).toBe('Country'); + expect(plotly.layout.yaxis.title.text).toBe('Percentage of countries'); + }); + it('assembleExcel returns an Excel chart spec with a wide data matrix', () => { const spec = assembleExcel(INPUT) as any; expect(spec).toBeDefined(); @@ -68,6 +98,29 @@ describe('public API smoke', () => { expect(spec.seriesBy).toBe('Columns'); }); + it('assembleExcel uses field display names for native axis titles', () => { + const spec = assembleExcel({ + data: { values: [ + { year: '2024', percentageOfCountries: 42 }, + { year: '2025', percentageOfCountries: 47 }, + ] }, + semantic_types: { year: 'Category', percentageOfCountries: 'Percent' }, + field_display_names: { + year: 'Year', + percentageOfCountries: 'Percentage of countries', + }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'year', y: 'percentageOfCountries' }, + }, + }); + + expect(spec.categoryAxis?.title).toBe('Year'); + expect(spec.valueAxis?.title).toBe('Percentage of countries'); + expect(spec.data[0]).toEqual(['Year', 'Percentage of countries']); + expect(spec.data[1]).toEqual(['2024', 42]); + }); + it('assembleExcel preserves bar-template roles when both axes are quantitative', () => { const spec = assembleExcel({ data: { values: [{ X: 1, Value: 10 }, { X: 2, Value: 20 }] }, @@ -104,7 +157,28 @@ describe('public API smoke', () => { expect(spec.chartType).toBe('ColumnClustered'); expect(spec.data[0]).toEqual(['Value', 'Count']); expect(spec.data.slice(1).reduce((sum: number, row: any[]) => sum + row[1], 0)).toBe(3); - expect(spec.gapWidth).toBe(0); + expect(spec.gapWidth).toBe(20); + }); + + it('assembleExcel emits grouped histograms as native row series', () => { + const spec = assembleExcel({ + data: { values: [ + { Height: 160, Gender: 'Male' }, + { Height: 170, Gender: 'Female' }, + ] }, + semantic_types: { Height: 'Quantity', Gender: 'Category' }, + chart_spec: { chartType: 'Histogram', encodings: { x: 'Height', color: 'Gender' } }, + }); + + expect(spec.chartType).toBe('ColumnStacked'); + expect(spec.seriesBy).toBe('Rows'); + expect(spec.series).toBeUndefined(); + expect(spec.data).toEqual([ + ['Height', '160-162', '', '162-164', '164-166', '166-168', '168-170'], + ['Male', 1, 0, 0, 0, 0, 0], + ['Female', 0, 0, 0, 0, 0, 1], + ]); + expect(spec.gapWidth).toBe(20); }); it('assembleExcel emits a native delta Waterfall with connector lines', () => { @@ -337,7 +411,7 @@ describe('public API smoke', () => { expect(spec.data.slice(1).map((row: any[]) => row[0])).toEqual([46024, 46027]); }); - it('assembleExcel thins dense Candlestick date labels without dropping OHLC rows', () => { + it('assembleExcel uses native date intervals for dense Candlestick labels without dropping OHLC rows', () => { const values = Array.from({ length: 90 }, (_, index) => ({ Date: new Date(Date.UTC(2026, 0, index + 1)).toISOString().slice(0, 10), Open: 100 + index, @@ -356,7 +430,13 @@ describe('public API smoke', () => { }) as any; expect(spec.data).toHaveLength(91); - expect(spec.categoryAxis.tickLabelSpacing).toBe(3); + expect(spec.categoryAxis).toMatchObject({ + categoryType: 'DateAxis', + baseTimeUnit: 'Days', + majorUnit: 20, + majorTimeUnitScale: 'Days', + }); + expect(spec.categoryAxis.tickLabelSpacing).toBeUndefined(); }); it('assembleExcel rejects color-grouped native Boxplots', () => { @@ -427,7 +507,14 @@ describe('public API smoke', () => { }) as any; expect(spec.data).toHaveLength(values.length + 1); - expect(spec.categoryAxis.tickLabelSpacing).toBe(2); + expect(spec.data[1][0]).toBe(46023); + expect(spec.categoryAxis).toMatchObject({ + categoryType: 'DateAxis', + baseTimeUnit: 'Days', + majorUnit: 10, + majorTimeUnitScale: 'Days', + }); + expect(spec.categoryAxis.tickLabelSpacing).toBeUndefined(); }); it('assembleExcel emits grouped bubble series with explicit size ranges', () => { @@ -657,7 +744,7 @@ describe('public API smoke', () => { visible: true, numberFormat: undefined, fontColor: '#FFFFFF', - fontSize: 11, + fontSize: 13, }); expect(spec.seriesFormats).toEqual([{ color: '#4472C4' }]); }); @@ -827,7 +914,7 @@ describe('public API smoke', () => { expect(excel.data.slice(1).map((row: any[]) => row[0])).toEqual(expectedOrder); expect(expectedOrder).toEqual(values.slice(0, expectedOrder.length).map((row) => row.Group)); expect(excel.data.length).toBeLessThan(values.length + 1); - expect(excel.categoryAxis.labelFontSize).toBe(5); + expect(excel.categoryAxis.labelFontSize).toBe(8); }); it('assembleExcel caps dense bars using the selected value sort', () => { @@ -867,9 +954,9 @@ describe('public API smoke', () => { expect(spec.data).toEqual([ ['Date', 'A', 'B'], - ['2026-01-01', 10, null], - ['2026-01-02', 20, 20], - ['2026-01-03', 30, null], + [46023, 10, null], + [46024, 20, 20], + [46025, 30, null], ]); }); @@ -898,9 +985,9 @@ describe('public API smoke', () => { expect(spec.data).toEqual([ ['Date', 'actual', 'forecast'], - ['2026-01-01', 10, null], - ['2026-01-02', 12, 12], - ['2026-01-03', null, 15], + [46023, 10, null], + [46024, 12, 12], + [46025, null, 15], ]); expect(spec.seriesFormats).toEqual([ { color: '#4472C4', lineStyle: 'Continuous' }, diff --git a/packages/flint-js/tests/theme-axis-labels.test.ts b/packages/flint-js/tests/theme-axis-labels.test.ts new file mode 100644 index 00000000..6b1408b3 --- /dev/null +++ b/packages/flint-js/tests/theme-axis-labels.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite } from '../src'; + +/** + * Vega drops a tick label only once its box *overlaps* its neighbour's. Two + * numbers whose boxes merely abut therefore both survive, and on a log axis + * `20,000` beside `30,000` prints as `20,00030,000` — one number that is not + * in the data. Numbers need a character's worth of air between them before + * they read as two. + */ + +const nations = [ + { country: 'Ethiopia', income: 2000, life: 66.2 }, + { country: 'Bangladesh', income: 4200, life: 72.3 }, + { country: 'India', income: 6900, life: 69.4 }, + { country: 'Indonesia', income: 12400, life: 71.5 }, + { country: 'China', income: 16800, life: 76.7 }, + { country: 'Mexico', income: 19800, life: 75.0 }, + { country: 'Russia', income: 25800, life: 72.4 }, + { country: 'Germany', income: 50900, life: 81.0 }, + { country: 'Qatar', income: 116900, life: 80.1 }, +]; + +function scatter(theme?: string): any { + const out: any = assembleVegaLite({ + data: { values: nations }, + semantic_types: { country: 'Country', income: 'Quantity', life: 'Quantity' }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: { field: 'income' }, y: { field: 'life' } }, + chartProperties: { logScale_x: true }, + baseSize: { width: 400, height: 300 }, + }, + ...(theme ? { theme_spec: theme } : {}), + } as any); + return out.spec ?? out; +} + +function bars(theme?: string): any { + const out: any = assembleVegaLite({ + data: { + values: [ + { region: 'North', sales: 120 }, + { region: 'South', sales: 90 }, + { region: 'East', sales: 140 }, + { region: 'West', sales: 70 }, + ], + }, + semantic_types: { region: 'Category', sales: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'region' }, y: { field: 'sales' } }, + baseSize: { width: 400, height: 300 }, + }, + ...(theme ? { theme_spec: theme } : {}), + } as any); + return out.spec ?? out; +} + +describe('two tick numbers may not read as one', () => { + it('holds numeric axis labels apart by about a character', () => { + const spec = scatter('swiss'); + const sep = spec.config.axisX.labelSeparation; + const size = spec.config.axisX.labelFontSize; + expect(sep).toBeGreaterThan(0); + expect(sep).toBeGreaterThanOrEqual(Math.round(size * 0.5)); + }); + + it('holds them apart with no house named at all', () => { + const spec = scatter(); + expect(spec.config.axisX.labelSeparation).toBeGreaterThan(0); + expect(spec.config.axisY.labelSeparation).toBeGreaterThan(0); + }); + + it('leaves a band axis alone, where thinning would drop a category', () => { + const spec = bars('swiss'); + expect(spec.config.axisX.labelSeparation).toBeUndefined(); + expect(spec.config.axisY.labelSeparation).toBeGreaterThan(0); + }); +}); + +/** + * Some houses ask for the axis to be ticked at the values the data holds, + * rather than at round numbers between them — an axis of Olympic years has no + * 2014 on it. That is a claim the data is *spaced* by something. Fifteen + * countries' incomes are not: 5,300 is Nigeria, not a mark on a ruler. + */ +describe('an axis is ticked at observations only where they are a step', () => { + it('leaves a measure axis to its round numbers', () => { + const spec = scatter('nyt'); + const enc = spec.encoding?.x ?? spec.layer?.[0]?.encoding?.x; + expect(enc.axis?.values).toBeUndefined(); + }); + + it('still ticks a regularly spaced index at its own observations', () => { + const games = [2012, 2016, 2020, 2024].flatMap((year) => + ['United States', 'China'].map((country, i) => ({ year, country, rank: i + 1 })), + ); + const out: any = assembleVegaLite({ + data: { values: games }, + semantic_types: { year: 'Quantity', country: 'Category', rank: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: { field: 'year' }, y: { field: 'rank' }, color: { field: 'country' } }, + baseSize: { width: 500, height: 300 }, + }, + theme_spec: 'nyt', + } as any); + const spec = out.spec ?? out; + const enc = spec.encoding?.x ?? spec.layer?.[0]?.encoding?.x; + expect(enc.axis?.values).toEqual([2012, 2016, 2020, 2024]); + }); +}); diff --git a/packages/flint-js/tests/theme-diverging-direction.test.ts b/packages/flint-js/tests/theme-diverging-direction.test.ts new file mode 100644 index 00000000..b7f963c0 --- /dev/null +++ b/packages/flint-js/tests/theme-diverging-direction.test.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { THEME_PRESETS } from '../src'; + +/** + * A diverging ramp is read before the key is: the warm end is the high end. + * A house that runs the ramp the other way paints a hot July blue and a cold + * January red, and the chart says the opposite of the data to anyone who does + * not stop to check the legend. + */ + +/** Rough warmth: how far red sits above blue in the stop. */ +function warmth(hex: string): number { + const n = parseInt(hex.slice(1), 16); + return ((n >> 16) & 255) - (n & 255); +} + +describe('a diverging ramp runs cool to warm', () => { + const houses = Object.values(THEME_PRESETS) + .map((p: any) => [p.id, p.spec?.ink?.series?.diverging?.stops] as const) + .filter(([, stops]) => Array.isArray(stops) && stops.length >= 2); + + it('is stated by more than one house, or this test proves nothing', () => { + expect(houses.length).toBeGreaterThan(1); + }); + + for (const [id, stops] of houses) { + it(`${id} puts its warm end at the top`, () => { + const low = warmth(stops![0]); + const high = warmth(stops![stops!.length - 1]); + expect(high).toBeGreaterThan(low); + }); + } +}); diff --git a/packages/flint-js/tests/theme-legend-rows.test.ts b/packages/flint-js/tests/theme-legend-rows.test.ts new file mode 100644 index 00000000..8830f7b0 --- /dev/null +++ b/packages/flint-js/tests/theme-legend-rows.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite } from '../src'; +import type { ThemeSpec } from '../src/core/theme/types'; + +/** + * A chart can need more than one key: one that names the colours and one that + * measures the sizes. Laid side by side above the plot they eat the block + * between them, and past a point the second is pushed against the first. + * + * Where they go is therefore a measurement, not a preference. + */ + +const rows = (n: number) => + Array.from({ length: n }, (_, i) => ({ + Country: `Country ${i}`, + Region: ['Europe', 'Americas', 'Asia', 'Africa'][i % 4], + GDP: 1000 * (i + 1), + Life: 60 + i, + Population: 10 * (i + 1), + })); + +const theme = (extra: Partial = {}): ThemeSpec => ({ + id: 'house', + label: 'House', + ink: { + surface: { canvas: '#ffffff' }, + series: { single: '#333333', categorical: ['#1', '#2', '#3', '#4'] }, + }, + legend: { show: 'always', placement: ['top'], direction: 'horizontal' }, + ...extra, +} as ThemeSpec); + +function bubble(width: number, sizeField: string | null = 'Population'): any { + return assembleVegaLite({ + data: { values: rows(8) }, + semantic_types: { + Country: 'Country', Region: 'Category', + GDP: 'Amount', Life: 'Quantity', Population: 'Quantity', + }, + chart_spec: { + chartType: 'Scatter Plot', + title: 'Money buys years', + encodings: { + x: 'GDP', + y: 'Life', + color: 'Region', + ...(sizeField ? { size: sizeField } : {}), + }, + baseSize: { width, height: 240 }, + }, + theme_spec: theme(), + } as any) as any; +} + +const layoutOf = (spec: any) => spec.config?.legend?.layout; + +describe('two keys above one plot', () => { + it('gives each a row when they will not fit across the block', () => { + const spec = bubble(320); + expect(layoutOf(spec)?.top?.direction).toBe('vertical'); + expect(spec._theme.report.some((r: any) => /row each/.test(r.message))).toBe(true); + }); + + it('leaves them on one row when the block is wide enough', () => { + const spec = bubble(1200); + expect(layoutOf(spec)?.top?.direction).toBeUndefined(); + expect(spec._theme.report.some((r: any) => /row each/.test(r.message))).toBe(false); + }); + + it('says nothing about rows when there is only one key', () => { + const spec = bubble(320, null); + expect(layoutOf(spec)?.top?.direction).toBeUndefined(); + expect(spec._theme.report.some((r: any) => /row each/.test(r.message))).toBe(false); + }); + + /** + * A size key inherits the mark's ink. Beside a colour key that is the ink + * of the first category, which makes the row read as one more of them. + */ + it('draws the size key in neutral ink beside a colour key', () => { + const spec = bubble(320); + const found: any[] = []; + JSON.stringify(spec, (_k, v) => { + if (v?.symbolFillColor) found.push(v.symbolFillColor); + return v; + }); + expect(found.length).toBeGreaterThan(0); + }); +}); + +/** + * How many entries a horizontal key fits in one row. + * + * Vega-Lite packs a legend row — each entry takes the width of its own name. + * Charging every entry the width of the longest one wraps keys that would + * have fitted, which is what put "None at all" on a second row under a row + * with a third of its block still empty. + */ +describe('a legend row is packed, not ruled into columns', () => { + const likert = ['A great deal', 'Some', 'Not much', 'None at all']; + const many = [ + 'Strongly agree', 'Somewhat agree', 'Neither agree nor disagree', + 'Somewhat disagree', 'Strongly disagree', 'No opinion', + 'Prefer not to say', 'Not applicable', + ]; + + const survey = (responses: string[], width: number): any => assembleVegaLite({ + data: { + values: ['Scientists', 'The military', 'The police', 'The press', 'Congress'] + .flatMap((Institution) => responses.map((Response) => ({ + Institution, Response, Share: 100 / responses.length, + }))), + }, + semantic_types: { Institution: 'Category', Response: 'Category', Share: 'Quantity' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'Share', y: 'Institution', color: 'Response' }, + title: 'Confidence in US institutions', + baseSize: { width, height: 300 }, + }, + theme_spec: 'swiss', + } as any) as any; + + const legendOf = (node: any): any => { + if (!node || typeof node !== 'object') return undefined; + if (node.encoding?.color?.legend) return node.encoding.color.legend; + for (const key of Object.keys(node)) { + const found = legendOf(node[key]); + if (found) return found; + } + return undefined; + }; + + it('leaves a row alone when the names it carries actually fit', () => { + // One long name and three short ones. Ruled into equal columns this + // asked for 4 × the width of "A great deal" and wrapped to three; + // packed, the four sit in one row with room to spare. + expect(legendOf(survey(likert, 400))?.columns).toBeUndefined(); + }); + + it('still wraps a key that genuinely overruns its block', () => { + const columns = legendOf(survey(many, 400))?.columns; + expect(columns).toBeGreaterThan(0); + expect(columns).toBeLessThan(many.length); + }); + + it('wraps harder as the block narrows', () => { + const wide = legendOf(survey(many, 900))?.columns ?? many.length; + const narrow = legendOf(survey(many, 400))?.columns ?? many.length; + expect(narrow).toBeLessThanOrEqual(wide); + }); +}); diff --git a/packages/flint-js/tests/theme-presets.test.ts b/packages/flint-js/tests/theme-presets.test.ts new file mode 100644 index 00000000..348b110f --- /dev/null +++ b/packages/flint-js/tests/theme-presets.test.ts @@ -0,0 +1,1577 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite } from '../src'; +import { markTypeOf } from '../src/vegalite/theme'; +import { THEME_PRESETS, DEFAULT_THEME_ICON, listThemePresets, resolveThemeSpec } from '../src/core/theme/presets'; +import type { ThemeSpec } from '../src/core/theme/types'; + +/** + * A house may hold preferences about how a chart is *built*, not only how it + * looks: which options a chart type takes by default, and what the compiler + * should assume about size and stretch. + * + * Both are the middle of three levels. What the caller wrote in the chart spec + * wins; the house comes next; flint's own defaults come last. + */ + +const DATA = [ + { Year: 2019, Sales: 12 }, + { Year: 2020, Sales: 18 }, + { Year: 2021, Sales: 15 }, + { Year: 2022, Sales: 24 }, +]; + +function theme(extra: Partial): ThemeSpec { + return { + id: 'house', + label: 'House', + ink: { surface: { canvas: '#ffffff' }, series: { single: '#333333' } }, + ...extra, + } as ThemeSpec; +} + +function build(themeSpec: ThemeSpec, chartProperties?: Record) { + return assembleVegaLite({ + data: { values: DATA }, + semantic_types: { Year: 'Year', Sales: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'Year', y: 'Sales' }, + ...(chartProperties ? { chartProperties } : {}), + }, + theme_spec: themeSpec, + } as any) as any; +} + +function markOf(spec: any): any { + const node = spec.layer?.find((l: any) => l.mark && !l.__themeSynthetic) ?? spec; + return typeof node.mark === 'string' ? { type: node.mark } : node.mark; +} + +/** + * The size a dot is actually drawn at. A mark-level `size` beats the config + * block, so reading the config alone can report a size nothing is drawn at. + */ +function dotSize(spec: any): number | undefined { + const mark = markOf(spec); + return mark?.size ?? spec.config?.[mark?.type]?.size; +} + +describe('naming a house Flint ships', () => { + it('reads the same as passing that house in full', () => { + const named = build('economist' as any); + const spelled = build(THEME_PRESETS.economist.spec); + expect(named._theme?.id).toBe('economist'); + expect(JSON.stringify(named)).toBe(JSON.stringify(spelled)); + }); + + it('says so when the name is not one of ours', () => { + expect(() => build('the-guardian' as any)).toThrow(/economist/); + }); + + it('lists every house it can resolve', () => { + for (const { id } of listThemePresets()) { + expect(resolveThemeSpec(id)).toEqual(resolveThemeSpec(THEME_PRESETS[id].spec)); + } + }); + + it('resolves inheritance inside a shipped house', () => { + const resolved = resolveThemeSpec('pop'); + + expect(THEME_PRESETS.pop.spec.extends).toBe('swiss'); + expect(resolved?.id).toBe('pop'); + expect(resolved?.ink?.surface?.canvas).toBe('#fff200'); + expect(resolved?.labels).toEqual(THEME_PRESETS.swiss.spec.labels); + }); + + it('deep-merges overrides into a named house without changing the preset', () => { + const original = THEME_PRESETS.economist.spec; + const inherited = resolveThemeSpec({ + extends: 'economist', + id: 'our-economist', + ink: { + series: { single: '#6b3fa0' }, + }, + structure: { + grid: { measure: 'omit' }, + }, + }); + + expect(inherited).not.toBe(original); + expect(inherited?.id).toBe('our-economist'); + expect(inherited?.ink?.series?.single).toBe('#6b3fa0'); + expect(inherited?.ink?.surface).toEqual(original.ink?.surface); + expect(inherited?.structure?.grid?.measure).toBe('omit'); + expect(inherited?.structure?.grid?.category).toBe(original.structure?.grid?.category); + expect(original.ink?.series?.single).not.toBe('#6b3fa0'); + }); + + it('replaces arrays rather than inventing a merged palette', () => { + const categorical = ['#111111', '#eeeeee']; + const inherited = resolveThemeSpec({ + extends: 'nyt', + ink: { series: { categorical } }, + }); + expect(inherited?.ink?.series?.categorical).toEqual(categorical); + }); + + it('rejects an unknown inherited house', () => { + expect(() => resolveThemeSpec({ extends: 'the-guardian' })).toThrow(/Unknown theme/); + }); + + it('assembles an inherited house through the public input', () => { + const spec = build({ + extends: 'economist', + id: 'our-economist', + ink: { series: { single: '#6b3fa0' } }, + }); + expect(spec._theme?.id).toBe('our-economist'); + expect(spec._theme?.decisions?.series?.single).toBe('#6b3fa0'); + }); + + /** + * The guidance names a number of colours, and a number in prose drifts the + * moment the palette beside it changes. It is the same number, so it has to + * stay the same number — however the house chooses to word it. + */ + it('states a colour count the house actually declares', () => { + for (const preset of Object.values(THEME_PRESETS)) { + const line = preset.guidance.split('\n') + .find(l => /colour|key/i.test(l)); + const stated = line && /(\d+)/.exec(line); + expect(stated, `${preset.id} says nothing about colour`).toBeTruthy(); + // A ThemeSpec may state no ink at all (the neutral house), but a + // *shipped* house that names a colour count must declare it. + const ink = preset.spec.ink; + expect(ink, `${preset.id} declares no ink`).toBeDefined(); + const series = ink!.series as any; + const declared = preset.spec.legend?.maxSwatches + ?? (series.categorical as string[]).length; + expect(Number(stated![1]), `${preset.id}`).toBe(declared); + } + }); + + it('keeps the Economist zero rule structural rather than accent red', () => { + const ink = THEME_PRESETS.economist.spec.ink!; + expect(ink.structure?.zero).toBe(ink.structure?.axis); + expect(ink.structure?.zero).not.toBe(ink.accent); + }); +}); + +describe('cartoon mark character', () => { + function scatter(count: number) { + const values = Array.from({ length: count }, (_, i) => ({ + X: i % 100, + Y: (i * 37) % 101, + })); + return assembleVegaLite({ + data: { values }, + semantic_types: { X: 'Quantity', Y: 'Quantity' }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: 'X', y: 'Y' }, + baseSize: { width: 380, height: 320 }, + }, + theme_spec: THEME_PRESETS.cartoon.spec, + } as any) as any; + } + + function connected(themeSpec: ThemeSpec, count: number) { + const values = Array.from({ length: count }, (_, i) => ({ + Step: i, + X: 50 + Math.sin(i / 4) * 20, + Y: 50 + Math.cos(i / 5) * 20, + })); + return assembleVegaLite({ + data: { values }, + semantic_types: { Step: 'Order', X: 'Quantity', Y: 'Quantity' }, + chart_spec: { + chartType: 'Connected Scatter Plot', + encodings: { x: 'X', y: 'Y', order: 'Step' }, + }, + theme_spec: themeSpec, + } as any) as any; + } + + it('puts the dark sticker edge around filled points', () => { + const spec = scatter(12); + expect(spec.config.point.stroke).toBe('#2e2b28'); + expect(spec.config.point.strokeWidth).toBe(2.5); + expect(dotSize(spec)).toBe(170); + + const line = build(THEME_PRESETS.cartoon.spec); + expect(line.config.line.point.stroke).toBe('#2e2b28'); + expect(line.config.line.point.strokeWidth).toBe(2.5); + }); + + it('uses the lab weight and breathing room for chart furniture', () => { + const spec = scatter(12); + for (const axis of [spec.config.axisX, spec.config.axisY]) { + expect(axis.domainWidth).toBe(2.5); + expect(axis.labelPadding).toBe(7); + } + expect(spec.config.axisX.gridWidth).toBe(0); + expect(spec.config.axisY.gridWidth).toBe(1.5); + }); + + it('shrinks a dense point cloud without flattening sparse dots', () => { + const dense = scatter(500); + expect(dotSize(dense)).toBeLessThan(170); + expect(dotSize(dense)).toBeGreaterThanOrEqual(20); + expect(dense.config.point.strokeWidth).toBeLessThan(2.5); + expect(JSON.stringify(dense._theme?.report ?? [])).toContain('cover too much'); + }); + + it('leaves area axis geometry to Vega-Lite', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { Year: 2020, Region: 'A', Value: 10 }, + { Year: 2021, Region: 'A', Value: 14 }, + { Year: 2020, Region: 'B', Value: 5 }, + { Year: 2021, Region: 'B', Value: 8 }, + ], + }, + semantic_types: { Year: 'Year', Region: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'Year', y: 'Value', color: 'Region' }, + chartProperties: { stackMode: 'stack' }, + }, + theme_spec: THEME_PRESETS.cartoon.spec, + } as any) as any; + const spine = spec.layer?.find((layer: any) => + layer.__themeSynthetic && markTypeOf(layer.mark) === 'rule' && layer.encoding?.x?.value === 0); + expect(spine).toBeUndefined(); + expect(JSON.stringify(spec._theme?.report ?? [])).not.toContain('closing edge'); + }); + + it('lifts the band axis over surface-stroked bars instead of redrawing it', () => { + const spec = assembleVegaLite({ + data: { values: [{ Group: 'A', Value: 10 }, { Group: 'B', Value: 14 }] }, + semantic_types: { Group: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Value', y: 'Group' }, + }, + theme_spec: THEME_PRESETS.swiss.spec, + } as any) as any; + + // No invented geometry: the old fix appended a rule at the measure's + // zero, which sat a hair off the real domain and doubled it. + const baseline = spec.layer?.find((layer: any) => + markTypeOf(layer.mark) === 'rule' && + layer.encoding?.x?.datum === 0 && + layer.encoding?.y === null); + expect(baseline).toBeUndefined(); + + // The band axis Vega already draws is simply drawn last. + const bar = spec.layer?.find((l: any) => markTypeOf(l.mark) === 'bar'); + expect(bar.mark.stroke).toBe('#f4f1ea'); + expect(bar.encoding.y.axis.zindex).toBe(1); + // The measure axis carries the grid, so it must stay behind the bars. + expect(bar.encoding.x.axis?.zindex).toBeUndefined(); + expect(JSON.stringify(spec._theme?.report ?? [])).toContain('band axis is drawn over the bars'); + }); + + it('leaves the band axis alone for a house that does not stroke its bars', () => { + const spec = assembleVegaLite({ + data: { values: [{ Group: 'A', Value: 10 }, { Group: 'B', Value: 14 }] }, + semantic_types: { Group: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Value', y: 'Group' }, + }, + theme_spec: THEME_PRESETS.economist.spec, + } as any) as any; + const bar = spec.layer?.find((l: any) => markTypeOf(l.mark) === 'bar') ?? spec; + expect(bar.encoding?.y?.axis?.zindex).toBeUndefined(); + }); + + it('holds the sticker corner to a share of the bar it rounds', () => { + const bars = (count: number) => assembleVegaLite({ + data: { + values: Array.from({ length: count }, (_, i) => ({ + G: `Cat${i}`, V: 100 + ((i * 37) % 400), + })), + }, + semantic_types: { G: 'Category', V: 'Quantity' }, + chart_spec: { chartType: 'Bar Chart', encodings: { x: 'G', y: 'V' } }, + theme_spec: THEME_PRESETS.cartoon.spec, + } as any) as any; + + const barMark = (spec: any) => + (spec.layer ?? [spec]).find((l: any) => markTypeOf(l.mark) === 'bar')?.mark; + + // Wide bars have room for the house's full roundness. + expect(bars(5).config.bar.cornerRadiusEnd).toBe(10); + expect(barMark(bars(5))?.cornerRadiusEnd).toBeUndefined(); + + // Thin bars keep the same *fraction* instead of being rounded away. + const thin = bars(60); + const radius = barMark(thin)?.cornerRadiusEnd; + expect(radius).toBeLessThan(10); + expect(radius).toBeGreaterThan(0); + expect(JSON.stringify(thin._theme?.report ?? [])).toContain('round the bar away'); + }); + + it('keeps a crowded trajectory in the lab dot-to-line proportion', () => { + const diameter = (size: number) => 2 * Math.sqrt(size / Math.PI); + const ratioOf = (spec: any) => { + const mark = markOf(spec); + return diameter(mark.point.size) / (mark.strokeWidth ?? spec.config.line.strokeWidth); + }; + + const sparse = connected(THEME_PRESETS.cartoon.spec, 8); + // Untouched, the house's own bead: 170px² on a 5px line. + expect(markOf(sparse).point.size).toBe(170); + expect(markOf(sparse).strokeWidth).toBeUndefined(); + + // The dot and the line shrink together, so the proportion the house + // authored survives the crowding rather than fattening with it. + for (const count of [20, 35, 55]) { + const spec = connected(THEME_PRESETS.cartoon.spec, count); + expect(markOf(spec).point.size).toBeLessThan(170); + expect(markOf(spec).strokeWidth).toBeLessThan(5); + expect(ratioOf(spec)).toBeCloseTo(ratioOf(sparse), 1); + } + }); + + it('keeps connected dots in the line ink and scales a crowded cartoon path', () => { + const swiss = connected(THEME_PRESETS.swiss.spec, 55); + const swissMark = markOf(swiss); + expect(swissMark.point.color).toBe(swissMark.color); + expect(swissMark.strokeWidth).toBeUndefined(); + expect(swiss.config.line.strokeWidth).toBe(3); + + const cartoon = connected(THEME_PRESETS.cartoon.spec, 55); + const cartoonMark = markOf(cartoon); + expect(cartoonMark.point.color).toBe(cartoonMark.color); + expect(cartoonMark.point.size).toBeLessThan(170); + expect(cartoonMark.point.strokeWidth).toBeLessThan(2.5); + expect(cartoonMark.strokeWidth).toBeLessThan(5); + expect(JSON.stringify(cartoon._theme?.report ?? [])).toContain('same bead on the same string'); + }); +}); + +describe('theme chartDefaults', () => { + it('applies a house option the caller did not state', () => { + const spec = build(theme({ chartDefaults: { 'Line Chart': { showPoints: true } } })); + expect(markOf(spec).point).toBeTruthy(); + }); + + it('yields to the same option stated in the chart spec', () => { + const spec = build( + theme({ chartDefaults: { 'Line Chart': { showPoints: true } } }), + { showPoints: false }, + ); + expect(markOf(spec).point).toBeFalsy(); + expect(JSON.stringify(spec._theme?.report ?? [])).toContain('showPoints'); + }); + + it('drops an option the chart type does not declare, and says so', () => { + const spec = build(theme({ chartDefaults: { '*': { notAnOption: 3 } } })); + expect(JSON.stringify(spec._theme?.report ?? [])).toContain('notAnOption'); + }); +}); + +describe('theme compileDefaults', () => { + it('supplies a base size the chart spec left open', () => { + const wide = build(theme({ compileDefaults: { baseSize: { width: 640, height: 400 } } })); + const narrow = build(theme({ compileDefaults: { baseSize: { width: 300, height: 400 } } })); + expect(wide._width).toBeGreaterThan(narrow._width); + }); + + it('yields to a base size the chart spec states', () => { + const stated = (themeSpec: ThemeSpec) => assembleVegaLite({ + data: { values: DATA }, + semantic_types: { Year: 'Year', Sales: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'Year', y: 'Sales' }, + baseSize: { width: 300, height: 200 }, + }, + theme_spec: themeSpec, + } as any) as any; + const housePrefersWide = stated(theme({ compileDefaults: { baseSize: { width: 900, height: 700 } } })); + const houseSaysNothing = stated(theme({})); + expect(housePrefersWide._width).toBe(houseSaysNothing._width); + }); +}); + +describe('theme annotation.unitsInAxisTitle', () => { + const withUnit = (themeSpec: ThemeSpec) => assembleVegaLite({ + data: { values: DATA }, + semantic_types: { + Year: 'Year', + Sales: { semanticType: 'Amount', unit: 'kg' }, + }, + chart_spec: { chartType: 'Line Chart', encodings: { x: 'Year', y: 'Sales' } }, + theme_spec: themeSpec, + } as any) as any; + + function yTitle(spec: any): unknown { + const node = spec.layer?.find((l: any) => l.encoding?.y) ?? spec; + return node.encoding?.y?.axis?.title; + } + + it('states the unit in the title where the house keeps titles', () => { + const spec = withUnit(theme({ + annotation: { axisTitles: 'always', unitsInAxisTitle: true }, + })); + expect(yTitle(spec)).toBe('Sales (kg)'); + }); + + it('leaves the title alone where the house has no such rule', () => { + const spec = withUnit(theme({ annotation: { axisTitles: 'always' } })); + expect(yTitle(spec)).not.toBe('Sales (kg)'); + }); +}); + +describe('theme annotation.pointEmphasis', () => { + const emphasised = (spec: any) => (spec.layer ?? []).filter( + (l: any) => l.__themeSynthetic && markTypeOf(l.mark) === 'point', + ); + + it('dots the latest reading on a line', () => { + const spec = build(theme({ annotation: { pointEmphasis: 'latest' } })); + const dots = emphasised(spec); + expect(dots).toHaveLength(1); + expect(JSON.stringify(dots[0].transform)).toContain('row_number'); + }); + + it('says nothing where the line already shows every point', () => { + const spec = build( + theme({ annotation: { pointEmphasis: 'latest' } }), + { showPoints: true }, + ); + expect(emphasised(spec)).toHaveLength(0); + expect(JSON.stringify(spec._theme?.report ?? [])).toContain('already shows every observation'); + }); +}); + +describe('a printed value carries the unit no axis can', () => { + const SHARES = [ + { Browser: 'Chrome', Share: 65 }, + { Browser: 'Safari', Share: 20 }, + { Browser: 'Edge', Share: 10 }, + { Browser: 'Other', Share: 5 }, + ]; + const pie = (extra: Partial) => assembleVegaLite({ + data: { values: SHARES }, + semantic_types: { Browser: 'Category', Share: 'Quantity' }, + chart_spec: { chartType: 'Pie Chart', encodings: { size: 'Share', color: 'Browser' } }, + theme_spec: theme({ + dataLabels: { show: 'always', placement: 'atMark', inkMode: 'fixed' }, + ...extra, + }), + } as any) as any; + + function labelText(spec: any): any { + const walk = (node: any, owner: any): any => { + if (!node || typeof node !== 'object') return undefined; + if (markTypeOf(node.mark) === 'text') return { text: node.encoding?.text, transform: owner?.transform }; + for (const key of ['layer', 'vconcat', 'hconcat', 'concat'] as const) { + for (const child of node[key] ?? []) { + const found = walk(child, node); + if (found) return found; + } + } + return node.spec ? walk(node.spec, node.spec) : undefined; + }; + return walk(spec, spec) ?? {}; + } + + it('appends the unit to each slice value', () => { + const spec = pie({ annotation: { unit: 'lastTick' } }); + const { text, transform } = labelText(spec); + expect(text?.field).toBe('__flintValueWithUnit'); + expect(JSON.stringify(transform)).toContain('%'); + }); + + it('carries the share % even where the house states no unit', () => { + // A pie slice summing to 100 *is* a percentage — the % is the number's + // meaning, not a house flourish, and the pie has no axis to hold it. So + // it rides on the value whatever the house's axis-unit policy. + const { text, transform } = labelText(pie({})); + expect(text?.field).toBe('__flintValueWithUnit'); + expect(JSON.stringify(transform)).toContain('%'); + }); + + it('leaves raw amounts bare — a pie that is not shares keeps its numbers', () => { + const AMOUNTS = [ + { Browser: 'Chrome', Share: 650 }, + { Browser: 'Safari', Share: 200 }, + { Browser: 'Edge', Share: 100 }, + { Browser: 'Other', Share: 50 }, + ]; + const spec = assembleVegaLite({ + data: { values: AMOUNTS }, + semantic_types: { Browser: 'Category', Share: 'Quantity' }, + chart_spec: { chartType: 'Pie Chart', encodings: { size: 'Share', color: 'Browser' } }, + theme_spec: theme({ + dataLabels: { show: 'always', placement: 'atMark', inkMode: 'fixed' }, + }), + } as any) as any; + const { text } = labelText(spec); + expect(text?.field).toBe('Share'); + }); +}); + +describe('a cell in a grid is a position', () => { + const GRID = [ + { City: 'Cairo', Month: 'Jan', Temp: 14 }, + { City: 'Cairo', Month: 'Feb', Temp: 16 }, + { City: 'Oslo', Month: 'Jan', Temp: -4 }, + { City: 'Oslo', Month: 'Feb', Temp: -2 }, + ]; + const heatmap = (themeSpec: ThemeSpec) => assembleVegaLite({ + data: { values: GRID }, + semantic_types: { City: 'Category', Month: 'Category', Temp: 'Quantity' }, + chart_spec: { chartType: 'Heatmap', encodings: { x: 'Month', y: 'City', color: 'Temp' } }, + theme_spec: themeSpec, + } as any) as any; + + it('prints the value in the cell, in ink that follows the ramp', () => { + const spec = heatmap(theme({ + dataLabels: { show: 'always', placement: 'atMark', inkMode: 'contrastWithMark' }, + ink: { + surface: { canvas: '#ffffff' }, + series: { single: '#333333', sequential: { stops: ['#eef3f8', '#051c2c'] } }, + }, + } as Partial)); + const label = (spec.layer ?? []).find( + (l: any) => l.__themeSynthetic && markTypeOf(l.mark) === 'text', + ); + expect(label).toBeTruthy(); + expect(label.encoding.text.field).toBe('Temp'); + expect(label.mark.baseline).toBe('middle'); + expect(label.encoding.color.condition?.test).toContain('Temp'); + }); +}); + +/** + * A wedge sits in no band, so the gap between two of them has to come out of + * the shapes themselves. The house says how wide, and says it separately from + * how it rules its bars — a half-pixel hairline that keeps two stacked + * segments apart disappears entirely on a circle. + */ +describe('holding wedges apart', () => { + const SHARE = [ + { Browser: 'Chrome', Share: 65 }, + { Browser: 'Safari', Share: 12 }, + { Browser: 'Edge', Share: 12 }, + { Browser: 'Firefox', Share: 6 }, + { Browser: 'Other', Share: 5 }, + ]; + const pie = (themeSpec: ThemeSpec, innerRadius = 0) => assembleVegaLite({ + data: { values: SHARE }, + semantic_types: { Browser: 'Category', Share: 'Percentage' }, + chart_spec: { + chartType: innerRadius ? 'Donut Chart' : 'Pie Chart', + encodings: { size: 'Share', color: 'Browser' }, + ...(innerRadius ? { chartProperties: { innerRadius } } : {}), + }, + theme_spec: themeSpec, + } as any) as any; + + const arcOf = (spec: any): any => { + let found: any; + JSON.stringify(spec, (_k, v) => { + if (!found && v && (v.type === 'arc' || v === 'arc')) found = v; + return v; + }); + return found; + }; + + it('cuts the wedges with a rule in the surface', () => { + const spec = pie(theme({ + marks: { slice: { gap: 1.5 } }, + } as Partial)); + const arc = arcOf(spec); + expect(arc.strokeWidth).toBe(1.5); + expect(arc.stroke).toBe('#ffffff'); + }); + + it('holds a donut apart the same way', () => { + const arc = arcOf(pie(theme({ marks: { slice: { gap: 2 } } } as Partial), 50)); + expect(arc.strokeWidth).toBe(2); + }); + + /** + * A house that says nothing about wedges is not silent: it has already + * said how it holds adjoining marks apart. + */ + it('falls back to the way the house rules its bars', () => { + const arc = arcOf(pie(theme({ + marks: { separator: { presence: 'hairline', source: 'surface', width: 1 } }, + } as Partial))); + expect(arc.strokeWidth).toBe(1); + }); + + it('leaves the wedges flush when the house holds nothing apart', () => { + const arc = arcOf(pie(theme({}))); + expect(arc.strokeWidth).toBeUndefined(); + }); + + it('swings the wedges apart when the house asks for an angle', () => { + const spec = pie(theme({ + marks: { slice: { gap: 4, gapStyle: 'pad' } }, + } as Partial)); + const arc = arcOf(spec); + expect(arc.padAngle).toBeGreaterThan(0); + expect(arc.stroke).toBeUndefined(); + // 5 wedges at 4px is well inside what the circumference can give. + expect(arc.padAngle).toBeLessThan((2 * Math.PI * 0.15) / 5); + }); + + /** + * Past a point the gaps are the chart. The house's width is honoured until + * the pads would take a sixth of the circle between them. + */ + it('holds the angle where the wedges would run out', () => { + const many = Array.from({ length: 40 }, (_, i) => ({ Browser: `B${i}`, Share: 1 })); + const spec = assembleVegaLite({ + data: { values: many }, + semantic_types: { Browser: 'Category', Share: 'Percentage' }, + chart_spec: { chartType: 'Pie Chart', encodings: { size: 'Share', color: 'Browser' } }, + theme_spec: theme({ marks: { slice: { gap: 8, gapStyle: 'pad' } } } as Partial), + } as any) as any; + const arc = arcOf(spec); + expect(arc.padAngle).toBeCloseTo((2 * Math.PI * 0.15) / 40, 6); + expect(spec._theme.report.some((r: any) => /held at/.test(r.message))).toBe(true); + }); +}); + +/** + * Two things a house says that nothing used to read: how much ink a connector + * deserves, and how far a label sits from the mark it names. + */ +describe('stems and the labels above them', () => { + const CO2 = [ + { Country: 'Qatar', Tonnes: 37 }, + { Country: 'UAE', Tonnes: 22 }, + { Country: 'India', Tonnes: 2 }, + ]; + const lollipop = (themeSpec: ThemeSpec) => assembleVegaLite({ + data: { values: CO2 }, + semantic_types: { Country: 'Country', Tonnes: 'Quantity' }, + chart_spec: { chartType: 'Lollipop Chart', encodings: { x: 'Country', y: 'Tonnes' } }, + theme_spec: themeSpec, + } as any) as any; + + const layerWith = (spec: any, type: string): any => { + let found: any; + walkSpec(spec, (n) => { + if (!found && n.mark && (n.mark.type ?? n.mark) === type) found = n; + }); + return found; + }; + function walkSpec(node: any, visit: (n: any) => void): void { + if (!node || typeof node !== 'object') return; + visit(node); + for (const key of ['layer', 'hconcat', 'vconcat', 'concat']) { + for (const child of node[key] ?? []) walkSpec(child, visit); + } + if (node.spec) walkSpec(node.spec, visit); + } + + /** + * The stem states a distance whose two ends are already drawn, so it is + * structure, not a series. + */ + it('demotes the stem to the house\'s structural hairline', () => { + const spec = lollipop(theme({ + marks: { connector: { presence: 'hairline', weight: 1 } }, + ink: { + surface: { canvas: '#ffffff' }, + series: { single: '#18a1cd' }, + structure: { rule: '#333333' }, + }, + } as Partial)); + const stem = layerWith(spec, 'rule'); + expect(stem.mark.strokeWidth).toBe(1); + expect(stem.mark.color).toBeTruthy(); + expect(stem.mark.color).not.toBe('#18a1cd'); + }); + + /** + * The offset is measured from the anchor, and a dot's anchor is its + * centre: the gap has to clear the radius before it is a gap at all. + */ + it('clears the dot before it starts counting the gap', () => { + const spec = lollipop(theme({ + dataLabels: { show: 'always', placement: 'outsideMark' }, + } as Partial)); + const label = layerWith(spec, 'text'); + const dot = layerWith(spec, 'circle') ?? layerWith(spec, 'point'); + const radius = Math.round(Math.sqrt(dot.mark.size / Math.PI)); + expect(radius).toBeGreaterThan(0); + expect(label.mark.dy).toBe(-(4 + radius)); + }); +}); + +describe('what a connector and a fit are allowed to say', () => { + /** + * A gridline is read through and sits at the bottom of the ordinal. A + * connector is read as part of the mark, so a house whose rules are + * already pale needs somewhere else to scale from. + */ + it('scales the connector from its own ink where the house states one', () => { + const spec = assembleVegaLite({ + data: { values: [{ Country: 'Qatar', Tonnes: 37 }, { Country: 'India', Tonnes: 2 }] }, + semantic_types: { Country: 'Country', Tonnes: 'Quantity' }, + chart_spec: { chartType: 'Lollipop Chart', encodings: { x: 'Country', y: 'Tonnes' } }, + theme_spec: theme({ + marks: { connector: { presence: 'full', weight: 1, style: 'dashed' } }, + ink: { + surface: { canvas: '#ffffff' }, + series: { single: '#18a1cd' }, + structure: { rule: '#dcdcdc', connector: '#c8c8c8' }, + }, + } as Partial), + } as any) as any; + let stem: any; + JSON.stringify(spec, (_k, v) => { + if (!stem && v?.type === 'rule') stem = v; + return v; + }); + expect(stem.color).toBe('#c8c8c8'); + expect(stem.strokeDash).toEqual([4, 3]); + }); + + /** + * A point on a line marks a reading. A fitted line has none: its vertices + * are where the fit was sampled. + */ + it('keeps the house\'s vertex points off a fitted line', () => { + const rows = Array.from({ length: 12 }, (_, i) => ({ HP: 40 + i * 15, MPG: 40 - i * 2 })); + const spec = assembleVegaLite({ + data: { values: rows }, + semantic_types: { HP: 'Quantity', MPG: 'Quantity' }, + chart_spec: { + chartType: 'Regression', + encodings: { x: 'HP', y: 'MPG' }, + chartProperties: { regressionMethod: 'linear' }, + }, + theme_spec: theme({ + marks: { point: { presence: 'full', size: 26 } }, + } as Partial), + } as any) as any; + const fit = (spec.layer ?? []).find( + (l: any) => (l.transform ?? []).some((t: any) => t.regression || t.loess), + ); + expect(fit).toBeTruthy(); + expect(fit.mark.point).toBe(false); + expect(spec.config.line.point).toBeTruthy(); + }); +}); + +/** + * Two dots and two connectors that a single number would get wrong. A vertex + * marker is found against a line the eye is already on; a scatter's dot is + * alone on the plot. A stem repeats a position already plotted; a bridge draws + * a distance plotted nowhere else. + */ +describe('what a connector joins, and what a dot has to carry alone', () => { + const PAIRS = [ + { Country: 'Japan', Sex: 'Male', Years: 81.5 }, { Country: 'Japan', Sex: 'Female', Years: 87.6 }, + { Country: 'Brazil', Sex: 'Male', Years: 69.0 }, { Country: 'Brazil', Sex: 'Female', Years: 76.0 }, + { Country: 'Nigeria', Sex: 'Male', Years: 51.0 }, { Country: 'Nigeria', Sex: 'Female', Years: 54.0 }, + ]; + + const marks = (spec: any): any[] => { + const out: any[] = []; + JSON.stringify(spec, (_k, v) => { + if (v && typeof v === 'object' && typeof v.type === 'string' && v.type !== 'quantitative') out.push(v); + return v; + }); + return out; + }; + + const dumbbell = (themeExtra: Partial): any => assembleVegaLite({ + data: { values: PAIRS }, + semantic_types: { Country: 'Category', Sex: 'Category', Years: 'Quantity' }, + chart_spec: { chartType: 'Ranged Dot Plot', encodings: { y: 'Country', x: 'Years', color: 'Sex' } }, + theme_spec: theme(themeExtra as any), + } as any) as any; + + it('draws the bridge at a mark\'s weight, not the stem\'s', () => { + const spec = dumbbell({ + marks: { + strokeWeight: 2, + connector: { presence: 'full', weight: 0.8, spanWeight: 3 }, + }, + ink: { surface: { canvas: '#ffffff' }, structure: { rule: '#d3dce1' } }, + } as Partial); + const bridge = marks(spec).find((m: any) => m.type === 'line'); + expect(bridge.strokeWidth).toBe(3); + // …and in structure's ink, not the ink of either dot it joins. + expect(bridge.color).toBe('#d3dce1'); + }); + + it('gives a silent house a bridge of its own line weight', () => { + const spec = dumbbell({ + marks: { strokeWeight: 1.6, connector: { presence: 'full', weight: 0.8 } }, + ink: { surface: { canvas: '#ffffff' }, structure: { rule: '#d3dce1' } }, + } as Partial); + expect(marks(spec).find((m: any) => m.type === 'line').strokeWidth).toBe(1.6); + }); + + it('sizes a dot the same wherever one is drawn', () => { + const rows = Array.from({ length: 12 }, (_, i) => ({ HP: 40 + i * 15, MPG: 40 - i * 2, Kind: `K${i % 3}` })); + const houseSpec = theme({ + marks: { point: { presence: 'full', size: 45 } }, + } as Partial); + const at = (encodings: Record) => assembleVegaLite({ + data: { values: rows }, + semantic_types: { HP: 'Quantity', MPG: 'Quantity', Kind: 'Category' }, + chart_spec: { chartType: 'Scatter Plot', encodings }, + theme_spec: houseSpec, + } as any) as any; + + // The plain scatter is drawn as `circle`, which has its own config + // block — sizing `point` alone would have missed it entirely. + const plain = at({ x: 'HP', y: 'MPG' }); + expect(markTypeOf(plain.mark)).toBe('circle'); + expect(dotSize(plain)).toBe(45); + + // A shape encoding promotes the same scatter to the `point` mark. It + // takes the house's size too — but from the mark, because + // `config.point` is shared with the symbol Vega-Lite draws for a + // line's vertices, whose size is a question about spacing. + const shaped = at({ x: 'HP', y: 'MPG', shape: { field: 'Kind' } }); + expect(markTypeOf(shaped.mark)).toBe('point'); + expect(dotSize(shaped)).toBe(45); + + expect(plain.config.line.point.size).toBe(45); + }); +}); + +describe('pop positional structure', () => { + it('makes the indexing grid secondary to the measure grid', () => { + const spec = assembleVegaLite({ + data: { values: [{ X: 1, Y: 3 }, { X: 2, Y: 5 }, { X: 3, Y: 4 }] }, + semantic_types: { X: 'Quantity', Y: 'Quantity' }, + chart_spec: { chartType: 'Scatter Plot', encodings: { x: 'X', y: 'Y' } }, + theme_spec: 'pop', + } as any) as any; + + expect(spec.config.axisX.grid).toBe(true); + expect(spec.config.axisX.gridWidth).toBe(0.5); + expect(spec.config.axisY.grid).toBe(true); + expect(spec.config.axisY.gridWidth).toBe(1); + for (const axis of [spec.config.axisX, spec.config.axisY]) { + expect(axis.gridColor).not.toBe('#111111'); + expect(axis.gridColor).not.toBe('#fff200'); + } + }); + + it('uses readable 1/2/5 spacing on a wide log scale', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Income: 1_000, Years: 55 }, + { Income: 5_000, Years: 68 }, + { Income: 20_000, Years: 76 }, + { Income: 100_000, Years: 82 }, + ] }, + semantic_types: { Income: 'Quantity', Years: 'Quantity' }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: 'Income', y: 'Years' }, + baseSize: { width: 720, height: 520 }, + chartProperties: { logScale_x: true }, + }, + theme_spec: 'pop', + } as any) as any; + + expect(spec.encoding.x.scale.type).toBe('log'); + expect(spec.encoding.x.axis.values).toEqual(expect.arrayContaining([ + 1_000, 2_000, 5_000, 10_000, 20_000, 50_000, 100_000, + ])); + }); + + it('hides both guides on a two-position slope axis', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Country: 'A', Year: 2000, Life: 62 }, + { Country: 'A', Year: 2021, Life: 67 }, + { Country: 'B', Year: 2000, Life: 72 }, + { Country: 'B', Year: 2021, Life: 78 }, + ] }, + semantic_types: { Country: 'Category', Year: 'Year', Life: 'Quantity' }, + chart_spec: { + chartType: 'Slope Chart', + encodings: { x: 'Year', y: 'Life', color: 'Country' }, + }, + theme_spec: 'pop', + } as any) as any; + + expect(spec.config.axisX.grid).toBe(false); + expect(spec.config.axisX.gridWidth).toBe(0); + expect(spec.config.axisY.grid).toBe(true); + }); + + it('outlines observed heatmap cells without restoring axis grids', () => { + const spec = assembleVegaLite({ + data: { values: [ + { City: 'Cairo', Month: '2025-01', Temp: 14 }, + { City: 'Cairo', Month: '2025-02', Temp: 15 }, + { City: 'Moscow', Month: '2025-01', Temp: -9 }, + { City: 'Moscow', Month: '2025-02', Temp: -7 }, + ] }, + semantic_types: { City: 'Category', Month: 'YearMonth', Temp: 'Quantity' }, + chart_spec: { chartType: 'Heatmap', encodings: { x: 'Month', y: 'City', color: 'Temp' } }, + theme_spec: 'pop', + } as any) as any; + let cell: any; + JSON.stringify(spec, (_key, value) => { + if (!cell && value?.type === 'rect') cell = value; + return value; + }); + + expect(cell.stroke).not.toBe('#fff200'); + expect(cell.stroke).not.toBe('#111111'); + expect(cell.strokeWidth).toBe(1); + expect(spec.config.axisX.grid).toBe(false); + expect(spec.config.axisY.grid).toBe(false); + }); +}); + +/** + * A cell adjoins on both axes and its fill is the reading, so how far apart + * cells stand is not the same question as how far apart bars stand — and it is + * a real difference between houses: flush cells read as a continuous field, + * cut cells as a table of separate readings. + */ +describe('holding cells apart', () => { + const GRID = [ + { City: 'Cairo', Month: 'Jan', Temp: 14 }, { City: 'Cairo', Month: 'Feb', Temp: 15 }, + { City: 'Moscow', Month: 'Jan', Temp: -9 }, { City: 'Moscow', Month: 'Feb', Temp: -7 }, + ]; + + const heatmap = (themeExtra: Partial): any => assembleVegaLite({ + data: { values: GRID }, + semantic_types: { City: 'Category', Month: 'Category', Temp: 'Quantity' }, + chart_spec: { chartType: 'Heatmap', encodings: { x: 'Month', y: 'City', color: 'Temp' } }, + theme_spec: theme(themeExtra as any), + } as any) as any; + + const cell = (spec: any): any => { + let found: any; + JSON.stringify(spec, (_k, v) => { + if (!found && v?.type === 'rect') found = v; + return v; + }); + return found; + }; + + it('cuts the cells apart in the surface, not in an ink the scale never named', () => { + const spec = heatmap({ + marks: { tile: { gap: 1 } }, + ink: { surface: { canvas: '#ffffff' } }, + } as Partial); + expect(cell(spec).strokeWidth).toBe(1); + expect(cell(spec).stroke).toBe('#ffffff'); + }); + + it('leaves the field continuous where the house cuts nothing', () => { + const spec = heatmap({ ink: { surface: { canvas: '#ffffff' } } } as Partial); + // Untouched, the mark is still the bare string the template wrote. + expect(cell(spec)?.strokeWidth).toBeUndefined(); + }); + + it('holds cells apart the way it holds bars apart when it says nothing', () => { + const spec = heatmap({ + marks: { separator: { presence: 'hairline', source: 'surface', width: 1.5 } }, + ink: { surface: { canvas: '#ffffff' } }, + } as Partial); + expect(cell(spec).strokeWidth).toBe(1.5); + }); +}); + +/** + * A size or opacity encoding is a *value* key, not a series-name key. A bubble + * map has no colour series — its whole legend is the size scale — so the + * "no series legend" decision must not sweep the size key away with it. + */ +describe('keeping a value key when there is no series legend', () => { + const CITIES = [ + { City: 'Tokyo', lon: 139, lat: 35, Pop: 37 }, + { City: 'Delhi', lon: 77, lat: 28, Pop: 32 }, + { City: 'Cairo', lon: 31, lat: 30, Pop: 22 }, + ]; + const bubble = (themeSpec: ThemeSpec) => assembleVegaLite({ + data: { values: CITIES }, + semantic_types: { City: 'Category', lon: 'Longitude', lat: 'Latitude', Pop: 'Quantity' }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: 'lon', y: 'lat', size: 'Pop' }, + }, + theme_spec: themeSpec, + } as any) as any; + + function sizeEnc(spec: any): any { + let found: any; + walkSpec(spec, (n) => { + const e = n.encoding?.size; + if (e?.field === 'Pop') found = e; + }); + return found; + } + function walkSpec(node: any, visit: (n: any) => void): void { + if (!node || typeof node !== 'object') return; + visit(node); + for (const key of ['layer', 'concat', 'hconcat', 'vconcat', 'spec']) { + const child = (node as any)[key]; + if (Array.isArray(child)) child.forEach((c) => walkSpec(c, visit)); + else if (child) walkSpec(child, visit); + } + } + + it('does not null the size legend when the chart names no series', () => { + const spec = bubble(theme({ ink: { surface: { canvas: '#ffffff' } } } as Partial)); + const size = sizeEnc(spec); + expect(size).toBeTruthy(); + expect(size.legend).not.toBeNull(); + }); +}); + +/** + * A composed chart has no single plot. Anything that budgets a mark against + * the room it has must ask the view the mark is drawn in, not the outermost + * one, or a narrow panel is measured against a plot several times its size. + */ +describe('vertex dots are budgeted against the panel that holds them', () => { + const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const SPARK = MONTHS.flatMap((Month, i) => + ['Revenue', 'Users'].map((Metric) => ({ Metric, Month, Value: 10 + i + (Metric === 'Users' ? 5 : 0) })), + ); + + function walkScoped(node: any, outerW: number | undefined, visit: (n: any, w?: number) => void): void { + if (!node || typeof node !== 'object') return; + const w = typeof node.width === 'number' ? node.width : outerW; + visit(node, w); + for (const key of ['layer', 'concat', 'hconcat', 'vconcat']) { + if (Array.isArray(node[key])) node[key].forEach((c: any) => walkScoped(c, w, visit)); + } + if (node.spec) walkScoped(node.spec, w, visit); + if (node.facet?.spec) walkScoped(node.facet.spec, w, visit); + } + + /** Edge-to-edge ink of a dot: `size` is an area, and the stroke straddles. */ + function outerDiameter(size: number, strokeWidth: number): number { + return 2 * Math.sqrt(size / Math.PI) + strokeWidth; + } + + function sparkline(themeId: string): any { + return assembleVegaLite({ + data: { values: SPARK }, + semantic_types: { Metric: 'Category', Month: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Sparkline', + encodings: { x: 'Month', y: 'Value', color: 'Metric' }, + baseSize: { width: 300, height: 300 }, + }, + theme_spec: THEME_PRESETS[themeId].spec, + } as any) as any; + } + + it('shrinks the cartoon dot so it fits the gap between two readings', () => { + const spec = sparkline('cartoon'); + let checked = 0; + walkScoped(spec, spec._width, (node, width) => { + const mark = node.mark; + if (markTypeOf(mark) !== 'line' || typeof mark !== 'object') return; + const dot = mark.point ?? spec.config?.line?.point; + if (!dot || typeof dot !== 'object') return; + expect(typeof width).toBe('number'); + // One reading per month along this panel. + const spacing = (width as number) / MONTHS.length; + expect(outerDiameter(dot.size, dot.strokeWidth ?? 0)).toBeLessThanOrEqual(spacing); + checked++; + }); + expect(checked).toBeGreaterThan(0); + }); + + it('leaves the dot alone where the panel is wide enough to hold it', () => { + // The same house and the same twelve readings across a full-width plot + // keep the authored size — the rule reacts to room, not to count. + const spec = assembleVegaLite({ + data: { values: MONTHS.map((Month, i) => ({ Month, Value: 10 + i })) }, + semantic_types: { Month: 'Month', Value: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'Month', y: 'Value' }, + baseSize: { width: 600, height: 300 }, + }, + theme_spec: THEME_PRESETS['cartoon'].spec, + } as any) as any; + const authored = THEME_PRESETS['cartoon'].spec.marks?.point?.size; + expect(spec.config.line.point.size).toBe(authored); + let overridden = false; + walkScoped(spec, spec._width, (node) => { + const mark = node.mark; + if (markTypeOf(mark) !== 'line' || typeof mark !== 'object') return; + if (mark.point && typeof mark.point === 'object' && mark.point.size != null) overridden = true; + }); + expect(overridden).toBe(false); + }); +}); + +/** + * A KPI card is drawn entirely in pixels, so none of it reaches the theme + * through an encoding. Its progress bar is nonetheless the one part of the + * tile that states a measurement, and it has to carry the house's ink. + */ +describe('KPI card progress bar takes house ink', () => { + const KPIS = [ + { metric: 'Behind', value: 20, goal: 100 }, + { metric: 'On track', value: 70, goal: 100 }, + { metric: 'Exceeded', value: 130, goal: 100 }, + ]; + + function card(themeId: string): any { + return assembleVegaLite({ + data: { values: KPIS }, + semantic_types: { metric: 'Category', value: 'Quantity', goal: 'Quantity' }, + chart_spec: { + chartType: 'KPI Card', + encodings: { metric: 'metric', value: 'value', goal: 'goal' }, + baseSize: { width: 600, height: 240 }, + }, + theme_spec: THEME_PRESETS[themeId].spec, + } as any) as any; + } + + function bars(spec: any): Record { + const out: Record = {}; + const visit = (n: any): void => { + if (!n || typeof n !== 'object') return; + if (Array.isArray(n)) { n.forEach(visit); return; } + if (n.__themeRole && n.mark?.type === 'rect') out[n.__themeRole] = n.mark.fill; + for (const k of Object.keys(n)) visit(n[k]); + }; + visit(spec); + return out; + } + + for (const id of Object.keys(THEME_PRESETS)) { + it(`${id}: the in-progress bar is the house's own ink`, () => { + const spec = card(id); + const found = bars(spec); + expect(found.accent).toBe(THEME_PRESETS[id].spec.ink?.series?.single); + }); + + it(`${id}: met and missed stay distinguishable from in-progress`, () => { + const found = bars(card(id)); + expect(found.accent).toBeTruthy(); + expect(found.positive).toBeTruthy(); + expect(found.negative).toBeTruthy(); + // A verdict painted the same ink as "still going" says nothing. + expect(found.positive).not.toBe(found.accent); + expect(found.negative).not.toBe(found.accent); + expect(found.positive).not.toBe(found.negative); + }); + } +}); + +/** + * A dumbbell's connector is not a third series. It joins one row's two dots, + * so whichever dot's ink it borrows it credits that dot with the whole span — + * and, worse, the line leaving that dot becomes indistinguishable from the dot + * itself. The bridge has to be structural: quiet, and its own colour. + */ +describe('ranged dot connector', () => { + const ROWS = ['USA', 'China', 'Japan', 'Germany'].flatMap((c, i) => [ + { Country: c, Value: 30 + i * 5, Metric: 'Min' }, + { Country: c, Value: 70 + i * 5, Metric: 'Max' }, + ]); + + function dumbbell(themeId: string): any { + return assembleVegaLite({ + data: { values: ROWS }, + semantic_types: { Country: 'Country', Value: 'Quantity', Metric: 'Category' }, + chart_spec: { + chartType: 'Ranged Dot Plot', + encodings: { x: 'Value', y: 'Country', color: 'Metric' }, + baseSize: { width: 480, height: 320 }, + }, + theme_spec: THEME_PRESETS[themeId].spec, + } as any) as any; + } + + // A house may wrap the plot (datawrapper puts its key in a `vconcat`), so + // neither the bridge nor the dots are reliably a top-level layer. + function collect(spec: any): { bridge?: any; range: string[] } { + let bridge: any; + const range: string[] = []; + const visit = (n: any): void => { + if (!n || typeof n !== 'object') return; + if (Array.isArray(n)) { n.forEach(visit); return; } + if (n.mark && markTypeOf(n.mark) === 'line' && !bridge) bridge = n; + const r = n.encoding?.color?.scale?.range; + if (Array.isArray(r)) range.push(...r); + for (const k of Object.keys(n)) if (k !== 'data') visit(n[k]); + }; + visit(spec); + return { bridge, range }; + } + + for (const id of Object.keys(THEME_PRESETS)) { + it(`${id}: the bridge carries none of the dots' colour`, () => { + const { bridge, range } = collect(dumbbell(id)); + expect(bridge).toBeTruthy(); + expect(range.length).toBeGreaterThan(1); + + const ink = bridge.mark?.color; + expect(ink).toBeTruthy(); + for (const series of range) { + expect(String(ink).toLowerCase()).not.toBe(String(series).toLowerCase()); + } + }); + } +}); + +/** + * Three rules about where a chart's walls are, and what may paint over them. + */ +describe('plot edges', () => { + const STRIP = ['Control', 'Treatment A', 'Treatment B'].flatMap((g) => + [38, 55, 72, 88, 100].map((v) => ({ Group: g, Score: v + g.length }))); + + function strip(themeId: string): any { + return assembleVegaLite({ + data: { values: STRIP }, + semantic_types: { Group: 'Category', Score: 'Quantity' }, + chart_spec: { + chartType: 'Strip Plot', + encodings: { x: 'Group', y: 'Score' }, + baseSize: { width: 480, height: 320 }, + }, + theme_spec: THEME_PRESETS[themeId].spec, + } as any) as any; + } + + // A house that draws no domain at all is not making a point about zero. + const RULES_A_DOMAIN = Object.keys(THEME_PRESETS).filter((id) => { + const c = strip(id).config ?? {}; + return c.axisY?.domainColor && c.axisY.domainColor !== 'transparent'; + }); + + it('some house draws a domain, so the rule is actually under test', () => { + expect(RULES_A_DOMAIN.length).toBeGreaterThan(0); + }); + + for (const id of RULES_A_DOMAIN) { + it(`${id}: a strip plot keeps the wall under its categories`, () => { + // Nothing on a strip plot is measured from the bottom of the plot, + // so the line there is a wall, not a false zero. + expect(strip(id).config?.axisX?.domain).not.toBe(false); + }); + } + + for (const id of Object.keys(THEME_PRESETS)) { + it(`${id}: dots are given room to sit inside the axes`, () => { + const spec = strip(id); + const pads: number[] = []; + const visit = (n: any): void => { + if (!n || typeof n !== 'object') return; + if (Array.isArray(n)) { n.forEach(visit); return; } + const p = n.encoding?.y?.scale?.padding; + if (typeof p === 'number') pads.push(p); + for (const k of Object.keys(n)) if (k !== 'data') visit(n[k]); + }; + visit(spec); + expect(pads.length).toBeGreaterThan(0); + // Room enough for the radius of the dot the house draws. + expect(Math.min(...pads)).toBeGreaterThan(0); + }); + + it(`${id}: a grid never repaints the spine it lands on`, () => { + const spec = strip(id); + const cfg = spec.config ?? {}; + const gridded = (['x', 'y'] as const).filter((ch) => { + const c = cfg[ch === 'x' ? 'axisX' : 'axisY']; + return c?.grid && c.gridColor && c.gridColor !== 'transparent'; + }); + for (const ch of gridded) { + const other = cfg[ch === 'x' ? 'axisY' : 'axisX']; + const spine = other?.domain !== false && other?.domainColor + && other.domainColor !== 'transparent'; + if (!spine) continue; + let conditional = false; + const visit = (n: any): void => { + if (!n || typeof n !== 'object') return; + if (Array.isArray(n)) { n.forEach(visit); return; } + const g = n.encoding?.[ch]?.axis?.gridColor; + if (g && typeof g === 'object' && g.condition) conditional = true; + for (const k of Object.keys(n)) if (k !== 'data') visit(n[k]); + }; + visit(spec); + expect(conditional).toBe(true); + } + }); + } + + for (const id of RULES_A_DOMAIN) { + it(`${id}: a dumbbell keeps the wall beside its rows`, () => { + // Same narrowing, the other way round: the dumbbell's value scale + // floats and its rows are bands, but the dots are read against the + // ticks, not measured from the left-hand wall. + const spec: any = assembleVegaLite({ + data: { + values: ['USA', 'China', 'Japan'].flatMap((c, i) => [ + { Country: c, Value: 30 + i * 5, Metric: 'Min' }, + { Country: c, Value: 70 + i * 5, Metric: 'Max' }, + ]), + }, + semantic_types: { Country: 'Country', Value: 'Quantity', Metric: 'Category' }, + chart_spec: { + chartType: 'Ranged Dot Plot', + encodings: { x: 'Value', y: 'Country', color: 'Metric' }, + baseSize: { width: 480, height: 320 }, + }, + theme_spec: THEME_PRESETS[id].spec, + } as any); + expect(spec.config?.axisY?.domain).not.toBe(false); + }); + } +}); + +describe('group dividers', () => { + // A dashed rule between one group of boxes and the next is written as + // `bandPosition: 1` — the end of the band, which is the middle of the gap + // only while there is no gap. Every house opens one, so the divider has to + // move with it or it reads as the left-hand group's own right edge. + // Sparse on purpose: dividers are drawn only when the lanes are packed + // locally, which is what a house does when not every group has every level. + const rows = ['Eng', 'Sales', 'HR', 'Ops', 'Legal'].flatMap((dept, d) => + ['L1', 'L2', 'L3', 'L4', 'L5'].slice(d % 3, (d % 3) + 2).flatMap((level) => + [90, 100, 110, 120].map((v, i) => ({ Department: dept, Level: level, Comp: v * 1000 + i })), + ), + ); + let anyDrawn = false; + + for (const id of Object.keys(THEME_PRESETS)) { + it(`${id}: centres group dividers in the gap between bands`, () => { + const spec: any = assembleVegaLite({ + data: { values: rows }, + semantic_types: { Department: 'Category', Level: 'Category', Comp: 'Currency' }, + chart_spec: { + chartType: 'Boxplot', + encodings: { x: 'Department', y: 'Comp', color: 'Level' }, + baseSize: { width: 480, height: 320 }, + }, + theme_spec: THEME_PRESETS[id].spec, + } as any); + + let padding: number | undefined; + const positions: number[] = []; + const collect = (node: any): void => { + if (!node || typeof node !== 'object') return; + for (const channel of ['x', 'y'] as const) { + const e = node.encoding?.[channel]; + if (e?.field !== 'Department') continue; + if (typeof e.scale?.paddingInner === 'number') padding = e.scale.paddingInner; + if (typeof e.bandPosition === 'number' && !node.encoding[`${channel}2`]) { + positions.push(e.bandPosition); + } + } + for (const key of ['layer', 'vconcat', 'hconcat', 'concat']) { + if (Array.isArray(node[key])) node[key].forEach(collect); + } + if (node.spec) collect(node.spec); + if (node.facet?.spec) collect(node.facet.spec); + }; + collect(spec); + + if (positions.length === 0) return; // this house's layout drew no dividers + anyDrawn = true; + const p = padding ?? 0; + // Band start + this many band widths lands halfway across the gap. + const expected = 1 + p / (2 * (1 - p)); + for (const got of positions) expect(got).toBeCloseTo(expected, 6); + }); + } + + it('draws dividers at all', () => { + expect(anyDrawn).toBe(true); + }); +}); + +describe('legibility on the house surface', () => { + const luminance = (hex: string): number => { + const h = hex.replace('#', ''); + const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h; + const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255) + .map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; + }; + const contrast = (a: string, b: string): number => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); + }; + const surfaceOf = (spec: any): string => + spec.config?.view?.fill ?? spec.config?.background ?? spec.background ?? '#ffffff'; + + it('keeps every Power BI dark data colour legible on its plot and panel', () => { + const spec = THEME_PRESETS.powerbi.spec; + const surfaces = [spec.ink?.surface?.plot, spec.ink?.surface?.panel]; + const palettes = [ + spec.ink?.series?.categorical ?? [], + spec.ink?.series?.categoricalExtended ?? [], + ]; + + for (const palette of palettes) { + for (const ink of palette) { + for (const surface of surfaces) { + expect(contrast(ink, surface!)).toBeGreaterThanOrEqual(3); + } + } + } + }); + + const boxRows = ['Eng', 'Sales', 'HR'].flatMap((dept) => + ['L1', 'L2'].flatMap((level) => + [90, 100, 110, 120].map((v, i) => ({ Department: dept, Level: level, Comp: v * 1000 + i })), + ), + ); + + for (const id of Object.keys(THEME_PRESETS)) { + it(`${id}: box plot whiskers stay legible when the box takes a colour channel`, () => { + const spec: any = assembleVegaLite({ + data: { values: boxRows }, + semantic_types: { Department: 'Category', Level: 'Category', Comp: 'Currency' }, + chart_spec: { + chartType: 'Boxplot', + encodings: { x: 'Department', y: 'Comp', color: 'Level' }, + baseSize: { width: 480, height: 320 }, + }, + theme_spec: THEME_PRESETS[id].spec, + } as any); + + const inks: string[] = []; + const collect = (node: any): void => { + if (!node || typeof node !== 'object') return; + const mark = typeof node.mark === 'string' ? { type: node.mark } : node.mark; + if (mark?.type === 'boxplot' && (node.encoding?.color?.field || node.encoding?.fill?.field)) { + inks.push(mark.rule?.color ?? mark.color ?? '#000000'); + } + for (const key of ['layer', 'vconcat', 'hconcat', 'concat']) { + if (Array.isArray(node[key])) node[key].forEach(collect); + } + if (node.spec) collect(node.spec); + if (node.facet?.spec) collect(node.facet.spec); + }; + collect(spec); + + expect(inks.length).toBeGreaterThan(0); + const surface = surfaceOf(spec); + for (const ink of inks) expect(contrast(ink, surface)).toBeGreaterThan(3); + }); + + it(`${id}: radar axis labels stay legible on the house surface`, () => { + const spec: any = assembleVegaLite({ + data: { + values: ['A', 'B'].flatMap((team) => + ['Speed', 'Attack', 'Tactics', 'Stamina'].map((axis, i) => ({ + Team: team, Measure: axis, Score: 40 + i * 12, + })), + ), + }, + semantic_types: { Team: 'Category', Measure: 'Category', Score: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + encodings: { x: 'Measure', y: 'Score', color: 'Team' }, + baseSize: { width: 480, height: 360 }, + }, + theme_spec: THEME_PRESETS[id].spec, + } as any); + + const inks: string[] = []; + const collect = (node: any): void => { + if (!node || typeof node !== 'object') return; + const mark = typeof node.mark === 'string' ? { type: node.mark } : node.mark; + if (mark?.type === 'text' && node.encoding?.text?.value !== undefined) { + const ink = mark.fill ?? mark.color; + if (typeof ink === 'string') inks.push(ink); + } + for (const key of ['layer', 'vconcat', 'hconcat', 'concat']) { + if (Array.isArray(node[key])) node[key].forEach(collect); + } + if (node.spec) collect(node.spec); + if (node.facet?.spec) collect(node.facet.spec); + }; + collect(spec); + + expect(inks.length).toBeGreaterThan(0); + const surface = surfaceOf(spec); + for (const ink of inks) expect(contrast(ink, surface)).toBeGreaterThan(3); + }); + } +}); + +describe('house icons', () => { + // A picker has to say what a house looks like before the reader has seen a + // chart in it. The icons are how it does that, so they have to be present, + // well-formed, and — the point of the set — distinguishable from each other. + const icons = Object.entries(THEME_PRESETS).map(([id, preset]) => [id, preset.icon] as const); + + for (const [id, icon] of icons) { + it(`${id}: ships a self-contained 16px SVG`, () => { + expect(icon).toMatch(/^$/); + // Self-contained: a bare `` element without the namespace does + // not render from an `` or a data URL. + expect(icon).toContain('xmlns="http://www.w3.org/2000/svg"'); + expect(icon).toContain('viewBox="0 0 16 16"'); + // No external references — an icon that fetches is an icon that + // fails offline, and these ship inside the package. + expect(icon).not.toMatch(/\b(?:href|src|url\()/); + }); + + it(`${id}: is drawn in colours the house actually uses`, () => { + const spec: any = THEME_PRESETS[id].spec; + const declared = new Set( + JSON.stringify(spec) + .match(/#[0-9a-fA-F]{6}/g) + ?.map((hex) => hex.toLowerCase()) ?? [], + ); + const series: string[] = spec.ink?.series?.categorical ?? []; + // The three bars are the first three of the house's own set: an icon + // that invents a palette is a promise the chart will not keep. + for (const hex of series.slice(0, 3)) expect(icon.toLowerCase()).toContain(hex.toLowerCase()); + // The tile is the house's canvas where it states one. + const canvas = spec.ink?.surface?.canvas; + if (canvas) expect(icon.toLowerCase()).toContain(String(canvas).toLowerCase()); + expect(declared.size).toBeGreaterThan(0); + }); + } + + it('gives every house a different picture', () => { + const seen = new Map(); + for (const [id, icon] of icons) { + const clash = seen.get(icon); + expect(clash, `${id} and ${clash} share an icon`).toBeUndefined(); + seen.set(icon, id); + } + expect(seen.size).toBe(icons.length); + }); + + it('offers "no house" its own picture too', () => { + expect(DEFAULT_THEME_ICON).toMatch(/^ { + // `listThemePresets` is what a model reads to choose a house. An SVG it + // cannot see is context spent for nothing. + for (const entry of listThemePresets()) { + expect(entry).not.toHaveProperty('icon'); + expect(JSON.stringify(entry)).not.toContain(' { + it('omits a title over labels that name their own kind', () => { + const spec = bars(house({ axisTitles: 'whenAmbiguous' }), 'Wetter than it looks'); + expect(axisTitle(spec, 'x')).toBeNull(); + }); + + it('keeps a title over a column of bare values', () => { + const spec = bars(house({ axisTitles: 'whenAmbiguous' }), 'Wetter than it looks'); + expect(axisTitle(spec, 'y')).not.toBeNull(); + }); + + it('lets a house delegate the naming to its headline', () => { + const spec = bars(house({ axisTitles: 'omit' }), 'Wetter than it looks'); + expect(axisTitle(spec, 'y')).toBeNull(); + }); + + it('puts the title back when there is no headline to delegate to', () => { + 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'); + }); +}); + +describe('title block position', () => { + it('places a title below the chart when the house treats it as a caption', () => { + const spec = bars({ + ...house({ axisTitles: 'always' }), + layout: { titleBlock: { position: 'bottom' } }, + }, 'Monthly rainfall'); + + expect(spec.config.title.orient).toBe('bottom'); + expect(spec._theme.decisions.title.position).toBe('bottom'); + }); + + it('keeps titles above the chart by default', () => { + const spec = bars(house({ axisTitles: 'always' }), 'Monthly rainfall'); + + expect(spec.config.title.orient).toBe('top'); + expect(spec._theme.decisions.title.position).toBe('top'); + }); + + it('places the Nature title as a centered caption below the figure', () => { + const spec = bars(THEME_PRESETS.nature.spec, 'Monthly rainfall'); + + expect(spec.config.title.orient).toBe('bottom'); + expect(spec.config.title.anchor).toBe('middle'); + }); +}); + +describe('legend titles', () => { + const PENGUINS = [ + { Island: 'Biscoe', Species: 'Adelie', Mass: 3400 }, + { Island: 'Dream', Species: 'Gentoo', Mass: 5100 }, + { Island: 'Torgersen', Species: 'Adelie', Mass: 3700 }, + { Island: 'Biscoe', Species: 'Gentoo', Mass: 4900 }, + ]; + + function keyed(colorField: string) { + return assembleVegaLite({ + data: { values: PENGUINS }, + semantic_types: { Island: 'City', Species: 'Category', Mass: 'Amount' }, + chart_spec: { + chartType: 'Bar Chart', + title: 'Heavier in the west', + encodings: { x: 'Island', y: 'Mass', color: colorField }, + }, + theme_spec: house({ axisTitles: 'omit' }, { title: 'whenAmbiguous' }), + } as any) as any; + } + + it('leaves a key of names untitled', () => { + const spec = keyed('Species'); + expect(spec._theme.decisions.legend.show).toBe(true); + expect(spec._theme.decisions.legend.title).toBe(false); + }); + + it('titles a key of values', () => { + const spec = keyed('Mass'); + expect(spec._theme.decisions.legend.show).toBe(true); + expect(spec._theme.decisions.legend.title).toBe(true); + }); +}); + +/** + * A masthead tab is canvas furniture — branding anchored to the graphic frame. + * + * The Economist's red tab sits at the very top-left of the graphic, flush with + * the title's own margin, and has nothing to do with the plot. So it is not a + * concat child (which would pin to the plot's data rectangle and drift right on + * a wide axis gutter) — it is recorded in `usermeta` for the renderer to draw + * onto the SVG, and a strip of top padding is reserved so it opens the graphic + * above the headline. + */ +describe('masthead furniture', () => { + function withTab(): any { + const themeSpec = { + id: 'tabbed', + label: 'Tabbed', + ink: { surface: { canvas: '#ffffff' }, series: { single: '#333333' } }, + annotation: { axisTitles: 'always' }, + furniture: [{ kind: 'mastheadTab', anchor: 'topLeft', color: '#e3120b', width: 26, height: 3 }], + } 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('records the tab as canvas furniture, flush with the title, and keeps the title on the graphic', () => { + const spec = withTab(); + // The tab is not wrapped into a concat — it draws onto the canvas. + expect(spec.vconcat).toBeUndefined(); + const tabs = spec.usermeta?.flintCanvasFurniture; + expect(Array.isArray(tabs)).toBe(true); + expect(tabs).toHaveLength(1); + const tab = tabs[0]; + expect(tab.kind).toBe('mastheadTab'); + expect(tab.color).toBe('#e3120b'); + expect(tab.width).toBe(26); + expect(tab.height).toBe(3); + // The tab anchors to the same left margin the title anchors to. + const pad = spec.padding; + const left = typeof pad === 'number' ? pad : pad.left; + expect(tab.x).toBe(left); + // A band of top padding is reserved so the tab opens above the headline. + const top = typeof pad === 'number' ? pad : pad.top; + expect(top).toBeGreaterThanOrEqual(tab.y + tab.height); + // The headline stays on the graphic frame. + expect(spec.title).toBeTruthy(); + }); +}); diff --git a/packages/flint-js/tests/value-label-format.test.ts b/packages/flint-js/tests/value-label-format.test.ts new file mode 100644 index 00000000..f5bf27df --- /dev/null +++ b/packages/flint-js/tests/value-label-format.test.ts @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite } from '../src'; +import { + formatValueApprox, + inferValueLabelFormat, + longestLabelChars, +} from '../src/core/theme/value-label-format'; + +/** + * How many digits a value printed on a mark carries, and how wide it lands. + * + * Two things have to hold together, and neither is any use alone: + * + * - the label carries digits a reader can act on. Left to Vega-Lite a bar + * gets captioned `3.14159265`; a house asking for a k/M suffix but no + * precision gets `1.23457M`. Both are the raw number wearing a costume. + * - the fit tests measure *that* label. They used to measure + * `String(Math.round(value))` — the width of a number nobody prints — + * so a chart of decimals was measured four times narrower than it drew, + * and its labels were offered straight into a pile. + */ +describe('value label precision', () => { + describe('choosing the digits', () => { + it('keeps three significant figures rather than the raw decimals', () => { + expect(inferValueLabelFormat([3.14159265, 2.71828182], undefined)).toBe(',.2~f'); + expect(formatValueApprox(3.14159265, ',.2~f')).toBe('3.14'); + }); + + it('does not invent decimals the data does not have', () => { + // 45 is not 45.0: whole numbers stay whole, however much room there is. + expect(inferValueLabelFormat([12, 45, 78], undefined)).toBe(',d'); + }); + + it('does not round the small values in a series out of existence', () => { + // Sized off the largest value alone, 0.001 and 0.05 both print `0` on + // bars that plainly are not zero. The smallest value claims the + // decimals it needs; `~` keeps them off the large ones. + const pattern = inferValueLabelFormat([0.001, 0.05, 3.2, 180, 5000], undefined); + expect(pattern).toBe(',.3~f'); + const drawn = [0.001, 0.05, 3.2, 180, 5000].map((v) => formatValueApprox(v, pattern)); + expect(drawn).toEqual(['0.001', '0.05', '3.2', '180', '5,000']); + }); + + it('falls back to an exponent when the zeros outrun the digits', () => { + expect(inferValueLabelFormat([1e-7, 3.5e-7], undefined)).toBe('.2~e'); + expect(formatValueApprox(3.5e-7, '.2~e')).toBe('3.5e-7'); + }); + + it('reaches for a k/M suffix once the numbers get long', () => { + expect(inferValueLabelFormat([1234567, 2345678], undefined)).toBe('.3~s'); + expect(formatValueApprox(1234567, '.3~s')).toBe('1.23M'); + }); + + it('never uses an SI suffix on values below one', () => { + // d3 applies `s` in both directions, so 0.00123 comes out `1.23m` — and + // on a chart `m` reads as *millions*. A house asking to shorten large + // numbers is not asking for that, so small values keep their decimals. + const house = '~s'; + expect(inferValueLabelFormat([0.00123456, 0.0034], house)).not.toContain('s'); + expect(inferValueLabelFormat([0.00123456, 0.0034], house)).toBe('.5~f'); + }); + + it('fills in a precision the house left open, and respects one it stated', () => { + // `~s` is a style ("use a suffix"), not a precision — left open it + // prints every significant digit it has. + expect(inferValueLabelFormat([1234567.891], '~s')).toBe('.3~s'); + // But a house that named its precision has decided; nothing overrides it. + expect(inferValueLabelFormat([1234567.891], ',.2f')).toBe(',.2f'); + }); + }); + + describe('a label may not contradict the mark it sits on', () => { + it('raises a stated precision that would print every bar the same', () => { + // McKinsey states `precision: 'integer'`. On eight bars of visibly + // different height that printed `100` eight times — a caption the + // chart itself refutes, and the reader believes the number. + const near = [100.1, 100.2, 100.15, 100.05, 100.25, 100.12, 100.18, 100.08]; + expect(inferValueLabelFormat(near, ',.0f')).toBe(',.2~f'); + // The house's grouping and sign survive; only the digits move. + expect(inferValueLabelFormat(near, '+,.0f')).toBe('+,.2~f'); + }); + + it('raises a stated precision that would print a value as zero', () => { + expect(inferValueLabelFormat([0.45, 0.82, 0.13], ',.0f')).toBe(',.1~f'); + expect(inferValueLabelFormat([0.00123456, 0.0034, 0.0021], ',.0f')).toBe(',.3~f'); + }); + + it('leaves a stated precision alone when the labels stay distinct', () => { + expect(inferValueLabelFormat([1234, 5678, 4321], ',.0f')).toBe(',.0f'); + // A real zero is allowed to print as zero. + expect(inferValueLabelFormat([0, 17, 25], ',.0f')).toBe(',.0f'); + }); + + it('finds the digits an inferred format needs, not just the ones its magnitude suggests', () => { + // Three significant figures off the largest value gives `100` here; + // the information in this series lives two digits further down. + expect(inferValueLabelFormat([100.1, 100.2, 100.15, 100.05], undefined)).toBe(',.2~f'); + }); + + it('drops the suffix when three significant figures would collapse the values', () => { + // `.3~s` prints both of these `1M`. + expect(inferValueLabelFormat([1000000, 1000400], undefined)).toBe(',d'); + // Where they stay apart, the suffix is still the shorter read. + expect(inferValueLabelFormat([123456789, 987654321], undefined)).toBe('.3~s'); + }); + + it('never invents a digit the data does not carry', () => { + expect(inferValueLabelFormat([3, 17, 42], ',.0f')).toBe(',.0f'); + expect(inferValueLabelFormat([3, 17, 42], undefined)).toBe(',d'); + }); + }); + + describe('measuring the label that will actually be drawn', () => { + /** + * Verified against real d3-format: every pattern/value pair below was + * compared with `d3.format(pattern)(value)` and matched in width for all + * of them (d3 renders a minus as U+2212 where this uses ASCII, which is + * the same width). flint-js carries no runtime dependencies, so the + * comparison is pinned here as a table rather than run against d3. + */ + const cases: Array<[number, string, string]> = [ + [1234567, '.3~s', '1.23M'], + [12345, '.3~s', '12.3k'], + [1999.99, '.3~s', '2k'], + [3.14159265, ',.2f', '3.14'], + [0.00123456, ',.5~f', '0.00123'], + [-1234.5678, ',d', '-1,235'], + [1234567, ',d', '1,234,567'], + [0.45, '.0%', '45%'], + [2500, '.3~s', '2.5k'], + ]; + for (const [value, pattern, expected] of cases) { + it(`${pattern} renders ${value} as ${expected}`, () => { + expect(formatValueApprox(value, pattern)).toBe(expected); + }); + } + + it('measures the formatted string, not the rounded integer', () => { + // The old estimate: `String(Math.round(3.14159265)).length` === 1. + expect(longestLabelChars([3.14159265, 2.71828182], ',.2~f')).toBe(4); + // ...and a suffix shortens a long number rather than lengthening it. + expect(longestLabelChars([1234567, 987654321], '.3~s')).toBe(5); + }); + + it('falls back to the raw rendering when no format is stated', () => { + // Which is what Vega-Lite would print, so the measurement stays honest. + expect(longestLabelChars([3.14159265], undefined)).toBe(10); + }); + }); + + describe('the fit tests inherit the honest width', () => { + const bars = (values: number[]) => ({ + data: { values: values.map((v, i) => ({ cat: `Category ${i + 1}`, val: v })) }, + semantic_types: { cat: 'nominal', val: 'quantitative' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'cat', y: 'val' }, + baseSize: { width: 420, height: 400 }, + chartProperties: { showValueLabels: true }, + }, + theme_spec: 'nyt', + }) as any; + + it('a wide label is withheld where a narrow one is printed', () => { + // Same bar count, same room. Only the printed width differs: `13` sits + // in the band, `0.00123` does not — it is nearly twice the band wide, + // and there is nowhere on a vertical bar chart to put a number that + // wide without laying it across its neighbours. The old estimate + // measured both as their rounded integer — one character each — and + // printed the wide one anyway. + const narrow: any = assembleVegaLite(bars(Array.from({ length: 14 }, (_, i) => 10 + i))); + const wide: any = assembleVegaLite(bars(Array.from({ length: 14 }, (_, i) => 0.00123456 + i * 0.0001))); + expect(narrow._theme?.decisions?.dataLabels?.show).toBe(true); + expect(narrow._theme?.decisions?.dataLabels?.placement).toBe('atMark'); + expect(wide._theme?.decisions?.dataLabels?.show).toBe(false); + // ...and given room, the same wide labels are printed. The width is the + // reason, not the digits. + const roomy: any = assembleVegaLite({ + ...bars(Array.from({ length: 14 }, (_, i) => 0.00123456 + i * 0.0001)), + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'cat', y: 'val' }, + baseSize: { width: 1400, height: 400 }, + chartProperties: { showValueLabels: true }, + }, + }); + expect(roomy._theme?.decisions?.dataLabels?.show).toBe(true); + }); + + it('gives an unthemed chart a format too, rather than the raw number', () => { + // Without a house there was no format at all, so Vega-Lite printed the + // number as JavaScript renders it and a tidy bar chart came out + // captioned `3.14159265`. + const spec: any = assembleVegaLite({ + data: { values: [3.14159265, 2.71828182, 1.41421356].map((v, i) => ({ cat: `C${i + 1}`, val: v })) }, + semantic_types: { cat: 'nominal', val: 'quantitative' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'cat', y: 'val' }, + baseSize: { width: 420, height: 340 }, + chartProperties: { showValueLabels: true }, + }, + } as any); + const text = (spec.layer ?? []).find((l: any) => (l.mark?.type ?? l.mark) === 'text'); + expect(text?.encoding?.text?.format).toBe(',.2~f'); + }); + + it('reports the precision it chose, so the digits are not silently changed', () => { + const spec: any = assembleVegaLite(bars([3.14159265, 2.71828182, 1.41421356])); + const said = (spec._theme?.report ?? []).map((r: any) => `${r.path}: ${r.message}`); + expect(said.some((m: string) => m.startsWith('annotation.numberFormat:'))).toBe(true); + }); + }); + + describe('which side of the mark the number sits on', () => { + const signed = (values: number[], horizontal = false) => 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 }, + chartProperties: { showValueLabels: true }, + }, + theme_spec: 'economist', + } as any) as any; + + const labelMark = (spec: any) => + (spec.layer ?? []).find((l: any) => (l.mark?.type ?? l.mark) === 'text')?.mark; + + 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])); + 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('flips left and right instead when the bars run sideways', () => { + const spec = signed([-1234.5, -88, 12, 940, -3], true); + const marks = (spec.layer ?? []) + .filter((l: any) => (l.mark?.type ?? l.mark) === 'text') + .map((l: any) => l.mark); + // Sideways there is nothing to raise or lower; the side is left or + // right. Bars too short to hold their number are labelled by a second + // layer on the opposite side, so the two layers must be mirrors — + // otherwise one of them is putting the number through its own bar. + expect(marks.length).toBe(2); + const exprs = marks.map((m: any) => m.align.expr).sort(); + expect(exprs).toEqual([ + `datum["val"] < 0 ? 'left' : 'right'`, + `datum["val"] < 0 ? 'right' : 'left'`, + ]); + for (const m of marks) expect(m.baseline).toBe('middle'); + }); + + it('leaves an all-positive chart on a plain offset', () => { + // No negatives, nothing to resolve per mark: the spec stays literal. + const mark = labelMark(signed([12, 940, 3])); + expect(typeof mark.baseline).toBe('string'); + expect(typeof mark.dy).toBe('number'); + }); + }); +}); diff --git a/packages/flint-js/tests/value-labels.test.ts b/packages/flint-js/tests/value-labels.test.ts new file mode 100644 index 00000000..a5fdcb15 --- /dev/null +++ b/packages/flint-js/tests/value-labels.test.ts @@ -0,0 +1,535 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite, getChartOptions } from '../src'; + +/** + * The `showValueLabels` toggle — "print the numbers on the marks?". + * + * A house already has a standing habit here (`dataLabels.show`), and that habit + * is what seeds the control. The toggle exists so a reader can overrule it for + * one chart, in both directions, without ever being able to overprint a chart + * too dense to read. + * + * The invariants worth protecting: + * + * - the seed is the house's own answer at this density, so leaving the + * control alone and writing its seed back produce the same chart; + * - `false` silences even a house that always prints — this was impossible + * before, the theme printed labels no chartProperty could suppress; + * - `true` overrules a cautious house, but not the hard density ceiling; + * - past that ceiling the control is withheld, not offered inert; + * - templates that print their own labels (waterfall, heatmap) answer to the + * same toggle and expose only that one, while still accepting the older + * `showTextLabels` spelling as input. + */ + +const bars = (n: number) => + Array.from({ length: n }, (_, i) => ({ cat: `C${i + 1}`, val: 10 + ((i * 7) % 90) })); + +/** Tall enough that each band can hold a number, up to a large n. */ +const barChart = (n: number, theme: string | undefined, props?: Record) => ({ + data: { values: bars(n) }, + semantic_types: { cat: 'nominal', val: 'quantitative' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { y: 'cat', x: 'val' }, + baseSize: { width: 700, height: 1800 }, + ...(props ? { chartProperties: props } : {}), + }, + ...(theme ? { theme_spec: theme } : {}), +}) as any; + +/** Count text marks anywhere in the spec — the value labels are text layers. */ +function countTextMarks(spec: any): number { + let n = 0; + const walk = (node: any) => { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { node.forEach(walk); return; } + const mark = typeof node.mark === 'string' ? node.mark : node.mark?.type; + if (mark === 'text') n += 1; + for (const key of Object.keys(node)) if (key !== 'mark') walk(node[key]); + }; + walk(spec); + return n; +} + +const option = (input: any, key: string) => + getChartOptions(input).find((o: any) => o.key === key); + +/** Houses that print labels whenever they fit, vs. houses that always print. */ +const WHEN_THEY_FIT = ['economist', 'nature', 'powerbi', 'datawrapper']; +const ALWAYS = ['nyt', 'mckinsey']; + +describe('showValueLabels', () => { + it('seeds from the house, so writing the seed back changes nothing', () => { + for (const house of [...WHEN_THEY_FIT, ...ALWAYS]) { + for (const n of [12, 30, 50, 90]) { + const seed = option(barChart(n, house), 'showValueLabels')?.value; + expect(typeof seed, `${house} n=${n}`).toBe('boolean'); + const untouched = countTextMarks(assembleVegaLite(barChart(n, house))); + const reseeded = countTextMarks(assembleVegaLite(barChart(n, house, { showValueLabels: seed }))); + expect(reseeded, `${house} n=${n} round-trip`).toBe(untouched); + } + } + }); + + it('the seed reflects each house\'s own habit at a density where they disagree', () => { + // 50 bars: comfortably readable, but past the point a cautious house bothers. + for (const house of WHEN_THEY_FIT) { + expect(option(barChart(50, house), 'showValueLabels')?.value, house).toBe(false); + } + for (const house of ALWAYS) { + expect(option(barChart(50, house), 'showValueLabels')?.value, house).toBe(true); + } + }); + + it('false silences a house that would otherwise print', () => { + for (const house of [...WHEN_THEY_FIT, ...ALWAYS]) { + // Sparse enough that every house prints of its own accord. + expect(countTextMarks(assembleVegaLite(barChart(12, house))), `${house} baseline`) + .toBeGreaterThan(0); + expect(countTextMarks(assembleVegaLite(barChart(12, house, { showValueLabels: false }))), house) + .toBe(0); + } + }); + + it('true overrules a cautious house', () => { + for (const house of WHEN_THEY_FIT) { + expect(countTextMarks(assembleVegaLite(barChart(50, house))), `${house} untouched`).toBe(0); + expect(countTextMarks(assembleVegaLite(barChart(50, house, { showValueLabels: true }))), house) + .toBeGreaterThan(0); + } + }); + + it('true is not a licence to overprint: the density ceiling still holds', () => { + for (const house of [...WHEN_THEY_FIT, ...ALWAYS]) { + expect(countTextMarks(assembleVegaLite(barChart(130, house, { showValueLabels: true }))), house) + .toBe(0); + } + }); + + it('is withheld rather than offered inert once labels cannot be read', () => { + for (const house of [...WHEN_THEY_FIT, ...ALWAYS]) { + expect(option(barChart(12, house), 'showValueLabels')?.applicable, `${house} sparse`).toBe(true); + expect(option(barChart(130, house), 'showValueLabels')?.applicable, `${house} dense`).toBe(false); + } + }); + + it('is a toggle, not a multi-choice', () => { + const opt = option(barChart(12, 'economist'), 'showValueLabels'); + expect(opt?.type).toBe('binary'); + }); +}); + +describe('showValueLabels without a theme', () => { + it('is offered on a plain bar chart, defaulting to off', () => { + const opt = option(barChart(12, undefined), 'showValueLabels'); + expect(opt?.applicable).toBe(true); + expect(opt?.value).toBe(false); + }); + + it('prints the numbers when asked, with no house in sight', () => { + expect(countTextMarks(assembleVegaLite(barChart(12, undefined)))).toBe(0); + expect(countTextMarks(assembleVegaLite(barChart(12, undefined, { showValueLabels: true })))) + .toBeGreaterThan(0); + }); + + it('obeys the same density ceiling as a house does', () => { + expect(option(barChart(130, undefined), 'showValueLabels')?.applicable).toBe(false); + expect(countTextMarks(assembleVegaLite(barChart(130, undefined, { showValueLabels: true })))).toBe(0); + }); + + it('leaves an untouched chart exactly as it was', () => { + // The default is silence, so simply grounding the neutral house must not + // put a single mark on a chart nobody asked to label. + const strip = (s: any) => { const { _theme, _options, _warnings, ...rest } = s; return JSON.stringify(rest); }; + const untouched = strip(assembleVegaLite(barChart(12, undefined))); + const explicitOff = strip(assembleVegaLite(barChart(12, undefined, { showValueLabels: false }))); + expect(explicitOff).toBe(untouched); + }); + + it('does not leak a _theme onto a chart that named no house', () => { + expect((assembleVegaLite(barChart(12, undefined)) as any)._theme).toBeUndefined(); + expect((assembleVegaLite(barChart(12, undefined, { showValueLabels: true })) as any)._theme) + .toBeUndefined(); + }); +}); + +describe('an empty ThemeSpec is the neutral house, not an error', () => { + it('grounds instead of throwing', () => { + expect(() => assembleVegaLite({ ...barChart(12, undefined), theme_spec: {} } as any)).not.toThrow(); + }); + + it('is a real theme, so it reports itself', () => { + const spec = assembleVegaLite({ ...barChart(12, undefined), theme_spec: {} } as any) as any; + expect(spec._theme?.id).toBe('flint'); + }); +}); + +describe('showValueLabels on templates that print their own labels', () => { + const waterfall = (props?: Record) => ({ + data: { + values: Array.from({ length: 12 }, (_, i) => ({ + step: `S${i + 1}`, delta: (i % 3 === 0 ? -1 : 1) * (5 + i), + })), + }, + semantic_types: { step: 'nominal', delta: 'quantitative' }, + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { x: 'step', y: 'delta' }, + baseSize: { width: 600, height: 380 }, + ...(props ? { chartProperties: props } : {}), + }, + }) as any; + + const heatmap = (props?: Record) => ({ + data: { + values: Array.from({ length: 12 }, (_, i) => ({ + row: `R${i % 4}`, col: `C${Math.floor(i / 4)}`, val: (i * 13) % 50, + })), + }, + semantic_types: { row: 'nominal', col: 'nominal', val: 'quantitative' }, + chart_spec: { + chartType: 'Heatmap', + encodings: { x: 'col', y: 'row', color: 'val' }, + baseSize: { width: 600, height: 380 }, + ...(props ? { chartProperties: props } : {}), + }, + }) as any; + + for (const [name, build] of [['waterfall', waterfall], ['heatmap', heatmap]] as const) { + it(`${name} answers to the same toggle`, () => { + expect(countTextMarks(assembleVegaLite(build({ showValueLabels: true })))).toBeGreaterThan(0); + expect(countTextMarks(assembleVegaLite(build({ showValueLabels: false })))).toBe(0); + }); + + it(`${name} still accepts the older showTextLabels spelling`, () => { + expect(countTextMarks(assembleVegaLite(build({ showTextLabels: true })))).toBeGreaterThan(0); + }); + + it(`${name} offers one labels control, not two`, () => { + const shown = getChartOptions(build()) + .filter((o: any) => /label/i.test(o.key)) + .map((o: any) => o.key); + expect(shown).toEqual(['showValueLabels']); + }); + } + + it('keeps heatmap label expressions valid after swapping fields with display names', () => { + const input = { + data: { + values: [ + { 'Product Group': 'North', 'Sales Month': 'Jan', 'Gross Margin %': 62 }, + { 'Product Group': 'South', 'Sales Month': 'Jan', 'Gross Margin %': 37 }, + ], + }, + semantic_types: { + 'Product Group': 'Category', + 'Sales Month': 'Month', + 'Gross Margin %': 'Percentage', + }, + chart_spec: { + chartType: 'Heatmap', + encodings: { + x: 'Sales Month', + y: 'Product Group', + color: 'Gross Margin %', + }, + chartProperties: { + arrange: 'flip:x-y', + showValueLabels: true, + }, + }, + theme_spec: 'cartoon', + } as any; + + const spec = assembleVegaLite(input) as any; + const text = spec.layer.find((layer: any) => layer.mark?.type === 'text'); + expect(text.encoding.x.field).toBe('Product Group'); + expect(text.encoding.y.field).toBe('Sales Month'); + const conditions = Array.isArray(text.encoding.color.condition) + ? text.encoding.color.condition + : [text.encoding.color.condition]; + const contrastTest = conditions.map((condition: any) => condition.test).join(' '); + expect(contrastTest) + .toContain('datum["Gross Margin %"]'); + expect(contrastTest) + .not.toContain('datum.Gross Margin %'); + }); + + it('is withheld where the template already writes its own text', () => { + // A rose prints its own text on the marks, so the label layer stands down. + // Offering a toggle there would be offering a control that changes nothing. + const rose = (props?: Record) => ({ + data: { values: bars(6) }, + semantic_types: { cat: 'nominal', val: 'quantitative' }, + chart_spec: { + chartType: 'Rose Chart', + encodings: { x: 'cat', y: 'val' }, + baseSize: { width: 600, height: 380 }, + ...(props ? { chartProperties: props } : {}), + }, + }) as any; + expect(option(rose(), 'showValueLabels')?.applicable).toBe(false); + // ...and it is withheld precisely because it would be inert. + const strip = (s: any) => { const { _theme, _options, _warnings, ...rest } = s; return JSON.stringify(rest); }; + expect(strip(assembleVegaLite(rose({ showValueLabels: true })))) + .toBe(strip(assembleVegaLite(rose({ showValueLabels: false })))); + }); +}); + +/** + * Bars that share a band — grouped and stacked. + * + * Both divide the room a single bar would have had, and each divides it a + * different way, so each needs its own fit test: + * + * - a *grouped* bar splits the band across the categorical axis, so the room + * for a number is the band over the series count. The applicability test + * used to read the whole band while the renderer read the slot, so between + * those two readings the control was offered on charts that then printed + * nothing; the horizontal case had no slot test at all and printed a + * hundred-odd clipped numbers over each other. + * - a *stacked* bar keeps the whole band but splits the measure axis, so + * what has to hold a line of text is each segment's own thickness. The + * number goes in the middle of the segment: at the edge it reads as the + * running total, which is why stacks went unlabelled before. + */ +describe('value labels on bars that share a band', () => { + const grid = (cats: number, series: number, value: (c: number, s: number) => number) => { + const out: any[] = []; + for (let c = 0; c < cats; c++) { + for (let s = 0; s < series; s++) out.push({ cat: `C${c + 1}`, grp: `S${s + 1}`, val: value(c, s) }); + } + return out; + }; + const spread = (c: number, s: number) => 10 + ((c * 7 + s * 13) % 60); + + const sharedBar = ( + chartType: 'Grouped Bar Chart' | 'Stacked Bar Chart', + cats: number, + series: number, + opts: { horizontal?: boolean; props?: Record; value?: (c: number, s: number) => number } = {}, + ) => ({ + data: { values: grid(cats, series, opts.value ?? spread) }, + semantic_types: { cat: 'nominal', grp: 'nominal', val: 'quantitative' }, + chart_spec: { + chartType, + encodings: chartType === 'Grouped Bar Chart' + ? (opts.horizontal ? { y: 'cat', x: 'val', group: 'grp' } : { x: 'cat', y: 'val', group: 'grp' }) + : (opts.horizontal ? { y: 'cat', x: 'val', color: 'grp' } : { x: 'cat', y: 'val', color: 'grp' }), + baseSize: { width: 800, height: 420 }, + ...(opts.props ? { chartProperties: opts.props } : {}), + }, + theme_spec: 'nyt', + }) as any; + + /** The synthetic label layer, wherever it ended up. */ + const labelLayer = (spec: any): any => { + let found: any; + const walk = (node: any) => { + if (!node || typeof node !== 'object' || found) return; + if (Array.isArray(node)) { node.forEach(walk); return; } + const mark = typeof node.mark === 'string' ? node.mark : node.mark?.type; + if (mark === 'text' && node.__themeSynthetic) { found = node; return; } + for (const key of Object.keys(node)) if (key !== 'mark') walk(node[key]); + }; + walk(spec); + return found; + }; + + describe('grouped bars offer the control only where the slot holds the number', () => { + for (const horizontal of [false, true]) { + const way = horizontal ? 'horizontal' : 'vertical'; + + it(`${way}: a roomy grid both offers and prints`, () => { + const input = sharedBar('Grouped Bar Chart', 4, 3, { horizontal }); + expect(option(input, 'showValueLabels')?.applicable).toBe(true); + const on = sharedBar('Grouped Bar Chart', 4, 3, { horizontal, props: { showValueLabels: true } }); + expect(countTextMarks(assembleVegaLite(on))).toBeGreaterThan(0); + }); + + it(`${way}: a crowded grid withholds the control rather than offering it inert`, () => { + // 20 categories x 6 series leaves each bar a few pixels: the numbers + // would overlap and clip. The control must be withheld *and* silent — + // offering it while printing nothing is the bug this pins down. + const input = sharedBar('Grouped Bar Chart', 20, 6, { horizontal }); + expect(option(input, 'showValueLabels')?.applicable).toBe(false); + const on = sharedBar('Grouped Bar Chart', 20, 6, { horizontal, props: { showValueLabels: true } }); + expect(countTextMarks(assembleVegaLite(on))).toBe(0); + }); + } + }); + + describe('stacked bars label each segment, in the middle of it', () => { + it('offers the control and prints a number per segment', () => { + const input = sharedBar('Stacked Bar Chart', 6, 3); + expect(option(input, 'showValueLabels')?.applicable).toBe(true); + const layer = labelLayer(assembleVegaLite(sharedBar('Stacked Bar Chart', 6, 3, { props: { showValueLabels: true } }))); + expect(layer).toBeTruthy(); + // Centred in the segment, not at its edge: the edge reads as the total. + expect(layer.encoding.y.stack).toBeTruthy(); + expect(layer.encoding.y.bandPosition).toBe(0.5); + expect(layer.mark.baseline).toBe('middle'); + }); + + it('stacks its labels in the same order as the bars', () => { + // Vega-Lite reads stack order off the colour field, which the label + // layer does not carry; without a stated order every number lands on a + // neighbour's segment. The order is stated as a position within the + // colour scale's domain rather than as a sort of the field itself, + // because those two differ whenever a template pins its own domain. + // The two axes run opposite ways, so the direction flips with the + // orientation. + const vertical = labelLayer(assembleVegaLite( + sharedBar('Stacked Bar Chart', 6, 3, { props: { showValueLabels: true } }))); + expect(vertical.encoding.order).toMatchObject({ field: '__flintStackOrder', sort: 'descending' }); + const horizontal = labelLayer(assembleVegaLite( + sharedBar('Stacked Bar Chart', 6, 3, { horizontal: true, props: { showValueLabels: true } }))); + expect(horizontal.encoding.order).toMatchObject({ field: '__flintStackOrder', sort: 'ascending' }); + }); + + it('takes the order from the domain the bars stack by, not the alphabet', () => { + // A Likert scale is pinned to its own order — "A great deal", "Some", + // "Not much", "None at all" — which is not alphabetical. Sorting the + // colour field instead put every number on the wrong segment: `Some` + // sorts last but stacks second. + const responses = ['A great deal', 'Some', 'Not much', 'None at all']; + const values = [['Scientists', [39, 45, 12, 4]], ['Congress', [8, 30, 38, 24]]] as [string, number[]][]; + const spec: any = assembleVegaLite({ + data: { + values: values.flatMap(([Institution, vals]) => + responses.map((Response, i) => ({ Institution, Response, Share: vals[i] }))), + }, + semantic_types: { Institution: 'Category', Response: 'Category', Share: 'Quantity' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'Share', y: 'Institution', color: 'Response' }, + baseSize: { width: 600, height: 380 }, + chartProperties: { showValueLabels: true }, + }, + theme_spec: 'swiss', + } as any); + const bar = (spec.layer ?? []).find((l: any) => (l.mark?.type ?? l.mark) === 'bar'); + const domain = bar.encoding.color.scale.domain; + expect(domain).toEqual(responses); + // The label layer indexes into exactly that domain. + const order = (labelLayer(spec).transform ?? []) + .find((t: any) => t.as === '__flintStackOrder'); + expect(order).toBeTruthy(); + expect(order.calculate).toContain(JSON.stringify(domain)); + }); + + it('picks the ink per segment, so a dark series does not swallow its number', () => { + // Swiss puts a near-black in its categorical palette and prints its + // labels in a near-black ink: on that one series the number vanished. + // One ink cannot serve a palette, so the label carries its own colour + // scale over the same field — same sort, so the domains line up — and + // each entry is the ink readable on the matching fill. + const responses = ['A great deal', 'Some', 'Not much', 'None at all']; + const spec: any = assembleVegaLite({ + data: { + values: [['Scientists', [39, 45, 12, 4]], ['Congress', [8, 30, 38, 24]]] + .flatMap(([Institution, vals]: any) => + responses.map((Response, i) => ({ Institution, Response, Share: vals[i] }))), + }, + semantic_types: { Institution: 'Category', Response: 'Category', Share: 'Quantity' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'Share', y: 'Institution', color: 'Response' }, + baseSize: { width: 600, height: 380 }, + chartProperties: { showValueLabels: true }, + }, + theme_spec: 'swiss', + } as any); + const bar = (spec.layer ?? []).find((l: any) => (l.mark?.type ?? l.mark) === 'bar'); + const fills: string[] = bar.encoding.color.scale.range; + const inks: string[] = labelLayer(spec).encoding.color.scale.range; + expect(inks.length).toBe(fills.length); + // Not one ink repeated: the palette spans light and dark, so the inks + // must too, or one of them is sitting on its own colour. + expect(new Set(inks).size).toBeGreaterThan(1); + // And the two scales must not be merged into one by Vega-Lite, or the + // fill range wins and the number is painted its own background. + expect(spec.resolve?.scale?.color).toBe('independent'); + }); + + it('drops the number from segments too thin to hold it', () => { + // One series is a sliver against the others; it cannot carry a line of + // text, so it is hidden by opacity — not dropped, which would restack + // the surviving labels onto the wrong segments. + const sliver = (_c: number, s: number) => (s === 0 ? 1 : 60); + const layer = labelLayer(assembleVegaLite( + sharedBar('Stacked Bar Chart', 6, 3, { props: { showValueLabels: true }, value: sliver }))); + expect(layer.encoding.opacity?.condition?.test).toBeTruthy(); + expect(layer.encoding.opacity.value).toBe(0); + }); + + it('prints shares, not raw values, when the stack is normalized', () => { + // The axis is a percentage and the segment's length *is* its share, so a + // raw value there would name a quantity the chart does not draw. + const layer = labelLayer(assembleVegaLite(sharedBar('Stacked Bar Chart', 6, 3, { + props: { stackMode: 'normalize', showValueLabels: true }, + }))); + expect(layer.encoding.text.format).toBe('.0%'); + expect(JSON.stringify(layer.transform)).toContain('joinaggregate'); + }); + + it('withholds the control when the bars are narrower than the number', () => { + // A stacked bar cannot move a too-wide number above itself the way a + // single-series bar can — above the bar is the top of the whole stack. + const wide = (c: number, s: number) => 1234567 + c * 100000 + s * 70000; + const input = sharedBar('Stacked Bar Chart', 20, 4, { value: wide, props: { valueFormat: 'raw' } }); + expect(option(input, 'showValueLabels')?.applicable).toBe(false); + const on = sharedBar('Stacked Bar Chart', 20, 4, { + value: wide, props: { valueFormat: 'raw', showValueLabels: true }, + }); + expect(countTextMarks(assembleVegaLite(on))).toBe(0); + }); + }); + + describe('a ribbon is not a set of marks', () => { + const stackedArea = (normalized: boolean) => { + const values = ([[1990, [4430, 1780, 2160]], [2000, [5990, 2760, 2620]], + [2010, [8670, 4760, 3440]], [2020, [9420, 6270, 4360]]] as [number, number[]][]) + .flatMap(([Year, vals]) => ['Coal', 'Gas', 'Hydro'] + .map((Source, i) => ({ Year, Source, TWh: vals[i] }))); + return { + data: { values }, + semantic_types: { Year: 'Year', Source: 'Category', TWh: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'Year', y: 'TWh', color: 'Source' }, + baseSize: { width: 600, height: 380 }, + ...(normalized ? { chartProperties: { stackMode: 'normalize' } } : {}), + }, + theme_spec: 'swiss', + } as any; + }; + + it('does not print values on a normalized stacked area', () => { + // `isPartToWhole` is true here — a normalized stack *is* parts of a + // whole — but that test was written for wedges, which have a slot each. + // An area is one continuous ribbon: the numbers land on the vertices, + // which are sampling points rather than marks to read off one at a + // time. Worse, on a normalized chart the axis is a percentage while the + // number is a raw total, so it names a quantity nothing on the chart + // draws. + const spec: any = assembleVegaLite(stackedArea(true)); + expect(spec._theme?.decisions?.dataLabels?.possible).toBe(false); + expect(countTextMarks(spec)).toBe(0); + }); + + it('does not print them on a plain stacked area either', () => { + const spec: any = assembleVegaLite(stackedArea(false)); + expect(countTextMarks(spec)).toBe(0); + }); + + it('withholds the toggle rather than offering it inert', () => { + const offered = getChartOptions(stackedArea(true)).map((o: any) => o.key); + expect(offered).not.toContain('showValueLabels'); + }); + }); +}); diff --git a/packages/flint-js/tests/waterfall-titles.test.ts b/packages/flint-js/tests/waterfall-titles.test.ts new file mode 100644 index 00000000..6849d84f --- /dev/null +++ b/packages/flint-js/tests/waterfall-titles.test.ts @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite } from '../src'; + +/** + * Waterfall Chart axis titles (Vega-Lite). + * + * A waterfall is built from window transforms, so several layers bind internal + * `__wf_*` columns to the same x/y scales as the user's own fields. Vega-Lite + * derives an axis title by concatenating the titles of every field on a shared + * scale, so any internal field left untitled surfaces in the rendered axis + * ("Week, __wf_lead"). These tests assert the two invariants that keep the axes + * readable: + * + * - the titles the assembler resolved (including `field_display_names`) are + * the ones that reach the axes, and + * - no internal `__wf_*` column can contribute to an axis title. + */ + +const DATA = [ + { week: '2026-06-01', wsu_change: 1200 }, + { week: '2026-06-08', wsu_change: -430 }, + { week: '2026-06-15', wsu_change: 880 }, + { week: '2026-06-22', wsu_change: -210 }, +]; + +function build( + fieldDisplayNames?: Record, + chartProperties?: Record, +) { + return assembleVegaLite({ + data: { values: DATA }, + semantic_types: { week: 'Date', wsu_change: 'Quantity' }, + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { x: { field: 'week' }, y: { field: 'wsu_change' } }, + baseSize: { width: 500, height: 320 }, + ...(chartProperties ? { chartProperties } : {}), + }, + ...(fieldDisplayNames ? { field_display_names: fieldDisplayNames } : {}), + } as never) as any; +} + +/** The same chart split into small multiples, which nests the layered unit. */ +function buildFaceted() { + const rows = DATA.flatMap((r) => [ + { ...r, region: 'East' }, + { ...r, region: 'West' }, + ]); + return assembleVegaLite({ + data: { values: rows }, + semantic_types: { week: 'Date', wsu_change: 'Quantity', region: 'Category' }, + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { + x: { field: 'week' }, + y: { field: 'wsu_change' }, + column: { field: 'region' }, + }, + baseSize: { width: 640, height: 320 }, + }, + field_display_names: { wsu_change: 'WSU weekly change', week: 'Week (Mon, JST)' }, + } as never) as any; +} + +/** + * Every encoding object in the spec. Faceted output nests the layered unit under + * `spec.spec`, so recurse rather than only reading the top level. + */ +function allEncodings(spec: any): Array<[string, any]> { + const out: Array<[string, any]> = []; + const visit = (node: any) => { + if (!node || typeof node !== 'object') return; + for (const [channel, def] of Object.entries(node.encoding ?? {})) { + if (def && typeof def === 'object') out.push([channel, def]); + } + for (const layer of node.layer ?? []) visit(layer); + visit(node.spec); + }; + visit(spec); + return out; +} + +describe('Waterfall Chart axis titles', () => { + it('applies field_display_names to the x and y axes', () => { + const spec = build({ wsu_change: 'WSU weekly change', week: 'Week (Mon, JST)' }); + + expect(spec.encoding.x.title).toBe('Week (Mon, JST)'); + + const bar = spec.layer.find((l: any) => (l.mark?.type ?? l.mark) === 'bar'); + expect(bar.encoding.y.title).toBe('WSU weekly change'); + }); + + it('falls back to the raw field names when no display names are given', () => { + const spec = build(); + + expect(spec.encoding.x.title).toBe('week'); + + const bar = spec.layer.find((l: any) => (l.mark?.type ?? l.mark) === 'bar'); + expect(bar.encoding.y.title).toBe('wsu_change'); + }); + + it('never lets an internal __wf_* column contribute to an axis title', () => { + const specs = [ + build(), + build({ week: 'Week (Mon, JST)' }), + // Value labels bind two more internal columns to the y scale. + build({ week: 'Week (Mon, JST)' }, { showTextLabels: true }), + // Faceted output nests the layered unit one level down. + buildFaceted(), + ]; + + for (const spec of specs) { + // Vega-Lite derives a title from the field name whenever a positional + // encoding leaves `title` undefined, and concatenates every derived title + // on a shared scale — but an explicit title on the scale's primary + // channel wins outright. So an untitled internal column is only safe on a + // scale whose primary channel carries an explicit title. + for (const scale of ['x', 'y'] as const) { + const onScale = allEncodings(spec).filter(([channel]) => channel === scale || channel === `${scale}2`); + + const untitledInternal = onScale.filter( + ([, def]) => typeof def.field === 'string' && def.field.startsWith('__wf_') && def.title === undefined, + ); + if (untitledInternal.length === 0) continue; + + const titled = onScale.filter(([channel, def]) => channel === scale && typeof def.title === 'string'); + + expect( + titled.length, + `${scale} carries internal columns ${untitledInternal + .map(([, d]) => d.field) + .join(', ')} but no explicit title to stop Vega-Lite naming the axis after them`, + ).toBeGreaterThan(0); + } + } + }); + + it('never suppresses a shared axis title with a null on a primary channel', () => { + // Vega-Lite merges the titles of every layer on a shared scale, and an + // explicit null anywhere in that set wins — so a `title: null` used to hide + // an internal column on x or y blanks the axis for the whole chart. Only + // secondary channels (x2/y2) may opt out that way. + for (const spec of [build(), build({ week: 'W' }, { showTextLabels: true })]) { + const nulled = allEncodings(spec) + .filter(([channel, def]) => /^(x|y)$/.test(channel) && def.title === null) + .map(([channel, def]) => `${channel}:${def.field}`); + + expect(nulled).toEqual([]); + } + }); +}); diff --git a/packages/flint-mcp/README.md b/packages/flint-mcp/README.md index 9dcae739..783471fc 100644 --- a/packages/flint-mcp/README.md +++ b/packages/flint-mcp/README.md @@ -14,7 +14,7 @@ execution counterpart: it compiles, validates, and renders that one spec. Most chart MCP servers expose one tool per chart type (26+ tools) because every chart has a different schema, and they upload your config to a remote render service. Flint has **one schema** (`ChartAssemblyInput`) spanning ~40 chart -types × multiple backends, so this server exposes **five focused tools** and +types × multiple backends, so this server exposes **six focused tools** and renders **locally**. ## Tools @@ -25,17 +25,44 @@ renders **locally**. | `compile_chart` | spec + `backend` | backend-native spec JSON + warnings | | `validate_chart` | spec + `backend` | validity, warnings/errors, computed size | | `list_chart_types` | `backend?` | chart types + encoding channels per backend | +| `list_themes` | optional preset `id` | shipped visual themes, plus guidance for a selected theme | | `create_chart_view` | spec | interactive chart **UI** (MCP App): live SVG preview + customization panel | +## Visual themes + +For Vega-Lite charts, agents should use a preset id from `list_themes`: + +```json +{ "theme_spec": "economist" } +``` + +When the user requests a brand adjustment, extend a preset and keep the +override small: + +```json +{ + "theme_spec": { + "extends": "economist", + "id": "our-brand", + "ink": { "series": { "single": "#6b3fa0" } } + } +} +``` + +See the full +[ThemeSpec guide](https://microsoft.github.io/flint-chart/#/documentation/theme-spec) +for supported fields and merge behavior. + ## MCP App: interactive chart view In hosts that support MCP App UIs (e.g. Claude Desktop), `create_chart_view` opens an interactive view that renders the spec live (Vega-Lite → SVG) and shows a customization panel built from Flint's own option model — chart type, channel bindings, chart properties (corner radius, stack mode, donut hole, …), and -encoding actions (sort). Rendering and edits run entirely in the host UI; no -data leaves the host. The UI is a single self-contained HTML bundle served as -the `ui://flint-chart/chart-view.html` resource and built with `npm run build:ui`. +encoding actions (sort), plus Flint's visual theme presets. Rendering and edits +run entirely in the host UI; no data leaves the host. The UI is a single +self-contained HTML bundle served as the +`ui://flint-chart/chart-view.html` resource and built with `npm run build:ui`. ## Resources and prompt diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index 3b3fbedc..daed293c 100644 --- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md @@ -83,15 +83,19 @@ published, use the npm package or MCP server for released workflows. interface ChartAssemblyInput { // Bound by the HOST or by you, depending on the situation (see below). data: { values: any[] } | { url: string }; - semantic_types?: Record; // field → semantic type ← you write this + semantic_types?: Record; // field → type ( ← you write this) chart_spec: { // ← you write this chartType: string; // e.g. "Scatter Plot" + title?: string; // the headline — write one + subtitle?: string; // what is measured, of whom, when, in what units encodings: Record; // channel → { field, ... } (or array) baseSize?: { width: number; height: number }; // target layout size, default 400×320 canvasSize?: { width: number; height: number }; // optional hard ceiling on stretch chartProperties?: Record; // per-chart tuning (optional) }; options?: Record; // global layout options (rarely needed) + field_display_names?: Record; // field → readable axis/legend title + theme_spec?: string | { extends: string; [key: string]: any }; // preset or preset override (Vega-Lite only) } ``` @@ -167,6 +171,77 @@ For a Vega-Lite-specific style tweak: This edited Vega-Lite spec is no longer a portable Flint spec. Do not send it to `render_chart`; use `render_chart` only for Flint `ChartAssemblyInput`. +## Write a headline + +Set `chart_spec.title` to the finding, in a sentence, and `chart_spec.subtitle` +to the reading of it — what is measured, of whom, when, in what units: + +``` +title: "A pyramid that is no longer a pyramid" +subtitle: "United States population by age and sex, 2020, millions" +``` + +`Jan`, `Cairo`, `Chrome` name their own kind; `26`, `5,300`, `0.42` do not, and +the headline is where they get named. Leave it out only where the chart is not +read on its own — a sparkline in a cell, a tile under its own caption. Nothing +breaks: with no headline to lean on, the compiler keeps the axis titles instead. + +## Visual themes (`theme_spec`) + +Use one of two forms. Prefer a preset unless the user asks for a specific +brand adjustment. + +### 1. Use a preset + +Call `list_themes` to choose an id, then place it beside `chart_spec`: + +```json +{ "chart_spec": { ... }, "theme_spec": "economist" } +``` + +| id | what it is for | +| --- | --- | +| `nyt` | Newsroom graphics: headline states the finding, values on the marks, series named at their ends. | +| `economist` | Print weekly: compact, flat headline over a deck, units repeated down the ruler. | +| `swiss` | International Typographic Style: strong grid structure, black typography, and a focused red accent. | +| `nature` | Journal figure: small panel, axis titles with units, statistics beside the fit. | +| `mckinsey` | Consulting deck: wide bands, every value printed, headline states the takeaway. | +| `datawrapper` | Embedded web chart: narrow column, plain headline and deck, rule under the footer. | +| `powerbi` | Dashboard tile: compact, legend to the right, latest point emphasised. | +| `powerbi-light` | Light dashboard tile: white canvas, fine gridlines, and bright categorical color. | +| `cartoon` | Playful illustration: warm paper, rounded type, bold outlines, and bright color. | + +### 2. Override a preset + +Keep overrides narrow and state only what the user wants to change: + +```json +{ + "theme_spec": { + "extends": "economist", + "id": "our-brand", + "ink": { + "series": { + "single": "#6b3fa0" + } + } + } +} +``` + +Common simple overrides are `ink.surface.canvas`, `ink.series.single`, +`ink.series.categorical`, `type.headline.family`, and `layout.density` +(`"compact"`, `"normal"`, or `"airy"`). If replacing +`ink.series.categorical`, also replace `categoricalExtended` so charts with +many series keep the requested brand palette. + +Do not copy an entire preset or invent theme keys. A theme controls +presentation; fields, aggregation, filtering, and sorting still belong in the +chart input. ThemeSpec currently affects Vega-Lite only. + +Full reference: +https://microsoft.github.io/flint-chart/#/documentation/theme-spec + ## Step 1 — pick `chartType` Use one of the registered names **exactly**. Vega-Lite is the default and @@ -250,8 +325,8 @@ support a subset (verify if targeting a non-VL backend): `"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`, `"Parallel Coordinates"`, `"Graph"`, `"Tree"`. - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, - Combo, Line, Bump, Area, Range Area, Pie, Doughnut, Histogram, Radar, Rose, - Slope, Connected Scatter. + Lollipop, Bump, Combo, Line, Area, Range Area, Pie, Doughnut, Histogram, + Radar, Rose, Slope, Connected Scatter. You do not need to call the library or inspect its source to author the input — pick from this table. @@ -333,6 +408,29 @@ What choosing well gets you (automatically): If you don't know, use `Quantity` for numbers, `Category` for strings, `Date`/`DateTime` for date-shaped values. Do **not** invent type names. +### Saying more than the type name + +A field's entry can be an object instead of a string when the type alone +understates what you know: + +```json +"semantic_types": { + "anomaly": { "semanticType": "Quantity", "unit": "°C", "divergingMidpoint": 0 }, + "rating": { "semanticType": "Score", "intrinsicDomain": [1, 5] } +} +``` + +- `unit` — the unit or currency code: `"USD"`, `"°C"`, `"kg"`. +- `intrinsicDomain` — the field's own bounds, for bounded scales only: `[1, 5]` + for a five-star rating, `[0, 100]` for a percentage score. Not for + open-ended measures. +- `divergingMidpoint` — where the middle colour of a diverging scale sits. + Set it if you can tell what the reader is comparing against; leave it out if + you can't. +- `sortOrder` — the order the categories should appear in, when the order in + the data is not the one you want and it isn't alphabetical either: + `["Low", "Medium", "High"]`. For a handful of categories, not a long list. + ## Chart-level properties (`chartProperties`) `chartProperties` is an optional per-chart tuning map. Set a property only @@ -365,7 +463,8 @@ derived). Values are clamped to the ranges shown. | Lollipop | `dotSize` | 20–300 (80) | Circle size (px) | | Waterfall | `cornerRadius` | 0–8 (0) | Round bar corners | | Waterfall | `totals` | `auto` \| `none` \| `first` \| `last` \| `both` (`auto`) | Which bars anchor to zero as totals (only when no Type column) | -| Waterfall | `showTextLabels` | boolean (false) | Render value labels on bars | +| Waterfall | `showTextLabels` | boolean (false) | Legacy spelling of `showValueLabels`; still accepted | +| Bar / Grouped Bar / Stacked Bar / Lollipop / Pyramid / Pie / Donut / Heatmap / Waterfall | `showValueLabels` | boolean | Print the numbers on the marks. Works with or without a theme: unset, it follows the house's own habit at this density (and with no house named, stays off), so the default the compiler reports is always the honest one. Set it to overrule that for one chart. Reported inapplicable (and ignored) where the marks are too dense to carry readable numbers, or where the template already writes its own text, so it is never a control that does nothing. On a stacked bar each segment prints its own value in the middle of the segment (at the edge it would read as the running total); segments too thin to hold a line of text go unlabelled, and a normalized stack prints each segment's share rather than its raw value, since the share is what the length shows. The printed number is rounded to roughly three significant figures — with a k/M suffix once the values get long, and enough decimals that the smallest value in the series still says something — so a raw `3.14159265` lands as `3.14` and a series of `0.001` to `5000` reads at both ends. Rounding never goes so far that two marks of different size print the same number, or that a non-zero value prints as `0`; where a house asked for a coarser precision than that, the digits are raised until the labels agree with the marks. | | Regression | `regressionMethod` | `linear` \| `log` \| `exp` \| `pow` \| `quad` \| `poly` (`linear`) | Fit method | | Regression | `polyOrder` | 1–5 (3) | Polynomial order (when `poly`) | | Radar | `filled` | boolean (true) | Fill the polygon | @@ -395,6 +494,8 @@ default: - **Sort a category axis by its measure:** `encodings.x = { field: "name", sortBy: "y", sortOrder: "descending" }`. - **Pick a color scheme:** `encodings.color = { field: "region", scheme: "tableau10" }`. - **Override an inferred type:** `encodings.x = { field: "year", type: "ordinal" }` (e.g. treat a year as discrete bands). +- **Use readable field titles:** `field_display_names = { percentageOfCountries: "Percentage of countries" }`. + Keep encodings bound to the real column name; Flint uses the display name for axis titles and legend headers. - **Resize the chart:** Flint sizes from two numbers — `baseSize` (the *target* it aims for, default 400×320) and `canvasSize` (a *hard ceiling* it may never exceed). With dense data the chart stretches from base toward the ceiling. diff --git a/packages/flint-mcp/package.json b/packages/flint-mcp/package.json index 4d7fb2cc..7dec93e7 100644 --- a/packages/flint-mcp/package.json +++ b/packages/flint-mcp/package.json @@ -1,6 +1,6 @@ { "name": "flint-chart-mcp", - "version": "0.4.1", + "version": "0.5.0", "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.4.1", + "flint-chart": "^0.5.0", "vega": "^6.0.0", "vega-interpreter": "^2.2.1", "vega-lite": "^6.0.0", diff --git a/packages/flint-mcp/src/render/fonts.ts b/packages/flint-mcp/src/render/fonts.ts index 659cb653..fee35eae 100644 --- a/packages/flint-mcp/src/render/fonts.ts +++ b/packages/flint-mcp/src/render/fonts.ts @@ -22,8 +22,33 @@ export const DEFAULT_FONT_FAMILY = 'Liberation Sans'; export const CHART_FONT_FAMILY = "Arial, 'Helvetica Neue', Helvetica, 'Liberation Sans', Roboto, sans-serif"; -/** Generic/aliased family names the bundled Arial-metric font answers to. */ -const SANS_ALIASES = ['sans-serif', 'Arial', 'Helvetica', 'Liberation Sans']; +/** + * Generic/aliased family names the bundled Arial-metric font answers to. + * + * This list is what keeps text measurement and rasterisation agreeing. The two + * are done by different engines — `@napi-rs/canvas` measures, resvg draws — + * and each resolves a CSS font stack against its own view of the system. Where + * they disagree, Vega reserves a box using one font's width and resvg fills it + * with another's, so a title measured narrow and drawn wide runs off the edge + * of the canvas and is clipped. Registering the bundled face under a name + * forces the measuring side onto it, and the bundled faces are also the ones + * handed to resvg, so both sides land on the same metrics. + * + * That is why the platform sans names are here rather than left to the system. + * `Helvetica Neue` is the one that bites on macOS: it ships as a `.ttc` + * collection, which resvg's font database will not open, so resvg fell back to + * the bundled face while the canvas happily measured the real thing — 26px + * narrower over a chart title, and every house leading with that stack had its + * headline shaved off. Naming it here is not a preference about how the chart + * should look; it is the bundled Arial-metric face standing in for a + * platform-specific one, which is the same job it already does for `Helvetica` + * and `Arial`. + * + * A family only belongs here when resvg cannot draw it. Georgia and Comic Sans + * MS are deliberately absent: both engines find them, they agree to within a + * pixel, and adding them would throw away a house's typeface for nothing. + */ +const SANS_ALIASES = ['sans-serif', 'Arial', 'Helvetica', 'Helvetica Neue', 'Liberation Sans']; /** Arial-metric primary faces (registered for layout + rendering). */ const SANS_FONTS = [ diff --git a/packages/flint-mcp/src/render/vegalite.ts b/packages/flint-mcp/src/render/vegalite.ts index 098234fe..3cb6f532 100644 --- a/packages/flint-mcp/src/render/vegalite.ts +++ b/packages/flint-mcp/src/render/vegalite.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { injectCanvasFurnitureSVG, readCanvasFurniture } from 'flint-chart'; import { CHART_FONT_FAMILY, installVegaTextMetrics } from './fonts.js'; import { svgToResult } from './svg.js'; import type { RenderResult, RenderFormat } from './types.js'; @@ -52,7 +53,11 @@ export async function renderVegaLite( const view = new vega.View(runtime, { renderer: 'none' }); view.logLevel(vega.Error); await view.runAsync(); - const svg = await view.toSVG(); + // A house may anchor branding to the graphic frame rather than the plot — + // the Economist's red masthead tab. Vega-Lite cannot express that, so the + // theme records the coordinates and the renderer paints it on afterwards. + // Done before rasterising, so the PNG carries it too. + const svg = injectCanvasFurnitureSVG(await view.toSVG(), readCanvasFurniture(spec)); view.finalize(); // Vega's View width/height report the content box, excluding axes/legends. diff --git a/packages/flint-mcp/src/server.ts b/packages/flint-mcp/src/server.ts index 5a3de6ec..1355fdf3 100644 --- a/packages/flint-mcp/src/server.ts +++ b/packages/flint-mcp/src/server.ts @@ -14,7 +14,7 @@ import { renderChart, resolveDataSource } from './render/index.js'; import type { RenderBackend } from './render/types.js'; import { compileChart } from './tools/compile.js'; import { validateChart } from './tools/validate.js'; -import { listChartTypes } from './tools/list.js'; +import { listChartTypes, listThemes } from './tools/list.js'; import { buildAssemblyInputShape, toAssemblyInput, @@ -141,7 +141,9 @@ export function createServer(options: CreateServerOptions = {}): McpServer { 'when the host has no App UI support or the user explicitly wants a ' + 'static image. Use compile_chart for the backend spec JSON, ' + 'validate_chart to check a spec, and list_chart_types to discover chart ' + - 'types and their channels. Before authoring specs, read the ' + + 'types and their channels. Use list_themes to discover visual themes; ' + + 'prefer a preset id, and use an `extends` override only when the user ' + + 'asks to customize it. Before authoring specs, read the ' + 'flint://agent-skill resource or use the author_flint_chart prompt.' + dataAccessNote(options), }, @@ -271,6 +273,29 @@ export function createServer(options: CreateServerOptions = {}): McpServer { }, ); + // --- list_themes -------------------------------------------------------- + server.registerTool( + 'list_themes', + { + title: 'List themes', + description: + 'List Flint visual theme presets for `theme_spec` (for example, ' + + '`theme_spec: "economist"`). Pass an `id` for preset-specific authoring ' + + 'guidance. To customize a preset, use an object with `extends` and a ' + + 'small set of overrides.', + inputSchema: { + id: z.string().optional(), + }, + }, + async (args: any) => { + try { + return jsonResult(listThemes(args?.id as string | undefined)); + } catch (err) { + return errorResult(err); + } + }, + ); + // --- create_chart_view (MCP App: interactive chart + config UI) --------- registerAppTool( server, diff --git a/packages/flint-mcp/src/tools/list.ts b/packages/flint-mcp/src/tools/list.ts index ddc86d8d..7490e8ae 100644 --- a/packages/flint-mcp/src/tools/list.ts +++ b/packages/flint-mcp/src/tools/list.ts @@ -5,6 +5,8 @@ import { vlAllTemplateDefs, ecAllTemplateDefs, cjsAllTemplateDefs, + listThemePresets, + THEME_PRESETS, type ChartTemplateDef, } from 'flint-chart'; import type { RenderBackend } from '../render/types.js'; @@ -43,3 +45,24 @@ export function listChartTypes(backend?: RenderBackend): BackendCatalog[] { return { backend: b, count: chartTypes.length, chartTypes }; }); } + +/** + * The design languages Flint ships, to be named in `theme_spec`. + * + * Without an `id`, the catalogue: enough to choose by. With one, that house's + * guidance too — what an author has to supply for it to work, which is worth + * reading before writing the chart spec, not after. + */ +export function listThemes(id?: string) { + if (!id) return { themes: listThemePresets() }; + const preset = THEME_PRESETS[id]; + if (!preset) { + throw new Error( + `Unknown theme \`${id}\`. Flint ships: ${Object.keys(THEME_PRESETS).join(', ')}.`, + ); + } + // `spec` is the compiler's business and `icon` is a picker's; neither + // helps an agent decide, and both are large. + const { spec: _spec, icon: _icon, ...rest } = preset; + return rest; +} diff --git a/packages/flint-mcp/src/tools/schemas.ts b/packages/flint-mcp/src/tools/schemas.ts index de8176f1..294851ca 100644 --- a/packages/flint-mcp/src/tools/schemas.ts +++ b/packages/flint-mcp/src/tools/schemas.ts @@ -54,6 +54,16 @@ export const chartSpecSchema = z chartType: z .string() .describe('Chart template name, e.g. "Bar Chart", "Scatter Plot", "Heatmap".'), + title: z + .string() + .optional() + .describe( + 'Headline: what this chart says, in words. Strongly recommended — many design languages drop axis titles and rely on the headline to name the measure, so a chart without one reads as unlabelled numbers.', + ), + subtitle: z + .string() + .optional() + .describe('Deck: what is measured, of whom, when, and in what units. E.g. "US population by age and sex, 2020, millions".'), encodings: z .record(z.string(), z.any()) .describe( @@ -88,8 +98,16 @@ export function buildAssemblyInputShape(disableFileReference = false) { semantic_types: z .record(z.string(), z.any()) .optional() - .describe('Field name → semantic type, e.g. { revenue: "Quantity", country: "Country" }.'), + .describe( + 'Field name → semantic type, e.g. { revenue: "Quantity", country: "Country" }. A value may instead be an annotation object when the type alone understates what you know, e.g. { rating: { semanticType: "Score", intrinsicDomain: [1, 5] } } — see the flint://agent-skill resource.', + ), chart_spec: chartSpecSchema, + theme_spec: z + .union([z.string(), z.record(z.string(), z.any())]) + .optional() + .describe( + 'Visual theme for Vega-Lite. Prefer a preset id from list_themes (e.g. "economist"). To customize it, pass an object with `extends` plus a small set of overrides. Full guide: https://microsoft.github.io/flint-chart/#/documentation/theme-spec', + ), options: z .record(z.string(), z.any()) .optional() @@ -110,11 +128,14 @@ export type AssemblyInputArgs = { chart_spec: { chartType: string; encodings: Record; + title?: string; + subtitle?: string; baseSize?: { width: number; height: number }; canvasSize?: { width: number; height: number }; chartProperties?: Record; }; options?: Record; + theme_spec?: string | Record; field_display_names?: Record; }; @@ -124,6 +145,7 @@ export function toAssemblyInput(args: AssemblyInputArgs): ChartAssemblyInput { data: args.data, semantic_types: args.semantic_types, chart_spec: args.chart_spec, + theme_spec: args.theme_spec, options: args.options, field_display_names: args.field_display_names, } as ChartAssemblyInput; diff --git a/packages/flint-mcp/tests/http.test.ts b/packages/flint-mcp/tests/http.test.ts index cc1b49b0..60264563 100644 --- a/packages/flint-mcp/tests/http.test.ts +++ b/packages/flint-mcp/tests/http.test.ts @@ -15,6 +15,11 @@ const barChart = { chart_spec: { chartType: 'Bar Chart', encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + // Real calls carry a headline: it is the first thing a house styles, and + // some (the Economist's masthead tab) hang furniture off it. A titleless + // fixture exercises a chart nobody actually asks for. + title: 'Revenue by region', + subtitle: 'FY2024, $m', baseSize: { width: 320, height: 220 }, }, }; @@ -43,6 +48,7 @@ describe('MCP server over HTTP (stateless streamable transport)', () => { 'compile_chart', 'create_chart_view', 'list_chart_types', + 'list_themes', 'render_chart', 'validate_chart', ]); diff --git a/packages/flint-mcp/tests/options.test.ts b/packages/flint-mcp/tests/options.test.ts new file mode 100644 index 00000000..1063306d --- /dev/null +++ b/packages/flint-mcp/tests/options.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import type { ChartAssemblyInput } from 'flint-chart'; +import { buildPanelModel, setProperty, withTheme } from '../ui/src/options.js'; + +/** A two-series line chart — the shape the NYT house puts points on. */ +const lines: ChartAssemblyInput = { + data: { + values: [ + { month: '2024-01-01', sales: 3, region: 'North' }, + { month: '2024-02-01', sales: 5, region: 'North' }, + { month: '2024-03-01', sales: 4, region: 'North' }, + { month: '2024-01-01', sales: 2, region: 'South' }, + { month: '2024-02-01', sales: 6, region: 'South' }, + { month: '2024-03-01', sales: 7, region: 'South' }, + ], + }, + semantic_types: { month: 'Date', sales: 'Quantity', region: 'Category' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: { field: 'month' }, y: { field: 'sales' }, color: { field: 'region' } }, + title: 'Sales by region', + }, +}; + +function optionValue(input: ChartAssemblyInput, key: string): unknown { + return buildPanelModel(input).properties.find((option) => option.key === key)?.value; +} + +describe('house defaults reach the options bar', () => { + it('shows the house\'s own value for a property nobody has set', () => { + expect(optionValue(lines, 'showPoints')).toBe(false); + expect(optionValue(withTheme(lines, 'nyt'), 'showPoints')).toBe(true); + }); + + it('keeps a value that disagrees with the house it was set under', () => { + const nyt = withTheme(lines, 'nyt'); + const off = setProperty(nyt, 'showPoints', false); + // Said against a house that wanted points — a decision, and it survives. + expect(optionValue(withTheme(off, 'economist'), 'showPoints')).toBe(false); + }); + + it('lets go of a value that only echoed the house it was set under', () => { + // Toggled on and back off under flint: the result says nothing flint had + // not already said, so it must not outrank the next house. + const fiddled = setProperty(lines, 'showPoints', false); + expect(optionValue(fiddled, 'showPoints')).toBe(false); + expect(optionValue(withTheme(fiddled, 'nyt'), 'showPoints')).toBe(true); + }); + + it('does not disturb properties the reader never touched', () => { + const themed = withTheme(setProperty(lines, 'interpolate', 'monotone'), 'nyt'); + expect(optionValue(themed, 'interpolate')).toBe('monotone'); + expect(optionValue(themed, 'showPoints')).toBe(true); + }); + + it('leaves the input alone', () => { + const before = JSON.stringify(lines); + withTheme(setProperty(lines, 'showPoints', false), 'nyt'); + expect(JSON.stringify(lines)).toBe(before); + }); +}); diff --git a/packages/flint-mcp/tests/preview-size.test.ts b/packages/flint-mcp/tests/preview-size.test.ts new file mode 100644 index 00000000..06303b07 --- /dev/null +++ b/packages/flint-mcp/tests/preview-size.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The preview must render into the room it will actually be shown in. + * + * When it renders larger, the `.chart` rule scales the finished SVG down to + * fit and takes the type with it — an 11px axis label displayed at 0.72x lands + * at 7.9px, smaller than the app's own 11px chrome. These tests pin the sizing + * contract that keeps the graphic at 1:1. + */ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; +import { compile } from 'vega-lite'; +import { parse, View } from 'vega'; +import type { ChartAssemblyInput } from 'flint-chart'; +import { THEME_PRESETS } from 'flint-chart'; +import { assemblePreviewSpec, APP_PREVIEW_CANVAS_SIZE } from '../ui/src/render.js'; + +/** A titled, decked, banded bar chart — the shape a real tool call produces. */ +function bars(themeId?: string): ChartAssemblyInput { + const input: ChartAssemblyInput = { + data: { + values: [ + { region: 'North', revenue: 128 }, + { region: 'South', revenue: 94 }, + { region: 'East', revenue: 156 }, + { region: 'West', revenue: 112 }, + { region: 'Central', revenue: 88 }, + ], + }, + semantic_types: { region: 'Category', revenue: 'Money' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + title: 'Revenue by region', + subtitle: 'FY2024, $m', + }, + }; + if (themeId) input.theme_spec = THEME_PRESETS[themeId].spec; + return input; +} + +/** The rendered size of a spec, as the browser would lay it out. */ +async function renderedSize(spec: Record): Promise<{ width: number; height: number }> { + const view = new View(parse(compile(spec as never).spec as never, { background: '#fff' } as never), { + renderer: 'none', + }); + await view.runAsync(); + const svg = await view.toSVG(); + view.finalize(); + return { + width: Number(svg.match(/]*\swidth="([\d.]+)"/)?.[1] ?? 0), + height: Number(svg.match(/]*\sheight="([\d.]+)"/)?.[1] ?? 0), + }; +} + +describe('the preview renders into the room it is given', () => { + it('keeps the graphic within the frame it was measured at', async () => { + // 468 is --chart-max-height in styles.css; the frame grows to the chart + // up to that, so only the width has to be respected exactly. + for (const width of [320, 440, 584]) { + for (const house of ['swiss', 'economist', 'nature', 'mckinsey']) { + const size = await renderedSize(assemblePreviewSpec(bars(house), { width })); + // A little overshoot is absorbed by the axis chrome that sits outside + // the plot ceiling; a large one means the type is being scaled down. + expect(size.width, `${house} at ${width}px`).toBeLessThanOrEqual(width * 1.08); + expect(size.height, `${house} at ${width}px`).toBeLessThanOrEqual(468 * 1.05); + } + } + }); + + it('gives a wider frame a wider chart', async () => { + const narrow = await renderedSize(assemblePreviewSpec(bars('swiss'), { width: 320 })); + const wide = await renderedSize(assemblePreviewSpec(bars('swiss'), { width: 584 })); + expect(wide.width).toBeGreaterThan(narrow.width); + }); + + it('ignores a viewport too small to be a real measurement', async () => { + // A collapsed or unmounted box must not shrink the chart to nothing. + const collapsed = await renderedSize(assemblePreviewSpec(bars('swiss'), { width: 0 })); + const absent = await renderedSize(assemblePreviewSpec(bars('swiss'))); + expect(collapsed).toEqual(absent); + }); + + it('still lets a caller state its own size', async () => { + // A stated size opts out of the preview's sizing entirely, so the measured + // frame must make no difference at all. + const stated = () => { + const input = bars('swiss'); + input.chart_spec.baseSize = { width: 700, height: 300 }; + input.chart_spec.canvasSize = { width: 700, height: 300 }; + return input; + }; + const narrowFrame = await renderedSize(assemblePreviewSpec(stated(), { width: 320 })); + const wideFrame = await renderedSize(assemblePreviewSpec(stated(), { width: 584 })); + const noFrame = await renderedSize(assemblePreviewSpec(stated())); + expect(narrowFrame).toEqual(wideFrame); + expect(narrowFrame).toEqual(noFrame); + }); + + it('leaves the house in charge of the type', async () => { + // The frame decides how much room there is, not how big the type is: the + // same house must read the same at any frame width it fits in. + const at = (width: number) => + (assemblePreviewSpec(bars('swiss'), { width }) as any).config?.title?.fontSize; + expect(at(584)).toBe(at(520)); + expect(at(584)).toBeGreaterThan(14); + }); + + it('draws to the same height the stylesheet will allow', () => { + // These two are the same measurement written in two languages. If they + // drift, the chart is drawn to one size and displayed at another, and the + // difference comes back as a scrollbar or as shrunken type. + const css = readFileSync(new URL('../ui/src/styles.css', import.meta.url), 'utf8'); + const declared = css.match(/--chart-max-height:\s*(\d+)px/)?.[1]; + expect(declared, '--chart-max-height missing from styles.css').toBeDefined(); + expect(Number(declared)).toBe(APP_PREVIEW_CANVAS_SIZE.height); + }); +}); diff --git a/packages/flint-mcp/tests/render.test.ts b/packages/flint-mcp/tests/render.test.ts index 4f1df9d4..c30d5384 100644 --- a/packages/flint-mcp/tests/render.test.ts +++ b/packages/flint-mcp/tests/render.test.ts @@ -18,6 +18,11 @@ const sales: ChartAssemblyInput = { chart_spec: { chartType: 'Bar Chart', encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + // Real calls carry a headline: it is the first thing a house styles, and + // some (the Economist's masthead tab) hang furniture off it. A titleless + // fixture exercises a chart nobody actually asks for. + title: 'Revenue by region', + subtitle: 'FY2024, $m', baseSize: { width: 360, height: 240 }, }, }; @@ -116,3 +121,29 @@ describe('input guards', () => { } }); }); + +describe('house styling survives the render', () => { + // The Economist's red masthead tab is anchored to the graphic frame, not the + // plot, so Vega-Lite cannot draw it — the theme records its coordinates and + // the renderer paints it on afterwards. That last step is easy to lose (the + // spec still looks correct), so it is asserted on the artifact. + it('paints canvas-anchored furniture into the SVG', async () => { + const plain = await renderChart(sales, 'vegalite', { format: 'svg' }); + const themed = await renderChart( + { ...sales, theme_spec: 'economist' }, + 'vegalite', + { format: 'svg' }, + ); + expect(plain.svg).not.toContain('#e3120b'); + expect(themed.svg).toContain('#e3120b'); + }); + + it('carries the tab through to the PNG', async () => { + const themed = await renderChart( + { ...sales, theme_spec: 'economist' }, + 'vegalite', + { format: 'png' }, + ); + expect(themed.buffer!.length).toBeGreaterThan(1000); + }); +}); diff --git a/packages/flint-mcp/tests/server.test.ts b/packages/flint-mcp/tests/server.test.ts index bf3300a5..ae43c173 100644 --- a/packages/flint-mcp/tests/server.test.ts +++ b/packages/flint-mcp/tests/server.test.ts @@ -19,6 +19,11 @@ const barChart = { chart_spec: { chartType: 'Bar Chart', encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + // Real calls carry a headline: it is the first thing a house styles, and + // some (the Economist's masthead tab) hang furniture off it. A titleless + // fixture exercises a chart nobody actually asks for. + title: 'Revenue by region', + subtitle: 'FY2024, $m', baseSize: { width: 320, height: 220 }, }, }; @@ -52,6 +57,7 @@ describe('MCP server', () => { 'compile_chart', 'create_chart_view', 'list_chart_types', + 'list_themes', 'render_chart', 'validate_chart', ]); diff --git a/packages/flint-mcp/ui/src/FlintApp.tsx b/packages/flint-mcp/ui/src/FlintApp.tsx index e00165cd..b368db35 100644 --- a/packages/flint-mcp/ui/src/FlintApp.tsx +++ b/packages/flint-mcp/ui/src/FlintApp.tsx @@ -15,12 +15,15 @@ import { useApp } from '@modelcontextprotocol/ext-apps/react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { ChartAssemblyInput, ChartOption } from 'flint-chart'; +import { THEME_PRESETS, DEFAULT_THEME_ICON } from 'flint-chart'; import { renderFlintSvg, type FlintRenderResult } from './render'; import { chartIconFor } from './chart-icons'; import { buildPanelModel, setProperty, + valueKey, + withTheme, type PanelModel, type ResolvedAction, } from './options'; @@ -33,11 +36,6 @@ type ControlSpec = | { type: 'discrete'; options: { value: unknown; label: string }[] } | { type: 'binary' }; -/** Stable string key for an arbitrary option value (handles undefined/objects). */ -function valueKey(value: unknown): string { - return JSON.stringify(value ?? null); -} - function compactSelectLabel(label: string): string { const withoutHint = label.replace(/\s*\([^)]*\)\s*$/u, '').trim(); if (withoutHint.length <= 16) return withoutHint; @@ -267,7 +265,7 @@ function TransformControl(props: { (canSwitchType ? ( + + {open && ( +
    + {THEME_CHOICES.map((choice) => { + const selected = choice.id === current.id; + return ( +
  • { + onTheme(choice.id); + setOpen(false); + }} + > + + {choice.label} +
  • + ); + })} +
+ )} + + ); +} + function ActionButton(props: { label: string; className?: string; @@ -443,6 +530,10 @@ function OptionsBar(props: { return (
+ onInput(withTheme(input, id))} + /> {((model.chartType && model.chartType.length > 1) || (model.arrange && model.arrange.length > 1)) && ( ('idle'); const [copyError, setCopyError] = useState(null); const renderSeq = useRef(0); + // The width the chart actually has. Rendering into the real width means the + // finished SVG is shown at 1:1 rather than being scaled down to fit, which + // is what otherwise shrinks every label below the app's own chrome. Height + // is deliberately not measured: the frame grows to the chart. + const [chartWidth, setChartWidth] = useState(null); + const chartBoxObserver = useRef(null); + + // Measure the chart frame, quantised so a scrollbar appearing and vanishing + // cannot start an oscillation between two neighbouring sizes. + const measureChartBox = useCallback((node: HTMLDivElement | null) => { + chartBoxObserver.current?.disconnect(); + chartBoxObserver.current = null; + if (!node || typeof ResizeObserver === 'undefined') return; + const read = () => { + const style = window.getComputedStyle(node); + // `clientWidth` already excludes any scrollbar, so the reading does not + // shrink in response to the chart it is measuring. + const width = node.clientWidth + - parseFloat(style.paddingLeft || '0') - parseFloat(style.paddingRight || '0'); + if (!(width > 0)) return; + const next = Math.max(0, Math.floor(width / 8) * 8); + setChartWidth((prev) => (prev === next ? prev : next)); + }; + read(); + const observer = new ResizeObserver(read); + observer.observe(node); + chartBoxObserver.current = observer; + }, []); + useEffect(() => () => chartBoxObserver.current?.disconnect(), []); // Re-seed when a new tool input arrives from the host. useEffect(() => setCurrent(input), [input]); @@ -533,7 +653,7 @@ export function FlintAppInner(props: { const seq = ++renderSeq.current; setCopyStatus('idle'); const handle = setTimeout(() => { - renderFlintSvg(current) + renderFlintSvg(current, undefined, chartWidth ? { width: chartWidth } : undefined) .then((result) => { if (seq === renderSeq.current) { setRender(result); @@ -547,12 +667,32 @@ export function FlintAppInner(props: { }); }, 100); return () => clearTimeout(handle); - }, [current]); + }, [current, chartWidth]); const model = useMemo(() => buildPanelModel(current), [current]); + + // The frame the chart sits in takes the chart's own paper. + // + // A house that paints a canvas — Swiss's cream, PowerBI's near black — puts + // that colour inside the SVG, and the SVG is only as big as the graphic. The + // frame is not: it holds a floor height and centres what it is given, so the + // painted rectangle ends up floating in a white surround with a hard edge + // around it, looking like a picture pasted onto the page rather than the + // surface the chart is drawn on. + // + // Reading the colour back off the assembled spec rather than the house is + // deliberate. `background` is where the theme records its resolved surface, + // so this follows houses that defer the decision to their host as well as + // ones that make it themselves, and it needs no list of which is which. + const surface = typeof render?.vlSpec?.background === 'string' + ? render.vlSpec.background + : undefined; + const canReset = useMemo( - () => JSON.stringify(current.chart_spec) !== JSON.stringify(input.chart_spec), - [current.chart_spec, input.chart_spec], + () => + JSON.stringify(current.chart_spec) !== JSON.stringify(input.chart_spec) || + JSON.stringify(current.theme_spec ?? null) !== JSON.stringify(input.theme_spec ?? null), + [current.chart_spec, input.chart_spec, current.theme_spec, input.theme_spec], ); const handleCopyPng = useCallback(async () => { @@ -623,10 +763,18 @@ export function FlintAppInner(props: { Could not render chart
{error}
- ) : render ? ( -
) : ( -
Rendering…
+ // The frame is always mounted, so its size is known before the first + // render and the chart can be assembled to fit it straight away. +
+ {render + ?
+ : Rendering…} +
)} {warnings.length > 0 && ( diff --git a/packages/flint-mcp/ui/src/options.ts b/packages/flint-mcp/ui/src/options.ts index 0f8b83e9..b5466759 100644 --- a/packages/flint-mcp/ui/src/options.ts +++ b/packages/flint-mcp/ui/src/options.ts @@ -30,6 +30,11 @@ import type { RawEncodingValue, } from 'flint-chart'; +/** Stable string key for an arbitrary option value (handles undefined/objects). */ +export function valueKey(value: unknown): string { + return JSON.stringify(value ?? null); +} + /** A resolved encoding action ready for rendering (control + current value). */ export interface ResolvedAction { key: string; @@ -238,6 +243,64 @@ export function setChannelField( return next; } +/** + * Drop the chart properties that only restate what the current configuration + * would have chosen anyway. + * + * A control the reader nudged and a control that happens to sit where it was + * put are stored the same way — as an explicit value — so once written, a + * value outranks every house forever. That is right when it is a decision and + * wrong when it is an echo, and after a theme change the difference is + * visible: the reader picks a house whose line carries points and gets a bare + * line, because a `showPoints: false` identical to the old house's default is + * still sitting there outranking it. + * + * A value that agrees with what the chart would have done unaided says + * nothing, so it is not carried across. Anything that disagrees is a real + * choice and stays. + */ +function withoutEchoedProperties(input: ChartAssemblyInput): ChartAssemblyInput { + const stated = input.chart_spec.chartProperties; + const keys = Object.keys(stated ?? {}); + if (!keys.length) return input; + const kept: Record = {}; + for (const key of keys) { + const rest = { ...stated }; + delete rest[key]; + const unaided = { ...input, chart_spec: { ...input.chart_spec, chartProperties: rest } }; + let fallback: unknown; + try { + fallback = getChartOptions(unaided).find((option) => option.key === key)?.value; + } catch { + // The chart does not compile without it — not ours to judge; keep it. + fallback = undefined; + } + if (fallback === undefined || valueKey(fallback) !== valueKey(stated![key])) { + kept[key] = stated![key]; + } + } + return { ...input, chart_spec: { ...input.chart_spec, chartProperties: kept } }; +} + +/** + * Name the house the chart is drawn in, or clear it back to flint's own + * defaults. `theme_spec` sits at the top of the input rather than inside + * `chart_spec` because it is not a property of this chart — the same house + * applies whatever the chart turns out to be. + * + * A house does not only restyle a chart; it can change what the chart *is* — + * the NYT puts points on a line, Nature puts points in a box plot. Those are + * defaults, so they yield to anything stated, which means the panel has to let + * go of the values it is only holding by inheritance before the new house can + * speak. See {@link withoutEchoedProperties}. + */ +export function withTheme(input: ChartAssemblyInput, themeId: string | undefined): ChartAssemblyInput { + const next = cloneInput(withoutEchoedProperties(input)); + if (themeId === undefined) delete next.theme_spec; + else next.theme_spec = themeId; + return next; +} + /** * Set (or reset, when value is undefined) a chart property or encoding-action * override. Both are stored under `chart_spec.chartProperties[key]`. diff --git a/packages/flint-mcp/ui/src/render.ts b/packages/flint-mcp/ui/src/render.ts index ebce92d8..a27387e2 100644 --- a/packages/flint-mcp/ui/src/render.ts +++ b/packages/flint-mcp/ui/src/render.ts @@ -7,7 +7,7 @@ * the chart re-renders instantly as the user edits options. No server round * trip, no data leaving the host. */ -import { assembleVegaLite } from 'flint-chart'; +import { assembleVegaLite, injectCanvasFurnitureSVG, readCanvasFurniture, resolveThemeSpec } from 'flint-chart'; import type { ChartAssemblyInput } from 'flint-chart'; import { compile } from 'vega-lite'; import { parse, View, Error as VegaError } from 'vega'; @@ -28,7 +28,26 @@ export interface FlintRenderResult { const DEFAULT_BACKGROUND = '#ffffff'; const APP_PREVIEW_BASE_SIZE = { width: 360, height: 270 } as const; -const APP_PREVIEW_CANVAS_SIZE = { width: 720, height: 540 } as const; +/** + * The room the preview has when nobody has measured it. + * + * This is a *ceiling*, not a footprint: it is how far the layout may stretch, + * and it must match the box the SVG is finally shown in. When it does not, the + * `.chart` rule (`max-width/height: 100%`) quietly scales the finished graphic + * down to fit, and the type goes with it — an 11px axis label rendered into a + * 540px-tall frame and displayed in a 348px one lands at 7.9px, smaller than + * the app's own 11px chrome. + * + * Width is the host's to dictate, so the app measures it and passes it in. + * Height is not: the frame grows to whatever the chart needs, because a title, + * a deck, a legend and an axis can easily take 145px before the plot gets any, + * and squeezing the plot to fit a short frame shrinks the type faster than the + * frame saves it. This height is a backstop, and matches `--chart-max-height` + * in `styles.css`. + */ +export const APP_PREVIEW_CANVAS_SIZE = { width: 588, height: 468 } as const; +/** Below this a viewport reading is noise (a collapsed or unmounted box). */ +const MIN_VIEWPORT_WIDTH = 240; const APP_PREVIEW_MIN_STEP_PLOT_SIZE = { width: 220, height: 160 } as const; const APP_PREVIEW_MAX_AUTO_STEP = 96; const COPY_PNG_TARGET_LONG_EDGE = 1920; @@ -44,14 +63,63 @@ function usesAutoPreviewSize(input: ChartAssemblyInput): boolean { return !input.chart_spec.baseSize && !input.chart_spec.canvasSize; } -function withAppPreviewDefaults(input: ChartAssemblyInput): ChartAssemblyInput { +/** + * The footprint the chosen house draws at, if it states one. + * + * A house's `baseSize` is not a stray number: it is the measure the rest of the + * style was set against — the Economist's wide print column, Nature's narrow + * single-column figure — and the type scale is read off it. Handing the + * assembler the app's own preview size instead pre-empts it, and every house + * comes out at the same size in the same type, which is the one thing a set of + * houses must not do. + */ +function houseBaseSize(input: ChartAssemblyInput): { width: number; height: number } | undefined { + try { + return resolveThemeSpec(input.theme_spec)?.compileDefaults?.baseSize; + } catch { + // An unknown house is the assembler's error to report, not ours to swallow. + return undefined; + } +} + +/** + * The ceiling to render into: the measured frame width when the app has one, + * the default otherwise. Readings are floored to whole pixels and rejected + * when implausibly small, so a transient zero-size layout pass cannot collapse + * the chart. + */ +function previewCanvasSize(viewport?: { width: number; height?: number }): { width: number; height: number } { + const height = Number.isFinite(viewport?.height) && (viewport!.height as number) > 0 + ? Math.floor(viewport!.height as number) + : APP_PREVIEW_CANVAS_SIZE.height; + const width = Math.floor(viewport?.width ?? NaN); + if (!Number.isFinite(width) || width < MIN_VIEWPORT_WIDTH) { + return { width: APP_PREVIEW_CANVAS_SIZE.width, height }; + } + return { width, height }; +} + +function withAppPreviewDefaults( + input: ChartAssemblyInput, + viewport?: { width: number; height?: number }, +): ChartAssemblyInput { if (!usesAutoPreviewSize(input)) return input; + // The ceiling is the widget's business — it is how much room there is. The + // footprint is the house's, and the app's preview size only stands in where + // no house has spoken. + const base = houseBaseSize(input) ?? APP_PREVIEW_BASE_SIZE; + const ceiling = previewCanvasSize(viewport); return { ...input, chart_spec: { ...input.chart_spec, - baseSize: { ...APP_PREVIEW_BASE_SIZE }, - canvasSize: { ...APP_PREVIEW_CANVAS_SIZE }, + // A footprint larger than the room available is not a footprint; clamp + // it so the house still leads but cannot overflow the frame. + baseSize: { + width: Math.min(base.width, ceiling.width), + height: Math.min(base.height, ceiling.height), + }, + canvasSize: ceiling, }, }; } @@ -72,43 +140,94 @@ function uniqueValueCount(input: ChartAssemblyInput, field: string | undefined): return new Set(rows.map((row) => row?.[field]).filter((value) => value != null)).size; } -function applyStepMinimum(node: unknown, dimension: 'width' | 'height', itemCount: number): void { +/** + * A step-sized plot (one band per category) asks for only as much room as its + * bands need, so five categories can leave half a wide frame empty. When the + * preview knows how much room it has, it spends it: the bands grow until the + * plot fills the frame, less an allowance for the axis, legend and title block + * around it. `APP_PREVIEW_MAX_AUTO_STEP` still caps how fat a single band may + * get, so a two-category chart does not become two enormous slabs. + * + * This runs after assembly, so it only moves the step — the labels keep the + * size and angle chosen for the original one. That is why the cap matters: + * widen far enough and the axis would keep labels rotated as though it were + * still cramped. Letting the assembler size bands to the frame up front is the + * real fix, and belongs in the layout rather than here. + */ +const APP_PREVIEW_CHROME_ALLOWANCE = { width: 110, height: 170 } as const; + +function stepTarget(dimension: 'width' | 'height', ceiling: { width: number; height: number }): number { + return Math.max( + APP_PREVIEW_MIN_STEP_PLOT_SIZE[dimension], + ceiling[dimension] - APP_PREVIEW_CHROME_ALLOWANCE[dimension], + ); +} + +function applyStepMinimum( + node: unknown, + dimension: 'width' | 'height', + itemCount: number, + minPlotSize: number, +): void { if (!node || typeof node !== 'object' || itemCount <= 0) return; const record = node as Record; const size = record[dimension]; if (size && typeof size === 'object') { const stepSize = (size as { step?: unknown }).step; if (typeof stepSize === 'number' && Number.isFinite(stepSize)) { - const minPlotSize = APP_PREVIEW_MIN_STEP_PLOT_SIZE[dimension]; const desiredStep = Math.min(APP_PREVIEW_MAX_AUTO_STEP, Math.ceil(minPlotSize / itemCount)); if (stepSize < desiredStep) { record[dimension] = { ...(size as Record), step: desiredStep }; } } } - applyStepMinimum(record.spec, dimension, itemCount); + applyStepMinimum(record.spec, dimension, itemCount, minPlotSize); } -function widenSmallStepPlotsForPreview(vlSpec: Record, input: ChartAssemblyInput): void { +function widenSmallStepPlotsForPreview( + vlSpec: Record, + input: ChartAssemblyInput, + ceiling: { width: number; height: number }, +): void { const xCount = uniqueValueCount(input, encodingField(input, 'x')); const yCount = uniqueValueCount(input, encodingField(input, 'y')); - applyStepMinimum(vlSpec, 'width', xCount); - applyStepMinimum(vlSpec, 'height', yCount); + applyStepMinimum(vlSpec, 'width', xCount, stepTarget('width', ceiling)); + applyStepMinimum(vlSpec, 'height', yCount, stepTarget('height', ceiling)); +} + +/** + * Assemble the preview's Vega-Lite spec: give the chart the room the frame + * actually has, then let step-sized plots spend it. + * + * Split out from {@link renderFlintSvg} so the sizing can be exercised without + * a browser canvas, which the PNG half of the render needs and Node has not. + */ +export function assemblePreviewSpec( + input: ChartAssemblyInput, + viewport?: { width: number; height?: number }, +): Record { + const usePreviewDefaults = usesAutoPreviewSize(input); + const previewInput = withAppPreviewDefaults(input, viewport); + const spec = assembleVegaLite(previewInput) as Record; + if (usePreviewDefaults) widenSmallStepPlotsForPreview(spec, previewInput, previewCanvasSize(viewport)); + return spec; } /** * Assemble a Flint {@link ChartAssemblyInput} to a Vega-Lite spec and render it * to an SVG string. Throws on assembly or compile failure so the caller can * surface the message. + * + * `viewport` is the width the SVG will be displayed in (and, optionally, a + * height ceiling). Passing it lets the layout use exactly the room it has, so + * the result is shown at 1:1 and the type keeps the size its house chose. */ export async function renderFlintSvg( input: ChartAssemblyInput, background: string = DEFAULT_BACKGROUND, + viewport?: { width: number; height?: number }, ): Promise { - const usePreviewDefaults = usesAutoPreviewSize(input); - const previewInput = withAppPreviewDefaults(input); - const raw = assembleVegaLite(previewInput) as Record; - if (usePreviewDefaults) widenSmallStepPlotsForPreview(raw, previewInput); + const raw = assemblePreviewSpec(input, viewport); const warnings = (raw._warnings as FlintRenderResult['warnings']) ?? []; // Pass the assembled spec straight through: Vega-Lite ignores unknown // top-level keys, so Flint's private annotations (`_warnings`, `_width`, @@ -129,7 +248,23 @@ export async function renderFlintSvg( view.logLevel(VegaError); await view.runAsync(); const pngScale = copyPngScale(view.width(), view.height()); - const [svg, canvas] = await Promise.all([view.toSVG(), view.toCanvas(pngScale)]); + const [rawSvg, canvas] = await Promise.all([view.toSVG(), view.toCanvas(pngScale)]); + // Canvas-anchored furniture (the Economist masthead tab) is painted after + // Vega is done, because it belongs to the graphic frame rather than the plot + // and Vega-Lite has no way to say so. Both artifacts get it: the SVG by + // markup, the PNG by drawing straight onto the same view's canvas at the + // scale it was rasterised at. + const furniture = readCanvasFurniture(vlSpec); + const svg = injectCanvasFurnitureSVG(rawSvg, furniture); + if (furniture.length) { + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D | null; + if (ctx) { + for (const item of furniture) { + ctx.fillStyle = item.color; + ctx.fillRect(item.x * pngScale, item.y * pngScale, item.width * pngScale, item.height * pngScale); + } + } + } const png = await new Promise((resolve, reject) => { canvas.toBlob( (blob) => blob ? resolve(blob) : reject(new Error('Could not encode chart PNG')), diff --git a/packages/flint-mcp/ui/src/styles.css b/packages/flint-mcp/ui/src/styles.css index 8b0a4ea7..9981a313 100644 --- a/packages/flint-mcp/ui/src/styles.css +++ b/packages/flint-mcp/ui/src/styles.css @@ -15,6 +15,9 @@ --font: Arial, Roboto, "Helvetica Neue", sans-serif; --option-cell-width: 176px; --option-readout-width: 44px; + /* The tallest a chart may be drawn. Keep in step with the height in + APP_PREVIEW_CANVAS_SIZE (packages/flint-mcp/ui/src/render.ts). */ + --chart-max-height: 468px; } * { @@ -53,35 +56,52 @@ body { min-width: 0; } +/* The chart frame. Its width is measured and handed to the assembler, so the + SVG should already fit across; the `max-*` rules are a backstop for specs + that state their own size and so opt out of the preview's sizing. + + Height is deliberately not fixed. A title, deck, legend and axis can take + 145px before the plot gets any, so pinning a short frame and scaling the + graphic down shrinks the type below the app's own 11px chrome. The frame + grows to the chart instead, up to --chart-max-height. + + That cap is a length, not a percentage, and is repeated on the SVG below. + A percentage `max-height` resolves against the parent's height, and this + parent has none to give — so the SVG would render at full height, the frame + would stop at its cap, and the difference would become a scrollbar. */ .chart { background: var(--paper); padding: 6px; display: flex; width: 100%; max-width: 600px; - height: 75vw; - max-height: 360px; + min-height: 220px; + /* Content box + the 6px padding either side (box-sizing: border-box). */ + max-height: calc(var(--chart-max-height) + 12px); margin-inline: auto; justify-content: center; align-items: center; overflow: auto; } +/* The SVG is the flex item; this wrapper only carries the markup. */ +.chart-svg { + display: contents; +} + +.chart-pending { + color: var(--muted); +} + .chart svg { display: block; width: auto; height: auto; max-width: 100%; - max-height: 100%; + max-height: var(--chart-max-height); flex: none; } -.placeholder { - border: 1px solid var(--hairline); - padding: 40px; - text-align: center; - color: var(--muted); -} .error { border: 1px solid var(--hairline); diff --git a/packages/flint-py/README.md b/packages/flint-py/README.md index 14a44dbb..02e2446a 100644 --- a/packages/flint-py/README.md +++ b/packages/flint-py/README.md @@ -58,6 +58,6 @@ and asserts deep equality against the recorded JS spec. ```bash # Run the Python compatibility tests cd packages/flint-py -uv pip install -e .[test] +uv pip install -e '.[test]' uv run pytest -q ``` diff --git a/packages/flint-py/flint/vegalite/templates/area.py b/packages/flint-py/flint/vegalite/templates/area.py index 8b6809c5..2580b133 100644 --- a/packages/flint-py/flint/vegalite/templates/area.py +++ b/packages/flint-py/flint/vegalite/templates/area.py @@ -1,7 +1,7 @@ """Area Chart template.""" from __future__ import annotations -from .utils import default_build_encodings, set_mark_prop +from .utils import default_build_encodings, set_mark_prop, align_stack_order_to_color_order def _apply_interpolate(vg_spec, config): @@ -34,6 +34,7 @@ def _area_instantiate(spec, ctx): if enc and (enc.get("type") == "quantitative" or enc.get("aggregate")): spec["encoding"][axis]["stack"] = None if stack_mode == "layered" else stack_mode break + align_stack_order_to_color_order(spec, ctx) area_chart_def = { @@ -92,6 +93,7 @@ def _streamgraph_instantiate(spec, ctx): x_enc["stack"] = "center" x_enc["axis"] = None _apply_interpolate(spec, ctx.get("chartProperties")) + align_stack_order_to_color_order(spec, ctx) streamgraph_def = { diff --git a/packages/flint-py/flint/vegalite/templates/bar.py b/packages/flint-py/flint/vegalite/templates/bar.py index 1861d102..b1df159f 100644 --- a/packages/flint-py/flint/vegalite/templates/bar.py +++ b/packages/flint-py/flint/vegalite/templates/bar.py @@ -14,6 +14,7 @@ detect_banded_axis_from_semantics, detect_banded_axis_force_discrete, resolve_as_discrete, + align_stack_order_to_color_order, ) @@ -156,6 +157,7 @@ def _stacked_bar_instantiate(spec, ctx): if ae and (ae.get("type") == "quantitative" or ae.get("aggregate")): encoding[axis]["stack"] = None if config["stackMode"] == "layered" else config["stackMode"] break + align_stack_order_to_color_order(spec, ctx) adjust_bar_marks(spec, ctx) diff --git a/packages/flint-py/flint/vegalite/templates/candlestick.py b/packages/flint-py/flint/vegalite/templates/candlestick.py index 43b3a535..89102913 100644 --- a/packages/flint-py/flint/vegalite/templates/candlestick.py +++ b/packages/flint-py/flint/vegalite/templates/candlestick.py @@ -1,6 +1,9 @@ """Candlestick Chart template.""" from __future__ import annotations +from ...core import js_round +from .utils import adjust_bar_marks + def _candlestick_declare(cs, table, chart_properties): return {"axisFlags": {"x": {"banded": True}}} @@ -48,33 +51,59 @@ def _candlestick_instantiate(spec, ctx): spec["layer"][1]["encoding"]["y2"] = {"field": close["field"]} if open_ and open_.get("field") and close and close.get("field"): + # `<=`, not `<`: a session that closes exactly where it opened has not + # fallen, and colouring it as a decline is a false statement. spec["encoding"]["color"] = { "condition": { - "test": f"datum['{open_['field']}'] < datum['{close['field']}']", + "test": f"datum['{open_['field']}'] <= datum['{close['field']}']", "value": "#06982d", }, "value": "#ae1325", } - # Compute bar width from x-axis cardinality - table = ctx.get("table") or [] - canvas = ctx.get("canvasSize") or {} - plot_width = canvas.get("width") or 400 - x_field = (spec.get("encoding") or {}).get("x", {}).get("field") - if x_field and table: - seen = set() - for r in table: - seen.add(r.get(x_field)) - cardinality = max(1, len(seen)) - bar_size = max(2, min(20, round(plot_width * 0.6 / cardinality))) + # Body width. + # + # On a banded *continuous* x -- the usual case, dates -- the slot width is + # set by the smallest gap between observations, not by the row count: nine + # trading days spanning eleven calendar days occupy eleven slots, two of + # which are the weekend. Sizing on cardinality makes every body wider than + # its own slot and adjacent candles fuse into a single polygon. + # adjust_bar_marks() already performs that min-gap analysis for bar marks, + # so use it rather than keep a second, wrong copy of the arithmetic here. + # + # It returns the largest *non-overlapping* size, and bodies that merely + # touch still read as one shape when consecutive sessions move the same + # way. A candlestick needs a visible gutter, so take a fraction of it. + body_fill = 0.8 + layout = ctx.get("layout") or {} + if (layout.get("xContinuousAsDiscrete") or 0) > 0: + adjust_bar_marks(spec, ctx) + fitted = (spec["layer"][1].get("mark") or {}).get("size", 14) + bar_size = max(2, int(fitted * body_fill)) else: - bar_size = 14 + step = layout.get("xStep") or 20 + bar_size = max(2, js_round(step * body_fill)) layer1_mark = spec["layer"][1].get("mark") or {} if isinstance(layer1_mark, str): layer1_mark = {"type": layer1_mark} spec["layer"][1]["mark"] = {**layer1_mark, "size": bar_size} + # Doji sessions. + # + # When open == close the open->close bar has zero height and vanishes, so a + # flat session renders as a bare wick with no candle on it. Draw it as a + # horizontal tick at the shared price, which is the convention and is + # exactly what the bar degenerates to. + if open_ and open_.get("field") and close and close.get("field"): + spec["layer"].append({ + "transform": [ + {"filter": f"datum['{open_['field']}'] === datum['{close['field']}']"}, + ], + "mark": {"type": "tick", "size": bar_size, "thickness": 2}, + "encoding": {"y": {"field": close["field"]}}, + }) + candlestick_chart_def = { "chart": "Candlestick Chart", diff --git a/packages/flint-py/flint/vegalite/templates/utils.py b/packages/flint-py/flint/vegalite/templates/utils.py index 44b3c9b0..9c837cba 100644 --- a/packages/flint-py/flint/vegalite/templates/utils.py +++ b/packages/flint-py/flint/vegalite/templates/utils.py @@ -248,6 +248,64 @@ def max_non_overlap_size( return max(min_size, max_width) +def align_stack_order_to_color_order(spec: dict, ctx: dict) -> None: + """Make a stacked mark's segment order match its colour order. + + The assembler expresses "keep the order the data arrived in" as + ``color.sort: None``. That governs the legend, but NOT the stack: + Vega-Lite derives the stack's sort from the colour *scale*, and a scale + with no explicit domain falls back to sorting the field ascending — i.e. + alphabetically. The result is a chart whose legend reads + ``A great deal, Some, Not much, None at all`` while its bars stack + ``A great deal, None at all, Not much, Some``. For any ordered series + (a Likert scale, an age band, a size class) that silently destroys the + meaning of the stack. + + Pinning ``scale.domain`` to the same order makes Vega-Lite emit a + ``__sort_index`` and sort the stack by it, so legend and stack + agree. An explicit ``sort`` array does NOT achieve this — Vega-Lite + applies it to the legend only — which is why the domain is what gets set. + """ + encoding = spec.get("encoding") or {} + color = encoding.get("color") + # Only meaningful for a discrete colour series. + if not color or not color.get("field"): + return + if color.get("type") not in ("nominal", "ordinal"): + return + # Called from stacked templates, where stacking is implicit unless it has + # been explicitly switched off (``stackMode: 'layered'`` -> ``stack: None``). + for axis in ("x", "y"): + ae = encoding.get(axis) + if ae and "stack" in ae and ae["stack"] in (None, False): + return + # An explicit domain already pins the order; don't override the caller. + if (color.get("scale") or {}).get("domain"): + return + + # ``sort: None`` means data order; a list means that list. Anything else + # (a field-driven sort spec, "ascending"/"descending") is left alone. + sort = color.get("sort", "__missing__") + field = color["field"] + if sort is None: + order = [] + seen = set() + for row in ctx.get("table") or []: + value = (row or {}).get(field) + if value is None or value in seen: + continue + seen.add(value) + order.append(value) + elif isinstance(sort, list): + order = list(sort) + else: + return + if len(order) < 2: + return + + color["scale"] = {**(color.get("scale") or {}), "domain": order} + + def adjust_bar_marks(spec: dict, ctx: dict) -> None: """Adjust bar/rect marks for continuous-as-discrete axes.""" layout = ctx["layout"] diff --git a/packages/flint-py/tools/run_full_eval.py b/packages/flint-py/tools/run_full_eval.py index cf363202..f75d21cb 100644 --- a/packages/flint-py/tools/run_full_eval.py +++ b/packages/flint-py/tools/run_full_eval.py @@ -1,8 +1,8 @@ """Run the Python `assemble_vegalite` against every extracted JS fixture. Categorises each fixture as PASS, MISMATCH, PY_ERROR, or NO_EXPECTED, then -writes a structured `results.json` and a human-readable `REPORT.md` to -`flint-py/tests/fixtures/` for downstream analysis. +writes a structured `results.json` next to the corpus in `shared/test-data/` +and a human-readable `FULL_GALLERY_REPORT.md` to `flint-py/tests/`. Usage: python flint-py/tools/run_full_eval.py @@ -20,7 +20,11 @@ from typing import Any ROOT = Path(__file__).resolve().parent.parent -FIXTURES = ROOT / "tests" / "fixtures" +# The fixture corpus moved to shared/test-data in 1d1ee39 ("rename +# agents→agent-skills, test-fixtures→test-data"); results.json is tracked +# alongside it. The report stays under tests/, where the committed copy lives. +FIXTURES = ROOT.parent.parent / "shared" / "test-data" +REPORT_DIR = ROOT / "tests" sys.path.insert(0, str(ROOT)) from flint.vegalite import assemble_vegalite # noqa: E402 @@ -342,7 +346,7 @@ def main() -> int: "Without these fixes the report would show ~70 MISMATCH cases.\n" ) - out_path = FIXTURES.parent / "FULL_GALLERY_REPORT.md" + out_path = REPORT_DIR / "FULL_GALLERY_REPORT.md" out_path.write_text("".join(lines)) print(f"Wrote {out_path}") print("Summary:") diff --git a/scripts/check-theme-lab.mjs b/scripts/check-theme-lab.mjs new file mode 100644 index 00000000..7587b0d0 --- /dev/null +++ b/scripts/check-theme-lab.mjs @@ -0,0 +1,323 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Validates every hand-authored theme-lab spec by compiling it with Vega-Lite + * and running it through a headless Vega view to SVG. Catches the things a + * JSON file can't catch on its own: bad channel names, invalid filters, + * unresolvable fields, layer/axis conflicts. + * + * Run: node scripts/check-theme-lab.mjs + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import * as vegaLite from 'vega-lite'; +import * as vega from 'vega'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DIR = resolve(__dirname, '../site/src/playground/theme-lab-assets'); + +const files = readdirSync(DIR) + .filter((f) => f.endsWith('.json') && !f.startsWith('_')) + .sort(); + +let failures = 0; +let warnings = 0; + +for (const file of files) { + const raw = JSON.parse(readFileSync(resolve(DIR, file), 'utf8')); + const spec = {}; + for (const [k, v] of Object.entries(raw)) if (!k.startsWith('__')) spec[k] = v; + + const logs = []; + const logger = { + level() { return this; }, + error(...a) { logs.push(['error', a.join(' ')]); return this; }, + warn(...a) { logs.push(['warn', a.join(' ')]); return this; }, + info() { return this; }, + debug() { return this; }, + }; + + try { + const { spec: vgSpec } = vegaLite.compile(spec, { logger }); + const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }).logger(logger); + await view.runAsync(); + const svg = await view.toSVG(); + view.finalize(); + const errs = logs.filter(([l]) => l === 'error'); + const warns = logs.filter(([l]) => l === 'warn'); + if (errs.length) { + failures++; + console.log(`✗ ${file}`); + errs.slice(0, 4).forEach(([, m]) => console.log(` error: ${m}`)); + } else if (warns.length) { + warnings++; + console.log(`~ ${file} (${svg.length} bytes)`); + warns.slice(0, 4).forEach(([, m]) => console.log(` warn: ${m}`)); + } else { + console.log(`✓ ${file} (${svg.length} bytes)`); + } + } catch (err) { + failures++; + console.log(`✗ ${file}`); + console.log(` ${err.message.split('\n')[0]}`); + } +} + +console.log(`\n${files.length} specs · ${failures} failed · ${warnings} with warnings`); + +// --------------------------------------------------------------------------- +// Headline parity. The whole point of the lab is that the two columns differ in +// style only, so the baseline and its bespoke counterpart must carry byte-identical +// title and subtitle text. This is the check that stops the comparison quietly +// becoming unfair when a spec is hand-edited. +// --------------------------------------------------------------------------- + +function headlineOf(spec) { + const t = spec.title; + if (!t) return null; + if (typeof t === 'string') return { title: t, subtitle: '' }; + const sub = t.subtitle; + return { + title: t.text ?? '', + subtitle: Array.isArray(sub) ? sub.join(' ') : (sub ?? ''), + }; +} + +const pairs = new Map(); +for (const file of files) { + const match = /^(.*)\.([a-z-]+)\.json$/.exec(file); + if (!match) continue; + const [, id, kind] = match; + const bucket = pairs.get(id) ?? { themed: [] }; + if (kind === 'flint') bucket.flint = file; + else bucket.themed.push(file); + pairs.set(id, bucket); +} + +/** Every (id, flint, themed) triple — an id may carry one redesign per language. */ +function* columnPairs() { + for (const [id, { flint, themed }] of pairs) { + if (!flint) continue; + for (const t of themed) yield [id, flint, t]; + } +} + +let mismatches = 0; +let compared = 0; +for (const [id, flint, themed] of columnPairs()) { + compared++; + const a = headlineOf(JSON.parse(readFileSync(resolve(DIR, flint), 'utf8'))); + const b = headlineOf(JSON.parse(readFileSync(resolve(DIR, themed), 'utf8'))); + if (!a || !b) { + mismatches++; + console.log(`✗ ${id}: missing title on ${!a ? flint : themed}`); + continue; + } + if (a.title !== b.title || a.subtitle !== b.subtitle) { + mismatches++; + console.log(`✗ ${id}: headline text differs between columns`); + console.log(` flint : ${a.title} — ${a.subtitle}`); + console.log(` themed: ${b.title} — ${b.subtitle}`); + } +} +console.log( + mismatches + ? `${mismatches} headline mismatch(es)` + : `${compared} pairs · headline text identical in both columns`, +); + +// --------------------------------------------------------------------------- +// Offset/band-sizing lint. A mark-level `width`/`height` `{band: n}` combined +// with an xOffset/yOffset channel silently pushes the mark off the centre of +// its sub-band, so bars drift away from the labels and axis ticks that belong +// to them. It is invisible at a glance and only shows up under measurement. +// Size via the offset scale's paddingInner instead. +// --------------------------------------------------------------------------- + +function hasBandSize(node) { + if (!node || typeof node !== 'object') return false; + if (Array.isArray(node)) return node.some(hasBandSize); + for (const [k, v] of Object.entries(node)) { + if ((k === 'width' || k === 'height') && v && typeof v === 'object' && 'band' in v) return true; + if (hasBandSize(v)) return true; + } + return false; +} + +function hasOffsetChannel(node) { + if (!node || typeof node !== 'object') return false; + if (Array.isArray(node)) return node.some(hasOffsetChannel); + for (const [k, v] of Object.entries(node)) { + if (k === 'xOffset' || k === 'yOffset') return true; + if (hasOffsetChannel(v)) return true; + } + return false; +} + +let lints = 0; +for (const file of files) { + if (file.endsWith('.flint.json')) continue; // generated, not ours to lint + const raw = JSON.parse(readFileSync(resolve(DIR, file), 'utf8')); + if (hasOffsetChannel(raw) && hasBandSize(raw)) { + lints++; + console.log(`✗ ${file}: mark band sizing + offset channel — marks will not centre on their sub-band`); + } +} +if (lints === 0) console.log('no band/offset centring conflicts'); + +// --------------------------------------------------------------------------- +// Orientation parity. The lab claims the two columns differ in STYLE only, so a +// redesign that also transposes the chart is comparing two different decisions +// at once and the reader cannot tell which one did the work. Whichever +// orientation is right, both columns have to use it. +// --------------------------------------------------------------------------- + +function collectEncodings(node, out = []) { + if (!node || typeof node !== 'object') return out; + if (Array.isArray(node)) { node.forEach((n) => collectEncodings(n, out)); return out; } + if (node.encoding && typeof node.encoding === 'object') out.push(node.encoding); + for (const [k, v] of Object.entries(node)) { + if (k === 'encoding' || k === 'data' || k === 'config') continue; + collectEncodings(v, out); + } + return out; +} + +const DISCRETE = new Set(['nominal', 'ordinal']); + +/** 'vertical' = categories along x, 'horizontal' = categories along y. */ +function orientationOf(spec) { + let x, y; + for (const enc of collectEncodings(spec)) { + // The outermost definition wins; layers only refine it. + if (x === undefined && enc.x?.type) x = enc.x.type; + if (y === undefined && enc.y?.type) y = enc.y.type; + } + if (!x || !y) return null; + if (DISCRETE.has(x) && y === 'quantitative') return 'vertical'; + if (DISCRETE.has(y) && x === 'quantitative') return 'horizontal'; + return null; // temporal/continuous on both axes — orientation is not a choice +} + +let flips = 0; +for (const [id, flint, themed] of columnPairs()) { + const a = orientationOf(JSON.parse(readFileSync(resolve(DIR, flint), 'utf8'))); + const b = orientationOf(JSON.parse(readFileSync(resolve(DIR, themed), 'utf8'))); + if (a && b && a !== b) { + flips++; + console.log(`✗ ${themed}: orientation differs — flint is ${a}, themed is ${b}`); + } +} +if (flips === 0) console.log('no orientation mismatches between columns'); + +// --------------------------------------------------------------------------- +// Sort parity (warning only). What order the categories come in is a statement +// about the data — which country leads, which age band sits on top, which band +// rests on the baseline. That decision belongs upstream in the Flint spec; a +// design language governs how a chart looks, not what it says. So a redesign +// may PIN the order the baseline already produces, but it must not change it. +// +// This is a warning rather than a failure because the effective order has to be +// approximated (sorting by a calculate-derived field cannot be resolved without +// running the transforms), so it can report a difference that does not render. +// --------------------------------------------------------------------------- + +function collectValues(node, out = []) { + if (!node || typeof node !== 'object') return out; + if (Array.isArray(node)) { node.forEach((n) => collectValues(n, out)); return out; } + if (Array.isArray(node.data?.values)) { + for (const r of node.data.values) if (r && typeof r === 'object') out.push(r); + } + for (const v of Object.values(node)) collectValues(v, out); + return out; +} + +const ORDER_CHANNELS = new Set(['x', 'y', 'color', 'column', 'row']); + +/** Every discrete channel that has a field, as [field, sort] pairs. */ +function collectSorts(node, out = []) { + if (!node || typeof node !== 'object') return out; + if (Array.isArray(node)) { node.forEach((n) => collectSorts(n, out)); return out; } + if (node.encoding && typeof node.encoding === 'object') { + for (const [ch, def] of Object.entries(node.encoding)) { + if (!ORDER_CHANNELS.has(ch) || !def || typeof def !== 'object') continue; + if (!def.field || !DISCRETE.has(def.type)) continue; + // A hidden legend has no visible order to disagree about. + if (ch === 'color' && def.legend === null) continue; + // An explicit scale domain, where present, is what actually orders the legend. + const domain = def.scale?.domain; + const explicit = Array.isArray(domain) && domain.every((d) => d === null || typeof d !== 'object'); + out.push([def.field, explicit ? domain : ('sort' in def ? def.sort : '')]); + } + } + if (node.facet?.field) out.push([node.facet.field, 'sort' in node.facet ? node.facet.sort : '']); + for (const v of Object.values(node)) collectSorts(v, out); + return out; +} + +/** Best-effort reconstruction of the category sequence a channel will render. */ +function effectiveOrder(field, sort, rows) { + const seen = []; + for (const r of rows) if (field in r && !seen.includes(r[field])) seen.push(r[field]); + if (Array.isArray(sort)) { + if (sort.every((s) => s && typeof s === 'object')) return null; // sort-by-field spec + return sort.filter((v) => seen.includes(v)); + } + if (sort === null) return seen; + if (sort === '' || sort === 'ascending') return [...seen].sort(); + if (sort === 'descending') return [...seen].sort().reverse(); + if (sort && typeof sort === 'object' && sort.field) { + const agg = new Map(); + for (const r of rows) { + if (!(field in r) || typeof r[sort.field] !== 'number') continue; + const list = agg.get(r[field]) ?? []; + list.push(r[sort.field]); + agg.set(r[field], list); + } + if (agg.size !== seen.length) return null; // derived field — cannot resolve here + const pick = { max: (a) => Math.max(...a), min: (a) => Math.min(...a), sum: (a) => a.reduce((x, y) => x + y, 0), count: (a) => a.length }; + const fn = pick[sort.op] ?? pick.min; + const ranked = [...seen].sort((p, q) => fn(agg.get(p)) - fn(agg.get(q))); + return sort.order === 'descending' ? ranked.reverse() : ranked; + } + return null; +} + +function orderMap(spec) { + const rows = collectValues(spec); + const map = new Map(); + for (const [field, sort] of collectSorts(spec)) { + if (field.startsWith('__')) continue; // helper fields from hand-written transforms + const order = effectiveOrder(field, sort, rows); + if (order && order.length && !map.has(field)) map.set(field, order); + } + return map; +} + +let resorts = 0; +for (const [id, flint, themed] of columnPairs()) { + const a = orderMap(JSON.parse(readFileSync(resolve(DIR, flint), 'utf8'))); + const b = orderMap(JSON.parse(readFileSync(resolve(DIR, themed), 'utf8'))); + for (const [field, want] of a) { + const got = b.get(field); + if (!got) continue; + const sameSet = want.length === got.length && [...want].sort().join('\u0000') === [...got].sort().join('\u0000'); + if (sameSet && want.join('\u0000') !== got.join('\u0000')) { + resorts++; + console.log(`! ${themed}: redesign re-sorts "${field}" — order is a semantic decision, not a theme one`); + console.log(` flint : ${want.join(', ')}`); + console.log(` themed: ${got.join(', ')}`); + } + } +} +console.log( + resorts + ? `${resorts} sort-order difference(s) — warning only, verify by rendering` + : 'no sort-order differences between columns', +); + +process.exit(failures || mismatches || lints || flips ? 1 : 0); diff --git a/scripts/gen-chart-reference.ts b/scripts/gen-chart-reference.ts index 16e57c54..e61f5fa3 100644 --- a/scripts/gen-chart-reference.ts +++ b/scripts/gen-chart-reference.ts @@ -98,7 +98,8 @@ const PARAM_DESCRIPTIONS: Record = { intervalLabels: 'Text shown on task intervals.', interpolate: 'Line or area interpolation method.', showPoints: 'Overlay point markers on the line.', - showTextLabels: 'Render value labels on the marks.', + showTextLabels: 'Render value labels on the marks (legacy spelling of showValueLabels).', + showValueLabels: 'Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription.', showPercent: 'Show each value as a percentage of the total.', stackMode: 'Stacking strategy for overlapping series.', binCount: 'Maximum bin cap; Auto lets the backend choose.', @@ -137,7 +138,8 @@ const ZH_PARAM_DESCRIPTIONS: Record = { intervalLabels: '在任务区间上显示文本。', interpolate: '线或区域的插值方式。', showPoints: '在线上叠加点标记。', - showTextLabels: '在标记上显示数值标签。', + showTextLabels: '在标记上显示数值标签(showValueLabels 的旧写法)。', + showValueLabels: '在标记上打印数值。默认值来自主题在当前密度下的习惯;标记过密时不提供该选项。堆叠柱状图会在每个分段中部打印该分段自身的数值;归一化堆叠时打印占比。打印的数值约保留三位有效数字,数值较大时使用 k/M 后缀,使标记上呈现的是可读数字而非完整转录。', showPercent: '将数值显示为总量百分比。', stackMode: '重叠系列的堆叠策略。', binCount: '最大分箱数。', diff --git a/scripts/theme-r2.ts b/scripts/theme-r2.ts new file mode 100644 index 00000000..b7fddb1b --- /dev/null +++ b/scripts/theme-r2.ts @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Round-2 theme audit: renders each case of the R2 corpus as a contact sheet + * + * flint | nyt | economist | nature + * mckinsey | datawrapper | powerbi + * + * to `audit-out/r2/.png`, plus `_report.txt` collecting every ground and + * realize report and every render failure. + * + * There is no hand-authored column here — at this corpus size there cannot be. + * The question these sheets answer is the held-out one: does the compiler + * produce something broken, illegible or absurd on a chart nobody tuned it + * against, and is the house still recognisable. + * + * Run: npx esbuild scripts/theme-r2.ts --bundle --platform=node --format=esm \ + * --outfile=scripts/.r2.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/.r2.mjs + * + * node scripts/.r2.mjs every case + * node scripts/.r2.mjs bar line cases whose id contains "bar" or "line" + * node scripts/.r2.mjs --pair bar-n30.economist one 2-panel sheet, large + */ + +import { writeFileSync, mkdirSync, rmSync, existsSync } 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 } from '../packages/flint-js/src/index'; +import { injectCanvasFurnitureSVG, readCanvasFurniture } from '../packages/flint-js/src/vegalite/canvas-furniture'; +import { THEME_PRESETS } from '../packages/flint-js/src/core/theme/presets'; +import { R2_CASES, r2Input, type R2Case } from '../site/src/playground/theme-lab-r2-data'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUT = resolve(__dirname, '../audit-out/r2'); + +// resvg loads system fonts, but does not always match display faces by name +// (e.g. Comic Sans MS for the Cartoon house). Point it at the files so the +// offline sheets show each house's real type where the font is installed; +// missing files are dropped so this stays portable. +const FONT_OPT = { + loadSystemFonts: true, + fontFiles: [ + '/System/Library/Fonts/Supplemental/Comic Sans MS.ttf', + '/System/Library/Fonts/Supplemental/Comic Sans MS Bold.ttf', + '/System/Library/Fonts/Supplemental/ChalkboardSE.ttc', + '/Library/Fonts/Comic Sans MS.ttf', + '/Library/Fonts/Comic Sans MS Bold.ttf', + ].filter((p) => existsSync(p)), +}; + +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]); + } +} + +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' }); + let svg = await view.toSVG(); + view.finalize(); + svg = injectCanvasFurnitureSVG(svg, readCanvasFurniture(vgSpec)); + const m = /]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"/.exec(svg); + return { svg, width: m ? Number(m[1]) : 400, height: m ? Number(m[2]) : 300 }; +} + +/** Lay panels out in a grid, each cell sized to the largest panel. */ +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: R2Case, themeId: string | null): Built { + try { + const input = r2Input(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: R2Case, column: string, scale: number): 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: 400 * scale, 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: 400 * scale, height: 300, label: `${column} ✗ render`, background: '#ffdddd' }, notes }; + } +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const pairIdx = argv.indexOf('--pair'); + + if (pairIdx >= 0) { + const target = argv[pairIdx + 1]; + const dot = target.lastIndexOf('.'); + const id = target.slice(0, dot); + const theme = target.slice(dot + 1); + const c = R2_CASES.find((x) => x.id === id); + if (!c) throw new Error(`no R2 case \`${id}\``); + if (!THEME_PRESETS[theme]) throw new Error(`no theme \`${theme}\``); + mkdirSync(OUT, { recursive: true }); + const panels: Panel[] = []; + for (const col of ['flint', theme]) panels.push((await panelFor(c, col, 1)).panel); + const sheet = contactSheet(panels, 2, `${id} — ${c.probe}`); + writeFileSync( + resolve(OUT, `pair.${id}.${theme}.png`), + new Resvg(sheet.svg, { font: FONT_OPT, fitTo: { mode: 'width', value: Math.round(sheet.width * 2.5) } }).render().asPng(), + ); + console.log(`wrote ${resolve(OUT, `pair.${id}.${theme}.png`)}`); + return; + } + + const filters = argv.filter((a) => !a.startsWith('--')); + const cases = filters.length + ? R2_CASES.filter((c) => filters.some((f) => c.id.includes(f) || c.family.toLowerCase().includes(f.toLowerCase()))) + : R2_CASES; + + 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, 1); + 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.gen}[${c.index}] · ${c.probe}`); + writeFileSync( + resolve(OUT, `${c.id}.png`), + new Resvg(sheet.svg, { font: FONT_OPT, fitTo: { mode: 'width', value: sheet.width * 2 } }).render().asPng(), + ); + written++; + process.stdout.write(`\r${written}/${cases.length} ${c.id.padEnd(28)}`); + } + + 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) console.log(` ${f}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/site/icon-candidates/02-flint-shard.svg b/site/icon-candidates/02-flint-shard.svg new file mode 100644 index 00000000..f2beefcd --- /dev/null +++ b/site/icon-candidates/02-flint-shard.svg @@ -0,0 +1,9 @@ + + Struck flint shard + + + + + + + \ No newline at end of file diff --git a/site/icon-candidates/05-broad-flint.svg b/site/icon-candidates/05-broad-flint.svg new file mode 100644 index 00000000..586274de --- /dev/null +++ b/site/icon-candidates/05-broad-flint.svg @@ -0,0 +1,9 @@ + + Broad flint with diamond sparks + + + + + + + \ No newline at end of file diff --git a/site/icon-candidates/06-strike-rays.svg b/site/icon-candidates/06-strike-rays.svg new file mode 100644 index 00000000..904f909f --- /dev/null +++ b/site/icon-candidates/06-strike-rays.svg @@ -0,0 +1,9 @@ + + Sharp flint with strike rays + + + + + + + \ No newline at end of file diff --git a/site/icon-candidates/07-ember-chips.svg b/site/icon-candidates/07-ember-chips.svg new file mode 100644 index 00000000..e08e942e --- /dev/null +++ b/site/icon-candidates/07-ember-chips.svg @@ -0,0 +1,9 @@ + + Low flint with ember chips + + + + + + + \ No newline at end of file diff --git a/site/icon-candidates/08-ember-trail.svg b/site/icon-candidates/08-ember-trail.svg new file mode 100644 index 00000000..346936d0 --- /dev/null +++ b/site/icon-candidates/08-ember-trail.svg @@ -0,0 +1,9 @@ + + Cut flint with ember trail + + + + + + + \ No newline at end of file diff --git a/site/icon-candidates/09-shard-cool-sparks.svg b/site/icon-candidates/09-shard-cool-sparks.svg new file mode 100644 index 00000000..cc855c0a --- /dev/null +++ b/site/icon-candidates/09-shard-cool-sparks.svg @@ -0,0 +1,9 @@ + + Flint shard with cool sparks + + + + + + + \ No newline at end of file diff --git a/site/icon-candidates/10-shard-ember-dust.svg b/site/icon-candidates/10-shard-ember-dust.svg new file mode 100644 index 00000000..073ba807 --- /dev/null +++ b/site/icon-candidates/10-shard-ember-dust.svg @@ -0,0 +1,12 @@ + + Flint shard with ember dust + + + + + + + + + + \ No newline at end of file diff --git a/site/icon-candidates/11-shard-strike-rays.svg b/site/icon-candidates/11-shard-strike-rays.svg new file mode 100644 index 00000000..622529a4 --- /dev/null +++ b/site/icon-candidates/11-shard-strike-rays.svg @@ -0,0 +1,10 @@ + + Flint shard with strike rays + + + + + + + + \ No newline at end of file diff --git a/site/icon-candidates/12-shard-pixel-sparks.svg b/site/icon-candidates/12-shard-pixel-sparks.svg new file mode 100644 index 00000000..aacd0b14 --- /dev/null +++ b/site/icon-candidates/12-shard-pixel-sparks.svg @@ -0,0 +1,9 @@ + + Flint shard with pixel sparks + + + + + + + \ No newline at end of file diff --git a/site/icon-candidates/13-shard-chip-burst.svg b/site/icon-candidates/13-shard-chip-burst.svg new file mode 100644 index 00000000..33bdda42 --- /dev/null +++ b/site/icon-candidates/13-shard-chip-burst.svg @@ -0,0 +1,10 @@ + + Flint shard with chip burst + + + + + + + + \ No newline at end of file diff --git a/site/icon-candidates/14-shard-pixelized.svg b/site/icon-candidates/14-shard-pixelized.svg new file mode 100644 index 00000000..22d077f5 --- /dev/null +++ b/site/icon-candidates/14-shard-pixelized.svg @@ -0,0 +1,36 @@ + + Pixelized flint shard + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/site/src/components/ChartCodeModal.tsx b/site/src/components/ChartCodeModal.tsx index af1ad599..36c5c92e 100644 --- a/site/src/components/ChartCodeModal.tsx +++ b/site/src/components/ChartCodeModal.tsx @@ -5,8 +5,8 @@ import { JsonCodeMirror } from './JsonCodeMirror'; import { ScaleToFit } from './ScaleToFit'; import { WallChart } from './WallChart'; import { GalleryOptionsBar } from './GalleryOptionsBar'; -import { testCaseToAssemblyInput } from '../shared/test-case-utils'; -import { buildPanelModel } from '../shared/chart-options'; +import { testCaseToAssemblyInput, withHouse } from '../shared/test-case-utils'; +import { buildPanelModel, withoutEchoedOverrides } from '../shared/chart-options'; import { buildGalleryEditorHref } from '../shared/editor-payload'; import { useLocale } from '../i18n/LocaleContext'; import { humanizeVariants } from '../shared/wall-title'; @@ -49,6 +49,10 @@ export function ChartCodeModal({ // Temporary, display-only overrides driven by the options bar. These tweak // the shown Flint spec JSON without mutating any underlying test case. const [tempOptions, setTempOptions] = useState>({}); + // The house the preview is drawn in. Only Vega-Lite reads `theme_spec`, so + // the switch is offered only there. + const [themeId, setThemeId] = useState(undefined); + const canTheme = chart.backend === 'vegalite'; const testCase = tests[index]; @@ -62,7 +66,7 @@ export function ChartCodeModal({ // actions are stored). Display only — never persisted. const displayInput = useMemo(() => { if (!testCase) return null; - const base = testCaseToAssemblyInput(testCase); + const base = withHouse(testCaseToAssemblyInput(testCase), canTheme ? themeId : undefined); return { ...base, chart_spec: { @@ -70,7 +74,16 @@ export function ChartCodeModal({ chartProperties: { ...base.chart_spec.chartProperties, ...tempOptions }, }, }; - }, [testCase, tempOptions]); + }, [testCase, tempOptions, themeId, canTheme]); + + // A house can change what a chart *is*, not only how it looks — the NYT puts + // points on a line. Those are defaults, so they yield to anything the bar has + // stated, and a value the reader never really chose would silently outrank + // the new house. Let go of the ones that were only echoing the old one. + const chooseTheme = (next: string | undefined) => { + if (displayInput) setTempOptions((options) => withoutEchoedOverrides(displayInput, options)); + setThemeId(next); + }; const panelModel = useMemo( () => (displayInput ? buildPanelModel(displayInput, chart.backend) : null), @@ -234,6 +247,7 @@ export function ChartCodeModal({ testCase={testCase} backend={chart.backend} chartPropertyOverrides={tempOptions} + themeId={themeId} /> )} @@ -266,8 +280,13 @@ export function ChartCodeModal({ 0} - onReset={() => setTempOptions({})} + themeId={themeId} + onTheme={canTheme ? chooseTheme : undefined} + canReset={Object.keys(tempOptions).length > 0 || themeId !== undefined} + onReset={() => { + setTempOptions({}); + setThemeId(undefined); + }} onChange={(key, value) => setTempOptions((prev) => { const next = { ...prev }; diff --git a/site/src/components/CodeBlock.tsx b/site/src/components/CodeBlock.tsx index 35e42f5f..56bf32c7 100644 --- a/site/src/components/CodeBlock.tsx +++ b/site/src/components/CodeBlock.tsx @@ -7,6 +7,7 @@ import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javasc import markup from 'react-syntax-highlighter/dist/esm/languages/prism/markup'; import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript'; import oneDark from 'react-syntax-highlighter/dist/esm/styles/prism/one-dark'; +import oneLight from 'react-syntax-highlighter/dist/esm/styles/prism/one-light'; import { siteTheme } from '../shared/theme'; SyntaxHighlighter.registerLanguage('typescript', typescript); @@ -74,18 +75,39 @@ export function CodeBlock({ language = 'typescript', children, customStyle, + highlightLines, + variant = 'dark', }: { language?: string; children: string; customStyle?: CSSProperties; + highlightLines?: readonly number[]; + variant?: 'dark' | 'light'; }) { const Highlighter = SyntaxHighlighter as unknown as React.ElementType; + const highlighted = new Set(highlightLines); return ( 0} + lineProps={(lineNumber: number) => + highlighted.has(lineNumber) + ? { + style: { + display: 'block', + margin: '0 -14px', + padding: '0 11px', + borderLeft: `3px solid ${siteTheme.accent}`, + background: variant === 'light' + ? 'rgba(9, 105, 218, 0.08)' + : 'rgba(88, 166, 255, 0.12)', + }, + } + : {} + } codeTagProps={{ style: { fontFamily: siteTheme.fontMono }, }} diff --git a/site/src/components/GalleryOptionsBar.tsx b/site/src/components/GalleryOptionsBar.tsx index 612321cf..62b7bef6 100644 --- a/site/src/components/GalleryOptionsBar.tsx +++ b/site/src/components/GalleryOptionsBar.tsx @@ -13,16 +13,13 @@ import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import type { ChartOption } from 'flint-chart'; +import { THEME_PRESETS, DEFAULT_THEME_ICON } from 'flint-chart'; import { siteTheme } from '../shared/theme'; import { chartIconFor } from '../shared/chart-categories'; +import { valueKey } from '../shared/chart-options'; import type { ControlSpec, PanelModel, ResolvedAction } from '../shared/chart-options'; import './gallery-options-bar.css'; -/** Stable string key for an arbitrary option value (handles undefined/objects). */ -function valueKey(value: unknown): string { - return JSON.stringify(value ?? null); -} - /** Trim a trailing "(hint)" and clip long labels for the compact select. */ function compactSelectLabel(label: string): string { const withoutHint = label.replace(/\s*\([^)]*\)\s*$/u, '').trim(); @@ -102,6 +99,222 @@ function DiscreteControl(props: { ); } +/** A theme preset's icon as an ``-ready URL. */ +function iconUrl(svg: string): string { + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +interface ThemeChoice { + id: string | undefined; + label: string; + icon: string; + description: string; +} + +const THEME_CHOICES: ThemeChoice[] = [ + { + id: undefined, + label: 'Flint default', + icon: DEFAULT_THEME_ICON, + description: "Flint's own defaults — no house applied.", + }, + ...Object.values(THEME_PRESETS).map((preset) => ({ + id: preset.id, + label: preset.label, + icon: preset.icon, + description: preset.description, + })), +]; + +/** + * The house switch. First in the bar, because a theme is the outermost + * decision on the chart: it settles the surface, the ink and the type that + * everything the other controls touch is then drawn in. + * + * It is the one control in the bar that names itself rather than going by icon + * alone. The others switch a property whose name is already on the chart — a + * stack, a sort, a chart type you can see — but a house is read off the drawing + * as a whole, and two houses can look alike at 15px. Spelling it out is also + * what makes the control legible as a *list of houses* rather than a mystery + * glyph the reader has to open to understand. + * + * Only Vega-Lite reads `theme_spec`, so on the other backends the control is + * absent rather than inert — a switch that does nothing reads as a bug in the + * theme, which is exactly what themes must never look like. + */ +export function ThemeControl(props: { + themeId: string | undefined; + onTheme: (id: string | undefined) => void; + onPreview?: (id: string | undefined) => void; + onPreviewEnd?: () => void; + placement?: 'top' | 'bottom'; + prominent?: boolean; +}) { + const { + themeId, + onTheme, + onPreview, + onPreviewEnd, + placement = 'top', + prominent = false, + } = props; + const [open, setOpen] = useState(false); + const [hover, setHover] = useState(false); + const rootRef = useRef(null); + const choices = THEME_CHOICES; + const current = choices.find((choice) => choice.id === themeId) ?? choices[0]; + + const closeMenu = () => { + setOpen(false); + onPreviewEnd?.(); + }; + + useEffect(() => { + if (!open) return; + const onDoc = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) closeMenu(); + }; + document.addEventListener('mousedown', onDoc); + return () => document.removeEventListener('mousedown', onDoc); + }, [open, onPreviewEnd]); + + return ( +
{ + if (event.key === 'Escape' && open) { + event.preventDefault(); + event.stopPropagation(); + closeMenu(); + } + }} + > + {prominent && ( + + + Theme: + + )} + + + {open && ( +
    + {choices.map((choice) => { + const selected = choice.id === current.id; + return ( +
  • { + onTheme(choice.id); + closeMenu(); + }} + onFocus={() => onPreview?.(choice.id)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '6px 8px', + borderRadius: 7, + cursor: 'pointer', + fontSize: 12, + color: siteTheme.text, + background: selected ? 'rgba(0,0,0,0.06)' : 'transparent', + }} + onMouseEnter={(e) => { + onPreview?.(choice.id); + if (!selected) (e.currentTarget as HTMLLIElement).style.background = 'rgba(0,0,0,0.04)'; + }} + onMouseLeave={(e) => { + if (!selected) (e.currentTarget as HTMLLIElement).style.background = 'transparent'; + }} + > + + {choice.label} +
  • + ); + })} +
+ )} +
+ ); +} + function ControlRow(props: { label: string; spec: ControlSpec; @@ -376,9 +589,12 @@ export function GalleryOptionsBar(props: { onReset: () => void; canReset: boolean; chartType: string; + /** Current house, or undefined for flint's own defaults. Omit to hide the switch. */ + themeId?: string; + onTheme?: (id: string | undefined) => void; style?: CSSProperties; }) { - const { model, onChange, onReset, canReset, chartType, style } = props; + const { model, onChange, onReset, canReset, chartType, themeId, onTheme, style } = props; const controls: { key: string; label: string; spec: ControlSpec; value: unknown }[] = [ ...model.properties.map((option: ChartOption) => ({ @@ -397,6 +613,7 @@ export function GalleryOptionsBar(props: { return (
+ {onTheme && } {((model.chartType && model.chartType.length > 1) || (model.arrange && model.arrange.length > 1)) && ( ; } + // Interactive ThemeSpec forms: preset id, custom object, and inherited + // preset with overrides. The fence content is intentionally empty; the + // panel owns complete, valid examples and keeps its selector in sync. + if (className?.includes('language-flint-theme-spec')) { + return ; + } + + // The live catalogue includes each preset's own SVG icon and reads the + // same metadata as the pickers, so adding a house cannot leave a stale + // hand-written table in the guide. + if (className?.includes('language-flint-theme-presets')) { + return ; + } + const language = resolveCodeLanguage(className); if (language && language !== 'text' && language !== 'plaintext') { return {text}; diff --git a/site/src/components/McpAppMockup.tsx b/site/src/components/McpAppMockup.tsx index f6aa3c85..207fe198 100644 --- a/site/src/components/McpAppMockup.tsx +++ b/site/src/components/McpAppMockup.tsx @@ -10,6 +10,13 @@ type Example = { input: ChartAssemblyInput; }; +// Every fixture carries a headline and a deck, because every real call does. +// They are also the part of a chart a house has most to say about — the type +// scale is set on them and the Economist hangs its masthead tab off them — so +// a titleless fixture makes the theme switch look like it does almost nothing. +// Sizes are left unstated for the same reason: a stated size outranks the +// house's own footprint, and the widget's sizing is one of the things this +// page exists to exercise. const lineInput: ChartAssemblyInput = { data: { values: [ @@ -37,12 +44,12 @@ const lineInput: ChartAssemblyInput = { y: { field: 'commits' }, color: { field: 'area' }, }, + title: 'Where the commits went', + subtitle: 'Commits per month, 2026', chartProperties: { interpolate: 'monotone', showPoints: true, }, - baseSize: { width: 360, height: 360 }, - canvasSize: { width: 720, height: 720 }, }, options: { addTooltips: true }, field_display_names: { @@ -74,9 +81,9 @@ const scatterInput: ChartAssemblyInput = { y: { field: 'mpg' }, color: { field: 'origin' }, }, + title: 'Heavier cars, thirstier engines', + subtitle: 'Miles per gallon against kerb weight, tonnes', chartProperties: { opacity: 0.8 }, - baseSize: { width: 360, height: 360 }, - canvasSize: { width: 720, height: 720 }, }, options: { addTooltips: true }, field_display_names: { weight: 'Weight', mpg: 'MPG', origin: 'Origin' }, @@ -103,9 +110,9 @@ const areaInput: ChartAssemblyInput = { y: { field: 'hours' }, color: { field: 'stage' }, }, + title: 'Build overtakes design', + subtitle: 'Hours logged by stage, 2026', chartProperties: { interpolate: 'monotone' }, - baseSize: { width: 360, height: 360 }, - canvasSize: { width: 720, height: 720 }, }, options: { addTooltips: true }, field_display_names: { month: 'Month', stage: 'Stage', hours: 'Hours' }, @@ -128,9 +135,9 @@ const barInput: ChartAssemblyInput = { x: { field: 'team' }, y: { field: 'wins' }, }, + title: 'The Bears run away with it', + subtitle: 'Wins, regular season', chartProperties: { cornerRadius: 3 }, - baseSize: { width: 360, height: 360 }, - canvasSize: { width: 720, height: 720 }, }, options: { addTooltips: true }, field_display_names: { team: 'Team', wins: 'Wins' }, @@ -162,8 +169,12 @@ const sparklineInput: ChartAssemblyInput = { y: { field: 'latency' }, color: { field: 'service' }, }, + title: 'Latency, service by service', + subtitle: 'Median response in ms, last 14 days', chartProperties: { interpolate: 'monotone' }, // Sparklines want a wide, short canvas: long traces packed into short rows. + // The only fixture that states a size, so the app's other sizing branch — + // caller said, so nobody else gets a say — stays covered here too. baseSize: { width: 720, height: 360 }, canvasSize: { width: 720, height: 360 }, }, diff --git a/site/src/components/SiteShell.tsx b/site/src/components/SiteShell.tsx index 9c386aee..2d4927b0 100644 --- a/site/src/components/SiteShell.tsx +++ b/site/src/components/SiteShell.tsx @@ -67,6 +67,9 @@ export function SiteNavBar(_props: { flush?: boolean } = {}) { {t('nav.mcp')} + + {t('nav.themes')} + {t('nav.gallery')} diff --git a/site/src/components/ThemePresetList.tsx b/site/src/components/ThemePresetList.tsx new file mode 100644 index 00000000..8cfc0179 --- /dev/null +++ b/site/src/components/ThemePresetList.tsx @@ -0,0 +1,96 @@ +import type { CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { THEME_PRESETS } from 'flint-chart'; +import { siteTheme } from '../shared/theme'; + +export const themeIconUrl = (svg: string) => + `data:image/svg+xml,${encodeURIComponent(svg)}`; + +export function ThemePresetIcon({ + icon, + size = 18, +}: { + icon: string; + size?: number; +}) { + return ( + + ); +} + +/** Live preset catalogue, so docs cannot drift from the themes Flint ships. */ +export function ThemePresetList() { + const { t } = useTranslation(); + return ( +
+ + {Object.values(THEME_PRESETS).map((preset) => ( +
+ +
+
+ {preset.label} + {preset.id} +
+
{t(`themes.descriptions.${preset.id}`)}
+
+
+ ))} +
+ ); +} + +const listStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + gap: 8, + margin: '12px 0 16px', +}; + +const itemStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: '20px minmax(0, 1fr)', + gap: 9, + alignItems: 'start', + padding: 10, + border: `1px solid ${siteTheme.border}`, + borderRadius: 8, + background: siteTheme.surface, +}; + +const nameRowStyle: CSSProperties = { + display: 'flex', + alignItems: 'baseline', + gap: 7, + flexWrap: 'wrap', + color: siteTheme.text, + fontSize: 13, +}; + +const idStyle: CSSProperties = { + padding: '1px 5px', + borderRadius: 4, + background: 'rgba(31, 35, 40, 0.06)', + color: siteTheme.textMuted, + fontFamily: siteTheme.fontMono, + fontSize: 10.5, +}; + +const descriptionStyle: CSSProperties = { + marginTop: 3, + color: siteTheme.textMuted, + fontSize: 11.5, + lineHeight: 1.45, +}; + +const responsiveStyles = ` + @media (max-width: 640px) { + .theme-preset-list { + grid-template-columns: minmax(0, 1fr) !important; + } + } +`; diff --git a/site/src/components/ThemeSpecPanel.tsx b/site/src/components/ThemeSpecPanel.tsx new file mode 100644 index 00000000..38429112 --- /dev/null +++ b/site/src/components/ThemeSpecPanel.tsx @@ -0,0 +1,443 @@ +import { useMemo, useRef, useState } from 'react'; +import type { CSSProperties } from 'react'; +import { useTranslation } from 'react-i18next'; +import { THEME_PRESETS } from 'flint-chart'; +import { CodeBlock } from './CodeBlock'; +import { ThemePresetIcon } from './ThemePresetList'; +import { ScaleToFit } from './ScaleToFit'; +import { VegaLiteView } from './VegaLiteView'; +import { BACKENDS } from '../shared/supported-backends'; +import { PREVIEW_CASES } from '../shared/preview-cases'; +import { siteTheme } from '../shared/theme'; + +type Mode = 'preset' | 'custom' | 'inherit'; + +const MODES: Mode[] = ['preset', 'custom', 'inherit']; +const LIFE_EXPECTANCY = PREVIEW_CASES.find((item) => item.id === 'life-expectancy')!; + +/** + * The three legal shapes of `theme_spec`, shown inside a complete Flint input. + * + * This is deliberately generated rather than copied into three markdown code + * fences. The preset selector has to update both the named form and the base + * of the inherited form, or the control would teach one thing while showing + * another. + */ +export function ThemeSpecPanel() { + const { t } = useTranslation(); + const [mode, setMode] = useState('preset'); + const [presetId, setPresetId] = useState('economist'); + const pickerRef = useRef(null); + const preset = THEME_PRESETS[presetId]; + const previewCanvas = + mode === 'custom' + ? '#e7f1f8' + : preset.spec.ink?.surface?.canvas ?? '#ffffff'; + + const input = useMemo( + () => exampleFor(mode, presetId, t), + [mode, presetId, t], + ); + const display = useMemo( + () => displaySource(input), + [input], + ); + const compiled = useMemo( + () => { + try { + return { ok: true as const, value: BACKENDS.vegalite.assemble(input as any) }; + } catch (error) { + return { ok: false as const, error }; + } + }, + [input], + ); + + return ( +
+ +
+
+ {MODES.map((item) => { + const selected = item === mode; + return ( + + ); + })} +
+ + {mode !== 'custom' ? ( +
+ {mode === 'inherit' ? t('docs.themeSpecPanel.base') : t('docs.themeSpecPanel.preset')} +
+ + + {preset.label} + {preset.id} + + +
+ {Object.values(THEME_PRESETS).map((choice) => { + const selected = choice.id === presetId; + return ( + + ); + })} +
+
+
+ ) : null} +
+ +
+ + {display.source} + +
+
+ + {compiled.ok ? ( + + ) : ( +
+                  {t('docs.themeSpecPanel.renderError')}{' '}
+                  {String((compiled.error as Error)?.message ?? compiled.error)}
+                
+ )} +
+
+

+ {mode === 'custom' + ? t('docs.themeSpecPanel.customDescription') + : mode === 'inherit' + ? t('docs.themeSpecPanel.inheritDescription', { name: preset.label }) + : t(`themes.descriptions.${preset.id}`)} +

+
+ {t('docs.themeSpecPanel.exampleSource', { source: LIFE_EXPECTANCY.source })} +
+
+
+
+ ); +} + +function exampleFor( + mode: Mode, + presetId: string, + t: ReturnType['t'], +): Record { + const themeSpec = + mode === 'preset' + ? presetId + : mode === 'inherit' + ? { + extends: presetId, + id: `our-${presetId}`, + label: `Our ${THEME_PRESETS[presetId].label}`, + ink: { + series: { + single: '#6b3fa0', + categorical: ['#6b3fa0', '#c4558c', '#e48b5d', '#3f8f8b'], + categoricalExtended: [ + '#6b3fa0', '#c4558c', '#e48b5d', '#3f8f8b', + '#8d6cab', '#d06f61', '#d5aa3d', '#4f7899', + ], + }, + }, + type: { + headline: { family: 'Aptos Display', weight: 'bold' }, + }, + layout: { + density: 'compact', + }, + } + : { + id: 'our-brand', + label: 'Our brand', + ink: { + surface: { canvas: '#e7f1f8', plot: '#e7f1f8' }, + text: { primary: '#202124', secondary: '#5f6368' }, + structure: { grid: '#bfd2df', axis: '#202124' }, + series: { + single: '#6b3fa0', + categorical: ['#6b3fa0', '#c4558c', '#e48b5d', '#3f8f8b', '#d5aa3d', '#4f7899'], + }, + accent: '#6b3fa0', + }, + type: { + headline: { family: 'Aptos Display', size: 'text.hero900', weight: 'bold' }, + axisLabel: { family: 'Aptos', size: 'text.300' }, + valueLabel: { family: 'Aptos', weight: 'semibold' }, + }, + structure: { + axis: { + categorical: { line: 'full', ticks: 'omit' }, + measure: { line: 'omit', ticks: 'omit' }, + }, + grid: { measure: 'hairline', category: 'omit' }, + }, + marks: { + bandFraction: 0.72, + strokeWeight: 2, + cornerRadius: 3, + }, + legend: { + placement: ['top', 'right'], + title: 'whenAmbiguous', + }, + layout: { + density: 'normal', + titleBlock: { gap: 'normal' }, + }, + }; + + return { + // Six countries keep the input compact enough to read while still + // exercising series colour, direct labels, axes, and legend policy. + data: { values: LIFE_EXPECTANCY.data.slice(0, 12) }, + semantic_types: LIFE_EXPECTANCY.semantic_types, + chart_spec: { + chartType: LIFE_EXPECTANCY.chartType, + encodings: LIFE_EXPECTANCY.encodings, + title: t('docs.themeSpecPanel.exampleTitle'), + subtitle: t('docs.themeSpecPanel.exampleSubtitle'), + baseSize: { width: 380, height: 300 }, + }, + theme_spec: themeSpec, + }; +} + +/** + * Keep the whole Flint-input shape visible without letting a real dataset bury + * the subject of this lesson. This is display-only; the preview compiles the + * complete input above. + */ +function displaySource(input: Record): { source: string; highlightLines: number[] } { + const lines = [ + '{', + ' "data": { ... },', + ' "semantic_types": { ... },', + ]; + appendProperty(lines, 'chart_spec', input.chart_spec, true); + const themeStart = lines.length + 1; + appendProperty(lines, 'theme_spec', input.theme_spec, false); + const themeEnd = lines.length; + lines.push('}'); + + return { + source: lines.join('\n'), + highlightLines: Array.from( + { length: themeEnd - themeStart + 1 }, + (_, index) => themeStart + index, + ), + }; +} + +function appendProperty( + lines: string[], + key: string, + value: unknown, + comma: boolean, +): void { + const valueLines = JSON.stringify(value, null, 2).split('\n'); + if (valueLines.length === 1) { + lines.push(` "${key}": ${valueLines[0]}${comma ? ',' : ''}`); + return; + } + lines.push(` "${key}": ${valueLines[0]}`); + lines.push(...valueLines.slice(1, -1).map((line) => ` ${line}`)); + lines.push(` ${valueLines.at(-1)}${comma ? ',' : ''}`); +} + +const panelStyle: CSSProperties = { + margin: '16px 0 22px', + padding: 12, + border: `1px solid ${siteTheme.border}`, + borderRadius: siteTheme.radius, + background: siteTheme.surface, +}; + +const toolbarStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + flexWrap: 'wrap', +}; + +const tabsStyle: CSSProperties = { + display: 'inline-flex', + gap: 3, + padding: 3, + borderRadius: 8, + background: 'rgba(31, 35, 40, 0.06)', +}; + +const tabStyle: CSSProperties = { + minHeight: 30, + padding: '0 11px', + border: '1px solid transparent', + borderRadius: 6, + cursor: 'pointer', + font: 'inherit', + fontSize: 12.5, +}; + +const selectLabelStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 8, + color: siteTheme.textMuted, + fontSize: 12.5, +}; + +const pickerStyle: CSSProperties = { + position: 'relative', + minWidth: 190, +}; + +const pickerSummaryStyle: CSSProperties = { + minHeight: 32, + boxSizing: 'border-box', + display: 'flex', + alignItems: 'center', + gap: 7, + padding: '4px 8px', + border: `1px solid ${siteTheme.border}`, + borderRadius: 6, + background: siteTheme.surface, + color: siteTheme.text, + cursor: 'pointer', + listStyle: 'none', + fontSize: 12.5, +}; + +const pickerMenuStyle: CSSProperties = { + position: 'absolute', + zIndex: 5, + top: 'calc(100% + 4px)', + right: 0, + width: 240, + maxHeight: 310, + overflowY: 'auto', + boxSizing: 'border-box', + padding: 4, + border: `1px solid ${siteTheme.border}`, + borderRadius: 7, + background: siteTheme.surface, + boxShadow: '0 8px 24px rgba(31, 35, 40, 0.16)', +}; + +const pickerOptionStyle: CSSProperties = { + width: '100%', + minHeight: 32, + display: 'flex', + alignItems: 'center', + gap: 8, + padding: '5px 7px', + border: 0, + borderRadius: 5, + cursor: 'pointer', + font: 'inherit', + fontSize: 12.5, +}; + +const pickerIdStyle: CSSProperties = { + color: siteTheme.textMuted, + fontFamily: siteTheme.fontMono, + fontSize: 10.5, +}; + +const codeStyle: CSSProperties = { + height: 470, + overflow: 'auto', + margin: 0, + border: `1px solid ${siteTheme.border}`, + background: '#f6f8fa', + fontSize: 11.5, + lineHeight: 1.45, +}; + +const exampleGridStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) minmax(300px, 0.95fr)', + gap: 12, + alignItems: 'stretch', + marginTop: 10, +}; + +const chartColumnStyle: CSSProperties = { + minWidth: 0, + alignSelf: 'start', +}; + +const sourceStyle: CSSProperties = { + marginTop: 4, + color: siteTheme.textMuted, + fontSize: 11, + lineHeight: 1.4, +}; + +const chartDescriptionStyle: CSSProperties = { + margin: '10px 0 0', + color: siteTheme.textMuted, + fontSize: 12.5, + lineHeight: 1.5, +}; + +const errorStyle: CSSProperties = { + maxWidth: 280, + margin: 0, + color: siteTheme.error, + fontSize: 11, + whiteSpace: 'pre-wrap', +}; + +const responsiveStyles = ` + .theme-preset-summary::-webkit-details-marker { + display: none; + } + @media (max-width: 760px) { + .theme-spec-example-grid { + grid-template-columns: minmax(0, 1fr) !important; + } + } +`; diff --git a/site/src/components/VegaLiteView.tsx b/site/src/components/VegaLiteView.tsx index bdefd886..e79b964c 100644 --- a/site/src/components/VegaLiteView.tsx +++ b/site/src/components/VegaLiteView.tsx @@ -1,17 +1,42 @@ import { useEffect, useRef } from 'react'; import embed from 'vega-embed'; +import { readCanvasFurniture } from 'flint-chart'; -export function VegaLiteView({ spec }: { spec: any }) { +const SVG_NS = 'http://www.w3.org/2000/svg'; + +export function VegaLiteView({ spec, renderer = 'canvas' }: { spec: any; renderer?: 'canvas' | 'svg' }) { const ref = useRef(null); useEffect(() => { if (!ref.current) return; + const host = ref.current; let cancelled = false; - embed(ref.current, spec, { actions: false, renderer: 'canvas' }).catch((err) => { - if (!cancelled) console.error('vega-embed failed', err); - }); + // Canvas-anchored furniture (the Economist red tab) is drawn onto the SVG + // after render — Vega-Lite cannot express it. That requires SVG output, so + // a spec carrying furniture is forced to render as SVG regardless of the + // requested renderer. + const furniture = readCanvasFurniture(spec); + const useRenderer = furniture.length ? 'svg' : renderer; + embed(host, spec, { actions: false, renderer: useRenderer }) + .then(() => { + if (cancelled || !furniture.length) return; + const svgEl = host.querySelector('svg'); + if (!svgEl) return; + for (const it of furniture) { + const rect = document.createElementNS(SVG_NS, 'rect'); + rect.setAttribute('x', String(it.x)); + rect.setAttribute('y', String(it.y)); + rect.setAttribute('width', String(it.width)); + rect.setAttribute('height', String(it.height)); + rect.setAttribute('fill', it.color); + svgEl.appendChild(rect); + } + }) + .catch((err) => { + if (!cancelled) console.error('vega-embed failed', err); + }); return () => { cancelled = true; }; - }, [spec]); + }, [spec, renderer]); return
; } diff --git a/site/src/components/WallChart.tsx b/site/src/components/WallChart.tsx index 178f6d55..4d084630 100644 --- a/site/src/components/WallChart.tsx +++ b/site/src/components/WallChart.tsx @@ -1,10 +1,11 @@ import { useMemo } from 'react'; +import type { ThemeSpec } from 'flint-chart'; import type { TestCase } from 'flint-chart/test-data'; import { VegaLiteView } from './VegaLiteView'; import { EChartsView } from './EChartsView'; import { ChartjsView } from './ChartjsView'; import { PlotlyView } from './PlotlyView'; -import { testCaseToAssemblyInput, thumbnailCanvasSize, type CanvasSize } from '../shared/test-case-utils'; +import { testCaseToAssemblyInput, thumbnailCanvasSize, withHouse, type CanvasSize } from '../shared/test-case-utils'; import { BACKENDS, type PreviewBackend } from '../shared/supported-backends'; import { siteTheme } from '../shared/theme'; @@ -19,6 +20,10 @@ export function WallChart({ backend, canvasSize, chartPropertyOverrides, + themeId, + themeSpec, + useThemeCanvas = false, + headline, }: { testCase: TestCase; backend: PreviewBackend; @@ -28,20 +33,39 @@ export function WallChart({ * gallery's dynamic options bar). Display only — not persisted. */ chartPropertyOverrides?: Record; + /** + * House to draw in, named by preset id. Only the Vega-Lite assembler reads + * `theme_spec`, so it is left off elsewhere rather than passed and ignored. + */ + themeId?: string; + /** Inline custom house; takes precedence over `themeId`. */ + themeSpec?: ThemeSpec; + /** Use the house/default native base size with the shared 720px ceiling. */ + useThemeCanvas?: boolean; + /** + * What the chart says, in words. Test cases carry a developer's name for the + * case ("Phase 1 — Line: MAU trend…"), not a headline a reader would want, so + * the caller supplies one where the chart is shown to readers. + */ + headline?: { title?: string; subtitle?: string }; }) { const input = useMemo(() => { const base = testCaseToAssemblyInput(testCase, canvasSize ?? thumbnailCanvasSize(testCase)); - if (!chartPropertyOverrides || Object.keys(chartPropertyOverrides).length === 0) { - return base; - } - return { - ...base, - chart_spec: { - ...base.chart_spec, - chartProperties: { ...base.chart_spec.chartProperties, ...chartPropertyOverrides }, - }, + const themed: any = withHouse( + base, + backend === 'vegalite' ? (themeSpec ?? themeId) : undefined, + useThemeCanvas && backend === 'vegalite', + ); + const spec = { + ...themed.chart_spec, + ...(headline?.title ? { title: headline.title } : {}), + ...(headline?.subtitle ? { subtitle: headline.subtitle } : {}), + ...(chartPropertyOverrides && Object.keys(chartPropertyOverrides).length > 0 + ? { chartProperties: { ...themed.chart_spec.chartProperties, ...chartPropertyOverrides } } + : {}), }; - }, [testCase, canvasSize, chartPropertyOverrides]); + return { ...themed, chart_spec: spec }; + }, [testCase, canvasSize, chartPropertyOverrides, themeId, themeSpec, useThemeCanvas, backend, headline?.title, headline?.subtitle]); const compiled = useMemo(() => { try { diff --git a/site/src/i18n/messages/en.json b/site/src/i18n/messages/en.json index e40ad72d..987cc5da 100644 --- a/site/src/i18n/messages/en.json +++ b/site/src/i18n/messages/en.json @@ -2,6 +2,7 @@ "nav": { "about": "About", "mcp": "MCP Server", + "themes": "Themes", "gallery": "Gallery", "documentation": "Documentation", "editor": "Online Editor", @@ -26,6 +27,23 @@ "mobileLabel": "Documentation", "mobileAria": "Choose documentation page", "fallbackNote": "Chinese translation is not available yet; showing the English version.", + "themeSpecPanel": { + "aria": "ThemeSpec examples", + "modeAria": "ThemeSpec form", + "preset": "Preset", + "base": "Base theme", + "modes": { + "preset": "Preset", + "custom": "Custom", + "inherit": "Inherit" + }, + "customDescription": "A complete design system written directly in theme_spec. Every block is optional.", + "inheritDescription": "Start with {{name}}, then override only the decisions your brand changes.", + "renderError": "Could not render:", + "exampleTitle": "Life expectancy, 2000–2021", + "exampleSubtitle": "Years at birth. Most countries improved while the United States declined.", + "exampleSource": "Source: {{source}}" + }, "groups": { "quick-start": "Quick start", "introduction": "Language design", @@ -73,6 +91,10 @@ "title": "Auto Layout Algorithm", "description": "Spring, gas-pressure, radial, and area sizing models." }, + "theme-spec": { + "title": "Using themes", + "description": "Use a preset, define a design system, or inherit and override a shipped theme." + }, "api-reference": { "title": "API reference", "description": "ChartAssemblyInput, assemblers, encodings, options, and exports." @@ -89,10 +111,22 @@ "title": "Chart.js charts", "description": "Every Chart.js chart type, its channels, and configurable parameters." }, + "reference-plotly": { + "title": "Plotly charts", + "description": "Every Plotly chart type, its channels, and configurable parameters." + }, + "reference-excel": { + "title": "Excel charts", + "description": "Every native Excel chart type, its channels, and Office.js mapping." + }, "development": { "title": "Development guide", "description": "Monorepo setup, daily commands, and test strategy." }, + "test-plan": { + "title": "Chart engine test plan", + "description": "Shared visual cases, coverage matrices, and backend bring-up workflow." + }, "adding-a-semantic-type": { "title": "Extending semantic types", "description": "Decide when a new field meaning is needed, then register and verify it." @@ -124,7 +158,124 @@ "backends": { "vegalite": "Compiles a Flint spec into a clean Vega-Lite specification — ideal for crisp, publication-quality statistical graphics rendered with Vega.", "echarts": "Compiles a Flint spec into an Apache ECharts option — well suited to interactive dashboards and rich visual effects.", - "chartjs": "Compiles a Flint spec into a Chart.js config — a lightweight choice for embedding charts in everyday web apps." + "chartjs": "Compiles a Flint spec into a Chart.js config — a lightweight choice for embedding charts in everyday web apps.", + "plotly": "Compiles a Flint spec into a Plotly.js figure — well suited to analytical charts with native hover, zoom, and legend interactions." + } + }, + "themes": { + "title": "Explore themes", + "concept": "A Flint theme formally defines a coherent visual identity for an entire chart library.", + "principles": { + "layout": { + "title": "Layout", + "body": "Controls spacing, density, and the placement of axes, legends, labels, and annotations." + }, + "semantics": { + "title": "Semantic presentation", + "body": "Uses field roles, order, groups, and hierarchy to determine contrast, emphasis, and series identity." + }, + "identity": { + "title": "Visual system", + "body": "Defines typography, color, surfaces, line weight, corners, and mark shapes as one recognizable system." + } + }, + "generalization": "The compiler applies the theme’s layout behavior, semantic presentation, and visual identity across chart types, data shapes, and canvas sizes.", + "docsPointer": "See Using themes for how to apply and customize themes.", + "flintDefault": "Flint default", + "switchAria": "Theme", + "layoutToggle": { + "aria": "Chart arrangement", + "grid": "Grid", + "gridTitle": "Arrange charts in a regular comparison grid", + "scatter": "Scatter", + "scatterTitle": "Scatter charts like photographs for editorial screenshots" + }, + "descriptions": { + "default": "Balanced, neutral defaults designed to work across chart types.", + "nyt": "Newsroom graphics with headlines that state the finding, values on marks, and labels at the end of each series.", + "economist": "Compact print graphics with flat headlines, explanatory decks, and units repeated along the axis.", + "nature": "Journal figures with compact panels, explicit units, and statistics placed beside fitted lines.", + "mckinsey": "Presentation-ready charts with broad bands, prominent values, and takeaway-led headlines.", + "datawrapper": "Clean web graphics with narrow layouts, plain typography, and restrained rules.", + "powerbi": "Dark dashboard tiles with compact layouts, legends on the right, and emphasis on the latest value.", + "powerbi-light": "Light dashboard tiles with a white canvas, fine gridlines, and legends on the right.", + "swiss": "International Typographic Style with a visible grid, strong black structure, and a single red accent.", + "pop": "A pop-art remix of Swiss with electric process colors, heavy black structure, and punchy display type.", + "cartoon": "Playful charts with warm paper, rounded type, bold outlines, bright colors, and a soft dashed grid." + }, + "cases": { + "keeling": { + "title": "Keeling Curve", + "blurb": "Atmospheric CO₂ at Mauna Loa, 316 ppm to 421 and still climbing." + }, + "driving": { + "title": "Driving shifts into reverse", + "blurb": "Miles driven per person against the price of gas, 1956–2010." + }, + "seattle-range": { + "title": "Seattle temperature range", + "blurb": "Average daily low to high, month by month." + }, + "temp-heatmap": { + "title": "Monthly temperature by city", + "blurb": "Tropics warm all year, Moscow frozen, Sydney running backwards." + }, + "browser-pie": { + "title": "Desktop browser share", + "blurb": "Chrome takes two thirds; Safari and Edge tie far behind." + }, + "co2-lollipop": { + "title": "CO₂ emissions per person", + "blurb": "Sixteen countries spanning a twentyfold range, in tonnes." + }, + "life-expectancy": { + "title": "Life expectancy, 2000 to 2021", + "blurb": "Almost every country rose. The US line dips through COVID." + }, + "lifeexp-dumbbell": { + "title": "The female–male life gap", + "blurb": "Each bar spans male to female life expectancy in one country." + }, + "electricity-mix-area": { + "title": "World electricity mix", + "blurb": "Coal holds its share for thirty years as wind and solar arrive." + }, + "big-mac": { + "title": "The Big Mac index", + "blurb": "One burger, priced worldwide: a rough gauge of what money buys." + }, + "olympic-bump": { + "title": "Olympic medal-table rank", + "blurb": "Four Summer Games, 2012 to 2024, with first place on top." + }, + "nutrition-radar": { + "title": "Almonds, oats and yogurt", + "blurb": "Five nutrients per 100 g; each food traces its own polygon." + }, + "faithful-hist": { + "title": "Old Faithful eruptions", + "blurb": "Two humps, near two minutes and four and a half. A mean hides both." + }, + "trust-likert": { + "title": "Confidence in US institutions", + "blurb": "Centred on the neutral split: trust left, doubt right." + }, + "population-waterfall": { + "title": "World population, 1950 to 2020", + "blurb": "A bridge from 2.5 to 7.9 billion, one step per UN sub-region." + }, + "us-pyramid": { + "title": "US population by age and sex", + "blurb": "Age bands stacked upward, the sexes mirrored either side of zero." + }, + "gapminder-bubble": { + "title": "Wealth against health", + "blurb": "Rosling’s bubbles: size is population, colour is continent." + }, + "earnings-education": { + "title": "Earnings by education and sex", + "blurb": "Two gaps at once: the ladder, and the gap inside every rung." + } } }, "modal": { @@ -158,12 +309,19 @@ "installNpm": "Install Flint with npm (TypeScript / JavaScript).", "installMcp": "For agents, use the MCP server; see the Agent Skill for standalone guidance.", "installGallery": "Explore {{chartTypes}} chart types and {{examples}} examples in the gallery.", + "themeLead": "Flint employs a formal theme specification so designers can define a visual identity once and apply it consistently across an entire chart library.", + "ctaThemes": "Visual Themes", "ctaGallery": "Explore Gallery", "ctaMcp": "Get MCP Server", "ctaGithub": "Visit GitHub", "ctaDocs": "Read the docs", "news": { "title": "Updates", + "release050": { + "date": "August 5, 2026", + "dateTime": "2026-08-05", + "text": "Flint 0.5.0 introduces formal visual themes with ten presets, custom ThemeSpec authoring, and preset inheritance." + }, "release040": { "date": "July 24, 2026", "dateTime": "2026-07-24", @@ -193,13 +351,14 @@ "leadMiddle": ". Instead of requiring verbose low-level parameters such as scales, axes, spacing, and layout, the Flint compiler derives optimized chart settings from the data, semantic types, chart type, and encodings.", "leadHighlight2": "Flint supports {{chartTypes}} chart types across {{backends}} rendering backends through one unified interface", "backendRosterLabel": "Backends", + "themeRosterLabel": "Themes", + "themeRosterMore": "+ {{count}} more", "flintSpec": "Flint spec", "compiledChart": "Compiled chart", "backendAria": "Rendering backend", "prevExample": "Previous example", "nextExample": "Next example", "exampleAria": "Example", - "openInEditor": "Open this example in the editor", "seeGallery": "See more in the gallery", "closingTitle": "Start building with Flint.", "closingBody": "Open source and ready to use. Start from GitHub or browse examples in the gallery.", @@ -225,36 +384,55 @@ "title": "Render with different backends", "body": "Flint supports {{chartTypes}} chart types across Vega-Lite, ECharts, Chart.js, and Plotly, with {{examples}} backend-specific examples in the gallery, and can emit native Excel charts through Office.js. Despite their different APIs and programming models, Flint hides them behind a unified interface.", "example": "Switch backends to use their native strengths: ECharts for hierarchical sunbursts, Plotly for statistical and analytical traces, or Excel for editable charts embedded in a workbook." + }, + "themes": { + "title": "Render with different themes", + "body": "A Flint theme is a formal specification that guides the layout algorithm, applies presentation rules by semantic role, and defines geometry, typography, and color as one visual system.", + "example": "When changing the visual theme from Economist to Swiss, the data and encodings remain fixed while spacing, structure, labels, marks, typography, and color adapt to reflect each theme’s visual language." } }, "examples": { "facetedLine": { "label": "Faceted line chart", - "caption": "Monthly active users by region, laid out as small multiples over time." + "caption": "Monthly active users by region, laid out as small multiples over time.", + "title": "Monthly active users by region", + "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." + "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; blue is a gain, red a loss" }, "waterfall": { "label": "Waterfall", - "caption": "Monthly gains and losses accumulated into the running user total for the year." + "caption": "Monthly gains and losses accumulated into the running user total for the year.", + "title": "How the player base moved through 2025", + "subtitle": "Net new players each month, accumulating into the year's total" }, "sunburst": { "label": "Sunburst", - "caption": "User activity broken into a three-level hierarchy: region, game type, and game." + "caption": "User activity broken into a three-level hierarchy: region, game type, and game.", + "title": "Where the players are", + "subtitle": "Monthly active users by region, then platform, then title" }, "donut": { "label": "Donut chart", - "caption": "Film ratings as proportional slices, with an inner radius applied from the chart spec." + "caption": "Film ratings as proportional slices, with an inner radius applied from the chart spec.", + "title": "Films by MPAA rating", + "subtitle": "Share of releases carrying each certificate" }, "regression": { "label": "Regression scatter", - "caption": "Critic and audience scores with a fitted trend line to reveal their relationship." + "caption": "Critic and audience scores with a fitted trend line to reveal their relationship.", + "title": "Critics and audiences broadly agree", + "subtitle": "IMDB rating against Rotten Tomatoes score, one point per film, with a fitted trend" }, "sortedBar": { "label": "Sorted bar chart", - "caption": "Film genres ordered by count, from the most common to the least." + "caption": "Film genres ordered by count, from the most common to the least.", + "title": "Films by genre", + "subtitle": "Number of releases, most common to least" } } }, diff --git a/site/src/i18n/messages/zh-CN.json b/site/src/i18n/messages/zh-CN.json index 71f19c4e..5bf7ee38 100644 --- a/site/src/i18n/messages/zh-CN.json +++ b/site/src/i18n/messages/zh-CN.json @@ -2,6 +2,7 @@ "nav": { "about": "关于", "mcp": "MCP 服务器", + "themes": "主题", "gallery": "图表示例", "documentation": "文档", "editor": "在线编辑器", @@ -26,6 +27,23 @@ "mobileLabel": "文档", "mobileAria": "选择文档页面", "fallbackNote": "暂无中文译本,正在显示英文原文。", + "themeSpecPanel": { + "aria": "ThemeSpec 示例", + "modeAria": "ThemeSpec 形式", + "preset": "预设", + "base": "基础主题", + "modes": { + "preset": "预设", + "custom": "自定义", + "inherit": "继承" + }, + "customDescription": "直接在 theme_spec 中编写完整的设计系统。所有区块都是可选的。", + "inheritDescription": "从 {{name}} 开始,只覆盖你的品牌需要改变的决策。", + "renderError": "无法渲染:", + "exampleTitle": "预期寿命,2000–2021", + "exampleSubtitle": "出生时预期寿命。多数国家有所提高,美国则出现下降。", + "exampleSource": "来源:{{source}}" + }, "groups": { "quick-start": "快速开始", "introduction": "语言设计", @@ -73,6 +91,10 @@ "title": "自动布局算法", "description": "弹簧、气压、径向与面积等尺寸模型。" }, + "theme-spec": { + "title": "使用主题", + "description": "使用预设、定义设计系统,或继承并覆盖内置主题。" + }, "api-reference": { "title": "API 参考", "description": "ChartAssemblyInput、组装器、编码、选项与导出。" @@ -89,10 +111,22 @@ "title": "Chart.js 图表", "description": "全部 Chart.js 图表类型、通道与可配置参数。" }, + "reference-plotly": { + "title": "Plotly 图表", + "description": "全部 Plotly 图表类型、通道与可配置参数。" + }, + "reference-excel": { + "title": "Excel 图表", + "description": "全部原生 Excel 图表类型、通道与 Office.js 映射。" + }, "development": { "title": "开发指南", "description": "Monorepo 搭建、日常命令与测试策略。" }, + "test-plan": { + "title": "图表引擎测试计划", + "description": "共享视觉案例、覆盖矩阵与后端接入流程。" + }, "adding-a-semantic-type": { "title": "扩展语义类型", "description": "判断何时需要新的字段含义,然后注册并验证。" @@ -124,7 +158,124 @@ "backends": { "vegalite": "将 Flint 规范编译为干净的 Vega-Lite 规范——适合用 Vega 渲染清晰、出版级的统计图形。", "echarts": "将 Flint 规范编译为 Apache ECharts option——适合交互式仪表盘与丰富视觉效果。", - "chartjs": "将 Flint 规范编译为 Chart.js 配置——适合在日常 Web 应用中轻量嵌入图表。" + "chartjs": "将 Flint 规范编译为 Chart.js 配置——适合在日常 Web 应用中轻量嵌入图表。", + "plotly": "将 Flint 规范编译为 Plotly.js figure——适合带有原生悬停、缩放和图例交互的分析图表。" + } + }, + "themes": { + "title": "探索主题", + "concept": "Flint 主题通过正式规范,为整个图表库定义统一的视觉识别。", + "principles": { + "layout": { + "title": "布局", + "body": "控制间距、疏密,以及坐标轴、图例、标签和注释的位置。" + }, + "semantics": { + "title": "语义表现", + "body": "根据字段角色、顺序、分组与层级决定对比、强调和系列识别。" + }, + "identity": { + "title": "视觉系统", + "body": "将字体、颜色、表面、线宽、圆角和图形形状定义为一套可识别的系统。" + } + }, + "generalization": "编译器会将主题的布局行为、语义表现和视觉识别应用到不同的图表类型、数据形态和画布尺寸中。", + "docsPointer": "有关应用和自定义主题的方法,请参阅使用主题。", + "flintDefault": "Flint 默认", + "switchAria": "主题", + "layoutToggle": { + "aria": "图表排列方式", + "grid": "网格", + "gridTitle": "将图表排列为规整的对比网格", + "scatter": "散落", + "scatterTitle": "将图表像照片一样散落排列,便于制作编辑截图" + }, + "descriptions": { + "default": "均衡、中性的默认样式,适用于各种图表类型。", + "nyt": "新闻图表风格:以结论为标题,在图形上标注数值,并在曲线末端标出系列名称。", + "economist": "紧凑的印刷图表风格:简洁标题、解释性副标题,并沿坐标轴重复显示单位。", + "nature": "学术期刊图表风格:紧凑面板、明确单位,并在拟合线旁标注统计信息。", + "mckinsey": "演示文稿风格:宽阔色带、醒目数值,以及突出核心结论的标题。", + "datawrapper": "简洁的网页图表风格:窄幅布局、朴素排版与克制的分隔线。", + "powerbi": "深色仪表板风格:紧凑布局、右侧图例,并突出最新数值。", + "powerbi-light": "浅色仪表板风格:白色画布、细网格线与右侧图例。", + "swiss": "国际主义平面设计风格:清晰网格、强烈的黑色结构与单一红色强调色。", + "pop": "波普艺术版 Swiss:高饱和印刷色、粗重黑色结构与醒目的展示字体。", + "cartoon": "活泼的漫画风格:暖色纸张、圆润字体、粗描边、明亮配色与柔和虚线网格。" + }, + "cases": { + "keeling": { + "title": "基林曲线", + "blurb": "莫纳罗亚的大气二氧化碳,从 316 ppm 升至 421,仍在攀升。" + }, + "driving": { + "title": "开车里程掉头向下", + "blurb": "人均行驶里程与油价的对照,1956–2010 年。" + }, + "seattle-range": { + "title": "西雅图气温区间", + "blurb": "逐月的日均最低到最高气温。" + }, + "temp-heatmap": { + "title": "各城市月度气温", + "blurb": "热带全年温暖,莫斯科冰封,悉尼季节相反。" + }, + "browser-pie": { + "title": "桌面浏览器份额", + "blurb": "Chrome 占去三分之二,Safari 与 Edge 远远落后且旗鼓相当。" + }, + "co2-lollipop": { + "title": "人均二氧化碳排放", + "blurb": "十六个国家,相差二十倍,单位为吨。" + }, + "life-expectancy": { + "title": "预期寿命,2000 至 2021", + "blurb": "几乎每个国家都在上升,美国的线在疫情期间下探。" + }, + "lifeexp-dumbbell": { + "title": "男女寿命差距", + "blurb": "每条线连接一个国家男性与女性的预期寿命。" + }, + "electricity-mix-area": { + "title": "全球发电结构", + "blurb": "风能与太阳能登场的三十年里,煤炭份额岿然不动。" + }, + "big-mac": { + "title": "巨无霸指数", + "blurb": "同一个汉堡的全球价格:衡量货币购买力的粗略标尺。" + }, + "olympic-bump": { + "title": "奥运奖牌榜排名", + "blurb": "四届夏季奥运会,2012 至 2024,第一名居顶。" + }, + "nutrition-radar": { + "title": "杏仁、燕麦与酸奶", + "blurb": "每 100 克的五项营养素,每种食物各自勾出一个多边形。" + }, + "faithful-hist": { + "title": "老忠实泉喷发", + "blurb": "两个峰,约两分钟与四分半,平均值会把两者都藏起来。" + }, + "trust-likert": { + "title": "美国民众对机构的信心", + "blurb": "以中立点对齐:信任在左,怀疑在右。" + }, + "population-waterfall": { + "title": "世界人口,1950 至 2020", + "blurb": "从 25 亿到 79 亿的一座桥,每一级是一个联合国次区域。" + }, + "us-pyramid": { + "title": "美国人口的年龄与性别", + "blurb": "年龄段向上堆叠,两性以零为轴左右对照。" + }, + "gapminder-bubble": { + "title": "财富与健康", + "blurb": "罗斯林的气泡图:大小是人口,颜色是洲。" + }, + "earnings-education": { + "title": "按学历与性别看收入", + "blurb": "同时呈现两种差距:阶梯本身,以及每一级内部的差距。" + } } }, "modal": { @@ -158,12 +309,19 @@ "installNpm": "通过 npm 安装 Flint(TypeScript / JavaScript)。", "installMcp": "智能体请使用 MCP 服务器;独立指南见 Agent Skill。", "installGallery": "前往图表示例,探索 {{chartTypes}} 种图表类型和 {{examples}} 个示例。", + "themeLead": "Flint 的正式主题规范让设计师只需定义一次视觉识别,就能在整个图表库中统一应用。", + "ctaThemes": "视觉主题", "ctaGallery": "浏览图表示例", "ctaMcp": "使用 MCP 服务器", "ctaGithub": "访问 GitHub", "ctaDocs": "阅读文档", "news": { "title": "更新", + "release050": { + "date": "2026 年 8 月 5 日", + "dateTime": "2026-08-05", + "text": "Flint 0.5.0 引入正式的视觉主题系统,包含九种预设、自定义 ThemeSpec 与预设继承。" + }, "release040": { "date": "2026 年 7 月 24 日", "dateTime": "2026-07-24", @@ -193,13 +351,14 @@ "leadMiddle": "。你不必手动编写比例尺、坐标轴、间距、布局等繁琐的底层参数;Flint 编译器会结合数据、语义类型、图表类型和编码,自动推导出更合适的图表配置。", "leadHighlight2": "Flint 目前通过统一接口,在 {{backends}} 种渲染后端上支持 {{chartTypes}} 种图表类型", "backendRosterLabel": "渲染后端", + "themeRosterLabel": "主题", + "themeRosterMore": "另有 {{count}} 种", "flintSpec": "Flint 规范", "compiledChart": "编译结果", "backendAria": "渲染后端", "prevExample": "上一个示例", "nextExample": "下一个示例", "exampleAria": "示例", - "openInEditor": "在编辑器中打开此示例", "seeGallery": "在图表示例中查看更多", "closingTitle": "开始使用 Flint", "closingBody": "Flint 已开源,可直接使用。前往 GitHub 获取源码,或先浏览图表示例。", @@ -225,36 +384,55 @@ "title": "一份规范,多种渲染后端", "body": "Flint 支持通过 Vega-Lite、ECharts、Chart.js 和 Plotly 渲染 {{chartTypes}} 种图表,图表示例中收录了 {{examples}} 个后端专属示例,还可通过 Office.js 生成原生 Excel 图表。不同后端的 API 和编程模型各不相同,Flint 则以统一接口抹平差异。", "example": "按需切换后端以发挥其原生优势:ECharts 适合层级旭日图,Plotly 擅长统计和分析图表,Excel 则可生成工作簿中可继续编辑的原生图表。" + }, + "themes": { + "title": "用不同主题呈现", + "body": "Flint 主题是一套正式规范,用于引导布局算法、根据语义角色应用表现规则,并将几何、字体和颜色组织成统一的视觉系统。", + "example": "将视觉主题从 Economist 切换为 Swiss 时,数据与编码保持不变,而间距、结构、标签、图形、字体和颜色会随之调整,以体现各自的视觉语言。" } }, "examples": { "facetedLine": { "label": "分地区折线图", - "caption": "按地区拆分成多个小图,展示月活用户随时间的变化。" + "caption": "按地区拆分成多个小图,展示月活用户随时间的变化。", + "title": "各地区月活跃用户", + "subtitle": "2025 年逐月活跃玩家数,每个地区一幅小图,按平台着色" }, "heatmap": { "label": "发散热力图", - "caption": "按地区与月份展示用户净增减,颜色以零为中心。" + "caption": "按地区与月份展示用户净增减,颜色以零为中心。", + "title": "哪些游戏在何时获得了玩家", + "subtitle": "2025 年各游戏逐月净增用户;蓝色为增长,红色为流失" }, "waterfall": { "label": "瀑布图", - "caption": "逐月累加用户增减量,呈现全年用户总数的变化过程。" + "caption": "逐月累加用户增减量,呈现全年用户总数的变化过程。", + "title": "2025 年玩家规模的变化过程", + "subtitle": "逐月净增用户,累加为全年总量" }, "sunburst": { "label": "旭日图", - "caption": "将用户活动拆成三级层级:地区、游戏类型与游戏。" + "caption": "将用户活动拆成三级层级:地区、游戏类型与游戏。", + "title": "玩家分布在哪里", + "subtitle": "月活跃用户按地区、平台、游戏逐层拆分" }, "donut": { "label": "环图", - "caption": "以比例扇区展示影片分级,并从图表规范应用内半径。" + "caption": "以比例扇区展示影片分级,并从图表规范应用内半径。", + "title": "按 MPAA 分级划分的影片", + "subtitle": "各分级影片数量占比" }, "regression": { "label": "回归散点图", - "caption": "评论与观众评分,并拟合趋势线以揭示关系。" + "caption": "评论与观众评分,并拟合趋势线以揭示关系。", + "title": "影评人与观众的评价大体一致", + "subtitle": "IMDB 评分对烂番茄评分,每部影片一个点,并拟合趋势线" }, "sortedBar": { "label": "排序柱状图", - "caption": "按数量从多到少排列影片类型。" + "caption": "按数量从多到少排列影片类型。", + "title": "按类型划分的影片", + "subtitle": "影片数量,从多到少" } } }, diff --git a/site/src/main.tsx b/site/src/main.tsx index afa714e7..606733cc 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -9,12 +9,17 @@ import { ChartWall } from './routes/ChartWall'; import { ExcelGallery } from './routes/ExcelGallery'; import { Editor } from './routes/Editor'; import { McpServer } from './routes/McpServer'; +import { Themes } from './routes/Themes'; import { DocSectionPage } from './routes/DocSectionPage'; import { PlaygroundShell } from './playground/PlaygroundShell'; import { Illustrations } from './playground/Illustrations'; import { McpUi } from './playground/McpUi'; import { Labs } from './playground/Labs'; import { DemoWall } from './playground/DemoWall'; +import { ThemeLab } from './playground/ThemeLab'; +import { ThemeLabR2 } from './playground/ThemeLabR2'; +import { ThemeLabReal } from './playground/ThemeLabReal'; +import { StyleReferences } from './playground/StyleReferences'; import { FullTestCases } from './playground/FullTestCases'; import { LocaleProvider, useLocale } from './i18n/LocaleContext'; import type { Locale } from './i18n/locales'; @@ -43,6 +48,7 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> {/* Playground is public — poke around and play with the widgets. */} } /> } /> @@ -52,6 +58,16 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + {/* The theme wall graduated to the public /themes page. */} + } /> + } /> + } /> + } /> + } /> + {/* The Swiss and cartoon labs were the same page twice; keep the + links they were reached by working. */} + } /> + } /> } /> {/* Tutorials merged into Documentation as the "Quick start" group. */} diff --git a/site/src/playground/ChartRedesignFigure.tsx b/site/src/playground/ChartRedesignFigure.tsx index a2c2e676..a846e64c 100644 --- a/site/src/playground/ChartRedesignFigure.tsx +++ b/site/src/playground/ChartRedesignFigure.tsx @@ -10,50 +10,95 @@ const trendValues = foodPrices.values.map(({ month, item, price }) => ({ price, })); -const foodNames = [...new Set(foodPrices.values.map(({ item }) => item))]; -const changesByFood = new Map>(); -for (const { month, item, annualChange } of foodPrices.values) { - if (annualChange === null) continue; - const series = changesByFood.get(item) ?? new Map(); - series.set(month, annualChange); - changesByFood.set(item, series); -} +// A correlation matrix is symmetric: swapping its axes produces the same +// picture and makes a working transpose control look broken. Use a rectangular +// food-by-month grid here so the two arrangements are visibly distinct. +const heatmapItems = [...new Set(foodPrices.values.map(({ item }) => item))]; +const heatmapMonths = [...new Set(foodPrices.values.map(({ month }) => month))].slice(-6); +const heatmapRows = new Map( + foodPrices.values.map((row) => [`${row.item}\0${row.month}`, row]), +); +const heatmapValues = heatmapItems.flatMap((item) => + heatmapMonths.map((month) => { + const row = heatmapRows.get(`${item}\0${month}`); + return { + month, + item, + annualChange: row?.annualChange ?? null, + }; + }), +); -function pearsonCorrelation(left: Map, right: Map): number { - const pairs = [...left].flatMap(([month, leftValue]) => { - const rightValue = right.get(month); - return rightValue === undefined ? [] : [[leftValue, rightValue] as const]; - }); - const leftMean = pairs.reduce((sum, [value]) => sum + value, 0) / pairs.length; - const rightMean = pairs.reduce((sum, [, value]) => sum + value, 0) / pairs.length; - let covariance = 0; - let leftVariance = 0; - let rightVariance = 0; - for (const [leftValue, rightValue] of pairs) { - const leftDelta = leftValue - leftMean; - const rightDelta = rightValue - rightMean; - covariance += leftDelta * rightDelta; - leftVariance += leftDelta ** 2; - rightVariance += rightDelta ** 2; - } - const denominator = Math.sqrt(leftVariance * rightVariance); - return denominator === 0 ? 0 : covariance / denominator; -} +type RedesignVariant = 'sparkline' | 'heatmap' | 'theme'; -const correlationValues = foodNames.flatMap((rowFood) => - foodNames.map((columnFood) => ({ - rowFood, - columnFood, - correlation: pearsonCorrelation( - changesByFood.get(rowFood) ?? new Map(), - changesByFood.get(columnFood) ?? new Map(), - ), - })), +/** + * Gapminder, which is where a house has the most to say: the type scale, the + * palette a continent scale is cut from, the grid, the axis furniture and the + * shape of a point — fill, outline and how big a bubble is allowed to get — + * all move together. A single-series chart would make the switch look like a + * recolouring. + */ +const gapminderValues = ([ + ['Norway', 64800, 82.3, 5.3, 'Europe'], + ['United States', 62600, 78.6, 327, 'Americas'], + ['Japan', 39300, 84.2, 127, 'Asia'], + ['China', 16800, 76.7, 1393, 'Asia'], + ['India', 6900, 69.4, 1353, 'Asia'], + ['Nigeria', 5300, 54.3, 196, 'Africa'], + ['Brazil', 15600, 75.7, 209, 'Americas'], + ['Germany', 50900, 81.0, 83, 'Europe'], + ['Ethiopia', 2000, 66.2, 109, 'Africa'], + ['Russia', 25800, 72.4, 145, 'Europe'], + ['Mexico', 19800, 75.0, 126, 'Americas'], + ['Indonesia', 12400, 71.5, 268, 'Asia'], + ['Qatar', 116900, 80.1, 2.8, 'Asia'], + ['South Africa', 13000, 63.9, 57, 'Africa'], + ['Bangladesh', 4200, 72.3, 161, 'Asia'], +] as [string, number, number, number, string][]).map( + ([country, income, life, population, continent]) => ({ + country, income, life, population, continent, + }), ); -type RedesignVariant = 'sparkline' | 'heatmap'; - function chartInput(variant: RedesignVariant, transformed: boolean): ChartAssemblyInput { + if (variant === 'theme') { + return { + data: { values: gapminderValues }, + semantic_types: { + country: 'Country', + income: 'Quantity', + life: 'Quantity', + population: 'Quantity', + continent: 'Category', + }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { + x: { field: 'income' }, + y: { field: 'life' }, + size: { field: 'population' }, + color: { field: 'continent' }, + }, + title: 'Wealth and health of nations', + subtitle: 'Life expectancy vs income per capita, 2018', + chartProperties: { logScale_x: true }, + // Swiss stacks a colour key and a size key above the plot, which costs + // about 140px. At this height the taller of the two states lands on + // the frame exactly, so neither panel has to be scaled down to fit. + baseSize: { width: 400, height: 260 }, + canvasSize: { width: 400, height: 260 }, + }, + field_display_names: { + country: 'Country', + income: 'GDP per capita', + life: 'Life expectancy', + population: 'Population (M)', + continent: 'Continent', + }, + ...(transformed ? { theme_spec: 'swiss' } : {}), + }; + } + if (variant === 'sparkline') { return { data: { values: trendValues }, @@ -77,27 +122,27 @@ function chartInput(variant: RedesignVariant, transformed: boolean): ChartAssemb } return { - data: { values: correlationValues }, + data: { values: heatmapValues }, semantic_types: { - rowFood: 'Category', - columnFood: 'Category', - correlation: 'Correlation', + month: 'YearMonth', + item: 'Category', + annualChange: 'Percentage', }, chart_spec: { chartType: 'Heatmap', encodings: { - x: { field: 'columnFood' }, - y: { field: 'rowFood' }, - color: { field: 'correlation' }, + x: { field: 'month' }, + y: { field: 'item' }, + color: { field: 'annualChange' }, }, - chartProperties: { showTextLabels: transformed }, + chartProperties: { showValueLabels: transformed }, baseSize: { width: 390, height: 300 }, canvasSize: { width: 390, height: 300 }, }, field_display_names: { - rowFood: 'Food', - columnFood: 'Food', - correlation: 'Price correlation', + month: 'Month', + item: 'Food', + annualChange: 'Annual price change (%)', }, }; } @@ -122,7 +167,12 @@ function McpView({ if (!showInteraction || !rootRef.current) return; const root = rootRef.current; const markTarget = () => { - if (variant === 'sparkline') { + if (variant === 'theme') { + const options = root.querySelectorAll('.tc-opt'); + for (const option of options) { + if (option.textContent?.trim() === 'Swiss') option.classList.add('redesign-pointer-target'); + } + } else if (variant === 'sparkline') { const options = root.querySelectorAll('.tc-opt'); for (const option of options) { if (option.textContent?.trim() === 'Sparkline') option.classList.add('redesign-pointer-target'); @@ -130,7 +180,7 @@ function McpView({ } else { const controls = root.querySelectorAll('.opt'); for (const control of controls) { - if (control.querySelector('.opt-label')?.textContent?.trim() === 'Labels') { + if (control.querySelector('.opt-label')?.textContent?.trim() === 'Values') { control.classList.add('redesign-pointer-target', 'redesign-property-target'); } } @@ -140,9 +190,14 @@ function McpView({ observer.observe(root, { childList: true, subtree: true }); markTarget(); + // The theme switch and the chart-type switch share `.tc-type` for their + // styling, and the theme one renders first — so an unqualified selector + // opens the wrong menu. const trigger = variant === 'sparkline' - ? root.querySelector('.tc-type') - : null; + ? root.querySelector('.tc-type-chart') + : variant === 'theme' + ? root.querySelector('.tc-type-theme') + : null; if (trigger && !menuOpenRequestedRef.current && trigger.getAttribute('aria-expanded') !== 'true') { menuOpenRequestedRef.current = true; trigger.click(); @@ -175,6 +230,7 @@ export function ChartRedesignFigure() {
+
); } \ No newline at end of file diff --git a/site/src/playground/ColorDecisionFigure.tsx b/site/src/playground/ColorDecisionFigure.tsx new file mode 100644 index 00000000..3b7388e3 --- /dev/null +++ b/site/src/playground/ColorDecisionFigure.tsx @@ -0,0 +1,298 @@ +import { useMemo, useState, type CSSProperties } from 'react'; +import { assembleVegaLite, THEME_PRESETS, type ChartAssemblyInput } from 'flint-chart'; +import { VegaLiteView } from '../components/VegaLiteView'; +import { siteTheme } from '../shared/theme'; + +/** + * Interactive dev-labs panel for the categorical colour-overflow decision. + * + * Pick a chart type, drag the category count up, and switch colour schemes to + * watch how Flint hands out inks when the field has more categories than a + * house owns colours: + * + * - a *discrete* house palette keeps its indexed set (auto-upsized to the + * house's extended tier where it has one), then folds the long tail onto one + * muted overflow ink with an explicit "Others (N)" legend row — the top + * categories by share stay named, the rest read as "everything else"; + * - a *continuous* ramp samples every category along the scheme, so all N are + * accommodated and the legend is the standard categorical list. + * + * The decision lives in the Vega-Lite theme realizer, so this panel drives that + * backend directly rather than the multi-backend adapter. + */ + +/** Deterministic RNG so the demo data is stable across re-renders. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +type ChartKind = 'Bar Chart' | 'Scatter Plot' | 'Pie Chart'; + +const CHART_KINDS: ChartKind[] = ['Bar Chart', 'Scatter Plot', 'Pie Chart']; + +/** N categories, each with a value (skewed so a share ordering is meaningful) + * and an X/Y position for the scatter case. */ +function categoryRows(n: number, kind: ChartKind): Record[] { + const rng = mulberry32(0x51c0 + n); + return Array.from({ length: n }, (_, i) => { + const Category = `Cat ${String(i + 1).padStart(2, '0')}`; + if (kind === 'Scatter Plot') { + return { Category, X: Math.round(rng() * 100), Y: Math.round(rng() * 100) }; + } + // A gentle decreasing skew so the earliest categories are the biggest — + // gives the "top by share keep a colour" fold something to rank. + return { Category, Value: Math.round(12 + (n - i) * (3 + rng() * 5)) }; + }); +} + +const BASE = { width: 520, height: 400 } as const; + +function buildInput(kind: ChartKind, n: number): ChartAssemblyInput { + const values = categoryRows(n, kind); + if (kind === 'Scatter Plot') { + return { + data: { values }, + semantic_types: { Category: 'Category', X: 'Quantity', Y: 'Quantity' }, + chart_spec: { + chartType: kind, + encodings: { x: { field: 'X' }, y: { field: 'Y' }, color: { field: 'Category' } }, + baseSize: BASE, + }, + }; + } + if (kind === 'Pie Chart') { + return { + data: { values }, + semantic_types: { Category: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: kind, + encodings: { size: { field: 'Value' }, color: { field: 'Category' } }, + baseSize: BASE, + }, + }; + } + return { + data: { values }, + semantic_types: { Category: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: kind, + encodings: { x: { field: 'Category' }, y: { field: 'Value' }, color: { field: 'Category' } }, + baseSize: BASE, + }, + }; +} + +/** House themes carry the categorical set + extended tier + overflow ink that + * the fold reads; "continuous" is a synthetic option handled separately. */ +const CONTINUOUS = 'continuous'; +type SchemeId = string; + +const SCHEMES: { id: SchemeId; label: string }[] = [ + { id: 'nyt', label: 'NYT — discrete (5 → 12 + Others)' }, + { id: 'nature', label: 'Nature — discrete (6 → 12 + Others)' }, + { id: 'datawrapper', label: 'Datawrapper — discrete (5 → 12 + Others)' }, + { id: 'powerbi-light', label: 'Power BI Light — discrete (6 → 12 + Others)' }, + { id: 'economist', label: 'Economist — discrete, restrained (6 + Others)' }, + { id: CONTINUOUS, label: 'Continuous ramp (viridis) — accommodate all' }, +]; + +/** Walk the assembled spec and point every categorical colour channel at a + * continuous scheme, so a nominal field is sampled across the whole ramp. */ +function applyContinuousScheme(spec: any, ramp: string): void { + const visit = (node: any) => { + if (!node || typeof node !== 'object') return; + for (const channel of ['color', 'fill', 'stroke']) { + const enc = node.encoding?.[channel]; + if (enc && enc.field) { + enc.scale = { scheme: ramp }; + if (enc.legend && typeof enc.legend === 'object') delete enc.legend.values; + } + } + for (const key of Object.keys(node)) { + const v = node[key]; + if (Array.isArray(v)) v.forEach(visit); + else if (v && typeof v === 'object') visit(v); + } + }; + visit(spec); +} + +const labelStyle: CSSProperties = { fontSize: 13, color: siteTheme.textMuted, fontWeight: 500 }; + +const pill = (active: boolean): CSSProperties => ({ + cursor: 'pointer', + fontSize: 12, + padding: '5px 12px', + borderRadius: 7, + border: 'none', + whiteSpace: 'nowrap', + background: active ? siteTheme.surface : 'transparent', + boxShadow: active ? '0 1px 2px rgba(0,0,0,0.12)' : 'none', + color: active ? siteTheme.text : siteTheme.textMuted, + fontWeight: active ? 600 : 500, +}); + +export function ColorDecisionFigure() { + const [kind, setKind] = useState('Pie Chart'); + const [count, setCount] = useState(18); + const [scheme, setScheme] = useState('nyt'); + + const { spec, error, folded } = useMemo(() => { + try { + const input = buildInput(kind, count); + if (scheme === CONTINUOUS) { + const s = assembleVegaLite(input) as any; + applyContinuousScheme(s, 'viridis'); + return { spec: s, error: null as string | null, folded: false }; + } + const preset = THEME_PRESETS[scheme]; + const s = assembleVegaLite({ ...input, theme_spec: preset.spec }) as any; + // The realizer adds a `__flintColorKey` transform only when it folds the + // tail — a cheap signal for the caption. + const foldedNow = JSON.stringify(s).includes('__flintColorKey'); + return { spec: s, error: null, folded: foldedNow }; + } catch (err) { + return { spec: null, error: String((err as Error)?.message ?? err), folded: false }; + } + }, [kind, count, scheme]); + + const pct = ((count - 3) / (40 - 3)) * 100; + + return ( +
+
+

+ Colour overflow — top-K + "Others (N)" +

+

+ Add categories past a house's palette and watch the decision. A discrete house + keeps its indexed inks (auto-upsized to its extended tier), then folds the long tail onto one muted + overflow ink with an explicit Others (N) legend row. A continuous ramp + samples every category, so all N are accommodated with a standard legend. Lines and areas never fold + (that would thread one path through unrelated series), so this panel uses bars, points, and wedges. +

+
+ +
+
+ {/* Chart-type tabs */} +
+ {CHART_KINDS.map((id) => ( + + ))} +
+ + {/* Scheme selector */} + + + {/* Category-count slider */} + + + + {scheme === CONTINUOUS + ? 'all categories sampled along the ramp' + : folded + ? 'folded — top inks kept, tail → Others' + : 'within palette — every category named'} + +
+ + {/* Chart stage */} +
+ {error ? ( +
{error}
+ ) : ( + spec && + )} +
+
+
+ ); +} diff --git a/site/src/playground/DemoWall.tsx b/site/src/playground/DemoWall.tsx index 051f86bd..f3c60edd 100644 --- a/site/src/playground/DemoWall.tsx +++ b/site/src/playground/DemoWall.tsx @@ -1,17 +1,18 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { BACKENDS } from '../shared/supported-backends'; import { VegaLiteView } from '../components/VegaLiteView'; import { PlotlyView } from '../components/PlotlyView'; import { ScaleToFit } from '../components/ScaleToFit'; import { siteTheme } from '../shared/theme'; -import { PREVIEW_CASES, type PreviewCase } from './new-case-preview-data'; +import { ThemePicker } from './ThemePicker'; +import { PREVIEW_CASES, type PreviewCase } from '../shared/preview-cases'; /** VL if it has a template for the chart type, else fall back to Plotly. */ function pickBackend(chartType: string): 'vegalite' | 'plotly' { return BACKENDS.vegalite.getTemplateDef(chartType) ? 'vegalite' : 'plotly'; } -function buildInput(c: PreviewCase) { +function buildInput(c: PreviewCase, themeId: string | undefined) { return { data: { values: c.data }, semantic_types: c.semantic_types, @@ -21,6 +22,9 @@ function buildInput(c: PreviewCase) { baseSize: { width: 300, height: 200 }, ...(c.chartProperties ? { chartProperties: c.chartProperties } : {}), }, + // Only Vega-Lite reads this; Plotly ignores it, so a Plotly tile stays + // on Flint's defaults and the wall shows honestly how far a house reaches. + ...(themeId ? { theme_spec: themeId } : {}), } as any; } @@ -77,15 +81,15 @@ const FAMILY_OF: Record = { const familyOf = (chartType: string) => FAMILY_OF[chartType] ?? 'Points & correlation'; -function CaseCard({ c }: { c: PreviewCase }) { +function CaseCard({ c, themeId }: { c: PreviewCase; themeId: string | undefined }) { const backend = pickBackend(c.chartType); const compiled = useMemo(() => { try { - return { ok: true as const, value: BACKENDS[backend].assemble(buildInput(c)) }; + return { ok: true as const, value: BACKENDS[backend].assemble(buildInput(c, themeId)) }; } catch (err) { return { ok: false as const, err }; } - }, [c, backend]); + }, [c, backend, themeId]); return (
(undefined); const groups = useMemo(() => { const byFamily = new Map(); PREVIEW_CASES.forEach((c, index) => { @@ -130,11 +135,9 @@ export function DemoWall() {

Demo wall ({PREVIEW_CASES.length} candidates)

-

- Candidate real-world datasets, grouped by family. Rendered with Vega-Lite by - default, Plotly where the chart type isn't in Vega-Lite. Hover a tile for its - source, license and row count. -

+
+ +
{groups.map(({ fam, items }) => (
@@ -143,7 +146,7 @@ export function DemoWall() {
{items.map(({ c }) => ( - + ))}
diff --git a/site/src/playground/FullTestCases.tsx b/site/src/playground/FullTestCases.tsx index 607ccfce..951c9849 100644 --- a/site/src/playground/FullTestCases.tsx +++ b/site/src/playground/FullTestCases.tsx @@ -82,11 +82,6 @@ export function FullTestCases() {

Full test cases ({names.length} generators)

-

- Every case from every test-data generator — the complete reference set used for - regression and new-backend bring-up. Expand a generator to render its cases (on - the first backend that supports each chart type). Heavy, so sections render lazily. -

{names.map((name) => ( diff --git a/site/src/playground/Labs.tsx b/site/src/playground/Labs.tsx index 7b66debb..db9bfc62 100644 --- a/site/src/playground/Labs.tsx +++ b/site/src/playground/Labs.tsx @@ -1,6 +1,7 @@ import { DodgeToggleFigure } from './DodgeToggleFigure'; import { LocalDodgeFigure } from './LocalDodgeFigure'; import { BandExpansionFigure } from './BandExpansionFigure'; +import { ColorDecisionFigure } from './ColorDecisionFigure'; export function Labs() { return ( @@ -8,6 +9,7 @@ export function Labs() {

Labs

+ diff --git a/site/src/playground/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index 755ee73f..5fdefef0 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -1,15 +1,57 @@ -import { NavLink, Link, Outlet } from 'react-router-dom'; +import { NavLink, Link, Outlet, useLocation } from 'react-router-dom'; import { siteTheme } from '../shared/theme'; import './playground.css'; -const pages = [ +type NavLeaf = { to: string; label: string }; +type NavEntry = NavLeaf | { group: string; children: NavLeaf[] }; + +const pages: NavEntry[] = [ { to: 'illustrations', label: 'Illustrations' }, { to: 'mcp-ui', label: 'MCP UI test' }, { to: 'labs', label: 'Labs' }, { to: 'demo-wall', label: 'Demo wall' }, + { + group: 'Theme labs', + children: [ + { to: 'theme-labs', label: 'Theme lab' }, + { to: 'theme-lab-r2', label: 'Theme lab R2' }, + { to: 'theme-lab-real', label: 'Theme lab real' }, + { to: 'style-references', label: 'Style references' }, + ], + }, { to: 'full-test-cases', label: 'Full test cases' }, ]; +function ThemeLabsMenu({ group, children }: { group: string; children: NavLeaf[] }) { + const { pathname } = useLocation(); + // A page may carry further segments (style-references/swiss), so match the + // page rather than the end of the path. + const active = children.some((c) => pathname.includes(`/${c.to}`)); + return ( +
+ +
+ {children.map((page) => ( + isActive ? 'dev-nav-link dev-nav-link-active' : 'dev-nav-link'} + > + {page.label} + + ))} +
+
+ ); +} + export function PlaygroundShell() { return (
@@ -19,13 +61,17 @@ export function PlaygroundShell() {
-

- You shouldn't be here! This is the lab where I test new features. Curious what's next? Ping me and let's grab coffee. -

diff --git a/site/src/playground/StyleReferences.tsx b/site/src/playground/StyleReferences.tsx new file mode 100644 index 00000000..8a4e171c --- /dev/null +++ b/site/src/playground/StyleReferences.tsx @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Style references — the hand-authored look-and-feel targets, on one page. + * + * Every reference is the same thing seen from a different house: a grid of + * manual Vega-Lite mockups a preset is judged against by eye. That made two + * near-identical pages, so there is now one, and adding a house is a matter of + * adding it to the registry in `style-references.ts`. + * + * The house is in the URL rather than in state alone, so a reference can be + * linked to — which is what these pages are for. + */ + +import { NavLink, useParams } from 'react-router-dom'; +import { siteTheme } from '../shared/theme'; +import { VegaLiteView } from '../components/VegaLiteView'; +import { STYLE_REFERENCES, findStyleReference } from './style-references'; + +export function StyleReferences() { + const { house } = useParams<{ house?: string }>(); + const reference = findStyleReference(house); + + return ( +
+
+

+ Style references · hand-authored mockups +

+ +
+ {STYLE_REFERENCES.map((r) => ( + ({ + padding: '4px 12px', + fontSize: 13, + fontWeight: 600, + textDecoration: 'none', + border: `1px solid ${isActive ? siteTheme.accent : siteTheme.border}`, + color: isActive ? siteTheme.accent : siteTheme.textMuted, + background: isActive ? `${siteTheme.accent}14` : 'transparent', + })} + > + {r.label} + + ))} +
+ +
+ +
+ {reference.cases.map((c) => ( +
+
+ +
+
+
+ {c.title} + + {c.id} + +
+
{c.note}
+
+
+ ))} +
+
+ ); +} diff --git a/site/src/playground/ThemeLab.tsx b/site/src/playground/ThemeLab.tsx new file mode 100644 index 00000000..56f6b2df --- /dev/null +++ b/site/src/playground/ThemeLab.tsx @@ -0,0 +1,871 @@ +import { useEffect, useMemo, useState } from 'react'; +import { VegaLiteView } from '../components/VegaLiteView'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { siteTheme } from '../shared/theme'; +import { THEME_PRESETS, assembleVegaLite, vlGetTemplateDef } from 'flint-chart'; +import { PREVIEW_CASES } from '../shared/preview-cases'; +import THEME_META from './theme-lab-assets/_themes.json'; +import HEADLINES from './theme-lab-assets/_headlines.json'; +import THEME_SPECS from './theme-lab-assets/_themespecs.json'; + +/** + * Theme lab — Flint's default Vega-Lite output next to a hand-authored + * bespoke redesign of the same chart, plus the same chart compiled from the + * ThemeSpec the human read. + * + * Columns 1 and 3 are compiled here, in the browser, by the same compiler the + * library ships: a change to a preset or to a realize pass shows up on this + * page as soon as the module reloads. Only column 2 is a file — a human wrote + * it once, by hand, and it is the reference the other two are judged against. + */ + +type ThemeId = keyof typeof THEME_META.themes; + +interface ThemeMeta { + label: string; + alias: string; + surface: string; + ink: string; + accent: string; + swatches: string[]; + intent: string; + signature: string[]; +} + +interface FlintIndexEntry { + id: string; + chartType: string; + title: string; + subtitle: string; + source: string; + rows: number; +} + +const THEMES = THEME_META.themes as unknown as Record; +const THEME_ORDER = THEME_META.order as ThemeId[]; +const HEADLINE_MAP = HEADLINES.headlines as Record; + +// Column 2, and only column 2. `..json` is the hand-authored +// redesign; nothing else in this folder is read by the page. +const MANUAL_MODULES = import.meta.glob('./theme-lab-assets/*.json', { eager: true }) as Record< + string, + { default: any } +>; + +interface ThemeReport { + stage: 'ground' | 'realize'; + path: string; + message: string; +} + +interface LabRow { + id: string; + chartType: string; + title: string; + source: string; + rows: number; + theme: ThemeId; + flintSpec: any; + themedSpec: any; + compiledSpec?: any; + compiledReport: ThemeReport[]; + design: string[]; +} + +/** + * Flint's `_`-prefixed bookkeeping (`_options`, `_width`, `_theme`) is not part + * of the Vega-Lite the reader is meant to see. A double underscore is + * load-bearing (`__geo_id` is a choropleth join key) and stays. + */ +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]); + } +} + +/** + * The assembly input for a case, built the same way for both compiled columns — + * the only difference between them is whether a ThemeSpec is attached, which is + * the whole point of the comparison. + */ +function inputFor(c: (typeof PREVIEW_CASES)[number]): any { + const headline = HEADLINE_MAP[c.id] ?? { title: c.title }; + return { + data: { values: c.data }, + semantic_types: c.semantic_types, + chart_spec: { + chartType: c.chartType, + title: headline.title, + ...(headline.subtitle ? { subtitle: headline.subtitle } : {}), + encodings: c.encodings, + baseSize: { width: 340, height: 230 }, + ...(c.chartProperties ? { chartProperties: c.chartProperties } : {}), + }, + }; +} + +const CASE_INDEX: FlintIndexEntry[] = PREVIEW_CASES + .filter((c) => vlGetTemplateDef(c.chartType)) + .map((c) => { + const headline = HEADLINE_MAP[c.id] ?? { title: c.title }; + return { + id: c.id, + chartType: c.chartType, + title: headline.title, + subtitle: headline.subtitle ?? '', + source: c.source, + rows: c.data.length, + }; + }); + +function computeRows(): LabRow[] { + // One row per (id, theme). A case may carry several hand-authored themes — + // replicating one chart across every language is the only way to tell what + // the language decided from what the chart forced. + const manualById = new Map(); + for (const [path, mod] of Object.entries(MANUAL_MODULES)) { + const file = path.split('/').pop()!; + if (file.startsWith('_')) continue; + const match = /^(.*)\.([a-z0-9-]+)\.json$/.exec(file); + if (!match || !(match[2] in THEMES)) continue; + manualById.set(match[1], [...(manualById.get(match[1]) ?? []), mod.default]); + } + + const caseById = new Map(PREVIEW_CASES.map((c) => [c.id, c])); + const meta = new Map(CASE_INDEX.map((e) => [e.id, e])); + const rows: LabRow[] = []; + + for (const [id, manualList] of manualById) { + const c = caseById.get(id); + if (!c || !vlGetTemplateDef(c.chartType)) continue; + const input = inputFor(c); + const info = meta.get(id); + + let flint: any; + try { + flint = assembleVegaLite(input) as any; + stripInternal(flint); + } catch (err) { + console.error(`theme lab: ${id} failed to compile —`, (err as Error).message); + continue; + } + + for (const manual of manualList) { + const theme = manual.__theme__ as ThemeId; + const preset = (THEME_PRESETS as any)[theme]; + let compiled: any; + let report: ThemeReport[] = []; + try { + compiled = assembleVegaLite({ ...input, theme_spec: preset?.spec }) as any; + report = compiled._theme?.report ?? []; + stripInternal(compiled); + } catch (err) { + console.error(`theme lab: ${id}.${theme} failed to compile —`, (err as Error).message); + compiled = undefined; + } + rows.push({ + id, + chartType: info?.chartType ?? '—', + title: info?.title ?? id, + source: info?.source ?? '', + rows: info?.rows ?? 0, + theme, + flintSpec: flint, + themedSpec: manual, + compiledSpec: compiled, + compiledReport: report, + design: (manual.__design__ as string[]) ?? [], + }); + } + } + // Group by case so a fully replicated set sits together on the wall. + return rows.sort( + (a, b) => a.id.localeCompare(b.id) || THEME_ORDER.indexOf(a.theme) - THEME_ORDER.indexOf(b.theme), + ); +} + +// Compiling every case twice is cheap, but not cheap enough to do it once per +// call site. One pass per module load. +let ROWS_CACHE: LabRow[] | null = null; +function buildRows(): LabRow[] { + if (!ROWS_CACHE) ROWS_CACHE = computeRows(); + return ROWS_CACHE; +} + +/** Vega-Lite ignores unknown top-level keys, but strip ours anyway. */ +function cleanSpec(spec: any): any { + const out: any = {}; + for (const [k, v] of Object.entries(spec)) { + if (!k.startsWith('__')) out[k] = v; + } + return out; +} + +function ThemeChip({ theme }: { theme: ThemeId }) { + const t = THEMES[theme]; + return ( + + + {t.label} + {t.alias} + + ); +} + +function SpecCell({ spec, dark, label }: { spec: any; dark: boolean; label: string }) { + const cleaned = useMemo(() => cleanSpec(spec), [spec]); + return ( +
+
+ {label} +
+
+ + + +
+
+ ); +} + +/** Small, uniform, shrink-to-fit chart tile used on the wall. */ +function Thumb({ spec, bg, height }: { spec: any; bg: string; height: number }) { + return ( +
+ + + +
+ ); +} + +/** + * One wall tile = one (chart, language) triple: baseline, the human's redesign, + * and the compiler's reading of the same ThemeSpec. Small enough to scan a whole + * language in one screen; the argument lives in the popup, not here. + */ +function WallTile({ row, showFlint, onOpen }: { row: LabRow; showFlint: boolean; onOpen: () => void }) { + const t = THEMES[row.theme]; + const flint = useMemo(() => cleanSpec(row.flintSpec), [row.flintSpec]); + const themed = useMemo(() => cleanSpec(row.themedSpec), [row.themedSpec]); + const compiled = useMemo( + () => (row.compiledSpec ? cleanSpec(row.compiledSpec) : null), + [row.compiledSpec], + ); + return ( + + ); +} + +type CoverageStatus = 'full' | 'partial' | 'blocked'; +interface CoverageEntry { + status: CoverageStatus; + notes: string[]; +} + +const THEMESPEC_BY_THEME = Object.fromEntries( + Object.entries(THEME_PRESETS).map(([id, preset]) => [id, preset.spec]), +) as Record; +const THEMESPEC_COVERAGE = THEME_SPECS.coverage as unknown as Record; + +/** How many charts each single ThemeSpec is on the hook for. */ +const ROWS_PER_THEME: Record = buildRows().reduce>((acc, r) => { + acc[r.theme as string] = (acc[r.theme as string] ?? 0) + 1; + return acc; +}, {}); + +const COVERAGE_INK: Record = { + full: '#2d8659', + partial: '#b7791f', + blocked: '#c0392b', +}; + +const COVERAGE_LABEL: Record = { + full: 'expressible', + partial: 'partly expressible', + blocked: 'not expressible', +}; + +/** Full-size pair, the design argument, and both raw specs. */ +function DetailModal({ row, onClose }: { row: LabRow; onClose: () => void }) { + const [showSpec, setShowSpec] = useState(false); + const [showThemeSpec, setShowThemeSpec] = useState(false); + const t = THEMES[row.theme]; + const themeSpec = THEMESPEC_BY_THEME[row.theme as string]; + const coverage: CoverageEntry = THEMESPEC_COVERAGE[row.id] ?? { status: 'full', notes: [] }; + const flint = useMemo(() => cleanSpec(row.flintSpec), [row.flintSpec]); + const themed = useMemo(() => cleanSpec(row.themedSpec), [row.themedSpec]); + const compiled = useMemo( + () => (row.compiledSpec ? cleanSpec(row.compiledSpec) : null), + [row.compiledSpec], + ); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + style={{ + width: 'min(100%, 1180px)', + background: siteTheme.surface, + border: `1px solid ${siteTheme.border}`, + borderRadius: 10, + padding: 20, + }} + > +
+ + + {row.id} + + + {row.chartType} · {row.rows} rows + + + + +
+ +
+ + + {compiled ? ( + + ) : ( +
+ 3 · not compiled +
+ )} +
+ + {row.compiledReport.length > 0 && ( + <> +

+ What the compiler had to give up ({row.compiledReport.length}) +

+
    + {row.compiledReport.map((r, i) => ( +
  • + + {r.stage} + {' '} + {r.path}{' '} + — {r.message} +
  • + ))} +
+ + )} + +

+ What had to change ({row.design.length}) +

+
    + {row.design.map((d, i) => ( +
  1. + {d} +
  2. + ))} +
+ + {showThemeSpec && ( +
+

+ ThemeSpec · {t.label} +

+

+ One spec per design language, not one per chart. This is the whole of {t.label} — + the same object is what would have to produce all {' '} + {ROWS_PER_THEME[row.theme] ?? 0} of its charts in this lab. Written against{' '} + + themespec.v12 + + . +

+
+ + {COVERAGE_LABEL[coverage.status]} + + + for {row.id} + +
+ {coverage.notes.length > 0 && ( +
    + {coverage.notes.map((n, i) => ( +
  • + {n} +
  • + ))} +
+ )} +
{JSON.stringify(themeSpec, null, 2)}
+
+ )} + + {showSpec && ( +
+
{JSON.stringify(row.flintSpec, null, 2)}
+
{JSON.stringify(row.themedSpec, null, 2)}
+
+ )} +
+
+ ); +} + +const specPre: React.CSSProperties = { + margin: 0, + maxHeight: 340, + overflow: 'auto', + fontFamily: siteTheme.fontMono, + fontSize: 10.5, + lineHeight: 1.45, + background: '#fafafa', + border: `1px solid ${siteTheme.border}`, + borderRadius: 6, + padding: 10, + color: siteTheme.text, +}; + +export function ThemeLab() { + const rows = useMemo(buildRows, []); + const [filter, setFilter] = useState('all'); + const [openKey, setOpenKey] = useState(null); + const shown = filter === 'all' ? rows : rows.filter((r) => r.theme === filter); + const open = rows.find((r) => `${r.id}-${r.theme}` === openKey) ?? null; + const counts = useMemo(() => { + const m = new Map(); + rows.forEach((r) => m.set(r.theme, (m.get(r.theme) ?? 0) + 1)); + return m; + }, [rows]); + + return ( +
+
+
+

+ Theme lab{' '} + + ({rows.length} redesigns) + +

+
+ +
+ + {THEME_ORDER.map((id) => ( + + ))} +
+ +
+ + Palettes + +
+ {THEME_ORDER.filter((id) => filter === 'all' || filter === id).map((id) => { + const t = THEMES[id]; + return ( +
+
+ {t.label} + {t.alias} +
+
+ {t.swatches.map((c) => ( + + ))} + +
+
+ ); + })} +
+
+ +
+ {shown.map((row, i) => ( + setOpenKey(`${row.id}-${row.theme}`)} + /> + ))} +
+ + {open && setOpenKey(null)} />} + + {shown.length === 0 && ( +

+ No hand-authored specs for this theme yet. +

+ )} + +
+
+ ); +} + +function filterBtn(active: boolean): React.CSSProperties { + return { + fontSize: 12, + padding: '4px 12px', + borderRadius: 999, + border: `1px solid ${active ? siteTheme.accent : siteTheme.border}`, + background: active ? siteTheme.accentBg : 'transparent', + color: active ? siteTheme.accent : siteTheme.text, + cursor: 'pointer', + }; +} diff --git a/site/src/playground/ThemeLabR2.tsx b/site/src/playground/ThemeLabR2.tsx new file mode 100644 index 00000000..455b7aaf --- /dev/null +++ b/site/src/playground/ThemeLabR2.tsx @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Theme lab — round 2 (coverage). + * + * One row per gallery case, seven columns: Flint's default plus every house. + * There is no hand-authored column here — at this corpus size there cannot be + * (doc 05). The page is an inspection surface: does the compiler produce + * anything broken, illegible or absurd on a chart nobody tuned it against, and + * is the house still recognisable. + * + * Cases are paged by family so the DOM never holds more than one family at a + * time, and each cell compiles only when it scrolls into view. + */ + +import { useState, type ReactNode } from 'react'; +import { siteTheme } from '../shared/theme'; +import { + R2_CASES, + R2_FAMILY_ORDER, + type R2Case, + type R2Family, +} from './theme-lab-r2-data'; +import { R2Cell, R2_COLUMNS } from './ThemeLabR2Cell'; + +function byFamily(family: R2Family): R2Case[] { + return R2_CASES.filter((c) => c.family === family); +} + +function Pill({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function Row({ c }: { c: R2Case }) { + return ( +
+
+
+ {c.id} · {c.title} + {c.subtitle ? ( + — {c.subtitle} + ) : null} +
+
+ {c.gen} + probe: {c.probe} +
+
+
+ {R2_COLUMNS.map((col) => ( + + ))} +
+
+ ); +} + +export function ThemeLabR2() { + const [family, setFamily] = useState(R2_FAMILY_ORDER[0]); + const cases = byFamily(family); + + return ( +
+
+

+ Theme lab · round 2 (coverage) +

+
+ + + + {cases.map((c) => ( + + ))} +
+ ); +} diff --git a/site/src/playground/ThemeLabR2Cell.tsx b/site/src/playground/ThemeLabR2Cell.tsx new file mode 100644 index 00000000..158deff3 --- /dev/null +++ b/site/src/playground/ThemeLabR2Cell.tsx @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * A single (case × column) cell of the round-2 theme grid. It compiles its + * spec — Flint's default, or one house's ThemeSpec on top of it — the first + * time it scrolls into view, so a page of 80-odd cases across seven columns + * pays only for the panels a reader is actually looking at. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { THEME_PRESETS, assembleVegaLite } from 'flint-chart'; +import { VegaLiteView } from '../components/VegaLiteView'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { siteTheme } from '../shared/theme'; +import { r2Input, type R2Case } from './theme-lab-r2-data'; + +export const R2_COLUMNS = ['flint', ...Object.keys(THEME_PRESETS)] as const; +export type R2Column = (typeof R2_COLUMNS)[number]; + +/** Tile geometry — fixed so a chart is never flex-shrunk below legibility. */ +export const R2_TILE_WIDTH = 320; +const R2_TILE_HEIGHT = 320; + +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]); + } +} + +interface Compiled { + spec?: any; + background: string; + error?: string; + reportCount: number; +} + +function compileCell(c: R2Case, column: R2Column): Compiled { + try { + const input = r2Input(c); + const themeId = column === 'flint' ? null : column; + const spec = assembleVegaLite( + themeId ? { ...input, theme_spec: (THEME_PRESETS as any)[themeId].spec } : input, + ) as any; + const reportCount = spec._theme?.report?.length ?? 0; + const background = typeof spec.background === 'string' ? spec.background : '#ffffff'; + stripInternal(spec); + delete spec.$schema; + return { spec, background, reportCount }; + } catch (err) { + return { background: '#ffe8e8', error: (err as Error).message, reportCount: 0 }; + } +} + +export function R2Cell({ c, column }: { c: R2Case; column: R2Column }) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, + { rootMargin: '300px' }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const built = useMemo(() => (visible ? compileCell(c, column) : null), [visible, c, column]); + + return ( +
+
+ {column} + {built && !built.error ? ( + {built.reportCount} notes + ) : null} +
+
+ {!built ? ( +
+ … +
+ ) : built.error ? ( +
+ {built.error} +
+ ) : ( + + + + )} +
+
+ ); +} diff --git a/site/src/playground/ThemeLabReal.tsx b/site/src/playground/ThemeLabReal.tsx new file mode 100644 index 00000000..49c84d97 --- /dev/null +++ b/site/src/playground/ThemeLabReal.tsx @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Theme lab — real data. + * + * The R2 page drives the houses with synthetic gallery generators (clean + * cardinalities, tidy labels). This page drives them with the *real-world* + * datasets in `shared/preview-cases.ts` (the r1 theme-lab set): real category + * names, real distributions, negatives and long labels. It is the browser twin + * of the `audit-out/real/` contact sheets (`scripts/theme-real.ts`). + * + * One row per case, eight columns: Flint's default plus every house. Only the + * VL-supported cases are shown (Plotly-only cases have no ThemeSpec path). The + * corpus is chunked into fixed-size pages so the DOM never holds more than a + * handful of rows, and each cell compiles only when it scrolls into view. + */ + +import { useState, type ReactNode } from 'react'; +import { vlGetTemplateDef } from 'flint-chart'; +import { siteTheme } from '../shared/theme'; +import { PREVIEW_CASES, type PreviewCase } from '../shared/preview-cases'; +import { RealCell, REAL_COLUMNS } from './ThemeLabRealCell'; + +/** VL-supported real cases (Plotly-only cases have no ThemeSpec path). */ +const REAL_CASES: PreviewCase[] = PREVIEW_CASES.filter((c) => vlGetTemplateDef(c.chartType)); + +const PAGE_SIZE = 6; +const PAGE_COUNT = Math.ceil(REAL_CASES.length / PAGE_SIZE); + +function Pill({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function Row({ c }: { c: PreviewCase }) { + return ( +
+
+
+ {c.id} · {c.title} +
+ {c.blurb ? ( +
{c.blurb}
+ ) : null} +
+ {c.chartType} + {c.source ? src: {c.source} : null} +
+
+
+ {REAL_COLUMNS.map((col) => ( + + ))} +
+
+ ); +} + +export function ThemeLabReal() { + const [page, setPage] = useState(0); + const start = page * PAGE_SIZE; + const cases = REAL_CASES.slice(start, start + PAGE_SIZE); + + return ( +
+
+

+ Theme lab · real data{' '} + + ({REAL_CASES.length} cases) + +

+
+ + + + {cases.map((c) => ( + + ))} +
+ ); +} diff --git a/site/src/playground/ThemeLabRealCell.tsx b/site/src/playground/ThemeLabRealCell.tsx new file mode 100644 index 00000000..7aafded1 --- /dev/null +++ b/site/src/playground/ThemeLabRealCell.tsx @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * A single (real-dataset case × column) cell of the real-data theme grid. Same + * lazy-compile discipline as `ThemeLabR2Cell`: it assembles its spec — Flint's + * default, or one house's ThemeSpec on top — only the first time it scrolls + * into view, so a page of real-world cases across eight columns pays only for + * the panels a reader is actually looking at. + * + * The input mirrors `scripts/theme-real.ts` `inputFor` (and `ThemeLab.tsx`), so + * a cell here shows exactly what the `audit-out/real/` contact sheets show. + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { THEME_PRESETS, assembleVegaLite } from 'flint-chart'; +import { VegaLiteView } from '../components/VegaLiteView'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { siteTheme } from '../shared/theme'; +import type { PreviewCase } from '../shared/preview-cases'; + +export const REAL_COLUMNS = ['flint', ...Object.keys(THEME_PRESETS)] as const; +export type RealColumn = (typeof REAL_COLUMNS)[number]; + +/** Tile geometry — fixed so a chart is never flex-shrunk below legibility. */ +export const REAL_TILE_WIDTH = 320; +const REAL_TILE_HEIGHT = 320; + +/** Assembly input for a real case — mirrors `scripts/theme-real.ts` `inputFor`. */ +export function realInput(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 } : {}), + }, + }; +} + +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]); + } +} + +interface Compiled { + spec?: any; + background: string; + error?: string; + reportCount: number; +} + +function compileCell(c: PreviewCase, column: RealColumn): Compiled { + try { + const input = realInput(c); + const themeId = column === 'flint' ? null : column; + const spec = assembleVegaLite( + themeId ? { ...input, theme_spec: (THEME_PRESETS as any)[themeId].spec } : input, + ) as any; + const reportCount = spec._theme?.report?.length ?? 0; + const background = typeof spec.background === 'string' ? spec.background : '#ffffff'; + stripInternal(spec); + delete spec.$schema; + return { spec, background, reportCount }; + } catch (err) { + return { background: '#ffe8e8', error: (err as Error).message, reportCount: 0 }; + } +} + +export function RealCell({ c, column }: { c: PreviewCase; column: RealColumn }) { + const ref = useRef(null); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, + { rootMargin: '300px' }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const built = useMemo(() => (visible ? compileCell(c, column) : null), [visible, c, column]); + + return ( +
+
+ {column} + {built && !built.error ? ( + {built.reportCount} notes + ) : null} +
+
+ {!built ? ( +
+ … +
+ ) : built.error ? ( +
+ {built.error} +
+ ) : ( + + + + )} +
+
+ ); +} diff --git a/site/src/playground/ThemePicker.tsx b/site/src/playground/ThemePicker.tsx new file mode 100644 index 00000000..b18a27ae --- /dev/null +++ b/site/src/playground/ThemePicker.tsx @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Icon-only house switch for the dev playground. + * + * The playground is a place for looking at charts, so the control carries no + * words: each house is its own icon, and the name lives in the tooltip and the + * accessible name where it can be reached without taking space from the wall. + * + * "Flint default" is a choice in the row rather than an empty slot, because + * not theming is the baseline every house is read against. + */ + +import { THEME_PRESETS, DEFAULT_THEME_ICON } from 'flint-chart'; +import { siteTheme } from '../shared/theme'; + +export type ThemeChoice = { id: string | undefined; label: string; icon: string }; + +export const THEME_CHOICES: ThemeChoice[] = [ + { id: undefined, label: 'Flint default', icon: DEFAULT_THEME_ICON }, + ...Object.values(THEME_PRESETS).map((p) => ({ id: p.id, label: p.label, icon: p.icon })), +]; + +const iconUrl = (svg: string) => `data:image/svg+xml,${encodeURIComponent(svg)}`; + +export function ThemePicker({ + themeId, + onTheme, + size = 26, +}: { + themeId: string | undefined; + onTheme: (id: string | undefined) => void; + size?: number; +}) { + return ( +
+ {THEME_CHOICES.map((choice) => { + const selected = choice.id === themeId; + return ( + + ); + })} +
+ ); +} diff --git a/site/src/playground/cartoon-lab-data.ts b/site/src/playground/cartoon-lab-data.ts new file mode 100644 index 00000000..77ab5174 --- /dev/null +++ b/site/src/playground/cartoon-lab-data.ts @@ -0,0 +1,392 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Cartoon lab — hand-authored Vega-Lite mockups of a playful, colourful + * "cartoon" look, in the spirit of xkcd / modern flat illustration. + * + * These are NOT theme-pipeline output. They are manual reference specs for the + * shipped `cartoon` ThemeSpec preset. A first pipeline attempt (font + palette + * + rounded bars) felt flat, so this lab established the reusable levers the + * preset now owns AND a few it still intentionally does not: thick dark + * "sticker" outlines on rounded marks and dots, wobble-free but chunky strokes, + * emoji markers, and a warm paper canvas with a bright crayon palette. + * + * The design tokens the preset is checked against: + * - Type: a rounded comic face (Comic Sans / Comic Neue / Chalkboard). + * - Palette: bright crayon — sky, coral, sunflower, grass, grape, tangerine. + * - Furniture: warm cream paper, dashed soft grid, round-capped axes. + * - Marks: fat dark outlines + rounded corners (the "sticker" look), big + * dots with a white halo, thick round-cap lines. + * - Mood: friendly, approachable, one clear point per chart. + * + * Refs: xkcd.com · "Humor Sans" / xkcd Script · Comic Neue · modern flat-cartoon + * infographics (rounded bars, thick outlines, sticker shapes). + */ + +// The case shape is shared with the other style references. +import type { StyleReferenceCase } from './style-references'; + +const FONT = "'Comic Sans MS', 'Comic Neue', 'Chalkboard SE', 'Marker Felt', cursive"; +const PAPER = '#fffdf5'; +const INK = '#2e2b28'; +const OUTLINE = '#2e2b28'; + +/** Bright crayon set: sky, coral, sunflower, grass, grape, tangerine. */ +export const CARTOON_PALETTE = ['#3aa9ff', '#ff5d5d', '#ffc23c', '#4cc76a', '#9b6cff', '#ff8a3d']; + +/** Shared "system" so the type, grid and axes stay consistent across charts. */ +const cartoonConfig: any = { + background: PAPER, + font: FONT, + padding: { left: 18, top: 16, right: 18, bottom: 16 }, + title: { + anchor: 'start', + font: FONT, + fontSize: 19, + fontWeight: 'bold', + color: INK, + subtitleFont: FONT, + subtitleFontSize: 12.5, + subtitleColor: '#8a837a', + subtitlePadding: 6, + offset: 12, + }, + view: { stroke: null }, + axis: { + domain: true, + domainColor: INK, + domainWidth: 2.5, + domainCap: 'round', + grid: true, + gridColor: '#ece5d6', + gridDash: [3, 5], + gridWidth: 1.5, + ticks: false, + labelFont: FONT, + labelFontSize: 12, + labelColor: INK, + labelPadding: 7, + titleFont: FONT, + titleFontSize: 12.5, + titleFontWeight: 'bold', + titleColor: INK, + }, + legend: { + orient: 'top', + direction: 'horizontal', + titleFont: FONT, + titleColor: INK, + titleFontSize: 12, + titleFontWeight: 'bold', + labelFont: FONT, + labelFontSize: 12, + labelColor: INK, + symbolType: 'circle', + symbolSize: 130, + symbolStrokeColor: OUTLINE, + symbolStrokeWidth: 1.5, + offset: 8, + padding: 0, + }, +}; + +const W = 360; +const H = 300; + +export const CARTOON_CASES: StyleReferenceCase[] = [ + // ── 1. Sticker bars — rounded tops + fat dark outline = the cartoon tell. ── + { + id: 'cartoon-sticker-bars', + title: 'Sticker bars', + note: 'Rounded tops + a fat dark outline give bars a sticker / balloon feel. Bright fills, bold value labels.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'Favourite ice cream 🍦', subtitle: 'Votes in Ms. Rivera’s class' }, + data: { + values: [ + { flavour: 'Choc', votes: 12, c: '#8a5a2a' }, + { flavour: 'Vanilla', votes: 9, c: '#ffc23c' }, + { flavour: 'Strawberry', votes: 15, c: '#ff5d5d' }, + { flavour: 'Mint', votes: 7, c: '#4cc76a' }, + { flavour: 'Berry', votes: 11, c: '#9b6cff' }, + ], + }, + layer: [ + { + mark: { + type: 'bar', + cornerRadiusEnd: 14, + stroke: OUTLINE, + strokeWidth: 2.5, + }, + encoding: { + color: { field: 'c', type: 'nominal', scale: null, legend: null }, + }, + }, + { + mark: { type: 'text', dy: -10, font: FONT, fontSize: 14, fontWeight: 'bold', color: INK }, + encoding: { text: { field: 'votes', type: 'quantitative' } }, + }, + ], + encoding: { + x: { + field: 'flavour', + type: 'nominal', + sort: null, + axis: { labelAngle: 0, grid: false, title: null }, + scale: { paddingInner: 0.35, paddingOuter: 0.2 }, + }, + y: { + field: 'votes', + type: 'quantitative', + axis: { title: null, tickCount: 4, grid: true }, + scale: { domain: [0, 18] }, + }, + }, + config: cartoonConfig, + }, + }, + + // ── 2. Bouncy line — thick round-cap monotone line, big haloed dots. ── + { + id: 'cartoon-bouncy-line', + title: 'Bouncy line', + note: 'Fat round-capped smoothed line with big white-haloed dots — reads like a friendly path, not a data trace.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'My mood this week 😄', subtitle: 'How fun each day felt (0–10)' }, + data: { + values: [ + { day: 'Mon', mood: 4 }, + { day: 'Tue', mood: 6 }, + { day: 'Wed', mood: 3 }, + { day: 'Thu', mood: 7 }, + { day: 'Fri', mood: 9 }, + { day: 'Sat', mood: 10 }, + { day: 'Sun', mood: 8 }, + ], + }, + encoding: { + x: { field: 'day', type: 'nominal', sort: null, axis: { labelAngle: 0, grid: false, title: null } }, + y: { + field: 'mood', + type: 'quantitative', + axis: { title: null, tickCount: 5 }, + scale: { domain: [0, 11] }, + }, + }, + layer: [ + { + mark: { + type: 'line', + color: '#3aa9ff', + strokeWidth: 6, + strokeCap: 'round', + strokeJoin: 'round', + interpolate: 'monotone', + }, + }, + { + mark: { + type: 'point', + filled: true, + color: '#3aa9ff', + size: 260, + stroke: '#ffffff', + strokeWidth: 4, + }, + }, + { + mark: { + type: 'point', + filled: false, + stroke: OUTLINE, + strokeWidth: 2, + size: 260, + }, + }, + ], + config: cartoonConfig, + }, + }, + + // ── 3. Bubble buddies — outlined circles, size = the reading, playful. ── + { + id: 'cartoon-bubbles', + title: 'Bubble buddies', + note: 'Big outlined bubbles, size carries the value, white halo pops them off the paper. Colour just for fun.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'Pets in our street 🐾', subtitle: 'How many of each (bubble = count)' }, + data: { + values: [ + { pet: 'Dogs', x: 1, y: 3, n: 14, c: '#ff8a3d' }, + { pet: 'Cats', x: 2, y: 2, n: 11, c: '#9b6cff' }, + { pet: 'Fish', x: 3, y: 3.2, n: 20, c: '#3aa9ff' }, + { pet: 'Birds', x: 4, y: 1.8, n: 6, c: '#4cc76a' }, + { pet: 'Bunnies', x: 5, y: 2.6, n: 8, c: '#ff5d5d' }, + ], + }, + layer: [ + { + mark: { type: 'circle', stroke: OUTLINE, strokeWidth: 2.5, opacity: 1 }, + encoding: { + size: { + field: 'n', + type: 'quantitative', + scale: { range: [400, 4200] }, + legend: null, + }, + color: { field: 'c', type: 'nominal', scale: null, legend: null }, + }, + }, + { + mark: { type: 'text', font: FONT, fontSize: 12, fontWeight: 'bold', color: INK, dy: 0 }, + encoding: { text: { field: 'pet' } }, + }, + ], + encoding: { + x: { field: 'x', type: 'quantitative', axis: null, scale: { domain: [0.3, 5.7] } }, + y: { field: 'y', type: 'quantitative', axis: null, scale: { domain: [0.8, 4] } }, + }, + config: cartoonConfig, + }, + }, + + // ── 4. Gumball pie — bright wedges, fat white gaps, thick outline ring. ── + { + id: 'cartoon-gumball-pie', + title: 'Gumball pie', + note: 'Bright wedges cut apart by fat white gaps and wrapped in a dark outline — a gumball look, not a spreadsheet pie.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'Where my day goes ⏰', subtitle: 'Hours, roughly' }, + data: { + values: [ + { thing: 'Sleep', hrs: 9 }, + { thing: 'School', hrs: 6 }, + { thing: 'Play', hrs: 4 }, + { thing: 'Food', hrs: 2 }, + { thing: 'Screens', hrs: 3 }, + ], + }, + mark: { + type: 'arc', + stroke: OUTLINE, + strokeWidth: 2.5, + padAngle: 0.05, + cornerRadius: 6, + innerRadius: 0, + }, + encoding: { + theta: { field: 'hrs', type: 'quantitative', stack: true }, + color: { + field: 'thing', + type: 'nominal', + sort: null, + scale: { range: CARTOON_PALETTE }, + legend: { title: null }, + }, + order: { field: 'hrs', sort: 'descending' }, + }, + config: cartoonConfig, + }, + }, + + // ── 5. Emoji markers — the mark *is* the picture. A lever a theme lacks. ── + { + id: 'cartoon-emoji-lollipop', + title: 'Emoji lollipops', + note: 'Emoji markers on stems: the mark itself is the picture. This is a fun lever the current theme spec has no way to express.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'Snack scores 🍩', subtitle: 'Average yum rating (out of 10)' }, + data: { + values: [ + { snack: 'Donut', score: 9, emoji: '🍩' }, + { snack: 'Apple', score: 5, emoji: '🍎' }, + { snack: 'Pizza', score: 8, emoji: '🍕' }, + { snack: 'Grapes', score: 6, emoji: '🍇' }, + { snack: 'Cookie', score: 7, emoji: '🍪' }, + ], + }, + encoding: { + x: { field: 'snack', type: 'nominal', sort: null, axis: { labelAngle: 0, grid: false, title: null } }, + y: { + field: 'score', + type: 'quantitative', + axis: { title: null, tickCount: 5, grid: true }, + scale: { domain: [0, 10.5] }, + }, + }, + layer: [ + { + mark: { type: 'rule', color: '#c9c1b2', strokeWidth: 4, strokeCap: 'round' }, + encoding: { y2: { datum: 0 } }, + }, + { + mark: { type: 'text', fontSize: 30, baseline: 'middle' }, + encoding: { text: { field: 'emoji' } }, + }, + ], + config: cartoonConfig, + }, + }, + + // ── 6. Outlined stacked bars — rounded top, dark outline, white dividers. ── + { + id: 'cartoon-stacked', + title: 'Layer-cake stacks', + note: 'Stacks with a rounded outlined top and thick white dividers — the pieces read as sweets stacked in a jar.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'Marbles in each jar 🫙', subtitle: 'By colour' }, + data: { + values: (() => { + const rows: any[] = []; + const jars: Record> = { + 'Jar A': { Red: 6, Blue: 4, Yellow: 3 }, + 'Jar B': { Red: 3, Blue: 7, Yellow: 5 }, + 'Jar C': { Red: 5, Blue: 2, Yellow: 8 }, + 'Jar D': { Red: 2, Blue: 6, Yellow: 4 }, + }; + for (const [jar, byC] of Object.entries(jars)) + for (const [colour, n] of Object.entries(byC)) rows.push({ jar, colour, n }); + return rows; + })(), + }, + mark: { + type: 'bar', + stroke: '#ffffff', + strokeWidth: 2.5, + cornerRadius: 4, + }, + encoding: { + x: { field: 'jar', type: 'nominal', sort: null, axis: { labelAngle: 0, grid: false, title: null }, scale: { paddingInner: 0.4 } }, + y: { field: 'n', type: 'quantitative', stack: 'zero', axis: { title: null, tickCount: 4 } }, + color: { + field: 'colour', + type: 'nominal', + sort: null, + scale: { domain: ['Red', 'Blue', 'Yellow'], range: ['#ff5d5d', '#3aa9ff', '#ffc23c'] }, + legend: { title: null }, + }, + order: { field: 'colour' }, + }, + config: cartoonConfig, + }, + }, +]; diff --git a/site/src/playground/chart-redesign-figure.css b/site/src/playground/chart-redesign-figure.css index e5146989..d924a180 100644 --- a/site/src/playground/chart-redesign-figure.css +++ b/site/src/playground/chart-redesign-figure.css @@ -10,7 +10,7 @@ .chart-redesign-figure { width: 100%; max-width: 100%; - min-height: 520px; + min-height: 560px; display: grid; grid-template-columns: minmax(0, 1fr) 72px minmax(0, 1fr); align-items: center; @@ -22,19 +22,27 @@ .redesign-real-mcp { min-width: 0; - min-height: 430px; + min-height: 470px; box-sizing: border-box; border: 1px solid rgba(0, 0, 0, 0.12); border-radius: 5px; background: #fff; + /* The app is built so the chart frame grows to the graphic, up to this cap. + Pinning `.chart` to a fixed height instead turns any graphic taller than + the frame — a Swiss bubble chart carries a colour key and a size key above + the plot — into a scrollbar inside the figure. Capping the *graphic* is + what the app already knows how to do, so the two panels still frame alike + without either of them scrolling. */ + --chart-max-height: 400px; } .redesign-real-mcp .app { - min-height: 428px; + min-height: 468px; } .redesign-real-mcp .chart { - height: 350px; + min-height: 400px; + overflow: hidden; } .redesign-real-mcp .tc-menu { diff --git a/site/src/playground/playground.css b/site/src/playground/playground.css index 0e8a057d..f9c5c54d 100644 --- a/site/src/playground/playground.css +++ b/site/src/playground/playground.css @@ -62,50 +62,59 @@ background: rgba(0, 0, 0, 0.075); } +/* Grouped nav item (e.g. "Theme labs") with a hover/focus dropdown. */ +.dev-nav-group { + position: relative; + display: inline-flex; +} + +.dev-nav-group-toggle { + border: none; + background: transparent; + cursor: pointer; + font-family: inherit; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.dev-nav-caret { + font-size: 10px; + line-height: 1; + opacity: 0.7; +} + +.dev-nav-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 200; + display: none; + flex-direction: column; + min-width: 150px; + padding: 6px; + gap: 2px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 8px; + background: #ffffff; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12); +} + +.dev-nav-group:hover .dev-nav-dropdown, +.dev-nav-group:focus-within .dev-nav-dropdown { + display: flex; +} + +.dev-nav-dropdown .dev-nav-link { + white-space: nowrap; +} + .dev-content { box-sizing: border-box; width: 100%; padding: 28px 24px 48px; } -.dev-workbench-note { - display: flex; - align-items: center; - gap: 9px; - width: min(100%, 960px); - margin: 0 auto 12px; - color: #66707d; - font-size: 13px; - line-height: 1.5; - animation: dev-note-enter 480ms cubic-bezier(0.2, 0.75, 0.25, 1) both; -} - -.dev-workbench-note::before { - content: ''; - width: 9px; - height: 12px; - flex: 0 0 9px; - border: 1px solid #7f8b99; - border-radius: 2px 2px 0 0; - background: - radial-gradient(circle at 72% 52%, #6f7b88 0 1px, transparent 1.5px), - linear-gradient(90deg, #d5dce4, #b8c3ce); - box-shadow: inset -2px 0 rgba(88, 103, 119, 0.16); - transform-origin: left center; - animation: dev-secret-door 3.6s ease-in-out infinite; -} - -@keyframes dev-note-enter { - from { - opacity: 0; - transform: translateY(5px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - @keyframes dev-secret-door { 0%, 12%, 30%, 100% { transform: perspective(28px) rotateY(0deg); @@ -156,11 +165,4 @@ .dev-content { padding: 20px 14px 36px; } -} - -@media (prefers-reduced-motion: reduce) { - .dev-workbench-note, - .dev-workbench-note::before { - animation: none; - } } \ No newline at end of file diff --git a/site/src/playground/style-references.ts b/site/src/playground/style-references.ts new file mode 100644 index 00000000..0031ff26 --- /dev/null +++ b/site/src/playground/style-references.ts @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Style references — the hand-authored look-and-feel targets. + * + * Each entry is a set of manual Vega-Lite specs written to establish what a + * house should look like before (or alongside) a ThemeSpec preset that has to + * reproduce it. They are NOT theme-pipeline output: that is the point. A + * preset is judged against them by eye. + * + * The specs themselves stay in their own files — they are long, hand-tuned, + * and each is a design document in its own right. This registry only says + * which references exist and what each one is arguing for, so the page that + * shows them needs no per-house code. + */ + +import { SWISS_CASES } from './swiss-lab-data'; +import { CARTOON_CASES } from './cartoon-lab-data'; + +/** One mockup: a spec plus what it is meant to demonstrate. */ +export interface StyleReferenceCase { + id: string; + title: string; + note: string; + spec: any; +} + +export interface StyleReference { + /** URL segment and switcher key. */ + id: string; + label: string; + /** What the reference is arguing for, and how far the preset got. */ + blurb: string; + /** Where the look comes from, so a reader can check the source. */ + refs: string; + cases: StyleReferenceCase[]; +} + +export const STYLE_REFERENCES: StyleReference[] = [ + { + id: 'swiss', + label: 'Swiss', + blurb: + 'The International Typographic Style — a visible modular grid, flush-left Helvetica ' + + 'title block, warm paper with a single signal-red accent, and hard corners. Written ' + + 'to establish the target before the swiss preset was grounded.', + refs: 'swissted.com · Poster House "The Swiss Grid" · Müller-Brockmann · Vignelli subway map · Aicher \'72 palette.', + cases: SWISS_CASES, + }, + { + id: 'cartoon', + label: 'Cartoon', + blurb: + 'A playful, xkcd-flavoured look — a rounded comic face, a bright crayon palette on warm ' + + 'paper, fat dark "sticker" outlines on rounded marks, and big bordered dots. The ' + + 'cartoon preset now owns the reusable levers; emoji markers stay hand-authored, ' + + 'deliberately outside the theme spec.', + refs: 'xkcd.com · "Humor Sans" / xkcd Script · Comic Neue · modern flat-cartoon infographics.', + cases: CARTOON_CASES, + }, +]; + +export const DEFAULT_STYLE_REFERENCE = STYLE_REFERENCES[0].id; + +export const findStyleReference = (id: string | undefined): StyleReference => + STYLE_REFERENCES.find((r) => r.id === id) ?? STYLE_REFERENCES[0]; diff --git a/site/src/playground/swiss-lab-data.ts b/site/src/playground/swiss-lab-data.ts new file mode 100644 index 00000000..d786c2e0 --- /dev/null +++ b/site/src/playground/swiss-lab-data.ts @@ -0,0 +1,359 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Swiss / International Typographic Style — hand-authored Vega-Lite mockups. + * + * These are NOT produced by the theme pipeline. They are manual reference + * specs, written to establish the look & feel of a future "swiss" ThemeSpec + * preset so a human can eyeball the target before we ground it. The design + * tokens they encode (and that the eventual preset must reproduce): + * + * - Type: Helvetica / Akzidenz-Grotesk, flush-left, bold title block. + * - Grid: the modular grid is VISIBLE (unlike NYT, which hides it). + * - Palette: ink black + warm paper + ONE saturated accent (signal red), + * with flat cobalt / mustard / green as extended categoricals. + * - Furniture: hard square corners, thick baseline/domain rules, no shadows, + * no rounding, generous structured margins. + * - Mood: objective, high-contrast, systematic. + * + * Refs: swissted.com · Poster House "The Swiss Grid" · Müller-Brockmann + * Tonhalle posters · Vignelli 1972 NYC Subway Map · Aicher 1972 Munich palette. + */ + +// The case shape is shared with the other style references. +import type { StyleReferenceCase } from './style-references'; + +const FONT = '"Helvetica Neue", Helvetica, Arial, sans-serif'; +const INK = '#1a1a1a'; +const PAPER = '#f4f1ea'; +const GRID = '#d9d5cc'; +const RED = '#e2231a'; + +/** Signal-red first, then flat cobalt / mustard / forest / slate. */ +export const SWISS_PALETTE = [RED, INK, '#0067a5', '#f2b705', '#2a7f4f', '#6b6b6b']; + +/** Shared config — the "system". Every spec spreads this so the grid, type + * and rules stay identical across chart types (that consistency IS the style). */ +const swissConfig = { + background: PAPER, + font: FONT, + padding: { left: 16, top: 14, right: 16, bottom: 14 }, + title: { + anchor: 'start', + font: FONT, + fontSize: 16, + fontWeight: 700, + color: INK, + subtitleFont: FONT, + subtitleFontSize: 11.5, + subtitleColor: '#666', + subtitlePadding: 6, + offset: 14, + }, + view: { stroke: null }, + axis: { + domain: true, + domainColor: INK, + domainWidth: 1.5, + grid: true, + gridColor: GRID, + gridWidth: 1, + tickColor: INK, + tickWidth: 1.5, + tickSize: 6, + labelFont: FONT, + labelFontSize: 11, + labelColor: INK, + labelPadding: 4, + titleFont: FONT, + titleFontSize: 11.5, + titleFontWeight: 700, + titleColor: INK, + }, + legend: { + orient: 'top', + direction: 'horizontal', + titleFont: FONT, + titleColor: INK, + titleFontSize: 11, + titleFontWeight: 700, + labelFont: FONT, + labelFontSize: 11, + labelColor: INK, + symbolType: 'square', + symbolSize: 90, + offset: 6, + padding: 0, + }, +}; + +const W = 360; +const H = 300; + +export const SWISS_CASES: StyleReferenceCase[] = [ + // ── 1. Vertical bars — the archetypal Swiss chart. Flat red, value grid. ── + { + id: 'swiss-bar', + title: 'Vertical bars', + note: 'Single accent (signal red), visible value grid, flush-left bold title, hard corners.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'Output by sector', subtitle: 'Index, 2024 (2015 = 100)' }, + data: { + values: [ + { sector: 'Manufacturing', value: 128 }, + { sector: 'Services', value: 141 }, + { sector: 'Construction', value: 96 }, + { sector: 'Agriculture', value: 74 }, + { sector: 'Energy', value: 112 }, + { sector: 'Transport', value: 103 }, + ], + }, + mark: { type: 'bar', color: RED, cornerRadius: 0 }, + encoding: { + x: { + field: 'sector', + type: 'nominal', + sort: null, + axis: { labelAngle: 0, grid: false, title: null, labelFontSize: 10 }, + }, + y: { + field: 'value', + type: 'quantitative', + axis: { title: 'Index', tickCount: 5 }, + }, + }, + config: swissConfig, + }, + }, + + // ── 2. Multi-series line — straight linear segments, ink + red + cobalt. ── + { + id: 'swiss-line', + title: 'Line series', + note: 'Straight linear segments (no smoothing), thick strokes, square legend swatches on top.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'Real wages by region', subtitle: 'Index, 2010–2024 (2010 = 100)' }, + data: { + values: (() => { + const rows: any[] = []; + const series: Record = { + North: [100, 103, 107, 111, 116, 121, 124, 129], + South: [100, 101, 99, 102, 105, 104, 108, 111], + West: [100, 98, 97, 100, 103, 109, 115, 122], + }; + const years = [2010, 2012, 2014, 2016, 2018, 2020, 2022, 2024]; + for (const [region, vals] of Object.entries(series)) + vals.forEach((v, i) => rows.push({ region, year: years[i], value: v })); + return rows; + })(), + }, + mark: { type: 'line', strokeWidth: 3, interpolate: 'linear', point: false }, + encoding: { + x: { + field: 'year', + type: 'quantitative', + axis: { format: 'd', title: null, tickCount: 4, grid: false }, + scale: { nice: false }, + }, + y: { field: 'value', type: 'quantitative', axis: { title: 'Index' } }, + color: { + field: 'region', + type: 'nominal', + scale: { range: SWISS_PALETTE }, + legend: { title: null }, + }, + }, + config: swissConfig, + }, + }, + + // ── 3. Grouped bars — categorical comparison, flat palette, hard modules. ── + { + id: 'swiss-grouped-bar', + title: 'Grouped bars', + note: 'Flat categorical palette, no gaps rounded, grouped columns read as a grid module.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'Energy mix by country', subtitle: 'Share of generation, 2024 (%)' }, + data: { + values: (() => { + const rows: any[] = []; + const data: Record> = { + Fossil: { CH: 2, DE: 42, FR: 8, IT: 55 }, + Nuclear: { CH: 33, DE: 6, FR: 68, IT: 0 }, + Renewable: { CH: 65, DE: 52, FR: 24, IT: 45 }, + }; + for (const [source, byC] of Object.entries(data)) + for (const [country, v] of Object.entries(byC)) + rows.push({ source, country, value: v }); + return rows; + })(), + }, + mark: { type: 'bar', cornerRadius: 0 }, + encoding: { + x: { + field: 'country', + type: 'nominal', + axis: { labelAngle: 0, grid: false, title: null }, + }, + xOffset: { field: 'source' }, + y: { field: 'value', type: 'quantitative', axis: { title: 'Share (%)' } }, + color: { + field: 'source', + type: 'nominal', + scale: { range: SWISS_PALETTE }, + legend: { title: null }, + }, + }, + config: swissConfig, + }, + }, + + // ── 4. Scatter — geometric square marks on a full modular grid. ── + { + id: 'swiss-scatter', + title: 'Scatter', + note: 'Square marks (geometric), both axes gridded → the modular grid is the picture.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'Density vs. rent', subtitle: '48 districts, 2024' }, + data: { + values: (() => { + // Deterministic pseudo-random cloud with a mild positive trend. + const rows: any[] = []; + let s = 7; + const rnd = () => ((s = (s * 9301 + 49297) % 233280) / 233280); + for (let i = 0; i < 48; i++) { + const d = 20 + rnd() * 160; + rows.push({ density: d, rent: 8 + d * 0.09 + (rnd() - 0.5) * 9 }); + } + return rows; + })(), + }, + mark: { + type: 'point', + shape: 'square', + filled: true, + size: 70, + fill: RED, + fillOpacity: 0.85, + stroke: INK, + strokeWidth: 0.6, + }, + encoding: { + x: { + field: 'density', + type: 'quantitative', + axis: { title: 'Density (per ha)' }, + scale: { nice: true }, + }, + y: { + field: 'rent', + type: 'quantitative', + axis: { title: 'Rent (CHF/m²)' }, + scale: { nice: true }, + }, + }, + config: swissConfig, + }, + }, + + // ── 5. Stacked area — two flat fills, ink + red, straight segments. ── + { + id: 'swiss-area', + title: 'Stacked area', + note: 'Flat solid fills (no gradients), straight segments, value grid over the stack.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'Ridership by line', subtitle: 'Million trips per year' }, + data: { + values: (() => { + const rows: any[] = []; + const years = [2018, 2019, 2020, 2021, 2022, 2023, 2024]; + const tram = [42, 44, 28, 33, 41, 46, 49]; + const bus = [31, 32, 22, 25, 29, 33, 35]; + years.forEach((y, i) => { + rows.push({ year: y, line: 'Tram', trips: tram[i] }); + rows.push({ year: y, line: 'Bus', trips: bus[i] }); + }); + return rows; + })(), + }, + mark: { type: 'area', interpolate: 'linear', line: { strokeWidth: 1 } }, + encoding: { + x: { + field: 'year', + type: 'quantitative', + axis: { format: 'd', title: null, tickCount: 4, grid: false }, + scale: { nice: false }, + }, + y: { + field: 'trips', + type: 'quantitative', + stack: 'zero', + axis: { title: 'Trips (M)' }, + }, + color: { + field: 'line', + type: 'nominal', + scale: { range: SWISS_PALETTE }, + legend: { title: null }, + }, + }, + config: swissConfig, + }, + }, + + // ── 6. Horizontal ranking — flush-left category labels, sorted, red bars. ── + { + id: 'swiss-hbar', + title: 'Horizontal ranking', + note: 'Flush-left category labels, sorted descending, single accent, value grid on x.', + spec: { + width: W, + height: H, + background: PAPER, + title: { text: 'Cost of a coffee', subtitle: 'City average, CHF' }, + data: { + values: [ + { city: 'Zürich', value: 4.8 }, + { city: 'Geneva', value: 4.6 }, + { city: 'Copenhagen', value: 4.3 }, + { city: 'Oslo', value: 4.1 }, + { city: 'Stockholm', value: 3.7 }, + { city: 'Vienna', value: 3.2 }, + { city: 'Berlin', value: 3.0 }, + { city: 'Lisbon', value: 1.9 }, + ], + }, + mark: { type: 'bar', color: RED, cornerRadius: 0 }, + encoding: { + y: { + field: 'city', + type: 'nominal', + sort: '-x', + axis: { title: null, grid: false, labelFontSize: 11, domain: false, ticks: false }, + }, + x: { + field: 'value', + type: 'quantitative', + axis: { title: 'CHF', tickCount: 5 }, + }, + }, + config: swissConfig, + }, + }, +]; diff --git a/site/src/playground/theme-lab-assets/_flint-index.json b/site/src/playground/theme-lab-assets/_flint-index.json new file mode 100644 index 00000000..d59a360d --- /dev/null +++ b/site/src/playground/theme-lab-assets/_flint-index.json @@ -0,0 +1,474 @@ +[ + { + "id": "driving", + "chartType": "Connected Scatter Plot", + "title": "Driving shifts into reverse", + "subtitle": "Miles driven per person against the price of a gallon of gas, United States, 1956–2010", + "source": "NYT / Hannah Fairfield (2010), from US FHWA vehicle-miles + EIA/BLS gas prices; via vega-datasets driving.json", + "rows": 55 + }, + { + "id": "penguins", + "chartType": "Scatter Plot", + "title": "Flipper length versus body mass in three penguin species", + "subtitle": "Palmer Archipelago, Antarctica; n = 33", + "source": "Horst, Hill & Gorman (2020), Palmer Station LTER — 33-row sample", + "rows": 33 + }, + { + "id": "keeling", + "chartType": "Line Chart", + "title": "Keeling Curve", + "subtitle": "Atmospheric CO₂ at Mauna Loa, annual mean, parts per million", + "source": "Scripps CO₂ Program / NOAA GML, Mauna Loa Observatory", + "rows": 14 + }, + { + "id": "population", + "chartType": "Bar Chart", + "title": "Most populous countries, 2023", + "subtitle": "Population in millions", + "source": "UN World Population Prospects 2022 / World Bank (2023)", + "rows": 10 + }, + { + "id": "population-region", + "chartType": "Area Chart", + "title": "World population by region", + "subtitle": "1950–2020, millions of people", + "source": "UN World Population Prospects 2022", + "rows": 25 + }, + { + "id": "faithful", + "chartType": "Scatter Plot", + "title": "Old Faithful eruptions — waiting time vs duration", + "subtitle": "", + "source": "Härdle (1991) / R \"faithful\" dataset — 36-row sample", + "rows": 36 + }, + { + "id": "life-expectancy", + "chartType": "Slope Chart", + "title": "Two decades of longer lives", + "subtitle": "Life expectancy at birth, years, 2000 and 2021", + "source": "World Bank / Our World in Data", + "rows": 14 + }, + { + "id": "population-waterfall", + "chartType": "Waterfall Chart", + "title": "Asia added more people than the world held in 1950", + "subtitle": "Contribution to world population growth by region, 1950–2020, millions", + "source": "UN World Population Prospects 2022 (regional deltas)", + "rows": 6 + }, + { + "id": "nutrition-radar", + "chartType": "Radar Chart", + "title": "Nutrition profile per 100 g — Almonds vs Oats vs Greek yogurt", + "subtitle": "", + "source": "USDA FoodData Central (representative per-100g values)", + "rows": 15 + }, + { + "id": "auto-mpg", + "chartType": "Regression", + "title": "Power against fuel economy", + "subtitle": "Ordinary least squares fit; each point is one car model", + "source": "UCI / StatLib Auto MPG dataset (sample)", + "rows": 22 + }, + { + "id": "faithful-hist", + "chartType": "Histogram", + "title": "Old Faithful eruption durations", + "subtitle": "Two clusters, not one: short eruptions near 2 min and long ones near 4 min", + "source": "R \"faithful\" dataset (sample)", + "rows": 36 + }, + { + "id": "faithful-density", + "chartType": "Density Plot", + "title": "Old Faithful — eruption duration density", + "subtitle": "", + "source": "R \"faithful\" dataset (sample)", + "rows": 36 + }, + { + "id": "penguins-box", + "chartType": "Boxplot", + "title": "Body mass of three Pygoscelis species", + "subtitle": "Boxes show median and interquartile range; whiskers span the full sample; points are individual birds", + "source": "Palmer Station LTER (sample)", + "rows": 33 + }, + { + "id": "penguins-violin", + "chartType": "Violin Plot", + "title": "Body mass by penguin species", + "subtitle": "Kernel density with all observations overlaid; horizontal rule marks the median", + "source": "Palmer Station LTER (sample)", + "rows": 33 + }, + { + "id": "iris-strip", + "chartType": "Strip Plot", + "title": "Iris petal length by species", + "subtitle": "", + "source": "Fisher's Iris (1936), sample", + "rows": 30 + }, + { + "id": "exam-ecdf", + "chartType": "ECDF Plot", + "title": "Empirical distribution of exam scores", + "subtitle": "n = 30; each step is one score", + "source": "Illustrative class scores", + "rows": 30 + }, + { + "id": "medals-grouped", + "chartType": "Grouped Bar Chart", + "title": "Paris 2024 Olympic medals — top nations", + "subtitle": "", + "source": "IOC, Paris 2024 medal table", + "rows": 15 + }, + { + "id": "electricity-stacked", + "chartType": "Stacked Bar Chart", + "title": "Electricity generation mix by country, 2023 (%)", + "subtitle": "", + "source": "Our World in Data / Ember (approx.)", + "rows": 15 + }, + { + "id": "co2-lollipop", + "chartType": "Lollipop Chart", + "title": "Emissions per person run from 37 tonnes to 2", + "subtitle": "Carbon dioxide emissions per capita, 2022, tonnes", + "source": "Our World in Data (Global Carbon Project)", + "rows": 12 + }, + { + "id": "us-pyramid", + "chartType": "Pyramid Chart", + "title": "A pyramid that is no longer a pyramid", + "subtitle": "United States population by age and sex, 2020, millions", + "source": "US Census 2020 (approx.)", + "rows": 12 + }, + { + "id": "gdp-bartable", + "chartType": "Bar Table", + "title": "America and China lap the field", + "subtitle": "Gross domestic product, 2023, trillion US dollars", + "source": "IMF / World Bank 2023", + "rows": 8 + }, + { + "id": "lifeexp-dumbbell", + "chartType": "Ranged Dot Plot", + "title": "Women outlive men everywhere, but not by the same margin", + "subtitle": "Life expectancy at birth by sex, 2021, years", + "source": "World Bank 2021", + "rows": 12 + }, + { + "id": "seattle-range", + "chartType": "Range Area Chart", + "title": "Seattle, month by month", + "subtitle": "Average daily high and low temperature, °F, 1991–2020 normals", + "source": "NOAA climate normals", + "rows": 12 + }, + { + "id": "olympic-bump", + "chartType": "Bump Chart", + "title": "Four Games, four different stories", + "subtitle": "Rank in the Summer Olympics medal table, 2012–2024", + "source": "IOC medal tables", + "rows": 16 + }, + { + "id": "population-stream", + "chartType": "Streamgraph", + "title": "Where the world's people are", + "subtitle": "Population by region, 1950–2020, millions", + "source": "UN World Population Prospects 2022", + "rows": 25 + }, + { + "id": "browser-pie", + "chartType": "Pie Chart", + "title": "Chrome holds two-thirds of the desktop market", + "subtitle": "Desktop browser share, 2024, per cent", + "source": "StatCounter (approx. 2024)", + "rows": 5 + }, + { + "id": "mobile-donut", + "chartType": "Donut Chart", + "title": "Mobile OS market share, 2024", + "subtitle": "", + "source": "StatCounter (approx. 2024)", + "rows": 3 + }, + { + "id": "seattle-rose", + "chartType": "Rose Chart", + "title": "Seattle monthly rainfall (mm)", + "subtitle": "", + "source": "NOAA climate normals", + "rows": 12 + }, + { + "id": "stock-candle", + "chartType": "Candlestick Chart", + "title": "Two weeks of a stock going nowhere", + "subtitle": "Daily open, high, low and close, 2–12 January 2024", + "source": "Illustrative daily prices", + "rows": 9 + }, + { + "id": "temp-heatmap", + "chartType": "Heatmap", + "title": "Average monthly temperature", + "subtitle": "°C, climate normals, four cities", + "source": "Climate normals (approx.)", + "rows": 48 + }, + { + "id": "renewable-kpi", + "chartType": "KPI Card", + "title": "Renewables supply 30% of the world's electricity", + "subtitle": "Share of global electricity generation, 2023, against a 45% target", + "source": "Our World in Data / Ember", + "rows": 1 + }, + { + "id": "renewable-bullet", + "chartType": "Bullet Chart", + "title": "Every country is short of its renewable target", + "subtitle": "Renewable share of electricity, 2023, per cent, against national targets", + "source": "Our World in Data / Ember (targets illustrative)", + "rows": 5 + }, + { + "id": "cities-map", + "chartType": "Map", + "title": "World's largest cities (metro population)", + "subtitle": "", + "source": "UN / city statistics (approx.)", + "rows": 10 + }, + { + "id": "release-gantt", + "chartType": "Gantt Chart", + "title": "Software release schedule", + "subtitle": "", + "source": "Illustrative project plan", + "rows": 5 + }, + { + "id": "kpi-sparkline", + "chartType": "Sparkline", + "title": "Monthly KPIs", + "subtitle": "Twelve-month trend and latest value", + "source": "Illustrative company metrics", + "rows": 36 + }, + { + "id": "gapminder-bubble", + "chartType": "Scatter Plot", + "title": "Money buys years, up to a point", + "subtitle": "Life expectancy against GDP per capita, 2018; bubble area is population", + "source": "Gapminder / World Bank (2018)", + "rows": 15 + }, + { + "id": "anscombe", + "chartType": "Regression", + "title": "Anscombe's Quartet — same stats, different shapes", + "subtitle": "", + "source": "F. J. Anscombe (1973)", + "rows": 44 + }, + { + "id": "temp-anomaly", + "chartType": "Bar Chart", + "title": "Global temperature anomaly by decade", + "subtitle": "°C against the 1951–1980 average", + "source": "NASA GISTEMP (approx. decadal means)", + "rows": 9 + }, + { + "id": "us-unemployment", + "chartType": "Line Chart", + "title": "American unemployment", + "subtitle": "Annual rate, per cent of the labour force, 2000–2023", + "source": "US Bureau of Labor Statistics", + "rows": 24 + }, + { + "id": "titanic", + "chartType": "Grouped Bar Chart", + "title": "Titanic survival rate by class and sex", + "subtitle": "", + "source": "Encyclopedia Titanica passenger records", + "rows": 6 + }, + { + "id": "diamonds", + "chartType": "Scatter Plot", + "title": "Diamonds — carat vs price", + "subtitle": "", + "source": "ggplot2 \"diamonds\" (sample)", + "rows": 20 + }, + { + "id": "happiness", + "chartType": "Scatter Plot", + "title": "World Happiness vs income per capita (2023)", + "subtitle": "", + "source": "World Happiness Report 2023 / World Bank", + "rows": 12 + }, + { + "id": "sunspots", + "chartType": "Line Chart", + "title": "Sunspot number, 2000–2023", + "subtitle": "", + "source": "SILSO / Royal Observatory of Belgium (approx.)", + "rows": 24 + }, + { + "id": "marathon-wr", + "chartType": "Line Chart", + "title": "Men's marathon world record, 1908–2023 (minutes)", + "subtitle": "", + "source": "World Athletics record progression", + "rows": 16 + }, + { + "id": "ev-share", + "chartType": "Line Chart", + "title": "Electric cars as a share of new sales", + "subtitle": "Norway, China, Germany and the United States, 2018–2023, per cent of new car sales", + "source": "Our World in Data / IEA (approx.)", + "rows": 16 + }, + { + "id": "internet-users", + "chartType": "Area Chart", + "title": "Share of the world online, 1995–2023 (%)", + "subtitle": "", + "source": "Our World in Data / ITU", + "rows": 8 + }, + { + "id": "big-mac", + "chartType": "Bar Chart", + "title": "The price of a Big Mac", + "subtitle": "2023, converted to US dollars at market exchange rates", + "source": "The Economist Big Mac index (approx. 2023)", + "rows": 12 + }, + { + "id": "oecd-unemployment-facet", + "chartType": "Line Chart", + "title": "Out of work", + "subtitle": "Unemployment rate, %, 2000–2022", + "source": "OECD Labour Force Statistics (harmonised unemployment rate)", + "rows": 24 + }, + { + "id": "spending-quintile", + "chartType": "Stacked Bar Chart", + "title": "Where each income group's money goes", + "subtitle": "Share of annual household spending, by income quintile", + "source": "US Bureau of Labor Statistics, Consumer Expenditure Survey (approx. 2022)", + "rows": 25 + }, + { + "id": "trust-likert", + "chartType": "Stacked Bar Chart", + "title": "Confidence in US institutions", + "subtitle": "% of adults expressing each level of confidence", + "source": "Pew Research Center / Gallup confidence-in-institutions series (approx. 2023)", + "rows": 20 + }, + { + "id": "fed-funds-step", + "chartType": "Line Chart", + "title": "Federal funds target rate", + "subtitle": "Upper bound at year end, %", + "source": "US Federal Reserve, FOMC target range (year-end upper bound)", + "rows": 10 + }, + { + "id": "renewables-projection", + "chartType": "Line Chart", + "title": "Global renewable capacity", + "subtitle": "Gigawatts installed, observed to 2023 and projected to 2030", + "source": "IEA Renewables market report (approx. figures)", + "rows": 9 + }, + { + "id": "earnings-education", + "chartType": "Grouped Bar Chart", + "title": "Median weekly earnings by education and sex, 2023", + "subtitle": "US dollars, full-time wage and salary workers", + "source": "US Bureau of Labor Statistics, usual weekly earnings (2023 annual)", + "rows": 10 + }, + { + "id": "electricity-mix-area", + "chartType": "Area Chart", + "title": "Where the power comes from", + "subtitle": "World electricity generation by source, % of total", + "source": "IEA / Ember global electricity review (approx. shares)", + "rows": 24 + }, + { + "id": "state-unemployment", + "chartType": "Choropleth", + "title": "Unemployment rate by state, 2023", + "subtitle": "Annual average, % of the civilian labour force", + "source": "US Bureau of Labor Statistics, Local Area Unemployment Statistics (2023 annual)", + "rows": 50 + }, + { + "id": "causes-death", + "chartType": "Bar Chart", + "title": "What Americans die of", + "subtitle": "Leading causes of death, United States, 2022, thousands of deaths", + "source": "CDC / National Center for Health Statistics, leading causes of death (approx. 2022)", + "rows": 10 + }, + { + "id": "state-jobless", + "chartType": "Bar Chart", + "title": "Unemployment by state", + "subtitle": "Annual average unemployment rate, 50 US states, 2023, per cent", + "source": "US Bureau of Labor Statistics, Local Area Unemployment Statistics (2023 annual)", + "rows": 50 + }, + { + "id": "oecd-facet-16", + "chartType": "Line Chart", + "title": "Sixteen labour markets, one shock", + "subtitle": "Harmonised unemployment rate, selected OECD economies, 2000–2023, per cent", + "source": "OECD Labour Force Statistics (harmonised unemployment rate, approx.)", + "rows": 80 + }, + { + "id": "temp-uncertainty", + "chartType": "Range Area Chart", + "title": "The record gets more certain as it gets warmer", + "subtitle": "Global mean temperature anomaly against 1961–1990, with 95% confidence interval, °C", + "source": "Met Office Hadley Centre / UEA CRU, HadCRUT5 global annual anomaly vs 1961–1990 (approx.)", + "rows": 10 + } +] diff --git a/site/src/playground/theme-lab-assets/_headlines.json b/site/src/playground/theme-lab-assets/_headlines.json new file mode 100644 index 00000000..b4e8a259 --- /dev/null +++ b/site/src/playground/theme-lab-assets/_headlines.json @@ -0,0 +1,188 @@ +{ + "_readme": [ + "Canonical headline text for the theme lab. The SAME title and subtitle strings are", + "stamped onto both columns: `scripts/dump-flint-specs.ts` writes them into every", + "`.flint.json` baseline (Flint itself has no title support — ChartAssemblyInput", + "has no title field — so a consumer has to post-process the spec), and each", + "hand-authored `..json` carries the identical strings.", + "", + "Holding the words constant is the point: it removes the confound where a bespoke", + "redesign looks better merely because it has a headline and the baseline does not.", + "What differs between the two columns is typography, anchoring, spacing and colour —", + "never the information.", + "", + "Cases with no entry here fall back to the corpus title from shared/preview-cases.ts", + "and get no subtitle. Duplicating strings that also live in the corpus is deliberate:", + "the theme lab owns its own text so specs can be tuned without touching the gallery." + ], + "headlines": { + "ev-share": { + "title": "Electric cars as a share of new sales", + "subtitle": "Norway, China, Germany and the United States, 2018–2023, per cent of new car sales" + }, + "renewable-kpi": { + "title": "Renewables supply 30% of the world's electricity", + "subtitle": "Share of global electricity generation, 2023, against a 45% target" + }, + "renewable-bullet": { + "title": "Every country is short of its renewable target", + "subtitle": "Renewable share of electricity, 2023, per cent, against national targets" + }, + "stock-candle": { + "title": "Two weeks of a stock going nowhere", + "subtitle": "Daily open, high, low and close, 2–12 January 2024" + }, + "us-pyramid": { + "title": "A pyramid that is no longer a pyramid", + "subtitle": "United States population by age and sex, 2020, millions" + }, + "co2-lollipop": { + "title": "Emissions per person run from 37 tonnes to 2", + "subtitle": "Carbon dioxide emissions per capita, 2022, tonnes" + }, + "browser-pie": { + "title": "Chrome holds two-thirds of the desktop market", + "subtitle": "Desktop browser share, 2024, per cent" + }, + "gdp-bartable": { + "title": "America and China lap the field", + "subtitle": "Gross domestic product, 2023, trillion US dollars" + }, + "population-waterfall": { + "title": "Asia added more people than the world held in 1950", + "subtitle": "Contribution to world population growth by region, 1950–2020, millions" + }, + "lifeexp-dumbbell": { + "title": "Women outlive men everywhere, but not by the same margin", + "subtitle": "Life expectancy at birth by sex, 2021, years" + }, + "faithful-hist": { + "title": "Old Faithful eruption durations", + "subtitle": "Two clusters, not one: short eruptions near 2 min and long ones near 4 min" + }, + "penguins-violin": { + "title": "Body mass by penguin species", + "subtitle": "Kernel density with all observations overlaid; horizontal rule marks the median" + }, + "auto-mpg": { + "title": "Power against fuel economy", + "subtitle": "Ordinary least squares fit; each point is one car model" + }, + "life-expectancy": { + "title": "Two decades of longer lives", + "subtitle": "Life expectancy at birth, years, 2000 and 2021" + }, + "seattle-range": { + "title": "Seattle, month by month", + "subtitle": "Average daily high and low temperature, °F, 1991–2020 normals" + }, + "gapminder-bubble": { + "title": "Money buys years, up to a point", + "subtitle": "Life expectancy against GDP per capita, 2018; bubble area is population" + }, + "driving": { + "title": "Driving shifts into reverse", + "subtitle": "Miles driven per person against the price of a gallon of gas, United States, 1956–2010" + }, + "olympic-bump": { + "title": "Four Games, four different stories", + "subtitle": "Rank in the Summer Olympics medal table, 2012–2024" + }, + "population-stream": { + "title": "Where the world's people are", + "subtitle": "Population by region, 1950–2020, millions" + }, + "keeling": { + "title": "Keeling Curve", + "subtitle": "Atmospheric CO₂ at Mauna Loa, annual mean, parts per million" + }, + "temp-anomaly": { + "title": "Global temperature anomaly by decade", + "subtitle": "°C against the 1951–1980 average" + }, + "us-unemployment": { + "title": "American unemployment", + "subtitle": "Annual rate, per cent of the labour force, 2000–2023" + }, + "renewables-projection": { + "title": "Global renewable capacity", + "subtitle": "Gigawatts installed, observed to 2023 and projected to 2030" + }, + "big-mac": { + "title": "The price of a Big Mac", + "subtitle": "2023, converted to US dollars at market exchange rates" + }, + "oecd-unemployment-facet": { + "title": "Out of work", + "subtitle": "Unemployment rate, %, 2000–2022" + }, + "electricity-mix-area": { + "title": "Where the power comes from", + "subtitle": "World electricity generation by source, % of total" + }, + "penguins": { + "title": "Flipper length versus body mass in three penguin species", + "subtitle": "Palmer Archipelago, Antarctica; n = 33" + }, + "penguins-box": { + "title": "Body mass of three Pygoscelis species", + "subtitle": "Boxes show median and interquartile range; whiskers span the full sample; points are individual birds" + }, + "exam-ecdf": { + "title": "Empirical distribution of exam scores", + "subtitle": "n = 30; each step is one score" + }, + "population": { + "title": "Most populous countries, 2023", + "subtitle": "Population in millions" + }, + "earnings-education": { + "title": "Median weekly earnings by education and sex, 2023", + "subtitle": "US dollars, full-time wage and salary workers" + }, + "spending-quintile": { + "title": "Where each income group's money goes", + "subtitle": "Share of annual household spending, by income quintile" + }, + "population-region": { + "title": "World population by region", + "subtitle": "1950–2020, millions of people" + }, + "trust-likert": { + "title": "Confidence in US institutions", + "subtitle": "% of adults expressing each level of confidence" + }, + "state-unemployment": { + "title": "Unemployment rate by state, 2023", + "subtitle": "Annual average, % of the civilian labour force" + }, + "temp-heatmap": { + "title": "Average monthly temperature", + "subtitle": "°C, climate normals, four cities" + }, + "fed-funds-step": { + "title": "Federal funds target rate", + "subtitle": "Upper bound at year end, %" + }, + "kpi-sparkline": { + "title": "Monthly KPIs", + "subtitle": "Twelve-month trend and latest value" + }, + "causes-death": { + "title": "What Americans die of", + "subtitle": "Leading causes of death, United States, 2022, thousands of deaths" + }, + "state-jobless": { + "title": "Unemployment by state", + "subtitle": "Annual average unemployment rate, 50 US states, 2023, per cent" + }, + "oecd-facet-16": { + "title": "Sixteen labour markets, one shock", + "subtitle": "Harmonised unemployment rate, selected OECD economies, 2000–2023, per cent" + }, + "temp-uncertainty": { + "title": "The record gets more certain as it gets warmer", + "subtitle": "Global mean temperature anomaly against 1961–1990, with 95% confidence interval, °C" + } + } +} diff --git a/site/src/playground/theme-lab-assets/_themes.json b/site/src/playground/theme-lab-assets/_themes.json new file mode 100644 index 00000000..e30c31b8 --- /dev/null +++ b/site/src/playground/theme-lab-assets/_themes.json @@ -0,0 +1,117 @@ +{ + "order": ["nyt", "economist", "nature", "mckinsey", "datawrapper", "powerbi", "powerbi-light"], + "themes": { + "nyt": { + "label": "NYT", + "alias": "narrative-editorial", + "surface": "#ffffff", + "ink": "#121212", + "accent": "#c2352b", + "swatches": ["#c2352b", "#2f6b9a", "#d9a441", "#4a8b6f", "#7f6a9e", "#9e9e9e"], + "intent": "One chart, one idea. Typography carries the hierarchy, the frame gets out of the way, and a single accent colour does the pointing that a caption would otherwise have to do.", + "signature": [ + "Serif headline flush left, sans-serif everything else", + "No frame: horizontal gridlines only, black x baseline kept", + "Legend replaced by direct series labels where the data allows", + "One editorial accent; other series fall back to grey", + "Y-axis title rotated flat above the axis as a bare unit" + ] + }, + "economist": { + "label": "Economist", + "alias": "compact-editorial", + "surface": "#ffffff", + "ink": "#121317", + "accent": "#e3120b", + "swatches": ["#006ba2", "#3ebcd2", "#379a8b", "#ebb434", "#b4ba39", "#9a607f"], + "intent": "A branded house style that has to survive being printed a hundred times a week. Fixed identity, dense information, zero per-chart art direction.", + "signature": [ + "Red masthead rule top-left; bold headline, grey deck below", + "Value axis moved to the top; horizontal gridlines only", + "Hard baseline, thin bars, wide gutters", + "Fixed house palette used in a fixed order", + "9.5pt labels throughout — sized for a two-column print grid" + ] + }, + "nature": { + "label": "Nature", + "alias": "scientific-publication", + "surface": "#ffffff", + "ink": "#000000", + "accent": "#0072b2", + "swatches": ["#0072b2", "#e69f00", "#009e73", "#cc79a7", "#56b4e9", "#d55e00"], + "intent": "A figure panel that must be readable at 89 mm column width, in greyscale, by a colour-blind reviewer, and defensible in a methods section.", + "signature": [ + "Column-width geometry: 8.5pt type, hairlines, tight padding", + "Black L-shaped spines, outward ticks, no gridlines", + "Okabe–Ito palette; colour is never the only cue", + "Terse semantic title centred below the panel like a figure caption", + "Units mandatory in every axis title" + ] + }, + "mckinsey": { + "label": "McKinsey", + "alias": "executive-briefing", + "surface": "#ffffff", + "ink": "#051c2c", + "accent": "#2251ff", + "swatches": ["#2251ff", "#b3c0c9", "#d3dce1", "#8fa0ab", "#00a9f4", "#051c2c"], + "intent": "The chart is evidence for a claim someone is about to make out loud. Everything that is not the claim is greyed out or deleted.", + "signature": [ + "Value axis deleted — numbers printed on the marks", + "Exactly one series in brand blue; the rest grey", + "No gridlines, ticks or axis titles", + "Horizontal bars, sorted, labels reading left to right", + "Deep-navy ink and generous whitespace" + ] + }, + "datawrapper": { + "label": "Datawrapper", + "alias": "accessible-civic", + "surface": "#ffffff", + "ink": "#333333", + "accent": "#18a1cd", + "swatches": ["#18a1cd", "#e2a233", "#c04a4a", "#2d8659", "#7e5aa2", "#6b6b6b"], + "intent": "A public-facing chart that will be read on a phone and republished by a local newsroom. Legibility is the whole brief.", + "signature": [ + "11pt type floor so it survives mobile downscaling", + "High-contrast ink, light dashed gridlines, no frame", + "Explicit colour domain so a category keeps its hue across a story", + "Legend on top with large square symbols", + "Footer hairline closing the chart block" + ] + }, + "powerbi": { + "label": "Power BI", + "alias": "product-dashboard", + "surface": "#1b1a19", + "ink": "#f3f2f1", + "accent": "#118dff", + "swatches": ["#118dff", "#e66c37", "#3bd1c7", "#e044a7", "#d9b300", "#8764b8"], + "intent": "A tile in a grid of tiles that refreshes every hour. Geometry has to stay stable as the data changes, and the whole thing has to work in dark mode.", + "signature": [ + "Dark card surface; ink and grid tuned for #1B1A19", + "Compact 9.5pt Segoe UI — the tile is small on purpose", + "Dashed low-contrast gridlines; marks stay dominant", + "Legend in a fixed position as a reusable convention", + "Status colours reserved for thresholds" + ] + }, + "powerbi-light": { + "label": "Power BI (light)", + "alias": "product-dashboard-light", + "surface": "#ffffff", + "ink": "#252423", + "accent": "#118dff", + "swatches": ["#118dff", "#12239e", "#e66c37", "#6b007b", "#e044a7", "#744ec2"], + "intent": "The default polished light-mode report tile: a white card in a grid of cards, the same product as the dark theme with the ink flipped. Geometry stays stable as the data refreshes; legibility comes from a hairline grid and a fixed palette, not contrast against a dark ground.", + "signature": [ + "White card surface; #252423 ink, #ededed hairline grid", + "Compact 9.5pt Segoe UI — the tile is small on purpose", + "Solid low-contrast gridlines; marks stay dominant", + "Legend fixed on the right as a reusable convention", + "Status colours reserved for thresholds" + ] + } + } +} diff --git a/site/src/playground/theme-lab-assets/_themespecs.json b/site/src/playground/theme-lab-assets/_themespecs.json new file mode 100644 index 00000000..35806d51 --- /dev/null +++ b/site/src/playground/theme-lab-assets/_themespecs.json @@ -0,0 +1,95 @@ +{ + "$comment": "Theme Lab bookkeeping. The ThemeSpecs themselves now ship in flint-js (core/theme/presets); this file keeps only the coverage record: per case, whether the spec vocabulary is sufficient for a compiler to reach the hand-authored result.", + "schema": "https://flint.dev/schema/themespec/v12", + "coverage": { + "$comment": "status: `full` = v6 is sufficient for a compiler; `partial` = reachable but one stated move is not derivable; `blocked` = a required move is outside the ThemeSpec by construction.", + "electricity-mix-area": { + "status": "blocked", + "notes": [ + "Colour assigned by domain meaning: coal and gas take dark slate and rust so the ramp reads dirty-to-clean. No compiler-resolvable signal distinguishes coal from wind. This is editorial knowledge, not a theme policy." + ] + }, + "gdp-bartable": { + "status": "blocked", + "notes": [ + "A two-panel hconcat is collapsed into one plot. §7 forbids the ThemeSpec from altering composition, and this redesign depends on it." + ] + }, + "us-pyramid": { + "status": "full", + "notes": [ + "Reclassified in iteration 11. 'Male' and 'Female' are titles over the two halves of the plot, not names attached to marks, so this case supports no legend placement at all - it is `annotation`/title territory. It had been counted as direct labelling only because the draft implementation derived the two words by filtering the data to the '75+' band. Fixing that removed Datawrapper's only series-end evidence." + ] + }, + "stock-candle": { + "status": "blocked", + "notes": [ + "A temporal scale is converted to ordinal to delete non-trading gaps. That changes what the x position means, so it is semantic." + ] + }, + "penguins-box": { + "status": "partial", + "notes": [ + "Whisker extent changed from 1.5×IQR to min-max. A statistical definition, not a treatment — correctly outside the ThemeSpec, but the redesign needs it." + ] + }, + "faithful-hist": { + "status": "partial", + "notes": [ + "Bin width stated rather than inferred. Binning changes the claim, so it is semantic." + ] + }, + "exam-ecdf": { + "status": "partial", + "notes": [ + "Curve extended to both edges of the score range so the function starts at 0 and reaches 1. A domain decision." + ] + }, + "driving": { + "status": "partial", + "notes": [ + "Zero baseline dropped. Zero inclusion is semantic — and NYT drops it here while forcing it in us-unemployment, so there is no house policy to encode." + ] + }, + "keeling": { + "status": "partial", + "notes": [ + "Zero baseline dropped, same reasoning." + ] + }, + "renewable-kpi": { + "status": "partial", + "notes": [ + "Flint emits '67% of 45' as a string literal; the redesign computes it. A template-quality fix, not a theming act." + ] + }, + "trust-likert": { + "status": "partial", + "notes": [ + "stackMode 'center' is a no-op when every row totals 100, so the chart is not the diverging Likert it declares. A chart-type capability gap." + ] + }, + "renewable-bullet": { + "status": "partial", + "notes": [ + "Qualitative bands are not comparable across rows; the redesign replaces them. A semantic repair." + ] + }, + "temp-heatmap": { + "status": "full", + "notes": [ + "The continuous-colour cluster, added in iteration 9. All six languages redesign the same heatmap, so the ramp policy is separated from the chart that asked for it.", + "Confirms that ranked `legend.placement` degrades on its own: NYT, Economist and McKinsey all rank `seriesEnd` first, which a continuous ramp cannot realize, so grounding falls through to `top` and every one of them keeps a boxed legend - without the theme naming an exception.", + "Forced two fields - `ink.series.selection.signed: sequential` (McKinsey) and `legend.suppressWhenValuesPrinted` (McKinsey again). The repeated appearance of `suppressWhen*` flags is what eventually exposed `keying` as a legend block, in iteration 10.", + "Nature's ramp neutral is #FFFFFF, which is also its surface. Dropping the cell stroke follows from comparing `ramp.neutral` to the resolved surface, so it needs no new field - but it is the first case where the MIDPOINT, not the endpoints, collides with the paper." + ] + }, + "state-unemployment": { + "status": "partial", + "notes": [ + "`consumption: quantize` with `quantizeCount: 5` reaches the banded scale and the swatch key. What it does not reach is the break FORMAT: at one significant figure the five bands read 2%, 3%, 3%, 4%, 4%, and the renderer then drops the collisions. Choosing the precision that makes breaks distinct is a numeric decision the ThemeSpec cannot make without seeing the domain." + ] + } + }, + "$evidence": "ink.series.sequential / .diverging are measured from every redesign in the lab that carries a continuous colour scale: state-unemployment.datawrapper (quantized sequential) and temp-heatmap in all six languages (diverging, except McKinsey). browser-pie.mckinsey samples a ramp categorically. temp-anomaly.nyt looks continuous but is nominal Below/Above average, so it is ink.series.status. McKinsey carries no diverging ramp because the house has no warm ink: on temp-heatmap it answers a signed measure with a single-hue sequential wash plus printed values, which is a measured house decision rather than a gap. Nature is the only language whose ramp does not hold its endpoints against the surface - its midpoint IS the surface, which is why its cells are left unstroked." +} diff --git a/site/src/playground/theme-lab-assets/anscombe.flint.json b/site/src/playground/theme-lab-assets/anscombe.flint.json new file mode 100644 index 00000000..5fca2868 --- /dev/null +++ b/site/src/playground/theme-lab-assets/anscombe.flint.json @@ -0,0 +1,331 @@ +{ + "facet": { + "field": "Dataset", + "type": "nominal", + "sort": null + }, + "columns": 4, + "spec": { + "layer": [ + { + "mark": "circle", + "encoding": { + "x": { + "field": "X", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Y", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "color": {}, + "size": {} + } + }, + { + "mark": { + "type": "line", + "color": "red" + }, + "transform": [ + { + "regression": "Y", + "on": "X" + } + ], + "encoding": { + "x": { + "field": "X", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Y", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + } + } + } + ], + "encoding": {} + }, + "config": { + "view": { + "continuousWidth": 105, + "continuousHeight": 154, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 8, + "titleFontSize": 11, + "titleFontWeight": "normal", + "titleColor": "#666" + }, + "axisY": { + "labelFontSize": 8, + "titleFontSize": 11, + "titleFontWeight": "normal", + "titleColor": "#666" + }, + "legend": { + "labelFontSize": 9, + "titleFontSize": 9 + }, + "headerFacet": { + "labelLimit": 125 + }, + "facet": { + "spacing": 11 + } + }, + "title": { + "text": "Anscombe's Quartet — same stats, different shapes" + }, + "data": { + "values": [ + { + "Dataset": "I", + "X": 10, + "Y": 8.04 + }, + { + "Dataset": "I", + "X": 8, + "Y": 6.95 + }, + { + "Dataset": "I", + "X": 13, + "Y": 7.58 + }, + { + "Dataset": "I", + "X": 9, + "Y": 8.81 + }, + { + "Dataset": "I", + "X": 11, + "Y": 8.33 + }, + { + "Dataset": "I", + "X": 14, + "Y": 9.96 + }, + { + "Dataset": "I", + "X": 6, + "Y": 7.24 + }, + { + "Dataset": "I", + "X": 4, + "Y": 4.26 + }, + { + "Dataset": "I", + "X": 12, + "Y": 10.84 + }, + { + "Dataset": "I", + "X": 7, + "Y": 4.82 + }, + { + "Dataset": "I", + "X": 5, + "Y": 5.68 + }, + { + "Dataset": "II", + "X": 10, + "Y": 9.14 + }, + { + "Dataset": "II", + "X": 8, + "Y": 8.14 + }, + { + "Dataset": "II", + "X": 13, + "Y": 8.74 + }, + { + "Dataset": "II", + "X": 9, + "Y": 8.77 + }, + { + "Dataset": "II", + "X": 11, + "Y": 9.26 + }, + { + "Dataset": "II", + "X": 14, + "Y": 8.1 + }, + { + "Dataset": "II", + "X": 6, + "Y": 6.13 + }, + { + "Dataset": "II", + "X": 4, + "Y": 3.1 + }, + { + "Dataset": "II", + "X": 12, + "Y": 9.13 + }, + { + "Dataset": "II", + "X": 7, + "Y": 7.26 + }, + { + "Dataset": "II", + "X": 5, + "Y": 4.74 + }, + { + "Dataset": "III", + "X": 10, + "Y": 7.46 + }, + { + "Dataset": "III", + "X": 8, + "Y": 6.77 + }, + { + "Dataset": "III", + "X": 13, + "Y": 12.74 + }, + { + "Dataset": "III", + "X": 9, + "Y": 7.11 + }, + { + "Dataset": "III", + "X": 11, + "Y": 7.81 + }, + { + "Dataset": "III", + "X": 14, + "Y": 8.84 + }, + { + "Dataset": "III", + "X": 6, + "Y": 6.08 + }, + { + "Dataset": "III", + "X": 4, + "Y": 5.39 + }, + { + "Dataset": "III", + "X": 12, + "Y": 8.15 + }, + { + "Dataset": "III", + "X": 7, + "Y": 6.42 + }, + { + "Dataset": "III", + "X": 5, + "Y": 5.73 + }, + { + "Dataset": "IV", + "X": 8, + "Y": 6.58 + }, + { + "Dataset": "IV", + "X": 8, + "Y": 5.76 + }, + { + "Dataset": "IV", + "X": 8, + "Y": 7.71 + }, + { + "Dataset": "IV", + "X": 8, + "Y": 8.84 + }, + { + "Dataset": "IV", + "X": 8, + "Y": 8.47 + }, + { + "Dataset": "IV", + "X": 8, + "Y": 7.04 + }, + { + "Dataset": "IV", + "X": 8, + "Y": 5.25 + }, + { + "Dataset": "IV", + "X": 19, + "Y": 12.5 + }, + { + "Dataset": "IV", + "X": 8, + "Y": 5.56 + }, + { + "Dataset": "IV", + "X": 8, + "Y": 7.91 + }, + { + "Dataset": "IV", + "X": 8, + "Y": 6.89 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/auto-mpg.flint.json b/site/src/playground/theme-lab-assets/auto-mpg.flint.json new file mode 100644 index 00000000..66a0dcf5 --- /dev/null +++ b/site/src/playground/theme-lab-assets/auto-mpg.flint.json @@ -0,0 +1,186 @@ +{ + "layer": [ + { + "mark": "circle", + "encoding": { + "x": { + "field": "Horsepower", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "MPG", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "color": {}, + "size": {} + } + }, + { + "mark": { + "type": "line", + "color": "red" + }, + "transform": [ + { + "regression": "MPG", + "on": "Horsepower" + } + ], + "encoding": { + "x": { + "field": "Horsepower", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "MPG", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + } + } + } + ], + "encoding": {}, + "config": { + "view": { + "continuousWidth": 313, + "continuousHeight": 250 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Power against fuel economy", + "subtitle": [ + "Ordinary least squares fit; each point is one car model" + ] + }, + "data": { + "values": [ + { + "Horsepower": 130, + "MPG": 18 + }, + { + "Horsepower": 165, + "MPG": 15 + }, + { + "Horsepower": 150, + "MPG": 18 + }, + { + "Horsepower": 150, + "MPG": 16 + }, + { + "Horsepower": 140, + "MPG": 17 + }, + { + "Horsepower": 198, + "MPG": 15 + }, + { + "Horsepower": 220, + "MPG": 14 + }, + { + "Horsepower": 215, + "MPG": 14 + }, + { + "Horsepower": 97, + "MPG": 22 + }, + { + "Horsepower": 85, + "MPG": 26 + }, + { + "Horsepower": 88, + "MPG": 25 + }, + { + "Horsepower": 46, + "MPG": 26 + }, + { + "Horsepower": 90, + "MPG": 25 + }, + { + "Horsepower": 95, + "MPG": 24 + }, + { + "Horsepower": 68, + "MPG": 29 + }, + { + "Horsepower": 70, + "MPG": 27 + }, + { + "Horsepower": 52, + "MPG": 30 + }, + { + "Horsepower": 65, + "MPG": 31 + }, + { + "Horsepower": 67, + "MPG": 30 + }, + { + "Horsepower": 48, + "MPG": 43 + }, + { + "Horsepower": 66, + "MPG": 32 + }, + { + "Horsepower": 100, + "MPG": 22 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/auto-mpg.nature.json b/site/src/playground/theme-lab-assets/auto-mpg.nature.json new file mode 100644 index 00000000..bb5ab5f5 --- /dev/null +++ b/site/src/playground/theme-lab-assets/auto-mpg.nature.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nature", + "__design__": [ + "Empty colour and size channels removed. Flint emits 'color: {}' and 'size: {}' on the point layer with no field behind them, which is a spec-level no-op that still costs Vega-Lite a scale resolution — and it is the reason this baseline throws a warning when compiled.", + "Fit statistics printed on the panel: slope, R² and n, computed by the same regression transform that draws the line. A trend line without a fit statistic is a drawing, not a result — a reviewer cannot tell a real relationship from a line through noise.", + "Regression line recoloured from Flint's literal 'red' to black, and the points to Okabe–Ito blue. Red is the convention for an error or an alert; the fit is neither, and in greyscale it becomes indistinguishable from the data anyway.", + "Line drawn *under* the points rather than over them, and clipped to the observed power range rather than run out to the panel edges — the fit should not occlude the observations it summarises, nor imply predictions outside the data it was estimated from.", + "Both axis titles given their units (hp, miles per US gallon) and the axis extents pinned, so the same panel can be reprinted beside another without the scales silently changing with the sample.", + "Black L-shaped spines with outward 3.5pt ticks, no gridlines, 8.5pt type and 89 mm column geometry." + ], + "background": "#ffffff", + "padding": { "left": 4, "top": 4, "right": 6, "bottom": 4 }, + "width": 220, + "height": 165, + "title": { + "text": "Power against fuel economy", + "subtitle": ["Ordinary least squares fit; each point is one car model"] + }, + "data": { + "values": [ + { "Horsepower": 130, "MPG": 18 }, + { "Horsepower": 165, "MPG": 15 }, + { "Horsepower": 150, "MPG": 18 }, + { "Horsepower": 150, "MPG": 16 }, + { "Horsepower": 140, "MPG": 17 }, + { "Horsepower": 198, "MPG": 15 }, + { "Horsepower": 220, "MPG": 14 }, + { "Horsepower": 215, "MPG": 14 }, + { "Horsepower": 97, "MPG": 22 }, + { "Horsepower": 85, "MPG": 26 }, + { "Horsepower": 88, "MPG": 25 }, + { "Horsepower": 46, "MPG": 26 }, + { "Horsepower": 90, "MPG": 25 }, + { "Horsepower": 95, "MPG": 24 }, + { "Horsepower": 68, "MPG": 29 }, + { "Horsepower": 70, "MPG": 27 }, + { "Horsepower": 52, "MPG": 30 }, + { "Horsepower": 65, "MPG": 31 }, + { "Horsepower": 67, "MPG": 30 }, + { "Horsepower": 48, "MPG": 43 }, + { "Horsepower": 66, "MPG": 32 }, + { "Horsepower": 100, "MPG": 22 } + ] + }, + "encoding": { + "x": { + "field": "Horsepower", + "type": "quantitative", + "title": "Engine power (hp)", + "scale": { "zero": false, "nice": false, "domain": [30, 235] }, + "axis": { "tickCount": 5, "format": "d" } + }, + "y": { + "field": "MPG", + "type": "quantitative", + "title": "Fuel economy (miles per US gallon)", + "scale": { "zero": false, "nice": false, "domain": [10, 46] }, + "axis": { "tickCount": 5, "format": "d" } + } + }, + "layer": [ + { + "transform": [{ "regression": "MPG", "on": "Horsepower", "extent": [46, 220] }], + "mark": { "type": "line", "color": "#000000", "strokeWidth": 1.1 } + }, + { + "mark": { + "type": "point", + "filled": true, + "size": 26, + "color": "#0072b2", + "stroke": "#ffffff", + "strokeWidth": 0.5 + } + }, + { + "transform": [ + { "regression": "MPG", "on": "Horsepower", "params": true }, + { + "calculate": "'slope = ' + format(datum.coef[1], '.3f') + ' mpg/hp · R² = ' + format(datum.rSquared, '.2f') + ' · n = 22'", + "as": "__fit" + }, + { "calculate": "233", "as": "Horsepower" }, + { "calculate": "45.5", "as": "MPG" } + ], + "mark": { + "type": "text", + "align": "right", + "baseline": "top", + "fontSize": 8, + "color": "#000000" + }, + "encoding": { + "text": { "field": "__fit", "type": "nominal" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 6, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 8, + "subtitleFontStyle": "italic", + "subtitleColor": "#3c3c3c", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#000000", + "labelPadding": 2, + "titleFont": "Arial, Helvetica, sans-serif", + "titleFontSize": 9, + "titleFontWeight": 400, + "titleColor": "#000000", + "titlePadding": 4, + "grid": false, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 3.5 + } + } +} diff --git a/site/src/playground/theme-lab-assets/big-mac.economist.json b/site/src/playground/theme-lab-assets/big-mac.economist.json new file mode 100644 index 00000000..ec4c38de --- /dev/null +++ b/site/src/playground/theme-lab-assets/big-mac.economist.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "economist", + "__design__": [ + "Masthead furniture added above the plot with a vconcat: a 26×3 red rect (#E3120B) that is pure brand identity, carrying no data. Vega-Lite has no header slot, so it has to be its own concat row.", + "Category order pinned explicitly rather than left as data order (Flint emits sort: null), so the ranking is a stated property of the chart instead of an accident of row order.", + "Value axis moved to the top (axis.orient) with gridlines only, and its title deleted — the deck already says the unit. The category axis loses its domain, ticks and title entirely.", + "House palette applied as a fixed colour, not a scheme: Economist blue #006BA2 for every bar, with red #E3120B held in reserve as the highlight hue.", + "Bar height narrowed to 0.68 of the band — the house style runs thinner bars with more gutter than Flint's default.", + "Type tightened to 9.5pt labels / 13.5pt bold headline with a grey deck: the same chart has to survive a two-column print grid." + ], + "background": "#ffffff", + "padding": { "left": 6, "top": 4, "right": 12, "bottom": 6 }, + "title": { + "text": "The price of a Big Mac", + "subtitle": ["2023, converted to US dollars at market exchange rates"] + }, + "spacing": 8, + "vconcat": [ + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#e3120b", "stroke": null }, + "width": 26, + "height": 3, + "view": { "stroke": null } + }, + { + "width": 280, + "height": 210, + "data": { + "values": [ + { "Country": "Switzerland", "Price (USD)": 8.1 }, + { "Country": "Norway", "Price (USD)": 6.9 }, + { "Country": "United States", "Price (USD)": 5.7 }, + { "Country": "Euro area", "Price (USD)": 5.5 }, + { "Country": "UK", "Price (USD)": 4.9 }, + { "Country": "Brazil", "Price (USD)": 4.5 }, + { "Country": "Mexico", "Price (USD)": 3.9 }, + { "Country": "China", "Price (USD)": 3.5 }, + { "Country": "Japan", "Price (USD)": 3.2 }, + { "Country": "Egypt", "Price (USD)": 2.7 }, + { "Country": "South Africa", "Price (USD)": 2.6 }, + { "Country": "India", "Price (USD)": 2.5 } + ] + }, + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": [ + "Switzerland", + "Norway", + "United States", + "Euro area", + "UK", + "Brazil", + "Mexico", + "China", + "Japan", + "Egypt", + "South Africa", + "India" + ], + "title": null, + "axis": { "domain": false, "ticks": false, "labelPadding": 4 } + }, + "x": { + "field": "Price (USD)", + "type": "quantitative", + "title": null, + "scale": { "zero": true }, + "axis": { + "orient": "top", + "grid": true, + "gridColor": "#c9d3da", + "domain": false, + "ticks": false, + "tickCount": 5, + "format": "$.0f", + "labelPadding": 2 + } + } + }, + "mark": { "type": "bar", "height": { "band": 0.68 }, "color": "#006ba2" } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#54585a", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#54585a", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 9.5, + "titleFontWeight": 400, + "titleColor": "#54585a", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/big-mac.flint.json b/site/src/playground/theme-lab-assets/big-mac.flint.json new file mode 100644 index 00000000..35b07ea1 --- /dev/null +++ b/site/src/playground/theme-lab-assets/big-mac.flint.json @@ -0,0 +1,103 @@ +{ + "mark": "bar", + "encoding": { + "x": { + "field": "Price (USD)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Country", + "type": "nominal", + "sort": null + } + }, + "config": { + "view": { + "continuousWidth": 261, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "height": { + "step": 20 + }, + "title": { + "text": "The price of a Big Mac", + "subtitle": [ + "2023, converted to US dollars at market exchange rates" + ] + }, + "data": { + "values": [ + { + "Country": "Switzerland", + "Price (USD)": 8.1 + }, + { + "Country": "Norway", + "Price (USD)": 6.9 + }, + { + "Country": "United States", + "Price (USD)": 5.7 + }, + { + "Country": "Euro area", + "Price (USD)": 5.5 + }, + { + "Country": "UK", + "Price (USD)": 4.9 + }, + { + "Country": "Brazil", + "Price (USD)": 4.5 + }, + { + "Country": "Mexico", + "Price (USD)": 3.9 + }, + { + "Country": "China", + "Price (USD)": 3.5 + }, + { + "Country": "Japan", + "Price (USD)": 3.2 + }, + { + "Country": "Egypt", + "Price (USD)": 2.7 + }, + { + "Country": "South Africa", + "Price (USD)": 2.6 + }, + { + "Country": "India", + "Price (USD)": 2.5 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/browser-pie.datawrapper.json b/site/src/playground/theme-lab-assets/browser-pie.datawrapper.json new file mode 100644 index 00000000..8cce52f5 --- /dev/null +++ b/site/src/playground/theme-lab-assets/browser-pie.datawrapper.json @@ -0,0 +1,172 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "datawrapper", + "__design__": [ + "Still a pie. The form is weak here — Safari and Edge are both on 12 per cent, and as two arcs of equal angle at different orientations no reader can confirm that by looking — but the question this table asks is what a design language does to a chart, not what it replaces it with. So the tie is made checkable by printing the numbers, not by abandoning the geometry.", + "Values printed outside every slice. Flint's pie states no quantities at all: the reader gets five arcs and a colour key and is asked to estimate. With 65, 12, 12, 6 and 5 on the page the two 12s are identical characters and the tie is settled.", + "Labels set outside the wedges in near-black, not inside them in white. None of the five house inks clears 4.5:1 against white type — the orange is nearer 2:1 — so an accessibility-first language cannot use the usual place for the number. Outside, on white, every label runs at about 12:1.", + "tableau10 replaced by the five house inks as an explicit domain/range pair, so a browser keeps its colour across every chart in a story rather than taking whatever hue its row index happens to draw.", + "Legend kept, but moved to the top as a horizontal strip of square swatches with no title. A pie is the one form that genuinely needs a key — the categories live in the marks and there is no axis to name them — so the key is restyled rather than removed.", + "Slice order left exactly as the baseline has it, running clockwise from twelve o'clock in source order. Sorting by share would read better — Chrome's 65 per cent would open the circle and the two twelves would end up adjacent — but which wedge follows which is a statement about the data, decided upstream of any house style.", + "A 1.5px white separator between slices — what keeps two adjacent hues legible as two shapes for a reader who cannot tell the hues apart.", + "11pt type floor, no frame, hairline footer." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 16, "bottom": 8 }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": ["Desktop browser share, 2024, per cent"] + }, + "spacing": 8, + "vconcat": [ + { + "width": 260, + "height": 190, + "data": { + "values": [ + { "Browser": "Chrome", "Share": 65 }, + { "Browser": "Safari", "Share": 12 }, + { "Browser": "Edge", "Share": 12 }, + { "Browser": "Firefox", "Share": 6 }, + { "Browser": "Other", "Share": 5 } + ] + }, + "transform": [ + { + "window": [{ "op": "row_number", "as": "__i" }], + "frame": [null, null] + }, + { + "window": [{ "op": "sum", "field": "Share", "as": "__cum" }], + "sort": [{ "field": "__i", "order": "ascending" }], + "frame": [null, 0] + }, + { "calculate": "(datum.__cum - datum.Share) / 100 * 2 * PI", "as": "__start" }, + { "calculate": "datum.__cum / 100 * 2 * PI", "as": "__end" }, + { "calculate": "(datum.__cum - datum.Share / 2) / 100 * 2 * PI", "as": "__mid" }, + { "calculate": "datum.Share + '%'", "as": "__pct" } + ], + "encoding": { + "color": { + "field": "Browser", + "type": "nominal", + "title": null, + "scale": { + "domain": ["Chrome", "Safari", "Edge", "Firefox", "Other"], + "range": ["#18a1cd", "#e2a233", "#c04a4a", "#2d8659", "#7e5aa2"] + } + } + }, + "layer": [ + { + "mark": { + "type": "arc", + "outerRadius": 70, + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "encoding": { + "theta": { + "field": "__start", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "theta2": { "field": "__end" } + } + }, + { + "transform": [{ "filter": "datum.__mid < PI" }], + "mark": { + "type": "text", + "radius": 80, + "align": "left", + "baseline": "middle", + "fontSize": 11 + }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__pct", "type": "nominal" }, + "color": { "value": "#333333" } + } + }, + { + "transform": [{ "filter": "datum.__mid >= PI" }], + "mark": { + "type": "text", + "radius": 80, + "align": "right", + "baseline": "middle", + "fontSize": 11 + }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__pct", "type": "nominal" }, + "color": { "value": "#333333" } + } + } + ] + }, + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#dcdcdc", "stroke": null }, + "width": 270, + "height": 1, + "view": { "stroke": null } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 12, + "lineHeight": 18, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "subtitleLineHeight": 15 + }, + "view": { "stroke": null }, + "legend": { + "orient": "top", + "direction": "horizontal", + "title": null, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 11, + "labelColor": "#333333", + "symbolType": "square", + "symbolSize": 100, + "symbolStrokeWidth": 0, + "labelOffset": 5, + "columnPadding": 14, + "offset": 2, + "padding": 0 + }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 11, + "labelColor": "#333333", + "labelPadding": 5, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 10.5, + "titleFontWeight": 400, + "titleColor": "#767676", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/browser-pie.economist.json b/site/src/playground/theme-lab-assets/browser-pie.economist.json new file mode 100644 index 00000000..24fa766f --- /dev/null +++ b/site/src/playground/theme-lab-assets/browser-pie.economist.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "economist", + "__design__": [ + "Legend deleted, names written against the wedges. Same answer as the NYT row, and the second time these two have converged on a chart where the categories live inside the marks — on the ranked bar they took opposite sides. A key is a lookup table, and neither house prints a lookup table when the label can go where the reader already is.", + "Shares printed with the names. Safari and Edge are both on 12, and two equal angles at different orientations cannot be compared by eye; the numbers are what make the tie checkable. On a pie no language can fall back on the axis, so the values-or-axis disagreement that separates these six elsewhere never arises.", + "House palette in place of tableau10, ordered so the two twelves are not adjacent hues of the same temperature, with Other in the sand tone the paper reserves for residuals.", + "Red masthead rule, sans headline, no frame — the fixtures that make the chart recognisable before it is read.", + "White separators at 1.5px between arcs.", + "Slice order left exactly as the baseline has it, clockwise from twelve o'clock in source order." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 10, "bottom": 8 }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": ["Desktop browser share, 2024, per cent"] + }, + "spacing": 6, + "vconcat": [ + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#e3120b", "stroke": null }, + "width": 26, + "height": 3, + "view": { "stroke": null } + }, + { + "width": 245, + "height": 195, + "data": { + "values": [ + { "Browser": "Chrome", "Share": 65 }, + { "Browser": "Safari", "Share": 12 }, + { "Browser": "Edge", "Share": 12 }, + { "Browser": "Firefox", "Share": 6 }, + { "Browser": "Other", "Share": 5 } + ] + }, + "transform": [ + { "window": [{ "op": "row_number", "as": "__i" }], "frame": [null, null] }, + { + "window": [{ "op": "sum", "field": "Share", "as": "__cum" }], + "sort": [{ "field": "__i", "order": "ascending" }], + "frame": [null, 0] + }, + { "calculate": "(datum.__cum - datum.Share) / 100 * 2 * PI", "as": "__start" }, + { "calculate": "datum.__cum / 100 * 2 * PI", "as": "__end" }, + { "calculate": "(datum.__cum - datum.Share / 2) / 100 * 2 * PI", "as": "__mid" }, + { "calculate": "datum.Browser + ' ' + datum.Share + '%'", "as": "__lab" } + ], + "encoding": { + "color": { + "field": "Browser", + "type": "nominal", + "legend": null, + "scale": { + "domain": ["Chrome", "Safari", "Edge", "Firefox", "Other"], + "range": ["#006ba2", "#3ebcd2", "#a1655a", "#3f5661", "#c8b88a"] + } + } + }, + "layer": [ + { + "mark": { "type": "arc", "outerRadius": 60, "stroke": "#ffffff", "strokeWidth": 1.5 }, + "encoding": { + "theta": { + "field": "__start", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "theta2": { "field": "__end" } + } + }, + { + "transform": [{ "filter": "datum.__mid < PI" }], + "mark": { "type": "text", "radius": 68, "align": "left", "baseline": "middle", "fontSize": 10 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__lab", "type": "nominal" }, + "color": { "value": "#1c2b36" } + } + }, + { + "transform": [{ "filter": "datum.__mid >= PI" }], + "mark": { "type": "text", "radius": 68, "align": "right", "baseline": "middle", "fontSize": 10 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__lab", "type": "nominal" }, + "color": { "value": "#1c2b36" } + } + } + ] + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#1c2b36", + "anchor": "start", + "offset": 8, + "lineHeight": 17, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#63737f", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "text": { "font": "'Helvetica Neue', Helvetica, Arial, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/browser-pie.flint.json b/site/src/playground/theme-lab-assets/browser-pie.flint.json new file mode 100644 index 00000000..ca04b56b --- /dev/null +++ b/site/src/playground/theme-lab-assets/browser-pie.flint.json @@ -0,0 +1,71 @@ +{ + "mark": "arc", + "encoding": { + "theta": { + "field": "Share", + "type": "quantitative" + }, + "color": { + "field": "Browser", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + } + }, + "width": 340, + "height": 292, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 292 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": [ + "Desktop browser share, 2024, per cent" + ] + }, + "data": { + "values": [ + { + "Browser": "Chrome", + "Share": 65 + }, + { + "Browser": "Safari", + "Share": 12 + }, + { + "Browser": "Edge", + "Share": 12 + }, + { + "Browser": "Firefox", + "Share": 6 + }, + { + "Browser": "Other", + "Share": 5 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/browser-pie.mckinsey.json b/site/src/playground/theme-lab-assets/browser-pie.mckinsey.json new file mode 100644 index 00000000..9d91f916 --- /dev/null +++ b/site/src/playground/theme-lab-assets/browser-pie.mckinsey.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "mckinsey", + "__design__": [ + "Five categorical hues replaced by one hue at five lightness steps. Flint's tableau10 gives Chrome, Safari and Edge unrelated colours, which reads as five unrelated things; they are five parts of one quantity, and a single-hue ramp says that without any annotation. The ramp follows the slice order, which here runs from the largest share to the smallest, so it restates what the arcs already show rather than asserting anything extra.", + "Legend deleted, name and share written against each wedge. The key is a lookup step, and the number is the deliverable — the same reading this language applied to the ranked bar and to the line.", + "Values printed because angle cannot be read. Safari and Edge are both 12 per cent; as two equal arcs at different orientations the tie is invisible, and there is no axis to fall back on.", + "Labels in navy on white, outside the circle. Inside would put type over a ramp that runs from near-black to pale, so the same label colour cannot clear contrast on both ends.", + "No frame, no fill, nothing behind the circle. 1px white separators are the only scaffolding left.", + "Slice order left exactly as the baseline has it, clockwise from twelve o'clock in source order." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 10, "bottom": 8 }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": ["Desktop browser share, 2024, per cent"] + }, + "width": 250, + "height": 205, + "data": { + "values": [ + { "Browser": "Chrome", "Share": 65 }, + { "Browser": "Safari", "Share": 12 }, + { "Browser": "Edge", "Share": 12 }, + { "Browser": "Firefox", "Share": 6 }, + { "Browser": "Other", "Share": 5 } + ] + }, + "transform": [ + { "window": [{ "op": "row_number", "as": "__i" }], "frame": [null, null] }, + { + "window": [{ "op": "sum", "field": "Share", "as": "__cum" }], + "sort": [{ "field": "__i", "order": "ascending" }], + "frame": [null, 0] + }, + { "calculate": "(datum.__cum - datum.Share) / 100 * 2 * PI", "as": "__start" }, + { "calculate": "datum.__cum / 100 * 2 * PI", "as": "__end" }, + { "calculate": "(datum.__cum - datum.Share / 2) / 100 * 2 * PI", "as": "__mid" }, + { "calculate": "datum.Browser + ' ' + datum.Share + '%'", "as": "__lab" } + ], + "encoding": { + "color": { + "field": "Browser", + "type": "nominal", + "legend": null, + "scale": { + "domain": ["Chrome", "Safari", "Edge", "Firefox", "Other"], + "range": ["#051c2c", "#12405c", "#2a6f95", "#6ba4c4", "#b8d3e2"] + } + } + }, + "layer": [ + { + "mark": { "type": "arc", "outerRadius": 62, "stroke": "#ffffff", "strokeWidth": 1 }, + "encoding": { + "theta": { + "field": "__start", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "theta2": { "field": "__end" } + } + }, + { + "transform": [{ "filter": "datum.__mid < PI" }], + "mark": { "type": "text", "radius": 70, "align": "left", "baseline": "middle", "fontSize": 10 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__lab", "type": "nominal" }, + "color": { "value": "#051c2c" } + } + }, + { + "transform": [{ "filter": "datum.__mid >= PI" }], + "mark": { "type": "text", "radius": 70, "align": "right", "baseline": "middle", "fontSize": 10 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__lab", "type": "nominal" }, + "color": { "value": "#051c2c" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#5c6b75", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "text": { "font": "'Helvetica Neue', Helvetica, Arial, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/browser-pie.nature.json b/site/src/playground/theme-lab-assets/browser-pie.nature.json new file mode 100644 index 00000000..88c53ace --- /dev/null +++ b/site/src/playground/theme-lab-assets/browser-pie.nature.json @@ -0,0 +1,120 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nature", + "__design__": [ + "The key is kept and the shares are printed as well — the category named twice, once in the legend and once against its own wedge. Every other language here treats that as waste and picks one. A figure that will be reproduced at column width, photocopied and cited on its own cannot assume the reader has the caption, so this is the third cluster in a row where this language answers a problem by adding rather than removing.", + "Palette is the Okabe-Ito colour-vision-safe set with grey for Other. Flint's tableau10 puts a green and an orange of near-equal lightness on the two 12 per cent slices; under deuteranopia those are one hue, and the two arcs are the same size, so the pair becomes genuinely unreadable rather than merely hard.", + "1.5px white separators, which is what keeps two arcs legible as two shapes once hue is unreliable.", + "Values are unavoidable here. Safari and Edge are both 12 per cent, and equal angles at different orientations cannot be compared; this is the one form where no language gets to trust the geometry.", + "Type at 8.5pt throughout so the key fits beside a 190-point plot without the figure growing.", + "Slice order left exactly as the baseline has it, clockwise from twelve o'clock in source order." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 10, "bottom": 6 }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": ["Desktop browser share, 2024, per cent"] + }, + "width": 190, + "height": 165, + "data": { + "values": [ + { "Browser": "Chrome", "Share": 65 }, + { "Browser": "Safari", "Share": 12 }, + { "Browser": "Edge", "Share": 12 }, + { "Browser": "Firefox", "Share": 6 }, + { "Browser": "Other", "Share": 5 } + ] + }, + "transform": [ + { "window": [{ "op": "row_number", "as": "__i" }], "frame": [null, null] }, + { + "window": [{ "op": "sum", "field": "Share", "as": "__cum" }], + "sort": [{ "field": "__i", "order": "ascending" }], + "frame": [null, 0] + }, + { "calculate": "(datum.__cum - datum.Share) / 100 * 2 * PI", "as": "__start" }, + { "calculate": "datum.__cum / 100 * 2 * PI", "as": "__end" }, + { "calculate": "(datum.__cum - datum.Share / 2) / 100 * 2 * PI", "as": "__mid" }, + { "calculate": "datum.Share + '%'", "as": "__pct" } + ], + "encoding": { + "color": { + "field": "Browser", + "type": "nominal", + "title": null, + "scale": { + "domain": ["Chrome", "Safari", "Edge", "Firefox", "Other"], + "range": ["#0072b2", "#e69f00", "#009e73", "#cc79a7", "#999999"] + }, + "legend": { + "orient": "right", + "labelFontSize": 8.5, + "labelColor": "#000000", + "symbolType": "square", + "symbolSize": 60, + "symbolStrokeWidth": 0, + "offset": 4 + } + } + }, + "layer": [ + { + "mark": { "type": "arc", "outerRadius": 58, "stroke": "#ffffff", "strokeWidth": 1.5 }, + "encoding": { + "theta": { + "field": "__start", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "theta2": { "field": "__end" } + } + }, + { + "transform": [{ "filter": "datum.__mid < PI" }], + "mark": { "type": "text", "radius": 66, "align": "left", "baseline": "middle", "fontSize": 8.5 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__pct", "type": "nominal" }, + "color": { "value": "#000000" } + } + }, + { + "transform": [{ "filter": "datum.__mid >= PI" }], + "mark": { "type": "text", "radius": 66, "align": "right", "baseline": "middle", "fontSize": 8.5 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__pct", "type": "nominal" }, + "color": { "value": "#000000" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 6, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 8, + "subtitleFontStyle": "italic", + "subtitleColor": "#3c3c3c", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "legend": { "labelFont": "Arial, Helvetica, sans-serif" }, + "text": { "font": "Arial, Helvetica, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/browser-pie.nyt.json b/site/src/playground/theme-lab-assets/browser-pie.nyt.json new file mode 100644 index 00000000..6617da1c --- /dev/null +++ b/site/src/playground/theme-lab-assets/browser-pie.nyt.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nyt", + "__design__": [ + "Legend deleted, every slice named on the page next to its own wedge. Flint's key sits to the right in source order, so reading the chart means matching five hues to five words and carrying the match back into the circle. With the name written against the arc there is nothing to match.", + "The share is printed with the name. This is not a stylistic addition: Safari and Edge are both on 12 per cent, and as two arcs of equal angle at different orientations that tie is unconfirmable by eye. Angle is the one channel where a language cannot decide to trust the geometry — which is why the values-or-axis split that separates these six on a bar chart has nothing to bite on here.", + "Palette reduced to four working hues plus grey for Other. Flint's tableau10 gives Other a saturated purple, the same weight as Chrome, which makes a residual category look like a finding.", + "White separators at 1.5px so two adjacent arcs stay two shapes.", + "Serif headline, small grey deck, no frame, no fill behind the circle.", + "Slice order left exactly as the baseline has it, clockwise from twelve o'clock in source order." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 10, "bottom": 8 }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": ["Desktop browser share, 2024, per cent"] + }, + "width": 250, + "height": 205, + "data": { + "values": [ + { "Browser": "Chrome", "Share": 65 }, + { "Browser": "Safari", "Share": 12 }, + { "Browser": "Edge", "Share": 12 }, + { "Browser": "Firefox", "Share": 6 }, + { "Browser": "Other", "Share": 5 } + ] + }, + "transform": [ + { "window": [{ "op": "row_number", "as": "__i" }], "frame": [null, null] }, + { + "window": [{ "op": "sum", "field": "Share", "as": "__cum" }], + "sort": [{ "field": "__i", "order": "ascending" }], + "frame": [null, 0] + }, + { "calculate": "(datum.__cum - datum.Share) / 100 * 2 * PI", "as": "__start" }, + { "calculate": "datum.__cum / 100 * 2 * PI", "as": "__end" }, + { "calculate": "(datum.__cum - datum.Share / 2) / 100 * 2 * PI", "as": "__mid" }, + { "calculate": "datum.Browser + ' ' + datum.Share + '%'", "as": "__lab" } + ], + "encoding": { + "color": { + "field": "Browser", + "type": "nominal", + "legend": null, + "scale": { + "domain": ["Chrome", "Safari", "Edge", "Firefox", "Other"], + "range": ["#2f6b9a", "#c2352b", "#5c8a3c", "#9a6c3f", "#9e9e9e"] + } + } + }, + "layer": [ + { + "mark": { "type": "arc", "outerRadius": 62, "stroke": "#ffffff", "strokeWidth": 1.5 }, + "encoding": { + "theta": { + "field": "__start", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "theta2": { "field": "__end" } + } + }, + { + "transform": [{ "filter": "datum.__mid < PI" }], + "mark": { "type": "text", "radius": 70, "align": "left", "baseline": "middle", "fontSize": 10.5 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__lab", "type": "nominal" }, + "color": { "value": "#121212" } + } + }, + { + "transform": [{ "filter": "datum.__mid >= PI" }], + "mark": { "type": "text", "radius": 70, "align": "right", "baseline": "middle", "fontSize": 10.5 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__lab", "type": "nominal" }, + "color": { "value": "#121212" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, 'Times New Roman', serif", + "fontSize": 15, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 10, + "lineHeight": 19, + "subtitleFont": "Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "text": { "font": "Helvetica, Arial, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/browser-pie.powerbi-light.json b/site/src/playground/theme-lab-assets/browser-pie.powerbi-light.json new file mode 100644 index 00000000..353e7bd5 --- /dev/null +++ b/site/src/playground/theme-lab-assets/browser-pie.powerbi-light.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi-light", + "__design__": [ + "The light counterpart of the dark tile — same furniture, ink flipped. The key is kept, on the right, because a tile is a query result: the category set changes when a slicer moves, and a legend redraws in place where labels pinned to particular wedges would have to be re-laid out.", + "Shares printed against the arcs anyway. Safari and Edge are both on 12 per cent and equal angles at different orientations cannot be compared — the one form where no language gets to trust the geometry, since there is no axis to fall back on.", + "Palette is the standard #118DFF-led data colours, unmodified — on a white card the mid-tones separate cleanly, so the dark theme's brightness lift is not needed.", + "Separators drawn in white — the card colour — so the gaps read as the surface showing through, not as a fifth ring of ink.", + "Labels at 9.5pt in #605C5C, the secondary ink: full-strength #252423 is reserved for the headline, and a step down keeps the wedge annotations subordinate to it.", + "Slice order left exactly as the baseline has it, clockwise from twelve o'clock in source order." + ], + "background": "#ffffff", + "padding": { "left": 10, "top": 8, "right": 12, "bottom": 8 }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": ["Desktop browser share, 2024, per cent"] + }, + "width": 215, + "height": 185, + "data": { + "values": [ + { "Browser": "Chrome", "Share": 65 }, + { "Browser": "Safari", "Share": 12 }, + { "Browser": "Edge", "Share": 12 }, + { "Browser": "Firefox", "Share": 6 }, + { "Browser": "Other", "Share": 5 } + ] + }, + "transform": [ + { "window": [{ "op": "row_number", "as": "__i" }], "frame": [null, null] }, + { + "window": [{ "op": "sum", "field": "Share", "as": "__cum" }], + "sort": [{ "field": "__i", "order": "ascending" }], + "frame": [null, 0] + }, + { "calculate": "(datum.__cum - datum.Share) / 100 * 2 * PI", "as": "__start" }, + { "calculate": "datum.__cum / 100 * 2 * PI", "as": "__end" }, + { "calculate": "(datum.__cum - datum.Share / 2) / 100 * 2 * PI", "as": "__mid" }, + { "calculate": "datum.Share + '%'", "as": "__pct" } + ], + "encoding": { + "color": { + "field": "Browser", + "type": "nominal", + "title": null, + "scale": { + "domain": ["Chrome", "Safari", "Edge", "Firefox", "Other"], + "range": ["#118dff", "#e66c37", "#12239e", "#6b007b", "#a19f9d"] + }, + "legend": { + "orient": "right", + "labelFontSize": 9, + "labelColor": "#605e5c", + "symbolType": "square", + "symbolSize": 70, + "symbolStrokeWidth": 0, + "offset": 6 + } + } + }, + "layer": [ + { + "mark": { "type": "arc", "outerRadius": 62, "stroke": "#ffffff", "strokeWidth": 1.5 }, + "encoding": { + "theta": { + "field": "__start", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "theta2": { "field": "__end" } + } + }, + { + "transform": [{ "filter": "datum.__mid < PI" }], + "mark": { "type": "text", "radius": 70, "align": "left", "baseline": "middle", "fontSize": 9.5 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__pct", "type": "nominal" }, + "color": { "value": "#605e5c" } + } + }, + { + "transform": [{ "filter": "datum.__mid >= PI" }], + "mark": { "type": "text", "radius": 70, "align": "right", "baseline": "middle", "fontSize": 9.5 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__pct", "type": "nominal" }, + "color": { "value": "#605e5c" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, -apple-system, sans-serif", + "title": { + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#252423", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#605e5c", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "legend": { "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif" }, + "text": { "font": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/browser-pie.powerbi.json b/site/src/playground/theme-lab-assets/browser-pie.powerbi.json new file mode 100644 index 00000000..3d7d7be8 --- /dev/null +++ b/site/src/playground/theme-lab-assets/browser-pie.powerbi.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi", + "__design__": [ + "The key is kept, on the right. A tile is a query result: the category set changes when a slicer moves, and a legend redraws in place where labels pinned to particular wedges have to be re-laid out. The three languages that direct-label here are authoring a fixed picture; this one is describing something that will be filtered.", + "Shares printed against the arcs anyway. Safari and Edge are both on 12 per cent and equal angles at different orientations cannot be compared — the one form where no language gets to trust the geometry, since there is no axis to fall back on.", + "Palette lifted to survive the dark ground. Flint's tableau10 is mixed for white; at this brightness its mid-tones go muddy and the two 12 per cent slices stop separating.", + "Separators drawn in the ground colour rather than white, so the gaps read as the panel showing through instead of as a fifth ring of ink.", + "Labels at 9.5pt in #C8C6C4 rather than white: pure white on near-black blooms, and a light grey holds contrast without the halo.", + "Slice order left exactly as the baseline has it, clockwise from twelve o'clock in source order." + ], + "background": "#1b1a19", + "padding": { "left": 10, "top": 8, "right": 12, "bottom": 8 }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": ["Desktop browser share, 2024, per cent"] + }, + "width": 215, + "height": 185, + "data": { + "values": [ + { "Browser": "Chrome", "Share": 65 }, + { "Browser": "Safari", "Share": 12 }, + { "Browser": "Edge", "Share": 12 }, + { "Browser": "Firefox", "Share": 6 }, + { "Browser": "Other", "Share": 5 } + ] + }, + "transform": [ + { "window": [{ "op": "row_number", "as": "__i" }], "frame": [null, null] }, + { + "window": [{ "op": "sum", "field": "Share", "as": "__cum" }], + "sort": [{ "field": "__i", "order": "ascending" }], + "frame": [null, 0] + }, + { "calculate": "(datum.__cum - datum.Share) / 100 * 2 * PI", "as": "__start" }, + { "calculate": "datum.__cum / 100 * 2 * PI", "as": "__end" }, + { "calculate": "(datum.__cum - datum.Share / 2) / 100 * 2 * PI", "as": "__mid" }, + { "calculate": "datum.Share + '%'", "as": "__pct" } + ], + "encoding": { + "color": { + "field": "Browser", + "type": "nominal", + "title": null, + "scale": { + "domain": ["Chrome", "Safari", "Edge", "Firefox", "Other"], + "range": ["#118dff", "#e66c37", "#3bd1c7", "#e044a7", "#8a8886"] + }, + "legend": { + "orient": "right", + "labelFontSize": 9, + "labelColor": "#c8c6c4", + "symbolType": "square", + "symbolSize": 70, + "symbolStrokeWidth": 0, + "offset": 6 + } + } + }, + "layer": [ + { + "mark": { "type": "arc", "outerRadius": 62, "stroke": "#1b1a19", "strokeWidth": 1.5 }, + "encoding": { + "theta": { + "field": "__start", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "theta2": { "field": "__end" } + } + }, + { + "transform": [{ "filter": "datum.__mid < PI" }], + "mark": { "type": "text", "radius": 70, "align": "left", "baseline": "middle", "fontSize": 9.5 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__pct", "type": "nominal" }, + "color": { "value": "#c8c6c4" } + } + }, + { + "transform": [{ "filter": "datum.__mid >= PI" }], + "mark": { "type": "text", "radius": 70, "align": "right", "baseline": "middle", "fontSize": 9.5 }, + "encoding": { + "theta": { + "field": "__mid", + "type": "quantitative", + "scale": { "domain": [0, 6.283185307179586], "range": [0, 6.283185307179586] } + }, + "text": { "field": "__pct", "type": "nominal" }, + "color": { "value": "#c8c6c4" } + } + } + ], + "config": { + "background": "#1b1a19", + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, -apple-system, sans-serif", + "title": { + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#a19f9d", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "legend": { "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif" }, + "text": { "font": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/causes-death.datawrapper.json b/site/src/playground/theme-lab-assets/causes-death.datawrapper.json new file mode 100644 index 00000000..35089f10 --- /dev/null +++ b/site/src/playground/theme-lab-assets/causes-death.datawrapper.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "datawrapper", + "__design__": [ + "The label moved off the axis and onto the bar. Five of these ten names are longer than the bar they belong to, and Flint's y-axis truncates every one of them — 'Cerebrovascular disea…', 'Nephritis, nephrotic …', 'Intentional self-harm…'. A category axis reserves a fixed gutter and clips whatever exceeds it; setting each name on its own line above its bar gives the text the full width of the chart and nothing needs abbreviating.", + "Which means the layout is driven by type, not by the bars. Row height is set by two lines of 11pt text plus the bar, not by dividing the plot height by ten — so the chart grows downward with the length of the list rather than squeezing.", + "Values printed at the end of each bar. The axis can be read to the nearest hundred thousand at best, and the bottom three causes differ by six thousand deaths, which no gridline will ever resolve.", + "One measure, one colour: every bar in the house ink #18A1CD. Flint already uses a single hue here; what changes is that the hue is now the theme's, so this chart sits inside a story with the others.", + "Row order left exactly as the baseline has it — the source arrived ranked, so the bars already descend without a theme touching the sort.", + "Gridlines dashed #DCDCDC behind the bars, no frame, no axis titles — the subtitle already states the unit, so repeating 'Deaths (thousands)' under the axis is the same words twice.", + "11pt type floor and a hairline footer rule." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 16, "bottom": 8 }, + "title": { + "text": "What Americans die of", + "subtitle": ["Leading causes of death, United States, 2022, thousands of deaths"] + }, + "spacing": 8, + "vconcat": [ + { + "width": 300, + "height": 340, + "data": { + "values": [ + { "Cause": "Diseases of heart", "Deaths (thousands)": 703 }, + { "Cause": "Malignant neoplasms", "Deaths (thousands)": 608 }, + { "Cause": "Unintentional injuries", "Deaths (thousands)": 227 }, + { "Cause": "Cerebrovascular diseases", "Deaths (thousands)": 165 }, + { "Cause": "Chronic lower respiratory diseases", "Deaths (thousands)": 148 }, + { "Cause": "Alzheimer disease", "Deaths (thousands)": 120 }, + { "Cause": "Diabetes mellitus", "Deaths (thousands)": 102 }, + { "Cause": "Nephritis, nephrotic syndrome and nephrosis", "Deaths (thousands)": 58 }, + { "Cause": "Chronic liver disease and cirrhosis", "Deaths (thousands)": 55 }, + { "Cause": "Intentional self-harm (suicide)", "Deaths (thousands)": 49 } + ] + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "title": null, + "sort": null, + "axis": null + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 760] }, + "axis": { + "values": [0, 200, 400, 600], + "grid": true, + "gridColor": "#dcdcdc", + "gridDash": [2, 2], + "domain": false, + "ticks": false, + "labelPadding": 4 + } + } + }, + "layer": [ + { + "mark": { + "type": "bar", + "color": "#18a1cd", + "height": { "band": 0.34 } + } + }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "alphabetic", + "dy": -11, + "fontSize": 11, + "color": "#333333" + }, + "encoding": { + "x": { "datum": 0 }, + "text": { "field": "Cause", "type": "nominal" } + } + }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 6, + "fontSize": 11, + "fontWeight": 600, + "color": "#333333" + }, + "encoding": { + "text": { "field": "Deaths (thousands)", "type": "quantitative", "format": "d" } + } + } + ] + }, + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#dcdcdc", "stroke": null }, + "width": 320, + "height": 1, + "view": { "stroke": null } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 12, + "lineHeight": 18, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "subtitleLineHeight": 15 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 11, + "labelColor": "#333333", + "labelPadding": 5, + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/causes-death.economist.json b/site/src/playground/theme-lab-assets/causes-death.economist.json new file mode 100644 index 00000000..ea282601 --- /dev/null +++ b/site/src/playground/theme-lab-assets/causes-death.economist.json @@ -0,0 +1,105 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "economist", + "__design__": [ + "The category gutter is uncapped rather than widened by hand. Flint reserves a fixed gutter and clips five of the ten names to fit it — 'Cerebrovascular disea…', 'Nephritis, nephrotic …', 'Intentional self-harm…'. Setting 'labelLimit: 0' lets the axis claim what the strings actually need; the bars give up about a third of the frame to pay for it, which on a list whose longest name runs forty-three characters is the correct trade.", + "Value axis moved to the top over a #C9D3DA grid, the same treatment this language uses on every other bar chart here. The reader meets the scale before the bars rather than after them, and on a ranked list the eye is travelling downward, so a bottom axis is read last or not at all.", + "No number printed on any bar. This chart therefore cannot separate chronic liver disease at 55 from self-harm at 49 — six thousand deaths, about a pixel of bar. That is a real loss, taken deliberately: the ranking is what the chart is for, and ten numbers would double the type on a page that is already mostly type.", + "One measure, one ink: every bar in #006BA2. The house red is spent on the masthead tab and nothing else, so it never competes with the data.", + "Row order left exactly as the baseline has it — the source arrived ranked, so the bars descend without a theme touching the sort.", + "Red 26×3 masthead tab above the title, 9.5pt labels, no frame, no axis titles: the subtitle already states the unit, so repeating it under an axis would be the same words twice." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 16, "bottom": 8 }, + "title": { + "text": "What Americans die of", + "subtitle": ["Leading causes of death, United States, 2022, thousands of deaths"] + }, + "spacing": 7, + "vconcat": [ + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#e3120b", "stroke": null }, + "width": 26, + "height": 3, + "view": { "stroke": null } + }, + { + "width": 260, + "height": 235, + "mark": { "type": "bar", "color": "#006ba2", "height": { "band": 0.68 } }, + "data": { + "values": [ + { "Cause": "Diseases of heart", "Deaths (thousands)": 703 }, + { "Cause": "Malignant neoplasms", "Deaths (thousands)": 608 }, + { "Cause": "Unintentional injuries", "Deaths (thousands)": 227 }, + { "Cause": "Cerebrovascular diseases", "Deaths (thousands)": 165 }, + { "Cause": "Chronic lower respiratory diseases", "Deaths (thousands)": 148 }, + { "Cause": "Alzheimer disease", "Deaths (thousands)": 120 }, + { "Cause": "Diabetes mellitus", "Deaths (thousands)": 102 }, + { "Cause": "Nephritis, nephrotic syndrome and nephrosis", "Deaths (thousands)": 58 }, + { "Cause": "Chronic liver disease and cirrhosis", "Deaths (thousands)": 55 }, + { "Cause": "Intentional self-harm (suicide)", "Deaths (thousands)": 49 } + ] + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "title": null, + "sort": null, + "axis": { + "labelLimit": 0, + "labelFontSize": 9.5, + "labelColor": "#121317", + "labelPadding": 4, + "domain": false, + "ticks": false, + "grid": false + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 760] }, + "axis": { + "orient": "top", + "values": [0, 200, 400, 600], + "format": "d", + "grid": true, + "gridColor": "#c9d3da", + "domain": false, + "ticks": false, + "labelPadding": 2 + } + } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#54585a", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#54585a", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/causes-death.flint.json b/site/src/playground/theme-lab-assets/causes-death.flint.json new file mode 100644 index 00000000..d41418de --- /dev/null +++ b/site/src/playground/theme-lab-assets/causes-death.flint.json @@ -0,0 +1,95 @@ +{ + "mark": "bar", + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "height": { + "step": 23 + }, + "title": { + "text": "What Americans die of", + "subtitle": [ + "Leading causes of death, United States, 2022, thousands of deaths" + ] + }, + "data": { + "values": [ + { + "Cause": "Diseases of heart", + "Deaths (thousands)": 703 + }, + { + "Cause": "Malignant neoplasms", + "Deaths (thousands)": 608 + }, + { + "Cause": "Unintentional injuries", + "Deaths (thousands)": 227 + }, + { + "Cause": "Cerebrovascular diseases", + "Deaths (thousands)": 165 + }, + { + "Cause": "Chronic lower respiratory diseases", + "Deaths (thousands)": 148 + }, + { + "Cause": "Alzheimer disease", + "Deaths (thousands)": 120 + }, + { + "Cause": "Diabetes mellitus", + "Deaths (thousands)": 102 + }, + { + "Cause": "Nephritis, nephrotic syndrome and nephrosis", + "Deaths (thousands)": 58 + }, + { + "Cause": "Chronic liver disease and cirrhosis", + "Deaths (thousands)": 55 + }, + { + "Cause": "Intentional self-harm (suicide)", + "Deaths (thousands)": 49 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/causes-death.mckinsey.json b/site/src/playground/theme-lab-assets/causes-death.mckinsey.json new file mode 100644 index 00000000..3e07da82 --- /dev/null +++ b/site/src/playground/theme-lab-assets/causes-death.mckinsey.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "mckinsey", + "__design__": [ + "Values set in a right-aligned column, not floating at the end of each bar. The Economist row on this same data prints the number where the bar stops, which leaves a ragged edge of digits stepping in from 703 down to 49. Locking them to a common right margin makes the figures a readable column in their own right — the reader can scan the numbers without reading the bars at all, which is what a deck page is for.", + "The value axis is deleted and replaced by a single vertical rule at zero. Flint's axis reads to about a hundred thousand deaths, so it cannot separate the bottom three causes — 55 and 49 are six thousand apart. With every value printed the axis has no remaining job; the zero rule is kept because without it the bars have no common origin to be judged against.", + "The category gutter is uncapped. Flint clips five of the ten names to a fixed width — 'Cerebrovascular disea…', 'Nephritis, nephrotic …', 'Intentional self-harm…'. In a document that will be read once, in a meeting, an abbreviation the reader has to decode is a failure.", + "One measure, one ink: every bar in #051C2C. The house blue #2251FF is held in reserve for charts that need a second series, so this page spends no colour at all on a distinction that does not exist in the data.", + "Row order left exactly as the baseline has it — the source arrived ranked, so the bars descend without a theme touching the sort." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 14, "bottom": 8 }, + "title": { + "text": "What Americans die of", + "subtitle": ["Leading causes of death, United States, 2022, thousands of deaths"] + }, + "width": 250, + "height": 235, + "data": { + "values": [ + { "Cause": "Diseases of heart", "Deaths (thousands)": 703 }, + { "Cause": "Malignant neoplasms", "Deaths (thousands)": 608 }, + { "Cause": "Unintentional injuries", "Deaths (thousands)": 227 }, + { "Cause": "Cerebrovascular diseases", "Deaths (thousands)": 165 }, + { "Cause": "Chronic lower respiratory diseases", "Deaths (thousands)": 148 }, + { "Cause": "Alzheimer disease", "Deaths (thousands)": 120 }, + { "Cause": "Diabetes mellitus", "Deaths (thousands)": 102 }, + { "Cause": "Nephritis, nephrotic syndrome and nephrosis", "Deaths (thousands)": 58 }, + { "Cause": "Chronic liver disease and cirrhosis", "Deaths (thousands)": 55 }, + { "Cause": "Intentional self-harm (suicide)", "Deaths (thousands)": 49 } + ] + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "title": null, + "sort": null, + "axis": { + "labelLimit": 0, + "labelFontSize": 10, + "labelColor": "#051c2c", + "labelPadding": 8, + "domain": false, + "ticks": false, + "grid": false + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 880] }, + "axis": null + } + }, + "layer": [ + { "mark": { "type": "bar", "color": "#051c2c", "height": { "band": 0.6 } } }, + { + "mark": { + "type": "text", + "align": "right", + "baseline": "middle", + "fontSize": 10, + "fontWeight": 600, + "color": "#051c2c" + }, + "encoding": { + "x": { "datum": 880 }, + "text": { "field": "Deaths (thousands)", "type": "quantitative", "format": "d" } + } + }, + { + "mark": { "type": "rule", "color": "#051c2c", "strokeWidth": 1 }, + "encoding": { "x": { "datum": 0 }, "y": { "value": 0 }, "y2": { "value": 235 } } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#5c6b75", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#051c2c", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/causes-death.nature.json b/site/src/playground/theme-lab-assets/causes-death.nature.json new file mode 100644 index 00000000..40208b4d --- /dev/null +++ b/site/src/playground/theme-lab-assets/causes-death.nature.json @@ -0,0 +1,99 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nature", + "__design__": [ + "The axis title is restored, not removed. Every other language here reasons that the subtitle already names the unit and drops 'Deaths (thousands)' from the axis. A journal figure is reproduced at column width, cited, and lifted out of the article it came from, so the axis must state its own unit — this is the one case where redundant labelling is the correct decision rather than the lazy one.", + "Domain line and outward ticks kept, gridlines refused. Flint draws neither a domain nor ticks; a figure that will be printed at 85 mm needs the scale anchored by a rule the reader can sight along, and gridlines crossing the bars would add ten horizontal interruptions to a panel that is already dense.", + "Type dropped to 8.5pt and the category gutter uncapped. Flint truncates five of ten names — 'Cerebrovascular disea…', 'Nephritis, nephrotic …', 'Intentional self-harm…'. An abbreviated cause of death is not a cause of death, and at journal sizes the smaller face is what makes room for the full string without the panel growing.", + "Bars narrowed to 55% of the band. At this scale the gaps do the work of separating rows, so no rule or alternating fill is needed between them.", + "Single fill #0072B2, the blue from the Okabe-Ito set. One measure needs one colour, and choosing it from a colour-vision-safe set costs nothing here but means the figure is already compliant if a second series is ever added.", + "Row order left exactly as the baseline has it — the source arrived ranked, so the bars descend without a theme touching the sort." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 14, "bottom": 6 }, + "title": { + "text": "What Americans die of", + "subtitle": ["Leading causes of death, United States, 2022, thousands of deaths"] + }, + "width": 215, + "height": 205, + "data": { + "values": [ + { "Cause": "Diseases of heart", "Deaths (thousands)": 703 }, + { "Cause": "Malignant neoplasms", "Deaths (thousands)": 608 }, + { "Cause": "Unintentional injuries", "Deaths (thousands)": 227 }, + { "Cause": "Cerebrovascular diseases", "Deaths (thousands)": 165 }, + { "Cause": "Chronic lower respiratory diseases", "Deaths (thousands)": 148 }, + { "Cause": "Alzheimer disease", "Deaths (thousands)": 120 }, + { "Cause": "Diabetes mellitus", "Deaths (thousands)": 102 }, + { "Cause": "Nephritis, nephrotic syndrome and nephrosis", "Deaths (thousands)": 58 }, + { "Cause": "Chronic liver disease and cirrhosis", "Deaths (thousands)": 55 }, + { "Cause": "Intentional self-harm (suicide)", "Deaths (thousands)": 49 } + ] + }, + "mark": { "type": "bar", "color": "#0072b2", "height": { "band": 0.55 } }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "title": null, + "sort": null, + "axis": { + "labelLimit": 0, + "labelFontSize": 8.5, + "labelColor": "#000000", + "labelPadding": 3, + "domain": true, + "domainColor": "#000000", + "ticks": true, + "tickColor": "#000000", + "tickSize": 3, + "grid": false + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "title": "Deaths (thousands)", + "scale": { "zero": true, "nice": false, "domain": [0, 760] }, + "axis": { + "values": [0, 200, 400, 600], + "format": "d", + "grid": false, + "domain": true, + "domainColor": "#000000", + "ticks": true, + "tickColor": "#000000", + "tickSize": 3.5, + "labelFontSize": 8.5, + "labelColor": "#000000", + "titleFontSize": 9, + "titleFontWeight": 400, + "titleColor": "#000000", + "titlePadding": 4 + } + } + }, + "config": { + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 6, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 8, + "subtitleFontStyle": "italic", + "subtitleColor": "#3c3c3c", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "Arial, Helvetica, sans-serif", + "titleFont": "Arial, Helvetica, sans-serif" + } + } +} diff --git a/site/src/playground/theme-lab-assets/causes-death.nyt.json b/site/src/playground/theme-lab-assets/causes-death.nyt.json new file mode 100644 index 00000000..94d72148 --- /dev/null +++ b/site/src/playground/theme-lab-assets/causes-death.nyt.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nyt", + "__design__": [ + "The value axis is deleted outright and every bar carries its own number. Flint's axis reads to about a hundred thousand deaths at best, which cannot separate the bottom of the list — chronic liver disease at 55 and self-harm at 49 are six thousand deaths apart, roughly a pixel of bar. Ten numbers on ten bars settle that, and they cost less ink than an axis plus a grid.", + "Which leaves no grid to draw. This is the opposite of the Economist row on the same data, which keeps a top axis and refuses per-bar numbers, and the disagreement is the point: both are defensible, and neither follows from the data.", + "The category gutter is given whatever width the names need instead of a fixed one. Flint reserves a gutter and clips what exceeds it — 'Cerebrovascular disea…', 'Nephritis, nephrotic …', 'Intentional self-harm…' — so five of ten rows cannot be read. Setting 'labelLimit: 0' removes the cap; the cost is that roughly two fifths of the frame is now type rather than bar, which is the honest price of a list whose longest name runs forty-three characters.", + "One measure, one ink: every bar in #2F6B9A. The bars are the only saturated element on the page — the type is #121212 and #6B6B6B, and the house red is not spent here because nothing in this chart is being singled out.", + "Row order left exactly as the baseline has it — the source arrived ranked, so the bars descend without a theme touching the sort.", + "Serif headline over sans labels — the house signature, and the only place the two typefaces meet." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 18, "bottom": 8 }, + "title": { + "text": "What Americans die of", + "subtitle": ["Leading causes of death, United States, 2022, thousands of deaths"] + }, + "width": 260, + "height": 250, + "data": { + "values": [ + { "Cause": "Diseases of heart", "Deaths (thousands)": 703 }, + { "Cause": "Malignant neoplasms", "Deaths (thousands)": 608 }, + { "Cause": "Unintentional injuries", "Deaths (thousands)": 227 }, + { "Cause": "Cerebrovascular diseases", "Deaths (thousands)": 165 }, + { "Cause": "Chronic lower respiratory diseases", "Deaths (thousands)": 148 }, + { "Cause": "Alzheimer disease", "Deaths (thousands)": 120 }, + { "Cause": "Diabetes mellitus", "Deaths (thousands)": 102 }, + { "Cause": "Nephritis, nephrotic syndrome and nephrosis", "Deaths (thousands)": 58 }, + { "Cause": "Chronic liver disease and cirrhosis", "Deaths (thousands)": 55 }, + { "Cause": "Intentional self-harm (suicide)", "Deaths (thousands)": 49 } + ] + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "title": null, + "sort": null, + "axis": { + "labelLimit": 0, + "labelFontSize": 10, + "labelColor": "#121212", + "labelPadding": 6, + "domain": false, + "ticks": false, + "grid": false + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 810] }, + "axis": null + } + }, + "layer": [ + { "mark": { "type": "bar", "color": "#2f6b9a", "height": { "band": 0.62 } } }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 5, + "fontSize": 10, + "color": "#121212" + }, + "encoding": { + "text": { "field": "Deaths (thousands)", "type": "quantitative", "format": "d" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, 'Times New Roman', Times, serif", + "fontSize": 15, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7 + }, + "view": { "stroke": null } + } +} diff --git a/site/src/playground/theme-lab-assets/causes-death.powerbi-light.json b/site/src/playground/theme-lab-assets/causes-death.powerbi-light.json new file mode 100644 index 00000000..5eedc591 --- /dev/null +++ b/site/src/playground/theme-lab-assets/causes-death.powerbi-light.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi-light", + "__design__": [ + "The light counterpart of the dark tile. Axis and data labels are both kept — the tile is read twice, once across a room and once with a cursor on it, so it answers at both distances. The cost is admitted: this is the busiest of the light rows too.", + "White card, #118DFF fill. On a light page a saturated blue bar is the heaviest mark on the surface, so the ranking reads as printed ink — the opposite reading of the dark tile, where the same bar is the lightest thing and reads as emitted. The grid is a hairline #ededed, pale enough to sit behind the bars without competing.", + "The category gutter is uncapped. Flint clips five of the ten names to a fixed width; a tile that truncates is a tile that needs a tooltip to be usable, and a tooltip is not a chart.", + "Value labels at 9pt in #605E5C, the secondary ink — subordinate to the #252423 headline, legible against the bars.", + "Row order left exactly as the baseline has it — the source arrived ranked, so the bars descend without a theme touching the sort." + ], + "background": "#ffffff", + "padding": { "left": 10, "top": 8, "right": 16, "bottom": 8 }, + "title": { + "text": "What Americans die of", + "subtitle": ["Leading causes of death, United States, 2022, thousands of deaths"] + }, + "width": 250, + "height": 240, + "data": { + "values": [ + { "Cause": "Diseases of heart", "Deaths (thousands)": 703 }, + { "Cause": "Malignant neoplasms", "Deaths (thousands)": 608 }, + { "Cause": "Unintentional injuries", "Deaths (thousands)": 227 }, + { "Cause": "Cerebrovascular diseases", "Deaths (thousands)": 165 }, + { "Cause": "Chronic lower respiratory diseases", "Deaths (thousands)": 148 }, + { "Cause": "Alzheimer disease", "Deaths (thousands)": 120 }, + { "Cause": "Diabetes mellitus", "Deaths (thousands)": 102 }, + { "Cause": "Nephritis, nephrotic syndrome and nephrosis", "Deaths (thousands)": 58 }, + { "Cause": "Chronic liver disease and cirrhosis", "Deaths (thousands)": 55 }, + { "Cause": "Intentional self-harm (suicide)", "Deaths (thousands)": 49 } + ] + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "title": null, + "sort": null, + "axis": { + "labelLimit": 0, + "labelFontSize": 9.5, + "labelColor": "#252423", + "labelPadding": 6, + "domain": false, + "ticks": false, + "grid": false + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 810] }, + "axis": { + "values": [0, 200, 400, 600, 800], + "grid": true, + "gridColor": "#ededed", + "domain": false, + "ticks": false, + "labelFontSize": 9.5, + "labelColor": "#605e5c", + "labelPadding": 4 + } + } + }, + "layer": [ + { "mark": { "type": "bar", "color": "#118dff", "height": { "band": 0.6 } } }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 5, + "fontSize": 9, + "color": "#605e5c" + }, + "encoding": { + "text": { "field": "Deaths (thousands)", "type": "quantitative", "format": "d" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, -apple-system, sans-serif", + "title": { + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#252423", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#605e5c", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "titleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "domain": false, + "ticks": false, + "grid": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/causes-death.powerbi.json b/site/src/playground/theme-lab-assets/causes-death.powerbi.json new file mode 100644 index 00000000..1443bf4b --- /dev/null +++ b/site/src/playground/theme-lab-assets/causes-death.powerbi.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi", + "__design__": [ + "Axis and data labels both kept — the only row here that does not choose. Every other language on this data picks one: NYT and Nature trust the axis, the Economist and McKinsey trust the printed number. A dashboard tile is read twice, once across a room and once with a cursor on it, so it has to answer at both distances. The cost is admitted: this is the busiest of the six.", + "Dark ground #1B1A19 and a #118DFF fill. On a light page a saturated blue bar is the heaviest thing on it; on a dark ground the bar is the lightest, so the same ranking reads as emitted rather than printed. The grid drops to #33312F — barely above the ground, because on dark a mid-grey grid glows louder than the bars.", + "The category gutter is uncapped. Flint clips five of the ten names to a fixed width — 'Cerebrovascular disea…', 'Nephritis, nephrotic …', 'Intentional self-harm…'. A tile that truncates is a tile that needs a tooltip to be usable, and a tooltip is not a chart.", + "Labels at 9.5pt in #C8C6C4 rather than white. Pure white type on near-black is the one combination that blooms at low brightness; stepping down to a light grey holds the contrast without the halo.", + "Row order left exactly as the baseline has it — the source arrived ranked, so the bars descend without a theme touching the sort." + ], + "background": "#1b1a19", + "padding": { "left": 10, "top": 8, "right": 16, "bottom": 8 }, + "title": { + "text": "What Americans die of", + "subtitle": ["Leading causes of death, United States, 2022, thousands of deaths"] + }, + "width": 250, + "height": 240, + "data": { + "values": [ + { "Cause": "Diseases of heart", "Deaths (thousands)": 703 }, + { "Cause": "Malignant neoplasms", "Deaths (thousands)": 608 }, + { "Cause": "Unintentional injuries", "Deaths (thousands)": 227 }, + { "Cause": "Cerebrovascular diseases", "Deaths (thousands)": 165 }, + { "Cause": "Chronic lower respiratory diseases", "Deaths (thousands)": 148 }, + { "Cause": "Alzheimer disease", "Deaths (thousands)": 120 }, + { "Cause": "Diabetes mellitus", "Deaths (thousands)": 102 }, + { "Cause": "Nephritis, nephrotic syndrome and nephrosis", "Deaths (thousands)": 58 }, + { "Cause": "Chronic liver disease and cirrhosis", "Deaths (thousands)": 55 }, + { "Cause": "Intentional self-harm (suicide)", "Deaths (thousands)": 49 } + ] + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "title": null, + "sort": null, + "axis": { + "labelLimit": 0, + "labelFontSize": 9.5, + "labelColor": "#c8c6c4", + "labelPadding": 6, + "domain": false, + "ticks": false, + "grid": false + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 810] }, + "axis": { + "values": [0, 200, 400, 600, 800], + "grid": true, + "gridColor": "#3b3a39", + "domain": false, + "ticks": false, + "labelFontSize": 9.5, + "labelColor": "#a19f9d", + "labelPadding": 4 + } + } + }, + "layer": [ + { "mark": { "type": "bar", "color": "#118dff", "height": { "band": 0.6 } } }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 5, + "fontSize": 9, + "color": "#c8c6c4" + }, + "encoding": { + "text": { "field": "Deaths (thousands)", "type": "quantitative", "format": "d" } + } + } + ], + "config": { + "background": "#1b1a19", + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, -apple-system, sans-serif", + "title": { + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#a19f9d", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "titleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "domain": false, + "ticks": false, + "grid": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/cities-map.flint.json b/site/src/playground/theme-lab-assets/cities-map.flint.json new file mode 100644 index 00000000..976f889d --- /dev/null +++ b/site/src/playground/theme-lab-assets/cities-map.flint.json @@ -0,0 +1,147 @@ +{ + "layer": [ + { + "mark": { + "type": "geoshape", + "fill": "lightgray", + "stroke": "white" + }, + "data": { + "url": "https://vega.github.io/vega-lite/data/world-110m.json", + "format": { + "type": "topojson", + "feature": "countries" + } + }, + "projection": { + "type": "equalEarth" + } + }, + { + "mark": "circle", + "encoding": { + "longitude": { + "field": "Lon", + "type": "quantitative", + "scale": { + "nice": false + } + }, + "latitude": { + "field": "Lat", + "type": "quantitative", + "scale": { + "nice": false + } + }, + "size": { + "field": "Population (M)", + "type": "quantitative", + "scale": { + "type": "sqrt", + "zero": true, + "range": [ + 9, + 361 + ] + } + } + }, + "projection": { + "type": "equalEarth" + } + } + ], + "width": 600, + "height": 350, + "config": { + "view": { + "continuousWidth": 600, + "continuousHeight": 350, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "World's largest cities (metro population)" + }, + "data": { + "values": [ + { + "City": "Tokyo", + "Lon": 139.7, + "Lat": 35.7, + "Population (M)": 37.4 + }, + { + "City": "Delhi", + "Lon": 77.2, + "Lat": 28.6, + "Population (M)": 32.9 + }, + { + "City": "Shanghai", + "Lon": 121.5, + "Lat": 31.2, + "Population (M)": 29.2 + }, + { + "City": "São Paulo", + "Lon": -46.6, + "Lat": -23.5, + "Population (M)": 22.6 + }, + { + "City": "Mexico City", + "Lon": -99.1, + "Lat": 19.4, + "Population (M)": 22.1 + }, + { + "City": "Cairo", + "Lon": 31.2, + "Lat": 30, + "Population (M)": 21.3 + }, + { + "City": "New York", + "Lon": -74, + "Lat": 40.7, + "Population (M)": 18.9 + }, + { + "City": "Lagos", + "Lon": 3.4, + "Lat": 6.5, + "Population (M)": 15.4 + }, + { + "City": "London", + "Lon": -0.1, + "Lat": 51.5, + "Population (M)": 9.5 + }, + { + "City": "Los Angeles", + "Lon": -118.2, + "Lat": 34.1, + "Population (M)": 12.4 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/co2-lollipop.datawrapper.json b/site/src/playground/theme-lab-assets/co2-lollipop.datawrapper.json new file mode 100644 index 00000000..086c49c2 --- /dev/null +++ b/site/src/playground/theme-lab-assets/co2-lollipop.datawrapper.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "datawrapper", + "__design__": [ + "Flint's lollipop contradicts itself. The stems are drawn down to y2 = 0 while the y scale carries 'zero: false', so the axis floor lands at 2 and every stem is cut off below it — India's 2.0 has no stem at all and Brazil's is a stub. Either the baseline is zero or it is not; here it is, and the scale says so.", + "Row order left exactly as the baseline has it. China and Germany are both on 8.0 tonnes, and re-sorting by value silently picks a different one to put first — which is the whole argument for keeping order out of a theme's hands: it decides something the data does not.", + "Value labels above each dot. The fact in the subtitle is a range from 37 to 2, an eighteen-fold spread that no reader estimates off a gridline, and the three-way cluster around 8 (Japan 8.5, China 8, Germany 8) is unresolvable without them.", + "Stems demoted to a 1px #C8C8C8 hairline and the ink moved to the dots. Flint draws both at full strength, so the stem — which encodes nothing the dot's height does not already state — carries as much weight as the value it hangs from.", + "Single house colour #18A1CD across all twelve. There is one measure and no category here, so colour stays out of it; a highlight would assert a comparison the subtitle does not make.", + "Flint's ',.12~g' y-axis format — twelve significant digits on a per-capita tonnage — replaced by plain integers, and the axis title dropped because the unit is already in the subtitle.", + "Orientation left vertical, deliberately, to hold the pair aligned. Twelve country names do not fit horizontally at a 24px step, so the labels rotate 45° rather than truncate: a name the reader cannot finish is worse than one they tilt their head for.", + "11pt type floor, dashed #DCDCDC horizontal gridlines, no frame, a near-black x baseline for contrast, and a hairline footer row to close the block." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 12, "bottom": 8 }, + "title": { + "text": "Emissions per person run from 37 tonnes to 2", + "subtitle": ["Carbon dioxide emissions per capita, 2022, tonnes"] + }, + "spacing": 8, + "vconcat": [ + { + "width": 290, + "height": 175, + "data": { + "values": [ + { "Country": "Qatar", "Tonnes/person": 37 }, + { "Country": "UAE", "Tonnes/person": 22 }, + { "Country": "United States", "Tonnes/person": 15 }, + { "Country": "Canada", "Tonnes/person": 14 }, + { "Country": "Russia", "Tonnes/person": 11 }, + { "Country": "Japan", "Tonnes/person": 8.5 }, + { "Country": "China", "Tonnes/person": 8 }, + { "Country": "Germany", "Tonnes/person": 8 }, + { "Country": "UK", "Tonnes/person": 5 }, + { "Country": "France", "Tonnes/person": 4.6 }, + { "Country": "Brazil", "Tonnes/person": 2.3 }, + { "Country": "India", "Tonnes/person": 2 } + ] + }, + "encoding": { + "x": { + "field": "Country", + "type": "nominal", + "title": null, + "sort": null, + "axis": { + "labelAngle": -45, + "labelAlign": "right", + "grid": false, + "domain": true, + "domainColor": "#333333", + "domainWidth": 1.2, + "ticks": false, + "labelPadding": 5 + } + }, + "y": { + "field": "Tonnes/person", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 42] }, + "axis": { + "values": [0, 10, 20, 30, 40], + "format": "d", + "grid": true, + "gridColor": "#dcdcdc", + "gridDash": [2, 2], + "domain": false, + "ticks": false + } + } + }, + "layer": [ + { + "mark": { "type": "rule", "color": "#c8c8c8", "strokeWidth": 1 }, + "encoding": { "y2": { "datum": 0 } } + }, + { + "mark": { + "type": "circle", + "size": 80, + "color": "#18a1cd", + "opacity": 1, + "stroke": "#ffffff", + "strokeWidth": 1 + } + }, + { + "mark": { + "type": "text", + "align": "center", + "baseline": "bottom", + "dy": -9, + "fontSize": 10, + "color": "#333333" + }, + "encoding": { + "text": { "field": "Tonnes/person", "type": "quantitative", "format": ".3~g" } + } + } + ] + }, + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#dcdcdc", "stroke": null }, + "width": 310, + "height": 1, + "view": { "stroke": null } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 12, + "lineHeight": 18, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "subtitleLineHeight": 15 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 11, + "labelColor": "#333333", + "labelPadding": 5, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 10.5, + "titleFontWeight": 400, + "titleColor": "#767676", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/co2-lollipop.flint.json b/site/src/playground/theme-lab-assets/co2-lollipop.flint.json new file mode 100644 index 00000000..af2be7ee --- /dev/null +++ b/site/src/playground/theme-lab-assets/co2-lollipop.flint.json @@ -0,0 +1,138 @@ +{ + "encoding": {}, + "layer": [ + { + "mark": { + "type": "rule", + "strokeWidth": 1.5 + }, + "encoding": { + "x": { + "field": "Country", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Tonnes/person", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y2": { + "datum": 0 + } + } + }, + { + "mark": { + "type": "circle", + "size": 80, + "opacity": 1 + }, + "encoding": { + "x": { + "field": "Country", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Tonnes/person", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + } + } + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "width": { + "step": 23 + }, + "title": { + "text": "Emissions per person run from 37 tonnes to 2", + "subtitle": [ + "Carbon dioxide emissions per capita, 2022, tonnes" + ] + }, + "data": { + "values": [ + { + "Country": "Qatar", + "Tonnes/person": 37 + }, + { + "Country": "UAE", + "Tonnes/person": 22 + }, + { + "Country": "United States", + "Tonnes/person": 15 + }, + { + "Country": "Canada", + "Tonnes/person": 14 + }, + { + "Country": "Russia", + "Tonnes/person": 11 + }, + { + "Country": "Japan", + "Tonnes/person": 8.5 + }, + { + "Country": "China", + "Tonnes/person": 8 + }, + { + "Country": "Germany", + "Tonnes/person": 8 + }, + { + "Country": "UK", + "Tonnes/person": 5 + }, + { + "Country": "France", + "Tonnes/person": 4.6 + }, + { + "Country": "Brazil", + "Tonnes/person": 2.3 + }, + { + "Country": "India", + "Tonnes/person": 2 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/compiled/_report.json b/site/src/playground/theme-lab-assets/compiled/_report.json new file mode 100644 index 00000000..57450e8b --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/_report.json @@ -0,0 +1,1915 @@ +{ + "driving.nyt": { + "report": [ + { + "stage": "ground", + "path": "annotation.axisTitles", + "message": "both rulers carry a measure — a headline can name one of them, so the axis titles are kept" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "realize", + "path": "axes.y.title.placement", + "message": "the axis title lies flat above the axis, where it reads as a label rather than a caption on its side" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] + }, + "penguins.nature": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + } + ] + }, + "keeling.nyt": { + "report": [ + { + "stage": "ground", + "path": "chartDefaults.Line Chart.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 14 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `ppm` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] + }, + "population.mckinsey": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "`column` approximated as `outsideMark` — Vega-Lite has no label gutter" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + } + ] + }, + "population-region.datawrapper": { + "report": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 5 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 149px — under half the 330px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 330px — not a fixed stub" + } + ] + }, + "life-expectancy.economist": { + "report": [ + { + "stage": "ground", + "path": "chartDefaults.Slope Chart.showText", + "message": "house rule: `showText` set to true" + }, + { + "stage": "ground", + "path": "chartDefaults.Slope Chart.showSeriesInLabel", + "message": "house rule: `showSeriesInLabel` set to true" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~23px and the band is 136px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "ink.series.categorical", + "message": "7 series against 6 house inks, but the house names them on the mark — colour stops naming and takes the single ink" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "axes.x.domain", + "message": "the value scale floats — a rule under the categories would claim a base the chart does not have" + }, + { + "stage": "realize", + "path": "dataLabels", + "message": "template already prints its own labels — left alone" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "the chart already prints its own end labels — no second set drawn" + } + ] + }, + "population-waterfall.mckinsey": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "ink.series", + "message": "`__wf_color` is created by a backend transform — the whole categorical set is offered rather than guessing a count" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "the segments are stacked — a value at a segment edge would read as the running total" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.connector", + "message": "the lead line is drawn at 0.8px in structural ink — it runs across the categories at one level, and the two mark ends it touches already state that level" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 200px — under half the 456px block, so the key stays a caption to the chart" + } + ] + }, + "auto-mpg.nature": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point", + "message": "the fitted line keeps no vertex points — its vertices are where the fit was sampled, not where anything was measured" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "annotation.statistics", + "message": "the fit is stated as well as drawn — n = 22, R² = 0.76, slope = −0.120 — computed from the 22 rows the line was fitted to" + } + ] + }, + "faithful-hist.nature": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + } + ] + }, + "penguins-box.nature": { + "report": [ + { + "stage": "ground", + "path": "chartDefaults.Boxplot.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~52px and the band is 52px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.summary.widthFraction", + "message": "the house fills 40% of the band with the box — 21px of a 52px band" + }, + { + "stage": "realize", + "path": "ink.series", + "message": "the box is hollow because the observations are drawn through it — the outline is scaffolding and takes the text ink, not the series ink" + } + ] + }, + "penguins-violin.nature": { + "report": [ + { + "stage": "ground", + "path": "chartDefaults.Violin Plot.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "chartDefaults.Violin Plot.showMedian", + "message": "house rule: `showMedian` set to true" + }, + { + "stage": "ground", + "path": "chartDefaults.Violin Plot.showContour", + "message": "house rule: `showContour` set to true" + }, + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "legend.suppressWhenAxisNames", + "message": "legend removed — it restated the categorical axis" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.redundantEncoding", + "message": "no mark in this chart can carry a redundant channel — colour is on its own" + } + ] + }, + "exam-ecdf.nature": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point", + "message": "28 readings on one line is past the 12 a reader can take one at a time, so the house's dots stand down and the line keeps its shape" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + } + ] + }, + "co2-lollipop.datawrapper": { + "report": [ + { + "stage": "realize", + "path": "marks.connector", + "message": "the stem is drawn at 1px in structural ink — it leads the eye to the axis and states nothing the dot's position has not" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 295px — not a fixed stub" + } + ] + }, + "us-pyramid.datawrapper": { + "report": [ + { + "stage": "realize", + "path": "ink.series", + "message": "the series is carried by the panels of a concatenation rather than a colour channel — the house set is assigned across the panels" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 101px — under half the 224px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "facets.header", + "message": "the panel names are set in their own panel's ink — the name is the swatch, so no key is drawn beside it" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + }, + { + "stage": "realize", + "path": "furniture", + "message": "not drawn — the chart is already a concatenation" + } + ] + }, + "gdp-bartable.mckinsey": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "ink.series.endpointsAgainstSurface", + "message": "a ramp end sat too close to the surface to be seen as a value — it was pulled away from the page" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "the segments are stacked — a value at a segment edge would read as the running total" + }, + { + "stage": "realize", + "path": "axes.y.label.padding", + "message": "the template holds a 105px gutter for its labels — that is layout, not padding, so it stands" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 87px — under half the 193px block, so the key stays a caption to the chart" + } + ] + }, + "lifeexp-dumbbell.mckinsey": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.connector", + "message": "the bridge is drawn at 3px in structural ink — the distance it spans is the reading, so it carries a mark's weight and none of a series' colour" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized as a synthesized text layer at each series' last point" + } + ] + }, + "seattle-range.economist": { + "report": [ + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~18px and the band is 23px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.domain", + "message": "the value scale floats — a rule under the categories would claim a base the chart does not have" + } + ] + }, + "olympic-bump.nyt": { + "report": [ + { + "stage": "ground", + "path": "chartDefaults.Bump Chart.interpolate", + "message": "house rule: `interpolate` set to \"linear\"" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "ground", + "path": "marks.redundantEncoding", + "message": "`whenNeeded` withheld — the house has a distinct ink for every series" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 4 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized as a synthesized text layer at each series' last point" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] + }, + "population-stream.nyt": { + "report": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "the segments are stacked — a value at a segment edge would read as the running total" + }, + { + "stage": "ground", + "path": "marks.redundantEncoding", + "message": "`whenNeeded` withheld — the house has a distinct ink for every series" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 5 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "the bands climb away from their own labels — the names sit outside the plot in series ink, as a list" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized inside each band at its last reading — a name in the band beats a swatch beside the chart" + } + ] + }, + "browser-pie.datawrapper": { + "report": [ + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1.5px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 164px — under half the 364px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "annotation.unit", + "message": "each printed value carries its unit `%` — there is no axis left to state it on" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 364px — not a fixed stub" + } + ] + }, + "browser-pie.economist": { + "report": [ + { + "stage": "ground", + "path": "variants", + "message": "applied variant {\"isPartToWhole\":true} — Measured: electricity-mix-area puts y on the right, seattle-range does not. Both are area marks, so markChannel cannot separate them; isPartToWhole can. On a pie the policy is inert." + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1.5px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 164px — under half the 364px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "annotation.unit", + "message": "each printed value carries its unit `%` — there is no axis left to state it on" + } + ] + }, + "browser-pie.mckinsey": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "ink.series.endpointsAgainstSurface", + "message": "a ramp end sat too close to the surface to be seen as a value — it was pulled away from the page" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.suppressWhenValuesPrinted", + "message": "legend kept — the values are printed but nothing else names the series" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 164px — under half the 364px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "`column` approximated as `outsideMark` — Vega-Lite has no label gutter" + } + ] + }, + "browser-pie.nature": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1.5px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "marks.redundantEncoding", + "message": "no mark in this chart can carry a redundant channel — colour is on its own" + } + ] + }, + "browser-pie.nyt": { + "report": [ + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "ground", + "path": "marks.redundantEncoding", + "message": "`whenNeeded` withheld — the house has a distinct ink for every series" + }, + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1.5px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 164px — under half the 364px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "annotation.unit", + "message": "each printed value carries its unit `%` — there is no axis left to state it on" + } + ] + }, + "browser-pie.powerbi": { + "report": [ + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1.5px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "annotation.unit", + "message": "each printed value carries its unit `%` — there is no axis left to state it on" + } + ] + }, + "stock-candle.powerbi": { + "report": [ + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 9 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "dataLabels", + "message": "the mark carries 2 measures (Open, Close) — no single value to print" + } + ] + }, + "temp-heatmap.datawrapper": { + "report": [ + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~21px and the band is 32px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false (band 32px, 100 marks)" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "marks.tile", + "message": "the cells are cut apart by 1.5px — the grid reads as a table of separate readings rather than one continuous field" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 185px — under half the 411px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 411px — not a fixed stub" + } + ] + }, + "temp-heatmap.economist": { + "report": [ + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~18px and the band is 32px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false (band 32px, 100 marks)" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 185px — under half the 411px block, so the key stays a caption to the chart" + } + ] + }, + "temp-heatmap.mckinsey": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house asks for 80px categories, but both axes are banded — the marks are cells, whose size the grid settles, not the house" + }, + { + "stage": "ground", + "path": "ink.series.endpointsAgainstSurface", + "message": "a ramp end sat too close to the surface to be seen as a value — it was pulled away from the page" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.suppressWhenValuesPrinted", + "message": "legend removed — the ramp was a value key and every mark now prints its value" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "marks.tile", + "message": "the cells are cut apart by 0.6px — the grid reads as a table of separate readings rather than one continuous field" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "`column` printed in the cell instead — a grid is continuous and has no outside" + } + ] + }, + "temp-heatmap.nature": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house asks for 46px categories, but both axes are banded — the marks are cells, whose size the grid settles, not the house" + }, + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~19px and the band is 32px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "legend.title", + "message": "the key is a ruler, not a list of names — without a title nothing says what its numbers count" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false (band 32px, 100 marks)" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "marks.tile", + "message": "the cells are cut apart by 0.5px — the grid reads as a table of separate readings rather than one continuous field" + } + ] + }, + "temp-heatmap.nyt": { + "report": [ + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~18px and the band is 32px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "marks.tile", + "message": "the cells are cut apart by 1px — the grid reads as a table of separate readings rather than one continuous field" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 185px — under half the 411px block, so the key stays a caption to the chart" + } + ] + }, + "temp-heatmap.powerbi": { + "report": [ + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~18px and the band is 32px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false (band 32px, 100 marks)" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "marks.tile", + "message": "the cells are cut apart by 1px — the grid reads as a table of separate readings rather than one continuous field" + } + ] + }, + "renewable-kpi.powerbi": { + "report": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "ink.series", + "message": "the template drew its own furniture in literal colours — those keep their role and are re-toned against the surface" + } + ] + }, + "renewable-bullet.powerbi": { + "report": [ + { + "stage": "ground", + "path": "ink.series", + "message": "`__status` is created by a backend transform — the whole categorical set is offered rather than guessing a count" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "ink.series", + "message": "the layer states its own colour beside a colour-encoded layer — it is context, not series, so it keeps its role and is only re-toned against the surface" + } + ] + }, + "kpi-sparkline.powerbi": { + "report": [ + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~16px and the band is 21px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "ink.series.selection.redundantWithFacet", + "message": "series colour collapsed to single — the facet already names the series" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "axes.x.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 9.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 9.5px would not stand in the band" + } + ] + }, + "gapminder-bubble.economist": { + "report": [ + { + "stage": "ground", + "path": "annotation.axisTitles", + "message": "both rulers carry a measure — a headline can name one of them, so the axis titles are kept" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.sizeRange", + "message": "sized marks run from 10 to 450px² — the house's range, not the renderer's" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 149px — under half the 331px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "the size key is drawn in neutral ink — beside a colour key, swatches in series ink read as another category" + }, + { + "stage": "realize", + "path": "legend.maxSwatches", + "message": "the key to values is sampled at 3 round sizes — a swatch for every tick reads as data, not as a key" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "2 keys want 402px across a 331px block — they take a row each" + } + ] + }, + "temp-anomaly.nyt": { + "report": [ + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "the segments are stacked — a value at a segment edge would read as the running total" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `°C` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "structure.grid.zero", + "message": "the measure changes sign inside the plot — zero is drawn as its own rule, not as one gridline among the rest" + }, + { + "stage": "realize", + "path": "ink.series.status", + "message": "the categories carry a sign — Below average is negative, Above average is positive" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 99px — under half the 221px block, so the key stays a caption to the chart" + } + ] + }, + "us-unemployment.nyt": { + "report": [ + { + "stage": "ground", + "path": "chartDefaults.Line Chart.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 13 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `%` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] + }, + "ev-share.datawrapper": { + "report": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 4 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `%` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 149px — under half the 330px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 330px — not a fixed stub" + } + ] + }, + "ev-share.economist": { + "report": [ + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized as a synthesized text layer at each series' last point" + } + ] + }, + "ev-share.mckinsey": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized as a synthesized text layer at each series' last point" + } + ] + }, + "ev-share.nature": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + } + ] + }, + "ev-share.nyt": { + "report": [ + { + "stage": "ground", + "path": "chartDefaults.Line Chart.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "ground", + "path": "marks.redundantEncoding", + "message": "`whenNeeded` withheld — the house has a distinct ink for every series" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 4 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `%` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized as a synthesized text layer at each series' last point" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] + }, + "ev-share.powerbi": { + "report": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 4 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the latest reading carries a dot — the house marks where the line lands" + } + ] + }, + "big-mac.economist": { + "report": [ + { + "stage": "ground", + "path": "variants", + "message": "applied variant {\"markChannel\":\"length\"} — Measured: big-mac, causes-death and state-jobless all carry the measure axis opposite (3 of 3 bar charts). On a banded chart the far edge is where a reader enters." + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "realize", + "path": "axes.x.unit", + "message": "every label carries its unit — `$` — because the house prints no axis title to hold it" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks shorter than their own label print it outside instead" + } + ] + }, + "oecd-unemployment-facet.economist": { + "report": [ + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 8.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 8.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + } + ] + }, + "spending-quintile.mckinsey": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the house sets category labels flat, but the widest needs ~83px in a 78px band — the angle is left to the layout" + }, + { + "stage": "ground", + "path": "ink.series.endpointsAgainstSurface", + "message": "a ramp end sat too close to the surface to be seen as a value — it was pulled away from the page" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "the segments are stacked — a value at a segment edge would read as the running total" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 188px — under half the 417px block, so the key stays a caption to the chart" + } + ] + }, + "trust-likert.datawrapper": { + "report": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.unit", + "message": "the unit `%` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 135px — under half the 300px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 300px — not a fixed stub" + } + ] + }, + "fed-funds-step.powerbi": { + "report": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 10 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the latest reading carries a dot — the house marks where the line lands" + } + ] + }, + "renewables-projection.nyt": { + "report": [ + { + "stage": "ground", + "path": "chartDefaults.Line Chart.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 8 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `GW` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] + }, + "earnings-education.mckinsey": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.suppressWhenValuesPrinted", + "message": "legend kept — the values are printed but nothing else names the series" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 164px — under half the 364px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "`column` approximated as `outsideMark` — Vega-Lite has no label gutter" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + } + ] + }, + "electricity-mix-area.economist": { + "report": [ + { + "stage": "ground", + "path": "variants", + "message": "applied variant {\"isPartToWhole\":true} — Measured: electricity-mix-area puts y on the right, seattle-range does not. Both are area marks, so markChannel cannot separate them; isPartToWhole can. On a pie the policy is inert." + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "the right margin holds the value axis, so a name too big for its band has nowhere to stand — the key is drawn `top` instead" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 149px — under half the 330px block, so the key stays a caption to the chart" + } + ] + }, + "state-unemployment.datawrapper": { + "report": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 200px — under half the 535px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 535px — not a fixed stub" + } + ] + }, + "causes-death.datawrapper": { + "report": [ + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 300px — not a fixed stub" + } + ] + }, + "causes-death.economist": { + "report": [ + { + "stage": "ground", + "path": "variants", + "message": "applied variant {\"markChannel\":\"length\"} — Measured: big-mac, causes-death and state-jobless all carry the measure axis opposite (3 of 3 bar charts). On a banded chart the far edge is where a reader enters." + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks shorter than their own label print it outside instead" + } + ] + }, + "causes-death.mckinsey": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "`column` approximated as `outsideMark` — Vega-Lite has no label gutter" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + } + ] + }, + "causes-death.nature": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + } + ] + }, + "causes-death.nyt": { + "report": [ + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks shorter than their own label print it outside instead" + } + ] + }, + "causes-death.powerbi": { + "report": [ + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks shorter than their own label print it outside instead" + } + ] + }, + "state-jobless.economist": { + "report": [ + { + "stage": "ground", + "path": "variants", + "message": "applied variant {\"markChannel\":\"length\"} — Measured: big-mac, causes-death and state-jobless all carry the measure axis opposite (3 of 3 bar charts). On a banded chart the far edge is where a reader enters." + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false (band 9px, 50 marks)" + }, + { + "stage": "realize", + "path": "axes.x.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 6px and the house's 10.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 6px and the house's 10.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + } + ] + }, + "oecd-facet-16.powerbi": { + "report": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 8.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 5 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 8.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the latest reading carries a dot — the house marks where the line lands" + } + ] + }, + "temp-uncertainty.nature": { + "report": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/compiled/auto-mpg.nature.json b/site/src/playground/theme-lab-assets/compiled/auto-mpg.nature.json new file mode 100644 index 00000000..756a0208 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/auto-mpg.nature.json @@ -0,0 +1,327 @@ +{ + "layer": [ + { + "mark": { + "type": "circle", + "color": "#0072b2" + }, + "encoding": { + "x": { + "field": "Horsepower", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "MPG", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "color": {}, + "size": {} + } + }, + { + "mark": { + "type": "line", + "color": "#0072b2", + "point": false + }, + "transform": [ + { + "regression": "MPG", + "on": "Horsepower" + } + ], + "encoding": { + "x": { + "field": "Horsepower", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "MPG", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + } + } + }, + { + "__themeSynthetic": true, + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "text", + "text": "n = 22 · R² = 0.76 · slope = −0.120", + "align": "right", + "baseline": "bottom", + "x": { + "expr": "width" + }, + "y": { + "expr": "-4" + }, + "font": "Arial, Helvetica, sans-serif", + "fontSize": 10.5, + "fill": "#000000" + } + } + ], + "encoding": {}, + "config": { + "view": { + "continuousWidth": 313, + "continuousHeight": 250, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "tickCount": 7 + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 6 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 13, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 12, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 10.5, + "subtitleFontStyle": "italic", + "subtitleColor": "#000000", + "subtitlePadding": 6, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.2, + "point": { + "filled": true, + "size": 45, + "stroke": "#ffffff", + "strokeWidth": 0.6 + } + }, + "trail": { + "size": 1.2 + }, + "point": { + "size": 45, + "filled": true + }, + "circle": { + "size": 45, + "filled": true + }, + "square": { + "size": 45, + "filled": true + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#000000", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Power against fuel economy", + "subtitle": [ + "Ordinary least squares fit; each point is one car model" + ] + }, + "background": "#ffffff", + "padding": 8, + "data": { + "values": [ + { + "Horsepower": 130, + "MPG": 18 + }, + { + "Horsepower": 165, + "MPG": 15 + }, + { + "Horsepower": 150, + "MPG": 18 + }, + { + "Horsepower": 150, + "MPG": 16 + }, + { + "Horsepower": 140, + "MPG": 17 + }, + { + "Horsepower": 198, + "MPG": 15 + }, + { + "Horsepower": 220, + "MPG": 14 + }, + { + "Horsepower": 215, + "MPG": 14 + }, + { + "Horsepower": 97, + "MPG": 22 + }, + { + "Horsepower": 85, + "MPG": 26 + }, + { + "Horsepower": 88, + "MPG": 25 + }, + { + "Horsepower": 46, + "MPG": 26 + }, + { + "Horsepower": 90, + "MPG": 25 + }, + { + "Horsepower": 95, + "MPG": 24 + }, + { + "Horsepower": 68, + "MPG": 29 + }, + { + "Horsepower": 70, + "MPG": 27 + }, + { + "Horsepower": 52, + "MPG": 30 + }, + { + "Horsepower": 65, + "MPG": 31 + }, + { + "Horsepower": 67, + "MPG": 30 + }, + { + "Horsepower": 48, + "MPG": 43 + }, + { + "Horsepower": 66, + "MPG": 32 + }, + { + "Horsepower": 100, + "MPG": 22 + } + ] + }, + "__theme__": "nature", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point", + "message": "the fitted line keeps no vertex points — its vertices are where the fit was sampled, not where anything was measured" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "annotation.statistics", + "message": "the fit is stated as well as drawn — n = 22, R² = 0.76, slope = −0.120 — computed from the 22 rows the line was fitted to" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/big-mac.economist.json b/site/src/playground/theme-lab-assets/compiled/big-mac.economist.json new file mode 100644 index 00000000..17cf68b6 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/big-mac.economist.json @@ -0,0 +1,301 @@ +{ + "background": "#ffffff", + "padding": { + "left": 8, + "right": 27, + "top": 8, + "bottom": 8 + }, + "title": { + "text": "The price of a Big Mac", + "subtitle": [ + "2023, converted to US dollars at market exchange rates" + ] + }, + "spacing": 6, + "vconcat": [ + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#e3120b" + }, + "width": 26, + "height": 3, + "data": { + "values": [ + {} + ] + } + }, + { + "height": { + "step": 20 + }, + "layer": [ + { + "mark": { + "type": "bar", + "color": "#006ba2" + }, + "encoding": { + "x": { + "field": "Price (USD)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "orient": "top", + "labelExpr": "\"$\" + datum.label" + } + }, + "y": { + "field": "Country", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.31999999999999995 + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 9.5, + "align": "right", + "baseline": "middle", + "dx": -5, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "Price (USD)", + "type": "quantitative" + }, + "x": { + "field": "Price (USD)", + "type": "quantitative" + }, + "y": { + "field": "Country", + "type": "nominal", + "sort": null + } + }, + "transform": [ + { + "filter": "abs(datum[\"Price (USD)\"]) >= 0.5552068965517242" + } + ] + }, + { + "__themeSynthetic": true, + "transform": [ + { + "filter": "abs(datum[\"Price (USD)\"]) < 0.5552068965517242" + } + ], + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 9.5, + "align": "left", + "baseline": "middle", + "dx": 4, + "color": "#121317" + }, + "encoding": { + "text": { + "field": "Price (USD)", + "type": "quantitative" + }, + "x": { + "field": "Price (USD)", + "type": "quantitative" + }, + "y": { + "field": "Country", + "type": "nominal", + "sort": null + } + } + } + ] + } + ], + "config": { + "view": { + "continuousWidth": 261, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 9.5, + "titleFontSize": 9.5, + "grid": true, + "gridColor": "#d8dfe4", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "tickCount": 6 + }, + "axisY": { + "labelFontSize": 9.5, + "titleFontSize": 9.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121317", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#54585a", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.6 + }, + "trail": { + "size": 1.6 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#54585a", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Country": "Switzerland", + "Price (USD)": 8.1 + }, + { + "Country": "Norway", + "Price (USD)": 6.9 + }, + { + "Country": "United States", + "Price (USD)": 5.7 + }, + { + "Country": "Euro area", + "Price (USD)": 5.5 + }, + { + "Country": "UK", + "Price (USD)": 4.9 + }, + { + "Country": "Brazil", + "Price (USD)": 4.5 + }, + { + "Country": "Mexico", + "Price (USD)": 3.9 + }, + { + "Country": "China", + "Price (USD)": 3.5 + }, + { + "Country": "Japan", + "Price (USD)": 3.2 + }, + { + "Country": "Egypt", + "Price (USD)": 2.7 + }, + { + "Country": "South Africa", + "Price (USD)": 2.6 + }, + { + "Country": "India", + "Price (USD)": 2.5 + } + ] + }, + "__theme__": "economist", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "variants", + "message": "applied variant {\"markChannel\":\"length\"} — Measured: big-mac, causes-death and state-jobless all carry the measure axis opposite (3 of 3 bar charts). On a banded chart the far edge is where a reader enters." + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "realize", + "path": "axes.x.unit", + "message": "every label carries its unit — `$` — because the house prints no axis title to hold it" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks shorter than their own label print it outside instead" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/browser-pie.datawrapper.json b/site/src/playground/theme-lab-assets/compiled/browser-pie.datawrapper.json new file mode 100644 index 00000000..105a854c --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/browser-pie.datawrapper.json @@ -0,0 +1,204 @@ +{ + "background": "#ffffff", + "padding": 12, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": [ + "Desktop browser share, 2024, per cent" + ] + }, + "spacing": 6, + "vconcat": [ + { + "width": 340, + "height": 292, + "transform": [ + { + "calculate": "datum[\"Share\"] + '' + \"%\"", + "as": "__flintValueWithUnit" + } + ], + "layer": [ + { + "mark": { + "type": "arc", + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "encoding": {} + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 11, + "radius": 160, + "color": "#333333" + }, + "encoding": { + "text": { + "field": "__flintValueWithUnit", + "type": "nominal" + }, + "color": { + "value": "#333333" + } + } + } + ], + "encoding": { + "theta": { + "field": "Share", + "type": "quantitative", + "stack": true + }, + "color": { + "field": "Browser", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#18a1cd", + "#e2a233", + "#c04a4a", + "#2d8659", + "#7e5aa2" + ] + } + }, + "order": { + "field": "Browser", + "type": "nominal", + "sort": "ascending" + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#dcdcdc" + }, + "width": 364, + "height": 1, + "data": { + "values": [ + {} + ] + } + } + ], + "resolve": { + "legend": { + "color": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 292, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 12.5, + "titleFontSize": 12.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#999999", + "gradientLength": 164, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 12.5, + "labelColor": "#666666", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Browser": "Chrome", + "Share": 65 + }, + { + "Browser": "Safari", + "Share": 12 + }, + { + "Browser": "Edge", + "Share": 12 + }, + { + "Browser": "Firefox", + "Share": 6 + }, + { + "Browser": "Other", + "Share": 5 + } + ] + }, + "__theme__": "datawrapper", + "__compiled__": true, + "__report__": [ + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1.5px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 164px — under half the 364px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "annotation.unit", + "message": "each printed value carries its unit `%` — there is no axis left to state it on" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 364px — not a fixed stub" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/browser-pie.economist.json b/site/src/playground/theme-lab-assets/compiled/browser-pie.economist.json new file mode 100644 index 00000000..340176f4 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/browser-pie.economist.json @@ -0,0 +1,214 @@ +{ + "background": "#ffffff", + "padding": 8, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": [ + "Desktop browser share, 2024, per cent" + ] + }, + "spacing": 6, + "vconcat": [ + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#e3120b" + }, + "width": 26, + "height": 3, + "data": { + "values": [ + {} + ] + } + }, + { + "width": 340, + "height": 292, + "transform": [ + { + "calculate": "datum[\"Share\"] + '' + \"%\"", + "as": "__flintValueWithUnit" + } + ], + "layer": [ + { + "mark": { + "type": "arc", + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "encoding": {} + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10.5, + "radius": 105.11999999999999, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "__flintValueWithUnit", + "type": "nominal" + }, + "color": { + "value": "#ffffff" + } + } + } + ], + "encoding": { + "theta": { + "field": "Share", + "type": "quantitative", + "stack": true + }, + "color": { + "field": "Browser", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#3f5661", + "#a1655a", + "#006ba2", + "#7ba7b8", + "#3ebcd2" + ] + } + }, + "order": { + "field": "Browser", + "type": "nominal", + "sort": "ascending" + } + } + } + ], + "resolve": { + "legend": { + "color": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 292, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#8b9196", + "gradientLength": 164, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 12.5, + "subtitleColor": "#54585a", + "subtitlePadding": 8, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.6 + }, + "trail": { + "size": 1.6 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#54585a", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Browser": "Chrome", + "Share": 65 + }, + { + "Browser": "Safari", + "Share": 12 + }, + { + "Browser": "Edge", + "Share": 12 + }, + { + "Browser": "Firefox", + "Share": 6 + }, + { + "Browser": "Other", + "Share": 5 + } + ] + }, + "__theme__": "economist", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "variants", + "message": "applied variant {\"isPartToWhole\":true} — Measured: electricity-mix-area puts y on the right, seattle-range does not. Both are area marks, so markChannel cannot separate them; isPartToWhole can. On a pie the policy is inert." + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1.5px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 164px — under half the 364px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "annotation.unit", + "message": "each printed value carries its unit `%` — there is no axis left to state it on" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/browser-pie.mckinsey.json b/site/src/playground/theme-lab-assets/compiled/browser-pie.mckinsey.json new file mode 100644 index 00000000..847041db --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/browser-pie.mckinsey.json @@ -0,0 +1,218 @@ +{ + "width": 340, + "height": 292, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 292, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#8a969d", + "gradientLength": 164, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#5a6872", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "point": { + "size": 64, + "filled": true + }, + "circle": { + "size": 64, + "filled": true + }, + "square": { + "size": 64, + "filled": true + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#5a6872", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": [ + "Desktop browser share, 2024, per cent" + ] + }, + "background": "#ffffff", + "padding": 20, + "layer": [ + { + "mark": { + "type": "arc", + "stroke": "#ffffff", + "strokeWidth": 1 + }, + "encoding": {} + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "radius": 160, + "color": "#051c2c" + }, + "encoding": { + "text": { + "field": "Share", + "type": "quantitative", + "format": ",.0f" + }, + "color": { + "value": "#051c2c" + } + } + } + ], + "encoding": { + "theta": { + "field": "Share", + "type": "quantitative", + "stack": true + }, + "color": { + "field": "Browser", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#051c2c", + "#5b82ab", + "#9db8d2", + "#cfdcea", + "#e2e7ec" + ] + } + }, + "order": { + "field": "Browser", + "type": "nominal", + "sort": "ascending" + } + }, + "data": { + "values": [ + { + "Browser": "Chrome", + "Share": 65 + }, + { + "Browser": "Safari", + "Share": 12 + }, + { + "Browser": "Edge", + "Share": 12 + }, + { + "Browser": "Firefox", + "Share": 6 + }, + { + "Browser": "Other", + "Share": 5 + } + ] + }, + "__theme__": "mckinsey", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "ink.series.endpointsAgainstSurface", + "message": "a ramp end sat too close to the surface to be seen as a value — it was pulled away from the page" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.suppressWhenValuesPrinted", + "message": "legend kept — the values are printed but nothing else names the series" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 164px — under half the 364px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "`column` approximated as `outsideMark` — Vega-Lite has no label gutter" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/browser-pie.nature.json b/site/src/playground/theme-lab-assets/compiled/browser-pie.nature.json new file mode 100644 index 00000000..3c33e321 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/browser-pie.nature.json @@ -0,0 +1,192 @@ +{ + "width": 340, + "height": 292, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 292, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 11, + "titleFontSize": 11, + "orient": "right", + "direction": "vertical", + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#8c8c8c", + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 13, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 12, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 11, + "subtitleFontStyle": "italic", + "subtitleColor": "#000000", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.2, + "point": { + "filled": true, + "size": 45, + "stroke": "#ffffff", + "strokeWidth": 0.6 + } + }, + "trail": { + "size": 1.2 + }, + "point": { + "size": 45, + "filled": true + }, + "circle": { + "size": 45, + "filled": true + }, + "square": { + "size": 45, + "filled": true + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 11, + "labelColor": "#000000", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": [ + "Desktop browser share, 2024, per cent" + ] + }, + "background": "#ffffff", + "padding": 8, + "layer": [ + { + "mark": { + "type": "arc", + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "encoding": {} + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "Arial, Helvetica, sans-serif", + "fontSize": 11, + "radius": 160, + "color": "#000000" + }, + "encoding": { + "text": { + "field": "Share", + "type": "quantitative" + }, + "color": { + "value": "#000000" + } + } + } + ], + "encoding": { + "theta": { + "field": "Share", + "type": "quantitative", + "stack": true + }, + "color": { + "field": "Browser", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#0072b2", + "#e69f00", + "#009e73", + "#cc79a7", + "#56b4e9" + ] + } + }, + "order": { + "field": "Browser", + "type": "nominal", + "sort": "ascending" + } + }, + "data": { + "values": [ + { + "Browser": "Chrome", + "Share": 65 + }, + { + "Browser": "Safari", + "Share": 12 + }, + { + "Browser": "Edge", + "Share": 12 + }, + { + "Browser": "Firefox", + "Share": 6 + }, + { + "Browser": "Other", + "Share": 5 + } + ] + }, + "__theme__": "nature", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1.5px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "marks.redundantEncoding", + "message": "no mark in this chart can carry a redundant channel — colour is on its own" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/browser-pie.nyt.json b/site/src/playground/theme-lab-assets/compiled/browser-pie.nyt.json new file mode 100644 index 00000000..532f24ad --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/browser-pie.nyt.json @@ -0,0 +1,196 @@ +{ + "width": 340, + "height": 292, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 292, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#8a8a8a", + "gradientLength": 164, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, serif", + "fontSize": 16.5, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 15, + "subtitleFont": "Georgia, serif", + "subtitleFontSize": 12.5, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 8, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + }, + "trail": { + "size": 2.4 + }, + "rule": { + "strokeCap": "round" + }, + "header": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#6b6b6b", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": [ + "Desktop browser share, 2024, per cent" + ] + }, + "background": "#ffffff", + "padding": 12, + "transform": [ + { + "calculate": "format(datum[\"Share\"], \"~s\") + \"%\"", + "as": "__flintValueWithUnit" + } + ], + "layer": [ + { + "mark": { + "type": "arc", + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "encoding": {} + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "Helvetica, Arial, sans-serif", + "fontSize": 10.5, + "fontWeight": 700, + "radius": 105.11999999999999, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "__flintValueWithUnit", + "type": "nominal" + }, + "color": { + "value": "#ffffff" + } + } + } + ], + "encoding": { + "theta": { + "field": "Share", + "type": "quantitative", + "stack": true + }, + "color": { + "field": "Browser", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#2f6b9a", + "#c2352b", + "#4a8b6f", + "#7f6a9e", + "#d9a441" + ] + } + }, + "order": { + "field": "Browser", + "type": "nominal", + "sort": "ascending" + } + }, + "data": { + "values": [ + { + "Browser": "Chrome", + "Share": 65 + }, + { + "Browser": "Safari", + "Share": 12 + }, + { + "Browser": "Edge", + "Share": 12 + }, + { + "Browser": "Firefox", + "Share": 6 + }, + { + "Browser": "Other", + "Share": 5 + } + ] + }, + "__theme__": "nyt", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "ground", + "path": "marks.redundantEncoding", + "message": "`whenNeeded` withheld — the house has a distinct ink for every series" + }, + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1.5px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 164px — under half the 364px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "annotation.unit", + "message": "each printed value carries its unit `%` — there is no axis left to state it on" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/browser-pie.powerbi.json b/site/src/playground/theme-lab-assets/compiled/browser-pie.powerbi.json new file mode 100644 index 00000000..4ca22117 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/browser-pie.powerbi.json @@ -0,0 +1,174 @@ +{ + "width": 340, + "height": 292, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 292, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "right", + "direction": "vertical", + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#a19f9d", + "gradientLength": 90, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#1b1a19", + "font": "'Segoe UI', system-ui, sans-serif", + "title": { + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 11, + "subtitleFont": "'Segoe UI', system-ui, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#c8c6c4", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.2, + "strokeCap": "square" + }, + "trail": { + "size": 2.2 + }, + "rule": { + "strokeCap": "square" + }, + "header": { + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#c8c6c4", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Chrome holds two-thirds of the desktop market", + "subtitle": [ + "Desktop browser share, 2024, per cent" + ] + }, + "background": "#1b1a19", + "padding": 8, + "transform": [ + { + "calculate": "datum[\"Share\"] + '' + \"%\"", + "as": "__flintValueWithUnit" + } + ], + "layer": [ + { + "mark": { + "type": "arc", + "stroke": "#1b1a19", + "strokeWidth": 1.5 + }, + "encoding": {} + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 10.5, + "radius": 105.11999999999999, + "color": "#1b1a19" + }, + "encoding": { + "text": { + "field": "__flintValueWithUnit", + "type": "nominal" + }, + "color": { + "value": "#1b1a19" + } + } + } + ], + "encoding": { + "theta": { + "field": "Share", + "type": "quantitative", + "stack": true + }, + "color": { + "field": "Browser", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#118dff", + "#12239e", + "#e66c37", + "#6b007b", + "#e044a7" + ] + } + }, + "order": { + "field": "Browser", + "type": "nominal", + "sort": "ascending" + } + }, + "data": { + "values": [ + { + "Browser": "Chrome", + "Share": 65 + }, + { + "Browser": "Safari", + "Share": 12 + }, + { + "Browser": "Edge", + "Share": 12 + }, + { + "Browser": "Firefox", + "Share": 6 + }, + { + "Browser": "Other", + "Share": 5 + } + ] + }, + "__theme__": "powerbi", + "__compiled__": true, + "__report__": [ + { + "stage": "realize", + "path": "marks.slice.gap", + "message": "a 1.5px rule cuts the wedges apart — two arcs of the same size read as two shapes, not one" + }, + { + "stage": "realize", + "path": "annotation.unit", + "message": "each printed value carries its unit `%` — there is no axis left to state it on" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/causes-death.datawrapper.json b/site/src/playground/theme-lab-assets/compiled/causes-death.datawrapper.json new file mode 100644 index 00000000..3972f389 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/causes-death.datawrapper.json @@ -0,0 +1,286 @@ +{ + "background": "#ffffff", + "padding": { + "left": 12, + "right": 45, + "top": 12, + "bottom": 12 + }, + "title": { + "text": "What Americans die of", + "subtitle": [ + "Leading causes of death, United States, 2022, thousands of deaths" + ] + }, + "spacing": 6, + "vconcat": [ + { + "height": { + "step": 23 + }, + "layer": [ + { + "mark": { + "type": "bar", + "stroke": "#ffffff", + "strokeWidth": 1.5, + "color": "#18a1cd" + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.33999999999999997 + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 11, + "align": "left", + "baseline": "middle", + "dx": 4, + "color": "#333333" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + }, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) <= 621.5022142857142" + } + ] + }, + { + "__themeSynthetic": true, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) > 621.5022142857142" + } + ], + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 11, + "align": "right", + "baseline": "middle", + "dx": -5, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + } + } + ] + }, + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#dcdcdc" + }, + "width": 300, + "height": 1, + "data": { + "values": [ + {} + ] + } + } + ], + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12, + "titleFontSize": 11, + "grid": true, + "gridColor": "#e6e6e6", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "tickCount": 6 + }, + "axisY": { + "labelFontSize": 12, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#333333", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 12, + "labelColor": "#666666", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Cause": "Diseases of heart", + "Deaths (thousands)": 703 + }, + { + "Cause": "Malignant neoplasms", + "Deaths (thousands)": 608 + }, + { + "Cause": "Unintentional injuries", + "Deaths (thousands)": 227 + }, + { + "Cause": "Cerebrovascular diseases", + "Deaths (thousands)": 165 + }, + { + "Cause": "Chronic lower respiratory diseases", + "Deaths (thousands)": 148 + }, + { + "Cause": "Alzheimer disease", + "Deaths (thousands)": 120 + }, + { + "Cause": "Diabetes mellitus", + "Deaths (thousands)": 102 + }, + { + "Cause": "Nephritis, nephrotic syndrome and nephrosis", + "Deaths (thousands)": 58 + }, + { + "Cause": "Chronic liver disease and cirrhosis", + "Deaths (thousands)": 55 + }, + { + "Cause": "Intentional self-harm (suicide)", + "Deaths (thousands)": 49 + } + ] + }, + "__theme__": "datawrapper", + "__compiled__": true, + "__report__": [ + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 300px — not a fixed stub" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/causes-death.economist.json b/site/src/playground/theme-lab-assets/compiled/causes-death.economist.json new file mode 100644 index 00000000..58bc9faf --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/causes-death.economist.json @@ -0,0 +1,287 @@ +{ + "background": "#ffffff", + "padding": { + "left": 8, + "right": 28, + "top": 8, + "bottom": 8 + }, + "title": { + "text": "What Americans die of", + "subtitle": [ + "Leading causes of death, United States, 2022, thousands of deaths" + ] + }, + "spacing": 6, + "vconcat": [ + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#e3120b" + }, + "width": 26, + "height": 3, + "data": { + "values": [ + {} + ] + } + }, + { + "height": { + "step": 23 + }, + "layer": [ + { + "mark": { + "type": "bar", + "color": "#006ba2" + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.31999999999999995 + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "orient": "top" + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10, + "align": "right", + "baseline": "middle", + "dx": -5, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + }, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) >= 76.82785714285714" + } + ] + }, + { + "__themeSynthetic": true, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) < 76.82785714285714" + } + ], + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10, + "align": "left", + "baseline": "middle", + "dx": 4, + "color": "#121317" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + } + } + ] + } + ], + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#d8dfe4", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "tickCount": 6 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121317", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 12, + "subtitleColor": "#54585a", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.6 + }, + "trail": { + "size": 1.6 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#54585a", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Cause": "Diseases of heart", + "Deaths (thousands)": 703 + }, + { + "Cause": "Malignant neoplasms", + "Deaths (thousands)": 608 + }, + { + "Cause": "Unintentional injuries", + "Deaths (thousands)": 227 + }, + { + "Cause": "Cerebrovascular diseases", + "Deaths (thousands)": 165 + }, + { + "Cause": "Chronic lower respiratory diseases", + "Deaths (thousands)": 148 + }, + { + "Cause": "Alzheimer disease", + "Deaths (thousands)": 120 + }, + { + "Cause": "Diabetes mellitus", + "Deaths (thousands)": 102 + }, + { + "Cause": "Nephritis, nephrotic syndrome and nephrosis", + "Deaths (thousands)": 58 + }, + { + "Cause": "Chronic liver disease and cirrhosis", + "Deaths (thousands)": 55 + }, + { + "Cause": "Intentional self-harm (suicide)", + "Deaths (thousands)": 49 + } + ] + }, + "__theme__": "economist", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "variants", + "message": "applied variant {\"markChannel\":\"length\"} — Measured: big-mac, causes-death and state-jobless all carry the measure axis opposite (3 of 3 bar charts). On a banded chart the far edge is where a reader enters." + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks shorter than their own label print it outside instead" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/causes-death.mckinsey.json b/site/src/playground/theme-lab-assets/compiled/causes-death.mckinsey.json new file mode 100644 index 00000000..907df180 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/causes-death.mckinsey.json @@ -0,0 +1,303 @@ +{ + "config": { + "view": { + "continuousWidth": 314, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labels": false, + "labelAngle": 0, + "tickCount": 7 + }, + "axisY": { + "labelFontSize": 12, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelLimit": 0, + "labelAngle": 0 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#5a6872", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "point": { + "size": 64, + "filled": true + }, + "circle": { + "size": 64, + "filled": true + }, + "square": { + "size": 64, + "filled": true + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#5a6872", + "labelFontWeight": "normal", + "title": null + } + }, + "height": { + "step": 29 + }, + "title": { + "text": "What Americans die of", + "subtitle": [ + "Leading causes of death, United States, 2022, thousands of deaths" + ] + }, + "background": "#ffffff", + "padding": { + "left": 20, + "right": 56, + "top": 20, + "bottom": 20 + }, + "layer": [ + { + "mark": { + "type": "bar", + "stroke": "#ffffff", + "strokeWidth": 0.6, + "color": "#051c2c" + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.4 + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "align": "left", + "baseline": "middle", + "dx": 4, + "color": "#051c2c" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative", + "format": ",.0f" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + }, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) <= 626.1625477707006" + } + ] + }, + { + "__themeSynthetic": true, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) > 626.1625477707006" + } + ], + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "align": "right", + "baseline": "middle", + "dx": -5, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative", + "format": ",.0f" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + } + } + ], + "data": { + "values": [ + { + "Cause": "Diseases of heart", + "Deaths (thousands)": 703 + }, + { + "Cause": "Malignant neoplasms", + "Deaths (thousands)": 608 + }, + { + "Cause": "Unintentional injuries", + "Deaths (thousands)": 227 + }, + { + "Cause": "Cerebrovascular diseases", + "Deaths (thousands)": 165 + }, + { + "Cause": "Chronic lower respiratory diseases", + "Deaths (thousands)": 148 + }, + { + "Cause": "Alzheimer disease", + "Deaths (thousands)": 120 + }, + { + "Cause": "Diabetes mellitus", + "Deaths (thousands)": 102 + }, + { + "Cause": "Nephritis, nephrotic syndrome and nephrosis", + "Deaths (thousands)": 58 + }, + { + "Cause": "Chronic liver disease and cirrhosis", + "Deaths (thousands)": 55 + }, + { + "Cause": "Intentional self-harm (suicide)", + "Deaths (thousands)": 49 + } + ] + }, + "__theme__": "mckinsey", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "`column` approximated as `outsideMark` — Vega-Lite has no label gutter" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/causes-death.nature.json b/site/src/playground/theme-lab-assets/compiled/causes-death.nature.json new file mode 100644 index 00000000..05bb2f42 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/causes-death.nature.json @@ -0,0 +1,279 @@ +{ + "config": { + "view": { + "continuousWidth": 314, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "tickCount": 7 + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 13, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 12, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 10.5, + "subtitleFontStyle": "italic", + "subtitleColor": "#000000", + "subtitlePadding": 6, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.2, + "point": { + "filled": true, + "size": 45, + "stroke": "#ffffff", + "strokeWidth": 0.6 + } + }, + "trail": { + "size": 1.2 + }, + "point": { + "size": 45, + "filled": true + }, + "circle": { + "size": 45, + "filled": true + }, + "square": { + "size": 45, + "filled": true + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#000000", + "labelFontWeight": "normal", + "title": null + } + }, + "height": { + "step": 29 + }, + "title": { + "text": "What Americans die of", + "subtitle": [ + "Leading causes of death, United States, 2022, thousands of deaths" + ] + }, + "background": "#ffffff", + "padding": { + "left": 8, + "right": 40, + "top": 8, + "bottom": 8 + }, + "layer": [ + { + "mark": { + "type": "bar", + "stroke": "#ffffff", + "strokeWidth": 0.5, + "color": "#0072b2" + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "sort": null, + "scale": { + "paddingInner": 0.44999999999999996 + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "Arial, Helvetica, sans-serif", + "fontSize": 10.5, + "align": "left", + "baseline": "middle", + "dx": 4, + "color": "#000000" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + }, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) <= 632.4089490445859" + } + ] + }, + { + "__themeSynthetic": true, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) > 632.4089490445859" + } + ], + "mark": { + "type": "text", + "font": "Arial, Helvetica, sans-serif", + "fontSize": 10.5, + "align": "right", + "baseline": "middle", + "dx": -5, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + } + } + ], + "data": { + "values": [ + { + "Cause": "Diseases of heart", + "Deaths (thousands)": 703 + }, + { + "Cause": "Malignant neoplasms", + "Deaths (thousands)": 608 + }, + { + "Cause": "Unintentional injuries", + "Deaths (thousands)": 227 + }, + { + "Cause": "Cerebrovascular diseases", + "Deaths (thousands)": 165 + }, + { + "Cause": "Chronic lower respiratory diseases", + "Deaths (thousands)": 148 + }, + { + "Cause": "Alzheimer disease", + "Deaths (thousands)": 120 + }, + { + "Cause": "Diabetes mellitus", + "Deaths (thousands)": 102 + }, + { + "Cause": "Nephritis, nephrotic syndrome and nephrosis", + "Deaths (thousands)": 58 + }, + { + "Cause": "Chronic liver disease and cirrhosis", + "Deaths (thousands)": 55 + }, + { + "Cause": "Intentional self-harm (suicide)", + "Deaths (thousands)": 49 + } + ] + }, + "__theme__": "nature", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/causes-death.nyt.json b/site/src/playground/theme-lab-assets/compiled/causes-death.nyt.json new file mode 100644 index 00000000..e864427b --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/causes-death.nyt.json @@ -0,0 +1,272 @@ +{ + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labels": false, + "tickCount": 5 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, serif", + "fontSize": 15.5, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "Georgia, serif", + "subtitleFontSize": 12, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + }, + "trail": { + "size": 2.4 + }, + "rule": { + "strokeCap": "round" + }, + "header": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "labelFontWeight": "normal", + "title": null + } + }, + "height": { + "step": 23 + }, + "title": { + "text": "What Americans die of", + "subtitle": [ + "Leading causes of death, United States, 2022, thousands of deaths" + ] + }, + "background": "#ffffff", + "padding": { + "left": 12, + "right": 32, + "top": 12, + "bottom": 12 + }, + "layer": [ + { + "mark": { + "type": "bar", + "color": "#2f6b9a" + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.28 + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 700, + "align": "right", + "baseline": "middle", + "dx": -5, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative", + "format": "~s" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + }, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) >= 76.82785714285714" + } + ] + }, + { + "__themeSynthetic": true, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) < 76.82785714285714" + } + ], + "mark": { + "type": "text", + "font": "Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 700, + "align": "left", + "baseline": "middle", + "dx": 4, + "color": "#121212" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative", + "format": "~s" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + } + } + ], + "data": { + "values": [ + { + "Cause": "Diseases of heart", + "Deaths (thousands)": 703 + }, + { + "Cause": "Malignant neoplasms", + "Deaths (thousands)": 608 + }, + { + "Cause": "Unintentional injuries", + "Deaths (thousands)": 227 + }, + { + "Cause": "Cerebrovascular diseases", + "Deaths (thousands)": 165 + }, + { + "Cause": "Chronic lower respiratory diseases", + "Deaths (thousands)": 148 + }, + { + "Cause": "Alzheimer disease", + "Deaths (thousands)": 120 + }, + { + "Cause": "Diabetes mellitus", + "Deaths (thousands)": 102 + }, + { + "Cause": "Nephritis, nephrotic syndrome and nephrosis", + "Deaths (thousands)": 58 + }, + { + "Cause": "Chronic liver disease and cirrhosis", + "Deaths (thousands)": 55 + }, + { + "Cause": "Intentional self-harm (suicide)", + "Deaths (thousands)": 49 + } + ] + }, + "__theme__": "nyt", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks shorter than their own label print it outside instead" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/causes-death.powerbi.json b/site/src/playground/theme-lab-assets/compiled/causes-death.powerbi.json new file mode 100644 index 00000000..8397c924 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/causes-death.powerbi.json @@ -0,0 +1,266 @@ +{ + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#323130", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "tickCount": 5 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#1b1a19", + "font": "'Segoe UI', system-ui, sans-serif", + "title": { + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 11, + "subtitleFont": "'Segoe UI', system-ui, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#c8c6c4", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.2, + "strokeCap": "square" + }, + "trail": { + "size": 2.2 + }, + "rule": { + "strokeCap": "square" + }, + "header": { + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelFontSize": 10, + "labelColor": "#c8c6c4", + "labelFontWeight": "normal", + "title": null + } + }, + "height": { + "step": 23 + }, + "title": { + "text": "What Americans die of", + "subtitle": [ + "Leading causes of death, United States, 2022, thousands of deaths" + ] + }, + "background": "#1b1a19", + "padding": { + "left": 8, + "right": 28, + "top": 8, + "bottom": 8 + }, + "layer": [ + { + "mark": { + "type": "bar", + "stroke": "#1b1a19", + "strokeWidth": 1, + "color": "#118dff" + }, + "encoding": { + "y": { + "field": "Cause", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.09999999999999998 + } + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 10, + "align": "right", + "baseline": "middle", + "dx": -5, + "color": "#1b1a19" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + }, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) >= 76.82785714285714" + } + ] + }, + { + "__themeSynthetic": true, + "transform": [ + { + "filter": "abs(datum[\"Deaths (thousands)\"]) < 76.82785714285714" + } + ], + "mark": { + "type": "text", + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 10, + "align": "left", + "baseline": "middle", + "dx": 4, + "color": "#f3f2f1" + }, + "encoding": { + "text": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "x": { + "field": "Deaths (thousands)", + "type": "quantitative" + }, + "y": { + "field": "Cause", + "type": "nominal", + "sort": null + } + } + } + ], + "data": { + "values": [ + { + "Cause": "Diseases of heart", + "Deaths (thousands)": 703 + }, + { + "Cause": "Malignant neoplasms", + "Deaths (thousands)": 608 + }, + { + "Cause": "Unintentional injuries", + "Deaths (thousands)": 227 + }, + { + "Cause": "Cerebrovascular diseases", + "Deaths (thousands)": 165 + }, + { + "Cause": "Chronic lower respiratory diseases", + "Deaths (thousands)": 148 + }, + { + "Cause": "Alzheimer disease", + "Deaths (thousands)": 120 + }, + { + "Cause": "Diabetes mellitus", + "Deaths (thousands)": 102 + }, + { + "Cause": "Nephritis, nephrotic syndrome and nephrosis", + "Deaths (thousands)": 58 + }, + { + "Cause": "Chronic liver disease and cirrhosis", + "Deaths (thousands)": 55 + }, + { + "Cause": "Intentional self-harm (suicide)", + "Deaths (thousands)": 49 + } + ] + }, + "__theme__": "powerbi", + "__compiled__": true, + "__report__": [ + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks shorter than their own label print it outside instead" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/co2-lollipop.datawrapper.json b/site/src/playground/theme-lab-assets/compiled/co2-lollipop.datawrapper.json new file mode 100644 index 00000000..d1727efb --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/co2-lollipop.datawrapper.json @@ -0,0 +1,281 @@ +{ + "background": "#ffffff", + "padding": 12, + "title": { + "text": "Emissions per person run from 37 tonnes to 2", + "subtitle": [ + "Carbon dioxide emissions per capita, 2022, tonnes" + ] + }, + "spacing": 6, + "vconcat": [ + { + "encoding": {}, + "layer": [ + { + "mark": { + "type": "rule", + "strokeWidth": 1, + "color": "#c8c8c8" + }, + "encoding": { + "x": { + "field": "Country", + "type": "nominal", + "sort": null, + "axis": { + "title": null + } + }, + "y": { + "field": "Tonnes/person", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g", + "title": null + } + }, + "y2": { + "datum": 0 + } + } + }, + { + "mark": { + "type": "circle", + "size": 80, + "opacity": 1, + "color": "#18a1cd" + }, + "encoding": { + "x": { + "field": "Country", + "type": "nominal", + "sort": null, + "axis": { + "title": null + } + }, + "y": { + "field": "Tonnes/person", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g", + "title": null + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 11, + "align": "center", + "baseline": "bottom", + "dy": -9, + "color": "#333333" + }, + "encoding": { + "text": { + "field": "Tonnes/person", + "type": "quantitative" + }, + "x": { + "field": "Country", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Tonnes/person", + "type": "quantitative" + } + } + } + ], + "width": { + "step": 23 + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#dcdcdc" + }, + "width": 295, + "height": 1, + "data": { + "values": [ + {} + ] + } + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12.5, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#333333", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ] + }, + "axisY": { + "labelFontSize": 12.5, + "titleFontSize": 11, + "grid": true, + "gridColor": "#e6e6e6", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0, + "tickCount": 5 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 12.5, + "labelColor": "#666666", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Country": "Qatar", + "Tonnes/person": 37 + }, + { + "Country": "UAE", + "Tonnes/person": 22 + }, + { + "Country": "United States", + "Tonnes/person": 15 + }, + { + "Country": "Canada", + "Tonnes/person": 14 + }, + { + "Country": "Russia", + "Tonnes/person": 11 + }, + { + "Country": "Japan", + "Tonnes/person": 8.5 + }, + { + "Country": "China", + "Tonnes/person": 8 + }, + { + "Country": "Germany", + "Tonnes/person": 8 + }, + { + "Country": "UK", + "Tonnes/person": 5 + }, + { + "Country": "France", + "Tonnes/person": 4.6 + }, + { + "Country": "Brazil", + "Tonnes/person": 2.3 + }, + { + "Country": "India", + "Tonnes/person": 2 + } + ] + }, + "__theme__": "datawrapper", + "__compiled__": true, + "__report__": [ + { + "stage": "realize", + "path": "marks.connector", + "message": "the stem is drawn at 1px in structural ink — it leads the eye to the axis and states nothing the dot's position has not" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 295px — not a fixed stub" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/driving.nyt.json b/site/src/playground/theme-lab-assets/compiled/driving.nyt.json new file mode 100644 index 00000000..66a76e79 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/driving.nyt.json @@ -0,0 +1,450 @@ +{ + "mark": { + "type": "line", + "point": true, + "interpolate": "linear", + "strokeWidth": 2, + "color": "#2f6b9a" + }, + "encoding": { + "x": { + "field": "Miles/person", + "type": "quantitative", + "scale": { + "nice": true, + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Gas price", + "type": "quantitative", + "scale": { + "nice": true, + "zero": true + }, + "axis": { + "format": ",.12~g", + "titleAngle": 0, + "titleAlign": "left", + "titleAnchor": "start", + "titleX": 0, + "titleY": -16, + "titleBaseline": "bottom" + } + }, + "order": { + "field": "Year", + "type": "quantitative" + } + }, + "config": { + "view": { + "continuousWidth": 320, + "continuousHeight": 244, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "tickCount": 5 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#ececec", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 4 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 24 + }, + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, serif", + "fontSize": 16.5, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 15, + "subtitleFont": "Georgia, serif", + "subtitleFontSize": 12, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + }, + "trail": { + "size": 2.4 + }, + "rule": { + "strokeCap": "round" + }, + "header": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Driving shifts into reverse", + "subtitle": [ + "Miles driven per person against the price of a gallon of gas, United States, 1956–2010" + ] + }, + "background": "#ffffff", + "padding": { + "left": 12, + "right": 12, + "top": 30, + "bottom": 12 + }, + "data": { + "values": [ + { + "Year": "1956", + "Miles/person": 3675, + "Gas price": 2.38 + }, + { + "Year": "1957", + "Miles/person": 3706, + "Gas price": 2.4 + }, + { + "Year": "1958", + "Miles/person": 3766, + "Gas price": 2.26 + }, + { + "Year": "1959", + "Miles/person": 3905, + "Gas price": 2.31 + }, + { + "Year": "1960", + "Miles/person": 3935, + "Gas price": 2.27 + }, + { + "Year": "1961", + "Miles/person": 3977, + "Gas price": 2.25 + }, + { + "Year": "1962", + "Miles/person": 4085, + "Gas price": 2.22 + }, + { + "Year": "1963", + "Miles/person": 4218, + "Gas price": 2.12 + }, + { + "Year": "1964", + "Miles/person": 4369, + "Gas price": 2.11 + }, + { + "Year": "1965", + "Miles/person": 4538, + "Gas price": 2.14 + }, + { + "Year": "1966", + "Miles/person": 4676, + "Gas price": 2.14 + }, + { + "Year": "1967", + "Miles/person": 4827, + "Gas price": 2.14 + }, + { + "Year": "1968", + "Miles/person": 5038, + "Gas price": 2.13 + }, + { + "Year": "1969", + "Miles/person": 5207, + "Gas price": 2.07 + }, + { + "Year": "1970", + "Miles/person": 5376, + "Gas price": 2.01 + }, + { + "Year": "1971", + "Miles/person": 5617, + "Gas price": 1.93 + }, + { + "Year": "1972", + "Miles/person": 5973, + "Gas price": 1.87 + }, + { + "Year": "1973", + "Miles/person": 6154, + "Gas price": 1.9 + }, + { + "Year": "1974", + "Miles/person": 5943, + "Gas price": 2.34 + }, + { + "Year": "1975", + "Miles/person": 6111, + "Gas price": 2.31 + }, + { + "Year": "1976", + "Miles/person": 6389, + "Gas price": 2.32 + }, + { + "Year": "1977", + "Miles/person": 6630, + "Gas price": 2.36 + }, + { + "Year": "1978", + "Miles/person": 6883, + "Gas price": 2.23 + }, + { + "Year": "1979", + "Miles/person": 6744, + "Gas price": 2.68 + }, + { + "Year": "1980", + "Miles/person": 6672, + "Gas price": 3.3 + }, + { + "Year": "1981", + "Miles/person": 6732, + "Gas price": 3.3 + }, + { + "Year": "1982", + "Miles/person": 6835, + "Gas price": 2.92 + }, + { + "Year": "1983", + "Miles/person": 6943, + "Gas price": 2.66 + }, + { + "Year": "1984", + "Miles/person": 7130, + "Gas price": 2.48 + }, + { + "Year": "1985", + "Miles/person": 7323, + "Gas price": 2.36 + }, + { + "Year": "1986", + "Miles/person": 7558, + "Gas price": 1.76 + }, + { + "Year": "1987", + "Miles/person": 7770, + "Gas price": 1.76 + }, + { + "Year": "1988", + "Miles/person": 8089, + "Gas price": 1.68 + }, + { + "Year": "1989", + "Miles/person": 8397, + "Gas price": 1.75 + }, + { + "Year": "1990", + "Miles/person": 8529, + "Gas price": 1.88 + }, + { + "Year": "1991", + "Miles/person": 8535, + "Gas price": 1.78 + }, + { + "Year": "1992", + "Miles/person": 8662, + "Gas price": 1.69 + }, + { + "Year": "1993", + "Miles/person": 8855, + "Gas price": 1.6 + }, + { + "Year": "1994", + "Miles/person": 8909, + "Gas price": 1.59 + }, + { + "Year": "1995", + "Miles/person": 9150, + "Gas price": 1.6 + }, + { + "Year": "1996", + "Miles/person": 9192, + "Gas price": 1.67 + }, + { + "Year": "1997", + "Miles/person": 9416, + "Gas price": 1.65 + }, + { + "Year": "1998", + "Miles/person": 9590, + "Gas price": 1.39 + }, + { + "Year": "1999", + "Miles/person": 9687, + "Gas price": 1.5 + }, + { + "Year": "2000", + "Miles/person": 9717, + "Gas price": 1.89 + }, + { + "Year": "2001", + "Miles/person": 9699, + "Gas price": 1.77 + }, + { + "Year": "2002", + "Miles/person": 9814, + "Gas price": 1.64 + }, + { + "Year": "2003", + "Miles/person": 9868, + "Gas price": 1.86 + }, + { + "Year": "2004", + "Miles/person": 9994, + "Gas price": 2.14 + }, + { + "Year": "2005", + "Miles/person": 10067, + "Gas price": 2.53 + }, + { + "Year": "2006", + "Miles/person": 10037, + "Gas price": 2.79 + }, + { + "Year": "2007", + "Miles/person": 10025, + "Gas price": 2.95 + }, + { + "Year": "2008", + "Miles/person": 9880, + "Gas price": 3.31 + }, + { + "Year": "2009", + "Miles/person": 9657, + "Gas price": 2.38 + }, + { + "Year": "2010", + "Miles/person": 9596, + "Gas price": 2.61 + } + ] + }, + "__theme__": "nyt", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "annotation.axisTitles", + "message": "both rulers carry a measure — a headline can name one of them, so the axis titles are kept" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "realize", + "path": "axes.y.title.placement", + "message": "the axis title lies flat above the axis, where it reads as a label rather than a caption on its side" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/earnings-education.mckinsey.json b/site/src/playground/theme-lab-assets/compiled/earnings-education.mckinsey.json new file mode 100644 index 00000000..dc99bc6d --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/earnings-education.mckinsey.json @@ -0,0 +1,357 @@ +{ + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labels": false, + "labelAngle": 0, + "tickCount": 8 + }, + "axisY": { + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelLimit": 0, + "labelAngle": 0 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#8a969d", + "gradientLength": 164, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#5a6872", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "point": { + "size": 64, + "filled": true + }, + "circle": { + "size": 64, + "filled": true + }, + "square": { + "size": 64, + "filled": true + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#5a6872", + "labelFontWeight": "normal", + "title": null + } + }, + "height": { + "step": 59, + "for": "position" + }, + "title": { + "text": "Median weekly earnings by education and sex, 2023", + "subtitle": [ + "US dollars, full-time wage and salary workers" + ] + }, + "background": "#ffffff", + "padding": { + "left": 20, + "right": 58, + "top": 20, + "bottom": 20 + }, + "layer": [ + { + "mark": { + "type": "bar", + "stroke": "#ffffff", + "strokeWidth": 0.6 + }, + "encoding": { + "x": { + "field": "Weekly earnings ($)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null + } + }, + "y": { + "field": "Education", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.4 + } + }, + "color": { + "field": "Sex", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#051c2c", + "#2251ff" + ] + } + }, + "yOffset": { + "field": "Sex", + "type": "nominal", + "sort": null + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "align": "left", + "baseline": "middle", + "dx": 4, + "color": "#051c2c" + }, + "encoding": { + "text": { + "field": "Weekly earnings ($)", + "type": "quantitative", + "format": ",.0f" + }, + "x": { + "field": "Weekly earnings ($)", + "type": "quantitative" + }, + "y": { + "field": "Education", + "type": "nominal", + "sort": null + }, + "yOffset": { + "field": "Sex", + "type": "nominal", + "sort": null + } + }, + "transform": [ + { + "filter": "abs(datum[\"Weekly earnings ($)\"]) <= 1886.8235294117646" + } + ] + }, + { + "__themeSynthetic": true, + "transform": [ + { + "filter": "abs(datum[\"Weekly earnings ($)\"]) > 1886.8235294117646" + } + ], + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "align": "right", + "baseline": "middle", + "dx": -5, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "Weekly earnings ($)", + "type": "quantitative", + "format": ",.0f" + }, + "x": { + "field": "Weekly earnings ($)", + "type": "quantitative" + }, + "y": { + "field": "Education", + "type": "nominal", + "sort": null + }, + "yOffset": { + "field": "Sex", + "type": "nominal", + "sort": null + } + } + } + ], + "data": { + "values": [ + { + "Education": "Advanced degree", + "Sex": "Men", + "Weekly earnings ($)": 2160 + }, + { + "Education": "Advanced degree", + "Sex": "Women", + "Weekly earnings ($)": 1600 + }, + { + "Education": "Bachelor's degree", + "Sex": "Men", + "Weekly earnings ($)": 1700 + }, + { + "Education": "Bachelor's degree", + "Sex": "Women", + "Weekly earnings ($)": 1290 + }, + { + "Education": "Some college", + "Sex": "Men", + "Weekly earnings ($)": 1120 + }, + { + "Education": "Some college", + "Sex": "Women", + "Weekly earnings ($)": 890 + }, + { + "Education": "High school", + "Sex": "Men", + "Weekly earnings ($)": 1000 + }, + { + "Education": "High school", + "Sex": "Women", + "Weekly earnings ($)": 790 + }, + { + "Education": "Less than high school", + "Sex": "Men", + "Weekly earnings ($)": 780 + }, + { + "Education": "Less than high school", + "Sex": "Women", + "Weekly earnings ($)": 620 + } + ] + }, + "__theme__": "mckinsey", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.suppressWhenValuesPrinted", + "message": "legend kept — the values are printed but nothing else names the series" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 164px — under half the 364px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "`column` approximated as `outsideMark` — Vega-Lite has no label gutter" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/electricity-mix-area.economist.json b/site/src/playground/theme-lab-assets/compiled/electricity-mix-area.economist.json new file mode 100644 index 00000000..0b89acac --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/electricity-mix-area.economist.json @@ -0,0 +1,326 @@ +{ + "background": "#ffffff", + "padding": 8, + "title": { + "text": "Where the power comes from", + "subtitle": [ + "World electricity generation by source, % of total" + ] + }, + "spacing": 6, + "vconcat": [ + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#e3120b" + }, + "width": 26, + "height": 3, + "data": { + "values": [ + {} + ] + } + }, + { + "mark": "area", + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null + } + }, + "y": { + "field": "Generation (TWh)", + "type": "quantitative", + "stack": "normalize", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "orient": "right" + } + }, + "color": { + "field": "Source", + "type": "nominal", + "sort": null, + "scale": { + "domain": [ + "Coal", + "Gas", + "Hydro", + "Nuclear", + "Wind & solar", + "Other" + ], + "range": [ + "#3f5661", + "#a1655a", + "#006ba2", + "#7ba7b8", + "#3ebcd2", + "#c8b88a" + ] + } + } + } + } + ], + "resolve": { + "legend": { + "color": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121317", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#d8dfe4", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 6 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 10, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#8b9196", + "gradientLength": 149, + "title": null + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 12, + "subtitleColor": "#54585a", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.6 + }, + "trail": { + "size": 1.6 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#54585a", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Year": "1990", + "Source": "Coal", + "Generation (TWh)": 4430 + }, + { + "Year": "1990", + "Source": "Gas", + "Generation (TWh)": 1780 + }, + { + "Year": "1990", + "Source": "Hydro", + "Generation (TWh)": 2160 + }, + { + "Year": "1990", + "Source": "Nuclear", + "Generation (TWh)": 2000 + }, + { + "Year": "1990", + "Source": "Wind & solar", + "Generation (TWh)": 10 + }, + { + "Year": "1990", + "Source": "Other", + "Generation (TWh)": 1580 + }, + { + "Year": "2000", + "Source": "Coal", + "Generation (TWh)": 5990 + }, + { + "Year": "2000", + "Source": "Gas", + "Generation (TWh)": 2760 + }, + { + "Year": "2000", + "Source": "Hydro", + "Generation (TWh)": 2620 + }, + { + "Year": "2000", + "Source": "Nuclear", + "Generation (TWh)": 2590 + }, + { + "Year": "2000", + "Source": "Wind & solar", + "Generation (TWh)": 80 + }, + { + "Year": "2000", + "Source": "Other", + "Generation (TWh)": 1310 + }, + { + "Year": "2010", + "Source": "Coal", + "Generation (TWh)": 8670 + }, + { + "Year": "2010", + "Source": "Gas", + "Generation (TWh)": 4760 + }, + { + "Year": "2010", + "Source": "Hydro", + "Generation (TWh)": 3440 + }, + { + "Year": "2010", + "Source": "Nuclear", + "Generation (TWh)": 2760 + }, + { + "Year": "2010", + "Source": "Wind & solar", + "Generation (TWh)": 380 + }, + { + "Year": "2010", + "Source": "Other", + "Generation (TWh)": 1520 + }, + { + "Year": "2020", + "Source": "Coal", + "Generation (TWh)": 9420 + }, + { + "Year": "2020", + "Source": "Gas", + "Generation (TWh)": 6270 + }, + { + "Year": "2020", + "Source": "Hydro", + "Generation (TWh)": 4360 + }, + { + "Year": "2020", + "Source": "Nuclear", + "Generation (TWh)": 2700 + }, + { + "Year": "2020", + "Source": "Wind & solar", + "Generation (TWh)": 2720 + }, + { + "Year": "2020", + "Source": "Other", + "Generation (TWh)": 1610 + } + ] + }, + "__theme__": "economist", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "variants", + "message": "applied variant {\"isPartToWhole\":true} — Measured: electricity-mix-area puts y on the right, seattle-range does not. Both are area marks, so markChannel cannot separate them; isPartToWhole can. On a pie the policy is inert." + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "the right margin holds the value axis, so a name too big for its band has nowhere to stand — the key is drawn `top` instead" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 149px — under half the 330px block, so the key stays a caption to the chart" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/ev-share.datawrapper.json b/site/src/playground/theme-lab-assets/compiled/ev-share.datawrapper.json new file mode 100644 index 00000000..279bd628 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/ev-share.datawrapper.json @@ -0,0 +1,310 @@ +{ + "background": "#ffffff", + "padding": 12, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": [ + "Norway, China, Germany and the United States, 2018–2023, per cent of new car sales" + ] + }, + "spacing": 6, + "vconcat": [ + { + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null, + "values": [ + { + "year": 2018, + "utc": true + }, + { + "year": 2020, + "utc": true + }, + { + "year": 2022, + "utc": true + }, + { + "year": 2023, + "utc": true + } + ], + "format": "%Y" + }, + "scale": { + "type": "utc" + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.index === 1 ? datum.label + \"%\" : datum.label" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#18a1cd", + "#e2a233", + "#c04a4a", + "#2d8659" + ] + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#dcdcdc" + }, + "width": 330, + "height": 1, + "data": { + "values": [ + {} + ] + } + } + ], + "resolve": { + "legend": { + "color": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#333333", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ] + }, + "axisY": { + "labelFontSize": 12, + "titleFontSize": 11, + "grid": true, + "gridColor": "#e6e6e6", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0, + "tickCount": 6 + }, + "legend": { + "labelFontSize": 12, + "titleFontSize": 12, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#999999", + "gradientLength": 149, + "title": null + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 12, + "labelColor": "#666666", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Year": "2018", + "Country": "Norway", + "EV share (%)": 49 + }, + { + "Year": "2020", + "Country": "Norway", + "EV share (%)": 75 + }, + { + "Year": "2022", + "Country": "Norway", + "EV share (%)": 88 + }, + { + "Year": "2023", + "Country": "Norway", + "EV share (%)": 93 + }, + { + "Year": "2018", + "Country": "China", + "EV share (%)": 4 + }, + { + "Year": "2020", + "Country": "China", + "EV share (%)": 6 + }, + { + "Year": "2022", + "Country": "China", + "EV share (%)": 29 + }, + { + "Year": "2023", + "Country": "China", + "EV share (%)": 38 + }, + { + "Year": "2018", + "Country": "Germany", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "Germany", + "EV share (%)": 13 + }, + { + "Year": "2022", + "Country": "Germany", + "EV share (%)": 31 + }, + { + "Year": "2023", + "Country": "Germany", + "EV share (%)": 25 + }, + { + "Year": "2018", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2022", + "Country": "United States", + "EV share (%)": 8 + }, + { + "Year": "2023", + "Country": "United States", + "EV share (%)": 10 + } + ] + }, + "__theme__": "datawrapper", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 4 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `%` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 149px — under half the 330px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 330px — not a fixed stub" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/ev-share.economist.json b/site/src/playground/theme-lab-assets/compiled/ev-share.economist.json new file mode 100644 index 00000000..6f454d8b --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/ev-share.economist.json @@ -0,0 +1,334 @@ +{ + "background": "#ffffff", + "padding": { + "left": 8, + "right": 88, + "top": 8, + "bottom": 8 + }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": [ + "Norway, China, Germany and the United States, 2018–2023, per cent of new car sales" + ] + }, + "spacing": 6, + "vconcat": [ + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#e3120b" + }, + "width": 26, + "height": 3, + "data": { + "values": [ + {} + ] + } + }, + { + "layer": [ + { + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.label + \"%\"" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#3f5661", + "#a1655a", + "#006ba2", + "#7ba7b8" + ] + }, + "legend": null + } + } + }, + { + "__themeSynthetic": true, + "transform": [ + { + "window": [ + { + "op": "row_number", + "as": "__seriesEndRank" + } + ], + "sort": [ + { + "field": "Year", + "order": "descending" + } + ], + "groupby": [ + "Country" + ] + }, + { + "filter": "datum.__seriesEndRank === 1" + } + ], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 5, + "dy": 0, + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10 + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "EV share (%)", + "type": "quantitative" + }, + "text": { + "field": "Country", + "type": "nominal" + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#3f5661", + "#a1655a", + "#006ba2", + "#7ba7b8" + ] + }, + "legend": null + } + } + } + ] + } + ], + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121317", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#d8dfe4", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 6 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 12, + "subtitleColor": "#54585a", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.6 + }, + "trail": { + "size": 1.6 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#54585a", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Year": "2018", + "Country": "Norway", + "EV share (%)": 49 + }, + { + "Year": "2020", + "Country": "Norway", + "EV share (%)": 75 + }, + { + "Year": "2022", + "Country": "Norway", + "EV share (%)": 88 + }, + { + "Year": "2023", + "Country": "Norway", + "EV share (%)": 93 + }, + { + "Year": "2018", + "Country": "China", + "EV share (%)": 4 + }, + { + "Year": "2020", + "Country": "China", + "EV share (%)": 6 + }, + { + "Year": "2022", + "Country": "China", + "EV share (%)": 29 + }, + { + "Year": "2023", + "Country": "China", + "EV share (%)": 38 + }, + { + "Year": "2018", + "Country": "Germany", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "Germany", + "EV share (%)": 13 + }, + { + "Year": "2022", + "Country": "Germany", + "EV share (%)": 31 + }, + { + "Year": "2023", + "Country": "Germany", + "EV share (%)": 25 + }, + { + "Year": "2018", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2022", + "Country": "United States", + "EV share (%)": 8 + }, + { + "Year": "2023", + "Country": "United States", + "EV share (%)": 10 + } + ] + }, + "__theme__": "economist", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized as a synthesized text layer at each series' last point" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/ev-share.mckinsey.json b/site/src/playground/theme-lab-assets/compiled/ev-share.mckinsey.json new file mode 100644 index 00000000..2c9d8f20 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/ev-share.mckinsey.json @@ -0,0 +1,328 @@ +{ + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelAngle": 0 + }, + "axisY": { + "labelFontSize": 12, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelLimit": 0, + "labelAngle": 0, + "tickCount": 6 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#5a6872", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "point": { + "size": 64, + "filled": true + }, + "circle": { + "size": 64, + "filled": true + }, + "square": { + "size": 64, + "filled": true + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#5a6872", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": [ + "Norway, China, Germany and the United States, 2018–2023, per cent of new car sales" + ] + }, + "background": "#ffffff", + "padding": { + "left": 20, + "right": 100, + "top": 20, + "bottom": 20 + }, + "layer": [ + { + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#051c2c", + "#2251ff", + "#00a9f4", + "#00cfb4" + ] + }, + "legend": null + } + } + }, + { + "__themeSynthetic": true, + "transform": [ + { + "window": [ + { + "op": "row_number", + "as": "__seriesEndRank" + } + ], + "sort": [ + { + "field": "Year", + "order": "descending" + } + ], + "groupby": [ + "Country" + ] + }, + { + "filter": "datum.__seriesEndRank === 1" + } + ], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 5, + "dy": 0, + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10 + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "EV share (%)", + "type": "quantitative" + }, + "text": { + "field": "Country", + "type": "nominal" + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#051c2c", + "#2251ff", + "#00a9f4", + "#00cfb4" + ] + }, + "legend": null + } + } + } + ], + "data": { + "values": [ + { + "Year": "2018", + "Country": "Norway", + "EV share (%)": 49 + }, + { + "Year": "2020", + "Country": "Norway", + "EV share (%)": 75 + }, + { + "Year": "2022", + "Country": "Norway", + "EV share (%)": 88 + }, + { + "Year": "2023", + "Country": "Norway", + "EV share (%)": 93 + }, + { + "Year": "2018", + "Country": "China", + "EV share (%)": 4 + }, + { + "Year": "2020", + "Country": "China", + "EV share (%)": 6 + }, + { + "Year": "2022", + "Country": "China", + "EV share (%)": 29 + }, + { + "Year": "2023", + "Country": "China", + "EV share (%)": 38 + }, + { + "Year": "2018", + "Country": "Germany", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "Germany", + "EV share (%)": 13 + }, + { + "Year": "2022", + "Country": "Germany", + "EV share (%)": 31 + }, + { + "Year": "2023", + "Country": "Germany", + "EV share (%)": 25 + }, + { + "Year": "2018", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2022", + "Country": "United States", + "EV share (%)": 8 + }, + { + "Year": "2023", + "Country": "United States", + "EV share (%)": 10 + } + ] + }, + "__theme__": "mckinsey", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized as a synthesized text layer at each series' last point" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/ev-share.nature.json b/site/src/playground/theme-lab-assets/compiled/ev-share.nature.json new file mode 100644 index 00000000..f4cd8b15 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/ev-share.nature.json @@ -0,0 +1,254 @@ +{ + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#0072b2", + "#e69f00", + "#009e73", + "#cc79a7" + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 6 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "right", + "direction": "vertical", + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#8c8c8c", + "title": null + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 12.5, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 11, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 10.5, + "subtitleFontStyle": "italic", + "subtitleColor": "#000000", + "subtitlePadding": 6, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.2, + "point": { + "filled": true, + "size": 45, + "stroke": "#ffffff", + "strokeWidth": 0.6 + } + }, + "trail": { + "size": 1.2 + }, + "point": { + "size": 45, + "filled": true + }, + "circle": { + "size": 45, + "filled": true + }, + "square": { + "size": 45, + "filled": true + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#000000", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": [ + "Norway, China, Germany and the United States, 2018–2023, per cent of new car sales" + ] + }, + "background": "#ffffff", + "padding": 8, + "data": { + "values": [ + { + "Year": "2018", + "Country": "Norway", + "EV share (%)": 49 + }, + { + "Year": "2020", + "Country": "Norway", + "EV share (%)": 75 + }, + { + "Year": "2022", + "Country": "Norway", + "EV share (%)": 88 + }, + { + "Year": "2023", + "Country": "Norway", + "EV share (%)": 93 + }, + { + "Year": "2018", + "Country": "China", + "EV share (%)": 4 + }, + { + "Year": "2020", + "Country": "China", + "EV share (%)": 6 + }, + { + "Year": "2022", + "Country": "China", + "EV share (%)": 29 + }, + { + "Year": "2023", + "Country": "China", + "EV share (%)": 38 + }, + { + "Year": "2018", + "Country": "Germany", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "Germany", + "EV share (%)": 13 + }, + { + "Year": "2022", + "Country": "Germany", + "EV share (%)": 31 + }, + { + "Year": "2023", + "Country": "Germany", + "EV share (%)": 25 + }, + { + "Year": "2018", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2022", + "Country": "United States", + "EV share (%)": 8 + }, + { + "Year": "2023", + "Country": "United States", + "EV share (%)": 10 + } + ] + }, + "__theme__": "nature", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/ev-share.nyt.json b/site/src/playground/theme-lab-assets/compiled/ev-share.nyt.json new file mode 100644 index 00000000..15282e63 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/ev-share.nyt.json @@ -0,0 +1,363 @@ +{ + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#ececec", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 4 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, serif", + "fontSize": 16, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "Georgia, serif", + "subtitleFontSize": 12, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + }, + "trail": { + "size": 2.4 + }, + "rule": { + "strokeCap": "round" + }, + "header": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": [ + "Norway, China, Germany and the United States, 2018–2023, per cent of new car sales" + ] + }, + "background": "#ffffff", + "padding": { + "left": 12, + "right": 92, + "top": 12, + "bottom": 12 + }, + "layer": [ + { + "mark": { + "type": "line", + "point": { + "stroke": "#ffffff", + "strokeWidth": 1.5 + } + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null, + "values": [ + { + "year": 2018, + "utc": true + }, + { + "year": 2020, + "utc": true + }, + { + "year": 2022, + "utc": true + }, + { + "year": 2023, + "utc": true + } + ], + "format": "%Y" + }, + "scale": { + "type": "utc" + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.index === 1 ? datum.label + \"%\" : datum.label" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#2f6b9a", + "#c2352b", + "#4a8b6f", + "#7f6a9e" + ] + }, + "legend": null + } + } + }, + { + "__themeSynthetic": true, + "transform": [ + { + "window": [ + { + "op": "row_number", + "as": "__seriesEndRank" + } + ], + "sort": [ + { + "field": "Year", + "order": "descending" + } + ], + "groupby": [ + "Country" + ] + }, + { + "filter": "datum.__seriesEndRank === 1" + } + ], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 5, + "dy": 0, + "font": "Helvetica, Arial, sans-serif", + "fontSize": 10 + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "EV share (%)", + "type": "quantitative" + }, + "text": { + "field": "Country", + "type": "nominal" + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#2f6b9a", + "#c2352b", + "#4a8b6f", + "#7f6a9e" + ] + }, + "legend": null + } + } + } + ], + "data": { + "values": [ + { + "Year": "2018", + "Country": "Norway", + "EV share (%)": 49 + }, + { + "Year": "2020", + "Country": "Norway", + "EV share (%)": 75 + }, + { + "Year": "2022", + "Country": "Norway", + "EV share (%)": 88 + }, + { + "Year": "2023", + "Country": "Norway", + "EV share (%)": 93 + }, + { + "Year": "2018", + "Country": "China", + "EV share (%)": 4 + }, + { + "Year": "2020", + "Country": "China", + "EV share (%)": 6 + }, + { + "Year": "2022", + "Country": "China", + "EV share (%)": 29 + }, + { + "Year": "2023", + "Country": "China", + "EV share (%)": 38 + }, + { + "Year": "2018", + "Country": "Germany", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "Germany", + "EV share (%)": 13 + }, + { + "Year": "2022", + "Country": "Germany", + "EV share (%)": 31 + }, + { + "Year": "2023", + "Country": "Germany", + "EV share (%)": 25 + }, + { + "Year": "2018", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2022", + "Country": "United States", + "EV share (%)": 8 + }, + { + "Year": "2023", + "Country": "United States", + "EV share (%)": 10 + } + ] + }, + "__theme__": "nyt", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "chartDefaults.Line Chart.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "ground", + "path": "marks.redundantEncoding", + "message": "`whenNeeded` withheld — the house has a distinct ink for every series" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 4 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `%` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized as a synthesized text layer at each series' last point" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/ev-share.powerbi.json b/site/src/playground/theme-lab-assets/compiled/ev-share.powerbi.json new file mode 100644 index 00000000..51bf53c7 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/ev-share.powerbi.json @@ -0,0 +1,345 @@ +{ + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ] + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#323130", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0, + "tickCount": 4 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 10, + "orient": "right", + "direction": "vertical", + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#a19f9d", + "gradientLength": 90, + "title": null, + "symbolSize": 64 + }, + "facet": { + "spacing": 25 + }, + "background": "#1b1a19", + "font": "'Segoe UI', system-ui, sans-serif", + "title": { + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 11, + "subtitleFont": "'Segoe UI', system-ui, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#c8c6c4", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.2, + "strokeCap": "square" + }, + "trail": { + "size": 2.2 + }, + "rule": { + "strokeCap": "square" + }, + "header": { + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelFontSize": 10, + "labelColor": "#c8c6c4", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": [ + "Norway, China, Germany and the United States, 2018–2023, per cent of new car sales" + ] + }, + "background": "#1b1a19", + "padding": 8, + "layer": [ + { + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null, + "values": [ + { + "year": 2018, + "utc": true + }, + { + "year": 2020, + "utc": true + }, + { + "year": 2022, + "utc": true + }, + { + "year": 2023, + "utc": true + } + ], + "format": "%Y" + }, + "scale": { + "type": "utc" + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.label + \"%\"" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#118dff", + "#12239e", + "#e66c37", + "#6b007b" + ] + } + } + } + }, + { + "__themeSynthetic": true, + "transform": [ + { + "window": [ + { + "op": "row_number", + "as": "__peLast" + } + ], + "sort": [ + { + "field": "Year", + "order": "descending" + } + ], + "groupby": [ + "Country" + ] + }, + { + "filter": "datum.__peLast === 1" + } + ], + "mark": { + "type": "point", + "filled": true, + "size": 30.800000000000004, + "stroke": "#1b1a19", + "strokeWidth": 1.5 + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "EV share (%)", + "type": "quantitative" + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#118dff", + "#12239e", + "#e66c37", + "#6b007b" + ] + } + } + } + } + ], + "data": { + "values": [ + { + "Year": "2018", + "Country": "Norway", + "EV share (%)": 49 + }, + { + "Year": "2020", + "Country": "Norway", + "EV share (%)": 75 + }, + { + "Year": "2022", + "Country": "Norway", + "EV share (%)": 88 + }, + { + "Year": "2023", + "Country": "Norway", + "EV share (%)": 93 + }, + { + "Year": "2018", + "Country": "China", + "EV share (%)": 4 + }, + { + "Year": "2020", + "Country": "China", + "EV share (%)": 6 + }, + { + "Year": "2022", + "Country": "China", + "EV share (%)": 29 + }, + { + "Year": "2023", + "Country": "China", + "EV share (%)": 38 + }, + { + "Year": "2018", + "Country": "Germany", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "Germany", + "EV share (%)": 13 + }, + { + "Year": "2022", + "Country": "Germany", + "EV share (%)": 31 + }, + { + "Year": "2023", + "Country": "Germany", + "EV share (%)": 25 + }, + { + "Year": "2018", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2022", + "Country": "United States", + "EV share (%)": 8 + }, + { + "Year": "2023", + "Country": "United States", + "EV share (%)": 10 + } + ] + }, + "__theme__": "powerbi", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 4 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the latest reading carries a dot — the house marks where the line lands" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/exam-ecdf.nature.json b/site/src/playground/theme-lab-assets/compiled/exam-ecdf.nature.json new file mode 100644 index 00000000..c6b091a7 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/exam-ecdf.nature.json @@ -0,0 +1,301 @@ +{ + "mark": { + "type": "line", + "interpolate": "step-after", + "point": false, + "color": "#0072b2" + }, + "transform": [ + { + "window": [ + { + "op": "count", + "field": "Score", + "as": "__ecdf_count" + } + ], + "sort": [ + { + "field": "Score", + "order": "ascending" + } + ], + "frame": [ + null, + 0 + ] + }, + { + "joinaggregate": [ + { + "op": "count", + "field": "Score", + "as": "__ecdf_total" + } + ] + }, + { + "calculate": "datum['__ecdf_count'] / datum['__ecdf_total']", + "as": "__ecdf" + } + ], + "encoding": { + "x": { + "field": "Score", + "type": "quantitative", + "scale": { + "nice": false, + "zero": false + }, + "title": "Score", + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "__ecdf", + "type": "quantitative", + "scale": { + "domain": [ + 0, + 1 + ] + }, + "title": "Cumulative proportion", + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 11, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "tickCount": 8 + }, + "axisY": { + "labelFontSize": 11, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 5 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 13, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 12, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 11, + "subtitleFontStyle": "italic", + "subtitleColor": "#000000", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.2, + "point": { + "filled": true, + "size": 45, + "stroke": "#ffffff", + "strokeWidth": 0.6 + } + }, + "trail": { + "size": 1.2 + }, + "point": { + "size": 45, + "filled": true + }, + "circle": { + "size": 45, + "filled": true + }, + "square": { + "size": 45, + "filled": true + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 11, + "labelColor": "#000000", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Empirical distribution of exam scores", + "subtitle": [ + "n = 30; each step is one score" + ] + }, + "background": "#ffffff", + "padding": 8, + "data": { + "values": [ + { + "Score": 55 + }, + { + "Score": 62 + }, + { + "Score": 68 + }, + { + "Score": 71 + }, + { + "Score": 73 + }, + { + "Score": 74 + }, + { + "Score": 76 + }, + { + "Score": 77 + }, + { + "Score": 78 + }, + { + "Score": 79 + }, + { + "Score": 80 + }, + { + "Score": 81 + }, + { + "Score": 82 + }, + { + "Score": 83 + }, + { + "Score": 84 + }, + { + "Score": 85 + }, + { + "Score": 85 + }, + { + "Score": 86 + }, + { + "Score": 87 + }, + { + "Score": 88 + }, + { + "Score": 88 + }, + { + "Score": 89 + }, + { + "Score": 90 + }, + { + "Score": 91 + }, + { + "Score": 92 + }, + { + "Score": 93 + }, + { + "Score": 94 + }, + { + "Score": 95 + }, + { + "Score": 97 + }, + { + "Score": 99 + } + ] + }, + "__theme__": "nature", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point", + "message": "28 readings on one line is past the 12 a reader can take one at a time, so the house's dots stand down and the line keeps its shape" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/faithful-hist.nature.json b/site/src/playground/theme-lab-assets/compiled/faithful-hist.nature.json new file mode 100644 index 00000000..fc79bc63 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/faithful-hist.nature.json @@ -0,0 +1,265 @@ +{ + "mark": { + "type": "bar", + "stroke": "#ffffff", + "strokeWidth": 0.5, + "color": "#0072b2" + }, + "encoding": { + "x": { + "bin": true, + "field": "Duration (min)", + "type": "quantitative" + }, + "y": { + "aggregate": "count" + } + }, + "config": { + "view": { + "continuousWidth": 440, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12, + "titleFontSize": 12, + "labelAngle": 0, + "labelAlign": "center", + "labelBaseline": "top", + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "tickCount": 10 + }, + "axisY": { + "labelFontSize": 12, + "titleFontSize": 12, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 5 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 13, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 12, + "subtitleFontStyle": "italic", + "subtitleColor": "#000000", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.2, + "point": { + "filled": true, + "size": 45, + "stroke": "#ffffff", + "strokeWidth": 0.6 + } + }, + "trail": { + "size": 1.2 + }, + "point": { + "size": 45, + "filled": true + }, + "circle": { + "size": 45, + "filled": true + }, + "square": { + "size": 45, + "filled": true + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 12, + "labelColor": "#000000", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Old Faithful eruption durations", + "subtitle": [ + "Two clusters, not one: short eruptions near 2 min and long ones near 4 min" + ] + }, + "background": "#ffffff", + "padding": 8, + "data": { + "values": [ + { + "Duration (min)": 3.6 + }, + { + "Duration (min)": 1.8 + }, + { + "Duration (min)": 3.333 + }, + { + "Duration (min)": 2.283 + }, + { + "Duration (min)": 4.533 + }, + { + "Duration (min)": 2.883 + }, + { + "Duration (min)": 4.7 + }, + { + "Duration (min)": 3.6 + }, + { + "Duration (min)": 1.95 + }, + { + "Duration (min)": 4.35 + }, + { + "Duration (min)": 1.833 + }, + { + "Duration (min)": 3.917 + }, + { + "Duration (min)": 4.2 + }, + { + "Duration (min)": 1.75 + }, + { + "Duration (min)": 4.7 + }, + { + "Duration (min)": 2.167 + }, + { + "Duration (min)": 1.75 + }, + { + "Duration (min)": 4.8 + }, + { + "Duration (min)": 1.6 + }, + { + "Duration (min)": 4.25 + }, + { + "Duration (min)": 1.8 + }, + { + "Duration (min)": 1.75 + }, + { + "Duration (min)": 3.45 + }, + { + "Duration (min)": 3.067 + }, + { + "Duration (min)": 4.533 + }, + { + "Duration (min)": 3.6 + }, + { + "Duration (min)": 1.967 + }, + { + "Duration (min)": 4.083 + }, + { + "Duration (min)": 3.85 + }, + { + "Duration (min)": 4.433 + }, + { + "Duration (min)": 4.3 + }, + { + "Duration (min)": 4.467 + }, + { + "Duration (min)": 3.367 + }, + { + "Duration (min)": 4.033 + }, + { + "Duration (min)": 3.833 + }, + { + "Duration (min)": 2.017 + } + ] + }, + "__theme__": "nature", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/fed-funds-step.powerbi.json b/site/src/playground/theme-lab-assets/compiled/fed-funds-step.powerbi.json new file mode 100644 index 00000000..1862566e --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/fed-funds-step.powerbi.json @@ -0,0 +1,290 @@ +{ + "config": { + "view": { + "continuousWidth": 317, + "continuousHeight": 247, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ] + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#323130", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0, + "tickCount": 4 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + }, + "background": "#1b1a19", + "font": "'Segoe UI', system-ui, sans-serif", + "title": { + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 11, + "subtitleFont": "'Segoe UI', system-ui, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#c8c6c4", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.2, + "strokeCap": "square" + }, + "trail": { + "size": 2.2 + }, + "rule": { + "strokeCap": "square" + }, + "header": { + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelFontSize": 10, + "labelColor": "#c8c6c4", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Federal funds target rate", + "subtitle": [ + "Upper bound at year end, %" + ] + }, + "background": "#1b1a19", + "padding": 8, + "layer": [ + { + "mark": { + "type": "line", + "interpolate": "step", + "color": "#118dff" + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null, + "values": [ + { + "year": 2015, + "utc": true + }, + { + "year": 2016, + "utc": true + }, + { + "year": 2017, + "utc": true + }, + { + "year": 2018, + "utc": true + }, + { + "year": 2019, + "utc": true + }, + { + "year": 2020, + "utc": true + }, + { + "year": 2021, + "utc": true + }, + { + "year": 2022, + "utc": true + }, + { + "year": 2023, + "utc": true + }, + { + "year": 2024, + "utc": true + } + ], + "format": "%Y" + }, + "scale": { + "type": "utc" + } + }, + "y": { + "field": "Target rate (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.label + \"%\"" + } + } + } + }, + { + "__themeSynthetic": true, + "transform": [ + { + "window": [ + { + "op": "row_number", + "as": "__peLast" + } + ], + "sort": [ + { + "field": "Year", + "order": "descending" + } + ], + "groupby": [] + }, + { + "filter": "datum.__peLast === 1" + } + ], + "mark": { + "type": "point", + "filled": true, + "size": 30.800000000000004, + "color": "#118dff" + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Target rate (%)", + "type": "quantitative" + } + } + } + ], + "data": { + "values": [ + { + "Year": "2015", + "Target rate (%)": 0.5 + }, + { + "Year": "2016", + "Target rate (%)": 0.75 + }, + { + "Year": "2017", + "Target rate (%)": 1.5 + }, + { + "Year": "2018", + "Target rate (%)": 2.5 + }, + { + "Year": "2019", + "Target rate (%)": 1.75 + }, + { + "Year": "2020", + "Target rate (%)": 0.25 + }, + { + "Year": "2021", + "Target rate (%)": 0.25 + }, + { + "Year": "2022", + "Target rate (%)": 4.5 + }, + { + "Year": "2023", + "Target rate (%)": 5.5 + }, + { + "Year": "2024", + "Target rate (%)": 4.75 + } + ] + }, + "__theme__": "powerbi", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 10 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the latest reading carries a dot — the house marks where the line lands" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/gapminder-bubble.economist.json b/site/src/playground/theme-lab-assets/compiled/gapminder-bubble.economist.json new file mode 100644 index 00000000..5e465bae --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/gapminder-bubble.economist.json @@ -0,0 +1,349 @@ +{ + "background": "#ffffff", + "padding": 8, + "title": { + "text": "Money buys years, up to a point", + "subtitle": [ + "Life expectancy against GDP per capita, 2018; bubble area is population" + ] + }, + "spacing": 6, + "vconcat": [ + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#e3120b" + }, + "width": 26, + "height": 3, + "data": { + "values": [ + {} + ] + } + }, + { + "mark": "circle", + "encoding": { + "x": { + "field": "GDP per capita", + "type": "quantitative", + "scale": { + "type": "log" + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Life expectancy", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "size": { + "field": "Population (M)", + "type": "quantitative", + "scale": { + "type": "sqrt", + "zero": true, + "range": [ + 10, + 450 + ] + }, + "legend": { + "symbolFillColor": "#8b9196", + "values": [ + 200, + 600, + 1000 + ] + } + }, + "color": { + "field": "Continent", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#3f5661", + "#a1655a", + "#006ba2", + "#7ba7b8" + ] + } + } + } + } + ], + "resolve": { + "legend": { + "color": "independent", + "size": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 309, + "continuousHeight": 253, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121317", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "tickCount": 7 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#d8dfe4", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 6 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 10, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#8b9196", + "gradientLength": 149, + "title": null, + "layout": { + "top": { + "direction": "vertical", + "anchor": "start" + } + } + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 12, + "subtitleColor": "#54585a", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.6 + }, + "trail": { + "size": 1.6 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#54585a", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Country": "Norway", + "GDP per capita": 64800, + "Life expectancy": 82.3, + "Population (M)": 5.3, + "Continent": "Europe" + }, + { + "Country": "United States", + "GDP per capita": 62600, + "Life expectancy": 78.6, + "Population (M)": 327, + "Continent": "Americas" + }, + { + "Country": "Japan", + "GDP per capita": 39300, + "Life expectancy": 84.2, + "Population (M)": 127, + "Continent": "Asia" + }, + { + "Country": "China", + "GDP per capita": 16800, + "Life expectancy": 76.7, + "Population (M)": 1393, + "Continent": "Asia" + }, + { + "Country": "India", + "GDP per capita": 6900, + "Life expectancy": 69.4, + "Population (M)": 1353, + "Continent": "Asia" + }, + { + "Country": "Nigeria", + "GDP per capita": 5300, + "Life expectancy": 54.3, + "Population (M)": 196, + "Continent": "Africa" + }, + { + "Country": "Brazil", + "GDP per capita": 15600, + "Life expectancy": 75.7, + "Population (M)": 209, + "Continent": "Americas" + }, + { + "Country": "Germany", + "GDP per capita": 50900, + "Life expectancy": 81, + "Population (M)": 83, + "Continent": "Europe" + }, + { + "Country": "Ethiopia", + "GDP per capita": 2000, + "Life expectancy": 66.2, + "Population (M)": 109, + "Continent": "Africa" + }, + { + "Country": "Russia", + "GDP per capita": 25800, + "Life expectancy": 72.4, + "Population (M)": 145, + "Continent": "Europe" + }, + { + "Country": "Mexico", + "GDP per capita": 19800, + "Life expectancy": 75, + "Population (M)": 126, + "Continent": "Americas" + }, + { + "Country": "Indonesia", + "GDP per capita": 12400, + "Life expectancy": 71.5, + "Population (M)": 268, + "Continent": "Asia" + }, + { + "Country": "Qatar", + "GDP per capita": 116900, + "Life expectancy": 80.1, + "Population (M)": 2.8, + "Continent": "Asia" + }, + { + "Country": "South Africa", + "GDP per capita": 13000, + "Life expectancy": 63.9, + "Population (M)": 57, + "Continent": "Africa" + }, + { + "Country": "Bangladesh", + "GDP per capita": 4200, + "Life expectancy": 72.3, + "Population (M)": 161, + "Continent": "Asia" + } + ] + }, + "__theme__": "economist", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "annotation.axisTitles", + "message": "both rulers carry a measure — a headline can name one of them, so the axis titles are kept" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.sizeRange", + "message": "sized marks run from 10 to 450px² — the house's range, not the renderer's" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 149px — under half the 331px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "the size key is drawn in neutral ink — beside a colour key, swatches in series ink read as another category" + }, + { + "stage": "realize", + "path": "legend.maxSwatches", + "message": "the key to values is sampled at 3 round sizes — a swatch for every tick reads as data, not as a key" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "2 keys want 402px across a 331px block — they take a row each" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/gdp-bartable.mckinsey.json b/site/src/playground/theme-lab-assets/compiled/gdp-bartable.mckinsey.json new file mode 100644 index 00000000..d3f1bbe3 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/gdp-bartable.mckinsey.json @@ -0,0 +1,419 @@ +{ + "spacing": 8, + "resolve": { + "scale": { + "y": "shared" + } + }, + "hconcat": [ + { + "data": { + "name": "__bt_displayTable" + }, + "width": 180, + "height": 230, + "title": { + "text": "Country", + "anchor": "start", + "offset": 6, + "fontSize": 12, + "fontWeight": "normal", + "color": "#999" + }, + "mark": { + "type": "bar", + "height": { + "band": 0.842 + }, + "stroke": "#ffffff", + "strokeWidth": 0.6 + }, + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": [ + "United States", + "China", + "Germany", + "Japan", + "India", + "UK", + "France", + "Brazil" + ], + "axis": { + "title": null, + "labelAlign": "left", + "labelLimit": 105, + "labelPadding": 105 + }, + "scale": { + "paddingInner": 0.4 + } + }, + "x": { + "field": "GDP ($T)", + "type": "quantitative", + "axis": null, + "scale": { + "nice": false + } + }, + "color": { + "field": "GDP ($T)", + "type": "quantitative", + "legend": null, + "scale": { + "range": [ + "#e2e7ec", + "#cfdcea", + "#9db8d2", + "#5b82ab", + "#051c2c" + ] + } + } + } + }, + { + "data": { + "name": "__bt_displayTable" + }, + "width": 66, + "height": 230, + "transform": [ + { + "aggregate": [ + { + "op": "sum", + "field": "GDP ($T)", + "as": "__bt_val" + }, + { + "op": "min", + "field": "__bt_sort", + "as": "__bt_sort" + }, + { + "op": "max", + "field": "__bt_others_num", + "as": "__bt_others_num" + } + ], + "groupby": [ + "Country" + ] + } + ], + "title": { + "text": "GDP ($T)", + "anchor": "end", + "offset": 6, + "limit": 62, + "fontSize": 12, + "fontWeight": "normal", + "color": "#999" + }, + "mark": { + "type": "text", + "align": "right", + "baseline": "middle", + "fontSize": 12 + }, + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": [ + "United States", + "China", + "Germany", + "Japan", + "India", + "UK", + "France", + "Brazil" + ], + "axis": null + }, + "x": { + "datum": 1, + "axis": null, + "scale": { + "type": "linear", + "domain": [ + 0, + 1 + ] + } + }, + "text": { + "field": "__bt_val", + "type": "quantitative" + }, + "color": { + "value": "#666" + } + } + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelAngle": 0, + "tickCount": 8 + }, + "axisY": { + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelLimit": 0, + "labelAngle": 0 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#8a969d", + "gradientLength": 87, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#5a6872", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "point": { + "size": 64, + "filled": true + }, + "circle": { + "size": 64, + "filled": true + }, + "square": { + "size": 64, + "filled": true + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#5a6872", + "labelFontWeight": "normal", + "title": null + } + }, + "datasets": { + "__bt_displayTable": [ + { + "Country": "United States", + "GDP ($T)": 27.4, + "__bt_sort": 0, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "China", + "GDP ($T)": 17.8, + "__bt_sort": 1, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "Germany", + "GDP ($T)": 4.5, + "__bt_sort": 2, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "Japan", + "GDP ($T)": 4.2, + "__bt_sort": 3, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "India", + "GDP ($T)": 3.9, + "__bt_sort": 4, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "UK", + "GDP ($T)": 3.3, + "__bt_sort": 5, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "France", + "GDP ($T)": 3, + "__bt_sort": 6, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "Brazil", + "GDP ($T)": 2.2, + "__bt_sort": 7, + "__bt_others": false, + "__bt_others_num": 0 + } + ] + }, + "height": { + "step": 36 + }, + "title": { + "text": "America and China lap the field", + "subtitle": [ + "Gross domestic product, 2023, trillion US dollars" + ] + }, + "background": "#ffffff", + "padding": 20, + "data": { + "values": [ + { + "Country": "United States", + "GDP ($T)": 27.4 + }, + { + "Country": "China", + "GDP ($T)": 17.8 + }, + { + "Country": "Germany", + "GDP ($T)": 4.5 + }, + { + "Country": "Japan", + "GDP ($T)": 4.2 + }, + { + "Country": "India", + "GDP ($T)": 3.9 + }, + { + "Country": "UK", + "GDP ($T)": 3.3 + }, + { + "Country": "France", + "GDP ($T)": 3 + }, + { + "Country": "Brazil", + "GDP ($T)": 2.2 + } + ] + }, + "__theme__": "mckinsey", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "ink.series.endpointsAgainstSurface", + "message": "a ramp end sat too close to the surface to be seen as a value — it was pulled away from the page" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "the segments are stacked — a value at a segment edge would read as the running total" + }, + { + "stage": "realize", + "path": "axes.y.label.padding", + "message": "the template holds a 105px gutter for its labels — that is layout, not padding, so it stands" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 87px — under half the 193px block, so the key stays a caption to the chart" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/keeling.nyt.json b/site/src/playground/theme-lab-assets/compiled/keeling.nyt.json new file mode 100644 index 00000000..97102793 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/keeling.nyt.json @@ -0,0 +1,277 @@ +{ + "mark": { + "type": "line", + "point": true, + "color": "#2f6b9a" + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null, + "values": [ + { + "year": 1959, + "utc": true + }, + { + "year": 1965, + "utc": true + }, + { + "year": 1970, + "utc": true + }, + { + "year": 1975, + "utc": true + }, + { + "year": 1980, + "utc": true + }, + { + "year": 1985, + "utc": true + }, + { + "year": 1990, + "utc": true + }, + { + "year": 1995, + "utc": true + }, + { + "year": 2000, + "utc": true + }, + { + "year": 2005, + "utc": true + }, + { + "year": 2010, + "utc": true + }, + { + "year": 2015, + "utc": true + }, + { + "year": 2020, + "utc": true + }, + { + "year": 2023, + "utc": true + } + ], + "format": "%Y" + }, + "scale": { + "type": "utc" + } + }, + "y": { + "field": "CO₂ (ppm)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.index === 1 ? datum.label + \" ppm\" : datum.label" + } + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#ececec", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 4 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, serif", + "fontSize": 16, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "Georgia, serif", + "subtitleFontSize": 12, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + }, + "trail": { + "size": 2.4 + }, + "rule": { + "strokeCap": "round" + }, + "header": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Keeling Curve", + "subtitle": [ + "Atmospheric CO₂ at Mauna Loa, annual mean, parts per million" + ] + }, + "background": "#ffffff", + "padding": 12, + "data": { + "values": [ + { + "Year": "1959", + "CO₂ (ppm)": 315.98 + }, + { + "Year": "1965", + "CO₂ (ppm)": 320.04 + }, + { + "Year": "1970", + "CO₂ (ppm)": 325.68 + }, + { + "Year": "1975", + "CO₂ (ppm)": 331.11 + }, + { + "Year": "1980", + "CO₂ (ppm)": 338.8 + }, + { + "Year": "1985", + "CO₂ (ppm)": 346.12 + }, + { + "Year": "1990", + "CO₂ (ppm)": 354.45 + }, + { + "Year": "1995", + "CO₂ (ppm)": 360.82 + }, + { + "Year": "2000", + "CO₂ (ppm)": 369.71 + }, + { + "Year": "2005", + "CO₂ (ppm)": 379.98 + }, + { + "Year": "2010", + "CO₂ (ppm)": 389.9 + }, + { + "Year": "2015", + "CO₂ (ppm)": 400.83 + }, + { + "Year": "2020", + "CO₂ (ppm)": 414.24 + }, + { + "Year": "2023", + "CO₂ (ppm)": 421.08 + } + ] + }, + "__theme__": "nyt", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "chartDefaults.Line Chart.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 14 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `ppm` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/kpi-sparkline.powerbi.json b/site/src/playground/theme-lab-assets/compiled/kpi-sparkline.powerbi.json new file mode 100644 index 00000000..5960d21a --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/kpi-sparkline.powerbi.json @@ -0,0 +1,775 @@ +{ + "hconcat": [ + { + "data": { + "values": [ + { + "Metric": "Revenue ($k)" + }, + { + "Metric": "Active users (k)" + }, + { + "Metric": "Churn (%)" + } + ] + }, + "facet": { + "row": { + "field": "Metric", + "type": "nominal", + "sort": [ + "Revenue ($k)", + "Active users (k)", + "Churn (%)" + ], + "header": null + } + }, + "spec": { + "width": 116, + "height": 64, + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "fontSize": 11 + }, + "encoding": { + "y": { + "value": 32, + "axis": { + "title": null + } + }, + "x": { + "value": 0, + "axis": { + "title": null + } + }, + "text": { + "field": "Metric", + "type": "nominal" + } + } + }, + "title": { + "text": "Metric", + "anchor": "start", + "fontSize": 11, + "fontWeight": "normal", + "color": "#999", + "offset": 6 + } + }, + { + "data": { + "values": [ + { + "Month": "Jan", + "Metric": "Revenue ($k)", + "Value": 120 + }, + { + "Month": "Feb", + "Metric": "Revenue ($k)", + "Value": 125 + }, + { + "Month": "Mar", + "Metric": "Revenue ($k)", + "Value": 130 + }, + { + "Month": "Apr", + "Metric": "Revenue ($k)", + "Value": 128 + }, + { + "Month": "May", + "Metric": "Revenue ($k)", + "Value": 140 + }, + { + "Month": "Jun", + "Metric": "Revenue ($k)", + "Value": 145 + }, + { + "Month": "Jul", + "Metric": "Revenue ($k)", + "Value": 150 + }, + { + "Month": "Aug", + "Metric": "Revenue ($k)", + "Value": 148 + }, + { + "Month": "Sep", + "Metric": "Revenue ($k)", + "Value": 160 + }, + { + "Month": "Oct", + "Metric": "Revenue ($k)", + "Value": 165 + }, + { + "Month": "Nov", + "Metric": "Revenue ($k)", + "Value": 170 + }, + { + "Month": "Dec", + "Metric": "Revenue ($k)", + "Value": 180 + }, + { + "Month": "Jan", + "Metric": "Active users (k)", + "Value": 40 + }, + { + "Month": "Feb", + "Metric": "Active users (k)", + "Value": 42 + }, + { + "Month": "Mar", + "Metric": "Active users (k)", + "Value": 45 + }, + { + "Month": "Apr", + "Metric": "Active users (k)", + "Value": 47 + }, + { + "Month": "May", + "Metric": "Active users (k)", + "Value": 50 + }, + { + "Month": "Jun", + "Metric": "Active users (k)", + "Value": 52 + }, + { + "Month": "Jul", + "Metric": "Active users (k)", + "Value": 55 + }, + { + "Month": "Aug", + "Metric": "Active users (k)", + "Value": 58 + }, + { + "Month": "Sep", + "Metric": "Active users (k)", + "Value": 60 + }, + { + "Month": "Oct", + "Metric": "Active users (k)", + "Value": 63 + }, + { + "Month": "Nov", + "Metric": "Active users (k)", + "Value": 66 + }, + { + "Month": "Dec", + "Metric": "Active users (k)", + "Value": 70 + }, + { + "Month": "Jan", + "Metric": "Churn (%)", + "Value": 5.2 + }, + { + "Month": "Feb", + "Metric": "Churn (%)", + "Value": 5 + }, + { + "Month": "Mar", + "Metric": "Churn (%)", + "Value": 4.8 + }, + { + "Month": "Apr", + "Metric": "Churn (%)", + "Value": 4.9 + }, + { + "Month": "May", + "Metric": "Churn (%)", + "Value": 4.6 + }, + { + "Month": "Jun", + "Metric": "Churn (%)", + "Value": 4.5 + }, + { + "Month": "Jul", + "Metric": "Churn (%)", + "Value": 4.3 + }, + { + "Month": "Aug", + "Metric": "Churn (%)", + "Value": 4.4 + }, + { + "Month": "Sep", + "Metric": "Churn (%)", + "Value": 4.1 + }, + { + "Month": "Oct", + "Metric": "Churn (%)", + "Value": 4 + }, + { + "Month": "Nov", + "Metric": "Churn (%)", + "Value": 3.9 + }, + { + "Month": "Dec", + "Metric": "Churn (%)", + "Value": 3.8 + } + ] + }, + "facet": { + "row": { + "field": "Metric", + "type": "nominal", + "sort": [ + "Revenue ($k)", + "Active users (k)", + "Churn (%)" + ], + "header": null + } + }, + "spec": { + "width": 154, + "height": 64, + "layer": [ + { + "mark": { + "type": "line", + "strokeWidth": 1.5 + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": null + }, + "y": { + "field": "Value", + "type": "quantitative", + "axis": null, + "scale": { + "range": [ + 62, + 2 + ] + } + }, + "color": { + "field": "Metric", + "type": "nominal", + "legend": null, + "scale": { + "range": [ + "#118dff" + ] + } + } + } + }, + { + "mark": { + "type": "rule", + "strokeDash": [ + 3, + 2 + ], + "stroke": "#9a9a9a", + "strokeWidth": 1, + "opacity": 0.7 + }, + "encoding": { + "y": { + "field": "Value", + "aggregate": "mean", + "type": "quantitative", + "axis": null + } + } + } + ] + }, + "resolve": { + "scale": { + "y": "independent" + } + }, + "title": { + "text": "Value", + "anchor": "middle", + "fontSize": 11, + "fontWeight": "normal", + "color": "#999", + "offset": 6 + } + }, + { + "data": { + "values": [ + { + "Metric": "Revenue ($k)", + "flintSparkAvg": 146.75 + }, + { + "Metric": "Active users (k)", + "flintSparkAvg": 54 + }, + { + "Metric": "Churn (%)", + "flintSparkAvg": 4.458333333333333 + } + ] + }, + "facet": { + "row": { + "field": "Metric", + "type": "nominal", + "sort": [ + "Revenue ($k)", + "Active users (k)", + "Churn (%)" + ], + "header": null + } + }, + "spec": { + "width": 54, + "height": 64, + "mark": { + "type": "text", + "align": "right", + "baseline": "middle", + "fontSize": 11, + "fontWeight": 600 + }, + "encoding": { + "y": { + "value": 32, + "axis": { + "title": null + } + }, + "x": { + "value": 54, + "axis": { + "title": null + } + }, + "text": { + "field": "flintSparkAvg", + "type": "quantitative", + "format": ".3~s" + }, + "color": { + "field": "Metric", + "type": "nominal", + "legend": null, + "scale": { + "range": [ + "#118dff" + ] + } + } + } + }, + "title": { + "text": "Average", + "anchor": "end", + "fontSize": 11, + "fontWeight": "normal", + "color": "#999", + "offset": 6 + } + } + ], + "spacing": 8, + "resolve": { + "scale": { + "y": "independent", + "color": "shared" + } + }, + "config": { + "view": { + "continuousWidth": 250, + "continuousHeight": 83, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 8, + "titleFontSize": 9.5, + "titleFontWeight": "normal", + "titleColor": "#c8c6c4", + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "gridDash": [ + 3, + 3 + ], + "labelAngle": 0 + }, + "axisY": { + "labelFontSize": 8, + "titleFontSize": 9.5, + "titleFontWeight": "normal", + "titleColor": "#c8c6c4", + "grid": true, + "gridColor": "#323130", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0, + "tickCount": 3 + }, + "legend": { + "labelFontSize": 9.5, + "titleFontSize": 9.5, + "orient": "right", + "direction": "vertical", + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#a19f9d", + "gradientLength": 90, + "title": null + }, + "facet": { + "spacing": { + "row": 16, + "column": 8 + } + }, + "background": "#1b1a19", + "font": "'Segoe UI', system-ui, sans-serif", + "title": { + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 11.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', system-ui, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#c8c6c4", + "subtitlePadding": 6, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.2, + "strokeCap": "square" + }, + "trail": { + "size": 2.2 + }, + "rule": { + "strokeCap": "square" + }, + "header": { + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#c8c6c4", + "labelFontWeight": "normal", + "title": null + } + }, + "width": { + "step": 21 + }, + "title": { + "text": "Monthly KPIs", + "subtitle": [ + "Twelve-month trend and latest value" + ] + }, + "background": "#1b1a19", + "padding": 8, + "data": { + "values": [ + { + "Month": "Jan", + "Metric": "Revenue ($k)", + "Value": 120 + }, + { + "Month": "Feb", + "Metric": "Revenue ($k)", + "Value": 125 + }, + { + "Month": "Mar", + "Metric": "Revenue ($k)", + "Value": 130 + }, + { + "Month": "Apr", + "Metric": "Revenue ($k)", + "Value": 128 + }, + { + "Month": "May", + "Metric": "Revenue ($k)", + "Value": 140 + }, + { + "Month": "Jun", + "Metric": "Revenue ($k)", + "Value": 145 + }, + { + "Month": "Jul", + "Metric": "Revenue ($k)", + "Value": 150 + }, + { + "Month": "Aug", + "Metric": "Revenue ($k)", + "Value": 148 + }, + { + "Month": "Sep", + "Metric": "Revenue ($k)", + "Value": 160 + }, + { + "Month": "Oct", + "Metric": "Revenue ($k)", + "Value": 165 + }, + { + "Month": "Nov", + "Metric": "Revenue ($k)", + "Value": 170 + }, + { + "Month": "Dec", + "Metric": "Revenue ($k)", + "Value": 180 + }, + { + "Month": "Jan", + "Metric": "Active users (k)", + "Value": 40 + }, + { + "Month": "Feb", + "Metric": "Active users (k)", + "Value": 42 + }, + { + "Month": "Mar", + "Metric": "Active users (k)", + "Value": 45 + }, + { + "Month": "Apr", + "Metric": "Active users (k)", + "Value": 47 + }, + { + "Month": "May", + "Metric": "Active users (k)", + "Value": 50 + }, + { + "Month": "Jun", + "Metric": "Active users (k)", + "Value": 52 + }, + { + "Month": "Jul", + "Metric": "Active users (k)", + "Value": 55 + }, + { + "Month": "Aug", + "Metric": "Active users (k)", + "Value": 58 + }, + { + "Month": "Sep", + "Metric": "Active users (k)", + "Value": 60 + }, + { + "Month": "Oct", + "Metric": "Active users (k)", + "Value": 63 + }, + { + "Month": "Nov", + "Metric": "Active users (k)", + "Value": 66 + }, + { + "Month": "Dec", + "Metric": "Active users (k)", + "Value": 70 + }, + { + "Month": "Jan", + "Metric": "Churn (%)", + "Value": 5.2 + }, + { + "Month": "Feb", + "Metric": "Churn (%)", + "Value": 5 + }, + { + "Month": "Mar", + "Metric": "Churn (%)", + "Value": 4.8 + }, + { + "Month": "Apr", + "Metric": "Churn (%)", + "Value": 4.9 + }, + { + "Month": "May", + "Metric": "Churn (%)", + "Value": 4.6 + }, + { + "Month": "Jun", + "Metric": "Churn (%)", + "Value": 4.5 + }, + { + "Month": "Jul", + "Metric": "Churn (%)", + "Value": 4.3 + }, + { + "Month": "Aug", + "Metric": "Churn (%)", + "Value": 4.4 + }, + { + "Month": "Sep", + "Metric": "Churn (%)", + "Value": 4.1 + }, + { + "Month": "Oct", + "Metric": "Churn (%)", + "Value": 4 + }, + { + "Month": "Nov", + "Metric": "Churn (%)", + "Value": 3.9 + }, + { + "Month": "Dec", + "Metric": "Churn (%)", + "Value": 3.8 + } + ] + }, + "__theme__": "powerbi", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~16px and the band is 21px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "ink.series.selection.redundantWithFacet", + "message": "series colour collapsed to single — the facet already names the series" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "axes.x.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 9.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 9.5px would not stand in the band" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/life-expectancy.economist.json b/site/src/playground/theme-lab-assets/compiled/life-expectancy.economist.json new file mode 100644 index 00000000..c7406254 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/life-expectancy.economist.json @@ -0,0 +1,426 @@ +{ + "background": "#ffffff", + "padding": 8, + "title": { + "text": "Two decades of longer lives", + "subtitle": [ + "Life expectancy at birth, years, 2000 and 2021" + ] + }, + "spacing": 6, + "vconcat": [ + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#e3120b" + }, + "width": 26, + "height": 3, + "data": { + "values": [ + {} + ] + } + }, + { + "layer": [ + { + "mark": { + "type": "line", + "point": { + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "interpolate": "linear", + "strokeWidth": 2 + }, + "encoding": { + "x": { + "field": "Year", + "type": "ordinal", + "sort": [ + "2000", + "2021" + ], + "scale": { + "padding": 0.75 + }, + "axis": { + "title": null + } + }, + "y": { + "field": "Life expectancy", + "type": "quantitative", + "scale": { + "zero": false, + "nice": true, + "padding": 12 + }, + "axis": { + "format": ",.12~g", + "title": null + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#006ba2" + ] + }, + "legend": null + } + } + }, + { + "transform": [ + { + "filter": { + "field": "Year", + "equal": "2000" + } + }, + { + "calculate": "datum[\"Country\"] + ' ' + format(datum[\"Life expectancy\"], \".3~s\")", + "as": "__slopeLabel" + } + ], + "mark": { + "type": "text", + "align": "right", + "baseline": "middle", + "dx": -8, + "fontSize": 11 + }, + "encoding": { + "x": { + "field": "Year", + "type": "ordinal", + "sort": [ + "2000", + "2021" + ], + "scale": { + "padding": 0.75 + }, + "axis": { + "title": null + } + }, + "y": { + "field": "Life expectancy", + "type": "quantitative", + "scale": { + "zero": false, + "nice": true, + "padding": 12 + }, + "axis": { + "format": ",.12~g", + "title": null + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#006ba2" + ] + }, + "legend": null + }, + "text": { + "field": "__slopeLabel", + "type": "nominal" + } + } + }, + { + "transform": [ + { + "filter": { + "field": "Year", + "equal": "2021" + } + }, + { + "calculate": "datum[\"Country\"] + ' ' + format(datum[\"Life expectancy\"], \".3~s\")", + "as": "__slopeLabel" + } + ], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 8, + "fontSize": 11 + }, + "encoding": { + "x": { + "field": "Year", + "type": "ordinal", + "sort": [ + "2000", + "2021" + ], + "scale": { + "padding": 0.75 + }, + "axis": { + "title": null + } + }, + "y": { + "field": "Life expectancy", + "type": "quantitative", + "scale": { + "zero": false, + "nice": true, + "padding": 12 + }, + "axis": { + "format": ",.12~g", + "title": null + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#006ba2" + ] + }, + "legend": null + }, + "text": { + "field": "__slopeLabel", + "type": "nominal" + } + } + } + ], + "width": { + "step": 136 + } + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "labelAngle": 0, + "labelAlign": "center", + "labelBaseline": "top", + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "#121317", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": true, + "gridColor": "#d8dfe4", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 5 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 12.5, + "subtitleColor": "#54585a", + "subtitlePadding": 8, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.6 + }, + "trail": { + "size": 1.6 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#54585a", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Country": "China", + "Year": "2000", + "Life expectancy": 72 + }, + { + "Country": "China", + "Year": "2021", + "Life expectancy": 78.2 + }, + { + "Country": "India", + "Year": "2000", + "Life expectancy": 62.5 + }, + { + "Country": "India", + "Year": "2021", + "Life expectancy": 67.2 + }, + { + "Country": "United States", + "Year": "2000", + "Life expectancy": 76.6 + }, + { + "Country": "United States", + "Year": "2021", + "Life expectancy": 76.3 + }, + { + "Country": "Nigeria", + "Year": "2000", + "Life expectancy": 46.6 + }, + { + "Country": "Nigeria", + "Year": "2021", + "Life expectancy": 52.7 + }, + { + "Country": "Japan", + "Year": "2000", + "Life expectancy": 81.1 + }, + { + "Country": "Japan", + "Year": "2021", + "Life expectancy": 84.5 + }, + { + "Country": "Russia", + "Year": "2000", + "Life expectancy": 65.5 + }, + { + "Country": "Russia", + "Year": "2021", + "Life expectancy": 70.1 + }, + { + "Country": "Brazil", + "Year": "2000", + "Life expectancy": 70.1 + }, + { + "Country": "Brazil", + "Year": "2021", + "Life expectancy": 72.8 + } + ] + }, + "__theme__": "economist", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "chartDefaults.Slope Chart.showText", + "message": "house rule: `showText` set to true" + }, + { + "stage": "ground", + "path": "chartDefaults.Slope Chart.showSeriesInLabel", + "message": "house rule: `showSeriesInLabel` set to true" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~23px and the band is 136px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "ink.series.categorical", + "message": "7 series against 6 house inks, but the house names them on the mark — colour stops naming and takes the single ink" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "axes.x.domain", + "message": "the value scale floats — a rule under the categories would claim a base the chart does not have" + }, + { + "stage": "realize", + "path": "dataLabels", + "message": "template already prints its own labels — left alone" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "the chart already prints its own end labels — no second set drawn" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/lifeexp-dumbbell.mckinsey.json b/site/src/playground/theme-lab-assets/compiled/lifeexp-dumbbell.mckinsey.json new file mode 100644 index 00000000..1704907c --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/lifeexp-dumbbell.mckinsey.json @@ -0,0 +1,322 @@ +{ + "encoding": { + "x": { + "field": "Life expectancy", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g", + "title": null + } + }, + "y": { + "field": "Country", + "type": "nominal", + "sort": null, + "axis": { + "title": null + } + } + }, + "layer": [ + { + "mark": { + "type": "line", + "color": "#d3dce1", + "strokeWidth": 3 + }, + "encoding": { + "detail": { + "field": "Country", + "type": "nominal", + "sort": null + } + } + }, + { + "mark": { + "type": "point", + "filled": true + }, + "encoding": { + "color": { + "field": "Sex", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#051c2c", + "#2251ff" + ] + }, + "legend": null + } + } + }, + { + "__themeSynthetic": true, + "transform": [ + { + "window": [ + { + "op": "row_number", + "as": "__seriesEndRank" + } + ], + "groupby": [ + "Sex" + ] + }, + { + "filter": "datum.__seriesEndRank === 1" + } + ], + "mark": { + "type": "text", + "align": "center", + "baseline": "bottom", + "dx": 0, + "dy": -5, + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10.5 + }, + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": null + }, + "x": { + "field": "Life expectancy", + "type": "quantitative" + }, + "text": { + "field": "Sex", + "type": "nominal" + }, + "color": { + "field": "Sex", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#051c2c", + "#2251ff" + ] + }, + "legend": null + } + } + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelAngle": 0, + "tickCount": 8 + }, + "axisY": { + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelLimit": 0, + "labelAngle": 0 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#5a6872", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "point": { + "size": 64, + "filled": true + }, + "circle": { + "size": 64, + "filled": true + }, + "square": { + "size": 64, + "filled": true + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#5a6872", + "labelFontWeight": "normal", + "title": null + } + }, + "height": { + "step": 49 + }, + "title": { + "text": "Women outlive men everywhere, but not by the same margin", + "subtitle": [ + "Life expectancy at birth by sex, 2021, years" + ] + }, + "background": "#ffffff", + "padding": 20, + "data": { + "values": [ + { + "Country": "Japan", + "Sex": "Male", + "Life expectancy": 81.5 + }, + { + "Country": "Japan", + "Sex": "Female", + "Life expectancy": 87.6 + }, + { + "Country": "United States", + "Sex": "Male", + "Life expectancy": 73.5 + }, + { + "Country": "United States", + "Sex": "Female", + "Life expectancy": 79.3 + }, + { + "Country": "India", + "Sex": "Male", + "Life expectancy": 66 + }, + { + "Country": "India", + "Sex": "Female", + "Life expectancy": 69 + }, + { + "Country": "Brazil", + "Sex": "Male", + "Life expectancy": 69 + }, + { + "Country": "Brazil", + "Sex": "Female", + "Life expectancy": 76 + }, + { + "Country": "Nigeria", + "Sex": "Male", + "Life expectancy": 51 + }, + { + "Country": "Nigeria", + "Sex": "Female", + "Life expectancy": 54 + }, + { + "Country": "Germany", + "Sex": "Male", + "Life expectancy": 78.5 + }, + { + "Country": "Germany", + "Sex": "Female", + "Life expectancy": 83.4 + } + ] + }, + "__theme__": "mckinsey", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.connector", + "message": "the bridge is drawn at 3px in structural ink — the distance it spans is the reading, so it carries a mark's weight and none of a series' colour" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized as a synthesized text layer at each series' last point" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/oecd-facet-16.powerbi.json b/site/src/playground/theme-lab-assets/compiled/oecd-facet-16.powerbi.json new file mode 100644 index 00000000..4a8c72ad --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/oecd-facet-16.powerbi.json @@ -0,0 +1,654 @@ +{ + "config": { + "view": { + "continuousWidth": 67, + "continuousHeight": 75, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 8, + "titleFontSize": 8.5, + "titleFontWeight": "normal", + "titleColor": "#c8c6c4", + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "gridDash": [ + 3, + 3 + ] + }, + "axisY": { + "labelFontSize": 8, + "titleFontSize": 8.5, + "titleFontWeight": "normal", + "titleColor": "#c8c6c4", + "grid": true, + "gridColor": "#323130", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0, + "tickCount": 3 + }, + "legend": { + "labelFontSize": 9, + "titleFontSize": 9 + }, + "headerFacet": { + "labelFontSize": 9, + "labelLimit": 87 + }, + "facet": { + "spacing": { + "row": 14, + "column": 7 + } + }, + "background": "#1b1a19", + "font": "'Segoe UI', system-ui, sans-serif", + "title": { + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 9, + "subtitleFont": "'Segoe UI', system-ui, sans-serif", + "subtitleFontSize": 9.5, + "subtitleColor": "#c8c6c4", + "subtitlePadding": 6, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.2, + "strokeCap": "square" + }, + "trail": { + "size": 2.2 + }, + "rule": { + "strokeCap": "square" + }, + "header": { + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#c8c6c4", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Sixteen labour markets, one shock", + "subtitle": [ + "Harmonised unemployment rate, selected OECD economies, 2000–2023, per cent" + ] + }, + "background": "#1b1a19", + "padding": 8, + "facet": { + "field": "Country", + "type": "nominal", + "sort": null + }, + "columns": 6, + "spec": { + "layer": [ + { + "mark": { + "type": "line", + "color": "#118dff" + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null, + "values": [ + { + "year": 2000, + "utc": true + }, + { + "year": 2007, + "utc": true + }, + { + "year": 2010, + "utc": true + }, + { + "year": 2015, + "utc": true + }, + { + "year": 2023, + "utc": true + } + ], + "format": "%Y" + }, + "scale": { + "type": "utc" + } + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.label + \"%\"" + } + } + } + }, + { + "__themeSynthetic": true, + "transform": [ + { + "window": [ + { + "op": "row_number", + "as": "__peLast" + } + ], + "sort": [ + { + "field": "Year", + "order": "descending" + } + ], + "groupby": [] + }, + { + "filter": "datum.__peLast === 1" + } + ], + "mark": { + "type": "point", + "filled": true, + "size": 30.800000000000004, + "color": "#118dff" + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative" + } + } + } + ] + }, + "data": { + "values": [ + { + "Year": "2000", + "Country": "Australia", + "Unemployment (%)": 6.3 + }, + { + "Year": "2007", + "Country": "Australia", + "Unemployment (%)": 4.4 + }, + { + "Year": "2010", + "Country": "Australia", + "Unemployment (%)": 5.2 + }, + { + "Year": "2015", + "Country": "Australia", + "Unemployment (%)": 6.1 + }, + { + "Year": "2023", + "Country": "Australia", + "Unemployment (%)": 3.7 + }, + { + "Year": "2000", + "Country": "Austria", + "Unemployment (%)": 4.7 + }, + { + "Year": "2007", + "Country": "Austria", + "Unemployment (%)": 4.9 + }, + { + "Year": "2010", + "Country": "Austria", + "Unemployment (%)": 4.8 + }, + { + "Year": "2015", + "Country": "Austria", + "Unemployment (%)": 5.7 + }, + { + "Year": "2023", + "Country": "Austria", + "Unemployment (%)": 5.1 + }, + { + "Year": "2000", + "Country": "Belgium", + "Unemployment (%)": 6.9 + }, + { + "Year": "2007", + "Country": "Belgium", + "Unemployment (%)": 7.5 + }, + { + "Year": "2010", + "Country": "Belgium", + "Unemployment (%)": 8.3 + }, + { + "Year": "2015", + "Country": "Belgium", + "Unemployment (%)": 8.5 + }, + { + "Year": "2023", + "Country": "Belgium", + "Unemployment (%)": 5.5 + }, + { + "Year": "2000", + "Country": "Canada", + "Unemployment (%)": 6.8 + }, + { + "Year": "2007", + "Country": "Canada", + "Unemployment (%)": 6 + }, + { + "Year": "2010", + "Country": "Canada", + "Unemployment (%)": 8 + }, + { + "Year": "2015", + "Country": "Canada", + "Unemployment (%)": 6.9 + }, + { + "Year": "2023", + "Country": "Canada", + "Unemployment (%)": 5.4 + }, + { + "Year": "2000", + "Country": "Denmark", + "Unemployment (%)": 4.3 + }, + { + "Year": "2007", + "Country": "Denmark", + "Unemployment (%)": 3.8 + }, + { + "Year": "2010", + "Country": "Denmark", + "Unemployment (%)": 7.5 + }, + { + "Year": "2015", + "Country": "Denmark", + "Unemployment (%)": 6.2 + }, + { + "Year": "2023", + "Country": "Denmark", + "Unemployment (%)": 5.1 + }, + { + "Year": "2000", + "Country": "Finland", + "Unemployment (%)": 9.8 + }, + { + "Year": "2007", + "Country": "Finland", + "Unemployment (%)": 6.9 + }, + { + "Year": "2010", + "Country": "Finland", + "Unemployment (%)": 8.4 + }, + { + "Year": "2015", + "Country": "Finland", + "Unemployment (%)": 9.4 + }, + { + "Year": "2023", + "Country": "Finland", + "Unemployment (%)": 7.2 + }, + { + "Year": "2000", + "Country": "France", + "Unemployment (%)": 9 + }, + { + "Year": "2007", + "Country": "France", + "Unemployment (%)": 8 + }, + { + "Year": "2010", + "Country": "France", + "Unemployment (%)": 9.3 + }, + { + "Year": "2015", + "Country": "France", + "Unemployment (%)": 10.4 + }, + { + "Year": "2023", + "Country": "France", + "Unemployment (%)": 7.3 + }, + { + "Year": "2000", + "Country": "Germany", + "Unemployment (%)": 7.9 + }, + { + "Year": "2007", + "Country": "Germany", + "Unemployment (%)": 8.7 + }, + { + "Year": "2010", + "Country": "Germany", + "Unemployment (%)": 7 + }, + { + "Year": "2015", + "Country": "Germany", + "Unemployment (%)": 4.6 + }, + { + "Year": "2023", + "Country": "Germany", + "Unemployment (%)": 3.1 + }, + { + "Year": "2000", + "Country": "Ireland", + "Unemployment (%)": 4.2 + }, + { + "Year": "2007", + "Country": "Ireland", + "Unemployment (%)": 4.7 + }, + { + "Year": "2010", + "Country": "Ireland", + "Unemployment (%)": 13.9 + }, + { + "Year": "2015", + "Country": "Ireland", + "Unemployment (%)": 9.9 + }, + { + "Year": "2023", + "Country": "Ireland", + "Unemployment (%)": 4.3 + }, + { + "Year": "2000", + "Country": "Italy", + "Unemployment (%)": 10.1 + }, + { + "Year": "2007", + "Country": "Italy", + "Unemployment (%)": 6.1 + }, + { + "Year": "2010", + "Country": "Italy", + "Unemployment (%)": 8.4 + }, + { + "Year": "2015", + "Country": "Italy", + "Unemployment (%)": 11.9 + }, + { + "Year": "2023", + "Country": "Italy", + "Unemployment (%)": 7.7 + }, + { + "Year": "2000", + "Country": "Japan", + "Unemployment (%)": 4.7 + }, + { + "Year": "2007", + "Country": "Japan", + "Unemployment (%)": 3.8 + }, + { + "Year": "2010", + "Country": "Japan", + "Unemployment (%)": 5.1 + }, + { + "Year": "2015", + "Country": "Japan", + "Unemployment (%)": 3.4 + }, + { + "Year": "2023", + "Country": "Japan", + "Unemployment (%)": 2.6 + }, + { + "Year": "2000", + "Country": "Netherlands", + "Unemployment (%)": 3.1 + }, + { + "Year": "2007", + "Country": "Netherlands", + "Unemployment (%)": 3.6 + }, + { + "Year": "2010", + "Country": "Netherlands", + "Unemployment (%)": 5 + }, + { + "Year": "2015", + "Country": "Netherlands", + "Unemployment (%)": 6.9 + }, + { + "Year": "2023", + "Country": "Netherlands", + "Unemployment (%)": 3.6 + }, + { + "Year": "2000", + "Country": "Norway", + "Unemployment (%)": 3.2 + }, + { + "Year": "2007", + "Country": "Norway", + "Unemployment (%)": 2.5 + }, + { + "Year": "2010", + "Country": "Norway", + "Unemployment (%)": 3.6 + }, + { + "Year": "2015", + "Country": "Norway", + "Unemployment (%)": 4.4 + }, + { + "Year": "2023", + "Country": "Norway", + "Unemployment (%)": 3.6 + }, + { + "Year": "2000", + "Country": "Spain", + "Unemployment (%)": 13.9 + }, + { + "Year": "2007", + "Country": "Spain", + "Unemployment (%)": 8.2 + }, + { + "Year": "2010", + "Country": "Spain", + "Unemployment (%)": 19.9 + }, + { + "Year": "2015", + "Country": "Spain", + "Unemployment (%)": 22.1 + }, + { + "Year": "2023", + "Country": "Spain", + "Unemployment (%)": 12.2 + }, + { + "Year": "2000", + "Country": "Sweden", + "Unemployment (%)": 5.6 + }, + { + "Year": "2007", + "Country": "Sweden", + "Unemployment (%)": 6.1 + }, + { + "Year": "2010", + "Country": "Sweden", + "Unemployment (%)": 8.6 + }, + { + "Year": "2015", + "Country": "Sweden", + "Unemployment (%)": 7.4 + }, + { + "Year": "2023", + "Country": "Sweden", + "Unemployment (%)": 7.7 + }, + { + "Year": "2000", + "Country": "United States", + "Unemployment (%)": 4 + }, + { + "Year": "2007", + "Country": "United States", + "Unemployment (%)": 4.6 + }, + { + "Year": "2010", + "Country": "United States", + "Unemployment (%)": 9.6 + }, + { + "Year": "2015", + "Country": "United States", + "Unemployment (%)": 5.3 + }, + { + "Year": "2023", + "Country": "United States", + "Unemployment (%)": 3.6 + } + ] + }, + "__theme__": "powerbi", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 8.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 5 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 8.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the latest reading carries a dot — the house marks where the line lands" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/oecd-unemployment-facet.economist.json b/site/src/playground/theme-lab-assets/compiled/oecd-unemployment-facet.economist.json new file mode 100644 index 00000000..b53bdf00 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/oecd-unemployment-facet.economist.json @@ -0,0 +1,309 @@ +{ + "background": "#ffffff", + "padding": 8, + "title": { + "text": "Out of work", + "subtitle": [ + "Unemployment rate, %, 2000–2022" + ] + }, + "spacing": 6, + "vconcat": [ + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#e3120b" + }, + "width": 26, + "height": 3, + "data": { + "values": [ + {} + ] + } + }, + { + "mark": { + "type": "line", + "color": "#006ba2" + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null + } + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.label + \"%\"" + } + }, + "facet": { + "field": "Country", + "type": "nominal", + "sort": null, + "columns": 4 + } + } + } + ], + "config": { + "view": { + "continuousWidth": 105, + "continuousHeight": 155, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 8, + "titleFontSize": 8.5, + "titleFontWeight": "normal", + "titleColor": "#54585a", + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121317", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif" + }, + "axisY": { + "labelFontSize": 8, + "titleFontSize": 8.5, + "titleFontWeight": "normal", + "titleColor": "#54585a", + "grid": true, + "gridColor": "#d8dfe4", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelLimit": 0, + "tickCount": 3 + }, + "legend": { + "labelFontSize": 9, + "titleFontSize": 9 + }, + "headerFacet": { + "labelLimit": 125 + }, + "facet": { + "spacing": { + "row": 14, + "column": 11 + } + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 11, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#54585a", + "subtitlePadding": 6, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.6 + }, + "trail": { + "size": 1.6 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#54585a", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Year": "2000", + "Country": "United States", + "Unemployment (%)": 4 + }, + { + "Year": "2005", + "Country": "United States", + "Unemployment (%)": 5.1 + }, + { + "Year": "2010", + "Country": "United States", + "Unemployment (%)": 9.6 + }, + { + "Year": "2015", + "Country": "United States", + "Unemployment (%)": 5.3 + }, + { + "Year": "2020", + "Country": "United States", + "Unemployment (%)": 8.1 + }, + { + "Year": "2022", + "Country": "United States", + "Unemployment (%)": 3.6 + }, + { + "Year": "2000", + "Country": "Germany", + "Unemployment (%)": 7.9 + }, + { + "Year": "2005", + "Country": "Germany", + "Unemployment (%)": 11.2 + }, + { + "Year": "2010", + "Country": "Germany", + "Unemployment (%)": 7 + }, + { + "Year": "2015", + "Country": "Germany", + "Unemployment (%)": 4.6 + }, + { + "Year": "2020", + "Country": "Germany", + "Unemployment (%)": 3.7 + }, + { + "Year": "2022", + "Country": "Germany", + "Unemployment (%)": 3.1 + }, + { + "Year": "2000", + "Country": "Japan", + "Unemployment (%)": 4.7 + }, + { + "Year": "2005", + "Country": "Japan", + "Unemployment (%)": 4.4 + }, + { + "Year": "2010", + "Country": "Japan", + "Unemployment (%)": 5.1 + }, + { + "Year": "2015", + "Country": "Japan", + "Unemployment (%)": 3.4 + }, + { + "Year": "2020", + "Country": "Japan", + "Unemployment (%)": 2.8 + }, + { + "Year": "2022", + "Country": "Japan", + "Unemployment (%)": 2.6 + }, + { + "Year": "2000", + "Country": "Spain", + "Unemployment (%)": 13.9 + }, + { + "Year": "2005", + "Country": "Spain", + "Unemployment (%)": 9.2 + }, + { + "Year": "2010", + "Country": "Spain", + "Unemployment (%)": 19.9 + }, + { + "Year": "2015", + "Country": "Spain", + "Unemployment (%)": 22.1 + }, + { + "Year": "2020", + "Country": "Spain", + "Unemployment (%)": 15.5 + }, + { + "Year": "2022", + "Country": "Spain", + "Unemployment (%)": 12.9 + } + ] + }, + "__theme__": "economist", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 8.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 8px and the house's 8.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/olympic-bump.nyt.json b/site/src/playground/theme-lab-assets/compiled/olympic-bump.nyt.json new file mode 100644 index 00000000..c447367c --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/olympic-bump.nyt.json @@ -0,0 +1,377 @@ +{ + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#ececec", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 4 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, serif", + "fontSize": 16, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "Georgia, serif", + "subtitleFontSize": 12, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + }, + "trail": { + "size": 2.4 + }, + "rule": { + "strokeCap": "round" + }, + "header": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Four Games, four different stories", + "subtitle": [ + "Rank in the Summer Olympics medal table, 2012–2024" + ] + }, + "background": "#ffffff", + "padding": { + "left": 12, + "right": 92, + "top": 12, + "bottom": 12 + }, + "layer": [ + { + "mark": { + "type": "line", + "point": { + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "interpolate": "linear", + "strokeWidth": 2 + }, + "encoding": { + "x": { + "field": "Games", + "type": "temporal", + "scale": { + "padding": 10, + "type": "utc" + }, + "axis": { + "title": null, + "values": [ + { + "year": 2012, + "utc": true + }, + { + "year": 2016, + "utc": true + }, + { + "year": 2020, + "utc": true + }, + { + "year": 2024, + "utc": true + } + ], + "format": "%Y" + } + }, + "y": { + "field": "Rank", + "type": "quantitative", + "scale": { + "reverse": true, + "domain": [ + 1, + 7 + ], + "zero": false, + "nice": false, + "padding": 14 + }, + "axis": { + "values": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "tickCount": 7, + "format": ",.12~g", + "title": null + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#2f6b9a", + "#c2352b", + "#4a8b6f", + "#7f6a9e" + ] + }, + "legend": null + } + } + }, + { + "__themeSynthetic": true, + "transform": [ + { + "window": [ + { + "op": "row_number", + "as": "__seriesEndRank" + } + ], + "sort": [ + { + "field": "Games", + "order": "descending" + } + ], + "groupby": [ + "Country" + ] + }, + { + "filter": "datum.__seriesEndRank === 1" + } + ], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 5, + "dy": 0, + "font": "Helvetica, Arial, sans-serif", + "fontSize": 10 + }, + "encoding": { + "x": { + "field": "Games", + "type": "temporal" + }, + "y": { + "field": "Rank", + "type": "quantitative" + }, + "text": { + "field": "Country", + "type": "nominal" + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#2f6b9a", + "#c2352b", + "#4a8b6f", + "#7f6a9e" + ] + }, + "legend": null + } + } + } + ], + "data": { + "values": [ + { + "Games": "2012", + "Country": "United States", + "Rank": 1 + }, + { + "Games": "2016", + "Country": "United States", + "Rank": 1 + }, + { + "Games": "2020", + "Country": "United States", + "Rank": 1 + }, + { + "Games": "2024", + "Country": "United States", + "Rank": 1 + }, + { + "Games": "2012", + "Country": "China", + "Rank": 2 + }, + { + "Games": "2016", + "Country": "China", + "Rank": 3 + }, + { + "Games": "2020", + "Country": "China", + "Rank": 2 + }, + { + "Games": "2024", + "Country": "China", + "Rank": 2 + }, + { + "Games": "2012", + "Country": "Great Britain", + "Rank": 3 + }, + { + "Games": "2016", + "Country": "Great Britain", + "Rank": 2 + }, + { + "Games": "2020", + "Country": "Great Britain", + "Rank": 4 + }, + { + "Games": "2024", + "Country": "Great Britain", + "Rank": 7 + }, + { + "Games": "2012", + "Country": "Japan", + "Rank": 6 + }, + { + "Games": "2016", + "Country": "Japan", + "Rank": 6 + }, + { + "Games": "2020", + "Country": "Japan", + "Rank": 5 + }, + { + "Games": "2024", + "Country": "Japan", + "Rank": 3 + } + ] + }, + "__theme__": "nyt", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "chartDefaults.Bump Chart.interpolate", + "message": "house rule: `interpolate` set to \"linear\"" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "ground", + "path": "marks.point.halo", + "message": "the dots carry a ring of the page around them — the lines cross, and at a crossing the ring is the only thing that says which line a dot sits on" + }, + { + "stage": "ground", + "path": "marks.redundantEncoding", + "message": "`whenNeeded` withheld — the house has a distinct ink for every series" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 4 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized as a synthesized text layer at each series' last point" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/penguins-box.nature.json b/site/src/playground/theme-lab-assets/compiled/penguins-box.nature.json new file mode 100644 index 00000000..ff1f6a4e --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/penguins-box.nature.json @@ -0,0 +1,377 @@ +{ + "layer": [ + { + "transform": [ + { + "calculate": "(random() * 2 - 1) * 0.30000", + "as": "__off" + } + ], + "mark": { + "type": "point", + "filled": true, + "size": 25, + "opacity": 0.7, + "stroke": "#ffffff", + "strokeWidth": 0.5, + "color": "#0072b2" + }, + "encoding": { + "x": { + "field": "Species", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Body mass (g)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "xOffset": { + "field": "__off", + "type": "quantitative", + "scale": { + "domain": [ + -0.5, + 0.5 + ] + }, + "axis": null + } + } + }, + { + "mark": { + "type": "boxplot", + "outliers": false, + "box": { + "filled": false, + "strokeWidth": 1.5 + }, + "median": { + "color": "#000000", + "strokeWidth": 2, + "opacity": 1 + }, + "size": 21, + "color": "#000000" + }, + "encoding": { + "x": { + "field": "Species", + "type": "nominal", + "sort": null, + "scale": { + "paddingInner": 0.44999999999999996 + } + }, + "y": { + "field": "Body mass (g)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + } + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 11, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelAngle": 0 + }, + "axisY": { + "labelFontSize": 11, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 5 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 13, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 12, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 11, + "subtitleFontStyle": "italic", + "subtitleColor": "#000000", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.2, + "point": { + "filled": true, + "size": 45, + "stroke": "#ffffff", + "strokeWidth": 0.6 + } + }, + "trail": { + "size": 1.2 + }, + "point": { + "size": 45, + "filled": true + }, + "circle": { + "size": 45, + "filled": true + }, + "square": { + "size": 45, + "filled": true + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 11, + "labelColor": "#000000", + "labelFontWeight": "normal", + "title": null + } + }, + "width": { + "step": 52 + }, + "title": { + "text": "Body mass of three Pygoscelis species", + "subtitle": [ + "Boxes show median and interquartile range; whiskers span the full sample; points are individual birds" + ] + }, + "background": "#ffffff", + "padding": 8, + "data": { + "values": [ + { + "Species": "Adelie", + "Body mass (g)": 3750 + }, + { + "Species": "Adelie", + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Body mass (g)": 3250 + }, + { + "Species": "Adelie", + "Body mass (g)": 3450 + }, + { + "Species": "Adelie", + "Body mass (g)": 3650 + }, + { + "Species": "Adelie", + "Body mass (g)": 3625 + }, + { + "Species": "Adelie", + "Body mass (g)": 4675 + }, + { + "Species": "Adelie", + "Body mass (g)": 3200 + }, + { + "Species": "Adelie", + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Body mass (g)": 4400 + }, + { + "Species": "Adelie", + "Body mass (g)": 3700 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3500 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3900 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3650 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3525 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3950 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3800 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3300 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 4800 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 4050 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4500 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4450 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5400 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4800 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5000 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4650 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5950 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5250 + } + ] + }, + "__theme__": "nature", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "chartDefaults.Boxplot.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~52px and the band is 52px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.summary.widthFraction", + "message": "the house fills 40% of the band with the box — 21px of a 52px band" + }, + { + "stage": "realize", + "path": "ink.series", + "message": "the box is hollow because the observations are drawn through it — the outline is scaffolding and takes the text ink, not the series ink" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/penguins-violin.nature.json b/site/src/playground/theme-lab-assets/compiled/penguins-violin.nature.json new file mode 100644 index 00000000..0bfcf2bc --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/penguins-violin.nature.json @@ -0,0 +1,570 @@ +{ + "facet": { + "field": "Species", + "type": "nominal", + "sort": null, + "spacing": 0, + "header": { + "titleOrient": "bottom", + "labelOrient": "bottom", + "labelPadding": 2 + } + }, + "columns": 3, + "spec": { + "layer": [ + { + "transform": [ + { + "density": "Body mass (g)", + "groupby": [ + "Species" + ], + "as": [ + "value", + "density" + ], + "extent": [ + 2702.9783569331094, + 6447.021643066891 + ] + }, + { + "calculate": "datum.density / 2", + "as": "__violinHalf" + }, + { + "calculate": "-datum.density / 2", + "as": "__violinNegHalf" + } + ], + "mark": { + "type": "area", + "orient": "horizontal", + "fillOpacity": 0.35, + "color": "#0072b2" + }, + "encoding": { + "y": { + "field": "value", + "type": "quantitative", + "title": "Body mass (g)", + "axis": { + "format": ",.12~g" + } + }, + "x": { + "type": "quantitative", + "title": null, + "axis": { + "labels": false, + "format": ",.12~g" + }, + "field": "__violinHalf", + "stack": null + }, + "x2": { + "field": "__violinNegHalf" + } + } + }, + { + "transform": [ + { + "density": "Body mass (g)", + "groupby": [ + "Species" + ], + "as": [ + "value", + "density" + ], + "extent": [ + 2702.9783569331094, + 6447.021643066891 + ] + }, + { + "calculate": "datum.density / 2", + "as": "__violinHalf" + }, + { + "calculate": "-datum.density / 2", + "as": "__violinNegHalf" + } + ], + "mark": { + "type": "line", + "orient": "horizontal", + "strokeWidth": 1, + "opacity": 0.9, + "point": false, + "color": "#0072b2" + }, + "encoding": { + "y": { + "field": "value", + "type": "quantitative", + "title": "Body mass (g)", + "axis": { + "format": ",.12~g" + } + }, + "x": { + "type": "quantitative", + "title": null, + "axis": { + "labels": false, + "format": ",.12~g" + }, + "field": "__violinHalf", + "stack": null + } + } + }, + { + "transform": [ + { + "density": "Body mass (g)", + "groupby": [ + "Species" + ], + "as": [ + "value", + "density" + ], + "extent": [ + 2702.9783569331094, + 6447.021643066891 + ] + }, + { + "calculate": "datum.density / 2", + "as": "__violinHalf" + }, + { + "calculate": "-datum.density / 2", + "as": "__violinNegHalf" + } + ], + "mark": { + "type": "line", + "orient": "horizontal", + "strokeWidth": 1, + "opacity": 0.9, + "point": false, + "color": "#0072b2" + }, + "encoding": { + "y": { + "field": "value", + "type": "quantitative", + "title": "Body mass (g)", + "axis": { + "format": ",.12~g" + } + }, + "x": { + "type": "quantitative", + "title": null, + "axis": { + "labels": false, + "format": ",.12~g" + }, + "field": "__violinNegHalf", + "stack": null + } + } + }, + { + "transform": [ + { + "calculate": "(random() - 0.5) * 0.0003017977226794782", + "as": "__violinJitter" + } + ], + "mark": { + "type": "point", + "filled": true, + "size": 16, + "opacity": 0.9, + "color": "#0072b2" + }, + "encoding": { + "y": { + "field": "Body mass (g)", + "type": "quantitative", + "title": "Body mass (g)", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "x": { + "field": "__violinJitter", + "type": "quantitative", + "title": null, + "axis": null, + "stack": null + } + } + }, + { + "transform": [ + { + "aggregate": [ + { + "op": "median", + "field": "Body mass (g)", + "as": "__violinMedian" + } + ], + "groupby": [ + "Species" + ] + } + ], + "mark": { + "type": "rule", + "strokeWidth": 1.5 + }, + "encoding": { + "y": { + "field": "__violinMedian", + "type": "quantitative", + "title": "Body mass (g)", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "x": { + "datum": -0.0003621572672153738, + "type": "quantitative", + "axis": { + "format": ",.12~g" + } + }, + "x2": { + "datum": 0.0003621572672153738 + } + } + } + ], + "encoding": { + "color": { + "field": "Species", + "type": "nominal", + "legend": null, + "scale": { + "range": [ + "#0072b2", + "#e69f00", + "#009e73" + ] + } + } + }, + "width": 93, + "height": 160 + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 11, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 11, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 5 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 13, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 12, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 11, + "subtitleFontStyle": "italic", + "subtitleColor": "#000000", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.2, + "point": { + "filled": true, + "size": 45, + "stroke": "#ffffff", + "strokeWidth": 0.6 + } + }, + "trail": { + "size": 1.2 + }, + "point": { + "size": 45, + "filled": true + }, + "circle": { + "size": 45, + "filled": true + }, + "square": { + "size": 45, + "filled": true + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 11, + "labelColor": "#000000", + "labelFontWeight": "normal", + "title": null + } + }, + "width": { + "step": 52 + }, + "title": { + "text": "Body mass by penguin species", + "subtitle": [ + "Kernel density with all observations overlaid; horizontal rule marks the median" + ] + }, + "background": "#ffffff", + "padding": 8, + "data": { + "values": [ + { + "Species": "Adelie", + "Body mass (g)": 3750 + }, + { + "Species": "Adelie", + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Body mass (g)": 3250 + }, + { + "Species": "Adelie", + "Body mass (g)": 3450 + }, + { + "Species": "Adelie", + "Body mass (g)": 3650 + }, + { + "Species": "Adelie", + "Body mass (g)": 3625 + }, + { + "Species": "Adelie", + "Body mass (g)": 4675 + }, + { + "Species": "Adelie", + "Body mass (g)": 3200 + }, + { + "Species": "Adelie", + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Body mass (g)": 4400 + }, + { + "Species": "Adelie", + "Body mass (g)": 3700 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3500 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3900 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3650 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3525 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3950 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3800 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3300 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 4800 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 4050 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4500 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4450 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5400 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4800 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5000 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4650 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5950 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5250 + } + ] + }, + "__theme__": "nature", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "chartDefaults.Violin Plot.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "chartDefaults.Violin Plot.showMedian", + "message": "house rule: `showMedian` set to true" + }, + { + "stage": "ground", + "path": "chartDefaults.Violin Plot.showContour", + "message": "house rule: `showContour` set to true" + }, + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "legend.suppressWhenAxisNames", + "message": "legend removed — it restated the categorical axis" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.redundantEncoding", + "message": "no mark in this chart can carry a redundant channel — colour is on its own" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/penguins.nature.json b/site/src/playground/theme-lab-assets/compiled/penguins.nature.json new file mode 100644 index 00000000..e0ca97d4 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/penguins.nature.json @@ -0,0 +1,351 @@ +{ + "mark": { + "type": "point" + }, + "encoding": { + "x": { + "field": "Flipper length (mm)", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Body mass (g)", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Species", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + "#0072b2", + "#e69f00", + "#009e73" + ] + } + }, + "shape": { + "field": "Species", + "type": "nominal" + } + }, + "config": { + "view": { + "continuousWidth": 306, + "continuousHeight": 256, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "tickCount": 7 + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 6 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "right", + "direction": "vertical", + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#8c8c8c", + "title": null + }, + "facet": { + "spacing": 26 + }, + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 12.5, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 11, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 10.5, + "subtitleFontStyle": "italic", + "subtitleColor": "#000000", + "subtitlePadding": 6, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.2, + "point": { + "filled": true, + "size": 45, + "stroke": "#ffffff", + "strokeWidth": 0.6 + } + }, + "trail": { + "size": 1.2 + }, + "point": { + "size": 45, + "filled": true + }, + "circle": { + "size": 45, + "filled": true + }, + "square": { + "size": 45, + "filled": true + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#000000", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Flipper length versus body mass in three penguin species", + "subtitle": [ + "Palmer Archipelago, Antarctica; n = 33" + ] + }, + "background": "#ffffff", + "padding": 8, + "data": { + "values": [ + { + "Species": "Adelie", + "Flipper length (mm)": 181, + "Body mass (g)": 3750 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 186, + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 195, + "Body mass (g)": 3250 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 193, + "Body mass (g)": 3450 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 190, + "Body mass (g)": 3650 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 181, + "Body mass (g)": 3625 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 195, + "Body mass (g)": 4675 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 182, + "Body mass (g)": 3200 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 191, + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 198, + "Body mass (g)": 4400 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 185, + "Body mass (g)": 3700 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 192, + "Body mass (g)": 3500 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 196, + "Body mass (g)": 3900 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 193, + "Body mass (g)": 3650 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 188, + "Body mass (g)": 3525 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 197, + "Body mass (g)": 3950 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 198, + "Body mass (g)": 3800 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 178, + "Body mass (g)": 3300 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 207, + "Body mass (g)": 4800 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 201, + "Body mass (g)": 4050 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 191, + "Body mass (g)": 3550 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 211, + "Body mass (g)": 4500 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 230, + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 210, + "Body mass (g)": 4450 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 218, + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 215, + "Body mass (g)": 5400 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 219, + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 209, + "Body mass (g)": 4800 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 215, + "Body mass (g)": 5000 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 214, + "Body mass (g)": 4650 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 216, + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 221, + "Body mass (g)": 5950 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 217, + "Body mass (g)": 5250 + } + ] + }, + "__theme__": "nature", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/population-region.datawrapper.json b/site/src/playground/theme-lab-assets/compiled/population-region.datawrapper.json new file mode 100644 index 00000000..ca7e1216 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/population-region.datawrapper.json @@ -0,0 +1,356 @@ +{ + "background": "#ffffff", + "padding": 12, + "title": { + "text": "World population by region", + "subtitle": [ + "1950–2020, millions of people" + ] + }, + "spacing": 6, + "vconcat": [ + { + "mark": "area", + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null, + "values": [ + { + "year": 1950, + "utc": true + }, + { + "year": 1970, + "utc": true + }, + { + "year": 1990, + "utc": true + }, + { + "year": 2010, + "utc": true + }, + { + "year": 2020, + "utc": true + } + ], + "format": "%Y" + }, + "scale": { + "type": "utc" + } + }, + "y": { + "field": "Population", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null + } + }, + "color": { + "field": "Region", + "type": "nominal", + "sort": null, + "scale": { + "domain": [ + "Asia", + "Africa", + "Europe", + "Americas", + "Oceania" + ], + "range": [ + "#18a1cd", + "#e2a233", + "#c04a4a", + "#2d8659", + "#7e5aa2" + ] + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#dcdcdc" + }, + "width": 330, + "height": 1, + "data": { + "values": [ + {} + ] + } + } + ], + "resolve": { + "legend": { + "color": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#333333", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ] + }, + "axisY": { + "labelFontSize": 12, + "titleFontSize": 11, + "grid": true, + "gridColor": "#e6e6e6", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0, + "tickCount": 6 + }, + "legend": { + "labelFontSize": 12, + "titleFontSize": 12, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#999999", + "gradientLength": 149, + "title": null + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 12, + "labelColor": "#666666", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Year": "1950", + "Region": "Asia", + "Population": 1404 + }, + { + "Year": "1950", + "Region": "Africa", + "Population": 227 + }, + { + "Year": "1950", + "Region": "Europe", + "Population": 549 + }, + { + "Year": "1950", + "Region": "Americas", + "Population": 339 + }, + { + "Year": "1950", + "Region": "Oceania", + "Population": 13 + }, + { + "Year": "1970", + "Region": "Asia", + "Population": 2142 + }, + { + "Year": "1970", + "Region": "Africa", + "Population": 365 + }, + { + "Year": "1970", + "Region": "Europe", + "Population": 657 + }, + { + "Year": "1970", + "Region": "Americas", + "Population": 512 + }, + { + "Year": "1970", + "Region": "Oceania", + "Population": 20 + }, + { + "Year": "1990", + "Region": "Asia", + "Population": 3226 + }, + { + "Year": "1990", + "Region": "Africa", + "Population": 630 + }, + { + "Year": "1990", + "Region": "Europe", + "Population": 721 + }, + { + "Year": "1990", + "Region": "Americas", + "Population": 724 + }, + { + "Year": "1990", + "Region": "Oceania", + "Population": 27 + }, + { + "Year": "2010", + "Region": "Asia", + "Population": 4194 + }, + { + "Year": "2010", + "Region": "Africa", + "Population": 1039 + }, + { + "Year": "2010", + "Region": "Europe", + "Population": 736 + }, + { + "Year": "2010", + "Region": "Americas", + "Population": 934 + }, + { + "Year": "2010", + "Region": "Oceania", + "Population": 37 + }, + { + "Year": "2020", + "Region": "Asia", + "Population": 4641 + }, + { + "Year": "2020", + "Region": "Africa", + "Population": 1361 + }, + { + "Year": "2020", + "Region": "Europe", + "Population": 748 + }, + { + "Year": "2020", + "Region": "Americas", + "Population": 1023 + }, + { + "Year": "2020", + "Region": "Oceania", + "Population": 45 + } + ] + }, + "__theme__": "datawrapper", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 5 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 149px — under half the 330px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 330px — not a fixed stub" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/population-stream.nyt.json b/site/src/playground/theme-lab-assets/compiled/population-stream.nyt.json new file mode 100644 index 00000000..6835645d --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/population-stream.nyt.json @@ -0,0 +1,415 @@ +{ + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#ececec", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 4 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, serif", + "fontSize": 16, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "Georgia, serif", + "subtitleFontSize": 12, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + }, + "trail": { + "size": 2.4 + }, + "rule": { + "strokeCap": "round" + }, + "header": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Where the world's people are", + "subtitle": [ + "Population by region, 1950–2020, millions" + ] + }, + "background": "#ffffff", + "padding": { + "left": 12, + "right": 108, + "top": 12, + "bottom": 12 + }, + "layer": [ + { + "mark": "area", + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null, + "values": [ + { + "year": 1950, + "utc": true + }, + { + "year": 1970, + "utc": true + }, + { + "year": 1990, + "utc": true + }, + { + "year": 2010, + "utc": true + }, + { + "year": 2020, + "utc": true + } + ], + "format": "%Y" + }, + "scale": { + "type": "utc" + } + }, + "y": { + "field": "Population", + "type": "quantitative", + "stack": "center", + "axis": null, + "scale": { + "zero": true + } + }, + "color": { + "field": "Region", + "type": "nominal", + "sort": null, + "scale": { + "domain": [ + "Asia", + "Africa", + "Europe", + "Americas", + "Oceania" + ], + "range": [ + "#2f6b9a", + "#c2352b", + "#4a8b6f", + "#7f6a9e", + "#d9a441" + ] + }, + "legend": null + } + } + }, + { + "__themeSynthetic": true, + "transform": [ + { + "window": [ + { + "op": "row_number", + "as": "__bandEndRank" + } + ], + "sort": [ + { + "field": "Year", + "order": "descending" + } + ], + "groupby": [ + "Region" + ] + }, + { + "filter": "datum.__bandEndRank === 1" + }, + { + "calculate": "indexof([\"Asia\",\"Africa\",\"Europe\",\"Americas\",\"Oceania\"], datum[\"Region\"] + '') >= 0 ? datum[\"Region\"] + ' ' + format(datum[\"Population\"], \"~s\") : ''", + "as": "__bandEndLabel" + } + ], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 6, + "font": "Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": "bold" + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Population", + "type": "quantitative", + "stack": "center", + "bandPosition": 0.5 + }, + "text": { + "field": "__bandEndLabel", + "type": "nominal" + }, + "color": { + "field": "Region", + "type": "nominal", + "sort": null, + "scale": { + "domain": [ + "Asia", + "Africa", + "Europe", + "Americas", + "Oceania" + ], + "range": [ + "#2f6b9a", + "#c2352b", + "#4a8b6f", + "#7f6a9e", + "#d9a441" + ] + }, + "legend": null + } + } + } + ], + "resolve": { + "scale": { + "color": "independent" + } + }, + "data": { + "values": [ + { + "Year": "1950", + "Region": "Asia", + "Population": 1404 + }, + { + "Year": "1950", + "Region": "Africa", + "Population": 227 + }, + { + "Year": "1950", + "Region": "Europe", + "Population": 549 + }, + { + "Year": "1950", + "Region": "Americas", + "Population": 339 + }, + { + "Year": "1950", + "Region": "Oceania", + "Population": 13 + }, + { + "Year": "1970", + "Region": "Asia", + "Population": 2142 + }, + { + "Year": "1970", + "Region": "Africa", + "Population": 365 + }, + { + "Year": "1970", + "Region": "Europe", + "Population": 657 + }, + { + "Year": "1970", + "Region": "Americas", + "Population": 512 + }, + { + "Year": "1970", + "Region": "Oceania", + "Population": 20 + }, + { + "Year": "1990", + "Region": "Asia", + "Population": 3226 + }, + { + "Year": "1990", + "Region": "Africa", + "Population": 630 + }, + { + "Year": "1990", + "Region": "Europe", + "Population": 721 + }, + { + "Year": "1990", + "Region": "Americas", + "Population": 724 + }, + { + "Year": "1990", + "Region": "Oceania", + "Population": 27 + }, + { + "Year": "2010", + "Region": "Asia", + "Population": 4194 + }, + { + "Year": "2010", + "Region": "Africa", + "Population": 1039 + }, + { + "Year": "2010", + "Region": "Europe", + "Population": 736 + }, + { + "Year": "2010", + "Region": "Americas", + "Population": 934 + }, + { + "Year": "2010", + "Region": "Oceania", + "Population": 37 + }, + { + "Year": "2020", + "Region": "Asia", + "Population": 4641 + }, + { + "Year": "2020", + "Region": "Africa", + "Population": 1361 + }, + { + "Year": "2020", + "Region": "Europe", + "Population": 748 + }, + { + "Year": "2020", + "Region": "Americas", + "Population": 1023 + }, + { + "Year": "2020", + "Region": "Oceania", + "Population": 45 + } + ] + }, + "__theme__": "nyt", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "the segments are stacked — a value at a segment edge would read as the running total" + }, + { + "stage": "ground", + "path": "marks.redundantEncoding", + "message": "`whenNeeded` withheld — the house has a distinct ink for every series" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 5 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "the bands climb away from their own labels — the names sit outside the plot in series ink, as a list" + }, + { + "stage": "realize", + "path": "legend.placement", + "message": "`seriesEnd` realized inside each band at its last reading — a name in the band beats a swatch beside the chart" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/population-waterfall.mckinsey.json b/site/src/playground/theme-lab-assets/compiled/population-waterfall.mckinsey.json new file mode 100644 index 00000000..f17db8eb --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/population-waterfall.mckinsey.json @@ -0,0 +1,341 @@ +{ + "encoding": { + "x": { + "field": "Step", + "type": "ordinal", + "sort": null, + "axis": { + "labelAngle": -45, + "title": null + }, + "scale": { + "paddingInner": 0.4 + } + } + }, + "transform": [ + { + "window": [ + { + "op": "row_number", + "as": "__wf_row" + } + ] + }, + { + "joinaggregate": [ + { + "op": "count", + "as": "__wf_total" + } + ] + }, + { + "calculate": "datum.__wf_row === datum.__wf_total ? 'end' : 'delta'", + "as": "__wf_type" + }, + { + "window": [ + { + "op": "sum", + "field": "Population (M)", + "as": "__wf_sum_raw" + } + ] + }, + { + "calculate": "datum['__wf_type'] === 'end' ? datum.__wf_sum_raw - datum['Population (M)'] : datum.__wf_sum_raw", + "as": "__wf_sum" + }, + { + "calculate": "datum['__wf_type'] === 'end' ? 0 : datum.__wf_sum - datum['Population (M)']", + "as": "__wf_prev_sum" + }, + { + "calculate": "(datum['__wf_type'] === 'start' || datum['__wf_type'] === 'end') ? 'total' : datum['Population (M)'] >= 0 ? 'increase' : 'decrease'", + "as": "__wf_color" + }, + { + "window": [ + { + "op": "lead", + "field": "Step", + "as": "__wf_lead" + } + ] + }, + { + "calculate": "datum.__wf_lead === null ? datum['Step'] : datum.__wf_lead", + "as": "__wf_lead" + }, + { + "calculate": "datum.__wf_lead === datum['Step'] ? null : datum.__wf_sum", + "as": "__wf_connector_y" + } + ], + "layer": [ + { + "mark": { + "type": "bar", + "stroke": "#ffffff", + "strokeWidth": 0.6 + }, + "encoding": { + "y": { + "field": "__wf_prev_sum", + "type": "quantitative", + "title": "Population (M)", + "axis": { + "format": ",.12~g", + "title": null + } + }, + "y2": { + "field": "__wf_sum" + }, + "color": { + "field": "__wf_color", + "type": "nominal", + "scale": { + "domain": [ + "total", + "increase", + "decrease" + ], + "range": [ + "#051c2c", + "#2251ff", + "#00a9f4", + "#00cfb4", + "#8c9ba5" + ] + }, + "legend": { + "title": "Type" + } + } + } + }, + { + "mark": { + "type": "rule", + "color": "#d3dce1", + "opacity": 0.7, + "strokeWidth": 0.8 + }, + "encoding": { + "x": { + "field": "Step", + "type": "ordinal", + "sort": null, + "bandPosition": 0, + "axis": { + "title": null + } + }, + "x2": { + "field": "__wf_lead", + "bandPosition": 1 + }, + "y": { + "field": "__wf_connector_y", + "type": "quantitative", + "axis": { + "format": ",.12~g", + "title": null + } + } + } + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelAngle": 0 + }, + "axisY": { + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelLimit": 0, + "labelAngle": 0, + "tickCount": 5 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#8a969d", + "gradientLength": 200, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#5a6872", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "point": { + "size": 64, + "filled": true + }, + "circle": { + "size": 64, + "filled": true + }, + "square": { + "size": 64, + "filled": true + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#5a6872", + "labelFontWeight": "normal", + "title": null + } + }, + "width": { + "step": 71 + }, + "title": { + "text": "Asia added more people than the world held in 1950", + "subtitle": [ + "Contribution to world population growth by region, 1950–2020, millions" + ] + }, + "background": "#ffffff", + "padding": 20, + "data": { + "values": [ + { + "Step": "1950", + "Population (M)": 2536 + }, + { + "Step": "Asia", + "Population (M)": 3237 + }, + { + "Step": "Africa", + "Population (M)": 1134 + }, + { + "Step": "Americas", + "Population (M)": 684 + }, + { + "Step": "Europe", + "Population (M)": 199 + }, + { + "Step": "Oceania", + "Population (M)": 32 + } + ] + }, + "__theme__": "mckinsey", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "ink.series", + "message": "`__wf_color` is created by a backend transform — the whole categorical set is offered rather than guessing a count" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "the segments are stacked — a value at a segment edge would read as the running total" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.connector", + "message": "the lead line is drawn at 0.8px in structural ink — it runs across the categories at one level, and the two mark ends it touches already state that level" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 200px — under half the 456px block, so the key stays a caption to the chart" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/population.mckinsey.json b/site/src/playground/theme-lab-assets/compiled/population.mckinsey.json new file mode 100644 index 00000000..4576b1ab --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/population.mckinsey.json @@ -0,0 +1,303 @@ +{ + "config": { + "view": { + "continuousWidth": 314, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labels": false, + "labelAngle": 0, + "tickCount": 7 + }, + "axisY": { + "labelFontSize": 12, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelLimit": 0, + "labelAngle": 0 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#5a6872", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "point": { + "size": 64, + "filled": true + }, + "circle": { + "size": 64, + "filled": true + }, + "square": { + "size": 64, + "filled": true + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#5a6872", + "labelFontWeight": "normal", + "title": null + } + }, + "height": { + "step": 29 + }, + "title": { + "text": "Most populous countries, 2023", + "subtitle": [ + "Population in millions" + ] + }, + "background": "#ffffff", + "padding": { + "left": 20, + "right": 56, + "top": 20, + "bottom": 20 + }, + "layer": [ + { + "mark": { + "type": "bar", + "stroke": "#ffffff", + "strokeWidth": 0.6, + "color": "#051c2c" + }, + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.4 + } + }, + "x": { + "field": "Population", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "align": "left", + "baseline": "middle", + "dx": 4, + "color": "#051c2c" + }, + "encoding": { + "text": { + "field": "Population", + "type": "quantitative", + "format": ",.0f" + }, + "x": { + "field": "Population", + "type": "quantitative" + }, + "y": { + "field": "Country", + "type": "nominal", + "sort": null + } + }, + "transform": [ + { + "filter": "abs(datum[\"Population\"]) <= 1238.6052993630574" + } + ] + }, + { + "__themeSynthetic": true, + "transform": [ + { + "filter": "abs(datum[\"Population\"]) > 1238.6052993630574" + } + ], + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "align": "right", + "baseline": "middle", + "dx": -5, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "Population", + "type": "quantitative", + "format": ",.0f" + }, + "x": { + "field": "Population", + "type": "quantitative" + }, + "y": { + "field": "Country", + "type": "nominal", + "sort": null + } + } + } + ], + "data": { + "values": [ + { + "Country": "India", + "Population": 1428.6 + }, + { + "Country": "China", + "Population": 1425.7 + }, + { + "Country": "United States", + "Population": 339.9 + }, + { + "Country": "Indonesia", + "Population": 277.5 + }, + { + "Country": "Pakistan", + "Population": 240.5 + }, + { + "Country": "Nigeria", + "Population": 223.8 + }, + { + "Country": "Brazil", + "Population": 216.4 + }, + { + "Country": "Bangladesh", + "Population": 173 + }, + { + "Country": "Russia", + "Population": 144.4 + }, + { + "Country": "Mexico", + "Population": 128.5 + } + ] + }, + "__theme__": "mckinsey", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "`column` approximated as `outsideMark` — Vega-Lite has no label gutter" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/renewable-bullet.powerbi.json b/site/src/playground/theme-lab-assets/compiled/renewable-bullet.powerbi.json new file mode 100644 index 00000000..b3090f24 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/renewable-bullet.powerbi.json @@ -0,0 +1,404 @@ +{ + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.09999999999999998 + } + } + }, + "layer": [ + { + "data": { + "values": [ + { + "Country": "Norway", + "__lo": 0, + "__hi": 25 + }, + { + "Country": "Brazil", + "__lo": 0, + "__hi": 23.75 + }, + { + "Country": "Germany", + "__lo": 0, + "__hi": 20 + }, + { + "Country": "World", + "__lo": 0, + "__hi": 15 + }, + { + "Country": "United States", + "__lo": 0, + "__hi": 12.5 + } + ] + }, + "mark": { + "type": "rect", + "color": "#373737", + "opacity": 1, + "stroke": "#1b1a19", + "strokeWidth": 1 + }, + "encoding": { + "x": { + "field": "__lo", + "type": "quantitative", + "axis": { + "title": null, + "format": ",.12~g" + } + }, + "x2": { + "field": "__hi" + } + } + }, + { + "data": { + "values": [ + { + "Country": "Norway", + "__lo": 25, + "__hi": 50 + }, + { + "Country": "Brazil", + "__lo": 23.75, + "__hi": 47.5 + }, + { + "Country": "Germany", + "__lo": 20, + "__hi": 40 + }, + { + "Country": "World", + "__lo": 15, + "__hi": 30 + }, + { + "Country": "United States", + "__lo": 12.5, + "__hi": 25 + } + ] + }, + "mark": { + "type": "rect", + "color": "#2d2d2d", + "opacity": 1, + "stroke": "#1b1a19", + "strokeWidth": 1 + }, + "encoding": { + "x": { + "field": "__lo", + "type": "quantitative", + "axis": { + "title": null, + "format": ",.12~g" + } + }, + "x2": { + "field": "__hi" + } + } + }, + { + "data": { + "values": [ + { + "Country": "Norway", + "__lo": 50, + "__hi": 75 + }, + { + "Country": "Brazil", + "__lo": 47.5, + "__hi": 71.25 + }, + { + "Country": "Germany", + "__lo": 40, + "__hi": 60 + }, + { + "Country": "World", + "__lo": 30, + "__hi": 45 + }, + { + "Country": "United States", + "__lo": 25, + "__hi": 37.5 + } + ] + }, + "mark": { + "type": "rect", + "color": "#242424", + "opacity": 1, + "stroke": "#1b1a19", + "strokeWidth": 1 + }, + "encoding": { + "x": { + "field": "__lo", + "type": "quantitative", + "axis": { + "title": null, + "format": ",.12~g" + } + }, + "x2": { + "field": "__hi" + } + } + }, + { + "mark": { + "type": "bar", + "height": { + "band": 0.5 + }, + "stroke": "#1b1a19", + "strokeWidth": 1 + }, + "encoding": { + "x": { + "field": "Share", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "title": null, + "format": ",.12~g" + } + }, + "color": { + "field": "__status", + "type": "nominal", + "scale": { + "domain": [ + "Below target", + "Meets target" + ], + "range": [ + "#118dff", + "#12239e", + "#e66c37", + "#6b007b", + "#e044a7", + "#744ec2" + ] + }, + "legend": { + "title": null + }, + "title": null + } + }, + "transform": [ + { + "calculate": "datum[\"Share\"] >= datum[\"Target\"] ? 'Meets target' : 'Below target'", + "as": "__status" + } + ] + }, + { + "mark": { + "type": "tick", + "color": "#ffffff", + "thickness": 3, + "opacity": 1, + "size": 17 + }, + "encoding": { + "x": { + "field": "Target", + "type": "quantitative", + "axis": { + "title": null, + "format": ",.12~g" + } + } + } + } + ], + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#323130", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "tickCount": 5 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 10, + "orient": "right", + "direction": "vertical", + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#a19f9d", + "gradientLength": 90, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#1b1a19", + "font": "'Segoe UI', system-ui, sans-serif", + "title": { + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 11, + "subtitleFont": "'Segoe UI', system-ui, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#c8c6c4", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.2, + "strokeCap": "square" + }, + "trail": { + "size": 2.2 + }, + "rule": { + "strokeCap": "square" + }, + "header": { + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelFontSize": 10, + "labelColor": "#c8c6c4", + "labelFontWeight": "normal", + "title": null + } + }, + "height": { + "step": 23 + }, + "title": { + "text": "Every country is short of its renewable target", + "subtitle": [ + "Renewable share of electricity, 2023, per cent, against national targets" + ] + }, + "background": "#1b1a19", + "padding": 8, + "data": { + "values": [ + { + "Country": "Norway", + "Share": 98.6, + "Target": 100 + }, + { + "Country": "Brazil", + "Share": 89.2, + "Target": 95 + }, + { + "Country": "Germany", + "Share": 51.6, + "Target": 80 + }, + { + "Country": "World", + "Share": 30.3, + "Target": 60 + }, + { + "Country": "United States", + "Share": 22.7, + "Target": 50 + } + ] + }, + "__theme__": "powerbi", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "ink.series", + "message": "`__status` is created by a backend transform — the whole categorical set is offered rather than guessing a count" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "ink.series", + "message": "the layer states its own colour beside a colour-encoded layer — it is context, not series, so it keeps its role and is only re-toned against the surface" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/renewable-kpi.powerbi.json b/site/src/playground/theme-lab-assets/compiled/renewable-kpi.powerbi.json new file mode 100644 index 00000000..ba4f1288 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/renewable-kpi.powerbi.json @@ -0,0 +1,256 @@ +{ + "layer": [ + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "rect", + "fill": "#1a1a1a", + "stroke": "#2f2f2f", + "strokeWidth": 1, + "cornerRadius": 8, + "tooltip": null + }, + "encoding": { + "x": { + "value": 13 + }, + "x2": { + "value": 327 + }, + "y": { + "value": 14 + }, + "y2": { + "value": 229 + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "text", + "fontSize": 22, + "fontWeight": 500, + "fill": "#cfcfcf", + "align": "center", + "baseline": "top", + "text": "Renewable share", + "tooltip": null + }, + "encoding": { + "x": { + "value": 170 + }, + "y": { + "value": 45 + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "text", + "fontSize": 68, + "fontWeight": "bold", + "fill": "#ffffff", + "align": "center", + "baseline": "middle", + "text": "30.3", + "tooltip": null + }, + "encoding": { + "x": { + "value": 170 + }, + "y": { + "value": 113 + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "text", + "fontSize": 18, + "fontWeight": 400, + "fill": "#b3b3b3", + "align": "center", + "baseline": "top", + "text": "67% of 45", + "tooltip": null + }, + "encoding": { + "x": { + "value": 170 + }, + "y": { + "value": 165 + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "rect", + "fill": "#2f2f2f", + "cornerRadius": 3.5, + "tooltip": null + }, + "encoding": { + "x": { + "value": 34 + }, + "x2": { + "value": 306 + }, + "y": { + "value": 192 + }, + "y2": { + "value": 199 + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "rect", + "fill": "#5b8def", + "cornerRadius": 3.5, + "tooltip": null + }, + "encoding": { + "x": { + "value": 34 + }, + "x2": { + "value": 217.14666666666668 + }, + "y": { + "value": 192 + }, + "y2": { + "value": 199 + } + } + } + ], + "width": 340, + "height": 243, + "resolve": { + "scale": { + "x": "independent", + "y": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 243, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#1b1a19", + "font": "'Segoe UI', system-ui, sans-serif", + "title": { + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 11, + "subtitleFont": "'Segoe UI', system-ui, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#c8c6c4", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.2, + "strokeCap": "square" + }, + "trail": { + "size": 2.2 + }, + "rule": { + "strokeCap": "square" + }, + "header": { + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#c8c6c4", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Renewables supply 30% of the world's electricity", + "subtitle": [ + "Share of global electricity generation, 2023, against a 45% target" + ] + }, + "background": "#1b1a19", + "padding": 8, + "data": { + "values": [ + { + "Metric": "Renewable share", + "Share (%)": 30.3, + "Target": 45 + } + ] + }, + "__theme__": "powerbi", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "ink.series", + "message": "the template drew its own furniture in literal colours — those keep their role and are re-toned against the surface" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/renewables-projection.nyt.json b/site/src/playground/theme-lab-assets/compiled/renewables-projection.nyt.json new file mode 100644 index 00000000..17b20837 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/renewables-projection.nyt.json @@ -0,0 +1,269 @@ +{ + "mark": { + "type": "line", + "point": true, + "color": "#2f6b9a" + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null, + "values": [ + { + "year": 2015, + "utc": true + }, + { + "year": 2017, + "utc": true + }, + { + "year": 2019, + "utc": true + }, + { + "year": 2021, + "utc": true + }, + { + "year": 2023, + "utc": true + }, + { + "year": 2025, + "utc": true + }, + { + "year": 2027, + "utc": true + }, + { + "year": 2030, + "utc": true + } + ], + "format": "%Y" + }, + "scale": { + "type": "utc" + } + }, + "y": { + "field": "Capacity (GW)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.index === 1 ? datum.label + \" GW\" : datum.label" + } + }, + "strokeDash": { + "field": "Series", + "type": "nominal", + "sort": null, + "scale": { + "range": [ + [ + 1, + 0 + ], + [ + 7.199999999999999, + 4.8 + ], + [ + 2.88, + 2.88 + ], + [ + 12, + 4.8, + 2.88, + 4.8 + ] + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10, + "titleFontSize": 10, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 10, + "grid": true, + "gridColor": "#ececec", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 4 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, serif", + "fontSize": 16, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "Georgia, serif", + "subtitleFontSize": 12, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.4, + "strokeCap": "butt", + "strokeJoin": "round" + }, + "trail": { + "size": 2.4 + }, + "rule": { + "strokeCap": "round" + }, + "header": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "Global renewable capacity", + "subtitle": [ + "Gigawatts installed, observed to 2023 and projected to 2030" + ] + }, + "background": "#ffffff", + "padding": 12, + "data": { + "values": [ + { + "Year": "2015", + "Series": "Observed", + "Capacity (GW)": 785 + }, + { + "Year": "2017", + "Series": "Observed", + "Capacity (GW)": 1080 + }, + { + "Year": "2019", + "Series": "Observed", + "Capacity (GW)": 1440 + }, + { + "Year": "2021", + "Series": "Observed", + "Capacity (GW)": 1900 + }, + { + "Year": "2023", + "Series": "Observed", + "Capacity (GW)": 2560 + }, + { + "Year": "2023", + "Series": "Projected", + "Capacity (GW)": 2560 + }, + { + "Year": "2025", + "Series": "Projected", + "Capacity (GW)": 3400 + }, + { + "Year": "2027", + "Series": "Projected", + "Capacity (GW)": 4300 + }, + { + "Year": "2030", + "Series": "Projected", + "Capacity (GW)": 5800 + } + ] + }, + "__theme__": "nyt", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "chartDefaults.Line Chart.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 8 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `GW` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/seattle-range.economist.json b/site/src/playground/theme-lab-assets/compiled/seattle-range.economist.json new file mode 100644 index 00000000..5afd1c4d --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/seattle-range.economist.json @@ -0,0 +1,252 @@ +{ + "background": "#ffffff", + "padding": 8, + "title": { + "text": "Seattle, month by month", + "subtitle": [ + "Average daily high and low temperature, °F, 1991–2020 normals" + ] + }, + "spacing": 6, + "vconcat": [ + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#e3120b" + }, + "width": 26, + "height": 3, + "data": { + "values": [ + {} + ] + } + }, + { + "mark": { + "type": "area", + "opacity": 0.5, + "line": { + "strokeWidth": 1 + }, + "color": "#006ba2" + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": { + "title": null + } + }, + "y": { + "field": "Low", + "type": "quantitative", + "scale": { + "zero": false, + "nice": true + }, + "axis": { + "format": ",.12~g", + "title": null + } + }, + "y2": { + "field": "High" + } + }, + "width": { + "step": 23 + } + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "#121317", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "labelAngle": 0 + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": true, + "gridColor": "#d8dfe4", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 5 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 12.5, + "subtitleColor": "#54585a", + "subtitlePadding": 8, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.6 + }, + "trail": { + "size": 1.6 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#54585a", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Month": "Jan", + "Low": 37, + "High": 47 + }, + { + "Month": "Feb", + "Low": 37, + "High": 50 + }, + { + "Month": "Mar", + "Low": 40, + "High": 54 + }, + { + "Month": "Apr", + "Low": 43, + "High": 59 + }, + { + "Month": "May", + "Low": 48, + "High": 65 + }, + { + "Month": "Jun", + "Low": 53, + "High": 70 + }, + { + "Month": "Jul", + "Low": 56, + "High": 76 + }, + { + "Month": "Aug", + "Low": 57, + "High": 77 + }, + { + "Month": "Sep", + "Low": 53, + "High": 71 + }, + { + "Month": "Oct", + "Low": 46, + "High": 60 + }, + { + "Month": "Nov", + "Low": 40, + "High": 51 + }, + { + "Month": "Dec", + "Low": 36, + "High": 46 + } + ] + }, + "__theme__": "economist", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~18px and the band is 23px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.domain", + "message": "the value scale floats — a rule under the categories would claim a base the chart does not have" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/spending-quintile.mckinsey.json b/site/src/playground/theme-lab-assets/compiled/spending-quintile.mckinsey.json new file mode 100644 index 00000000..e5c1a0c9 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/spending-quintile.mckinsey.json @@ -0,0 +1,343 @@ +{ + "mark": { + "type": "bar", + "stroke": "#ffffff", + "strokeWidth": 0.6 + }, + "encoding": { + "x": { + "field": "Quintile", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.4 + } + }, + "y": { + "field": "Spending ($)", + "type": "quantitative", + "stack": "normalize", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null + } + }, + "color": { + "field": "Category", + "type": "nominal", + "sort": null, + "scale": { + "domain": [ + "Housing", + "Transportation", + "Food", + "Healthcare", + "Everything else" + ], + "range": [ + "#051c2c", + "#5b82ab", + "#9db8d2", + "#cfdcea", + "#e2e7ec" + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelLimit": 0, + "labelAngle": 0, + "tickCount": 5 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#8a969d", + "gradientLength": 188, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#5a6872", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "point": { + "size": 64, + "filled": true + }, + "circle": { + "size": 64, + "filled": true + }, + "square": { + "size": 64, + "filled": true + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#5a6872", + "labelFontWeight": "normal", + "title": null + } + }, + "width": { + "step": 78 + }, + "title": { + "text": "Where each income group's money goes", + "subtitle": [ + "Share of annual household spending, by income quintile" + ] + }, + "background": "#ffffff", + "padding": 20, + "data": { + "values": [ + { + "Quintile": "Lowest fifth", + "Category": "Housing", + "Spending ($)": 12800 + }, + { + "Quintile": "Lowest fifth", + "Category": "Transportation", + "Spending ($)": 4900 + }, + { + "Quintile": "Lowest fifth", + "Category": "Food", + "Spending ($)": 5100 + }, + { + "Quintile": "Lowest fifth", + "Category": "Healthcare", + "Spending ($)": 2900 + }, + { + "Quintile": "Lowest fifth", + "Category": "Everything else", + "Spending ($)": 6400 + }, + { + "Quintile": "Second fifth", + "Category": "Housing", + "Spending ($)": 16100 + }, + { + "Quintile": "Second fifth", + "Category": "Transportation", + "Spending ($)": 7600 + }, + { + "Quintile": "Second fifth", + "Category": "Food", + "Spending ($)": 6300 + }, + { + "Quintile": "Second fifth", + "Category": "Healthcare", + "Spending ($)": 4000 + }, + { + "Quintile": "Second fifth", + "Category": "Everything else", + "Spending ($)": 10700 + }, + { + "Quintile": "Middle fifth", + "Category": "Housing", + "Spending ($)": 19400 + }, + { + "Quintile": "Middle fifth", + "Category": "Transportation", + "Spending ($)": 10600 + }, + { + "Quintile": "Middle fifth", + "Category": "Food", + "Spending ($)": 7600 + }, + { + "Quintile": "Middle fifth", + "Category": "Healthcare", + "Spending ($)": 4700 + }, + { + "Quintile": "Middle fifth", + "Category": "Everything else", + "Spending ($)": 16400 + }, + { + "Quintile": "Fourth fifth", + "Category": "Housing", + "Spending ($)": 24300 + }, + { + "Quintile": "Fourth fifth", + "Category": "Transportation", + "Spending ($)": 13700 + }, + { + "Quintile": "Fourth fifth", + "Category": "Food", + "Spending ($)": 9100 + }, + { + "Quintile": "Fourth fifth", + "Category": "Healthcare", + "Spending ($)": 6100 + }, + { + "Quintile": "Fourth fifth", + "Category": "Everything else", + "Spending ($)": 22800 + }, + { + "Quintile": "Highest fifth", + "Category": "Housing", + "Spending ($)": 36600 + }, + { + "Quintile": "Highest fifth", + "Category": "Transportation", + "Spending ($)": 19500 + }, + { + "Quintile": "Highest fifth", + "Category": "Food", + "Spending ($)": 13400 + }, + { + "Quintile": "Highest fifth", + "Category": "Healthcare", + "Spending ($)": 8500 + }, + { + "Quintile": "Highest fifth", + "Category": "Everything else", + "Spending ($)": 43800 + } + ] + }, + "__theme__": "mckinsey", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 80px" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the house sets category labels flat, but the widest needs ~83px in a 78px band — the angle is left to the layout" + }, + { + "stage": "ground", + "path": "ink.series.endpointsAgainstSurface", + "message": "a ramp end sat too close to the surface to be seen as a value — it was pulled away from the page" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "the segments are stacked — a value at a segment edge would read as the running total" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 188px — under half the 417px block, so the key stays a caption to the chart" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/state-jobless.economist.json b/site/src/playground/theme-lab-assets/compiled/state-jobless.economist.json new file mode 100644 index 00000000..36799b6f --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/state-jobless.economist.json @@ -0,0 +1,396 @@ +{ + "background": "#ffffff", + "padding": 8, + "title": { + "text": "Unemployment by state", + "subtitle": [ + "Annual average unemployment rate, 50 US states, 2023, per cent" + ] + }, + "spacing": 6, + "vconcat": [ + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#e3120b" + }, + "width": 26, + "height": 3, + "data": { + "values": [ + {} + ] + } + }, + { + "mark": { + "type": "bar", + "color": "#006ba2" + }, + "encoding": { + "x": { + "field": "State", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.31999999999999995 + } + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "orient": "right", + "labelExpr": "datum.label + \"%\"" + } + } + }, + "width": { + "step": 9 + } + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 144, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 6, + "titleFontSize": 10.5, + "labelAngle": -90, + "labelAlign": "right", + "labelBaseline": "middle", + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121317", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 6, + "titleFontSize": 10.5, + "grid": true, + "gridColor": "#d8dfe4", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 3 + }, + "legend": { + "labelFontSize": 9, + "titleFontSize": 9 + }, + "facet": { + "spacing": { + "row": 18, + "column": 14 + } + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 12.5, + "subtitleColor": "#54585a", + "subtitlePadding": 8, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.6 + }, + "trail": { + "size": 1.6 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#54585a", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "State": "Alabama", + "Unemployment (%)": 2.3 + }, + { + "State": "Alaska", + "Unemployment (%)": 4.3 + }, + { + "State": "Arizona", + "Unemployment (%)": 3.9 + }, + { + "State": "Arkansas", + "Unemployment (%)": 3.3 + }, + { + "State": "California", + "Unemployment (%)": 4.8 + }, + { + "State": "Colorado", + "Unemployment (%)": 3.2 + }, + { + "State": "Connecticut", + "Unemployment (%)": 3.9 + }, + { + "State": "Delaware", + "Unemployment (%)": 4.2 + }, + { + "State": "Florida", + "Unemployment (%)": 2.9 + }, + { + "State": "Georgia", + "Unemployment (%)": 3.2 + }, + { + "State": "Hawaii", + "Unemployment (%)": 3 + }, + { + "State": "Idaho", + "Unemployment (%)": 3.2 + }, + { + "State": "Illinois", + "Unemployment (%)": 4.5 + }, + { + "State": "Indiana", + "Unemployment (%)": 3.3 + }, + { + "State": "Iowa", + "Unemployment (%)": 2.9 + }, + { + "State": "Kansas", + "Unemployment (%)": 2.8 + }, + { + "State": "Kentucky", + "Unemployment (%)": 4.2 + }, + { + "State": "Louisiana", + "Unemployment (%)": 3.5 + }, + { + "State": "Maine", + "Unemployment (%)": 2.9 + }, + { + "State": "Maryland", + "Unemployment (%)": 2.1 + }, + { + "State": "Massachusetts", + "Unemployment (%)": 3.3 + }, + { + "State": "Michigan", + "Unemployment (%)": 3.9 + }, + { + "State": "Minnesota", + "Unemployment (%)": 2.8 + }, + { + "State": "Mississippi", + "Unemployment (%)": 3.2 + }, + { + "State": "Missouri", + "Unemployment (%)": 3 + }, + { + "State": "Montana", + "Unemployment (%)": 2.9 + }, + { + "State": "Nebraska", + "Unemployment (%)": 2.4 + }, + { + "State": "Nevada", + "Unemployment (%)": 5.3 + }, + { + "State": "New Hampshire", + "Unemployment (%)": 2.4 + }, + { + "State": "New Jersey", + "Unemployment (%)": 4.1 + }, + { + "State": "New Mexico", + "Unemployment (%)": 3.8 + }, + { + "State": "New York", + "Unemployment (%)": 4.1 + }, + { + "State": "North Carolina", + "Unemployment (%)": 3.4 + }, + { + "State": "North Dakota", + "Unemployment (%)": 1.9 + }, + { + "State": "Ohio", + "Unemployment (%)": 3.6 + }, + { + "State": "Oklahoma", + "Unemployment (%)": 3 + }, + { + "State": "Oregon", + "Unemployment (%)": 3.7 + }, + { + "State": "Pennsylvania", + "Unemployment (%)": 3.4 + }, + { + "State": "Rhode Island", + "Unemployment (%)": 2.9 + }, + { + "State": "South Carolina", + "Unemployment (%)": 3 + }, + { + "State": "South Dakota", + "Unemployment (%)": 1.9 + }, + { + "State": "Tennessee", + "Unemployment (%)": 3.2 + }, + { + "State": "Texas", + "Unemployment (%)": 4 + }, + { + "State": "Utah", + "Unemployment (%)": 2.5 + }, + { + "State": "Vermont", + "Unemployment (%)": 2.1 + }, + { + "State": "Virginia", + "Unemployment (%)": 2.8 + }, + { + "State": "Washington", + "Unemployment (%)": 4.1 + }, + { + "State": "West Virginia", + "Unemployment (%)": 4 + }, + { + "State": "Wisconsin", + "Unemployment (%)": 2.9 + }, + { + "State": "Wyoming", + "Unemployment (%)": 3.1 + } + ] + }, + "__theme__": "economist", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "variants", + "message": "applied variant {\"markChannel\":\"length\"} — Measured: big-mac, causes-death and state-jobless all carry the measure axis opposite (3 of 3 bar charts). On a banded chart the far edge is where a reader enters." + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false (band 9px, 50 marks)" + }, + { + "stage": "realize", + "path": "axes.x.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 6px and the house's 10.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.label.fontSize", + "message": "the axis is crowded — the layout fitted its labels at 6px and the house's 10.5px would not stand in the band" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "every label carries its unit — `%` — because the house prints no axis title to hold it" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/state-unemployment.datawrapper.json b/site/src/playground/theme-lab-assets/compiled/state-unemployment.datawrapper.json new file mode 100644 index 00000000..2e999e09 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/state-unemployment.datawrapper.json @@ -0,0 +1,625 @@ +{ + "background": "#ffffff", + "padding": 12, + "title": { + "text": "Unemployment rate by state, 2023", + "subtitle": [ + "Annual average, % of the civilian labour force" + ] + }, + "spacing": 6, + "vconcat": [ + { + "mark": { + "type": "geoshape", + "stroke": "white", + "strokeWidth": 0.5 + }, + "encoding": { + "color": { + "field": "Unemployment (%)", + "type": "quantitative", + "scale": { + "type": "quantize", + "range": [ + "#dceef6", + "#a9d3e6", + "#6aabcc", + "#2f7fa8", + "#0b5c82" + ] + } + }, + "tooltip": [ + { + "field": "State", + "type": "nominal" + }, + { + "field": "Unemployment (%)", + "type": "quantitative" + } + ] + }, + "width": 500, + "height": 300, + "data": { + "url": "https://vega.github.io/vega-lite/data/us-10m.json", + "format": { + "type": "topojson", + "feature": "states" + } + }, + "projection": { + "type": "albersUsa" + }, + "transform": [ + { + "lookup": "id", + "from": { + "data": { + "values": [ + { + "State": "Alabama", + "Unemployment (%)": 2.3, + "__geo_id": 1 + }, + { + "State": "Alaska", + "Unemployment (%)": 4.3, + "__geo_id": 2 + }, + { + "State": "Arizona", + "Unemployment (%)": 3.9, + "__geo_id": 4 + }, + { + "State": "Arkansas", + "Unemployment (%)": 3.3, + "__geo_id": 5 + }, + { + "State": "California", + "Unemployment (%)": 4.8, + "__geo_id": 6 + }, + { + "State": "Colorado", + "Unemployment (%)": 3.2, + "__geo_id": 8 + }, + { + "State": "Connecticut", + "Unemployment (%)": 3.9, + "__geo_id": 9 + }, + { + "State": "Delaware", + "Unemployment (%)": 4.2, + "__geo_id": 10 + }, + { + "State": "Florida", + "Unemployment (%)": 2.9, + "__geo_id": 12 + }, + { + "State": "Georgia", + "Unemployment (%)": 3.2, + "__geo_id": 13 + }, + { + "State": "Hawaii", + "Unemployment (%)": 3, + "__geo_id": 15 + }, + { + "State": "Idaho", + "Unemployment (%)": 3.2, + "__geo_id": 16 + }, + { + "State": "Illinois", + "Unemployment (%)": 4.5, + "__geo_id": 17 + }, + { + "State": "Indiana", + "Unemployment (%)": 3.3, + "__geo_id": 18 + }, + { + "State": "Iowa", + "Unemployment (%)": 2.9, + "__geo_id": 19 + }, + { + "State": "Kansas", + "Unemployment (%)": 2.8, + "__geo_id": 20 + }, + { + "State": "Kentucky", + "Unemployment (%)": 4.2, + "__geo_id": 21 + }, + { + "State": "Louisiana", + "Unemployment (%)": 3.5, + "__geo_id": 22 + }, + { + "State": "Maine", + "Unemployment (%)": 2.9, + "__geo_id": 23 + }, + { + "State": "Maryland", + "Unemployment (%)": 2.1, + "__geo_id": 24 + }, + { + "State": "Massachusetts", + "Unemployment (%)": 3.3, + "__geo_id": 25 + }, + { + "State": "Michigan", + "Unemployment (%)": 3.9, + "__geo_id": 26 + }, + { + "State": "Minnesota", + "Unemployment (%)": 2.8, + "__geo_id": 27 + }, + { + "State": "Mississippi", + "Unemployment (%)": 3.2, + "__geo_id": 28 + }, + { + "State": "Missouri", + "Unemployment (%)": 3, + "__geo_id": 29 + }, + { + "State": "Montana", + "Unemployment (%)": 2.9, + "__geo_id": 30 + }, + { + "State": "Nebraska", + "Unemployment (%)": 2.4, + "__geo_id": 31 + }, + { + "State": "Nevada", + "Unemployment (%)": 5.3, + "__geo_id": 32 + }, + { + "State": "New Hampshire", + "Unemployment (%)": 2.4, + "__geo_id": 33 + }, + { + "State": "New Jersey", + "Unemployment (%)": 4.1, + "__geo_id": 34 + }, + { + "State": "New Mexico", + "Unemployment (%)": 3.8, + "__geo_id": 35 + }, + { + "State": "New York", + "Unemployment (%)": 4.1, + "__geo_id": 36 + }, + { + "State": "North Carolina", + "Unemployment (%)": 3.4, + "__geo_id": 37 + }, + { + "State": "North Dakota", + "Unemployment (%)": 1.9, + "__geo_id": 38 + }, + { + "State": "Ohio", + "Unemployment (%)": 3.6, + "__geo_id": 39 + }, + { + "State": "Oklahoma", + "Unemployment (%)": 3, + "__geo_id": 40 + }, + { + "State": "Oregon", + "Unemployment (%)": 3.7, + "__geo_id": 41 + }, + { + "State": "Pennsylvania", + "Unemployment (%)": 3.4, + "__geo_id": 42 + }, + { + "State": "Rhode Island", + "Unemployment (%)": 2.9, + "__geo_id": 44 + }, + { + "State": "South Carolina", + "Unemployment (%)": 3, + "__geo_id": 45 + }, + { + "State": "South Dakota", + "Unemployment (%)": 1.9, + "__geo_id": 46 + }, + { + "State": "Tennessee", + "Unemployment (%)": 3.2, + "__geo_id": 47 + }, + { + "State": "Texas", + "Unemployment (%)": 4, + "__geo_id": 48 + }, + { + "State": "Utah", + "Unemployment (%)": 2.5, + "__geo_id": 49 + }, + { + "State": "Vermont", + "Unemployment (%)": 2.1, + "__geo_id": 50 + }, + { + "State": "Virginia", + "Unemployment (%)": 2.8, + "__geo_id": 51 + }, + { + "State": "Washington", + "Unemployment (%)": 4.1, + "__geo_id": 53 + }, + { + "State": "West Virginia", + "Unemployment (%)": 4, + "__geo_id": 54 + }, + { + "State": "Wisconsin", + "Unemployment (%)": 2.9, + "__geo_id": 55 + }, + { + "State": "Wyoming", + "Unemployment (%)": 3.1, + "__geo_id": 56 + } + ] + }, + "key": "__geo_id", + "fields": [ + "Unemployment (%)", + "State" + ] + } + } + ] + }, + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#dcdcdc" + }, + "width": 535, + "height": 1, + "data": { + "values": [ + {} + ] + } + } + ], + "resolve": { + "legend": { + "color": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 500, + "continuousHeight": 300, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 12.5, + "titleFontSize": 12.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#999999", + "gradientLength": 200, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 12.5, + "labelColor": "#666666", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "State": "Alabama", + "Unemployment (%)": 2.3 + }, + { + "State": "Alaska", + "Unemployment (%)": 4.3 + }, + { + "State": "Arizona", + "Unemployment (%)": 3.9 + }, + { + "State": "Arkansas", + "Unemployment (%)": 3.3 + }, + { + "State": "California", + "Unemployment (%)": 4.8 + }, + { + "State": "Colorado", + "Unemployment (%)": 3.2 + }, + { + "State": "Connecticut", + "Unemployment (%)": 3.9 + }, + { + "State": "Delaware", + "Unemployment (%)": 4.2 + }, + { + "State": "Florida", + "Unemployment (%)": 2.9 + }, + { + "State": "Georgia", + "Unemployment (%)": 3.2 + }, + { + "State": "Hawaii", + "Unemployment (%)": 3 + }, + { + "State": "Idaho", + "Unemployment (%)": 3.2 + }, + { + "State": "Illinois", + "Unemployment (%)": 4.5 + }, + { + "State": "Indiana", + "Unemployment (%)": 3.3 + }, + { + "State": "Iowa", + "Unemployment (%)": 2.9 + }, + { + "State": "Kansas", + "Unemployment (%)": 2.8 + }, + { + "State": "Kentucky", + "Unemployment (%)": 4.2 + }, + { + "State": "Louisiana", + "Unemployment (%)": 3.5 + }, + { + "State": "Maine", + "Unemployment (%)": 2.9 + }, + { + "State": "Maryland", + "Unemployment (%)": 2.1 + }, + { + "State": "Massachusetts", + "Unemployment (%)": 3.3 + }, + { + "State": "Michigan", + "Unemployment (%)": 3.9 + }, + { + "State": "Minnesota", + "Unemployment (%)": 2.8 + }, + { + "State": "Mississippi", + "Unemployment (%)": 3.2 + }, + { + "State": "Missouri", + "Unemployment (%)": 3 + }, + { + "State": "Montana", + "Unemployment (%)": 2.9 + }, + { + "State": "Nebraska", + "Unemployment (%)": 2.4 + }, + { + "State": "Nevada", + "Unemployment (%)": 5.3 + }, + { + "State": "New Hampshire", + "Unemployment (%)": 2.4 + }, + { + "State": "New Jersey", + "Unemployment (%)": 4.1 + }, + { + "State": "New Mexico", + "Unemployment (%)": 3.8 + }, + { + "State": "New York", + "Unemployment (%)": 4.1 + }, + { + "State": "North Carolina", + "Unemployment (%)": 3.4 + }, + { + "State": "North Dakota", + "Unemployment (%)": 1.9 + }, + { + "State": "Ohio", + "Unemployment (%)": 3.6 + }, + { + "State": "Oklahoma", + "Unemployment (%)": 3 + }, + { + "State": "Oregon", + "Unemployment (%)": 3.7 + }, + { + "State": "Pennsylvania", + "Unemployment (%)": 3.4 + }, + { + "State": "Rhode Island", + "Unemployment (%)": 2.9 + }, + { + "State": "South Carolina", + "Unemployment (%)": 3 + }, + { + "State": "South Dakota", + "Unemployment (%)": 1.9 + }, + { + "State": "Tennessee", + "Unemployment (%)": 3.2 + }, + { + "State": "Texas", + "Unemployment (%)": 4 + }, + { + "State": "Utah", + "Unemployment (%)": 2.5 + }, + { + "State": "Vermont", + "Unemployment (%)": 2.1 + }, + { + "State": "Virginia", + "Unemployment (%)": 2.8 + }, + { + "State": "Washington", + "Unemployment (%)": 4.1 + }, + { + "State": "West Virginia", + "Unemployment (%)": 4 + }, + { + "State": "Wisconsin", + "Unemployment (%)": 2.9 + }, + { + "State": "Wyoming", + "Unemployment (%)": 3.1 + } + ] + }, + "__theme__": "datawrapper", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 200px — under half the 535px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 535px — not a fixed stub" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/stock-candle.powerbi.json b/site/src/playground/theme-lab-assets/compiled/stock-candle.powerbi.json new file mode 100644 index 00000000..a6872dc2 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/stock-candle.powerbi.json @@ -0,0 +1,336 @@ +{ + "encoding": { + "x": { + "field": "Date", + "type": "temporal", + "scale": { + "nice": false, + "domain": [ + "2024-01-01T09:00:00.000Z", + "2024-01-12T15:00:00.000Z" + ], + "type": "utc" + }, + "axis": { + "title": null, + "values": [ + { + "year": 2024, + "utc": true, + "month": 1, + "date": 2 + }, + { + "year": 2024, + "utc": true, + "month": 1, + "date": 3 + }, + { + "year": 2024, + "utc": true, + "month": 1, + "date": 4 + }, + { + "year": 2024, + "utc": true, + "month": 1, + "date": 5 + }, + { + "year": 2024, + "utc": true, + "month": 1, + "date": 8 + }, + { + "year": 2024, + "utc": true, + "month": 1, + "date": 9 + }, + { + "year": 2024, + "utc": true, + "month": 1, + "date": 10 + }, + { + "year": 2024, + "utc": true, + "month": 1, + "date": 11 + }, + { + "year": 2024, + "utc": true, + "month": 1, + "date": 12 + } + ], + "format": "%b %-d" + } + }, + "y": { + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "title": null, + "format": ",.12~g" + } + }, + "color": { + "condition": { + "test": "datum['Open'] <= datum['Close']", + "value": "#06982d" + }, + "value": "#ae1325" + } + }, + "layer": [ + { + "mark": "rule", + "encoding": { + "y": { + "field": "Low", + "axis": { + "title": null + } + }, + "y2": { + "field": "High" + } + } + }, + { + "mark": { + "type": "bar", + "size": 16, + "stroke": "#1b1a19", + "strokeWidth": 1, + "color": "#118dff" + }, + "encoding": { + "y": { + "field": "Open", + "axis": { + "title": null + } + }, + "y2": { + "field": "Close" + } + } + }, + { + "transform": [ + { + "filter": "datum['Open'] === datum['Close']" + } + ], + "mark": { + "type": "tick", + "size": 16, + "thickness": 2, + "color": "#118dff" + }, + "encoding": { + "y": { + "field": "Close", + "axis": { + "title": null + } + } + } + } + ], + "title": { + "text": "Two weeks of a stock going nowhere", + "subtitle": [ + "Daily open, high, low and close, 2–12 January 2024" + ] + }, + "config": { + "view": { + "continuousWidth": 230, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 9, + "titleFontSize": 9, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ] + }, + "axisY": { + "labelFontSize": 9, + "titleFontSize": 9, + "grid": true, + "gridColor": "#323130", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0, + "tickCount": 4 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#1b1a19", + "font": "'Segoe UI', system-ui, sans-serif", + "title": { + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 11, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', system-ui, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#c8c6c4", + "subtitlePadding": 6, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.2, + "strokeCap": "square" + }, + "trail": { + "size": 2.2 + }, + "rule": { + "strokeCap": "square" + }, + "header": { + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelFontSize": 9, + "labelColor": "#c8c6c4", + "labelFontWeight": "normal", + "title": null + } + }, + "background": "#1b1a19", + "padding": 8, + "data": { + "values": [ + { + "Date": "2024-01-02", + "Open": 187, + "High": 188, + "Low": 183, + "Close": 185 + }, + { + "Date": "2024-01-03", + "Open": 184, + "High": 185, + "Low": 182, + "Close": 184 + }, + { + "Date": "2024-01-04", + "Open": 182, + "High": 183, + "Low": 180, + "Close": 182 + }, + { + "Date": "2024-01-05", + "Open": 182, + "High": 182, + "Low": 179, + "Close": 181 + }, + { + "Date": "2024-01-08", + "Open": 182, + "High": 186, + "Low": 182, + "Close": 185 + }, + { + "Date": "2024-01-09", + "Open": 184, + "High": 185, + "Low": 183, + "Close": 185 + }, + { + "Date": "2024-01-10", + "Open": 184, + "High": 186, + "Low": 183, + "Close": 186 + }, + { + "Date": "2024-01-11", + "Open": 186, + "High": 187, + "Low": 183, + "Close": 186 + }, + { + "Date": "2024-01-12", + "Open": 186, + "High": 188, + "Low": 185, + "Close": 185 + } + ] + }, + "__theme__": "powerbi", + "__compiled__": true, + "__report__": [ + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 9 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "dataLabels", + "message": "the mark carries 2 measures (Open, Close) — no single value to print" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/temp-anomaly.nyt.json b/site/src/playground/theme-lab-assets/compiled/temp-anomaly.nyt.json new file mode 100644 index 00000000..4cb56ca2 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/temp-anomaly.nyt.json @@ -0,0 +1,296 @@ +{ + "encoding": { + "x": { + "field": "Decade", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.28 + } + }, + "y": { + "field": "Anomaly (°C)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.index === 1 ? datum.label + \"°C\" : datum.label" + } + }, + "color": { + "field": "Direction", + "type": "nominal", + "sort": null, + "scale": { + "domain": [ + "Below average", + "Above average" + ], + "range": [ + "#c2352b", + "#2f6b9a" + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": true, + "gridColor": "#ececec", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 4 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#8a8a8a", + "gradientLength": 99, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, serif", + "fontSize": 16.5, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 15, + "subtitleFont": "Georgia, serif", + "subtitleFontSize": 12.5, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 8, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + }, + "trail": { + "size": 2.4 + }, + "rule": { + "strokeCap": "round" + }, + "header": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#6b6b6b", + "labelFontWeight": "normal", + "title": null + } + }, + "width": { + "step": 23 + }, + "title": { + "text": "Global temperature anomaly by decade", + "subtitle": [ + "°C against the 1951–1980 average" + ] + }, + "background": "#ffffff", + "padding": 12, + "layer": [ + { + "mark": "bar", + "encoding": { + "x": { + "field": "Decade", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.28 + } + }, + "y": { + "field": "Anomaly (°C)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.index === 1 ? datum.label + \"°C\" : datum.label" + } + }, + "color": { + "field": "Direction", + "type": "nominal", + "sort": null, + "scale": { + "domain": [ + "Below average", + "Above average" + ], + "range": [ + "#c2352b", + "#2f6b9a" + ] + } + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "rule", + "color": "#121212", + "strokeWidth": 1 + }, + "encoding": { + "y": { + "datum": 0 + } + } + } + ], + "data": { + "values": [ + { + "Decade": "1880s", + "Anomaly (°C)": -0.17, + "Direction": "Below average" + }, + { + "Decade": "1900s", + "Anomaly (°C)": -0.16, + "Direction": "Below average" + }, + { + "Decade": "1920s", + "Anomaly (°C)": -0.27, + "Direction": "Below average" + }, + { + "Decade": "1940s", + "Anomaly (°C)": 0.12, + "Direction": "Above average" + }, + { + "Decade": "1960s", + "Anomaly (°C)": -0.03, + "Direction": "Below average" + }, + { + "Decade": "1980s", + "Anomaly (°C)": 0.26, + "Direction": "Above average" + }, + { + "Decade": "2000s", + "Anomaly (°C)": 0.4, + "Direction": "Above average" + }, + { + "Decade": "2010s", + "Anomaly (°C)": 0.72, + "Direction": "Above average" + }, + { + "Decade": "2020s", + "Anomaly (°C)": 1.02, + "Direction": "Above average" + } + ] + }, + "__theme__": "nyt", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "the segments are stacked — a value at a segment edge would read as the running total" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `°C` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "structure.grid.zero", + "message": "the measure changes sign inside the plot — zero is drawn as its own rule, not as one gridline among the rest" + }, + { + "stage": "realize", + "path": "ink.series.status", + "message": "the categories carry a sign — Below average is negative, Above average is positive" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 99px — under half the 221px block, so the key stays a caption to the chart" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/temp-heatmap.datawrapper.json b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.datawrapper.json new file mode 100644 index 00000000..73bdefd6 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.datawrapper.json @@ -0,0 +1,483 @@ +{ + "background": "#ffffff", + "padding": 12, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ] + }, + "spacing": 6, + "vconcat": [ + { + "mark": { + "type": "rect", + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": { + "title": null + } + }, + "y": { + "field": "City", + "type": "nominal", + "sort": null, + "axis": { + "title": null + } + }, + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "scale": { + "domain": [ + -29, + 29 + ], + "type": "quantize", + "range": [ + "#2f7fa8", + "#a9d3e6", + "#f0ece4", + "#e8ac70", + "#c04a4a" + ] + } + } + }, + "width": { + "step": 32 + }, + "height": { + "step": 32 + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#dcdcdc" + }, + "width": 411, + "height": 1, + "data": { + "values": [ + {} + ] + } + } + ], + "resolve": { + "legend": { + "color": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12.5, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelAngle": 0 + }, + "axisY": { + "labelFontSize": 12.5, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 12.5, + "titleFontSize": 12.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#999999", + "gradientLength": 185, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 12.5, + "labelColor": "#666666", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + }, + "__theme__": "datawrapper", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~21px and the band is 32px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false (band 32px, 100 marks)" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "marks.tile", + "message": "the cells are cut apart by 1.5px — the grid reads as a table of separate readings rather than one continuous field" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 185px — under half the 411px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 411px — not a fixed stub" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/temp-heatmap.economist.json b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.economist.json new file mode 100644 index 00000000..cd5e6983 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.economist.json @@ -0,0 +1,470 @@ +{ + "background": "#ffffff", + "padding": 8, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ] + }, + "spacing": 6, + "vconcat": [ + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#e3120b" + }, + "width": 26, + "height": 3, + "data": { + "values": [ + {} + ] + } + }, + { + "mark": "rect", + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": { + "title": null + } + }, + "y": { + "field": "City", + "type": "nominal", + "sort": null, + "axis": { + "title": null + } + }, + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "scale": { + "domain": [ + -29, + 29 + ], + "range": [ + "#006ba2", + "#7ba7b8", + "#e9e5dc", + "#c8967a", + "#a1655a" + ] + } + } + }, + "width": { + "step": 32 + }, + "height": { + "step": 32 + } + } + ], + "resolve": { + "legend": { + "color": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "labelAngle": 0 + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#54585a", + "titleFontWeight": "normal", + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#8b9196", + "gradientLength": 185, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 12.5, + "subtitleColor": "#54585a", + "subtitlePadding": 8, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.6 + }, + "trail": { + "size": 1.6 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#54585a", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + }, + "__theme__": "economist", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~18px and the band is 32px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "dataLabels.inkMode", + "message": "no ink mode declared but the label sits on the mark — it contrasts with what it is printed on" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false (band 32px, 100 marks)" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 185px — under half the 411px block, so the key stays a caption to the chart" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/temp-heatmap.mckinsey.json b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.mckinsey.json new file mode 100644 index 00000000..89d39f07 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.mckinsey.json @@ -0,0 +1,518 @@ +{ + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelAngle": 0 + }, + "axisY": { + "labelFontSize": 12.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#5a6872", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#5a6872", + "titleFontWeight": "normal", + "labelLimit": 0, + "labelAngle": 0 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 13, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#5a6872", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "point": { + "size": 64, + "filled": true + }, + "circle": { + "size": 64, + "filled": true + }, + "square": { + "size": 64, + "filled": true + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#5a6872", + "labelFontWeight": "normal", + "title": null + } + }, + "width": { + "step": 32 + }, + "height": { + "step": 32 + }, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ] + }, + "background": "#ffffff", + "padding": 20, + "layer": [ + { + "mark": { + "type": "rect", + "stroke": "#ffffff", + "strokeWidth": 0.6 + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": { + "title": null + } + }, + "y": { + "field": "City", + "type": "nominal", + "sort": null, + "axis": { + "title": null + } + }, + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "scale": { + "domain": [ + -29, + 29 + ], + "range": [ + "#e2e7ec", + "#cfdcea", + "#9db8d2", + "#5b82ab", + "#051c2c" + ] + }, + "legend": null + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "align": "center", + "baseline": "middle" + }, + "encoding": { + "text": { + "field": "Temp (°C)", + "type": "quantitative", + "format": ",.0f" + }, + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ] + }, + "y": { + "field": "City", + "type": "nominal", + "sort": null + }, + "color": { + "condition": { + "test": "(datum[\"Temp (°C)\"] >= 8.8125 && datum[\"Temp (°C)\"] <= 29)", + "value": "#ffffff" + }, + "value": "#051c2c" + } + } + } + ], + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + }, + "__theme__": "mckinsey", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house asks for 80px categories, but both axes are banded — the marks are cells, whose size the grid settles, not the house" + }, + { + "stage": "ground", + "path": "ink.series.endpointsAgainstSurface", + "message": "a ramp end sat too close to the surface to be seen as a value — it was pulled away from the page" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`inline` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "legend.suppressWhenValuesPrinted", + "message": "legend removed — the ramp was a value key and every mark now prints its value" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 64px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "marks.tile", + "message": "the cells are cut apart by 0.6px — the grid reads as a table of separate readings rather than one continuous field" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "`column` printed in the cell instead — a grid is continuous and has no outside" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/temp-heatmap.nature.json b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.nature.json new file mode 100644 index 00000000..4b510414 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.nature.json @@ -0,0 +1,466 @@ +{ + "mark": { + "type": "rect", + "stroke": "#ffffff", + "strokeWidth": 0.5 + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ] + }, + "y": { + "field": "City", + "type": "nominal", + "sort": null + }, + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "scale": { + "domain": [ + -29, + 29 + ], + "range": [ + "#0072b2", + "#83b9db", + "#ffffff", + "#eba06a", + "#d55e00" + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 11, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelAngle": 0 + }, + "axisY": { + "labelFontSize": 11, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 11, + "titleFontSize": 11, + "orient": "right", + "direction": "vertical", + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#8c8c8c" + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 13, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 12, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 11, + "subtitleFontStyle": "italic", + "subtitleColor": "#000000", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.2, + "point": { + "filled": true, + "size": 45, + "stroke": "#ffffff", + "strokeWidth": 0.6 + } + }, + "trail": { + "size": 1.2 + }, + "point": { + "size": 45, + "filled": true + }, + "circle": { + "size": 45, + "filled": true + }, + "square": { + "size": 45, + "filled": true + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 11, + "labelColor": "#000000", + "labelFontWeight": "normal", + "title": null + } + }, + "width": { + "step": 32 + }, + "height": { + "step": 32 + }, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ] + }, + "background": "#ffffff", + "padding": 8, + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + }, + "__theme__": "nature", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house asks for 46px categories, but both axes are banded — the marks are cells, whose size the grid settles, not the house" + }, + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~19px and the band is 32px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "legend.title", + "message": "the key is a ruler, not a list of names — without a title nothing says what its numbers count" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false (band 32px, 100 marks)" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "marks.tile", + "message": "the cells are cut apart by 0.5px — the grid reads as a table of separate readings rather than one continuous field" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/temp-heatmap.nyt.json b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.nyt.json new file mode 100644 index 00000000..226b2b1d --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.nyt.json @@ -0,0 +1,507 @@ +{ + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labelAngle": 0 + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "top", + "direction": "horizontal", + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#8a8a8a", + "gradientLength": 185, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, serif", + "fontSize": 16.5, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 15, + "subtitleFont": "Georgia, serif", + "subtitleFontSize": 12.5, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 8, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + }, + "trail": { + "size": 2.4 + }, + "rule": { + "strokeCap": "round" + }, + "header": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#6b6b6b", + "labelFontWeight": "normal", + "title": null + } + }, + "width": { + "step": 32 + }, + "height": { + "step": 32 + }, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ] + }, + "background": "#ffffff", + "padding": 12, + "layer": [ + { + "mark": { + "type": "rect", + "stroke": "#ffffff", + "strokeWidth": 1 + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": { + "title": null + } + }, + "y": { + "field": "City", + "type": "nominal", + "sort": null, + "axis": { + "title": null + } + }, + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "scale": { + "domain": [ + -29, + 29 + ], + "range": [ + "#2f6b9a", + "#8fb4cc", + "#efece5", + "#dd9a86", + "#c2352b" + ] + } + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "Helvetica, Arial, sans-serif", + "fontSize": 10.5, + "fontWeight": 700, + "align": "center", + "baseline": "middle" + }, + "encoding": { + "text": { + "field": "Temp (°C)", + "type": "quantitative", + "format": "~s" + }, + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ] + }, + "y": { + "field": "City", + "type": "nominal", + "sort": null + }, + "color": { + "condition": { + "test": "(datum[\"Temp (°C)\"] >= -9 && datum[\"Temp (°C)\"] <= 1.6875) || (datum[\"Temp (°C)\"] >= 17.125 && datum[\"Temp (°C)\"] <= 29)", + "value": "#ffffff" + }, + "value": "#121212" + } + } + } + ], + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + }, + "__theme__": "nyt", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "structure.axis.categorical.line", + "message": "no axis carries a measure — the cells are the structure, and a rule under their names is a line under nothing" + }, + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~18px and the band is 32px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "legend.placement", + "message": "`seriesEnd` not available for this chart — falling through" + }, + { + "stage": "ground", + "path": "structure.axis.measure.suppressWhenValuesPrinted", + "message": "measure axis removed — every mark prints its own value" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "marks.tile", + "message": "the cells are cut apart by 1px — the grid reads as a table of separate readings rather than one continuous field" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 185px — under half the 411px block, so the key stays a caption to the chart" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/temp-heatmap.powerbi.json b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.powerbi.json new file mode 100644 index 00000000..8409058f --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/temp-heatmap.powerbi.json @@ -0,0 +1,442 @@ +{ + "mark": { + "type": "rect", + "stroke": "#1b1a19", + "strokeWidth": 1 + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": { + "title": null + } + }, + "y": { + "field": "City", + "type": "nominal", + "sort": null, + "axis": { + "title": null + } + }, + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "scale": { + "domain": [ + -29, + 29 + ], + "range": [ + "#118dff", + "#5aa9f0", + "#4a4948", + "#e08a4a", + "#d64550" + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelAngle": 0 + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "labelPadding": 7, + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#c8c6c4", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "orient": "right", + "direction": "vertical", + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelColor": "#c8c6c4", + "titleFont": "'Segoe UI', system-ui, sans-serif", + "titleColor": "#a19f9d", + "gradientLength": 90, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#1b1a19", + "font": "'Segoe UI', system-ui, sans-serif", + "title": { + "font": "'Segoe UI', system-ui, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 11, + "subtitleFont": "'Segoe UI', system-ui, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#c8c6c4", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.2, + "strokeCap": "square" + }, + "trail": { + "size": 2.2 + }, + "rule": { + "strokeCap": "square" + }, + "header": { + "labelFont": "'Segoe UI', system-ui, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#c8c6c4", + "labelFontWeight": "normal", + "title": null + } + }, + "width": { + "step": 32 + }, + "height": { + "step": 32 + }, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ] + }, + "background": "#1b1a19", + "padding": 8, + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + }, + "__theme__": "powerbi", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "axes.x.label.angle", + "message": "the widest name needs ~18px and the band is 32px — at the house's label size they read straight" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false (band 32px, 100 marks)" + }, + { + "stage": "realize", + "path": "marks.bandFraction", + "message": "the marks are cells in a grid, not bars in a row — band occupancy is a bar rule and does not apply" + }, + { + "stage": "realize", + "path": "marks.tile", + "message": "the cells are cut apart by 1px — the grid reads as a table of separate readings rather than one continuous field" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/temp-uncertainty.nature.json b/site/src/playground/theme-lab-assets/compiled/temp-uncertainty.nature.json new file mode 100644 index 00000000..63d6e982 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/temp-uncertainty.nature.json @@ -0,0 +1,225 @@ +{ + "mark": { + "type": "area", + "opacity": 0.5, + "line": { + "strokeWidth": 1 + }, + "color": "#0072b2" + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Lower (°C)", + "type": "quantitative", + "scale": { + "zero": false, + "nice": true + }, + "axis": { + "format": ",.12~g" + } + }, + "y2": { + "field": "Upper (°C)" + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 5, + "labelFont": "Arial, Helvetica, sans-serif", + "labelColor": "#000000", + "labelPadding": 4, + "titleFont": "Arial, Helvetica, sans-serif", + "titleColor": "#000000", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 6 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + }, + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 12.5, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 11, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 10.5, + "subtitleFontStyle": "italic", + "subtitleColor": "#000000", + "subtitlePadding": 6, + "frame": "bounds" + }, + "line": { + "strokeWidth": 1.2, + "point": { + "filled": true, + "size": 45, + "stroke": "#ffffff", + "strokeWidth": 0.6 + } + }, + "trail": { + "size": 1.2 + }, + "point": { + "size": 45, + "filled": true + }, + "circle": { + "size": 45, + "filled": true + }, + "square": { + "size": 45, + "filled": true + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#000000", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "The record gets more certain as it gets warmer", + "subtitle": [ + "Global mean temperature anomaly against 1961–1990, with 95% confidence interval, °C" + ] + }, + "background": "#ffffff", + "padding": 8, + "data": { + "values": [ + { + "Year": "1850", + "Anomaly (°C)": -0.42, + "Lower (°C)": -0.62, + "Upper (°C)": -0.22 + }, + { + "Year": "1870", + "Anomaly (°C)": -0.36, + "Lower (°C)": -0.53, + "Upper (°C)": -0.19 + }, + { + "Year": "1890", + "Anomaly (°C)": -0.42, + "Lower (°C)": -0.56, + "Upper (°C)": -0.28 + }, + { + "Year": "1910", + "Anomaly (°C)": -0.44, + "Lower (°C)": -0.55, + "Upper (°C)": -0.33 + }, + { + "Year": "1930", + "Anomaly (°C)": -0.16, + "Lower (°C)": -0.25, + "Upper (°C)": -0.07 + }, + { + "Year": "1950", + "Anomaly (°C)": -0.17, + "Lower (°C)": -0.24, + "Upper (°C)": -0.1 + }, + { + "Year": "1970", + "Anomaly (°C)": -0.08, + "Lower (°C)": -0.14, + "Upper (°C)": -0.02 + }, + { + "Year": "1990", + "Anomaly (°C)": 0.25, + "Lower (°C)": 0.2, + "Upper (°C)": 0.3 + }, + { + "Year": "2010", + "Anomaly (°C)": 0.56, + "Lower (°C)": 0.52, + "Upper (°C)": 0.6 + }, + { + "Year": "2023", + "Anomaly (°C)": 1.11, + "Lower (°C)": 1.07, + "Upper (°C)": 1.15 + } + ] + }, + "__theme__": "nature", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "layout.bandStep", + "message": "the house gives each category 46px" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "marks.point.size", + "message": "a dot is drawn at 45px² wherever one is drawn — the house's size, not the renderer's, and the layout may still shrink it" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/trust-likert.datawrapper.json b/site/src/playground/theme-lab-assets/compiled/trust-likert.datawrapper.json new file mode 100644 index 00000000..15239677 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/trust-likert.datawrapper.json @@ -0,0 +1,316 @@ +{ + "background": "#ffffff", + "padding": 12, + "title": { + "text": "Confidence in US institutions", + "subtitle": [ + "% of adults expressing each level of confidence" + ] + }, + "spacing": 6, + "vconcat": [ + { + "mark": { + "type": "bar", + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "encoding": { + "x": { + "field": "Share (%)", + "type": "quantitative", + "stack": "center", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.index === 1 ? datum.label + \"%\" : datum.label" + } + }, + "y": { + "field": "Institution", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.33999999999999997 + } + }, + "color": { + "field": "Response", + "type": "nominal", + "sort": null, + "scale": { + "domain": [ + "A great deal", + "Some", + "Not much", + "None at all" + ], + "range": [ + "#18a1cd", + "#e2a233", + "#c04a4a", + "#2d8659" + ] + } + } + }, + "height": { + "step": 23 + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "rect", + "color": "#dcdcdc" + }, + "width": 300, + "height": 1, + "data": { + "values": [ + {} + ] + } + } + ], + "resolve": { + "legend": { + "color": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12, + "titleFontSize": 11, + "grid": true, + "gridColor": "#e6e6e6", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "tickCount": 6 + }, + "axisY": { + "labelFontSize": 12, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#333333", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 12, + "titleFontSize": 12, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#999999", + "gradientLength": 135, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 12, + "labelColor": "#666666", + "labelFontWeight": "normal", + "title": null + } + }, + "data": { + "values": [ + { + "Institution": "Scientists", + "Response": "A great deal", + "Share (%)": 39 + }, + { + "Institution": "Scientists", + "Response": "Some", + "Share (%)": 45 + }, + { + "Institution": "Scientists", + "Response": "Not much", + "Share (%)": 12 + }, + { + "Institution": "Scientists", + "Response": "None at all", + "Share (%)": 4 + }, + { + "Institution": "The military", + "Response": "A great deal", + "Share (%)": 32 + }, + { + "Institution": "The military", + "Response": "Some", + "Share (%)": 43 + }, + { + "Institution": "The military", + "Response": "Not much", + "Share (%)": 18 + }, + { + "Institution": "The military", + "Response": "None at all", + "Share (%)": 7 + }, + { + "Institution": "The police", + "Response": "A great deal", + "Share (%)": 26 + }, + { + "Institution": "The police", + "Response": "Some", + "Share (%)": 44 + }, + { + "Institution": "The police", + "Response": "Not much", + "Share (%)": 21 + }, + { + "Institution": "The police", + "Response": "None at all", + "Share (%)": 9 + }, + { + "Institution": "The press", + "Response": "A great deal", + "Share (%)": 11 + }, + { + "Institution": "The press", + "Response": "Some", + "Share (%)": 32 + }, + { + "Institution": "The press", + "Response": "Not much", + "Share (%)": 34 + }, + { + "Institution": "The press", + "Response": "None at all", + "Share (%)": 23 + }, + { + "Institution": "Congress", + "Response": "A great deal", + "Share (%)": 8 + }, + { + "Institution": "Congress", + "Response": "Some", + "Share (%)": 30 + }, + { + "Institution": "Congress", + "Response": "Not much", + "Share (%)": 38 + }, + { + "Institution": "Congress", + "Response": "None at all", + "Share (%)": 24 + } + ] + }, + "__theme__": "datawrapper", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "dataLabels.show", + "message": "`whenTheyFit` resolved to false — no banded axis to key values to" + }, + { + "stage": "realize", + "path": "axes.x.unit", + "message": "the unit `%` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 135px — under half the 300px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "furniture", + "message": "the footerRule runs the width of the block — 300px — not a fixed stub" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/us-pyramid.datawrapper.json b/site/src/playground/theme-lab-assets/compiled/us-pyramid.datawrapper.json new file mode 100644 index 00000000..8f7ad793 --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/us-pyramid.datawrapper.json @@ -0,0 +1,475 @@ +{ + "spacing": 0, + "resolve": { + "scale": { + "y": "shared" + } + }, + "hconcat": [ + { + "transform": [ + { + "filter": { + "field": "Sex", + "equal": "Male" + } + } + ], + "title": { + "text": "Male", + "anchor": "middle", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": "normal", + "color": "#18a1cd" + }, + "width": 209, + "height": 230, + "layer": [ + { + "mark": { + "type": "bar", + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "encoding": { + "y": { + "field": "Age", + "type": "nominal", + "sort": null, + "axis": { + "title": null + }, + "scale": { + "paddingInner": 0.33999999999999997 + } + }, + "x": { + "scale": { + "reverse": true, + "domain": [ + 0, + 34 + ] + }, + "stack": null, + "field": "Population", + "type": "quantitative", + "axis": { + "title": null + } + }, + "opacity": { + "value": 0.9 + }, + "color": { + "value": "#18a1cd" + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 11, + "align": "right", + "baseline": "middle", + "dx": -5, + "color": "#333333" + }, + "encoding": { + "text": { + "field": "Population", + "type": "quantitative" + }, + "x": { + "stack": null, + "field": "Population", + "type": "quantitative" + }, + "y": { + "field": "Age", + "type": "nominal", + "sort": null + } + }, + "transform": [ + { + "filter": "abs(datum[\"Population\"]) <= 30.88657142857143" + } + ] + }, + { + "__themeSynthetic": true, + "transform": [ + { + "filter": "abs(datum[\"Population\"]) > 30.88657142857143" + } + ], + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 11, + "align": "left", + "baseline": "middle", + "dx": 4, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "Population", + "type": "quantitative" + }, + "x": { + "stack": null, + "field": "Population", + "type": "quantitative" + }, + "y": { + "field": "Age", + "type": "nominal", + "sort": null + } + } + } + ] + }, + { + "transform": [ + { + "filter": { + "field": "Sex", + "equal": "Female" + } + } + ], + "title": { + "text": "Female", + "anchor": "middle", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 12, + "fontWeight": "normal", + "color": "#e2a233" + }, + "width": 209, + "height": 230, + "layer": [ + { + "mark": { + "type": "bar", + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "encoding": { + "y": { + "axis": null, + "field": "Age", + "type": "nominal", + "sort": null, + "scale": { + "paddingInner": 0.33999999999999997 + } + }, + "x": { + "stack": null, + "field": "Population", + "type": "quantitative", + "scale": { + "domain": [ + 0, + 34 + ] + }, + "axis": { + "title": null + } + }, + "opacity": { + "value": 0.9 + }, + "color": { + "value": "#e2a233" + } + } + }, + { + "__themeSynthetic": true, + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 11, + "align": "left", + "baseline": "middle", + "dx": 4, + "color": "#333333" + }, + "encoding": { + "text": { + "field": "Population", + "type": "quantitative" + }, + "x": { + "stack": null, + "field": "Population", + "type": "quantitative" + }, + "y": { + "field": "Age", + "type": "nominal", + "sort": null + } + }, + "transform": [ + { + "filter": "abs(datum[\"Population\"]) <= 30.88657142857143" + } + ] + }, + { + "__themeSynthetic": true, + "transform": [ + { + "filter": "abs(datum[\"Population\"]) > 30.88657142857143" + } + ], + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 11, + "align": "right", + "baseline": "middle", + "dx": -5, + "color": "#ffffff" + }, + "encoding": { + "text": { + "field": "Population", + "type": "quantitative" + }, + "x": { + "stack": null, + "field": "Population", + "type": "quantitative" + }, + "y": { + "field": "Age", + "type": "nominal", + "sort": null + } + } + } + ] + } + ], + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 12, + "titleFontSize": 11, + "grid": true, + "gridColor": "#e6e6e6", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "tickCount": 6 + }, + "axisY": { + "labelFontSize": 12, + "titleFontSize": 11, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#333333", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "labelPadding": 7, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#666666", + "titleFontWeight": "normal", + "gridDash": [ + 3, + 3 + ], + "labelLimit": 0 + }, + "legend": { + "labelFontSize": 12, + "titleFontSize": 12, + "orient": "top", + "direction": "horizontal", + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#666666", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleColor": "#999999", + "gradientLength": 101, + "title": null + }, + "facet": { + "spacing": 23 + }, + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2 + }, + "trail": { + "size": 2 + }, + "header": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 12, + "labelColor": "#666666", + "labelFontWeight": "normal", + "title": null + } + }, + "height": { + "step": 23 + }, + "title": { + "text": "A pyramid that is no longer a pyramid", + "subtitle": [ + "United States population by age and sex, 2020, millions" + ] + }, + "background": "#ffffff", + "padding": { + "left": 45, + "right": 45, + "top": 12, + "bottom": 12 + }, + "data": { + "values": [ + { + "Age": "75+", + "Sex": "Male", + "Population": 10 + }, + { + "Age": "75+", + "Sex": "Female", + "Population": 15 + }, + { + "Age": "60–74", + "Sex": "Male", + "Population": 26 + }, + { + "Age": "60–74", + "Sex": "Female", + "Population": 28 + }, + { + "Age": "45–59", + "Sex": "Male", + "Population": 30 + }, + { + "Age": "45–59", + "Sex": "Female", + "Population": 31 + }, + { + "Age": "30–44", + "Sex": "Male", + "Population": 33 + }, + { + "Age": "30–44", + "Sex": "Female", + "Population": 33 + }, + { + "Age": "15–29", + "Sex": "Male", + "Population": 34 + }, + { + "Age": "15–29", + "Sex": "Female", + "Population": 32 + }, + { + "Age": "0–14", + "Sex": "Male", + "Population": 31 + }, + { + "Age": "0–14", + "Sex": "Female", + "Population": 30 + } + ] + }, + "__theme__": "datawrapper", + "__compiled__": true, + "__report__": [ + { + "stage": "realize", + "path": "ink.series", + "message": "the series is carried by the panels of a concatenation rather than a colour channel — the house set is assigned across the panels" + }, + { + "stage": "realize", + "path": "legend.gradientLength", + "message": "the ramp runs 101px — under half the 224px block, so the key stays a caption to the chart" + }, + { + "stage": "realize", + "path": "facets.header", + "message": "the panel names are set in their own panel's ink — the name is the swatch, so no key is drawn beside it" + }, + { + "stage": "realize", + "path": "dataLabels.placement", + "message": "marks that reach the end of the scale print their label inside instead" + }, + { + "stage": "realize", + "path": "furniture", + "message": "not drawn — the chart is already a concatenation" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/compiled/us-unemployment.nyt.json b/site/src/playground/theme-lab-assets/compiled/us-unemployment.nyt.json new file mode 100644 index 00000000..060a54df --- /dev/null +++ b/site/src/playground/theme-lab-assets/compiled/us-unemployment.nyt.json @@ -0,0 +1,313 @@ +{ + "mark": { + "type": "line", + "point": true, + "color": "#2f6b9a" + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "axis": { + "title": null, + "values": [ + { + "year": 2000, + "utc": true + }, + { + "year": 2002, + "utc": true + }, + { + "year": 2004, + "utc": true + }, + { + "year": 2006, + "utc": true + }, + { + "year": 2008, + "utc": true + }, + { + "year": 2010, + "utc": true + }, + { + "year": 2012, + "utc": true + }, + { + "year": 2014, + "utc": true + }, + { + "year": 2016, + "utc": true + }, + { + "year": 2018, + "utc": true + }, + { + "year": 2020, + "utc": true + }, + { + "year": 2022, + "utc": true + }, + { + "year": 2023, + "utc": true + } + ], + "format": "%Y" + }, + "scale": { + "type": "utc" + } + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g", + "title": null, + "labelExpr": "datum.index === 1 ? datum.label + \"%\" : datum.label" + } + } + }, + "config": { + "view": { + "continuousWidth": 326, + "continuousHeight": 240, + "stroke": null + }, + "axisX": { + "labelLimit": 0, + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": false, + "gridColor": "transparent", + "gridWidth": 0, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal" + }, + "axisY": { + "labelFontSize": 10.5, + "titleFontSize": 10.5, + "grid": true, + "gridColor": "#ececec", + "gridWidth": 1, + "domain": false, + "domainColor": "transparent", + "domainWidth": 0, + "ticks": false, + "tickColor": "transparent", + "tickWidth": 0, + "tickSize": 0, + "labelFont": "Helvetica, Arial, sans-serif", + "labelColor": "#6b6b6b", + "labelPadding": 2, + "titleFont": "Helvetica, Arial, sans-serif", + "titleColor": "#6b6b6b", + "titleFontWeight": "normal", + "labelLimit": 0, + "tickCount": 4 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 24 + }, + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, serif", + "fontSize": 16.5, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 15, + "subtitleFont": "Georgia, serif", + "subtitleFontSize": 12.5, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 8, + "frame": "bounds" + }, + "line": { + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + }, + "trail": { + "size": 2.4 + }, + "rule": { + "strokeCap": "round" + }, + "header": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#6b6b6b", + "labelFontWeight": "normal", + "title": null + } + }, + "title": { + "text": "American unemployment", + "subtitle": [ + "Annual rate, per cent of the labour force, 2000–2023" + ] + }, + "background": "#ffffff", + "padding": 12, + "data": { + "values": [ + { + "Year": "2000", + "Unemployment (%)": 4 + }, + { + "Year": "2001", + "Unemployment (%)": 4.7 + }, + { + "Year": "2002", + "Unemployment (%)": 5.8 + }, + { + "Year": "2003", + "Unemployment (%)": 6 + }, + { + "Year": "2004", + "Unemployment (%)": 5.5 + }, + { + "Year": "2005", + "Unemployment (%)": 5.1 + }, + { + "Year": "2006", + "Unemployment (%)": 4.6 + }, + { + "Year": "2007", + "Unemployment (%)": 4.6 + }, + { + "Year": "2008", + "Unemployment (%)": 5.8 + }, + { + "Year": "2009", + "Unemployment (%)": 9.3 + }, + { + "Year": "2010", + "Unemployment (%)": 9.6 + }, + { + "Year": "2011", + "Unemployment (%)": 8.9 + }, + { + "Year": "2012", + "Unemployment (%)": 8.1 + }, + { + "Year": "2013", + "Unemployment (%)": 7.4 + }, + { + "Year": "2014", + "Unemployment (%)": 6.2 + }, + { + "Year": "2015", + "Unemployment (%)": 5.3 + }, + { + "Year": "2016", + "Unemployment (%)": 4.9 + }, + { + "Year": "2017", + "Unemployment (%)": 4.4 + }, + { + "Year": "2018", + "Unemployment (%)": 3.9 + }, + { + "Year": "2019", + "Unemployment (%)": 3.7 + }, + { + "Year": "2020", + "Unemployment (%)": 8.1 + }, + { + "Year": "2021", + "Unemployment (%)": 5.3 + }, + { + "Year": "2022", + "Unemployment (%)": 3.6 + }, + { + "Year": "2023", + "Unemployment (%)": 3.6 + } + ] + }, + "__theme__": "nyt", + "__compiled__": true, + "__report__": [ + { + "stage": "ground", + "path": "chartDefaults.Line Chart.showPoints", + "message": "house rule: `showPoints` set to true" + }, + { + "stage": "ground", + "path": "dataLabels.show", + "message": "no banded axis to key values to — one number per datum would be noise, not a label" + }, + { + "stage": "realize", + "path": "axes.x.tickLabels", + "message": "sparse labels — the axis is ticked at 13 of the dates the data holds, not at round numbers between them" + }, + { + "stage": "realize", + "path": "axes.y.unit", + "message": "the unit `%` rides on the last label, where the ruler ends" + }, + { + "stage": "realize", + "path": "annotation.pointEmphasis", + "message": "the line already shows every observation — a second dot at the end would say nothing new" + } + ] +} diff --git a/site/src/playground/theme-lab-assets/diamonds.flint.json b/site/src/playground/theme-lab-assets/diamonds.flint.json new file mode 100644 index 00000000..0c2aac7e --- /dev/null +++ b/site/src/playground/theme-lab-assets/diamonds.flint.json @@ -0,0 +1,134 @@ +{ + "mark": "circle", + "encoding": { + "x": { + "field": "Carat", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Price (USD)", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 309, + "continuousHeight": 253 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Diamonds — carat vs price" + }, + "data": { + "values": [ + { + "Carat": 0.23, + "Price (USD)": 326 + }, + { + "Carat": 0.21, + "Price (USD)": 326 + }, + { + "Carat": 0.29, + "Price (USD)": 334 + }, + { + "Carat": 0.31, + "Price (USD)": 335 + }, + { + "Carat": 0.24, + "Price (USD)": 336 + }, + { + "Carat": 0.4, + "Price (USD)": 900 + }, + { + "Carat": 0.5, + "Price (USD)": 1500 + }, + { + "Carat": 0.6, + "Price (USD)": 1800 + }, + { + "Carat": 0.7, + "Price (USD)": 2500 + }, + { + "Carat": 0.8, + "Price (USD)": 3000 + }, + { + "Carat": 0.9, + "Price (USD)": 3900 + }, + { + "Carat": 1, + "Price (USD)": 5000 + }, + { + "Carat": 1.01, + "Price (USD)": 5200 + }, + { + "Carat": 1.1, + "Price (USD)": 6000 + }, + { + "Carat": 1.2, + "Price (USD)": 7200 + }, + { + "Carat": 1.3, + "Price (USD)": 8200 + }, + { + "Carat": 1.5, + "Price (USD)": 10000 + }, + { + "Carat": 1.7, + "Price (USD)": 13000 + }, + { + "Carat": 2, + "Price (USD)": 18000 + }, + { + "Carat": 2.5, + "Price (USD)": 25000 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/driving.flint.json b/site/src/playground/theme-lab-assets/driving.flint.json new file mode 100644 index 00000000..855d132f --- /dev/null +++ b/site/src/playground/theme-lab-assets/driving.flint.json @@ -0,0 +1,343 @@ +{ + "mark": { + "type": "line", + "point": true, + "interpolate": "linear", + "strokeWidth": 2 + }, + "encoding": { + "x": { + "field": "Miles/person", + "type": "quantitative", + "scale": { + "nice": true, + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Gas price", + "type": "quantitative", + "scale": { + "nice": true, + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "order": { + "field": "Year", + "type": "quantitative" + } + }, + "config": { + "view": { + "continuousWidth": 320, + "continuousHeight": 244 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 24 + } + }, + "title": { + "text": "Driving shifts into reverse", + "subtitle": [ + "Miles driven per person against the price of a gallon of gas, United States, 1956–2010" + ] + }, + "data": { + "values": [ + { + "Year": "1956", + "Miles/person": 3675, + "Gas price": 2.38 + }, + { + "Year": "1957", + "Miles/person": 3706, + "Gas price": 2.4 + }, + { + "Year": "1958", + "Miles/person": 3766, + "Gas price": 2.26 + }, + { + "Year": "1959", + "Miles/person": 3905, + "Gas price": 2.31 + }, + { + "Year": "1960", + "Miles/person": 3935, + "Gas price": 2.27 + }, + { + "Year": "1961", + "Miles/person": 3977, + "Gas price": 2.25 + }, + { + "Year": "1962", + "Miles/person": 4085, + "Gas price": 2.22 + }, + { + "Year": "1963", + "Miles/person": 4218, + "Gas price": 2.12 + }, + { + "Year": "1964", + "Miles/person": 4369, + "Gas price": 2.11 + }, + { + "Year": "1965", + "Miles/person": 4538, + "Gas price": 2.14 + }, + { + "Year": "1966", + "Miles/person": 4676, + "Gas price": 2.14 + }, + { + "Year": "1967", + "Miles/person": 4827, + "Gas price": 2.14 + }, + { + "Year": "1968", + "Miles/person": 5038, + "Gas price": 2.13 + }, + { + "Year": "1969", + "Miles/person": 5207, + "Gas price": 2.07 + }, + { + "Year": "1970", + "Miles/person": 5376, + "Gas price": 2.01 + }, + { + "Year": "1971", + "Miles/person": 5617, + "Gas price": 1.93 + }, + { + "Year": "1972", + "Miles/person": 5973, + "Gas price": 1.87 + }, + { + "Year": "1973", + "Miles/person": 6154, + "Gas price": 1.9 + }, + { + "Year": "1974", + "Miles/person": 5943, + "Gas price": 2.34 + }, + { + "Year": "1975", + "Miles/person": 6111, + "Gas price": 2.31 + }, + { + "Year": "1976", + "Miles/person": 6389, + "Gas price": 2.32 + }, + { + "Year": "1977", + "Miles/person": 6630, + "Gas price": 2.36 + }, + { + "Year": "1978", + "Miles/person": 6883, + "Gas price": 2.23 + }, + { + "Year": "1979", + "Miles/person": 6744, + "Gas price": 2.68 + }, + { + "Year": "1980", + "Miles/person": 6672, + "Gas price": 3.3 + }, + { + "Year": "1981", + "Miles/person": 6732, + "Gas price": 3.3 + }, + { + "Year": "1982", + "Miles/person": 6835, + "Gas price": 2.92 + }, + { + "Year": "1983", + "Miles/person": 6943, + "Gas price": 2.66 + }, + { + "Year": "1984", + "Miles/person": 7130, + "Gas price": 2.48 + }, + { + "Year": "1985", + "Miles/person": 7323, + "Gas price": 2.36 + }, + { + "Year": "1986", + "Miles/person": 7558, + "Gas price": 1.76 + }, + { + "Year": "1987", + "Miles/person": 7770, + "Gas price": 1.76 + }, + { + "Year": "1988", + "Miles/person": 8089, + "Gas price": 1.68 + }, + { + "Year": "1989", + "Miles/person": 8397, + "Gas price": 1.75 + }, + { + "Year": "1990", + "Miles/person": 8529, + "Gas price": 1.88 + }, + { + "Year": "1991", + "Miles/person": 8535, + "Gas price": 1.78 + }, + { + "Year": "1992", + "Miles/person": 8662, + "Gas price": 1.69 + }, + { + "Year": "1993", + "Miles/person": 8855, + "Gas price": 1.6 + }, + { + "Year": "1994", + "Miles/person": 8909, + "Gas price": 1.59 + }, + { + "Year": "1995", + "Miles/person": 9150, + "Gas price": 1.6 + }, + { + "Year": "1996", + "Miles/person": 9192, + "Gas price": 1.67 + }, + { + "Year": "1997", + "Miles/person": 9416, + "Gas price": 1.65 + }, + { + "Year": "1998", + "Miles/person": 9590, + "Gas price": 1.39 + }, + { + "Year": "1999", + "Miles/person": 9687, + "Gas price": 1.5 + }, + { + "Year": "2000", + "Miles/person": 9717, + "Gas price": 1.89 + }, + { + "Year": "2001", + "Miles/person": 9699, + "Gas price": 1.77 + }, + { + "Year": "2002", + "Miles/person": 9814, + "Gas price": 1.64 + }, + { + "Year": "2003", + "Miles/person": 9868, + "Gas price": 1.86 + }, + { + "Year": "2004", + "Miles/person": 9994, + "Gas price": 2.14 + }, + { + "Year": "2005", + "Miles/person": 10067, + "Gas price": 2.53 + }, + { + "Year": "2006", + "Miles/person": 10037, + "Gas price": 2.79 + }, + { + "Year": "2007", + "Miles/person": 10025, + "Gas price": 2.95 + }, + { + "Year": "2008", + "Miles/person": 9880, + "Gas price": 3.31 + }, + { + "Year": "2009", + "Miles/person": 9657, + "Gas price": 2.38 + }, + { + "Year": "2010", + "Miles/person": 9596, + "Gas price": 2.61 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/driving.nyt.json b/site/src/playground/theme-lab-assets/driving.nyt.json new file mode 100644 index 00000000..c5903978 --- /dev/null +++ b/site/src/playground/theme-lab-assets/driving.nyt.json @@ -0,0 +1,185 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nyt", + "__design__": [ + "Both zero baselines dropped. Flint anchors each quantity at zero, which is right for a bar but wrong here: a connected scatter is read as a *path*, and forcing both scales to zero squashes fifty-five years of travel into the top-right corner where the loops cannot be seen.", + "Every seventh point, plus the two ends of the path, given its own year label — taken from the Year field the baseline was already using only to order the path. A connected scatter is unreadable without knowing which way time runs, and a legend cannot say that, so the direct labels are load-bearing, not decoration. The rule is positional: no year is picked out for being interesting.", + "Points reduced to small white-filled dots with an accent ring rather than filled markers, so the line stays continuous through them and the labelled years still read as stops.", + "One editorial accent (#c2352b) for the whole path, at 1.8px with round joins. Flint's default blue hairline at 2px reads as a series in a set; here there is only one series and the colour is doing tone, not identity.", + "Frame removed, horizontal gridlines only, black x baseline with outward ticks. Both axes keep a title because neither 'miles per person' nor 'price of a gallon' names itself — the NYT rule deletes self-evident axis titles, not all of them.", + "Y-axis title rotated flat above the axis as a bare unit; serif headline flush left with the source line folded into a sans deck." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 20, "bottom": 6 }, + "width": 300, + "height": 210, + "title": { + "text": "Driving shifts into reverse", + "subtitle": ["Miles driven per person against the price of a gallon of gas, United States, 1956–2010"] + }, + "data": { + "values": [ + { "Year": "1956", "Miles/person": 3675, "Gas price": 2.38 }, + { "Year": "1957", "Miles/person": 3706, "Gas price": 2.4 }, + { "Year": "1958", "Miles/person": 3766, "Gas price": 2.26 }, + { "Year": "1959", "Miles/person": 3905, "Gas price": 2.31 }, + { "Year": "1960", "Miles/person": 3935, "Gas price": 2.27 }, + { "Year": "1961", "Miles/person": 3977, "Gas price": 2.25 }, + { "Year": "1962", "Miles/person": 4085, "Gas price": 2.22 }, + { "Year": "1963", "Miles/person": 4218, "Gas price": 2.12 }, + { "Year": "1964", "Miles/person": 4369, "Gas price": 2.11 }, + { "Year": "1965", "Miles/person": 4538, "Gas price": 2.14 }, + { "Year": "1966", "Miles/person": 4676, "Gas price": 2.14 }, + { "Year": "1967", "Miles/person": 4827, "Gas price": 2.14 }, + { "Year": "1968", "Miles/person": 5038, "Gas price": 2.13 }, + { "Year": "1969", "Miles/person": 5207, "Gas price": 2.07 }, + { "Year": "1970", "Miles/person": 5376, "Gas price": 2.01 }, + { "Year": "1971", "Miles/person": 5617, "Gas price": 1.93 }, + { "Year": "1972", "Miles/person": 5973, "Gas price": 1.87 }, + { "Year": "1973", "Miles/person": 6154, "Gas price": 1.9 }, + { "Year": "1974", "Miles/person": 5943, "Gas price": 2.34 }, + { "Year": "1975", "Miles/person": 6111, "Gas price": 2.31 }, + { "Year": "1976", "Miles/person": 6389, "Gas price": 2.32 }, + { "Year": "1977", "Miles/person": 6630, "Gas price": 2.36 }, + { "Year": "1978", "Miles/person": 6883, "Gas price": 2.23 }, + { "Year": "1979", "Miles/person": 6744, "Gas price": 2.68 }, + { "Year": "1980", "Miles/person": 6672, "Gas price": 3.3 }, + { "Year": "1981", "Miles/person": 6732, "Gas price": 3.3 }, + { "Year": "1982", "Miles/person": 6835, "Gas price": 2.92 }, + { "Year": "1983", "Miles/person": 6943, "Gas price": 2.66 }, + { "Year": "1984", "Miles/person": 7130, "Gas price": 2.48 }, + { "Year": "1985", "Miles/person": 7323, "Gas price": 2.36 }, + { "Year": "1986", "Miles/person": 7558, "Gas price": 1.76 }, + { "Year": "1987", "Miles/person": 7770, "Gas price": 1.76 }, + { "Year": "1988", "Miles/person": 8089, "Gas price": 1.68 }, + { "Year": "1989", "Miles/person": 8397, "Gas price": 1.75 }, + { "Year": "1990", "Miles/person": 8529, "Gas price": 1.88 }, + { "Year": "1991", "Miles/person": 8535, "Gas price": 1.78 }, + { "Year": "1992", "Miles/person": 8662, "Gas price": 1.69 }, + { "Year": "1993", "Miles/person": 8855, "Gas price": 1.6 }, + { "Year": "1994", "Miles/person": 8909, "Gas price": 1.59 }, + { "Year": "1995", "Miles/person": 9150, "Gas price": 1.6 }, + { "Year": "1996", "Miles/person": 9192, "Gas price": 1.67 }, + { "Year": "1997", "Miles/person": 9416, "Gas price": 1.65 }, + { "Year": "1998", "Miles/person": 9590, "Gas price": 1.39 }, + { "Year": "1999", "Miles/person": 9687, "Gas price": 1.5 }, + { "Year": "2000", "Miles/person": 9717, "Gas price": 1.89 }, + { "Year": "2001", "Miles/person": 9699, "Gas price": 1.77 }, + { "Year": "2002", "Miles/person": 9814, "Gas price": 1.64 }, + { "Year": "2003", "Miles/person": 9868, "Gas price": 1.86 }, + { "Year": "2004", "Miles/person": 9994, "Gas price": 2.14 }, + { "Year": "2005", "Miles/person": 10067, "Gas price": 2.53 }, + { "Year": "2006", "Miles/person": 10037, "Gas price": 2.79 }, + { "Year": "2007", "Miles/person": 10025, "Gas price": 2.95 }, + { "Year": "2008", "Miles/person": 9880, "Gas price": 3.31 }, + { "Year": "2009", "Miles/person": 9657, "Gas price": 2.38 }, + { "Year": "2010", "Miles/person": 9596, "Gas price": 2.61 } + ] + }, + "encoding": { + "x": { + "field": "Miles/person", + "type": "quantitative", + "title": "miles driven per person", + "scale": { "zero": false, "nice": true }, + "axis": { "tickCount": 5, "format": ",.0f", "labelFlush": true } + }, + "y": { + "field": "Gas price", + "type": "quantitative", + "title": "cost of a gallon of gas", + "scale": { "zero": false, "nice": true }, + "axis": { "tickCount": 4, "format": "$.2f" } + }, + "order": { "field": "Year", "type": "quantitative" } + }, + "layer": [ + { + "mark": { + "type": "line", + "color": "#c2352b", + "strokeWidth": 1.8, + "strokeJoin": "round", + "strokeCap": "round" + } + }, + { + "mark": { + "type": "point", + "filled": true, + "size": 14, + "color": "#ffffff", + "stroke": "#c2352b", + "strokeWidth": 1.2 + } + }, + { + "transform": [ + { "filter": "toNumber(datum.Year) % 7 === 0 || datum.Year === '1956' || datum.Year === '2010'" } + ], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 6, + "dy": -4, + "font": "Georgia, 'Times New Roman', Times, serif", + "fontSize": 9.5, + "color": "#121212" + }, + "encoding": { "text": { "field": "Year", "type": "nominal" } } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, 'Times New Roman', Times, serif", + "fontSize": 15, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 10, + "titleFontWeight": 400, + "titleColor": "#8e8e8e", + "domain": false, + "ticks": false, + "grid": false + }, + "axisY": { + "grid": true, + "gridColor": "#e4e4e4", + "gridWidth": 1, + "labelPadding": 4, + "titleAngle": 0, + "titleAlign": "left", + "titleAnchor": "start", + "titleBaseline": "bottom", + "titleY": -8, + "titlePadding": 0 + }, + "axisX": { + "grid": false, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": true, + "tickColor": "#121212", + "tickSize": 4, + "titleAnchor": "end", + "titlePadding": 4 + } + } +} diff --git a/site/src/playground/theme-lab-assets/earnings-education.flint.json b/site/src/playground/theme-lab-assets/earnings-education.flint.json new file mode 100644 index 00000000..592469d7 --- /dev/null +++ b/site/src/playground/theme-lab-assets/earnings-education.flint.json @@ -0,0 +1,119 @@ +{ + "mark": "bar", + "encoding": { + "x": { + "field": "Weekly earnings ($)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Education", + "type": "nominal", + "sort": null + }, + "color": { + "field": "Sex", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + }, + "yOffset": { + "field": "Sex", + "type": "nominal", + "sort": null + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "height": { + "step": 46, + "for": "position" + }, + "title": { + "text": "Median weekly earnings by education and sex, 2023", + "subtitle": [ + "US dollars, full-time wage and salary workers" + ] + }, + "data": { + "values": [ + { + "Education": "Advanced degree", + "Sex": "Men", + "Weekly earnings ($)": 2160 + }, + { + "Education": "Advanced degree", + "Sex": "Women", + "Weekly earnings ($)": 1600 + }, + { + "Education": "Bachelor's degree", + "Sex": "Men", + "Weekly earnings ($)": 1700 + }, + { + "Education": "Bachelor's degree", + "Sex": "Women", + "Weekly earnings ($)": 1290 + }, + { + "Education": "Some college", + "Sex": "Men", + "Weekly earnings ($)": 1120 + }, + { + "Education": "Some college", + "Sex": "Women", + "Weekly earnings ($)": 890 + }, + { + "Education": "High school", + "Sex": "Men", + "Weekly earnings ($)": 1000 + }, + { + "Education": "High school", + "Sex": "Women", + "Weekly earnings ($)": 790 + }, + { + "Education": "Less than high school", + "Sex": "Men", + "Weekly earnings ($)": 780 + }, + { + "Education": "Less than high school", + "Sex": "Women", + "Weekly earnings ($)": 620 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/earnings-education.mckinsey.json b/site/src/playground/theme-lab-assets/earnings-education.mckinsey.json new file mode 100644 index 00000000..a2ece70d --- /dev/null +++ b/site/src/playground/theme-lab-assets/earnings-education.mckinsey.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "mckinsey", + "__design__": [ + "Value axis deleted and every bar labelled directly with its own dollar figure. The comparison the chart is for — the gap inside each rung — is now a subtraction the reader can do in place.", + "Colour cut from tableau10 to the two house inks, navy #051C2C and brand blue #2251FF, at equal weight. The chart is a comparison of two series, so neither may be recessive; what changes is that the hues now come from one palette instead of two arbitrary points on a categorical wheel.", + "Legend kept, but reduced to one unlabelled row of two swatches at the top. With the value axis gone the colours are the only thing naming the series, so dropping the key would leave the chart unreadable; what the briefing format objects to is the boxed, titled legend block, not the key itself.", + "Sub-bar spacing moved onto the offset scale's paddingInner rather than a fixed band fraction on the mark, so every bar stays centred on its own sub-band — a mark-level band height silently pushes the bar off the row its label sits on. Set to 0: the two bars of a pair meet, so each education level reads as one unit and the only gap on the chart is the one between levels.", + "Education labels left as full phrases at 10.5pt instead of being truncated — the category is the argument, so it gets the width.", + "All gridlines, ticks, domains and axis titles removed; the currency format ($,.0f) moves onto the labels so the unit is stated once per mark." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 34, "bottom": 8 }, + "width": 210, + "title": { + "text": "Median weekly earnings by education and sex, 2023", + "subtitle": ["US dollars, full-time wage and salary workers"] + }, + "height": { "step": 22 }, + "data": { + "values": [ + { "Education": "Less than high school", "Sex": "Men", "Weekly earnings ($)": 780 }, + { "Education": "Less than high school", "Sex": "Women", "Weekly earnings ($)": 620 }, + { "Education": "High school", "Sex": "Men", "Weekly earnings ($)": 1000 }, + { "Education": "High school", "Sex": "Women", "Weekly earnings ($)": 790 }, + { "Education": "Some college", "Sex": "Men", "Weekly earnings ($)": 1120 }, + { "Education": "Some college", "Sex": "Women", "Weekly earnings ($)": 890 }, + { "Education": "Bachelor's degree", "Sex": "Men", "Weekly earnings ($)": 1700 }, + { "Education": "Bachelor's degree", "Sex": "Women", "Weekly earnings ($)": 1290 }, + { "Education": "Advanced degree", "Sex": "Men", "Weekly earnings ($)": 2160 }, + { "Education": "Advanced degree", "Sex": "Women", "Weekly earnings ($)": 1600 } + ] + }, + "encoding": { + "y": { + "field": "Education", + "type": "nominal", + "sort": ["Advanced degree", "Bachelor's degree", "Some college", "High school", "Less than high school"], + "title": null, + "scale": { "paddingInner": 0.25 }, + "axis": { "domain": false, "ticks": false, "grid": false, "labelPadding": 8 } + }, + "yOffset": { + "field": "Sex", + "type": "nominal", + "sort": ["Men", "Women"], + "scale": { "paddingInner": 0 } + }, + "x": { + "field": "Weekly earnings ($)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "domain": [0, 2600] }, + "axis": null + }, + "color": { + "field": "Sex", + "type": "nominal", + "title": null, + "sort": ["Men", "Women"], + "scale": { "domain": ["Men", "Women"], "range": ["#051c2c", "#2251ff"] }, + "legend": { + "orient": "top", + "direction": "horizontal", + "symbolType": "square", + "symbolSize": 90, + "columnPadding": 8, + "offset": 6, + "padding": 0 + } + } + }, + "layer": [ + { "mark": { "type": "bar" } }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 5, + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 9.5, + "fontWeight": 600 + }, + "encoding": { + "text": { "field": "Weekly earnings ($)", "type": "quantitative", "format": "$,.0f" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#8fa0ab", + "subtitlePadding": 7 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#051c2c", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/electricity-mix-area.economist.json b/site/src/playground/theme-lab-assets/electricity-mix-area.economist.json new file mode 100644 index 00000000..bb8c1559 --- /dev/null +++ b/site/src/playground/theme-lab-assets/electricity-mix-area.economist.json @@ -0,0 +1,149 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "economist", + "__design__": [ + "Masthead rule added above the plot as a separate vconcat row — brand furniture, no data.", + "Colour reassigned so the ramp reads dirty-to-clean — coal and gas take dark slate and rust, the low-carbon sources take the house blues — instead of tableau10's arbitrary assignment, which interleaved fossil and renewable hues. Which hue lands on which source changes; the order of the bands does not.", + "Stack order pinned to the sequence the baseline already produces (Coal → Gas → Hydro → Nuclear → Wind & solar → Other) rather than left to 'sort: null'. The spec now states the sequence instead of inheriting it from row order — the sequence itself is untouched, because what sits against the baseline is a claim about the data.", + "Y axis moved to the right edge with a 0–100 domain and % labels, which is where the paper puts a value axis so the series' left-hand start is unobstructed.", + "Legend moved from a right-hand column to a single horizontal key above the plot, listed in stack order so the key reads bottom-band-first exactly as the chart is built.", + "Area interpolation left linear but the band edges given 0.4px white strokes, so adjacent fills separate without a legend lookup at small sizes.", + "Gridlines dropped entirely: on a 100% stack the total is constant, so horizontal rules only compete with the band boundaries." + ], + "background": "#ffffff", + "padding": { "left": 6, "top": 4, "right": 8, "bottom": 6 }, + "title": { + "text": "Where the power comes from", + "subtitle": ["World electricity generation by source, % of total"] + }, + "spacing": 10, + "vconcat": [ + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#e3120b", "stroke": null }, + "width": 26, + "height": 3, + "view": { "stroke": null } + }, + { + "width": 330, + "height": 200, + "data": { + "values": [ + { "Year": "1990", "Source": "Coal", "Generation (TWh)": 4430 }, + { "Year": "1990", "Source": "Gas", "Generation (TWh)": 1780 }, + { "Year": "1990", "Source": "Hydro", "Generation (TWh)": 2160 }, + { "Year": "1990", "Source": "Nuclear", "Generation (TWh)": 2000 }, + { "Year": "1990", "Source": "Wind & solar", "Generation (TWh)": 10 }, + { "Year": "1990", "Source": "Other", "Generation (TWh)": 1580 }, + { "Year": "2000", "Source": "Coal", "Generation (TWh)": 5990 }, + { "Year": "2000", "Source": "Gas", "Generation (TWh)": 2760 }, + { "Year": "2000", "Source": "Hydro", "Generation (TWh)": 2620 }, + { "Year": "2000", "Source": "Nuclear", "Generation (TWh)": 2590 }, + { "Year": "2000", "Source": "Wind & solar", "Generation (TWh)": 80 }, + { "Year": "2000", "Source": "Other", "Generation (TWh)": 1310 }, + { "Year": "2010", "Source": "Coal", "Generation (TWh)": 8670 }, + { "Year": "2010", "Source": "Gas", "Generation (TWh)": 4760 }, + { "Year": "2010", "Source": "Hydro", "Generation (TWh)": 3440 }, + { "Year": "2010", "Source": "Nuclear", "Generation (TWh)": 2760 }, + { "Year": "2010", "Source": "Wind & solar", "Generation (TWh)": 380 }, + { "Year": "2010", "Source": "Other", "Generation (TWh)": 1520 }, + { "Year": "2020", "Source": "Coal", "Generation (TWh)": 9420 }, + { "Year": "2020", "Source": "Gas", "Generation (TWh)": 6270 }, + { "Year": "2020", "Source": "Hydro", "Generation (TWh)": 4360 }, + { "Year": "2020", "Source": "Nuclear", "Generation (TWh)": 2700 }, + { "Year": "2020", "Source": "Wind & solar", "Generation (TWh)": 2720 }, + { "Year": "2020", "Source": "Other", "Generation (TWh)": 1610 } + ] + }, + "mark": { "type": "area", "stroke": "#ffffff", "strokeWidth": 0.4, "interpolate": "linear" }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "scale": { "type": "utc" }, + "axis": { + "format": "%Y", + "values": ["1990", "2000", "2010", "2020"], + "labelFlush": true, + "grid": false, + "domain": true, + "domainColor": "#121317", + "ticks": true, + "tickColor": "#121317", + "tickSize": 4, + "labelPadding": 3 + } + }, + "y": { + "field": "Generation (TWh)", + "type": "quantitative", + "stack": "normalize", + "title": null, + "axis": { + "orient": "right", + "format": ".0%", + "tickCount": 5, + "grid": false, + "domain": false, + "ticks": false, + "labelPadding": 5 + } + }, + "color": { + "field": "Source", + "type": "nominal", + "title": null, + "sort": ["Coal", "Gas", "Hydro", "Nuclear", "Wind & solar", "Other"], + "scale": { + "domain": ["Coal", "Gas", "Hydro", "Nuclear", "Wind & solar", "Other"], + "range": ["#3f5661", "#a1655a", "#006ba2", "#7ba7b8", "#3ebcd2", "#c8b88a"] + }, + "legend": { + "orient": "top", + "direction": "horizontal", + "symbolType": "square", + "symbolSize": 90, + "columnPadding": 8, + "offset": 4, + "padding": 0 + } + }, + "order": { + "field": "Source", + "type": "nominal", + "sort": ["Coal", "Gas", "Hydro", "Nuclear", "Wind & solar", "Other"] + } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#54585a", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 9, + "labelColor": "#54585a" + }, + "legend": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 9, + "labelColor": "#121317", + "symbolStrokeWidth": 0 + } + } +} diff --git a/site/src/playground/theme-lab-assets/electricity-mix-area.flint.json b/site/src/playground/theme-lab-assets/electricity-mix-area.flint.json new file mode 100644 index 00000000..5583c68c --- /dev/null +++ b/site/src/playground/theme-lab-assets/electricity-mix-area.flint.json @@ -0,0 +1,188 @@ +{ + "mark": "area", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Generation (TWh)", + "type": "quantitative", + "stack": "normalize", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Source", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10", + "domain": [ + "Coal", + "Gas", + "Hydro", + "Nuclear", + "Wind & solar", + "Other" + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Where the power comes from", + "subtitle": [ + "World electricity generation by source, % of total" + ] + }, + "data": { + "values": [ + { + "Year": "1990", + "Source": "Coal", + "Generation (TWh)": 4430 + }, + { + "Year": "1990", + "Source": "Gas", + "Generation (TWh)": 1780 + }, + { + "Year": "1990", + "Source": "Hydro", + "Generation (TWh)": 2160 + }, + { + "Year": "1990", + "Source": "Nuclear", + "Generation (TWh)": 2000 + }, + { + "Year": "1990", + "Source": "Wind & solar", + "Generation (TWh)": 10 + }, + { + "Year": "1990", + "Source": "Other", + "Generation (TWh)": 1580 + }, + { + "Year": "2000", + "Source": "Coal", + "Generation (TWh)": 5990 + }, + { + "Year": "2000", + "Source": "Gas", + "Generation (TWh)": 2760 + }, + { + "Year": "2000", + "Source": "Hydro", + "Generation (TWh)": 2620 + }, + { + "Year": "2000", + "Source": "Nuclear", + "Generation (TWh)": 2590 + }, + { + "Year": "2000", + "Source": "Wind & solar", + "Generation (TWh)": 80 + }, + { + "Year": "2000", + "Source": "Other", + "Generation (TWh)": 1310 + }, + { + "Year": "2010", + "Source": "Coal", + "Generation (TWh)": 8670 + }, + { + "Year": "2010", + "Source": "Gas", + "Generation (TWh)": 4760 + }, + { + "Year": "2010", + "Source": "Hydro", + "Generation (TWh)": 3440 + }, + { + "Year": "2010", + "Source": "Nuclear", + "Generation (TWh)": 2760 + }, + { + "Year": "2010", + "Source": "Wind & solar", + "Generation (TWh)": 380 + }, + { + "Year": "2010", + "Source": "Other", + "Generation (TWh)": 1520 + }, + { + "Year": "2020", + "Source": "Coal", + "Generation (TWh)": 9420 + }, + { + "Year": "2020", + "Source": "Gas", + "Generation (TWh)": 6270 + }, + { + "Year": "2020", + "Source": "Hydro", + "Generation (TWh)": 4360 + }, + { + "Year": "2020", + "Source": "Nuclear", + "Generation (TWh)": 2700 + }, + { + "Year": "2020", + "Source": "Wind & solar", + "Generation (TWh)": 2720 + }, + { + "Year": "2020", + "Source": "Other", + "Generation (TWh)": 1610 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/electricity-stacked.flint.json b/site/src/playground/theme-lab-assets/electricity-stacked.flint.json new file mode 100644 index 00000000..721d180f --- /dev/null +++ b/site/src/playground/theme-lab-assets/electricity-stacked.flint.json @@ -0,0 +1,140 @@ +{ + "mark": "bar", + "encoding": { + "x": { + "field": "Country", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Share", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Source", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10", + "domain": [ + "Nuclear", + "Renewables", + "Fossil" + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "width": { + "step": 23 + }, + "title": { + "text": "Electricity generation mix by country, 2023 (%)" + }, + "data": { + "values": [ + { + "Country": "France", + "Source": "Nuclear", + "Share": 65 + }, + { + "Country": "France", + "Source": "Renewables", + "Share": 27 + }, + { + "Country": "France", + "Source": "Fossil", + "Share": 8 + }, + { + "Country": "Germany", + "Source": "Renewables", + "Share": 52 + }, + { + "Country": "Germany", + "Source": "Fossil", + "Share": 45 + }, + { + "Country": "Germany", + "Source": "Nuclear", + "Share": 3 + }, + { + "Country": "United States", + "Source": "Fossil", + "Share": 60 + }, + { + "Country": "United States", + "Source": "Nuclear", + "Share": 18 + }, + { + "Country": "United States", + "Source": "Renewables", + "Share": 22 + }, + { + "Country": "China", + "Source": "Fossil", + "Share": 62 + }, + { + "Country": "China", + "Source": "Renewables", + "Share": 33 + }, + { + "Country": "China", + "Source": "Nuclear", + "Share": 5 + }, + { + "Country": "Brazil", + "Source": "Renewables", + "Share": 89 + }, + { + "Country": "Brazil", + "Source": "Fossil", + "Share": 9 + }, + { + "Country": "Brazil", + "Source": "Nuclear", + "Share": 2 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/ev-share.datawrapper.json b/site/src/playground/theme-lab-assets/ev-share.datawrapper.json new file mode 100644 index 00000000..7b213009 --- /dev/null +++ b/site/src/playground/theme-lab-assets/ev-share.datawrapper.json @@ -0,0 +1,136 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "datawrapper", + "__design__": [ + "The key is kept but moved above the plot and set inline, reading left to right in the baseline's series order. Flint stacks it vertically down the right edge, which costs about a quarter of the frame on a chart only 300 points wide; running it along the top costs one line of type and puts the country names on the reader's path into the chart rather than beside it.", + "Which is a different answer from the two direct-labelling rows here, and the reason is width, not taste: this language targets an embed in a narrow column, where a label sitting to the right of the last point is the first thing to be clipped.", + "House palette #18A1CD, #E2A233, #C04A4A, #2D8659, pinned to the baseline's series order by an explicit domain. Flint's 'set2' pastels sit at similar lightness, so on four crossing lines they separate by hue alone; these four differ in lightness as well and survive a greyscale print.", + "Dashed #DCDCDC horizontal grid at 25-point steps, no vertical rules, no axis domain, no axis title — the subtitle already gives the unit.", + "Year axis labelled at all four observations. The sampling is uneven and this language would rather show that than round it off.", + "11pt type floor and a hairline footer rule.", + "Series order left exactly as the baseline has it: Norway, China, Germany, United States." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 16, "bottom": 8 }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": ["Norway, China, Germany and the United States, 2018–2023, per cent of new car sales"] + }, + "spacing": 8, + "vconcat": [ + { + "width": 300, + "height": 200, + "data": { + "values": [ + { "Year": "2018", "Country": "Norway", "EV share (%)": 49 }, + { "Year": "2020", "Country": "Norway", "EV share (%)": 75 }, + { "Year": "2022", "Country": "Norway", "EV share (%)": 88 }, + { "Year": "2023", "Country": "Norway", "EV share (%)": 93 }, + { "Year": "2018", "Country": "China", "EV share (%)": 4 }, + { "Year": "2020", "Country": "China", "EV share (%)": 6 }, + { "Year": "2022", "Country": "China", "EV share (%)": 29 }, + { "Year": "2023", "Country": "China", "EV share (%)": 38 }, + { "Year": "2018", "Country": "Germany", "EV share (%)": 2 }, + { "Year": "2020", "Country": "Germany", "EV share (%)": 13 }, + { "Year": "2022", "Country": "Germany", "EV share (%)": 31 }, + { "Year": "2023", "Country": "Germany", "EV share (%)": 25 }, + { "Year": "2018", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2020", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2022", "Country": "United States", "EV share (%)": 8 }, + { "Year": "2023", "Country": "United States", "EV share (%)": 10 } + ] + }, + "mark": { "type": "line", "strokeWidth": 2.2 }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "scale": { "type": "utc" }, + "axis": { + "values": ["2018", "2020", "2022", "2023"], + "format": "%Y", + "grid": false, + "domain": false, + "ticks": false, + "labelPadding": 5 + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 100] }, + "axis": { + "values": [0, 25, 50, 75, 100], + "format": "d", + "grid": true, + "gridColor": "#dcdcdc", + "gridDash": [2, 2], + "domain": false, + "ticks": false, + "labelPadding": 4 + } + }, + "color": { + "field": "Country", + "type": "nominal", + "title": null, + "scale": { + "domain": ["Norway", "China", "Germany", "United States"], + "range": ["#18a1cd", "#e2a233", "#c04a4a", "#2d8659"] + }, + "legend": { + "orient": "top", + "direction": "horizontal", + "anchor": "start", + "labelFontSize": 11, + "labelColor": "#333333", + "symbolType": "stroke", + "symbolStrokeWidth": 3, + "symbolSize": 90, + "offset": 4, + "padding": 0 + } + } + } + }, + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#dcdcdc", "stroke": null }, + "width": 320, + "height": 1, + "view": { "stroke": null } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 12, + "lineHeight": 18, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "subtitleLineHeight": 15 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 11, + "labelColor": "#333333", + "labelPadding": 5, + "grid": false, + "domain": false, + "ticks": false + }, + "legend": { "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/ev-share.economist.json b/site/src/playground/theme-lab-assets/ev-share.economist.json new file mode 100644 index 00000000..086b677e --- /dev/null +++ b/site/src/playground/theme-lab-assets/ev-share.economist.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "economist", + "__design__": [ + "The legend is deleted and each line is named at its end — the same conclusion the NYT row reaches on this data, by the same reasoning. Worth recording as a convergence rather than dressing up as a difference: on a ranked bar these two languages disagreed about whether to print numbers, and on four separated lines they do not disagree at all. The design decision is forced by the chart, not by the house.", + "Year axis cut to its endpoints, 2018 and 2023. The series is four unevenly spaced observations, so a tick at every one implies the gaps are equal; labelling only the span states the period and leaves the line to describe the path.", + "Horizontal grid at #C9D3DA in 25-point steps, no vertical rules, no axis domain. The grid is the same weight this language uses on every other time series here, which is what makes the rows comparable at all.", + "Palette drawn from the house set — #006BA2, #3EBCD2, #A1655A, #7BA7B8 — in the baseline's series order, pinned by an explicit colour domain so the assignment cannot drift. The red is spent on the masthead tab and nothing else, so no country reads as flagged.", + "Series order left exactly as the baseline has it: Norway, China, Germany, United States.", + "Red 26×3 masthead tab, 9.5pt labels, no frame: the subtitle already states the unit, so an axis title would be the same words twice." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 86, "bottom": 8 }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": ["Norway, China, Germany and the United States, 2018–2023, per cent of new car sales"] + }, + "spacing": 8, + "vconcat": [ + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#e3120b", "stroke": null }, + "width": 26, + "height": 3, + "view": { "stroke": null } + }, + { + "width": 290, + "height": 200, + "data": { + "values": [ + { "Year": "2018", "Country": "Norway", "EV share (%)": 49 }, + { "Year": "2020", "Country": "Norway", "EV share (%)": 75 }, + { "Year": "2022", "Country": "Norway", "EV share (%)": 88 }, + { "Year": "2023", "Country": "Norway", "EV share (%)": 93 }, + { "Year": "2018", "Country": "China", "EV share (%)": 4 }, + { "Year": "2020", "Country": "China", "EV share (%)": 6 }, + { "Year": "2022", "Country": "China", "EV share (%)": 29 }, + { "Year": "2023", "Country": "China", "EV share (%)": 38 }, + { "Year": "2018", "Country": "Germany", "EV share (%)": 2 }, + { "Year": "2020", "Country": "Germany", "EV share (%)": 13 }, + { "Year": "2022", "Country": "Germany", "EV share (%)": 31 }, + { "Year": "2023", "Country": "Germany", "EV share (%)": 25 }, + { "Year": "2018", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2020", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2022", "Country": "United States", "EV share (%)": 8 }, + { "Year": "2023", "Country": "United States", "EV share (%)": 10 } + ] + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "scale": { "type": "utc" }, + "axis": { + "values": ["2018", "2023"], + "format": "%Y", + "grid": false, + "domain": false, + "ticks": false, + "labelPadding": 4 + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 100] }, + "axis": { + "values": [0, 25, 50, 75, 100], + "format": "d", + "grid": true, + "gridColor": "#c9d3da", + "domain": false, + "ticks": false + } + }, + "color": { + "field": "Country", + "type": "nominal", + "legend": null, + "scale": { + "domain": ["Norway", "China", "Germany", "United States"], + "range": ["#006ba2", "#3ebcd2", "#a1655a", "#7ba7b8"] + } + } + }, + "layer": [ + { "mark": { "type": "line", "strokeWidth": 2 } }, + { + "transform": [{ "filter": "utcyear(datum.Year) === 2023" }], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 6, + "fontSize": 9.5, + "fontWeight": 600 + }, + "encoding": { "text": { "field": "Country", "type": "nominal" } } + } + ] + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#54585a", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#54585a", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/ev-share.flint.json b/site/src/playground/theme-lab-assets/ev-share.flint.json new file mode 100644 index 00000000..81e4936f --- /dev/null +++ b/site/src/playground/theme-lab-assets/ev-share.flint.json @@ -0,0 +1,139 @@ +{ + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": [ + "Norway, China, Germany and the United States, 2018–2023, per cent of new car sales" + ] + }, + "data": { + "values": [ + { + "Year": "2018", + "Country": "Norway", + "EV share (%)": 49 + }, + { + "Year": "2020", + "Country": "Norway", + "EV share (%)": 75 + }, + { + "Year": "2022", + "Country": "Norway", + "EV share (%)": 88 + }, + { + "Year": "2023", + "Country": "Norway", + "EV share (%)": 93 + }, + { + "Year": "2018", + "Country": "China", + "EV share (%)": 4 + }, + { + "Year": "2020", + "Country": "China", + "EV share (%)": 6 + }, + { + "Year": "2022", + "Country": "China", + "EV share (%)": 29 + }, + { + "Year": "2023", + "Country": "China", + "EV share (%)": 38 + }, + { + "Year": "2018", + "Country": "Germany", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "Germany", + "EV share (%)": 13 + }, + { + "Year": "2022", + "Country": "Germany", + "EV share (%)": 31 + }, + { + "Year": "2023", + "Country": "Germany", + "EV share (%)": 25 + }, + { + "Year": "2018", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2020", + "Country": "United States", + "EV share (%)": 2 + }, + { + "Year": "2022", + "Country": "United States", + "EV share (%)": 8 + }, + { + "Year": "2023", + "Country": "United States", + "EV share (%)": 10 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/ev-share.mckinsey.json b/site/src/playground/theme-lab-assets/ev-share.mckinsey.json new file mode 100644 index 00000000..06f73090 --- /dev/null +++ b/site/src/playground/theme-lab-assets/ev-share.mckinsey.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "mckinsey", + "__design__": [ + "Legend deleted, series named at the line ends, and the 2023 value printed next to the name. This is the deck reading of the same problem the ranked-bar row posed: the number is the deliverable, so it goes on the page rather than being recovered from an axis. Norway 93, China 38, Germany 25, United States 10 — four figures a reader can repeat without looking twice.", + "Which makes the value axis redundant, so it is deleted along with every gridline. What remains is a single baseline rule at zero, kept because four lines rising from different starting points need a common floor to be judged against.", + "Year axis reduced to its four observed points with no domain or ticks. The series is unevenly sampled — 2018, 2020, 2022, 2023 — and a nice-numbered axis would smooth over that.", + "Palette is the house progression navy → blue → cyan → teal, pinned to the baseline's series order. All four carry similar weight: none is greyed back, because nothing in this data licenses singling a country out.", + "Series order left exactly as the baseline has it: Norway, China, Germany, United States." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 108, "bottom": 8 }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": ["Norway, China, Germany and the United States, 2018–2023, per cent of new car sales"] + }, + "width": 280, + "height": 205, + "data": { + "values": [ + { "Year": "2018", "Country": "Norway", "EV share (%)": 49 }, + { "Year": "2020", "Country": "Norway", "EV share (%)": 75 }, + { "Year": "2022", "Country": "Norway", "EV share (%)": 88 }, + { "Year": "2023", "Country": "Norway", "EV share (%)": 93 }, + { "Year": "2018", "Country": "China", "EV share (%)": 4 }, + { "Year": "2020", "Country": "China", "EV share (%)": 6 }, + { "Year": "2022", "Country": "China", "EV share (%)": 29 }, + { "Year": "2023", "Country": "China", "EV share (%)": 38 }, + { "Year": "2018", "Country": "Germany", "EV share (%)": 2 }, + { "Year": "2020", "Country": "Germany", "EV share (%)": 13 }, + { "Year": "2022", "Country": "Germany", "EV share (%)": 31 }, + { "Year": "2023", "Country": "Germany", "EV share (%)": 25 }, + { "Year": "2018", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2020", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2022", "Country": "United States", "EV share (%)": 8 }, + { "Year": "2023", "Country": "United States", "EV share (%)": 10 } + ] + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "scale": { "type": "utc" }, + "axis": { + "values": ["2018", "2020", "2022", "2023"], + "format": "%Y", + "grid": false, + "domain": false, + "ticks": false, + "labelFontSize": 10, + "labelColor": "#051c2c", + "labelPadding": 6 + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 100] }, + "axis": null + }, + "color": { + "field": "Country", + "type": "nominal", + "legend": null, + "scale": { + "domain": ["Norway", "China", "Germany", "United States"], + "range": ["#051c2c", "#2251ff", "#00a9f4", "#3ebcb6"] + } + } + }, + "layer": [ + { + "mark": { "type": "rule", "color": "#051c2c", "strokeWidth": 1 }, + "encoding": { "y": { "datum": 0 }, "color": null } + }, + { "mark": { "type": "line", "strokeWidth": 2.2 } }, + { "mark": { "type": "point", "filled": true, "size": 30 } }, + { + "transform": [{ "filter": "utcyear(datum.Year) === 2023" }], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 8, + "fontSize": 10, + "fontWeight": 600 + }, + "encoding": { + "text": { + "field": "Country", + "type": "nominal" + } + } + }, + { + "transform": [{ "filter": "utcyear(datum.Year) === 2023" }], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 8, + "dy": 12, + "fontSize": 10, + "fontWeight": 400 + }, + "encoding": { + "text": { "field": "EV share (%)", "type": "quantitative", "format": "d" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#5c6b75", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#051c2c", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/ev-share.nature.json b/site/src/playground/theme-lab-assets/ev-share.nature.json new file mode 100644 index 00000000..a43a5c31 --- /dev/null +++ b/site/src/playground/theme-lab-assets/ev-share.nature.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nature", + "__design__": [ + "The key is kept and each series is given a shape as well as a hue. Both direct-labelling languages here delete the legend; a journal figure cannot, because it is reproduced at column width, printed in greyscale often enough to matter, and read by people for whom #0072B2 and #009E73 may be the same colour. Encoding country twice — colour and marker — is redundant on a good screen and load-bearing everywhere else. The legend then has to exist to decode the shape.", + "Palette is the Okabe-Ito colour-vision-safe set, pinned to the baseline's series order by an explicit domain. Flint's default 'set2' scheme is pastel and its greens and oranges converge under the two commonest forms of colour blindness.", + "Both axes titled and both scales anchored by a domain line with outward ticks. Every other row here drops the axis title because the subtitle names the unit; a figure that will be lifted out of its article and cited on its own cannot rely on the caption being carried with it.", + "No gridlines. At 85 mm the panel is small and the four lines cross twice in the middle years, so horizontal rules would add interruptions exactly where the data is hardest to follow.", + "Type at 8.5pt throughout, which is what makes room for a legend beside a 215-point plot without the figure growing.", + "Series order left exactly as the baseline has it: Norway, China, Germany, United States." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 12, "bottom": 6 }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": ["Norway, China, Germany and the United States, 2018–2023, per cent of new car sales"] + }, + "width": 215, + "height": 175, + "data": { + "values": [ + { "Year": "2018", "Country": "Norway", "EV share (%)": 49 }, + { "Year": "2020", "Country": "Norway", "EV share (%)": 75 }, + { "Year": "2022", "Country": "Norway", "EV share (%)": 88 }, + { "Year": "2023", "Country": "Norway", "EV share (%)": 93 }, + { "Year": "2018", "Country": "China", "EV share (%)": 4 }, + { "Year": "2020", "Country": "China", "EV share (%)": 6 }, + { "Year": "2022", "Country": "China", "EV share (%)": 29 }, + { "Year": "2023", "Country": "China", "EV share (%)": 38 }, + { "Year": "2018", "Country": "Germany", "EV share (%)": 2 }, + { "Year": "2020", "Country": "Germany", "EV share (%)": 13 }, + { "Year": "2022", "Country": "Germany", "EV share (%)": 31 }, + { "Year": "2023", "Country": "Germany", "EV share (%)": 25 }, + { "Year": "2018", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2020", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2022", "Country": "United States", "EV share (%)": 8 }, + { "Year": "2023", "Country": "United States", "EV share (%)": 10 } + ] + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": "Year", + "scale": { "type": "utc" }, + "axis": { + "values": ["2018", "2020", "2022", "2023"], + "format": "%Y", + "grid": false, + "domain": true, + "domainColor": "#000000", + "ticks": true, + "tickColor": "#000000", + "tickSize": 3.5, + "labelFontSize": 8.5, + "labelColor": "#000000", + "titleFontSize": 9, + "titleFontWeight": 400, + "titleColor": "#000000", + "titlePadding": 4 + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "title": "EV share of new sales (%)", + "scale": { "zero": true, "nice": false, "domain": [0, 100] }, + "axis": { + "values": [0, 25, 50, 75, 100], + "format": "d", + "grid": false, + "domain": true, + "domainColor": "#000000", + "ticks": true, + "tickColor": "#000000", + "tickSize": 3.5, + "labelFontSize": 8.5, + "labelColor": "#000000", + "titleFontSize": 9, + "titleFontWeight": 400, + "titleColor": "#000000", + "titlePadding": 4 + } + }, + "color": { + "field": "Country", + "type": "nominal", + "title": null, + "scale": { + "domain": ["Norway", "China", "Germany", "United States"], + "range": ["#0072b2", "#e69f00", "#009e73", "#cc79a7"] + }, + "legend": { + "orient": "right", + "labelFontSize": 8.5, + "labelColor": "#000000", + "symbolStrokeWidth": 1.2, + "symbolSize": 40, + "offset": 6 + } + }, + "shape": { + "field": "Country", + "type": "nominal", + "title": null, + "scale": { + "domain": ["Norway", "China", "Germany", "United States"], + "range": ["circle", "triangle-up", "square", "diamond"] + }, + "legend": { + "orient": "right", + "labelFontSize": 8.5, + "labelColor": "#000000", + "symbolStrokeWidth": 1.2, + "symbolSize": 40, + "offset": 6 + } + } + }, + "layer": [ + { "mark": { "type": "line", "strokeWidth": 1.2 } }, + { "mark": { "type": "point", "filled": true, "size": 28 } } + ], + "config": { + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 6, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 8, + "subtitleFontStyle": "italic", + "subtitleColor": "#3c3c3c", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "Arial, Helvetica, sans-serif", + "titleFont": "Arial, Helvetica, sans-serif" + }, + "legend": { "labelFont": "Arial, Helvetica, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/ev-share.nyt.json b/site/src/playground/theme-lab-assets/ev-share.nyt.json new file mode 100644 index 00000000..0af029be --- /dev/null +++ b/site/src/playground/theme-lab-assets/ev-share.nyt.json @@ -0,0 +1,116 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nyt", + "__design__": [ + "The legend is deleted and each line is named at its own right-hand end. Flint puts a four-swatch key beside the plot, which makes reading the chart a two-step lookup: find the colour, carry it back to the line. With four series that end far apart vertically — 93, 38, 25 and 10 — the label can sit where the line stops and the lookup disappears. This is the same move as the direct value labels on the ranked-bar row, applied to a different channel.", + "Which means colour stops being load-bearing. It still separates the lines where they cross in the middle years, but nothing depends on the reader remembering which hue is which, so the palette can be four muted inks rather than four maximally separated ones.", + "Horizontal grid at #E4E4E4 on the value axis only, at 25-point steps, with the year axis unruled. On a percentage series the question is always 'how high', never 'when' precisely, so the grid runs one way.", + "Points marked at each observation. The series is sampled at 2018, 2020, 2022, 2023 — unevenly — and a bare line implies a continuous reading it cannot support. The dots say where the data actually is.", + "Series order left exactly as the baseline has it, Norway through United States; the colour domain is pinned to that order so the assignment cannot drift.", + "Serif headline over sans labels — the house signature, and the only place the two typefaces meet." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 86, "bottom": 8 }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": ["Norway, China, Germany and the United States, 2018–2023, per cent of new car sales"] + }, + "width": 300, + "height": 210, + "data": { + "values": [ + { "Year": "2018", "Country": "Norway", "EV share (%)": 49 }, + { "Year": "2020", "Country": "Norway", "EV share (%)": 75 }, + { "Year": "2022", "Country": "Norway", "EV share (%)": 88 }, + { "Year": "2023", "Country": "Norway", "EV share (%)": 93 }, + { "Year": "2018", "Country": "China", "EV share (%)": 4 }, + { "Year": "2020", "Country": "China", "EV share (%)": 6 }, + { "Year": "2022", "Country": "China", "EV share (%)": 29 }, + { "Year": "2023", "Country": "China", "EV share (%)": 38 }, + { "Year": "2018", "Country": "Germany", "EV share (%)": 2 }, + { "Year": "2020", "Country": "Germany", "EV share (%)": 13 }, + { "Year": "2022", "Country": "Germany", "EV share (%)": 31 }, + { "Year": "2023", "Country": "Germany", "EV share (%)": 25 }, + { "Year": "2018", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2020", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2022", "Country": "United States", "EV share (%)": 8 }, + { "Year": "2023", "Country": "United States", "EV share (%)": 10 } + ] + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "scale": { "type": "utc" }, + "axis": { + "values": ["2018", "2020", "2022", "2023"], + "format": "%Y", + "grid": false, + "domain": false, + "ticks": false, + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "labelPadding": 4 + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 100] }, + "axis": { + "values": [0, 25, 50, 75, 100], + "format": "d", + "grid": true, + "gridColor": "#e4e4e4", + "domain": false, + "ticks": false, + "labelFontSize": 10, + "labelColor": "#6b6b6b" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "legend": null, + "scale": { + "domain": ["Norway", "China", "Germany", "United States"], + "range": ["#2f6b9a", "#c2352b", "#5c8a3c", "#9a6c3f"] + } + } + }, + "layer": [ + { "mark": { "type": "line", "strokeWidth": 2 } }, + { "mark": { "type": "point", "filled": true, "size": 26 } }, + { + "transform": [{ "filter": "utcyear(datum.Year) === 2023" }], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 7, + "fontSize": 10.5, + "fontWeight": 600 + }, + "encoding": { "text": { "field": "Country", "type": "nominal" } } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, 'Times New Roman', Times, serif", + "fontSize": 15, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7 + }, + "view": { "stroke": null } + } +} diff --git a/site/src/playground/theme-lab-assets/ev-share.powerbi-light.json b/site/src/playground/theme-lab-assets/ev-share.powerbi-light.json new file mode 100644 index 00000000..4b08f754 --- /dev/null +++ b/site/src/playground/theme-lab-assets/ev-share.powerbi-light.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi-light", + "__design__": [ + "The light counterpart of the dark tile. The key is kept on the right, where this language always puts it: the series set changes when a slicer moves, and a legend that redraws in place survives that, whereas labels pinned to the last point of each line rearrange themselves every time the data changes.", + "White card with a hairline #ededed grid — pale enough not to compete with the lines, the mirror of the dark tile where a mid-grey rule had to be dropped almost to the ground for the same reason.", + "Standard #118DFF-led data palette, unmodified and pinned to the baseline's series order — on white the mid-tones separate without the brightness lift the dark ground forced.", + "Points marked at each observation. The series is sampled unevenly, and on a tile that will be hovered for a tooltip the marker is also the target the cursor is looking for.", + "Labels at 9.5pt in #605E5C, the secondary ink, subordinate to the #252423 headline.", + "Series order left exactly as the baseline has it: Norway, China, Germany, United States." + ], + "background": "#ffffff", + "padding": { "left": 10, "top": 8, "right": 14, "bottom": 8 }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": ["Norway, China, Germany and the United States, 2018–2023, per cent of new car sales"] + }, + "width": 265, + "height": 195, + "data": { + "values": [ + { "Year": "2018", "Country": "Norway", "EV share (%)": 49 }, + { "Year": "2020", "Country": "Norway", "EV share (%)": 75 }, + { "Year": "2022", "Country": "Norway", "EV share (%)": 88 }, + { "Year": "2023", "Country": "Norway", "EV share (%)": 93 }, + { "Year": "2018", "Country": "China", "EV share (%)": 4 }, + { "Year": "2020", "Country": "China", "EV share (%)": 6 }, + { "Year": "2022", "Country": "China", "EV share (%)": 29 }, + { "Year": "2023", "Country": "China", "EV share (%)": 38 }, + { "Year": "2018", "Country": "Germany", "EV share (%)": 2 }, + { "Year": "2020", "Country": "Germany", "EV share (%)": 13 }, + { "Year": "2022", "Country": "Germany", "EV share (%)": 31 }, + { "Year": "2023", "Country": "Germany", "EV share (%)": 25 }, + { "Year": "2018", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2020", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2022", "Country": "United States", "EV share (%)": 8 }, + { "Year": "2023", "Country": "United States", "EV share (%)": 10 } + ] + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "scale": { "type": "utc" }, + "axis": { + "values": ["2018", "2020", "2022", "2023"], + "format": "%Y", + "grid": false, + "domain": false, + "ticks": false, + "labelFontSize": 9.5, + "labelColor": "#605e5c", + "labelPadding": 4 + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 100] }, + "axis": { + "values": [0, 25, 50, 75, 100], + "format": "d", + "grid": true, + "gridColor": "#ededed", + "domain": false, + "ticks": false, + "labelFontSize": 9.5, + "labelColor": "#605e5c" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "title": null, + "scale": { + "domain": ["Norway", "China", "Germany", "United States"], + "range": ["#118dff", "#e66c37", "#12239e", "#e044a7"] + }, + "legend": { + "orient": "right", + "labelFontSize": 9, + "labelColor": "#605e5c", + "symbolType": "stroke", + "symbolStrokeWidth": 3, + "symbolSize": 80, + "offset": 8 + } + } + }, + "layer": [ + { "mark": { "type": "line", "strokeWidth": 2 } }, + { "mark": { "type": "point", "filled": true, "size": 28 } } + ], + "config": { + "background": "#ffffff", + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, -apple-system, sans-serif", + "title": { + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#252423", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#605e5c", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "titleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "domain": false, + "ticks": false, + "grid": false + }, + "legend": { "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/ev-share.powerbi.json b/site/src/playground/theme-lab-assets/ev-share.powerbi.json new file mode 100644 index 00000000..179346b1 --- /dev/null +++ b/site/src/playground/theme-lab-assets/ev-share.powerbi.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi", + "__design__": [ + "The key is kept, on the right, where this language always puts it. A dashboard tile is one visual among several on a canvas the user is filtering, and the series set changes when a slicer moves — a legend that redraws in place survives that, whereas labels pinned to the last point of each line rearrange themselves every time the data changes. The two direct-labelling rows here are authoring a fixed picture; this one is describing a query result.", + "Dark ground #1B1A19 with the grid dropped to #3B3A39. On a light page a grid must be pale enough not to compete with the ink; on a dark one the same problem inverts, and a mid-grey rule reads brighter than the lines it is meant to sit behind.", + "Palette lifted to survive the dark ground — #118DFF, #E66C37, #3BD1C7, #E044A7 — pinned to the baseline's series order. Flint's 'set2' pastels are chosen for white and go muddy at this brightness.", + "Points marked at each observation. The series is sampled unevenly, and on a tile that will be hovered for a tooltip the marker is also the target the cursor is looking for.", + "Labels at 9.5pt in #C8C6C4 rather than white: pure white type on near-black blooms at low brightness, and a light grey holds the contrast without the halo.", + "Series order left exactly as the baseline has it: Norway, China, Germany, United States." + ], + "background": "#1b1a19", + "padding": { "left": 10, "top": 8, "right": 14, "bottom": 8 }, + "title": { + "text": "Electric cars as a share of new sales", + "subtitle": ["Norway, China, Germany and the United States, 2018–2023, per cent of new car sales"] + }, + "width": 265, + "height": 195, + "data": { + "values": [ + { "Year": "2018", "Country": "Norway", "EV share (%)": 49 }, + { "Year": "2020", "Country": "Norway", "EV share (%)": 75 }, + { "Year": "2022", "Country": "Norway", "EV share (%)": 88 }, + { "Year": "2023", "Country": "Norway", "EV share (%)": 93 }, + { "Year": "2018", "Country": "China", "EV share (%)": 4 }, + { "Year": "2020", "Country": "China", "EV share (%)": 6 }, + { "Year": "2022", "Country": "China", "EV share (%)": 29 }, + { "Year": "2023", "Country": "China", "EV share (%)": 38 }, + { "Year": "2018", "Country": "Germany", "EV share (%)": 2 }, + { "Year": "2020", "Country": "Germany", "EV share (%)": 13 }, + { "Year": "2022", "Country": "Germany", "EV share (%)": 31 }, + { "Year": "2023", "Country": "Germany", "EV share (%)": 25 }, + { "Year": "2018", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2020", "Country": "United States", "EV share (%)": 2 }, + { "Year": "2022", "Country": "United States", "EV share (%)": 8 }, + { "Year": "2023", "Country": "United States", "EV share (%)": 10 } + ] + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "scale": { "type": "utc" }, + "axis": { + "values": ["2018", "2020", "2022", "2023"], + "format": "%Y", + "grid": false, + "domain": false, + "ticks": false, + "labelFontSize": 9.5, + "labelColor": "#c8c6c4", + "labelPadding": 4 + } + }, + "y": { + "field": "EV share (%)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 100] }, + "axis": { + "values": [0, 25, 50, 75, 100], + "format": "d", + "grid": true, + "gridColor": "#3b3a39", + "domain": false, + "ticks": false, + "labelFontSize": 9.5, + "labelColor": "#c8c6c4" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "title": null, + "scale": { + "domain": ["Norway", "China", "Germany", "United States"], + "range": ["#118dff", "#e66c37", "#3bd1c7", "#e044a7"] + }, + "legend": { + "orient": "right", + "labelFontSize": 9, + "labelColor": "#c8c6c4", + "symbolType": "stroke", + "symbolStrokeWidth": 3, + "symbolSize": 80, + "offset": 8 + } + } + }, + "layer": [ + { "mark": { "type": "line", "strokeWidth": 2 } }, + { "mark": { "type": "point", "filled": true, "size": 28 } } + ], + "config": { + "background": "#1b1a19", + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, -apple-system, sans-serif", + "title": { + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#a19f9d", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "titleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "domain": false, + "ticks": false, + "grid": false + }, + "legend": { "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/exam-ecdf.flint.json b/site/src/playground/theme-lab-assets/exam-ecdf.flint.json new file mode 100644 index 00000000..86e7e04b --- /dev/null +++ b/site/src/playground/theme-lab-assets/exam-ecdf.flint.json @@ -0,0 +1,190 @@ +{ + "mark": { + "type": "line", + "interpolate": "step-after" + }, + "transform": [ + { + "window": [ + { + "op": "count", + "field": "Score", + "as": "__ecdf_count" + } + ], + "sort": [ + { + "field": "Score", + "order": "ascending" + } + ], + "frame": [ + null, + 0 + ] + }, + { + "joinaggregate": [ + { + "op": "count", + "field": "Score", + "as": "__ecdf_total" + } + ] + }, + { + "calculate": "datum['__ecdf_count'] / datum['__ecdf_total']", + "as": "__ecdf" + } + ], + "encoding": { + "x": { + "field": "Score", + "type": "quantitative", + "scale": { + "nice": false, + "zero": false + }, + "title": "Score", + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "__ecdf", + "type": "quantitative", + "scale": { + "domain": [ + 0, + 1 + ] + }, + "title": "Cumulative proportion", + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "Empirical distribution of exam scores", + "subtitle": [ + "n = 30; each step is one score" + ] + }, + "data": { + "values": [ + { + "Score": 55 + }, + { + "Score": 62 + }, + { + "Score": 68 + }, + { + "Score": 71 + }, + { + "Score": 73 + }, + { + "Score": 74 + }, + { + "Score": 76 + }, + { + "Score": 77 + }, + { + "Score": 78 + }, + { + "Score": 79 + }, + { + "Score": 80 + }, + { + "Score": 81 + }, + { + "Score": 82 + }, + { + "Score": 83 + }, + { + "Score": 84 + }, + { + "Score": 85 + }, + { + "Score": 85 + }, + { + "Score": 86 + }, + { + "Score": 87 + }, + { + "Score": 88 + }, + { + "Score": 88 + }, + { + "Score": 89 + }, + { + "Score": 90 + }, + { + "Score": 91 + }, + { + "Score": 92 + }, + { + "Score": 93 + }, + { + "Score": 94 + }, + { + "Score": 95 + }, + { + "Score": 97 + }, + { + "Score": 99 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/exam-ecdf.nature.json b/site/src/playground/theme-lab-assets/exam-ecdf.nature.json new file mode 100644 index 00000000..7bdaf38e --- /dev/null +++ b/site/src/playground/theme-lab-assets/exam-ecdf.nature.json @@ -0,0 +1,129 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nature", + "__design__": [ + "Step interpolation forced to 'step-after'. An empirical CDF is a right-continuous step function; a straight line between observations asserts values that were never measured.", + "Curve extended to both edges of the score range so the function starts at 0 and reaches 1 inside the plot, using the existing minimum and maximum rather than letting the line float.", + "Y scale pinned to exactly [0, 1] with ticks at the quartiles (0, 0.25, 0.5, 0.75, 1) — the values a reader of an ECDF is actually looking up.", + "Observations added back as small open circles on the step, because an ECDF drawn as a bare curve conceals its own sample size.", + "Grid removed and replaced with black L-spines and outward 3.5px ticks; the axis domain is the frame.", + "Colour reduced to a single black 1.2px stroke — one series needs no hue.", + "Y title rewritten as 'Cumulative proportion' and set flat at 9pt; the axis is a proportion, not a count, and the baseline title left that implicit." + ], + "background": "#ffffff", + "padding": { "left": 4, "top": 4, "right": 8, "bottom": 4 }, + "width": 260, + "height": 200, + "title": { + "text": "Empirical distribution of exam scores", + "subtitle": ["n = 30; circles mark individual scores"] + }, + "data": { + "values": [ + { "Score": 55 }, { "Score": 62 }, { "Score": 68 }, { "Score": 71 }, + { "Score": 73 }, { "Score": 74 }, { "Score": 76 }, { "Score": 77 }, + { "Score": 78 }, { "Score": 79 }, { "Score": 80 }, { "Score": 81 }, + { "Score": 82 }, { "Score": 83 }, { "Score": 84 }, { "Score": 85 }, + { "Score": 85 }, { "Score": 86 }, { "Score": 87 }, { "Score": 88 }, + { "Score": 88 }, { "Score": 89 }, { "Score": 90 }, { "Score": 91 }, + { "Score": 92 }, { "Score": 93 }, { "Score": 94 }, { "Score": 95 }, + { "Score": 97 }, { "Score": 99 } + ] + }, + "transform": [ + { + "window": [{ "op": "count", "field": "Score", "as": "__cume" }], + "sort": [{ "field": "Score", "order": "ascending" }], + "frame": [null, 0] + }, + { "joinaggregate": [{ "op": "count", "field": "Score", "as": "__n" }] }, + { "calculate": "datum.__cume / datum.__n", "as": "Cumulative proportion" } + ], + "encoding": { + "x": { + "field": "Score", + "type": "quantitative", + "title": "Score", + "scale": { "domain": [50, 100], "nice": false }, + "axis": { + "values": [50, 60, 70, 80, 90, 100], + "grid": false, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickSize": 3.5, + "labelPadding": 2, + "titlePadding": 4 + } + }, + "y": { + "field": "Cumulative proportion", + "type": "quantitative", + "title": "Cumulative proportion", + "scale": { "domain": [0, 1], "nice": false }, + "axis": { + "values": [0, 0.25, 0.5, 0.75, 1], + "format": ".2f", + "grid": false, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickSize": 3.5, + "labelPadding": 2, + "titlePadding": 4 + } + } + }, + "layer": [ + { + "mark": { + "type": "line", + "interpolate": "step-after", + "color": "#000000", + "strokeWidth": 1.2, + "clip": true + } + }, + { + "mark": { + "type": "point", + "filled": false, + "size": 20, + "stroke": "#000000", + "strokeWidth": 0.9, + "fill": "#ffffff", + "clip": true + } + } + ], + "config": { + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Helvetica, Arial, sans-serif", + "fontSize": 10.5, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 10, + "subtitleFont": "Helvetica, Arial, sans-serif", + "subtitleFontSize": 8, + "subtitleColor": "#3c3c3c", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#000000", + "titleFont": "Helvetica, Arial, sans-serif", + "titleFontSize": 9, + "titleColor": "#000000", + "titleFontWeight": 400 + } + } +} diff --git a/site/src/playground/theme-lab-assets/faithful-density.flint.json b/site/src/playground/theme-lab-assets/faithful-density.flint.json new file mode 100644 index 00000000..df0b17be --- /dev/null +++ b/site/src/playground/theme-lab-assets/faithful-density.flint.json @@ -0,0 +1,162 @@ +{ + "mark": "area", + "transform": [ + { + "density": "Duration (min)" + } + ], + "encoding": { + "x": { + "field": "value", + "type": "quantitative", + "title": "Duration (min)", + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "density", + "type": "quantitative", + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "Old Faithful — eruption duration density" + }, + "data": { + "values": [ + { + "Duration (min)": 3.6 + }, + { + "Duration (min)": 1.8 + }, + { + "Duration (min)": 3.333 + }, + { + "Duration (min)": 2.283 + }, + { + "Duration (min)": 4.533 + }, + { + "Duration (min)": 2.883 + }, + { + "Duration (min)": 4.7 + }, + { + "Duration (min)": 3.6 + }, + { + "Duration (min)": 1.95 + }, + { + "Duration (min)": 4.35 + }, + { + "Duration (min)": 1.833 + }, + { + "Duration (min)": 3.917 + }, + { + "Duration (min)": 4.2 + }, + { + "Duration (min)": 1.75 + }, + { + "Duration (min)": 4.7 + }, + { + "Duration (min)": 2.167 + }, + { + "Duration (min)": 1.75 + }, + { + "Duration (min)": 4.8 + }, + { + "Duration (min)": 1.6 + }, + { + "Duration (min)": 4.25 + }, + { + "Duration (min)": 1.8 + }, + { + "Duration (min)": 1.75 + }, + { + "Duration (min)": 3.45 + }, + { + "Duration (min)": 3.067 + }, + { + "Duration (min)": 4.533 + }, + { + "Duration (min)": 3.6 + }, + { + "Duration (min)": 1.967 + }, + { + "Duration (min)": 4.083 + }, + { + "Duration (min)": 3.85 + }, + { + "Duration (min)": 4.433 + }, + { + "Duration (min)": 4.3 + }, + { + "Duration (min)": 4.467 + }, + { + "Duration (min)": 3.367 + }, + { + "Duration (min)": 4.033 + }, + { + "Duration (min)": 3.833 + }, + { + "Duration (min)": 2.017 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/faithful-hist.flint.json b/site/src/playground/theme-lab-assets/faithful-hist.flint.json new file mode 100644 index 00000000..9faa8ac7 --- /dev/null +++ b/site/src/playground/theme-lab-assets/faithful-hist.flint.json @@ -0,0 +1,156 @@ +{ + "mark": "bar", + "encoding": { + "x": { + "bin": true, + "field": "Duration (min)", + "type": "quantitative" + }, + "y": { + "aggregate": "count" + } + }, + "config": { + "view": { + "continuousWidth": 253, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11, + "labelAngle": -45, + "labelAlign": "right", + "labelBaseline": "top" + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "Old Faithful eruption durations", + "subtitle": [ + "Two clusters, not one: short eruptions near 2 min and long ones near 4 min" + ] + }, + "data": { + "values": [ + { + "Duration (min)": 3.6 + }, + { + "Duration (min)": 1.8 + }, + { + "Duration (min)": 3.333 + }, + { + "Duration (min)": 2.283 + }, + { + "Duration (min)": 4.533 + }, + { + "Duration (min)": 2.883 + }, + { + "Duration (min)": 4.7 + }, + { + "Duration (min)": 3.6 + }, + { + "Duration (min)": 1.95 + }, + { + "Duration (min)": 4.35 + }, + { + "Duration (min)": 1.833 + }, + { + "Duration (min)": 3.917 + }, + { + "Duration (min)": 4.2 + }, + { + "Duration (min)": 1.75 + }, + { + "Duration (min)": 4.7 + }, + { + "Duration (min)": 2.167 + }, + { + "Duration (min)": 1.75 + }, + { + "Duration (min)": 4.8 + }, + { + "Duration (min)": 1.6 + }, + { + "Duration (min)": 4.25 + }, + { + "Duration (min)": 1.8 + }, + { + "Duration (min)": 1.75 + }, + { + "Duration (min)": 3.45 + }, + { + "Duration (min)": 3.067 + }, + { + "Duration (min)": 4.533 + }, + { + "Duration (min)": 3.6 + }, + { + "Duration (min)": 1.967 + }, + { + "Duration (min)": 4.083 + }, + { + "Duration (min)": 3.85 + }, + { + "Duration (min)": 4.433 + }, + { + "Duration (min)": 4.3 + }, + { + "Duration (min)": 4.467 + }, + { + "Duration (min)": 3.367 + }, + { + "Duration (min)": 4.033 + }, + { + "Duration (min)": 3.833 + }, + { + "Duration (min)": 2.017 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/faithful-hist.nature.json b/site/src/playground/theme-lab-assets/faithful-hist.nature.json new file mode 100644 index 00000000..2fefbb52 --- /dev/null +++ b/site/src/playground/theme-lab-assets/faithful-hist.nature.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nature", + "__design__": [ + "Bin width stated rather than inferred. Flint's 'bin: true' hands the choice to d3's default, so the histogram's shape is a function of a heuristic the reader cannot see. Here it is a fixed 0.5-minute step over [1.5, 5], which gives the seven bins the square-root rule recommends for n = 36 — a number that can be written into a methods line and reproduced.", + "n printed on the axis title. A count histogram whose sample size is not stated cannot be compared with any other panel, and 36 observations is small enough that the bimodality claim depends on knowing it.", + "Bars given a white 0.5px separator and a flat Okabe–Ito blue fill. In greyscale the separator is what keeps adjacent bins from merging into one silhouette — the figure has to survive being printed without colour.", + "Frame replaced by black L-shaped spines with outward 3.5pt ticks and no gridlines. A gridline behind a bar is measuring the bar's own edge; the spine and tick do the job with less ink.", + "Both axis titles carry their unit explicitly (minutes, count), which is the one piece of chrome a figure panel is not allowed to drop.", + "Geometry cut to 89 mm column width with 8.5pt labels and 4px padding, so the panel is legible at the size it will actually be printed rather than at the size it was designed." + ], + "background": "#ffffff", + "padding": { "left": 4, "top": 4, "right": 6, "bottom": 4 }, + "width": 230, + "height": 160, + "title": { + "text": "Old Faithful eruption durations", + "subtitle": ["Two clusters, not one: short eruptions near 2 min and long ones near 4 min"] + }, + "data": { + "values": [ + { "Duration (min)": 3.6 }, + { "Duration (min)": 1.8 }, + { "Duration (min)": 3.333 }, + { "Duration (min)": 2.283 }, + { "Duration (min)": 4.533 }, + { "Duration (min)": 2.883 }, + { "Duration (min)": 4.7 }, + { "Duration (min)": 3.6 }, + { "Duration (min)": 1.95 }, + { "Duration (min)": 4.35 }, + { "Duration (min)": 1.833 }, + { "Duration (min)": 3.917 }, + { "Duration (min)": 4.2 }, + { "Duration (min)": 1.75 }, + { "Duration (min)": 4.7 }, + { "Duration (min)": 2.167 }, + { "Duration (min)": 1.75 }, + { "Duration (min)": 4.8 }, + { "Duration (min)": 1.6 }, + { "Duration (min)": 4.25 }, + { "Duration (min)": 1.8 }, + { "Duration (min)": 1.75 }, + { "Duration (min)": 3.45 }, + { "Duration (min)": 3.067 }, + { "Duration (min)": 4.533 }, + { "Duration (min)": 3.6 }, + { "Duration (min)": 1.967 }, + { "Duration (min)": 4.083 }, + { "Duration (min)": 3.85 }, + { "Duration (min)": 4.433 }, + { "Duration (min)": 4.3 }, + { "Duration (min)": 4.467 }, + { "Duration (min)": 3.367 }, + { "Duration (min)": 4.033 }, + { "Duration (min)": 3.833 }, + { "Duration (min)": 2.017 } + ] + }, + "mark": { + "type": "bar", + "color": "#0072b2", + "stroke": "#ffffff", + "strokeWidth": 0.5 + }, + "encoding": { + "x": { + "field": "Duration (min)", + "type": "quantitative", + "bin": { "step": 0.5, "extent": [1.5, 5] }, + "title": "Eruption duration (min)", + "axis": { "format": ".1f", "tickCount": 8, "labelOverlap": "greedy" } + }, + "y": { + "aggregate": "count", + "type": "quantitative", + "title": "Count (n = 36)", + "scale": { "nice": false, "domain": [0, 9] }, + "axis": { "values": [0, 3, 6, 9], "format": "d" } + } + }, + "config": { + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 6, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 8, + "subtitleFontStyle": "italic", + "subtitleColor": "#3c3c3c", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#000000", + "labelPadding": 2, + "titleFont": "Arial, Helvetica, sans-serif", + "titleFontSize": 9, + "titleFontWeight": 400, + "titleColor": "#000000", + "titlePadding": 4, + "grid": false, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 3.5 + } + } +} diff --git a/site/src/playground/theme-lab-assets/faithful.flint.json b/site/src/playground/theme-lab-assets/faithful.flint.json new file mode 100644 index 00000000..2f58731f --- /dev/null +++ b/site/src/playground/theme-lab-assets/faithful.flint.json @@ -0,0 +1,198 @@ +{ + "mark": "circle", + "encoding": { + "x": { + "field": "Duration (min)", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Waiting (min)", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 310, + "continuousHeight": 253 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Old Faithful eruptions — waiting time vs duration" + }, + "data": { + "values": [ + { + "Duration (min)": 3.6, + "Waiting (min)": 79 + }, + { + "Duration (min)": 1.8, + "Waiting (min)": 54 + }, + { + "Duration (min)": 3.333, + "Waiting (min)": 74 + }, + { + "Duration (min)": 2.283, + "Waiting (min)": 62 + }, + { + "Duration (min)": 4.533, + "Waiting (min)": 85 + }, + { + "Duration (min)": 2.883, + "Waiting (min)": 55 + }, + { + "Duration (min)": 4.7, + "Waiting (min)": 88 + }, + { + "Duration (min)": 3.6, + "Waiting (min)": 85 + }, + { + "Duration (min)": 1.95, + "Waiting (min)": 51 + }, + { + "Duration (min)": 4.35, + "Waiting (min)": 85 + }, + { + "Duration (min)": 1.833, + "Waiting (min)": 54 + }, + { + "Duration (min)": 3.917, + "Waiting (min)": 84 + }, + { + "Duration (min)": 4.2, + "Waiting (min)": 78 + }, + { + "Duration (min)": 1.75, + "Waiting (min)": 47 + }, + { + "Duration (min)": 4.7, + "Waiting (min)": 83 + }, + { + "Duration (min)": 2.167, + "Waiting (min)": 52 + }, + { + "Duration (min)": 1.75, + "Waiting (min)": 62 + }, + { + "Duration (min)": 4.8, + "Waiting (min)": 84 + }, + { + "Duration (min)": 1.6, + "Waiting (min)": 52 + }, + { + "Duration (min)": 4.25, + "Waiting (min)": 79 + }, + { + "Duration (min)": 1.8, + "Waiting (min)": 51 + }, + { + "Duration (min)": 1.75, + "Waiting (min)": 47 + }, + { + "Duration (min)": 3.45, + "Waiting (min)": 78 + }, + { + "Duration (min)": 3.067, + "Waiting (min)": 69 + }, + { + "Duration (min)": 4.533, + "Waiting (min)": 74 + }, + { + "Duration (min)": 3.6, + "Waiting (min)": 83 + }, + { + "Duration (min)": 1.967, + "Waiting (min)": 55 + }, + { + "Duration (min)": 4.083, + "Waiting (min)": 76 + }, + { + "Duration (min)": 3.85, + "Waiting (min)": 78 + }, + { + "Duration (min)": 4.433, + "Waiting (min)": 79 + }, + { + "Duration (min)": 4.3, + "Waiting (min)": 73 + }, + { + "Duration (min)": 4.467, + "Waiting (min)": 77 + }, + { + "Duration (min)": 3.367, + "Waiting (min)": 66 + }, + { + "Duration (min)": 4.033, + "Waiting (min)": 80 + }, + { + "Duration (min)": 3.833, + "Waiting (min)": 74 + }, + { + "Duration (min)": 2.017, + "Waiting (min)": 52 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/fed-funds-step.flint.json b/site/src/playground/theme-lab-assets/fed-funds-step.flint.json new file mode 100644 index 00000000..2010e33d --- /dev/null +++ b/site/src/playground/theme-lab-assets/fed-funds-step.flint.json @@ -0,0 +1,94 @@ +{ + "mark": { + "type": "line", + "interpolate": "step" + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Target rate (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 317, + "continuousHeight": 247 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Federal funds target rate", + "subtitle": [ + "Upper bound at year end, %" + ] + }, + "data": { + "values": [ + { + "Year": "2015", + "Target rate (%)": 0.5 + }, + { + "Year": "2016", + "Target rate (%)": 0.75 + }, + { + "Year": "2017", + "Target rate (%)": 1.5 + }, + { + "Year": "2018", + "Target rate (%)": 2.5 + }, + { + "Year": "2019", + "Target rate (%)": 1.75 + }, + { + "Year": "2020", + "Target rate (%)": 0.25 + }, + { + "Year": "2021", + "Target rate (%)": 0.25 + }, + { + "Year": "2022", + "Target rate (%)": 4.5 + }, + { + "Year": "2023", + "Target rate (%)": 5.5 + }, + { + "Year": "2024", + "Target rate (%)": 4.75 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/fed-funds-step.powerbi.json b/site/src/playground/theme-lab-assets/fed-funds-step.powerbi.json new file mode 100644 index 00000000..9438af4c --- /dev/null +++ b/site/src/playground/theme-lab-assets/fed-funds-step.powerbi.json @@ -0,0 +1,128 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi", + "__design__": [ + "Whole tile inverted to the #1B1A19 dashboard canvas so it sits inside a dark report page instead of punching a white hole in it.", + "Step interpolation kept but pushed to 'step-after', which is the correct semantics for a policy rate: the rate holds until the meeting that changes it, so the vertical must land on the announcement year.", + "Area fill added under the step line as the same accent at 18% opacity. A rate path has a meaningful zero, so the region between the line and zero carries information the bare stroke was throwing away.", + "Accent switched to Power BI's #118DFF at 2.2px with a square line cap, matching the tile's other visuals.", + "Y axis reduced to a 4-tick grid of 1px #3B3A39 rules with no domain and no ticks; the values get a % suffix so the unit lives on the labels instead of an axis title.", + "Latest point emphasised with a filled marker so a glancing read lands on the current rate — this uses only the last row of the same series, no new data.", + "Segoe UI at 9.5pt in #C8C6C4, the dashboard's secondary ink, with the title in #F3F2F1 at 12.5pt." + ], + "background": "#1b1a19", + "padding": { "left": 8, "top": 6, "right": 12, "bottom": 8 }, + "width": 300, + "height": 190, + "title": { + "text": "Federal funds target rate", + "subtitle": ["Upper bound at year end, %"] + }, + "data": { + "values": [ + { "Year": "2015", "Target rate (%)": 0.5 }, + { "Year": "2016", "Target rate (%)": 0.75 }, + { "Year": "2017", "Target rate (%)": 1.5 }, + { "Year": "2018", "Target rate (%)": 2.5 }, + { "Year": "2019", "Target rate (%)": 1.75 }, + { "Year": "2020", "Target rate (%)": 0.25 }, + { "Year": "2021", "Target rate (%)": 0.25 }, + { "Year": "2022", "Target rate (%)": 4.5 }, + { "Year": "2023", "Target rate (%)": 5.5 }, + { "Year": "2024", "Target rate (%)": 4.75 } + ] + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "scale": { "type": "utc" }, + "axis": { + "format": "%Y", + "tickCount": 5, + "grid": false, + "domain": true, + "domainColor": "#3b3a39", + "ticks": false, + "labelPadding": 6, + "labelColor": "#c8c6c4" + } + }, + "y": { + "field": "Target rate (%)", + "type": "quantitative", + "title": null, + "scale": { "domain": [0, 6] }, + "axis": { + "tickCount": 4, + "format": ".1f", + "labelExpr": "datum.label + '%'", + "grid": true, + "gridColor": "#3b3a39", + "gridWidth": 1, + "domain": false, + "ticks": false, + "labelPadding": 6, + "labelColor": "#c8c6c4" + } + } + }, + "layer": [ + { + "mark": { + "type": "area", + "interpolate": "step-after", + "line": false, + "color": "#118dff", + "opacity": 0.18 + } + }, + { + "mark": { + "type": "line", + "interpolate": "step-after", + "color": "#118dff", + "strokeWidth": 2.2, + "strokeCap": "square", + "strokeJoin": "miter" + } + }, + { + "transform": [ + { "window": [{ "op": "row_number", "as": "__rank" }], "sort": [{ "field": "Year", "order": "descending" }] }, + { "filter": "datum.__rank === 1" } + ], + "mark": { + "type": "point", + "filled": true, + "size": 60, + "color": "#118dff", + "stroke": "#1b1a19", + "strokeWidth": 2 + } + } + ], + "config": { + "background": "#1b1a19", + "font": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "title": { + "font": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "subtitleFontSize": 9.5, + "subtitleColor": "#8a8886", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#c8c6c4" + } + } +} diff --git a/site/src/playground/theme-lab-assets/gapminder-bubble.economist.json b/site/src/playground/theme-lab-assets/gapminder-bubble.economist.json new file mode 100644 index 00000000..a486d209 --- /dev/null +++ b/site/src/playground/theme-lab-assets/gapminder-bubble.economist.json @@ -0,0 +1,165 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "economist", + "__design__": [ + "Log-scale ticks pinned to the decades and labelled as money ($1k / $10k / $100k). Flint's ',.12~g' format on a log axis prints whatever values d3 happens to pick, which is how a log axis ends up labelled 2,000 · 5,000 · 20,000 and stops looking logarithmic at all.", + "Both legends moved to a single row above the plot: continents as small filled circles, and the size key as three named population steps. A size ramp with no stated values is decoration — the reader can see that one bubble is bigger without being able to say by how much.", + "Size range narrowed from 9–361 to 12–260 and fill opacity dropped to 0.75 with a white hairline. At 15 bubbles the baseline's largest circles swallowed their neighbours; the halo is what keeps China and India separable where they overlap.", + "Palette switched from tableau10 to four house hues, bound to the continents by name so a continent keeps the same colour in every chart in the section instead of taking whatever tableau10 hands it by row position. The order the legend lists them in still follows the baseline.", + "Gridlines kept on both axes but pushed to #dfe6ea, because a scatter is read by position in two dimensions and removing the vertical rules would leave the log axis unusable.", + "Y axis given a bare 'years' unit and the x axis title deleted — the tick labels already carry dollar signs, so naming the axis 'GDP per capita' twice adds nothing.", + "Red masthead rule, 13.5pt bold headline over a grey deck, 9.5pt labels throughout." + ], + "background": "#ffffff", + "padding": { "left": 6, "top": 4, "right": 14, "bottom": 6 }, + "title": { + "text": "Money buys years, up to a point", + "subtitle": ["Life expectancy against GDP per capita, 2018; bubble area is population"] + }, + "spacing": 8, + "resolve": { "legend": { "color": "independent", "size": "independent" } }, + "vconcat": [ + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#e3120b", "stroke": null }, + "width": 26, + "height": 3, + "view": { "stroke": null } + }, + { + "width": 290, + "height": 200, + "data": { + "values": [ + { "Country": "Norway", "GDP per capita": 64800, "Life expectancy": 82.3, "Population (M)": 5.3, "Continent": "Europe" }, + { "Country": "United States", "GDP per capita": 62600, "Life expectancy": 78.6, "Population (M)": 327, "Continent": "Americas" }, + { "Country": "Japan", "GDP per capita": 39300, "Life expectancy": 84.2, "Population (M)": 127, "Continent": "Asia" }, + { "Country": "China", "GDP per capita": 16800, "Life expectancy": 76.7, "Population (M)": 1393, "Continent": "Asia" }, + { "Country": "India", "GDP per capita": 6900, "Life expectancy": 69.4, "Population (M)": 1353, "Continent": "Asia" }, + { "Country": "Nigeria", "GDP per capita": 5300, "Life expectancy": 54.3, "Population (M)": 196, "Continent": "Africa" }, + { "Country": "Brazil", "GDP per capita": 15600, "Life expectancy": 75.7, "Population (M)": 209, "Continent": "Americas" }, + { "Country": "Germany", "GDP per capita": 50900, "Life expectancy": 81, "Population (M)": 83, "Continent": "Europe" }, + { "Country": "Ethiopia", "GDP per capita": 2000, "Life expectancy": 66.2, "Population (M)": 109, "Continent": "Africa" }, + { "Country": "Russia", "GDP per capita": 25800, "Life expectancy": 72.4, "Population (M)": 145, "Continent": "Europe" }, + { "Country": "Mexico", "GDP per capita": 19800, "Life expectancy": 75, "Population (M)": 126, "Continent": "Americas" }, + { "Country": "Indonesia", "GDP per capita": 12400, "Life expectancy": 71.5, "Population (M)": 268, "Continent": "Asia" }, + { "Country": "Qatar", "GDP per capita": 116900, "Life expectancy": 80.1, "Population (M)": 2.8, "Continent": "Asia" }, + { "Country": "South Africa", "GDP per capita": 13000, "Life expectancy": 63.9, "Population (M)": 57, "Continent": "Africa" }, + { "Country": "Bangladesh", "GDP per capita": 4200, "Life expectancy": 72.3, "Population (M)": 161, "Continent": "Asia" } + ] + }, + "mark": { + "type": "circle", + "opacity": 0.75, + "stroke": "#ffffff", + "strokeWidth": 0.8 + }, + "encoding": { + "x": { + "field": "GDP per capita", + "type": "quantitative", + "title": null, + "scale": { "type": "log", "domain": [1500, 160000], "nice": false }, + "axis": { + "values": [2000, 5000, 10000, 20000, 50000, 100000], + "labelExpr": "datum.value >= 1000 ? '$' + (datum.value / 1000) + 'k' : '$' + datum.value", + "grid": true, + "gridColor": "#dfe6ea", + "labelPadding": 4 + } + }, + "y": { + "field": "Life expectancy", + "type": "quantitative", + "title": "years", + "scale": { "zero": false, "nice": false, "domain": [50, 88] }, + "axis": { + "tickCount": 4, + "format": ".0f", + "grid": true, + "gridColor": "#dfe6ea", + "labelPadding": 4, + "titleAngle": 0, + "titleAlign": "left", + "titleAnchor": "start", + "titleBaseline": "bottom", + "titleY": -8, + "titlePadding": 0 + } + }, + "size": { + "field": "Population (M)", + "type": "quantitative", + "title": "population, m", + "scale": { "type": "sqrt", "zero": true, "range": [12, 260] }, + "legend": { + "orient": "top", + "direction": "horizontal", + "values": [100, 500, 1400], + "format": ",.0f", + "symbolFillColor": "#b8c4cc", + "symbolStrokeColor": "#8fa0ab", + "titleFontSize": 9, + "titleFontWeight": 400, + "titleColor": "#54585a", + "labelFontSize": 9, + "offset": 4, + "padding": 0 + } + }, + "color": { + "field": "Continent", + "type": "nominal", + "title": null, + "scale": { + "domain": ["Europe", "Americas", "Asia", "Africa"], + "range": ["#379a8b", "#ebb434", "#006ba2", "#e3120b"] + }, + "legend": { + "orient": "top", + "direction": "horizontal", + "symbolType": "circle", + "symbolSize": 70, + "columnPadding": 8, + "labelFontSize": 9.5, + "offset": 4, + "padding": 0 + } + } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#54585a", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#54585a", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 9.5, + "titleFontWeight": 400, + "titleColor": "#54585a", + "domain": false, + "ticks": false + }, + "legend": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelColor": "#54585a", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif" + } + } +} diff --git a/site/src/playground/theme-lab-assets/gapminder-bubble.flint.json b/site/src/playground/theme-lab-assets/gapminder-bubble.flint.json new file mode 100644 index 00000000..80a40f5d --- /dev/null +++ b/site/src/playground/theme-lab-assets/gapminder-bubble.flint.json @@ -0,0 +1,184 @@ +{ + "mark": "circle", + "encoding": { + "x": { + "field": "GDP per capita", + "type": "quantitative", + "scale": { + "type": "log" + }, + "axis": { + "gridColor": "#e8e8e8", + "gridOpacity": 0.5, + "format": ",.12~g" + } + }, + "y": { + "field": "Life expectancy", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "size": { + "field": "Population (M)", + "type": "quantitative", + "scale": { + "type": "sqrt", + "zero": true, + "range": [ + 9, + 361 + ] + } + }, + "color": { + "field": "Continent", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + } + }, + "config": { + "view": { + "continuousWidth": 309, + "continuousHeight": 253 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Money buys years, up to a point", + "subtitle": [ + "Life expectancy against GDP per capita, 2018; bubble area is population" + ] + }, + "data": { + "values": [ + { + "Country": "Norway", + "GDP per capita": 64800, + "Life expectancy": 82.3, + "Population (M)": 5.3, + "Continent": "Europe" + }, + { + "Country": "United States", + "GDP per capita": 62600, + "Life expectancy": 78.6, + "Population (M)": 327, + "Continent": "Americas" + }, + { + "Country": "Japan", + "GDP per capita": 39300, + "Life expectancy": 84.2, + "Population (M)": 127, + "Continent": "Asia" + }, + { + "Country": "China", + "GDP per capita": 16800, + "Life expectancy": 76.7, + "Population (M)": 1393, + "Continent": "Asia" + }, + { + "Country": "India", + "GDP per capita": 6900, + "Life expectancy": 69.4, + "Population (M)": 1353, + "Continent": "Asia" + }, + { + "Country": "Nigeria", + "GDP per capita": 5300, + "Life expectancy": 54.3, + "Population (M)": 196, + "Continent": "Africa" + }, + { + "Country": "Brazil", + "GDP per capita": 15600, + "Life expectancy": 75.7, + "Population (M)": 209, + "Continent": "Americas" + }, + { + "Country": "Germany", + "GDP per capita": 50900, + "Life expectancy": 81, + "Population (M)": 83, + "Continent": "Europe" + }, + { + "Country": "Ethiopia", + "GDP per capita": 2000, + "Life expectancy": 66.2, + "Population (M)": 109, + "Continent": "Africa" + }, + { + "Country": "Russia", + "GDP per capita": 25800, + "Life expectancy": 72.4, + "Population (M)": 145, + "Continent": "Europe" + }, + { + "Country": "Mexico", + "GDP per capita": 19800, + "Life expectancy": 75, + "Population (M)": 126, + "Continent": "Americas" + }, + { + "Country": "Indonesia", + "GDP per capita": 12400, + "Life expectancy": 71.5, + "Population (M)": 268, + "Continent": "Asia" + }, + { + "Country": "Qatar", + "GDP per capita": 116900, + "Life expectancy": 80.1, + "Population (M)": 2.8, + "Continent": "Asia" + }, + { + "Country": "South Africa", + "GDP per capita": 13000, + "Life expectancy": 63.9, + "Population (M)": 57, + "Continent": "Africa" + }, + { + "Country": "Bangladesh", + "GDP per capita": 4200, + "Life expectancy": 72.3, + "Population (M)": 161, + "Continent": "Asia" + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/gdp-bartable.flint.json b/site/src/playground/theme-lab-assets/gdp-bartable.flint.json new file mode 100644 index 00000000..4201a191 --- /dev/null +++ b/site/src/playground/theme-lab-assets/gdp-bartable.flint.json @@ -0,0 +1,284 @@ +{ + "spacing": 8, + "resolve": { + "scale": { + "y": "shared" + } + }, + "hconcat": [ + { + "data": { + "name": "__bt_displayTable" + }, + "width": 180, + "height": 230, + "title": { + "text": "Country", + "anchor": "start", + "offset": 6, + "fontSize": 12, + "fontWeight": "normal", + "color": "#999" + }, + "mark": { + "type": "bar", + "height": { + "band": 0.842 + } + }, + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": [ + "United States", + "China", + "Germany", + "Japan", + "India", + "UK", + "France", + "Brazil" + ], + "axis": { + "title": null, + "domain": false, + "ticks": false, + "labelFontSize": 13, + "labelAlign": "left", + "labelPadding": 105, + "labelLimit": 105 + } + }, + "x": { + "field": "GDP ($T)", + "type": "quantitative", + "axis": null, + "scale": { + "nice": false + } + }, + "color": { + "field": "GDP ($T)", + "type": "quantitative", + "legend": null, + "scale": { + "range": [ + "#cdebd3", + "#41a25f" + ] + } + } + } + }, + { + "data": { + "name": "__bt_displayTable" + }, + "width": 66, + "height": 230, + "transform": [ + { + "aggregate": [ + { + "op": "sum", + "field": "GDP ($T)", + "as": "__bt_val" + }, + { + "op": "min", + "field": "__bt_sort", + "as": "__bt_sort" + }, + { + "op": "max", + "field": "__bt_others_num", + "as": "__bt_others_num" + } + ], + "groupby": [ + "Country" + ] + } + ], + "title": { + "text": "GDP ($T)", + "anchor": "end", + "offset": 6, + "limit": 62, + "fontSize": 12, + "fontWeight": "normal", + "color": "#999" + }, + "mark": { + "type": "text", + "align": "right", + "baseline": "middle", + "fontSize": 12 + }, + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": [ + "United States", + "China", + "Germany", + "Japan", + "India", + "UK", + "France", + "Brazil" + ], + "axis": null + }, + "x": { + "datum": 1, + "axis": null, + "scale": { + "type": "linear", + "domain": [ + 0, + 1 + ] + } + }, + "text": { + "field": "__bt_val", + "type": "quantitative" + }, + "color": { + "value": "#666" + } + } + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "datasets": { + "__bt_displayTable": [ + { + "Country": "United States", + "GDP ($T)": 27.4, + "__bt_sort": 0, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "China", + "GDP ($T)": 17.8, + "__bt_sort": 1, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "Germany", + "GDP ($T)": 4.5, + "__bt_sort": 2, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "Japan", + "GDP ($T)": 4.2, + "__bt_sort": 3, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "India", + "GDP ($T)": 3.9, + "__bt_sort": 4, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "UK", + "GDP ($T)": 3.3, + "__bt_sort": 5, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "France", + "GDP ($T)": 3, + "__bt_sort": 6, + "__bt_others": false, + "__bt_others_num": 0 + }, + { + "Country": "Brazil", + "GDP ($T)": 2.2, + "__bt_sort": 7, + "__bt_others": false, + "__bt_others_num": 0 + } + ] + }, + "height": { + "step": 27 + }, + "title": { + "text": "America and China lap the field", + "subtitle": [ + "Gross domestic product, 2023, trillion US dollars" + ] + }, + "data": { + "values": [ + { + "Country": "United States", + "GDP ($T)": 27.4 + }, + { + "Country": "China", + "GDP ($T)": 17.8 + }, + { + "Country": "Germany", + "GDP ($T)": 4.5 + }, + { + "Country": "Japan", + "GDP ($T)": 4.2 + }, + { + "Country": "India", + "GDP ($T)": 3.9 + }, + { + "Country": "UK", + "GDP ($T)": 3.3 + }, + { + "Country": "France", + "GDP ($T)": 3 + }, + { + "Country": "Brazil", + "GDP ($T)": 2.2 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/gdp-bartable.mckinsey.json b/site/src/playground/theme-lab-assets/gdp-bartable.mckinsey.json new file mode 100644 index 00000000..d4224b9f --- /dev/null +++ b/site/src/playground/theme-lab-assets/gdp-bartable.mckinsey.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "mckinsey", + "__design__": [ + "The two-panel hconcat collapsed into one plot. Flint builds a bar table as a 180px chart glued to a 66px text panel driven by a fake 'x: {datum: 1}' scale and a duplicated '__bt_displayTable'. The same result — label, bar, number — is one layered spec, and the number now sits at the end of the bar it belongs to instead of in a detached column the eye has to re-associate by row.", + "One measure, one colour: every bar in a flat brand blue #2251FF. Flint maps GDP to both bar length and a #cdebd3→#41a25f ramp — the same number encoded twice, which buys no information and implies a continuous scale the reader has no key for. Length already carries the quantity.", + "Value axis deleted; the numbers are on the marks. Nothing here needs traversal to a ruler.", + "The 105px labelPadding / 105px labelLimit hack removed. Flint pushes country names 105px right and then truncates them at exactly the same width, which is how 'United States' ends up at the edge of clipping. Ordinary right-aligned axis labels do the job.", + "Row order pinned in the spec rather than inherited from row position — the rendered sequence is the baseline's — and the x domain padded to 31 so the labels sit outside the bars." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 26, "bottom": 8 }, + "title": { + "text": "America and China lap the field", + "subtitle": ["Gross domestic product, 2023, trillion US dollars"] + }, + "width": 250, + "height": 200, + "data": { + "values": [ + { "Country": "United States", "GDP ($T)": 27.4 }, + { "Country": "China", "GDP ($T)": 17.8 }, + { "Country": "Germany", "GDP ($T)": 4.5 }, + { "Country": "Japan", "GDP ($T)": 4.2 }, + { "Country": "India", "GDP ($T)": 3.9 }, + { "Country": "UK", "GDP ($T)": 3.3 }, + { "Country": "France", "GDP ($T)": 3 }, + { "Country": "Brazil", "GDP ($T)": 2.2 } + ] + }, + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": { "field": "GDP ($T)", "op": "max", "order": "descending" }, + "title": null, + "axis": { "domain": false, "ticks": false, "grid": false, "labelPadding": 6 } + }, + "x": { + "field": "GDP ($T)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 31] }, + "axis": null + } + }, + "layer": [ + { + "mark": { "type": "bar", "height": { "band": 0.62 }, "color": "#2251ff" } + }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 5, + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "color": "#051c2c" + }, + "encoding": { + "text": { "field": "GDP ($T)", "type": "quantitative", "format": ".1f" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#5c6b75", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#051c2c", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 9, + "titleColor": "#8fa0ab", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/happiness.flint.json b/site/src/playground/theme-lab-assets/happiness.flint.json new file mode 100644 index 00000000..76208e74 --- /dev/null +++ b/site/src/playground/theme-lab-assets/happiness.flint.json @@ -0,0 +1,116 @@ +{ + "mark": "circle", + "encoding": { + "x": { + "field": "GDP per capita", + "type": "quantitative", + "scale": { + "type": "log" + }, + "axis": { + "gridColor": "#e8e8e8", + "gridOpacity": 0.5, + "format": ",.12~g" + } + }, + "y": { + "field": "Happiness score", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 312, + "continuousHeight": 251 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "World Happiness vs income per capita (2023)" + }, + "data": { + "values": [ + { + "Country": "Finland", + "GDP per capita": 54000, + "Happiness score": 7.8 + }, + { + "Country": "Denmark", + "GDP per capita": 68000, + "Happiness score": 7.6 + }, + { + "Country": "United States", + "GDP per capita": 76000, + "Happiness score": 6.9 + }, + { + "Country": "Germany", + "GDP per capita": 51000, + "Happiness score": 7 + }, + { + "Country": "Japan", + "GDP per capita": 34000, + "Happiness score": 6.1 + }, + { + "Country": "Brazil", + "GDP per capita": 9000, + "Happiness score": 6.1 + }, + { + "Country": "China", + "GDP per capita": 12800, + "Happiness score": 5.8 + }, + { + "Country": "Mexico", + "GDP per capita": 11000, + "Happiness score": 6.3 + }, + { + "Country": "India", + "GDP per capita": 2400, + "Happiness score": 4 + }, + { + "Country": "Nigeria", + "GDP per capita": 2200, + "Happiness score": 4.9 + }, + { + "Country": "Kenya", + "GDP per capita": 2100, + "Happiness score": 4.5 + }, + { + "Country": "Costa Rica", + "GDP per capita": 12500, + "Happiness score": 7.1 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/internet-users.flint.json b/site/src/playground/theme-lab-assets/internet-users.flint.json new file mode 100644 index 00000000..f1f1f040 --- /dev/null +++ b/site/src/playground/theme-lab-assets/internet-users.flint.json @@ -0,0 +1,81 @@ +{ + "mark": "area", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Internet users (%)", + "type": "quantitative", + "stack": null, + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 315, + "continuousHeight": 248 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Share of the world online, 1995–2023 (%)" + }, + "data": { + "values": [ + { + "Year": "1995", + "Internet users (%)": 1 + }, + { + "Year": "2000", + "Internet users (%)": 7 + }, + { + "Year": "2005", + "Internet users (%)": 16 + }, + { + "Year": "2010", + "Internet users (%)": 29 + }, + { + "Year": "2015", + "Internet users (%)": 43 + }, + { + "Year": "2018", + "Internet users (%)": 51 + }, + { + "Year": "2020", + "Internet users (%)": 60 + }, + { + "Year": "2023", + "Internet users (%)": 67 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/iris-strip.flint.json b/site/src/playground/theme-lab-assets/iris-strip.flint.json new file mode 100644 index 00000000..376a9273 --- /dev/null +++ b/site/src/playground/theme-lab-assets/iris-strip.flint.json @@ -0,0 +1,193 @@ +{ + "mark": { + "type": "circle", + "opacity": 0.6, + "size": 100 + }, + "encoding": { + "x": { + "field": "Species", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Petal length (cm)", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "xOffset": { + "field": "__jitter", + "type": "quantitative", + "axis": null, + "scale": { + "domain": [ + -10, + 10 + ] + } + } + }, + "width": { + "step": 57 + }, + "transform": [ + { + "calculate": "-6 + random() * 12", + "as": "__jitter" + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "Iris petal length by species" + }, + "data": { + "values": [ + { + "Species": "Setosa", + "Petal length (cm)": 1.4 + }, + { + "Species": "Setosa", + "Petal length (cm)": 1.4 + }, + { + "Species": "Setosa", + "Petal length (cm)": 1.3 + }, + { + "Species": "Setosa", + "Petal length (cm)": 1.5 + }, + { + "Species": "Setosa", + "Petal length (cm)": 1.4 + }, + { + "Species": "Setosa", + "Petal length (cm)": 1.7 + }, + { + "Species": "Setosa", + "Petal length (cm)": 1.4 + }, + { + "Species": "Setosa", + "Petal length (cm)": 1.5 + }, + { + "Species": "Setosa", + "Petal length (cm)": 1.5 + }, + { + "Species": "Setosa", + "Petal length (cm)": 1.6 + }, + { + "Species": "Versicolor", + "Petal length (cm)": 4.7 + }, + { + "Species": "Versicolor", + "Petal length (cm)": 4.5 + }, + { + "Species": "Versicolor", + "Petal length (cm)": 4.9 + }, + { + "Species": "Versicolor", + "Petal length (cm)": 4 + }, + { + "Species": "Versicolor", + "Petal length (cm)": 4.6 + }, + { + "Species": "Versicolor", + "Petal length (cm)": 4.5 + }, + { + "Species": "Versicolor", + "Petal length (cm)": 4.7 + }, + { + "Species": "Versicolor", + "Petal length (cm)": 3.3 + }, + { + "Species": "Versicolor", + "Petal length (cm)": 4.6 + }, + { + "Species": "Versicolor", + "Petal length (cm)": 3.9 + }, + { + "Species": "Virginica", + "Petal length (cm)": 6 + }, + { + "Species": "Virginica", + "Petal length (cm)": 5.1 + }, + { + "Species": "Virginica", + "Petal length (cm)": 5.9 + }, + { + "Species": "Virginica", + "Petal length (cm)": 5.6 + }, + { + "Species": "Virginica", + "Petal length (cm)": 5.8 + }, + { + "Species": "Virginica", + "Petal length (cm)": 6.6 + }, + { + "Species": "Virginica", + "Petal length (cm)": 4.5 + }, + { + "Species": "Virginica", + "Petal length (cm)": 6.3 + }, + { + "Species": "Virginica", + "Petal length (cm)": 5.8 + }, + { + "Species": "Virginica", + "Petal length (cm)": 6.1 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/keeling.flint.json b/site/src/playground/theme-lab-assets/keeling.flint.json new file mode 100644 index 00000000..192fe2e0 --- /dev/null +++ b/site/src/playground/theme-lab-assets/keeling.flint.json @@ -0,0 +1,107 @@ +{ + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "CO₂ (ppm)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Keeling Curve", + "subtitle": [ + "Atmospheric CO₂ at Mauna Loa, annual mean, parts per million" + ] + }, + "data": { + "values": [ + { + "Year": "1959", + "CO₂ (ppm)": 315.98 + }, + { + "Year": "1965", + "CO₂ (ppm)": 320.04 + }, + { + "Year": "1970", + "CO₂ (ppm)": 325.68 + }, + { + "Year": "1975", + "CO₂ (ppm)": 331.11 + }, + { + "Year": "1980", + "CO₂ (ppm)": 338.8 + }, + { + "Year": "1985", + "CO₂ (ppm)": 346.12 + }, + { + "Year": "1990", + "CO₂ (ppm)": 354.45 + }, + { + "Year": "1995", + "CO₂ (ppm)": 360.82 + }, + { + "Year": "2000", + "CO₂ (ppm)": 369.71 + }, + { + "Year": "2005", + "CO₂ (ppm)": 379.98 + }, + { + "Year": "2010", + "CO₂ (ppm)": 389.9 + }, + { + "Year": "2015", + "CO₂ (ppm)": 400.83 + }, + { + "Year": "2020", + "CO₂ (ppm)": 414.24 + }, + { + "Year": "2023", + "CO₂ (ppm)": 421.08 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/keeling.nyt.json b/site/src/playground/theme-lab-assets/keeling.nyt.json new file mode 100644 index 00000000..15d29b34 --- /dev/null +++ b/site/src/playground/theme-lab-assets/keeling.nyt.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nyt", + "__design__": [ + "Title typography carries the hierarchy instead of a caption: serif (Georgia) at 15pt, flush left, with the unit split out as a sans deck. Same words as Flint's title, different weight structure.", + "Zero baseline dropped (scale.zero true → false). Flint is right to default to zero for a quantity, but a ppm record needs the top 100 ppm of range to read as a curve.", + "Frame removed: view.stroke null, horizontal gridlines only (#e4e4e4), and the y-axis domain line deleted. The x-axis keeps a black baseline with outward ticks so the time direction stays anchored.", + "Y-axis title rotated flat (titleAngle 0) and parked above the axis as a bare unit; x-axis title deleted, because a year axis labels itself.", + "Tick count cut from Flint's default to 4 on y and flush-aligned end labels on x — fewer, larger numbers rather than an even grid.", + "One editorial accent (#c2352b) at 2.5px with round caps, replacing Flint's default blue hairline." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 34, "bottom": 6 }, + "width": 300, + "height": 190, + "title": { + "text": "Keeling Curve", + "subtitle": ["Atmospheric CO₂ at Mauna Loa, annual mean, parts per million"] + }, + "data": { + "values": [ + { "Year": "1959", "CO₂ (ppm)": 315.98 }, + { "Year": "1965", "CO₂ (ppm)": 320.04 }, + { "Year": "1970", "CO₂ (ppm)": 325.68 }, + { "Year": "1975", "CO₂ (ppm)": 331.11 }, + { "Year": "1980", "CO₂ (ppm)": 338.8 }, + { "Year": "1985", "CO₂ (ppm)": 346.12 }, + { "Year": "1990", "CO₂ (ppm)": 354.45 }, + { "Year": "1995", "CO₂ (ppm)": 360.82 }, + { "Year": "2000", "CO₂ (ppm)": 369.71 }, + { "Year": "2005", "CO₂ (ppm)": 379.98 }, + { "Year": "2010", "CO₂ (ppm)": 389.9 }, + { "Year": "2015", "CO₂ (ppm)": 400.83 }, + { "Year": "2020", "CO₂ (ppm)": 414.24 }, + { "Year": "2023", "CO₂ (ppm)": 421.08 } + ] + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "axis": { "format": "%Y", "tickCount": 5 } + }, + "y": { + "field": "CO₂ (ppm)", + "type": "quantitative", + "title": "ppm", + "scale": { "zero": false, "nice": true }, + "axis": { "tickCount": 4, "format": ",.0f" } + } + }, + "mark": { "type": "line", "color": "#c2352b", "strokeWidth": 2.5, "strokeCap": "round" }, + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, 'Times New Roman', Times, serif", + "fontSize": 15, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 10, + "titleFontWeight": 400, + "titleColor": "#8e8e8e", + "domain": false, + "ticks": false, + "grid": false + }, + "axisY": { + "grid": true, + "gridColor": "#e4e4e4", + "gridWidth": 1, + "domain": false, + "ticks": false, + "labelPadding": 4, + "titleAngle": 0, + "titleAlign": "left", + "titleAnchor": "start", + "titleBaseline": "bottom", + "titleY": -8, + "titlePadding": 0 + }, + "axisX": { + "grid": false, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": true, + "tickColor": "#121212", + "tickSize": 4, + "labelFlush": true + } + } +} diff --git a/site/src/playground/theme-lab-assets/kpi-sparkline.flint.json b/site/src/playground/theme-lab-assets/kpi-sparkline.flint.json new file mode 100644 index 00000000..41016668 --- /dev/null +++ b/site/src/playground/theme-lab-assets/kpi-sparkline.flint.json @@ -0,0 +1,635 @@ +{ + "hconcat": [ + { + "data": { + "values": [ + { + "Metric": "Revenue ($k)" + }, + { + "Metric": "Active users (k)" + }, + { + "Metric": "Churn (%)" + } + ] + }, + "facet": { + "row": { + "field": "Metric", + "type": "nominal", + "sort": [ + "Revenue ($k)", + "Active users (k)", + "Churn (%)" + ], + "header": null + } + }, + "spec": { + "width": 116, + "height": 64, + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "fontSize": 11 + }, + "encoding": { + "y": { + "value": 32 + }, + "x": { + "value": 0 + }, + "text": { + "field": "Metric", + "type": "nominal" + } + } + }, + "title": { + "text": "Metric", + "anchor": "start", + "fontSize": 11, + "fontWeight": "normal", + "color": "#999", + "offset": 6 + } + }, + { + "data": { + "values": [ + { + "Month": "Jan", + "Metric": "Revenue ($k)", + "Value": 120 + }, + { + "Month": "Feb", + "Metric": "Revenue ($k)", + "Value": 125 + }, + { + "Month": "Mar", + "Metric": "Revenue ($k)", + "Value": 130 + }, + { + "Month": "Apr", + "Metric": "Revenue ($k)", + "Value": 128 + }, + { + "Month": "May", + "Metric": "Revenue ($k)", + "Value": 140 + }, + { + "Month": "Jun", + "Metric": "Revenue ($k)", + "Value": 145 + }, + { + "Month": "Jul", + "Metric": "Revenue ($k)", + "Value": 150 + }, + { + "Month": "Aug", + "Metric": "Revenue ($k)", + "Value": 148 + }, + { + "Month": "Sep", + "Metric": "Revenue ($k)", + "Value": 160 + }, + { + "Month": "Oct", + "Metric": "Revenue ($k)", + "Value": 165 + }, + { + "Month": "Nov", + "Metric": "Revenue ($k)", + "Value": 170 + }, + { + "Month": "Dec", + "Metric": "Revenue ($k)", + "Value": 180 + }, + { + "Month": "Jan", + "Metric": "Active users (k)", + "Value": 40 + }, + { + "Month": "Feb", + "Metric": "Active users (k)", + "Value": 42 + }, + { + "Month": "Mar", + "Metric": "Active users (k)", + "Value": 45 + }, + { + "Month": "Apr", + "Metric": "Active users (k)", + "Value": 47 + }, + { + "Month": "May", + "Metric": "Active users (k)", + "Value": 50 + }, + { + "Month": "Jun", + "Metric": "Active users (k)", + "Value": 52 + }, + { + "Month": "Jul", + "Metric": "Active users (k)", + "Value": 55 + }, + { + "Month": "Aug", + "Metric": "Active users (k)", + "Value": 58 + }, + { + "Month": "Sep", + "Metric": "Active users (k)", + "Value": 60 + }, + { + "Month": "Oct", + "Metric": "Active users (k)", + "Value": 63 + }, + { + "Month": "Nov", + "Metric": "Active users (k)", + "Value": 66 + }, + { + "Month": "Dec", + "Metric": "Active users (k)", + "Value": 70 + }, + { + "Month": "Jan", + "Metric": "Churn (%)", + "Value": 5.2 + }, + { + "Month": "Feb", + "Metric": "Churn (%)", + "Value": 5 + }, + { + "Month": "Mar", + "Metric": "Churn (%)", + "Value": 4.8 + }, + { + "Month": "Apr", + "Metric": "Churn (%)", + "Value": 4.9 + }, + { + "Month": "May", + "Metric": "Churn (%)", + "Value": 4.6 + }, + { + "Month": "Jun", + "Metric": "Churn (%)", + "Value": 4.5 + }, + { + "Month": "Jul", + "Metric": "Churn (%)", + "Value": 4.3 + }, + { + "Month": "Aug", + "Metric": "Churn (%)", + "Value": 4.4 + }, + { + "Month": "Sep", + "Metric": "Churn (%)", + "Value": 4.1 + }, + { + "Month": "Oct", + "Metric": "Churn (%)", + "Value": 4 + }, + { + "Month": "Nov", + "Metric": "Churn (%)", + "Value": 3.9 + }, + { + "Month": "Dec", + "Metric": "Churn (%)", + "Value": 3.8 + } + ] + }, + "facet": { + "row": { + "field": "Metric", + "type": "nominal", + "sort": [ + "Revenue ($k)", + "Active users (k)", + "Churn (%)" + ], + "header": null + } + }, + "spec": { + "width": 154, + "height": 64, + "layer": [ + { + "mark": { + "type": "line", + "strokeWidth": 1.5 + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": null + }, + "y": { + "field": "Value", + "type": "quantitative", + "axis": null, + "scale": { + "range": [ + 62, + 2 + ] + } + }, + "color": { + "field": "Metric", + "type": "nominal", + "legend": null + } + } + }, + { + "mark": { + "type": "rule", + "strokeDash": [ + 3, + 2 + ], + "stroke": "#9a9a9a", + "strokeWidth": 1, + "opacity": 0.7 + }, + "encoding": { + "y": { + "field": "Value", + "aggregate": "mean", + "type": "quantitative", + "axis": null + } + } + } + ] + }, + "resolve": { + "scale": { + "y": "independent" + } + }, + "title": { + "text": "Value", + "anchor": "middle", + "fontSize": 11, + "fontWeight": "normal", + "color": "#999", + "offset": 6 + } + }, + { + "data": { + "values": [ + { + "Metric": "Revenue ($k)", + "flintSparkAvg": 146.75 + }, + { + "Metric": "Active users (k)", + "flintSparkAvg": 54 + }, + { + "Metric": "Churn (%)", + "flintSparkAvg": 4.458333333333333 + } + ] + }, + "facet": { + "row": { + "field": "Metric", + "type": "nominal", + "sort": [ + "Revenue ($k)", + "Active users (k)", + "Churn (%)" + ], + "header": null + } + }, + "spec": { + "width": 54, + "height": 64, + "mark": { + "type": "text", + "align": "right", + "baseline": "middle", + "fontSize": 11, + "fontWeight": 600 + }, + "encoding": { + "y": { + "value": 32 + }, + "x": { + "value": 54 + }, + "text": { + "field": "flintSparkAvg", + "type": "quantitative", + "format": ".3~s" + }, + "color": { + "field": "Metric", + "type": "nominal", + "legend": null + } + } + }, + "title": { + "text": "Average", + "anchor": "end", + "fontSize": 11, + "fontWeight": "normal", + "color": "#999", + "offset": 6 + } + } + ], + "spacing": 8, + "resolve": { + "scale": { + "y": "independent", + "color": "shared" + } + }, + "config": { + "view": { + "continuousWidth": 250, + "continuousHeight": 83, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 8, + "titleFontSize": 11, + "titleFontWeight": "normal", + "titleColor": "#666" + }, + "axisY": { + "labelFontSize": 8, + "titleFontSize": 11, + "titleFontWeight": "normal", + "titleColor": "#666" + }, + "legend": { + "labelFontSize": 9, + "titleFontSize": 9 + }, + "facet": { + "spacing": 8 + } + }, + "width": { + "step": 21 + }, + "title": { + "text": "Monthly KPIs", + "subtitle": [ + "Twelve-month trend and latest value" + ] + }, + "data": { + "values": [ + { + "Month": "Jan", + "Metric": "Revenue ($k)", + "Value": 120 + }, + { + "Month": "Feb", + "Metric": "Revenue ($k)", + "Value": 125 + }, + { + "Month": "Mar", + "Metric": "Revenue ($k)", + "Value": 130 + }, + { + "Month": "Apr", + "Metric": "Revenue ($k)", + "Value": 128 + }, + { + "Month": "May", + "Metric": "Revenue ($k)", + "Value": 140 + }, + { + "Month": "Jun", + "Metric": "Revenue ($k)", + "Value": 145 + }, + { + "Month": "Jul", + "Metric": "Revenue ($k)", + "Value": 150 + }, + { + "Month": "Aug", + "Metric": "Revenue ($k)", + "Value": 148 + }, + { + "Month": "Sep", + "Metric": "Revenue ($k)", + "Value": 160 + }, + { + "Month": "Oct", + "Metric": "Revenue ($k)", + "Value": 165 + }, + { + "Month": "Nov", + "Metric": "Revenue ($k)", + "Value": 170 + }, + { + "Month": "Dec", + "Metric": "Revenue ($k)", + "Value": 180 + }, + { + "Month": "Jan", + "Metric": "Active users (k)", + "Value": 40 + }, + { + "Month": "Feb", + "Metric": "Active users (k)", + "Value": 42 + }, + { + "Month": "Mar", + "Metric": "Active users (k)", + "Value": 45 + }, + { + "Month": "Apr", + "Metric": "Active users (k)", + "Value": 47 + }, + { + "Month": "May", + "Metric": "Active users (k)", + "Value": 50 + }, + { + "Month": "Jun", + "Metric": "Active users (k)", + "Value": 52 + }, + { + "Month": "Jul", + "Metric": "Active users (k)", + "Value": 55 + }, + { + "Month": "Aug", + "Metric": "Active users (k)", + "Value": 58 + }, + { + "Month": "Sep", + "Metric": "Active users (k)", + "Value": 60 + }, + { + "Month": "Oct", + "Metric": "Active users (k)", + "Value": 63 + }, + { + "Month": "Nov", + "Metric": "Active users (k)", + "Value": 66 + }, + { + "Month": "Dec", + "Metric": "Active users (k)", + "Value": 70 + }, + { + "Month": "Jan", + "Metric": "Churn (%)", + "Value": 5.2 + }, + { + "Month": "Feb", + "Metric": "Churn (%)", + "Value": 5 + }, + { + "Month": "Mar", + "Metric": "Churn (%)", + "Value": 4.8 + }, + { + "Month": "Apr", + "Metric": "Churn (%)", + "Value": 4.9 + }, + { + "Month": "May", + "Metric": "Churn (%)", + "Value": 4.6 + }, + { + "Month": "Jun", + "Metric": "Churn (%)", + "Value": 4.5 + }, + { + "Month": "Jul", + "Metric": "Churn (%)", + "Value": 4.3 + }, + { + "Month": "Aug", + "Metric": "Churn (%)", + "Value": 4.4 + }, + { + "Month": "Sep", + "Metric": "Churn (%)", + "Value": 4.1 + }, + { + "Month": "Oct", + "Metric": "Churn (%)", + "Value": 4 + }, + { + "Month": "Nov", + "Metric": "Churn (%)", + "Value": 3.9 + }, + { + "Month": "Dec", + "Metric": "Churn (%)", + "Value": 3.8 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/kpi-sparkline.powerbi.json b/site/src/playground/theme-lab-assets/kpi-sparkline.powerbi.json new file mode 100644 index 00000000..9bcac33a --- /dev/null +++ b/site/src/playground/theme-lab-assets/kpi-sparkline.powerbi.json @@ -0,0 +1,152 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi", + "__design__": [ + "Whole tile inverted to the #1B1A19 dashboard canvas so the three rows sit inside a report page rather than punching a white hole in it.", + "Rows given independent y scales. Flint shares one scale across all three metrics, which flattens revenue in the 120–180 range against churn at 3.8–5.2 into a single unreadable band; a sparkline row is a shape, not a magnitude comparison.", + "Colour freed from metric identity. The row label already names the metric, so the hue no longer has to; all three lines take Power BI green #22B14C because all three are currently moving in the desired direction — revenue and users up, churn down.", + "Row header moved from a rotated left-hand axis title to a flush-left label above each sparkline, which is how a KPI strip is laid out.", + "Current value printed at the right of each row, taken from the last point of the same series — the number a dashboard reader wants first, which the baseline made them read off a shared axis.", + "All axes, gridlines and ticks deleted. A sparkline has no axis by definition; the baseline's month labels and value ticks were consuming more pixels than the line.", + "Line weight 1.6px with a round join, plus a filled endpoint marker so the eye lands on the current period; row height cut to 34px so all three fit in one tile." + ], + "background": "#1b1a19", + "padding": { "left": 6, "top": 4, "right": 8, "bottom": 4 }, + "title": { + "text": "Monthly KPIs", + "subtitle": ["Twelve-month trend and latest value"] + }, + "data": { + "values": [ + { "Month": "Jan", "Metric": "Revenue ($k)", "Value": 120 }, + { "Month": "Feb", "Metric": "Revenue ($k)", "Value": 125 }, + { "Month": "Mar", "Metric": "Revenue ($k)", "Value": 130 }, + { "Month": "Apr", "Metric": "Revenue ($k)", "Value": 128 }, + { "Month": "May", "Metric": "Revenue ($k)", "Value": 140 }, + { "Month": "Jun", "Metric": "Revenue ($k)", "Value": 145 }, + { "Month": "Jul", "Metric": "Revenue ($k)", "Value": 150 }, + { "Month": "Aug", "Metric": "Revenue ($k)", "Value": 148 }, + { "Month": "Sep", "Metric": "Revenue ($k)", "Value": 160 }, + { "Month": "Oct", "Metric": "Revenue ($k)", "Value": 165 }, + { "Month": "Nov", "Metric": "Revenue ($k)", "Value": 170 }, + { "Month": "Dec", "Metric": "Revenue ($k)", "Value": 180 }, + { "Month": "Jan", "Metric": "Active users (k)", "Value": 40 }, + { "Month": "Feb", "Metric": "Active users (k)", "Value": 42 }, + { "Month": "Mar", "Metric": "Active users (k)", "Value": 45 }, + { "Month": "Apr", "Metric": "Active users (k)", "Value": 47 }, + { "Month": "May", "Metric": "Active users (k)", "Value": 50 }, + { "Month": "Jun", "Metric": "Active users (k)", "Value": 52 }, + { "Month": "Jul", "Metric": "Active users (k)", "Value": 55 }, + { "Month": "Aug", "Metric": "Active users (k)", "Value": 58 }, + { "Month": "Sep", "Metric": "Active users (k)", "Value": 60 }, + { "Month": "Oct", "Metric": "Active users (k)", "Value": 63 }, + { "Month": "Nov", "Metric": "Active users (k)", "Value": 66 }, + { "Month": "Dec", "Metric": "Active users (k)", "Value": 70 }, + { "Month": "Jan", "Metric": "Churn (%)", "Value": 5.2 }, + { "Month": "Feb", "Metric": "Churn (%)", "Value": 5.0 }, + { "Month": "Mar", "Metric": "Churn (%)", "Value": 4.8 }, + { "Month": "Apr", "Metric": "Churn (%)", "Value": 4.9 }, + { "Month": "May", "Metric": "Churn (%)", "Value": 4.6 }, + { "Month": "Jun", "Metric": "Churn (%)", "Value": 4.5 }, + { "Month": "Jul", "Metric": "Churn (%)", "Value": 4.3 }, + { "Month": "Aug", "Metric": "Churn (%)", "Value": 4.4 }, + { "Month": "Sep", "Metric": "Churn (%)", "Value": 4.1 }, + { "Month": "Oct", "Metric": "Churn (%)", "Value": 4.0 }, + { "Month": "Nov", "Metric": "Churn (%)", "Value": 3.9 }, + { "Month": "Dec", "Metric": "Churn (%)", "Value": 3.8 } + ] + }, + "facet": { + "row": { + "field": "Metric", + "type": "nominal", + "sort": ["Revenue ($k)", "Active users (k)", "Churn (%)"], + "title": null, + "header": { + "labelAngle": 0, + "labelAlign": "left", + "labelAnchor": "start", + "labelOrient": "top", + "labelFontSize": 10, + "labelFontWeight": 600, + "labelColor": "#f3f2f1", + "labelPadding": 0 + } + } + }, + "spacing": 14, + "resolve": { "scale": { "y": "independent" } }, + "spec": { + "width": 250, + "height": 34, + "encoding": { + "x": { + "field": "Month", + "type": "ordinal", + "sort": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + "title": null, + "axis": null + }, + "y": { + "field": "Value", + "type": "quantitative", + "title": null, + "axis": null + } + }, + "layer": [ + { + "mark": { + "type": "line", + "color": "#22b14c", + "strokeWidth": 1.6, + "strokeJoin": "round", + "strokeCap": "round" + } + }, + { + "transform": [{ "filter": "datum.Month === 'Dec'" }], + "mark": { + "type": "point", + "filled": true, + "size": 40, + "color": "#22b14c" + } + }, + { + "transform": [{ "filter": "datum.Month === 'Dec'" }], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 8, + "font": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "fontSize": 12, + "fontWeight": 600, + "color": "#f3f2f1" + }, + "encoding": { + "text": { "field": "Value", "type": "quantitative", "format": ".3~f" } + } + } + ] + }, + "config": { + "background": "#1b1a19", + "font": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "title": { + "font": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "subtitleFontSize": 9.5, + "subtitleColor": "#8a8886", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "header": { "labelFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/life-expectancy.economist.json b/site/src/playground/theme-lab-assets/life-expectancy.economist.json new file mode 100644 index 00000000..28c5c813 --- /dev/null +++ b/site/src/playground/theme-lab-assets/life-expectancy.economist.json @@ -0,0 +1,139 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "economist", + "__design__": [ + "Value axis deleted outright and both endpoints printed instead. A slope chart is read as a set of gradients between two labelled columns; the axis exists only so the reader can convert a height back into a number, which the number itself does better.", + "Legend replaced by the country name at both ends of every line. Seven lines that cross cannot be traced back to a key, and repeating the name on the right is what makes a crossing survivable.", + "Colour reduced from set2 to two house hues carrying a rule, not an identity: Economist blue #006BA2 where life expectancy rose, house red #E3120B where it fell. The rule is computed from the same two numbers already on the page, so it states nothing the chart is not showing.", + "Points shrunk to 3px filled dots and the line to 1.4px. Flint's 2px line with default points reads as a time series sampled twice; a slope chart has no time between its columns, so the segment is a connector, not a trend.", + "Column padding widened and the two year labels moved to the top, above the values they head — the year is a column heading here, not a measurement axis.", + "Red masthead rule, 13.5pt bold headline over a grey deck, 9.5pt labels throughout: the house identity is fixed per publication, not chosen per chart." + ], + "background": "#ffffff", + "padding": { "left": 6, "top": 4, "right": 12, "bottom": 6 }, + "title": { + "text": "Two decades of longer lives", + "subtitle": ["Life expectancy at birth, years, 2000 and 2021"] + }, + "spacing": 8, + "vconcat": [ + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#e3120b", "stroke": null }, + "width": 26, + "height": 3, + "view": { "stroke": null } + }, + { + "width": 150, + "height": 250, + "data": { + "values": [ + { "Country": "China", "Year": "2000", "Life expectancy": 72 }, + { "Country": "China", "Year": "2021", "Life expectancy": 78.2 }, + { "Country": "India", "Year": "2000", "Life expectancy": 62.5 }, + { "Country": "India", "Year": "2021", "Life expectancy": 67.2 }, + { "Country": "United States", "Year": "2000", "Life expectancy": 76.6 }, + { "Country": "United States", "Year": "2021", "Life expectancy": 76.3 }, + { "Country": "Nigeria", "Year": "2000", "Life expectancy": 46.6 }, + { "Country": "Nigeria", "Year": "2021", "Life expectancy": 52.7 }, + { "Country": "Japan", "Year": "2000", "Life expectancy": 81.1 }, + { "Country": "Japan", "Year": "2021", "Life expectancy": 84.5 }, + { "Country": "Russia", "Year": "2000", "Life expectancy": 65.5 }, + { "Country": "Russia", "Year": "2021", "Life expectancy": 70.1 }, + { "Country": "Brazil", "Year": "2000", "Life expectancy": 70.1 }, + { "Country": "Brazil", "Year": "2021", "Life expectancy": 72.8 } + ] + }, + "transform": [ + { + "window": [{ "op": "last_value", "field": "Life expectancy", "as": "__end" }], + "frame": [null, null], + "sort": [{ "field": "Year", "order": "ascending" }], + "groupby": ["Country"] + }, + { + "window": [{ "op": "first_value", "field": "Life expectancy", "as": "__start" }], + "frame": [null, null], + "sort": [{ "field": "Year", "order": "ascending" }], + "groupby": ["Country"] + }, + { "calculate": "datum.__end >= datum.__start ? 'up' : 'down'", "as": "__direction" }, + { "calculate": "datum.Country + ' ' + format(datum['Life expectancy'], '.1f')", "as": "__label" } + ], + "encoding": { + "x": { + "field": "Year", + "type": "ordinal", + "sort": ["2000", "2021"], + "title": null, + "scale": { "padding": 0.5 }, + "axis": { + "orient": "top", + "labelAngle": 0, + "labelFontSize": 10, + "labelFontWeight": 700, + "labelColor": "#121317", + "labelPadding": 8, + "domain": false, + "ticks": false, + "grid": false + } + }, + "y": { + "field": "Life expectancy", + "type": "quantitative", + "title": null, + "scale": { "zero": false, "nice": false, "domain": [44, 87] }, + "axis": null + }, + "color": { + "field": "__direction", + "type": "nominal", + "legend": null, + "scale": { "domain": ["up", "down"], "range": ["#006ba2", "#e3120b"] } + }, + "detail": { "field": "Country", "type": "nominal" } + }, + "layer": [ + { "mark": { "type": "line", "strokeWidth": 1.4 } }, + { "mark": { "type": "point", "filled": true, "size": 22 } }, + { + "transform": [{ "filter": "datum.Year === '2000'" }], + "mark": { "type": "text", "align": "right", "baseline": "middle", "dx": -7, "fontSize": 9 }, + "encoding": { "text": { "field": "__label", "type": "nominal" } } + }, + { + "transform": [{ "filter": "datum.Year === '2021'" }], + "mark": { "type": "text", "align": "left", "baseline": "middle", "dx": 7, "fontSize": 9 }, + "encoding": { "text": { "field": "__label", "type": "nominal" } } + } + ] + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#54585a", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#54585a", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/life-expectancy.flint.json b/site/src/playground/theme-lab-assets/life-expectancy.flint.json new file mode 100644 index 00000000..af145345 --- /dev/null +++ b/site/src/playground/theme-lab-assets/life-expectancy.flint.json @@ -0,0 +1,149 @@ +{ + "mark": { + "type": "line", + "point": true, + "interpolate": "linear", + "strokeWidth": 2 + }, + "encoding": { + "x": { + "field": "Year", + "type": "ordinal", + "sort": [ + "2000", + "2021" + ], + "scale": { + "padding": 0.4 + } + }, + "y": { + "field": "Life expectancy", + "type": "quantitative", + "scale": { + "zero": false, + "nice": true, + "padding": 12 + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11, + "labelAngle": 0, + "labelAlign": "center", + "labelBaseline": "top" + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "width": { + "step": 136 + }, + "title": { + "text": "Two decades of longer lives", + "subtitle": [ + "Life expectancy at birth, years, 2000 and 2021" + ] + }, + "data": { + "values": [ + { + "Country": "China", + "Year": "2000", + "Life expectancy": 72 + }, + { + "Country": "China", + "Year": "2021", + "Life expectancy": 78.2 + }, + { + "Country": "India", + "Year": "2000", + "Life expectancy": 62.5 + }, + { + "Country": "India", + "Year": "2021", + "Life expectancy": 67.2 + }, + { + "Country": "United States", + "Year": "2000", + "Life expectancy": 76.6 + }, + { + "Country": "United States", + "Year": "2021", + "Life expectancy": 76.3 + }, + { + "Country": "Nigeria", + "Year": "2000", + "Life expectancy": 46.6 + }, + { + "Country": "Nigeria", + "Year": "2021", + "Life expectancy": 52.7 + }, + { + "Country": "Japan", + "Year": "2000", + "Life expectancy": 81.1 + }, + { + "Country": "Japan", + "Year": "2021", + "Life expectancy": 84.5 + }, + { + "Country": "Russia", + "Year": "2000", + "Life expectancy": 65.5 + }, + { + "Country": "Russia", + "Year": "2021", + "Life expectancy": 70.1 + }, + { + "Country": "Brazil", + "Year": "2000", + "Life expectancy": 70.1 + }, + { + "Country": "Brazil", + "Year": "2021", + "Life expectancy": 72.8 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/lifeexp-dumbbell.flint.json b/site/src/playground/theme-lab-assets/lifeexp-dumbbell.flint.json new file mode 100644 index 00000000..bb09be3b --- /dev/null +++ b/site/src/playground/theme-lab-assets/lifeexp-dumbbell.flint.json @@ -0,0 +1,142 @@ +{ + "encoding": { + "x": { + "field": "Life expectancy", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Country", + "type": "nominal", + "sort": null + } + }, + "layer": [ + { + "mark": "line", + "encoding": { + "detail": { + "field": "Country", + "type": "nominal", + "sort": null + } + } + }, + { + "mark": { + "type": "point", + "filled": true + }, + "encoding": { + "color": { + "field": "Sex", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + } + } + } + ], + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "height": { + "step": 23 + }, + "title": { + "text": "Women outlive men everywhere, but not by the same margin", + "subtitle": [ + "Life expectancy at birth by sex, 2021, years" + ] + }, + "data": { + "values": [ + { + "Country": "Japan", + "Sex": "Male", + "Life expectancy": 81.5 + }, + { + "Country": "Japan", + "Sex": "Female", + "Life expectancy": 87.6 + }, + { + "Country": "United States", + "Sex": "Male", + "Life expectancy": 73.5 + }, + { + "Country": "United States", + "Sex": "Female", + "Life expectancy": 79.3 + }, + { + "Country": "India", + "Sex": "Male", + "Life expectancy": 66 + }, + { + "Country": "India", + "Sex": "Female", + "Life expectancy": 69 + }, + { + "Country": "Brazil", + "Sex": "Male", + "Life expectancy": 69 + }, + { + "Country": "Brazil", + "Sex": "Female", + "Life expectancy": 76 + }, + { + "Country": "Nigeria", + "Sex": "Male", + "Life expectancy": 51 + }, + { + "Country": "Nigeria", + "Sex": "Female", + "Life expectancy": 54 + }, + { + "Country": "Germany", + "Sex": "Male", + "Life expectancy": 78.5 + }, + { + "Country": "Germany", + "Sex": "Female", + "Life expectancy": 83.4 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/lifeexp-dumbbell.mckinsey.json b/site/src/playground/theme-lab-assets/lifeexp-dumbbell.mckinsey.json new file mode 100644 index 00000000..19e2ccc5 --- /dev/null +++ b/site/src/playground/theme-lab-assets/lifeexp-dumbbell.mckinsey.json @@ -0,0 +1,157 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "mckinsey", + "__design__": [ + "Row order left exactly as the baseline has it — Japan, United States, India, Brazil, Nigeria, Germany. Sorting by the gap would make the chart answer its own headline, which is exactly why a theme may not do it: which country follows which is a statement about the data, not a house style.", + "The gap is stated as a number at the end of each row. It is the subject of the chart and Flint asks the reader to measure it off a shared axis by eye.", + "tableau10 replaced by two colours from the house palette: deep navy #051C2C for men, brand blue #2251FF for women. Neither is greyed out — the comparison is symmetric and this is one of the rare cases where the 'one series in blue, the rest in grey' rule would assert something the data does not.", + "Legend deleted and each dot named inline, in the colour it names, on the first row only. This is a label attached to a mark, not a legend moved to a new place: it sits at its own dot's position and the reader picks the name up while reading the row. Two dots do not justify a lookup table. The exact offset above the dot is implementation — the decision is inline-at-the-mark.", + "The connecting line demoted to a 3px #D3DCE1 bar behind the dots. Flint draws it as a default-blue line at the same visual weight as the markers, so the connector competes with the endpoints it exists to join.", + "The value axis kept, against house rule, and reduced to five unlabelled-title ticks. A dumbbell means nothing without absolute position — 51 to 54 and 81.5 to 87.6 are different facts. This is the one chart in the McKinsey set where deleting the axis would destroy the reading, and the numbers on the marks carry the gap instead.", + "Flint's ',.12~g' axis format dropped for plain integers." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 20, "bottom": 8 }, + "title": { + "text": "Women outlive men everywhere, but not by the same margin", + "subtitle": ["Life expectancy at birth by sex, 2021, years"] + }, + "width": 250, + "height": 178, + "data": { + "values": [ + { "Country": "Japan", "Sex": "Male", "Life expectancy": 81.5 }, + { "Country": "Japan", "Sex": "Female", "Life expectancy": 87.6 }, + { "Country": "United States", "Sex": "Male", "Life expectancy": 73.5 }, + { "Country": "United States", "Sex": "Female", "Life expectancy": 79.3 }, + { "Country": "India", "Sex": "Male", "Life expectancy": 66 }, + { "Country": "India", "Sex": "Female", "Life expectancy": 69 }, + { "Country": "Brazil", "Sex": "Male", "Life expectancy": 69 }, + { "Country": "Brazil", "Sex": "Female", "Life expectancy": 76 }, + { "Country": "Nigeria", "Sex": "Male", "Life expectancy": 51 }, + { "Country": "Nigeria", "Sex": "Female", "Life expectancy": 54 }, + { "Country": "Germany", "Sex": "Male", "Life expectancy": 78.5 }, + { "Country": "Germany", "Sex": "Female", "Life expectancy": 83.4 } + ] + }, + "transform": [ + { + "joinaggregate": [ + { "op": "min", "field": "Life expectancy", "as": "__lo" }, + { "op": "max", "field": "Life expectancy", "as": "__hi" } + ], + "groupby": ["Country"] + }, + { "calculate": "datum.__hi - datum.__lo", "as": "__gap" }, + { "calculate": "'+' + format(datum.__gap, '.1f')", "as": "__gapLabel" } + ], + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "title": null, + "sort": null, + "scale": { "paddingInner": 0.6, "paddingOuter": 0.55 }, + "axis": { "domain": false, "ticks": false, "grid": false, "labelPadding": 6 } + }, + "x": { + "field": "Life expectancy", + "type": "quantitative", + "title": null, + "scale": { "zero": false, "nice": false, "domain": [46, 94] }, + "axis": { + "values": [50, 60, 70, 80, 90], + "format": "d", + "grid": false, + "domain": false, + "ticks": false, + "labelColor": "#8fa0ab", + "labelPadding": 6 + } + } + }, + "layer": [ + { + "mark": { "type": "bar", "height": 3, "color": "#d3dce1" }, + "encoding": { + "x": { "field": "__lo", "type": "quantitative" }, + "x2": { "field": "__hi" } + } + }, + { + "mark": { "type": "point", "filled": true, "size": 90, "stroke": "#ffffff", "strokeWidth": 1.2 }, + "encoding": { + "color": { + "field": "Sex", + "type": "nominal", + "scale": { "domain": ["Male", "Female"], "range": ["#051c2c", "#2251ff"] }, + "legend": null + } + } + }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 9, + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "color": "#051c2c" + }, + "encoding": { + "x": { "field": "__hi", "type": "quantitative" }, + "text": { "field": "__gapLabel", "type": "nominal" } + } + }, + { + "transform": [{ "filter": "datum.Country === 'Japan'" }], + "mark": { + "type": "text", + "baseline": "bottom", + "dy": -12, + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600 + }, + "encoding": { + "text": { "field": "Sex", "type": "nominal" }, + "color": { + "field": "Sex", + "type": "nominal", + "scale": { "domain": ["Male", "Female"], "range": ["#051c2c", "#2251ff"] }, + "legend": null + } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#5c6b75", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#051c2c", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 9, + "titleColor": "#8fa0ab", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/marathon-wr.flint.json b/site/src/playground/theme-lab-assets/marathon-wr.flint.json new file mode 100644 index 00000000..e4159c87 --- /dev/null +++ b/site/src/playground/theme-lab-assets/marathon-wr.flint.json @@ -0,0 +1,112 @@ +{ + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Record (min)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Men's marathon world record, 1908–2023 (minutes)" + }, + "data": { + "values": [ + { + "Year": "1908", + "Record (min)": 175 + }, + { + "Year": "1925", + "Record (min)": 173 + }, + { + "Year": "1935", + "Record (min)": 166 + }, + { + "Year": "1947", + "Record (min)": 165 + }, + { + "Year": "1958", + "Record (min)": 155 + }, + { + "Year": "1967", + "Record (min)": 150 + }, + { + "Year": "1969", + "Record (min)": 148 + }, + { + "Year": "1988", + "Record (min)": 127 + }, + { + "Year": "1998", + "Record (min)": 126 + }, + { + "Year": "2003", + "Record (min)": 125 + }, + { + "Year": "2008", + "Record (min)": 124 + }, + { + "Year": "2011", + "Record (min)": 123.6 + }, + { + "Year": "2014", + "Record (min)": 122.9 + }, + { + "Year": "2018", + "Record (min)": 121.6 + }, + { + "Year": "2022", + "Record (min)": 121.1 + }, + { + "Year": "2023", + "Record (min)": 120.6 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/medals-grouped.flint.json b/site/src/playground/theme-lab-assets/medals-grouped.flint.json new file mode 100644 index 00000000..bfb42ffd --- /dev/null +++ b/site/src/playground/theme-lab-assets/medals-grouped.flint.json @@ -0,0 +1,141 @@ +{ + "mark": "bar", + "encoding": { + "x": { + "field": "Country", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Count", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Medal", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + }, + "xOffset": { + "field": "Medal", + "type": "nominal", + "sort": null + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "width": { + "step": 68, + "for": "position" + }, + "title": { + "text": "Paris 2024 Olympic medals — top nations" + }, + "data": { + "values": [ + { + "Country": "United States", + "Medal": "Gold", + "Count": 40 + }, + { + "Country": "United States", + "Medal": "Silver", + "Count": 44 + }, + { + "Country": "United States", + "Medal": "Bronze", + "Count": 42 + }, + { + "Country": "China", + "Medal": "Gold", + "Count": 40 + }, + { + "Country": "China", + "Medal": "Silver", + "Count": 27 + }, + { + "Country": "China", + "Medal": "Bronze", + "Count": 24 + }, + { + "Country": "Japan", + "Medal": "Gold", + "Count": 20 + }, + { + "Country": "Japan", + "Medal": "Silver", + "Count": 12 + }, + { + "Country": "Japan", + "Medal": "Bronze", + "Count": 13 + }, + { + "Country": "Australia", + "Medal": "Gold", + "Count": 18 + }, + { + "Country": "Australia", + "Medal": "Silver", + "Count": 19 + }, + { + "Country": "Australia", + "Medal": "Bronze", + "Count": 16 + }, + { + "Country": "France", + "Medal": "Gold", + "Count": 16 + }, + { + "Country": "France", + "Medal": "Silver", + "Count": 26 + }, + { + "Country": "France", + "Medal": "Bronze", + "Count": 22 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/mobile-donut.flint.json b/site/src/playground/theme-lab-assets/mobile-donut.flint.json new file mode 100644 index 00000000..c947a2e2 --- /dev/null +++ b/site/src/playground/theme-lab-assets/mobile-donut.flint.json @@ -0,0 +1,60 @@ +{ + "mark": "arc", + "encoding": { + "theta": { + "field": "Share", + "type": "quantitative" + }, + "color": { + "field": "OS", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + } + }, + "width": 346, + "height": 346, + "config": { + "view": { + "continuousWidth": 346, + "continuousHeight": 346 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "Mobile OS market share, 2024" + }, + "data": { + "values": [ + { + "OS": "Android", + "Share": 71 + }, + { + "OS": "iOS", + "Share": 28 + }, + { + "OS": "Other", + "Share": 1 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/nutrition-radar.flint.json b/site/src/playground/theme-lab-assets/nutrition-radar.flint.json new file mode 100644 index 00000000..0ef64c59 --- /dev/null +++ b/site/src/playground/theme-lab-assets/nutrition-radar.flint.json @@ -0,0 +1,979 @@ +{ + "width": 230, + "height": 230, + "layer": [ + { + "data": { + "values": [ + { + "__type": "spoke", + "__x": 0, + "__y": 0, + "__x2": 0, + "__y2": -1 + }, + { + "__type": "spoke", + "__x": 0, + "__y": 0, + "__x2": 0.9510565162951535, + "__y2": -0.30901699437494745 + }, + { + "__type": "spoke", + "__x": 0, + "__y": 0, + "__x2": 0.5877852522924732, + "__y2": 0.8090169943749473 + }, + { + "__type": "spoke", + "__x": 0, + "__y": 0, + "__x2": -0.587785252292473, + "__y2": 0.8090169943749475 + }, + { + "__type": "spoke", + "__x": 0, + "__y": 0, + "__x2": -0.9510565162951536, + "__y2": -0.30901699437494723 + } + ] + }, + "mark": { + "type": "rule", + "stroke": "#ddd", + "strokeWidth": 0.8 + }, + "encoding": { + "x": { + "field": "__x", + "type": "quantitative", + "scale": { + "domain": [ + -1.18, + 1.18 + ] + }, + "axis": null + }, + "y": { + "field": "__y", + "type": "quantitative", + "scale": { + "domain": [ + -1.18, + 1.18 + ] + }, + "axis": null + }, + "x2": { + "field": "__x2" + }, + "y2": { + "field": "__y2" + } + } + }, + { + "data": { + "values": [ + { + "__type": "ring", + "__level": 0.25, + "__x": 0, + "__y": -0.25, + "__x2": 0.23776412907378838, + "__y2": -0.07725424859373686 + }, + { + "__type": "ring", + "__level": 0.25, + "__x": 0.23776412907378838, + "__y": -0.07725424859373686, + "__x2": 0.1469463130731183, + "__y2": 0.20225424859373684 + }, + { + "__type": "ring", + "__level": 0.25, + "__x": 0.1469463130731183, + "__y": 0.20225424859373684, + "__x2": -0.14694631307311826, + "__y2": 0.20225424859373686 + }, + { + "__type": "ring", + "__level": 0.25, + "__x": -0.14694631307311826, + "__y": 0.20225424859373686, + "__x2": -0.2377641290737884, + "__y2": -0.07725424859373681 + }, + { + "__type": "ring", + "__level": 0.25, + "__x": -0.2377641290737884, + "__y": -0.07725424859373681, + "__x2": 0, + "__y2": -0.25 + }, + { + "__type": "ring", + "__level": 0.5, + "__x": 0, + "__y": -0.5, + "__x2": 0.47552825814757677, + "__y2": -0.15450849718747373 + }, + { + "__type": "ring", + "__level": 0.5, + "__x": 0.47552825814757677, + "__y": -0.15450849718747373, + "__x2": 0.2938926261462366, + "__y2": 0.40450849718747367 + }, + { + "__type": "ring", + "__level": 0.5, + "__x": 0.2938926261462366, + "__y": 0.40450849718747367, + "__x2": -0.2938926261462365, + "__y2": 0.4045084971874737 + }, + { + "__type": "ring", + "__level": 0.5, + "__x": -0.2938926261462365, + "__y": 0.4045084971874737, + "__x2": -0.4755282581475768, + "__y2": -0.15450849718747361 + }, + { + "__type": "ring", + "__level": 0.5, + "__x": -0.4755282581475768, + "__y": -0.15450849718747361, + "__x2": 0, + "__y2": -0.5 + }, + { + "__type": "ring", + "__level": 0.75, + "__x": 0, + "__y": -0.75, + "__x2": 0.7132923872213651, + "__y2": -0.2317627457812106 + }, + { + "__type": "ring", + "__level": 0.75, + "__x": 0.7132923872213651, + "__y": -0.2317627457812106, + "__x2": 0.4408389392193549, + "__y2": 0.6067627457812105 + }, + { + "__type": "ring", + "__level": 0.75, + "__x": 0.4408389392193549, + "__y": 0.6067627457812105, + "__x2": -0.4408389392193548, + "__y2": 0.6067627457812106 + }, + { + "__type": "ring", + "__level": 0.75, + "__x": -0.4408389392193548, + "__y": 0.6067627457812106, + "__x2": -0.7132923872213652, + "__y2": -0.23176274578121042 + }, + { + "__type": "ring", + "__level": 0.75, + "__x": -0.7132923872213652, + "__y": -0.23176274578121042, + "__x2": 0, + "__y2": -0.75 + }, + { + "__type": "ring", + "__level": 1, + "__x": 0, + "__y": -1, + "__x2": 0.9510565162951535, + "__y2": -0.30901699437494745 + }, + { + "__type": "ring", + "__level": 1, + "__x": 0.9510565162951535, + "__y": -0.30901699437494745, + "__x2": 0.5877852522924732, + "__y2": 0.8090169943749473 + }, + { + "__type": "ring", + "__level": 1, + "__x": 0.5877852522924732, + "__y": 0.8090169943749473, + "__x2": -0.587785252292473, + "__y2": 0.8090169943749475 + }, + { + "__type": "ring", + "__level": 1, + "__x": -0.587785252292473, + "__y": 0.8090169943749475, + "__x2": -0.9510565162951536, + "__y2": -0.30901699437494723 + }, + { + "__type": "ring", + "__level": 1, + "__x": -0.9510565162951536, + "__y": -0.30901699437494723, + "__x2": 0, + "__y2": -1 + } + ] + }, + "mark": { + "type": "rule", + "stroke": "#e0e0e0", + "strokeWidth": 0.6 + }, + "encoding": { + "x": { + "field": "__x", + "type": "quantitative", + "axis": null + }, + "y": { + "field": "__y", + "type": "quantitative", + "axis": null + }, + "x2": { + "field": "__x2" + }, + "y2": { + "field": "__y2" + } + } + }, + { + "data": { + "values": [ + { + "__label": [ + "Protein", + "(25)" + ], + "__x": 0, + "__y": -1.15, + "__align": "center", + "__baseline": "bottom", + "__dx": 0, + "__dy": -4 + } + ] + }, + "mark": { + "type": "text", + "fontSize": 10, + "fill": "#555", + "align": "center", + "baseline": "bottom", + "dx": 0, + "dy": -4, + "limit": 120, + "lineHeight": 13 + }, + "encoding": { + "x": { + "field": "__x", + "type": "quantitative", + "axis": null + }, + "y": { + "field": "__y", + "type": "quantitative", + "axis": null + }, + "text": { + "value": [ + "Protein", + "(25)" + ] + } + } + }, + { + "data": { + "values": [ + { + "__label": [ + "Fat", + "(50)" + ], + "__x": 1.0937149937394264, + "__y": -0.35536954353118955, + "__align": "left", + "__baseline": "bottom", + "__dx": 4, + "__dy": 0 + } + ] + }, + "mark": { + "type": "text", + "fontSize": 10, + "fill": "#555", + "align": "left", + "baseline": "bottom", + "dx": 4, + "dy": 0, + "limit": 120, + "lineHeight": 13 + }, + "encoding": { + "x": { + "field": "__x", + "type": "quantitative", + "axis": null + }, + "y": { + "field": "__y", + "type": "quantitative", + "axis": null + }, + "text": { + "value": [ + "Fat", + "(50)" + ] + } + } + }, + { + "data": { + "values": [ + { + "__label": [ + "Carbs", + "(100)" + ], + "__x": 0.6759530401363442, + "__y": 0.9303695435311894, + "__align": "left", + "__baseline": "top", + "__dx": 4, + "__dy": 0 + } + ] + }, + "mark": { + "type": "text", + "fontSize": 10, + "fill": "#555", + "align": "left", + "baseline": "top", + "dx": 4, + "dy": 0, + "limit": 120, + "lineHeight": 13 + }, + "encoding": { + "x": { + "field": "__x", + "type": "quantitative", + "axis": null + }, + "y": { + "field": "__y", + "type": "quantitative", + "axis": null + }, + "text": { + "value": [ + "Carbs", + "(100)" + ] + } + } + }, + { + "data": { + "values": [ + { + "__label": [ + "Fiber", + "(20)" + ], + "__x": -0.6759530401363439, + "__y": 0.9303695435311895, + "__align": "right", + "__baseline": "top", + "__dx": -4, + "__dy": 0 + } + ] + }, + "mark": { + "type": "text", + "fontSize": 10, + "fill": "#555", + "align": "right", + "baseline": "top", + "dx": -4, + "dy": 0, + "limit": 120, + "lineHeight": 13 + }, + "encoding": { + "x": { + "field": "__x", + "type": "quantitative", + "axis": null + }, + "y": { + "field": "__y", + "type": "quantitative", + "axis": null + }, + "text": { + "value": [ + "Fiber", + "(20)" + ] + } + } + }, + { + "data": { + "values": [ + { + "__label": [ + "Sugar", + "(5)" + ], + "__x": -1.0937149937394266, + "__y": -0.35536954353118927, + "__align": "right", + "__baseline": "bottom", + "__dx": -4, + "__dy": 0 + } + ] + }, + "mark": { + "type": "text", + "fontSize": 10, + "fill": "#555", + "align": "right", + "baseline": "bottom", + "dx": -4, + "dy": 0, + "limit": 120, + "lineHeight": 13 + }, + "encoding": { + "x": { + "field": "__x", + "type": "quantitative", + "axis": null + }, + "y": { + "field": "__y", + "type": "quantitative", + "axis": null + }, + "text": { + "value": [ + "Sugar", + "(5)" + ] + } + } + }, + { + "data": { + "values": [ + { + "__group": "Almonds", + "__axis": "Protein", + "__value": 0.84, + "__raw": 21, + "__angle": 0, + "__x": 0, + "__y": -0.84 + }, + { + "__group": "Almonds", + "__axis": "Fat", + "__value": 1, + "__raw": 50, + "__angle": 72, + "__x": 0.9510565162951535, + "__y": -0.30901699437494745 + }, + { + "__group": "Almonds", + "__axis": "Carbs", + "__value": 0.22, + "__raw": 22, + "__angle": 144, + "__x": 0.12931275550434412, + "__y": 0.1779837387624884 + }, + { + "__group": "Almonds", + "__axis": "Fiber", + "__value": 0.6, + "__raw": 12, + "__angle": 216, + "__x": -0.3526711513754838, + "__y": 0.4854101966249684 + }, + { + "__group": "Almonds", + "__axis": "Sugar", + "__value": 0.8, + "__raw": 4, + "__angle": 288, + "__x": -0.760845213036123, + "__y": -0.2472135954999578 + }, + { + "__group": "Oats", + "__axis": "Protein", + "__value": 0.68, + "__raw": 17, + "__angle": 0, + "__x": 0, + "__y": -0.68 + }, + { + "__group": "Oats", + "__axis": "Fat", + "__value": 0.14, + "__raw": 7, + "__angle": 72, + "__x": 0.1331479122813215, + "__y": -0.043262379212492645 + }, + { + "__group": "Oats", + "__axis": "Carbs", + "__value": 0.66, + "__raw": 66, + "__angle": 144, + "__x": 0.38793826651303237, + "__y": 0.5339512162874652 + }, + { + "__group": "Oats", + "__axis": "Fiber", + "__value": 0.55, + "__raw": 11, + "__angle": 216, + "__x": -0.32328188876086017, + "__y": 0.44495934690622113 + }, + { + "__group": "Oats", + "__axis": "Sugar", + "__value": 0.2, + "__raw": 1, + "__angle": 288, + "__x": -0.19021130325903074, + "__y": -0.06180339887498945 + }, + { + "__group": "Greek yogurt", + "__axis": "Protein", + "__value": 0.4, + "__raw": 10, + "__angle": 0, + "__x": 0, + "__y": -0.4 + }, + { + "__group": "Greek yogurt", + "__axis": "Fat", + "__value": 0.1, + "__raw": 5, + "__angle": 72, + "__x": 0.09510565162951536, + "__y": -0.030901699437494747 + }, + { + "__group": "Greek yogurt", + "__axis": "Carbs", + "__value": 0.04, + "__raw": 4, + "__angle": 144, + "__x": 0.02351141009169893, + "__y": 0.032360679774997896 + }, + { + "__group": "Greek yogurt", + "__axis": "Fiber", + "__value": 0, + "__raw": 0, + "__angle": 216, + "__x": 0, + "__y": 0 + }, + { + "__group": "Greek yogurt", + "__axis": "Sugar", + "__value": 0.8, + "__raw": 4, + "__angle": 288, + "__x": -0.760845213036123, + "__y": -0.2472135954999578 + } + ] + }, + "mark": { + "type": "line", + "interpolate": "linear-closed", + "strokeWidth": 1.5, + "point": false, + "fillOpacity": 0.15 + }, + "encoding": { + "x": { + "field": "__x", + "type": "quantitative", + "axis": null + }, + "y": { + "field": "__y", + "type": "quantitative", + "axis": null + }, + "order": { + "field": "__angle", + "type": "quantitative" + }, + "tooltip": [ + { + "field": "__axis", + "type": "nominal", + "title": "Nutrient" + }, + { + "field": "__raw", + "type": "quantitative", + "title": "Grams/100g" + } + ], + "stroke": { + "field": "__group", + "type": "nominal", + "title": "Food" + }, + "fill": { + "field": "__group", + "type": "nominal", + "title": "Food", + "legend": null + } + } + }, + { + "data": { + "values": [ + { + "__group": "Almonds", + "__axis": "Protein", + "__value": 0.84, + "__raw": 21, + "__angle": 0, + "__x": 0, + "__y": -0.84 + }, + { + "__group": "Almonds", + "__axis": "Fat", + "__value": 1, + "__raw": 50, + "__angle": 72, + "__x": 0.9510565162951535, + "__y": -0.30901699437494745 + }, + { + "__group": "Almonds", + "__axis": "Carbs", + "__value": 0.22, + "__raw": 22, + "__angle": 144, + "__x": 0.12931275550434412, + "__y": 0.1779837387624884 + }, + { + "__group": "Almonds", + "__axis": "Fiber", + "__value": 0.6, + "__raw": 12, + "__angle": 216, + "__x": -0.3526711513754838, + "__y": 0.4854101966249684 + }, + { + "__group": "Almonds", + "__axis": "Sugar", + "__value": 0.8, + "__raw": 4, + "__angle": 288, + "__x": -0.760845213036123, + "__y": -0.2472135954999578 + }, + { + "__group": "Oats", + "__axis": "Protein", + "__value": 0.68, + "__raw": 17, + "__angle": 0, + "__x": 0, + "__y": -0.68 + }, + { + "__group": "Oats", + "__axis": "Fat", + "__value": 0.14, + "__raw": 7, + "__angle": 72, + "__x": 0.1331479122813215, + "__y": -0.043262379212492645 + }, + { + "__group": "Oats", + "__axis": "Carbs", + "__value": 0.66, + "__raw": 66, + "__angle": 144, + "__x": 0.38793826651303237, + "__y": 0.5339512162874652 + }, + { + "__group": "Oats", + "__axis": "Fiber", + "__value": 0.55, + "__raw": 11, + "__angle": 216, + "__x": -0.32328188876086017, + "__y": 0.44495934690622113 + }, + { + "__group": "Oats", + "__axis": "Sugar", + "__value": 0.2, + "__raw": 1, + "__angle": 288, + "__x": -0.19021130325903074, + "__y": -0.06180339887498945 + }, + { + "__group": "Greek yogurt", + "__axis": "Protein", + "__value": 0.4, + "__raw": 10, + "__angle": 0, + "__x": 0, + "__y": -0.4 + }, + { + "__group": "Greek yogurt", + "__axis": "Fat", + "__value": 0.1, + "__raw": 5, + "__angle": 72, + "__x": 0.09510565162951536, + "__y": -0.030901699437494747 + }, + { + "__group": "Greek yogurt", + "__axis": "Carbs", + "__value": 0.04, + "__raw": 4, + "__angle": 144, + "__x": 0.02351141009169893, + "__y": 0.032360679774997896 + }, + { + "__group": "Greek yogurt", + "__axis": "Fiber", + "__value": 0, + "__raw": 0, + "__angle": 216, + "__x": 0, + "__y": 0 + }, + { + "__group": "Greek yogurt", + "__axis": "Sugar", + "__value": 0.8, + "__raw": 4, + "__angle": 288, + "__x": -0.760845213036123, + "__y": -0.2472135954999578 + } + ] + }, + "mark": { + "type": "point", + "filled": true, + "size": 25 + }, + "encoding": { + "x": { + "field": "__x", + "type": "quantitative", + "axis": null + }, + "y": { + "field": "__y", + "type": "quantitative", + "axis": null + }, + "tooltip": [ + { + "field": "__group", + "type": "nominal", + "title": "Food" + }, + { + "field": "__axis", + "type": "nominal", + "title": "Nutrient" + }, + { + "field": "__raw", + "type": "quantitative", + "title": "Grams/100g" + } + ], + "color": { + "field": "__group", + "type": "nominal", + "title": "Food", + "legend": null + } + } + } + ], + "config": { + "view": { + "continuousWidth": 230, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "Nutrition profile per 100 g — Almonds vs Oats vs Greek yogurt" + }, + "data": { + "values": [ + { + "Food": "Almonds", + "Nutrient": "Protein", + "Grams/100g": 21 + }, + { + "Food": "Almonds", + "Nutrient": "Fat", + "Grams/100g": 50 + }, + { + "Food": "Almonds", + "Nutrient": "Carbs", + "Grams/100g": 22 + }, + { + "Food": "Almonds", + "Nutrient": "Fiber", + "Grams/100g": 12 + }, + { + "Food": "Almonds", + "Nutrient": "Sugar", + "Grams/100g": 4 + }, + { + "Food": "Oats", + "Nutrient": "Protein", + "Grams/100g": 17 + }, + { + "Food": "Oats", + "Nutrient": "Fat", + "Grams/100g": 7 + }, + { + "Food": "Oats", + "Nutrient": "Carbs", + "Grams/100g": 66 + }, + { + "Food": "Oats", + "Nutrient": "Fiber", + "Grams/100g": 11 + }, + { + "Food": "Oats", + "Nutrient": "Sugar", + "Grams/100g": 1 + }, + { + "Food": "Greek yogurt", + "Nutrient": "Protein", + "Grams/100g": 10 + }, + { + "Food": "Greek yogurt", + "Nutrient": "Fat", + "Grams/100g": 5 + }, + { + "Food": "Greek yogurt", + "Nutrient": "Carbs", + "Grams/100g": 4 + }, + { + "Food": "Greek yogurt", + "Nutrient": "Fiber", + "Grams/100g": 0 + }, + { + "Food": "Greek yogurt", + "Nutrient": "Sugar", + "Grams/100g": 4 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/oecd-facet-16.flint.json b/site/src/playground/theme-lab-assets/oecd-facet-16.flint.json new file mode 100644 index 00000000..f4159e4e --- /dev/null +++ b/site/src/playground/theme-lab-assets/oecd-facet-16.flint.json @@ -0,0 +1,465 @@ +{ + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "facet": { + "field": "Country", + "type": "nominal", + "sort": null, + "columns": 6 + } + }, + "config": { + "view": { + "continuousWidth": 67, + "continuousHeight": 75 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 8, + "titleFontSize": 11, + "titleFontWeight": "normal", + "titleColor": "#666" + }, + "axisY": { + "labelFontSize": 8, + "titleFontSize": 11, + "titleFontWeight": "normal", + "titleColor": "#666" + }, + "legend": { + "labelFontSize": 9, + "titleFontSize": 9 + }, + "headerFacet": { + "labelFontSize": 9, + "labelLimit": 87 + }, + "facet": { + "spacing": 7 + } + }, + "title": { + "text": "Sixteen labour markets, one shock", + "subtitle": [ + "Harmonised unemployment rate, selected OECD economies, 2000–2023, per cent" + ] + }, + "data": { + "values": [ + { + "Year": "2000", + "Country": "Australia", + "Unemployment (%)": 6.3 + }, + { + "Year": "2007", + "Country": "Australia", + "Unemployment (%)": 4.4 + }, + { + "Year": "2010", + "Country": "Australia", + "Unemployment (%)": 5.2 + }, + { + "Year": "2015", + "Country": "Australia", + "Unemployment (%)": 6.1 + }, + { + "Year": "2023", + "Country": "Australia", + "Unemployment (%)": 3.7 + }, + { + "Year": "2000", + "Country": "Austria", + "Unemployment (%)": 4.7 + }, + { + "Year": "2007", + "Country": "Austria", + "Unemployment (%)": 4.9 + }, + { + "Year": "2010", + "Country": "Austria", + "Unemployment (%)": 4.8 + }, + { + "Year": "2015", + "Country": "Austria", + "Unemployment (%)": 5.7 + }, + { + "Year": "2023", + "Country": "Austria", + "Unemployment (%)": 5.1 + }, + { + "Year": "2000", + "Country": "Belgium", + "Unemployment (%)": 6.9 + }, + { + "Year": "2007", + "Country": "Belgium", + "Unemployment (%)": 7.5 + }, + { + "Year": "2010", + "Country": "Belgium", + "Unemployment (%)": 8.3 + }, + { + "Year": "2015", + "Country": "Belgium", + "Unemployment (%)": 8.5 + }, + { + "Year": "2023", + "Country": "Belgium", + "Unemployment (%)": 5.5 + }, + { + "Year": "2000", + "Country": "Canada", + "Unemployment (%)": 6.8 + }, + { + "Year": "2007", + "Country": "Canada", + "Unemployment (%)": 6 + }, + { + "Year": "2010", + "Country": "Canada", + "Unemployment (%)": 8 + }, + { + "Year": "2015", + "Country": "Canada", + "Unemployment (%)": 6.9 + }, + { + "Year": "2023", + "Country": "Canada", + "Unemployment (%)": 5.4 + }, + { + "Year": "2000", + "Country": "Denmark", + "Unemployment (%)": 4.3 + }, + { + "Year": "2007", + "Country": "Denmark", + "Unemployment (%)": 3.8 + }, + { + "Year": "2010", + "Country": "Denmark", + "Unemployment (%)": 7.5 + }, + { + "Year": "2015", + "Country": "Denmark", + "Unemployment (%)": 6.2 + }, + { + "Year": "2023", + "Country": "Denmark", + "Unemployment (%)": 5.1 + }, + { + "Year": "2000", + "Country": "Finland", + "Unemployment (%)": 9.8 + }, + { + "Year": "2007", + "Country": "Finland", + "Unemployment (%)": 6.9 + }, + { + "Year": "2010", + "Country": "Finland", + "Unemployment (%)": 8.4 + }, + { + "Year": "2015", + "Country": "Finland", + "Unemployment (%)": 9.4 + }, + { + "Year": "2023", + "Country": "Finland", + "Unemployment (%)": 7.2 + }, + { + "Year": "2000", + "Country": "France", + "Unemployment (%)": 9 + }, + { + "Year": "2007", + "Country": "France", + "Unemployment (%)": 8 + }, + { + "Year": "2010", + "Country": "France", + "Unemployment (%)": 9.3 + }, + { + "Year": "2015", + "Country": "France", + "Unemployment (%)": 10.4 + }, + { + "Year": "2023", + "Country": "France", + "Unemployment (%)": 7.3 + }, + { + "Year": "2000", + "Country": "Germany", + "Unemployment (%)": 7.9 + }, + { + "Year": "2007", + "Country": "Germany", + "Unemployment (%)": 8.7 + }, + { + "Year": "2010", + "Country": "Germany", + "Unemployment (%)": 7 + }, + { + "Year": "2015", + "Country": "Germany", + "Unemployment (%)": 4.6 + }, + { + "Year": "2023", + "Country": "Germany", + "Unemployment (%)": 3.1 + }, + { + "Year": "2000", + "Country": "Ireland", + "Unemployment (%)": 4.2 + }, + { + "Year": "2007", + "Country": "Ireland", + "Unemployment (%)": 4.7 + }, + { + "Year": "2010", + "Country": "Ireland", + "Unemployment (%)": 13.9 + }, + { + "Year": "2015", + "Country": "Ireland", + "Unemployment (%)": 9.9 + }, + { + "Year": "2023", + "Country": "Ireland", + "Unemployment (%)": 4.3 + }, + { + "Year": "2000", + "Country": "Italy", + "Unemployment (%)": 10.1 + }, + { + "Year": "2007", + "Country": "Italy", + "Unemployment (%)": 6.1 + }, + { + "Year": "2010", + "Country": "Italy", + "Unemployment (%)": 8.4 + }, + { + "Year": "2015", + "Country": "Italy", + "Unemployment (%)": 11.9 + }, + { + "Year": "2023", + "Country": "Italy", + "Unemployment (%)": 7.7 + }, + { + "Year": "2000", + "Country": "Japan", + "Unemployment (%)": 4.7 + }, + { + "Year": "2007", + "Country": "Japan", + "Unemployment (%)": 3.8 + }, + { + "Year": "2010", + "Country": "Japan", + "Unemployment (%)": 5.1 + }, + { + "Year": "2015", + "Country": "Japan", + "Unemployment (%)": 3.4 + }, + { + "Year": "2023", + "Country": "Japan", + "Unemployment (%)": 2.6 + }, + { + "Year": "2000", + "Country": "Netherlands", + "Unemployment (%)": 3.1 + }, + { + "Year": "2007", + "Country": "Netherlands", + "Unemployment (%)": 3.6 + }, + { + "Year": "2010", + "Country": "Netherlands", + "Unemployment (%)": 5 + }, + { + "Year": "2015", + "Country": "Netherlands", + "Unemployment (%)": 6.9 + }, + { + "Year": "2023", + "Country": "Netherlands", + "Unemployment (%)": 3.6 + }, + { + "Year": "2000", + "Country": "Norway", + "Unemployment (%)": 3.2 + }, + { + "Year": "2007", + "Country": "Norway", + "Unemployment (%)": 2.5 + }, + { + "Year": "2010", + "Country": "Norway", + "Unemployment (%)": 3.6 + }, + { + "Year": "2015", + "Country": "Norway", + "Unemployment (%)": 4.4 + }, + { + "Year": "2023", + "Country": "Norway", + "Unemployment (%)": 3.6 + }, + { + "Year": "2000", + "Country": "Spain", + "Unemployment (%)": 13.9 + }, + { + "Year": "2007", + "Country": "Spain", + "Unemployment (%)": 8.2 + }, + { + "Year": "2010", + "Country": "Spain", + "Unemployment (%)": 19.9 + }, + { + "Year": "2015", + "Country": "Spain", + "Unemployment (%)": 22.1 + }, + { + "Year": "2023", + "Country": "Spain", + "Unemployment (%)": 12.2 + }, + { + "Year": "2000", + "Country": "Sweden", + "Unemployment (%)": 5.6 + }, + { + "Year": "2007", + "Country": "Sweden", + "Unemployment (%)": 6.1 + }, + { + "Year": "2010", + "Country": "Sweden", + "Unemployment (%)": 8.6 + }, + { + "Year": "2015", + "Country": "Sweden", + "Unemployment (%)": 7.4 + }, + { + "Year": "2023", + "Country": "Sweden", + "Unemployment (%)": 7.7 + }, + { + "Year": "2000", + "Country": "United States", + "Unemployment (%)": 4 + }, + { + "Year": "2007", + "Country": "United States", + "Unemployment (%)": 4.6 + }, + { + "Year": "2010", + "Country": "United States", + "Unemployment (%)": 9.6 + }, + { + "Year": "2015", + "Country": "United States", + "Unemployment (%)": 5.3 + }, + { + "Year": "2023", + "Country": "United States", + "Unemployment (%)": 3.6 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/oecd-facet-16.powerbi.json b/site/src/playground/theme-lab-assets/oecd-facet-16.powerbi.json new file mode 100644 index 00000000..0c3b49a6 --- /dev/null +++ b/site/src/playground/theme-lab-assets/oecd-facet-16.powerbi.json @@ -0,0 +1,193 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi", + "__design__": [ + "Sixteen panels laid out 4×4 instead of 6×3. Flint's wrap leaves two empty cells in the bottom row, and an empty cell in a grid is read as missing data rather than as spare space. A square grid divides sixteen exactly.", + "Panel frames removed. At this density the sixteen rectangles are the loudest thing on the page — thirty-two vertical rules competing with sixteen data lines. The panels are separated by whitespace and their headers instead.", + "The 'Country' super-header deleted. It titles a strip of headers that are already obviously country names, and it costs a full row of vertical space that sixteen panels cannot spare.", + "Panel order left exactly as the baseline has it. Reordering the grid by rate would help it read as a ranking, but which country follows which is a claim about the data, not a house style — it belongs in the encoding, upstream of any theme.", + "Shared y scale kept, deliberately. It is the reason twelve of the sixteen lines sit low in their panels — but a small multiple exists to be compared across panels, and per-panel scales would make Japan's 2.6% and Spain's 22.1% draw as the same picture. The cost is real and it is the right cost.", + "Y axis labelled once per row and x once per column, at 8pt, with the axis titles dropped — the subtitle carries the unit and the years, so sixteen repetitions of 'Unemployment (%)' state it fifteen times too often.", + "Country names given no label limit; Flint clips 'United Stat…' in a header that has a whole panel width available to it.", + "Dark #1B1A19 report canvas, 1.4px lines in #4FC3F7, no gridlines except a single hairline at each labelled tick." + ], + "background": "#1b1a19", + "padding": { "left": 6, "top": 6, "right": 10, "bottom": 6 }, + "title": { + "text": "Sixteen labour markets, one shock", + "subtitle": ["Harmonised unemployment rate, selected OECD economies, 2000–2023, per cent"] + }, + "data": { + "values": [ + { "Year": 2000, "Country": "Australia", "Unemployment (%)": 6.3 }, + { "Year": 2007, "Country": "Australia", "Unemployment (%)": 4.4 }, + { "Year": 2010, "Country": "Australia", "Unemployment (%)": 5.2 }, + { "Year": 2015, "Country": "Australia", "Unemployment (%)": 6.1 }, + { "Year": 2023, "Country": "Australia", "Unemployment (%)": 3.7 }, + { "Year": 2000, "Country": "Austria", "Unemployment (%)": 4.7 }, + { "Year": 2007, "Country": "Austria", "Unemployment (%)": 4.9 }, + { "Year": 2010, "Country": "Austria", "Unemployment (%)": 4.8 }, + { "Year": 2015, "Country": "Austria", "Unemployment (%)": 5.7 }, + { "Year": 2023, "Country": "Austria", "Unemployment (%)": 5.1 }, + { "Year": 2000, "Country": "Belgium", "Unemployment (%)": 6.9 }, + { "Year": 2007, "Country": "Belgium", "Unemployment (%)": 7.5 }, + { "Year": 2010, "Country": "Belgium", "Unemployment (%)": 8.3 }, + { "Year": 2015, "Country": "Belgium", "Unemployment (%)": 8.5 }, + { "Year": 2023, "Country": "Belgium", "Unemployment (%)": 5.5 }, + { "Year": 2000, "Country": "Canada", "Unemployment (%)": 6.8 }, + { "Year": 2007, "Country": "Canada", "Unemployment (%)": 6 }, + { "Year": 2010, "Country": "Canada", "Unemployment (%)": 8 }, + { "Year": 2015, "Country": "Canada", "Unemployment (%)": 6.9 }, + { "Year": 2023, "Country": "Canada", "Unemployment (%)": 5.4 }, + { "Year": 2000, "Country": "Denmark", "Unemployment (%)": 4.3 }, + { "Year": 2007, "Country": "Denmark", "Unemployment (%)": 3.8 }, + { "Year": 2010, "Country": "Denmark", "Unemployment (%)": 7.5 }, + { "Year": 2015, "Country": "Denmark", "Unemployment (%)": 6.2 }, + { "Year": 2023, "Country": "Denmark", "Unemployment (%)": 5.1 }, + { "Year": 2000, "Country": "Finland", "Unemployment (%)": 9.8 }, + { "Year": 2007, "Country": "Finland", "Unemployment (%)": 6.9 }, + { "Year": 2010, "Country": "Finland", "Unemployment (%)": 8.4 }, + { "Year": 2015, "Country": "Finland", "Unemployment (%)": 9.4 }, + { "Year": 2023, "Country": "Finland", "Unemployment (%)": 7.2 }, + { "Year": 2000, "Country": "France", "Unemployment (%)": 9 }, + { "Year": 2007, "Country": "France", "Unemployment (%)": 8 }, + { "Year": 2010, "Country": "France", "Unemployment (%)": 9.3 }, + { "Year": 2015, "Country": "France", "Unemployment (%)": 10.4 }, + { "Year": 2023, "Country": "France", "Unemployment (%)": 7.3 }, + { "Year": 2000, "Country": "Germany", "Unemployment (%)": 7.9 }, + { "Year": 2007, "Country": "Germany", "Unemployment (%)": 8.7 }, + { "Year": 2010, "Country": "Germany", "Unemployment (%)": 7 }, + { "Year": 2015, "Country": "Germany", "Unemployment (%)": 4.6 }, + { "Year": 2023, "Country": "Germany", "Unemployment (%)": 3.1 }, + { "Year": 2000, "Country": "Ireland", "Unemployment (%)": 4.2 }, + { "Year": 2007, "Country": "Ireland", "Unemployment (%)": 4.7 }, + { "Year": 2010, "Country": "Ireland", "Unemployment (%)": 13.9 }, + { "Year": 2015, "Country": "Ireland", "Unemployment (%)": 9.9 }, + { "Year": 2023, "Country": "Ireland", "Unemployment (%)": 4.3 }, + { "Year": 2000, "Country": "Italy", "Unemployment (%)": 10.1 }, + { "Year": 2007, "Country": "Italy", "Unemployment (%)": 6.1 }, + { "Year": 2010, "Country": "Italy", "Unemployment (%)": 8.4 }, + { "Year": 2015, "Country": "Italy", "Unemployment (%)": 11.9 }, + { "Year": 2023, "Country": "Italy", "Unemployment (%)": 7.7 }, + { "Year": 2000, "Country": "Japan", "Unemployment (%)": 4.7 }, + { "Year": 2007, "Country": "Japan", "Unemployment (%)": 3.8 }, + { "Year": 2010, "Country": "Japan", "Unemployment (%)": 5.1 }, + { "Year": 2015, "Country": "Japan", "Unemployment (%)": 3.4 }, + { "Year": 2023, "Country": "Japan", "Unemployment (%)": 2.6 }, + { "Year": 2000, "Country": "Netherlands", "Unemployment (%)": 3.1 }, + { "Year": 2007, "Country": "Netherlands", "Unemployment (%)": 3.6 }, + { "Year": 2010, "Country": "Netherlands", "Unemployment (%)": 5 }, + { "Year": 2015, "Country": "Netherlands", "Unemployment (%)": 6.9 }, + { "Year": 2023, "Country": "Netherlands", "Unemployment (%)": 3.6 }, + { "Year": 2000, "Country": "Norway", "Unemployment (%)": 3.2 }, + { "Year": 2007, "Country": "Norway", "Unemployment (%)": 2.5 }, + { "Year": 2010, "Country": "Norway", "Unemployment (%)": 3.6 }, + { "Year": 2015, "Country": "Norway", "Unemployment (%)": 4.4 }, + { "Year": 2023, "Country": "Norway", "Unemployment (%)": 3.6 }, + { "Year": 2000, "Country": "Spain", "Unemployment (%)": 13.9 }, + { "Year": 2007, "Country": "Spain", "Unemployment (%)": 8.2 }, + { "Year": 2010, "Country": "Spain", "Unemployment (%)": 19.9 }, + { "Year": 2015, "Country": "Spain", "Unemployment (%)": 22.1 }, + { "Year": 2023, "Country": "Spain", "Unemployment (%)": 12.2 }, + { "Year": 2000, "Country": "Sweden", "Unemployment (%)": 5.6 }, + { "Year": 2007, "Country": "Sweden", "Unemployment (%)": 6.1 }, + { "Year": 2010, "Country": "Sweden", "Unemployment (%)": 8.6 }, + { "Year": 2015, "Country": "Sweden", "Unemployment (%)": 7.4 }, + { "Year": 2023, "Country": "Sweden", "Unemployment (%)": 7.7 }, + { "Year": 2000, "Country": "United States", "Unemployment (%)": 4 }, + { "Year": 2007, "Country": "United States", "Unemployment (%)": 4.6 }, + { "Year": 2010, "Country": "United States", "Unemployment (%)": 9.6 }, + { "Year": 2015, "Country": "United States", "Unemployment (%)": 5.3 }, + { "Year": 2023, "Country": "United States", "Unemployment (%)": 3.6 } + ] + }, + "facet": { + "field": "Country", + "type": "nominal", + "title": null, + "sort": null, + "header": { + "labelAngle": 0, + "labelAlign": "left", + "labelAnchor": "start", + "labelFontSize": 9.5, + "labelColor": "#f3f2f1", + "labelFontWeight": 600, + "labelLimit": 0, + "labelPadding": 2, + "titleColor": "#a19f9d" + } + }, + "columns": 4, + "spacing": { "row": 16, "column": 16 }, + "spec": { + "width": 110, + "height": 62, + "mark": { "type": "line", "color": "#4fc3f7", "strokeWidth": 1.4, "strokeCap": "round" }, + "encoding": { + "x": { + "field": "Year", + "type": "quantitative", + "title": null, + "scale": { "zero": false, "nice": false, "domain": [2000, 2023] }, + "axis": { + "values": [2000, 2010, 2023], + "format": "d", + "labelFontSize": 8, + "labelColor": "#8a8886", + "grid": false, + "domain": false, + "ticks": false, + "labelPadding": 3 + } + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 24] }, + "axis": { + "values": [0, 10, 20], + "format": "d", + "labelFontSize": 8, + "labelColor": "#8a8886", + "grid": true, + "gridColor": "#302f2d", + "gridWidth": 1, + "domain": false, + "ticks": false, + "labelPadding": 3 + } + } + } + }, + "config": { + "background": "#1b1a19", + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, -apple-system, sans-serif", + "title": { + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#a19f9d", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "labelFontSize": 8, + "labelColor": "#8a8886", + "domain": false, + "ticks": false, + "grid": false + }, + "header": { + "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "titleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif" + } + } +} diff --git a/site/src/playground/theme-lab-assets/oecd-unemployment-facet.economist.json b/site/src/playground/theme-lab-assets/oecd-unemployment-facet.economist.json new file mode 100644 index 00000000..723c58ed --- /dev/null +++ b/site/src/playground/theme-lab-assets/oecd-unemployment-facet.economist.json @@ -0,0 +1,138 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "economist", + "__design__": [ + "Masthead rule added above the panels as its own vconcat row — the same brand furniture the paper puts on every chart, carrying no data.", + "Panel headers rewritten as flush-left bold labels with no 'Country' title and no boxes. Flint emits a centred grey header per facet; the house style treats each panel title as a small headline.", + "Shared y axis moved to the right of the last panel is not expressible in Vega-Lite, so the compromise is: y labels only on the first panel (via a per-panel axis config) and gridlines carried across all four.", + "Y scale pinned to a common [0, 24] domain. Flint already shares the scale, but pinning it makes the comparison a stated property rather than a side effect of the data.", + "Panel geometry widened to 88×150 with 14px spacing — four narrow columns read as one chart; Flint's 105×155 with 11px spacing reads as four charts.", + "Type dropped to 8.5pt labels and the x axis reduced to two ticks per panel (start and end), because a small multiple is read by shape, not by value.", + "House blue #006BA2 at 1.6px replaces the default hairline; the tick marks and baseline go hard black." + ], + "background": "#ffffff", + "padding": { "left": 6, "top": 4, "right": 10, "bottom": 6 }, + "title": { + "text": "Out of work", + "subtitle": ["Unemployment rate, %, 2000–2022"] + }, + "spacing": 10, + "vconcat": [ + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#e3120b", "stroke": null }, + "width": 26, + "height": 3, + "view": { "stroke": null } + }, + { + "data": { + "values": [ + { "Year": "2000", "Country": "United States", "Unemployment (%)": 4.0 }, + { "Year": "2005", "Country": "United States", "Unemployment (%)": 5.1 }, + { "Year": "2010", "Country": "United States", "Unemployment (%)": 9.6 }, + { "Year": "2015", "Country": "United States", "Unemployment (%)": 5.3 }, + { "Year": "2020", "Country": "United States", "Unemployment (%)": 8.1 }, + { "Year": "2022", "Country": "United States", "Unemployment (%)": 3.6 }, + { "Year": "2000", "Country": "Germany", "Unemployment (%)": 7.9 }, + { "Year": "2005", "Country": "Germany", "Unemployment (%)": 11.2 }, + { "Year": "2010", "Country": "Germany", "Unemployment (%)": 7.0 }, + { "Year": "2015", "Country": "Germany", "Unemployment (%)": 4.6 }, + { "Year": "2020", "Country": "Germany", "Unemployment (%)": 3.7 }, + { "Year": "2022", "Country": "Germany", "Unemployment (%)": 3.1 }, + { "Year": "2000", "Country": "Japan", "Unemployment (%)": 4.7 }, + { "Year": "2005", "Country": "Japan", "Unemployment (%)": 4.4 }, + { "Year": "2010", "Country": "Japan", "Unemployment (%)": 5.1 }, + { "Year": "2015", "Country": "Japan", "Unemployment (%)": 3.4 }, + { "Year": "2020", "Country": "Japan", "Unemployment (%)": 2.8 }, + { "Year": "2022", "Country": "Japan", "Unemployment (%)": 2.6 }, + { "Year": "2000", "Country": "Spain", "Unemployment (%)": 13.9 }, + { "Year": "2005", "Country": "Spain", "Unemployment (%)": 9.2 }, + { "Year": "2010", "Country": "Spain", "Unemployment (%)": 19.9 }, + { "Year": "2015", "Country": "Spain", "Unemployment (%)": 22.1 }, + { "Year": "2020", "Country": "Spain", "Unemployment (%)": 15.5 }, + { "Year": "2022", "Country": "Spain", "Unemployment (%)": 12.9 } + ] + }, + "facet": { + "field": "Country", + "type": "nominal", + "sort": ["United States", "Germany", "Japan", "Spain"], + "columns": 4, + "title": null, + "header": { + "labelAnchor": "start", + "labelFontSize": 10, + "labelFontWeight": 700, + "labelColor": "#121317", + "labelPadding": 2, + "labelOrient": "top" + } + }, + "spacing": 14, + "spec": { + "width": 88, + "height": 150, + "mark": { "type": "line", "color": "#006ba2", "strokeWidth": 1.6 }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "scale": { "type": "utc" }, + "axis": { + "format": "%y", + "values": ["2000", "2022"], + "labelFlush": true, + "grid": false, + "domain": true, + "domainColor": "#121317", + "ticks": true, + "tickColor": "#121317", + "tickSize": 3 + } + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "title": null, + "scale": { "domain": [0, 24] }, + "axis": { + "tickCount": 4, + "grid": true, + "gridColor": "#c9d3da", + "domain": false, + "ticks": false, + "labelPadding": 3 + } + } + } + }, + "resolve": { "axis": { "y": "shared" } } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#54585a", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#54585a", + "titleFontSize": 9 + }, + "header": { "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif" } + } +} diff --git a/site/src/playground/theme-lab-assets/oecd-unemployment-facet.flint.json b/site/src/playground/theme-lab-assets/oecd-unemployment-facet.flint.json new file mode 100644 index 00000000..1cdf5691 --- /dev/null +++ b/site/src/playground/theme-lab-assets/oecd-unemployment-facet.flint.json @@ -0,0 +1,184 @@ +{ + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "facet": { + "field": "Country", + "type": "nominal", + "sort": null, + "columns": 4 + } + }, + "config": { + "view": { + "continuousWidth": 105, + "continuousHeight": 155 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 8, + "titleFontSize": 11, + "titleFontWeight": "normal", + "titleColor": "#666" + }, + "axisY": { + "labelFontSize": 8, + "titleFontSize": 11, + "titleFontWeight": "normal", + "titleColor": "#666" + }, + "legend": { + "labelFontSize": 9, + "titleFontSize": 9 + }, + "headerFacet": { + "labelLimit": 125 + }, + "facet": { + "spacing": 11 + } + }, + "title": { + "text": "Out of work", + "subtitle": [ + "Unemployment rate, %, 2000–2022" + ] + }, + "data": { + "values": [ + { + "Year": "2000", + "Country": "United States", + "Unemployment (%)": 4 + }, + { + "Year": "2005", + "Country": "United States", + "Unemployment (%)": 5.1 + }, + { + "Year": "2010", + "Country": "United States", + "Unemployment (%)": 9.6 + }, + { + "Year": "2015", + "Country": "United States", + "Unemployment (%)": 5.3 + }, + { + "Year": "2020", + "Country": "United States", + "Unemployment (%)": 8.1 + }, + { + "Year": "2022", + "Country": "United States", + "Unemployment (%)": 3.6 + }, + { + "Year": "2000", + "Country": "Germany", + "Unemployment (%)": 7.9 + }, + { + "Year": "2005", + "Country": "Germany", + "Unemployment (%)": 11.2 + }, + { + "Year": "2010", + "Country": "Germany", + "Unemployment (%)": 7 + }, + { + "Year": "2015", + "Country": "Germany", + "Unemployment (%)": 4.6 + }, + { + "Year": "2020", + "Country": "Germany", + "Unemployment (%)": 3.7 + }, + { + "Year": "2022", + "Country": "Germany", + "Unemployment (%)": 3.1 + }, + { + "Year": "2000", + "Country": "Japan", + "Unemployment (%)": 4.7 + }, + { + "Year": "2005", + "Country": "Japan", + "Unemployment (%)": 4.4 + }, + { + "Year": "2010", + "Country": "Japan", + "Unemployment (%)": 5.1 + }, + { + "Year": "2015", + "Country": "Japan", + "Unemployment (%)": 3.4 + }, + { + "Year": "2020", + "Country": "Japan", + "Unemployment (%)": 2.8 + }, + { + "Year": "2022", + "Country": "Japan", + "Unemployment (%)": 2.6 + }, + { + "Year": "2000", + "Country": "Spain", + "Unemployment (%)": 13.9 + }, + { + "Year": "2005", + "Country": "Spain", + "Unemployment (%)": 9.2 + }, + { + "Year": "2010", + "Country": "Spain", + "Unemployment (%)": 19.9 + }, + { + "Year": "2015", + "Country": "Spain", + "Unemployment (%)": 22.1 + }, + { + "Year": "2020", + "Country": "Spain", + "Unemployment (%)": 15.5 + }, + { + "Year": "2022", + "Country": "Spain", + "Unemployment (%)": 12.9 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/olympic-bump.flint.json b/site/src/playground/theme-lab-assets/olympic-bump.flint.json new file mode 100644 index 00000000..1aed67ed --- /dev/null +++ b/site/src/playground/theme-lab-assets/olympic-bump.flint.json @@ -0,0 +1,164 @@ +{ + "mark": { + "type": "line", + "point": true, + "interpolate": "linear", + "strokeWidth": 2 + }, + "encoding": { + "x": { + "field": "Games", + "type": "temporal", + "scale": { + "padding": 10 + } + }, + "y": { + "field": "Rank", + "type": "quantitative", + "scale": { + "reverse": true, + "domain": [ + 1, + 7 + ], + "zero": false, + "nice": false, + "padding": 14 + }, + "axis": { + "values": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7 + ], + "tickCount": 7, + "format": ",.12~g" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Four Games, four different stories", + "subtitle": [ + "Rank in the Summer Olympics medal table, 2012–2024" + ] + }, + "data": { + "values": [ + { + "Games": "2012", + "Country": "United States", + "Rank": 1 + }, + { + "Games": "2016", + "Country": "United States", + "Rank": 1 + }, + { + "Games": "2020", + "Country": "United States", + "Rank": 1 + }, + { + "Games": "2024", + "Country": "United States", + "Rank": 1 + }, + { + "Games": "2012", + "Country": "China", + "Rank": 2 + }, + { + "Games": "2016", + "Country": "China", + "Rank": 3 + }, + { + "Games": "2020", + "Country": "China", + "Rank": 2 + }, + { + "Games": "2024", + "Country": "China", + "Rank": 2 + }, + { + "Games": "2012", + "Country": "Great Britain", + "Rank": 3 + }, + { + "Games": "2016", + "Country": "Great Britain", + "Rank": 2 + }, + { + "Games": "2020", + "Country": "Great Britain", + "Rank": 4 + }, + { + "Games": "2024", + "Country": "Great Britain", + "Rank": 7 + }, + { + "Games": "2012", + "Country": "Japan", + "Rank": 6 + }, + { + "Games": "2016", + "Country": "Japan", + "Rank": 6 + }, + { + "Games": "2020", + "Country": "Japan", + "Rank": 5 + }, + { + "Games": "2024", + "Country": "Japan", + "Rank": 3 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/olympic-bump.nyt.json b/site/src/playground/theme-lab-assets/olympic-bump.nyt.json new file mode 100644 index 00000000..cf307207 --- /dev/null +++ b/site/src/playground/theme-lab-assets/olympic-bump.nyt.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nyt", + "__design__": [ + "Legend replaced by a direct label at the end of every line. On a bump chart the legend is the worst possible key — it forces a colour lookup for each of four lines at each of four columns, when the country name can simply sit where the line ends. Labels go on the right only; a matching set on the left collided with the rank axis, and the axis already states where each line starts.", + "Rank axis relabelled as ordinals (1st, 2nd, …) instead of the bare integers Flint prints. The numbers are ranks, not a quantity, and 'zero: true' on a rank scale — which the baseline sets — reserves space below 1st for a rank that cannot exist.", + "Tick set pinned to the ranks actually occupied (1–7) so no line lands between gridlines, and the axis title deleted: once the labels read '1st', the word 'rank' is redundant.", + "Palette cut from set2 to four NYT swatches used at full ink for the two constant leaders and desaturated grey for none of them — every series is a real answer to 'who finished where', so nothing here earns being greyed out.", + "Monotone interpolation dropped for straight segments. A bump chart's slope is its message; a spline invents curvature between two Games where nothing was measured.", + "Frame removed, gridlines turned horizontal-only and pushed to #ededed, and the x axis reduced to four Games labels on a black baseline." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 8, "bottom": 6 }, + "width": 210, + "height": 200, + "title": { + "text": "Four Games, four different stories", + "subtitle": ["Rank in the Summer Olympics medal table, 2012–2024"] + }, + "data": { + "values": [ + { "Games": "2012", "Country": "United States", "Rank": 1 }, + { "Games": "2016", "Country": "United States", "Rank": 1 }, + { "Games": "2020", "Country": "United States", "Rank": 1 }, + { "Games": "2024", "Country": "United States", "Rank": 1 }, + { "Games": "2012", "Country": "China", "Rank": 2 }, + { "Games": "2016", "Country": "China", "Rank": 3 }, + { "Games": "2020", "Country": "China", "Rank": 2 }, + { "Games": "2024", "Country": "China", "Rank": 2 }, + { "Games": "2012", "Country": "Great Britain", "Rank": 3 }, + { "Games": "2016", "Country": "Great Britain", "Rank": 2 }, + { "Games": "2020", "Country": "Great Britain", "Rank": 4 }, + { "Games": "2024", "Country": "Great Britain", "Rank": 7 }, + { "Games": "2012", "Country": "Japan", "Rank": 6 }, + { "Games": "2016", "Country": "Japan", "Rank": 6 }, + { "Games": "2020", "Country": "Japan", "Rank": 5 }, + { "Games": "2024", "Country": "Japan", "Rank": 3 } + ] + }, + "encoding": { + "x": { + "field": "Games", + "type": "ordinal", + "title": null, + "scale": { "padding": 0.12 }, + "axis": { "labelAngle": 0, "labelPadding": 6 } + }, + "y": { + "field": "Rank", + "type": "quantitative", + "title": null, + "scale": { "reverse": true, "zero": false, "nice": false, "domain": [1, 7], "padding": 14 }, + "axis": { + "values": [1, 2, 3, 4, 5, 6, 7], + "labelExpr": "datum.value == 1 ? '1st' : datum.value == 2 ? '2nd' : datum.value == 3 ? '3rd' : datum.value + 'th'" + } + }, + "color": { + "field": "Country", + "type": "nominal", + "legend": null, + "scale": { + "domain": ["United States", "China", "Great Britain", "Japan"], + "range": ["#c2352b", "#2f6b9a", "#7f6a9e", "#4a8b6f"] + } + } + }, + "layer": [ + { "mark": { "type": "line", "strokeWidth": 2, "strokeJoin": "round", "strokeCap": "round" } }, + { + "mark": { + "type": "point", + "filled": true, + "size": 34, + "stroke": "#ffffff", + "strokeWidth": 1.4 + } + }, + { + "transform": [{ "filter": "datum.Games === '2024'" }], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 8, + "fontSize": 10, + "fontWeight": 600 + }, + "encoding": { "text": { "field": "Country", "type": "nominal" } } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, 'Times New Roman', Times, serif", + "fontSize": 15, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "domain": false, + "ticks": false, + "grid": false + }, + "axisY": { + "grid": true, + "gridColor": "#ededed", + "gridWidth": 1, + "labelPadding": 6, + "labelFontWeight": 600, + "labelColor": "#121212" + }, + "axisX": { + "grid": false, + "domain": true, + "domainColor": "#121212", + "domainWidth": 1, + "ticks": true, + "tickColor": "#121212", + "tickSize": 4 + } + } +} diff --git a/site/src/playground/theme-lab-assets/penguins-box.flint.json b/site/src/playground/theme-lab-assets/penguins-box.flint.json new file mode 100644 index 00000000..e1abd351 --- /dev/null +++ b/site/src/playground/theme-lab-assets/penguins-box.flint.json @@ -0,0 +1,190 @@ +{ + "mark": { + "type": "boxplot", + "size": 22 + }, + "encoding": { + "x": { + "field": "Species", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Body mass (g)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "width": { + "step": 32 + }, + "title": { + "text": "Body mass of three Pygoscelis species", + "subtitle": [ + "Boxes show median and interquartile range; whiskers span the full sample; points are individual birds" + ] + }, + "data": { + "values": [ + { + "Species": "Adelie", + "Body mass (g)": 3750 + }, + { + "Species": "Adelie", + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Body mass (g)": 3250 + }, + { + "Species": "Adelie", + "Body mass (g)": 3450 + }, + { + "Species": "Adelie", + "Body mass (g)": 3650 + }, + { + "Species": "Adelie", + "Body mass (g)": 3625 + }, + { + "Species": "Adelie", + "Body mass (g)": 4675 + }, + { + "Species": "Adelie", + "Body mass (g)": 3200 + }, + { + "Species": "Adelie", + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Body mass (g)": 4400 + }, + { + "Species": "Adelie", + "Body mass (g)": 3700 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3500 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3900 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3650 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3525 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3950 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3800 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3300 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 4800 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 4050 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4500 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4450 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5400 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4800 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5000 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4650 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5950 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5250 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/penguins-box.nature.json b/site/src/playground/theme-lab-assets/penguins-box.nature.json new file mode 100644 index 00000000..c426c7f0 --- /dev/null +++ b/site/src/playground/theme-lab-assets/penguins-box.nature.json @@ -0,0 +1,165 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nature", + "__design__": [ + "Box replaced by a box-and-points composite: the summary box is drawn narrow and hollow, and every observation is jittered across it so the points read through the outline. At n = 10–13 per group a filled box hides the sample it claims to summarise; journals require the reader to see n.", + "Whiskers set to the full data range (extent 'min-max') rather than 1.5×IQR, because with this few points the Tukey fence produces outliers that are not outliers.", + "Box fill removed and the outline taken to 1px black; the median becomes the only heavy rule in the mark, which is the single value the box exists to communicate.", + "Colour moved off the box and onto the observation dots, using the Okabe–Ito colourblind-safe triple (#0072B2, #E69F00, #009E73) rather than tableau10.", + "Legend deleted — the species are already labelled on the x axis, so a colour key restates the axis.", + "Grid removed and replaced with black L-spines and outward 3.5px ticks; y domain pinned to 3000–6000 so the tick interval is a round 500 g.", + "Y title shortened to 'Body mass (g)' at 9pt and the whole figure sized to a 240×250 single-column block, the width a journal actually prints." + ], + "background": "#ffffff", + "padding": { "left": 4, "top": 4, "right": 6, "bottom": 4 }, + "width": { "step": 74 }, + "height": 250, + "title": { + "text": "Body mass of three Pygoscelis species", + "subtitle": ["Boxes show median and interquartile range; whiskers span the full sample; points are individual birds"] + }, + "data": { + "values": [ + { "Species": "Adelie", "Body mass (g)": 3750 }, + { "Species": "Adelie", "Body mass (g)": 3800 }, + { "Species": "Adelie", "Body mass (g)": 3250 }, + { "Species": "Adelie", "Body mass (g)": 3450 }, + { "Species": "Adelie", "Body mass (g)": 3650 }, + { "Species": "Adelie", "Body mass (g)": 3625 }, + { "Species": "Adelie", "Body mass (g)": 4675 }, + { "Species": "Adelie", "Body mass (g)": 3200 }, + { "Species": "Adelie", "Body mass (g)": 3800 }, + { "Species": "Adelie", "Body mass (g)": 4400 }, + { "Species": "Adelie", "Body mass (g)": 3700 }, + { "Species": "Chinstrap", "Body mass (g)": 3500 }, + { "Species": "Chinstrap", "Body mass (g)": 3900 }, + { "Species": "Chinstrap", "Body mass (g)": 3650 }, + { "Species": "Chinstrap", "Body mass (g)": 3525 }, + { "Species": "Chinstrap", "Body mass (g)": 3950 }, + { "Species": "Chinstrap", "Body mass (g)": 3800 }, + { "Species": "Chinstrap", "Body mass (g)": 3300 }, + { "Species": "Chinstrap", "Body mass (g)": 4800 }, + { "Species": "Chinstrap", "Body mass (g)": 4050 }, + { "Species": "Chinstrap", "Body mass (g)": 3550 }, + { "Species": "Gentoo", "Body mass (g)": 4500 }, + { "Species": "Gentoo", "Body mass (g)": 5700 }, + { "Species": "Gentoo", "Body mass (g)": 4450 }, + { "Species": "Gentoo", "Body mass (g)": 5700 }, + { "Species": "Gentoo", "Body mass (g)": 5400 }, + { "Species": "Gentoo", "Body mass (g)": 5550 }, + { "Species": "Gentoo", "Body mass (g)": 4800 }, + { "Species": "Gentoo", "Body mass (g)": 5000 }, + { "Species": "Gentoo", "Body mass (g)": 4650 }, + { "Species": "Gentoo", "Body mass (g)": 5550 }, + { "Species": "Gentoo", "Body mass (g)": 5950 }, + { "Species": "Gentoo", "Body mass (g)": 5250 } + ] + }, + "encoding": { + "x": { + "field": "Species", + "type": "nominal", + "sort": ["Adelie", "Chinstrap", "Gentoo"], + "title": null, + "axis": { + "labelAngle": 0, + "grid": false, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickSize": 3.5, + "labelPadding": 3 + } + }, + "y": { + "field": "Body mass (g)", + "type": "quantitative", + "title": "Body mass (g)", + "scale": { "domain": [3000, 6000], "nice": false }, + "axis": { + "values": [3000, 3500, 4000, 4500, 5000, 5500, 6000], + "grid": false, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickSize": 3.5, + "labelPadding": 2, + "titlePadding": 4 + } + } + }, + "layer": [ + { + "mark": { + "type": "boxplot", + "extent": "min-max", + "size": 26, + "box": { "fill": null, "stroke": "#000000", "strokeWidth": 1 }, + "median": { "stroke": "#000000", "strokeWidth": 1.8 }, + "rule": { "stroke": "#000000", "strokeWidth": 1 }, + "ticks": { "stroke": "#000000", "strokeWidth": 1, "size": 9 }, + "outliers": false + } + }, + { + "transform": [ + { "calculate": "random() * 2 - 1", "as": "__jitter" } + ], + "mark": { + "type": "point", + "filled": true, + "size": 22, + "opacity": 0.85, + "stroke": "#ffffff", + "strokeWidth": 0.4 + }, + "encoding": { + "xOffset": { + "field": "__jitter", + "type": "quantitative", + "scale": { "domain": [-3, 3] } + }, + "color": { + "field": "Species", + "type": "nominal", + "scale": { + "domain": ["Adelie", "Chinstrap", "Gentoo"], + "range": ["#0072b2", "#e69f00", "#009e73"] + }, + "legend": null + } + } + } + ], + "config": { + "background": "#ffffff", + "font": "Helvetica, Arial, sans-serif", + "title": { + "font": "Helvetica, Arial, sans-serif", + "fontSize": 10.5, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 10, + "subtitleFont": "Helvetica, Arial, sans-serif", + "subtitleFontSize": 8, + "subtitleColor": "#3c3c3c", + "subtitleLineHeight": 11, + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#000000", + "titleFont": "Helvetica, Arial, sans-serif", + "titleFontSize": 9, + "titleColor": "#000000", + "titleFontWeight": 400 + } + } +} diff --git a/site/src/playground/theme-lab-assets/penguins-violin.flint.json b/site/src/playground/theme-lab-assets/penguins-violin.flint.json new file mode 100644 index 00000000..2eeec14a --- /dev/null +++ b/site/src/playground/theme-lab-assets/penguins-violin.flint.json @@ -0,0 +1,228 @@ +{ + "mark": { + "type": "area", + "orient": "horizontal" + }, + "transform": [ + { + "density": "Body mass (g)", + "groupby": [ + "Species" + ], + "as": [ + "value", + "density" + ], + "extent": [ + 2702.9783569331094, + 6447.021643066891 + ] + } + ], + "encoding": { + "y": { + "field": "value", + "type": "quantitative", + "title": "Body mass (g)", + "axis": { + "format": ",.12~g" + } + }, + "x": { + "field": "density", + "type": "quantitative", + "stack": "center", + "impute": null, + "title": null, + "axis": { + "labels": false, + "ticks": false, + "grid": false, + "format": ",.12~g" + } + }, + "color": { + "field": "Species", + "type": "nominal", + "legend": null + }, + "facet": { + "field": "Species", + "type": "nominal", + "sort": null, + "spacing": 0, + "header": { + "titleOrient": "bottom", + "labelOrient": "bottom", + "labelPadding": 2 + }, + "columns": 3 + } + }, + "width": 93, + "height": 160, + "config": { + "view": { + "continuousWidth": 93, + "continuousHeight": 160 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "Body mass by penguin species", + "subtitle": [ + "Kernel density with all observations overlaid; horizontal rule marks the median" + ] + }, + "data": { + "values": [ + { + "Species": "Adelie", + "Body mass (g)": 3750 + }, + { + "Species": "Adelie", + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Body mass (g)": 3250 + }, + { + "Species": "Adelie", + "Body mass (g)": 3450 + }, + { + "Species": "Adelie", + "Body mass (g)": 3650 + }, + { + "Species": "Adelie", + "Body mass (g)": 3625 + }, + { + "Species": "Adelie", + "Body mass (g)": 4675 + }, + { + "Species": "Adelie", + "Body mass (g)": 3200 + }, + { + "Species": "Adelie", + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Body mass (g)": 4400 + }, + { + "Species": "Adelie", + "Body mass (g)": 3700 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3500 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3900 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3650 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3525 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3950 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3800 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3300 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 4800 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 4050 + }, + { + "Species": "Chinstrap", + "Body mass (g)": 3550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4500 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4450 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5400 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4800 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5000 + }, + { + "Species": "Gentoo", + "Body mass (g)": 4650 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5950 + }, + { + "Species": "Gentoo", + "Body mass (g)": 5250 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/penguins-violin.nature.json b/site/src/playground/theme-lab-assets/penguins-violin.nature.json new file mode 100644 index 00000000..df629c3e --- /dev/null +++ b/site/src/playground/theme-lab-assets/penguins-violin.nature.json @@ -0,0 +1,195 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nature", + "__design__": [ + "Every observation drawn on top of its own density curve. A kernel density estimate from eleven penguins is a smooth curve asserting a shape that eleven points cannot establish; a figure panel has to let the reviewer see how much sample the curve was built from.", + "n stated per panel in the facet header. Flint labels each panel with the species name alone, which is the one thing the reader could already infer from the axis.", + "Density extent rounded to [2700, 6500] g. The baseline inherits an extent of [2702.9783569331094, 6447.021643066891] — a number no methods section could reproduce and no reader could justify.", + "Violins given a pale fill with a 0.8px black outline instead of a solid hue. Shape has to survive greyscale reproduction, so the outline carries the form and the colour is redundant reinforcement rather than the only cue.", + "Okabe–Ito palette replacing Flint's category default, and the same three hues used for both the fill and the points so a species reads as one object across the two marks.", + "Median marked with a short black rule inside each violin — the single summary statistic a distribution figure is normally asked for, and the one thing a density curve makes hardest to locate.", + "Black L-shaped spines with outward ticks, no gridlines, 8.5pt type, units mandatory in the axis title, and the density axis left unlabelled because its units are arbitrary and labelling them would invite a comparison that is not meaningful." + ], + "background": "#ffffff", + "padding": { "left": 4, "top": 4, "right": 6, "bottom": 4 }, + "title": { + "text": "Body mass by penguin species", + "subtitle": ["Kernel density with all observations overlaid; horizontal rule marks the median"] + }, + "data": { + "values": [ + { "Species": "Adelie", "Body mass (g)": 3750 }, + { "Species": "Adelie", "Body mass (g)": 3800 }, + { "Species": "Adelie", "Body mass (g)": 3250 }, + { "Species": "Adelie", "Body mass (g)": 3450 }, + { "Species": "Adelie", "Body mass (g)": 3650 }, + { "Species": "Adelie", "Body mass (g)": 3625 }, + { "Species": "Adelie", "Body mass (g)": 4675 }, + { "Species": "Adelie", "Body mass (g)": 3200 }, + { "Species": "Adelie", "Body mass (g)": 3800 }, + { "Species": "Adelie", "Body mass (g)": 4400 }, + { "Species": "Adelie", "Body mass (g)": 3700 }, + { "Species": "Chinstrap", "Body mass (g)": 3500 }, + { "Species": "Chinstrap", "Body mass (g)": 3900 }, + { "Species": "Chinstrap", "Body mass (g)": 3650 }, + { "Species": "Chinstrap", "Body mass (g)": 3525 }, + { "Species": "Chinstrap", "Body mass (g)": 3950 }, + { "Species": "Chinstrap", "Body mass (g)": 3800 }, + { "Species": "Chinstrap", "Body mass (g)": 3300 }, + { "Species": "Chinstrap", "Body mass (g)": 4800 }, + { "Species": "Chinstrap", "Body mass (g)": 4050 }, + { "Species": "Chinstrap", "Body mass (g)": 3550 }, + { "Species": "Gentoo", "Body mass (g)": 4500 }, + { "Species": "Gentoo", "Body mass (g)": 5700 }, + { "Species": "Gentoo", "Body mass (g)": 4450 }, + { "Species": "Gentoo", "Body mass (g)": 5700 }, + { "Species": "Gentoo", "Body mass (g)": 5400 }, + { "Species": "Gentoo", "Body mass (g)": 5550 }, + { "Species": "Gentoo", "Body mass (g)": 4800 }, + { "Species": "Gentoo", "Body mass (g)": 5000 }, + { "Species": "Gentoo", "Body mass (g)": 4650 }, + { "Species": "Gentoo", "Body mass (g)": 5550 }, + { "Species": "Gentoo", "Body mass (g)": 5950 }, + { "Species": "Gentoo", "Body mass (g)": 5250 } + ] + }, + "transform": [ + { "joinaggregate": [{ "op": "count", "as": "__n" }], "groupby": ["Species"] }, + { "calculate": "datum.Species + ' (n = ' + datum.__n + ')'", "as": "__panel" } + ], + "facet": { + "field": "__panel", + "type": "nominal", + "sort": ["Adelie (n = 11)", "Chinstrap (n = 10)", "Gentoo (n = 12)"], + "title": null, + "header": { + "labelOrient": "bottom", + "labelPadding": 4, + "labelFontSize": 8.5, + "labelColor": "#000000" + } + }, + "columns": 3, + "spacing": 4, + "resolve": { "scale": { "x": "shared" } }, + "spec": { + "width": 68, + "height": 175, + "encoding": { + "y": { + "field": "Body mass (g)", + "type": "quantitative", + "title": "Body mass (g)", + "scale": { "zero": false, "nice": false, "domain": [2700, 6500] }, + "axis": { "tickCount": 5, "format": ",d" } + }, + "color": { + "field": "Species", + "type": "nominal", + "legend": null, + "scale": { + "domain": ["Adelie", "Chinstrap", "Gentoo"], + "range": ["#0072b2", "#e69f00", "#009e73"] + } + } + }, + "layer": [ + { + "transform": [ + { + "density": "Body mass (g)", + "groupby": ["Species"], + "extent": [2700, 6500], + "as": ["__value", "__density"] + }, + { "calculate": "-datum.__density / 2", "as": "__xLeft" }, + { "calculate": "datum.__density / 2", "as": "__xRight" } + ], + "mark": { + "type": "area", + "orient": "horizontal", + "opacity": 0.28, + "stroke": "#000000", + "strokeWidth": 0.8, + "strokeOpacity": 1 + }, + "encoding": { + "y": { "field": "__value", "type": "quantitative" }, + "x": { + "field": "__xLeft", + "type": "quantitative", + "title": null, + "scale": { "nice": false, "domain": [-0.0006, 0.0006] }, + "axis": null + }, + "x2": { "field": "__xRight" } + } + }, + { + "transform": [{ "calculate": "(random() * 2 - 1) * 0.00019", "as": "__jx" }], + "mark": { + "type": "point", + "filled": true, + "size": 14, + "opacity": 0.85, + "stroke": "#ffffff", + "strokeWidth": 0.4 + }, + "encoding": { + "x": { "field": "__jx", "type": "quantitative", "axis": null } + } + }, + { + "transform": [ + { "aggregate": [{ "op": "median", "field": "Body mass (g)", "as": "__median" }], "groupby": ["Species"] } + ], + "mark": { "type": "rule", "color": "#000000", "strokeWidth": 1.2 }, + "encoding": { + "y": { "field": "__median", "type": "quantitative" }, + "x": { "datum": -0.00028, "type": "quantitative", "axis": null }, + "x2": { "datum": 0.00028 } + } + } + ] + }, + "config": { + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 6, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 8, + "subtitleFontStyle": "italic", + "subtitleColor": "#3c3c3c", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#000000", + "labelPadding": 2, + "titleFont": "Arial, Helvetica, sans-serif", + "titleFontSize": 9, + "titleFontWeight": 400, + "titleColor": "#000000", + "titlePadding": 4, + "grid": false, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 3.5 + }, + "header": { + "labelFont": "Arial, Helvetica, sans-serif" + } + } +} diff --git a/site/src/playground/theme-lab-assets/penguins.flint.json b/site/src/playground/theme-lab-assets/penguins.flint.json new file mode 100644 index 00000000..2b7f7049 --- /dev/null +++ b/site/src/playground/theme-lab-assets/penguins.flint.json @@ -0,0 +1,230 @@ +{ + "mark": "circle", + "encoding": { + "x": { + "field": "Flipper length (mm)", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Body mass (g)", + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Species", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + } + }, + "config": { + "view": { + "continuousWidth": 306, + "continuousHeight": 256 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 26 + } + }, + "title": { + "text": "Flipper length versus body mass in three penguin species", + "subtitle": [ + "Palmer Archipelago, Antarctica; n = 33" + ] + }, + "data": { + "values": [ + { + "Species": "Adelie", + "Flipper length (mm)": 181, + "Body mass (g)": 3750 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 186, + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 195, + "Body mass (g)": 3250 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 193, + "Body mass (g)": 3450 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 190, + "Body mass (g)": 3650 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 181, + "Body mass (g)": 3625 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 195, + "Body mass (g)": 4675 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 182, + "Body mass (g)": 3200 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 191, + "Body mass (g)": 3800 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 198, + "Body mass (g)": 4400 + }, + { + "Species": "Adelie", + "Flipper length (mm)": 185, + "Body mass (g)": 3700 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 192, + "Body mass (g)": 3500 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 196, + "Body mass (g)": 3900 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 193, + "Body mass (g)": 3650 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 188, + "Body mass (g)": 3525 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 197, + "Body mass (g)": 3950 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 198, + "Body mass (g)": 3800 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 178, + "Body mass (g)": 3300 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 207, + "Body mass (g)": 4800 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 201, + "Body mass (g)": 4050 + }, + { + "Species": "Chinstrap", + "Flipper length (mm)": 191, + "Body mass (g)": 3550 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 211, + "Body mass (g)": 4500 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 230, + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 210, + "Body mass (g)": 4450 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 218, + "Body mass (g)": 5700 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 215, + "Body mass (g)": 5400 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 219, + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 209, + "Body mass (g)": 4800 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 215, + "Body mass (g)": 5000 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 214, + "Body mass (g)": 4650 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 216, + "Body mass (g)": 5550 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 221, + "Body mass (g)": 5950 + }, + { + "Species": "Gentoo", + "Flipper length (mm)": 217, + "Body mass (g)": 5250 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/penguins.nature.json b/site/src/playground/theme-lab-assets/penguins.nature.json new file mode 100644 index 00000000..397d5107 --- /dev/null +++ b/site/src/playground/theme-lab-assets/penguins.nature.json @@ -0,0 +1,145 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nature", + "__design__": [ + "Grid deleted entirely and the frame replaced by black L-shaped spines with outward 3.5pt ticks. Flint's default kept a light box and no domain lines; a figure panel needs the opposite.", + "Palette switched from tableau10 to Okabe–Ito (#0072B2 / #E69F00 / #009E73), which stays separable in deuteranopia and in greyscale.", + "Redundant encoding added: species also maps to shape, and the two legends merge into one, so colour is never the only channel carrying the group.", + "Units made mandatory in both axis titles; type dropped to 8.5pt labels / 9pt titles for an 89 mm single-column figure.", + "Legend kept but moved inside the top-left of the plot — a journal figure pays for width, not for a legend gutter.", + "Point size cut to 26 with a thin white stroke so overlapping birds stay countable at print size, and mark type changed from circle to point so the shape channel is available at all.", + "Headline set as a small 12pt bold sans line with the sample description demoted to an 8pt italic deck — the journal convention of a terse label over a methods line, rather than one undifferentiated title string." + ], + "background": "#ffffff", + "padding": { "left": 4, "top": 4, "right": 6, "bottom": 4 }, + "width": 250, + "height": 195, + "title": { + "text": "Flipper length versus body mass in three penguin species", + "subtitle": ["Palmer Archipelago, Antarctica; n = 33"] + }, + "data": { + "values": [ + { "Species": "Adelie", "Flipper length (mm)": 181, "Body mass (g)": 3750 }, + { "Species": "Adelie", "Flipper length (mm)": 186, "Body mass (g)": 3800 }, + { "Species": "Adelie", "Flipper length (mm)": 195, "Body mass (g)": 3250 }, + { "Species": "Adelie", "Flipper length (mm)": 193, "Body mass (g)": 3450 }, + { "Species": "Adelie", "Flipper length (mm)": 190, "Body mass (g)": 3650 }, + { "Species": "Adelie", "Flipper length (mm)": 181, "Body mass (g)": 3625 }, + { "Species": "Adelie", "Flipper length (mm)": 195, "Body mass (g)": 4675 }, + { "Species": "Adelie", "Flipper length (mm)": 182, "Body mass (g)": 3200 }, + { "Species": "Adelie", "Flipper length (mm)": 191, "Body mass (g)": 3800 }, + { "Species": "Adelie", "Flipper length (mm)": 198, "Body mass (g)": 4400 }, + { "Species": "Adelie", "Flipper length (mm)": 185, "Body mass (g)": 3700 }, + { "Species": "Chinstrap", "Flipper length (mm)": 192, "Body mass (g)": 3500 }, + { "Species": "Chinstrap", "Flipper length (mm)": 196, "Body mass (g)": 3900 }, + { "Species": "Chinstrap", "Flipper length (mm)": 193, "Body mass (g)": 3650 }, + { "Species": "Chinstrap", "Flipper length (mm)": 188, "Body mass (g)": 3525 }, + { "Species": "Chinstrap", "Flipper length (mm)": 197, "Body mass (g)": 3950 }, + { "Species": "Chinstrap", "Flipper length (mm)": 198, "Body mass (g)": 3800 }, + { "Species": "Chinstrap", "Flipper length (mm)": 178, "Body mass (g)": 3300 }, + { "Species": "Chinstrap", "Flipper length (mm)": 207, "Body mass (g)": 4800 }, + { "Species": "Chinstrap", "Flipper length (mm)": 201, "Body mass (g)": 4050 }, + { "Species": "Chinstrap", "Flipper length (mm)": 191, "Body mass (g)": 3550 }, + { "Species": "Gentoo", "Flipper length (mm)": 211, "Body mass (g)": 4500 }, + { "Species": "Gentoo", "Flipper length (mm)": 230, "Body mass (g)": 5700 }, + { "Species": "Gentoo", "Flipper length (mm)": 210, "Body mass (g)": 4450 }, + { "Species": "Gentoo", "Flipper length (mm)": 218, "Body mass (g)": 5700 }, + { "Species": "Gentoo", "Flipper length (mm)": 215, "Body mass (g)": 5400 }, + { "Species": "Gentoo", "Flipper length (mm)": 219, "Body mass (g)": 5550 }, + { "Species": "Gentoo", "Flipper length (mm)": 209, "Body mass (g)": 4800 }, + { "Species": "Gentoo", "Flipper length (mm)": 215, "Body mass (g)": 5000 }, + { "Species": "Gentoo", "Flipper length (mm)": 214, "Body mass (g)": 4650 }, + { "Species": "Gentoo", "Flipper length (mm)": 216, "Body mass (g)": 5550 }, + { "Species": "Gentoo", "Flipper length (mm)": 221, "Body mass (g)": 5950 }, + { "Species": "Gentoo", "Flipper length (mm)": 217, "Body mass (g)": 5250 } + ] + }, + "mark": { "type": "point", "filled": true, "size": 26, "opacity": 0.95, "stroke": "#ffffff", "strokeWidth": 0.4 }, + "encoding": { + "x": { + "field": "Flipper length (mm)", + "type": "quantitative", + "title": "Flipper length (mm)", + "scale": { "zero": false, "nice": true }, + "axis": { "tickCount": 5 } + }, + "y": { + "field": "Body mass (g)", + "type": "quantitative", + "title": "Body mass (g)", + "scale": { "zero": false, "nice": true }, + "axis": { "tickCount": 5, "format": ",.0f" } + }, + "color": { + "field": "Species", + "type": "nominal", + "sort": null, + "title": "Species", + "scale": { "range": ["#0072b2", "#e69f00", "#009e73"] }, + "legend": { + "orient": "top-left", + "direction": "vertical", + "fillColor": "#ffffff", + "strokeColor": null, + "offset": 4, + "padding": 2 + } + }, + "shape": { + "field": "Species", + "type": "nominal", + "sort": null, + "title": "Species", + "scale": { "range": ["circle", "triangle-up", "square"] } + } + }, + "config": { + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 6, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 8, + "subtitleFontStyle": "italic", + "subtitleColor": "#3c3c3c", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#000000", + "labelPadding": 2, + "titleFont": "Arial, Helvetica, sans-serif", + "titleFontSize": 9, + "titleFontWeight": 400, + "titleColor": "#000000", + "titlePadding": 4, + "grid": false, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 3.5 + }, + "legend": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#000000", + "titleFont": "Arial, Helvetica, sans-serif", + "titleFontSize": 9, + "titleFontWeight": 400, + "titleColor": "#000000", + "symbolSize": 30, + "rowPadding": 0 + } + } +} diff --git a/site/src/playground/theme-lab-assets/population-region.datawrapper.json b/site/src/playground/theme-lab-assets/population-region.datawrapper.json new file mode 100644 index 00000000..a628f0a5 --- /dev/null +++ b/site/src/playground/theme-lab-assets/population-region.datawrapper.json @@ -0,0 +1,153 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "datawrapper", + "__design__": [ + "Type floor raised: 11pt axis labels and 11pt legend against Flint's 10pt. A civic chart has to survive being downscaled onto a phone.", + "Legend moved to the top with 100-unit square symbols and no title — it reads as a colour key strip rather than a boxed sidebar on the right.", + "Stack order pinned to the sequence the baseline already produces (Asia → Africa → Europe → Americas → Oceania) rather than left to 'sort: null', so the spec states the sequence instead of inheriting it from row order. The sequence itself is unchanged.", + "Scheme replaced by an explicit five-colour domain/range pair, so a region keeps the same colour across every chart in a story.", + "Gridlines switched to a light dashed rule (#DCDCDC, [2,2]) that reads behind the areas; the chart frame and y-axis domain are removed, but the x baseline is kept in near-black for contrast.", + "Y-axis title rotated flat to a bare unit ('millions'); x-axis title deleted and years formatted %Y.", + "A hairline footer rule is appended as a second vconcat row — it closes the chart block visually and is the anchor a source line would attach to." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 14, "bottom": 8 }, + "title": { + "text": "World population by region", + "subtitle": ["1950–2020, millions of people"] + }, + "spacing": 8, + "vconcat": [ + { + "width": 280, + "height": 190, + "data": { + "values": [ + { "Year": "1950", "Region": "Asia", "Population": 1404 }, + { "Year": "1950", "Region": "Africa", "Population": 227 }, + { "Year": "1950", "Region": "Europe", "Population": 549 }, + { "Year": "1950", "Region": "Americas", "Population": 339 }, + { "Year": "1950", "Region": "Oceania", "Population": 13 }, + { "Year": "1970", "Region": "Asia", "Population": 2142 }, + { "Year": "1970", "Region": "Africa", "Population": 365 }, + { "Year": "1970", "Region": "Europe", "Population": 657 }, + { "Year": "1970", "Region": "Americas", "Population": 512 }, + { "Year": "1970", "Region": "Oceania", "Population": 20 }, + { "Year": "1990", "Region": "Asia", "Population": 3226 }, + { "Year": "1990", "Region": "Africa", "Population": 630 }, + { "Year": "1990", "Region": "Europe", "Population": 721 }, + { "Year": "1990", "Region": "Americas", "Population": 724 }, + { "Year": "1990", "Region": "Oceania", "Population": 27 }, + { "Year": "2010", "Region": "Asia", "Population": 4194 }, + { "Year": "2010", "Region": "Africa", "Population": 1039 }, + { "Year": "2010", "Region": "Europe", "Population": 736 }, + { "Year": "2010", "Region": "Americas", "Population": 934 }, + { "Year": "2010", "Region": "Oceania", "Population": 37 }, + { "Year": "2020", "Region": "Asia", "Population": 4641 }, + { "Year": "2020", "Region": "Africa", "Population": 1361 }, + { "Year": "2020", "Region": "Europe", "Population": 748 }, + { "Year": "2020", "Region": "Americas", "Population": 1023 }, + { "Year": "2020", "Region": "Oceania", "Population": 45 } + ] + }, + "mark": { "type": "area", "line": false, "opacity": 1 }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "axis": { "format": "%Y", "tickCount": 5, "domain": true, "domainColor": "#333333", "grid": false } + }, + "y": { + "field": "Population", + "type": "quantitative", + "stack": "zero", + "title": "millions", + "axis": { + "format": ",.0f", + "tickCount": 4, + "grid": true, + "gridColor": "#dcdcdc", + "gridDash": [2, 2], + "domain": false, + "ticks": false, + "titleAngle": 0, + "titleAlign": "left", + "titleAnchor": "start", + "titleBaseline": "bottom", + "titleY": -8, + "titlePadding": 0 + } + }, + "color": { + "field": "Region", + "type": "nominal", + "title": null, + "sort": ["Asia", "Africa", "Europe", "Americas", "Oceania"], + "scale": { + "domain": ["Asia", "Africa", "Europe", "Americas", "Oceania"], + "range": ["#18a1cd", "#e2a233", "#2d8659", "#c04a4a", "#7e5aa2"] + }, + "legend": { + "orient": "top", + "direction": "horizontal", + "symbolType": "square", + "symbolSize": 100, + "columnPadding": 12, + "offset": 4, + "padding": 0 + } + }, + "order": { + "field": "Region", + "type": "nominal", + "sort": ["Asia", "Africa", "Europe", "Americas", "Oceania"] + } + } + }, + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#dcdcdc", "stroke": null }, + "width": 300, + "height": 1, + "view": { "stroke": null } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 12, + "lineHeight": 18, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "subtitleLineHeight": 15 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 11, + "labelColor": "#333333", + "labelPadding": 5, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 10.5, + "titleFontWeight": 400, + "titleColor": "#767676", + "grid": false, + "domain": false, + "ticks": false + }, + "legend": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 11, + "labelColor": "#333333" + } + } +} diff --git a/site/src/playground/theme-lab-assets/population-region.flint.json b/site/src/playground/theme-lab-assets/population-region.flint.json new file mode 100644 index 00000000..8ab3f1c8 --- /dev/null +++ b/site/src/playground/theme-lab-assets/population-region.flint.json @@ -0,0 +1,191 @@ +{ + "mark": "area", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Population", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Region", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10", + "domain": [ + "Asia", + "Africa", + "Europe", + "Americas", + "Oceania" + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "World population by region", + "subtitle": [ + "1950–2020, millions of people" + ] + }, + "data": { + "values": [ + { + "Year": "1950", + "Region": "Asia", + "Population": 1404 + }, + { + "Year": "1950", + "Region": "Africa", + "Population": 227 + }, + { + "Year": "1950", + "Region": "Europe", + "Population": 549 + }, + { + "Year": "1950", + "Region": "Americas", + "Population": 339 + }, + { + "Year": "1950", + "Region": "Oceania", + "Population": 13 + }, + { + "Year": "1970", + "Region": "Asia", + "Population": 2142 + }, + { + "Year": "1970", + "Region": "Africa", + "Population": 365 + }, + { + "Year": "1970", + "Region": "Europe", + "Population": 657 + }, + { + "Year": "1970", + "Region": "Americas", + "Population": 512 + }, + { + "Year": "1970", + "Region": "Oceania", + "Population": 20 + }, + { + "Year": "1990", + "Region": "Asia", + "Population": 3226 + }, + { + "Year": "1990", + "Region": "Africa", + "Population": 630 + }, + { + "Year": "1990", + "Region": "Europe", + "Population": 721 + }, + { + "Year": "1990", + "Region": "Americas", + "Population": 724 + }, + { + "Year": "1990", + "Region": "Oceania", + "Population": 27 + }, + { + "Year": "2010", + "Region": "Asia", + "Population": 4194 + }, + { + "Year": "2010", + "Region": "Africa", + "Population": 1039 + }, + { + "Year": "2010", + "Region": "Europe", + "Population": 736 + }, + { + "Year": "2010", + "Region": "Americas", + "Population": 934 + }, + { + "Year": "2010", + "Region": "Oceania", + "Population": 37 + }, + { + "Year": "2020", + "Region": "Asia", + "Population": 4641 + }, + { + "Year": "2020", + "Region": "Africa", + "Population": 1361 + }, + { + "Year": "2020", + "Region": "Europe", + "Population": 748 + }, + { + "Year": "2020", + "Region": "Americas", + "Population": 1023 + }, + { + "Year": "2020", + "Region": "Oceania", + "Population": 45 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/population-stream.flint.json b/site/src/playground/theme-lab-assets/population-stream.flint.json new file mode 100644 index 00000000..903d6b97 --- /dev/null +++ b/site/src/playground/theme-lab-assets/population-stream.flint.json @@ -0,0 +1,190 @@ +{ + "mark": "area", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Population", + "type": "quantitative", + "stack": "center", + "axis": null, + "scale": { + "zero": true + } + }, + "color": { + "field": "Region", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10", + "domain": [ + "Asia", + "Africa", + "Europe", + "Americas", + "Oceania" + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Where the world's people are", + "subtitle": [ + "Population by region, 1950–2020, millions" + ] + }, + "data": { + "values": [ + { + "Year": "1950", + "Region": "Asia", + "Population": 1404 + }, + { + "Year": "1950", + "Region": "Africa", + "Population": 227 + }, + { + "Year": "1950", + "Region": "Europe", + "Population": 549 + }, + { + "Year": "1950", + "Region": "Americas", + "Population": 339 + }, + { + "Year": "1950", + "Region": "Oceania", + "Population": 13 + }, + { + "Year": "1970", + "Region": "Asia", + "Population": 2142 + }, + { + "Year": "1970", + "Region": "Africa", + "Population": 365 + }, + { + "Year": "1970", + "Region": "Europe", + "Population": 657 + }, + { + "Year": "1970", + "Region": "Americas", + "Population": 512 + }, + { + "Year": "1970", + "Region": "Oceania", + "Population": 20 + }, + { + "Year": "1990", + "Region": "Asia", + "Population": 3226 + }, + { + "Year": "1990", + "Region": "Africa", + "Population": 630 + }, + { + "Year": "1990", + "Region": "Europe", + "Population": 721 + }, + { + "Year": "1990", + "Region": "Americas", + "Population": 724 + }, + { + "Year": "1990", + "Region": "Oceania", + "Population": 27 + }, + { + "Year": "2010", + "Region": "Asia", + "Population": 4194 + }, + { + "Year": "2010", + "Region": "Africa", + "Population": 1039 + }, + { + "Year": "2010", + "Region": "Europe", + "Population": 736 + }, + { + "Year": "2010", + "Region": "Americas", + "Population": 934 + }, + { + "Year": "2010", + "Region": "Oceania", + "Population": 37 + }, + { + "Year": "2020", + "Region": "Asia", + "Population": 4641 + }, + { + "Year": "2020", + "Region": "Africa", + "Population": 1361 + }, + { + "Year": "2020", + "Region": "Europe", + "Population": 748 + }, + { + "Year": "2020", + "Region": "Americas", + "Population": 1023 + }, + { + "Year": "2020", + "Region": "Oceania", + "Population": 45 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/population-stream.nyt.json b/site/src/playground/theme-lab-assets/population-stream.nyt.json new file mode 100644 index 00000000..3c8bfcc0 --- /dev/null +++ b/site/src/playground/theme-lab-assets/population-stream.nyt.json @@ -0,0 +1,141 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nyt", + "__design__": [ + "Legend deleted and each region named inside its own ribbon, with its 2020 figure attached. A streamgraph has no value axis to read against — Flint correctly sets axis null — so a right-hand key leaves the reader with shapes they cannot convert into numbers.", + "Stacking taken off the encoding and onto an explicit stack transform with offset 'center', so the label layer can compute each ribbon's midpoint from the same y0/y1 the area is drawn from. Positioning labels by eye on a centred stack is how they end up half a band out. The transform sorts descending because Vega-Lite draws the first colour-domain entry at the TOP of a vertical stack, and the two columns have to read in the same direction.", + "Oceania's label dropped just below its ribbon rather than sitting inside it: at 45m against a 7.8bn total the band is about one pixel tall, and a design that only labels the bands that happen to be thick enough is a design that hides its smallest category.", + "Gridlines turned vertical, not horizontal. On a centred stack a horizontal rule means nothing — there is no fixed baseline for it to measure from — whereas a vertical rule at each observed year marks where the thicknesses are actually comparable.", + "Five NYT swatches replacing tableau10, with labels reversed out in white; the ribbons carry saturated ink because on a stream the fill *is* the mark, not a container for one.", + "Serif headline flush left, frame and axis domain removed, x reduced to the five years where data exists — the baseline's temporal axis was interpolating tick positions between measurements." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 10, "bottom": 6 }, + "width": 300, + "height": 200, + "title": { + "text": "Where the world's people are", + "subtitle": ["Population by region, 1950–2020, millions"] + }, + "data": { + "values": [ + { "Year": "1950", "Region": "Asia", "Population": 1404 }, + { "Year": "1950", "Region": "Africa", "Population": 227 }, + { "Year": "1950", "Region": "Europe", "Population": 549 }, + { "Year": "1950", "Region": "Americas", "Population": 339 }, + { "Year": "1950", "Region": "Oceania", "Population": 13 }, + { "Year": "1970", "Region": "Asia", "Population": 2142 }, + { "Year": "1970", "Region": "Africa", "Population": 365 }, + { "Year": "1970", "Region": "Europe", "Population": 657 }, + { "Year": "1970", "Region": "Americas", "Population": 512 }, + { "Year": "1970", "Region": "Oceania", "Population": 20 }, + { "Year": "1990", "Region": "Asia", "Population": 3226 }, + { "Year": "1990", "Region": "Africa", "Population": 630 }, + { "Year": "1990", "Region": "Europe", "Population": 721 }, + { "Year": "1990", "Region": "Americas", "Population": 724 }, + { "Year": "1990", "Region": "Oceania", "Population": 27 }, + { "Year": "2010", "Region": "Asia", "Population": 4194 }, + { "Year": "2010", "Region": "Africa", "Population": 1039 }, + { "Year": "2010", "Region": "Europe", "Population": 736 }, + { "Year": "2010", "Region": "Americas", "Population": 934 }, + { "Year": "2010", "Region": "Oceania", "Population": 37 }, + { "Year": "2020", "Region": "Asia", "Population": 4641 }, + { "Year": "2020", "Region": "Africa", "Population": 1361 }, + { "Year": "2020", "Region": "Europe", "Population": 748 }, + { "Year": "2020", "Region": "Americas", "Population": 1023 }, + { "Year": "2020", "Region": "Oceania", "Population": 45 } + ] + }, + "transform": [ + { + "calculate": "indexof(['Asia', 'Africa', 'Europe', 'Americas', 'Oceania'], datum.Region)", + "as": "__band" + }, + { + "stack": "Population", + "groupby": ["Year"], + "sort": [{ "field": "__band", "order": "descending" }], + "offset": "center", + "as": ["__y0", "__y1"] + }, + { + "calculate": "datum.Region === 'Oceania' ? datum.__y0 - 270 : (datum.__y0 + datum.__y1) / 2", + "as": "__labelY" + }, + { + "calculate": "datum.Region + ' ' + format(datum.Population, ',')", + "as": "__label" + } + ], + "encoding": { + "x": { + "field": "Year", + "type": "ordinal", + "title": null, + "scale": { "padding": 0 }, + "axis": { "labelAngle": 0, "labelPadding": 6, "grid": true, "gridColor": "#e4e4e4", "gridDash": [2, 2] } + }, + "color": { + "field": "Region", + "type": "nominal", + "legend": null, + "scale": { + "domain": ["Asia", "Africa", "Europe", "Americas", "Oceania"], + "range": ["#c2352b", "#d9a441", "#4a8b6f", "#2f6b9a", "#7f6a9e"] + } + } + }, + "layer": [ + { + "mark": { "type": "area", "interpolate": "monotone", "line": false }, + "encoding": { + "y": { "field": "__y0", "type": "quantitative", "title": null, "axis": null }, + "y2": { "field": "__y1" } + } + }, + { + "transform": [{ "filter": "datum.Year === '2020'" }], + "mark": { + "type": "text", + "align": "right", + "baseline": "middle", + "dx": -7, + "fontSize": 9.5, + "fontWeight": 600 + }, + "encoding": { + "y": { "field": "__labelY", "type": "quantitative", "axis": null }, + "text": { "field": "__label", "type": "nominal" }, + "color": { + "condition": { "test": "datum.Region === 'Oceania'", "value": "#121212" }, + "value": "#ffffff" + } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, 'Times New Roman', Times, serif", + "fontSize": 15, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "domain": false, + "ticks": false, + "grid": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/population-waterfall.flint.json b/site/src/playground/theme-lab-assets/population-waterfall.flint.json new file mode 100644 index 00000000..fe9a91a5 --- /dev/null +++ b/site/src/playground/theme-lab-assets/population-waterfall.flint.json @@ -0,0 +1,197 @@ +{ + "encoding": { + "x": { + "field": "Step", + "type": "ordinal", + "sort": null, + "axis": { + "labelAngle": -45 + } + } + }, + "transform": [ + { + "window": [ + { + "op": "row_number", + "as": "__wf_row" + } + ] + }, + { + "joinaggregate": [ + { + "op": "count", + "as": "__wf_total" + } + ] + }, + { + "calculate": "datum.__wf_row === datum.__wf_total ? 'end' : 'delta'", + "as": "__wf_type" + }, + { + "window": [ + { + "op": "sum", + "field": "Population (M)", + "as": "__wf_sum_raw" + } + ] + }, + { + "calculate": "datum['__wf_type'] === 'end' ? datum.__wf_sum_raw - datum['Population (M)'] : datum.__wf_sum_raw", + "as": "__wf_sum" + }, + { + "calculate": "datum['__wf_type'] === 'end' ? 0 : datum.__wf_sum - datum['Population (M)']", + "as": "__wf_prev_sum" + }, + { + "calculate": "(datum['__wf_type'] === 'start' || datum['__wf_type'] === 'end') ? 'total' : datum['Population (M)'] >= 0 ? 'increase' : 'decrease'", + "as": "__wf_color" + }, + { + "window": [ + { + "op": "lead", + "field": "Step", + "as": "__wf_lead" + } + ] + }, + { + "calculate": "datum.__wf_lead === null ? datum['Step'] : datum.__wf_lead", + "as": "__wf_lead" + }, + { + "calculate": "datum.__wf_lead === datum['Step'] ? null : datum.__wf_sum", + "as": "__wf_connector_y" + } + ], + "layer": [ + { + "mark": { + "type": "bar" + }, + "encoding": { + "y": { + "field": "__wf_prev_sum", + "type": "quantitative", + "title": "Population (M)", + "axis": { + "format": ",.12~g" + } + }, + "y2": { + "field": "__wf_sum" + }, + "color": { + "field": "__wf_color", + "type": "nominal", + "scale": { + "domain": [ + "total", + "increase", + "decrease" + ], + "range": [ + "#f7e0b6", + "#93c4aa", + "#f78a64" + ] + }, + "legend": { + "title": "Type" + } + } + } + }, + { + "mark": { + "type": "rule", + "color": "#6b7280", + "opacity": 0.7, + "strokeWidth": 1 + }, + "encoding": { + "x": { + "field": "Step", + "type": "ordinal", + "sort": null, + "bandPosition": 0 + }, + "x2": { + "field": "__wf_lead", + "bandPosition": 1 + }, + "y": { + "field": "__wf_connector_y", + "type": "quantitative", + "axis": { + "format": ",.12~g" + } + } + } + } + ], + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "width": { + "step": 23 + }, + "title": { + "text": "Asia added more people than the world held in 1950", + "subtitle": [ + "Contribution to world population growth by region, 1950–2020, millions" + ] + }, + "data": { + "values": [ + { + "Step": "1950", + "Population (M)": 2536 + }, + { + "Step": "Asia", + "Population (M)": 3237 + }, + { + "Step": "Africa", + "Population (M)": 1134 + }, + { + "Step": "Americas", + "Population (M)": 684 + }, + { + "Step": "Europe", + "Population (M)": 199 + }, + { + "Step": "Oceania", + "Population (M)": 32 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/population-waterfall.mckinsey.json b/site/src/playground/theme-lab-assets/population-waterfall.mckinsey.json new file mode 100644 index 00000000..6e69fba6 --- /dev/null +++ b/site/src/playground/theme-lab-assets/population-waterfall.mckinsey.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "mckinsey", + "__design__": [ + "Flint's waterfall mislabels its own last bar. Its transform marks the final row as the 'end' total, so Oceania — a 32-million contribution — is drawn as a full-height 7,790 bar in 'total' beige. The chart says the last region listed is the sum of everything before it. Here every one of the five regions is a delta and the running total is stated once, as a label, where the last bar ends.", + "The three-swatch legend deleted. Flint spends a legend on 'total / increase / decrease' when no bar in this data decreases. Two fills — navy #051C2C for the 1950 base, brand blue #2251FF for every contribution — need no key because the x labels already name them, and the distinction they draw is the one the chart type is built on: a level against the steps that move it.", + "Value axis deleted and the numbers moved onto the bars, signed. '+3,237' says contribution; the unsigned '2,536' on the first bar says level. Flint's ',.12~g' axis format — twelve significant digits on a count of millions — is gone with it.", + "The −45° rotated x labels straightened. '1950', 'Asia', 'Africa', 'Americas', 'Europe', 'Oceania' fit horizontally at a 42px step; rotation was a fallback for a width that was never measured.", + "Connectors kept but thinned to a 0.8px #d3dce1 hairline. They are scaffolding for reading the steps, not data, and Flint draws them at the same weight as the bars." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 10, "bottom": 8 }, + "title": { + "text": "Asia added more people than the world held in 1950", + "subtitle": ["Contribution to world population growth by region, 1950–2020, millions"] + }, + "width": 262, + "height": 200, + "data": { + "values": [ + { "Step": "1950", "Population (M)": 2536 }, + { "Step": "Asia", "Population (M)": 3237 }, + { "Step": "Africa", "Population (M)": 1134 }, + { "Step": "Americas", "Population (M)": 684 }, + { "Step": "Europe", "Population (M)": 199 }, + { "Step": "Oceania", "Population (M)": 32 } + ] + }, + "transform": [ + { "window": [{ "op": "sum", "field": "Population (M)", "as": "__cum" }], "frame": [null, 0] }, + { "calculate": "datum.__cum - datum['Population (M)']", "as": "__prev" }, + { "window": [{ "op": "lead", "field": "Step", "as": "__lead" }] }, + { "calculate": "datum.__lead === null ? null : datum.__cum", "as": "__connY" }, + { + "calculate": "datum.Step === '1950' ? format(datum['Population (M)'], ',') : '+' + format(datum['Population (M)'], ',')", + "as": "__label" + } + ], + "encoding": { + "x": { + "field": "Step", + "type": "ordinal", + "sort": null, + "title": null, + "scale": { "paddingInner": 0.35 }, + "axis": { "labelAngle": 0, "domain": false, "ticks": false, "labelPadding": 4 } + } + }, + "layer": [ + { + "mark": { "type": "rule", "color": "#d3dce1", "strokeWidth": 0.8 }, + "encoding": { + "x": { "field": "Step", "type": "ordinal", "sort": null, "bandPosition": 1 }, + "x2": { "field": "__lead", "bandPosition": 0 }, + "y": { "field": "__connY", "type": "quantitative", "axis": null, "scale": { "domain": [0, 8600], "nice": false } } + } + }, + { + "mark": { "type": "bar", "width": { "band": 0.78 } }, + "encoding": { + "y": { + "field": "__prev", + "type": "quantitative", + "title": null, + "axis": null, + "scale": { "domain": [0, 8600], "nice": false } + }, + "y2": { "field": "__cum" }, + "color": { + "condition": [ + { "test": "datum.Step === '1950'", "value": "#051c2c" } + ], + "value": "#2251ff" + } + } + }, + { + "mark": { + "type": "text", + "align": "center", + "baseline": "bottom", + "dy": -4, + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600 + }, + "encoding": { + "y": { "field": "__cum", "type": "quantitative", "axis": null, "scale": { "domain": [0, 8600], "nice": false } }, + "text": { "field": "__label", "type": "nominal" }, + "color": { + "condition": [ + { "test": "datum.Step === '1950'", "value": "#051c2c" } + ], + "value": "#2251ff" + } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#5c6b75", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#051c2c", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 9, + "titleColor": "#8fa0ab", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/population.flint.json b/site/src/playground/theme-lab-assets/population.flint.json new file mode 100644 index 00000000..5ac2b2ae --- /dev/null +++ b/site/src/playground/theme-lab-assets/population.flint.json @@ -0,0 +1,95 @@ +{ + "mark": "bar", + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": null + }, + "x": { + "field": "Population", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "height": { + "step": 23 + }, + "title": { + "text": "Most populous countries, 2023", + "subtitle": [ + "Population in millions" + ] + }, + "data": { + "values": [ + { + "Country": "India", + "Population": 1428.6 + }, + { + "Country": "China", + "Population": 1425.7 + }, + { + "Country": "United States", + "Population": 339.9 + }, + { + "Country": "Indonesia", + "Population": 277.5 + }, + { + "Country": "Pakistan", + "Population": 240.5 + }, + { + "Country": "Nigeria", + "Population": 223.8 + }, + { + "Country": "Brazil", + "Population": 216.4 + }, + { + "Country": "Bangladesh", + "Population": 173 + }, + { + "Country": "Russia", + "Population": 144.4 + }, + { + "Country": "Mexico", + "Population": 128.5 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/population.mckinsey.json b/site/src/playground/theme-lab-assets/population.mckinsey.json new file mode 100644 index 00000000..cfedd310 --- /dev/null +++ b/site/src/playground/theme-lab-assets/population.mckinsey.json @@ -0,0 +1,109 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "mckinsey", + "__design__": [ + "Value axis deleted (axis: null) and replaced by direct value labels at the end of each bar. This is the single biggest structural change: the reader never traverses to an axis.", + "One measure, one colour: every bar in brand blue #2251FF, every value label in navy #051C2C. Population is already fully encoded by length, so a second colour treatment would be the same number stated twice.", + "Bars flipped to horizontal, keeping the baseline's row order; country labels read left-to-right at 10pt instead of Flint's rotated x labels.", + "All gridlines, ticks, domains and axis titles removed. What remains is category label → bar → number.", + "X scale domain padded to 1650 so the value labels have room to sit outside the bars rather than colliding with the plot edge.", + "Bar height cut to 0.62 of the band and ink switched to deep navy #051C2C — the deck grid wants whitespace more than it wants mark area." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 26, "bottom": 8 }, + "title": { + "text": "Most populous countries, 2023", + "subtitle": ["Population in millions"] + }, + "width": 250, + "height": 210, + "data": { + "values": [ + { "Country": "India", "Population": 1428.6 }, + { "Country": "China", "Population": 1425.7 }, + { "Country": "United States", "Population": 339.9 }, + { "Country": "Indonesia", "Population": 277.5 }, + { "Country": "Pakistan", "Population": 240.5 }, + { "Country": "Nigeria", "Population": 223.8 }, + { "Country": "Brazil", "Population": 216.4 }, + { "Country": "Bangladesh", "Population": 173 }, + { "Country": "Russia", "Population": 144.4 }, + { "Country": "Mexico", "Population": 128.5 } + ] + }, + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": [ + "India", + "China", + "United States", + "Indonesia", + "Pakistan", + "Nigeria", + "Brazil", + "Bangladesh", + "Russia", + "Mexico" + ], + "title": null, + "axis": { "domain": false, "ticks": false, "grid": false, "labelPadding": 6 } + }, + "x": { + "field": "Population", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "domain": [0, 1650] }, + "axis": null + } + }, + "layer": [ + { + "mark": { "type": "bar", "height": { "band": 0.62 }, "color": "#2251ff" } + }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 5, + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "color": "#051c2c" + }, + "encoding": { + "text": { "field": "Population", "type": "quantitative", "format": ",.0f" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#5c6b75", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#051c2c", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 9, + "titleColor": "#8fa0ab", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/release-gantt.flint.json b/site/src/playground/theme-lab-assets/release-gantt.flint.json new file mode 100644 index 00000000..f8064f21 --- /dev/null +++ b/site/src/playground/theme-lab-assets/release-gantt.flint.json @@ -0,0 +1,90 @@ +{ + "mark": { + "type": "bar", + "cornerRadius": 2, + "height": { + "band": 0.7 + } + }, + "encoding": { + "y": { + "field": "Task", + "type": "nominal", + "sort": { + "field": "Start", + "op": "min", + "order": "ascending" + }, + "axis": { + "title": null + } + }, + "x": { + "field": "Start", + "type": "temporal", + "axis": { + "title": null + } + }, + "x2": { + "field": "End" + } + }, + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "height": { + "step": 23 + }, + "title": { + "text": "Software release schedule" + }, + "data": { + "values": [ + { + "Task": "Planning", + "Start": "2024-01-01", + "End": "2024-01-14" + }, + { + "Task": "Design", + "Start": "2024-01-15", + "End": "2024-02-04" + }, + { + "Task": "Implementation", + "Start": "2024-02-05", + "End": "2024-03-17" + }, + { + "Task": "Testing", + "Start": "2024-03-11", + "End": "2024-04-07" + }, + { + "Task": "Launch", + "Start": "2024-04-08", + "End": "2024-04-15" + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/renewable-bullet.flint.json b/site/src/playground/theme-lab-assets/renewable-bullet.flint.json new file mode 100644 index 00000000..d0e69dea --- /dev/null +++ b/site/src/playground/theme-lab-assets/renewable-bullet.flint.json @@ -0,0 +1,285 @@ +{ + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "sort": null, + "axis": { + "title": null + } + } + }, + "layer": [ + { + "data": { + "values": [ + { + "Country": "Norway", + "__lo": 0, + "__hi": 25 + }, + { + "Country": "Brazil", + "__lo": 0, + "__hi": 23.75 + }, + { + "Country": "Germany", + "__lo": 0, + "__hi": 20 + }, + { + "Country": "World", + "__lo": 0, + "__hi": 15 + }, + { + "Country": "United States", + "__lo": 0, + "__hi": 12.5 + } + ] + }, + "mark": { + "type": "rect", + "color": "#e2e2e2", + "opacity": 1 + }, + "encoding": { + "x": { + "field": "__lo", + "type": "quantitative", + "axis": { + "title": "Share", + "format": ",.12~g" + } + }, + "x2": { + "field": "__hi" + } + } + }, + { + "data": { + "values": [ + { + "Country": "Norway", + "__lo": 25, + "__hi": 50 + }, + { + "Country": "Brazil", + "__lo": 23.75, + "__hi": 47.5 + }, + { + "Country": "Germany", + "__lo": 20, + "__hi": 40 + }, + { + "Country": "World", + "__lo": 15, + "__hi": 30 + }, + { + "Country": "United States", + "__lo": 12.5, + "__hi": 25 + } + ] + }, + "mark": { + "type": "rect", + "color": "#ececec", + "opacity": 1 + }, + "encoding": { + "x": { + "field": "__lo", + "type": "quantitative", + "axis": { + "title": "Share", + "format": ",.12~g" + } + }, + "x2": { + "field": "__hi" + } + } + }, + { + "data": { + "values": [ + { + "Country": "Norway", + "__lo": 50, + "__hi": 75 + }, + { + "Country": "Brazil", + "__lo": 47.5, + "__hi": 71.25 + }, + { + "Country": "Germany", + "__lo": 40, + "__hi": 60 + }, + { + "Country": "World", + "__lo": 30, + "__hi": 45 + }, + { + "Country": "United States", + "__lo": 25, + "__hi": 37.5 + } + ] + }, + "mark": { + "type": "rect", + "color": "#f5f5f5", + "opacity": 1 + }, + "encoding": { + "x": { + "field": "__lo", + "type": "quantitative", + "axis": { + "title": "Share", + "format": ",.12~g" + } + }, + "x2": { + "field": "__hi" + } + } + }, + { + "mark": { + "type": "bar", + "height": { + "band": 0.5 + } + }, + "encoding": { + "x": { + "field": "Share", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "title": "Share", + "format": ",.12~g" + } + }, + "color": { + "field": "__status", + "type": "nominal", + "scale": { + "domain": [ + "Below target", + "Meets target" + ], + "range": [ + "#c44e52", + "#2f855a" + ] + }, + "legend": { + "title": null + }, + "title": null + } + }, + "transform": [ + { + "calculate": "datum[\"Share\"] >= datum[\"Target\"] ? 'Meets target' : 'Below target'", + "as": "__status" + } + ] + }, + { + "mark": { + "type": "tick", + "color": "#1a1a1a", + "thickness": 3, + "opacity": 1, + "size": 17 + }, + "encoding": { + "x": { + "field": "Target", + "type": "quantitative", + "axis": { + "title": "Share", + "format": ",.12~g" + } + } + } + } + ], + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "height": { + "step": 23 + }, + "title": { + "text": "Every country is short of its renewable target", + "subtitle": [ + "Renewable share of electricity, 2023, per cent, against national targets" + ] + }, + "data": { + "values": [ + { + "Country": "Norway", + "Share": 98.6, + "Target": 100 + }, + { + "Country": "Brazil", + "Share": 89.2, + "Target": 95 + }, + { + "Country": "Germany", + "Share": 51.6, + "Target": 80 + }, + { + "Country": "World", + "Share": 30.3, + "Target": 60 + }, + { + "Country": "United States", + "Share": 22.7, + "Target": 50 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/renewable-bullet.powerbi.json b/site/src/playground/theme-lab-assets/renewable-bullet.powerbi.json new file mode 100644 index 00000000..ce66c876 --- /dev/null +++ b/site/src/playground/theme-lab-assets/renewable-bullet.powerbi.json @@ -0,0 +1,118 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi", + "__design__": [ + "Flint's qualitative bands are not comparable across rows. It hardcodes five separate datasets in which each country's three grey bands are 25%, 50% and 75% of that country's own target — so on a shared 0–100 axis Norway's first band ends at 25 and America's ends at 12.5. Identical greys sit at different absolute positions and mean different things. Replaced by one track running 0–100, which is the actual range of a percentage and is the same on every row.", + "The legend deleted. Flint offers 'Below target' in red and 'Meets target' in green when not one of the five countries meets its target — a two-item key of which one item never appears, spending a colour on an empty set.", + "The shortfall stated as a number at the end of each row, in #E66C37. This is what the chart is for, and Flint asks the reader to measure the distance from the bar end to the tick by eye.", + "Bars in brand #118DFF, target ticks in #F3F2F1 at 2px. Flint's red bars pre-judge every row as a failure before the reader has looked at the gap; the theme reserves status colour for the threshold statement, and here it appears once, on the number.", + "Descending order pinned in the spec rather than depending on 'sort: null' and the order the rows happen to arrive in. The rendered sequence is the baseline's; only its robustness changes.", + "Inverted to the #1B1A19 report canvas with 9.5pt Segoe UI, dashed #3B3A39 gridlines at low contrast, and Flint's ',.12~g' axis format replaced by integers with a per-cent suffix." + ], + "background": "#1b1a19", + "padding": { "left": 10, "top": 6, "right": 14, "bottom": 8 }, + "title": { + "text": "Every country is short of its renewable target", + "subtitle": ["Renewable share of electricity, 2023, per cent, against national targets"] + }, + "width": 260, + "height": 150, + "data": { + "values": [ + { "Country": "Norway", "Share": 98.6, "Target": 100 }, + { "Country": "Brazil", "Share": 89.2, "Target": 95 }, + { "Country": "Germany", "Share": 51.6, "Target": 80 }, + { "Country": "World", "Share": 30.3, "Target": 60 }, + { "Country": "United States", "Share": 22.7, "Target": 50 } + ] + }, + "transform": [ + { "calculate": "'−' + format(datum.Target - datum.Share, '.1f')", "as": "__gapLabel" } + ], + "encoding": { + "y": { + "field": "Country", + "type": "nominal", + "title": null, + "sort": { "field": "Share", "op": "max", "order": "descending" }, + "scale": { "paddingInner": 0.45 }, + "axis": { "grid": false, "domain": false, "ticks": false, "labelColor": "#f3f2f1", "labelPadding": 6 } + }, + "x": { + "field": "Share", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 104] }, + "axis": { + "values": [0, 25, 50, 75, 100], + "labelExpr": "datum.value + '%'", + "grid": true, + "gridColor": "#3b3a39", + "gridDash": [3, 3], + "domain": false, + "ticks": false + } + } + }, + "layer": [ + { + "mark": { "type": "bar", "fill": "#2b2a29", "height": { "band": 1 } }, + "encoding": { + "x": { "datum": 0 }, + "x2": { "datum": 100 } + } + }, + { + "mark": { "type": "bar", "fill": "#118dff", "height": { "band": 1 } } + }, + { + "mark": { "type": "tick", "color": "#f3f2f1", "thickness": 2, "size": 20 }, + "encoding": { + "x": { "field": "Target", "type": "quantitative" } + } + }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 8, + "fontSize": 10, + "fontWeight": 600, + "color": "#e66c37" + }, + "encoding": { + "x": { "field": "Target", "type": "quantitative" }, + "text": { "field": "__gapLabel", "type": "nominal" } + } + } + ], + "config": { + "background": "#1b1a19", + "font": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "title": { + "font": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "subtitleFontSize": 9.5, + "subtitleColor": "#8a8886", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#8a8886", + "titleFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "titleFontSize": 9.5, + "titleColor": "#8a8886", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/renewable-kpi.flint.json b/site/src/playground/theme-lab-assets/renewable-kpi.flint.json new file mode 100644 index 00000000..d695f9ae --- /dev/null +++ b/site/src/playground/theme-lab-assets/renewable-kpi.flint.json @@ -0,0 +1,208 @@ +{ + "layer": [ + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "rect", + "fill": "#ffffff", + "stroke": "#e6e9ef", + "strokeWidth": 1, + "cornerRadius": 8, + "tooltip": null + }, + "encoding": { + "x": { + "value": 13 + }, + "x2": { + "value": 327 + }, + "y": { + "value": 14 + }, + "y2": { + "value": 229 + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "text", + "fontSize": 22, + "fontWeight": 500, + "fill": "#4a4a4a", + "align": "center", + "baseline": "top", + "text": "Renewable share", + "tooltip": null + }, + "encoding": { + "x": { + "value": 170 + }, + "y": { + "value": 45 + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "text", + "fontSize": 68, + "fontWeight": "bold", + "fill": "#1a1a1a", + "align": "center", + "baseline": "middle", + "text": "30.3", + "tooltip": null + }, + "encoding": { + "x": { + "value": 170 + }, + "y": { + "value": 113 + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "text", + "fontSize": 18, + "fontWeight": 400, + "fill": "#666", + "align": "center", + "baseline": "top", + "text": "67% of 45", + "tooltip": null + }, + "encoding": { + "x": { + "value": 170 + }, + "y": { + "value": 165 + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "rect", + "fill": "#e6e9ef", + "cornerRadius": 3.5, + "tooltip": null + }, + "encoding": { + "x": { + "value": 34 + }, + "x2": { + "value": 306 + }, + "y": { + "value": 192 + }, + "y2": { + "value": 199 + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "rect", + "fill": "#5b8def", + "cornerRadius": 3.5, + "tooltip": null + }, + "encoding": { + "x": { + "value": 34 + }, + "x2": { + "value": 217.14666666666668 + }, + "y": { + "value": 192 + }, + "y2": { + "value": 199 + } + } + } + ], + "width": 340, + "height": 243, + "resolve": { + "scale": { + "x": "independent", + "y": "independent" + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 243, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "Renewables supply 30% of the world's electricity", + "subtitle": [ + "Share of global electricity generation, 2023, against a 45% target" + ] + }, + "data": { + "values": [ + { + "Metric": "Renewable share", + "Share (%)": 30.3, + "Target": 45 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/renewable-kpi.powerbi.json b/site/src/playground/theme-lab-assets/renewable-kpi.powerbi.json new file mode 100644 index 00000000..e31d0c03 --- /dev/null +++ b/site/src/playground/theme-lab-assets/renewable-kpi.powerbi.json @@ -0,0 +1,191 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi", + "__design__": [ + "The data is out of the geometry. Flint draws the progress bar as a rect ending at x = 217.14666666666668 — a pixel coordinate with the arithmetic 30.3 ÷ 45 × 272 already performed and baked in. Change the number in the dataset and the bar does not move. Here the bar is a rect on a real 0–50 scale driven by the field.", + "'67% of 45' is a string literal in Flint's spec, not a computed label. Same failure, worse consequence: it is the only place the target appears, and it will keep saying 67% after the data changes. It is recomputed here from Share and Target, alongside the gap in points.", + "The white card deleted. A #FFFFFF panel with #1A1A1A type is a hole punched in a #1B1A19 report page; the tile now sits on the canvas and takes its contrast from the type, not from an inverted surface.", + "The target is now on the chart, as a tick at 45 with its own label, instead of surviving only inside a sentence. A progress bar with no visible goal is a bar with no scale.", + "Status colour used where the theme reserves it — for a threshold. The fill is brand #118DFF because the measure itself is neutral; the shortfall is #E66C37 because the value is under target. Flint's #5B8DEF says nothing either way.", + "The metric name comes from the Metric field rather than being retyped as a mark literal, so the tile is a template rather than a drawing.", + "68pt display type cut to 40pt Segoe UI semibold. A KPI number has to be the largest thing on the tile, not the largest thing on the page." + ], + "background": "#1b1a19", + "padding": { "left": 10, "top": 6, "right": 12, "bottom": 8 }, + "title": { + "text": "Renewables supply 30% of the world's electricity", + "subtitle": ["Share of global electricity generation, 2023, against a 45% target"] + }, + "data": { + "values": [{ "Metric": "Renewable share", "Share (%)": 30.3, "Target": 45 }] + }, + "transform": [ + { "calculate": "format(datum['Share (%)'], '.1f') + '%'", "as": "__big" }, + { + "calculate": "format(datum['Share (%)'] / datum.Target, '.0%') + ' of target'", + "as": "__pctLabel" + }, + { + "calculate": "format(datum.Target - datum['Share (%)'], '.1f') + ' points short'", + "as": "__gapLabel" + } + ], + "spacing": 6, + "vconcat": [ + { + "width": 300, + "height": 74, + "layer": [ + { + "mark": { + "type": "text", + "align": "left", + "baseline": "top", + "fontSize": 11, + "fontWeight": 600, + "color": "#b3b0ad" + }, + "encoding": { + "x": { "value": 0 }, + "y": { "value": 0 }, + "text": { "field": "Metric", "type": "nominal" } + } + }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "fontSize": 40, + "fontWeight": 600, + "color": "#f3f2f1" + }, + "encoding": { + "x": { "value": 0 }, + "y": { "value": 42 }, + "text": { "field": "__big", "type": "nominal" } + } + }, + { + "mark": { + "type": "text", + "align": "left", + "baseline": "bottom", + "fontSize": 11, + "color": "#e66c37" + }, + "encoding": { + "x": { "value": 0 }, + "y": { "value": 74 }, + "text": { "field": "__gapLabel", "type": "nominal" } + } + }, + { + "mark": { + "type": "text", + "align": "right", + "baseline": "bottom", + "fontSize": 11, + "color": "#b3b0ad" + }, + "encoding": { + "x": { "value": 300 }, + "y": { "value": 74 }, + "text": { "field": "__pctLabel", "type": "nominal" } + } + } + ] + }, + { + "width": 300, + "height": 22, + "encoding": { + "x": { + "field": "Share (%)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 50] }, + "axis": { + "values": [0, 10, 20, 30, 40, 50], + "labelExpr": "datum.value + '%'", + "grid": false, + "domain": false, + "ticks": false, + "labelPadding": 4 + } + } + }, + "layer": [ + { + "mark": { "type": "rect", "fill": "#3b3a39", "cornerRadius": 3 }, + "encoding": { + "x": { "datum": 0 }, + "x2": { "datum": 50 }, + "y": { "value": 4 }, + "y2": { "value": 16 } + } + }, + { + "mark": { "type": "rect", "fill": "#118dff", "cornerRadius": 3 }, + "encoding": { + "x": { "datum": 0 }, + "x2": { "field": "Share (%)" }, + "y": { "value": 4 }, + "y2": { "value": 16 } + } + }, + { + "mark": { "type": "rule", "stroke": "#f3f2f1", "strokeWidth": 2 }, + "encoding": { + "x": { "field": "Target", "type": "quantitative" }, + "y": { "value": 0 }, + "y2": { "value": 20 } + } + }, + { + "mark": { + "type": "text", + "align": "center", + "baseline": "bottom", + "dy": -2, + "fontSize": 9.5, + "color": "#f3f2f1" + }, + "encoding": { + "x": { "field": "Target", "type": "quantitative" }, + "y": { "value": 0 }, + "text": { "value": "Target" } + } + } + ] + } + ], + "config": { + "background": "#1b1a19", + "font": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "title": { + "font": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "subtitleFontSize": 9.5, + "subtitleColor": "#8a8886", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#8a8886", + "titleFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "titleFontSize": 9.5, + "titleColor": "#8a8886", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/renewables-projection.flint.json b/site/src/playground/theme-lab-assets/renewables-projection.flint.json new file mode 100644 index 00000000..528f437f --- /dev/null +++ b/site/src/playground/theme-lab-assets/renewables-projection.flint.json @@ -0,0 +1,101 @@ +{ + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Capacity (GW)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "strokeDash": { + "field": "Series", + "type": "nominal", + "sort": null + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "Global renewable capacity", + "subtitle": [ + "Gigawatts installed, observed to 2023 and projected to 2030" + ] + }, + "data": { + "values": [ + { + "Year": "2015", + "Series": "Observed", + "Capacity (GW)": 785 + }, + { + "Year": "2017", + "Series": "Observed", + "Capacity (GW)": 1080 + }, + { + "Year": "2019", + "Series": "Observed", + "Capacity (GW)": 1440 + }, + { + "Year": "2021", + "Series": "Observed", + "Capacity (GW)": 1900 + }, + { + "Year": "2023", + "Series": "Observed", + "Capacity (GW)": 2560 + }, + { + "Year": "2023", + "Series": "Projected", + "Capacity (GW)": 2560 + }, + { + "Year": "2025", + "Series": "Projected", + "Capacity (GW)": 3400 + }, + { + "Year": "2027", + "Series": "Projected", + "Capacity (GW)": 4300 + }, + { + "Year": "2030", + "Series": "Projected", + "Capacity (GW)": 5800 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/renewables-projection.nyt.json b/site/src/playground/theme-lab-assets/renewables-projection.nyt.json new file mode 100644 index 00000000..25fcdac9 --- /dev/null +++ b/site/src/playground/theme-lab-assets/renewables-projection.nyt.json @@ -0,0 +1,154 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nyt", + "__design__": [ + "Dash pattern re-authored so 'Observed' is solid and 'Projected' is a [5,4] dash on the same colour. Flint's default dash scale gives both series a dash and both a legend entry, which reads as two measures rather than one measure in two states.", + "Colour held constant across the two series and the legend deleted; the difference is carried entirely by line texture, which is the convention for observed-versus-forecast.", + "Series labelled at the line ends instead of in a key, using the existing Series field. This is not annotation — it is the legend, moved onto the marks so the eye never leaves the plot.", + "Y scale forced through zero with a 0–6000 domain and thousands-suffixed labels, because a capacity total is a magnitude and a truncated axis would exaggerate the projected climb.", + "Axis reduced to a horizontal grid of #E4E4E4 hairlines with no y domain and no ticks, plus a solid black x baseline — the newspaper's standard time-series frame.", + "Y title flattened to a single unit word ('GW') sitting horizontally at the top of the axis, replacing the rotated 'Capacity (GW)'.", + "Georgia for the headline and deck, Helvetica for everything numeric; line weight raised to 2.4px with a round cap.", + "The join point at 2023 is drawn once as a filled dot so the handoff between the two states has a visible pivot instead of an ambiguous overlap." + ], + "background": "#ffffff", + "padding": { "left": 4, "top": 4, "right": 46, "bottom": 6 }, + "width": 300, + "height": 210, + "title": { + "text": "Global renewable capacity", + "subtitle": ["Gigawatts installed, observed to 2023 and projected to 2030"] + }, + "data": { + "values": [ + { "Year": "2015", "Series": "Observed", "Capacity (GW)": 785 }, + { "Year": "2017", "Series": "Observed", "Capacity (GW)": 1080 }, + { "Year": "2019", "Series": "Observed", "Capacity (GW)": 1440 }, + { "Year": "2021", "Series": "Observed", "Capacity (GW)": 1900 }, + { "Year": "2023", "Series": "Observed", "Capacity (GW)": 2560 }, + { "Year": "2023", "Series": "Projected", "Capacity (GW)": 2560 }, + { "Year": "2025", "Series": "Projected", "Capacity (GW)": 3400 }, + { "Year": "2027", "Series": "Projected", "Capacity (GW)": 4300 }, + { "Year": "2030", "Series": "Projected", "Capacity (GW)": 5800 } + ] + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "scale": { "type": "utc" }, + "axis": { + "format": "%Y", + "values": ["2015", "2020", "2025", "2030"], + "labelFlush": true, + "grid": false, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickSize": 4, + "labelPadding": 3 + } + }, + "y": { + "field": "Capacity (GW)", + "type": "quantitative", + "title": "GW", + "scale": { "domain": [0, 6000] }, + "axis": { + "tickCount": 4, + "labelExpr": "datum.value === 0 ? '0' : datum.value / 1000 + ',000'", + "grid": true, + "gridColor": "#e4e4e4", + "gridWidth": 1, + "domain": false, + "ticks": false, + "labelPadding": 5, + "titleAngle": 0, + "titleAlign": "left", + "titleX": -4, + "titleY": -10, + "titleBaseline": "bottom" + } + } + }, + "layer": [ + { + "mark": { + "type": "line", + "color": "#1a1a1a", + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + }, + "encoding": { + "strokeDash": { + "field": "Series", + "type": "nominal", + "scale": { "domain": ["Observed", "Projected"], "range": [[1, 0], [5, 4]] }, + "legend": null + } + } + }, + { + "transform": [ + { "filter": "datum.Series === 'Observed'" }, + { "window": [{ "op": "row_number", "as": "__rank" }], "sort": [{ "field": "Year", "order": "descending" }] }, + { "filter": "datum.__rank === 1" } + ], + "mark": { + "type": "point", + "filled": true, + "size": 55, + "color": "#1a1a1a" + } + }, + { + "transform": [ + { "window": [{ "op": "row_number", "as": "__rank" }], "groupby": ["Series"], "sort": [{ "field": "Year", "order": "descending" }] }, + { "filter": "datum.__rank === 1" } + ], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 7, + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600, + "color": "#1a1a1a" + }, + "encoding": { + "text": { "field": "Series", "type": "nominal" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, 'Times New Roman', serif", + "fontSize": 16, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 12, + "subtitleFont": "Georgia, 'Times New Roman', serif", + "subtitleFontSize": 11, + "subtitleColor": "#666666", + "subtitlePadding": 8 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#666666", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 10, + "titleColor": "#666666", + "titleFontWeight": 400 + } + } +} diff --git a/site/src/playground/theme-lab-assets/seattle-range.economist.json b/site/src/playground/theme-lab-assets/seattle-range.economist.json new file mode 100644 index 00000000..616452dc --- /dev/null +++ b/site/src/playground/theme-lab-assets/seattle-range.economist.json @@ -0,0 +1,160 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "economist", + "__design__": [ + "The two edges of the band named directly at December. Flint draws a translucent ribbon with no legend at all — nothing on the page says which boundary is the daily high and which is the low, so the chart is literally unreadable without the field names.", + "Opacity raised from 0.5 to a solid house blue at 0.22 and both edges of the band outlined at 1.2px. A half-transparent fill was standing in for a border; and Vega-Lite's area `line` property outlines only the y boundary, so the baseline's ribbon has a drawn low edge and an undrawn high one — the two curves have to be layered explicitly to get a band with a defined top and bottom.", + "The house 'value axis on top' rule deliberately not applied. It exists so that a reader of a horizontal bar chart meets the unit before the bars; on a vertical chart the value axis is y, and moving it to the top turns the gridlines through ninety degrees and makes the scale unreadable. A house style is a set of reasons, not a set of coordinates.", + "Degree sign moved onto the tick labels (°F) and the axis title deleted; stating a unit twice is a habit, not a design.", + "Month axis reduced to a hard black baseline with no ticks or domain elsewhere, and the band given a monotone interpolation so a twelve-point seasonal curve does not read as eleven straight decisions.", + "Red masthead rule, 13.5pt bold headline over a grey deck, 9.5pt labels: the same fixed identity every chart in the section gets." + ], + "background": "#ffffff", + "padding": { "left": 6, "top": 4, "right": 22, "bottom": 6 }, + "title": { + "text": "Seattle, month by month", + "subtitle": ["Average daily high and low temperature, °F, 1991–2020 normals"] + }, + "spacing": 8, + "vconcat": [ + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#e3120b", "stroke": null }, + "width": 26, + "height": 3, + "view": { "stroke": null } + }, + { + "width": 250, + "height": 190, + "data": { + "values": [ + { "Month": "Jan", "Low": 37, "High": 47 }, + { "Month": "Feb", "Low": 37, "High": 50 }, + { "Month": "Mar", "Low": 40, "High": 54 }, + { "Month": "Apr", "Low": 43, "High": 59 }, + { "Month": "May", "Low": 48, "High": 65 }, + { "Month": "Jun", "Low": 53, "High": 70 }, + { "Month": "Jul", "Low": 56, "High": 76 }, + { "Month": "Aug", "Low": 57, "High": 77 }, + { "Month": "Sep", "Low": 53, "High": 71 }, + { "Month": "Oct", "Low": 46, "High": 60 }, + { "Month": "Nov", "Low": 40, "High": 51 }, + { "Month": "Dec", "Low": 36, "High": 46 } + ] + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + "title": null, + "scale": { "padding": 0.04 }, + "axis": { + "labelAngle": 0, + "labelPadding": 6, + "domain": true, + "domainColor": "#121317", + "domainWidth": 1, + "grid": false + } + }, + "y": { + "field": "Low", + "type": "quantitative", + "title": null, + "scale": { "zero": false, "nice": false, "domain": [30, 82] }, + "axis": { + "tickCount": 5, + "format": ".0f", + "labelExpr": "datum.label + '°F'", + "grid": true, + "gridColor": "#c9d3da", + "domain": false, + "ticks": false, + "labelPadding": 4 + } + }, + "y2": { "field": "High" } + }, + "layer": [ + { + "mark": { + "type": "area", + "interpolate": "monotone", + "color": "#006ba2", + "opacity": 0.22, + "line": false + } + }, + { + "mark": { "type": "line", "interpolate": "monotone", "color": "#006ba2", "strokeWidth": 1.2 }, + "encoding": { "y": { "field": "High", "type": "quantitative" }, "y2": null } + }, + { + "mark": { "type": "line", "interpolate": "monotone", "color": "#006ba2", "strokeWidth": 1.2 }, + "encoding": { "y": { "field": "Low", "type": "quantitative" }, "y2": null } + }, + { + "transform": [{ "filter": "datum.Month === 'Dec'" }], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 6, + "fontSize": 9.5, + "fontWeight": 600, + "color": "#006ba2" + }, + "encoding": { + "y": { "field": "High", "type": "quantitative" }, + "y2": null, + "text": { "value": "High" } + } + }, + { + "transform": [{ "filter": "datum.Month === 'Dec'" }], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 6, + "fontSize": 9.5, + "fontWeight": 600, + "color": "#006ba2" + }, + "encoding": { + "y": { "field": "Low", "type": "quantitative" }, + "y2": null, + "text": { "value": "Low" } + } + } + ] + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#54585a", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#54585a", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/seattle-range.flint.json b/site/src/playground/theme-lab-assets/seattle-range.flint.json new file mode 100644 index 00000000..89f4ffff --- /dev/null +++ b/site/src/playground/theme-lab-assets/seattle-range.flint.json @@ -0,0 +1,138 @@ +{ + "mark": { + "type": "area", + "opacity": 0.5, + "line": { + "strokeWidth": 1 + } + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ] + }, + "y": { + "field": "Low", + "type": "quantitative", + "scale": { + "zero": false, + "nice": true + }, + "axis": { + "format": ",.12~g" + } + }, + "y2": { + "field": "High" + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "width": { + "step": 23 + }, + "title": { + "text": "Seattle, month by month", + "subtitle": [ + "Average daily high and low temperature, °F, 1991–2020 normals" + ] + }, + "data": { + "values": [ + { + "Month": "Jan", + "Low": 37, + "High": 47 + }, + { + "Month": "Feb", + "Low": 37, + "High": 50 + }, + { + "Month": "Mar", + "Low": 40, + "High": 54 + }, + { + "Month": "Apr", + "Low": 43, + "High": 59 + }, + { + "Month": "May", + "Low": 48, + "High": 65 + }, + { + "Month": "Jun", + "Low": 53, + "High": 70 + }, + { + "Month": "Jul", + "Low": 56, + "High": 76 + }, + { + "Month": "Aug", + "Low": 57, + "High": 77 + }, + { + "Month": "Sep", + "Low": 53, + "High": 71 + }, + { + "Month": "Oct", + "Low": 46, + "High": 60 + }, + { + "Month": "Nov", + "Low": 40, + "High": 51 + }, + { + "Month": "Dec", + "Low": 36, + "High": 46 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/seattle-rose.flint.json b/site/src/playground/theme-lab-assets/seattle-rose.flint.json new file mode 100644 index 00000000..9c2d9279 --- /dev/null +++ b/site/src/playground/theme-lab-assets/seattle-rose.flint.json @@ -0,0 +1,172 @@ +{ + "encoding": { + "theta": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "stack": true + } + }, + "layer": [ + { + "mark": { + "type": "arc", + "stroke": "white", + "padAngle": 0.02 + }, + "encoding": { + "radius": { + "field": "Rainfall (mm)", + "type": "quantitative", + "scale": { + "type": "sqrt" + } + }, + "color": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ] + } + } + }, + { + "mark": { + "type": "text", + "radiusOffset": 15, + "fontSize": 11 + }, + "encoding": { + "text": { + "field": "Month", + "type": "nominal" + }, + "radius": { + "field": "Rainfall (mm)", + "type": "quantitative", + "scale": { + "type": "sqrt" + } + } + }, + "transform": [ + { + "aggregate": [ + { + "op": "sum", + "field": "Rainfall (mm)", + "as": "Rainfall (mm)" + } + ], + "groupby": [ + "Month" + ] + } + ] + } + ], + "width": 230, + "height": 230, + "config": { + "view": { + "continuousWidth": 230, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "Seattle monthly rainfall (mm)" + }, + "data": { + "values": [ + { + "Month": "Jan", + "Rainfall (mm)": 140 + }, + { + "Month": "Feb", + "Rainfall (mm)": 90 + }, + { + "Month": "Mar", + "Rainfall (mm)": 95 + }, + { + "Month": "Apr", + "Rainfall (mm)": 70 + }, + { + "Month": "May", + "Rainfall (mm)": 50 + }, + { + "Month": "Jun", + "Rainfall (mm)": 40 + }, + { + "Month": "Jul", + "Rainfall (mm)": 18 + }, + { + "Month": "Aug", + "Rainfall (mm)": 25 + }, + { + "Month": "Sep", + "Rainfall (mm)": 40 + }, + { + "Month": "Oct", + "Rainfall (mm)": 100 + }, + { + "Month": "Nov", + "Rainfall (mm)": 165 + }, + { + "Month": "Dec", + "Rainfall (mm)": 155 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/spending-quintile.flint.json b/site/src/playground/theme-lab-assets/spending-quintile.flint.json new file mode 100644 index 00000000..046e9473 --- /dev/null +++ b/site/src/playground/theme-lab-assets/spending-quintile.flint.json @@ -0,0 +1,196 @@ +{ + "mark": "bar", + "encoding": { + "x": { + "field": "Quintile", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Spending ($)", + "type": "quantitative", + "stack": "normalize", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Category", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10", + "domain": [ + "Housing", + "Transportation", + "Food", + "Healthcare", + "Everything else" + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "width": { + "step": 23 + }, + "title": { + "text": "Where each income group's money goes", + "subtitle": [ + "Share of annual household spending, by income quintile" + ] + }, + "data": { + "values": [ + { + "Quintile": "Lowest fifth", + "Category": "Housing", + "Spending ($)": 12800 + }, + { + "Quintile": "Lowest fifth", + "Category": "Transportation", + "Spending ($)": 4900 + }, + { + "Quintile": "Lowest fifth", + "Category": "Food", + "Spending ($)": 5100 + }, + { + "Quintile": "Lowest fifth", + "Category": "Healthcare", + "Spending ($)": 2900 + }, + { + "Quintile": "Lowest fifth", + "Category": "Everything else", + "Spending ($)": 6400 + }, + { + "Quintile": "Second fifth", + "Category": "Housing", + "Spending ($)": 16100 + }, + { + "Quintile": "Second fifth", + "Category": "Transportation", + "Spending ($)": 7600 + }, + { + "Quintile": "Second fifth", + "Category": "Food", + "Spending ($)": 6300 + }, + { + "Quintile": "Second fifth", + "Category": "Healthcare", + "Spending ($)": 4000 + }, + { + "Quintile": "Second fifth", + "Category": "Everything else", + "Spending ($)": 10700 + }, + { + "Quintile": "Middle fifth", + "Category": "Housing", + "Spending ($)": 19400 + }, + { + "Quintile": "Middle fifth", + "Category": "Transportation", + "Spending ($)": 10600 + }, + { + "Quintile": "Middle fifth", + "Category": "Food", + "Spending ($)": 7600 + }, + { + "Quintile": "Middle fifth", + "Category": "Healthcare", + "Spending ($)": 4700 + }, + { + "Quintile": "Middle fifth", + "Category": "Everything else", + "Spending ($)": 16400 + }, + { + "Quintile": "Fourth fifth", + "Category": "Housing", + "Spending ($)": 24300 + }, + { + "Quintile": "Fourth fifth", + "Category": "Transportation", + "Spending ($)": 13700 + }, + { + "Quintile": "Fourth fifth", + "Category": "Food", + "Spending ($)": 9100 + }, + { + "Quintile": "Fourth fifth", + "Category": "Healthcare", + "Spending ($)": 6100 + }, + { + "Quintile": "Fourth fifth", + "Category": "Everything else", + "Spending ($)": 22800 + }, + { + "Quintile": "Highest fifth", + "Category": "Housing", + "Spending ($)": 36600 + }, + { + "Quintile": "Highest fifth", + "Category": "Transportation", + "Spending ($)": 19500 + }, + { + "Quintile": "Highest fifth", + "Category": "Food", + "Spending ($)": 13400 + }, + { + "Quintile": "Highest fifth", + "Category": "Healthcare", + "Spending ($)": 8500 + }, + { + "Quintile": "Highest fifth", + "Category": "Everything else", + "Spending ($)": 43800 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/spending-quintile.mckinsey.json b/site/src/playground/theme-lab-assets/spending-quintile.mckinsey.json new file mode 100644 index 00000000..ccf48cbd --- /dev/null +++ b/site/src/playground/theme-lab-assets/spending-quintile.mckinsey.json @@ -0,0 +1,144 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "mckinsey", + "__design__": [ + "Five competing hues collapsed to one ordered blue value ramp. On a composition chart the reader tracks one band at a time, and five saturated categorical colours make all five shout at once; a ramp that darkens toward the baseline lets the eye follow the stack in the order it is built.", + "No band is singled out. The headline asks where each group's money goes, not whether housing costs too much, so promoting one category to brand blue would be the chart arguing something the data was not selected to argue.", + "Stack order pinned Housing → Transportation → Food → Healthcare → Everything else, so the largest band sits on the baseline where its height can be read against a straight edge rather than a ragged one.", + "Y axis reduced to five percent ticks with no gridlines; on a 100% stack the total is fixed, so the only rules that matter are the band boundaries.", + "Quintile labels shortened to rank names and kept horizontal — 'fifth' is already stated once in the subtitle, and a rotated category label on a five-bar chart is a layout failure, not a style choice.", + "Legend moved to the top as one horizontal row in stack order, so the key reads bottom-band-first exactly as the chart is built.", + "Bar width raised to 0.7 of the band and a 0.6px white segment stroke added, which is what separates the bands of a single-hue ramp without adding another colour." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 10, "bottom": 8 }, + "height": 210, + "title": { + "text": "Where each income group's money goes", + "subtitle": ["Share of annual household spending, by income quintile"] + }, + "width": { "step": 54 }, + "data": { + "values": [ + { "Quintile": "Lowest fifth", "Category": "Housing", "Spending ($)": 12800 }, + { "Quintile": "Lowest fifth", "Category": "Transportation", "Spending ($)": 4900 }, + { "Quintile": "Lowest fifth", "Category": "Food", "Spending ($)": 5100 }, + { "Quintile": "Lowest fifth", "Category": "Healthcare", "Spending ($)": 2900 }, + { "Quintile": "Lowest fifth", "Category": "Everything else", "Spending ($)": 6400 }, + { "Quintile": "Second fifth", "Category": "Housing", "Spending ($)": 16100 }, + { "Quintile": "Second fifth", "Category": "Transportation", "Spending ($)": 7600 }, + { "Quintile": "Second fifth", "Category": "Food", "Spending ($)": 6300 }, + { "Quintile": "Second fifth", "Category": "Healthcare", "Spending ($)": 4000 }, + { "Quintile": "Second fifth", "Category": "Everything else", "Spending ($)": 10700 }, + { "Quintile": "Middle fifth", "Category": "Housing", "Spending ($)": 19400 }, + { "Quintile": "Middle fifth", "Category": "Transportation", "Spending ($)": 10600 }, + { "Quintile": "Middle fifth", "Category": "Food", "Spending ($)": 7600 }, + { "Quintile": "Middle fifth", "Category": "Healthcare", "Spending ($)": 4700 }, + { "Quintile": "Middle fifth", "Category": "Everything else", "Spending ($)": 16400 }, + { "Quintile": "Fourth fifth", "Category": "Housing", "Spending ($)": 24300 }, + { "Quintile": "Fourth fifth", "Category": "Transportation", "Spending ($)": 13700 }, + { "Quintile": "Fourth fifth", "Category": "Food", "Spending ($)": 9100 }, + { "Quintile": "Fourth fifth", "Category": "Healthcare", "Spending ($)": 6100 }, + { "Quintile": "Fourth fifth", "Category": "Everything else", "Spending ($)": 22800 }, + { "Quintile": "Highest fifth", "Category": "Housing", "Spending ($)": 36600 }, + { "Quintile": "Highest fifth", "Category": "Transportation", "Spending ($)": 19500 }, + { "Quintile": "Highest fifth", "Category": "Food", "Spending ($)": 13400 }, + { "Quintile": "Highest fifth", "Category": "Healthcare", "Spending ($)": 8500 }, + { "Quintile": "Highest fifth", "Category": "Everything else", "Spending ($)": 43800 } + ] + }, + "transform": [ + { + "calculate": "indexof(['Housing', 'Transportation', 'Food', 'Healthcare', 'Everything else'], datum.Category)", + "as": "__stackRank" + } + ], + "encoding": { + "x": { + "field": "Quintile", + "type": "nominal", + "sort": ["Lowest fifth", "Second fifth", "Middle fifth", "Fourth fifth", "Highest fifth"], + "title": null, + "axis": { + "labelAngle": 0, + "labelExpr": "replace(datum.label, ' fifth', '')", + "domain": false, + "ticks": false, + "grid": false, + "labelPadding": 6 + } + }, + "y": { + "field": "Spending ($)", + "type": "quantitative", + "stack": "normalize", + "title": null, + "axis": { + "format": ".0%", + "tickCount": 5, + "grid": false, + "domain": false, + "ticks": false, + "labelPadding": 5 + } + }, + "color": { + "field": "Category", + "type": "nominal", + "title": null, + "sort": ["Housing", "Transportation", "Food", "Healthcare", "Everything else"], + "scale": { + "domain": ["Housing", "Transportation", "Food", "Healthcare", "Everything else"], + "range": ["#051c2c", "#20618f", "#4a90c4", "#9dc3e0", "#d6e4f0"] + }, + "legend": { + "orient": "top", + "direction": "horizontal", + "symbolType": "square", + "symbolSize": 100, + "columnPadding": 8, + "offset": 10, + "padding": 0 + } + }, + "order": { + "field": "__stackRank", + "type": "quantitative", + "sort": "ascending" + } + }, + "mark": { + "type": "bar", + "width": { "band": 0.7 }, + "stroke": "#ffffff", + "strokeWidth": 0.6 + }, + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13, + "fontWeight": 700, + "color": "#051c2c", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#8fa0ab", + "subtitlePadding": 7 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#051c2c" + }, + "legend": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#051c2c", + "symbolStrokeWidth": 0 + } + } +} diff --git a/site/src/playground/theme-lab-assets/state-jobless.economist.json b/site/src/playground/theme-lab-assets/state-jobless.economist.json new file mode 100644 index 00000000..e13eb734 --- /dev/null +++ b/site/src/playground/theme-lab-assets/state-jobless.economist.json @@ -0,0 +1,148 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "economist", + "__design__": [ + "State names turned 90° and given no label limit. This is the whole problem with fifty categories on one axis: Flint's default clips nineteen of them, and four of those clip into pairs that cannot be told apart — 'North C…' against 'North D…', 'South C…' against 'South D…'. A truncated label that is still unique costs the reader a guess; a truncated label that is not unique is simply wrong, and the chart has no way to say which Carolina it means.", + "Order left exactly as the baseline has it. Sorting these bars by rate would make a far better chart, and that is precisely why it does not belong here — what to sort by is a statement about the data, decided upstream of any house style. A design language governs how a chart looks, not what it says.", + "Value axis moved to the right-hand edge and labelled in per cent, house style — at fifty bars the scale is a reference the reader returns to, not an introduction, so it sits where the eye ends up rather than in front of the tallest bar.", + "Both axis titles dropped. 'State' sits under fifty state names, and 'Unemployment (%)' repeats a subtitle that already says 'unemployment rate … per cent'. Neither adds a word the reader does not already have.", + "Frame and vertical gridlines removed, horizontals kept in pale blue-grey behind the bars. At this density every rule that is not carrying a value is competing with a bar for the same few pixels.", + "House blue #006BA2, red masthead rule, 9.5pt labels dropping to 8pt on the category axis — sized for a print column." + ], + "background": "#ffffff", + "padding": { "left": 6, "top": 4, "right": 12, "bottom": 6 }, + "title": { + "text": "Unemployment by state", + "subtitle": ["Annual average unemployment rate, 50 US states, 2023, per cent"] + }, + "spacing": 8, + "vconcat": [ + { + "mark": { "type": "rect", "fill": "#e3120b", "stroke": null }, + "width": 26, + "height": 3, + "view": { "stroke": null } + }, + { + "width": 430, + "height": 175, + "data": { + "values": [ + { "State": "Alabama", "Unemployment (%)": 2.3 }, + { "State": "Alaska", "Unemployment (%)": 4.3 }, + { "State": "Arizona", "Unemployment (%)": 3.9 }, + { "State": "Arkansas", "Unemployment (%)": 3.3 }, + { "State": "California", "Unemployment (%)": 4.8 }, + { "State": "Colorado", "Unemployment (%)": 3.2 }, + { "State": "Connecticut", "Unemployment (%)": 3.9 }, + { "State": "Delaware", "Unemployment (%)": 4.2 }, + { "State": "Florida", "Unemployment (%)": 2.9 }, + { "State": "Georgia", "Unemployment (%)": 3.2 }, + { "State": "Hawaii", "Unemployment (%)": 3 }, + { "State": "Idaho", "Unemployment (%)": 3.2 }, + { "State": "Illinois", "Unemployment (%)": 4.5 }, + { "State": "Indiana", "Unemployment (%)": 3.3 }, + { "State": "Iowa", "Unemployment (%)": 2.9 }, + { "State": "Kansas", "Unemployment (%)": 2.8 }, + { "State": "Kentucky", "Unemployment (%)": 4.2 }, + { "State": "Louisiana", "Unemployment (%)": 3.5 }, + { "State": "Maine", "Unemployment (%)": 2.9 }, + { "State": "Maryland", "Unemployment (%)": 2.1 }, + { "State": "Massachusetts", "Unemployment (%)": 3.3 }, + { "State": "Michigan", "Unemployment (%)": 3.9 }, + { "State": "Minnesota", "Unemployment (%)": 2.8 }, + { "State": "Mississippi", "Unemployment (%)": 3.2 }, + { "State": "Missouri", "Unemployment (%)": 3 }, + { "State": "Montana", "Unemployment (%)": 2.9 }, + { "State": "Nebraska", "Unemployment (%)": 2.4 }, + { "State": "Nevada", "Unemployment (%)": 5.3 }, + { "State": "New Hampshire", "Unemployment (%)": 2.4 }, + { "State": "New Jersey", "Unemployment (%)": 4.1 }, + { "State": "New Mexico", "Unemployment (%)": 3.8 }, + { "State": "New York", "Unemployment (%)": 4.1 }, + { "State": "North Carolina", "Unemployment (%)": 3.4 }, + { "State": "North Dakota", "Unemployment (%)": 1.9 }, + { "State": "Ohio", "Unemployment (%)": 3.6 }, + { "State": "Oklahoma", "Unemployment (%)": 3 }, + { "State": "Oregon", "Unemployment (%)": 3.7 }, + { "State": "Pennsylvania", "Unemployment (%)": 3.4 }, + { "State": "Rhode Island", "Unemployment (%)": 2.9 }, + { "State": "South Carolina", "Unemployment (%)": 3 }, + { "State": "South Dakota", "Unemployment (%)": 1.9 }, + { "State": "Tennessee", "Unemployment (%)": 3.2 }, + { "State": "Texas", "Unemployment (%)": 4 }, + { "State": "Utah", "Unemployment (%)": 2.5 }, + { "State": "Vermont", "Unemployment (%)": 2.1 }, + { "State": "Virginia", "Unemployment (%)": 2.8 }, + { "State": "Washington", "Unemployment (%)": 4.1 }, + { "State": "West Virginia", "Unemployment (%)": 4 }, + { "State": "Wisconsin", "Unemployment (%)": 2.9 }, + { "State": "Wyoming", "Unemployment (%)": 3.1 } + ] + }, + "encoding": { + "x": { + "field": "State", + "type": "nominal", + "title": null, + "sort": null, + "scale": { "paddingInner": 0.25 }, + "axis": { + "labelAngle": -90, + "labelAlign": "right", + "labelBaseline": "middle", + "labelFontSize": 8, + "labelLimit": 0, + "labelPadding": 4, + "domain": true, + "domainColor": "#121317", + "domainWidth": 1, + "grid": false + } + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "title": null, + "scale": { "zero": true, "nice": false, "domain": [0, 5.5] }, + "axis": { + "orient": "right", + "values": [0, 1, 2, 3, 4, 5], + "labelExpr": "datum.value + '%'", + "grid": true, + "gridColor": "#c9d3da", + "domain": false, + "ticks": false, + "labelPadding": 4 + } + } + }, + "mark": { "type": "bar", "color": "#006ba2" } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 13.5, + "fontWeight": 700, + "color": "#121317", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 10.5, + "subtitleColor": "#54585a", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#54585a", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/state-jobless.flint.json b/site/src/playground/theme-lab-assets/state-jobless.flint.json new file mode 100644 index 00000000..42595137 --- /dev/null +++ b/site/src/playground/theme-lab-assets/state-jobless.flint.json @@ -0,0 +1,258 @@ +{ + "mark": "bar", + "encoding": { + "x": { + "field": "State", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 144 + }, + "axisX": { + "labelLimit": 40, + "labelFontSize": 6, + "titleFontSize": 9, + "labelAngle": -90, + "labelAlign": "right", + "labelBaseline": "middle" + }, + "axisY": { + "labelFontSize": 6, + "titleFontSize": 9 + }, + "legend": { + "labelFontSize": 9, + "titleFontSize": 9 + }, + "facet": { + "spacing": 14 + } + }, + "width": { + "step": 9 + }, + "title": { + "text": "Unemployment by state", + "subtitle": [ + "Annual average unemployment rate, 50 US states, 2023, per cent" + ] + }, + "data": { + "values": [ + { + "State": "Alabama", + "Unemployment (%)": 2.3 + }, + { + "State": "Alaska", + "Unemployment (%)": 4.3 + }, + { + "State": "Arizona", + "Unemployment (%)": 3.9 + }, + { + "State": "Arkansas", + "Unemployment (%)": 3.3 + }, + { + "State": "California", + "Unemployment (%)": 4.8 + }, + { + "State": "Colorado", + "Unemployment (%)": 3.2 + }, + { + "State": "Connecticut", + "Unemployment (%)": 3.9 + }, + { + "State": "Delaware", + "Unemployment (%)": 4.2 + }, + { + "State": "Florida", + "Unemployment (%)": 2.9 + }, + { + "State": "Georgia", + "Unemployment (%)": 3.2 + }, + { + "State": "Hawaii", + "Unemployment (%)": 3 + }, + { + "State": "Idaho", + "Unemployment (%)": 3.2 + }, + { + "State": "Illinois", + "Unemployment (%)": 4.5 + }, + { + "State": "Indiana", + "Unemployment (%)": 3.3 + }, + { + "State": "Iowa", + "Unemployment (%)": 2.9 + }, + { + "State": "Kansas", + "Unemployment (%)": 2.8 + }, + { + "State": "Kentucky", + "Unemployment (%)": 4.2 + }, + { + "State": "Louisiana", + "Unemployment (%)": 3.5 + }, + { + "State": "Maine", + "Unemployment (%)": 2.9 + }, + { + "State": "Maryland", + "Unemployment (%)": 2.1 + }, + { + "State": "Massachusetts", + "Unemployment (%)": 3.3 + }, + { + "State": "Michigan", + "Unemployment (%)": 3.9 + }, + { + "State": "Minnesota", + "Unemployment (%)": 2.8 + }, + { + "State": "Mississippi", + "Unemployment (%)": 3.2 + }, + { + "State": "Missouri", + "Unemployment (%)": 3 + }, + { + "State": "Montana", + "Unemployment (%)": 2.9 + }, + { + "State": "Nebraska", + "Unemployment (%)": 2.4 + }, + { + "State": "Nevada", + "Unemployment (%)": 5.3 + }, + { + "State": "New Hampshire", + "Unemployment (%)": 2.4 + }, + { + "State": "New Jersey", + "Unemployment (%)": 4.1 + }, + { + "State": "New Mexico", + "Unemployment (%)": 3.8 + }, + { + "State": "New York", + "Unemployment (%)": 4.1 + }, + { + "State": "North Carolina", + "Unemployment (%)": 3.4 + }, + { + "State": "North Dakota", + "Unemployment (%)": 1.9 + }, + { + "State": "Ohio", + "Unemployment (%)": 3.6 + }, + { + "State": "Oklahoma", + "Unemployment (%)": 3 + }, + { + "State": "Oregon", + "Unemployment (%)": 3.7 + }, + { + "State": "Pennsylvania", + "Unemployment (%)": 3.4 + }, + { + "State": "Rhode Island", + "Unemployment (%)": 2.9 + }, + { + "State": "South Carolina", + "Unemployment (%)": 3 + }, + { + "State": "South Dakota", + "Unemployment (%)": 1.9 + }, + { + "State": "Tennessee", + "Unemployment (%)": 3.2 + }, + { + "State": "Texas", + "Unemployment (%)": 4 + }, + { + "State": "Utah", + "Unemployment (%)": 2.5 + }, + { + "State": "Vermont", + "Unemployment (%)": 2.1 + }, + { + "State": "Virginia", + "Unemployment (%)": 2.8 + }, + { + "State": "Washington", + "Unemployment (%)": 4.1 + }, + { + "State": "West Virginia", + "Unemployment (%)": 4 + }, + { + "State": "Wisconsin", + "Unemployment (%)": 2.9 + }, + { + "State": "Wyoming", + "Unemployment (%)": 3.1 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/state-unemployment.datawrapper.json b/site/src/playground/theme-lab-assets/state-unemployment.datawrapper.json new file mode 100644 index 00000000..234cdb27 --- /dev/null +++ b/site/src/playground/theme-lab-assets/state-unemployment.datawrapper.json @@ -0,0 +1,380 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "datawrapper", + "__design__": [ + "Continuous 'goldgreen' ramp replaced with a five-step quantize scale on a single blue hue. A continuous gradient across fifty small polygons is unreadable — the eye cannot invert a smooth ramp, so the values get binned into classes the reader can actually name.", + "Hue reduced to one family (#DCEEF6 → #0B5C82). Two-hue schemes on a choropleth imply a diverging quantity; unemployment has no meaningful midpoint here, so it gets a sequential ramp.", + "Legend moved from a right-hand gradient bar to a horizontal row of discrete swatches above the map, so the map itself keeps the full width of the tile.", + "Legend labels formatted with a % suffix and the class breaks stated explicitly (2, 3, 4, 5) rather than implied by tick positions on a gradient.", + "State boundaries thinned to 0.6px white and a 0.8px white outer edge added, which is what stops dense north-eastern states from merging into one blob.", + "Frame, view stroke and all padding removed and the projection given the whole 460×280 box; a map has no axes, so every pixel of chrome was waste.", + "Tooltip fields kept but the value formatted to one decimal with a unit, matching the legend so hover and key agree.", + "Key breaks relabelled to one decimal with overlap removal switched off. At .0f the five bands read 2%, 3%, 3%, 4%, 4% and Vega then dropped the collisions, so three of the five swatches had no readable break at all." + ], + "background": "#ffffff", + "padding": { + "left": 0, + "top": 4, + "right": 0, + "bottom": 4 + }, + "title": { + "text": "Unemployment rate by state, 2023", + "subtitle": [ + "Annual average, % of the civilian labour force" + ] + }, + "width": 460, + "height": 280, + "data": { + "url": "https://vega.github.io/vega-lite/data/us-10m.json", + "format": { + "type": "topojson", + "feature": "states" + } + }, + "projection": { + "type": "albersUsa" + }, + "transform": [ + { + "lookup": "id", + "from": { + "data": { + "values": [ + { + "State": "Alabama", + "Unemployment (%)": 2.3, + "__geo_id": 1 + }, + { + "State": "Alaska", + "Unemployment (%)": 4.3, + "__geo_id": 2 + }, + { + "State": "Arizona", + "Unemployment (%)": 3.9, + "__geo_id": 4 + }, + { + "State": "Arkansas", + "Unemployment (%)": 3.3, + "__geo_id": 5 + }, + { + "State": "California", + "Unemployment (%)": 4.8, + "__geo_id": 6 + }, + { + "State": "Colorado", + "Unemployment (%)": 3.2, + "__geo_id": 8 + }, + { + "State": "Connecticut", + "Unemployment (%)": 3.9, + "__geo_id": 9 + }, + { + "State": "Delaware", + "Unemployment (%)": 4.2, + "__geo_id": 10 + }, + { + "State": "Florida", + "Unemployment (%)": 2.9, + "__geo_id": 12 + }, + { + "State": "Georgia", + "Unemployment (%)": 3.2, + "__geo_id": 13 + }, + { + "State": "Hawaii", + "Unemployment (%)": 3, + "__geo_id": 15 + }, + { + "State": "Idaho", + "Unemployment (%)": 3.2, + "__geo_id": 16 + }, + { + "State": "Illinois", + "Unemployment (%)": 4.5, + "__geo_id": 17 + }, + { + "State": "Indiana", + "Unemployment (%)": 3.3, + "__geo_id": 18 + }, + { + "State": "Iowa", + "Unemployment (%)": 2.9, + "__geo_id": 19 + }, + { + "State": "Kansas", + "Unemployment (%)": 2.8, + "__geo_id": 20 + }, + { + "State": "Kentucky", + "Unemployment (%)": 4.2, + "__geo_id": 21 + }, + { + "State": "Louisiana", + "Unemployment (%)": 3.5, + "__geo_id": 22 + }, + { + "State": "Maine", + "Unemployment (%)": 2.9, + "__geo_id": 23 + }, + { + "State": "Maryland", + "Unemployment (%)": 2.1, + "__geo_id": 24 + }, + { + "State": "Massachusetts", + "Unemployment (%)": 3.3, + "__geo_id": 25 + }, + { + "State": "Michigan", + "Unemployment (%)": 3.9, + "__geo_id": 26 + }, + { + "State": "Minnesota", + "Unemployment (%)": 2.8, + "__geo_id": 27 + }, + { + "State": "Mississippi", + "Unemployment (%)": 3.2, + "__geo_id": 28 + }, + { + "State": "Missouri", + "Unemployment (%)": 3, + "__geo_id": 29 + }, + { + "State": "Montana", + "Unemployment (%)": 2.9, + "__geo_id": 30 + }, + { + "State": "Nebraska", + "Unemployment (%)": 2.4, + "__geo_id": 31 + }, + { + "State": "Nevada", + "Unemployment (%)": 5.3, + "__geo_id": 32 + }, + { + "State": "New Hampshire", + "Unemployment (%)": 2.4, + "__geo_id": 33 + }, + { + "State": "New Jersey", + "Unemployment (%)": 4.1, + "__geo_id": 34 + }, + { + "State": "New Mexico", + "Unemployment (%)": 3.8, + "__geo_id": 35 + }, + { + "State": "New York", + "Unemployment (%)": 4.1, + "__geo_id": 36 + }, + { + "State": "North Carolina", + "Unemployment (%)": 3.4, + "__geo_id": 37 + }, + { + "State": "North Dakota", + "Unemployment (%)": 1.9, + "__geo_id": 38 + }, + { + "State": "Ohio", + "Unemployment (%)": 3.6, + "__geo_id": 39 + }, + { + "State": "Oklahoma", + "Unemployment (%)": 3, + "__geo_id": 40 + }, + { + "State": "Oregon", + "Unemployment (%)": 3.7, + "__geo_id": 41 + }, + { + "State": "Pennsylvania", + "Unemployment (%)": 3.4, + "__geo_id": 42 + }, + { + "State": "Rhode Island", + "Unemployment (%)": 2.9, + "__geo_id": 44 + }, + { + "State": "South Carolina", + "Unemployment (%)": 3, + "__geo_id": 45 + }, + { + "State": "South Dakota", + "Unemployment (%)": 1.9, + "__geo_id": 46 + }, + { + "State": "Tennessee", + "Unemployment (%)": 3.2, + "__geo_id": 47 + }, + { + "State": "Texas", + "Unemployment (%)": 4, + "__geo_id": 48 + }, + { + "State": "Utah", + "Unemployment (%)": 2.5, + "__geo_id": 49 + }, + { + "State": "Vermont", + "Unemployment (%)": 2.1, + "__geo_id": 50 + }, + { + "State": "Virginia", + "Unemployment (%)": 2.8, + "__geo_id": 51 + }, + { + "State": "Washington", + "Unemployment (%)": 4.1, + "__geo_id": 53 + }, + { + "State": "West Virginia", + "Unemployment (%)": 4, + "__geo_id": 54 + }, + { + "State": "Wisconsin", + "Unemployment (%)": 2.9, + "__geo_id": 55 + }, + { + "State": "Wyoming", + "Unemployment (%)": 3.1, + "__geo_id": 56 + } + ] + }, + "key": "__geo_id", + "fields": [ + "Unemployment (%)", + "State" + ] + } + } + ], + "mark": { + "type": "geoshape", + "stroke": "#ffffff", + "strokeWidth": 0.6 + }, + "encoding": { + "color": { + "field": "Unemployment (%)", + "type": "quantitative", + "title": null, + "scale": { + "type": "quantize", + "domain": [ + 2, + 5 + ], + "range": [ + "#dceef6", + "#a9d3e6", + "#6aabcc", + "#2f7fa8", + "#0b5c82" + ] + }, + "legend": { + "orient": "top", + "direction": "horizontal", + "type": "symbol", + "symbolType": "square", + "symbolSize": 130, + "format": ".1f", + "labelExpr": "isValid(datum.label) ? datum.label + '%' : ''", + "columnPadding": 6, + "offset": 2, + "padding": 0, + "labelOverlap": false + } + }, + "tooltip": [ + { + "field": "State", + "type": "nominal" + }, + { + "field": "Unemployment (%)", + "type": "quantitative", + "format": ".1f" + } + ] + }, + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 15, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#767676", + "subtitlePadding": 8 + }, + "view": { + "stroke": null + }, + "legend": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10.5, + "labelColor": "#333333", + "symbolStrokeWidth": 0 + } + } +} diff --git a/site/src/playground/theme-lab-assets/state-unemployment.flint.json b/site/src/playground/theme-lab-assets/state-unemployment.flint.json new file mode 100644 index 00000000..2930bd7f --- /dev/null +++ b/site/src/playground/theme-lab-assets/state-unemployment.flint.json @@ -0,0 +1,332 @@ +{ + "mark": { + "type": "geoshape", + "stroke": "white", + "strokeWidth": 0.5 + }, + "encoding": { + "color": { + "field": "Unemployment (%)", + "type": "quantitative", + "scale": { + "scheme": "goldgreen" + } + }, + "tooltip": [ + { + "field": "State", + "type": "nominal" + }, + { + "field": "Unemployment (%)", + "type": "quantitative" + } + ] + }, + "width": 500, + "height": 300, + "data": { + "url": "https://vega.github.io/vega-lite/data/us-10m.json", + "format": { + "type": "topojson", + "feature": "states" + } + }, + "projection": { + "type": "albersUsa" + }, + "transform": [ + { + "lookup": "id", + "from": { + "data": { + "values": [ + { + "State": "Alabama", + "Unemployment (%)": 2.3, + "__geo_id": 1 + }, + { + "State": "Alaska", + "Unemployment (%)": 4.3, + "__geo_id": 2 + }, + { + "State": "Arizona", + "Unemployment (%)": 3.9, + "__geo_id": 4 + }, + { + "State": "Arkansas", + "Unemployment (%)": 3.3, + "__geo_id": 5 + }, + { + "State": "California", + "Unemployment (%)": 4.8, + "__geo_id": 6 + }, + { + "State": "Colorado", + "Unemployment (%)": 3.2, + "__geo_id": 8 + }, + { + "State": "Connecticut", + "Unemployment (%)": 3.9, + "__geo_id": 9 + }, + { + "State": "Delaware", + "Unemployment (%)": 4.2, + "__geo_id": 10 + }, + { + "State": "Florida", + "Unemployment (%)": 2.9, + "__geo_id": 12 + }, + { + "State": "Georgia", + "Unemployment (%)": 3.2, + "__geo_id": 13 + }, + { + "State": "Hawaii", + "Unemployment (%)": 3, + "__geo_id": 15 + }, + { + "State": "Idaho", + "Unemployment (%)": 3.2, + "__geo_id": 16 + }, + { + "State": "Illinois", + "Unemployment (%)": 4.5, + "__geo_id": 17 + }, + { + "State": "Indiana", + "Unemployment (%)": 3.3, + "__geo_id": 18 + }, + { + "State": "Iowa", + "Unemployment (%)": 2.9, + "__geo_id": 19 + }, + { + "State": "Kansas", + "Unemployment (%)": 2.8, + "__geo_id": 20 + }, + { + "State": "Kentucky", + "Unemployment (%)": 4.2, + "__geo_id": 21 + }, + { + "State": "Louisiana", + "Unemployment (%)": 3.5, + "__geo_id": 22 + }, + { + "State": "Maine", + "Unemployment (%)": 2.9, + "__geo_id": 23 + }, + { + "State": "Maryland", + "Unemployment (%)": 2.1, + "__geo_id": 24 + }, + { + "State": "Massachusetts", + "Unemployment (%)": 3.3, + "__geo_id": 25 + }, + { + "State": "Michigan", + "Unemployment (%)": 3.9, + "__geo_id": 26 + }, + { + "State": "Minnesota", + "Unemployment (%)": 2.8, + "__geo_id": 27 + }, + { + "State": "Mississippi", + "Unemployment (%)": 3.2, + "__geo_id": 28 + }, + { + "State": "Missouri", + "Unemployment (%)": 3, + "__geo_id": 29 + }, + { + "State": "Montana", + "Unemployment (%)": 2.9, + "__geo_id": 30 + }, + { + "State": "Nebraska", + "Unemployment (%)": 2.4, + "__geo_id": 31 + }, + { + "State": "Nevada", + "Unemployment (%)": 5.3, + "__geo_id": 32 + }, + { + "State": "New Hampshire", + "Unemployment (%)": 2.4, + "__geo_id": 33 + }, + { + "State": "New Jersey", + "Unemployment (%)": 4.1, + "__geo_id": 34 + }, + { + "State": "New Mexico", + "Unemployment (%)": 3.8, + "__geo_id": 35 + }, + { + "State": "New York", + "Unemployment (%)": 4.1, + "__geo_id": 36 + }, + { + "State": "North Carolina", + "Unemployment (%)": 3.4, + "__geo_id": 37 + }, + { + "State": "North Dakota", + "Unemployment (%)": 1.9, + "__geo_id": 38 + }, + { + "State": "Ohio", + "Unemployment (%)": 3.6, + "__geo_id": 39 + }, + { + "State": "Oklahoma", + "Unemployment (%)": 3, + "__geo_id": 40 + }, + { + "State": "Oregon", + "Unemployment (%)": 3.7, + "__geo_id": 41 + }, + { + "State": "Pennsylvania", + "Unemployment (%)": 3.4, + "__geo_id": 42 + }, + { + "State": "Rhode Island", + "Unemployment (%)": 2.9, + "__geo_id": 44 + }, + { + "State": "South Carolina", + "Unemployment (%)": 3, + "__geo_id": 45 + }, + { + "State": "South Dakota", + "Unemployment (%)": 1.9, + "__geo_id": 46 + }, + { + "State": "Tennessee", + "Unemployment (%)": 3.2, + "__geo_id": 47 + }, + { + "State": "Texas", + "Unemployment (%)": 4, + "__geo_id": 48 + }, + { + "State": "Utah", + "Unemployment (%)": 2.5, + "__geo_id": 49 + }, + { + "State": "Vermont", + "Unemployment (%)": 2.1, + "__geo_id": 50 + }, + { + "State": "Virginia", + "Unemployment (%)": 2.8, + "__geo_id": 51 + }, + { + "State": "Washington", + "Unemployment (%)": 4.1, + "__geo_id": 53 + }, + { + "State": "West Virginia", + "Unemployment (%)": 4, + "__geo_id": 54 + }, + { + "State": "Wisconsin", + "Unemployment (%)": 2.9, + "__geo_id": 55 + }, + { + "State": "Wyoming", + "Unemployment (%)": 3.1, + "__geo_id": 56 + } + ] + }, + "key": "__geo_id", + "fields": [ + "Unemployment (%)", + "State" + ] + } + } + ], + "config": { + "view": { + "continuousWidth": 500, + "continuousHeight": 300 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "title": { + "text": "Unemployment rate by state, 2023", + "subtitle": [ + "Annual average, % of the civilian labour force" + ] + } +} diff --git a/site/src/playground/theme-lab-assets/stock-candle.flint.json b/site/src/playground/theme-lab-assets/stock-candle.flint.json new file mode 100644 index 00000000..df6de1cc --- /dev/null +++ b/site/src/playground/theme-lab-assets/stock-candle.flint.json @@ -0,0 +1,171 @@ +{ + "encoding": { + "x": { + "field": "Date", + "type": "temporal", + "scale": { + "nice": false, + "domain": [ + "2024-01-01T09:00:00.000Z", + "2024-01-12T15:00:00.000Z" + ] + } + }, + "y": { + "type": "quantitative", + "scale": { + "zero": false + }, + "axis": { + "title": null, + "format": ",.12~g" + } + }, + "color": { + "condition": { + "test": "datum['Open'] <= datum['Close']", + "value": "#06982d" + }, + "value": "#ae1325" + } + }, + "layer": [ + { + "mark": "rule", + "encoding": { + "y": { + "field": "Low" + }, + "y2": { + "field": "High" + } + } + }, + { + "mark": { + "type": "bar", + "size": 16 + }, + "encoding": { + "y": { + "field": "Open" + }, + "y2": { + "field": "Close" + } + } + }, + { + "transform": [ + { + "filter": "datum['Open'] === datum['Close']" + } + ], + "mark": { + "type": "tick", + "size": 16, + "thickness": 2 + }, + "encoding": { + "y": { + "field": "Close" + } + } + } + ], + "title": { + "text": "Two weeks of a stock going nowhere", + "subtitle": [ + "Daily open, high, low and close, 2–12 January 2024" + ] + }, + "config": { + "view": { + "continuousWidth": 230, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "data": { + "values": [ + { + "Date": "2024-01-02", + "Open": 187, + "High": 188, + "Low": 183, + "Close": 185 + }, + { + "Date": "2024-01-03", + "Open": 184, + "High": 185, + "Low": 182, + "Close": 184 + }, + { + "Date": "2024-01-04", + "Open": 182, + "High": 183, + "Low": 180, + "Close": 182 + }, + { + "Date": "2024-01-05", + "Open": 182, + "High": 182, + "Low": 179, + "Close": 181 + }, + { + "Date": "2024-01-08", + "Open": 182, + "High": 186, + "Low": 182, + "Close": 185 + }, + { + "Date": "2024-01-09", + "Open": 184, + "High": 185, + "Low": 183, + "Close": 185 + }, + { + "Date": "2024-01-10", + "Open": 184, + "High": 186, + "Low": 183, + "Close": 186 + }, + { + "Date": "2024-01-11", + "Open": 186, + "High": 187, + "Low": 183, + "Close": 186 + }, + { + "Date": "2024-01-12", + "Open": 186, + "High": 188, + "Low": 185, + "Close": 185 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/stock-candle.powerbi.json b/site/src/playground/theme-lab-assets/stock-candle.powerbi.json new file mode 100644 index 00000000..1622703f --- /dev/null +++ b/site/src/playground/theme-lab-assets/stock-candle.powerbi.json @@ -0,0 +1,143 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi", + "__design__": [ + "Trading days made ordinal. Flint puts the dates on a temporal scale, so the weekend between 5 and 8 January opens a two-day hole in a series that has no data on either side of it — the chart draws the absence of trading as a price gap. Nine sessions, nine equal slots, and the body width stated as a fraction of the band rather than in pixels, so it holds at any size.", + "Flint's hardcoded temporal domain — '2024-01-01T09:00:00.000Z' to '2024-01-12T15:00:00.000Z', an ISO instant with a UTC offset, on daily bars — deleted with it. It is a padding hack expressed as a timestamp, and it silently shifts every candle if the reader's data is in another zone.", + "#06982D / #AE1325 replaced by #22B14C and #E66C37. Saturated green against saturated red is the one pairing that collapses for the roughly 8% of men with a red–green deficiency, and it is exactly the pairing where direction is the entire message. Green against orange survives.", + "Flat sessions given a body rather than a hairline. On 3, 4 and 11 January the open equals the close, so the candle has no height of its own; Flint draws the convention, a 2px horizontal tick. On a dark dashboard at this size that reads as an absence, so the body is padded to ±0.09 instead — the same event, stated at the weight of its neighbours.", + "Y axis given its unit and a domain pinned to 178–190, so the two-week range is read against a stated scale rather than whatever 'zero: false' happens to produce. Flint's ',.12~g' format — twelve significant digits on a dollar price — replaced by currency.", + "Latest close labelled at the right, in the direction colour. On a dashboard the last number is the one being looked for.", + "Inverted to the #1B1A19 canvas with 9.5pt Segoe UI and dashed #3B3A39 horizontal gridlines that sit behind the candles instead of competing with them." + ], + "background": "#1b1a19", + "padding": { "left": 10, "top": 6, "right": 14, "bottom": 8 }, + "title": { + "text": "Two weeks of a stock going nowhere", + "subtitle": ["Daily open, high, low and close, 2–12 January 2024"] + }, + "width": 250, + "height": 175, + "data": { + "values": [ + { "Date": "2024-01-02", "Open": 187, "High": 188, "Low": 183, "Close": 185 }, + { "Date": "2024-01-03", "Open": 184, "High": 185, "Low": 182, "Close": 184 }, + { "Date": "2024-01-04", "Open": 182, "High": 183, "Low": 180, "Close": 182 }, + { "Date": "2024-01-05", "Open": 182, "High": 182, "Low": 179, "Close": 181 }, + { "Date": "2024-01-08", "Open": 182, "High": 186, "Low": 182, "Close": 185 }, + { "Date": "2024-01-09", "Open": 184, "High": 185, "Low": 183, "Close": 185 }, + { "Date": "2024-01-10", "Open": 184, "High": 186, "Low": 183, "Close": 186 }, + { "Date": "2024-01-11", "Open": 186, "High": 187, "Low": 183, "Close": 186 }, + { "Date": "2024-01-12", "Open": 186, "High": 188, "Low": 185, "Close": 185 } + ] + }, + "transform": [ + { + "calculate": "datum.Open === datum.Close ? datum.Open - 0.09 : min(datum.Open, datum.Close)", + "as": "__bodyLo" + }, + { + "calculate": "datum.Open === datum.Close ? datum.Open + 0.09 : max(datum.Open, datum.Close)", + "as": "__bodyHi" + } + ], + "encoding": { + "x": { + "field": "Date", + "type": "ordinal", + "title": "January 2024", + "scale": { "paddingInner": 0.4 }, + "axis": { + "labelExpr": "toNumber(slice(datum.value, 8))", + "labelAngle": 0, + "grid": false, + "domain": false, + "ticks": false, + "labelPadding": 4 + } + }, + "y": { + "type": "quantitative", + "title": "US dollars", + "scale": { "zero": false, "nice": false, "domain": [178, 190] }, + "axis": { + "values": [178, 180, 182, 184, 186, 188, 190], + "format": "$d", + "grid": true, + "gridColor": "#3b3a39", + "gridDash": [3, 3], + "domain": false, + "ticks": false, + "titleAngle": 0, + "titleAlign": "left", + "titleAnchor": "start", + "titleBaseline": "bottom", + "titleY": -8, + "titlePadding": 0 + } + }, + "color": { + "condition": { "test": "datum.Open <= datum.Close", "value": "#22b14c" }, + "value": "#e66c37" + } + }, + "layer": [ + { + "mark": { "type": "rule", "strokeWidth": 1 }, + "encoding": { + "y": { "field": "Low", "type": "quantitative" }, + "y2": { "field": "High" } + } + }, + { + "mark": { "type": "bar", "width": { "band": 0.6 } }, + "encoding": { + "y": { "field": "__bodyLo", "type": "quantitative" }, + "y2": { "field": "__bodyHi" } + } + }, + { + "transform": [{ "filter": "datum.Date === '2024-01-12'" }], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 10, + "fontSize": 11, + "fontWeight": 600 + }, + "encoding": { + "y": { "field": "Close", "type": "quantitative" }, + "text": { "field": "Close", "type": "quantitative", "format": "$d" } + } + } + ], + "config": { + "background": "#1b1a19", + "font": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "title": { + "font": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "subtitleFontSize": 9.5, + "subtitleColor": "#8a8886", + "subtitlePadding": 6 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#8a8886", + "titleFont": "'Segoe UI', 'Helvetica Neue', Arial, sans-serif", + "titleFontSize": 9.5, + "titleColor": "#8a8886", + "grid": false, + "domain": false, + "ticks": false + } + } +} diff --git a/site/src/playground/theme-lab-assets/sunspots.flint.json b/site/src/playground/theme-lab-assets/sunspots.flint.json new file mode 100644 index 00000000..37d23f43 --- /dev/null +++ b/site/src/playground/theme-lab-assets/sunspots.flint.json @@ -0,0 +1,144 @@ +{ + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Sunspot number", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 332, + "continuousHeight": 235 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 24 + } + }, + "title": { + "text": "Sunspot number, 2000–2023" + }, + "data": { + "values": [ + { + "Year": "2000", + "Sunspot number": 120 + }, + { + "Year": "2001", + "Sunspot number": 111 + }, + { + "Year": "2002", + "Sunspot number": 104 + }, + { + "Year": "2003", + "Sunspot number": 64 + }, + { + "Year": "2004", + "Sunspot number": 40 + }, + { + "Year": "2005", + "Sunspot number": 30 + }, + { + "Year": "2006", + "Sunspot number": 15 + }, + { + "Year": "2007", + "Sunspot number": 8 + }, + { + "Year": "2008", + "Sunspot number": 3 + }, + { + "Year": "2009", + "Sunspot number": 3 + }, + { + "Year": "2010", + "Sunspot number": 16 + }, + { + "Year": "2011", + "Sunspot number": 56 + }, + { + "Year": "2012", + "Sunspot number": 58 + }, + { + "Year": "2013", + "Sunspot number": 65 + }, + { + "Year": "2014", + "Sunspot number": 79 + }, + { + "Year": "2015", + "Sunspot number": 67 + }, + { + "Year": "2016", + "Sunspot number": 40 + }, + { + "Year": "2017", + "Sunspot number": 22 + }, + { + "Year": "2018", + "Sunspot number": 7 + }, + { + "Year": "2019", + "Sunspot number": 4 + }, + { + "Year": "2020", + "Sunspot number": 9 + }, + { + "Year": "2021", + "Sunspot number": 29 + }, + { + "Year": "2022", + "Sunspot number": 83 + }, + { + "Year": "2023", + "Sunspot number": 123 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/temp-anomaly.flint.json b/site/src/playground/theme-lab-assets/temp-anomaly.flint.json new file mode 100644 index 00000000..d0caff50 --- /dev/null +++ b/site/src/playground/theme-lab-assets/temp-anomaly.flint.json @@ -0,0 +1,108 @@ +{ + "mark": "bar", + "encoding": { + "x": { + "field": "Decade", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Anomaly (°C)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Direction", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "width": { + "step": 23 + }, + "title": { + "text": "Global temperature anomaly by decade", + "subtitle": [ + "°C against the 1951–1980 average" + ] + }, + "data": { + "values": [ + { + "Decade": "1880s", + "Anomaly (°C)": -0.17, + "Direction": "Below average" + }, + { + "Decade": "1900s", + "Anomaly (°C)": -0.16, + "Direction": "Below average" + }, + { + "Decade": "1920s", + "Anomaly (°C)": -0.27, + "Direction": "Below average" + }, + { + "Decade": "1940s", + "Anomaly (°C)": 0.12, + "Direction": "Above average" + }, + { + "Decade": "1960s", + "Anomaly (°C)": -0.03, + "Direction": "Below average" + }, + { + "Decade": "1980s", + "Anomaly (°C)": 0.26, + "Direction": "Above average" + }, + { + "Decade": "2000s", + "Anomaly (°C)": 0.4, + "Direction": "Above average" + }, + { + "Decade": "2010s", + "Anomaly (°C)": 0.72, + "Direction": "Above average" + }, + { + "Decade": "2020s", + "Anomaly (°C)": 1.02, + "Direction": "Above average" + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/temp-anomaly.nyt.json b/site/src/playground/theme-lab-assets/temp-anomaly.nyt.json new file mode 100644 index 00000000..a2cde783 --- /dev/null +++ b/site/src/playground/theme-lab-assets/temp-anomaly.nyt.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nyt", + "__design__": [ + "Direction is already a colour channel in the data, so the redesign only re-palettes it: tableau10's arbitrary pair becomes cool blue #2F6B9A for below-average decades and warm red #C2352B for above. The hue now agrees with what the value means.", + "Legend dropped. A zero-crossing bar chart with a stated baseline is self-labelling, and the NYT house move is to spend that space on the plot instead of a key.", + "A black zero rule is layered in so the baseline is an explicit reference line rather than an axis artefact — derived from the scale, not from added data.", + "Y grid kept but pushed to #e4e4e4; the x-axis domain line is deleted because the zero rule already plays that role — Flint's default kept both and they fought.", + "Bar width narrowed to 0.72 of the band so the negative and positive runs read as two groups instead of a continuous block.", + "Axis number format switched to signed (+.1f), which does the work the label colour would otherwise have to do.", + "Axis titles removed; the unit moves to a flat '°C' above the y axis, and the title splits into serif line plus sans deck." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 20, "bottom": 6 }, + "width": 300, + "height": 190, + "title": { + "text": "Global temperature anomaly by decade", + "subtitle": ["°C against the 1951–1980 average"] + }, + "data": { + "values": [ + { "Decade": "1880s", "Anomaly (°C)": -0.17, "Direction": "Below average" }, + { "Decade": "1900s", "Anomaly (°C)": -0.16, "Direction": "Below average" }, + { "Decade": "1920s", "Anomaly (°C)": -0.27, "Direction": "Below average" }, + { "Decade": "1940s", "Anomaly (°C)": 0.12, "Direction": "Above average" }, + { "Decade": "1960s", "Anomaly (°C)": -0.03, "Direction": "Below average" }, + { "Decade": "1980s", "Anomaly (°C)": 0.26, "Direction": "Above average" }, + { "Decade": "2000s", "Anomaly (°C)": 0.4, "Direction": "Above average" }, + { "Decade": "2010s", "Anomaly (°C)": 0.72, "Direction": "Above average" }, + { "Decade": "2020s", "Anomaly (°C)": 1.02, "Direction": "Above average" } + ] + }, + "encoding": { + "x": { "field": "Decade", "type": "nominal", "sort": null, "title": null }, + "y": { + "field": "Anomaly (°C)", + "type": "quantitative", + "title": "°C", + "axis": { "tickCount": 4, "format": "+.1f" } + }, + "color": { + "field": "Direction", + "type": "nominal", + "sort": null, + "scale": { + "domain": ["Below average", "Above average"], + "range": ["#2f6b9a", "#c2352b"] + }, + "legend": null + } + }, + "layer": [ + { + "mark": { "type": "bar", "width": { "band": 0.72 } } + }, + { + "mark": { "type": "rule", "color": "#121212", "strokeWidth": 1 }, + "encoding": { "y": { "datum": 0 }, "x": null, "color": null } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, 'Times New Roman', Times, serif", + "fontSize": 15, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 14, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#6b6b6b", + "subtitlePadding": 7 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 10, + "titleFontWeight": 400, + "titleColor": "#8e8e8e", + "domain": false, + "ticks": false, + "grid": false + }, + "axisY": { + "grid": true, + "gridColor": "#e4e4e4", + "domain": false, + "ticks": false, + "titleAngle": 0, + "titleAlign": "left", + "titleAnchor": "start", + "titleBaseline": "bottom", + "titleY": -8, + "titlePadding": 0 + }, + "axisX": { + "grid": false, + "domain": false, + "ticks": false, + "labelAngle": 0, + "labelPadding": 4 + } + } +} diff --git a/site/src/playground/theme-lab-assets/temp-heatmap.datawrapper.json b/site/src/playground/theme-lab-assets/temp-heatmap.datawrapper.json new file mode 100644 index 00000000..dbd10de1 --- /dev/null +++ b/site/src/playground/theme-lab-assets/temp-heatmap.datawrapper.json @@ -0,0 +1,396 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "datawrapper", + "__design__": [ + "Ramp cut into five explicit bands at −2, 6, 14 and 22°C rather than left continuous — the same treatment the house gives a choropleth, because a reader can match a swatch to a cell but cannot read a position off a gradient.", + "Key becomes a row of five square swatches above the plot, labelled at the breaks — −2, 6, 14 and 22°C — rather than at the bin centres. The open lower bin carries no label because it has no lower bound to name.", + "Blue-to-red steps with a light neutral at the freezing break, so the crossing point lands on a swatch boundary rather than somewhere inside a gradient.", + "11pt type floor throughout — the chart has to survive being embedded at half width on a phone.", + "Axis domains and ticks deleted; month labels horizontal at 11pt, city names left-aligned in near-black #333.", + "A hairline footer rule closes the chart block, and 1.5px white gutters separate the cells.", + "Label overlap removal switched off on the key. Vega drops colliding tick labels by default, which silently deleted one of the five breaks and left the reader unable to tell which band was which." + ], + "background": "#ffffff", + "padding": { + "left": 10, + "top": 8, + "right": 12, + "bottom": 6 + }, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ], + "anchor": "start", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 15, + "fontWeight": "bold", + "color": "#333333", + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#666666", + "offset": 10 + }, + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + }, + "vconcat": [ + { + "width": 300, + "height": 116, + "mark": { + "type": "rect", + "stroke": "#ffffff", + "strokeWidth": 1.5 + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "title": null, + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": { + "domain": false, + "ticks": false, + "grid": false, + "labelAngle": 0, + "labelPadding": 5, + "labelFontSize": 11, + "labelColor": "#666666" + } + }, + "y": { + "field": "City", + "type": "nominal", + "title": null, + "sort": [ + "Singapore", + "Cairo", + "Moscow", + "Seattle" + ], + "axis": { + "domain": false, + "ticks": false, + "grid": false, + "labelPadding": 7, + "labelFontSize": 11, + "labelColor": "#333333" + } + }, + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "title": null, + "scale": { + "type": "quantize", + "domain": [ + -10, + 30 + ], + "range": [ + "#2f7fa8", + "#a9d3e6", + "#f0ece4", + "#e8ac70", + "#c04a4a" + ] + }, + "legend": { + "orient": "top", + "direction": "horizontal", + "type": "symbol", + "symbolType": "square", + "symbolSize": 170, + "columnPadding": 4, + "offset": 2, + "padding": 0, + "labelOverlap": false, + "format": ".0f", + "labelExpr": "isValid(datum.label) ? datum.label + '°' : ''", + "labelFontSize": 11, + "labelColor": "#333333" + } + } + } + }, + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "rect", + "fill": "#dcdcdc", + "stroke": null + }, + "width": 300, + "height": 1, + "view": { + "stroke": null + } + } + ], + "spacing": 8, + "resolve": { + "legend": { + "color": "independent" + } + } +} diff --git a/site/src/playground/theme-lab-assets/temp-heatmap.economist.json b/site/src/playground/theme-lab-assets/temp-heatmap.economist.json new file mode 100644 index 00000000..93a8766e --- /dev/null +++ b/site/src/playground/theme-lab-assets/temp-heatmap.economist.json @@ -0,0 +1,391 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "economist", + "__design__": [ + "Masthead rule added directly under the headline as its own concat row, sized 26×3 like every other chart in the paper — brand furniture, no data.", + "Diverging ramp assembled entirely from inks the paper already uses on categorical charts: house blue #006BA2 for cold, rust #A1655A for warm, meeting on the paper tint #E9E5DC at 0°C.", + "Key moved to a single horizontal gradient above the plot, where the paper puts every key, with the unit carried as the key's own title rather than an axis title.", + "No numbers printed in the cells. The paper reads a heat grid as a shape — it prints values on a bar because a bar has an end to print at, and a cell does not.", + "Both axis domains and ticks deleted; month and city labels sit tight against the grid at 9pt.", + "Cells separated by a 0.6px white hairline, so adjacent months stay countable without a gridline competing with the fill.", + "Legend resolution forced to independent so the key stays inside the plot row instead of being hoisted above the masthead by the concat container." + ], + "background": "#ffffff", + "padding": { + "left": 10, + "top": 6, + "right": 12, + "bottom": 8 + }, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ], + "anchor": "start", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": "bold", + "color": "#121317", + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#54585a", + "offset": 8 + }, + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + }, + "vconcat": [ + { + "data": { + "values": [ + {} + ] + }, + "mark": { + "type": "rect", + "fill": "#e3120b", + "stroke": null + }, + "width": 26, + "height": 3, + "view": { + "stroke": null + } + }, + { + "width": 288, + "height": 112, + "mark": { + "type": "rect", + "stroke": "#ffffff", + "strokeWidth": 0.6 + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "title": null, + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": { + "domain": false, + "ticks": false, + "grid": false, + "labelAngle": 0, + "labelPadding": 4, + "labelFontSize": 9, + "labelColor": "#54585a" + } + }, + "y": { + "field": "City", + "type": "nominal", + "title": null, + "sort": [ + "Singapore", + "Cairo", + "Moscow", + "Seattle" + ], + "axis": { + "domain": false, + "ticks": false, + "grid": false, + "labelPadding": 6, + "labelFontSize": 9, + "labelColor": "#121317" + } + }, + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "title": "°C", + "scale": { + "domainMid": 0, + "range": [ + "#006ba2", + "#7ba7b8", + "#e9e5dc", + "#c8967a", + "#a1655a" + ] + }, + "legend": { + "orient": "top", + "direction": "horizontal", + "gradientLength": 140, + "gradientThickness": 7, + "titleOrient": "left", + "offset": 4, + "format": ".0f", + "labelFontSize": 9, + "labelColor": "#54585a", + "titleFontSize": 9, + "titleColor": "#54585a", + "padding": 0 + } + } + } + } + ], + "spacing": 8, + "resolve": { + "legend": { + "color": "independent" + } + } +} diff --git a/site/src/playground/theme-lab-assets/temp-heatmap.flint.json b/site/src/playground/theme-lab-assets/temp-heatmap.flint.json new file mode 100644 index 00000000..4b4a700b --- /dev/null +++ b/site/src/playground/theme-lab-assets/temp-heatmap.flint.json @@ -0,0 +1,318 @@ +{ + "mark": "rect", + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ] + }, + "y": { + "field": "City", + "type": "nominal", + "sort": null + }, + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "scale": { + "scheme": "blueorange", + "domainMid": 0, + "domain": [ + -29, + 29 + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "width": { + "step": 32 + }, + "height": { + "step": 32 + }, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ] + }, + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/temp-heatmap.mckinsey.json b/site/src/playground/theme-lab-assets/temp-heatmap.mckinsey.json new file mode 100644 index 00000000..a3758445 --- /dev/null +++ b/site/src/playground/theme-lab-assets/temp-heatmap.mckinsey.json @@ -0,0 +1,378 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "mckinsey", + "__design__": [ + "The house has no warm ink, so the diverging ramp is dropped for a single-hue navy wash. Below-freezing stops being a colour state and becomes a number like any other.", + "Every cell prints its value. Once the numbers are on the page the colour is doing ordering work only, and it is pushed back to a pale wash so the digits sit clearly on top.", + "Key deleted. A gradient bar next to a grid of printed numbers restates what the numbers already say.", + "Both axes stripped of domain, ticks and titles; city names left-aligned in navy, months in a lighter grey above the grid.", + "Numbers reverse out to white on the four darkest steps and stay navy elsewhere — the switch is on the value, not on the row.", + "Generous 26px cells with a 0.8px white gutter — the grid is spaced to be read as a table, which is what it now is." + ], + "background": "#ffffff", + "padding": { + "left": 10, + "top": 8, + "right": 12, + "bottom": 8 + }, + "width": 312, + "height": 104, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ], + "anchor": "start", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14, + "fontWeight": "bold", + "color": "#051c2c", + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#5a6872", + "offset": 10 + }, + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "title": null, + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": { + "domain": false, + "ticks": false, + "grid": false, + "labelAngle": 0, + "labelPadding": 5, + "labelFontSize": 10, + "labelColor": "#8a969d", + "orient": "top" + } + }, + "y": { + "field": "City", + "type": "nominal", + "title": null, + "sort": [ + "Singapore", + "Cairo", + "Moscow", + "Seattle" + ], + "axis": { + "domain": false, + "ticks": false, + "grid": false, + "labelPadding": 8, + "labelFontSize": 11, + "labelColor": "#051c2c" + } + } + }, + "layer": [ + { + "mark": { + "type": "rect", + "stroke": "#ffffff", + "strokeWidth": 0.8 + }, + "encoding": { + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "title": null, + "scale": { + "range": [ + "#eef3f8", + "#cfdcea", + "#9db8d2", + "#5b82ab", + "#051c2c" + ] + }, + "legend": null + } + } + }, + { + "mark": { + "type": "text", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600 + }, + "encoding": { + "text": { + "field": "Temp (°C)", + "type": "quantitative", + "format": ".0f" + }, + "color": { + "condition": { + "test": "datum['Temp (°C)'] >= 19", + "value": "#ffffff" + }, + "value": "#051c2c" + } + } + } + ] +} diff --git a/site/src/playground/theme-lab-assets/temp-heatmap.nature.json b/site/src/playground/theme-lab-assets/temp-heatmap.nature.json new file mode 100644 index 00000000..49fd7607 --- /dev/null +++ b/site/src/playground/theme-lab-assets/temp-heatmap.nature.json @@ -0,0 +1,387 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nature", + "__design__": [ + "Ramp replaced with the Okabe-Ito diverging pair — #0072B2 to #D55E00 through white — which stays separable under deuteranopia and protanopia. A red-blue ramp does not.", + "Colour bar moved to the right as a vertical gradient with outward ticks and its own title carrying the unit, so the figure states its scale the way every other axis in the journal does.", + "Both axes keep their black domain line and outward ticks; the L-shaped spine is the same rule applied here as on every other panel.", + "Axis titles restored and named with units — a journal figure must be readable out of its caption.", + "Type dropped to 8.5pt Arial in pure black throughout, sized for an 89mm single column.", + "Cells left unstroked. The white gutter other houses use would be read as data on a diverging ramp whose midpoint is also white." + ], + "background": "#ffffff", + "padding": { + "left": 10, + "top": 8, + "right": 10, + "bottom": 8 + }, + "width": 252, + "height": 98, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ], + "anchor": "start", + "font": "Arial, Helvetica, sans-serif", + "fontSize": 11.5, + "fontWeight": "bold", + "color": "#000000", + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 9, + "subtitleColor": "#000000", + "offset": 8 + }, + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + }, + "mark": { + "type": "rect" + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "title": "Month", + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": { + "domain": true, + "domainColor": "#000000", + "domainWidth": 0.8, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 0.8, + "tickSize": 4, + "grid": false, + "labelAngle": 0, + "labelPadding": 2, + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#000000", + "titleFont": "Arial, Helvetica, sans-serif", + "titleFontSize": 8.5, + "titleColor": "#000000", + "titlePadding": 4 + } + }, + "y": { + "field": "City", + "type": "nominal", + "title": "City", + "sort": [ + "Singapore", + "Cairo", + "Moscow", + "Seattle" + ], + "axis": { + "domain": true, + "domainColor": "#000000", + "domainWidth": 0.8, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 0.8, + "tickSize": 4, + "grid": false, + "labelPadding": 2, + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#000000", + "titleFont": "Arial, Helvetica, sans-serif", + "titleFontSize": 8.5, + "titleColor": "#000000", + "titlePadding": 4 + } + }, + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "title": "Temperature (°C)", + "scale": { + "domainMid": 0, + "range": [ + "#0072b2", + "#83b9db", + "#ffffff", + "#eba06a", + "#d55e00" + ] + }, + "legend": { + "orient": "right", + "direction": "vertical", + "gradientLength": 90, + "gradientThickness": 8, + "titleOrient": "left", + "titleAlign": "center", + "titleAnchor": "middle", + "offset": 8, + "format": ".0f", + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#000000", + "titleFont": "Arial, Helvetica, sans-serif", + "titleFontSize": 8.5, + "titleColor": "#000000", + "tickCount": 5, + "gradientStrokeColor": "#000000", + "gradientStrokeWidth": 0.5 + } + } + } +} diff --git a/site/src/playground/theme-lab-assets/temp-heatmap.nyt.json b/site/src/playground/theme-lab-assets/temp-heatmap.nyt.json new file mode 100644 index 00000000..30c17623 --- /dev/null +++ b/site/src/playground/theme-lab-assets/temp-heatmap.nyt.json @@ -0,0 +1,397 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nyt", + "__design__": [ + "A continuous ramp cannot be direct-labelled, so the house's preferred device is unavailable and the legend it would normally displace survives - a short horizontal gradient above the plot, ticked at the two ends and at freezing.", + "Ramp rebuilt from the two house inks already used for signed measures: #2F6B9A for cold, #C2352B for warm, meeting at a warm paper grey at 0°C. Freezing is the pivot, so it gets the neutral.", + "Every cell prints its number. The house prints values wherever they fit, and a 23px cell fits two digits — the shade orders the grid, the number answers it.", + "Label ink reverses to white only on the four darkest steps at either end of the ramp. The switch is on distance from the midpoint, not on the sign of the value — a −1°C cell is nearly white and needs black type.", + "Serif headline flush left, axis domains and ticks removed, month and city labels in Helvetica — the grid itself is the only structure the chart needs.", + "1px white gutters between cells so the grid reads as cells rather than as a continuous field." + ], + "background": "#ffffff", + "padding": { + "left": 10, + "top": 8, + "right": 12, + "bottom": 8 + }, + "width": 288, + "height": 112, + "title": { + "text": "Average monthly temperature", + "subtitle": [ + "°C, climate normals, four cities" + ], + "anchor": "start", + "font": "Georgia, 'Times New Roman', serif", + "fontSize": 15, + "fontWeight": "bold", + "color": "#121212", + "subtitleFont": "Georgia, 'Times New Roman', serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#6b6b6b", + "offset": 10 + }, + "data": { + "values": [ + { + "City": "Singapore", + "Month": "Jan", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Feb", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Mar", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Apr", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "May", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Singapore", + "Month": "Jul", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Aug", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Oct", + "Temp (°C)": 27 + }, + { + "City": "Singapore", + "Month": "Nov", + "Temp (°C)": 26 + }, + { + "City": "Singapore", + "Month": "Dec", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jan", + "Temp (°C)": 14 + }, + { + "City": "Cairo", + "Month": "Feb", + "Temp (°C)": 15 + }, + { + "City": "Cairo", + "Month": "Mar", + "Temp (°C)": 18 + }, + { + "City": "Cairo", + "Month": "Apr", + "Temp (°C)": 22 + }, + { + "City": "Cairo", + "Month": "May", + "Temp (°C)": 26 + }, + { + "City": "Cairo", + "Month": "Jun", + "Temp (°C)": 28 + }, + { + "City": "Cairo", + "Month": "Jul", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Aug", + "Temp (°C)": 29 + }, + { + "City": "Cairo", + "Month": "Sep", + "Temp (°C)": 27 + }, + { + "City": "Cairo", + "Month": "Oct", + "Temp (°C)": 24 + }, + { + "City": "Cairo", + "Month": "Nov", + "Temp (°C)": 20 + }, + { + "City": "Cairo", + "Month": "Dec", + "Temp (°C)": 16 + }, + { + "City": "Moscow", + "Month": "Jan", + "Temp (°C)": -9 + }, + { + "City": "Moscow", + "Month": "Feb", + "Temp (°C)": -7 + }, + { + "City": "Moscow", + "Month": "Mar", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Apr", + "Temp (°C)": 7 + }, + { + "City": "Moscow", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Moscow", + "Month": "Jun", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Moscow", + "Month": "Aug", + "Temp (°C)": 17 + }, + { + "City": "Moscow", + "Month": "Sep", + "Temp (°C)": 11 + }, + { + "City": "Moscow", + "Month": "Oct", + "Temp (°C)": 5 + }, + { + "City": "Moscow", + "Month": "Nov", + "Temp (°C)": -1 + }, + { + "City": "Moscow", + "Month": "Dec", + "Temp (°C)": -6 + }, + { + "City": "Seattle", + "Month": "Jan", + "Temp (°C)": 5 + }, + { + "City": "Seattle", + "Month": "Feb", + "Temp (°C)": 6 + }, + { + "City": "Seattle", + "Month": "Mar", + "Temp (°C)": 8 + }, + { + "City": "Seattle", + "Month": "Apr", + "Temp (°C)": 10 + }, + { + "City": "Seattle", + "Month": "May", + "Temp (°C)": 13 + }, + { + "City": "Seattle", + "Month": "Jun", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Jul", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Aug", + "Temp (°C)": 19 + }, + { + "City": "Seattle", + "Month": "Sep", + "Temp (°C)": 16 + }, + { + "City": "Seattle", + "Month": "Oct", + "Temp (°C)": 11 + }, + { + "City": "Seattle", + "Month": "Nov", + "Temp (°C)": 7 + }, + { + "City": "Seattle", + "Month": "Dec", + "Temp (°C)": 4 + } + ] + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "title": null, + "sort": [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec" + ], + "axis": { + "domain": false, + "ticks": false, + "grid": false, + "labelAngle": 0, + "labelPadding": 4, + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#121212" + } + }, + "y": { + "field": "City", + "type": "nominal", + "title": null, + "sort": [ + "Singapore", + "Cairo", + "Moscow", + "Seattle" + ], + "axis": { + "domain": false, + "ticks": false, + "grid": false, + "labelPadding": 6, + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#121212" + } + } + }, + "layer": [ + { + "mark": { + "type": "rect", + "stroke": "#ffffff", + "strokeWidth": 1 + }, + "encoding": { + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "title": null, + "scale": { + "domainMid": 0, + "range": [ + "#2f6b9a", + "#8fb4cc", + "#efece5", + "#dd9a86", + "#c2352b" + ] + }, + "legend": { + "orient": "top", + "direction": "horizontal", + "gradientLength": 150, + "gradientThickness": 7, + "titleOrient": "left", + "offset": 6, + "format": ".0f", + "labelFont": "Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#6b6b6b", + "padding": 0, + "values": [ + -9, + 0, + 29 + ], + "labelExpr": "datum.label + '°'" + } + } + } + }, + { + "mark": { + "type": "text", + "font": "Helvetica, Arial, sans-serif", + "fontSize": 8.5 + }, + "encoding": { + "text": { + "field": "Temp (°C)", + "type": "quantitative", + "format": ".0f" + }, + "color": { + "condition": { + "test": "datum['Temp (°C)'] > 22 || datum['Temp (°C)'] <= -6", + "value": "#ffffff" + }, + "value": "#121212" + } + } + } + ] +} diff --git a/site/src/playground/theme-lab-assets/temp-heatmap.powerbi.json b/site/src/playground/theme-lab-assets/temp-heatmap.powerbi.json new file mode 100644 index 00000000..d602d831 --- /dev/null +++ b/site/src/playground/theme-lab-assets/temp-heatmap.powerbi.json @@ -0,0 +1,165 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "powerbi", + "__design__": [ + "Whole spec re-based onto a dark card surface (#1B1A19). Every ink value had to be re-derived — labels #C8C6C4, title #F3F2F1, hairlines #3B3A39 — because Flint's defaults assume white paper.", + "Colour ramp replaced by an explicit diverging scale pinned at domainMid 0, so 'below freezing' is a readable state and not just a darker blue.", + "Legend moved to the bottom as a horizontal gradient with a fixed 90px length — dashboard tiles need the legend in the same place on every visual, at a size that does not reflow when the data changes.", + "Data labels layered on each cell with a conditional colour (dark ink on light cells, light ink on dark cells) — the standard product answer to 'I need the number, not just the shade'.", + "Category axes stripped of domain, ticks and titles; month labels shortened to 10pt Segoe UI so 12 columns fit in a tile without rotating.", + "Cell gaps introduced via a 1px stroke in the surface colour, which reads as a grid on dark backgrounds where gridlines would not.", + "Explicit month sort added — the default nominal ordering is data-order, which is stable here but not guaranteed after a refresh." + ], + "background": "#1b1a19", + "padding": { "left": 10, "top": 8, "right": 12, "bottom": 8 }, + "width": 288, + "height": 130, + "title": { + "text": "Average monthly temperature", + "subtitle": ["°C, climate normals, four cities"] + }, + "data": { + "values": [ + { "City": "Singapore", "Month": "Jan", "Temp (°C)": 26 }, + { "City": "Singapore", "Month": "Feb", "Temp (°C)": 27 }, + { "City": "Singapore", "Month": "Mar", "Temp (°C)": 28 }, + { "City": "Singapore", "Month": "Apr", "Temp (°C)": 28 }, + { "City": "Singapore", "Month": "May", "Temp (°C)": 28 }, + { "City": "Singapore", "Month": "Jun", "Temp (°C)": 28 }, + { "City": "Singapore", "Month": "Jul", "Temp (°C)": 27 }, + { "City": "Singapore", "Month": "Aug", "Temp (°C)": 27 }, + { "City": "Singapore", "Month": "Sep", "Temp (°C)": 27 }, + { "City": "Singapore", "Month": "Oct", "Temp (°C)": 27 }, + { "City": "Singapore", "Month": "Nov", "Temp (°C)": 26 }, + { "City": "Singapore", "Month": "Dec", "Temp (°C)": 26 }, + { "City": "Cairo", "Month": "Jan", "Temp (°C)": 14 }, + { "City": "Cairo", "Month": "Feb", "Temp (°C)": 15 }, + { "City": "Cairo", "Month": "Mar", "Temp (°C)": 18 }, + { "City": "Cairo", "Month": "Apr", "Temp (°C)": 22 }, + { "City": "Cairo", "Month": "May", "Temp (°C)": 26 }, + { "City": "Cairo", "Month": "Jun", "Temp (°C)": 28 }, + { "City": "Cairo", "Month": "Jul", "Temp (°C)": 29 }, + { "City": "Cairo", "Month": "Aug", "Temp (°C)": 29 }, + { "City": "Cairo", "Month": "Sep", "Temp (°C)": 27 }, + { "City": "Cairo", "Month": "Oct", "Temp (°C)": 24 }, + { "City": "Cairo", "Month": "Nov", "Temp (°C)": 20 }, + { "City": "Cairo", "Month": "Dec", "Temp (°C)": 16 }, + { "City": "Moscow", "Month": "Jan", "Temp (°C)": -9 }, + { "City": "Moscow", "Month": "Feb", "Temp (°C)": -7 }, + { "City": "Moscow", "Month": "Mar", "Temp (°C)": -1 }, + { "City": "Moscow", "Month": "Apr", "Temp (°C)": 7 }, + { "City": "Moscow", "Month": "May", "Temp (°C)": 13 }, + { "City": "Moscow", "Month": "Jun", "Temp (°C)": 17 }, + { "City": "Moscow", "Month": "Jul", "Temp (°C)": 19 }, + { "City": "Moscow", "Month": "Aug", "Temp (°C)": 17 }, + { "City": "Moscow", "Month": "Sep", "Temp (°C)": 11 }, + { "City": "Moscow", "Month": "Oct", "Temp (°C)": 5 }, + { "City": "Moscow", "Month": "Nov", "Temp (°C)": -1 }, + { "City": "Moscow", "Month": "Dec", "Temp (°C)": -6 }, + { "City": "Seattle", "Month": "Jan", "Temp (°C)": 5 }, + { "City": "Seattle", "Month": "Feb", "Temp (°C)": 6 }, + { "City": "Seattle", "Month": "Mar", "Temp (°C)": 8 }, + { "City": "Seattle", "Month": "Apr", "Temp (°C)": 10 }, + { "City": "Seattle", "Month": "May", "Temp (°C)": 13 }, + { "City": "Seattle", "Month": "Jun", "Temp (°C)": 16 }, + { "City": "Seattle", "Month": "Jul", "Temp (°C)": 19 }, + { "City": "Seattle", "Month": "Aug", "Temp (°C)": 19 }, + { "City": "Seattle", "Month": "Sep", "Temp (°C)": 16 }, + { "City": "Seattle", "Month": "Oct", "Temp (°C)": 11 }, + { "City": "Seattle", "Month": "Nov", "Temp (°C)": 7 }, + { "City": "Seattle", "Month": "Dec", "Temp (°C)": 4 } + ] + }, + "encoding": { + "x": { + "field": "Month", + "type": "nominal", + "title": null, + "sort": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + "axis": { "domain": false, "ticks": false, "grid": false, "labelAngle": 0, "labelPadding": 4 } + }, + "y": { + "field": "City", + "type": "nominal", + "title": null, + "sort": ["Singapore", "Cairo", "Moscow", "Seattle"], + "axis": { "domain": false, "ticks": false, "grid": false, "labelPadding": 6 } + } + }, + "layer": [ + { + "mark": { "type": "rect", "stroke": "#1b1a19", "strokeWidth": 1 }, + "encoding": { + "color": { + "field": "Temp (°C)", + "type": "quantitative", + "title": "°C", + "scale": { + "domainMid": 0, + "range": ["#118dff", "#5aa9f0", "#4a4948", "#e08a4a", "#d64550"] + }, + "legend": { + "orient": "bottom", + "direction": "horizontal", + "gradientLength": 90, + "gradientThickness": 8, + "titleOrient": "left", + "offset": 6, + "format": ".0f" + } + } + } + }, + { + "mark": { + "type": "text", + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "fontSize": 8.5 + }, + "encoding": { + "text": { "field": "Temp (°C)", "type": "quantitative", "format": ".0f" }, + "color": { + "condition": { "test": "datum['Temp (°C)'] > 4 && datum['Temp (°C)'] < 22", "value": "#f3f2f1" }, + "value": "#12100f" + } + } + } + ], + "config": { + "background": "#1b1a19", + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, -apple-system, sans-serif", + "title": { + "font": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "fontSize": 12.5, + "fontWeight": 600, + "color": "#f3f2f1", + "anchor": "start", + "offset": 10, + "subtitleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "subtitleFontSize": 10, + "subtitleColor": "#a19f9d", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "labelFontSize": 9.5, + "labelColor": "#c8c6c4", + "titleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "titleFontSize": 9.5, + "titleColor": "#a19f9d", + "domain": false, + "ticks": false, + "grid": false + }, + "legend": { + "labelFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "labelFontSize": 9, + "labelColor": "#c8c6c4", + "titleFont": "'Segoe UI', 'Segoe UI Variable', system-ui, sans-serif", + "titleFontSize": 9, + "titleColor": "#a19f9d", + "titleFontWeight": 400 + } + } +} diff --git a/site/src/playground/theme-lab-assets/temp-uncertainty.flint.json b/site/src/playground/theme-lab-assets/temp-uncertainty.flint.json new file mode 100644 index 00000000..cee15d8f --- /dev/null +++ b/site/src/playground/theme-lab-assets/temp-uncertainty.flint.json @@ -0,0 +1,121 @@ +{ + "mark": { + "type": "area", + "opacity": 0.5, + "line": { + "strokeWidth": 1 + } + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Lower (°C)", + "type": "quantitative", + "scale": { + "zero": false, + "nice": true + }, + "axis": { + "format": ",.12~g" + } + }, + "y2": { + "field": "Upper (°C)" + } + }, + "config": { + "view": { + "continuousWidth": 308, + "continuousHeight": 254 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 25 + } + }, + "title": { + "text": "The record gets more certain as it gets warmer", + "subtitle": [ + "Global mean temperature anomaly against 1961–1990, with 95% confidence interval, °C" + ] + }, + "data": { + "values": [ + { + "Year": "1850", + "Anomaly (°C)": -0.42, + "Lower (°C)": -0.62, + "Upper (°C)": -0.22 + }, + { + "Year": "1870", + "Anomaly (°C)": -0.36, + "Lower (°C)": -0.53, + "Upper (°C)": -0.19 + }, + { + "Year": "1890", + "Anomaly (°C)": -0.42, + "Lower (°C)": -0.56, + "Upper (°C)": -0.28 + }, + { + "Year": "1910", + "Anomaly (°C)": -0.44, + "Lower (°C)": -0.55, + "Upper (°C)": -0.33 + }, + { + "Year": "1930", + "Anomaly (°C)": -0.16, + "Lower (°C)": -0.25, + "Upper (°C)": -0.07 + }, + { + "Year": "1950", + "Anomaly (°C)": -0.17, + "Lower (°C)": -0.24, + "Upper (°C)": -0.1 + }, + { + "Year": "1970", + "Anomaly (°C)": -0.08, + "Lower (°C)": -0.14, + "Upper (°C)": -0.02 + }, + { + "Year": "1990", + "Anomaly (°C)": 0.25, + "Lower (°C)": 0.2, + "Upper (°C)": 0.3 + }, + { + "Year": "2010", + "Anomaly (°C)": 0.56, + "Lower (°C)": 0.52, + "Upper (°C)": 0.6 + }, + { + "Year": "2023", + "Anomaly (°C)": 1.11, + "Lower (°C)": 1.07, + "Upper (°C)": 1.15 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/temp-uncertainty.nature.json b/site/src/playground/theme-lab-assets/temp-uncertainty.nature.json new file mode 100644 index 00000000..9b9abb34 --- /dev/null +++ b/site/src/playground/theme-lab-assets/temp-uncertainty.nature.json @@ -0,0 +1,100 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nature", + "__design__": [ + "The central estimate drawn inside its own interval. Flint's Range Area template has channels for y and y2 and none for the value the interval is about, so the anomaly — the number every sentence about this dataset quotes — is in the table and not on the chart. A confidence band with no estimate in it states the uncertainty and withholds the result.", + "The band's edges unstroked. Flint outlines the area, which draws a 95% interval as though it had a hard boundary at exactly 1.96σ; the whole content of the interval is that its edge is soft. Fill only, no line.", + "Axis title rewritten from 'Lower (°C), Upper (°C)' to the quantity being measured. Flint concatenates the names of the two encoded fields, which names the geometry rather than the variable and implies the panel shows two different things.", + "Zero pinned as a labelled tick. The series is an anomaly against the 1961–1990 mean, so zero is not an arbitrary round number — it is the definition of the baseline, and a reader cannot interpret a value of −0.42 without it.", + "Okabe–Ito blue at two weights: the band at 25% and the estimate line at full strength. The pair survives greyscale conversion and every form of colour blindness, because the distinction carried is lightness, not hue.", + "Column-width geometry — 89 mm at 8.5pt, hairline L-spines, outward ticks, no gridlines, and units in the axis title as the caption cannot supply them." + ], + "background": "#ffffff", + "padding": { "left": 4, "top": 4, "right": 8, "bottom": 4 }, + "width": 240, + "height": 160, + "title": { + "text": "The record gets more certain as it gets warmer", + "subtitle": ["Global mean temperature anomaly against 1961–1990, with 95% confidence interval, °C"] + }, + "data": { + "values": [ + { "Year": "1850", "Anomaly (°C)": -0.42, "Lower (°C)": -0.62, "Upper (°C)": -0.22 }, + { "Year": "1870", "Anomaly (°C)": -0.36, "Lower (°C)": -0.53, "Upper (°C)": -0.19 }, + { "Year": "1890", "Anomaly (°C)": -0.42, "Lower (°C)": -0.56, "Upper (°C)": -0.28 }, + { "Year": "1910", "Anomaly (°C)": -0.44, "Lower (°C)": -0.55, "Upper (°C)": -0.33 }, + { "Year": "1930", "Anomaly (°C)": -0.16, "Lower (°C)": -0.25, "Upper (°C)": -0.07 }, + { "Year": "1950", "Anomaly (°C)": -0.17, "Lower (°C)": -0.24, "Upper (°C)": -0.1 }, + { "Year": "1970", "Anomaly (°C)": -0.08, "Lower (°C)": -0.14, "Upper (°C)": -0.02 }, + { "Year": "1990", "Anomaly (°C)": 0.25, "Lower (°C)": 0.2, "Upper (°C)": 0.3 }, + { "Year": "2010", "Anomaly (°C)": 0.56, "Lower (°C)": 0.52, "Upper (°C)": 0.6 }, + { "Year": "2023", "Anomaly (°C)": 1.11, "Lower (°C)": 1.07, "Upper (°C)": 1.15 } + ] + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": "Year", + "axis": { "format": "%Y", "tickCount": 6, "labelFlush": true } + } + }, + "layer": [ + { + "mark": { "type": "area", "color": "#0072b2", "opacity": 0.25, "line": false }, + "encoding": { + "y": { + "field": "Lower (°C)", + "type": "quantitative", + "title": "Temperature anomaly (°C)", + "scale": { "zero": false, "nice": false, "domain": [-0.8, 1.3] }, + "axis": { "values": [-0.5, 0, 0.5, 1], "format": ".1f" } + }, + "y2": { "field": "Upper (°C)" } + } + }, + { + "mark": { "type": "line", "color": "#0072b2", "strokeWidth": 1.3 }, + "encoding": { + "y": { "field": "Anomaly (°C)", "type": "quantitative" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "Arial, Helvetica, sans-serif", + "title": { + "font": "Arial, Helvetica, sans-serif", + "fontSize": 12, + "fontWeight": 700, + "color": "#000000", + "anchor": "start", + "offset": 6, + "subtitleFont": "Arial, Helvetica, sans-serif", + "subtitleFontSize": 8, + "subtitleFontStyle": "italic", + "subtitleColor": "#3c3c3c", + "subtitlePadding": 5 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "Arial, Helvetica, sans-serif", + "labelFontSize": 8.5, + "labelColor": "#000000", + "labelPadding": 2, + "titleFont": "Arial, Helvetica, sans-serif", + "titleFontSize": 9, + "titleFontWeight": 400, + "titleColor": "#000000", + "titlePadding": 4, + "grid": false, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickWidth": 1, + "tickSize": 3.5 + } + } +} diff --git a/site/src/playground/theme-lab-assets/titanic.flint.json b/site/src/playground/theme-lab-assets/titanic.flint.json new file mode 100644 index 00000000..adfb4396 --- /dev/null +++ b/site/src/playground/theme-lab-assets/titanic.flint.json @@ -0,0 +1,99 @@ +{ + "mark": "bar", + "encoding": { + "x": { + "field": "Class", + "type": "nominal", + "sort": null + }, + "y": { + "field": "Survival (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "color": { + "field": "Sex", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10" + } + }, + "xOffset": { + "field": "Sex", + "type": "nominal", + "sort": null + } + }, + "config": { + "view": { + "continuousWidth": 340, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11, + "labelAngle": 0, + "labelAlign": "center", + "labelBaseline": "top" + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "width": { + "step": 46, + "for": "position" + }, + "title": { + "text": "Titanic survival rate by class and sex" + }, + "data": { + "values": [ + { + "Class": "1st", + "Sex": "Female", + "Survival (%)": 97 + }, + { + "Class": "1st", + "Sex": "Male", + "Survival (%)": 34 + }, + { + "Class": "2nd", + "Sex": "Female", + "Survival (%)": 89 + }, + { + "Class": "2nd", + "Sex": "Male", + "Survival (%)": 15 + }, + { + "Class": "3rd", + "Sex": "Female", + "Survival (%)": 49 + }, + { + "Class": "3rd", + "Sex": "Male", + "Survival (%)": 15 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/trust-likert.datawrapper.json b/site/src/playground/theme-lab-assets/trust-likert.datawrapper.json new file mode 100644 index 00000000..336164f5 --- /dev/null +++ b/site/src/playground/theme-lab-assets/trust-likert.datawrapper.json @@ -0,0 +1,149 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "datawrapper", + "__design__": [ + "Response scale re-coloured as a sequential ramp from confident blue to muted red (#18A1CD → #9FCBDD → #E0B48C → #C04A4A) instead of tableau10's four unrelated hues. On a Likert scale the categories are ordered, so the colour has to be ordered too.", + "Legend moved to the top as a single horizontal row of large squares, reading left to right in the same order as the stack — the key and the bar now share one grammar.", + "Value axis deleted. Every row totals 100, so one 0–100 axis is repeated five times and measures nothing the reader is asking about; the numbers move onto the segments instead.", + "Segment labels added from the existing Share field, blanked by a condition wherever the segment is too narrow (\u226412%) to hold two digits, with conditional ink for contrast. The condition has to blank the text rather than filter the rows: dropping rows would restack that layer and slide every label off its segment.", + "A numeric rank derived from the response order drives an explicit order channel shared by both layers. The bar layer would sort correctly from its colour domain alone, but the label layer overrides colour to get conditional ink, which leaves it stacking by ascending value; one shared rank keeps the two layers in register.", + "11pt type floor throughout and institution labels left-aligned in near-black #333 — the chart has to survive being embedded at 320px on a phone.", + "Row height raised to a 30px step and bar height cut to 0.66 of the band, which is what makes five long institution names readable without truncation." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 14, "bottom": 8 }, + "width": 300, + "title": { + "text": "Confidence in US institutions", + "subtitle": ["% of adults expressing each level of confidence"] + }, + "height": { "step": 30 }, + "data": { + "values": [ + { "Institution": "Scientists", "Response": "A great deal", "Share (%)": 39 }, + { "Institution": "Scientists", "Response": "Some", "Share (%)": 45 }, + { "Institution": "Scientists", "Response": "Not much", "Share (%)": 12 }, + { "Institution": "Scientists", "Response": "None at all", "Share (%)": 4 }, + { "Institution": "The military", "Response": "A great deal", "Share (%)": 32 }, + { "Institution": "The military", "Response": "Some", "Share (%)": 43 }, + { "Institution": "The military", "Response": "Not much", "Share (%)": 18 }, + { "Institution": "The military", "Response": "None at all", "Share (%)": 7 }, + { "Institution": "The police", "Response": "A great deal", "Share (%)": 26 }, + { "Institution": "The police", "Response": "Some", "Share (%)": 44 }, + { "Institution": "The police", "Response": "Not much", "Share (%)": 21 }, + { "Institution": "The police", "Response": "None at all", "Share (%)": 9 }, + { "Institution": "The press", "Response": "A great deal", "Share (%)": 11 }, + { "Institution": "The press", "Response": "Some", "Share (%)": 32 }, + { "Institution": "The press", "Response": "Not much", "Share (%)": 34 }, + { "Institution": "The press", "Response": "None at all", "Share (%)": 23 }, + { "Institution": "Congress", "Response": "A great deal", "Share (%)": 8 }, + { "Institution": "Congress", "Response": "Some", "Share (%)": 30 }, + { "Institution": "Congress", "Response": "Not much", "Share (%)": 38 }, + { "Institution": "Congress", "Response": "None at all", "Share (%)": 24 } + ] + }, + "transform": [ + { + "calculate": "indexof(['A great deal', 'Some', 'Not much', 'None at all'], datum.Response)", + "as": "__rank" + } + ], + "encoding": { + "x": { + "field": "Share (%)", + "type": "quantitative", + "stack": "center", + "title": null, + "axis": null + }, + "y": { + "field": "Institution", + "type": "nominal", + "sort": ["Scientists", "The military", "The police", "The press", "Congress"], + "title": null, + "axis": { "domain": false, "ticks": false, "grid": false, "labelPadding": 6 } + }, + "color": { + "field": "Response", + "type": "nominal", + "title": null, + "sort": ["A great deal", "Some", "Not much", "None at all"], + "scale": { + "domain": ["A great deal", "Some", "Not much", "None at all"], + "range": ["#18a1cd", "#9fcbdd", "#e0b48c", "#c04a4a"] + }, + "legend": { + "orient": "top", + "direction": "horizontal", + "symbolType": "square", + "symbolSize": 110, + "columnPadding": 10, + "offset": 4, + "padding": 0 + } + }, + "order": { "field": "__rank", "type": "quantitative" } + }, + "layer": [ + { "mark": { "type": "bar", "height": { "band": 0.66 } } }, + { + "mark": { + "type": "text", + "align": "center", + "baseline": "middle", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 600 + }, + "encoding": { + "x": { + "field": "Share (%)", + "type": "quantitative", + "stack": "center", + "bandPosition": 0.5 + }, + "text": { + "condition": { + "test": "datum['Share (%)'] > 12", + "field": "Share (%)", + "type": "quantitative", + "format": ".0f" + }, + "value": "" + }, + "color": { + "condition": { "test": "datum.Response === 'Some' || datum.Response === 'Not much'", "value": "#333333" }, + "value": "#ffffff" + } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 15, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 12, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11, + "subtitleColor": "#767676", + "subtitlePadding": 8 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 11, + "labelColor": "#333333" + }, + "legend": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 11, + "labelColor": "#333333", + "symbolStrokeWidth": 0 + } + } +} diff --git a/site/src/playground/theme-lab-assets/trust-likert.flint.json b/site/src/playground/theme-lab-assets/trust-likert.flint.json new file mode 100644 index 00000000..1f4150a3 --- /dev/null +++ b/site/src/playground/theme-lab-assets/trust-likert.flint.json @@ -0,0 +1,170 @@ +{ + "mark": "bar", + "encoding": { + "x": { + "field": "Share (%)", + "type": "quantitative", + "stack": "center", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + }, + "y": { + "field": "Institution", + "type": "nominal", + "sort": null + }, + "color": { + "field": "Response", + "type": "nominal", + "sort": null, + "scale": { + "scheme": "tableau10", + "domain": [ + "A great deal", + "Some", + "Not much", + "None at all" + ] + } + } + }, + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "height": { + "step": 23 + }, + "title": { + "text": "Confidence in US institutions", + "subtitle": [ + "% of adults expressing each level of confidence" + ] + }, + "data": { + "values": [ + { + "Institution": "Scientists", + "Response": "A great deal", + "Share (%)": 39 + }, + { + "Institution": "Scientists", + "Response": "Some", + "Share (%)": 45 + }, + { + "Institution": "Scientists", + "Response": "Not much", + "Share (%)": 12 + }, + { + "Institution": "Scientists", + "Response": "None at all", + "Share (%)": 4 + }, + { + "Institution": "The military", + "Response": "A great deal", + "Share (%)": 32 + }, + { + "Institution": "The military", + "Response": "Some", + "Share (%)": 43 + }, + { + "Institution": "The military", + "Response": "Not much", + "Share (%)": 18 + }, + { + "Institution": "The military", + "Response": "None at all", + "Share (%)": 7 + }, + { + "Institution": "The police", + "Response": "A great deal", + "Share (%)": 26 + }, + { + "Institution": "The police", + "Response": "Some", + "Share (%)": 44 + }, + { + "Institution": "The police", + "Response": "Not much", + "Share (%)": 21 + }, + { + "Institution": "The police", + "Response": "None at all", + "Share (%)": 9 + }, + { + "Institution": "The press", + "Response": "A great deal", + "Share (%)": 11 + }, + { + "Institution": "The press", + "Response": "Some", + "Share (%)": 32 + }, + { + "Institution": "The press", + "Response": "Not much", + "Share (%)": 34 + }, + { + "Institution": "The press", + "Response": "None at all", + "Share (%)": 23 + }, + { + "Institution": "Congress", + "Response": "A great deal", + "Share (%)": 8 + }, + { + "Institution": "Congress", + "Response": "Some", + "Share (%)": 30 + }, + { + "Institution": "Congress", + "Response": "Not much", + "Share (%)": 38 + }, + { + "Institution": "Congress", + "Response": "None at all", + "Share (%)": 24 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/us-pyramid.datawrapper.json b/site/src/playground/theme-lab-assets/us-pyramid.datawrapper.json new file mode 100644 index 00000000..0dbbf7fb --- /dev/null +++ b/site/src/playground/theme-lab-assets/us-pyramid.datawrapper.json @@ -0,0 +1,178 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "datawrapper", + "__design__": [ + "Flint's pyramid is upside down. It passes 'sort: null', so the age bands stay in source order and 0–14 lands at the top with 75+ at the bottom. Every population pyramid ever printed runs youngest at the base; Flint's reads as an inverted one, which is exactly the shape a reader interprets as a collapsing birth rate. The order is pinned here.", + "The two-panel hconcat collapsed into one plot with one axis. Flint mirrors by giving the left panel 'scale: {reverse: true}' and the right panel its own scale, so there are two independent rulers and the reader has to verify they match. Male counts are negated in a transform, and a single axis labels them with abs(), so the two halves are provably on the same scale.", + "'Male' and 'Female' stay what they were in Flint: titles for the two halves of the pyramid. They are set in the series colours and dropped to label weight, but they are headings for a region of the plot, not names attached to marks. They are pinned to the top of the plot area with their own two-row constant source, so they sit where a title sits and do not move if the data changes; the earlier draft derived them by filtering the data to the '75+' band, which made a title masquerade as a mark label.", + "Blue-for-boys, red-for-girls dropped. Datawrapper's #18A1CD and #E2A233 carry the split without importing a convention the data does not contain, and they stay distinguishable in greyscale and to the ~8% of men with a red–green deficiency.", + "Value labels added at every bar end. A pyramid is read for shape, but the 75+ band — 10 million men against 15 million women — is the one fact here that shape alone rounds away.", + "11pt type floor, dashed #DCDCDC vertical gridlines behind the bars, no frame, and a hairline footer row to close the block. The zero line is the same gridline as the others, promoted to near-black by a conditional gridColor rather than drawn as an extra layer.", + "Flint's 'opacity: 0.9' removed; it lightens the bars against the gridlines for no stated reason." + ], + "background": "#ffffff", + "padding": { "left": 8, "top": 6, "right": 14, "bottom": 8 }, + "title": { + "text": "A pyramid that is no longer a pyramid", + "subtitle": ["United States population by age and sex, 2020, millions"] + }, + "spacing": 8, + "vconcat": [ + { + "width": 290, + "height": 190, + "data": { + "values": [ + { "Age": "0–14", "Sex": "Male", "Population": 31 }, + { "Age": "0–14", "Sex": "Female", "Population": 30 }, + { "Age": "15–29", "Sex": "Male", "Population": 34 }, + { "Age": "15–29", "Sex": "Female", "Population": 32 }, + { "Age": "30–44", "Sex": "Male", "Population": 33 }, + { "Age": "30–44", "Sex": "Female", "Population": 33 }, + { "Age": "45–59", "Sex": "Male", "Population": 30 }, + { "Age": "45–59", "Sex": "Female", "Population": 31 }, + { "Age": "60–74", "Sex": "Male", "Population": 26 }, + { "Age": "60–74", "Sex": "Female", "Population": 28 }, + { "Age": "75+", "Sex": "Male", "Population": 10 }, + { "Age": "75+", "Sex": "Female", "Population": 15 } + ] + }, + "transform": [ + { "calculate": "datum.Sex === 'Male' ? -datum.Population : datum.Population", "as": "__signed" } + ], + "encoding": { + "x": { + "field": "__signed", + "type": "quantitative", + "stack": null, + "title": null, + "scale": { "nice": false, "domain": [-40, 40] }, + "axis": { + "values": [-30, -20, -10, 0, 10, 20, 30], + "labelExpr": "abs(datum.value)", + "grid": true, + "gridColor": { + "condition": { "test": "datum.value === 0", "value": "#333333" }, + "value": "#dcdcdc" + }, + "gridDash": { + "condition": { "test": "datum.value === 0", "value": [] }, + "value": [2, 2] + }, + "domain": false, + "ticks": false + } + }, + "y": { + "field": "Age", + "type": "nominal", + "title": null, + "sort": ["75+", "60–74", "45–59", "30–44", "15–29", "0–14"], + "scale": { "paddingInner": 0.3 }, + "axis": { "grid": false, "domain": false, "ticks": false, "labelPadding": 6 } + }, + "color": { + "field": "Sex", + "type": "nominal", + "scale": { "domain": ["Male", "Female"], "range": ["#18a1cd", "#e2a233"] }, + "legend": null + } + }, + "layer": [ + { "mark": { "type": "bar" } }, + { + "transform": [{ "filter": "datum.Sex === 'Male'" }], + "mark": { + "type": "text", + "align": "right", + "baseline": "middle", + "dx": -5, + "fontSize": 10.5 + }, + "encoding": { + "text": { "field": "Population", "type": "quantitative", "format": "d" }, + "color": { "value": "#333333" } + } + }, + { + "transform": [{ "filter": "datum.Sex === 'Female'" }], + "mark": { + "type": "text", + "align": "left", + "baseline": "middle", + "dx": 5, + "fontSize": 10.5 + }, + "encoding": { + "text": { "field": "Population", "type": "quantitative", "format": "d" }, + "color": { "value": "#333333" } + } + }, + { + "data": { + "values": [ + { "__half": "Male", "__halfX": -20 }, + { "__half": "Female", "__halfX": 20 } + ] + }, + "mark": { "type": "text", "baseline": "bottom", "dy": -8, "fontSize": 11, "fontWeight": 600 }, + "encoding": { + "x": { "field": "__halfX", "type": "quantitative" }, + "y": { "value": 0 }, + "text": { "field": "__half", "type": "nominal" }, + "color": { + "field": "__half", + "type": "nominal", + "scale": { "domain": ["Male", "Female"], "range": ["#18a1cd", "#e2a233"] }, + "legend": null + } + } + } + ] + }, + { + "data": { "values": [{}] }, + "mark": { "type": "rect", "fill": "#dcdcdc", "stroke": null }, + "width": 310, + "height": 1, + "view": { "stroke": null } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 14.5, + "fontWeight": 700, + "color": "#333333", + "anchor": "start", + "offset": 12, + "lineHeight": 18, + "subtitleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "subtitleFontSize": 11.5, + "subtitleColor": "#666666", + "subtitlePadding": 7, + "subtitleLineHeight": 15 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 11, + "labelColor": "#333333", + "labelPadding": 5, + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 10.5, + "titleFontWeight": 400, + "titleColor": "#767676", + "grid": false, + "domain": false, + "ticks": false + }, + "legend": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 11, + "labelColor": "#333333" + } + } +} diff --git a/site/src/playground/theme-lab-assets/us-pyramid.flint.json b/site/src/playground/theme-lab-assets/us-pyramid.flint.json new file mode 100644 index 00000000..17f34338 --- /dev/null +++ b/site/src/playground/theme-lab-assets/us-pyramid.flint.json @@ -0,0 +1,184 @@ +{ + "spacing": 0, + "resolve": { + "scale": { + "y": "shared" + } + }, + "hconcat": [ + { + "mark": "bar", + "encoding": { + "y": { + "field": "Age", + "type": "nominal", + "sort": null + }, + "x": { + "scale": { + "reverse": true, + "domain": [ + 0, + 34 + ] + }, + "stack": null, + "field": "Population", + "type": "quantitative" + }, + "opacity": { + "value": 0.9 + }, + "color": { + "value": "#4e79a7" + } + }, + "transform": [ + { + "filter": { + "field": "Sex", + "equal": "Male" + } + } + ], + "title": "Male", + "width": 209, + "height": 230 + }, + { + "mark": "bar", + "encoding": { + "y": { + "axis": null, + "field": "Age", + "type": "nominal", + "sort": null + }, + "x": { + "stack": null, + "field": "Population", + "type": "quantitative", + "scale": { + "domain": [ + 0, + 34 + ] + } + }, + "opacity": { + "value": 0.9 + }, + "color": { + "value": "#e15759" + } + }, + "transform": [ + { + "filter": { + "field": "Sex", + "equal": "Female" + } + } + ], + "title": "Female", + "width": 209, + "height": 230 + } + ], + "config": { + "view": { + "continuousWidth": 280, + "continuousHeight": 230, + "stroke": null + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 23 + } + }, + "height": { + "step": 23 + }, + "title": { + "text": "A pyramid that is no longer a pyramid", + "subtitle": [ + "United States population by age and sex, 2020, millions" + ] + }, + "data": { + "values": [ + { + "Age": "75+", + "Sex": "Male", + "Population": 10 + }, + { + "Age": "75+", + "Sex": "Female", + "Population": 15 + }, + { + "Age": "60–74", + "Sex": "Male", + "Population": 26 + }, + { + "Age": "60–74", + "Sex": "Female", + "Population": 28 + }, + { + "Age": "45–59", + "Sex": "Male", + "Population": 30 + }, + { + "Age": "45–59", + "Sex": "Female", + "Population": 31 + }, + { + "Age": "30–44", + "Sex": "Male", + "Population": 33 + }, + { + "Age": "30–44", + "Sex": "Female", + "Population": 33 + }, + { + "Age": "15–29", + "Sex": "Male", + "Population": 34 + }, + { + "Age": "15–29", + "Sex": "Female", + "Population": 32 + }, + { + "Age": "0–14", + "Sex": "Male", + "Population": 31 + }, + { + "Age": "0–14", + "Sex": "Female", + "Population": 30 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/us-unemployment.flint.json b/site/src/playground/theme-lab-assets/us-unemployment.flint.json new file mode 100644 index 00000000..f55602f2 --- /dev/null +++ b/site/src/playground/theme-lab-assets/us-unemployment.flint.json @@ -0,0 +1,147 @@ +{ + "mark": "line", + "encoding": { + "x": { + "field": "Year", + "type": "temporal" + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "scale": { + "zero": true + }, + "axis": { + "format": ",.12~g" + } + } + }, + "config": { + "view": { + "continuousWidth": 326, + "continuousHeight": 240 + }, + "axisX": { + "labelLimit": 100, + "labelFontSize": 10, + "titleFontSize": 11 + }, + "axisY": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "legend": { + "labelFontSize": 10, + "titleFontSize": 11 + }, + "facet": { + "spacing": 24 + } + }, + "title": { + "text": "American unemployment", + "subtitle": [ + "Annual rate, per cent of the labour force, 2000–2023" + ] + }, + "data": { + "values": [ + { + "Year": "2000", + "Unemployment (%)": 4 + }, + { + "Year": "2001", + "Unemployment (%)": 4.7 + }, + { + "Year": "2002", + "Unemployment (%)": 5.8 + }, + { + "Year": "2003", + "Unemployment (%)": 6 + }, + { + "Year": "2004", + "Unemployment (%)": 5.5 + }, + { + "Year": "2005", + "Unemployment (%)": 5.1 + }, + { + "Year": "2006", + "Unemployment (%)": 4.6 + }, + { + "Year": "2007", + "Unemployment (%)": 4.6 + }, + { + "Year": "2008", + "Unemployment (%)": 5.8 + }, + { + "Year": "2009", + "Unemployment (%)": 9.3 + }, + { + "Year": "2010", + "Unemployment (%)": 9.6 + }, + { + "Year": "2011", + "Unemployment (%)": 8.9 + }, + { + "Year": "2012", + "Unemployment (%)": 8.1 + }, + { + "Year": "2013", + "Unemployment (%)": 7.4 + }, + { + "Year": "2014", + "Unemployment (%)": 6.2 + }, + { + "Year": "2015", + "Unemployment (%)": 5.3 + }, + { + "Year": "2016", + "Unemployment (%)": 4.9 + }, + { + "Year": "2017", + "Unemployment (%)": 4.4 + }, + { + "Year": "2018", + "Unemployment (%)": 3.9 + }, + { + "Year": "2019", + "Unemployment (%)": 3.7 + }, + { + "Year": "2020", + "Unemployment (%)": 8.1 + }, + { + "Year": "2021", + "Unemployment (%)": 5.3 + }, + { + "Year": "2022", + "Unemployment (%)": 3.6 + }, + { + "Year": "2023", + "Unemployment (%)": 3.6 + } + ] + } +} diff --git a/site/src/playground/theme-lab-assets/us-unemployment.nyt.json b/site/src/playground/theme-lab-assets/us-unemployment.nyt.json new file mode 100644 index 00000000..c554c31d --- /dev/null +++ b/site/src/playground/theme-lab-assets/us-unemployment.nyt.json @@ -0,0 +1,166 @@ +{ + "$schema": "https://vega-lite.github.io/schema/vega-lite/v5.json", + "__theme__": "nyt", + "__design__": [ + "Y scale forced through zero. A rate that a reader will compare against 'none' has a real zero, and the truncated default made the 2020 spike look like a doubling of an already-high number rather than a jump from 3.7% to 8.1%.", + "Line taken to 2.4px in near-black with a round cap and join, so the pandemic spike keeps its corner instead of being rounded off by a hairline.", + "The first and last points of the series marked and labelled with their own numbers, taken from the same Unemployment field the line already draws. The endpoints are where a reader starts and stops, and they are the two values a line chart makes hardest to recover — everything between them is read as shape.", + "Axis reduced to a horizontal grid of #E4E4E4 hairlines with no y domain and no ticks; the x axis keeps a solid black baseline, which is the paper's standard time-series frame.", + "Y title flattened to the unit '%' sitting horizontally above the top gridline instead of a rotated 'Unemployment (%)'.", + "Tick labels thinned to four decades plus the final year, and formatted as two-digit years after the first, which is how the paper labels a long series.", + "Georgia for the headline and deck, Helvetica for the numbers; the deck states the unit and period so the axis does not have to." + ], + "background": "#ffffff", + "padding": { "left": 4, "top": 4, "right": 10, "bottom": 6 }, + "width": 320, + "height": 210, + "title": { + "text": "American unemployment", + "subtitle": ["Annual rate, per cent of the labour force, 2000–2023"] + }, + "data": { + "values": [ + { "Year": "2000", "Unemployment (%)": 4.0 }, + { "Year": "2001", "Unemployment (%)": 4.7 }, + { "Year": "2002", "Unemployment (%)": 5.8 }, + { "Year": "2003", "Unemployment (%)": 6.0 }, + { "Year": "2004", "Unemployment (%)": 5.5 }, + { "Year": "2005", "Unemployment (%)": 5.1 }, + { "Year": "2006", "Unemployment (%)": 4.6 }, + { "Year": "2007", "Unemployment (%)": 4.6 }, + { "Year": "2008", "Unemployment (%)": 5.8 }, + { "Year": "2009", "Unemployment (%)": 9.3 }, + { "Year": "2010", "Unemployment (%)": 9.6 }, + { "Year": "2011", "Unemployment (%)": 8.9 }, + { "Year": "2012", "Unemployment (%)": 8.1 }, + { "Year": "2013", "Unemployment (%)": 7.4 }, + { "Year": "2014", "Unemployment (%)": 6.2 }, + { "Year": "2015", "Unemployment (%)": 5.3 }, + { "Year": "2016", "Unemployment (%)": 4.9 }, + { "Year": "2017", "Unemployment (%)": 4.4 }, + { "Year": "2018", "Unemployment (%)": 3.9 }, + { "Year": "2019", "Unemployment (%)": 3.7 }, + { "Year": "2020", "Unemployment (%)": 8.1 }, + { "Year": "2021", "Unemployment (%)": 5.3 }, + { "Year": "2022", "Unemployment (%)": 3.6 }, + { "Year": "2023", "Unemployment (%)": 3.6 } + ] + }, + "encoding": { + "x": { + "field": "Year", + "type": "temporal", + "title": null, + "scale": { "type": "utc" }, + "axis": { + "values": ["2000", "2005", "2010", "2015", "2020", "2023"], + "labelExpr": "utcyear(datum.value) === 2000 ? '2000' : utcFormat(datum.value, '’%y')", + "labelFlush": true, + "grid": false, + "domain": true, + "domainColor": "#000000", + "domainWidth": 1, + "ticks": true, + "tickColor": "#000000", + "tickSize": 4, + "labelPadding": 3 + } + }, + "y": { + "field": "Unemployment (%)", + "type": "quantitative", + "title": "%", + "scale": { "domain": [0, 10], "nice": false }, + "axis": { + "values": [0, 2, 4, 6, 8, 10], + "grid": true, + "gridColor": "#e4e4e4", + "gridWidth": 1, + "domain": false, + "ticks": false, + "labelPadding": 5, + "titleAngle": 0, + "titleAlign": "left", + "titleX": -4, + "titleY": -10, + "titleBaseline": "bottom" + } + } + }, + "transform": [ + { + "window": [ + { "op": "row_number", "as": "__i" }, + { "op": "count", "as": "__n" } + ], + "frame": [null, null], + "sort": [{ "field": "Year", "order": "ascending" }] + } + ], + "layer": [ + { + "mark": { + "type": "line", + "color": "#1a1a1a", + "strokeWidth": 2.4, + "strokeCap": "round", + "strokeJoin": "round" + } + }, + { + "transform": [ + { "filter": "datum.__i === 1 || datum.__i === datum.__n" } + ], + "mark": { + "type": "point", + "filled": true, + "size": 45, + "color": "#1a1a1a" + } + }, + { + "transform": [ + { "filter": "datum.__i === 1 || datum.__i === datum.__n" } + ], + "mark": { + "type": "text", + "align": "center", + "baseline": "bottom", + "dy": -7, + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "fontSize": 10, + "fontWeight": 700, + "color": "#1a1a1a" + }, + "encoding": { + "text": { "field": "Unemployment (%)", "type": "quantitative", "format": ".1f" } + } + } + ], + "config": { + "background": "#ffffff", + "font": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "title": { + "font": "Georgia, 'Times New Roman', serif", + "fontSize": 16, + "fontWeight": 700, + "color": "#121212", + "anchor": "start", + "offset": 12, + "subtitleFont": "Georgia, 'Times New Roman', serif", + "subtitleFontSize": 11, + "subtitleColor": "#666666", + "subtitlePadding": 8 + }, + "view": { "stroke": null }, + "axis": { + "labelFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "labelFontSize": 10, + "labelColor": "#666666", + "titleFont": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "titleFontSize": 10, + "titleColor": "#666666", + "titleFontWeight": 400 + } + } +} diff --git a/site/src/playground/theme-lab-r2-data.ts b/site/src/playground/theme-lab-r2-data.ts new file mode 100644 index 00000000..b2773fdc --- /dev/null +++ b/site/src/playground/theme-lab-r2-data.ts @@ -0,0 +1,201 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Round-2 theme corpus. + * + * The theme lab's dev set has one case per chart type, all at a comfortable + * cardinality, all looked at while the compiler was being written. This file + * names a second corpus drawn from the gallery — charts written for entirely + * other purposes — chosen to vary the three things the lab holds constant: + * cardinality, data type, and chart shape. + * + * A case is a *reference* into `packages/flint-js/src/test-data`, not a copy of + * it: `(gen, index)` into `TEST_GENERATORS`. The gallery's own titles are + * shape descriptions ("N(5)×Q (5 pts)"), which tell a theme's typography + * nothing, so each case carries a plausible headline instead. + * + * `probe` says what the case is here to test. A case that stops probing + * anything should be removed, not kept for the count. + */ + +import { TEST_GENERATORS, type TestCase } from 'flint-chart/test-data'; +import { testCaseToAssemblyInput } from '../shared/test-case-utils'; + +export interface R2Case { + /** Stable slug: file name of the audit sheet, key of a gap note. */ + id: string; + /** `TEST_GENERATORS` key. */ + gen: string; + /** Index into that generator's cases. */ + index: number; + title: string; + subtitle?: string; + /** What this case is here to test. */ + probe: string; + family: R2Family; +} + +export type R2Family = + | 'Bars & ranking' + | 'Points & correlation' + | 'Distributions' + | 'Time & trends' + | 'Parts & radial' + | 'Maps & matrices' + | 'Single value & schedule'; + +export const R2_FAMILY_ORDER: R2Family[] = [ + 'Bars & ranking', + 'Points & correlation', + 'Distributions', + 'Time & trends', + 'Parts & radial', + 'Maps & matrices', + 'Single value & schedule', +]; + +export const R2_CASES: R2Case[] = [ + // ── Bars & ranking ──────────────────────────────────────────────────── + { id: 'bar-n5', gen: 'Bar Chart', index: 0, family: 'Bars & ranking', title: 'Revenue by segment', subtitle: 'Fiscal 2024, $m', probe: 'lowest useful bar count — a house that prints values has room for all of them' }, + { id: 'bar-n30', gen: 'Bar Chart', index: 2, family: 'Bars & ranking', title: 'Orders by product line', probe: '30 bands: label rotation, printed values, band occupancy' }, + { id: 'bar-n100', gen: 'Bar Chart', index: 3, family: 'Bars & ranking', title: 'Sessions by page', subtitle: 'Top 100 pages by traffic', probe: '100 bands, overflow and cutoff — every per-mark rule at its limit' }, + { id: 'bar-horizontal', gen: 'Bar Chart', index: 6, family: 'Bars & ranking', title: 'Downloads by platform', probe: 'horizontal bars: which axis is the index, which the measure' }, + { id: 'bar-temporal', gen: 'Bar Chart', index: 9, family: 'Bars & ranking', title: 'Monthly shipments', subtitle: 'Units, 2022–2023', probe: 'temporal band axis — tick policy on a bar chart' }, + { id: 'bar-temporal-color', gen: 'Bar Chart', index: 11, family: 'Bars & ranking', title: 'Shipments by region', subtitle: 'Units per month', probe: 'temporal bands with a nominal series on colour' }, + { id: 'bar-grid', gen: 'Bar Chart', index: 16, family: 'Bars & ranking', title: 'Category by tier', probe: 'two discrete positional channels on a bar template' }, + { id: 'stacked-n15', gen: 'Stacked Bar Chart', index: 1, family: 'Bars & ranking', title: 'Spend by department', subtitle: 'Five cost centres across fifteen teams', probe: '15 bands × 5 series: legend, printed values, stack order' }, + { id: 'stacked-numeric-color', gen: 'Stacked Bar Chart', index: 3, family: 'Bars & ranking', title: 'Responses by cohort', probe: 'numeric series values on colour — ordered, not nominal' }, + { id: 'stacked-horizontal-temporal', gen: 'Stacked Bar Chart', index: 9, family: 'Bars & ranking', title: 'Weekly hours by activity', probe: 'horizontal stack on a temporal index axis' }, + { id: 'grouped-numeric-color', gen: 'Grouped Bar Chart', index: 3, family: 'Bars & ranking', title: 'Scores by quarter', probe: 'grouped bars whose group field is numeric' }, + { id: 'grouped-sparse', gen: 'Grouped Bar Chart', index: 12, family: 'Bars & ranking', title: 'Sales by region and channel', subtitle: 'Not every channel operates in every region', probe: 'sparse cross-product: local dodge, lanes, legend' }, + { id: 'grouped-color-continuous', gen: 'Grouped Bar Chart', index: 8, family: 'Bars & ranking', title: 'Throughput by node', probe: '50 numeric groups — a ramp where a house expects a set' }, + { id: 'lollipop-color', gen: 'Lollipop Chart', index: 1, family: 'Bars & ranking', title: 'Adoption by market', probe: 'stem-and-dot marks with a colour series' }, + { id: 'lollipop-facet', gen: 'Lollipop Chart', index: 3, family: 'Bars & ranking', title: 'Adoption by market and tier', probe: 'lollipop inside small multiples' }, + { id: 'pyramid-12', gen: 'Pyramid Chart', index: 3, family: 'Bars & ranking', title: 'Households by income bracket', probe: '12 mirrored bands — two panels, two directions' }, + { id: 'pyramid-20', gen: 'Pyramid Chart', index: 6, family: 'Bars & ranking', title: 'Population by age band', subtitle: 'Twenty five-year bands', probe: 'pyramid past its comfortable height' }, + { id: 'bartable-diverging', gen: 'Bar Table', index: 4, family: 'Bars & ranking', title: 'Budget variance by team', probe: 'signed measure in a table — diverging ink, zero rule' }, + { id: 'bartable-long-labels', gen: 'Bar Table', index: 8, family: 'Bars & ranking', title: 'Programme spend', probe: 'category names wider than the gutter a theme leaves them' }, + { id: 'bartable-stacked', gen: 'Bar Table', index: 15, family: 'Bars & ranking', title: 'Views by month and channel', probe: 'a stacked bar inside a table row' }, + { id: 'bartable-facet-wrap', gen: 'Bar Table', index: 17, family: 'Bars & ranking', title: 'Leaders by market', probe: 'seven wrapped table panels' }, + { id: 'waterfall-typed', gen: 'Waterfall Chart', index: 1, family: 'Bars & ranking', title: 'Cash bridge', subtitle: 'Opening to closing balance, $m', probe: 'explicit step types — a three-role palette' }, + { id: 'waterfall-14', gen: 'Waterfall Chart', index: 3, family: 'Bars & ranking', title: 'Monthly cash flow', probe: '14 steps: connector, band width, printed values' }, + + // ── Points & correlation ────────────────────────────────────────────── + { id: 'scatter-color-n3', gen: 'Scatter Plot', index: 1, family: 'Points & correlation', title: 'Yield against temperature', subtitle: 'Three process lines', probe: 'the ordinary scatter: three series, one key' }, + { id: 'scatter-color-n50', gen: 'Scatter Plot', index: 12, family: 'Points & correlation', title: 'Latency against load', subtitle: 'By service', probe: '50 series — more categories than any house owns inks' }, + { id: 'scatter-shape', gen: 'Scatter Plot', index: 25, family: 'Points & correlation', title: 'Weight against economy', probe: 'series carried by shape, not colour' }, + { id: 'scatter-500', gen: 'Scatter Plot', index: 10, family: 'Points & correlation', title: 'Sensor readings', probe: '500 overlapping points — mark size and opacity' }, + { id: 'scatter-facet', gen: 'Scatter Plot', index: 28, family: 'Points & correlation', title: 'Height against weight', subtitle: 'By group', probe: 'faceted scatter' }, + { id: 'regression-6', gen: 'Regression', index: 3, family: 'Points & correlation', title: 'Cost against volume', subtitle: 'Six product families', probe: 'six fits at once — the statistics caption cannot speak' }, + { id: 'regression-quad', gen: 'Regression', index: 6, family: 'Points & correlation', title: 'Response against dose', probe: 'a non-linear fit' }, + { id: 'connected-3', gen: 'Connected Scatter Plot', index: 1, family: 'Points & correlation', title: 'Growth against inflation', subtitle: 'Three economies', probe: 'three trajectories, ordered by time' }, + { id: 'connected-spiral', gen: 'Connected Scatter Plot', index: 6, family: 'Points & correlation', title: 'Drift against noise', probe: 'a path that crosses itself 36 times' }, + { id: 'rangeddot', gen: 'Ranged Dot Plot', index: 0, family: 'Points & correlation', title: 'Before and after', subtitle: 'Eight sites', probe: 'two dots and a connector per row' }, + { id: 'strip-color', gen: 'Strip Plot', index: 1, family: 'Points & correlation', title: 'Trial scores by arm', probe: 'jittered sample with a colour series' }, + + // ── Distributions ───────────────────────────────────────────────────── + { id: 'hist-color', gen: 'Histogram', index: 1, family: 'Distributions', title: 'Age at diagnosis', subtitle: 'By sex', probe: 'two overlaid histograms' }, + { id: 'hist-1000', gen: 'Histogram', index: 2, family: 'Distributions', title: 'Part widths', probe: '1000 observations, many bins' }, + { id: 'density-3', gen: 'Density Plot', index: 1, family: 'Distributions', title: 'Latency by region', probe: 'three overlapping densities' }, + { id: 'density-facet', gen: 'Density Plot', index: 3, family: 'Distributions', title: 'Latency by region and site', probe: 'density inside small multiples' }, + { id: 'box-12', gen: 'Boxplot', index: 2, family: 'Distributions', title: 'Response time by service', probe: '12 boxes — band width against house preference' }, + { id: 'box-6x4', gen: 'Boxplot', index: 4, family: 'Distributions', title: 'Salary by department and level', probe: 'grouped boxes, six bands of four' }, + { id: 'box-sparse', gen: 'Boxplot', index: 7, family: 'Distributions', title: 'Salary by department and grade', subtitle: 'Not every grade exists in every department', probe: 'sparse box lanes' }, + { id: 'violin-zero', gen: 'Violin Plot', index: 3, family: 'Distributions', title: 'Daily returns by asset', probe: 'a distribution that crosses zero' }, + { id: 'violin-grid', gen: 'Violin Plot', index: 9, family: 'Distributions', title: 'Salary by department and level', probe: 'violins promoted to a grid' }, + { id: 'ecdf-2', gen: 'ECDF Plot', index: 1, family: 'Distributions', title: 'Test scores', subtitle: 'Control against treatment', probe: 'two cumulative curves' }, + { id: 'ecdf-facet', gen: 'ECDF Plot', index: 7, family: 'Distributions', title: 'Exam scores by subject', probe: 'faceted ECDF' }, + + // ── Time & trends ───────────────────────────────────────────────────── + { id: 'line-8', gen: 'Line Chart', index: 2, family: 'Time & trends', title: 'Traffic by channel', subtitle: 'Eight channels, daily', probe: 'eight series — more lines than a house names at the end' }, + { id: 'line-sparse', gen: 'Line Chart', index: 4, family: 'Time & trends', title: 'Coverage by cohort', probe: 'series with holes in them' }, + { id: 'line-continuous-color', gen: 'Line Chart', index: 5, family: 'Time & trends', title: 'Temperature over time', probe: 'a line whose colour carries a quantity' }, + { id: 'line-ordinal-30', gen: 'Line Chart', index: 8, family: 'Time & trends', title: 'Score by round', probe: '30 ordinal steps on the index axis' }, + { id: 'line-forecast', gen: 'Line Chart', index: 17, family: 'Time & trends', title: 'Demand, actual and forecast', subtitle: 'Three regions', probe: 'a dash that carries meaning, three times over' }, + { id: 'line-facet', gen: 'Line Chart', index: 20, family: 'Time & trends', title: 'Monthly revenue by region', probe: 'ten wrapped line panels' }, + { id: 'area-color-n4', gen: 'Area Chart', index: 1, family: 'Time & trends', title: 'Storage by tier', subtitle: 'Four tiers, monthly', probe: 'a four-band stack over time' }, + { id: 'area-ordinal', gen: 'Area Chart', index: 7, family: 'Time & trends', title: 'Visits by channel', probe: 'a stack over an ordinal index' }, + { id: 'area-facet', gen: 'Area Chart', index: 13, family: 'Time & trends', title: 'Monthly visits by channel', probe: 'faceted area' }, + { id: 'stream-5', gen: 'Streamgraph', index: 0, family: 'Time & trends', title: 'Attention by topic', probe: 'five wiggling bands and their names' }, + { id: 'rangearea-2', gen: 'Range Area Chart', index: 1, family: 'Time & trends', title: 'Monthly temperature range', subtitle: 'Two cities', probe: 'two bands over one index' }, + { id: 'rangearea-zero', gen: 'Range Area Chart', index: 4, family: 'Time & trends', title: 'Temperature anomaly band', probe: 'a band straddling zero' }, + { id: 'rangearea-facet', gen: 'Range Area Chart', index: 7, family: 'Time & trends', title: 'Daily temperature range by city', probe: 'faceted band' }, + { id: 'sparkline-3', gen: 'Sparkline', index: 1, family: 'Time & trends', title: 'Key metrics', probe: 'a table of trends — theme against a concatenation' }, + { id: 'sparkline-15', gen: 'Sparkline', index: 3, family: 'Time & trends', title: 'Metrics by service', probe: '15 rows of sparkline' }, + { id: 'bump-many', gen: 'Bump Chart', index: 2, family: 'Time & trends', title: 'Rank by quarter', subtitle: 'Twelve competitors', probe: 'twelve ranked series' }, + { id: 'bump-single', gen: 'Bump Chart', index: 4, family: 'Time & trends', title: 'Rank over four rounds', probe: 'a bump chart with one series and no key' }, + { id: 'slope-crossings', gen: 'Slope Chart', index: 1, family: 'Time & trends', title: 'Share, 2019 against 2024', subtitle: 'Eight companies', probe: 'eight slopes that cross' }, + { id: 'slope-negative', gen: 'Slope Chart', index: 3, family: 'Time & trends', title: 'Margin, before and after', probe: 'slopes across zero' }, + { id: 'slope-detail', gen: 'Slope Chart', index: 8, family: 'Time & trends', title: 'Units, before and after', probe: 'series carried by detail — no colour, no key' }, + { id: 'candle-90', gen: 'Candlestick Chart', index: 1, family: 'Time & trends', title: 'Daily price', subtitle: '90 sessions', probe: 'a dense composite mark' }, + { id: 'candle-facet', gen: 'Candlestick Chart', index: 3, family: 'Time & trends', title: 'Daily price by ticker', probe: 'faceted OHLC' }, + + // ── Parts & radial ──────────────────────────────────────────────────── + { id: 'pie-10', gen: 'Pie Chart', index: 1, family: 'Parts & radial', title: 'Share by vendor', probe: 'ten slices — more than a pie reads, which is the point' }, + { id: 'pie-25', gen: 'Pie Chart', index: 2, family: 'Parts & radial', title: 'Share by vendor', subtitle: 'Twenty-five vendors', probe: 'a pie past legibility' }, + { id: 'pie-skewed', gen: 'Pie Chart', index: 3, family: 'Parts & radial', title: 'Share by tier', probe: 'one slice at 80 per cent, five slivers' }, + { id: 'donut-4', gen: 'Donut Chart', index: 0, family: 'Parts & radial', title: 'Traffic by source', probe: 'the hole in the middle' }, + { id: 'rose-stacked', gen: 'Rose Chart', index: 1, family: 'Parts & radial', title: 'Wind by direction and season', probe: 'stacked petals' }, + { id: 'rose-months', gen: 'Rose Chart', index: 4, family: 'Parts & radial', title: 'Rainfall by month', probe: 'a cyclic ordinal index with an inner radius' }, + { id: 'rose-facet', gen: 'Rose Chart', index: 6, family: 'Parts & radial', title: 'Wind by direction and site', probe: 'faceted rose' }, + { id: 'radar-2', gen: 'Radar Chart', index: 1, family: 'Parts & radial', title: 'Team profile', subtitle: 'Two squads across six measures', probe: 'two overlaid polygons' }, + { id: 'radar-facet', gen: 'Radar Chart', index: 3, family: 'Parts & radial', title: 'Profile by region', probe: 'faceted radar' }, + { id: 'radar-12', gen: 'Radar Chart', index: 6, family: 'Parts & radial', title: 'Product profile', subtitle: 'Twelve measures', probe: 'twelve spokes and their names' }, + + // ── Maps & matrices ─────────────────────────────────────────────────── + { id: 'heat-ordinal', gen: 'Heatmap', index: 3, family: 'Maps & matrices', title: 'Activity by month and cohort', probe: 'an ordinal axis against a nominal one' }, + { id: 'heat-temporal', gen: 'Heatmap', index: 5, family: 'Maps & matrices', title: 'Activity by site and day', probe: '80 temporal columns' }, + { id: 'heat-wide', gen: 'Heatmap', index: 7, family: 'Maps & matrices', title: 'Coverage by test and suite', probe: 'a grid 80 wide and 5 tall' }, + { id: 'map-us', gen: 'Map', index: 0, family: 'Maps & matrices', title: 'US metro areas', probe: 'a projection: no axes, no bands' }, + { id: 'choropleth-us', gen: 'Choropleth', index: 0, family: 'Maps & matrices', title: 'Rate by state', probe: 'a ramp over geography' }, + + // ── Single value & schedule ─────────────────────────────────────────── + { id: 'gantt-project', gen: 'Gantt Chart', index: 0, family: 'Single value & schedule', title: 'Project schedule', probe: 'a temporal range per row' }, + { id: 'gantt-ci', gen: 'Gantt Chart', index: 1, family: 'Single value & schedule', title: 'Pipeline run', subtitle: 'Seconds from start', probe: 'a numeric range per row' }, + { id: 'bullet-12', gen: 'Bullet Chart', index: 1, family: 'Single value & schedule', title: 'Revenue against target', subtitle: 'Twelve stores', probe: 'twelve rows of measure, target and bands' }, + { id: 'kpi-verdicts', gen: 'KPI Card', index: 0, family: 'Single value & schedule', title: 'Quarterly KPIs', subtitle: 'Against goal', probe: 'a chart drawn entirely in pixels — whether the house reaches its progress bar, and whether accent, met and missed stay three distinguishable inks' }, + { id: 'kpi-pair', gen: 'KPI Card', index: 1, family: 'Single value & schedule', title: 'Adoption against plan', probe: 'two wide tiles — the big number at its largest, and card furniture re-toned to the house surface' }, +]; + +/** The base canvas every R2 case is designed at, so sheets are comparable. */ +export const R2_BASE_SIZE = { width: 300, height: 300 }; + +/** A generous square stretch ceiling. Each house starts from its own + * `compileDefaults.baseSize` (aspect ratio + footprint) and may grow up to + * this when the data needs the room, so the shape is the house's, not ours. */ +export const R2_CANVAS_SIZE = { width: 800, height: 800 }; + +const CASE_CACHE = new Map(); + +export function r2TestCase(c: R2Case): TestCase { + const key = `${c.gen}#${c.index}`; + const hit = CASE_CACHE.get(key); + if (hit) return hit; + const gen = TEST_GENERATORS[c.gen]; + if (!gen) throw new Error(`R2 case ${c.id}: no generator \`${c.gen}\``); + const cases = gen(); + const t = cases[c.index]; + if (!t) throw new Error(`R2 case ${c.id}: ${c.gen}[${c.index}] of ${cases.length}`); + CASE_CACHE.set(key, t); + return t; +} + +/** + * The assembly input for a case. Identical for all seven columns — the only + * difference between them is whether a ThemeSpec is attached. + */ +export function r2Input(c: R2Case): any { + const t = r2TestCase(c); + const input = testCaseToAssemblyInput(t, R2_BASE_SIZE); + // Let each house's own `compileDefaults.baseSize` drive its aspect ratio + // and footprint: dropping the caller's baseSize lets the theme's win + // (flint, with no theme, falls back to flint's neutral default). A single + // generous square ceiling lets every house stretch when the data needs it + // without dictating the shape it starts from. + delete input.chart_spec.baseSize; + input.chart_spec.canvasSize = R2_CANVAS_SIZE; + input.chart_spec.title = c.title; + if (c.subtitle) input.chart_spec.subtitle = c.subtitle; + return input; +} diff --git a/site/src/routes/ChartWall.tsx b/site/src/routes/ChartWall.tsx index 29c1eba4..21550e53 100644 --- a/site/src/routes/ChartWall.tsx +++ b/site/src/routes/ChartWall.tsx @@ -597,7 +597,7 @@ function BackendIntro({ const ${resultNames[category.id]} = ${category.fn}(input);`; - const backendDesc = t(`gallery.backends.${category.id}`); + const backendDesc = t(`gallery.backends.${category.id}`, { defaultValue: category.description }); return (
diff --git a/site/src/routes/DocSectionPage.tsx b/site/src/routes/DocSectionPage.tsx index 14a1f9cb..f9863a54 100644 --- a/site/src/routes/DocSectionPage.tsx +++ b/site/src/routes/DocSectionPage.tsx @@ -48,6 +48,16 @@ export function DocSectionPage({ section }: { section: DocSection }) { } }, [slug, section, firstSlug, navigate, lp]); + // Reset scroll to top when the active doc changes, unless a heading anchor + // is pending (handled by the next effect). + useEffect(() => { + const stored = sessionStorage.getItem(DOC_SCROLL_TO_KEY); + const hash = location.hash ? decodeURIComponent(location.hash.slice(1)) : ''; + if (!stored && !hash) { + mainRef.current?.scrollTo({ top: 0 }); + } + }, [activeSlug, location.hash]); + useEffect(() => { const stored = sessionStorage.getItem(DOC_SCROLL_TO_KEY); const hash = location.hash ? decodeURIComponent(location.hash.slice(1)) : ''; @@ -114,7 +124,7 @@ export function DocSectionPage({ section }: { section: DocSection }) { icon={doc.icon} dataAttr={{ 'data-doc-nav': doc.slug }} > - {t(`docs.entries.${doc.slug}.title`)} + {t(`docs.entries.${doc.slug}.title`, { defaultValue: doc.title })} ); })} @@ -186,7 +196,7 @@ function MobileDocPicker({ {group.docs.map((doc) => ( ))} diff --git a/site/src/routes/Landing.tsx b/site/src/routes/Landing.tsx index 41b05a67..72b4a4d1 100644 --- a/site/src/routes/Landing.tsx +++ b/site/src/routes/Landing.tsx @@ -1,18 +1,16 @@ -import { useEffect, useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react'; +import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslation, Trans } from 'react-i18next'; import type { TFunction } from 'i18next'; import { LocaleLink } from '../i18n/LocaleLink'; -import { useLocale } from '../i18n/LocaleContext'; -import { LOCALE_URL_SEGMENT } from '../i18n/locales'; import { TEST_GENERATORS, makeField, makeEncodingItem, buildMetadata, type TestCase } from 'flint-chart/test-data'; +import { THEME_PRESETS } from 'flint-chart'; import { SiteNavBar, MicrosoftDisclosures, GitHubIcon } from '../components/SiteShell'; import { WallChart } from '../components/WallChart'; import { ScaleToFit } from '../components/ScaleToFit'; -import { GalleryOptionsBar } from '../components/GalleryOptionsBar'; +import { GalleryOptionsBar, ThemeControl } from '../components/GalleryOptionsBar'; import { SpecPipelineFigure } from '../components/SpecPipelineFigure'; -import { testCaseToFlintSummary, testCaseToAssemblyInput } from '../shared/test-case-utils'; -import { buildGalleryEditorHref, openEditorWithPayload } from '../shared/editor-payload'; -import { buildPanelModel } from '../shared/chart-options'; +import { testCaseToFlintSummary, testCaseToAssemblyInput, withHouse } from '../shared/test-case-utils'; +import { buildPanelModel, withoutEchoedOverrides } from '../shared/chart-options'; import { CHART_CATEGORIES } from '../shared/chart-categories'; import { MOVIE_RATINGS } from './movie-ratings-data'; import { @@ -61,21 +59,46 @@ export function Landing() { backends: BACKEND_ROSTER_LINKS.length, })} - . + . {t('landing.themeLead')}

-
- - {t('landing.backendRosterLabel')} - - {BACKEND_ROSTER_LINKS.map((backend, index) => ( - - {index > 0 &&