From 0f872f53e5a6690dcf0efbab8c1d8b6b6cc96c53 Mon Sep 17 00:00:00 2001 From: jason-zl190 Date: Sun, 19 Jul 2026 18:54:55 +0800 Subject: [PATCH 01/40] feat(vegalite): add Calendar Heatmap template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the Calendar Heatmap to the Vega-Lite backend, mirroring the ECharts implementation (ecCalendarHeatmapDef). ECharts uses a first-class calendar coordinate system; Vega-Lite has none, but it needs no computed week/day fields either — timeUnit expresses the GitHub-style grid from a single date field: yearweek on x (one ordinal column per calendar week), day on y (Sun–Sat rows, Monday-first to match the ECharts dayLabel), and sum on color collapses rows sharing a calendar day into one cell. The upstream ECharts calendar.ts header notes VL "has no first-class calendar; would fake it with rect + computed week/day fields" — timeUnit avoids the computed fields, so no core changes are needed. Scheme handling mirrors the ECharts template's encodingActions: named Vega-Lite schemes (viridis/blues/greens/reds/oranges/purples) pass through as scale.scheme; "github" has no built-in Vega-Lite equivalent, so it resolves to an explicit scale.range (the same low→high ramp the ECharts template uses). - register in vlTemplateDefs (Tables & Maps group, next to Heatmap) - tests: 8 cases (registry, dual-axis timeUnit projection, Monday-first row order, per-day sum, count fallback, github range vs named scheme, cross-backend parity); assembled spec also verified through vl.compile - docs: regenerate reference-vegalite.md; move Calendar Heatmap from the SKILL.md "ECharts adds" list into the shared template table (+ synced bundled asset) Co-Authored-By: Claude Opus 4.8 (1M context) --- agent-skills/flint-chart-author/SKILL.md | 3 +- docs/reference-vegalite.md | 8 +- .../src/vegalite/templates/calendar.ts | 114 ++++++++++++++++++ .../flint-js/src/vegalite/templates/index.ts | 3 +- .../flint-js/tests/calendar-vegalite.test.ts | 95 +++++++++++++++ .../assets/flint-chart-author.SKILL.md | 3 +- 6 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 packages/flint-js/src/vegalite/templates/calendar.ts create mode 100644 packages/flint-js/tests/calendar-vegalite.test.ts diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index daed293c..af1a890f 100644 --- a/agent-skills/flint-chart-author/SKILL.md +++ b/agent-skills/flint-chart-author/SKILL.md @@ -268,6 +268,7 @@ properties"). Required channels are noted. | `"Boxplot"` | x, y, color, opacity, column, row | category + measure; props `whiskerMethod`, `showOutliers`, `dodge` | | `"ECDF Plot"` | x, color, detail, column, row | x = measure; cumulative distribution (step line); prop `showPoints` | | `"Heatmap"` | x, y, color, column, row | color = the measure | +| `"Calendar Heatmap"` | x, color | x = date; color = daily value (summed per day); GitHub-style week × weekday grid | | `"Line Chart"` | x, y, color, strokeDash, detail, opacity, column, row | props `interpolate`, `showPoints` | | `"Sparkline"` | x, y, color, detail, row, column | x + y required; small-multiple mini trend lines, one per series (series from `color` or `detail`); props `interpolate`, `baseline`, `trendWidth` | | `"Bump Chart"` | x, y, color, detail, column, row | rank-over-time lines | @@ -321,7 +322,7 @@ type column. **Backend coverage.** Vega-Lite supports all of the above. Other backends support a subset (verify if targeting a non-VL backend): -- **ECharts** adds: `"Calendar Heatmap"`, `"Gauge"`, +- **ECharts** adds: `"Gauge"`, `"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`, `"Parallel Coordinates"`, `"Graph"`, `"Tree"`. - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, diff --git a/docs/reference-vegalite.md b/docs/reference-vegalite.md index c7c6f7f7..a65a9836 100644 --- a/docs/reference-vegalite.md +++ b/docs/reference-vegalite.md @@ -6,7 +6,7 @@ The Vega-Lite backend serves as Flint's reference implementation and offers the ## What this page covers -This reference lists the 35 chart types currently supported by the Vega-Lite backend, grouped into 6 categories. Each chart entry shows: +This reference lists the 36 chart types currently supported by the Vega-Lite backend, grouped into 6 categories. Each chart entry shows: - **Encoding channels** — the visual roles accepted in `chart_spec.encodings`, such as `x`, `y`, `color`, `size`, `column`, or `row`. - **Options** — template-specific `chart_spec.chartProperties` keys, including control type, domain, default, availability, and description. @@ -411,6 +411,12 @@ The **Availability** column shows whether a parameter is `always` available or ` | `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | | `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | +### ![](chart-icon-calendar.svg) Calendar Heatmap + +**Encoding channels:** `x`, `color` + +_No template-specific parameters._ + ### ![](chart-icon-bar-table.svg) Bar Table **Encoding channels:** `y`, `x`, `color`, `column`, `row` diff --git a/packages/flint-js/src/vegalite/templates/calendar.ts b/packages/flint-js/src/vegalite/templates/calendar.ts new file mode 100644 index 00000000..5d7ced51 --- /dev/null +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Vega-Lite Calendar Heatmap template. + * + * The ECharts backend has a first-class calendar coordinate system; Vega-Lite + * has none, but it does not need computed week/day fields either — `timeUnit` + * expresses the GitHub-style grid directly from a single date field: + * x → timeUnit 'yearweek' (one ordinal column per calendar week) + * y → timeUnit 'day' (Sun–Sat, one ordinal row per weekday) + * so the same date field drives both axes and the sum-per-cell aggregation + * collapses to one value per calendar day. + * + * Encoding: + * x (temporal) → the date of each cell + * color (quantitative) → the cell value (defaults to a count of 1) + */ + +import { ChartTemplateDef, EncodingActionDef } from '../../core/types'; + +/** Weekday row order, Monday-first — mirrors the ECharts template's dayLabel.firstDay = 1. */ +const WEEKDAY_ORDER = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +/** + * Sequential schemes. Named Vega-Lite schemes pass through as `scale.scheme`; + * 'github' has no built-in Vega-Lite equivalent, so it resolves to an explicit + * `scale.range` (the same low→high ramp the ECharts template uses). + */ +const GITHUB_RANGE = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39']; +const VL_SCHEMES = new Set(['viridis', 'blues', 'greens', 'reds', 'oranges', 'purples']); + +export const vlCalendarHeatmapDef: ChartTemplateDef = { + chart: 'Calendar Heatmap', + template: { mark: { type: 'rect', cornerRadius: 2 }, encoding: {} }, + channels: ['x', 'color'], + markCognitiveChannel: 'color', + declareLayoutMode: () => ({ + // Both axes are ordinal bands (week columns × weekday rows); square-ish + // cells read as a calendar rather than a stretched grid. + axisFlags: { x: { banded: true }, y: { banded: true } }, + }), + instantiate: (spec, ctx) => { + const dateField = ctx.channelSemantics.x?.field; + const valueField = ctx.channelSemantics.color?.field; + if (!dateField) return; + + const encScheme = ctx.encodings?.color?.scheme; + const scheme = encScheme && encScheme !== 'default' ? encScheme : 'viridis'; + const colorScale = + scheme === 'github' + ? { range: GITHUB_RANGE } + : { scheme: VL_SCHEMES.has(scheme) ? scheme : 'viridis' }; + + spec.encoding = { + // One ordinal column per calendar week; month initials label the axis. + x: { + field: dateField, + timeUnit: 'yearweek', + type: 'ordinal', + title: null, + axis: { + format: '%b', + labelAngle: 0, + labelOverlap: true, + tickBand: 'extent', + domain: false, + ticks: false, + }, + }, + // Sun–Sat rows, Monday-first to match the ECharts calendar. + y: { + field: dateField, + timeUnit: 'day', + type: 'ordinal', + title: null, + sort: WEEKDAY_ORDER, + axis: { domain: false, ticks: false }, + }, + // Sum collapses multiple rows sharing a calendar day into one cell. + color: { + ...(valueField + ? { field: valueField, aggregate: 'sum' } + : { aggregate: 'count' }), + type: 'quantitative', + legend: { title: null }, + scale: colorScale, + }, + }; + }, + encodingActions: [ + { + key: 'colorScheme', + label: 'Scheme', + isApplicable: (ctx) => !!ctx.encodings.color?.field, + dependencies: ['color'], + control: { + type: 'discrete', + options: [ + { value: undefined, label: 'Default (Viridis)' }, + { value: 'viridis', label: 'Viridis' }, + { value: 'github', label: 'GitHub' }, + { value: 'blues', label: 'Blues' }, + { value: 'greens', label: 'Greens' }, + { value: 'reds', label: 'Reds' }, + { value: 'oranges', label: 'Oranges' }, + { value: 'purples', label: 'Purples' }, + ], + }, + get: (enc) => enc.color?.scheme, + set: (enc, value) => ({ ...enc, color: { ...enc.color, scheme: value } }), + }, + ] as EncodingActionDef[], +}; diff --git a/packages/flint-js/src/vegalite/templates/index.ts b/packages/flint-js/src/vegalite/templates/index.ts index d364e567..5b5072e6 100644 --- a/packages/flint-js/src/vegalite/templates/index.ts +++ b/packages/flint-js/src/vegalite/templates/index.ts @@ -37,6 +37,7 @@ import { radarChartDef } from './radar'; import { roseChartDef } from './rose'; import { mapDef, choroplethDef } from './map'; import { kpiCardDef } from './kpi-card'; +import { vlCalendarHeatmapDef } from './calendar'; /** * Cross-cutting properties injected into every template that supports @@ -288,7 +289,7 @@ export const vlTemplateDefs: { [key: string]: ChartTemplateDef[] } = Object.from "Distributions": [histogramDef, densityPlotDef, ecdfPlotDef, violinPlotDef, boxplotDef, pyramidChartDef, candlestickChartDef], "Lines & Areas": [lineChartDef, sparklineDef, bumpChartDef, slopeChartDef, areaChartDef, streamgraphDef, rangeAreaChartDef], "Circular": [pieChartDef, donutChartDef, roseChartDef, radarChartDef], - "Tables & Maps": [heatmapDef, barTableDef, kpiCardDef, mapDef, choroplethDef], + "Tables & Maps": [heatmapDef, vlCalendarHeatmapDef, barTableDef, kpiCardDef, mapDef, choroplethDef], }).map(([category, defs]) => [category, defs.map(withInjectedProperties)]), ); diff --git a/packages/flint-js/tests/calendar-vegalite.test.ts b/packages/flint-js/tests/calendar-vegalite.test.ts new file mode 100644 index 00000000..02201e57 --- /dev/null +++ b/packages/flint-js/tests/calendar-vegalite.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite, assembleECharts, vlGetTemplateDef } from '../src'; + +/** + * Vega-Lite Calendar Heatmap — parity with the ECharts calendar template. + * + * Vega-Lite has no first-class calendar coordinate system, so the grid is + * expressed with `timeUnit`: the same date field drives `yearweek` (week + * columns) on x and `day` (weekday rows) on y, and `sum` collapses rows that + * share a calendar day into one cell. + */ + +const DAILY = [ + { date: '2024-01-01', value: 5 }, + { date: '2024-01-02', value: 9 }, + { date: '2024-01-02', value: 1 }, // same day → summed with the row above + { date: '2024-01-08', value: 12 }, + { date: '2024-02-05', value: 3 }, +]; + +function calInput(extraEnc?: Record) { + return { + data: { values: DAILY }, + semantic_types: { date: 'Date', value: 'Amount' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: { field: 'date' }, color: { field: 'value', ...extraEnc } }, + baseSize: { width: 420, height: 160 }, + }, + }; +} + +describe('Vega-Lite Calendar Heatmap', () => { + it('is registered in the Vega-Lite template registry', () => { + expect(vlGetTemplateDef('Calendar Heatmap')).toBeDefined(); + }); + + it('drives both axes from the one date field via yearweek × day', () => { + const spec = assembleVegaLite(calInput()) as any; + expect(spec.mark?.type ?? spec.mark).toBe('rect'); + expect(spec.encoding.x.field).toBe('date'); + expect(spec.encoding.x.timeUnit).toBe('yearweek'); + expect(spec.encoding.y.field).toBe('date'); + expect(spec.encoding.y.timeUnit).toBe('day'); + }); + + it('orders weekday rows Monday-first (matches the ECharts calendar)', () => { + const spec = assembleVegaLite(calInput()) as any; + expect(spec.encoding.y.sort).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); + }); + + it('sums the value per calendar day', () => { + const spec = assembleVegaLite(calInput()) as any; + expect(spec.encoding.color.field).toBe('value'); + expect(spec.encoding.color.aggregate).toBe('sum'); + expect(spec.encoding.color.type).toBe('quantitative'); + }); + + it('falls back to a per-day count when no value field is given', () => { + const input = { + data: { values: DAILY }, + semantic_types: { date: 'Date' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: { field: 'date' } }, + baseSize: { width: 420, height: 160 }, + }, + }; + const spec = assembleVegaLite(input) as any; + expect(spec.encoding.color.aggregate).toBe('count'); + expect(spec.encoding.color.field).toBeUndefined(); + }); + + it('resolves the github scheme to an explicit range (no built-in Vega-Lite scheme)', () => { + const spec = assembleVegaLite(calInput({ scheme: 'github' })) as any; + expect(spec.encoding.color.scale.scheme).toBeUndefined(); + expect(Array.isArray(spec.encoding.color.scale.range)).toBe(true); + expect(spec.encoding.color.scale.range[0]).toBe('#ebedf0'); + }); + + it('passes a named scheme straight through to scale.scheme', () => { + const spec = assembleVegaLite(calInput({ scheme: 'blues' })) as any; + expect(spec.encoding.color.scale.scheme).toBe('blues'); + }); + + it('assembles the same chart type on both backends (registry parity)', () => { + const vl = assembleVegaLite(calInput()) as any; + const ec = assembleECharts(calInput()) as any; + expect(vl.encoding.x.timeUnit).toBe('yearweek'); // VL: timeUnit grid + expect(ec.series?.[0]?.coordinateSystem).toBe('calendar'); // EC: calendar coord + }); +}); diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index daed293c..af1a890f 100644 --- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md @@ -268,6 +268,7 @@ properties"). Required channels are noted. | `"Boxplot"` | x, y, color, opacity, column, row | category + measure; props `whiskerMethod`, `showOutliers`, `dodge` | | `"ECDF Plot"` | x, color, detail, column, row | x = measure; cumulative distribution (step line); prop `showPoints` | | `"Heatmap"` | x, y, color, column, row | color = the measure | +| `"Calendar Heatmap"` | x, color | x = date; color = daily value (summed per day); GitHub-style week × weekday grid | | `"Line Chart"` | x, y, color, strokeDash, detail, opacity, column, row | props `interpolate`, `showPoints` | | `"Sparkline"` | x, y, color, detail, row, column | x + y required; small-multiple mini trend lines, one per series (series from `color` or `detail`); props `interpolate`, `baseline`, `trendWidth` | | `"Bump Chart"` | x, y, color, detail, column, row | rank-over-time lines | @@ -321,7 +322,7 @@ type column. **Backend coverage.** Vega-Lite supports all of the above. Other backends support a subset (verify if targeting a non-VL backend): -- **ECharts** adds: `"Calendar Heatmap"`, `"Gauge"`, +- **ECharts** adds: `"Gauge"`, `"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`, `"Parallel Coordinates"`, `"Graph"`, `"Tree"`. - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, From ed3b37646c633dfde0ac5c6ad7970ce09435ff7a Mon Sep 17 00:00:00 2001 From: jason-zl190 Date: Sun, 19 Jul 2026 21:40:00 +0800 Subject: [PATCH 02/40] feat(vegalite): canonical GitHub calendar via quantile color scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `github` scheme now maps counts through a quantile scale (equal-count bins → the 5 canonical GitHub buckets) instead of a continuous ramp, so a Calendar Heatmap with `color.scheme: 'github'` renders the discrete, snapped levels people recognize from the GitHub contribution graph. Co-Authored-By: Claude Fable 5 --- packages/flint-js/src/vegalite/templates/calendar.ts | 4 +++- packages/flint-js/tests/calendar-vegalite.test.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/flint-js/src/vegalite/templates/calendar.ts b/packages/flint-js/src/vegalite/templates/calendar.ts index 5d7ced51..dfb4b7c4 100644 --- a/packages/flint-js/src/vegalite/templates/calendar.ts +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -49,7 +49,9 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { const scheme = encScheme && encScheme !== 'default' ? encScheme : 'viridis'; const colorScale = scheme === 'github' - ? { range: GITHUB_RANGE } + // Quantile scale snaps counts into the 5 canonical GitHub buckets + // (equal-count bins → discrete levels), rather than a smooth ramp. + ? { type: 'quantile' as const, range: GITHUB_RANGE } : { scheme: VL_SCHEMES.has(scheme) ? scheme : 'viridis' }; spec.encoding = { diff --git a/packages/flint-js/tests/calendar-vegalite.test.ts b/packages/flint-js/tests/calendar-vegalite.test.ts index 02201e57..7660bef6 100644 --- a/packages/flint-js/tests/calendar-vegalite.test.ts +++ b/packages/flint-js/tests/calendar-vegalite.test.ts @@ -74,11 +74,14 @@ describe('Vega-Lite Calendar Heatmap', () => { expect(spec.encoding.color.field).toBeUndefined(); }); - it('resolves the github scheme to an explicit range (no built-in Vega-Lite scheme)', () => { + it('resolves the github scheme to a quantile scale over the canonical 5-bucket range', () => { const spec = assembleVegaLite(calInput({ scheme: 'github' })) as any; expect(spec.encoding.color.scale.scheme).toBeUndefined(); - expect(Array.isArray(spec.encoding.color.scale.range)).toBe(true); - expect(spec.encoding.color.scale.range[0]).toBe('#ebedf0'); + // Quantile scale → discrete GitHub buckets, not a continuous ramp. + expect(spec.encoding.color.scale.type).toBe('quantile'); + expect(spec.encoding.color.scale.range).toEqual([ + '#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39', + ]); }); it('passes a named scheme straight through to scale.scheme', () => { From 4adde8832b0f781658f19f158ef4ae7af8e6580a Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 6 Aug 2026 00:29:46 -0700 Subject: [PATCH 03/40] Plotly: realize ThemeSpec decisions onto figures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the second backend for the theme pipeline. Stage 2 (`groundTheme`) is reused unchanged — no field in `core/theme` moved to fit Plotly, which is the first evidence that the neutral layer really is neutral. `plotly/theme.ts` writes `DesignDecisions` onto a `{data, layout}` figure: surface, typography, axes, marks, series ink, legend, facet chrome and data labels. Where Plotly cannot do what it is told, it approximates and says so in `figure._theme.report`. `assemblePlotly` also renders `chart_spec.title` for the first time; a house's headline treatment is most of what makes it recognisable, and there was nothing to treat. `scripts/plotly-sheet.ts` renders contact sheets through headless Chrome — plotly.js only runs in a browser — and `scripts/theme-plotly.ts` drives the lab, r2 and real corpora through it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0de04740-1ac7-48d9-844f-92d4e261c27c --- .gitignore | 4 + package-lock.json | 125 +++ package.json | 5 +- packages/flint-js/src/plotly/assemble.ts | 111 +- packages/flint-js/src/plotly/index.ts | 3 + packages/flint-js/src/plotly/theme.ts | 1211 ++++++++++++++++++++++ scripts/plotly-sheet.ts | 165 +++ scripts/theme-plotly.ts | 180 ++++ 8 files changed, 1796 insertions(+), 8 deletions(-) create mode 100644 packages/flint-js/src/plotly/theme.ts create mode 100644 scripts/plotly-sheet.ts create mode 100644 scripts/theme-plotly.ts diff --git a/.gitignore b/.gitignore index a5c88ef0..40ab3ddb 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,7 @@ loops/ # Generated eval-results data for the flint-py results viewer (8.5MB+, contains # run-specific paths/tracebacks). Regenerate locally via tools/build_results_page.py. packages/flint-py/tools/viewer/results.js + +# Theme audit renders and bundled audit scripts +audit-out/ +scripts/.*.mjs diff --git a/package-lock.json b/package-lock.json index badbf71a..7d6a3fae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "puppeteer-core": "^25.4.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -1384,6 +1385,35 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.6", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@puppeteer/browsers/-/browsers-3.0.6.tgz", + "integrity": "sha1-a3cuD8Ed6yVcijwUIZ40oWoqsj0=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, "node_modules/@resvg/resvg-js": { "version": "2.6.2", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", @@ -3484,6 +3514,23 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha1-khpYbe7NDC2LkkLEwbc8OqOf93w=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cliui/-/cliui-9.0.1.tgz", @@ -4019,6 +4066,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1653615", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz", + "integrity": "sha1-xgDgxhlhIVayQipm2Vi6GI2H2+g=", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6940,6 +6994,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha1-6jbPDMMEA2Aa4HTI93twks2rNtE=", + "dev": true, + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mlly/-/mlly-1.8.2.tgz", @@ -6953,6 +7014,16 @@ "ufo": "^1.6.3" } }, + "node_modules/modern-tar": { + "version": "0.7.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/modern-tar/-/modern-tar-0.7.7.tgz", + "integrity": "sha1-ynHXlgNjAHaxBzOwdRzKsoS7we8=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", @@ -7428,6 +7499,24 @@ "node": ">=6" } }, + "node_modules/puppeteer-core": { + "version": "25.4.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/puppeteer-core/-/puppeteer-core-25.4.0.tgz", + "integrity": "sha1-L8ulOpq5TVXxluHkLb55S7rfh1k=", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.6", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/qs": { "version": "6.15.3", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/qs/-/qs-6.15.3.tgz", @@ -8540,6 +8629,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha1-ZeJGKsawrs+uG/rBpPMCcHDbq6o=", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/typescript/-/typescript-5.9.3.tgz", @@ -9491,6 +9587,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha1-9Ru3HCYG6Q49VydgfHKLJdYXtYs=", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/which/-/which-2.0.2.tgz", @@ -9556,6 +9659,28 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ws/-/ws-8.21.1.tgz", + "integrity": "sha1-BFZQzUsSB4CedUcUYiPDgUqa9YY=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 531653b6..191fc32b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "flint-chart-monorepo", "private": true, - "description": "Flint \u2014 semantic chart compiler for AI agents (JS + Python)", + "description": "Flint — semantic chart compiler for AI agents (JS + Python)", "workspaces": [ "packages/flint-js", "packages/flint-mcp", @@ -36,6 +36,7 @@ "devDependencies": { "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", + "puppeteer-core": "^25.4.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -48,4 +49,4 @@ "js-yaml": "^4.2.0", "@hono/node-server": "^2.0.5" } -} \ No newline at end of file +} diff --git a/packages/flint-js/src/plotly/assemble.ts b/packages/flint-js/src/plotly/assemble.ts index 1a1c804a..80ee80da 100644 --- a/packages/flint-js/src/plotly/assemble.ts +++ b/packages/flint-js/src/plotly/assemble.ts @@ -48,6 +48,11 @@ import { plApplyCartesianAxisSpacing, plApplyLayoutToSpec, plApplyTooltips, plAp import { plCombineFacetPanels, niceBounds, type PlotlyFacetPanel } from './facet'; import { normalizeStaticSeries } from '../core/static-series'; import { normalizeChartProperties } from '../core/normalize-properties'; +import { groundTheme, resolveChartDefaults, resolveCompileDefaults } from '../core/theme/ground'; +import { resolveThemeSpec } from '../core/theme/presets'; +import { + realizeThemePlotly, realizeValueLabelsPlotly, plCollectMarkTypes, plCollectPositional, fitPlotlyTitle, +} from './theme'; // --------------------------------------------------------------------------- // Public API @@ -90,10 +95,17 @@ function applyFieldDisplayNames(figure: any, names: Record | und export function assemblePlotly(input: ChartAssemblyInput): any { const chartType = input.chart_spec.chartType; const semanticTypes = input.semantic_types ?? {}; - const sizeCeiling = input.chart_spec.canvasSize; - const baseSize = resolveBaseSize(input.chart_spec.baseSize, sizeCeiling); + // `theme_spec` may name a house Flint ships rather than spell one out. + const themeSpec = resolveThemeSpec(input.theme_spec); + // A house may prefer a size, a stretch budget, a facet gap. Those settle + // before anything is measured, and in a fixed order: the chart spec first, + // the theme's presets under it, flint's own defaults under that. + const themePresets = resolveCompileDefaults(themeSpec, input.options); + const housePresets = themeSpec?.compileDefaults; + const sizeCeiling = input.chart_spec.canvasSize ?? housePresets?.canvasSize; + const baseSize = resolveBaseSize(input.chart_spec.baseSize ?? housePresets?.baseSize, sizeCeiling); const canvasSize = baseSize; - const options = input.options ?? {}; + const options = themePresets.options ?? {}; let chartTemplate = plGetTemplateDef(chartType) as ChartTemplateDef; if (!chartTemplate) { throw new Error(`Unknown Plotly chart type: ${chartType}. Use plAllTemplateDefs to see available types.`); @@ -104,9 +116,21 @@ export function assemblePlotly(input: ChartAssemblyInput): any { const normalizedProps = normalizeChartProperties( chartTemplate.properties, input.chart_spec.chartProperties, ); - const chartProperties = normalizedProps.chartProperties; + let chartProperties = normalizedProps.chartProperties; warnings.push(...normalizedProps.warnings); + // A house's rules about the chart itself — points on a line, a bump chart + // left unsmoothed — change what is drawn, not how it is dressed, so they + // are folded in before the pipeline reads the properties. Anything the + // caller stated already is left alone. Mirrors the VL assembler. + if (themeSpec && !chartProperties) chartProperties = {}; + const chartDefaultsReport = chartProperties + ? resolveChartDefaults( + themeSpec, chartType, chartTemplate.properties, + input.chart_spec.chartProperties, chartProperties, + ) + : []; + // ═══════════════════════════════════════════════════════════════════════ // PRE-PHASE: Static Series Normalization // ═══════════════════════════════════════════════════════════════════════ @@ -418,6 +442,67 @@ export function assemblePlotly(input: ChartAssemblyInput): any { if (chartTemplate.postProcess) chartTemplate.postProcess(figure, instantiateContext); } + // ═══════════════════════════════════════════════════════════════════════ + // HEADLINE + // ═══════════════════════════════════════════════════════════════════════ + // + // Written before theming, because whether the chart has a headline is a + // fact the theme reasons about: a house that omits axis titles is leaning + // on this line to name the measure. The deck rides along on `_deck` — + // Plotly 2.x has no `title.subtitle`, so realization writes it as a second + // styled line rather than as a block of its own. + const headline = input.chart_spec.title?.trim(); + const deck = input.chart_spec.subtitle?.trim(); + if (headline || deck) { + figure.layout.title = { + ...(typeof figure.layout.title === 'object' ? figure.layout.title : {}), + text: headline ?? '', + ...(deck ? { _deck: deck } : {}), + }; + } + + // ═══════════════════════════════════════════════════════════════════════ + // THEME (level 2 grounding → level 3 realization) + // ═══════════════════════════════════════════════════════════════════════ + // + // Runs last: the theme is a style layer over a chart that already fits, so + // it must see the finished figure. Grounding runs whether or not a house + // was named — some design questions (can this chart carry its values, and + // at this density should it?) are Flint's own. Only realization is gated. + const markTypes = plCollectMarkTypes(figure); + const stackedMode = figure.layout?.barmode === 'relative' || figure.layout?.barmode === 'stack' + || (figure.data ?? []).some((t: any) => t?.stackgroup != null); + const design = groundTheme(themeSpec ?? {}, { + chartType, + markChannel: chartTemplate.markCognitiveChannel, + markTypes, + namesOnMarks: (chartProperties as any)?.showSeriesInLabel === true, + channelSemantics, + resolvedTypes: declaration.resolvedTypes as Record | undefined, + axisFlags: declaration.axisFlags, + positional: plCollectPositional(figure, channelSemantics), + layout: layoutResult, + table: values, + canvasSize, + stacked: stackedMode, + partToWhole: markTypes.includes('arc'), + titled: Boolean(figure.layout?.title?.text), + hostSurface: (input.options as any)?.background, + valueLabels: resolvePlValueLabelChoice(chartProperties), + }); + + if (themeSpec) { + const realizeReport = realizeThemePlotly(figure, design, values); + figure._theme = { + id: design.themeId, + report: [...themePresets.report, ...chartDefaultsReport, ...design.report, ...realizeReport], + decisions: design, + }; + } else { + realizeValueLabelsPlotly(figure, design, values); + fitPlotlyTitle(figure); + } + // ═══════════════════════════════════════════════════════════════════════ // RESULT // ═══════════════════════════════════════════════════════════════════════ @@ -443,8 +528,7 @@ export function assemblePlotly(input: ChartAssemblyInput): any { return figure; } -/** Inspect the Plotly legacy (composed) view transformation surface for an input. */ -export function getPlotlyPivot(input: ChartAssemblyInput): PivotSurface | undefined { +/** Inspect the Plotly legacy (composed) view transformation surface for an input. */export function getPlotlyPivot(input: ChartAssemblyInput): PivotSurface | undefined { const spec = assemblePlotly(input); return spec && spec._pivot ? (spec._pivot as PivotSurface) : undefined; } @@ -454,3 +538,18 @@ export function getPlotlyTransform(input: ChartAssemblyInput): TransformSurface const spec = assemblePlotly(input); return spec && spec._transform ? (spec._transform as TransformSurface) : undefined; } + +/** + * The reader's own answer to "print the numbers?". + * + * `showValueLabels` is the control; `showTextLabels` is the older boolean some + * templates still pass, where only `true` is meaningful (`false` means the + * caller never touched it). Mirrors the Vega-Lite assembler. + */ +function resolvePlValueLabelChoice( + chartProperties: Record | undefined, +): 'on' | 'off' | undefined { + const choice = chartProperties?.showValueLabels; + if (typeof choice === 'boolean') return choice ? 'on' : 'off'; + return chartProperties?.showTextLabels === true ? 'on' : undefined; +} diff --git a/packages/flint-js/src/plotly/index.ts b/packages/flint-js/src/plotly/index.ts index ae4acbd2..faf091a8 100644 --- a/packages/flint-js/src/plotly/index.ts +++ b/packages/flint-js/src/plotly/index.ts @@ -25,6 +25,9 @@ export { assemblePlotly, getPlotlyPivot, getPlotlyTransform } from './assemble'; // PL spec instantiation (Phase 2) export { plApplyLayoutToSpec, plApplyTooltips } from './instantiate-spec'; +// PL theme realization (stage 3) +export { realizeThemePlotly, realizeValueLabelsPlotly, plCollectMarkTypes, plCollectPositional, fitPlotlyTitle } from './theme'; + // PL template registry export { plTemplateDefs, diff --git a/packages/flint-js/src/plotly/theme.ts b/packages/flint-js/src/plotly/theme.ts new file mode 100644 index 00000000..38cc19c5 --- /dev/null +++ b/packages/flint-js/src/plotly/theme.ts @@ -0,0 +1,1211 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * ============================================================================= + * STAGE 3: THEME REALIZATION — Plotly backend + * ============================================================================= + * + * Takes the backend-neutral {@link DesignDecisions} produced by stage 2 + * (`core/theme/ground.ts`) and writes them into a Plotly figure + * (`{ data: trace[], layout }`). + * + * The stage boundary is the same one the Vega-Lite realizer obeys + * (design-docs/04-experiment.md §2): this file may *realize* or *approximate* + * what stage 2 decided, and must report every approximation; it may not decide + * anything itself. Any `if (chartType === …)` here would be a bug in stage 2. + * + * What is different about Plotly, and therefore what this file has to fake: + * + * - There is no `config` and no scale/mark separation. Style lives on each + * trace, so a decision about "the series" is a walk over `figure.data`. + * - Bars are sized by `layout.bargap`, not by a mark width, so band occupancy + * is one number for the whole figure rather than per mark. + * - Text on marks is a trace property (`text` + `textposition`), and Plotly + * places inside/outside labels itself — the geometry stage 2 computed + * (`insideMinValue`/`outsideMaxValue`) is handed over as `textposition: + * 'auto'` rather than realized as two filtered layers. + * - A figure may hold several subplot axis pairs (`xaxis2`, `yaxis3`, …) for + * facets and composites. Every axis pass walks all of them. + * ============================================================================= + */ + +import type { DesignDecisions, ThemeReport, ResolvedAxis, ResolvedText } from '../core/theme/types'; +import { parseColor, toHex, mixHex, isDarkSurface, contrastingInk, sampleRamp } from '../core/theme/presence'; + +type Say = (path: string, message: string) => void; + +// --------------------------------------------------------------------------- +// Facts read off the figure (never styles — see the stage boundary above) +// --------------------------------------------------------------------------- + +/** Plotly trace types that carry data values (as opposed to chrome). */ +const CHROME_TRACES = new Set(['table']); + +/** + * The mark family a Plotly trace belongs to, named in the vocabulary stage 2 + * speaks (`bar`, `line`, `point`, `area`, `arc`, `rect`, `boxplot`, `text`). + * + * Grounding reasons about mark *families* — whether the chart draws a + * connected path, whether a mark has an inside a label could sit in. Plotly + * says `scatter` for four different families and distinguishes them by `mode`, + * so the family is reconstructed here. + */ +export function markFamilies(trace: any): string[] { + const t = String(trace?.type ?? 'scatter'); + const out: string[] = []; + switch (t) { + case 'bar': + case 'histogram': + case 'funnel': + case 'waterfall': + out.push('bar'); + break; + case 'pie': + out.push('arc'); + break; + case 'heatmap': + case 'histogram2d': + case 'contour': + out.push('rect'); + break; + case 'box': + out.push('boxplot'); + break; + case 'violin': + out.push('boxplot', 'area'); + break; + case 'choropleth': + case 'choroplethmapbox': + out.push('geoshape'); + break; + case 'indicator': + out.push('text'); + break; + case 'scatter': + case 'scattergl': + case 'scatterpolar': + case 'scattergeo': + case 'barpolar': { + if (t === 'barpolar') { + out.push('arc'); + break; + } + const mode = String(trace?.mode ?? 'lines'); + const filled = trace?.fill && trace.fill !== 'none'; + if (filled) out.push('area'); + if (mode.includes('lines')) out.push('line'); + if (mode.includes('markers')) out.push('point'); + if (mode.includes('text')) out.push('text'); + if (!out.length) out.push('line'); + break; + } + default: + out.push(t); + } + return out; +} + +/** Every mark family present anywhere in the figure. Used to ground before realizing. */ +export function plCollectMarkTypes(figure: any): string[] { + const seen = new Set(); + for (const trace of figure?.data ?? []) { + if (CHROME_TRACES.has(String(trace?.type))) continue; + for (const m of markFamilies(trace)) seen.add(m); + } + return [...seen]; +} + +/** Plotly axis `type` → the encoding type stage 2 reasons about. */ +function axisEncodingType(type: string | undefined): string | undefined { + switch (type) { + case 'category': + return 'nominal'; + case 'date': + return 'temporal'; + case 'linear': + case 'log': + return 'quantitative'; + default: + return undefined; + } +} + +/** + * The facts about position and series that stage 2 is allowed to consult. + * + * Templates are free to name their semantic channels anything (a candlestick + * has no `y`), so `channelSemantics` can be silent about the axes a reader + * will actually see. The axis `type` and the field its title names are facts + * about the chart, not style choices. + */ +export function plCollectPositional( + figure: any, + channelSemantics: Record = {}, +): { + x?: { type?: string; field?: string }; + y?: { type?: string; field?: string }; + color?: { type?: string; field?: string }; + stacked?: boolean; +} { + const layout = figure?.layout ?? {}; + const out: any = {}; + for (const ch of ['x', 'y'] as const) { + const axis = layout[`${ch}axis`]; + const type = axisEncodingType(axis?.type) + ?? (channelSemantics[ch]?.type as string | undefined); + const field = channelSemantics[ch]?.field + ?? (typeof axis?.title?.text === 'string' ? axis.title.text : undefined); + if (type || field) out[ch] = { type, field }; + } + const colorCS = channelSemantics.color; + if (colorCS?.field) out.color = { type: colorCS.type, field: colorCS.field }; + + const barmode = String(layout.barmode ?? ''); + out.stacked = barmode === 'stack' || barmode === 'relative' + || (figure?.data ?? []).some((t: any) => t?.stackgroup != null); + return out; +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +function axisKeys(layout: any, ch: 'x' | 'y'): string[] { + return Object.keys(layout ?? {}).filter((k) => new RegExp(`^${ch}axis\\d*$`).test(k)); +} + +function fontOf(t: ResolvedText | undefined, fallbackFamily?: string): any { + if (!t) return undefined; + const f: any = {}; + if (t.font ?? fallbackFamily) f.family = t.font ?? fallbackFamily; + if (t.fontSize != null) f.size = t.fontSize; + if (t.color) f.color = t.color; + // Plotly has no font-weight/style on most font objects (2.x); bold and + // italic are carried as markup on the string instead — see `styleText`. + return f; +} + +/** True when this text role asks for weight or slope Plotly cannot set as a property. */ +function needsMarkup(t: ResolvedText | undefined): { bold: boolean; italic: boolean } { + const w = t?.fontWeight; + const bold = w === 'bold' || (typeof w === 'number' && w >= 600); + return { bold, italic: t?.fontStyle === 'italic' }; +} + +function styleText(s: string, t: ResolvedText | undefined): string { + const { bold, italic } = needsMarkup(t); + let out = s; + if (bold) out = `${out}`; + if (italic) out = `${out}`; + return out; +} + +/** A dash array in px → Plotly's `dash` string. */ +function dashOf(dash: number[] | undefined): string | undefined { + if (!dash || !dash.length) return undefined; + return dash.map((n) => `${n}px`).join(','); +} + +function isLiteralColor(v: unknown): v is string { + return typeof v === 'string' && /^(#|rgb|hsl)/i.test(v.trim()); +} + +/** Distinct values a field takes in the rows behind the chart. */ +function distinctCount(table: any[], field: string | undefined): number { + if (!field) return 0; + const seen = new Set(); + for (const row of table) if (row?.[field] != null) seen.add(row[field]); + return seen.size; +} + +/** + * Traces that draw the data, as opposed to context the template hard-coded. + * + * A trace whose colour is a literal the template chose *beside* colour-encoded + * traces is context — a band, a target, a reference — and it keeps its role + * (gap 22 in the Vega-Lite log). Here the same test is cruder because Plotly + * has no encodings: a trace is context when the template said so by naming it + * outside the legend (`showlegend: false` on a figure that has a legend) *and* + * it carries no name. + */ +function isContextTrace(trace: any): boolean { + return trace?._role === 'context' || trace?.hoverinfo === 'skip' && !trace?.name; +} + +function dataTraces(figure: any): any[] { + return (figure?.data ?? []).filter( + (t: any) => t && !CHROME_TRACES.has(String(t.type)) && !isContextTrace(t), + ); +} + +// --------------------------------------------------------------------------- +// Entry points +// --------------------------------------------------------------------------- + +export function realizeThemePlotly(figure: any, d: DesignDecisions, table: any[] = []): ThemeReport[] { + const report: ThemeReport[] = []; + const say: Say = (path, message) => report.push({ stage: 'realize', path, message }); + if (!figure) return report; + figure.layout ??= {}; + + applySurface(figure, d); + const titleH = applyTypography(figure, d); + applyAxes(figure, d, table, say); + applyMarks(figure, d, table, say); + applySeriesInk(figure, d, table, say); + const legendH = applyLegend(figure, d, say); + applyFacetChrome(figure, d); + applyDataLabels(figure, d, table, say); + layoutTopChrome(figure, d, titleH, legendH); + + return report; +} + +/** + * Print the value labels, and nothing else. + * + * The mirror of `realizeValueLabelsVegaLite`: value labels belong to Flint, not + * to a house, so an untheme'd Plotly chart still gets its numbers without also + * getting somebody's ink and type. + */ +export function realizeValueLabelsPlotly(figure: any, d: DesignDecisions, table: any[] = []): ThemeReport[] { + const report: ThemeReport[] = []; + const say: Say = (path, message) => report.push({ stage: 'realize', path, message }); + if (!figure) return report; + applyDataLabels(figure, d, table, say); + return report; +} + +// --------------------------------------------------------------------------- +// Surface & typography +// --------------------------------------------------------------------------- + +function applySurface(figure: any, d: DesignDecisions): void { + const layout = figure.layout; + layout.paper_bgcolor = d.surface.canvas; + layout.plot_bgcolor = d.surface.plot ?? d.surface.canvas; + + // Plotly draws no view border; a frame is the four axis lines mirrored. + if (d.frame.show) { + for (const ch of ['x', 'y'] as const) { + for (const key of axisKeys(layout, ch)) { + const ax = (layout[key] ??= {}); + ax.showline = true; + ax.linecolor = d.frame.color; + ax.linewidth = d.frame.width; + ax.mirror = true; + } + } + } +} + +/** + * Type, and the title block. + * + * Returns the height the title block needs, in px. Plotly does not wrap a + * title and does not reserve room for one that overruns the figure, so both + * are done here: the headline is broken to the width it has, and the height + * that costs is handed to {@link layoutTopChrome}. + */ +function applyTypography(figure: any, d: DesignDecisions): number { + const layout = figure.layout; + layout.font = { + ...(layout.font ?? {}), + ...(d.font ? { family: d.font } : {}), + color: d.text.primary, + }; + + const title = layout.title; + const headlineText = typeof title === 'string' ? title : title?.text; + if (!headlineText) return 0; + + // Plotly 2.x has no `title.subtitle`, so a deck is carried as a second line + // of the title with its own inline type. + const h = d.title.headline; + const deckText = (title as any)?._deck as string | undefined; + const deck = d.title.deck; + const width = Number(layout.width) || 400; + + const headlineSize = h.fontSize ?? 16; + const headLines = wrapToWidth( + String(headlineText).replace(/
/g, ' '), + width - 8, + headlineSize, + needsMarkup(h).bold, + ); + const lines = [styleText(headLines.join('
'), h)]; + + let height = headLines.length * headlineSize * 1.35 + 10; + if (deckText) { + const size = deck.fontSize ?? 12; + const color = deck.color ?? d.text.secondary; + const deckLines = wrapToWidth(deckText, width - 8, size, needsMarkup(deck).bold); + const style = [ + `font-size:${size}px`, + `color:${color}`, + ...(deck.font ? [`font-family:${deck.font}`] : []), + ].join(';'); + lines.push(`${styleText(deckLines.join('
'), deck)}
`); + height += deckLines.length * size * 1.35 + d.title.deckPadding; + } + + const anchor = d.title.anchor; + layout.title = { + text: lines.join('
'), + font: { ...fontOf(h, d.font), size: headlineSize }, + x: anchor === 'start' ? 0.005 : anchor === 'end' ? 0.995 : 0.5, + xanchor: anchor === 'start' ? 'left' : anchor === 'end' ? 'right' : 'center', + xref: 'container', + y: titleY(layout, headLines.length + (deckText ? 1 : 0), headlineSize), + yanchor: 'top', + yref: 'container', + }; + return height + d.title.offset; +} + +/** + * Where the title block has to be anchored to sit clear of the top edge. + * + * A multi-line Plotly title grows *upward* from its anchor, so a two-line + * headline anchored at the container top has its first line off the page. The + * anchor drops by the lines above it. + */ +function titleY(layout: any, lines: number, fontSize: number): number { + const height = Number(layout.height) || 300; + return 1 - (8 + Math.max(0, lines - 1) * fontSize * 1.35) / height; +} + +/** Break a line of text to a pixel width, at word boundaries where it can. */ +function wrapToWidth(text: string, width: number, fontSize: number, bold = false): string[] { + const perChar = fontSize * (bold ? 0.63 : 0.55); + const max = Math.max(8, Math.floor(width / perChar)); + if (text.length <= max) return [text]; + const words = text.split(/\s+/); + const lines: string[] = []; + let line = ''; + for (const w of words) { + if (!line) line = w; + else if ((line + ' ' + w).length <= max) line += ' ' + w; + else { + lines.push(line); + line = w; + } + } + if (line) lines.push(line); + return lines; +} + +/** + * Give the title and any legend above the plot room of their own. + * + * Plotly's `automargin` grows the margin *into* the plot, so a chart with a + * two-line headline and a key above it loses that much of its plotting + * rectangle. The chart was already sized to fit; the chrome the theme adds is + * therefore paid for in figure height, not taken out of the plot. + */ +function layoutTopChrome(figure: any, d: DesignDecisions, titleH: number, legendH: number): void { + const layout = figure.layout; + const need = titleH + legendH; + if (need <= 0) return; + const margin = (layout.margin ??= { t: 24, r: 32, b: 56, l: 64 }); + const before = margin.t ?? 0; + margin.t = Math.max(before, need); + const grew = margin.t - before; + if (grew > 0 && Number(layout.height)) layout.height = Math.round(layout.height + grew); + + // The key sits between the title and the plot, in the room just made. It + // hangs from just under the title rather than standing on the plot: a key + // measured a row short then runs into the plot, which reads, where running + // into the headline does not. + if (legendH > 0 && layout.legend && layout.legend.y > 1) { + const plotH = Math.max(1, (Number(layout.height) || 300) - margin.t - (margin.b ?? 0)); + layout.legend.yanchor = 'top'; + layout.legend.y = 1 + Math.max(8, margin.t - titleH) / plotH; + } + void d; +} + +// --------------------------------------------------------------------------- +// Axes +// --------------------------------------------------------------------------- + +function applyAxes(figure: any, d: DesignDecisions, table: any[], say: Say): void { + const layout = figure.layout; + for (const ch of ['x', 'y'] as const) { + const decided = d.axes[ch]; + if (!decided) continue; + for (const key of axisKeys(layout, ch)) { + applyAxis(layout[key] ?? (layout[key] = {}), decided, d, say, key); + } + } + applyUnits(figure, d, say); + applyTickLabels(figure, d, table, say); +} + +function applyAxis(ax: any, a: ResolvedAxis, d: DesignDecisions, say: Say, key: string): void { + // Grid + ax.showgrid = a.grid.show; + if (a.grid.show) { + ax.gridcolor = a.grid.color; + ax.gridwidth = a.grid.width; + const dash = dashOf(a.grid.dash); + if (dash) ax.griddash = dash; + } + + // Zero — Plotly draws one by default, which is a decision the house owns. + if (a.zeroRule?.show) { + ax.zeroline = true; + ax.zerolinecolor = a.zeroRule.color; + ax.zerolinewidth = a.zeroRule.width; + } else { + ax.zeroline = false; + } + + // Domain line + ax.showline = a.domain.show; + if (a.domain.show) { + ax.linecolor = a.domain.color; + ax.linewidth = a.domain.width; + } + + // Ticks + if (a.ticks.show) { + ax.ticks = 'outside'; + ax.ticklen = a.ticks.size; + ax.tickcolor = a.ticks.color; + ax.tickwidth = a.ticks.width; + } else { + ax.ticks = ''; + ax.ticklen = 0; + } + + // Tick labels + ax.showticklabels = a.label.show !== false; + if (ax.showticklabels) { + ax.tickfont = { ...(ax.tickfont ?? {}), ...fontOf(a.label, d.font) }; + if (a.label.angle != null) ax.tickangle = a.label.angle; + if (a.label.padding != null) ax.ticklabelstandoff = a.label.padding; + } + + // Title + const titleText = typeof ax.title === 'string' ? ax.title : ax.title?.text; + if (!a.title.show) { + ax.title = { text: '' }; + } else if (titleText) { + const text = a.title.unit && !titleText.includes(a.title.unit) + ? `${titleText} (${a.title.unit})` + : titleText; + ax.title = { + text: styleText(text, a.title), + font: fontOf(a.title, d.font), + standoff: (ax.title?.standoff ?? 16), + }; + if (a.title.placement === 'flatAboveAxis' && key.startsWith('y')) { + // A y title set flat above the axis is an annotation in Plotly, not + // a title property. Approximated as a rotated title for now. + say('annotation.axisTitlePlacement', 'flat axis title not realized on a y axis — kept rotated'); + } + } + + if (a.tickCount != null && ax.type !== 'category') ax.nticks = a.tickCount; +} + +/** + * The unit the measure carries, written onto the ticks. + * + * Plotly states where a suffix appears with `showticksuffix` — all, first or + * last — which is the same vocabulary the house uses. Only `firstAndLast` has + * no counterpart, and it is approximated with every tick. + */ +function applyUnits(figure: any, d: DesignDecisions, say: Say): void { + const layout = figure.layout; + for (const ch of ['x', 'y'] as const) { + const unit = d.axes[ch]?.unit; + if (!unit || unit.where === 'never' || !unit.text) continue; + const prefix = /^[$£€¥]$/.test(unit.text); + const where = unit.where === 'firstTick' ? 'first' + : unit.where === 'lastTick' ? 'last' + : 'all'; + for (const key of axisKeys(layout, ch)) { + const ax = layout[key]; + if (ax.type === 'category') continue; + if (prefix) { + ax.tickprefix = unit.text; + ax.showtickprefix = where; + } else { + ax.ticksuffix = unit.text; + ax.showticksuffix = where; + } + } + if (unit.where === 'firstAndLast') { + say('annotation.unit', 'unit written on every tick — Plotly states first, last or all, not both ends'); + } + } +} + +/** Which ticks carry a label: the values the data holds, thinned or cut to the ends. */ +function applyTickLabels(figure: any, d: DesignDecisions, table: any[], say: Say): void { + const layout = figure.layout; + for (const ch of ['x', 'y'] as const) { + const a = d.axes[ch]; + if (!a?.tickLabels || a.tickLabels === 'all') continue; + const field = ch === 'x' ? d.bound.categoryField : undefined; + for (const key of axisKeys(layout, ch)) { + const ax = layout[key]; + // A category axis already labels exactly what it holds, and Plotly + // reads `tickvals` there as *positions*, not as names — stating a + // year on one collapses the whole scale onto the first band. + if (ax.type === 'category') continue; + const values = observedValues(ax, table, field); + if (!values.length) continue; + const picked = thin(values, a.tickLabels, key); + if (!picked?.length) continue; + ax.tickmode = 'array'; + ax.tickvals = picked; + if (ax.type === 'date' && !ax.tickformat) { + ax.tickformat = dateFormatFor(picked); + ax.tickangle = 0; + } + say('structure.axis.tickLabels', `${key} ticked at ${picked.length} observed values (${a.tickLabels})`); + } + } +} + +/** The span the ticks cover decides how much of a date needs writing. */ +function dateFormatFor(values: any[]): string { + const times = values.map((v) => new Date(v).getTime()).filter((n) => Number.isFinite(n)); + if (times.length < 2) return '%Y'; + const days = (Math.max(...times) - Math.min(...times)) / 86_400_000; + if (days > 900) return '%Y'; + if (days > 60) return '%b %Y'; + return '%d %b'; +} + +/** The values this axis actually holds, in order. */ +function observedValues(ax: any, table: any[], field: string | undefined): any[] { + if (Array.isArray(ax?.categoryarray)) return ax.categoryarray; + if (Array.isArray(ax?.tickvals)) return ax.tickvals; + if (field) { + const seen = new Set(); + for (const row of table) if (row?.[field] != null) seen.add(row[field]); + return [...seen]; + } + return []; +} + +/** + * Thin a run of observed values to what the axis can label. + * + * The last value is always kept — it is the reading the chart ends on — and + * the one before it is dropped where the two would collide. + */ +function thin(values: any[], mode: string, key: string): any[] | null { + if (values.length < 2) return values; + if (mode === 'endpoints') return [values[0], values[values.length - 1]]; + if (mode === 'observed') return values; + if (mode !== 'sparse') return null; + + const span = key.startsWith('x') ? 6 : 5; + const step = Math.max(1, Math.ceil(values.length / span)); + const picked = values.filter((_, i) => i % step === 0); + const last = values[values.length - 1]; + if (picked[picked.length - 1] !== last) { + // Two ticks a fraction of a step apart print on top of each other. + const lastIndex = values.indexOf(picked[picked.length - 1]); + if (values.length - 1 - lastIndex < step / 2) picked.pop(); + picked.push(last); + } + return picked; +} + +// --------------------------------------------------------------------------- +// Marks +// --------------------------------------------------------------------------- + +/** VL states a point's *area* in px²; Plotly states its diameter in px. */ +function diameterOf(area: number): number { + return Math.max(2, Math.round(2 * Math.sqrt(area / Math.PI))); +} + +function applyMarks(figure: any, d: DesignDecisions, table: any[], say: Say): void { + const layout = figure.layout; + const m = d.marks; + + // Band occupancy is one number for the whole figure: Plotly sizes bars by + // the gap left between them, not by a mark width. + const bars = (figure.data ?? []).filter((t: any) => markFamilies(t).includes('bar')); + if (bars.length) { + layout.bargap = Math.max(0, Math.min(0.9, 1 - m.bandFraction)); + if (layout.barmode === 'group' && bars.length > 1) layout.bargroupgap = 0.05; + } + + for (const trace of figure.data ?? []) { + if (CHROME_TRACES.has(String(trace?.type))) continue; + const fams = markFamilies(trace); + + if (fams.includes('bar') || fams.includes('arc')) { + if (m.outline && !isContextTrace(trace)) { + trace.marker = { + ...(trace.marker ?? {}), + line: { color: m.outline.color, width: m.outline.width }, + }; + } + if (m.cornerRadius != null && trace.type === 'bar' && trace.marker?.cornerradius == null) { + trace.marker = { ...(trace.marker ?? {}), cornerradius: m.cornerRadius }; + } + } + + if (fams.includes('arc') && m.slice) { + // A wedge gap is drawn as a stroke in the surface colour: Plotly has + // no gap between slices. + trace.marker = { + ...(trace.marker ?? {}), + line: { color: m.slice.color || d.surface.canvas, width: m.slice.gap }, + }; + } + + if (fams.includes('rect') && m.tile) { + trace.xgap = m.tile.gap; + trace.ygap = m.tile.gap; + } + + if (fams.includes('line')) { + trace.line = { ...(trace.line ?? {}), width: m.strokeWidth }; + if (m.interpolate) { + const shape = plotlyLineShape(m.interpolate); + if (shape) trace.line.shape = shape; + else say('marks.line.interpolate', `\`${m.interpolate}\` has no Plotly line shape — left straight`); + } + } + + if (fams.includes('area') && m.fillOpacity != null && trace.fillcolor == null) { + trace.opacity = m.fillOpacity; + } + + if (fams.includes('point')) { + const size = m.point?.size; + if (size != null && !Array.isArray(trace.marker?.size)) { + trace.marker = { ...(trace.marker ?? {}), size: diameterOf(size) }; + } + if (m.point?.haloColor && m.point.haloWidth) { + trace.marker = { + ...(trace.marker ?? {}), + line: { color: m.point.haloColor, width: m.point.haloWidth }, + }; + } + } + + if (fams.includes('boxplot') && m.summary) { + if (m.summary.fill === false) trace.fillcolor = 'rgba(0,0,0,0)'; + if (m.summary.widthFraction != null) trace.width = undefined; + } + + // A house that dots its lines says so through the chart's own options + // where the template offers one; where it does not, the dots are added + // here. + if (m.point?.show && fams.includes('line') && !fams.includes('point')) { + trace.mode = String(trace.mode ?? 'lines').includes('markers') + ? trace.mode + : `${trace.mode ?? 'lines'}+markers`; + trace.marker = { + ...(trace.marker ?? {}), + size: diameterOf(m.point.size ?? 40), + }; + } + } + + // A sized mark's range: Plotly sizes by `sizeref` against the largest datum. + if (m.sizeRange) { + for (const trace of figure.data ?? []) { + const sizes = trace?.marker?.size; + if (!Array.isArray(sizes)) continue; + const max = Math.max(...sizes.filter((s: any) => Number.isFinite(s))); + if (!(max > 0)) continue; + trace.marker.sizemode = 'area'; + trace.marker.sizeref = (2 * max) / (m.sizeRange[1] || 400); + trace.marker.sizemin = m.minSize ? diameterOf(m.minSize) : 3; + } + } + + void table; +} + +function plotlyLineShape(interpolate: string): string | undefined { + switch (interpolate) { + case 'monotone': + case 'basis': + case 'cardinal': + case 'catmull-rom': + case 'natural': + return 'spline'; + case 'step': + case 'step-after': + return 'hv'; + case 'step-before': + return 'vh'; + case 'linear': + return 'linear'; + default: + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Series ink +// --------------------------------------------------------------------------- + +/** + * Repaint what the template painted. + * + * The template chose its colours from Flint's own palette against a white page. + * Everything the house has an opinion about is re-stated here; a trace that + * carries per-point colours (a status waterfall, a colour-mapped scatter) is + * re-stated value by value. + */ +function applySeriesInk(figure: any, d: DesignDecisions, table: any[], say: Say): void { + const s = d.series; + if (s.exhausted) { + say('ink.series', 'more series than the house names inks for — template palette kept'); + return; + } + + const traces = dataTraces(figure); + if (!traces.length) return; + + // A continuous colour channel: the ramp goes on the colorscale, not on a + // per-trace colour. + const ramped = traces.filter((t) => t.colorscale != null || t.marker?.colorscale != null); + if (ramped.length && (s.mode === 'sequential' || s.mode === 'diverging' || s.ramp)) { + const stops = s.range ?? s.ramp?.stops; + if (stops?.length) { + const scale = stops.map((c, i) => [stops.length === 1 ? 0 : i / (stops.length - 1), c] as [number, string]); + for (const t of ramped) { + if (t.colorscale != null) t.colorscale = scale; + if (t.marker?.colorscale != null) t.marker.colorscale = scale; + } + } + } + + // Per-series inks. One trace per series is the common Plotly shape; a + // single trace with an array of colours (a bar chart coloured by category) + // is the other. + const inks = s.mode === 'single' ? [s.single] : s.categorical; + if (!inks?.length) return; + + let seriesIndex = 0; + for (const trace of traces) { + if (trace.colorscale != null || trace.marker?.colorscale != null) continue; + const fams = markFamilies(trace); + + // A pie states one colour per slice on `marker.colors` — the whole + // series lives in one trace. + if (Array.isArray(trace.marker?.colors)) { + trace.marker.colors = (trace.marker.colors as any[]).map( + (c: any, i: number) => (isLiteralColor(c) ? inks[i % inks.length] : c), + ); + continue; + } + + const perPoint = Array.isArray(trace.marker?.color) + ? (trace.marker.color as unknown[]).filter(isLiteralColor) + : null; + if (perPoint && perPoint.length) { + // One trace, many colours: keep the mapping the template made, but + // restate each distinct colour in the house's set, in the order the + // template introduced them. + const seen = new Map(); + trace.marker.color = (trace.marker.color as any[]).map((c: any) => { + if (!isLiteralColor(c)) return c; + if (!seen.has(c)) seen.set(c, inks[seen.size % inks.length]); + return seen.get(c); + }); + continue; + } + + const ink = inks[seriesIndex % inks.length]; + if (fams.includes('bar') || fams.includes('arc') || fams.includes('boxplot')) { + trace.marker = { ...(trace.marker ?? {}), color: ink }; + if (trace.type === 'violin' || trace.type === 'box') trace.line = { ...(trace.line ?? {}), color: ink }; + } + if (fams.includes('line')) { + trace.line = { ...(trace.line ?? {}), color: ink }; + } + if (fams.includes('point')) { + trace.marker = { ...(trace.marker ?? {}), color: ink }; + } + if (fams.includes('area')) { + trace.fillcolor = withAlpha(ink, d.marks.fillOpacity ?? 0.8); + } + seriesIndex++; + } + + void table; + void distinctCount; +} + +function withAlpha(hex: string, alpha: number): string { + const c = parseColor(hex); + if (!c) return hex; + return `rgba(${c.r}, ${c.g}, ${c.b}, ${Math.max(0, Math.min(1, alpha))})`; +} + +// --------------------------------------------------------------------------- +// Legend +// --------------------------------------------------------------------------- + +const LEGEND_ANCHORS: Record = { + top: { x: 0, y: 1.12, xanchor: 'left', yanchor: 'bottom', orientation: 'h' }, + bottom: { x: 0, y: -0.2, xanchor: 'left', yanchor: 'top', orientation: 'h' }, + right: { x: 1.02, y: 1, xanchor: 'left', yanchor: 'top', orientation: 'v' }, + left: { x: -0.2, y: 1, xanchor: 'right', yanchor: 'top', orientation: 'v' }, + 'top-left': { x: 0.02, y: 0.98, xanchor: 'left', yanchor: 'top', orientation: 'v' }, + 'top-right': { x: 0.98, y: 0.98, xanchor: 'right', yanchor: 'top', orientation: 'v' }, + 'bottom-left': { x: 0.02, y: 0.02, xanchor: 'left', yanchor: 'bottom', orientation: 'v' }, + 'bottom-right': { x: 0.98, y: 0.02, xanchor: 'right', yanchor: 'bottom', orientation: 'v' }, +}; + +function applyLegend(figure: any, d: DesignDecisions, say: Say): number { + const layout = figure.layout; + const l = d.legend; + + if (!l.show) { + layout.showlegend = false; + for (const trace of figure.data ?? []) { + if (trace?.marker?.colorbar) trace.marker.showscale = false; + if (trace?.colorbar) trace.showscale = false; + if (trace?.showscale) trace.showscale = false; + } + return 0; + } + + // `seriesEnd` names each series at the end of its own line. Realized as + // annotations where there is a line to end; otherwise the house's own + // fallback is taken rather than a placement invented here. + let placement: string = l.placement; + if (placement === 'seriesEnd') { + if (labelSeriesEnds(figure, d, say)) { + layout.showlegend = false; + return 0; + } + placement = (l.fallbacks ?? []).find((p) => p !== 'seriesEnd') ?? 'right'; + say('legend.placement', `no series with an end to name — fell back to \`${placement}\``); + } + if (!LEGEND_ANCHORS[placement]) { + const next = (l.fallbacks ?? []).find((p) => LEGEND_ANCHORS[p]) ?? 'right'; + say('legend.placement', `\`${placement}\` is not a Plotly placement — fell back to \`${next}\``); + placement = next; + } + + const anchor = LEGEND_ANCHORS[l.orient ?? placement] ?? LEGEND_ANCHORS[placement] ?? LEGEND_ANCHORS.right; + layout.showlegend = true; + layout.legend = { + ...(layout.legend ?? {}), + ...anchor, + ...(l.direction ? { orientation: l.direction === 'horizontal' ? 'h' : 'v' } : {}), + font: fontOf(l.label, d.font), + bgcolor: 'rgba(0,0,0,0)', + borderwidth: 0, + title: l.title ? (layout.legend?.title ?? undefined) : { text: '' }, + }; + + // A key to values is a colorbar, not a swatch list. + for (const trace of figure.data ?? []) { + const bar = trace?.marker?.colorbar ?? trace?.colorbar; + if (!bar) continue; + Object.assign(bar, { + outlinewidth: 0, + tickfont: fontOf(l.label, d.font), + ...(l.gradientLength ? { len: l.gradientLength, lenmode: 'pixels' } : {}), + ...(l.title ? {} : { title: { text: '' } }), + }); + } + + // A horizontal key above the plot is chrome the figure has to pay for. + if (layout.legend.orientation === 'h' && layout.legend.y > 1) { + return horizontalLegendHeight(figure, l.label.fontSize ?? 11, needsMarkup(l.label).bold); + } + // A key beside the plot is paid for in width, for the same reason. + if (layout.legend.orientation === 'v' && layout.legend.x >= 1) { + const entries = legendEntries(figure); + if (entries.length) { + const size = l.label.fontSize ?? 11; + const need = Math.ceil(Math.max(...entries.map((e) => e.length)) * size * 0.6) + 40; + const margin = (layout.margin ??= {}); + const before = margin.r ?? 0; + margin.r = Math.max(before, need); + const grew = margin.r - before; + if (grew > 0 && Number(layout.width)) layout.width = Math.round(layout.width + grew); + } + } + return 0; +} + +/** The names a key will carry: one per trace, or one per slice of a pie. */ +function legendEntries(figure: any): string[] { + const out: string[] = []; + for (const t of figure.data ?? []) { + if (t?.showlegend === false) continue; + if (Array.isArray(t?.labels)) out.push(...t.labels.map(String)); + else if (t?.name) out.push(String(t.name)); + } + return out; +} + +/** How tall a horizontal key stacks once its entries are packed into the width. */ +function horizontalLegendHeight(figure: any, fontSize: number, bold = false): number { + const entries = legendEntries(figure); + if (!entries.length) return 0; + const width = Number(figure.layout?.width) || 400; + const rowHeight = fontSize + 14; + let rows = 1; + let used = 0; + for (const e of entries) { + const w = e.length * fontSize * (bold ? 0.72 : 0.62) + 46; + if (used > 0 && used + w > width) { + rows++; + used = w; + } else used += w; + } + return rows * rowHeight + 8; +} + +/** + * Where an annotation must sit to land on this value. + * + * A category axis is numbered from zero in the order the categories appear, so + * an annotation naming one has to state its serial number. + */ +function categoryPosition(ax: any, value: any): any { + if (ax?.type !== 'category') return value; + const cats = Array.isArray(ax.categoryarray) ? ax.categoryarray : null; + const i = cats ? cats.findIndex((c: any) => String(c) === String(value)) : -1; + return i >= 0 ? i : value; +} + +/** + * Push a stack of end-of-line names apart so none sits on another. + * + * Series that finish close together would otherwise print their names on the + * same few pixels. The names are nudged in data units — the annotation still + * points at the line's real last value, it is only shifted enough to be read. + */ +function dodgeVertically(notes: any[], figure: any, fontSize: number, traces: any[]): void { + if (notes.length < 2) return; + const layout = figure.layout; + const values = notes.map((a) => Number(a.y)).filter((v) => Number.isFinite(v)); + if (values.length !== notes.length) return; + + // A text line is worth a share of the *axis*, not of the band the names + // happen to fall in: measuring against the names alone under-counts the + // gap several times over on a chart whose scale starts at zero. + const ax = layout[(notes[0].yref ?? 'y').replace('y', 'yaxis')] ?? {}; + const all: number[] = []; + for (const t of traces) for (const v of t.y ?? []) if (Number.isFinite(Number(v))) all.push(Number(v)); + let span: number; + if (Array.isArray(ax.range) && ax.range.length === 2) span = Math.abs(Number(ax.range[1]) - Number(ax.range[0])); + else if (all.length) span = Math.max(...all, ax.rangemode === 'tozero' ? 0 : -Infinity) - Math.min(...all, 0); + else span = Math.max(...values) - Math.min(...values); + if (!span || !Number.isFinite(span)) return; + const plotH = Math.max( + 40, + (Number(layout.height) || 300) - (layout.margin?.t ?? 0) - (layout.margin?.b ?? 0), + ); + // The whole axis is taller than the band the names occupy, so this is a + // conservative (over-)estimate of how many data units a text line is worth. + const gap = (fontSize * 1.25 * span) / plotH; + const order = notes.slice().sort((a, b) => a.y - b.y); + for (let i = 1; i < order.length; i++) { + const below = Number(order[i - 1].y); + if (Number(order[i].y) - below < gap) order[i].y = below + gap; + } +} + +/** + * Name each series at the end of its own line. + * + * Returns false where nothing on the chart has an end to name — a bar chart + * has no last point — so the caller can take the house's next placement. + */ +function labelSeriesEnds(figure: any, d: DesignDecisions, say: Say): boolean { + const lines = dataTraces(figure).filter((t) => { + const fams = markFamilies(t); + return (fams.includes('line') || fams.includes('area')) && Array.isArray(t.x) && Array.isArray(t.y) && t.name; + }); + if (lines.length < 1) return false; + + const layout = figure.layout; + const annotations = (layout.annotations ??= []); + const placed: any[] = []; + for (const t of lines) { + const n = t.x.length; + if (!n) continue; + const axisKey = (t.xaxis ?? 'x').replace('x', 'xaxis'); + placed.push({ + // On a category axis Plotly reads an annotation's `x` as the + // category's *serial number*, not its name — naming the category + // there pushes the annotation thousands of bands to the right and + // drags the scale with it. + x: categoryPosition(layout[axisKey], t.x[n - 1]), + y: t.y[n - 1], + xref: t.xaxis ?? 'x', + yref: t.yaxis ?? 'y', + text: styleText(String(t.name), d.legend.label), + showarrow: false, + xanchor: 'left', + xshift: 6, + font: { + ...fontOf(d.legend.label, d.font), + color: t.line?.color ?? t.marker?.color ?? d.legend.label.color, + }, + }); + } + dodgeVertically(placed, figure, d.legend.label.fontSize ?? 11, lines); + annotations.push(...placed); + + // The names sit outside the plotting rectangle; make room for them. + const longest = Math.max(...lines.map((t) => String(t.name).length)); + const pad = Math.ceil(longest * (d.legend.label.fontSize ?? 11) * 0.55) + 12; + layout.margin = { ...(layout.margin ?? {}), r: Math.max(layout.margin?.r ?? 0, pad) }; + say('legend.placement', `series named at the end of each line (${lines.length})`); + return true; +} + +// --------------------------------------------------------------------------- +// Facet chrome +// --------------------------------------------------------------------------- + +function applyFacetChrome(figure: any, d: DesignDecisions): void { + const f = d.facets.header; + for (const a of figure.layout?.annotations ?? []) { + if (a?._role !== 'facet-header') continue; + if (!f.show) { + a.text = ''; + continue; + } + a.font = { ...(a.font ?? {}), ...fontOf(f, d.font) }; + a.text = styleText(String(a.text ?? '').replace(/^.*?: /, f.fieldTitle ? '$&' : ''), f); + } +} + +// --------------------------------------------------------------------------- +// Data labels +// --------------------------------------------------------------------------- + +/** Trace families that can print a number on the mark. */ +const LABELABLE = new Set(['bar', 'arc', 'point', 'line']); + +function applyDataLabels(figure: any, d: DesignDecisions, table: any[], say: Say): void { + const dl = d.dataLabels; + if (!dl.show) return; + + const traces = dataTraces(figure).filter((t) => markFamilies(t).some((m) => LABELABLE.has(m))); + if (!traces.length) { + say('dataLabels.show', 'nothing on this chart can carry a printed value'); + return; + } + + const fmt = dl.format ? `:${dl.format}` : ''; + const unit = dl.unit ?? ''; + let printed = 0; + + for (const trace of traces) { + const fams = markFamilies(trace); + if (trace.text != null || trace.texttemplate != null || trace.textinfo != null) { + // The template prints its own numbers. The theme may not change + // *what* is written — that was the template's decision — but the + // type it is written in is the house's. + trace.textfont = { ...(trace.textfont ?? {}), ...fontOf(dl.text, d.font) }; + if (dl.inkMode === 'contrastWithMark' && trace.textfont) delete trace.textfont.color; + printed++; + continue; + } + if (fams.includes('arc')) { + trace.textinfo = 'value'; + trace.texttemplate = `%{value${fmt}}${unit}`; + trace.textposition = dl.placement === 'outsideMark' ? 'outside' : 'inside'; + trace.textfont = fontOf(dl.text, d.font); + if (dl.inkMode === 'contrastWithMark') delete trace.textfont.color; + printed++; + continue; + } + + if (fams.includes('bar')) { + const measure = trace.orientation === 'h' ? 'x' : 'y'; + trace.texttemplate = `%{${measure}${fmt}}${unit}`; + // Plotly places the label inside where it fits and outside where it + // does not, which is exactly the geometry stage 2 computed with + // `insideMinValue`/`outsideMaxValue`. `auto` hands that decision to + // the renderer, which can measure the drawn bar; `outside` is + // honoured literally because it is a house habit, not a fit. + trace.textposition = dl.placement === 'outsideMark' ? 'outside' : 'auto'; + trace.cliponaxis = false; + trace.textfont = fontOf(dl.text, d.font); + if (dl.inkMode === 'contrastWithMark') { + // Plotly picks a contrasting ink itself when none is stated + // *inside* the bar, and uses `outsidetextfont` beyond it. + delete trace.textfont.color; + trace.outsidetextfont = fontOf(dl.text, d.font); + } + printed++; + continue; + } + + if (fams.includes('point') || fams.includes('line')) { + const measure = 'y'; + trace.mode = String(trace.mode ?? 'lines').includes('text') + ? trace.mode + : `${trace.mode ?? 'lines'}+text`; + trace.texttemplate = `%{${measure}${fmt}}${unit}`; + trace.textposition = 'top center'; + trace.textfont = fontOf(dl.text, d.font); + trace.cliponaxis = false; + printed++; + } + } + + if (!printed) say('dataLabels.show', 'every mark already prints its own text — theme labels stood down'); + void table; + void mixHex; + void toHex; + void isDarkSurface; + void contrastingInk; + void sampleRamp; +} + +/** + * Break a Plotly title to the width it has, with no house in sight. + * + * Plotly neither wraps a title nor reserves room for one, so a headline longer + * than the figure is simply cut off at both ends. That is Flint's own bug, not + * a theme's, so it is fixed on the untheme'd path too. + */ +export function fitPlotlyTitle(figure: any): void { + const layout = figure?.layout; + const title = layout?.title; + const text = typeof title === 'string' ? title : title?.text; + if (!text) return; + const size = title?.font?.size ?? 17; + const width = Number(layout.width) || 400; + const lines = wrapToWidth(String(text).replace(/
/g, ' '), width - 16, size); + const deck = title?._deck as string | undefined; + const all = deck ? [...lines, ...wrapToWidth(deck, width - 16, size * 0.75)] : lines; + layout.title = { + ...(typeof title === 'object' ? title : {}), + text: deck + ? `${lines.join('
')}
${deck}` + : lines.join('
'), + x: 0.5, + xanchor: 'center', + xref: 'container', + y: titleY(layout, all.length, size), + yanchor: 'top', + yref: 'container', + }; + const need = all.length * size * 1.35 + 16; + const margin = (layout.margin ??= {}); + const before = margin.t ?? 0; + margin.t = Math.max(before, need); + const grew = margin.t - before; + if (grew > 0 && Number(layout.height)) layout.height = Math.round(layout.height + grew); +} diff --git a/scripts/plotly-sheet.ts b/scripts/plotly-sheet.ts new file mode 100644 index 00000000..05e0cc9e --- /dev/null +++ b/scripts/plotly-sheet.ts @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Offline Plotly renderer for the theme audits. + * + * Vega-Lite compiles and renders in Node; Plotly does not — it is a browser + * library that measures text with the DOM. So the contact sheets are drawn by + * the real thing: a headless Chrome with `plotly.js-dist-min` loaded from + * `node_modules`, one page holding a grid of plots, screenshotted at 2×. + * + * Nothing here knows about themes. It takes figures and gives back PNGs, so + * the same helper serves the lab, the R2 corpus and the real-data sweep. + */ + +import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import puppeteer, { type Browser } from 'puppeteer-core'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PLOTLY_JS = resolve(__dirname, '../node_modules/plotly.js-dist-min/plotly.min.js'); + +const CHROME_CANDIDATES = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/google-chrome', + '/usr/bin/chromium-browser', + '/usr/bin/chromium', +]; + +export interface PlotlyPanel { + label: string; + /** `{ data, layout }`, or null when assembly failed. */ + figure: any | null; + /** Shown instead of a plot when the figure is missing. */ + error?: string; +} + +export interface PlotlySheet { + /** Output file stem — written as `/.png`. */ + name: string; + heading: string; + panels: PlotlyPanel[]; + cols?: number; +} + +function chromePath(): string { + const found = CHROME_CANDIDATES.find((p) => existsSync(p)); + if (!found) { + throw new Error( + 'No Chrome/Edge/Chromium found. Set one of:\n ' + CHROME_CANDIDATES.join('\n '), + ); + } + return found; +} + +/** The size a figure asks for, with Plotly's own defaults as the floor. */ +function sizeOf(figure: any): { width: number; height: number } { + const w = Number(figure?.layout?.width) || Number(figure?._width) || 420; + const h = Number(figure?.layout?.height) || Number(figure?._height) || 320; + return { width: Math.max(180, w), height: Math.max(150, h) }; +} + +function esc(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>'); +} + +/** Drop the `_internal` keys the assemblers hang off the figure. */ +function cleanFigure(figure: any): any { + return { + data: figure?.data ?? [], + layout: figure?.layout ?? {}, + config: { staticPlot: true, displayModeBar: false }, + }; +} + +export class PlotlyRenderer { + private browser: Browser | null = null; + private plotlySrc = ''; + + async open(): Promise { + this.plotlySrc = readFileSync(PLOTLY_JS, 'utf8'); + this.browser = await puppeteer.launch({ + executablePath: chromePath(), + headless: true, + args: ['--no-sandbox', '--disable-gpu', '--font-render-hinting=none'], + }); + } + + async close(): Promise { + await this.browser?.close(); + this.browser = null; + } + + /** Render one contact sheet to `/.png`. Returns any per-panel render errors. */ + async sheet(s: PlotlySheet, outDir: string): Promise { + if (!this.browser) throw new Error('renderer not open'); + mkdirSync(outDir, { recursive: true }); + + const cols = s.cols ?? 4; + const cellW = Math.ceil(Math.max(...s.panels.map((p) => sizeOf(p.figure).width))) + 2; + const cellH = Math.ceil(Math.max(...s.panels.map((p) => sizeOf(p.figure).height))) + 2; + + const page = await this.browser.newPage(); + await page.setViewport({ + width: cols * (cellW + 10) + 20, + height: Math.max(400, Math.ceil(s.panels.length / cols) * (cellH + 26) + 60), + deviceScaleFactor: 2, + }); + + const cells = s.panels + .map( + (p, i) => ` +
+
${esc(p.label)}
+
${ + p.figure ? '' : `
${esc(p.error ?? 'no figure')}
` + }
+
`, + ) + .join(''); + + await page.setContent( + ` + +

${esc(s.heading)}

${cells}
`, + { waitUntil: 'domcontentloaded' }, + ); + await page.addScriptTag({ content: this.plotlySrc }); + + const errors: string[] = await page.evaluate(async (figs: Array) => { + const out: string[] = []; + for (let i = 0; i < figs.length; i++) { + const f = figs[i]; + if (!f) continue; + try { + await (window as any).Plotly.newPlot(`p${i}`, f.data, f.layout, f.config); + } catch (err) { + out.push(`panel ${i}: ${(err as Error).message}`); + const el = document.getElementById(`p${i}`); + if (el) el.innerHTML = `
RENDER FAILED — ${(err as Error).message}
`; + } + } + return out; + }, s.panels.map((p) => (p.figure ? cleanFigure(p.figure) : null)) as any); + + const el = await page.$('#sheet'); + const png = (await el!.screenshot({ type: 'png' })) as Buffer; + writeFileSync(resolve(outDir, `${s.name}.png`), png); + await page.close(); + return errors; + } +} diff --git a/scripts/theme-plotly.ts b/scripts/theme-plotly.ts new file mode 100644 index 00000000..f16261a4 --- /dev/null +++ b/scripts/theme-plotly.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Plotly theme lab — contact sheets of one chart under every house. + * + * flint | nyt | economist | swiss | nature | mckinsey | datawrapper | powerbi | … + * + * Written to `audit-out/plotly-/.png`, plus `_report.txt` with every + * ground/realize report and every assembly or render failure. + * + * Three corpora, in the order the Vega-Lite experiment used them: + * --set lab a hand-picked handful of core chart types (start small) + * --set r2 the synthetic R2 corpus (clean cardinalities, wide coverage) + * --set real the real-world datasets (long labels, negatives, real shapes) + * + * Run: + * npx esbuild scripts/theme-plotly.ts --bundle --platform=node --format=esm \ + * --outfile=scripts/.plotly.mjs --external:puppeteer-core --log-level=error \ + * --alias:flint-chart/test-data=./packages/flint-js/src/test-data/index.ts \ + * --alias:flint-chart=./packages/flint-js/src/index.ts + * node scripts/.plotly.mjs --set lab + * node scripts/.plotly.mjs --set r2 bar line # filter by id/chart type + */ + +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +import { assemblePlotly, plGetTemplateDef } from '../packages/flint-js/src/index'; +import { THEME_PRESETS } from '../packages/flint-js/src/core/theme/presets'; +import { PREVIEW_CASES, type PreviewCase } from '../site/src/shared/preview-cases'; +import { R2_CASES, r2Input, type R2Case } from '../site/src/playground/theme-lab-r2-data'; +import { PlotlyRenderer, type PlotlyPanel } from './plotly-sheet'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const THEME_IDS = Object.keys(THEME_PRESETS); +const COLUMNS = ['flint', ...THEME_IDS]; + +/** The starting set: one chart of each core family, on real data. */ +const LAB_IDS = ['browser-pie', 'causes-death', 'keeling', 'penguins', 'life-expectancy', 'temp-heatmap']; + +interface Case { + id: string; + heading: string; + input: () => any; +} + +function realCase(c: PreviewCase): Case { + return { + id: c.id, + heading: `${c.id} · ${c.chartType} · ${c.title}`, + input: () => ({ + data: { values: c.data }, + semantic_types: c.semantic_types, + chart_spec: { + chartType: c.chartType, + title: c.title, + encodings: c.encodings, + baseSize: { width: 320, height: 300 }, + ...(c.chartProperties ? { chartProperties: c.chartProperties } : {}), + }, + }), + }; +} + +function r2Wrapped(c: R2Case): Case { + return { + id: c.id, + heading: `${c.id} · ${c.gen}[${c.index}] · ${c.probe}`, + input: () => r2Input(c), + }; +} + +function corpus(set: string, filters: string[]): Case[] { + let cases: Case[]; + if (set === 'r2') { + cases = R2_CASES.map(r2Wrapped); + } else if (set === 'real') { + cases = PREVIEW_CASES.map(realCase); + } else { + cases = PREVIEW_CASES.filter((c) => LAB_IDS.includes(c.id)).map(realCase); + } + if (filters.length) { + cases = cases.filter((c) => + filters.some((f) => c.id.toLowerCase().includes(f.toLowerCase()) + || c.heading.toLowerCase().includes(f.toLowerCase())), + ); + } + return cases; +} + +function stripInternal(node: any, depth = 0): void { + if (!node || typeof node !== 'object' || depth > 8) return; + if (Array.isArray(node)) return node.forEach((n) => stripInternal(n, depth + 1)); + for (const key of Object.keys(node)) { + if (/^_[^_]/.test(key)) delete node[key]; + else stripInternal(node[key], depth + 1); + } +} + +function build(c: Case, themeId: string | null): { figure: any | null; report: any[]; error?: string } { + try { + const input = c.input(); + const chartType = input.chart_spec.chartType; + if (!plGetTemplateDef(chartType)) { + return { figure: null, report: [], error: `no Plotly template for \`${chartType}\`` }; + } + const figure = assemblePlotly( + themeId ? { ...input, theme_spec: THEME_PRESETS[themeId].spec } : input, + ); + const report = figure?._theme?.report ?? []; + const clean = { data: figure.data, layout: figure.layout }; + stripInternal(clean); + return { figure: clean, report }; + } catch (err) { + return { figure: null, report: [], error: (err as Error).message }; + } +} + +async function main(): Promise { + const argv = process.argv.slice(2); + const setIdx = argv.indexOf('--set'); + const set = setIdx >= 0 ? argv[setIdx + 1] : 'lab'; + const filters = argv.filter((a, i) => !a.startsWith('--') && i !== setIdx + 1); + + const out = resolve(__dirname, `../audit-out/plotly-${set}`); + const cases = corpus(set, filters); + if (!cases.length) throw new Error(`no cases for set \`${set}\` with filters ${filters.join(',')}`); + if (!filters.length) rmSync(out, { recursive: true, force: true }); + mkdirSync(out, { recursive: true }); + + const renderer = new PlotlyRenderer(); + await renderer.open(); + + const reportLines: string[] = []; + const failures: string[] = []; + let written = 0; + + for (const c of cases) { + const panels: PlotlyPanel[] = []; + for (const col of COLUMNS) { + const built = build(c, col === 'flint' ? null : col); + panels.push({ label: col, figure: built.figure, error: built.error }); + const notes = built.report.map((r: any) => `[${r.stage}] ${r.path} — ${r.message}`); + if (built.error) { + notes.push(`ASSEMBLE FAILED — ${built.error}`); + failures.push(`${c.id}.${col}: ${built.error}`); + } + if (notes.length) { + reportLines.push(`${c.id}.${col}`); + for (const n of notes) reportLines.push(` ${n}`); + } + } + const errs = await renderer.sheet( + { name: c.id, heading: c.heading, panels, cols: 4 }, + out, + ); + for (const e of errs) { + failures.push(`${c.id}: RENDER ${e}`); + reportLines.push(`${c.id} RENDER FAILED — ${e}`); + } + written++; + process.stdout.write(`\r${written}/${cases.length} ${c.id.padEnd(30)}`); + } + + await renderer.close(); + writeFileSync(resolve(out, '_report.txt'), reportLines.join('\n') + '\n'); + console.log(`\nwrote ${written} contact sheets to ${out}`); + if (failures.length) { + console.log(`\n${failures.length} failures:`); + for (const f of failures.slice(0, 40)) console.log(` ${f}`); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From da3cbe34d050efb5d9cfd637e97c89bd631e7762 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 6 Aug 2026 00:54:48 -0700 Subject: [PATCH 04/40] Plotly theme: chrome placed by domain, and ink by direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second and third gap batches from the lab audits. Plotly places a KPI card, a radar and a map by `domain`, measured against the paper — growing the top margin does not move them, so the headline printed straight through the number. Their domains are shortened instead. The title geometry is now measured rather than guessed: scripting the real renderer shows `yanchor: 'top'` centres the block on `y`, which is why multi-line headlines kept losing their first line off the top of the figure. A waterfall and a candlestick colour by direction, not by series, so the house's categorical set never reached them; they take its status inks now. A mirrored axis (a population pyramid) prints unsigned labels, as its own axis does. Stacked segments keep their labels inside and drop the ones that will not fit rather than shrinking them. Map bubbles are rescaled into the house's size range directly, and a map's land, ocean and borders come from the house's surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0de04740-1ac7-48d9-844f-92d4e261c27c --- packages/flint-js/src/plotly/theme.ts | 301 ++++++++++++++++++++++++-- scripts/theme-plotly.ts | 10 + 2 files changed, 290 insertions(+), 21 deletions(-) diff --git a/packages/flint-js/src/plotly/theme.ts b/packages/flint-js/src/plotly/theme.ts index 38cc19c5..4107e69a 100644 --- a/packages/flint-js/src/plotly/theme.ts +++ b/packages/flint-js/src/plotly/theme.ts @@ -30,7 +30,13 @@ * ============================================================================= */ -import type { DesignDecisions, ThemeReport, ResolvedAxis, ResolvedText } from '../core/theme/types'; +import type { + DesignDecisions, + ThemeReport, + ResolvedAxis, + ResolvedText, + ResolvedSeriesInk, +} from '../core/theme/types'; import { parseColor, toHex, mixHex, isDarkSurface, contrastingInk, sampleRamp } from '../core/theme/presence'; type Say = (path: string, message: string) => void; @@ -250,6 +256,7 @@ export function realizeThemePlotly(figure: any, d: DesignDecisions, table: any[] figure.layout ??= {}; applySurface(figure, d); + applyGeoSurface(figure, d); const titleH = applyTypography(figure, d); applyAxes(figure, d, table, say); applyMarks(figure, d, table, say); @@ -316,6 +323,13 @@ function applyTypography(figure: any, d: DesignDecisions): number { color: d.text.primary, }; + // A card states its own caption and number inside the trace. + for (const trace of figure.data ?? []) { + if (trace?.type !== 'indicator') continue; + trace.title = { ...(trace.title ?? {}), font: { ...(trace.title?.font ?? {}), ...(d.font ? { family: d.font } : {}), color: d.text.secondary } }; + trace.number = { ...(trace.number ?? {}), font: { ...(trace.number?.font ?? {}), ...(d.font ? { family: d.font } : {}), color: d.text.primary } }; + } + const title = layout.title; const headlineText = typeof title === 'string' ? title : title?.text; if (!headlineText) return 0; @@ -336,7 +350,7 @@ function applyTypography(figure: any, d: DesignDecisions): number { ); const lines = [styleText(headLines.join('
'), h)]; - let height = headLines.length * headlineSize * 1.35 + 10; + let height = 8 + headLines.length * headlineSize * 1.35 + 6; if (deckText) { const size = deck.fontSize ?? 12; const color = deck.color ?? d.text.secondary; @@ -365,15 +379,19 @@ function applyTypography(figure: any, d: DesignDecisions): number { } /** - * Where the title block has to be anchored to sit clear of the top edge. + * Where the title block has to be anchored to sit 8px clear of the top edge. * - * A multi-line Plotly title grows *upward* from its anchor, so a two-line - * headline anchored at the container top has its first line off the page. The - * anchor drops by the lines above it. + * Plotly's `yanchor: 'top'` on a title is not a top anchor: measured, the block + * comes out *centred* on `y`, with a constant offset of about 0.59 of a line + * from the block's own baseline. So a two-line headline placed at the top of + * the container has its first line off the page. Solving that measurement for + * "block top = 8px" gives the expression below; it is calibrated against the + * real renderer for one, two and three lines. */ function titleY(layout: any, lines: number, fontSize: number): number { const height = Number(layout.height) || 300; - return 1 - (8 + Math.max(0, lines - 1) * fontSize * 1.35) / height; + const line = fontSize * 1.35; + return 1 - (8 + line * (Math.max(1, lines) / 2 - 0.59)) / height; } /** Break a line of text to a pixel width, at word boundaries where it can. */ @@ -413,6 +431,7 @@ function layoutTopChrome(figure: any, d: DesignDecisions, titleH: number, legend margin.t = Math.max(before, need); const grew = margin.t - before; if (grew > 0 && Number(layout.height)) layout.height = Math.round(layout.height + grew); + reserveAboveDomains(figure, need); // The key sits between the title and the plot, in the room just made. It // hangs from just under the title rather than standing on the plot: a key @@ -426,6 +445,62 @@ function layoutTopChrome(figure: any, d: DesignDecisions, titleH: number, legend void d; } +/** + * Push domain-positioned traces below the title block. + * + * A KPI card, gauge or pie placed by `domain` is laid out in fractions of the + * *paper*, not of the plotting rectangle, so growing the top margin does not + * move it: the headline lands on top of the number. The domain has to be + * shortened by hand. + */ +function reserveAboveDomains(figure: any, need: number): void { + const height = Number(figure?.layout?.height) || 0; + if (!height || need <= 0) return; + const top = Math.max(0.25, 1 - need / height); + const shorten = (owner: any): void => { + const y = owner?.domain?.y; + if (!Array.isArray(y) || y.length !== 2) return; + if (Number(y[1]) > top) owner.domain = { ...owner.domain, y: [Number(y[0]) * top, top] }; + }; + for (const trace of figure.data ?? []) shorten(trace); + // A polar, geographic or ternary plot is placed the same way, but from the + // layout rather than the trace. + for (const key of Object.keys(figure.layout ?? {})) { + if (!/^(polar|geo|ternary|scene|smith|map|mapbox)\d*$/.test(key)) continue; + const owner = figure.layout[key]; + if (owner?.domain?.y) shorten(owner); + else if (owner && typeof owner === 'object') owner.domain = { y: [0, top] }; + } +} + +/** + * A map has its own canvas, land and borders, none of which are axes. + * + * Left alone they stay Plotly's white-and-grey, which puts a white card in the + * middle of a dark house. + */ +function applyGeoSurface(figure: any, d: DesignDecisions): void { + const layout = figure?.layout ?? {}; + const plot = d.surface.plot ?? d.surface.canvas; + for (const key of Object.keys(layout)) { + if (!/^geo\d*$/.test(key)) continue; + const geo = layout[key]; + if (!geo || typeof geo !== 'object') continue; + const land = mixHex(plot, d.text.primary, 0.1); + const border = mixHex(plot, d.text.primary, 0.28); + Object.assign(geo, { + bgcolor: plot, + landcolor: land, + oceancolor: plot, + lakecolor: plot, + subunitcolor: border, + countrycolor: border, + coastlinecolor: border, + framecolor: border, + }); + } +} + // --------------------------------------------------------------------------- // Axes // --------------------------------------------------------------------------- @@ -480,8 +555,10 @@ function applyAxis(ax: any, a: ResolvedAxis, d: DesignDecisions, say: Say, key: ax.ticklen = 0; } - // Tick labels - ax.showticklabels = a.label.show !== false; + // Tick labels. A facet grid hides the labels on every panel but the + // outermost, which is a fact about the layout, not a house preference — + // turning them back on prints the same scale four times. + ax.showticklabels = a.label.show !== false && ax.showticklabels !== false; if (ax.showticklabels) { ax.tickfont = { ...(ax.tickfont ?? {}), ...fontOf(a.label, d.font) }; if (a.label.angle != null) ax.tickangle = a.label.angle; @@ -508,7 +585,15 @@ function applyAxis(ax: any, a: ResolvedAxis, d: DesignDecisions, say: Say, key: } } - if (a.tickCount != null && ax.type !== 'category') ax.nticks = a.tickCount; + // A tick budget is stated for a whole axis. A facet panel holds a fraction + // of the width, so it gets that fraction of the budget — otherwise the + // last tick of one panel prints on top of the first tick of the next. + if (a.tickCount != null && ax.type !== 'category') { + const dom = Array.isArray(ax.domain) && ax.domain.length === 2 + ? Math.abs(Number(ax.domain[1]) - Number(ax.domain[0])) + : 1; + ax.nticks = Math.max(2, Math.round(a.tickCount * (Number.isFinite(dom) ? dom : 1))); + } } /** @@ -715,16 +800,26 @@ function applyMarks(figure: any, d: DesignDecisions, table: any[], say: Say): vo } } - // A sized mark's range: Plotly sizes by `sizeref` against the largest datum. + // A sized mark's range. `marker.size` has already been mapped to pixel + // diameters by the template, so the house's range is imposed by rescaling + // those diameters — going through `sizeref` instead would treat drawn + // pixels as data and flatten the differences under its square root. if (m.sizeRange) { + const [lo, hi] = m.sizeRange; for (const trace of figure.data ?? []) { const sizes = trace?.marker?.size; if (!Array.isArray(sizes)) continue; - const max = Math.max(...sizes.filter((s: any) => Number.isFinite(s))); - if (!(max > 0)) continue; - trace.marker.sizemode = 'area'; - trace.marker.sizeref = (2 * max) / (m.sizeRange[1] || 400); - trace.marker.sizemin = m.minSize ? diameterOf(m.minSize) : 3; + const areas = sizes.map((dm: any) => Math.PI * (Number(dm) / 2) ** 2); + const finite = areas.filter((a: number) => Number.isFinite(a) && a > 0); + if (!finite.length) continue; + const min = Math.min(...finite); + const max = Math.max(...finite); + const floor = m.minSize != null ? Math.max(lo, m.minSize) : lo; + trace.marker.size = areas.map((a: number) => { + if (!Number.isFinite(a)) return diameterOf(floor); + const t = max > min ? (a - min) / (max - min) : 1; + return diameterOf(floor + t * (hi - floor)); + }); } } @@ -763,6 +858,50 @@ function plotlyLineShape(interpolate: string): string | undefined { * carries per-point colours (a status waterfall, a colour-mapped scatter) is * re-stated value by value. */ +/** + * Paint the direction blocks of a waterfall, candlestick or OHLC trace. + * + * Where the house names no status inks the template's colours stand: an + * arbitrary indexed ink on "down" would say the wrong thing. + */ +function paintDirectional(trace: any, d: DesignDecisions, say: Say): void { + const status = d.series.status; + let positive = status?.positive; + let negative = status?.negative; + let neutral = status?.neutral; + + if (!positive && !negative && !neutral) { + // A candlestick's red and green are a convention its readers rely on, + // so without status inks it is left alone. A waterfall's up and down + // are only contributions, and can take the indexed set. + if (trace.type !== 'waterfall') { + say('ink.series', 'rise/fall colours kept — the house names no status inks'); + return; + } + const inks = d.series.categorical ?? []; + if (!inks.length) return; + positive = inks[0]; + negative = inks[1] ?? inks[0]; + neutral = mixHex(d.text.primary, d.surface.plot ?? d.surface.canvas, 0.45); + say('ink.series', 'no status inks — the waterfall takes the indexed set, totals a neutral'); + } + + const blocks: Array<[string, string | undefined]> = [ + ['increasing', positive], + ['decreasing', negative ?? positive], + ['totals', neutral ?? positive], + ]; + for (const [key, ink] of blocks) { + const block = trace[key]; + if (!block || !ink) continue; + if (block.marker) block.marker = { ...block.marker, color: ink }; + else if (block.line) block.line = { ...block.line, color: ink }; + else trace[key] = { ...block, marker: { color: ink } }; + if (block.fillcolor != null) block.fillcolor = ink; + } + say('ink.series', 'rise, fall and total take the house status inks'); +} + function applySeriesInk(figure: any, d: DesignDecisions, table: any[], say: Say): void { const s = d.series; if (s.exhausted) { @@ -807,6 +946,15 @@ function applySeriesInk(figure: any, d: DesignDecisions, table: any[], say: Say) continue; } + // A waterfall or a candlestick states its colours by *direction*, in + // `increasing`/`decreasing`/`totals` blocks. Direction is not a series, + // so the categorical set says nothing about it — the house's status + // inks do. + if (trace.increasing || trace.decreasing || trace.totals) { + paintDirectional(trace, d, say); + continue; + } + const perPoint = Array.isArray(trace.marker?.color) ? (trace.marker.color as unknown[]).filter(isLiteralColor) : null; @@ -823,6 +971,14 @@ function applySeriesInk(figure: any, d: DesignDecisions, table: any[], say: Say) continue; } + // The wedge between funnel stages is furniture, not data: it takes a + // quiet mix of the surface and the text, like a grid line would. + if (trace.connector) { + const quiet = mixHex(d.surface.plot ?? d.surface.canvas, d.text.primary, 0.25); + if (trace.connector.fillcolor != null) trace.connector.fillcolor = quiet; + if (trace.connector.line) trace.connector.line = { ...trace.connector.line, color: quiet }; + } + const ink = inks[seriesIndex % inks.length]; if (fams.includes('bar') || fams.includes('arc') || fams.includes('boxplot')) { trace.marker = { ...(trace.marker ?? {}), color: ink }; @@ -921,9 +1077,14 @@ function applyLegend(figure: any, d: DesignDecisions, say: Say): number { }); } - // A horizontal key above the plot is chrome the figure has to pay for. - if (layout.legend.orientation === 'h' && layout.legend.y > 1) { - return horizontalLegendHeight(figure, l.label.fontSize ?? 11, needsMarkup(l.label).bold); + // A key above the plot is chrome the figure has to pay for, however it is + // stacked. + if (layout.legend.y > 1) { + if (layout.legend.orientation === 'h') { + return horizontalLegendHeight(figure, l.label.fontSize ?? 11, needsMarkup(l.label).bold); + } + const rows = legendEntries(figure).length; + return rows ? rows * ((l.label.fontSize ?? 11) + 12) + 8 : 0; } // A key beside the plot is paid for in width, for the same reason. if (layout.legend.orientation === 'v' && layout.legend.x >= 1) { @@ -1092,6 +1253,59 @@ function applyFacetChrome(figure: any, d: DesignDecisions): void { // Data labels // --------------------------------------------------------------------------- +/** + * Does this axis show unsigned labels for signed values? + * + * That is how a mirrored chart — a pyramid, a diverging bar — is built: one + * side is negative and the axis relabels it. It is a fact about the compiled + * chart, readable without knowing which chart type made it. + */ +function isMirroredAxis(ax: any): boolean { + const vals = ax?.tickvals; + const text = ax?.ticktext; + if (!Array.isArray(vals) || !Array.isArray(text) || vals.length !== text.length) return false; + return vals.some((v: any, i: number) => Number(v) < 0 && !String(text[i]).includes('-') + && !String(text[i]).includes('\u2212')); +} + +/** + * Blank the labels of segments too thin to hold one. + * + * `texttemplate` takes an array, one entry per point, so a per-segment + * decision is expressible. Returns how many were dropped. + */ +function blankSmallSegments(trace: any, figure: any, measure: 'x' | 'y', fontSize: number): number { + const values = trace[measure]; + if (!Array.isArray(values)) return 0; + const layout = figure.layout ?? {}; + const across = measure === 'y' + ? (Number(layout.height) || 300) - (layout.margin?.t ?? 0) - (layout.margin?.b ?? 0) + : (Number(layout.width) || 400) - (layout.margin?.l ?? 0) - (layout.margin?.r ?? 0); + + // The stack, not the trace, sets the scale: sum what every trace of the + // same orientation contributes to each band. + const totals: number[] = []; + for (const t of figure.data ?? []) { + const v = t?.[measure]; + if (!Array.isArray(v) || String(t.type ?? '') !== 'bar') continue; + v.forEach((n: any, i: number) => { + totals[i] = (totals[i] ?? 0) + Math.abs(Number(n) || 0); + }); + } + const span = Math.max(...totals.filter(Number.isFinite), 0); + if (!span || !Number.isFinite(across) || across <= 0) return 0; + + const min = (span * fontSize * 1.8) / across; + const template = String(trace.texttemplate); + let dropped = 0; + trace.texttemplate = values.map((v: any) => { + if (Math.abs(Number(v) || 0) >= min) return template; + dropped++; + return ''; + }); + return dropped; +} + /** Trace families that can print a number on the mark. */ const LABELABLE = new Set(['bar', 'arc', 'point', 'line']); @@ -1138,7 +1352,51 @@ function applyDataLabels(figure: any, d: DesignDecisions, table: any[], say: Say // `insideMinValue`/`outsideMaxValue`. `auto` hands that decision to // the renderer, which can measure the drawn bar; `outside` is // honoured literally because it is a house habit, not a fit. - trace.textposition = dl.placement === 'outsideMark' ? 'outside' : 'auto'; + // A segment of a stack has no outside — "outside" is the middle of + // the neighbouring segment — so a label that will not fit inside is + // dropped instead of moved, and it is never turned on its side. + const stacked = /^(stack|relative)$/.test(String(figure.layout?.barmode ?? '')); + // A population pyramid states one side of the split as negative + // numbers and then hides the sign on the axis. The label has to + // tell the same story the axis does. + const axKey = measure === 'x' + ? String(trace.xaxis ?? 'x').replace('x', 'xaxis') + : String(trace.yaxis ?? 'y').replace('y', 'yaxis'); + const mirrored = isMirroredAxis(figure.layout?.[axKey]); + if (mirrored) { + const abs = (trace[measure] as any[]).map((v) => Math.abs(Number(v) || 0)); + const cd = trace.customdata; + // The template may already be carrying the unsigned value for + // its own tooltip; reuse it rather than trample it. + const reusable = Array.isArray(cd) && cd.length === abs.length + && cd.every((v: any, i: number) => Number(v) === abs[i]); + if (cd == null || reusable) { + if (cd == null) trace.customdata = abs; + trace.texttemplate = `%{customdata${fmt}}${unit}`; + say('dataLabels.text', 'a mirrored measure prints its labels unsigned, as its axis does'); + } else { + say('dataLabels.text', 'a mirrored measure kept its signed label — the trace needs its own customdata'); + } + } + trace.textposition = stacked || mirrored + ? 'inside' + : dl.placement === 'outsideMark' ? 'outside' : 'auto'; + trace.textangle = 0; + if (stacked) { + trace.insidetextanchor = 'middle'; + trace.constraintext = 'both'; + // Left to itself Plotly shrinks a label until it fits its + // segment, which puts three type sizes on one chart. A segment + // too thin for the house's size loses its label instead. + const dropped = blankSmallSegments(trace, figure, measure, dl.text.fontSize ?? 11); + if (dropped) { + say( + 'dataLabels.show', + `${dropped} stacked segment(s) too thin to hold a label at the house's size`, + ); + } + say('dataLabels.placement', 'stacked segments keep their labels inside, or drop them'); + } trace.cliponaxis = false; trace.textfont = fontOf(dl.text, d.font); if (dl.inkMode === 'contrastWithMark') { @@ -1202,10 +1460,11 @@ export function fitPlotlyTitle(figure: any): void { yanchor: 'top', yref: 'container', }; - const need = all.length * size * 1.35 + 16; + const need = 8 + all.length * size * 1.35 + 8; const margin = (layout.margin ??= {}); const before = margin.t ?? 0; margin.t = Math.max(before, need); const grew = margin.t - before; if (grew > 0 && Number(layout.height)) layout.height = Math.round(layout.height + grew); + reserveAboveDomains(figure, need); } diff --git a/scripts/theme-plotly.ts b/scripts/theme-plotly.ts index f16261a4..91184065 100644 --- a/scripts/theme-plotly.ts +++ b/scripts/theme-plotly.ts @@ -41,6 +41,14 @@ const COLUMNS = ['flint', ...THEME_IDS]; /** The starting set: one chart of each core family, on real data. */ const LAB_IDS = ['browser-pie', 'causes-death', 'keeling', 'penguins', 'life-expectancy', 'temp-heatmap']; +/** The second lab set: the families the first set never touched. */ +const LAB2_IDS = [ + 'electricity-stacked', 'medals-grouped', 'population-region', 'penguins-box', + 'population-waterfall', 'education-funnel', 'stock-candle', 'olympic-bump', + 'us-pyramid', 'cities-map', 'renewable-kpi', 'release-gantt', + 'oecd-unemployment-facet', 'faithful-hist', 'nutrition-radar', 'renewables-gauge', +]; + interface Case { id: string; heading: string; @@ -77,6 +85,8 @@ function corpus(set: string, filters: string[]): Case[] { let cases: Case[]; if (set === 'r2') { cases = R2_CASES.map(r2Wrapped); + } else if (set === 'lab2') { + cases = PREVIEW_CASES.filter((c) => LAB2_IDS.includes(c.id)).map(realCase); } else if (set === 'real') { cases = PREVIEW_CASES.map(realCase); } else { From dc566211513c462fd05ba515269f1c97b25dd17d Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 6 Aug 2026 01:32:29 -0700 Subject: [PATCH 05/40] plotly theme: fix the gaps the synthetic corpus found Iteration 4 of the Plotly ThemeSpec work, against all 87 r2 cases x 11 columns. Every case assembles and renders; the gaps were in what the theme could and could not *name*. - context and reference traces: a bullet chart's zone bands and target tick now carry a `_role`, so they are restated against the house surface instead of being painted as series - point labels pick the channel that holds numbers, so a horizontal series no longer prints NaN - an indicator's delta takes the house status inks, lifted where a dark card would swallow the stock green and red - `layout.polar` is themed at last: grid, domain, tick type, and radial labels held straight - a key is measured against the plot area it wraps in, and a column of keys taller than the plot grows the figure instead of being clipped - date tick format follows the tick *step*, not the axis span - a filled band's edge takes no dots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0de04740-1ac7-48d9-844f-92d4e261c27c --- .../flint-js/src/plotly/templates/bullet.ts | 2 + packages/flint-js/src/plotly/theme.ts | 264 +++++++++++++++++- 2 files changed, 253 insertions(+), 13 deletions(-) diff --git a/packages/flint-js/src/plotly/templates/bullet.ts b/packages/flint-js/src/plotly/templates/bullet.ts index 018a8370..554ddb59 100644 --- a/packages/flint-js/src/plotly/templates/bullet.ts +++ b/packages/flint-js/src/plotly/templates/bullet.ts @@ -49,6 +49,7 @@ export const plBulletChartDef: ChartTemplateDef = { const zoneTraces = [0, 1, 2].map(i => ({ type: 'bar', name: `__zone${i}`, + _role: 'context' as const, orientation: 'h' as const, showlegend: false, hoverinfo: 'skip' as const, @@ -90,6 +91,7 @@ export const plBulletChartDef: ChartTemplateDef = { type: 'scatter', mode: 'markers', name: 'Target', + _role: 'reference' as const, x: categories.map(cat => goalOf(cat)), y: categories, showlegend: false, diff --git a/packages/flint-js/src/plotly/theme.ts b/packages/flint-js/src/plotly/theme.ts index 4107e69a..1605a6b7 100644 --- a/packages/flint-js/src/plotly/theme.ts +++ b/packages/flint-js/src/plotly/theme.ts @@ -239,12 +239,43 @@ function isContextTrace(trace: any): boolean { return trace?._role === 'context' || trace?.hoverinfo === 'skip' && !trace?.name; } +/** A reference mark states a target or a threshold: furniture, not a series. */ +function isReferenceTrace(trace: any): boolean { + return trace?._role === 'reference'; +} + function dataTraces(figure: any): any[] { return (figure?.data ?? []).filter( - (t: any) => t && !CHROME_TRACES.has(String(t.type)) && !isContextTrace(t), + (t: any) => t && !CHROME_TRACES.has(String(t.type)) && !isContextTrace(t) && !isReferenceTrace(t), ); } +/** + * Bands of context and reference ticks are drawn in fixed greys and blacks by + * the templates, which reads as a stack of light bars on a dark card. Each is + * restated against the house surface, keeping its relative weight. + */ +function applyFurnitureTraces(figure: any, d: DesignDecisions): void { + const surface = d.surface.plot ?? d.surface.canvas; + for (const trace of figure.data ?? []) { + if (isReferenceTrace(trace)) { + const line = trace.marker?.line; + if (line) trace.marker = { ...trace.marker, line: { ...line, color: d.text.primary } }; + else if (trace.marker) trace.marker = { ...trace.marker, color: d.text.primary }; + if (trace.line) trace.line = { ...trace.line, color: d.text.primary }; + continue; + } + if (!isContextTrace(trace)) continue; + const colour = trace.marker?.color; + if (typeof colour !== 'string') continue; + const rgb = parseColor(colour); + if (!rgb) continue; + const l = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255; + const weight = Math.min(0.25, Math.max(0.04, (1 - l) * 1.6)); + trace.marker = { ...trace.marker, color: mixHex(surface, d.text.primary, weight) }; + } +} + // --------------------------------------------------------------------------- // Entry points // --------------------------------------------------------------------------- @@ -257,6 +288,7 @@ export function realizeThemePlotly(figure: any, d: DesignDecisions, table: any[] applySurface(figure, d); applyGeoSurface(figure, d); + applyFurnitureTraces(figure, d); const titleH = applyTypography(figure, d); applyAxes(figure, d, table, say); applyMarks(figure, d, table, say); @@ -315,6 +347,25 @@ function applySurface(figure: any, d: DesignDecisions): void { * are done here: the headline is broken to the width it has, and the height * that costs is handed to {@link layoutTopChrome}. */ +/** + * A card's delta is a verdict, so it takes the house's status inks. Plotly's + * stock green and red are stated for a white card and go muddy on a dark one, + * so a house that names no status inks gets them lifted until they read. + */ +function applyDeltaInk(trace: any, d: DesignDecisions): void { + if (!trace.delta) return; + const bg = d.surface.plot ?? d.surface.canvas; + const lift = (ink: string) => (isDarkSurface(bg) ? mixHex(ink, '#ffffff', 0.35) : ink); + const up = d.series.status?.positive ?? lift('#2f8f4e'); + const down = d.series.status?.negative ?? lift('#c0392b'); + trace.delta = { + ...trace.delta, + increasing: { ...(trace.delta.increasing ?? {}), color: up }, + decreasing: { ...(trace.delta.decreasing ?? {}), color: down }, + font: { ...(trace.delta.font ?? {}), ...(d.font ? { family: d.font } : {}) }, + }; +} + function applyTypography(figure: any, d: DesignDecisions): number { const layout = figure.layout; layout.font = { @@ -328,8 +379,11 @@ function applyTypography(figure: any, d: DesignDecisions): number { if (trace?.type !== 'indicator') continue; trace.title = { ...(trace.title ?? {}), font: { ...(trace.title?.font ?? {}), ...(d.font ? { family: d.font } : {}), color: d.text.secondary } }; trace.number = { ...(trace.number ?? {}), font: { ...(trace.number?.font ?? {}), ...(d.font ? { family: d.font } : {}), color: d.text.primary } }; + applyDeltaInk(trace, d); } + restateNeutralAnnotations(layout, d); + const title = layout.title; const headlineText = typeof title === 'string' ? title : title?.text; if (!headlineText) return 0; @@ -378,6 +432,32 @@ function applyTypography(figure: any, d: DesignDecisions): number { return height + d.title.offset; } +/** + * Restate the template's own grey text in the house's inks. + * + * A bar table writes its category names, headers and totals as annotations in + * fixed greys, which are invisible on a dark house. A *grey* is furniture — it + * was chosen to be quiet, not to mean something — so it is re-read as a text + * role by its lightness and rewritten. A coloured annotation is data and is + * left alone. + */ +function restateNeutralAnnotations(layout: any, d: DesignDecisions): void { + for (const a of layout.annotations ?? []) { + const hex = a?.font?.color; + if (!hex) continue; + const c = parseColor(String(hex)); + if (!c) continue; + const grey = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b) < 40; + if (!grey) continue; + const light = (c.r + c.g + c.b) / 765; + a.font = { + ...a.font, + ...(d.font ? { family: d.font } : {}), + color: light < 0.3 ? d.text.primary : light < 0.62 ? d.text.secondary : d.text.muted, + }; + } +} + /** * Where the title block has to be anchored to sit 8px clear of the top edge. * @@ -511,14 +591,103 @@ function applyAxes(figure: any, d: DesignDecisions, table: any[], say: Say): voi const decided = d.axes[ch]; if (!decided) continue; for (const key of axisKeys(layout, ch)) { - applyAxis(layout[key] ?? (layout[key] = {}), decided, d, say, key); + applyAxis( + layout[key] ?? (layout[key] = {}), + decided, + d, + say, + key, + Math.max(80, (Number(layout.width) || 400) - (layout.margin?.l ?? 0) - (layout.margin?.r ?? 0)), + axisCategories(figure, layout[key], key, ch), + ); } } + applyPolarAxes(figure, d); applyUnits(figure, d, say); applyTickLabels(figure, d, table, say); } -function applyAxis(ax: any, a: ResolvedAxis, d: DesignDecisions, say: Say, key: string): void { +/** + * A polar plot keeps its scales inside `layout.polar`, out of reach of the + * cartesian pass. Its radial labels sit in the middle of the plot, where + * Plotly turns them on their side as soon as the circle gets small, so they + * are held straight and their count is kept low. + */ +function applyPolarAxes(figure: any, d: DesignDecisions): void { + const layout = figure.layout; + for (const key of Object.keys(layout ?? {})) { + if (!/^polar\d*$/.test(key)) continue; + const polar = layout[key]; + if (!polar || typeof polar !== 'object') continue; + polar.bgcolor = d.surface.plot ?? d.surface.canvas; + for (const [name, a] of [['radialaxis', d.axes.y], ['angularaxis', d.axes.x]] as const) { + if (!a) continue; + const ax = polar[name] ?? (polar[name] = {}); + ax.showgrid = a.grid.show; + if (a.grid.show) { + ax.gridcolor = a.grid.color; + ax.gridwidth = a.grid.width; + } + ax.showline = a.domain.show; + if (a.domain.show) { + ax.linecolor = a.domain.color; + ax.linewidth = a.domain.width; + } + if (a.label.show !== false) { + ax.tickfont = { ...(ax.tickfont ?? {}), ...fontOf(a.label, d.font) }; + if (name === 'radialaxis') { + ax.tickangle = 0; + ax.nticks = Math.min(a.tickCount ?? 4, 4); + } + } + } + } +} + +/** + * The names a banded axis carries: what the layout declares, or what the + * traces bound to that axis actually plot. + */ +function axisCategories(figure: any, ax: any, key: string, ch: 'x' | 'y'): any[] { + if (Array.isArray(ax?.categoryarray) && ax.categoryarray.length) return ax.categoryarray; + if (ax?.type !== 'category') return []; + const short = key.replace('axis', ''); + const seen = new Set(); + for (const t of figure.data ?? []) { + if (String(t?.[`${ch}axis`] ?? ch) !== short) continue; + for (const v of t?.[ch] ?? []) seen.add(String(v)); + } + return [...seen]; +} + +/** + * Would this axis's names read straight in the width it actually has? + * + * Only answerable for a banded axis, where the band width is the width divided + * by the number of names. Anything else is left to the house. + */ +function labelsFitStraight(ax: any, key: string, a: ResolvedAxis, width: number, cats: any[]): boolean { + if (!key.startsWith('x')) return true; + if ((a.label.angle ?? 0) !== 0) return true; + if (!Array.isArray(cats) || !cats.length) return true; + const span = Array.isArray(ax.domain) && ax.domain.length === 2 + ? Math.abs(Number(ax.domain[1]) - Number(ax.domain[0])) + : 1; + const band = (width * (Number.isFinite(span) ? span : 1)) / cats.length; + const longest = Math.max(...cats.map((c: any) => String(c).length)); + // Names need air between them, or 'JanFebMar' reads as one word. + return longest * (a.label.fontSize ?? 11) * 0.58 + 5 <= band; +} + +function applyAxis( + ax: any, + a: ResolvedAxis, + d: DesignDecisions, + say: Say, + key: string, + width: number, + cats: any[], +): void { // Grid ax.showgrid = a.grid.show; if (a.grid.show) { @@ -561,7 +730,19 @@ function applyAxis(ax: any, a: ResolvedAxis, d: DesignDecisions, say: Say, key: ax.showticklabels = a.label.show !== false && ax.showticklabels !== false; if (ax.showticklabels) { ax.tickfont = { ...(ax.tickfont ?? {}), ...fontOf(a.label, d.font) }; - if (a.label.angle != null) ax.tickangle = a.label.angle; + // A straight label angle was decided against the whole axis. A facet + // panel holds a fraction of it, and names that read straight across a + // full width run into each other across a quarter of one — so the + // angle the template chose stands where the house's will not fit. + if (a.label.angle != null) { + if (labelsFitStraight(ax, key, a, width, cats)) ax.tickangle = a.label.angle; + else { + say( + 'axes.label.angle', + `\`${key}\` keeps its turned labels — straight they would not fit the panel`, + ); + } + } if (a.label.padding != null) ax.ticklabelstandoff = a.label.padding; } @@ -662,8 +843,12 @@ function dateFormatFor(values: any[]): string { const times = values.map((v) => new Date(v).getTime()).filter((n) => Number.isFinite(n)); if (times.length < 2) return '%Y'; const days = (Math.max(...times) - Math.min(...times)) / 86_400_000; - if (days > 900) return '%Y'; - if (days > 60) return '%b %Y'; + // What matters is not the span but the *step*: eight ticks across three + // years written as years print "2020 2020 2021 2021" — the same label + // twice, which reads as a mistake. + const step = days / (times.length - 1); + if (days > 900 && step > 300) return '%Y'; + if (days > 60 && step > 20) return '%b %Y'; return '%d %b'; } @@ -698,7 +883,9 @@ function thin(values: any[], mode: string, key: string): any[] | null { if (picked[picked.length - 1] !== last) { // Two ticks a fraction of a step apart print on top of each other. const lastIndex = values.indexOf(picked[picked.length - 1]); - if (values.length - 1 - lastIndex < step / 2) picked.pop(); + // Two ticks less than a step apart print on top of each other; the + // last value is the one that has to be named, so the other goes. + if (values.length - 1 - lastIndex < step) picked.pop(); picked.push(last); } return picked; @@ -719,13 +906,19 @@ function applyMarks(figure: any, d: DesignDecisions, table: any[], say: Say): vo // Band occupancy is one number for the whole figure: Plotly sizes bars by // the gap left between them, not by a mark width. - const bars = (figure.data ?? []).filter((t: any) => markFamilies(t).includes('bar')); + // A trace another trace fills down to is the floor of a band, not a line + // of its own. + const traces: any[] = figure.data ?? []; + const floors = new Set(); + traces.forEach((t, i) => { if (t?.fill === 'tonexty' && i > 0) floors.add(i - 1); }); + + const bars = traces.filter((t: any) => markFamilies(t).includes('bar')); if (bars.length) { layout.bargap = Math.max(0, Math.min(0.9, 1 - m.bandFraction)); if (layout.barmode === 'group' && bars.length > 1) layout.bargroupgap = 0.05; } - for (const trace of figure.data ?? []) { + for (const [index, trace] of traces.entries()) { if (CHROME_TRACES.has(String(trace?.type))) continue; const fams = markFamilies(trace); @@ -789,7 +982,10 @@ function applyMarks(figure: any, d: DesignDecisions, table: any[], say: Say): vo // A house that dots its lines says so through the chart's own options // where the template offers one; where it does not, the dots are added // here. - if (m.point?.show && fams.includes('line') && !fams.includes('point')) { + // A filled band has no line to dot — its edge is the top of an area, + // and dotting it reads as data points that are not there. + const filled = (trace.fill != null && trace.fill !== 'none') || floors.has(index); + if (m.point?.show && fams.includes('line') && !fams.includes('point') && !filled) { trace.mode = String(trace.mode ?? 'lines').includes('markers') ? trace.mode : `${trace.mode ?? 'lines'}+markers`; @@ -1099,6 +1295,18 @@ function applyLegend(figure: any, d: DesignDecisions, say: Say): number { if (grew > 0 && Number(layout.width)) layout.width = Math.round(layout.width + grew); } } + + // A column of keys taller than the plot is simply cut off at the bottom, + // so the figure grows to hold it. + if (layout.legend.orientation !== 'h') { + const entries = legendEntries(figure); + const need = entries.length * ((l.label.fontSize ?? 11) + 14) + 20; + const have = (Number(layout.height) || 0) - (layout.margin?.t ?? 0) - (layout.margin?.b ?? 0); + if (entries.length && have > 0 && need > have) { + layout.height = Math.round(Number(layout.height) + (need - have)); + say('legend.placement', `the figure grew ${Math.round(need - have)}px to hold a column of ${entries.length} keys`); + } + } return 0; } @@ -1117,7 +1325,13 @@ function legendEntries(figure: any): string[] { function horizontalLegendHeight(figure: any, fontSize: number, bold = false): number { const entries = legendEntries(figure); if (!entries.length) return 0; - const width = Number(figure.layout?.width) || 400; + // A key above the plot is laid out across the *plot area*, not the paper, + // so it wraps sooner than the figure width suggests. + const layout = figure.layout ?? {}; + const width = Math.max( + 120, + (Number(layout.width) || 400) - (layout.margin?.l ?? 0) - (layout.margin?.r ?? 0), + ); const rowHeight = fontSize + 14; let rows = 1; let used = 0; @@ -1236,10 +1450,23 @@ function labelSeriesEnds(figure: any, d: DesignDecisions, say: Say): boolean { // Facet chrome // --------------------------------------------------------------------------- +/** + * Is this annotation a panel's name? + * + * Templates that emit a facet grid mark the header for us; the rest are + * recognised by where they sit — pinned to the top of the paper, over a panel, + * with no arrow. Getting this wrong only means a caption is typed as a header. + */ +function isFacetHeader(a: any): boolean { + if (a?._role === 'facet-header') return true; + return a?.xref === 'paper' && a?.yref === 'paper' && a?.showarrow === false + && a?.xanchor === 'center' && a?.yanchor === 'top'; +} + function applyFacetChrome(figure: any, d: DesignDecisions): void { const f = d.facets.header; for (const a of figure.layout?.annotations ?? []) { - if (a?._role !== 'facet-header') continue; + if (!isFacetHeader(a)) continue; if (!f.show) { a.text = ''; continue; @@ -1410,7 +1637,10 @@ function applyDataLabels(figure: any, d: DesignDecisions, table: any[], say: Say } if (fams.includes('point') || fams.includes('line')) { - const measure = 'y'; + // A point series is usually measured up the page, but not always: + // labelling a category with `%{y}` prints NaN. + const measure = numericChannel(trace); + if (!measure) continue; trace.mode = String(trace.mode ?? 'lines').includes('text') ? trace.mode : `${trace.mode ?? 'lines'}+text`; @@ -1431,6 +1661,14 @@ function applyDataLabels(figure: any, d: DesignDecisions, table: any[], say: Say void sampleRamp; } +/** Which of a trace's two channels carries the number worth printing. */ +function numericChannel(trace: any): 'x' | 'y' | null { + const numeric = (v: any) => Array.isArray(v) && v.some((n) => typeof n === 'number' && Number.isFinite(n)); + if (numeric(trace?.y)) return 'y'; + if (numeric(trace?.x)) return 'x'; + return null; +} + /** * Break a Plotly title to the width it has, with no house in sight. * From b7ffb8465c3bbd3551809f4a0a09356895055564 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 6 Aug 2026 01:53:33 -0700 Subject: [PATCH 06/40] plotly theme: measure ticks in room, not in counts Iteration 5, against the sixty-one real-data preview cases. All render; the gaps were measurements made in the wrong units. - a tick budget is a count, but a panel has a width: a sixteen-panel facet grid keeps only the ticks it has room to print, and every panel but the last gives up its edge label so it does not land on its neighbour's - a log axis is ticked by decade, so the budget no longer applies to it - an annotation printed in a series' colour follows that series to its new ink, so a sparkline's average is no longer blue beside a red line - a sparkline's average rule is `_role: 'reference'`, like a bullet chart's target; furniture is skipped by the mark pass, and a dashed rule takes the weight of a grid line Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0de04740-1ac7-48d9-844f-92d4e261c27c --- .../src/plotly/templates/sparkline.ts | 1 + packages/flint-js/src/plotly/theme.ts | 109 +++++++++++++++++- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/packages/flint-js/src/plotly/templates/sparkline.ts b/packages/flint-js/src/plotly/templates/sparkline.ts index a26c067d..f5d1c19b 100644 --- a/packages/flint-js/src/plotly/templates/sparkline.ts +++ b/packages/flint-js/src/plotly/templates/sparkline.ts @@ -212,6 +212,7 @@ export const plSparklineDef: ChartTemplateDef = { if (refY != null) { traces.push({ type: 'scatter', mode: 'lines', + _role: 'reference', xaxis: xRef, yaxis: yRef, x: [seriesRows[0].x, seriesRows[seriesRows.length - 1].x], y: [refY, refY], diff --git a/packages/flint-js/src/plotly/theme.ts b/packages/flint-js/src/plotly/theme.ts index 1605a6b7..ea63675e 100644 --- a/packages/flint-js/src/plotly/theme.ts +++ b/packages/flint-js/src/plotly/theme.ts @@ -262,7 +262,14 @@ function applyFurnitureTraces(figure: any, d: DesignDecisions): void { const line = trace.marker?.line; if (line) trace.marker = { ...trace.marker, line: { ...line, color: d.text.primary } }; else if (trace.marker) trace.marker = { ...trace.marker, color: d.text.primary }; - if (trace.line) trace.line = { ...trace.line, color: d.text.primary }; + // A rule is read against the marks, not with them: it takes the + // weight of a grid line rather than the weight of a series. + if (trace.line) { + trace.line = { + ...trace.line, + color: trace.line.dash ? mixHex(surface, d.text.primary, 0.45) : d.text.primary, + }; + } continue; } if (!isContextTrace(trace)) continue; @@ -769,12 +776,77 @@ function applyAxis( // A tick budget is stated for a whole axis. A facet panel holds a fraction // of the width, so it gets that fraction of the budget — otherwise the // last tick of one panel prints on top of the first tick of the next. - if (a.tickCount != null && ax.type !== 'category') { + // A log axis is ticked by decade. Asking it for a number of ticks makes + // Plotly name every minor as well — 2, 3, 4 … 9 between each power. + if (a.tickCount != null && ax.type !== 'category' && ax.type !== 'log') { const dom = Array.isArray(ax.domain) && ax.domain.length === 2 ? Math.abs(Number(ax.domain[1]) - Number(ax.domain[0])) : 1; ax.nticks = Math.max(2, Math.round(a.tickCount * (Number.isFinite(dom) ? dom : 1))); } + + // A budget in *numbers* is not a budget in *room*. Sixteen panels across + // one page leave 50px each, which holds one year, not four — and Plotly + // will print all four on top of each other rather than drop any. + if (key.startsWith('x') && ax.type !== 'category' && ax.type !== 'log' && ax.showticklabels) { + const dom = Array.isArray(ax.domain) && ax.domain.length === 2 + ? Math.abs(Number(ax.domain[1]) - Number(ax.domain[0])) + : 1; + const span = Number.isFinite(dom) ? dom : 1; + const each = tickLabelChars(ax) * (a.label.fontSize ?? 11) * 0.58 + 8; + const band = width * span - (span < 0.99 ? each * 1.5 : 0); + const fits = Math.max(1, Math.floor(band / each)); + if (fits < (ax.nticks ?? 6)) { + ax.nticks = Math.max(2, fits); + say('axes.tickCount', `\`${key}\` holds ${fits} label(s) in the room it has, not ${a.tickCount}`); + } + } +} + +/** + * Keep only as many of these ticks as the panel has room to print. Plotly + * prints every value in `tickvals`, however narrow the panel, so a grid of + * sixteen facets writes four years on top of each other. + */ +function fitTicksToBand(figure: any, ax: any, picked: any[], fontSize: number): any[] { + const layout = figure.layout ?? {}; + const width = Math.max(80, (Number(layout.width) || 400) - (layout.margin?.l ?? 0) - (layout.margin?.r ?? 0)); + const dom = Array.isArray(ax.domain) && ax.domain.length === 2 + ? Math.abs(Number(ax.domain[1]) - Number(ax.domain[0])) + : 1; + // A panel's end labels hang over its edges, so a facet has to leave a + // label's width of air or its last tick lands on its neighbour's first. + const span = Number.isFinite(dom) ? dom : 1; + const each = tickLabelChars(ax) * fontSize * 0.58 + 8; + const band = width * span - (span < 0.99 ? each * 1.5 : 0); + const fits = Math.max(1, Math.floor(band / each)); + // Panels sit edge to edge, so a tick at the right edge of one prints on + // the tick at the left edge of the next. Every panel but the last gives + // up its final label. + const trimmed = span < 0.99 && Number(ax.domain?.[1]) < 0.99 && picked.length > 1 + ? picked.slice(0, -1) + : picked; + picked = trimmed; + if (picked.length <= fits) return picked; + if (fits < 2) return [picked[0]]; + // Keep the ends and spread the rest, so the range still reads. + const step = (picked.length - 1) / (fits - 1); + const out: any[] = []; + for (let i = 0; i < fits; i++) out.push(picked[Math.round(i * step)]); + return [...new Set(out)]; +} + +/** How wide a tick label on this axis reads, in characters. */ +function tickLabelChars(ax: any): number { + const fmt = typeof ax.tickformat === 'string' ? ax.tickformat : ''; + if (fmt.includes('%')) { + if (/%d/.test(fmt) || /%b/.test(fmt)) return fmt.replace(/%./g, 'xxx').length; + return 4; + } + if (Array.isArray(ax.ticktext) && ax.ticktext.length) { + return Math.max(...ax.ticktext.map((t: any) => String(t).length)); + } + return ax.type === 'date' ? 4 : 5; } /** @@ -825,14 +897,15 @@ function applyTickLabels(figure: any, d: DesignDecisions, table: any[], say: Say if (ax.type === 'category') continue; const values = observedValues(ax, table, field); if (!values.length) continue; - const picked = thin(values, a.tickLabels, key); + let picked = thin(values, a.tickLabels, key); if (!picked?.length) continue; - ax.tickmode = 'array'; - ax.tickvals = picked; if (ax.type === 'date' && !ax.tickformat) { ax.tickformat = dateFormatFor(picked); ax.tickangle = 0; } + if (ch === 'x') picked = fitTicksToBand(figure, ax, picked, a.label.fontSize ?? 11); + ax.tickmode = 'array'; + ax.tickvals = picked; say('structure.axis.tickLabels', `${key} ticked at ${picked.length} observed values (${a.tickLabels})`); } } @@ -920,6 +993,8 @@ function applyMarks(figure: any, d: DesignDecisions, table: any[], say: Say): vo for (const [index, trace] of traces.entries()) { if (CHROME_TRACES.has(String(trace?.type))) continue; + // Furniture keeps the weight `applyFurnitureTraces` gave it. + if (isContextTrace(trace) || isReferenceTrace(trace)) continue; const fams = markFamilies(trace); if (fams.includes('bar') || fams.includes('arc')) { @@ -1128,6 +1203,7 @@ function applySeriesInk(figure: any, d: DesignDecisions, table: any[], say: Say) const inks = s.mode === 'single' ? [s.single] : s.categorical; if (!inks?.length) return; + const recoloured = new Map(); let seriesIndex = 0; for (const trace of traces) { if (trace.colorscale != null || trace.marker?.colorscale != null) continue; @@ -1176,6 +1252,8 @@ function applySeriesInk(figure: any, d: DesignDecisions, table: any[], say: Say) } const ink = inks[seriesIndex % inks.length]; + const was = trace.line?.color ?? trace.marker?.color; + if (typeof was === 'string' && isLiteralColor(was)) recoloured.set(was.toLowerCase(), ink); if (fams.includes('bar') || fams.includes('arc') || fams.includes('boxplot')) { trace.marker = { ...(trace.marker ?? {}), color: ink }; if (trace.type === 'violin' || trace.type === 'box') trace.line = { ...(trace.line ?? {}), color: ink }; @@ -1192,10 +1270,31 @@ function applySeriesInk(figure: any, d: DesignDecisions, table: any[], say: Say) seriesIndex++; } + restateSeriesAnnotations(figure, recoloured, say); + void table; void distinctCount; } +/** + * An annotation printed in a series' colour — the number at the end of a + * sparkline row — is naming that series. When the series changes ink, so does + * the annotation, or the row says one thing in two colours. + */ +function restateSeriesAnnotations(figure: any, recoloured: Map, say: Say): void { + if (!recoloured.size) return; + let moved = 0; + for (const note of figure.layout?.annotations ?? []) { + const colour = note?.font?.color; + if (typeof colour !== 'string') continue; + const ink = recoloured.get(colour.toLowerCase()); + if (!ink || ink.toLowerCase() === colour.toLowerCase()) continue; + note.font = { ...note.font, color: ink }; + moved++; + } + if (moved) say('ink.series', `${moved} annotation(s) named a series and followed its ink`); +} + function withAlpha(hex: string, alpha: number): string { const c = parseColor(hex); if (!c) return hex; From c32b9a7e1aed777f2a63a1d72774dfb1dab54996 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 6 Aug 2026 01:58:05 -0700 Subject: [PATCH 07/40] plotly theme: unit tests for the realizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins what the audit sweeps found by eye: furniture is restated rather than painted as a series, a target tick keeps its role and its ink, nothing prints NaN, a banded axis keeps its turned labels only where straight ones will not fit, a polar plot is themed, and every realize entry carries a path. Writing them found one more gap: a legend proxy — an empty bar standing in for a colour — was still being labelled. The labelling pass now skips a trace with no numbers in it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0de04740-1ac7-48d9-844f-92d4e261c27c --- packages/flint-js/src/plotly/theme.ts | 6 +- packages/flint-js/tests/theme-plotly.test.ts | 153 +++++++++++++++++++ 2 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 packages/flint-js/tests/theme-plotly.test.ts diff --git a/packages/flint-js/src/plotly/theme.ts b/packages/flint-js/src/plotly/theme.ts index ea63675e..7eed135b 100644 --- a/packages/flint-js/src/plotly/theme.ts +++ b/packages/flint-js/src/plotly/theme.ts @@ -1651,6 +1651,10 @@ function applyDataLabels(figure: any, d: DesignDecisions, table: any[], say: Say for (const trace of traces) { const fams = markFamilies(trace); + // A trace with no numbers in it has nothing to print. A legend proxy + // — one empty bar standing in for a colour — is the usual case, and + // labelling it writes NaN on the axis. + if (!fams.includes('arc') && !numericChannel(trace)) continue; if (trace.text != null || trace.texttemplate != null || trace.textinfo != null) { // The template prints its own numbers. The theme may not change // *what* is written — that was the template's decision — but the @@ -1671,7 +1675,7 @@ function applyDataLabels(figure: any, d: DesignDecisions, table: any[], say: Say } if (fams.includes('bar')) { - const measure = trace.orientation === 'h' ? 'x' : 'y'; + const measure = trace.orientation === 'h' ? 'x' : (numericChannel(trace) ?? 'y'); trace.texttemplate = `%{${measure}${fmt}}${unit}`; // Plotly places the label inside where it fits and outside where it // does not, which is exactly the geometry stage 2 computed with diff --git a/packages/flint-js/tests/theme-plotly.test.ts b/packages/flint-js/tests/theme-plotly.test.ts new file mode 100644 index 00000000..13bd4a57 --- /dev/null +++ b/packages/flint-js/tests/theme-plotly.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assemblePlotly } from '../src'; +import type { ThemeSpec } from '../src/core/theme/types'; + +/** + * The Plotly realizer writes the same backend-neutral `DesignDecisions` onto a + * `{data, layout}` figure that the Vega-Lite one writes onto a spec. The cases + * below are the ones the audit sweeps found by eye and that a rendered contact + * sheet is a slow way to check twice. + */ + +const theme = (extra: Partial = {}): ThemeSpec => ({ + id: 'house', + label: 'House', + ink: { + surface: { canvas: '#ffffff', plot: '#ffffff' }, + text: { primary: '#111111' }, + series: { single: '#cc0000', categorical: ['#cc0000', '#0044cc', '#118844'] }, + }, + ...extra, +} as ThemeSpec); + +const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +function bullet(spec: ThemeSpec = theme()): any { + return assemblePlotly({ + data: { + values: [ + { Store: 'Newark', Revenue: 489, Goal: 540 }, + { Store: 'Dallas', Revenue: 707, Goal: 650 }, + { Store: 'Austin', Revenue: 845, Goal: 720 }, + ], + }, + semantic_types: { Store: 'Category', Revenue: 'Amount', Goal: 'Amount' }, + chart_spec: { + chartType: 'Bullet Chart', + title: 'Revenue against target', + encodings: { y: 'Store', x: 'Revenue', goal: 'Goal' }, + baseSize: { width: 480, height: 300 }, + }, + theme_spec: spec, + } as any) as any; +} + +function monthlyLine(spec: ThemeSpec, width = 480): any { + return assemblePlotly({ + data: { + values: months.map((m, i) => ({ Month: m, Revenue: 100 + i * 7 })), + }, + semantic_types: { Month: 'Category', Revenue: 'Amount' }, + chart_spec: { + chartType: 'Line Chart', + title: 'Monthly revenue', + encodings: { x: 'Month', y: 'Revenue' }, + baseSize: { width, height: 300 }, + }, + theme_spec: spec, + } as any) as any; +} + +const traceNamed = (fig: any, name: string) => + (fig.data ?? []).find((t: any) => t.name === name); + +describe('furniture is not a series', () => { + it('restates a bullet chart\'s context bands against the house surface', () => { + const dark = bullet(theme({ + ink: { + surface: { canvas: '#111111', plot: '#111111' }, + text: { primary: '#f4f4f4' }, + series: { single: '#4499ff', categorical: ['#4499ff'] }, + }, + } as any)); + const zone = traceNamed(dark, '__zone0'); + expect(zone).toBeTruthy(); + // The template's #e2e2e2 would be a light bar on a dark card. + expect(String(zone.marker.color).toLowerCase()).not.toBe('#e2e2e2'); + expect(zone._role).toBe('context'); + }); + + it('never paints a band or a target tick with the series ink', () => { + const fig = bullet(); + const ink = String(traceNamed(fig, 'value').marker.color?.[0] ?? '').toLowerCase(); + for (const name of ['__zone0', '__zone1', '__zone2']) { + expect(String(traceNamed(fig, name).marker.color).toLowerCase()).not.toBe(ink); + } + const target = traceNamed(fig, 'Target'); + expect(target._role).toBe('reference'); + expect(String(target.marker.line.color).toLowerCase()).toBe('#111111'); + }); + + it('never prints NaN where a series is measured across the page', () => { + const fig = bullet(theme({ dataLabels: { show: 'always' } } as any)); + for (const t of fig.data ?? []) { + if (t.texttemplate == null) continue; + expect(String(t.texttemplate)).not.toContain('%{y'); + } + }); +}); + +describe('a banded axis measured against the room it has', () => { + it('keeps the template\'s turned labels where straight ones will not fit', () => { + const narrow = monthlyLine(theme({ axes: { label: { angle: 0 } } } as any), 260); + // Twelve month names across 260px cannot read straight. + expect(narrow.layout.xaxis.tickangle).not.toBe(0); + expect( + narrow._theme.report.some((r: any) => /keeps its turned labels/.test(r.message)), + ).toBe(true); + }); + + it('lets the house straighten them where there is room', () => { + const wide = monthlyLine(theme({ axes: { label: { angle: 0 } } } as any), 1400); + expect(wide.layout.xaxis.tickangle).toBe(0); + }); +}); + +describe('a polar plot is themed too', () => { + it('holds its radial labels straight and gives them the house type', () => { + const fig = assemblePlotly({ + data: { + values: months.slice(0, 5).flatMap((m, i) => [ + { Nutrient: m, Food: 'Oats', Grams: 10 + i }, + { Nutrient: m, Food: 'Almonds', Grams: 20 - i }, + ]), + }, + semantic_types: { Nutrient: 'Category', Food: 'Category', Grams: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + title: 'Nutrition profile', + encodings: { x: 'Nutrient', y: 'Grams', color: 'Food' }, + baseSize: { width: 380, height: 380 }, + }, + theme_spec: theme(), + } as any) as any; + const radial = fig.layout.polar?.radialaxis; + expect(radial).toBeTruthy(); + expect(radial.tickangle).toBe(0); + expect(fig.layout.polar.bgcolor).toBe('#ffffff'); + }); +}); + +describe('the report says what was approximated', () => { + it('records every realize decision under a path', () => { + const fig = bullet(); + expect(Array.isArray(fig._theme?.report)).toBe(true); + for (const entry of fig._theme.report) { + expect(typeof entry.path).toBe('string'); + expect(entry.path.length).toBeGreaterThan(0); + } + }); +}); From 9fd55bc1e7474074aa5f040407cb02ca67101d21 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Thu, 6 Aug 2026 10:10:28 -0700 Subject: [PATCH 08/40] site: view the Plotly themes in the browser The Plotly theme work could only be reviewed through offline contact sheets. Both corpus labs now carry a Vega-Lite | Plotly switch, so the same 87 synthetic and ~60 real cases can be seen themed in a browser. The real lab's case filter followed Vega-Lite's template registry with a comment saying Plotly had no ThemeSpec path. It does now, so the filter follows the selected backend. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0de04740-1ac7-48d9-844f-92d4e261c27c --- site/src/playground/ThemeLabR2.tsx | 49 ++++++++++++++++++++++-- site/src/playground/ThemeLabR2Cell.tsx | 49 +++++++++++++++++++----- site/src/playground/ThemeLabReal.tsx | 39 +++++++++++-------- site/src/playground/ThemeLabRealCell.tsx | 47 ++++++++++++++++++----- 4 files changed, 147 insertions(+), 37 deletions(-) diff --git a/site/src/playground/ThemeLabR2.tsx b/site/src/playground/ThemeLabR2.tsx index 455b7aaf..c2c366ea 100644 --- a/site/src/playground/ThemeLabR2.tsx +++ b/site/src/playground/ThemeLabR2.tsx @@ -22,7 +22,7 @@ import { type R2Case, type R2Family, } from './theme-lab-r2-data'; -import { R2Cell, R2_COLUMNS } from './ThemeLabR2Cell'; +import { R2Cell, R2_COLUMNS, type LabBackend } from './ThemeLabR2Cell'; function byFamily(family: R2Family): R2Case[] { return R2_CASES.filter((c) => c.family === family); @@ -47,7 +47,46 @@ function Pill({ children }: { children: ReactNode }) { ); } -function Row({ c }: { c: R2Case }) { +/** + * The same ThemeSpec is realized by two backends. Switching between them is + * the whole point of the lab: a house that only looks right in one of them is + * a gap in the shared decisions, not a rendering detail. + */ +export function BackendSwitch({ + value, + onChange, +}: { + value: LabBackend; + onChange: (b: LabBackend) => void; +}) { + return ( +
+ backend + {(['vegalite', 'plotly'] as const).map((b) => { + const active = b === value; + return ( + + ); + })} +
+ ); +} + +function Row({ c, backend }: { c: R2Case; backend: LabBackend }) { return (
@@ -71,7 +110,7 @@ function Row({ c }: { c: R2Case }) { }} > {R2_COLUMNS.map((col) => ( - + ))}
@@ -80,6 +119,7 @@ function Row({ c }: { c: R2Case }) { export function ThemeLabR2() { const [family, setFamily] = useState(R2_FAMILY_ORDER[0]); + const [backend, setBackend] = useState('vegalite'); const cases = byFamily(family); return ( @@ -88,6 +128,7 @@ export function ThemeLabR2() {

Theme lab · round 2 (coverage)

+