diff --git a/CHANGELOG.md b/CHANGELOG.md index a87c26c3..4919a6f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- ECharts categorical legends (and their title graphics) are pinned with + `legend.right` instead of a design-canvas `left` pixel. Hosts that size the + container independently of `_width` and call `chart.resize()` keep the + reserved gutter instead of overlapping the plot or clipping the legend + ([#98](https://github.com/microsoft/flint-chart/issues/98)). +- Visible units now require an explicit `unit` in the field's semantic + annotation. Conventional compact units may accompany values, while lexical + units such as `years` are stated once as part of the field title. Bar Tables + also no longer repeat their value column as annotations on the bars. +- A raw sum-stacked chart whose total lands exactly on a clean axis tick now + keeps that edge flush instead of adding an empty interval above it, including + machine-scale residue from calculated shares. Totals meaningfully beyond the + clean endpoint still advance to the next tick; the rule is derived from the + plotted stack and does not special-case percentages or 100. +- Series-end labels now use a bounded screen-space packing pass when endpoints + form one readable column. Small adjustments keep labels attached by proximity; + crowded or horizontally staggered sets fall back together to the next legend + placement instead of leaving a partial or overlapping direct-label system. + ## [0.5.1] - 2026-08-13 ### Added diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index af1a890f..f7326fb1 100644 --- a/agent-skills/flint-chart-author/SKILL.md +++ b/agent-skills/flint-chart-author/SKILL.md @@ -421,7 +421,20 @@ understates what you know: } ``` -- `unit` — the unit or currency code: `"USD"`, `"°C"`, `"kg"`. +- `unit` — an optional assertion that authorizes Flint to display a unit. Add + it only when the data or surrounding context establishes the measurement + and seeing it materially changes how a reader interprets the number. A type + such as `Duration`, a field name such as `life_expectancy`, or values that + merely look plausible are not enough evidence by themselves. + - Prefer canonical codes: `"USD"`, `"°C"`, `"kg"`, `"km/h"`, `"min"`. + - Conventional compact units are normalized and may appear beside values + (`USD` → `$`, `hours` → `hr`). + - Lexical units such as `"years"` are stated once beside the field name as + `field (years)`, not repeated after every value. + - Do not put explanatory phrases in `unit`. Put qualifications such as + `"per working-age resident"` or `"constant 2024 prices"` in the subtitle. + - Omit `unit` when its meaning, scale, or denominator is uncertain. Flint + does not infer a visible unit from the semantic type or field name. - `intrinsicDomain` — the field's own bounds, for bounded scales only: `[1, 5]` for a five-star rating, `[0, 100]` for a percentage score. Not for open-ended measures. diff --git a/docs/api-reference.md b/docs/api-reference.md index 9473e74b..5c67e97a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -209,7 +209,7 @@ interface AssembleOptions { maxStretchX?: number; // per-dimension width cap (derived from canvasSize) maxStretchY?: number; // per-dimension height cap (derived from canvasSize) facetElasticity?: number; // facet stretch (default 0.3) - minStep?: number; // min px per discrete item (default 6) + minStep?: number; // min px per discrete item (default 8) minSubplotSize?: number; // min facet subplot px (default 60) maxColorValues?: number; // color cardinality before truncation (default 24) stepPadding?: number; // band inner padding fraction (default 0.1) diff --git a/docs/community-backends.md b/docs/community-backends.md new file mode 100644 index 00000000..ffc539af --- /dev/null +++ b/docs/community-backends.md @@ -0,0 +1,63 @@ +# Community backends + +Community backends extend Flint to additional renderers and delivery surfaces. +They use the same `ChartAssemblyInput`, but may have different chart coverage, +release cadence, and gallery, editor, MCP, or ThemeSpec integration from Flint's +core backends. + +## Image-Charts + +> Originally contributed by +> [François-Guillaume Ribreau](https://github.com/FGRibreau). + +The Image-Charts backend compiles a Flint input into an unsigned URL for the +third-party [Image-Charts](https://www.image-charts.com/) service. It is useful +when the output must work as an ordinary image URL, including email, generated +documents, chat messages, and other no-JavaScript environments. + +```ts +import { + assembleImageCharts, + isImageChartsSupported, +} from 'flint-chart/image-charts'; + +if (isImageChartsSupported(input.chart_spec.chartType)) { + const artifact = assembleImageCharts(input); + // { type: 'image-charts', url: 'https://image-charts.com/chart?...' } +} +``` + +Assembly is pure: it creates the URL without making a network request. Loading +the returned URL sends the encoded chart data to Image-Charts, so do not use it +with confidential data unless sending that data to the service is acceptable +under your privacy and deployment requirements. + +### Supported charts + +- Bar Chart, Grouped Bar Chart, and Stacked Bar Chart +- Line Chart, Sparkline, and Area Chart +- Scatter Plot +- Pie Chart and Donut Chart +- Radar Chart + +Unsupported chart types and faceted inputs throw an error rather than silently +falling back to another representation. + +### Current scope + +- Output is an unsigned `https://image-charts.com/chart?...` GET URL. Account + identifiers, HMAC signatures, and secrets are outside this pure compiler. +- Width and height are clamped to 999 pixels, and total area is clamped to + 998,001 pixels, matching the service's documented chart-size limits. +- Data, labels, legends, colors, and titles are carried in the query string. + Large or label-heavy charts can produce long URLs; Flint does not currently + convert them to Image-Charts POST requests or enforce a maximum URL length. +- Banded bar charts use Flint's overflow filtering before URL serialization. +- The backend uses a fixed categorical palette. ThemeSpec and most + `chartProperties` are not applied. +- Flint does not currently render this artifact in its gallery, editor, or MCP + server. Availability, caching, retention, quotas, and subscription behavior + are controlled by Image-Charts. + +See the [Image-Charts API documentation](https://documentation.image-charts.com/) +for the hosted service's current request grammar and limits. diff --git a/docs/design-semantics.md b/docs/design-semantics.md index 0836a96a..b607a26e 100644 --- a/docs/design-semantics.md +++ b/docs/design-semantics.md @@ -656,7 +656,10 @@ Only override native formatting when semantic context adds value: prefix/suffix, | **Sentiment / Correlation** | `+` + data-driven | — | — | — | Signed decimal | | **Latitude / Longitude** | — (empty) | — | — | — | VL native | -Unit/currency priority is `annotation.unit` > column-name heuristics > data-value scanning > type defaults. +Visible unit text requires `annotation.unit`; semantic types, column names, and +data values do not authorize display by themselves. Conventional compact units +such as `$`, `%`, `°C`, `kg`, or `min` may accompany values. Lexical units such +as `years` are stated once with the field title (`field (years)`). **Parsing** is the compiler's job, guided by semantic type rather than stored on context: diff --git a/docs/design-stretch-model.md b/docs/design-stretch-model.md index 4a631341..6551ce04 100644 --- a/docs/design-stretch-model.md +++ b/docs/design-stretch-model.md @@ -283,12 +283,12 @@ The layout balances two directions: | $L_{\max}$ | Maximum axis length | `base × β` (β from `maxStretch` or `canvasSize`) | 800 px | | $N$ | Number of banded items | Field cardinality | data-dependent | | $\ell_0$ | Natural (base) size per band | `defaultBandSize` | ~20 px | -| $\ell_{\min}$ | Minimum size per band | `minStep` option | 6 px | +| $\ell_{\min}$ | Minimum size per band | `minStep` option | 8 px | | $\ell_{\max}$ | Maximum size per band | `maxBandSize` option | = $\ell_0$ | | $\alpha$ | Elasticity exponent | `elasticity` option | 0.5 | | $\beta$ | Maximum stretch multiplier | `maxStretch`, or derived from `canvasSize` | 1.5 | -> **Code defaults:** `elasticity: 0.5`, `minStep: 6`, and `maxStretch: 1.5` when no `canvasSize` ceiling is set. $\ell_0$ and $\ell_{\max}$ are given at a 300 px reference canvas and scaled with size: `round(bandSize × max(1, sizeRatio))`. +> **Code defaults:** `elasticity: 0.5`, `minStep: 8`, and `maxStretch: 1.5` when no `canvasSize` ceiling is set. $\ell_0$ and $\ell_{\max}$ are given at a 300 px reference canvas and scaled with size: `round(bandSize × max(1, sizeRatio))`. ### §2.2.1 Band size bounds — min, base, max @@ -422,7 +422,7 @@ Grouped items, such as a grouped bar chart with $m$ sub-bars per group, are trea | Parameter | Simple discrete | Grouped bar ($m$ sub-bars) | |---|---|---| | $\ell_0$ (natural) | `defaultStepSize` | $m \times$ `defaultStepSize` | -| $\ell_{\min}$ (solid) | `minStep` (6 px) | $2m$ px (2 px per sub-bar) | +| $\ell_{\min}$ (solid) | `minStep` (8 px) | $2m$ px (2 px per sub-bar) | | $N$ (item count) | Field cardinality | Number of **groups** | The elastic budget formula is unchanged — only the parameter values change. @@ -530,7 +530,7 @@ The minimum subplot size ($S_{\min}$) is axis-aware: |---|---|---| | $N$ | Number of discrete items | data-dependent | | $\ell_0$ | Natural step size | ~20 px | -| $\ell_{\min}$ | Minimum step size | 6 px | +| $\ell_{\min}$ | Minimum step size | 8 px | | $\alpha$ | Elasticity exponent | 0.5 | | $\beta$ | Maximum stretch | 1.5 | diff --git a/docs/reference-vegalite.md b/docs/reference-vegalite.md index a65a9836..1bbfdd3d 100644 --- a/docs/reference-vegalite.md +++ b/docs/reference-vegalite.md @@ -415,7 +415,9 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `color` -_No template-specific parameters._ +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `cornerRadius` | number | 0 – 8 (step 1) | `2` | always | Corner radius for supported marks. | ### ![](chart-icon-bar-table.svg) Bar Table diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md index 97f83fef..ad2fb88d 100644 --- a/docs/zh-CN/api-reference.md +++ b/docs/zh-CN/api-reference.md @@ -174,7 +174,7 @@ interface AssembleOptions { maxStretchX?: number; // per-dimension width cap (derived from canvasSize) maxStretchY?: number; // per-dimension height cap (derived from canvasSize) facetElasticity?: number; // facet stretch (default 0.3) - minStep?: number; // min px per discrete item (default 6) + minStep?: number; // min px per discrete item (default 8) minSubplotSize?: number; // min facet subplot px (default 60) maxColorValues?: number; // color cardinality before truncation (default 24) stepPadding?: number; // band inner padding fraction (default 0.1) diff --git a/docs/zh-CN/design-stretch-model.md b/docs/zh-CN/design-stretch-model.md index 78218072..cbe30394 100644 --- a/docs/zh-CN/design-stretch-model.md +++ b/docs/zh-CN/design-stretch-model.md @@ -263,11 +263,11 @@ continuousWidth = stepSize × (N + 1) | $L_{\max}$ | Maximum axis length | `base × β`(β 来自 `maxStretch` 或 `canvasSize`) | 800 px | | $N$ | Number of banded items | Field cardinality | data-dependent | | $\ell_0$ | Natural length per item | `defaultStepSize` | ~20 px | -| $\ell_{\min}$ | Minimum length per item | `minStep` option | 6 px | +| $\ell_{\min}$ | Minimum length per item | `minStep` option | 8 px | | $\alpha$ | Elasticity exponent | `elasticity` option | 0.5 | | $\beta$ | Maximum stretch multiplier | `maxStretch`,或从 `canvasSize` 推导 | 1.5 | -> **Code defaults:** 未设置 `canvasSize` 上限时,`elasticity: 0.5`、`minStep: 6`、`maxStretch: 1.5`。`defaultStepSize` 根据画布尺寸动态计算:`round(20 × max(1, sizeRatio) × defaultStepMultiplier)`。 +> **Code defaults:** 未设置 `canvasSize` 上限时,`elasticity: 0.5`、`minStep: 8`、`maxStretch: 1.5`。`defaultStepSize` 根据画布尺寸动态计算:`round(20 × max(1, sizeRatio) × defaultStepMultiplier)`。 ## §2.3 三种状态 @@ -357,7 +357,7 @@ $$\boxed{\ell = \frac{\kappa \cdot \ell_0 + L_0 / N}{1 + \kappa}}$$ | Parameter | Simple discrete | Grouped bar ($m$ sub-bars) | |---|---|---| | $\ell_0$ (natural) | `defaultStepSize` | $m \times$ `defaultStepSize` | -| $\ell_{\min}$ (solid) | `minStep` (6 px) | $2m$ px(每子 bar 2 px) | +| $\ell_{\min}$ (solid) | `minStep` (8 px) | $2m$ px(每子 bar 2 px) | | $N$ (item count) | Field cardinality | **组**数量 | elastic budget 公式不变 — 仅参数值变化。 @@ -465,7 +465,7 @@ gas pressure 模型(§3)在每个子图内运行,容器为 $W_{\text{sub}} |---|---|---| | $N$ | Number of discrete items | data-dependent | | $\ell_0$ | Natural step size | ~20 px | -| $\ell_{\min}$ | Minimum step size | 6 px | +| $\ell_{\min}$ | Minimum step size | 8 px | | $\alpha$ | Elasticity exponent | 0.5 | | $\beta$ | Maximum stretch | 1.5 | diff --git a/package-lock.json b/package-lock.json index b7958def..75d0209b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10301,8 +10301,10 @@ "chart.js": "^4.0.0", "echarts": "^5.0.0 || ^6.0.0", "plotly.js": "^2.0.0 || ^3.0.0", + "plotly.js-dist-min": "^2.0.0 || ^3.0.0", "vega": "^5.0.0 || ^6.0.0", - "vega-lite": "^5.0.0 || ^6.0.0" + "vega-lite": "^5.0.0 || ^6.0.0", + "vega-tooltip": "^1.0.0" }, "peerDependenciesMeta": { "chart.js": { @@ -10314,11 +10316,17 @@ "plotly.js": { "optional": true }, + "plotly.js-dist-min": { + "optional": true + }, "vega": { "optional": true }, "vega-lite": { "optional": true + }, + "vega-tooltip": { + "optional": true } } }, diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index e74f6b47..28627964 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -65,6 +65,36 @@ "import": "./dist/excel/index.js", "require": "./dist/excel/index.cjs" }, + "./image-charts": { + "types": "./dist/image-charts/index.d.ts", + "import": "./dist/image-charts/index.js", + "require": "./dist/image-charts/index.cjs" + }, + "./interactive": { + "types": "./dist/interactive/index.d.ts", + "import": "./dist/interactive/index.js", + "require": "./dist/interactive/index.cjs" + }, + "./vegalite/interactive": { + "types": "./dist/vegalite/interactive.d.ts", + "import": "./dist/vegalite/interactive.js", + "require": "./dist/vegalite/interactive.cjs" + }, + "./echarts/interactive": { + "types": "./dist/echarts/interactive.d.ts", + "import": "./dist/echarts/interactive.js", + "require": "./dist/echarts/interactive.cjs" + }, + "./chartjs/interactive": { + "types": "./dist/chartjs/interactive.d.ts", + "import": "./dist/chartjs/interactive.js", + "require": "./dist/chartjs/interactive.cjs" + }, + "./plotly/interactive": { + "types": "./dist/plotly/interactive.d.ts", + "import": "./dist/plotly/interactive.js", + "require": "./dist/plotly/interactive.cjs" + }, "./test-data": { "types": "./dist/test-data/index.d.ts", "import": "./dist/test-data/index.js", @@ -98,9 +128,11 @@ "peerDependencies": { "chart.js": "^4.0.0", "plotly.js": "^2.0.0 || ^3.0.0", + "plotly.js-dist-min": "^2.0.0 || ^3.0.0", "echarts": "^5.0.0 || ^6.0.0", "vega": "^5.0.0 || ^6.0.0", - "vega-lite": "^5.0.0 || ^6.0.0" + "vega-lite": "^5.0.0 || ^6.0.0", + "vega-tooltip": "^1.0.0" }, "peerDependenciesMeta": { "vega": { @@ -109,6 +141,9 @@ "vega-lite": { "optional": true }, + "vega-tooltip": { + "optional": true + }, "echarts": { "optional": true }, @@ -117,6 +152,9 @@ }, "plotly.js": { "optional": true + }, + "plotly.js-dist-min": { + "optional": true } }, "devDependencies": { diff --git a/packages/flint-js/src/README.md b/packages/flint-js/src/README.md index fee77c2a..523b4b0a 100644 --- a/packages/flint-js/src/README.md +++ b/packages/flint-js/src/README.md @@ -163,6 +163,29 @@ Each backend has its own assembly function. All accept the same | `assembleECharts(input)` | ECharts option object | `import { assembleECharts } from 'flint-chart'` | | `assembleChartjs(input)` | Chart.js config object | `import { assembleChartjs } from 'flint-chart'` | +### Interactive surface + +Interactive renderers are opt-in and shipped separately from the static assembly entry point. The surface owns viewport state, accessible scroll controls, and renderer lifecycle; the caller supplies only a container and chart input. + +```ts +import { buildInteractiveChart } from 'flint-chart/interactive'; + +const surface = buildInteractiveChart( + container, + input, + { + backend: 'vegalite', + renderer: 'canvas', + focusOnClick: true, + }, +); + +await surface.ready; +// Later: surface.destroy(); +``` + +The facade supports `vegalite`, `echarts`, `chartjs`, and `plotly`, and loads only the selected adapter. Viewport changes retain the backend instance and update it through Vega's dataflow, ECharts `setOption()`, Chart.js `update()`, or Plotly `react()`. Vega-Lite discrete marks also enable local click focus by default: click selects, Shift/Ctrl/Meta-click toggles marks, and clicking empty plot space clears. Set `focusOnClick: false` to disable it. Other backends currently ignore this option. Advanced integrations can use `mountInteractiveChartSurface()` with a custom `InteractiveRendererAdapter`. Existing `assemble*()` calls, static SVG/PNG rendering, and Excel output do not import or execute the interactive surface; they retain the normal first-window overflow fallback. + ### Input types ```ts diff --git a/packages/flint-js/src/chartjs/assemble.ts b/packages/flint-js/src/chartjs/assemble.ts index 85a16571..72da8d85 100644 --- a/packages/flint-js/src/chartjs/assemble.ts +++ b/packages/flint-js/src/chartjs/assemble.ts @@ -446,6 +446,9 @@ export function assembleChartjs(input: ChartAssemblyInput): any { if (warnings.length > 0) { cjsConfig._warnings = warnings; } + if (overflowResult.viewports.length > 0) { + cjsConfig._viewports = overflowResult.viewports; + } cjsConfig._dataLength = values.length; diff --git a/packages/flint-js/src/chartjs/interactive.ts b/packages/flint-js/src/chartjs/interactive.ts new file mode 100644 index 00000000..691aaf9a --- /dev/null +++ b/packages/flint-js/src/chartjs/interactive.ts @@ -0,0 +1,94 @@ +import { applyCategoryViewports } from '../core/filter-overflow'; +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; +import { assembleChartjs } from './assemble'; +import { Chart, registerables } from 'chart.js'; + +Chart.register(...registerables); + +function windowedInput( + input: ChartAssemblyInput, + viewports: CategoryViewport[], + starts: ViewportState, +): ChartAssemblyInput { + return { + ...input, + data: { + values: applyCategoryViewports(input.data.values ?? [], viewports, starts), + }, + }; +} + +function renderConfig(config: any): any { + return { + ...config, + options: { + ...(config.options ?? {}), + responsive: true, + maintainAspectRatio: false, + }, + }; +} + +export function createChartjsInteractiveRenderer(): InteractiveRendererAdapter { + return { + async mount(container, input) { + const plannedConfig = assembleChartjs(input) as any; + const viewports = (plannedConfig._viewports ?? []) as CategoryViewport[]; + const initialConfig = viewports.length > 0 + ? assembleChartjs(windowedInput(input, viewports, {})) as any + : plannedConfig; + const wrapper = document.createElement('div'); + const canvas = document.createElement('canvas'); + wrapper.style.position = 'relative'; + wrapper.style.width = Number.isFinite(initialConfig._width) ? `${initialConfig._width}px` : '100%'; + wrapper.style.height = `${Number.isFinite(initialConfig._height) ? initialConfig._height : 320}px`; + wrapper.style.maxWidth = '100%'; + wrapper.append(canvas); + container.append(wrapper); + const chart = new Chart(canvas, renderConfig(initialConfig)); + + let destroyed = false; + let updateTimer: number | undefined; + let latestStarts: ViewportState = {}; + + const schedule = (): void => { + if (destroyed || updateTimer !== undefined) return; + updateTimer = window.setTimeout(() => { + updateTimer = undefined; + if (destroyed) return; + const config = renderConfig(assembleChartjs(windowedInput(input, viewports, latestStarts))); + chart.data = config.data; + chart.options = config.options; + chart.update('none'); + }, 0); + }; + + return { + viewports, + getViewportGeometry(channel) { + const area = chart.chartArea; + return channel === 'x' + ? { offset: area.left, extent: area.right - area.left } + : { offset: area.top, extent: area.bottom - area.top }; + }, + setViewports(starts) { + latestStarts = { ...starts }; + schedule(); + }, + resize(size) { + wrapper.style.width = `${size.width}px`; + wrapper.style.height = `${size.height}px`; + chart.resize(size.width, size.height); + }, + destroy() { + if (destroyed) return; + destroyed = true; + if (updateTimer !== undefined) window.clearTimeout(updateTimer); + chart.destroy(); + container.replaceChildren(); + }, + }; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/core/compute-layout.ts b/packages/flint-js/src/core/compute-layout.ts index f5aec42b..d0fa3cf0 100644 --- a/packages/flint-js/src/core/compute-layout.ts +++ b/packages/flint-js/src/core/compute-layout.ts @@ -79,6 +79,9 @@ const APPROX_CHAR_WIDTH_RATIO = 0.62; */ const SPARSE_FIT_BAND_CEILING = 100; +/** Smallest readable default step for a discrete item before overflow activates. */ +export const DEFAULT_MIN_STEP = 8; + /** Distinct label strings for a discrete axis field, plus derived stats. */ interface DiscreteLabelStats { count: number; @@ -292,7 +295,7 @@ export function computeLayout( const { elasticity: elasticityVal = 0.5, facetElasticity: facetElasticityVal = 0.3, - minStep: minStepVal = 6, + minStep: minStepVal = DEFAULT_MIN_STEP, minSubplotSize: minSubplotVal = 60, stepPadding: stepPaddingVal = 0.1, bandStepFit: bandStepFitVal = 0, @@ -1436,7 +1439,7 @@ export function computeChannelBudgets( options: AssembleOptions, ): ChannelBudgets { const { - minStep: minStepVal = 6, + minStep: minStepVal = DEFAULT_MIN_STEP, stepPadding: stepPaddingVal = 0.1, maxColorValues: maxColorVal = 24, } = options; @@ -1592,7 +1595,7 @@ export function computeFacetGrid( const fixW = options.facetFixedPadding?.width ?? 0; const fixH = options.facetFixedPadding?.height ?? 0; const gap = options.facetGap ?? 0; - const minStep = options.minStep ?? 6; + const minStep = options.minStep ?? DEFAULT_MIN_STEP; const stepPadding = options.stepPadding ?? 0.1; const baseMinSubplot = options.minSubplotSize ?? 60; @@ -1888,7 +1891,7 @@ export function computeMinSubplotDimensions( data: any[], options: { minStep?: number; minSubplotSize?: number }, ): { minSubplotWidth: number; minSubplotHeight: number } { - const minStep = options.minStep ?? 6; + const minStep = options.minStep ?? DEFAULT_MIN_STEP; const minSubplot = options.minSubplotSize ?? 60; let minSubplotWidth = minSubplot; diff --git a/packages/flint-js/src/core/decisions.ts b/packages/flint-js/src/core/decisions.ts index b2803e47..cdc487f9 100644 --- a/packages/flint-js/src/core/decisions.ts +++ b/packages/flint-js/src/core/decisions.ts @@ -139,8 +139,8 @@ function resolveTemporalEncoding( if (['size', 'column', 'row'].includes(channel)) { return { vlType: 'ordinal', visCategory, channelOverride: true, cardinalityGuard: false }; } - // Temporal on color with low cardinality → ordinal for distinct colors - if (channel === 'color') { + // Temporal on color/group with low cardinality → ordinal for distinct colors + if (channel === 'color' || channel === 'group') { const uniqueCount = new Set(data.map(r => r[fieldName])).size; if (uniqueCount <= 12) { return { vlType: 'ordinal', visCategory, channelOverride: true, cardinalityGuard: false }; diff --git a/packages/flint-js/src/core/field-semantics.ts b/packages/flint-js/src/core/field-semantics.ts index 91e8b76c..5120d50a 100644 --- a/packages/flint-js/src/core/field-semantics.ts +++ b/packages/flint-js/src/core/field-semantics.ts @@ -261,6 +261,44 @@ const UNIT_SUFFIX_MAP: Record = { '%': '%', }; +export interface DisplayUnit { + /** Normalized display text, e.g. `USD` becomes `$` and `hours` becomes `hr`. */ + text: string; + /** Compact conventional tags may accompany values; lexical units belong once beside the field name. */ + placement: 'value' | 'field'; + /** Currency symbols precede values; other compact units follow them. */ + position: 'prefix' | 'suffix'; +} + +/** + * Resolve display intent only from a unit explicitly declared in the semantic + * annotation. A semantic type or suggestive field name is not permission to + * print a unit. + */ +export function resolveDisplayUnit(annotation?: SemanticAnnotation): DisplayUnit | undefined { + const declared = annotation?.unit?.trim(); + if (!declared) return undefined; + + const currency = CURRENCY_MAP[declared.toUpperCase()] ?? CURRENCY_MAP[declared]; + if (currency) return { text: currency, placement: 'value', position: 'prefix' }; + + const compact = UNIT_SUFFIX_MAP[declared] ?? UNIT_SUFFIX_MAP[declared.toLowerCase()]; + if (compact) return { text: compact.trim(), placement: 'value', position: 'suffix' }; + + // Field-level units are labels, not prose. Reject control characters, + // parenthetical fragments, and long descriptions; those belong in a + // subtitle supplied by the authoring agent. + if (declared.length > 24 || /[\r\n()]/.test(declared)) return undefined; + return { text: declared, placement: 'field', position: 'suffix' }; +} + +/** Append a field-level unit once, preserving labels that already name it. */ +export function titleWithDisplayUnit(title: string, unit?: DisplayUnit): string { + if (unit?.placement !== 'field') return title; + if (title.toLocaleLowerCase().includes(`(${unit.text.toLocaleLowerCase()})`)) return title; + return `${title} (${unit.text})`; +} + /** * Detect whether percentage data uses 0–1 (fractional) or 0–100 (whole-number) * representation. diff --git a/packages/flint-js/src/core/filter-overflow.ts b/packages/flint-js/src/core/filter-overflow.ts index 39cd07b1..065678c8 100644 --- a/packages/flint-js/src/core/filter-overflow.ts +++ b/packages/flint-js/src/core/filter-overflow.ts @@ -79,6 +79,7 @@ export function filterOverflow( }; const truncations: TruncationWarning[] = []; const warnings: ChartWarning[] = []; + const viewports: OverflowResult['viewports'] = []; let filteredData = data; // Compute group nominal count @@ -137,7 +138,22 @@ export function filterOverflow( nominalCounts[channel] = Math.min(uniqueValues.length, maxToKeep); if (uniqueValues.length > maxToKeep) { - const valuesToKeep = strategy(channel, fieldName, uniqueValues, maxToKeep, strategyContext); + const orderedValues = strategy === defaultOverflowStrategy + ? defaultOverflowOrder(channel, fieldName, uniqueValues, strategyContext) + : undefined; + const valuesToKeep = orderedValues + ? orderedValues.slice(0, maxToKeep) + : strategy(channel, fieldName, uniqueValues, maxToKeep, strategyContext); + + if ((channel === 'x' || channel === 'y') && orderedValues) { + viewports.push({ + channel, + field: fieldName, + orderedValues, + visibleCount: valuesToKeep.length, + totalCount: orderedValues.length, + }); + } const omittedCount = uniqueValues.length - valuesToKeep.length; const placeholder = `...${omittedCount} items omitted`; @@ -168,7 +184,34 @@ export function filterOverflow( } } - return { filteredData, nominalCounts, truncations, warnings }; + return { filteredData, nominalCounts, truncations, warnings, viewports }; +} + +/** Resolve a clamped category window for one viewport axis. */ +export function resolveCategoryViewport( + viewport: OverflowResult['viewports'][number], + requestedStart: number = 0, +): { start: number; end: number; values: any[] } { + const maxStart = Math.max(0, viewport.totalCount - viewport.visibleCount); + const start = Math.min(maxStart, Math.max(0, Math.floor(requestedStart))); + const end = Math.min(viewport.totalCount, start + viewport.visibleCount); + return { start, end, values: viewport.orderedValues.slice(start, end) }; +} + +/** + * Apply one or more host-controlled category windows to the original rows. + * A heatmap may provide both x and y starts; ordinary bar charts provide one. + */ +export function applyCategoryViewports( + data: any[], + viewports: OverflowResult['viewports'], + starts: Partial> = {}, +): any[] { + const windows = viewports.map((viewport) => ({ + field: viewport.field, + values: new Set(resolveCategoryViewport(viewport, starts[viewport.channel]).values), + })); + return data.filter((row) => windows.every((window) => window.values.has(row[window.field]))); } // --------------------------------------------------------------------------- @@ -185,7 +228,15 @@ export function filterOverflow( */ const defaultOverflowStrategy: OverflowStrategy = ( channel, fieldName, uniqueValues, maxToKeep, context, -) => { +) => defaultOverflowOrder(channel, fieldName, uniqueValues, context).slice(0, maxToKeep); + +/** Resolve the complete display order before a static or interactive window is applied. */ +function defaultOverflowOrder( + channel: string, + fieldName: string, + uniqueValues: any[], + context: OverflowStrategyContext, +): any[] { const { data, channelSemantics, encodings, allMarkTypes } = context; // Determine sort intent from user encodings @@ -211,7 +262,7 @@ const defaultOverflowStrategy: OverflowStrategy = ( const sortedList = JSON.parse(sortBy); if (Array.isArray(sortedList)) { const orderedValues = (sortOrder === 'descending') ? sortedList.reverse() : sortedList; - return orderedValues.filter((v: any) => uniqueValues.includes(v)).slice(0, maxToKeep); + return orderedValues.filter((v: any) => uniqueValues.includes(v)); } } catch { // not a JSON list, fall through @@ -243,7 +294,6 @@ const defaultOverflowStrategy: OverflowStrategy = ( return Array.from(valueAggregates.entries()) .map(([value, agg]) => ({ value, agg })) .sort((a, b) => isDescending ? b.agg - a.agg : a.agg - b.agg) - .slice(0, maxToKeep) .map(v => v.value); } @@ -253,29 +303,29 @@ const defaultOverflowStrategy: OverflowStrategy = ( const ordered = canonicalOrder.filter(value => present.has(value)); const canonicalValues = new Set(ordered); ordered.push(...uniqueValues.filter(value => !canonicalValues.has(value))); - return ordered.slice(0, maxToKeep); + return ordered; } // Match the display default for quantitative values treated as discrete. const fieldOriginalType = inferVisCategory(data.map(r => r[fieldName])); if (fieldOriginalType === 'quantitative' || channel === 'color') { return [...uniqueValues].sort((a, b) => Number(a) - Number(b)) - .slice(0, maxToKeep); + ; } // Facet channels: first N if (channel === 'column' || channel === 'row') { - return uniqueValues.slice(0, maxToKeep); + return uniqueValues; } // Explicit field-order sort follows the displayed label order. if (sortOrder === 'descending') { - return [...uniqueValues].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true })).slice(0, maxToKeep); + return [...uniqueValues].sort((a, b) => String(b).localeCompare(String(a), undefined, { numeric: true })); } if (sortOrder === 'ascending') { - return [...uniqueValues].sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true })).slice(0, maxToKeep); + return [...uniqueValues].sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true })); } // Default: first N values - return uniqueValues.slice(0, maxToKeep); -}; + return uniqueValues; +} diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index 555a4299..3f3e7754 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -39,6 +39,7 @@ export { type OverflowStrategy, type OverflowStrategyContext, type OverflowResult, + type CategoryViewport, type ChannelBudgets, } from './types'; @@ -132,7 +133,7 @@ export { // Phase modules (analysis pipeline — VL-free) export { resolveChannelSemantics, convertTemporalData } from './resolve-semantics'; -export { filterOverflow } from './filter-overflow'; +export { filterOverflow, resolveCategoryViewport, applyCategoryViewports } from './filter-overflow'; export { computeLayout, computeChannelBudgets } from './compute-layout'; export { normalizeStaticSeries, diff --git a/packages/flint-js/src/core/theme/ground.ts b/packages/flint-js/src/core/theme/ground.ts index 6cdbd7ae..ad4e7315 100644 --- a/packages/flint-js/src/core/theme/ground.ts +++ b/packages/flint-js/src/core/theme/ground.ts @@ -42,7 +42,7 @@ import { resolvePresenceInk, sampleRamp, } from './presence.js'; -import { CURRENCY_MAP } from '../field-semantics.js'; +import { resolveDisplayUnit } from '../field-semantics.js'; import { getRegistryEntry } from '../type-registry.js'; import { inferValueLabelFormat, longestLabelChars } from './value-label-format.js'; import { deepMerge } from './merge.js'; @@ -581,26 +581,9 @@ function percentOfWhole(ctx: GroundingContext, channel: string): string | undefi return n >= 3 && Math.abs(sum - 100) < 0.5 ? '%' : undefined; } -/** - * The unit a measure is counted in, when the chart already knows it. - * - * Either the annotation says so outright, or the field names it the way a - * person does — `CO₂ (ppm)`, `Unemployment (%)`. Anything longer than a short - * tag is a phrase, not a unit, and belongs in the subtitle. - */ -const UNIT_IN_FIELD_NAME = /\(([^()]{1,6})\)\s*$/; - -function unitText(ctx: GroundingContext, channel: string): string | undefined { +function displayUnit(ctx: GroundingContext, channel: string) { const sem = ctx.channelSemantics?.[channel]; - const declared = sem?.semanticAnnotation?.unit; - const field = sem?.field ?? (ctx.positional as any)?.[channel]?.field; - const named = typeof field === 'string' ? field.match(UNIT_IN_FIELD_NAME) : null; - const raw = (typeof declared === 'string' && declared.length > 0 && declared.length <= 6) - ? declared - : named?.[1]; - if (!raw) return undefined; - // A currency is written with its sign, not its ISO code: `$8`, not `8 USD`. - return CURRENCY_MAP[raw.toUpperCase()] ?? raw; + return resolveDisplayUnit(sem?.semanticAnnotation); } /** @@ -923,12 +906,14 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe // reads in shares, whatever the field was measured in. const unitPolicy = theme.annotation?.unit ?? 'never'; const inFieldUnits = ctx.stacked !== 'normalize' && !ctx.partToWhole; - const unit = role === 'measure' && inFieldUnits ? unitText(ctx, channel) : undefined; - const unitTag = unitPolicy !== 'never' ? unit : undefined; + const unit = role === 'measure' && inFieldUnits ? displayUnit(ctx, channel) : undefined; + const unitTag = unitPolicy !== 'never' && unit?.placement === 'value' ? unit.text : undefined; // Where the house keeps its axis titles, the title is the natural place // for the unit — `Weight (lb)` — and the ticks stay bare numbers. - const titleUnit = showTitle && theme.annotation?.unitsInAxisTitle === true ? unit : undefined; + const titleUnit = showTitle && unit && ( + unit.placement === 'field' || theme.annotation?.unitsInAxisTitle === true + ) ? unit.text : undefined; // The gap between a label and the plot is the same gap whether or not a // tick is drawn in it. Where there is one, the tick spans the first part @@ -1460,22 +1445,20 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe const shareUnit = signals.isPartToWhole && !axisStatesUnit ? percentOfWhole(ctx, valueUnitChannel ?? '') : undefined; + const valueDisplayUnit = displayUnit(ctx, valueUnitChannel ?? ''); const valueUnit = houseStatesUnit - ? (unitText(ctx, valueUnitChannel ?? '') ?? shareUnit) + ? (valueDisplayUnit?.placement === 'value' ? valueDisplayUnit.text : shareUnit) : shareUnit; // A label placed at the mark sits *inside* it, which only works while the // mark is longer than the label. Below that length the label has to move - // out, and above the point where the mark reaches the end of the scale an - // outside label has nowhere left to go. Grounding is the stage that can - // say where those two lines are. + // out. Outside placement is chart-wide: the backend reserves room instead + // of flipping only the longest mark inward. let insideMinValue: number | undefined; - let outsideMaxValue: number | undefined; if (dlShow && measureChannel) { const span = measureChannel === 'x' ? ctx.layout.subplotWidth : ctx.layout.subplotHeight; if (valueMaxAbs > 0 && span > 0) { insideMinValue = (valueLabelWidthPx / span) * valueMaxAbs; - outsideMaxValue = valueMaxAbs - insideMinValue; } } @@ -1754,7 +1737,6 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe format: numberFormat, ...(valueUnit ? { unit: valueUnit } : {}), insideMinValue, - outsideMaxValue, ...(segmentMinShare !== undefined ? { segmentMinShare } : {}), }, // A house that dots the end of a line is saying where the story stops. @@ -1773,6 +1755,7 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe padding, density, plotWidth: ctx.layout.subplotWidth, + plotHeight: ctx.layout.subplotHeight, xStep: ctx.layout.xStep, canvasWidth: ctx.canvasSize?.width, }, diff --git a/packages/flint-js/src/core/theme/types.ts b/packages/flint-js/src/core/theme/types.ts index dafa0f78..a3b80912 100644 --- a/packages/flint-js/src/core/theme/types.ts +++ b/packages/flint-js/src/core/theme/types.ts @@ -802,11 +802,6 @@ export interface ResolvedDataLabels { * question about space, not about style. */ insideMinValue?: number; - /** - * Above this magnitude the mark reaches the end of the scale, so an - * outside label would fall off the plot. The mirror of `insideMinValue`. - */ - outsideMaxValue?: number; /** * The smallest share of the measure axis a stacked segment may occupy and * still be labelled — a line of text over the plot's extent along that @@ -928,11 +923,12 @@ export interface DesignDecisions { spacing?: number; preferredColumns?: number; }; - /** `plotWidth`/`xStep` are what the layout settled, so an axis can ask whether its names still fit. */ + /** Plot dimensions and step are what layout settled, so realization can test whether annotations fit. */ layout: { padding: number; density: 'compact' | 'normal' | 'airy'; plotWidth?: number; + plotHeight?: number; xStep?: number; /** The graphic the caller asked for. Wider than `plotWidth` by the axis gutter. */ canvasWidth?: number; diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index b2fc03b7..352cf8da 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -308,6 +308,20 @@ export interface ChannelBudgets { facetGrid?: FacetGridResult; } +/** A scrollable window over an ordered positional category domain. */ +export interface CategoryViewport { + /** Positional channel controlled by this viewport. */ + channel: 'x' | 'y'; + /** Source field whose values define the category domain. */ + field: string; + /** Complete display order, before the static fallback window is applied. */ + orderedValues: any[]; + /** Number of categories shown in an interactive window at the minimum valid step. */ + visibleCount: number; + /** Total number of categories in the ordered domain. */ + totalCount: number; +} + /** Result of overflow filtering. */ export interface OverflowResult { /** Data after removing overflow rows */ @@ -318,6 +332,8 @@ export interface OverflowResult { truncations: TruncationWarning[]; /** Warning messages for the UI */ warnings: ChartWarning[]; + /** Positional category windows that an interactive host can navigate. */ + viewports: CategoryViewport[]; } /** @@ -972,6 +988,12 @@ export interface ChartTemplateDef { */ ownsValueLabels?: boolean; + /** + * The template already presents values in a dedicated table column, so a + * generic label layer would repeat the same number on the data mark. + */ + suppressValueLabels?: boolean; + /** * Opt out of a backend's *generic* column/row facet-splitting pass, even * though the template declares `x`/`y` (so the axis-less `hasAxes` gate diff --git a/packages/flint-js/src/docs/design-semantics.md b/packages/flint-js/src/docs/design-semantics.md index c2800eab..d6b80637 100644 --- a/packages/flint-js/src/docs/design-semantics.md +++ b/packages/flint-js/src/docs/design-semantics.md @@ -1162,7 +1162,10 @@ For generic decimal types (Number, Score, Rating, Ratio, Latitude, Longitude), t **Unit and currency from annotation metadata:** When the LLM provides `unit` in the annotation (e.g., `"unit": "EUR"` for Price, `"unit": "kg"` for Weight), the format spec uses that directly. See §3 for the full annotation schema. -**Fallback priority for units:** annotation.unit > column-name heuristics ("Weight (kg)") > data-value scanning ("$1,234") > type-specific defaults ("$" for Price). +**Visible-unit policy:** only `annotation.unit` authorizes unit text. Semantic +types, column names, and data scanning may inform parsing or other semantic +decisions, but do not cause a unit to be printed. Conventional compact units +may accompany values; lexical units are stated once with the field title. ### 5.1.1 Parsing @@ -2070,9 +2073,9 @@ After this phase, all semantic-type-driven decisions flow through the flat `Chan 1. **Unit/domain annotation reliability.** How reliably will the LLM provide `domain` and `unit`? Mitigation strategies: - (a) Require domain/unit for a small set of types (Rating, Score, Temperature, Price) — reject annotations without them - - (b) Treat domain/unit as best-effort hints — fall back gracefully to data-inferred or type-intrinsic defaults (current proposal) + - (b) Treat domain/unit as best-effort hints, but require an explicit unit annotation before displaying unit text (current policy) - (c) Prompt the user to confirm/correct LLM-provided annotations in certain cases - - Fallback priority: annotation.unit > column-name heuristics ("Weight (kg)") > data scan ("$1,234") > type defaults + - Visible unit text has no fallback: it requires `annotation.unit` - Note: `intrinsicDomain` replaces the old `domain` property for clarity 2. **Scale type auto-detection.** Should we auto-switch to log scale when data spans >2 orders of magnitude? This is powerful but can surprise users. Options: diff --git a/packages/flint-js/src/docs/design-stretch-model.md b/packages/flint-js/src/docs/design-stretch-model.md index 8ca851d1..b4227a7d 100644 --- a/packages/flint-js/src/docs/design-stretch-model.md +++ b/packages/flint-js/src/docs/design-stretch-model.md @@ -209,12 +209,12 @@ The layout balances two directions: | $L_{\max}$ | Maximum axis length | `width × maxStretch` | 800 px | | $N$ | Number of banded items | Field cardinality | data-dependent | | $\ell_0$ | Natural (base) size per band | `defaultBandSize` | ~20 px | -| $\ell_{\min}$ | Minimum size per band | `minStep` option | 6 px | +| $\ell_{\min}$ | Minimum size per band | `minStep` option | 8 px | | $\ell_{\max}$ | Maximum size per band | `maxBandSize` option | = $\ell_0$ | | $\alpha$ | Elasticity exponent | `elasticity` option | 0.5 | | $\beta$ | Maximum stretch multiplier | `maxStretch` option | 1.5 | -> **Code defaults:** `ElasticStretchParams` in `core/decisions.ts` — `elasticity: 0.5`, `maxStretch: 1.5`, `minStep: 6`. $\ell_0$ (`defaultBandSize`) and $\ell_{\max}$ (`maxBandSize`) are given at a 300px reference and scaled with size: `round(bandSize × max(1, sizeRatio))`. +> **Code defaults:** `ElasticStretchParams` in `core/decisions.ts` — `elasticity: 0.5`, `maxStretch: 1.5`, `minStep: 8`. $\ell_0$ (`defaultBandSize`) and $\ell_{\max}$ (`maxBandSize`) are given at a 300px reference and scaled with size: `round(bandSize × max(1, sizeRatio))`. ### §1.2.1 Band size bounds — min, base, max @@ -336,7 +336,7 @@ Grouped items (e.g., grouped bar with $m$ sub-bars per group) are treated as a s | Parameter | Simple discrete | Grouped bar ($m$ sub-bars) | |---|---|---| | $\ell_0$ (natural) | `defaultStepSize` | $m \times$ `defaultStepSize` | -| $\ell_{\min}$ (solid) | `minStep` (6 px) | $2m$ px (2 px per sub-bar) | +| $\ell_{\min}$ (solid) | `minStep` (8 px) | $2m$ px (2 px per sub-bar) | | $N$ (item count) | Field cardinality | Number of **groups** | The elastic budget formula is unchanged — only the parameter values change. @@ -444,7 +444,7 @@ The minimum subplot size ($S_{\min}$) is axis-aware: |---|---|---| | $N$ | Number of discrete items | data-dependent | | $\ell_0$ | Natural step size | ~20 px | -| $\ell_{\min}$ | Minimum step size | 6 px | +| $\ell_{\min}$ | Minimum step size | 8 px | | $\alpha$ | Elasticity exponent | 0.5 | | $\beta$ | Maximum stretch | 2.0 | diff --git a/packages/flint-js/src/echarts/assemble.ts b/packages/flint-js/src/echarts/assemble.ts index fa67bd77..d6978aba 100644 --- a/packages/flint-js/src/echarts/assemble.ts +++ b/packages/flint-js/src/echarts/assemble.ts @@ -538,6 +538,9 @@ export function assembleECharts(input: ChartAssemblyInput): any { if (warnings.length > 0) { ecOption._warnings = warnings; } + if (overflowResult.viewports.length > 0) { + ecOption._viewports = overflowResult.viewports; + } // Store data reference (unlike VL which embeds data.values, // ECharts data is embedded directly in series[].data) diff --git a/packages/flint-js/src/echarts/facet.ts b/packages/flint-js/src/echarts/facet.ts index ffad6a9c..35ce0bde 100644 --- a/packages/flint-js/src/echarts/facet.ts +++ b/packages/flint-js/src/echarts/facet.ts @@ -525,13 +525,14 @@ function repositionFacetedLegendBesideGrids(combined: any): void { const BUFFER = 16; const rightMost = Math.max(...grids.map((g: any) => (g.left ?? 0) + (g.width ?? 0))); + const { left: _ignoredLeft, ...legendRest } = combined.legend; + void _ignoredLeft; combined.legend = { - ...combined.legend, - left: rightMost + GAP, + ...legendRest, + right: BUFFER, top: combined.legend.top ?? 20, orient: combined.legend.orient || 'vertical', align: 'left', - right: undefined, textStyle: { fontSize: highCardinality ? 8 : 11, ...(combined.legend.textStyle || {}), @@ -565,13 +566,14 @@ function repositionFacetedPolarLegend(combined: any): void { const r = Number(p?.radius) || 0; return cx + r; })); + const { left: _ignoredLeft, ...legendRest } = combined.legend; + void _ignoredLeft; combined.legend = { - ...combined.legend, - left: rightMost + GAP, + ...legendRest, + right: BUFFER, top: combined.legend.top ?? 20, orient: combined.legend.orient || 'vertical', align: 'left', - right: undefined, textStyle: { fontSize: highCardinality ? 8 : 11, ...(combined.legend.textStyle || {}), diff --git a/packages/flint-js/src/echarts/instantiate-spec.ts b/packages/flint-js/src/echarts/instantiate-spec.ts index b4474d4c..0299005f 100644 --- a/packages/flint-js/src/echarts/instantiate-spec.ts +++ b/packages/flint-js/src/echarts/instantiate-spec.ts @@ -495,41 +495,24 @@ export function ecApplyLayoutToSpec( option.graphic = Array.isArray(existing) ? [...existing, titleGraphic] : (existing ? [existing, titleGraphic] : [titleGraphic]); } } else { - // Single legend: use left positioning so title and legend circles share the same left edge + // Single legend: pin to the canvas right edge (not a design-width + // `left` px). Hosts that call chart.resize() keep the gutter; + // `right = designW - left` is wrong — ECharts `right` is the inset + // to the legend's *right* edge, which would grow into the plot. + // See https://github.com/microsoft/flint-chart/issues/98 const maxLabelLen = Math.max(...legendLabels.map((l: string) => l.length), 3); const highCardinality = legendLabels.length >= 16; const legendSymbolWidth = highCardinality ? 12 : 14; const legendItemGap = 5; const estimatedTextWidth = Math.min(120, maxLabelLen * 7 + 30); option._legendWidth = legendSymbolWidth + legendItemGap + estimatedTextWidth; - const LEGEND_GAP = 12; const CANVAS_BUFFER = 16; - const rightMarginPx = option._legendWidth + LEGEND_GAP + CANVAS_BUFFER; - const hasYTitle = !!option.yAxis?.name; - const gridLeft = (hasYTitle ? 70 : 50) + CANVAS_BUFFER; - // Use same effective plot width as canvas block (grouped bar/boxplot widen the plot) so legend does not overlap chart - let plotW = layout?.subplotWidth ?? canvasSize?.width ?? 400; - const xIsDiscreteForLegend = layout.xNominalCount > 0 || layout.xContinuousAsDiscrete > 0; - if (xIsDiscreteForLegend) { - let xItemCount = layout.xNominalCount || layout.xContinuousAsDiscrete || 0; - if (layout.xStepUnit === 'group' && option.series && Array.isArray(option.series) && layout.xNominalCount > 0) { - const barSeriesCount = option.series.filter((s: any) => s.type === 'bar').length || option.series.length; - if (barSeriesCount > 0) { - xItemCount = Math.max(1, Math.round(layout.xNominalCount / barSeriesCount)); - } - } - plotW = xItemCount > 0 ? layout.xStep * xItemCount : plotW; - } - const boxplotMinWForLegend = estimateGroupedBoxplotMinPlotWidth(option, layout); - if (boxplotMinWForLegend > 0) { - plotW = Math.max(plotW, boxplotMinWForLegend); - } - const effectiveChartWidth = plotW + gridLeft + rightMarginPx; - const legendLeftPx = Math.max(0, effectiveChartWidth - rightMarginPx); + const { left: _ignoredLeft, ...legendRest } = option.legend; + void _ignoredLeft; option.legend = { - ...option.legend, + ...legendRest, top: legendTitle != null ? 20 : 0, - left: legendLeftPx, + right: CANVAS_BUFFER, orient: option.legend.orient || 'vertical', align: 'left', // icon on left, text on right textStyle: { @@ -542,7 +525,7 @@ export function ecApplyLayoutToSpec( if (legendTitle != null) { const titleGraphic = { type: 'text' as const, - left: legendLeftPx, + right: CANVAS_BUFFER, top: 4, z: 100, style: { @@ -551,6 +534,7 @@ export function ecApplyLayoutToSpec( fontWeight: 'bold', fill: '#333', textAlign: 'left', + width: option._legendWidth, }, }; const existing = option.graphic; diff --git a/packages/flint-js/src/echarts/interactive.ts b/packages/flint-js/src/echarts/interactive.ts new file mode 100644 index 00000000..b298707b --- /dev/null +++ b/packages/flint-js/src/echarts/interactive.ts @@ -0,0 +1,82 @@ +import { applyCategoryViewports } from '../core/filter-overflow'; +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; +import { assembleECharts } from './assemble'; +import * as echarts from 'echarts'; + +export interface EChartsInteractiveRendererOptions { + renderer?: 'canvas' | 'svg'; +} + +function windowedInput( + input: ChartAssemblyInput, + viewports: CategoryViewport[], + starts: ViewportState, +): ChartAssemblyInput { + return { + ...input, + data: { + values: applyCategoryViewports(input.data.values ?? [], viewports, starts), + }, + }; +} + +export function createEChartsInteractiveRenderer( + options: EChartsInteractiveRendererOptions = {}, +): InteractiveRendererAdapter { + return { + async mount(container, input) { + const plannedOption = assembleECharts(input) as any; + const viewports = (plannedOption._viewports ?? []) as CategoryViewport[]; + const initialOption = viewports.length > 0 + ? assembleECharts(windowedInput(input, viewports, {})) as any + : plannedOption; + const chart = echarts.init(container, undefined, { + renderer: options.renderer ?? 'canvas', + width: initialOption._width, + height: initialOption._height, + }); + chart.setOption(initialOption, { notMerge: true }); + + let destroyed = false; + let updateTimer: number | undefined; + let latestStarts: ViewportState = {}; + + const schedule = (): void => { + if (destroyed || updateTimer !== undefined) return; + updateTimer = window.setTimeout(() => { + updateTimer = undefined; + if (destroyed) return; + const option = assembleECharts(windowedInput(input, viewports, latestStarts)); + chart.setOption(option, { notMerge: true }); + }, 0); + }; + + return { + viewports, + getViewportGeometry(channel) { + const grid = (chart as any).getModel().getComponent('grid'); + const rect = grid?.coordinateSystem?.getRect?.(); + if (!rect) return undefined; + return channel === 'x' + ? { offset: rect.x, extent: rect.width } + : { offset: rect.y, extent: rect.height }; + }, + setViewports(starts) { + latestStarts = { ...starts }; + schedule(); + }, + resize(size) { + chart.resize(size); + }, + destroy() { + if (destroyed) return; + destroyed = true; + if (updateTimer !== undefined) window.clearTimeout(updateTimer); + chart.dispose(); + container.replaceChildren(); + }, + }; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/echarts/templates/heatmap.ts b/packages/flint-js/src/echarts/templates/heatmap.ts index 5dcaf9b6..b6783022 100644 --- a/packages/flint-js/src/echarts/templates/heatmap.ts +++ b/packages/flint-js/src/echarts/templates/heatmap.ts @@ -59,7 +59,7 @@ export const ecHeatmapDef: ChartTemplateDef = { declareLayoutMode: () => ({ axisFlags: { x: { banded: true }, y: { banded: true } }, // No paramOverrides needed — uses the backend default band size - // (defaultBandSize=20, minStep=6), matching VL heatmap sizing. + // (defaultBandSize=20, minStep=8), matching VL heatmap sizing. }), instantiate: (spec, ctx) => { const { channelSemantics, table, colorDecisions, encodings } = ctx; diff --git a/packages/flint-js/src/echarts/templates/streamgraph.ts b/packages/flint-js/src/echarts/templates/streamgraph.ts index ef8135c7..a414558b 100644 --- a/packages/flint-js/src/echarts/templates/streamgraph.ts +++ b/packages/flint-js/src/echarts/templates/streamgraph.ts @@ -200,22 +200,20 @@ export const ecStreamgraphDef: ChartTemplateDef = { option.singleAxis.left = option.singleAxis.left || 50; option.singleAxis.right = Math.max(option.singleAxis.right || 0, rightMargin); - // Position legend in the right margin so it doesn't overlap the stream + // Pin legend to the right gutter (not design-canvas `left`) so resize() + // does not drop it into the stream. See microsoft/flint-chart#98. if (hasLegend && option.legend) { - const legendLeft = option._width - rightMargin + BUFFER; - option.legend.left = legendLeft; - delete option.legend.right; // Use left to align with graphic titles + delete option.legend.left; + option.legend.right = BUFFER; option.legend.top = 20; option.legend.orient = option.legend.orient || 'vertical'; option.legend.align = 'left'; - // Also update any custom graphic legend titles if (Array.isArray(option.graphic)) { for (const g of option.graphic) { - // The legend title added in instantiate-spec.ts typically has top: 4 and type: 'text' if (g.type === 'text' && (g.top === 4 || g.top === 20) && g.style && g.style.fontWeight === 'bold') { - g.left = legendLeft; - delete g.right; + delete g.left; + g.right = BUFFER; } } } diff --git a/packages/flint-js/src/image-charts/assemble.ts b/packages/flint-js/src/image-charts/assemble.ts new file mode 100644 index 00000000..8ab7822d --- /dev/null +++ b/packages/flint-js/src/image-charts/assemble.ts @@ -0,0 +1,376 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Image-Charts chart assembly — a hosted-image-URL backend. + * + * Unlike the other backends, Image-Charts does not emit a spec object that a + * local renderer draws: it emits a single permanent `https://image-charts.com` + * URL that renders the chart server-side. That URL is embeddable anywhere an + * `` works (email, PDF, Slack, no-code tools) with no runtime JavaScript. + * + * Contract: + * - PURE. No network I/O, no crypto, no npm dependencies. `assembleImageCharts` + * only builds a string; the data reaches Image-Charts only if something later + * loads the `` — an explicit choice by the caller, exactly as choosing + * the Excel backend chooses Office.js. + * - FREE TIER ONLY. Unsigned URLs (no `icac`/`ichm` account/HMAC pair, no + * `chof` output override). Signed enterprise URLs need a server-side secret + * that has no place in a pure, offline compiler function. + * + * Reuses the SAME core analysis pipeline as the other backends (Phase 0 semantic + * resolution + banded-axis overflow filtering), then serializes the resolved + * channel semantics, category/series roles, and values into the Image-Charts + * query grammar (`cht`, `chd=a:`, `chs`, `chxt`/`chxl`, `chco`, `chdl`, `chm`, + * `chtt`). Like the Excel backend it does the work inline rather than through a + * template registry, and it gates chart types to the ones with a faithful `cht`. + */ + +import type { ChartAssemblyInput, ChartEncoding, SemanticResult } from '../core/types'; +import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics'; +import { detectBandedAxisFromSemantics } from '../core/axis-detection'; +import { computeChannelBudgets, deriveStretchCaps, resolveBaseSize } from '../core/compute-layout'; +import { filterOverflow } from '../core/filter-overflow'; +import type { LayoutDeclaration } from '../core/types'; +import { IMAGE_CHARTS_TYPE_MAP } from './chart-types'; + +/** A backend-native Image-Charts artifact: a permanent hosted-image URL. */ +export interface ImageChartsArtifact { + type: 'image-charts'; + url: string; +} + +type Cell = string | number; + +/** Image-Charts base endpoint (public free tier). */ +const IMAGE_CHARTS_ENDPOINT = 'https://image-charts.com/chart?'; + +/** Free-tier size ceilings: each side ≤ 999px and area ≤ 998001px². */ +const MAX_SIDE = 999; +const MAX_AREA = 998001; + +/** Default target size when the spec provides no `baseSize`. */ +const DEFAULT_SIZE = { width: 700, height: 400 }; + +/** + * Categorical palette (hex, no `#`) used for `chco`. Emitted only when color is + * meaningful (multiple series, pie slices, area fill, scatter markers); a single + * plain series keeps Image-Charts' own default color. + */ +const SERIES_COLORS = [ + '4472C4', 'ED7D31', '70AD47', 'FFC000', '5B9BD5', + 'A5A5A5', '264478', '9E480E', '636363', '997300', +]; + +/** Normalize shorthand (`"x": "field"`) to `{ field }`. */ +function normalizeEncodings(raw: Record): Record { + const out: Record = {}; + for (const [ch, v] of Object.entries(raw ?? {})) { + if (v == null) continue; + out[ch] = typeof v === 'string' ? { field: v } : (v as ChartEncoding); + } + return out; +} + +/** Clamp a target size to the free-tier ceilings (side ≤ 999, area ≤ 998001). */ +function clampChartSize(width: number, height: number): { width: number; height: number } { + let w = Math.min(MAX_SIDE, Math.max(1, Math.round(width))); + let h = Math.min(MAX_SIDE, Math.max(1, Math.round(height))); + if (w * h > MAX_AREA) { + const scale = Math.sqrt(MAX_AREA / (w * h)); + w = Math.max(1, Math.floor(w * scale)); + h = Math.max(1, Math.floor(h * scale)); + } + return { width: w, height: h }; +} + +/** + * Encode one label/title/legend segment: keep ASCII alphanumerics, map spaces to + * `+`, percent-encode everything else (UTF-8). Structural separators (`|`, `,`, + * `:`) are added by the caller between segments and never pass through here, so + * a label that literally contains them stays escaped and cannot break parsing. + */ +function encodeSegment(text: string): string { + let out = ''; + for (const ch of text) { + if (/[0-9A-Za-z]/.test(ch)) out += ch; + else if (ch === ' ') out += '+'; + else out += encodeURIComponent(ch); + } + return out; +} + +/** Format one datum for the `a:` (awesome) encoding; `_` marks a gap/null. */ +function formatValue(value: number | null): string { + if (value == null || !Number.isFinite(value)) return '_'; + if (Number.isInteger(value)) return String(value); + return String(Number(value.toFixed(4))); +} + +function finiteNumber(value: unknown): number | null { + if (value == null || (typeof value === 'string' && value.trim() === '')) return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function cellKey(value: unknown): string { + return `${typeof value}:${String(value)}`; +} + +function pairKey(first: unknown, second: unknown): string { + return JSON.stringify([cellKey(first), cellKey(second)]); +} + +/** Distinct values of a field in first-seen order (nulls skipped). */ +function distinct(rows: any[], field: string): Cell[] { + const seen = new Set(); + const out: Cell[] = []; + for (const r of rows) { + const v = r[field]; + if (v == null) continue; + if (!seen.has(v)) { seen.add(v); out.push(v as Cell); } + } + return out; +} + +/** + * Aggregate the long/tidy rows into a per-series × per-category value matrix, + * summing (or averaging) duplicates. `seriesField` undefined ⇒ one implicit + * series holding the whole measure column. + */ +function pivotValues( + rows: any[], + catField: string, + measField: string, + seriesField: string | undefined, + categories: Cell[], + seriesKeys: Cell[], + aggregate: 'sum' | 'average', +): (number | null)[][] { + const SINGLE = '__single__'; + const acc = new Map(); + for (const r of rows) { + const cv = r[catField]; + if (cv == null) continue; + const sv = seriesField ? r[seriesField] : SINGLE; + const num = finiteNumber(r[measField]); + if (num == null) continue; + const key = pairKey(cv, sv); + const e = acc.get(key) ?? { sum: 0, count: 0 }; + e.sum += num; e.count += 1; acc.set(key, e); + } + const valueAt = (cv: Cell, sv: Cell): number | null => { + const e = acc.get(pairKey(cv, seriesField ? sv : SINGLE)); + if (!e) return null; + return aggregate === 'average' ? e.sum / e.count : e.sum; + }; + return seriesKeys.map((sv) => categories.map((cv) => valueAt(cv, sv))); +} + +/** + * Assemble an {@link ImageChartsArtifact} (a permanent hosted-image URL) from a + * {@link ChartAssemblyInput}. + * + * @throws if the chart type has no faithful Image-Charts `cht` equivalent + * (e.g. Boxplot, Sankey, Heatmap) or its roles cannot be resolved. + */ +export function assembleImageCharts(input: ChartAssemblyInput): ImageChartsArtifact { + const flintType = input.chart_spec.chartType; + const mapping = IMAGE_CHARTS_TYPE_MAP[flintType]; + if (!mapping) { + throw new Error(`Image-Charts backend does not support chart type "${flintType}".`); + } + + const semanticTypes = input.semantic_types ?? {}; + const rawData: any[] = input.data.values ?? []; + const encodings = normalizeEncodings(input.chart_spec.encodings); + + if (encodings.column?.field || encodings.row?.field) { + throw new Error(`Image-Charts backend does not support faceting in one chart: "${flintType}".`); + } + + // ── Phase 0 (reused core): resolve per-channel semantics ──────────────── + let table = convertTemporalData(rawData, semanticTypes); + const sem: SemanticResult = resolveChannelSemantics(encodings, rawData, semanticTypes, table); + const typeOf = (ch: string) => sem[ch]?.type; + const isMeasure = (ch: string) => typeOf(ch) === 'quantitative'; + const fieldOf = (ch: string) => encodings[ch]?.field; + + // A categorical color/group binding becomes the series (legend) dimension; + // a quantitative color is not a series and is ignored on this tier. + const seriesCh = encodings.group?.field + ? 'group' + : encodings.color?.field && !isMeasure('color') + ? 'color' + : undefined; + const seriesField = seriesCh ? fieldOf(seriesCh) : undefined; + + // ── Overflow filtering for banded (bar) families, so URLs stay bounded ── + const keptCategoryOrder = new Map(); + if (mapping.cht === 'bvg' || mapping.cht === 'bhg' || mapping.cht === 'bvs' || mapping.cht === 'bhs') { + const detected = detectBandedAxisFromSemantics(sem, table, { preferAxis: 'x' }); + const declaration: LayoutDeclaration = { + axisFlags: detected ? { [detected.axis]: { banded: true } } : { x: { banded: true } }, + resolvedTypes: detected?.resolvedTypes, + }; + const baseSize = resolveBaseSize(input.chart_spec.baseSize, input.chart_spec.canvasSize); + const options = { + facetFixedPadding: { width: 50, height: 40 }, + facetGap: 10, + targetBandAR: 10, + ...deriveStretchCaps(baseSize, input.chart_spec.canvasSize, {}), + }; + const budgets = computeChannelBudgets(sem, declaration, table, baseSize, options); + const overflow = filterOverflow(sem, declaration, encodings, table, budgets, new Set(['bar'])); + table = overflow.filteredData; + overflow.truncations.forEach((t) => keptCategoryOrder.set(t.field, t.keptValues as Cell[])); + } + + const params: string[] = []; + const size = clampChartSize( + input.chart_spec.baseSize?.width ?? DEFAULT_SIZE.width, + input.chart_spec.baseSize?.height ?? DEFAULT_SIZE.height, + ); + + if (mapping.noAxes) { + buildPartToWhole(params, mapping.cht, sem, table, fieldOf); + } else if (mapping.xy) { + buildScatter(params, table, fieldOf, isMeasure, seriesField, flintType); + } else { + buildAxes( + params, mapping, flintType, sem, table, + fieldOf, typeOf, isMeasure, seriesField, keptCategoryOrder, + ); + } + + params.push(`chs=${size.width}x${size.height}`); + const title = input.chart_spec.title?.trim(); + if (title) params.push(`chtt=${encodeSegment(title)}`); + + return { type: 'image-charts', url: IMAGE_CHARTS_ENDPOINT + params.join('&') }; +} + +/** Pie / doughnut: one series of slices, each with its own label and color. */ +function buildPartToWhole( + params: string[], + cht: string, + sem: SemanticResult, + table: any[], + fieldOf: (ch: string) => string | undefined, +): void { + const catField = fieldOf('color') ?? fieldOf('x'); + const measField = fieldOf('size') ?? fieldOf('theta') ?? fieldOf('y'); + if (!catField || !measField) { + throw new Error(`Image-Charts backend could not resolve slice/value fields for a part-to-whole chart (category=${catField}, value=${measField}).`); + } + const slices = distinct(table, catField); + const measCh = fieldOf('size') === measField ? 'size' : fieldOf('theta') === measField ? 'theta' : 'y'; + const aggregate = sem[measCh]?.aggregationDefault ?? 'sum'; + const [values] = pivotValues(table, catField, measField, undefined, slices, ['__single__'], aggregate); + + params.push(`cht=${cht}`); + params.push(`chd=a:${values.map(formatValue).join(',')}`); + params.push(`chl=${slices.map((s) => encodeSegment(String(s))).join('|')}`); + params.push(`chco=${slices.map((_s, i) => SERIES_COLORS[i % SERIES_COLORS.length]).join('|')}`); +} + +/** Scatter: `lxy` with one (x-set, y-set) pair per series, drawn as markers. */ +function buildScatter( + params: string[], + table: any[], + fieldOf: (ch: string) => string | undefined, + isMeasure: (ch: string) => boolean, + seriesField: string | undefined, + flintType: string, +): void { + const xField = fieldOf('x'); + const yField = fieldOf('y'); + if (!xField || !yField || !isMeasure('x') || !isMeasure('y')) { + throw new Error(`Image-Charts backend requires quantitative x and y fields for "${flintType}".`); + } + const seriesKeys = seriesField ? distinct(table, seriesField) : ['__single__']; + const datasets: string[] = []; + const markers: string[] = []; + const colors: string[] = []; + seriesKeys.forEach((key, index) => { + const rows = seriesField ? table.filter((r) => r[seriesField] === key) : table; + const xs = rows.map((r) => finiteNumber(r[xField])); + const ys = rows.map((r) => finiteNumber(r[yField])); + datasets.push(xs.map(formatValue).join(',')); + datasets.push(ys.map(formatValue).join(',')); + const color = SERIES_COLORS[index % SERIES_COLORS.length]; + colors.push(color); + markers.push(`s,${color},${index},-1,6`); + }); + + params.push('cht=lxy'); + params.push(`chd=a:${datasets.join('|')}`); + params.push(`chco=${colors.join(',')}`); + params.push(`chm=${markers.join('|')}`); + if (seriesField && seriesKeys.length > 1) { + params.push(`chdl=${seriesKeys.map((s) => encodeSegment(String(s))).join('|')}`); + } +} + +/** Bar / line / area / radar: a category axis plus one measure per series. */ +function buildAxes( + params: string[], + mapping: { cht: string; horizontal?: string; radar?: boolean; area?: boolean }, + flintType: string, + sem: SemanticResult, + table: any[], + fieldOf: (ch: string) => string | undefined, + typeOf: (ch: string) => string | undefined, + isMeasure: (ch: string) => boolean, + seriesField: string | undefined, + keptCategoryOrder: Map, +): void { + // Horizontal bar when the measure sits on x and the category on y. + const horizontal = Boolean(mapping.horizontal) && isMeasure('x') && !isMeasure('y'); + const catCh = horizontal ? 'y' : 'x'; + const measCh = horizontal ? 'x' : 'y'; + const catField = fieldOf(catCh); + const measField = fieldOf(measCh); + if (!catField || !measField) { + throw new Error(`Image-Charts backend could not resolve category/measure for "${flintType}" (category=${catField}, measure=${measField}).`); + } + + let categories = keptCategoryOrder.get(catField) ?? distinct(table, catField); + // Ordered domains (line / area over time or a numeric axis) sort ascending. + if (!mapping.radar && (flintType === 'Line Chart' || flintType === 'Area Chart' || flintType === 'Sparkline')) { + if (typeOf(catCh) === 'temporal') { + categories = [...categories].sort((a, b) => new Date(String(a)).getTime() - new Date(String(b)).getTime()); + } else if (typeOf(catCh) === 'quantitative') { + categories = [...categories].sort((a, b) => Number(a) - Number(b)); + } + } + + const seriesKeys = seriesField ? distinct(table, seriesField) : [measField]; + const aggregate = sem[measCh]?.aggregationDefault ?? 'sum'; + const seriesValues = pivotValues(table, catField, measField, seriesField, categories, seriesKeys, aggregate); + + const cht = horizontal ? (mapping.horizontal as string) : mapping.cht; + params.push(`cht=${cht}`); + params.push(`chd=a:${seriesValues.map((vals) => vals.map(formatValue).join(',')).join('|')}`); + + // Category axis: index 0 (x) for vertical/radar, index 1 (y) for horizontal. + const categoryLabels = categories.map((c) => encodeSegment(String(c))).join('|'); + if (mapping.radar) { + params.push('chxt=r'); + params.push(`chxl=0:|${categoryLabels}`); + } else { + params.push('chxt=x,y'); + params.push(`chxl=${horizontal ? 1 : 0}:|${categoryLabels}`); + } + + const seriesColors = seriesKeys.map((_k, i) => SERIES_COLORS[i % SERIES_COLORS.length]); + if (seriesKeys.length > 1 || mapping.area) { + params.push(`chco=${seriesColors.join(',')}`); + } + if (mapping.area) { + params.push(`chm=${seriesColors.map((c, i) => `B,${c},${i},0,0`).join('|')}`); + } + if (seriesField && seriesKeys.length > 1) { + params.push(`chdl=${seriesKeys.map((s) => encodeSegment(String(s))).join('|')}`); + } +} diff --git a/packages/flint-js/src/image-charts/chart-types.ts b/packages/flint-js/src/image-charts/chart-types.ts new file mode 100644 index 00000000..7a66a373 --- /dev/null +++ b/packages/flint-js/src/image-charts/chart-types.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Image-Charts chart-type mapping. + * + * Image-Charts renders through a fixed set of `cht` chart codes (the Google + * Image Charts / Image-Charts query grammar), so a Flint chart type maps to the + * closest native `cht`. Orientation (vertical vs horizontal) is decided by the + * assembler from channel semantics and selects the `bv*` vs `bh*` family. + * + * Coverage is partial by design (like the Excel backend): only chart types with + * a faithful `cht` equivalent are mapped. Everything else throws in `assemble`. + */ + +/** Which Image-Charts `cht` family a Flint chart type maps to. */ +export interface ImageChartsTypeMapping { + /** Base Image-Charts `cht` value (vertical / category-on-x orientation). */ + cht: string; + /** `cht` for the horizontal (category-on-y) variant, when supported. */ + horizontal?: string; + /** True for pie/doughnut charts: slice labels, no value/category axes. */ + noAxes?: boolean; + /** True for XY (both-measure) scatter charts rendered as `lxy`. */ + xy?: boolean; + /** True for radar charts, which use the `chxt=r` polar axis. */ + radar?: boolean; + /** True for area charts: a line (`lc`) plus a `chm=B` fill to the baseline. */ + area?: boolean; +} + +/** Flint chart type (display name) → Image-Charts `cht` family. */ +export const IMAGE_CHARTS_TYPE_MAP: Record = { + 'Bar Chart': { cht: 'bvg', horizontal: 'bhg' }, + 'Grouped Bar Chart': { cht: 'bvg', horizontal: 'bhg' }, + 'Stacked Bar Chart': { cht: 'bvs', horizontal: 'bhs' }, + 'Line Chart': { cht: 'lc' }, + 'Sparkline': { cht: 'ls' }, + 'Area Chart': { cht: 'lc', area: true }, + 'Scatter Plot': { cht: 'lxy', xy: true }, + 'Pie Chart': { cht: 'p', noAxes: true }, + 'Donut Chart': { cht: 'pd', noAxes: true }, + 'Radar Chart': { cht: 'r', radar: true }, +}; + +/** Chart types this backend can render as an Image-Charts URL. */ +export function isImageChartsSupported(flintChartType: string): boolean { + return flintChartType in IMAGE_CHARTS_TYPE_MAP; +} diff --git a/packages/flint-js/src/image-charts/index.ts b/packages/flint-js/src/image-charts/index.ts new file mode 100644 index 00000000..ddc4957c --- /dev/null +++ b/packages/flint-js/src/image-charts/index.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @module flint-chart/image-charts + * + * Image-Charts backend for flint-chart. + * + * Compiles the core semantic layer into a single permanent + * `https://image-charts.com` chart URL (the Google Image Charts / Image-Charts + * query grammar). The URL renders server-side and embeds anywhere an `` + * works — email, PDF, Slack, no-code tools — with no runtime JavaScript. + * + * Architecture contrast with the other backends: + * VL: encoding-channel spec — { encoding: { x, y }, mark } + * EC: series-based option — { series: [...], xAxis, yAxis } + * CJS: dataset-based config — { type, data: { labels, datasets } } + * Excel: range/matrix spec — { chartType, data: [[...]], axes } + * Image-Charts: hosted-image URL — { type: 'image-charts', url } + * + * `assembleImageCharts` is PURE: it builds a string, performs no network I/O and + * no signing, and emits unsigned free-tier URLs only. + */ + +export { assembleImageCharts } from './assemble'; +export type { ImageChartsArtifact } from './assemble'; +export { IMAGE_CHARTS_TYPE_MAP, isImageChartsSupported } from './chart-types'; +export type { ImageChartsTypeMapping } from './chart-types'; diff --git a/packages/flint-js/src/index.ts b/packages/flint-js/src/index.ts index 824f9746..eeb753d1 100644 --- a/packages/flint-js/src/index.ts +++ b/packages/flint-js/src/index.ts @@ -57,3 +57,6 @@ export * from './plotly'; // Excel backend: assembleExcel + Excel chart spec types export * from './excel'; + +// Image-Charts backend: assembleImageCharts + hosted-image-URL artifact type +export * from './image-charts'; diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts new file mode 100644 index 00000000..67c12789 --- /dev/null +++ b/packages/flint-js/src/interactive/index.ts @@ -0,0 +1,79 @@ +import type { ChartAssemblyInput } from '../core/types'; +import { mountInteractiveChartSurface } from './surface'; +import type { BuildInteractiveChartOptions, InteractiveChartSurface } from './types'; + +export type { + BuildInteractiveChartOptions, + InteractiveBackend, + InteractiveChartSurface, + InteractiveChartSurfaceOptions, + InteractiveRenderer, + InteractiveRendererAdapter, + ViewportChannel, + ViewportGeometry, + ViewportState, +} from './types'; +export { clampViewportStart, mountInteractiveChartSurface } from './surface'; + +export function buildInteractiveChart( + container: HTMLElement, + input: ChartAssemblyInput, + options: BuildInteractiveChartOptions, +): InteractiveChartSurface { + const { backend, renderer, focusOnClick, expressionInterpreter, background, className, ariaLabel } = options; + switch (backend) { + case 'vegalite': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createVegaInteractiveRenderer } = await import('../vegalite/interactive'); + return createVegaInteractiveRenderer({ + renderer, + focusOnClick, + expressionInterpreter, + background, + }).mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel }, + ); + case 'echarts': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createEChartsInteractiveRenderer } = await import('../echarts/interactive'); + return createEChartsInteractiveRenderer({ renderer }).mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel }, + ); + case 'chartjs': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createChartjsInteractiveRenderer } = await import('../chartjs/interactive'); + return createChartjsInteractiveRenderer().mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel }, + ); + case 'plotly': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createPlotlyInteractiveRenderer } = await import('../plotly/interactive'); + return createPlotlyInteractiveRenderer().mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel }, + ); + } +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/surface.ts b/packages/flint-js/src/interactive/surface.ts new file mode 100644 index 00000000..498a3cf2 --- /dev/null +++ b/packages/flint-js/src/interactive/surface.ts @@ -0,0 +1,252 @@ +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { + InteractiveChartSurface, + InteractiveChartSurfaceOptions, + InteractiveRenderer, + InteractiveRendererAdapter, + ViewportChannel, + ViewportState, +} from './types'; + +const RAIL_THICKNESS = 8; +const RAIL_GAP = 9; +const RAIL_TRACK_COLOR = 'rgba(31, 41, 55, 0.035)'; +const RAIL_THUMB_COLOR = 'rgba(31, 41, 55, 0.14)'; +const MIN_HORIZONTAL_RAIL_INSET = 8; +const MAX_HORIZONTAL_RAIL_INSET = 16; +const HORIZONTAL_RAIL_INSET_RATIO = 0.025; + +export function clampViewportStart(viewport: CategoryViewport, requestedStart: number): number { + const max = Math.max(0, viewport.totalCount - viewport.visibleCount); + return Math.min(max, Math.max(0, Math.floor(requestedStart))); +} + +function applyStyles(element: HTMLElement, styles: Partial): void { + Object.assign(element.style, styles); +} + +function createViewportRail( + viewport: CategoryViewport, + initialStart: number, + onChange: (start: number) => void, +): { element: HTMLElement; update(start: number): void; setGeometry(offset: number, extent: number): void } { + const vertical = viewport.channel === 'y'; + const rail = document.createElement('div'); + const track = document.createElement('span'); + const thumb = document.createElement('span'); + let start = clampViewportStart(viewport, initialStart); + let dragOffset = 0; + + rail.dataset.flintViewport = viewport.channel; + applyStyles(rail, vertical ? { + display: 'flex', flexDirection: 'column', alignItems: 'center', alignSelf: 'start', minHeight: '0', + } : { + display: 'block', justifySelf: 'start', width: '100%', maxWidth: '100%', minWidth: '0', + }); + track.tabIndex = 0; + track.setAttribute('role', 'scrollbar'); + track.setAttribute('aria-label', `Visible ${viewport.field} range`); + track.setAttribute('aria-orientation', vertical ? 'vertical' : 'horizontal'); + applyStyles(track, vertical ? { + position: 'relative', display: 'block', width: `${RAIL_THICKNESS}px`, flex: '1 1 auto', minHeight: '96px', overflow: 'hidden', + borderRadius: '4px', background: RAIL_TRACK_COLOR, cursor: 'ns-resize', touchAction: 'none', outline: 'none', + } : { + position: 'relative', display: 'block', width: '100%', height: `${RAIL_THICKNESS}px`, overflow: 'hidden', + borderRadius: '4px', background: RAIL_TRACK_COLOR, cursor: 'ew-resize', touchAction: 'none', outline: 'none', + }); + applyStyles(thumb, vertical ? { + position: 'absolute', left: '0', right: '0', borderRadius: '4px', background: RAIL_THUMB_COLOR, pointerEvents: 'none', + } : { + position: 'absolute', top: '0', bottom: '0', borderRadius: '4px', background: RAIL_THUMB_COLOR, pointerEvents: 'none', + }); + track.append(thumb); + rail.append(track); + + const update = (requestedStart: number): void => { + start = clampViewportStart(viewport, requestedStart); + const end = Math.min(viewport.totalCount, start + viewport.visibleCount); + const max = Math.max(0, viewport.totalCount - viewport.visibleCount); + const leading = viewport.totalCount > 0 ? start / viewport.totalCount * 100 : 0; + const size = viewport.totalCount > 0 ? viewport.visibleCount / viewport.totalCount * 100 : 100; + track.setAttribute('aria-valuemin', '0'); + track.setAttribute('aria-valuemax', String(max)); + track.setAttribute('aria-valuenow', String(start)); + track.setAttribute('aria-valuetext', `${start + 1} through ${end} of ${viewport.totalCount}`); + if (vertical) { + thumb.style.top = `${leading}%`; + thumb.style.height = `${size}%`; + } else { + thumb.style.left = `${leading}%`; + thumb.style.width = `${size}%`; + } + }; + + const updateFromPointer = (event: PointerEvent): void => { + const rect = track.getBoundingClientRect(); + const length = vertical ? rect.height : rect.width; + const thumbLength = length * viewport.visibleCount / viewport.totalCount; + const available = Math.max(1, length - thumbLength); + const pointer = vertical ? event.clientY - rect.top : event.clientX - rect.left; + const max = Math.max(0, viewport.totalCount - viewport.visibleCount); + const next = Math.round(Math.min(1, Math.max(0, (pointer - dragOffset) / available)) * max); + update(next); + onChange(next); + }; + + track.addEventListener('pointerdown', (event) => { + const rect = track.getBoundingClientRect(); + const length = vertical ? rect.height : rect.width; + const pointer = vertical ? event.clientY - rect.top : event.clientX - rect.left; + const thumbLeading = length * start / viewport.totalCount; + const thumbLength = length * viewport.visibleCount / viewport.totalCount; + dragOffset = event.target === thumb ? pointer - thumbLeading : thumbLength / 2; + track.setPointerCapture(event.pointerId); + updateFromPointer(event); + }); + track.addEventListener('pointermove', (event) => { + if (track.hasPointerCapture(event.pointerId)) updateFromPointer(event); + }); + const release = (event: PointerEvent): void => { + if (track.hasPointerCapture(event.pointerId)) track.releasePointerCapture(event.pointerId); + }; + track.addEventListener('pointerup', release); + track.addEventListener('pointercancel', release); + track.addEventListener('keydown', (event) => { + const previous = vertical ? 'ArrowUp' : 'ArrowLeft'; + const next = vertical ? 'ArrowDown' : 'ArrowRight'; + const max = Math.max(0, viewport.totalCount - viewport.visibleCount); + let requested: number | undefined; + if (event.key === previous) requested = start - 1; + else if (event.key === next) requested = start + 1; + else if (event.key === 'PageUp') requested = start - viewport.visibleCount; + else if (event.key === 'PageDown') requested = start + viewport.visibleCount; + else if (event.key === 'Home') requested = 0; + else if (event.key === 'End') requested = max; + if (requested === undefined) return; + event.preventDefault(); + const clamped = Math.min(max, Math.max(0, requested)); + update(clamped); + onChange(clamped); + }); + update(start); + const setGeometry = (offset: number, extent: number): void => { + if (!Number.isFinite(offset) || !Number.isFinite(extent) || extent <= 0) return; + if (vertical) { + rail.style.marginTop = `${Math.max(0, Math.floor(offset))}px`; + rail.style.height = `${Math.floor(extent)}px`; + rail.style.maxHeight = '100%'; + } else { + const inset = Math.min( + MAX_HORIZONTAL_RAIL_INSET, + Math.max(MIN_HORIZONTAL_RAIL_INSET, Math.round(extent * HORIZONTAL_RAIL_INSET_RATIO)), + ); + rail.style.marginLeft = `${Math.max(0, Math.floor(offset + inset))}px`; + rail.style.width = `${Math.max(1, Math.floor(extent - inset * 2))}px`; + } + }; + return { element: rail, update, setGeometry }; +} + +function renderedChartExtent(chart: HTMLElement): { width: number; height: number } { + const bounds = Array.from(chart.children) + .map((element) => element.getBoundingClientRect()) + .filter((rect) => rect.width > 0 && rect.height > 0); + if (bounds.length === 0) { + const rect = chart.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + } + return { + width: Math.max(...bounds.map((rect) => rect.width)), + height: Math.max(...bounds.map((rect) => rect.height)), + }; +} + +export function mountInteractiveChartSurface( + container: HTMLElement, + input: ChartAssemblyInput, + adapter: InteractiveRendererAdapter, + options: InteractiveChartSurfaceOptions = {}, +): InteractiveChartSurface { + const root = document.createElement('div'); + const chart = document.createElement('div'); + const state: ViewportState = {}; + const rails = new Map>(); + let renderer: InteractiveRenderer | undefined; + let updateTimer: number | undefined; + let destroyed = false; + + root.className = options.className ?? 'flint-interactive-surface'; + root.setAttribute('role', 'figure'); + root.setAttribute('aria-label', options.ariaLabel ?? input.chart_spec.title ?? 'Interactive chart'); + applyStyles(root, { + display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateRows: 'minmax(0, auto) auto', + alignItems: 'stretch', rowGap: '6px', minWidth: '0', maxWidth: '100%', marginInline: 'auto', + }); + chart.dataset.flintChart = ''; + applyStyles(chart, { gridColumn: '1', gridRow: '1', minWidth: '0', overflow: 'hidden' }); + root.append(chart); + container.replaceChildren(root); + + const scheduleRender = (): void => { + if (!renderer || updateTimer !== undefined || destroyed) return; + updateTimer = window.setTimeout(() => { + updateTimer = undefined; + void renderer?.setViewports({ ...state }); + }, 0); + }; + const setViewport = (channel: ViewportChannel, requestedStart: number): void => { + const viewport = renderer?.viewports.find((candidate) => candidate.channel === channel); + if (!viewport) return; + state[channel] = clampViewportStart(viewport, requestedStart); + rails.get(channel)?.update(state[channel] ?? 0); + scheduleRender(); + }; + + const ready = adapter.mount(chart, input).then((mounted) => { + if (destroyed) { + mounted.destroy(); + return; + } + renderer = mounted; + for (const viewport of mounted.viewports) { + state[viewport.channel] = 0; + const rail = createViewportRail(viewport, 0, (start) => setViewport(viewport.channel, start)); + rails.set(viewport.channel, rail); + if (viewport.channel === 'x') { + rail.element.style.gridColumn = '1'; + rail.element.style.gridRow = '2'; + } else { + rail.element.style.gridColumn = '2'; + rail.element.style.gridRow = '1'; + root.style.gridTemplateColumns = `minmax(0, 1fr) ${RAIL_THICKNESS}px`; + root.style.columnGap = `${RAIL_GAP}px`; + } + root.append(rail.element); + } + const syncRailExtents = (): void => { + const extent = renderedChartExtent(chart); + const xGeometry = renderer?.getViewportGeometry?.('x'); + const yGeometry = renderer?.getViewportGeometry?.('y'); + rails.get('x')?.setGeometry(xGeometry?.offset ?? 0, xGeometry?.extent ?? extent.width); + rails.get('y')?.setGeometry(yGeometry?.offset ?? 0, yGeometry?.extent ?? extent.height); + const verticalRailGutter = rails.has('y') ? RAIL_THICKNESS + RAIL_GAP : 0; + root.style.width = `${Math.ceil(extent.width + verticalRailGutter)}px`; + }; + syncRailExtents(); + window.setTimeout(syncRailExtents, 0); + }); + + return { + element: root, + ready, + getViewportState: () => ({ ...state }), + setViewport, + destroy: () => { + if (destroyed) return; + destroyed = true; + if (updateTimer !== undefined) window.clearTimeout(updateTimer); + renderer?.destroy(); + container.replaceChildren(); + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts new file mode 100644 index 00000000..a06b6b34 --- /dev/null +++ b/packages/flint-js/src/interactive/types.ts @@ -0,0 +1,45 @@ +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; + +export type ViewportChannel = 'x' | 'y'; +export type ViewportState = Partial>; + +export interface ViewportGeometry { + offset: number; + extent: number; +} + +export interface InteractiveRenderer { + viewports: CategoryViewport[]; + setViewports(starts: ViewportState): void | Promise; + getViewportGeometry?(channel: ViewportChannel): ViewportGeometry | undefined; + resize?(size: { width: number; height: number }): void | Promise; + destroy(): void; +} + +export interface InteractiveRendererAdapter { + mount(container: HTMLElement, input: ChartAssemblyInput): Promise; +} + +export interface InteractiveChartSurfaceOptions { + className?: string; + ariaLabel?: string; +} + +export type InteractiveBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; + +export interface BuildInteractiveChartOptions extends InteractiveChartSurfaceOptions { + backend: InteractiveBackend; + renderer?: 'canvas' | 'svg'; + /** Enable local click focus where supported. Defaults to true for Vega-Lite. */ + focusOnClick?: boolean; + expressionInterpreter?: unknown; + background?: string; +} + +export interface InteractiveChartSurface { + readonly element: HTMLElement; + readonly ready: Promise; + getViewportState(): ViewportState; + setViewport(channel: ViewportChannel, start: number): void; + destroy(): void; +} \ 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 8b338b07..6cf3e7e0 100644 --- a/packages/flint-js/src/plotly/assemble.ts +++ b/packages/flint-js/src/plotly/assemble.ts @@ -554,6 +554,9 @@ export function assemblePlotly(input: ChartAssemblyInput): any { if (warnings.length > 0) { figure._warnings = warnings; } + if (overflowResult.viewports.length > 0) { + figure._viewports = overflowResult.viewports; + } figure._dataLength = values.length; diff --git a/packages/flint-js/src/plotly/interactive.ts b/packages/flint-js/src/plotly/interactive.ts new file mode 100644 index 00000000..fff13080 --- /dev/null +++ b/packages/flint-js/src/plotly/interactive.ts @@ -0,0 +1,84 @@ +import { applyCategoryViewports } from '../core/filter-overflow'; +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; +import { assemblePlotly } from './assemble'; +import Plotly from 'plotly.js-dist-min'; + +function windowedInput( + input: ChartAssemblyInput, + viewports: CategoryViewport[], + starts: ViewportState, +): ChartAssemblyInput { + return { + ...input, + data: { + values: applyCategoryViewports(input.data.values ?? [], viewports, starts), + }, + }; +} + +export function createPlotlyInteractiveRenderer(): InteractiveRendererAdapter { + return { + async mount(container, input) { + const plannedFigure = assemblePlotly(input) as any; + const viewports = (plannedFigure._viewports ?? []) as CategoryViewport[]; + const initialFigure = viewports.length > 0 + ? assemblePlotly(windowedInput(input, viewports, {})) as any + : plannedFigure; + await Plotly.newPlot(container, initialFigure.data ?? [], initialFigure.layout ?? {}, { + displayModeBar: false, + responsive: false, + }); + + let destroyed = false; + let running = false; + let updateTimer: number | undefined; + let requestedVersion = 0; + let appliedVersion = 0; + let latestStarts: ViewportState = {}; + + const schedule = (): void => { + if (destroyed || running || updateTimer !== undefined) return; + updateTimer = window.setTimeout(() => { + updateTimer = undefined; + if (destroyed) return; + const version = requestedVersion; + const figure = assemblePlotly(windowedInput(input, viewports, latestStarts)); + running = true; + void Plotly.react(container, figure.data ?? [], figure.layout ?? {}, { + displayModeBar: false, + responsive: false, + }).finally(() => { + running = false; + appliedVersion = version; + if (requestedVersion !== appliedVersion) schedule(); + }); + }, 0); + }; + + return { + viewports, + getViewportGeometry(channel) { + const axis = (container as any)._fullLayout?.[`${channel}axis`]; + if (!axis || !Number.isFinite(axis._offset) || !Number.isFinite(axis._length)) return undefined; + return { offset: axis._offset, extent: axis._length }; + }, + setViewports(starts) { + latestStarts = { ...starts }; + requestedVersion += 1; + schedule(); + }, + resize() { + void Plotly.Plots.resize(container); + }, + destroy() { + if (destroyed) return; + destroyed = true; + if (updateTimer !== undefined) window.clearTimeout(updateTimer); + Plotly.purge(container); + container.replaceChildren(); + }, + }; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/plotly/plotly-js-dist-min.d.ts b/packages/flint-js/src/plotly/plotly-js-dist-min.d.ts new file mode 100644 index 00000000..748f0073 --- /dev/null +++ b/packages/flint-js/src/plotly/plotly-js-dist-min.d.ts @@ -0,0 +1,4 @@ +declare module 'plotly.js-dist-min' { + const Plotly: any; + export default Plotly; +} \ No newline at end of file diff --git a/packages/flint-js/src/plotly/templates/bar-table.ts b/packages/flint-js/src/plotly/templates/bar-table.ts index c1bccb6a..c21483e6 100644 --- a/packages/flint-js/src/plotly/templates/bar-table.ts +++ b/packages/flint-js/src/plotly/templates/bar-table.ts @@ -108,7 +108,7 @@ interface AggRow { /** Aggregate raw rows into ranked-and-topN'd category rows for one facet scope. */ function buildScopeRows( rows: any[], yField: string, xField: string, colorField: string | undefined, - useMean: boolean, maxRows: number, reversed: boolean, + useMean: boolean, maxRows: number, reversed: boolean, xOrdinal: boolean, ): AggRow[] { const byCat = new Map }>(); for (const r of rows) { @@ -126,7 +126,7 @@ function buildScopeRows( const agg = (g: { sum: number; n: number }) => useMean ? g.sum / Math.max(1, g.n) : g.sum; const ranked = Array.from(byCat.entries()) .map(([cat, g]) => ({ cat, value: agg(g), byColor: colorField ? g.byColor : undefined })) - .sort((a, b) => reversed ? a.value - b.value : b.value - a.value); + .sort((a, b) => (reversed || xOrdinal) ? a.value - b.value : b.value - a.value); if (maxRows <= 0 || ranked.length <= maxRows) { return ranked.map(r => ({ ...r, isOthers: false })); @@ -177,6 +177,10 @@ export const plBarTableDef: ChartTemplateDef = { const showPercent = chartProperties?.showPercent === true; const useMean = channelSemantics.x?.aggregationDefault === 'average'; const reversed = !!channelSemantics.y?.reversed; + // Ordinal measures (Rank) are standings, not magnitudes — length-encoding + // them inverts the ranking (see issue #85). Honor the documented `Rank` + // behaviour: rank ascending (1 first), discrete colour, equal-length bars. + const xIsOrdinal = channelSemantics.x?.type === 'ordinal'; const xEntry = getRegistryEntry(channelSemantics.x?.semanticAnnotation?.semanticType ?? 'Unknown'); let hasNegative = false, hasPositive = false; @@ -233,7 +237,7 @@ export const plBarTableDef: ChartTemplateDef = { // ── Per-cell aggregation (Top-N rollup within each facet scope). ── const scoped = cells.map(row => row.map(cell => - buildScopeRows(cell.rows, yField, xField, colorField, useMean, maxRows, reversed))); + buildScopeRows(cell.rows, yField, xField, colorField, useMean, maxRows, reversed, xIsOrdinal))); const allColorValues = colorField ? [...new Set(scoped.flat().flatMap(sr => sr.filter(r => !r.isOthers).flatMap(r => [...(r.byColor?.keys() ?? [])])))] @@ -374,12 +378,16 @@ export const plBarTableDef: ChartTemplateDef = { }); } } else { - const vals = sr.map(r => r.value); + const vals = xIsOrdinal ? sr.map(() => 1) : sr.map(r => r.value); const finite = vals.filter(Number.isFinite); const vmin = finite.length ? Math.min(...finite, 0) : 0; const vmax = finite.length ? Math.max(...finite) : 1; - const colors = sr.map(r => { + const colors = sr.map((r, idx) => { if (r.isOthers) return OTHERS_GRAY; + if (xIsOrdinal) { + // Discrete colour per rank — no magnitude ramp. + return palette[idx % palette.length]; + } if (isDiverging) { const span = Math.max(Math.abs(vmin), Math.abs(vmax)) || 1; const t = r.value / span; // -1..1 diff --git a/packages/flint-js/src/plotly/theme.ts b/packages/flint-js/src/plotly/theme.ts index 3e930e34..cd4809c2 100644 --- a/packages/flint-js/src/plotly/theme.ts +++ b/packages/flint-js/src/plotly/theme.ts @@ -23,7 +23,7 @@ * 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: + * (`insideMinValue`) 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. @@ -2472,7 +2472,7 @@ function applyDataLabels(figure: any, d: DesignDecisions, table: any[], say: Say } // 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 + // `insideMinValue`. `auto` hands that decision to // the renderer, which can measure the drawn bar; `outside` is // honoured literally because it is a house habit, not a fit. // A segment of a stack has no outside — "outside" is the middle of diff --git a/packages/flint-js/src/test-data/image-charts-tests.ts b/packages/flint-js/src/test-data/image-charts-tests.ts new file mode 100644 index 00000000..abd64f6a --- /dev/null +++ b/packages/flint-js/src/test-data/image-charts-tests.ts @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Gallery generators for the Image-Charts backend. + * + * These cases exercise the URL-grammar paths the backend builds: a plain bar + * (`cht=bvg`, `chxl` categories), a multi-series grouped bar (`chco` + `chdl` + * legend), a line, a filled area (`chm=B`), a pie (per-slice `chl` + `chco`), + * and a scatter (`cht=lxy` + `chm=s` markers). The data is backend-agnostic — + * the gallery renders it through `assembleImageCharts`. + */ + +import { Type } from './df-types'; +import { TestCase, makeField, makeEncodingItem } from './types'; + +const CATEGORY_META = { type: Type.String, semanticType: 'Category', levels: [] as any[] }; +const QUANTITY_META = { type: Type.Number, semanticType: 'Quantity', levels: [] as any[] }; + +export function genImageChartsTests(): TestCase[] { + return [ + { + title: 'Bar — sales by region', + description: 'A single-series vertical bar, category labels on the x axis.', + tags: ['bar', 'nominal', 'quantitative', 'image-charts'], + chartType: 'Bar Chart', + data: [ + { Region: 'North', Sales: 42 }, + { Region: 'South', Sales: 35 }, + { Region: 'East', Sales: 58 }, + { Region: 'West', Sales: 27 }, + ], + fields: [makeField('Region'), makeField('Sales')], + metadata: { Region: CATEGORY_META, Sales: QUANTITY_META }, + encodingMap: { x: makeEncodingItem('Region'), y: makeEncodingItem('Sales') }, + }, + { + title: 'Grouped bar — sales by region and channel', + description: 'Two series dodge per category, driving a per-series palette and a legend.', + tags: ['bar', 'grouped', 'series', 'legend', 'image-charts'], + chartType: 'Grouped Bar Chart', + data: [ + { Region: 'North', Sales: 42, Channel: 'Retail' }, + { Region: 'North', Sales: 20, Channel: 'Online' }, + { Region: 'South', Sales: 35, Channel: 'Retail' }, + { Region: 'South', Sales: 31, Channel: 'Online' }, + ], + fields: [makeField('Region'), makeField('Sales'), makeField('Channel')], + metadata: { Region: CATEGORY_META, Sales: QUANTITY_META, Channel: CATEGORY_META }, + encodingMap: { + x: makeEncodingItem('Region'), + y: makeEncodingItem('Sales'), + group: makeEncodingItem('Channel'), + }, + }, + { + title: 'Line — monthly signups', + description: 'An ordered category axis with a single quantitative series.', + tags: ['line', 'temporal', 'quantitative', 'image-charts'], + chartType: 'Line Chart', + data: [ + { Month: '2026-01', Signups: 120 }, + { Month: '2026-02', Signups: 150 }, + { Month: '2026-03', Signups: 138 }, + { Month: '2026-04', Signups: 176 }, + ], + fields: [makeField('Month'), makeField('Signups')], + metadata: { + Month: { type: Type.String, semanticType: 'YearMonth', levels: [] }, + Signups: QUANTITY_META, + }, + encodingMap: { x: makeEncodingItem('Month'), y: makeEncodingItem('Signups') }, + }, + { + title: 'Area — traffic over time', + description: 'A line filled to the baseline via a chm=B marker.', + tags: ['area', 'temporal', 'quantitative', 'image-charts'], + chartType: 'Area Chart', + data: [ + { Day: '2026-01-01', Visits: 30 }, + { Day: '2026-01-02', Visits: 52 }, + { Day: '2026-01-03', Visits: 41 }, + { Day: '2026-01-04', Visits: 66 }, + ], + fields: [makeField('Day'), makeField('Visits')], + metadata: { + Day: { type: Type.Date, semanticType: 'Date', levels: [] }, + Visits: QUANTITY_META, + }, + encodingMap: { x: makeEncodingItem('Day'), y: makeEncodingItem('Visits') }, + }, + { + title: 'Pie — market share', + description: 'Slice labels and a per-slice palette.', + tags: ['pie', 'part-to-whole', 'image-charts'], + chartType: 'Pie Chart', + data: [ + { Vendor: 'Acme', Share: 45 }, + { Vendor: 'Globex', Share: 30 }, + { Vendor: 'Initech', Share: 15 }, + { Vendor: 'Umbrella', Share: 10 }, + ], + fields: [makeField('Vendor'), makeField('Share')], + metadata: { Vendor: CATEGORY_META, Share: QUANTITY_META }, + encodingMap: { color: makeEncodingItem('Vendor'), size: makeEncodingItem('Share') }, + }, + { + title: 'Scatter — weight vs mpg', + description: 'Two measures on lxy, drawn as chm=s point markers.', + tags: ['scatter', 'quantitative', 'image-charts'], + chartType: 'Scatter Plot', + data: [ + { Weight: 1.6, Mpg: 32 }, + { Weight: 2.1, Mpg: 27 }, + { Weight: 1.9, Mpg: 29 }, + { Weight: 2.4, Mpg: 24 }, + ], + fields: [makeField('Weight'), makeField('Mpg')], + metadata: { Weight: QUANTITY_META, Mpg: QUANTITY_META }, + encodingMap: { x: makeEncodingItem('Weight'), y: makeEncodingItem('Mpg') }, + }, + ]; +} diff --git a/packages/flint-js/src/test-data/index.ts b/packages/flint-js/src/test-data/index.ts index 3b441185..27761642 100644 --- a/packages/flint-js/src/test-data/index.ts +++ b/packages/flint-js/src/test-data/index.ts @@ -42,6 +42,7 @@ export { genLineAreaStretchTests } from './line-area-stretch-tests'; export { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests'; export { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests'; export { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests'; +export { genImageChartsTests } from './image-charts-tests'; export { genDiscreteAxisTests } from './discrete-axis-tests'; export { genDateTests, genDateYearTests, genDateMonthTests, genDateYearMonthTests, genDateDecadeTests, genDateDateTimeTests, genDateHoursTests } from './date-tests'; export { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests'; @@ -118,6 +119,7 @@ import { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests'; import { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests'; import { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests'; import { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests'; +import { genImageChartsTests } from './image-charts-tests'; import { genGalleryRegionalSurveyScatterTests, genGalleryRegionalSurveyLineTests, @@ -259,6 +261,7 @@ export const TEST_GENERATORS: Record TestCase[]> = { 'Chart.js: Stress Tests': genChartJsStressTests, 'Plotly: Core Templates': genPlotlyCoreTests, 'Plotly: Facets': genPlotlyFacetTests, + 'Image-Charts: Core Templates': genImageChartsTests, 'Gallery: Scatter': genGalleryRegionalSurveyScatterTests, 'Gallery: Line': genGalleryRegionalSurveyLineTests, 'Gallery: Bar': genGalleryRegionalSurveyBarTests, diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index 4ef2a977..5bcdddf9 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -60,7 +60,7 @@ import { applyPivot, applyTransform, type PivotSurface, type TransformSurface } import { vlGetTemplateDef } from './templates'; import { inferVisCategory, computeZeroDecision } from '../core/semantic-types'; import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics'; -import { toTypeString, type SemanticAnnotation } from '../core/field-semantics'; +import { resolveDisplayUnit, titleWithDisplayUnit, toTypeString, type SemanticAnnotation } from '../core/field-semantics'; import { filterOverflow } from '../core/filter-overflow'; import { computeLayout, computeChannelBudgets, computeMinSubplotDimensions, deriveStretchCaps, resolveBaseSize, resolveFacetColumnsOption } from '../core/compute-layout'; import { vlApplyLayoutToSpec, vlApplyTooltips } from './instantiate-spec'; @@ -842,7 +842,9 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { titled: Boolean(vgObj.title), headline: headlineText(vgObj.title), hostSurface: (input.options as any)?.background, - valueLabels: resolveValueLabelChoice(chartProperties), + valueLabels: chartTemplate.suppressValueLabels + ? 'off' + : resolveValueLabelChoice(chartProperties), geometryKinds: chartTemplate.geometryKinds, }); @@ -872,6 +874,9 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { if (warnings.length > 0) { result._warnings = warnings; } + if (overflowResult.viewports.length > 0) { + result._viewports = overflowResult.viewports; + } result._width = layoutResult.subplotWidth; result._height = layoutResult.subplotHeight; // Annotated option catalog: every configurable property this template @@ -916,8 +921,8 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { // whose template already writes its own text. Templates that print labels // *on request* are the exception: they answer to the toggle themselves. const designCoupledApplicability: Record = { - showValueLabels: ownsLabels - || (design?.dataLabels?.possible === true && !templateDrawsOwnText), + showValueLabels: !chartTemplate.suppressValueLabels && (ownsLabels + || (design?.dataLabels?.possible === true && !templateDrawsOwnText)), // The older spelling stays an accepted *input* for compatibility, but a // host should be shown one switch, not two that fight. showTextLabels: false, @@ -1312,6 +1317,17 @@ function buildVLEncodings( encodingObj.title = fieldDisplayNames[fieldName]; } + // A lexical unit explicitly declared by the author belongs once with + // the field name, independent of whether a visual theme is applied. + const displayUnit = resolveDisplayUnit(cs?.semanticAnnotation); + if ((channel === 'x' || channel === 'y') && cs?.type === 'quantitative' + && displayUnit?.placement === 'field' && encodingObj.title !== null) { + const currentTitle = typeof encodingObj.title === 'string' + ? encodingObj.title + : fieldName; + if (currentTitle) encodingObj.title = titleWithDisplayUnit(currentTitle, displayUnit); + } + // --- Collect resolved encoding --- if (Object.keys(encodingObj).length !== 0) { resolvedEncodings[channel] = encodingObj; diff --git a/packages/flint-js/src/vegalite/instantiate-spec.ts b/packages/flint-js/src/vegalite/instantiate-spec.ts index d89ce135..6e70a9f5 100644 --- a/packages/flint-js/src/vegalite/instantiate-spec.ts +++ b/packages/flint-js/src/vegalite/instantiate-spec.ts @@ -553,6 +553,54 @@ function computeStackedExtremes( return { maxPos, minNeg }; } +const NICE_E10 = Math.sqrt(50); +const NICE_E5 = Math.sqrt(10); +const NICE_E2 = Math.SQRT2; + +function niceStackSpan(start: number, stop: number, count: number): [number, number] { + let lo = start; + let hi = stop; + let previousStep: number | undefined; + for (let index = 0; index < 32; index += 1) { + const rawStep = (hi - lo) / Math.max(1, count); + const power = Math.floor(Math.log10(rawStep)); + const error = rawStep / 10 ** power; + const factor = error >= NICE_E10 ? 10 : error >= NICE_E5 ? 5 : error >= NICE_E2 ? 2 : 1; + const step = power >= 0 ? factor * 10 ** power : -(10 ** -power) / factor; + if (step === previousStep || step === 0 || !Number.isFinite(step)) break; + if (step > 0) { + lo = Math.floor(lo / step) * step; + hi = Math.ceil(hi / step) * step; + } else { + lo = Math.ceil(lo * step) / step; + hi = Math.floor(hi * step) / step; + } + previousStep = step; + } + return [lo, hi]; +} + +/** + * Pin a positive sum stack that already ends on the clean tick `nice` would + * choose. Stored calculated shares can total 99.9999999999; leaving that to + * Vega's post-stack arithmetic may cross the tick by a rounding bit and add a + * whole empty interval. A meaningful excess remains on automatic nice. + */ +function pinCleanStackEndpoint(enc: any, extremes: { maxPos: number; minNeg: number }): void { + if (extremes.minNeg < 0 || !(extremes.maxPos > 0)) return; + if (enc.scale?.domain != null || enc.scale?.domainMax != null || enc.scale?.nice === false) return; + const count = typeof enc.scale?.nice === 'number' ? enc.scale.nice : 10; + const tolerance = Math.max(1, Math.abs(extremes.maxPos)) * 1e-9; + const [, cleanMax] = niceStackSpan(0, extremes.maxPos - tolerance, count); + if (Math.abs(cleanMax - extremes.maxPos) > tolerance) return; + enc.scale = { + ...(enc.scale ?? {}), + domainMin: enc.scale?.domainMin ?? 0, + domainMax: cleanMax, + nice: false, + }; +} + /** * Detect whether a discrete category repeats across rows — i.e., multiple rows * share the same category value, which makes Vega-Lite stack the measure even @@ -745,11 +793,15 @@ function vlApplyFieldContext( const otherChannel = ch === 'y' ? 'x' : 'y'; const otherCS = channelSemantics[otherChannel]; const otherIsDiscrete = otherCS?.type === 'nominal' || otherCS?.type === 'ordinal'; - const isImplicitlyStacked = isBarLike && otherIsDiscrete && enc.stack !== null - && (hasColorEncoding || hasRepeatedCategory(context.table, otherCS?.field, enc.field)); + const isImplicitlyStacked = isBarLike && enc.stack !== null + && (hasColorEncoding + || (otherIsDiscrete && hasRepeatedCategory(context.table, otherCS?.field, enc.field))); const isStacked = isExplicitlyStacked || isImplicitlyStacked; const isNormalizeStacked = enc.stack === 'normalize'; const isSumStacked = isStacked && !isNormalizeStacked; + const stackedExtremes = isSumStacked + ? computeStackedExtremes(context.table, enc.field, ch, channelSemantics) + : undefined; // For sum-stacked charts, check if stacked totals exceed the // intrinsic domain. If they do, skip the domain constraint. @@ -770,9 +822,7 @@ function vlApplyFieldContext( // can't find the intrinsic bounds to snap totals against. const intrinsic = getEffectiveIntrinsicDomain(cs, context.table, enc.field); if (intrinsic) { - const extremes = computeStackedExtremes( - context.table, enc.field, ch, channelSemantics, - ); + const extremes = stackedExtremes; if (extremes !== undefined) { // VL stacks positive and negative contributions @@ -866,6 +916,8 @@ function vlApplyFieldContext( } } + if (stackedExtremes) pinCleanStackEndpoint(enc, stackedExtremes); + // ── 4. Tick constraint (axis.tickMinStep + axis.values) ── // Skip binned encodings — VL handles bin ticks natively. // Without this: Rating 1-5 and Count axes show fractional ticks diff --git a/packages/flint-js/src/vegalite/interactive-focus.ts b/packages/flint-js/src/vegalite/interactive-focus.ts new file mode 100644 index 00000000..aff589e4 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactive-focus.ts @@ -0,0 +1,136 @@ +const FOCUS_PARAM = '__flint_focus'; +const FOCUS_KEY = '__flint_focus_key'; +const CLEAR_MARK = '__flint_focus_clear'; +const DIMMED_OPACITY = 0.3; +const FOCUSABLE_MARKS = new Set(['bar', 'arc', 'point', 'circle', 'square', 'rect']); + +function markType(mark: unknown): string | undefined { + return typeof mark === 'string' + ? mark + : typeof mark === 'object' && mark !== null + ? (mark as Record).type as string | undefined + : undefined; +} + +function selectionField(encoding: Record | undefined): string | undefined { + if (!encoding) return undefined; + for (const channel of ['x', 'y', 'color']) { + const definition = encoding[channel]; + if ( + definition + && typeof definition === 'object' + && (definition.type === 'nominal' || definition.type === 'ordinal') + && typeof definition.field === 'string' + ) { + return definition.field; + } + } + return undefined; +} + +function focusParam(): Record { + return { + name: FOCUS_PARAM, + select: { + type: 'point', + fields: [FOCUS_KEY], + toggle: 'event.shiftKey || event.ctrlKey || event.metaKey', + clear: { type: 'click', markname: CLEAR_MARK }, + }, + }; +} + +function focusTransform(field: string): Record { + return { calculate: `datum[${JSON.stringify(field)}]`, as: FOCUS_KEY }; +} + +function focusOpacity(restOpacity: number): Record { + return { + condition: { param: FOCUS_PARAM, value: restOpacity }, + value: Math.min(DIMMED_OPACITY, restOpacity), + }; +} + +function withFocusDetail(encoding: Record): Record { + const focusDetail = { field: FOCUS_KEY, type: 'nominal' }; + const existing = encoding.detail; + return { + ...encoding, + detail: existing == null + ? focusDetail + : [...(Array.isArray(existing) ? existing : [existing]), focusDetail], + }; +} + +export function withoutInteractiveFocusField(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + const filtered = { ...(value as Record) }; + delete filtered[FOCUS_KEY]; + return filtered; +} + +function focusableEncoding(encoding: Record | undefined): boolean { + return !!encoding && !encoding.opacity && !encoding.fillOpacity && !encoding.strokeOpacity; +} + +function markWithoutOpacity(mark: unknown): { mark: unknown; opacity: number } { + if (!mark || typeof mark !== 'object' || typeof (mark as Record).opacity !== 'number') { + return { mark, opacity: 1 }; + } + const copy = { ...(mark as Record) }; + const opacity = copy.opacity as number; + delete copy.opacity; + return { mark: copy, opacity }; +} + +export function addInteractiveFocus(spec: Record): boolean { + if (Array.isArray(spec.params) && spec.params.length > 0) return false; + + const unitType = markType(spec.mark); + if (unitType && FOCUSABLE_MARKS.has(unitType) && focusableEncoding(spec.encoding)) { + const field = selectionField(spec.encoding); + if (!field) return false; + const resolvedMark = markWithoutOpacity(spec.mark); + spec.mark = resolvedMark.mark; + spec.transform = [...(Array.isArray(spec.transform) ? spec.transform : []), focusTransform(field)]; + spec.params = [focusParam()]; + spec.encoding = withFocusDetail({ ...spec.encoding, opacity: focusOpacity(resolvedMark.opacity) }); + return true; + } + + if (!Array.isArray(spec.layer)) return false; + const topEncoding = spec.encoding ?? {}; + for (const layer of spec.layer) { + const layerType = markType(layer?.mark); + const encoding = { ...topEncoding, ...(layer?.encoding ?? {}) }; + if (!layerType || !FOCUSABLE_MARKS.has(layerType) || !focusableEncoding(encoding)) continue; + if (Array.isArray(layer.params) && layer.params.length > 0) continue; + const field = selectionField(encoding); + if (!field) continue; + const resolvedMark = markWithoutOpacity(layer.mark); + layer.mark = resolvedMark.mark; + layer.params = [focusParam()]; + layer.encoding = withFocusDetail({ ...(layer.encoding ?? {}), opacity: focusOpacity(resolvedMark.opacity) }); + spec.transform = [...(Array.isArray(spec.transform) ? spec.transform : []), focusTransform(field)]; + return true; + } + return false; +} + +export function injectFocusClearMark(vegaSpec: Record): void { + if (!Array.isArray(vegaSpec.marks)) return; + vegaSpec.marks.unshift({ + type: 'rect', + name: CLEAR_MARK, + encode: { + enter: { + x: { value: 0 }, + x2: { signal: 'width' }, + y: { value: 0 }, + y2: { signal: 'height' }, + opacity: { value: 0 }, + tooltip: { value: null }, + }, + }, + }); +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts new file mode 100644 index 00000000..f5366346 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -0,0 +1,133 @@ +import { applyCategoryViewports } from '../core/filter-overflow'; +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { InteractiveRendererAdapter, ViewportState } from '../interactive/types'; +import { assembleVegaLite } from './assemble'; +import { addInteractiveFocus, injectFocusClearMark, withoutInteractiveFocusField } from './interactive-focus'; +import { compile } from 'vega-lite'; +import { Error as VegaError, parse, View } from 'vega'; +import { Handler } from 'vega-tooltip'; + +export interface VegaInteractiveRendererOptions { + renderer?: 'canvas' | 'svg'; + focusOnClick?: boolean; + expressionInterpreter?: unknown; + background?: string; +} + +function windowedInput( + input: ChartAssemblyInput, + viewports: CategoryViewport[], + starts: ViewportState, +): ChartAssemblyInput { + return { + ...input, + data: { + values: applyCategoryViewports(input.data.values ?? [], viewports, starts), + }, + }; +} + +function applyViewportSorts(node: unknown, viewports: CategoryViewport[]): void { + if (!node || typeof node !== 'object') return; + const record = node as Record; + for (const viewport of viewports) { + const encoding = record.encoding?.[viewport.channel]; + if (encoding?.field === viewport.field) encoding.sort = viewport.orderedValues; + } + for (const value of Object.values(record)) applyViewportSorts(value, viewports); +} + +export function createVegaInteractiveRenderer( + options: VegaInteractiveRendererOptions = {}, +): InteractiveRendererAdapter { + return { + async mount(container, input) { + const interactiveInput: ChartAssemblyInput = { + ...input, + options: { + ...input.options, + addTooltips: input.options?.addTooltips ?? true, + }, + }; + const assembled = assembleVegaLite(interactiveInput) as any; + const viewports = (assembled._viewports ?? []) as CategoryViewport[]; + const firstInput = windowedInput(interactiveInput, viewports, {}); + const vlSpec = assembleVegaLite(firstInput) as any; + applyViewportSorts(vlSpec, viewports); + const hasFocus = options.focusOnClick !== false && addInteractiveFocus(vlSpec); + const vegaSpec = compile(vlSpec).spec as any; + if (hasFocus) injectFocusClearMark(vegaSpec); + const source = vegaSpec.data?.find((entry: any) => Array.isArray(entry.values))?.name as string | undefined; + if (viewports.length > 0 && !source) { + throw new Error('Compiled chart has no mutable inline data source.'); + } + const view = new View( + parse(vegaSpec, { background: options.background } as any, { ast: true } as any), + { + renderer: options.renderer ?? 'canvas', + container, + ...(options.expressionInterpreter ? { expr: options.expressionInterpreter } : {}), + } as any, + ); + view.logLevel(VegaError); + const tooltip = new Handler(); + view.tooltip((handler, event, item, value) => { + tooltip.call(handler, event, item, withoutInteractiveFocusField(value)); + }); + await view.runAsync(); + + let destroyed = false; + let running = false; + let updateTimer: number | undefined; + let requestedVersion = 0; + let appliedVersion = 0; + let latestStarts: ViewportState = {}; + + const schedule = (): void => { + if (destroyed || running || updateTimer !== undefined || !source) return; + updateTimer = window.setTimeout(() => { + updateTimer = undefined; + if (destroyed) return; + const version = requestedVersion; + const rows = applyCategoryViewports(interactiveInput.data.values ?? [], viewports, latestStarts); + running = true; + view.data(source, []); + void view + .runAsync() + .then(() => view.data(source, rows).runAsync()) + .finally(() => { + running = false; + appliedVersion = version; + if (requestedVersion !== appliedVersion) schedule(); + }); + }, 0); + }; + + return { + viewports, + getViewportGeometry(channel) { + const [left, top] = view.origin(); + return channel === 'x' + ? { offset: left, extent: view.width() } + : { offset: top, extent: view.height() }; + }, + setViewports(starts) { + latestStarts = { ...starts }; + requestedVersion += 1; + schedule(); + }, + resize(size) { + view.width(size.width).height(size.height); + void view.runAsync(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + if (updateTimer !== undefined) window.clearTimeout(updateTimer); + view.finalize(); + container.replaceChildren(); + }, + }; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts index eaab140a..0d96eb46 100644 --- a/packages/flint-js/src/vegalite/templates/bar-table.ts +++ b/packages/flint-js/src/vegalite/templates/bar-table.ts @@ -3,7 +3,7 @@ import { ChartTemplateDef, ChartPropertyDef, ChannelSemantics } from '../../core/types'; import { getRegistryEntry } from '../../core/type-registry'; -import type { FormatSpec } from '../../core/field-semantics'; +import { resolveDisplayUnit, titleWithDisplayUnit, type FormatSpec } from '../../core/field-semantics'; import { formatSpecToVegaExpr } from '../format'; /** @@ -36,6 +36,7 @@ export const barTableDef: ChartTemplateDef = { }, channels: ["y", "x", "color", "column", "row"], markCognitiveChannel: 'length', + suppressValueLabels: true, declareLayoutMode: (cs, table, chartProperties) => { // Bar tables split the plot width into 3 horizontal panels // (bar | % | value), so they need a wider canvas than a basic @@ -115,6 +116,17 @@ export const barTableDef: ChartTemplateDef = { const yCS: ChannelSemantics | undefined = ctx.channelSemantics?.y; const xEntry = getRegistryEntry(xCS?.semanticAnnotation?.semanticType ?? 'Unknown'); + // ── Ordinal measures (Rank) ────────────────────────────────── + // An ordinal is a standing, not a magnitude: "how much better is 1st + // than 2nd" has no answer. Length-encoding it (bar length + sequential + // colour ramp) would invert the ranking — rank 1 gets the shortest, + // palest bar. Honor the documented `Rank` behaviour instead (see + // flint://agent-skill: "Rank → reversed axis (1 on top), discrete + // color"): sort by rank ascending (1 first), use a discrete colour + // scale, and keep bars equal-length so the mark does not imply a + // magnitude that isn't there. + const xIsOrdinal = xCS?.type === 'ordinal'; + // Sign profile of x values — used by the diverging-palette check. let hasNegative = false; let hasPositive = false; @@ -192,7 +204,9 @@ export const barTableDef: ChartTemplateDef = { && maxScopedCategoryCount > maxRows; const sortRowsByValue = (items: Array<{ cat: any; value: number }>) => items - .sort((a, b) => yCS?.reversed ? a.value - b.value : b.value - a.value); + .sort((a, b) => xIsOrdinal + ? a.value - b.value + : (yCS?.reversed ? a.value - b.value : b.value - a.value)); let displayTable: any[] = []; let othersCatLabel: string | undefined; @@ -268,7 +282,6 @@ export const barTableDef: ChartTemplateDef = { // Derived directly from field names; no override knobs. const categoryHeader = yField; const percentHeader = '%'; - const valueHeader = xField; // headerStyle.fontSize is set below once the responsive // `fontSize` constant is available. @@ -284,7 +297,19 @@ export const barTableDef: ChartTemplateDef = { // The %-share column (panel 1) is a different story: it's a // *derived* 0..1 ratio computed by us, so it always needs `%` // formatting. That's `pctPattern` below. - const valueFmt: FormatSpec | undefined = xCS?.format; + const displayUnit = resolveDisplayUnit(xCS?.semanticAnnotation); + const valueFmt: FormatSpec | undefined = displayUnit?.placement === 'value' + ? { + ...(xCS?.format ?? {}), + ...(displayUnit.position === 'prefix' && !xCS?.format?.prefix + ? { prefix: displayUnit.text } + : {}), + ...(displayUnit.position === 'suffix' && !xCS?.format?.suffix + ? { suffix: /^[A-Za-z]/.test(displayUnit.text) ? ` ${displayUnit.text}` : displayUnit.text } + : {}), + } + : xCS?.format; + const valueHeader = titleWithDisplayUnit(xField, displayUnit); const pctPattern = '.1%'; // ── Text-panel transforms ──────────────────────────────────── @@ -418,7 +443,9 @@ export const barTableDef: ChartTemplateDef = { } return uniqueCats .map(cat => ({ cat, value: aggValue(globalCategoryAgg.get(cat)!) })) - .sort((a, b) => yCS?.reversed ? a.value - b.value : b.value - a.value) + .sort((a, b) => xIsOrdinal + ? a.value - b.value + : (yCS?.reversed ? a.value - b.value : b.value - a.value)) .map(a => a.cat); })(); const ySort: any = ySortOrder && ySortOrder.length > 0 @@ -483,12 +510,19 @@ export const barTableDef: ChartTemplateDef = { legend: null, scale: { scheme: 'redyellowgreen', domainMid: 0 }, } - : { - field: xField, - type: 'quantitative', - legend: null, - scale: { range: ['#cdebd3', '#41a25f'] }, - }; + : xIsOrdinal + ? { + field: xField, + type: 'ordinal', + legend: null, + scale: { scheme: 'tableau10' }, + } + : { + field: xField, + type: 'quantitative', + legend: null, + scale: { range: ['#cdebd3', '#41a25f'] }, + }; // ── Dynamic panel widths from longest formatted label ──────── // @@ -616,13 +650,14 @@ export const barTableDef: ChartTemplateDef = { outFieldHint: string, ): any => { if (!fmt || (!fmt.pattern && !fmt.prefix && !fmt.suffix)) { - return { field: sourceField, type: 'quantitative' }; - } - if (!fmt.abbreviate && fmt.pattern && !fmt.prefix && !fmt.suffix) { - return { field: sourceField, type: 'quantitative', format: fmt.pattern }; + transformsOut.push({ calculate: `datum[${JSON.stringify(sourceField)}] + ''`, as: outFieldHint }); + return { field: outFieldHint, type: 'nominal' }; } const formatExpr = formatSpecToVegaExpr(fmt, `datum[${JSON.stringify(sourceField)}]`); - if (!formatExpr) return { field: sourceField, type: 'quantitative' }; + if (!formatExpr) { + transformsOut.push({ calculate: `datum[${JSON.stringify(sourceField)}] + ''`, as: outFieldHint }); + return { field: outFieldHint, type: 'nominal' }; + } transformsOut.push({ calculate: formatExpr, as: outFieldHint, @@ -708,12 +743,14 @@ export const barTableDef: ChartTemplateDef = { }, encoding: { y: yEncWithLabels, - x: { - field: barXField, - type: 'quantitative', - axis: null, - scale: barXScale, - }, + x: xIsOrdinal + ? { datum: 1, type: 'quantitative', axis: null, scale: { domain: [0, 1], nice: false } } + : { + field: barXField, + type: 'quantitative', + axis: null, + scale: barXScale, + }, color: barColorEnc, }, }); diff --git a/packages/flint-js/src/vegalite/theme.ts b/packages/flint-js/src/vegalite/theme.ts index 21a32be1..7cdea15d 100644 --- a/packages/flint-js/src/vegalite/theme.ts +++ b/packages/flint-js/src/vegalite/theme.ts @@ -275,12 +275,12 @@ export function realizeThemeVegaLite(spec: any, d: DesignDecisions, table: any[] harmonizeLinePoints(spec, d, table, say); applyConnectors(spec, d, say); applyRedundantChannels(spec, d, say); - demoteSeriesEnd(spec, d, say); + const seriesEndLayout = demoteSeriesEnd(spec, d, table, say); applyLegend(spec, config, d, table, say); applyFacetChrome(config, d); applyPanelTitles(spec, d, say); const valueLayer = applyDataLabels(spec, d, table, say); - applySeriesEndLabels(spec, d, valueLayer, table, say); + applySeriesEndLabels(spec, d, valueLayer, table, say, seriesEndLayout); applyPointEmphasis(spec, d, say); applyPrintedUnits(spec, d, say); applyStatistics(spec, d, table, say); @@ -738,9 +738,15 @@ function applyAxes(spec: any, config: any, d: DesignDecisions, table: any[], say const rightSeated = side === 'right'; // The title clears the topmost value instead of sitting on // it, so the lift carries a line of the label's own size. + // A column facet owns the next line above the plot; clear + // that header too instead of laying the shared y title on + // the final panel's name. const labelSize = axis.label.fontSize ?? 11; const gap = axis.title.gap ?? (axis.title.fontSize ?? 11) + 6; - const lift = gap + Math.round(labelSize * 0.75); + const headerClearance = d.facets.header.show && hasTopFacetHeader(spec) + ? Math.round((d.facets.header.fontSize ?? 11) * 1.7) + : 0; + const lift = gap + Math.round(labelSize * 0.75) + headerClearance; enc.axis = { ...(enc.axis ?? {}), titleAngle: 0, @@ -1441,6 +1447,15 @@ function panelCount(spec: any, table: any[]): number { return panels; } +function hasTopFacetHeader(spec: any): boolean { + let found = false; + walk(spec, (node) => { + if (node.encoding?.facet?.field || node.encoding?.column?.field + || node.facet?.field || node.facet?.column?.field) found = true; + }); + return found; +} + /** * Whether the spec draws a dot per row — a scatter, a strip, a dot plot. Only * then does the crowding budget below have a claim on the plot's area: a @@ -4594,9 +4609,9 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa delete labelEncoding.theta; } - // A label goes where there is room. A mark shorter than its own label - // cannot hold it, and a mark that reaches the end of the scale has no room - // past its end — so each case sends those few labels the other way. + // Inside placement has one legibility exception: a mark shorter than its + // own label cannot hold it, so that label moves outside. Outside placement + // is chart-wide and never flips only the longest mark inward. // Vega-Lite has no conditional `align`, so this is two layers with // complementary filters. const flipInk = (within: boolean): string | undefined => { @@ -4620,13 +4635,6 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa say('dataLabels.placement', message); }; - // A vertical bar's outside label is cleared by giving the measure scale - // headroom (below); a horizontal one by reserving right margin. The - // scale-end flip — printing the tallest bars' labels inside instead — - // solves the same "no room past the end" problem, so it is only needed - // where headroom is not the remedy: on horizontal bars. - const headroomClears = !inside && onMarkBody && !horizontal && !radial && !cells; - // A stacked segment is exempt: "outside" a segment is the top of the // stack, a different quantity. Segments too short for their number drop it // instead, which the keep test above already arranges. @@ -4634,8 +4642,6 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa if (inside && d.dataLabels.insideMinValue != null) { split(d.dataLabels.insideMinValue, '<', 'marks shorter than their own label print it outside instead'); growPadding(spec, horizontal ? 'right' : 'top', (t.fontSize ?? 10) * 2); - } else if (!inside && d.dataLabels.outsideMaxValue != null && !headroomClears) { - split(d.dataLabels.outsideMaxValue, '>', 'marks that reach the end of the scale print their label inside instead'); } } @@ -4778,9 +4784,19 @@ function addMeasureHeadroom( * *before* the legend is drawn — once the colour legends have been suppressed * in favour of end labels there is nothing to fall back to. */ -function demoteSeriesEnd(spec: any, d: DesignDecisions, say: (p: string, m: string) => void): void { - if (d.legend.placement !== 'seriesEnd' && d.legend.placement !== 'inline') return; - if (!d.legend.show) return; +interface SeriesEndLayout { + adjustedValues: Map; + maxDisplacement: number; +} + +function demoteSeriesEnd( + spec: any, + d: DesignDecisions, + table: any[], + say: (p: string, m: string) => void, +): SeriesEndLayout | undefined { + if (d.legend.placement !== 'seriesEnd' && d.legend.placement !== 'inline') return undefined; + if (!d.legend.show) return undefined; const body = plotBody(spec); // A band carries its own end label inside itself, so it counts as a run // with an end just as much as a line does. @@ -4816,14 +4832,158 @@ function demoteSeriesEnd(spec: any, d: DesignDecisions, say: (p: string, m: stri : marginTaken ? `the ${runsAlongX ? 'right' : 'top'} margin holds the value axis, so a name too big for its band has nowhere to stand` : null)); - if (!reason) return; + const collision = !reason && !bands + ? planSeriesEndLayout(spec, d, table, enc, field) + : undefined; + const finalReason = reason ?? collision?.reason; + if (!finalReason) return collision?.layout; // The house ranked its placements; a demotion should land on the next one // it named, not on whatever this function happens to prefer. const next = d.legend.fallbacks?.find((p) => p !== 'seriesEnd' && p !== 'inline') ?? 'right'; - say('legend.placement', `${reason} — the key is drawn \`${next}\` instead`); + say('legend.placement', `${finalReason} — the key is drawn \`${next}\` instead`); d.legend.placement = next; d.legend.orient = next === 'inside' ? 'top-right' : next as any; d.legend.direction = next === 'top' || next === 'bottom' ? 'horizontal' : 'vertical'; + return undefined; +} + +function planSeriesEndLayout( + spec: any, + d: DesignDecisions, + table: any[], + enc: any, + seriesField: string | undefined, +): { layout?: SeriesEndLayout; reason?: string } { + if (!seriesField || runChannel(d) !== 'x') return {}; + if (d.bound.isFaceted) return { reason: '`seriesEnd` collision checks do not guess across facet scales' }; + if (d.bound.seriesCount > 8) return { reason: '`seriesEnd` is limited to eight series so the margin stays readable' }; + + const domain = enc.x; + const value = enc.y; + if (!domain?.field || !value?.field || value.type !== 'quantitative') return {}; + if (value.scale?.type && value.scale.type !== 'linear') { + return { reason: '`seriesEnd` collision checks need a linear value scale' }; + } + + const orderedDomain = domain.type === 'quantitative' || domain.type === 'temporal' || domain.type === 'ordinal'; + const explicitOrder: Map | undefined = Array.isArray(domain.sort) + ? new Map(domain.sort.map((entry: unknown, index: number): [unknown, number] => [entry, index])) + : undefined; + const comparable = (raw: unknown): number | undefined => { + if (explicitOrder) return explicitOrder.get(raw); + if (domain.type === 'temporal') { + const time = raw instanceof Date ? raw.getTime() : Date.parse(String(raw)); + return Number.isFinite(time) ? time : undefined; + } + const number = Number(raw); + return Number.isFinite(number) ? number : undefined; + }; + + const endpoints = new Map(); + const allDomain: number[] = []; + const allValues: number[] = []; + table.forEach((row, order) => { + const series = row?.[seriesField]; + const domainValue = comparable(row?.[domain.field]); + const valueNumber = Number(row?.[value.field]); + if (series == null || domainValue == null || !Number.isFinite(valueNumber)) return; + allDomain.push(domainValue); + allValues.push(valueNumber); + const previous = endpoints.get(series); + const takesEnd = !previous || (orderedDomain + ? (domain.sort === 'descending' ? domainValue < previous.domain : domainValue > previous.domain) + : order > previous.order); + if (takesEnd) endpoints.set(series, { domain: domainValue, value: valueNumber, order }); + }); + if (endpoints.size < 2 || allDomain.length < 2 || allValues.length < 2) return {}; + + const plotWidth = Number(d.layout.plotWidth ?? spec.width); + const plotHeight = Number(d.layout.plotHeight ?? plotBody(spec).height ?? spec.height); + if (!(plotWidth > 0) || !(plotHeight > 0)) return { reason: '`seriesEnd` could not measure the plot for collision checks' }; + + const domainMin = Math.min(...allDomain); + const domainMax = Math.max(...allDomain); + const domainSpan = domainMax - domainMin; + if (!(domainSpan > 0)) return {}; + const endDomain = Array.from(endpoints.values(), (endpoint) => endpoint.domain); + const endSpreadPx = (Math.max(...endDomain) - Math.min(...endDomain)) / domainSpan * plotWidth; + const uniqueDomain = [...new Set(allDomain)].sort((a, b) => a - b); + const steps = uniqueDomain.slice(1).map((entry, index) => entry - uniqueDomain[index]).filter((step) => step > 0); + const medianStep = steps.length + ? steps.sort((a, b) => a - b)[Math.floor(steps.length / 2)] / domainSpan * plotWidth + : 0; + const alignmentTolerance = Math.max(8, medianStep * 0.25); + + let valueMin = value.scale?.domainMin ?? Math.min(...allValues); + let valueMax = value.scale?.domainMax ?? Math.max(...allValues); + if (Array.isArray(value.scale?.domain) && value.scale.domain.length >= 2) { + valueMin = Number(value.scale.domain[0]); + valueMax = Number(value.scale.domain[1]); + } + if (value.scale?.zero !== false) { + valueMin = Math.min(0, valueMin); + valueMax = Math.max(0, valueMax); + } + const valueSpan = valueMax - valueMin; + if (!(valueSpan > 0)) return {}; + + const reversed = value.scale?.reverse === true; + const toPixel = (number: number) => reversed + ? (number - valueMin) / valueSpan * plotHeight + : (valueMax - number) / valueSpan * plotHeight; + const fromPixel = (pixel: number) => reversed + ? valueMin + pixel / plotHeight * valueSpan + : valueMax - pixel / plotHeight * valueSpan; + const fontSize = Math.max(9, (d.legend.label.fontSize ?? 11) - 1); + const separation = fontSize + 2; + const naturalRows = Array.from(endpoints.values(), (endpoint) => toPixel(endpoint.value)) + .sort((a, b) => a - b); + const naturallyCollides = naturalRows.some((pixel, index) => + index > 0 && pixel - naturalRows[index - 1] < separation); + if (!naturallyCollides) return {}; + if (endSpreadPx > alignmentTolerance) { + return { reason: `series-end labels overlap and their endpoints span ${Math.round(endSpreadPx)}px horizontally, so they cannot be dodged as one column` }; + } + if (endpoints.size * separation > plotHeight) { + return { reason: '`seriesEnd` labels cannot fit vertically without overlap' }; + } + + const packed = Array.from(endpoints, ([series, endpoint]) => ({ + series, + value: endpoint.value, + desired: toPixel(endpoint.value), + placed: toPixel(endpoint.value), + })).sort((a, b) => a.desired - b.desired); + // A label centred on the top or bottom endpoint may straddle the plot + // boundary; Vega includes that text in the figure bounds. Pulling it half + // a line inward creates a needless dodge and disconnects it from the + // endpoint. Keep boundary labels pinned and pack only their neighbours. + const minCenter = 0; + const maxCenter = plotHeight; + packed[0].placed = Math.max(minCenter, packed[0].desired); + for (let index = 1; index < packed.length; index += 1) { + packed[index].placed = Math.max(packed[index].desired, packed[index - 1].placed + separation); + } + const overflow = packed[packed.length - 1].placed - maxCenter; + if (overflow > 0) packed.forEach((entry) => { entry.placed -= overflow; }); + for (let index = packed.length - 2; index >= 0; index -= 1) { + packed[index].placed = Math.min(packed[index].placed, packed[index + 1].placed - separation); + } + if (packed[0].placed < minCenter) { + const shift = minCenter - packed[0].placed; + packed.forEach((entry) => { entry.placed += shift; }); + } + + const maxDisplacement = Math.max(...packed.map((entry) => Math.abs(entry.placed - entry.desired))); + if (maxDisplacement > fontSize) { + return { reason: `series-end labels need ${Math.round(maxDisplacement)}px of dodge, more than one line of text` }; + } + return { + layout: { + adjustedValues: new Map(packed.map((entry) => [entry.series, fromPixel(entry.placed)])), + maxDisplacement, + }, + }; } /** @@ -4846,6 +5006,7 @@ function applySeriesEndLabels( valueLayer: any, table: any[], say: (p: string, m: string) => void, + layout?: SeriesEndLayout, ): void { if (d.legend.placement !== 'seriesEnd' && d.legend.placement !== 'inline') return; if (!d.legend.show) return; @@ -4964,6 +5125,18 @@ function applySeriesEndLabels( 'series name and final value merged into one label — they compete for the same space'); } + let labelValue = value; + if (layout?.maxDisplacement && layout.maxDisplacement > 0.5) { + const series = `datum[${JSON.stringify(seriesField)}]`; + let adjusted = `datum[${JSON.stringify(value.field)}]`; + for (const [name, number] of layout.adjustedValues) { + adjusted = `${series} === ${JSON.stringify(name)} ? ${number} : (${adjusted})`; + } + transform.push({ calculate: adjusted, as: '__seriesEndLabelValue' }); + labelValue = { ...value, field: '__seriesEndLabelValue' }; + say('legend.placement', `series-end labels dodged by at most ${Math.round(layout.maxDisplacement)}px to avoid overlap`); + } + const labelLayer: any = { __themeSynthetic: true, transform, @@ -4974,13 +5147,13 @@ function applySeriesEndLabels( dx: domainChannel === 'x' ? 5 : 0, dy: domainChannel === 'x' ? 0 : -5, font: t.font, - fontSize: t.fontSize, + fontSize: Math.max(9, (t.fontSize ?? 11) - 1), ...(t.fontWeight ? { fontWeight: t.fontWeight } : {}), ...(t.fontStyle ? { fontStyle: t.fontStyle } : {}), }, encoding: { [domainChannel]: stripAxis(domain), - [valueChannel]: stripAxis(value), + [valueChannel]: layout ? { ...stripAxis(labelValue), title: null } : stripAxis(labelValue), text: { field: textField, type: 'nominal' }, ...(colourEnc?.field ? { color: { ...colourEnc, legend: null } } : {}), }, diff --git a/packages/flint-js/tests/bar-table-labels.test.ts b/packages/flint-js/tests/bar-table-labels.test.ts new file mode 100644 index 00000000..0d06e9ad --- /dev/null +++ b/packages/flint-js/tests/bar-table-labels.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src'; + +describe('Bar Table labels', () => { + const titleText = (title: string | string[]) => Array.isArray(title) ? title.join(' ') : title; + + function barTable(unit?: string, field = 'life_expect_gain'): any { + return assembleVegaLite({ + data: { values: [ + { country: 'Peru', [field]: 33.49 }, + { country: 'Iran', [field]: 32.34 }, + ] }, + semantic_types: { + country: 'Country', + [field]: unit ? { semanticType: 'Duration', unit } : 'Duration', + }, + chart_spec: { + chartType: 'Bar Table', + encodings: { y: 'country', x: field }, + baseSize: { width: 600, height: 300 }, + }, + theme_spec: 'nyt', + } as any) as any; + } + + it('does not repeat the value as a generic annotation on each bar', () => { + const spec = barTable('years'); + + expect(spec.hconcat[0].mark.type).toBe('bar'); + expect(spec.hconcat[0].layer).toBeUndefined(); + const valuePanel = spec.hconcat.at(-1); + expect(valuePanel.mark.type).toBe('text'); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain (years)'); + expect(valuePanel.encoding.text.type).toBe('nominal'); + expect(JSON.stringify(valuePanel.transform)).not.toContain('years'); + }); + + it('prints a declared compact unit beside values', () => { + const valuePanel = barTable('kg').hconcat.at(-1); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain'); + expect(JSON.stringify(valuePanel.transform)).toContain(' kg'); + }); + + it('does not display an undeclared unit', () => { + const valuePanel = barTable().hconcat.at(-1); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain'); + expect(JSON.stringify(valuePanel.transform)).not.toMatch(/years| kg/); + }); + + it('does not duplicate a lexical unit already present in the field name', () => { + const valuePanel = barTable('years', 'life_expect_gain (years)').hconcat.at(-1); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain (years)'); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/bar-table-rank.test.ts b/packages/flint-js/tests/bar-table-rank.test.ts new file mode 100644 index 00000000..aacfaebe --- /dev/null +++ b/packages/flint-js/tests/bar-table-rank.test.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite, assemblePlotly } from '../src'; + +/** + * Regression test for issue #85: the `Rank` semantic type is an ordinal, not a + * magnitude. The Bar Table template used to length-encode it (bar length + + * sequential colour ramp), inverting the ranking so rank 1 got the shortest, + * palest bar. The documented behaviour is "Rank → reversed axis (1 on top), + * discrete color". + * + * The fix honours that in both the Vega-Lite and Plotly Bar Table templates: + * - rows ordered by rank ascending (1 first / on top), + * - discrete colour (no magnitude ramp), + * - equal-length bars (no length encoding of an ordinal). + */ + +const RANK_INPUT = { + data: { + values: [ + { Engine: 'Inworld TTS-2', Rank: 1 }, + { Engine: 'xAI leo', Rank: 2 }, + { Engine: 'Kokoro am_michael', Rank: 3 }, + { Engine: 'Gemini', Rank: 4 }, + { Engine: 'Inworld 1.5-max', Rank: 5 }, + ], + }, + semantic_types: { Engine: 'Name', Rank: 'Rank' }, + chart_spec: { + chartType: 'Bar Table', + encodings: { y: { field: 'Engine' }, x: { field: 'Rank' } }, + baseSize: { width: 560, height: 280 }, + }, +}; + +const RANK_ORDER_ASC = ['Inworld TTS-2', 'xAI leo', 'Kokoro am_michael', 'Gemini', 'Inworld 1.5-max']; + +describe('Bar Table honours Rank semantic (issue #85)', () => { + it('Vega-Lite: sorts rank ascending, discrete colour, equal-length bars', () => { + const spec = assembleVegaLite(RANK_INPUT as never) as any; + const barPanel = spec.hconcat[0]; + + // Rank ascending: rank 1 first (top). + expect(barPanel.encoding.y.sort).toEqual(RANK_ORDER_ASC); + + // Discrete colour scale (ordinal), not a sequential magnitude ramp. + expect(barPanel.encoding.color.type).toBe('ordinal'); + expect(barPanel.encoding.color.scale.scheme).toBeTruthy(); + + // No length encoding: bars are a constant value, not the rank field. + expect(barPanel.encoding.x.field).toBeUndefined(); + expect(barPanel.encoding.x.datum).toBe(1); + }); + + it('Plotly: sorts rank ascending, discrete colour, equal-length bars', () => { + const fig = assemblePlotly(RANK_INPUT as never) as any; + const trace = (fig.data ?? []).find((t: any) => t.type === 'bar' && t.orientation === 'h'); + + // Rank ascending: rank 1 first (top). + expect(trace.y).toEqual(RANK_ORDER_ASC); + + // Equal-length bars (no magnitude encoding) and discrete colour. + expect(trace.x.every((v: number) => v === 1)).toBe(true); + expect(new Set(trace.marker.color).size).toBeGreaterThan(1); + }); +}); diff --git a/packages/flint-js/tests/filter-overflow.test.ts b/packages/flint-js/tests/filter-overflow.test.ts index 4f828c61..21beda52 100644 --- a/packages/flint-js/tests/filter-overflow.test.ts +++ b/packages/flint-js/tests/filter-overflow.test.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { describe, expect, it } from 'vitest'; -import { filterOverflow } from '../src/core/filter-overflow'; +import { applyCategoryViewports, filterOverflow, resolveCategoryViewport } from '../src/core/filter-overflow'; import type { ChannelSemantics, ChartEncoding } from '../src/core/types'; const budgets = { maxValues: { x: 3 } }; @@ -76,4 +76,43 @@ describe('overflow category selection', () => { { field: 'Category', sortBy: 'y', sortOrder: 'descending' }, )).toEqual(['Delta', 'Charlie', 'Bravo']); }); + + it('retains the complete ordered domain for an interactive viewport', () => { + const data = [ + { Category: 'Delta', Value: 100 }, + { Category: 'Alpha', Value: 1 }, + { Category: 'Charlie', Value: 80 }, + { Category: 'Bravo', Value: 50 }, + ]; + + const result = filterOverflow( + { + x: { field: 'Category', type: 'nominal', semanticAnnotation: annotation }, + y: { field: 'Value', type: 'quantitative', semanticAnnotation: { semanticType: 'Quantity' } }, + }, + { axisFlags: { x: { banded: true } } }, + { + x: { field: 'Category', sortBy: 'y', sortOrder: 'descending' }, + y: { field: 'Value' }, + }, + data, + budgets, + marks, + ); + + expect(result.viewports).toEqual([{ + channel: 'x', + field: 'Category', + orderedValues: ['Delta', 'Charlie', 'Bravo', 'Alpha'], + visibleCount: 3, + totalCount: 4, + }]); + expect(resolveCategoryViewport(result.viewports[0], 99)).toEqual({ + start: 1, + end: 4, + values: ['Charlie', 'Bravo', 'Alpha'], + }); + expect(applyCategoryViewports(data, result.viewports, { x: 1 })) + .toEqual([data[1], data[2], data[3]]); + }); }); diff --git a/packages/flint-js/tests/interactive-focus.test.ts b/packages/flint-js/tests/interactive-focus.test.ts new file mode 100644 index 00000000..581978a7 --- /dev/null +++ b/packages/flint-js/tests/interactive-focus.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { + addInteractiveFocus, + injectFocusClearMark, + withoutInteractiveFocusField, +} from '../src/vegalite/interactive-focus'; + +describe('Vega-Lite interactive focus', () => { + it('adds point selection and dimming to a discrete unit mark', () => { + const spec: Record = { + mark: 'bar', + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + expect(addInteractiveFocus(spec)).toBe(true); + expect(spec.params[0].select).toMatchObject({ + type: 'point', + fields: ['__flint_focus_key'], + toggle: 'event.shiftKey || event.ctrlKey || event.metaKey', + }); + expect(spec.transform).toContainEqual({ + calculate: 'datum["category"]', + as: '__flint_focus_key', + }); + expect(spec.encoding.detail).toEqual({ field: '__flint_focus_key', type: 'nominal' }); + expect(spec.encoding.opacity).toEqual({ + condition: { param: '__flint_focus', value: 1 }, + value: 0.3, + }); + }); + + it('preserves authored selections and opacity encodings', () => { + const withParams: Record = { + mark: 'bar', + params: [{ name: 'authored', select: 'point' }], + encoding: { x: { field: 'category', type: 'nominal' } }, + }; + const withOpacity: Record = { + mark: 'bar', + encoding: { + x: { field: 'category', type: 'nominal' }, + opacity: { field: 'weight', type: 'quantitative' }, + }, + }; + + expect(addInteractiveFocus(withParams)).toBe(false); + expect(addInteractiveFocus(withOpacity)).toBe(false); + }); + + it('skips unsupported continuous marks', () => { + const spec: Record = { + mark: 'line', + encoding: { + x: { field: 'date', type: 'temporal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }; + + expect(addInteractiveFocus(spec)).toBe(false); + expect(spec).not.toHaveProperty('params'); + }); + + it('adds focus to the first eligible layer', () => { + const spec: Record = { + encoding: { x: { field: 'category', type: 'nominal' } }, + layer: [ + { mark: 'line', encoding: { y: { field: 'value', type: 'quantitative' } } }, + { mark: 'point', encoding: { y: { field: 'value', type: 'quantitative' } } }, + ], + }; + + expect(addInteractiveFocus(spec)).toBe(true); + expect(spec.layer[0]).not.toHaveProperty('params'); + expect(spec.layer[1].params[0].name).toBe('__flint_focus'); + expect(spec.layer[1].encoding.detail).toEqual({ field: '__flint_focus_key', type: 'nominal' }); + }); + + it('injects a transparent clear catcher below compiled marks', () => { + const spec: Record = { marks: [{ type: 'rect', name: 'marks' }] }; + + injectFocusClearMark(spec); + + expect(spec.marks[0]).toMatchObject({ + type: 'rect', + name: '__flint_focus_clear', + encode: { enter: { opacity: { value: 0 } } }, + }); + }); + + it('removes the internal focus key from tooltip objects', () => { + expect(withoutInteractiveFocusField({ + category: 'A', + value: 10, + __flint_focus_key: 'A', + })).toEqual({ category: 'A', value: 10 }); + expect(withoutInteractiveFocusField('label')).toBe('label'); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/series-end-collision.test.ts b/packages/flint-js/tests/series-end-collision.test.ts new file mode 100644 index 00000000..a4b3029d --- /dev/null +++ b/packages/flint-js/tests/series-end-collision.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src'; +import type { ThemeSpec } from '../src/core/theme/types'; + +const theme: ThemeSpec = { + id: 'series-end-test', + label: 'Series end test', + ink: { + surface: { canvas: '#fff', plot: '#fff' }, + text: { primary: '#111' }, + series: { single: '#333', categorical: ['#1261a0', '#d1495b', '#2a9d8f', '#725ac1'] }, + }, + legend: { show: 'always', placement: ['seriesEnd', 'right'] }, +} as ThemeSpec; + +function rows(endValues: number[], endYears?: number[]): any[] { + return endValues.flatMap((endValue, seriesIndex) => { + const endYear = endYears?.[seriesIndex] ?? 2020; + return [1950, 1980, endYear].map((year, index) => ({ + year, + series: `S${seriesIndex + 1}`, + value: index === 2 ? endValue : 70 - seriesIndex * 4 - index * 5, + })); + }); +} + +function build(endValues: number[], endYears?: number[]): any { + return assembleVegaLite({ + data: { values: rows(endValues, endYears) }, + semantic_types: { year: 'Year', series: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'year', y: 'value', color: 'series' }, + baseSize: { width: 480, height: 300 }, + }, + theme_spec: theme, + } as any) as any; +} + +function layers(spec: any): any[] { + const body = spec.layer ?? []; + return Array.isArray(body) ? body : []; +} + +function endLabel(spec: any): any | undefined { + return layers(spec).find((layer) => { + const mark = typeof layer.mark === 'string' ? layer.mark : layer.mark?.type; + return mark === 'text' && ['series', '__seriesEndLabel'].includes(layer.encoding?.text?.field); + }); +} + +function messages(spec: any): string { + return (spec._theme?.report ?? []) + .filter((entry: any) => entry.path === 'legend.placement') + .map((entry: any) => entry.message) + .join(' '); +} + +describe('series-end collision policy', () => { + it('keeps aligned, separated endpoints directly labelled', () => { + const spec = build([20, 40, 60]); + expect(endLabel(spec)?.encoding.y.field).toBe('value'); + expect(endLabel(spec)?.mark.fontSize).toBe(10); + expect(messages(spec)).toContain('synthesized text layer'); + }); + + it('slightly dodges close labels without adding connector ticks', () => { + const spec = build([50, 52]); + expect(endLabel(spec)?.encoding.y.field).toBe('__seriesEndLabelValue'); + expect(layers(spec).some((layer) => { + const mark = typeof layer.mark === 'string' ? layer.mark : layer.mark?.type; + return mark === 'rule' && layer.encoding?.y2?.field === '__seriesEndLabelValue'; + })).toBe(false); + expect(messages(spec)).toMatch(/dodged by at most \d+px/); + }); + + it.each([ + { edge: 'top', values: [25, 48, 72] }, + { edge: 'bottom', values: [0, 25, 48] }, + ])('keeps a label on the $edge boundary anchored to its endpoint', ({ values }) => { + const spec = build(values); + expect(endLabel(spec)?.encoding.y.field).toBe('value'); + expect(messages(spec)).not.toContain('dodged by at most'); + }); + + it('falls back as a set when dense labels need too much displacement', () => { + const spec = build([50, 51, 52, 53]); + expect(endLabel(spec)).toBeUndefined(); + expect(messages(spec)).toMatch(/more than one line of text/); + }); + + it('keeps staggered endpoints direct when their labels do not collide', () => { + const spec = build([20, 45, 70], [2020, 2010, 2000]); + expect(endLabel(spec)?.encoding.y.field).toBe('value'); + expect(messages(spec)).not.toContain('key is drawn'); + }); + + it('falls back when staggered endpoint labels actually collide', () => { + const spec = build([50, 52], [2020, 2000]); + expect(endLabel(spec)).toBeUndefined(); + expect(messages(spec)).toMatch(/labels overlap.*cannot be dodged as one column/); + }); +}); diff --git a/packages/flint-js/tests/sizing-ceiling.test.ts b/packages/flint-js/tests/sizing-ceiling.test.ts index 5fdb6b66..bfc8b099 100644 --- a/packages/flint-js/tests/sizing-ceiling.test.ts +++ b/packages/flint-js/tests/sizing-ceiling.test.ts @@ -3,7 +3,13 @@ import { describe, it, expect } from 'vitest'; import { assembleVegaLite } from '../src'; -import { deriveStretchCaps, resolveStretchCaps, resolveBaseSize } from '../src/core/compute-layout'; +import { + computeChannelBudgets, + DEFAULT_MIN_STEP, + deriveStretchCaps, + resolveStretchCaps, + resolveBaseSize, +} from '../src/core/compute-layout'; import { computeAxisStep } from '../src/core/decisions'; /** @@ -19,6 +25,25 @@ import { computeAxisStep } from '../src/core/decisions'; const BASE = { width: 400, height: 320 }; +describe('minimum discrete step', () => { + it('uses an 8px default when computing overflow capacity', () => { + const data = Array.from({ length: 20 }, (_, index) => ({ category: `C${index}`, value: index })); + const budgets = computeChannelBudgets( + { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + } as never, + {}, + data, + { width: 80, height: 100 }, + { maxStretch: 1 }, + ); + + expect(DEFAULT_MIN_STEP).toBe(8); + expect(budgets.maxValues.x).toBe(10); + }); +}); + describe('bandStepFit (base pitch ↔ available span)', () => { const decision = (bandStepFit: number) => computeAxisStep(4, 0, 400, { elasticity: 0.5, diff --git a/packages/flint-js/tests/slope.test.ts b/packages/flint-js/tests/slope.test.ts index 3490ea8e..375fd83d 100644 --- a/packages/flint-js/tests/slope.test.ts +++ b/packages/flint-js/tests/slope.test.ts @@ -144,6 +144,21 @@ describe('ECharts Slope chart', () => { expect(option.yAxis.type).toBe('value'); }); + it('anchors the color legend from the right so chart.resize() keeps the gutter', () => { + // Design-canvas `left` (e.g. 422 of 534) overlaps the plot once the host + // is wider than `_width`. `right` is the inset to the legend box edge. + expect(option.legend.right).toBe(16); + expect(option.legend.left).toBeUndefined(); + expect(option.legend.orient).toBe('vertical'); + expect(option.grid.right).toBeGreaterThan(option.legend.right); + const title = (option.graphic ?? []).find( + (g: { type?: string; style?: { fontWeight?: string } }) => + g.type === 'text' && g.style?.fontWeight === 'bold', + ); + expect(title?.right).toBe(16); + expect(title?.left).toBeUndefined(); + }); + it('orders temporal year periods as two ordered categories', () => { const temporal = byTitle( cases, diff --git a/packages/flint-js/tests/smoke.test.ts b/packages/flint-js/tests/smoke.test.ts index 0b0bdb5f..c6aaad15 100644 --- a/packages/flint-js/tests/smoke.test.ts +++ b/packages/flint-js/tests/smoke.test.ts @@ -8,6 +8,7 @@ import { assembleChartjs, assemblePlotly, assembleExcel, + assembleImageCharts, } from '../src'; const DATA = [ @@ -98,6 +99,41 @@ describe('public API smoke', () => { expect(spec.seriesBy).toBe('Columns'); }); + it('assembleImageCharts returns a permanent free-tier Image-Charts URL', () => { + const artifact = assembleImageCharts({ + data: { values: [ + { Category: 'A', Value: 10 }, + { Category: 'B', Value: 20 }, + { Category: 'C', Value: 15 }, + ] }, + semantic_types: { Category: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Category', y: 'Value' }, + title: 'Sales by region', + }, + }); + + expect(artifact.type).toBe('image-charts'); + expect(artifact.url.startsWith('https://image-charts.com/chart?')).toBe(true); + expect(artifact.url).toContain('cht=bvg'); + expect(artifact.url).toContain('chd=a:10,20,15'); + expect(artifact.url).toContain('chxl=0:|A|B|C'); + expect(artifact.url).toContain('chtt=Sales+by+region'); + // Free tier only: never signed, never an output override. + expect(artifact.url).not.toContain('icac'); + expect(artifact.url).not.toContain('ichm'); + expect(artifact.url).not.toContain('chof'); + }); + + it('assembleImageCharts throws on chart types with no faithful cht', () => { + expect(() => assembleImageCharts({ + data: { values: [{ Group: 'A', Value: 1 }, { Group: 'A', Value: 5 }] }, + semantic_types: { Group: 'Category', Value: 'Quantity' }, + chart_spec: { chartType: 'Boxplot', encodings: { x: 'Group', y: 'Value' } }, + })).toThrow('does not support chart type "Boxplot"'); + }); + it('assembleExcel uses field display names for native axis titles', () => { const spec = assembleExcel({ data: { values: [ diff --git a/packages/flint-js/tests/theme-axis-labels.test.ts b/packages/flint-js/tests/theme-axis-labels.test.ts index 6b1408b3..5aa5f8da 100644 --- a/packages/flint-js/tests/theme-axis-labels.test.ts +++ b/packages/flint-js/tests/theme-axis-labels.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { describe, it, expect } from 'vitest'; +import { compile } from 'vega-lite'; import { assembleVegaLite } from '../src'; /** @@ -114,3 +115,88 @@ describe('an axis is ticked at observations only where they are a step', () => { expect(enc.axis?.values).toEqual([2012, 2016, 2020, 2024]); }); }); + +describe('stacked measure endpoints', () => { + function stackedArea(total: number): any { + const values = [ + { year: 2000, cluster: 'A', share: 40 }, + { year: 2000, cluster: 'B', share: total - 40 }, + { year: 2001, cluster: 'A', share: 35 }, + { year: 2001, cluster: 'B', share: total - 35 }, + ]; + const out: any = assembleVegaLite({ + data: { values }, + semantic_types: { year: 'Year', cluster: 'Category', share: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'year', y: 'share', color: 'cluster' }, + baseSize: { width: 400, height: 300 }, + }, + theme_spec: 'swiss', + } as any); + return out.spec ?? out; + } + + it('keeps a clean stacked maximum flush with the axis', () => { + const spec = stackedArea(100); + expect(spec.encoding.y.scale).toMatchObject({ domainMin: 0, domainMax: 100, nice: false }); + }); + + it('treats floating-point residue from calculated shares as flush', () => { + const yearlyShares = [ + ['1955', 22.7131238639, 16.6700124857, 2.9797012748, 16.2510873496, 38.8083620953, 2.5777129306], + ['1960', 23.1673306654, 15.8887417506, 3.0347038067, 16.6089891163, 38.6352683699, 2.6649662911], + ['1965', 23.5800548972, 15.0968881093, 3.1139372122, 16.7420827415, 38.6837925492, 2.7832444907], + ['1970', 23.8122264795, 14.1851153816, 3.1965034231, 16.5995289954, 39.3139453013, 2.8926804191], + ['1975', 24.1962723441, 13.3226671714, 3.3192782807, 16.5053852188, 39.6345427027, 3.0218542823], + ['1980', 24.9156312003, 12.5591286953, 3.5311844524, 16.5577898534, 39.2200529277, 3.2162128709], + ['1985', 25.6874049251, 11.8031165523, 3.7351451602, 16.4680484502, 38.8244431604, 3.4818417517], + ['1990', 26.3861262603, 11.0895550616, 3.9583434243, 16.3135719813, 38.5452173843, 3.7071858881], + ['1995', 27.3018090463, 10.5337539377, 4.0952183708, 16.374040122, 37.8327450169, 3.8624335062], + ['2000', 28.247815136, 10.0455245409, 4.3245615068, 16.4175767489, 36.9529066531, 4.0116154143], + ['2005', 29.121162734, 9.7053050731, 4.5674750342, 16.3698617817, 36.0714490806, 4.1647462963], + ] as const; + const values = yearlyShares.flatMap(([year, ...shares]) => + shares.map((population_share, cluster) => ({ year, cluster: String(cluster), population_share })) + ); + const out: any = assembleVegaLite({ + data: { values }, + semantic_types: { year: 'Year', cluster: 'Category', population_share: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'year', y: 'population_share', color: 'cluster' }, + baseSize: { width: 300, height: 300 }, + title: 'Population share by cluster over time', + subtitle: 'Shares are calculated within each year', + }, + } as any); + const spec = out.spec ?? out; + const totals = yearlyShares.map(([, ...shares]) => shares.reduce((sum, share) => sum + share, 0)); + expect(Math.max(...totals)).toBeGreaterThan(100); + expect(Math.max(...totals)).toBeCloseTo(100, 8); + expect(spec.encoding.y.scale).toMatchObject({ domainMin: 0, domainMax: 100, nice: false }); + + const compiled = compile(spec).spec as any; + const yScale = compiled.scales.find((scale: any) => scale.name === 'y'); + expect(yScale).toMatchObject({ domainMin: 0, domainMax: 100, nice: false }); + }); + + it('leaves a meaningful stacked excess eligible for outward nice rounding', () => { + const out: any = assembleVegaLite({ + data: { values: [ + { year: 2000, cluster: 'A', share: 40 }, + { year: 2000, cluster: 'B', share: 60.3 }, + ] }, + semantic_types: { year: 'Year', cluster: 'Category', share: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'year', y: 'share', color: 'cluster' }, + baseSize: { width: 400, height: 300 }, + }, + } as any); + const spec = out.spec ?? out; + expect(spec.encoding.y.scale.domainMax).toBeUndefined(); + expect(spec.encoding.y.scale.nice).not.toBe(false); + }); + +}); diff --git a/packages/flint-js/tests/theme-plotly.test.ts b/packages/flint-js/tests/theme-plotly.test.ts index 8c97504e..f0b84274 100644 --- a/packages/flint-js/tests/theme-plotly.test.ts +++ b/packages/flint-js/tests/theme-plotly.test.ts @@ -246,7 +246,7 @@ describe('semantic geometry survives house styling', () => { ]); }); - it('thins labels on a dense categorical axis without dropping bars', () => { + it('thins labels in the visible dense window and retains the full viewport domain', () => { const values = Array.from({ length: 100 }, (_v, i) => ({ category: `Page ${i + 1}`, value: i + 1, @@ -261,8 +261,14 @@ describe('semantic geometry survives house styling', () => { }, theme_spec: theme(), } as any) as any; - expect(fig.data[0].x).toHaveLength(100); - expect(fig.layout.xaxis.tickvals.length).toBeLessThan(100); + expect(fig.data[0].x).toHaveLength(90); + expect(fig.layout.xaxis.tickvals.length).toBeLessThan(90); + expect(fig._viewports).toMatchObject([{ + channel: 'x', + visibleCount: 90, + totalCount: 100, + }]); + expect(fig._viewports[0].orderedValues).toHaveLength(100); }); it('factors color and dash into separate forecast legend dimensions', () => { diff --git a/packages/flint-js/tests/theme-titles.test.ts b/packages/flint-js/tests/theme-titles.test.ts index 731dc598..83c00906 100644 --- a/packages/flint-js/tests/theme-titles.test.ts +++ b/packages/flint-js/tests/theme-titles.test.ts @@ -129,6 +129,30 @@ describe('axis titles', () => { expect(bare.title).toBeUndefined(); }); + it('lifts a flat y title above column facet headers', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Year: 2000, Country: 'Germany', Rate: 8 }, + { Year: 2020, Country: 'Germany', Rate: 4 }, + { Year: 2000, Country: 'United States', Rate: 4 }, + { Year: 2020, Country: 'United States', Rate: 8 }, + ] }, + semantic_types: { Year: 'Year', Country: 'Country', Rate: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + title: 'Out of work', + encodings: { x: 'Year', y: 'Rate', column: 'Country' }, + }, + theme_spec: { + ...house({ axisTitles: 'whenAmbiguous', axisTitlePlacement: 'flatAboveAxis', axisTitleGap: 8 }), + structure: { axis: { measure: { placement: 'opposite' } } }, + }, + } as any) as any; + const y = spec.encoding?.y ?? spec.spec?.encoding?.y; + expect(y.axis.orient).toBe('right'); + expect(y.axis.titleY).toBeLessThanOrEqual(-30); + }); + it('leaves an authored subtitle untouched and keeps the measure named', () => { const spec = assembleVegaLite({ data: { values: MONTHLY }, diff --git a/packages/flint-js/tests/unit-display.test.ts b/packages/flint-js/tests/unit-display.test.ts new file mode 100644 index 00000000..a3484622 --- /dev/null +++ b/packages/flint-js/tests/unit-display.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src'; +import { resolveDisplayUnit } from '../src/core/field-semantics'; + +const values = [ + { country: 'Peru', gain: 33.49 }, + { country: 'Iran', gain: 32.34 }, +]; + +function bars(unit?: string, themed = true): any { + return assembleVegaLite({ + data: { values }, + semantic_types: { + country: 'Country', + gain: unit ? { semanticType: 'Duration', unit } : 'Duration', + }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'country', y: 'gain' }, + baseSize: { width: 400, height: 300 }, + }, + ...(themed ? { theme_spec: 'economist' } : {}), + } as any) as any; +} + +describe('explicit unit display policy', () => { + it('does not infer a visible unit from the semantic type', () => { + const axis = bars()._theme.decisions.axes.y; + expect(axis.unit).toBeUndefined(); + expect(axis.title.unit).toBeUndefined(); + }); + + it('places a declared compact unit beside values', () => { + const axis = bars('kg')._theme.decisions.axes.y; + expect(axis.unit).toMatchObject({ text: 'kg' }); + expect(axis.title.unit).toBeUndefined(); + }); + + it('normalizes conventional compact unit names', () => { + expect(resolveDisplayUnit({ semanticType: 'Duration', unit: 'hours' })) + .toEqual({ text: 'hr', placement: 'value', position: 'suffix' }); + expect(resolveDisplayUnit({ semanticType: 'Amount', unit: 'USD' })) + .toEqual({ text: '$', placement: 'value', position: 'prefix' }); + }); + + it('places a declared lexical unit beside the field name', () => { + const axis = bars('years')._theme.decisions.axes.y; + expect(axis.unit).toBeUndefined(); + expect(axis.title.unit).toBe('years'); + + const unthemed = bars('years', false); + expect(unthemed.encoding.y.title).toBe('gain (years)'); + }); + + it('does not display prose as a unit', () => { + expect(resolveDisplayUnit({ + semanticType: 'Quantity', + unit: 'per working-age resident in constant prices', + })).toBeUndefined(); + }); +}); diff --git a/packages/flint-js/tests/value-label-format.test.ts b/packages/flint-js/tests/value-label-format.test.ts index a57b1831..0d445abe 100644 --- a/packages/flint-js/tests/value-label-format.test.ts +++ b/packages/flint-js/tests/value-label-format.test.ts @@ -231,6 +231,31 @@ describe('value label precision', () => { const labelMark = (spec: any) => (spec.layer ?? []).find((l: any) => (l.mark?.type ?? l.mark) === 'text')?.mark; + it('keeps every label outside when the chart chooses outside placement', () => { + const spec: any = assembleVegaLite({ + data: { + values: [703, 608, 227, 165, 148, 120, 102, 58, 55, 49] + .map((value, index) => ({ cause: `Cause ${index + 1}`, value })), + }, + semantic_types: { cause: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { y: 'cause', x: 'value' }, + baseSize: { width: 420, height: 320 }, + chartProperties: { showValueLabels: true }, + }, + theme_spec: 'datawrapper', + } as any); + const body = spec.layer ? spec : spec.vconcat?.[0]; + const labels = (body?.layer ?? []) + .filter((layer: any) => (layer.mark?.type ?? layer.mark) === 'text'); + expect(spec._theme.decisions.dataLabels.placement).toBe('outsideMark'); + expect(labels).toHaveLength(1); + expect(labels[0].mark.align).toBe('left'); + expect(labels[0].mark.dx).toBeGreaterThan(0); + expect(labels[0].transform).toBeUndefined(); + }); + it('sends the label below a bar that runs down from zero', () => { // A bar drawn downwards ends at the bottom, so "outside" is below it. // Placed above, the number lands on top of the bar it labels. A narrow diff --git a/packages/flint-js/tests/year-legend.test.ts b/packages/flint-js/tests/year-legend.test.ts new file mode 100644 index 00000000..0fb61b36 --- /dev/null +++ b/packages/flint-js/tests/year-legend.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src/vegalite'; + +const values = [ + { 品牌: '惠普', 年度: 2025, 毛利: 49933.56 }, + { 品牌: '惠普', 年度: 2026, 毛利: 30973.54 }, + { 品牌: '华为', 年度: 2025, 毛利: 25407.73 }, + { 品牌: '华为', 年度: 2026, 毛利: 14659.13 }, +]; + +function input(typed: boolean) { + return { + data: { values }, + semantic_types: typed + ? { 品牌: 'Category', 年度: 'Year', 毛利: 'Currency' } + : { 品牌: 'Category', 毛利: 'Currency' }, + chart_spec: { + chartType: 'Grouped Bar Chart', + encodings: { + x: { field: '品牌' }, + y: { field: '毛利' }, + group: { field: '年度' }, + }, + }, + } as any; +} + +describe('two-value year legend', () => { + it('uses discrete colors when the group field is typed as Year', () => { + const spec = assembleVegaLite(input(true)) as any; + + expect(spec.encoding.color).toMatchObject({ field: '年度', type: 'ordinal' }); + expect(spec.encoding.xOffset).toMatchObject({ field: '年度', type: 'nominal' }); + }); + + it('keeps an unrecognized numeric group field quantitative', () => { + const spec = assembleVegaLite(input(false)) as any; + + expect(spec.encoding.color).toMatchObject({ field: '年度', type: 'quantitative' }); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tsup.config.ts b/packages/flint-js/tsup.config.ts index 519b97fa..6b795ae6 100644 --- a/packages/flint-js/tsup.config.ts +++ b/packages/flint-js/tsup.config.ts @@ -9,6 +9,12 @@ export default defineConfig({ 'chartjs/index': 'src/chartjs/index.ts', 'plotly/index': 'src/plotly/index.ts', 'excel/index': 'src/excel/index.ts', + 'image-charts/index': 'src/image-charts/index.ts', + 'interactive/index': 'src/interactive/index.ts', + 'vegalite/interactive': 'src/vegalite/interactive.ts', + 'echarts/interactive': 'src/echarts/interactive.ts', + 'chartjs/interactive': 'src/chartjs/interactive.ts', + 'plotly/interactive': 'src/plotly/interactive.ts', 'test-data/index': 'src/test-data/index.ts', 'gallery/index': 'src/gallery/index.ts', }, @@ -19,5 +25,8 @@ export default defineConfig({ splitting: false, treeshake: true, target: 'es2020', - external: ['vega', 'vega-lite', 'echarts', 'chart.js', 'plotly.js'], + external: [ + 'vega', 'vega-lite', 'vega-tooltip', 'echarts', 'chart.js', 'plotly.js', 'plotly.js-dist-min', + '../vegalite/interactive', '../echarts/interactive', '../chartjs/interactive', '../plotly/interactive', + ], }); diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index af1a890f..f7326fb1 100644 --- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md @@ -421,7 +421,20 @@ understates what you know: } ``` -- `unit` — the unit or currency code: `"USD"`, `"°C"`, `"kg"`. +- `unit` — an optional assertion that authorizes Flint to display a unit. Add + it only when the data or surrounding context establishes the measurement + and seeing it materially changes how a reader interprets the number. A type + such as `Duration`, a field name such as `life_expectancy`, or values that + merely look plausible are not enough evidence by themselves. + - Prefer canonical codes: `"USD"`, `"°C"`, `"kg"`, `"km/h"`, `"min"`. + - Conventional compact units are normalized and may appear beside values + (`USD` → `$`, `hours` → `hr`). + - Lexical units such as `"years"` are stated once beside the field name as + `field (years)`, not repeated after every value. + - Do not put explanatory phrases in `unit`. Put qualifications such as + `"per working-age resident"` or `"constant 2024 prices"` in the subtitle. + - Omit `unit` when its meaning, scale, or denominator is uncertain. Flint + does not infer a visible unit from the semantic type or field name. - `intrinsicDomain` — the field's own bounds, for bounded scales only: `[1, 5]` for a five-star rating, `[0, 100]` for a percentage score. Not for open-ended measures. diff --git a/packages/flint-py/flint/core/compute_layout.py b/packages/flint-py/flint/core/compute_layout.py index c510f02b..655f7ef0 100644 --- a/packages/flint-py/flint/core/compute_layout.py +++ b/packages/flint-py/flint/core/compute_layout.py @@ -16,6 +16,7 @@ from . import js_round +DEFAULT_MIN_STEP = 8 VL_SHORT_DISCRETE_CATEGORY_COUNT = 4 VL_SHORT_DISCRETE_LABEL_MAX_LEN = 8 @@ -190,7 +191,7 @@ def compute_layout( elasticity_val = options.get("elasticity", 0.5) max_stretch_x, max_stretch_y = resolve_stretch_caps(options) facet_elasticity_val = options.get("facetElasticity", 0.3) - min_step_val = options.get("minStep", 6) + min_step_val = options.get("minStep", DEFAULT_MIN_STEP) min_subplot_val = options.get("minSubplotSize", 60) step_padding_val = options.get("stepPadding", 0.1) maintain_continuous_axis_ratio = options.get("maintainContinuousAxisRatio", False) @@ -894,7 +895,7 @@ def compute_channel_budgets( options: dict[str, Any], ) -> dict[str, Any]: max_stretch_x, max_stretch_y = resolve_stretch_caps(options) - min_step_val = options.get("minStep", 6) + min_step_val = options.get("minStep", DEFAULT_MIN_STEP) step_padding_val = options.get("stepPadding", 0.1) max_color_val = options.get("maxColorValues", 24) @@ -996,7 +997,7 @@ def compute_facet_grid( fix_w = facet_fixed_padding.get("width", 0) fix_h = facet_fixed_padding.get("height", 0) gap = options.get("facetGap", 0) - min_step = options.get("minStep", 6) + min_step = options.get("minStep", DEFAULT_MIN_STEP) step_padding = options.get("stepPadding", 0.1) base_min_subplot = options.get("minSubplotSize", 60) @@ -1190,7 +1191,7 @@ def compute_min_subplot_dimensions( data: list[dict[str, Any]], options: dict[str, Any], ) -> dict[str, float]: - min_step = options.get("minStep", 6) + min_step = options.get("minStep", DEFAULT_MIN_STEP) min_subplot = options.get("minSubplotSize", 60) min_subplot_width = min_subplot diff --git a/packages/flint-py/flint/core/decisions.py b/packages/flint-py/flint/core/decisions.py index f363bbca..447abf9d 100644 --- a/packages/flint-py/flint/core/decisions.py +++ b/packages/flint-py/flint/core/decisions.py @@ -68,7 +68,7 @@ def _resolve_temporal_encoding( "vlType": "ordinal", "visCategory": vis_category, "channelOverride": True, "cardinalityGuard": False, } - if channel == "color": + if channel in ("color", "group"): unique_count = len({r.get(field_name) for r in data}) if unique_count <= 12: return { diff --git a/packages/flint-py/flint/core/filter_overflow.py b/packages/flint-py/flint/core/filter_overflow.py index 5cb7e3b4..8ce82461 100644 --- a/packages/flint-py/flint/core/filter_overflow.py +++ b/packages/flint-py/flint/core/filter_overflow.py @@ -36,6 +36,7 @@ def is_discrete_type(t: Optional[str]) -> bool: nominal_counts: dict[str, int] = {"x": 0, "y": 0, "column": 0, "row": 0, "group": 0} truncations: list[dict[str, Any]] = [] warnings: list[dict[str, Any]] = [] + viewports: list[dict[str, Any]] = [] filtered_data = data group_cs = channel_semantics.get("group") @@ -83,7 +84,23 @@ def is_discrete_type(t: Optional[str]) -> bool: nominal_counts[channel] = int(min(len(unique_values), max_to_keep)) if len(unique_values) > max_to_keep: - values_to_keep = strategy(channel, field_name, unique_values, int(max_to_keep), strategy_context) + ordered_values = ( + strategy(channel, field_name, unique_values, len(unique_values), strategy_context) + if strategy is _default_overflow_strategy else None + ) + values_to_keep = ( + ordered_values[:int(max_to_keep)] if ordered_values is not None + else strategy(channel, field_name, unique_values, int(max_to_keep), strategy_context) + ) + + if channel in ("x", "y") and ordered_values is not None: + viewports.append({ + "channel": channel, + "field": field_name, + "orderedValues": ordered_values, + "visibleCount": len(values_to_keep), + "totalCount": len(ordered_values), + }) omitted_count = len(unique_values) - len(values_to_keep) placeholder = f"...{omitted_count} items omitted" @@ -111,9 +128,30 @@ def is_discrete_type(t: Optional[str]) -> bool: "nominalCounts": nominal_counts, "truncations": truncations, "warnings": warnings, + "viewports": viewports, } +def resolve_category_viewport(viewport: dict[str, Any], requested_start: int = 0) -> dict[str, Any]: + max_start = max(0, viewport["totalCount"] - viewport["visibleCount"]) + start = min(max_start, max(0, math.floor(requested_start))) + end = min(viewport["totalCount"], start + viewport["visibleCount"]) + return {"start": start, "end": end, "values": viewport["orderedValues"][start:end]} + + +def apply_category_viewports( + data: list[dict[str, Any]], + viewports: list[dict[str, Any]], + starts: Optional[dict[str, int]] = None, +) -> list[dict[str, Any]]: + starts = starts or {} + windows = [ + (viewport["field"], set(resolve_category_viewport(viewport, starts.get(viewport["channel"], 0))["values"])) + for viewport in viewports + ] + return [row for row in data if all(row.get(field) in values for field, values in windows)] + + def _js_sort_key(v: Any) -> str: """JS Array.prototype.sort() default coerces to string.""" if v is None: diff --git a/packages/flint-py/flint/vegalite/assemble.py b/packages/flint-py/flint/vegalite/assemble.py index 5a537f9c..da6b4a42 100644 --- a/packages/flint-py/flint/vegalite/assemble.py +++ b/packages/flint-py/flint/vegalite/assemble.py @@ -509,6 +509,8 @@ def _is_discrete_t(t): if len(warnings) > 0: result["_warnings"] = warnings + if len(overflow_result["viewports"]) > 0: + result["_viewports"] = overflow_result["viewports"] result["_width"] = layout_result["subplotWidth"] result["_height"] = layout_result["subplotHeight"] diff --git a/site/src/components/EChartsView.tsx b/site/src/components/EChartsView.tsx index 3836c1da..ffaea6ed 100644 --- a/site/src/components/EChartsView.tsx +++ b/site/src/components/EChartsView.tsx @@ -20,12 +20,10 @@ export function EChartsView({ const chartRef = useRef(null); const [error, setError] = useState(null); - // The flint ECharts assembler computes a designed canvas size (`_width`/`_height`) - // and positions legends / visualMaps with absolute pixels relative to it — the same - // way Vega-Lite sizes its plot area and lets the SVG wrap around it. Render at those - // dimensions so the legend lands where it was designed, instead of snapping to the - // live container's bounding box (which made rose legends drift far right, streamgraph - // legends overlap the plot, and heatmap colour bars float below a stretched plot). + // The assembler still designs a canvas (`_width`/`_height`). Categorical legends + // are now `right`-anchored (issue #98) and survive container resize(); other + // chrome (visualMap, rose, some radii) is still design-px. Render at the + // designed size so those leftovers land where they were laid out. const designedWidth = asFinite(option?._width); const designedHeight = asFinite(option?._height); const renderHeight = designedHeight ?? height ?? 320; diff --git a/site/src/main.tsx b/site/src/main.tsx index fb8c034f..fff22776 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -22,8 +22,11 @@ import { ThemeLab } from './playground/ThemeLab'; import { ThemeLabR2 } from './playground/ThemeLabR2'; import { ThemeLabReal } from './playground/ThemeLabReal'; import { BandStretchingLab } from './playground/BandStretchingLab'; +import { LabelExperimentLab } from './playground/LabelExperimentLab'; +import { OverflowViewportLab } from './playground/OverflowViewportLab'; import { StyleReferences } from './playground/StyleReferences'; import { FullTestCases } from './playground/FullTestCases'; +import { DebugGym } from './playground/DebugGym'; import { LocaleProvider, useLocale } from './i18n/LocaleContext'; import type { Locale } from './i18n/locales'; import { localePath } from './i18n/paths'; @@ -70,7 +73,11 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> + } /> } /> + } /> + } /> {/* The Swiss and cartoon labs were the same page twice; keep the links they were reached by working. */} } /> diff --git a/site/src/playground/DebugGym.tsx b/site/src/playground/DebugGym.tsx new file mode 100644 index 00000000..4e1d66c3 --- /dev/null +++ b/site/src/playground/DebugGym.tsx @@ -0,0 +1,205 @@ +import { useMemo, useState, type CSSProperties } from 'react'; +import { assembleECharts, assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; +import { genEChartsSlopeTests } from 'flint-chart/test-data'; +import { EChartsView } from '../components/EChartsView'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { VegaLiteView } from '../components/VegaLiteView'; +import { testCaseToAssemblyInput } from '../shared/test-case-utils'; +import { siteTheme } from '../shared/theme'; + +const rows = [ + ['惠普', 2025, 49933.56], ['惠普', 2026, 30973.54], + ['华为', 2025, 25407.73], ['华为', 2026, 14659.13], + ['佳能', 2025, 14717.72], ['佳能', 2026, 5770.24], + ['奔图', 2025, 6094.31], ['奔图', 2026, 2518.72], + ['盈佳', 2025, 68500.12], ['盈佳', 2026, 63500.45], + ['爱普生', 2025, 13120.44], ['爱普生', 2026, 8920.16], +].map(([品牌, 年度, 毛利]) => ({ 品牌, 年度, 毛利 })); + +function makeInput(typed: boolean): ChartAssemblyInput { + return { + data: { values: rows }, + semantic_types: typed + ? { 品牌: 'Category', 年度: 'Year', 毛利: 'Currency' } + : { 品牌: 'Category', 毛利: 'Currency' }, + chart_spec: { + chartType: 'Grouped Bar Chart', + encodings: { + x: { field: '品牌' }, + y: { field: '毛利' }, + group: { field: '年度' }, + }, + baseSize: { width: 400, height: 260 }, + }, + }; +} + +function findFieldEncoding(node: unknown, field: string): Record | null { + if (!node || typeof node !== 'object') return null; + const record = node as Record; + for (const channel of ['color', 'fill', 'stroke']) { + if (record.encoding?.[channel]?.field === field) return record.encoding[channel]; + } + for (const value of Object.values(record)) { + if (Array.isArray(value)) { + for (const item of value) { + const found = findFieldEncoding(item, field); + if (found) return found; + } + } else { + const found = findFieldEncoding(value, field); + if (found) return found; + } + } + return null; +} + +function compileCase(typed: boolean) { + try { + const spec = assembleVegaLite(makeInput(typed)) as any; + const color = findFieldEncoding(spec, '年度'); + const resolvedType = color?.type ?? 'not found'; + const legendKind = resolvedType === 'quantitative' || resolvedType === 'temporal' + ? 'continuous gradient' + : 'categorical swatches'; + return { spec, error: null as string | null, resolvedType, legendKind }; + } catch (error) { + return { + spec: null, + error: String((error as Error)?.message ?? error), + resolvedType: 'error', + legendKind: 'error', + }; + } +} + +const cardStyle: CSSProperties = { + minWidth: 0, + border: `1px solid ${siteTheme.border}`, + borderRadius: siteTheme.radius, + background: siteTheme.surface, + padding: 12, +}; + +function CasePanel({ typed }: { typed: boolean }) { + const result = useMemo(() => compileCase(typed), [typed]); + return ( +
+
+

+ {typed ? 'Year semantic type supplied' : 'Year semantic type missing'} +

+ + {typed ? 'semantic_types: { 年度: "Year" }' : 'semantic_types: { /* 年度 omitted */ }'} + +
+ {result.error ? ( +
{result.error}
+ ) : ( + + + + )} +
+ Color type {result.resolvedType} + Legend {result.legendKind} +
+
+ ); +} + +const SLOPE_WIDTHS = [534, 800] as const; + +function legendTitle(option: any): any { + return (option.graphic ?? []).find( + (item: any) => item?.type === 'text' && item?.style?.fontWeight === 'bold', + ); +} + +function SlopeGym() { + const [hostWidth, setHostWidth] = useState<(typeof SLOPE_WIDTHS)[number]>(800); + const cases = useMemo(() => genEChartsSlopeTests().map((testCase) => { + const input = testCaseToAssemblyInput(testCase, { width: 420, height: 280 }); + const option = assembleECharts(input) as any; + return { testCase, option }; + }), []); + + return ( +
+
+
+

ECharts slope resize

+

+ Issue #98: the legend and its title stay pinned to the right gutter when the host resizes. +

+
+
+ {SLOPE_WIDTHS.map((width) => ( + + ))} +
+
+
+ {cases.map(({ testCase, option }) => { + const resized = { ...option, _width: hostWidth }; + const title = legendTitle(option); + const anchored = option.legend?.right === 16 + && option.legend?.left == null + && title?.right === 16 + && title?.left == null; + return ( +
+

{testCase.title}

+ + + +
+ Host {hostWidth}px + + {anchored ? 'right: 16 ✓' : 'anchor failed'} + +
+
+ ); + })} +
+
+ ); +} + +export function DebugGym() { + return ( +
+
+

Debug gym

+

+ Same two-year data, one variable: 年度: Year resolves to ordinal; an untyped numeric + 年度 remains quantitative. +

+
+
+ + +
+ +
+ ); +} \ No newline at end of file diff --git a/site/src/playground/LabelExperimentLab.tsx b/site/src/playground/LabelExperimentLab.tsx new file mode 100644 index 00000000..eb20a05e --- /dev/null +++ b/site/src/playground/LabelExperimentLab.tsx @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useMemo } from 'react'; +import { assembleVegaLite, THEME_PRESETS, type ChartAssemblyInput } from 'flint-chart'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { VegaLiteView } from '../components/VegaLiteView'; +import './label-experiment-lab.css'; + +type Outcome = 'direct' | 'dodge' | 'fallback'; + +interface ExperimentCase { + id: string; + title: string; + note: string; + expected: Outcome; + input: ChartAssemblyInput; +} + +const baseTheme = (THEME_PRESETS as any).datawrapper.spec; +const labelTheme = { + ...baseTheme, + id: 'label-experiment', + label: 'Label experiment', + legend: { + ...baseTheme.legend, + show: 'always', + placement: ['seriesEnd', 'right'], + }, +}; + +function lineRows(endValues: number[], endYears?: number[]): any[] { + return endValues.flatMap((endValue, seriesIndex) => { + const endYear = endYears?.[seriesIndex] ?? 2020; + return [1950, 1980, endYear].map((year, index) => ({ + year, + series: `S${seriesIndex + 1}`, + value: index === 2 ? endValue : 72 - seriesIndex * 4 - index * 5, + })); + }); +} + +function lineInput(endValues: number[], endYears?: number[]): ChartAssemblyInput { + return { + data: { values: lineRows(endValues, endYears) }, + semantic_types: { year: 'Year', series: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'year', y: 'value', color: 'series' }, + baseSize: { width: 420, height: 280 }, + }, + theme_spec: labelTheme, + } as ChartAssemblyInput; +} + +const connectedRows = [ + ['0', 1955, 4.3, 37], ['0', 1980, 5.5, 55], ['0', 2005, 6.1, 58], + ['1', 1955, 1.8, 79], ['1', 1980, 2.8, 69], ['1', 2005, 6.5, 54], + ['2', 1955, 1.6, 77], ['2', 1980, 2.7, 68], ['2', 2005, 6.7, 55], + ['3', 1955, 1.9, 75], ['3', 1980, 3.2, 66], ['3', 2005, 4.9, 63], + ['4', 1955, 1.7, 73], ['4', 1980, 3.0, 67], ['4', 2005, 5.9, 60], + ['5', 1955, 2.8, 71], ['5', 1980, 4.1, 68], ['5', 2005, 6.7, 47], +].map(([cluster, year, fertility, longevity]) => ({ cluster, year, fertility, longevity })); + +const CASES: ExperimentCase[] = [ + { + id: 'separated', + title: 'Aligned and separated', + note: 'One endpoint column; labels already have enough vertical air.', + expected: 'direct', + input: lineInput([18, 38, 58]), + }, + { + id: 'small-dodge', + title: 'Two close endpoints', + note: 'A sub-line-height adjustment is accepted and connected back to each point.', + expected: 'dodge', + input: lineInput([50, 52]), + }, + { + id: 'dense', + title: 'Dense endpoint cluster', + note: 'The required movement exceeds one line of text, so the whole set returns to a legend.', + expected: 'fallback', + input: lineInput([50, 51, 52, 53]), + }, + { + id: 'staggered', + title: 'Staggered final x positions', + note: 'Different final years are harmless when the natural label rows do not overlap.', + expected: 'direct', + input: lineInput([20, 42, 64], [2020, 2010, 2000]), + }, + { + id: 'boundary', + title: 'Top boundary endpoint', + note: 'The highest label stays centred on its endpoint; the figure bounds carry the overhang.', + expected: 'direct', + input: lineInput([25, 48, 72]), + }, + { + id: 'connected', + title: 'Connected scatter trajectories', + note: 'Rightmost points occupy a broad x range, matching the difficult real-world pattern.', + expected: 'fallback', + input: { + data: { values: connectedRows }, + semantic_types: { + cluster: 'Category', + year: 'Year', + fertility: { semanticType: 'Quantity', unit: 'children per woman' }, + longevity: { semanticType: 'Duration', unit: 'years' }, + }, + chart_spec: { + chartType: 'Connected Scatter Plot', + title: 'Cluster development trajectories', + subtitle: 'Synthetic endpoints modeled after the reported collision', + encodings: { x: 'fertility', y: 'longevity', color: 'cluster', order: 'year' }, + baseSize: { width: 420, height: 280 }, + }, + theme_spec: labelTheme, + } as ChartAssemblyInput, + }, +]; + +function stripInternal(node: any): void { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach(stripInternal); + return; + } + for (const key of Object.keys(node)) { + if (/^_[^_]/.test(key)) delete node[key]; + else stripInternal(node[key]); + } +} + +function buildCase(testCase: ExperimentCase): { spec?: any; outcome: Outcome; message: string; error?: string } { + try { + const spec = assembleVegaLite(testCase.input as any) as any; + const messages = (spec._theme?.report ?? []) + .filter((entry: any) => entry.path === 'legend.placement') + .map((entry: any) => entry.message); + const message = messages.find((entry: string) => + entry.includes('dodged by at most') + || entry.includes('key is drawn') + || entry.includes('do not form one readable label column') + || entry.includes('more than one line of text') + ) ?? messages.at(-1) ?? 'No placement report'; + const outcome: Outcome = messages.some((entry: string) => entry.includes('dodged by at most')) + ? 'dodge' + : messages.some((entry: string) => entry.includes('key is drawn')) + ? 'fallback' + : 'direct'; + stripInternal(spec); + return { spec, outcome, message }; + } catch (error) { + return { outcome: 'fallback', message: 'Assembly failed', error: String((error as Error)?.message ?? error) }; + } +} + +function CaseTile({ testCase }: { testCase: ExperimentCase }) { + const built = useMemo(() => buildCase(testCase), [testCase]); + const matches = built.outcome === testCase.expected; + + return ( +
+
+
+

{testCase.title}

+

{testCase.note}

+
+ + {built.outcome} + +
+
+ {built.error || !built.spec + ?
{built.error}
+ : ( + + + + )} +
+
+ {built.message} + {!matches && Expected {testCase.expected}} +
+
+ ); +} + +export function LabelExperimentLab() { + return ( +
+
+

Series-end label experiment

+

+ Direct labels stay when no more than eight endpoints form one column and need at most one + line of vertical adjustment. Otherwise, the complete set falls back to a legend. +

+
+
+ {CASES.map((testCase) => )} +
+
+ ); +} diff --git a/site/src/playground/OverflowViewportLab.tsx b/site/src/playground/OverflowViewportLab.tsx new file mode 100644 index 00000000..ebca8a48 --- /dev/null +++ b/site/src/playground/OverflowViewportLab.tsx @@ -0,0 +1,199 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ChartAssemblyInput } from 'flint-chart'; +import { buildInteractiveChart } from 'flint-chart/interactive'; +import { expressionInterpreter } from 'vega-interpreter'; +import { BACKENDS, getSupportedBackends, type PreviewBackend } from '../shared/supported-backends'; +import './overflow-viewport-lab.css'; + +const categoryRows = Array.from({ length: 160 }, (_, index) => ({ + Category: `Studio ${String(index + 1).padStart(2, '0')}`, + Gross: 18 + ((index * 47) % 83), +})); + +const heatmapRows = Array.from({ length: 90 }, (_, row) => + Array.from({ length: 130 }, (_, column) => ({ + Product: `Product ${String(row + 1).padStart(2, '0')}`, + Week: `W${String(column + 1).padStart(2, '0')}`, + Activity: 20 + ((row * 31 + column * 17) % 80), + })), +).flat(); + +const commonSizing = { + baseSize: { width: 430, height: 300 }, + canvasSize: { width: 620, height: 420 }, +}; + +const verticalInput: ChartAssemblyInput = { + semantic_types: { Category: 'Category', Gross: 'Currency' }, + chart_spec: { + chartType: 'Bar Chart', + title: 'WW Gross by Studio', + ...commonSizing, + encodings: { x: 'Category', y: 'Gross' }, + }, + data: { values: categoryRows }, +}; + +const horizontalInput: ChartAssemblyInput = { + ...verticalInput, + chart_spec: { + ...verticalInput.chart_spec, + encodings: { x: 'Gross', y: 'Category' }, + }, +}; + +const heatmapInput: ChartAssemblyInput = { + semantic_types: { Product: 'Category', Week: 'Category', Activity: 'Quantity' }, + chart_spec: { + chartType: 'Heatmap', + title: 'Product activity by week', + ...commonSizing, + encodings: { x: 'Week', y: 'Product', color: 'Activity' }, + }, + data: { values: heatmapRows }, +}; + +function InteractiveBackendSurface({ input, backend, renderer = 'canvas' }: { + input: ChartAssemblyInput; + backend: PreviewBackend; + renderer?: 'canvas' | 'svg'; +}) { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const surface = buildInteractiveChart( + container, + input, + { + backend, + renderer, + expressionInterpreter: backend === 'vegalite' ? expressionInterpreter : undefined, + ariaLabel: input.chart_spec.title, + }, + ); + void surface.ready.catch((error) => { + container.textContent = error instanceof Error ? error.message : String(error); + }); + return () => surface.destroy(); + }, [backend, input, renderer]); + + return
; +} + +function BackendPicker({ + value, + availableBackends, + onChange, +}: { + value: PreviewBackend; + availableBackends: PreviewBackend[]; + onChange: (value: PreviewBackend) => void; +}) { + return ( +
+ {availableBackends.map((backend) => ( + + ))} +
+ ); +} + +function GeneralViewportDemo({ + input, + initialBackend, + description, +}: { + input: ChartAssemblyInput; + initialBackend: PreviewBackend; + description: string; +}) { + const [backend, setBackend] = useState(initialBackend); + const availableBackends = getSupportedBackends(input.chart_spec.chartType); + + useEffect(() => { + if (!availableBackends.includes(backend)) { + setBackend(availableBackends[0] ?? 'vegalite'); + } + }, [availableBackends, backend]); + + return ( +
+
+
+

{input.chart_spec.title}

+

{description}

+
+ +
+
+ +
+
+ ); +} + +function McpViewportDemo() { + return ( +
+
+ Flint chart + Flint-owned interactive surface + Retained Vega renderer +
+
+ +
+
+ Theme Default + View retained while dragging +
+
+ ); +} + +export function OverflowViewportLab() { + return ( +
+
+

Overflow viewport lab

+

Category capacity becomes a navigable viewport only after the chart reaches its stretch ceiling and bands reach their normal minimum step. Static output still uses the first window; interactive hosts retain the complete ordered domain.

+
+ +
+ Retained MCP App path + One Vega compile; slider movement updates the existing dataflow. +
+ + +
+ General host path + The same core viewport plan drives every backend through ordinary assembly. +
+ + + +
+ ); +} \ No newline at end of file diff --git a/site/src/playground/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index 3c0cc16d..dbdbc1c4 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -9,6 +9,8 @@ const pages: NavEntry[] = [ { to: 'illustrations', label: 'Illustrations' }, { to: 'mcp-ui', label: 'MCP UI test' }, { to: 'labs', label: 'Labs' }, + { to: 'overflow-viewport', label: 'Overflow viewport' }, + { to: 'debug-gym', label: 'Debug gym' }, { to: 'demo-wall', label: 'Demo wall' }, { group: 'Theme labs', @@ -17,6 +19,7 @@ const pages: NavEntry[] = [ { to: 'theme-lab-r2', label: 'Theme lab R2' }, { to: 'theme-lab-real', label: 'Theme lab real' }, { to: 'band-stretching', label: 'Band stretching' }, + { to: 'label-experiment', label: 'Label experiment' }, { to: 'style-references', label: 'Style references' }, ], }, diff --git a/site/src/playground/label-experiment-lab.css b/site/src/playground/label-experiment-lab.css new file mode 100644 index 00000000..c89cd6c3 --- /dev/null +++ b/site/src/playground/label-experiment-lab.css @@ -0,0 +1,134 @@ +.label-lab { + max-width: 960px; + margin: 0 auto; + padding: 10px 4px 56px; + color: #16202a; +} + +.label-lab-intro { + margin-bottom: 18px; +} + +.label-lab-intro h1 { + margin: 0 0 5px; + font-size: 20px; + font-weight: 650; + letter-spacing: 0; +} + +.label-lab-intro p { + max-width: 820px; + margin: 0; + color: #5d6872; + font-size: 13px; + line-height: 1.55; +} + +.label-case-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.label-case { + display: grid; + grid-template-rows: auto 300px auto; + min-width: 0; + border-top: 1px solid #d8dde2; + background: #fff; +} + +.label-case-header { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: flex-start; + padding: 12px 0 10px; +} + +.label-case-header h2 { + margin: 0; + font-size: 15px; + line-height: 1.25; + letter-spacing: 0; +} + +.label-case-header p { + max-width: 520px; + margin: 5px 0 0; + color: #6a747d; + font-size: 12px; + line-height: 1.4; +} + +.label-outcome { + flex: 0 0 auto; + padding-top: 2px; + color: #737d86; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10px; + text-transform: uppercase; +} + +.label-chart-frame { + position: relative; + min-width: 0; + overflow: hidden; + border-top: 1px solid #edf0f2; + border-bottom: 1px solid #edf0f2; + background: #fff; +} + +.label-error { + display: grid; + height: 100%; + place-items: center; + color: #a52c24; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.label-case-footer { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 8px 0 12px; +} + +.label-case-footer > span { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + white-space: nowrap; +} + +.label-mismatch { color: #a52c24; } + +.label-case-footer code { + min-width: 0; + color: #5d6872; + font-size: 10px; + line-height: 1.45; + white-space: normal; + overflow-wrap: anywhere; +} + +@media (max-width: 980px) { + .label-lab { + padding: 10px 0 40px; + } + + .label-case-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .label-case { + grid-template-rows: auto 300px auto; + } + + .label-case-footer { + grid-template-columns: 1fr; + } +} diff --git a/site/src/playground/overflow-viewport-lab.css b/site/src/playground/overflow-viewport-lab.css new file mode 100644 index 00000000..c0904593 --- /dev/null +++ b/site/src/playground/overflow-viewport-lab.css @@ -0,0 +1,308 @@ +.ov-page { + gap: 26px; +} + +.ov-heading p, +.ov-demo-header p { + margin: 7px 0 0; + color: #66707a; + font-size: 13px; + line-height: 1.5; +} + +.ov-section-heading, +.ov-demo, +.ov-mcp { + width: min(100%, 960px); + box-sizing: border-box; +} + +.ov-section-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 20px; + margin-top: 10px; + border-bottom: 1px solid #cfd5da; + padding-bottom: 7px; +} + +.ov-section-heading span { + font-size: 14px; + font-weight: 650; +} + +.ov-section-heading small { + color: #737d86; +} + +.ov-demo, +.ov-mcp { + border: 1px solid #d8dde2; + border-radius: 8px; + background: #fff; + overflow: hidden; +} + +.ov-demo { + padding: 18px; +} + +.ov-demo-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; + margin-bottom: 14px; +} + +.ov-demo-header h2 { + margin: 0; + font-size: 15px; + letter-spacing: 0; +} + +.ov-backends { + display: inline-flex; + flex: 0 0 auto; + padding: 2px; + border-radius: 6px; + background: #eef1f3; +} + +.ov-backends button { + border: 0; + border-radius: 4px; + padding: 5px 8px; + color: #5d6670; + background: transparent; + font: inherit; + font-size: 11px; + cursor: pointer; +} + +.ov-backends button.active { + color: #1f2328; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); +} + +.ov-stage-row { + display: flex; + align-items: stretch; + gap: 9px; + min-width: 0; +} + +.ov-stage-row-grid { + width: fit-content; + max-width: 100%; + margin-inline: auto; +} + +.ov-stage-row-grid .ov-chart-column { + flex: 0 1 auto; +} + +.ov-stage-row-grid .ov-stage { + min-height: 0; +} + +.ov-chart-column { + flex: 1 1 auto; + min-width: 0; +} + +.ov-stage { + display: grid; + place-items: center; + min-height: 330px; + overflow: auto; + border-top: 1px solid #edf0f2; + border-bottom: 1px solid #edf0f2; +} + +.ov-interactive-stage { + display: block; + padding: 12px; +} + +.ov-interactive-mount { + width: 100%; + max-width: 960px; + margin-inline: auto; +} + +.ov-interactive-mount [data-flint-chart] { + display: grid; + place-items: center; + min-height: 220px; +} + +.ov-rail { + color: #59636d; + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.ov-rail-horizontal { + display: grid; + grid-template-columns: 105px minmax(120px, 1fr); + align-items: center; + gap: 10px; + min-height: 28px; + padding: 5px 2px 0; +} + +.ov-rail-vertical { + display: flex; + align-items: center; + flex-direction: column; + width: 34px; + padding: 7px 0; +} + +.ov-rail-vertical .ov-rail-label { + writing-mode: vertical-rl; + margin-bottom: 8px; +} + +.ov-window-track { + position: relative; + display: block; + overflow: hidden; + border-radius: 3px; + background: #dfe3e6; + touch-action: none; + cursor: ew-resize; +} + +.ov-window-track:focus-visible { + outline: 2px solid #118dff; + outline-offset: 3px; +} + +.ov-rail-horizontal .ov-window-track { + width: 100%; + height: 7px; +} + +.ov-rail-vertical .ov-window-track { + width: 7px; + flex: 1 1 auto; + min-height: 235px; + cursor: ns-resize; +} + +.ov-window-thumb { + position: absolute; + border-radius: 3px; + background: #4d5963; + pointer-events: none; +} + +.ov-rail-horizontal .ov-window-thumb { + top: 0; + bottom: 0; +} + +.ov-rail-vertical .ov-window-thumb { + left: 0; + right: 0; +} + +.ov-mcp-titlebar { + display: flex; + align-items: center; + gap: 12px; + min-height: 42px; + padding: 0 14px; + border-bottom: 1px solid #e2e5e8; + color: #636c75; + font-size: 11px; +} + +.ov-mcp-titlebar strong { + color: #1f2328; + font-size: 13px; +} + +.ov-live-status { + margin-left: auto; + color: #207047; + font-variant-numeric: tabular-nums; +} + +.ov-mcp-chart { + display: grid; + place-items: center; + min-width: 0; + padding: 12px; +} + +.ov-mcp-figure { + width: 100%; + max-width: 620px; + min-width: 0; +} + +.ov-live-chart { + display: grid; + place-items: center; + min-width: 0; + min-height: 220px; + overflow: auto; +} + +.ov-mcp-figure .ov-rail-horizontal { + padding-top: 4px; +} + +.ov-mcp-options { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 34px; + padding: 4px 12px; + background: #fff; +} + +.ov-option-chip, +.ov-option-note { + color: #68727c; + font-size: 10px; +} + +.ov-option-chip { + padding: 6px 8px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.05); +} + +.ov-option-chip strong { + margin-left: 5px; + color: #252a2f; + font-weight: 550; +} + +@media (max-width: 720px) { + .ov-demo-header, + .ov-section-heading { + align-items: stretch; + flex-direction: column; + } + + .ov-backends { + align-self: flex-start; + flex-wrap: wrap; + } + + .ov-option-note { + display: none; + } + + .ov-live-chart { + min-width: 0; + } +} \ No newline at end of file diff --git a/site/src/shared/docs-catalog.ts b/site/src/shared/docs-catalog.ts index 2cb735fd..5069074f 100644 --- a/site/src/shared/docs-catalog.ts +++ b/site/src/shared/docs-catalog.ts @@ -136,6 +136,12 @@ export const DOCUMENTATION_GROUPS: DocGroup[] = [ description: 'Every native Excel chart type, its channels, and Office.js mapping.', file: '../../../docs/reference-excel.md', }, + { + slug: 'community-backends', + title: 'Community backends', + description: 'Community-contributed renderers and delivery targets, their coverage, and integration notes.', + file: '../../../docs/community-backends.md', + }, ], }, { diff --git a/site/src/types/plotly.d.ts b/site/src/types/plotly.d.ts index 5116476f..d3c4ff6c 100644 --- a/site/src/types/plotly.d.ts +++ b/site/src/types/plotly.d.ts @@ -1,6 +1,10 @@ declare module 'plotly.js-dist-min' { const Plotly: { newPlot: (el: HTMLElement, data: unknown[], layout?: unknown, config?: unknown) => Promise; + react: (el: HTMLElement, data: unknown[], layout?: unknown, config?: unknown) => Promise; + Plots: { + resize: (el: HTMLElement) => Promise | void; + }; purge: (el: HTMLElement) => void; }; export default Plotly; diff --git a/site/vite.config.ts b/site/vite.config.ts index a4f3ecf3..88f387cc 100644 --- a/site/vite.config.ts +++ b/site/vite.config.ts @@ -15,6 +15,10 @@ export default defineConfig({ // NOTE: order matters — longer aliases must come first so 'flint-chart/test-data' // is matched before the bare 'flint-chart' substring alias. alias: [ + { + find: 'flint-chart/interactive', + replacement: path.resolve(__dirname, '../packages/flint-js/src/interactive/index.ts'), + }, { find: 'flint-chart/test-data', replacement: path.resolve(__dirname, '../packages/flint-js/src/test-data/index.ts'),