diff --git a/CHANGELOG.md b/CHANGELOG.md index a87c26c3..40c7ecc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Keyboard targeting now navigates and emits `focus-element` through the + `keyboard-targeting` interaction ID without requiring a click preset. Enter + and Space still invoke configured click presets when present. +- Independently retained `set-annotation` updates now render together by update + and semantic target identity; clearing one update leaves the others visible. +- 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/agent-skills/flint-theme-author/SKILL.md b/agent-skills/flint-theme-author/SKILL.md index 4df9c637..925d9697 100644 --- a/agent-skills/flint-theme-author/SKILL.md +++ b/agent-skills/flint-theme-author/SKILL.md @@ -129,7 +129,7 @@ the authored blocks and their jobs: | `layout` | Density, target width, title block, and band step | | `chartDefaults` | Optional defaults keyed by registered chart type or `*`; caller values still win | | `compileDefaults` | Preferred base size, canvas size, and supported assemble options | -| `interaction` | Tooltip format | +| `interaction` | Tooltip format and semantic selection-boundary paint | | `variants` | Conditional policy adaptations; variants may not change `ink` or `type` | ### High-value nested shapes @@ -154,6 +154,8 @@ the authored blocks and their jobs: } ``` +`interaction.selectionBoundary` accepts `color`, `width`, `opacity`, `haloColor`, `haloWidth`, and `haloOpacity`. Omitted paint is grounded from the theme: foreground from `ink.accent` then primary text, and halo from the plot or canvas surface. Use explicit values only when the house has a distinct interaction treatment. + This is a shape example, not a palette recommendation. Derive actual values from the user's references. diff --git a/docs/README.md b/docs/README.md index 41b96e06..a4fd1d77 100644 --- a/docs/README.md +++ b/docs/README.md @@ -699,6 +699,8 @@ channels. This decouples user/AI intent from rendering specifics. First-class channel for grouped bar charts. The analysis stage resolves its semantics (type, color scheme) without any VL knowledge. The grouping axis is auto-detected: whichever of `x`/`y` is discrete gets subdivided. +For a Grouped Bar Chart, `color` is an equivalent alias for `group`; when only +`color` is supplied, Flint canonicalizes it to `group` before analysis. During instantiation, `buildVLEncodings()` translates: - `group` → VL `color` encoding (for coloring) 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..3bd89349 100644 --- a/docs/reference-vegalite.md +++ b/docs/reference-vegalite.md @@ -31,7 +31,7 @@ The **Availability** column shows whether a parameter is `always` available or ` ### ![](chart-icon-scatter.svg) Scatter Plot -**Encoding channels:** `x`, `y`, `color`, `size`, `shape`, `opacity`, `column`, `row` +**Encoding channels:** `x`, `y`, `color`, `size`, `shape`, `detail`, `opacity`, `column`, `row` | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| @@ -110,7 +110,7 @@ The **Availability** column shows whether a parameter is `always` available or ` ### ![](chart-icon-column-grouped.svg) Grouped Bar Chart -**Encoding channels:** `x`, `y`, `group`, `column`, `row` +**Encoding channels:** `x`, `y`, `group`, `color`, `column`, `row` | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| @@ -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/theme-spec.md b/docs/theme-spec.md index fe0fd614..d2850ef5 100644 --- a/docs/theme-spec.md +++ b/docs/theme-spec.md @@ -70,11 +70,31 @@ Every field is optional. Start with the decisions that matter to your product, t | `annotation` | Units, axis titles, number formats, point emphasis, and statistics | | `layout`, `facets` | Density, title spacing, band steps, panel spacing, and shared scales | | `chartDefaults`, `compileDefaults` | House defaults for chart controls, base size, canvas size, and layout limits | +| `interaction` | Tooltip formatting and semantic selection-boundary paint | | `furniture` | Rules, tabs, and other recurring chart chrome | | `variants` | Semantic conditions that adapt policy to a chart's role, density, or shape | Theme rules are semantic. For example, `structure.grid.measure` controls the grid used to read values, whichever physical axis carries the measure. `legend.placement` gives the compiler an ordered set of acceptable positions rather than fixed coordinates. This is what lets one theme generalize across different chart types, data, and canvas sizes. +Selection boundaries are inferred from the theme unless explicitly stated. Their foreground defaults to `ink.accent`, then `ink.text.primary`; their halo defaults to the plot or canvas surface. This gives a continuous-color grid an outline that belongs to the house while remaining legible across both ends of its ramp. A theme can override the treatment: + +```json +{ + "interaction": { + "selectionBoundary": { + "color": "#b54a20", + "width": 1.25, + "opacity": 0.68, + "haloColor": "#ffffff", + "haloWidth": 2.5, + "haloOpacity": 0.35 + } + } +} +``` + +This block controls paint only. Set `haloWidth` to `0` to disable the contrast halo. The ChartDef still decides whether a representation needs a boundary and the renderer still computes its contiguous geometry. + ### 3. Inherit and override Use `extends` when a preset is close to your brand: 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..6b9a3687 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2582,6 +2582,290 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha1-1FUKhdCPSXj68KTDa4SMYeqsB+I=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha1-4CFRRk0C1KG0RkbQ/NuT+viP3ow=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha1-52DldluBiLHe+jK8i7YGL4Hkx5U=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha1-wvQ2KwRdRy4bGGzb7DKbpSva7mw=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha1-FwbKQM9+pZoK3Y9EVu//j4d1eT0=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha1-NoyWGhjech2oIA6AvzlD+1MTavI=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha1-mto/qcTQDjpQk/7QNWx6uSlgQjE=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha1-GFwagMyAf92io/6WD3wRxKJ5UuE=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha1-7wBNihKARs/OQ00XGC+DTkTvlbI=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha1-sTq6iyRCtAaMmp5tHYL4vOp3/AI=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha1-CjUfmW3Jmzf0+li0ksLRwE49rBc=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha1-4o2xv7+mFwdvd3DdHZpI6qO2xRs=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha1-wEorTyMYGqN28wrwKD28eztWmYA=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha1-bcj8bh81cE87BXCQvu63rGdL/xo=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha1-seRGVkTds/3zomP+uyQKbNYW3pA=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha1-nig68XlgHFSVgWALP+wllBkRMp0=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha1-YCP7Oy1GMiny1oD5rEtHRm9x8Xs=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha1-QSuQ6EhwKF8v+KhGxutgNE8SpBw=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha1-9jKzgMOsoduo40qgSbzWpK8j34o=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha1-365UptNdGedqyVZbyzKo5UaTGJw=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha1-1HQLD+NbHFi2bhSI9OftApUvVw8=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha1-a9NoO4My/A8B5wWbdja8XH7eczc=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha1-V6L3ByQub+Hega17/Myq9gYXmvs=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha1-3G1Pmpg3bxjqULrWw5U38bVGPDk=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha1-vXpF/AqMMWemMWdeYbwsorBY1KM=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha1-0VFsxQh1O+BoUs0GdY47tUoisOM=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha1-hHL+7NY5aRRQ3YAA6zPt1EThMj8=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha1-1rwea2p9tpzM+73Uw0twYy2enbI=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha1-cLvad9wjqnJ0E+IuIUr6Pw6FL3A=", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha1-ETa8V+nds8OQ3MybX/O30rjZRwY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha1-3Msy0cVrHhxuDxGA2ZSJbwOLxAs=", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/debug/-/debug-4.1.13.tgz", @@ -3739,6 +4023,47 @@ "integrity": "sha1-7EjA8+mT5QZIyG2lWeJhCZXPmJo=", "license": "MIT" }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3/-/d3-7.9.0.tgz", + "integrity": "sha1-V556yz10nK+IYL0XQa6NNxBwzV0=", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-array/-/d3-array-3.2.4.tgz", @@ -3751,6 +4076,43 @@ "node": ">=12" } }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha1-xCpKE+gTHWN7dF/Clzgkz+r5MyI=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha1-b3Z8Ttjct53n7ePhwPieY+9k0xw=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha1-0VbWH0hfzoMn5qvzOctB2Mu6aWY=", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-color/-/d3-color-3.1.0.tgz", @@ -3760,6 +4122,18 @@ "node": ">=12" } }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha1-u5IGO8jFZjrLJCL5nHPLtsauO8w=", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-delaunay": { "version": "6.0.4", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-delaunay/-/d3-delaunay-6.0.4.tgz", @@ -3781,6 +4155,19 @@ "node": ">=12" } }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha1-mUqunNI8cZ9TteEOOgphCMaWB7o=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-dsv": { "version": "3.0.1", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-dsv/-/d3-dsv-3.0.1.tgz", @@ -3827,6 +4214,27 @@ "node": ">=0.10.0" } }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha1-llisOKIUDVnTRhYPH2ww/aC9EvQ=", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha1-gxQb/5hWoO21443onNz+Y9CmCiI=", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-force": { "version": "3.0.0", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-force/-/d3-force-3.0.0.tgz", @@ -3922,6 +4330,15 @@ "node": ">=12" } }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha1-C0XT3RxIopyOBX5hNWk+yAvxY5g=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-quadtree": { "version": "3.0.1", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-quadtree/-/d3-quadtree-3.0.1.tgz", @@ -3931,6 +4348,15 @@ "node": ">=12" } }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha1-1JJjeNMz2cC/0eb6AZTTCuuqIPQ=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-scale/-/d3-scale-4.0.2.tgz", @@ -3960,6 +4386,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha1-wlM4IH76csxbm9FFihpBkB8eGzE=", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-shape/-/d3-shape-3.2.0.tgz", @@ -4005,6 +4440,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha1-aGn93hRIhoB3/dWYkgDLYbKhZF8=", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha1-0T9BZccyF//qpUKVzWlps+eu6PM=", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", @@ -10301,8 +10771,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 +10786,17 @@ "plotly.js": { "optional": true }, + "plotly.js-dist-min": { + "optional": true + }, "vega": { "optional": true }, "vega-lite": { "optional": true + }, + "vega-tooltip": { + "optional": true } } }, @@ -10340,6 +10818,7 @@ "zod": "^3.25.1" }, "bin": { + "flint-chart": "dist/flint-chart.js", "flint-chart-mcp": "dist/cli.js" }, "devDependencies": { @@ -10418,6 +10897,7 @@ "@fontsource-variable/inter": "^5.2.8", "@uiw/react-codemirror": "^4.23.0", "chart.js": "^4.5.1", + "d3": "^7.9.0", "echarts": "^6.0.0", "flint-chart": "*", "i18next": "^26.3.6", @@ -10438,6 +10918,7 @@ "vega-lite": "^6.4.1" }, "devDependencies": { + "@types/d3": "^7.4.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/react-syntax-highlighter": "^15.5.13", 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..f3f71494 100644 --- a/packages/flint-js/src/README.md +++ b/packages/flint-js/src/README.md @@ -163,6 +163,49 @@ 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 interaction coordination, viewport state, accessible scroll controls, and renderer lifecycle; the caller supplies only a container and chart input. + +```ts +import { buildInteractiveChart, clickHighlight, externalInteraction } from 'flint-chart/interactive'; + +const surface = buildInteractiveChart( + container, + input, + { + backend: 'vegalite', + renderer: 'canvas', + interactions: [clickHighlight({ targets: ['mark', 'legend', 'discreteAxis'] })], + }, +); + +await surface.ready; +// Later: surface.destroy(); +``` + +Application input is transport-neutral. Register an external handler with the chart, +then dispatch payloads by interaction ID from React state, a DOM listener, a WebSocket, +or another chart: + +```ts +const countryPicker = externalInteraction<{ country: string }>({ + id: 'country-picker', + handle: ({ country }) => ({ + id: 'country-selection', + ops: [{ + op: 'set-style', + targets: [{ select: { key: { Country: country } } }], + value: { state: 'emphasized' }, + }], + }), +}); + +await surface.dispatch('country-picker', { country: 'Japan' }); +``` + +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 interactions are enabled explicitly through `interactions`; `clickHighlight()` focuses configured mark, legend, and discrete-axis targets, Shift/Ctrl/Meta-click toggles targets, and clicking empty plot space clears. Other backends currently reject semantic interactions. Advanced integrations can use `mountInteractiveChartSurface()` with a custom `InteractiveRendererAdapter`; the surface invokes external handlers, while adapters expose interaction context and apply renderer-neutral updates. 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..1a3681ff 100644 --- a/packages/flint-js/src/chartjs/assemble.ts +++ b/packages/flint-js/src/chartjs/assemble.ts @@ -104,7 +104,7 @@ export function assembleChartjs(input: ChartAssemblyInput): any { // ═══════════════════════════════════════════════════════════════════════ const rawData = input.data.values ?? []; const normalized = normalizeStaticSeries( - input.chart_spec.encodings, rawData, semanticTypes, + input.chart_spec.encodings, rawData, semanticTypes, chartType, ); let data = normalized.data; const staticSeries = normalized.staticSeries; @@ -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..da968a5e 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,11 +133,12 @@ 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, normalizeEncodingShorthand, + normalizeChartEncodingAliases, coerceEncodingValue, STATIC_SERIES_KEY_COLUMN, STATIC_SERIES_VALUE_COLUMN, @@ -198,6 +200,7 @@ export { // ThemeSpec: public visual-system vocabulary and chart-specific grounding export { type ThemeSpec, + type ThemeInteraction, type ThemePreset, type DesignDecisions, type ThemeReport, diff --git a/packages/flint-js/src/core/interaction-contracts.ts b/packages/flint-js/src/core/interaction-contracts.ts new file mode 100644 index 00000000..35e5a95c --- /dev/null +++ b/packages/flint-js/src/core/interaction-contracts.ts @@ -0,0 +1,288 @@ +export interface RenderHit { + /** Backend render datum used while resolving physical hits; not semantic identity. */ + datum: Record; + endDatum?: Record; + /** All renderer datums in the same line/area path, when available. */ + pathData?: readonly Record[]; + source: 'mark' | 'legend-item'; + markType?: string; + markName?: string; + layerRole?: string; +} + +/** + * Backend-independent meaning and provenance of one resolved chart element. + * Consumers should reason from `value` and `records`, never from renderer metadata. + * Exact render lookup belongs to the backend and may map one element to many primitives. + */ +export interface SemanticElement { + /** Values represented by the mark's channels, or by a semantic control such as a legend item. */ + value: Record; + /** Contributing input records when provenance is available; zero or many may support one value. */ + records?: readonly Record[]; +} + +export type LegendDomain = + | { kind: 'value'; value: unknown } + | { kind: 'interval'; start?: number; end?: number }; + +export interface LegendTargetValue extends Record { + channel?: string; + field?: string; + domain: LegendDomain; +} + + export interface AxisTargetValue extends Record { + axis: 'x' | 'y'; + field: string; + value: unknown; + } + +/** A semantic subject: its visual role plus represented values and provenance. */ +export interface SemanticTarget { + visual: { + kind: 'mark' | 'path' | 'region' | 'widget' | 'handle' | 'legend' | 'axis'; + role: string; + }; + elements: readonly SemanticElement[]; +} + +export interface SemanticResolveEvent { + gesture: 'click' | 'hover' | 'rectangle' | 'angular'; + role: string; + hits: readonly RenderHit[]; + legend?: LegendTargetValue; +} + +export interface SemanticResolveContext { + allHits: readonly RenderHit[]; + keyField: string; + categoryField?: string; + seriesField?: string; +} + +export type ChartInteractionResolver = ( + event: SemanticResolveEvent, + context: SemanticResolveContext, +) => SemanticTarget | null; + +export type UpdateDomain = readonly [unknown, unknown]; + +export interface SemanticTargetRef { + visual: SemanticTarget['visual']; + elements: readonly SemanticElement[]; +} + +export interface SemanticTargetSelector { + select: { + key: Record; + visual?: Partial; + }; +} + +export type UpdateTarget = SemanticTargetRef | SemanticTargetSelector; + +export type AnnotationConnection = + | 'center' + | 'top' + | 'right' + | 'bottom' + | 'left' + | 'value-end' + | 'value-side' + | 'segment-midpoint' + | 'radial-midpoint' + | 'outer-radial'; + +export interface AnnotationConnectorAnchor { + role: string; + connection: AnnotationConnection; + valueAxis?: 'x' | 'y'; +} + +export interface AnnotationCandidate { + connection: AnnotationConnection; + valueAxis?: 'x' | 'y'; + crossSide?: 'start' | 'end'; + valueInset?: number; + anglePreference?: 'normal' | 'oblique'; + textAlign?: 'left' | 'center' | 'right'; + connector?: 'line' | 'none'; + maxWidth?: number; + maxDistance?: number; + priority?: number; + connectorAnchors?: readonly AnnotationConnectorAnchor[]; +} + +export interface AnnotationSpec { + text?: string; + candidates?: readonly AnnotationCandidate[]; + subject?: Partial; +} + +export interface StyleSpec { + visible?: boolean; + opacity?: number; + fill?: string; + stroke?: string; + strokeWidth?: number; + state?: 'normal' | 'focused' | 'emphasized' | 'muted'; + mutedOpacity?: number; +} + +export interface OverlayStyleSpec { + fill?: string; + fillOpacity?: number; + stroke?: string; + strokeWidth?: number; + strokeDash?: readonly number[]; + opacity?: number; + pointRadius?: number; + fontSize?: number; + fontWeight?: number | 'normal' | 'bold'; + textAlign?: 'start' | 'middle' | 'end'; + dx?: number; + dy?: number; +} + +export type OverlayMark = 'line' | 'point' | 'rule' | 'rect' | 'text'; + +export interface OverlayFieldEncoding { + field: string; +} + +/** A retained visual projected through an existing plot's scales. */ +export interface ChartOverlaySpec { + mark: OverlayMark; + data: { values: readonly Record[] }; + encodings: { + x: OverlayFieldEncoding; + y: OverlayFieldEncoding; + x2?: OverlayFieldEncoding; + y2?: OverlayFieldEncoding; + order?: OverlayFieldEncoding; + color?: OverlayFieldEncoding; + text?: OverlayFieldEncoding; + }; + role: string; + interactive?: boolean; + projectable?: boolean; + style?: OverlayStyleSpec; +} + +export interface FreeformOverlayTransform { + translate?: { x: number; y: number }; + scale?: number | { x: number; y: number }; + rotate?: number; +} + +/** SVG markup is serializable; SVGElement supports local application components. */ +export interface FreeformSvgBody { + type: 'svg'; + content: string | SVGElement; + transform?: FreeformOverlayTransform; +} + +export interface FreeformCloneBody { + type: 'clone'; + targets: readonly UpdateTarget[]; + transform?: FreeformOverlayTransform; + opacity?: number; +} + +export type FreeformOverlayBody = + | FreeformSvgBody + | FreeformCloneBody; + +/** Named renderer-space presentation, separate from data/scale overlays. */ +export interface FreeformOverlaySpec { + coordinateSpace: 'plot' | 'renderer'; + body: readonly FreeformOverlayBody[]; +} + +export type ChartUpdateOp = + | { + op: 'set-style'; + targets: readonly UpdateTarget[]; + value: StyleSpec; + } + | { + op: 'set-annotation'; + target: UpdateTarget; + value: AnnotationSpec | null; + } + | { + op: 'set-viewport'; + axes: 'x' | 'y' | 'xy'; + value: { x?: UpdateDomain; y?: UpdateDomain }; + } + | { + op: 'set-order'; + scope: 'category' | 'series' | 'facet'; + field: string; + values: readonly unknown[]; + } + | { + op: 'set-overlay'; + name: string; + value: ChartOverlaySpec | null; + } + | { + op: 'set-freeform-overlay'; + name: string; + value: FreeformOverlaySpec | null; + } + | { + op: 'set-data'; + source: 'main'; + value: { rows: readonly Record[] }; + }; + +export interface ChartUpdate { + id: string; + ops: readonly ChartUpdateOp[]; +} + +export interface NavigationDomainGuard { + minVisibleFraction: number; + maxVisibleFraction: number; + overscrollFraction: number; +} + +export interface NavigationRequest { + type?: 'navigation'; + phase: 'start' | 'preview' | 'commit' | 'cancel'; + operation: 'pan' | 'zoom' | 'reset'; + axes: 'x' | 'y' | 'xy'; + delta?: { x: number; y: number }; + factor?: number; + anchor?: { x: number; y: number }; +} + +export type NavigationUpdate = Extract; + +export interface InteractionContext { + readonly chartType: string; + readonly selected: readonly SemanticElement[]; + readonly available?: readonly SemanticElement[]; + readonly resolveGroupValue?: (element: SemanticElement) => unknown; + readonly resolveNavigation?: ( + request: NavigationRequest, + guard: NavigationDomainGuard, + ) => NavigationUpdate | null; + readonly categoryField?: string; + readonly seriesField?: string; + readonly legendDomains?: Readonly>; + readonly categoryAxis?: 'x' | 'y'; + readonly categoryOrder?: readonly unknown[]; + readonly reorderAxes?: readonly { + axis: 'x' | 'y'; + field: string; + order: readonly unknown[]; + }[]; +} + +export type ChartUpdatePresenter = ( + update: ChartUpdate, + context: InteractionContext, +) => ChartUpdate; \ No newline at end of file diff --git a/packages/flint-js/src/core/interaction-semantics.ts b/packages/flint-js/src/core/interaction-semantics.ts new file mode 100644 index 00000000..c4f717f5 --- /dev/null +++ b/packages/flint-js/src/core/interaction-semantics.ts @@ -0,0 +1,195 @@ +import type { + RenderHit, + SemanticElement, + SemanticResolveContext, + SemanticResolveEvent, + SemanticTarget, +} from './interaction-contracts'; + +export type { + ChartInteractionResolver, + AxisTargetValue, + RenderHit, + LegendDomain, + LegendTargetValue, + SemanticElement, + SemanticResolveContext, + SemanticResolveEvent, + SemanticTarget, +} from './interaction-contracts'; + +export type SemanticVisualFamily = 'legend' | 'axis' | 'facet' | 'annotation' | 'element'; + +export function semanticVisualFamily(role: string | undefined): SemanticVisualFamily { + if (role?.startsWith('legend-')) return 'legend'; + if (role?.startsWith('axis-')) return 'axis'; + if (role?.startsWith('facet-')) return 'facet'; + if (role?.startsWith('annotation')) return 'annotation'; + return 'element'; +} + +/** Neutral hover ink that blends with the mark instead of reading as a hard outline. */ +export const MUTED_HOVER_STROKE = 'rgba(71, 82, 92, 0.58)'; +export const MUTED_HOVER_FILL = '#eef1f3'; + +const renderKeysByElement = new WeakMap(); + +export function semanticElementRenderKeys(element: SemanticElement): readonly string[] { + return renderKeysByElement.get(element) ?? []; +} + +export function associateSemanticElementRenderKeys( + element: SemanticElement, + renderKeys: readonly string[], +): SemanticElement { + renderKeysByElement.set(element, [...new Set(renderKeys)]); + return element; +} + +function withoutRenderIdentity( + datum: Record, + keyField: string, +): Record { + return Object.fromEntries(Object.entries(datum).filter(([field]) => + field !== keyField && !field.startsWith('__flint_interaction_') && field !== '_vgsid_')); +} + +export function sourceRecordsForRenderedRecords( + renderedRecords: readonly Record[], + sourceRecords: readonly Record[], + provenanceFields: readonly string[], + temporalFields: readonly string[] = [], + rangeProvenance: readonly { + field: string; + startField: string; + endField: string; + }[] = [], +): readonly Record[] { + const temporal = new Set(temporalFields); + const temporalValue = (value: unknown): number | undefined => { + if (value instanceof Date) return value.getTime(); + if (typeof value === 'number') { + return Number.isInteger(value) && value >= 1000 && value <= 9999 + ? Date.UTC(value, 0, 1) + : value; + } + if (typeof value !== 'string') return undefined; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : parsed; + }; + const sameValue = (field: string, left: unknown, right: unknown): boolean => { + if (Object.is(left, right)) return true; + if (!temporal.has(field)) return false; + const leftTime = temporalValue(left); + const rightTime = temporalValue(right); + return leftTime !== undefined && rightTime !== undefined && leftTime === rightTime; + }; + return sourceRecords.filter((sourceRecord) => renderedRecords.some((renderedRecord) => { + const sourceFields = provenanceFields.filter((field) => field in sourceRecord); + const equalityMatches = provenanceFields.length === 0 || (sourceFields.length > 0 + && sourceFields.every((field) => + field in renderedRecord && sameValue(field, sourceRecord[field], renderedRecord[field]))); + if (!equalityMatches) return false; + return rangeProvenance.every(({ field, startField, endField }) => { + const value = sourceRecord[field]; + const start = renderedRecord[startField]; + const end = renderedRecord[endField]; + if (typeof value !== 'number' || typeof start !== 'number' || typeof end !== 'number') return false; + return value >= start && value < end; + }); + })); +} + +export function elementsFromHits(hits: readonly RenderHit[], keyField: string): SemanticElement[] { + const seen = new Set(); + const elements: SemanticElement[] = []; + for (const hit of hits) { + const key = hit.datum[keyField]; + if (typeof key !== 'string' || seen.has(key)) continue; + seen.add(key); + const records = (hit.endDatum ? [hit.datum, hit.endDatum] : [hit.datum]) + .map((datum) => withoutRenderIdentity(datum, keyField)); + elements.push(associateSemanticElementRenderKeys({ + value: withoutRenderIdentity(hit.datum, keyField), + records, + }, [key])); + } + return elements; +} + +export function fieldsFromEncodingChannels( + resolvedEncodings: Readonly>, + channels: readonly string[], + additionalFields: readonly string[] = [], +): string[] { + return [...new Set([ + ...channels.map((channel) => resolvedEncodings[channel]?.field).filter(Boolean), + ...additionalFields, + ])]; +} + +export function firstDiscreteEncodingField( + resolvedEncodings: Readonly>, + channels: readonly string[], +): string | undefined { + return channels + .map((channel) => resolvedEncodings[channel]) + .find((encoding) => encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal')) + ?.field; +} + +export function legendMatchedHits( + event: SemanticResolveEvent, + context: SemanticResolveContext, + field: string, +): RenderHit[] { + const domain = event.legend?.domain; + if (!domain) return []; + const matches = (datum: Record): boolean => { + if (domain.kind === 'value') return datum[field] === domain.value; + const rawValue = datum[field]; + const value = rawValue instanceof Date ? rawValue.getTime() : rawValue; + return typeof value === 'number' + && (domain.start === undefined || value >= domain.start) + && (domain.end === undefined || value < domain.end); + }; + return context.allHits + .filter((hit) => matches(hit.datum)) + .flatMap((hit) => { + const pathData = (hit.markType === 'line' || hit.markType === 'area') + && Array.isArray(hit.pathData) + ? hit.pathData.filter(matches) + : []; + return pathData.length > 0 + ? [ + { ...hit, source: 'legend-item' as const }, + ...pathData.map((datum) => ({ ...hit, datum, source: 'legend-item' as const })), + ] + : [{ ...hit, source: 'legend-item' as const }]; + }); +} + +export function targetFromHits( + hits: readonly RenderHit[], + keyField: string, + visual: SemanticTarget['visual'], +): SemanticTarget | null { + const elements = elementsFromHits(hits, keyField); + return elements.length > 0 ? { visual, elements } : null; +} + +export function resolveSeriesTarget( + event: SemanticResolveEvent, + context: SemanticResolveContext, + seriesField: string | undefined, +): SemanticTarget | null { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + const markType = event.hits[0]?.markType; + return targetFromHits(hits, context.keyField, { + kind: markType === 'line' || markType === 'area' ? 'path' : 'mark', + role: event.role === 'legend-item' ? 'legend-item' : markType ?? event.role, + }); +} diff --git a/packages/flint-js/src/core/static-series.ts b/packages/flint-js/src/core/static-series.ts index 6875359a..c341828c 100644 --- a/packages/flint-js/src/core/static-series.ts +++ b/packages/flint-js/src/core/static-series.ts @@ -59,6 +59,19 @@ export function normalizeEncodingShorthand( return out; } +/** Treat color and group as equivalent series bindings for grouped bars. */ +export function normalizeChartEncodingAliases( + chartType: string, + encodings: Record, +): Record { + if (chartType !== 'Grouped Bar Chart' || encodings.group || !encodings.color) { + return encodings; + } + const normalized: Record = { ...encodings, group: encodings.color }; + delete normalized.color; + return normalized; +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -89,10 +102,14 @@ export function normalizeStaticSeries( rawEncodings: Record, data: any[], semanticTypes: Record, + chartType = '', ): NormalizeStaticSeriesResult { // Expand bare-string channel shorthands (e.g. `{ x: "weight" }`) first so // the rest of the pipeline only ever sees full encoding objects. - const encodings = normalizeEncodingShorthand(rawEncodings); + const encodings = normalizeChartEncodingAliases( + chartType, + normalizeEncodingShorthand(rawEncodings), + ); // Find array-valued channels const arrayChannels: { channel: string; entries: ChartEncoding[] }[] = []; diff --git a/packages/flint-js/src/core/theme/ground.ts b/packages/flint-js/src/core/theme/ground.ts index 6cdbd7ae..85c5c282 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; } } @@ -1715,6 +1698,8 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe const padding = isPaintedSurface(canvas) ? Math.max(densityPadding, Math.round((axisLabelText.fontSize ?? 10) * 1.5)) : densityPadding; + const selectionBoundary = theme.interaction?.selectionBoundary; + const selectionBoundaryWidth = Math.max(0, selectionBoundary?.width ?? 1.25); return { themeId: theme.id ?? 'flint', @@ -1754,7 +1739,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. @@ -1768,11 +1752,29 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe } : undefined, marks, + interaction: { + continuousColorFocus: { + mutedFill: mixHex(plot ?? canvas, text.primary, 0.08, '#eeeeee'), + boundaryWidth: Math.min(selectionBoundaryWidth, 0.8), + boundaryOpacity: 0.42, + haloWidth: Math.min(Math.max(0, selectionBoundary?.haloWidth ?? 2.5), 1.25), + haloOpacity: 0.18, + }, + selectionBoundary: { + color: selectionBoundary?.color ?? theme.ink?.accent ?? text.primary, + width: selectionBoundaryWidth, + opacity: clamp(selectionBoundary?.opacity ?? 0.68, 0, 1), + haloColor: selectionBoundary?.haloColor ?? plot ?? canvas, + haloWidth: Math.max(0, selectionBoundary?.haloWidth ?? 2.5), + haloOpacity: clamp(selectionBoundary?.haloOpacity ?? 0.35, 0, 1), + }, + }, facets, layout: { 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..102c0777 100644 --- a/packages/flint-js/src/core/theme/types.ts +++ b/packages/flint-js/src/core/theme/types.ts @@ -601,6 +601,20 @@ export interface ThemeCompileDefaults extends Partial { canvasSize?: { width: number; height: number }; } +export interface ThemeInteraction { + tooltipFormat?: string; + /** Paint for the exterior of a contiguous semantic selection. */ + selectionBoundary?: { + color?: string; + width?: number; + opacity?: number; + haloColor?: string; + /** Set to zero to disable the contrast halo. */ + haloWidth?: number; + haloOpacity?: number; + }; +} + /** * Level 1. One JSON document per design language. * @@ -634,7 +648,7 @@ export interface ThemeSpec { geometry?: ThemeGeometry; chartDefaults?: ThemeChartDefaults; compileDefaults?: ThemeCompileDefaults; - interaction?: { tooltipFormat?: string }; + interaction?: ThemeInteraction; variants?: ThemeVariant[]; } @@ -802,11 +816,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 @@ -921,6 +930,23 @@ export interface DesignDecisions { size: number; }; marks: ResolvedMarks; + interaction: { + continuousColorFocus: { + mutedFill: string; + boundaryWidth: number; + boundaryOpacity: number; + haloWidth: number; + haloOpacity: number; + }; + selectionBoundary: { + color: string; + width: number; + opacity: number; + haloColor: string; + haloWidth: number; + haloOpacity: number; + }; + }; facets: { header: { show: boolean; fieldTitle: boolean } & ResolvedText; panelFrame: boolean; @@ -928,11 +954,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..1745dc41 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[]; } /** @@ -885,6 +901,18 @@ export interface ChartTemplateDef { /** Which encoding channels are available for this chart */ channels: string[]; + /** Cartesian positional channels whose continuous domains may be navigated at runtime. */ + navigation?: { + axes?: readonly ('x' | 'y')[]; + }; + + /** Whether authored categorical position axes support runtime domain reorder. */ + reorder?: false | { + axes?: readonly ('x' | 'y')[]; + includeConnectiveMarks?: boolean; + markTypes?: readonly string[]; + }; + /** * How the primary mark encodes its quantitative value. * Determines zero-baseline, scale tightness, and compression behavior. @@ -897,6 +925,39 @@ export interface ChartTemplateDef { */ markCognitiveChannel: MarkCognitiveChannel; + /** Template-owned semantic resolution and chart-specific presentation. */ + semanticInteractions?: (context: { + resolvedEncodings: Readonly>; + }) => { + fields: string[]; + provenanceFields?: readonly string[]; + temporalProvenanceFields?: readonly string[]; + rangeProvenance?: readonly { field: string; startField: string; endField: string }[]; + categoryField?: string; + seriesField?: string; + resolveGroupValue?: (element: import('./interaction-contracts').SemanticElement) => unknown; + reorderAxis?: { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean; markTypes?: readonly string[] }; + reorderAxes?: readonly { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean; markTypes?: readonly string[] }[]; + legendFields?: Record; + selectableMarks: string[]; + /** Backend marktype to anchor annotations to when one key matches several marks. */ + annotationMarkType?: string; + supportedRegionGestures?: ('cartesian' | 'angular')[]; + renderHoverStyles?: Record; + renderSelectionStyles?: Record; + resolve: import('./interaction-contracts').ChartInteractionResolver; + presentUpdate: import('./interaction-contracts').ChartUpdatePresenter; + }; + /** * Phase 1a: Declare layout intent. * Runs BEFORE layout computation. @@ -972,6 +1033,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/README.md b/packages/flint-js/src/docs/README.md index c5f00036..41ed0648 100644 --- a/packages/flint-js/src/docs/README.md +++ b/packages/flint-js/src/docs/README.md @@ -669,6 +669,8 @@ channels. This decouples user/AI intent from rendering specifics. First-class channel for grouped bar charts. The analysis stage resolves its semantics (type, color scheme) without any VL knowledge. The grouping axis is auto-detected: whichever of `x`/`y` is discrete gets subdivided. +For a Grouped Bar Chart, `color` is an equivalent alias for `group`; when only +`color` is supplied, Flint canonicalizes it to `group` before analysis. During instantiation, `buildVLEncodings()` translates: - `group` → VL `color` encoding (for coloring) 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..5ddd0f95 100644 --- a/packages/flint-js/src/echarts/assemble.ts +++ b/packages/flint-js/src/echarts/assemble.ts @@ -143,7 +143,7 @@ export function assembleECharts(input: ChartAssemblyInput): any { // ═══════════════════════════════════════════════════════════════════════ const rawData = input.data.values ?? []; const normalized = normalizeStaticSeries( - input.chart_spec.encodings, rawData, semanticTypes, + input.chart_spec.encodings, rawData, semanticTypes, chartType, ); let data = normalized.data; const staticSeries = normalized.staticSeries; @@ -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/excel/assemble.ts b/packages/flint-js/src/excel/assemble.ts index 0b26b049..608d2aa6 100644 --- a/packages/flint-js/src/excel/assemble.ts +++ b/packages/flint-js/src/excel/assemble.ts @@ -29,6 +29,7 @@ import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-se import { detectBandedAxisFromSemantics } from '../core/axis-detection'; import { computeChannelBudgets, deriveStretchCaps, resolveBaseSize } from '../core/compute-layout'; import { filterOverflow } from '../core/filter-overflow'; +import { normalizeChartEncodingAliases } from '../core/static-series'; import { formatSpecToExcel } from './chart-types'; import { excelGetTemplateDef } from './templates'; import type { @@ -191,7 +192,10 @@ export function assembleExcel(input: ChartAssemblyInput): ExcelChartSpec { const flintType = input.chart_spec.chartType; const semanticTypes = input.semantic_types ?? {}; const rawData: any[] = input.data.values ?? []; - const encodings = normalizeEncodings(input.chart_spec.encodings); + const encodings = normalizeChartEncodingAliases( + flintType, + normalizeEncodings(input.chart_spec.encodings), + ); // ── Phase 0 (reused core): resolve per-channel semantics ──────────────── let convertedData = convertTemporalData(rawData, semanticTypes); 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..80c9ff59 --- /dev/null +++ b/packages/flint-js/src/image-charts/assemble.ts @@ -0,0 +1,380 @@ +// 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 { normalizeChartEncodingAliases } from '../core/static-series'; +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 = normalizeChartEncodingAliases( + flintType, + 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/README.md b/packages/flint-js/src/interactive/README.md new file mode 100644 index 00000000..e6f70979 --- /dev/null +++ b/packages/flint-js/src/interactive/README.md @@ -0,0 +1,995 @@ +# Interaction Event And Update Architecture + +## Status + +The canvas-acquisition, semantic-resolution, external-dispatch, preset, and unified +`ChartUpdate` paths are implemented. Vega-Lite emits public `CanvasInteractionEvent` +payloads and renders declarative updates. Canvas gesture state owns preview, commit, +and cancel behavior; external payloads invoke their bound handlers directly. Complete +presentation-property coverage and semantic interaction runtimes for other backends +remain planned. + +## Goal + +Separate interaction input, semantic resolution, handling, chart updates, and renderer presentation so that: + +- canvas gestures and external application payloads invoke different handler contracts + that produce the same update language; +- resolved canvas interactions are useful whether or not a built-in update runs; +- chart resolution reports only the physical semantic unit that produced an internal event; +- an interaction handler decides what semantic cohort to act on, including chart-specific behavior; +- chart definitions decide how semantic updates should be presented; +- runtimes apply presented updates mechanically; +- resolved semantic events are emitted to the host application with stable chart identity; +- applications can bind transport-neutral payload handlers without understanding + renderer structure; +- precomputed renderer-neutral updates remain directly applicable as chart state. + +## Developer Quick Start + +Configure a canvas interaction with a reusable handler: + +```ts +import { + buildInteractiveChart, + clickTrigger, + externalInteraction, + type CanvasInteractionDef, +} from 'flint-chart/interactive'; + +const selectCountry: CanvasInteractionDef = { + id: 'select-country', + eventSource: clickTrigger, + handle: (event) => event.target ? { + id: 'country-selection', + ops: [{ + op: 'set-style', + targets: [event.target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], + } : null, +}; + +const surface = buildInteractiveChart(container, input, { + backend: 'vegalite', + interactions: [selectCountry], +}); + +await surface.ready; +``` + +Bind application input independently of its transport: + +```ts +const countryPicker = externalInteraction<{ country: string; selected: boolean }>({ + id: 'country-picker', + handle: ({ country, selected }) => ({ + id: 'country-selection', + ops: [{ + op: 'set-style', + targets: selected ? [{ select: { key: { Country: country } } }] : [], + value: { state: selected ? 'emphasized' : 'normal' }, + }], + }), +}); + +const surface = buildInteractiveChart(container, input, { + backend: 'vegalite', + interactions: [countryPicker], +}); + +const result = await surface.dispatch('country-picker', { + country: 'Japan', + selected: true, +}); + +if (result && result.status !== 'applied') { + console.warn(result.unresolvedTargets, result.unsupportedOps); +} +``` + +The selector is equality-only and each field must be declared by the compiled ChartDef. +Use an event-derived target when exact visual identity matters. Always inspect +`ChartUpdateResult` for externally supplied selectors because current data may no longer +contain the requested key. + +The surface API is intentionally small: + +| API | Purpose | +|---|---| +| `flint-interaction` event | Receive resolved canvas actions | +| `applyUpdate(update)` | Apply precomputed retained chart state by ID | +| `setUpdates(updates)` | Replace the retained update collection | +| `clearUpdate(id)` | Remove one retained update | +| `dispatch(interactionId, payload)` | Invoke an external handler and report its update result | +| `destroy()` | Remove listeners, renderer state, and DOM | + +## Cross-chart routing + +Emission is universal and distributed: `flint-interaction` bubbles from every configured +canvas interaction. Acceptance is explicit: each destination registers an external +interaction, and the application chooses destinations by dispatching its semantic payload. + +```ts +dashboard.addEventListener('flint-interaction', (nativeEvent) => { + const detail = (nativeEvent as CustomEvent).detail; + const selection = deriveSelection(detail.event); + + for (const [chartId, surface] of dashboardSurfaces) { + if (chartId === detail.chartId) continue; + void surface.dispatch('linked-selection', { selection }); + } +}); +``` + +Charts do not automatically consume events from neighboring charts. The dashboard, +story, or editor owns its cross-chart topology and semantic mapping. This coordinator is +scoped to that composition, not a global singleton. Each destination's +`externalInteraction({ id: 'linked-selection', handle })` maps the shared payload to +targets meaningful for that chart. + +## Pipeline + +```mermaid +flowchart LR + S[Canvas eventSource] --> B[Backend mount] + A[Raw browser or renderer event] --> B + B --> C[Gesture recognizer] + C --> N[Navigation event] + N --> F[Interaction coordinator] + C --> R[Backend hit adapter] + R --> D[ChartDef resolve] + D --> E[Semantic event] + E --> F[Interaction coordinator] + F --> G[flint-interaction transport] + F -. optional .-> H[Canvas handler] + X[External payload] --> D2[Surface dispatch by interaction ID] + D2 --> EH[External handler] + A2[Precomputed ChartUpdate] --> U[Update target resolution] + H --> I[ChartUpdate] + EH --> I + I --> U + U --> J[ChartDef presentUpdate] + J --> K[Renderer runtime] +``` + +The normative ownership boundary is: + +| Stage | Owner | Input | Output | Must not own | +| --- | --- | --- | --- | --- | +| 1. Declare interpretation | Interaction `eventSource` | Author configuration | Element, region, or navigation source descriptor | Renderer geometry or semantic meaning | +| 2. Capture gesture | Backend mount + shared recognizer | Source descriptor and native events | Renderer-neutral point or region geometry | Gesture inference, semantic meaning, or chart updates | +| 3. Resolve physical hits | Backend hit adapter | Normalized geometry and renderer state | `RenderHit[]` | Chart-type meaning or handler decisions | +| 4. Resolve semantics | ChartDef resolver | Gesture context and `RenderHit[]` | Physical `SemanticTarget` | Handler decisions or cohort expansion | +| 5. Coordinate | Interaction coordinator | Resolved semantic or navigation event | Canonical outbound event and optional handler invocation | Chart-specific semantic meaning | +| 6. Decide update | Bound canvas or external handler | Resolved canvas event or opaque external payload | `ChartUpdate` | Renderer-specific presentation | +| 7. Resolve update | Coordinator + compiled semantic index | Public refs/selectors | Current semantic elements | Product relationships or approximate matching | +| 8. Present update | ChartDef `presentUpdate` | `ChartUpdate` | Chart-specific presented update | Renderer mutation | +| 9. Apply update | Renderer runtime | Presented update | Renderer state | Semantic inference or handler decisions | + +Navigation deliberately takes a shorter resolution path. It controls a continuous +viewport rather than a semantic chart element, so it does not fabricate `RenderHit[]` +or a `SemanticTarget`; the coordinator still emits its normalized public event before +invoking an optional handler. + +ChartDef resolves and presents chart semantics. It does **not** own DOM transport. The coordinator emits resolved semantic events externally because transport identity (`chartId`, `interactionId`, and transaction metadata) is surface-level state, not chart semantics. + +An internal event follows this call sequence: + +```mermaid +sequenceDiagram + participant Browser as Browser/Vega + participant Mount as Backend mount + participant Gesture as Gesture recognizer + participant Hits as Backend hit adapter + participant ChartDef as ChartDef.resolve + participant Coordinator + participant Host as Host observer + participant Handler as Interaction handle + participant Present as ChartDef.presentUpdate + participant Runtime as Renderer runtime + + Browser->>Mount: native event + Mount->>Gesture: configured eventSource + pointer stream + Gesture-->>Mount: normalized gesture geometry + Mount->>Hits: geometry + renderer state + Hits-->>Coordinator: Element/Region event with physical hits + Coordinator->>ChartDef: normalized internal event + ChartDef-->>Coordinator: physical SemanticTarget + Coordinator-->>Host: flint-interaction semantic event + opt configured handler + Coordinator->>Handler: resolved event + Handler-->>Coordinator: ChartUpdate + Coordinator->>Coordinator: resolve update targets + Coordinator->>Present: ChartUpdate + Present-->>Coordinator: presented update + Coordinator->>Runtime: apply presented update + end +``` + +An external payload follows a shorter input path while sharing update processing: + +```mermaid +sequenceDiagram + participant Host as Application + participant Surface + participant Handler as Bound external handler + participant Index as Compiled semantic index + participant Present as ChartDef.presentUpdate + participant Runtime as Renderer runtime + + Host->>Surface: dispatch(interactionId, payload) + Surface->>Handler: payload + InteractionContext + Handler-->>Surface: ChartUpdate or null + Surface->>Index: resolve refs and key selectors + Index-->>Surface: current semantic elements + Surface->>Present: ChartUpdate + Present-->>Surface: presented update + Surface->>Runtime: apply presented update + Surface-->>Host: ChartUpdateResult or null +``` + +## Backward Semantic Resolution + +Internal interaction depends on a reversible path from authored chart semantics to rendered geometry and back. SVG nodes and Vega scenegraph items know about marks, bounds, and renderer data, but they do not inherently know which Flint semantic element they represent. Flint establishes that connection automatically during chart assembly, before rendering. + +This instrumentation is compiler-owned. The chart author declares semantic fields and interactions; they do not create hidden key columns, maintain selection parameters, or wire renderer predicates into every mark. The compiler derives the required identity metadata from the ChartDef and instruments generated marks consistently. By comparison, a direct Vega-Lite workflow generally requires the spec author to define selection parameters and connect them to mark encodings or transforms. Flint keeps that renderer bookkeeping out of the authored chart, so an agent or application can reason in terms of semantic elements rather than reconstructing scenegraph identity itself. + +At a high level, this resembles automatic differentiation: PyTorch instruments a forward computation so its runtime can traverse it backward without requiring users to maintain derivatives by hand. Flint instruments forward chart compilation so its runtime can traverse a rendered hit backward without requiring users to maintain selection keys or renderer-to-data mappings by hand. Flint performs semantic resolution rather than numerical differentiation, but the shared architectural idea is automatic, system-maintained provenance. + +```mermaid +flowchart LR + A[ChartDef semantic fields] --> B[Interaction instrumentation] + B --> C[Compiled renderer datum] + C --> D[SVG or scenegraph item] + D --> E[Physical RenderHit] + E --> F[ChartDef resolve] + F --> G[SemanticTarget] +``` + +### Instrumentation + +For each interactive mark, the compiler derives a stable key from the ChartDef's semantic identity fields and writes it into the renderer datum as `__flint_interaction_key`. The key survives Vega-Lite compilation and is therefore available on the rendered scenegraph item. It is private generated state, not part of the user's data contract. + +Generated semantic representations may also declare provenance. A generated text label declares: + +```ts +interface InteractionProvenance { + role: 'text-label'; + identity: 'inherit' | { fields: readonly string[] }; + presentation: 'on-mark' | 'independent'; +} +``` + +`identity` determines which semantic key the representation receives. Most value labels inherit the mark identity. An aggregate label can name a smaller field set; for example, a rose category label may identify only its category even though each arc is identified by category and series. + +Instrumentation lowers the generation-time provenance into runtime datum metadata: + +```ts +__flint_interaction_key // which semantic data identity this item represents +__flint_interaction_role // which kind of representation produced the hit +``` + +Generation-time provenance is removed before Vega compilation. The lowered datum fields are the bridge through compilation because Vega preserves them on scenegraph items. + +### Physical Hit Normalization + +The renderer trigger locates the SVG or scenegraph item under a pointer, reads its instrumented datum, and emits a renderer-neutral `RenderHit`. The trigger may report mark type, mark name, bounds, path geometry, and representation role, but it does not assign chart meaning. + +Text is deliberately inert unless its datum carries the `text-label` role. This prevents titles, axis text, and unrelated annotations from becoming selectable merely because they share chart data. + +The interaction key and role answer different questions: + +| Metadata | Question | Used for | +| --- | --- | --- | +| `__flint_interaction_key` | Which rendered primitive is this? | Private stable lookup for backend presentation updates | +| `__flint_interaction_role` | Which representation of that identity was hit? | Choosing representation-aware resolution behavior | + +### ChartDef Resolution + +The normalized role and `RenderHit[]` are passed to the owning ChartDef resolver. The resolver converts renderer facts into a `SemanticTarget`; this is the backward boundary where renderer-specific items become semantic elements. + +For a direct mark, resolution commonly maps each hit key to one `SemanticElement`. A representation can require different resolution even when it refers to related data. For example, clicking one rose arc resolves that arc, while clicking a `text-label` for January can resolve the January label identity to every arc represented by that aggregate label. + +The role is resolution context rather than semantic identity. Private render keys remain in a backend sidecar so the ChartDef and applications do not need to carry renderer identity. Normal marks can use the default `mark` role, while representations such as `text-label` require an explicit role when their backward mapping differs. + +The result contains no SVG node or Vega scenegraph item: + +```ts +interface SemanticTarget { + visual: { + kind: 'mark' | 'path' | 'region' | 'widget' | 'handle' | 'legend'; + role: string; + }; + elements: readonly SemanticElement[]; +} + +interface SemanticElement { + value: Record; + records?: readonly Record[]; +} +``` + +`value` is the represented transformed or aggregated chart value. It may contain derived +semantics such as stack or path endpoints. `records` are authored source rows only when +the runtime can prove their lineage; they are omitted rather than replaced with renderer +tuples when provenance is unavailable. Exact render identity is backend-private and can +map one semantic element to one or many rendered primitives. +After this boundary, presets and applications operate on semantic elements. They do not +inspect renderer geometry to rediscover meaning. + +## Acquisition Events + +Backends normalize physical input into these internal acquisition events. External +payloads do not enter this acquisition language because callers have already identified +the interaction and its semantic payload. + +```ts +type InteractionPhase = 'start' | 'preview' | 'commit' | 'cancel'; + +interface ElementInteractionEvent { + type: 'element'; + phase: 'preview' | 'commit' | 'cancel'; + hits: readonly RenderHit[]; + point?: PlotPoint; + modifiers?: InteractionModifiers; +} + +interface RegionInteractionEvent { + type: 'region'; + phase: InteractionPhase; + region: PlotRect | PlotPolygon; + hits: readonly RenderHit[]; + match: 'intersect' | 'contain'; + modifiers?: InteractionModifiers; +} + +interface NavigationInteractionEvent { + type: 'navigation'; + phase: InteractionPhase; + operation: 'pan' | 'zoom' | 'reset'; + axes: 'x' | 'y' | 'xy'; + delta?: PlotPoint; // plot fractions + factor?: number; + anchor?: PlotPoint; // plot fractions +} + +``` + +`Element` and `Region` describe physical chart input at the geometry level. They may contain coordinates, region geometry, rendered mark metadata, and data records in `RenderHit[]`, but they do not claim semantic meaning. `Navigation` describes a viewport transform in plot fractions and likewise carries no semantic target. External payloads bypass acquisition events and enter through their bound external handler. + +## Semantic Resolution + +Only internal Element and Region events are resolved. The owning ChartDef resolver is observational and data-driven. It answers what physical visual/data unit produced the event, not what should happen because of it. + +```ts +interface SemanticInteractionEvent { + type: 'semantic'; + source: 'element' | 'region'; + phase: InteractionPhase; + target: SemanticTarget | null; + point?: PlotPoint; + region?: PlotRect | PlotPolygon; + modifiers?: InteractionModifiers; +} +``` + +For a dumbbell, hover resolves the physical endpoint under the pointer. A direct click resolves +the complete category unit with its connector first and both endpoints following, so emphasis and +annotation share one semantic subject. + +After resolution, the coordinator constructs the semantic event, emits it through +`flint-interaction`, and invokes the matching interaction handler when configured. External +emission is therefore downstream of ChartDef resolution but is not performed by +ChartDef and does not depend on a chart update. + +The current `SemanticInteractionEvent` and `NavigationInteractionEvent` are internal +ingredients. Outbound transport uses one public resolved event shape: + +```ts +interface CanvasInteractionEvent { + action: CanvasInteractionAction; + phase: InteractionPhase; + operation?: InteractionOperation; + geometry: { + plot?: PlotGeometry; + domain?: DomainGeometry; + }; + target: SemanticTarget | null; + dropTarget?: SemanticTarget | null; + modifiers?: InteractionModifiers; +} +``` + +`action` reports the normalized semantic action, such as `click-element`, +`click-legend`, `brush-x`, `pan-viewport`, or `inspect-xy`. `geometry.plot` reports +renderer-neutral canvas geometry; optional `geometry.domain` reports scale-inverted +values. `target` reports the semantic object and data provenance. Drag-and-drop also +uses `dropTarget` for its destination. + +Current Vega-Lite acquisition emits element, legend, region/brush, and navigation +actions. The broader action union reserves the reviewed shape for inspection, +drag-and-drop, keyboard, axis, facet, and annotation recognizers as they are implemented. +`geometry.domain` is currently omitted; applications must treat it as optional. + +The coordinator emits meaningful lifecycle points: `start`, repeated `preview`, final +`commit`, and `cancel`. High-frequency pointer previews may be coalesced to animation +frames, but they are not reduced to commit-only output. + +## Interaction Handlers + +Canvas and external definitions bind different inputs to the same output language. +A canvas handler consumes a resolved canvas event; an external handler consumes its +application-defined payload. Both may return one `ChartUpdate`. + +```ts +interface CanvasInteractionDef { + readonly id: string; + readonly eventSource: InteractionEventSource; + handle?( + event: CanvasInteractionEvent, + context: InteractionContext, + ): ChartUpdate | null; +} + +interface ExternalInteractionDef { + readonly id: string; + readonly external: true; + handle(payload: TPayload, context: InteractionContext): ChartUpdate | null; +} + +type InteractionDef = CanvasInteractionDef | ExternalInteractionDef; +``` + +Canvas definitions have two declarative halves: + +1. `eventSource` declares **what input to capture and how to interpret it physically**. The same native `pointerdown -> pointermove -> pointerup` stream becomes a free rectangle for `select()`, an axis-constrained interval for `brushX()` or `brushY()`, and an angular sector for `brushAngle()`. +2. Optional `handle()` declares **what update JSON to produce** after normalization. + Target-bearing events first pass through ChartDef semantic resolution. The handler + consumes the same `CanvasInteractionEvent` emitted to applications and returns a + `ChartUpdate` containing renderer-neutral `set-style`, `set-annotation`, + `set-viewport`, or `set-order` operations. + +The backend mount reads `eventSource`; it does not infer a gesture from pointer motion. It installs the required native listeners, supplies renderer coordinates and hit testing, and runs the recognizer requested by the interaction. This keeps an identical drag stream deterministic and author-controlled. + +Transient gesture guides belong to that mount lifecycle, not to `ChartUpdate`. They visualize +the gesture's current geometry and clear on cancel, leave, or destroy. Disabling or styling a +guide never changes acquisition or the emitted semantic event: + +```ts +inspect({ + mode: 'xy', + guide: { + style: { + color: '#47525c', opacity: 0.58, width: 1, + haloColor: '#ffffff', haloOpacity: 0.64, haloWidth: 0.5, + }, + }, +}); + +inspectIndex({ + axis: 'x', + seriesBy: 'Series', + show: 'all', +}); + +brushX({ + guide: { style: { fill: '#2563eb', fillOpacity: 0.1 } }, +}); + +lassoSelect({ guide: false }); +``` + +Inspect lines, Cartesian regions, angular sectors, and lasso paths use the shared +renderer-neutral gesture-guide styles. Retained guides such as reference lines instead belong +to chart presentation state and may be created by effects through chart updates. + +`inspectIndex()` is the general reading preset for line and point charts. A discrete index +snaps to an observed slice; a temporal or quantitative index intersects a line continuously +between observations. Point marks are acquired when the pointer intersects them on the index +axis or comes within the axis-only `tolerance` (a plot-size fraction, default `0.01`). Outside +that bounded assistance radius, the index guide remains but no point value rule is shown. The +index guide and acquired value rules form a crosshair without dimming the chart. `show: 'all'` +returns every series in that slice. `show: 'single'` starts with the first series, while +`show: { series: value }` starts with a preferred series. In either single-series mode, clicking +a legend item switches tracking to that series; marks remain inert and available to other interactions. +The tracked series remains highlighted in its authored colour. Hovering another legend item previews it. +Both single-series policies require the `seriesBy` field. Aggregates such as averages remain custom-handler logic; +the preset does not transform records. Directional predicates on the lower-level +`inspectTrigger()` can support bespoke interactions such as threshold quadrants. + +Chart-specific action processing belongs in the handler. For example, ranged-dot region targets +are expanded to complete category units before producing a `set-style` update. Direct ranged-dot +clicks already resolve to the complete dumbbell in the owning ChartDef. + +The coordinator always emits a resolved canvas event and invokes `handle()` only when +present. External dispatch does not emit or synthesize a canvas event. Updates returned +by either handler enter the same target-resolution, presentation, and renderer pipeline. +This creates four public layers: + +1. predefined observers that acquire and resolve common canvas actions; +2. external definitions binding application payloads to update policies; +3. direct renderer-neutral `ChartUpdateOp` JSON for precomputed state; +4. presets joining common canvas actions to updates. + +Presets compose predefined triggers from `interactive/triggers.ts` with an optional handler. +They refer to reusable descriptors such as `clickTrigger` and `rectangleTrigger()` +rather than defining event acquisition inline. They are convenience APIs, not +architectural primitives or the primary extensibility model. + +## Triggers + +`interactive/triggers.ts` owns event-source contracts and built-in trigger descriptors. +`interactive/language/events.ts` owns the shared interaction-event vocabulary. A backend +owns renderer-specific event normalization and realizes trigger descriptors against its +native event and coordinate systems. + +Type colocation does not change production ownership: chart triggers produce Element, +Region, and Navigation normalized events. The coordinator produces +`SemanticInteractionEvent` only after ChartDef resolution. Its type lives in `events.ts` +so the full canvas event vocabulary has one definition site. External payloads bypass +this acquisition vocabulary and enter through their bound external interaction handler. + +Flint provides common triggers for element activation, hover preview, rectangle drag, +and navigation: + +```ts +clickTrigger +hoverTrigger +rectangleTrigger('intersect' | 'contain') +xBrushTrigger('intersect' | 'contain') +yBrushTrigger('intersect' | 'contain') +angularBrushTrigger('intersect' | 'contain') +navigationTrigger() +``` + +### Cartesian navigation + +`navigate()` combines drag pan, wheel or two-finger pinch zoom, and reset as one viewport handler. Pinch zoom is anchored at the moving midpoint between the two touches. ChartDefs opt in explicitly with `navigation.axes`; assembly then intersects that capability with resolved quantitative or temporal x/y encodings. An explicitly requested unsupported axis is an error. With `axes: 'available'`, categorical axes are omitted automatically. + +The gesture reports incremental pan deltas and zoom anchors as plot fractions. The +renderer reduces them to absolute `set-viewport` domains using percentage-based guards: + +```ts +navigate({ + axes: 'available', + domainGuard: { + minVisibleFraction: 0.02, + maxVisibleFraction: 1, + overscrollFraction: 0, + }, +}) +``` + +The backend scale adapter owns conversion between these normalized fractions and linear, temporal, or logarithmic scale domains. Guards are relative to the initial domain: minimum and maximum visible fractions bound zoom, while overscroll controls how far the current domain may move beyond its allowed extent. Vega realizes the result through explicit `domainRaw` signals, so viewport mutation remains separate from semantic selection stores. + +V1 requires top-level continuous Cartesian scales. Faceted charts are excluded because each child view needs scoped scale ownership, and geographic/map navigation is excluded because projected coordinates require a projection-aware adapter rather than Cartesian domain arithmetic. + +`xBrushTrigger()` and `yBrushTrigger()` emit region events constrained to one axis. An X brush spans the full plot height; a Y brush spans the full plot width. The trigger performs this projection in plot geometry and reports physical hits. It does not inspect chart orientation or invert scales. + +The corresponding `brushX()` and `brushY()` presets apply semantic emphasis to the elements resolved from those hits. This is element-based brushing; domain-range inversion for linked charts remains a separate ChartDef resolver capability. + +`angularBrushTrigger()` emits an annular-sector region centered on a rendered polar chart. Pointer angles use the renderer's convention: zero is 12 o'clock and positive angles proceed clockwise. The runtime unwraps pointer motion continuously, so a drag can cross the $0/2\pi$ seam or proceed counterclockwise without jumping to the complementary sector. + +The corresponding `brushAngle()` preset is accepted only when the owning ChartDef declares angular-region support. Pie, donut, rose, and radar charts opt in; Cartesian ChartDefs reject the interaction during planning. Arc intersection and containment use rendered `startAngle`, `endAngle`, `innerRadius`, and `outerRadius` geometry. Radar line segments and points are tested against the same rendered sector, while the existing ChartDef resolver retains ownership of semantic identity. + +Brushes support two lifecycle modes: + +```ts +brushX({ mode: 'ephemeral' }) // default: overlay exists only during the drag +brushY({ mode: 'stateful' }) // committed overlay remains editable +brushAngle() // ephemeral polar sector +``` + +Select and Cartesian brushes share the rectangular region gesture engine. `select()` configures a free two-dimensional ephemeral rectangle. A stateful axis brush retains its committed interval, allows dragging the body to move it, allows dragging either edge to resize it, and clears on an outside click or Escape. Angular brushing is currently ephemeral; editable wrapped-angle handles require a separate circular interaction model. Region events identify transitions with `create`, `move`, `resize-leading`, `resize-trailing`, and `clear` operations. This state and its interaction chrome are owned per chart surface by the trigger runtime; preset handlers remain stateless. + +The folder is organized as: + +- `index.ts`: event-source contracts and built-in trigger definitions. +- `events.ts`: shared geometry, phases, normalized input event types, and the post-resolution semantic event type. + +The public triggers are exported from `flint-chart/interactive`. The source contract remains open so applications can define custom sources. + +### Gesture and backend ownership + +Gesture recognition is shared interaction infrastructure; binding a gesture to rendered chart objects is backend infrastructure. + +The interaction is authoritative about gesture intent: + +```text +select() -> cartesian + xy -> free rectangular selection +brushX() -> cartesian + x -> horizontal interval +brushY() -> cartesian + y -> vertical interval +brushAngle() -> angular -> polar angular sector +navigate() -> cartesian axes -> pan / zoom viewport transform +``` + +The backend mount is authoritative about realization: + +```text +configured eventSource + native pointer stream + -> matching recognizer + -> normalized physical region + -> renderer-specific physical hits +``` + +It must not reinterpret an `x` brush as a free selection, choose angular behavior merely because the chart contains arcs, or guess among configured operations from pointer trajectory. + +`interactive/` owns: + +- renderer-neutral pointer-session state such as angular sweep accumulation and interval transitions; +- Cartesian and angular gesture math; +- renderer-neutral regions such as `PlotRect`, `PlotPolygon`, and `PlotAngularSector`; +- presets that translate resolved semantic targets into `ChartUpdate` JSON. + +A backend owns: + +- discovering its plot coordinate space and converting client points into it; +- finding renderer-specific frames such as the center and radii of a polar plot; +- mapping normalized regions to physical rendered hits; +- normalizing renderer element and legend events; +- mounting the recognizer declared by `eventSource` against its native event system; +- owning pointer capture and drawing backend-aligned gesture chrome; +- applying updates to renderer stores and drawing representation-specific presentation. + +For Vega-Lite, `vegalite/interactions/` is the composition boundary. It wires shared gesture recognizers to Vega coordinate discovery, scenegraph hit testing, ChartDef semantic resolution, interaction handlers, and Vega presentation. Shared gesture modules must not import Vega or inspect scenegraph items. + +The stages are therefore distinct: + +```mermaid +flowchart TD + Source["Configured eventSource
Owner: InteractionDef"] --> Capture + Pointer["PointerEvent clientX/clientY
Owner: browser"] --> Capture + Capture["Native listener and pointer capture
Owner: backend mount"] --> Measure + Measure["DOM bounds, logical size, plot origin
Owner: backend coordinate discovery"] --> Convert + Convert["Client -> renderer -> plot coordinates
Owner: shared coordinate geometry"] --> Gesture + Gesture["Rectangle, interval, or angular sector
Owner: shared gesture recognizer"] --> Hits + Hits["Renderer geometry -> RenderHit[]
Owner: backend hit adapter"] --> Resolve + Resolve["RenderHit[] -> SemanticTarget
Owner: ChartDef resolver"] --> Handler + Handler["CanvasInteractionEvent -> ChartUpdate
Owner: interaction handler"] --> ResolveUpdate + ResolveUpdate["Selectors -> resolved targets
Owner: coordinator"] --> Present + Present["Representation-aware update
Owner: ChartDef presenter"] --> Apply + Apply["Stores and visual overlays
Owner: backend runtime"] +``` + +Visual gesture feedback takes the reverse spatial path: the backend sends plot geometry through the shared plot-to-client and client-to-layout transforms, then draws it in its DOM, canvas, or SVG overlay. + +The implementation follows that boundary: + +```text +interactive/ + geometry/ + angular.ts # angular intervals and sector paths + coordinate-space.ts # renderer-neutral coordinate transforms + gestures/ + angular-region.ts # angular pointer-session state + cartesian-region.ts # Cartesian projection and interval transitions + navigation.ts # pan sessions and wheel normalization + presets/ # source and handler combinations + triggers.ts # renderer-neutral source descriptors + language/ # interaction event and chart update contracts + +vegalite/interactions/ + contracts.ts # Vega interaction plan contracts + stores.ts # Vega selection and hover stores + compile.ts # Vega-Lite instrumentation and Vega store injection + hit-adapter.ts # Vega coordinates, scenegraph traversal, and physical hits + runtime.ts # resolve -> handle -> present -> apply coordinator + gestures/ + region.ts # Vega mounting for rectangle, axis, and angular drags + navigation.ts # Vega mounting for pan, wheel zoom, and reset + navigation-scale.ts # Vega domain guards and signal updates + presentation/ + focus-overlay.ts # path focus and selection boundaries + annotation-overlay.ts # annotation candidate search, wrapping, and drawing +``` + +Vega interaction code imports its concrete owner directly. Compile instrumentation comes from `vegalite/interactions/compile.ts`, runtime coordination from `runtime.ts`, and physical adaptation from `hit-adapter.ts`. There is intentionally no cross-layer interaction barrel: narrow imports make ownership violations visible during review. + +A custom source may register listeners and emit normalized events. Renderer-specific mounting code may additionally compute renderer geometry and inspect rendered marks. Neither source descriptors nor mounts may resolve semantic targets, contain chart-type behavior, or construct chart updates. + +### Target feedback + +Assisted pointer and keyboard targeting share a transient target indicator and a floating semantic tooltip. Keyboard arrows move the indicator, apply the active hover styling, and emit `focus-element` through the `keyboard-targeting` interaction ID even when no click preset is configured. Enter or Space invokes any configured click presets. The tooltip uses the compiled pointer-hover fields, stays clear of the active mark, may extend beyond the chart canvas, and scrolls with the chart. + +Eligible element presets use modest assisted pointer targeting by default: click, annotation, +context, and double activation use an 8-pixel acquisition radius, hover uses 6 pixels, and long +press uses 12 pixels. Region selection, brushing, navigation, and element dragging never use +assisted acquisition. Set `assistedTargeting: false` to require direct hits globally, or provide +`maxDistance` as a hard override for all eligible presets. Indicator and detail feedback remain +opt-in: + +```ts +buildInteractiveChart(container, input, { + backend: 'vegalite', + interactions: [clickHighlight({ targets: ['mark', 'legend', 'discreteAxis'] }), axisHighlight()], + assistedTargeting: { + maxDistance: 10, + indicator: true, + details: { fields: ['country', 'value'], maxRows: 4 }, + }, + keyboardTargeting: true, +}); +``` + +`axisHighlight()` treats native categorical axis ticks as semantic controls. The compiler maps each Vega scale back to its authored field, and the runtime associates a tick with represented mark keys. Quantitative and temporal ticks remain inert until a nearest-value or interval policy is specified. + +## Update Language + +Presets and applications produce one renderer-neutral `ChartUpdate` format. There is no +separate request operator, resolved operator, or renderer-only operator language: + +```ts +interface ChartUpdate { + id: string; + ops: readonly ChartUpdateOp[]; +} + +type ChartUpdateOp = + | { op: 'set-style'; targets: readonly UpdateTarget[]; value: StyleSpec } + | { op: 'set-annotation'; target: UpdateTarget; value: AnnotationSpec | null } + | { op: 'set-viewport'; axes: 'x' | 'y' | 'xy'; value: { x?: Domain; y?: Domain } } + | { + op: 'set-order'; + scope: 'category' | 'series' | 'facet'; + field: string; + values: readonly unknown[]; + }; + +interface StyleSpec { + visible?: boolean; + opacity?: number; + fill?: string; + stroke?: string; + strokeWidth?: number; + state?: 'normal' | 'focused' | 'emphasized' | 'muted'; + mutedOpacity?: number; +} +``` + +Operators are plain JSON. Presets and applications construct object literals directly; +there are no trivial operator factory functions. + +The renderer has two inputs: + +```text +chart + updates -> rendered chart +``` + +`chart` is the immutable base specification. `updates` describes what the chart should +display and is suitable for serialization and static composition. The renderer does +not know whether an interaction is previewing, committing, or reverting an update. + +Interaction state owns that lifecycle. During a gesture, the interaction controller may +compose its private preview over retained updates before rendering. Commit retains the +result; cancel drops the preview and renders the retained collection again. Preview +state is not part of the chart API or update language. + +The surface can replace the retained collection atomically: + +```ts +await surface.setUpdates(updates); +``` + +`applyUpdate(update, { composition: 'auto' })` replaces one retained update by ID, while +`clearUpdate(id)` removes that retained update. The default `auto` policy composes all +retained updates in insertion order: presentation selections accumulate, while later +annotation, viewport, and order operations take precedence. The policy is explicit so +future composition modes can extend the API without changing this default behavior. + +Relative gesture data is not update state. Pan deltas, zoom factors, toggle modifiers, +and drag positions are reduced by interaction state into absolute `set-viewport`, +`set-style`, or `set-order` values. Cancelling a gesture requires no inverse +chart command: the interaction drops its preview and sends the prior effective updates. + +Targets may be exact event-derived refs or unresolved equality selectors: + +```ts +type UpdateTarget = + | SemanticTargetRef + | { + select: { + key: Record; + visual?: Partial; + }; + }; +``` + +Selectors accept only ChartDef-declared semantic fields. At runtime they are resolved to +the same `SemanticTargetRef` shape; the surrounding `ChartUpdateOp` does not change. + +ChartDefs may enrich an operator without changing its kind. For example, +`set-annotation` can begin with text only and gain meaningful connection candidates in +its `value`: + +```ts +interface AnnotationCandidate { + connection: 'center' | 'top' | 'right' | 'bottom' | 'left' + | 'value-end' | 'value-side' | 'segment-midpoint' | 'outer-radial'; + valueAxis?: 'x' | 'y'; + crossSide?: 'start' | 'end'; + valueInset?: number; + anglePreference?: 'normal' | 'oblique'; + textAlign?: 'left' | 'center' | 'right'; + connector?: 'line' | 'none'; + maxWidth?: number; + maxDistance?: number; + priority?: number; +} +``` + +The renderer reconstructs effective state from the two arrays and updates Vega stores, +signals, and overlays in one dataflow run. It does not transform or recompile the base +chart for each preview. Current Vega-Lite coverage includes emphasized/focused target +state, one effective annotation, exact continuous viewport domains, and category order. +Visibility and direct ink properties, multiple simultaneous annotations, and series or +facet order remain implementation work within the existing four-operator grammar. + +When a caller already has a complete `ChartUpdate`, it may bypass interaction handling +and apply that precomputed state directly: + +```ts +const result = await surface.applyUpdate({ + id: 'external-country-selection', + ops: [{ + op: 'set-style', + targets: [{ + select: { + key: { Country: 'Japan' }, + visual: { kind: 'mark' }, + }, + }], + value: { state: 'emphasized' }, + }], +}); +``` + +`ChartUpdateResult` reports applied, partially applied, or unsupported status plus +unresolved targets and unsupported ops. Missing keys are never silently rebound to +similar records. + +Vega-Lite currently implements update application when the chart has a compiled +interaction plan. Other backends, or a Vega-Lite chart without interaction +instrumentation, return `status: 'unsupported'` rather than silently ignoring an update. + +## External Interactions + +External definitions bind an arbitrary application payload to the same renderer-neutral +update language used by canvas interactions: + +```ts +const countryPicker = externalInteraction<{ country: string; selected: boolean }>({ + id: 'country-picker', + handle: ({ country, selected }) => ({ + id: 'country-picker', + ops: [{ + op: 'set-style', + targets: selected ? [{ select: { key: { Country: country } } }] : [], + value: { state: selected ? 'emphasized' : 'normal' }, + }], + }), +}); + +await surface.dispatch('country-picker', { country: 'Japan', selected: true }); +``` + +The transport may be React state, a DOM listener, a WebSocket, or another chart. Flint +looks up the definition by ID, passes the opaque payload and current interaction context +to its handler, then resolves and presents the returned `ChartUpdate`. Internal canvas +interactions additionally use backend gesture state machines to acquire start, preview, +commit, and cancel phases; external handlers do not synthesize those phases. + +Payload typing is enforced at the `externalInteraction()` definition, while the +heterogeneous surface boundary accepts `unknown`. Canvas hit testing and navigation +domain calculation remain backend-assisted, and interaction definitions are mount-scoped. + +## Outbound Events + +Resolved internal events are emitted as a bubbling, composed DOM event named `flint-interaction`. + +```ts +interface FlintInteractionEventDetail { + chartId: string; + interactionId: string; + timestamp: number; + transactionId?: string; + event: CanvasInteractionEvent; +} +``` + +`chartId` identifies the source chart and remains stable for the surface lifetime. It is available on `surface.chartId` and as `data-flint-chart-id` on the surface element. `interactionId` identifies the configured interaction receiving the event. + +`interactionId` identifies the configured observer that requested acquisition. It does +not imply that the observer has a handler. + +The interaction coordinator, not ChartDef, owns this emission. Outbound emission does not depend on whether the preset returns a canvas update. External applications may coordinate text, tables, or other charts from semantic events while leaving the source chart unchanged. + +## Identity + +Callers should provide `chartId` when coordinating charts. Flint generates an ID when omitted. Re-rendering, viewport changes, and data updates do not change the resolved ID. + +Chart identity belongs to the transport envelope, not `SemanticTarget`: semantic targets describe visual/data identity, while `chartId` describes event origin or dispatch destination. + +## Linked brushing + +`linkedBrush()` expands brushed marks to every available mark with the same authored semantic group: + +```ts +linkedBrush({ groupBy: 'Country' }); +linkedBrush({ groupBy: ['Country', 'Product'], brush: 'lasso' }); +``` + +The available marks may belong to facets, repeated views, or another renderer-defined +view composition. Group matching uses the same input-record field keys as +`clickGroupFocus({ groupBy })`. + +The key should be represented by a discrete positional channel, `detail`, or `color`. For a +quantitative scatter plot, prefer `detail` when identity should not alter appearance. Continuous +`x`, `y`, or `xy` values are not inferred as identities because measurements can change between +facets or collide. The preset emits the existing `set-style` operation; it does not filter data or +introduce facet-specific chart state. + +`clickGroupFocus()` infers a chart-semantic partition, while +`clickGroupFocus({ groupBy: 'Country' })` uses explicit input-record field-key expansion. Plain strings +always name fields, so `groupBy: 'auto'` selects a field literally named `auto`. +`clickHighlight({ targets: ['mark'] })` remains local to the acquired mark. +`clickAnnotate()` remains local to the acquired mark. + +For a custom stable partition, derive a field in the input data and name it with `groupBy`: + +```ts +clickGroupFocus({ groupBy: 'Quadrant' }); +``` + +This keeps grouping serializable and reusable by other chart semantics. Event-relative or otherwise +custom interaction logic belongs in a custom `handle`, which can emit the existing style updates. + +`hoverGroupFocus({ groupBy: 'Country' })` provides the transient counterpart. Its preview +clears on pointer exit and does not replace retained click or brush state. Assisted acquisition +finds a nearby mark within the preset's default 6-pixel radius. Separately, the default 8-pixel +`tolerance` keeps the last resolved cohort stable across narrow gaps; set it to zero to disable +that gap retention. + +## Click highlight targets + +`clickHighlight()` emphasizes cohorts through one retained interaction. Its `targets` +option accepts `mark`, `legend`, and `discreteAxis`; omitted targets enable all three. +`legendToggle()` remains a separate visibility interaction. + +Only compiler-declared discrete axis ticks are semantic cohorts; continuous ticks do not +implicitly become clickable selections. Continuous legend intervals remain resolvable labels. + +## Interaction affordances + +Canvas interactions declare cursor and hover affordances separately from their update handler. +Renderers combine those declarations for the semantic target under the pointer, so composed +presets share one discoverability policy instead of assigning cursors independently. Exact target +claims (`mark`, `legend-item`, or `axis-label`) take precedence over a plot-wide fallback; priority +resolves conflicts between equally specific claims. Active gesture states such as dragging and +resizing temporarily override the passive result. + +Affordances do not perform chart updates. The interaction handler still owns semantic behavior, +and the renderer still owns presentation. + +## Compatibility + +Canonical helpers include: + +- `clickHighlight()` +- `clickGroupFocus()` +- `clickAnnotate()` +- `linkedBrush()` +- `hoverGroupFocus()` +- `legendToggle()` +- `select()`, `brushX()`, `brushY()`, and `brushAngle()` +- `navigate()` + +All presets are implemented on the normalized event pipeline. Existing chart resolution and +`presentUpdate` hooks remain valid; chart-specific action expansion lives in interaction handlers. + +The long-term built-in preset set should stay small: hover highlight, click +highlight/select, region or brush highlight, and guarded navigation. Specialized +annotation formatting, legend toggle/isolate, group expansion, linked views, tooltips, +and product relationships are primarily recipes composed from outbound events and +update factories. Compatibility helpers may remain without establishing a pattern of +adding every action-to-update combination as a preset. diff --git a/packages/flint-js/src/interactive/affordances.ts b/packages/flint-js/src/interactive/affordances.ts new file mode 100644 index 00000000..66975447 --- /dev/null +++ b/packages/flint-js/src/interactive/affordances.ts @@ -0,0 +1,52 @@ +import type { CanvasInteractionDef } from './interactions'; + +export type InteractionAffordanceTarget = 'mark' | 'legend-item' | 'axis-label' | 'plot'; +export type InteractionCursor = 'activate' | 'drag' | 'region' | 'navigate' | 'inspect'; +export type InteractionHoverEffect = 'target' | 'cohort'; + +export interface InteractionAffordance { + readonly target: InteractionAffordanceTarget; + readonly cursor?: InteractionCursor; + readonly hover?: InteractionHoverEffect; + readonly priority?: number; +} + +const CURSOR_PRIORITY: Record = { + activate: 10, + inspect: 20, + navigate: 30, + region: 40, + drag: 50, +}; + +export function resolveInteractionAffordance( + interactions: readonly CanvasInteractionDef[], + target: InteractionAffordanceTarget, + eligibleInteractionIds?: ReadonlySet, +): InteractionAffordance | undefined { + const claims = interactions + .filter((interaction) => !eligibleInteractionIds || eligibleInteractionIds.has(interaction.id)) + .flatMap((interaction) => interaction.affordances ?? []) + .filter((affordance) => affordance.target === target + || (target !== 'plot' && affordance.target === 'plot')); + const exactClaims = claims.filter((claim) => claim.target === target); + const eligibleClaims = exactClaims.length > 0 ? exactClaims : claims; + const priority = (claim: InteractionAffordance): number => + claim.priority ?? (claim.cursor ? CURSOR_PRIORITY[claim.cursor] : 0); + const cursor = eligibleClaims.filter((claim) => claim.cursor) + .sort((left, right) => priority(right) - priority(left))[0]?.cursor; + const hover = eligibleClaims.filter((claim) => claim.hover) + .sort((left, right) => priority(right) - priority(left))[0]?.hover; + return cursor || hover ? { target, ...(cursor ? { cursor } : {}), ...(hover ? { hover } : {}) } : undefined; +} + +export function affordanceCursor(affordance: InteractionAffordance | undefined): string | undefined { + switch (affordance?.cursor) { + case 'activate': return 'pointer'; + case 'drag': return 'grab'; + case 'region': return 'crosshair'; + case 'navigate': return 'grab'; + case 'inspect': return 'crosshair'; + default: return undefined; + } +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/canvas-interaction.ts b/packages/flint-js/src/interactive/canvas-interaction.ts new file mode 100644 index 00000000..7a0ac40d --- /dev/null +++ b/packages/flint-js/src/interactive/canvas-interaction.ts @@ -0,0 +1,87 @@ +import { semanticVisualFamily, type SemanticTarget } from '../core/interaction-semantics'; +import type { InteractionEventSource } from './triggers'; +import type { + CanvasInteractionAction, + CanvasInteractionEvent, + NavigationInteractionEvent, + PlotGeometry, + SemanticInteractionEvent, +} from './language/events'; + +function elementAction( + source: InteractionEventSource, + target: SemanticTarget | null, +): CanvasInteractionAction { + if (source.gesture === 'keyboard') return 'focus-element'; + const family = semanticVisualFamily(target?.visual.role); + if (source.gesture === 'context') return `context-${family}` as CanvasInteractionAction; + if (source.gesture === 'long-press') return `long-press-${family}` as CanvasInteractionAction; + if (source.gesture === 'double') return `double-activate-${family}` as CanvasInteractionAction; + if (source.gesture === 'inspect') { + return source.inspect === 'x' ? 'inspect-x' + : source.inspect === 'y' ? 'inspect-y' + : 'inspect-xy'; + } + const gesture = source.gesture === 'hover' ? 'hover' : 'click'; + return `${gesture}-${family}` as CanvasInteractionAction; +} + +function regionAction(event: SemanticInteractionEvent): CanvasInteractionAction { + if (event.region && 'points' in event.region) return 'select-lasso'; + if (event.axis === 'x') return 'brush-x'; + if (event.axis === 'y') return 'brush-y'; + if (event.axis === 'angle') return 'brush-angle'; + return 'select-region'; +} + +function regionGeometry(event: SemanticInteractionEvent): PlotGeometry | undefined { + if (!event.region) return undefined; + if ('innerRadius' in event.region) { + return { kind: 'angular-sector', sector: event.region }; + } + if ('points' in event.region) { + return { kind: 'polygon', polygon: event.region }; + } + return { + kind: 'rect', + rect: event.region, + axis: event.axis === 'x' || event.axis === 'y' ? event.axis : 'xy', + }; +} + +export function toCanvasInteractionEvent( + event: SemanticInteractionEvent | NavigationInteractionEvent, + source: InteractionEventSource, +): CanvasInteractionEvent { + if (event.type === 'navigation') { + return { + action: `${event.operation}-viewport`, + phase: event.phase, + operation: event.operation, + geometry: { + plot: { + kind: 'viewport', + axes: event.axes, + delta: event.delta, + factor: event.factor, + anchor: event.anchor, + }, + }, + target: null, + modifiers: event.modifiers, + }; + } + + return { + action: event.source === 'region' ? regionAction(event) : elementAction(source, event.target), + phase: event.phase, + operation: event.operation, + geometry: { + plot: event.point + ? { kind: 'point', point: event.point } + : regionGeometry(event), + }, + target: event.target, + modifiers: event.modifiers, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/geometry/angular.ts b/packages/flint-js/src/interactive/geometry/angular.ts new file mode 100644 index 00000000..956eb62e --- /dev/null +++ b/packages/flint-js/src/interactive/geometry/angular.ts @@ -0,0 +1,49 @@ +import type { PlotAngularSector, PlotPoint } from '../language/events'; + +export const TAU = 2 * Math.PI; + +export function angularSegments(startAngle: number, endAngle: number): [number, number][] { + const sweep = endAngle - startAngle; + if (Math.abs(sweep) >= TAU - 1e-9) return [[0, TAU]]; + const leading = sweep >= 0 ? startAngle : endAngle; + const extent = Math.abs(sweep); + const start = ((leading % TAU) + TAU) % TAU; + const end = start + extent; + return end <= TAU ? [[start, end]] : [[start, TAU], [0, end - TAU]]; +} + +export function angularSectorPath(sector: PlotAngularSector): string { + const rawSweep = sector.endAngle - sector.startAngle; + const sweep = Math.min(TAU, Math.max(-TAU, rawSweep)); + if (Math.abs(sweep) < 1e-9 || sector.outerRadius <= 0) return ''; + const point = (radius: number, angle: number): PlotPoint => ({ + x: sector.center.x + radius * Math.sin(angle), + y: sector.center.y - radius * Math.cos(angle), + }); + const outerStart = point(sector.outerRadius, sector.startAngle); + if (Math.abs(sweep) >= TAU - 1e-9) { + const direction = sweep > 0 ? 1 : 0; + const reverse = direction ? 0 : 1; + const outerMid = point(sector.outerRadius, sector.startAngle + Math.sign(sweep) * Math.PI); + const outerCircle = `M ${outerStart.x} ${outerStart.y} ` + + `A ${sector.outerRadius} ${sector.outerRadius} 0 1 ${direction} ${outerMid.x} ${outerMid.y} ` + + `A ${sector.outerRadius} ${sector.outerRadius} 0 1 ${direction} ${outerStart.x} ${outerStart.y}`; + if (sector.innerRadius <= 0) return `${outerCircle} Z`; + const innerStart = point(sector.innerRadius, sector.startAngle); + const innerMid = point(sector.innerRadius, sector.startAngle + Math.sign(sweep) * Math.PI); + return `${outerCircle} L ${innerStart.x} ${innerStart.y} ` + + `A ${sector.innerRadius} ${sector.innerRadius} 0 1 ${reverse} ${innerMid.x} ${innerMid.y} ` + + `A ${sector.innerRadius} ${sector.innerRadius} 0 1 ${reverse} ${innerStart.x} ${innerStart.y} Z`; + } + const outerEnd = point(sector.outerRadius, sector.startAngle + sweep); + const largeArc = Math.abs(sweep) > Math.PI ? 1 : 0; + const sweepFlag = sweep > 0 ? 1 : 0; + const outerArc = `A ${sector.outerRadius} ${sector.outerRadius} 0 ${largeArc} ${sweepFlag} ${outerEnd.x} ${outerEnd.y}`; + if (sector.innerRadius <= 0) { + return `M ${sector.center.x} ${sector.center.y} L ${outerStart.x} ${outerStart.y} ${outerArc} Z`; + } + const innerEnd = point(sector.innerRadius, sector.startAngle + sweep); + const innerStart = point(sector.innerRadius, sector.startAngle); + return `M ${outerStart.x} ${outerStart.y} ${outerArc} L ${innerEnd.x} ${innerEnd.y} ` + + `A ${sector.innerRadius} ${sector.innerRadius} 0 ${largeArc} ${sweepFlag ? 0 : 1} ${innerStart.x} ${innerStart.y} Z`; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/geometry/coordinate-space.ts b/packages/flint-js/src/interactive/geometry/coordinate-space.ts new file mode 100644 index 00000000..c064cf52 --- /dev/null +++ b/packages/flint-js/src/interactive/geometry/coordinate-space.ts @@ -0,0 +1,83 @@ +import type { InteractionModifiers, PlotPoint } from '../language/events'; + +export interface RendererCoordinateSpace { + rect: DOMRect; + logicalWidth: number; + logicalHeight: number; + originX: number; + originY: number; + plotWidth: number; + plotHeight: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** + * Converts a rendered root-frame matrix into a plot origin expressed in the + * renderer's own units. `getCTM()` reports CSS pixels, so a CSS-scaled SVG + * would otherwise report an origin that disagrees with `logicalWidth`. + */ +export function rendererPlotOrigin( + matrix: { a: number; d?: number; e: number; f: number } | null | undefined, + viewOrigin: PlotPoint, +): PlotPoint { + if (!matrix) return viewOrigin; + const scaleX = matrix.a || 1; + const scaleY = matrix.d || scaleX; + return { x: matrix.e / scaleX, y: matrix.f / scaleY }; +} + +export function interactionModifiers(event: MouseEvent | PointerEvent): InteractionModifiers { + return { shift: event.shiftKey, ctrl: event.ctrlKey, meta: event.metaKey }; +} + +export function clientToPlotPoint(client: PlotPoint, space: RendererCoordinateSpace): PlotPoint { + const renderer = clientToRendererPoint(client, space); + return { + x: clamp(renderer.x - space.originX, 0, space.plotWidth), + y: clamp(renderer.y - space.originY, 0, space.plotHeight), + }; +} + +/** Client position in the full renderer, including plot margins and legends. */ +export function clientToRendererPoint(client: PlotPoint, space: RendererCoordinateSpace): PlotPoint { + return { + x: (client.x - space.rect.left) * space.logicalWidth / space.rect.width, + y: (client.y - space.rect.top) * space.logicalHeight / space.rect.height, + }; +} + +export function plotToClientPoint(point: PlotPoint, space: RendererCoordinateSpace): PlotPoint { + return { + x: space.rect.left + (point.x + space.originX) * space.rect.width / space.logicalWidth, + y: space.rect.top + (point.y + space.originY) * space.rect.height / space.logicalHeight, + }; +} + +export function clientToLayoutPoint( + point: PlotPoint, + rect: Pick, + layoutSize: { width: number; height: number }, +): PlotPoint { + return { + x: (point.x - rect.left) * layoutSize.width / rect.width, + y: (point.y - rect.top) * layoutSize.height / rect.height, + }; +} + +export function clientRectToLayoutRect( + rect: Pick, + containerRect: Pick, + layoutSize: { width: number; height: number }, +): { left: number; top: number; width: number; height: number } { + const leading = clientToLayoutPoint({ x: rect.left, y: rect.top }, containerRect, layoutSize); + const trailing = clientToLayoutPoint({ x: rect.right, y: rect.bottom }, containerRect, layoutSize); + return { + left: leading.x, + top: leading.y, + width: trailing.x - leading.x, + height: trailing.y - leading.y, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/gestures/angular-region.ts b/packages/flint-js/src/interactive/gestures/angular-region.ts new file mode 100644 index 00000000..53359a7b --- /dev/null +++ b/packages/flint-js/src/interactive/gestures/angular-region.ts @@ -0,0 +1,49 @@ +import type { PlotAngularSector, PlotPoint } from '../language/events'; +import { TAU } from '../geometry/angular'; + +export interface PolarFrame { + center: PlotPoint; + innerRadius: number; + outerRadius: number; +} + +export function polarPointerAngle(point: PlotPoint, frame: PolarFrame): number { + return Math.atan2(point.x - frame.center.x, frame.center.y - point.y); +} + +export class AngularRegionSession { + private previousAngle: number; + private sweep = 0; + private readonly startAngle: number; + + constructor( + start: PlotPoint, + readonly frame: PolarFrame, + ) { + this.startAngle = polarPointerAngle(start, frame); + this.previousAngle = this.startAngle; + } + + move(point: PlotPoint): void { + const angle = polarPointerAngle(point, this.frame); + this.sweep = Math.min(TAU, Math.max(-TAU, this.sweep + Math.atan2( + Math.sin(angle - this.previousAngle), + Math.cos(angle - this.previousAngle), + ))); + this.previousAngle = angle; + } + + dragDistance(): number { + return Math.abs(this.sweep) * this.frame.outerRadius; + } + + sector(): PlotAngularSector { + return { + center: this.frame.center, + innerRadius: this.frame.innerRadius, + outerRadius: this.frame.outerRadius, + startAngle: this.startAngle, + endAngle: this.startAngle + this.sweep, + }; + } +} diff --git a/packages/flint-js/src/interactive/gestures/cartesian-region.ts b/packages/flint-js/src/interactive/gestures/cartesian-region.ts new file mode 100644 index 00000000..e0637a64 --- /dev/null +++ b/packages/flint-js/src/interactive/gestures/cartesian-region.ts @@ -0,0 +1,89 @@ +import type { PlotPoint, RegionAxis, RegionOperation } from '../language/events'; + +export type CartesianRegionAxis = Extract; +export type IntervalOperation = Exclude; + +export interface PlotSize { + width: number; + height: number; +} + +export interface PlotFrame extends PlotSize { + x: number; + y: number; +} + +export interface Interval { + leading: number; + trailing: number; +} + +export function constrainCartesianRegion( + start: PlotPoint, + end: PlotPoint, + axis: CartesianRegionAxis, + plotSize: PlotSize | PlotFrame, +): { start: PlotPoint; end: PlotPoint } { + const left = 'x' in plotSize ? plotSize.x : 0; + const top = 'y' in plotSize ? plotSize.y : 0; + const right = left + plotSize.width; + const bottom = top + plotSize.height; + const clamp = (value: number, minimum: number, maximum: number): number => + Math.max(minimum, Math.min(maximum, value)); + return { + start: { + x: axis === 'y' ? left : clamp(start.x, left, right), + y: axis === 'x' ? top : clamp(start.y, top, bottom), + }, + end: { + x: axis === 'y' ? right : clamp(end.x, left, right), + y: axis === 'x' ? bottom : clamp(end.y, top, bottom), + }, + }; +} + +export function cartesianDragDistance(start: PlotPoint, end: PlotPoint, axis: CartesianRegionAxis): number { + if (axis === 'x') return Math.abs(end.x - start.x); + if (axis === 'y') return Math.abs(end.y - start.y); + return Math.hypot(end.x - start.x, end.y - start.y); +} + +export function axisValue(point: PlotPoint, axis: Exclude): number { + return axis === 'y' ? point.y : point.x; +} + +export function intervalPoints( + interval: Interval, + axis: Exclude, +): { start: PlotPoint; end: PlotPoint } { + return axis === 'y' + ? { start: { x: 0, y: interval.leading }, end: { x: 0, y: interval.trailing } } + : { start: { x: interval.leading, y: 0 }, end: { x: interval.trailing, y: 0 } }; +} + +export function updateInterval( + point: PlotPoint, + start: PlotPoint, + axis: Exclude, + limit: number, + operation: IntervalOperation, + initial?: Interval, +): Interval { + const value = axisValue(point, axis); + if (!initial || operation === 'create') { + const anchor = axisValue(start, axis); + return { leading: Math.min(anchor, value), trailing: Math.max(anchor, value) }; + } + if (operation === 'move') { + const width = initial.trailing - initial.leading; + const delta = value - axisValue(start, axis); + const leading = Math.max(0, Math.min(limit - width, initial.leading + delta)); + return { leading, trailing: leading + width }; + } + const leading = operation === 'resize-leading' ? value : initial.leading; + const trailing = operation === 'resize-trailing' ? value : initial.trailing; + return { + leading: Math.max(0, Math.min(limit, Math.min(leading, trailing))), + trailing: Math.max(0, Math.min(limit, Math.max(leading, trailing))), + }; +} diff --git a/packages/flint-js/src/interactive/gestures/navigation.ts b/packages/flint-js/src/interactive/gestures/navigation.ts new file mode 100644 index 00000000..fbd7fdc3 --- /dev/null +++ b/packages/flint-js/src/interactive/gestures/navigation.ts @@ -0,0 +1,69 @@ +import type { PlotPoint } from '../language/events'; + +export interface PlotSize { + width: number; + height: number; +} + +export class PanSession { + private previous: PlotPoint; + private totalDistance = 0; + + constructor(start: PlotPoint, private readonly plotSize: PlotSize) { + this.previous = start; + } + + move(point: PlotPoint): PlotPoint { + const pixelDelta = { x: point.x - this.previous.x, y: point.y - this.previous.y }; + this.previous = point; + this.totalDistance += Math.hypot(pixelDelta.x, pixelDelta.y); + return { + x: this.plotSize.width > 0 ? pixelDelta.x / this.plotSize.width : 0, + y: this.plotSize.height > 0 ? pixelDelta.y / this.plotSize.height : 0, + }; + } + + dragDistance(): number { + return this.totalDistance; + } +} + +export class PinchSession { + private previousDistance: number; + + constructor( + first: PlotPoint, + second: PlotPoint, + private readonly plotSize: PlotSize, + ) { + this.previousDistance = Math.hypot(second.x - first.x, second.y - first.y); + } + + move(first: PlotPoint, second: PlotPoint): { factor: number; anchor: PlotPoint } | null { + const distance = Math.hypot(second.x - first.x, second.y - first.y); + if (distance <= 0 || this.previousDistance <= 0) { + this.previousDistance = distance; + return null; + } + const factor = distance / this.previousDistance; + this.previousDistance = distance; + const midpoint = { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 }; + return { + factor, + anchor: { + x: this.plotSize.width > 0 ? midpoint.x / this.plotSize.width : 0.5, + y: this.plotSize.height > 0 ? midpoint.y / this.plotSize.height : 0.5, + }, + }; + } +} + +export function wheelZoomFactor( + deltaY: number, + deltaMode: number, + viewportHeight: number, + sensitivity: number, +): number { + const pixels = deltaMode === 1 ? deltaY * 16 : deltaMode === 2 ? deltaY * viewportHeight : deltaY; + return Math.exp(-pixels * sensitivity); +} diff --git a/packages/flint-js/src/interactive/guides.ts b/packages/flint-js/src/interactive/guides.ts new file mode 100644 index 00000000..56326da0 --- /dev/null +++ b/packages/flint-js/src/interactive/guides.ts @@ -0,0 +1,102 @@ +/** Shared lifecycle for transient visuals owned by an active gesture. */ +export interface GestureGuideController { + clear(): void; + destroy(): void; +} + +/** Renderer-neutral styling for a line-based gesture guide. */ +export interface LineGestureGuideStyle { + color: string; + opacity: number; + width: number; +} + +export interface InspectGestureGuideStyle extends LineGestureGuideStyle { + fillOpacity: number; + haloColor: string; + haloOpacity: number; + haloWidth: number; +} + +export interface AreaGestureGuideStyle { + fill: string; + fillOpacity: number; + stroke: string; + strokeOpacity: number; + strokeWidth: number; +} + +export interface GestureGuideOptions { + visible?: boolean; + style?: Partial; +} + +export type InspectGuideOptions = GestureGuideOptions; +export type RegionGuideOptions = GestureGuideOptions; + +export const DEFAULT_INSPECT_GUIDE_STYLE: Readonly = Object.freeze({ + color: '#47525c', + opacity: 0.58, + width: 1, + fillOpacity: 0.07, + haloColor: '#ffffff', + haloOpacity: 0.64, + haloWidth: 0.5, +}); + +export const DEFAULT_REGION_GUIDE_STYLE: Readonly = Object.freeze({ + fill: '#2563eb', + fillOpacity: 0.12, + stroke: '#2563eb', + strokeOpacity: 0.85, + strokeWidth: 1, +}); + +export function normalizeInspectGuideOptions( + options: InspectGuideOptions | false | undefined, +): { visible: boolean; style: InspectGestureGuideStyle } { + const style = options === false ? undefined : options?.style; + return { + visible: options !== false && options?.visible !== false, + style: { + color: style?.color ?? DEFAULT_INSPECT_GUIDE_STYLE.color, + opacity: Number.isFinite(style?.opacity) + ? Math.min(1, Math.max(0, style!.opacity!)) + : DEFAULT_INSPECT_GUIDE_STYLE.opacity, + width: Number.isFinite(style?.width) && style!.width! > 0 + ? style!.width! + : DEFAULT_INSPECT_GUIDE_STYLE.width, + fillOpacity: Number.isFinite(style?.fillOpacity) + ? Math.min(1, Math.max(0, style!.fillOpacity!)) + : DEFAULT_INSPECT_GUIDE_STYLE.fillOpacity, + haloColor: style?.haloColor ?? DEFAULT_INSPECT_GUIDE_STYLE.haloColor, + haloOpacity: Number.isFinite(style?.haloOpacity) + ? Math.min(1, Math.max(0, style!.haloOpacity!)) + : DEFAULT_INSPECT_GUIDE_STYLE.haloOpacity, + haloWidth: Number.isFinite(style?.haloWidth) && style!.haloWidth! >= 0 + ? style!.haloWidth! + : DEFAULT_INSPECT_GUIDE_STYLE.haloWidth, + }, + }; +} + +export function normalizeRegionGuideOptions( + options: RegionGuideOptions | false | undefined, +): { visible: boolean; style: AreaGestureGuideStyle } { + const style = options === false ? undefined : options?.style; + const unit = (value: number | undefined, fallback: number): number => Number.isFinite(value) + ? Math.min(1, Math.max(0, value!)) + : fallback; + return { + visible: options !== false && options?.visible !== false, + style: { + fill: style?.fill ?? DEFAULT_REGION_GUIDE_STYLE.fill, + fillOpacity: unit(style?.fillOpacity, DEFAULT_REGION_GUIDE_STYLE.fillOpacity), + stroke: style?.stroke ?? DEFAULT_REGION_GUIDE_STYLE.stroke, + strokeOpacity: unit(style?.strokeOpacity, DEFAULT_REGION_GUIDE_STYLE.strokeOpacity), + strokeWidth: Number.isFinite(style?.strokeWidth) && style!.strokeWidth! > 0 + ? style!.strokeWidth! + : DEFAULT_REGION_GUIDE_STYLE.strokeWidth, + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts new file mode 100644 index 00000000..637013e5 --- /dev/null +++ b/packages/flint-js/src/interactive/index.ts @@ -0,0 +1,229 @@ +import type { ChartAssemblyInput } from '../core/types'; +import { isCanvasInteraction, normalizeInteractions } from './interactions'; +import { mountInteractiveChartSurface } from './surface'; +import type { BuildInteractiveChartOptions, InteractiveChartSurface } from './types'; + +export type { + AssistedTargetingOptions, + TargetDetailsOptions, + TargetFeedbackOptions, + BuildInteractiveChartOptions, + ChartUpdateApplyOptions, + ChartUpdateComposition, + InteractiveBackend, + InteractiveChartSurface, + InteractiveChartSurfaceOptions, + InteractionDismissPolicy, + InteractiveRenderer, + InteractiveRendererAdapter, + ViewportChannel, + ViewportGeometry, + ViewportState, +} from './types'; +export type { + GestureGuideController, + GestureGuideOptions, + AreaGestureGuideStyle, + InspectGuideOptions, + LineGestureGuideStyle, + RegionGuideOptions, +} from './guides'; +export type { + InteractionAffordance, + InteractionAffordanceTarget, + InteractionCursor, + InteractionHoverEffect, +} from './affordances'; +export { affordanceCursor, resolveInteractionAffordance } from './affordances'; +export type { + AnnotationCandidate, + AnnotationConnection, + AnnotationSpec, + ChartOverlaySpec, + ChartUpdate, + ChartUpdateOp, + ChartUpdatePresenter, + BrushOptions, + BrushZoomOptions, + AngularBrushOptions, + AxisHighlightOptions, + ClickAnnotateOptions, + ClickHighlightOptions, + ClickHighlightTarget, + ClickGroupFocusOptions, + LinkedBrushOptions, + HoverGroupFocusOptions, + GroupBy, + ElementInteractionEvent, + FlintInteractionEventDetail, + InteractionPhase, + InteractionContext, + InteractionDef, + CanvasInteractionDef, + ExternalInteractionDef, + InteractionModifiers, + InspectOptions, + InspectIndexOptions, + LassoSelectOptions, + NavigateOptions, + NavigationAxes, + NavigationDomainGuard, + NavigationInteractionEvent, + NavigationOperation, + PlotPoint, + PlotAngularSector, + PlotPolygon, + PlotRect, + OverlayFieldEncoding, + OverlayMark, + OverlayStyleSpec, + RegionAxis, + RegionOperation, + RenderHit, + SelectOptions, + SemanticElement, + SemanticInteractionEvent, + SemanticTarget, + StyleSpec, + UpdateDomain, + UpdateTarget, +} from './interactions'; +export type { + CanvasInteractionAction, + CanvasInteractionEvent, + DomainCoordinate, + DomainGeometry, + PathProjection, + PlotGeometry, +} from './language/events'; +export { toCanvasInteractionEvent } from './canvas-interaction'; +export type { + ChartUpdateResult, + FreeformOverlayBody, + FreeformCloneBody, + FreeformOverlaySpec, + FreeformOverlayTransform, + FreeformSvgBody, + SemanticTargetRef, + SemanticTargetSelector, +} from './language/updates'; +export { matchesSemanticTargetSelector } from './language/updates'; +export { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupFocus, clickHighlight, contextActivate, doubleActivate, dragReorder, externalInteraction, hoverGroupFocus, inspect, inspectIndex, isCanvasInteraction, isExternalInteraction, lassoSelect, legendToggle, linkedBrush, longPress, navigate, select } from './interactions'; +export type { InspectIndexShow, InteractionEventSource } from './triggers'; +export { + axisBrushTrigger, + angularBrushTrigger, + dragTrigger, + brushZoomTrigger, + clickTrigger, + contextTrigger, + doubleActivateTrigger, + hoverTrigger, + inspectTrigger, + inspectIndexTrigger, + keyboardTrigger, + lassoTrigger, + longPressTrigger, + navigationTrigger, + rectangleTrigger, + xBrushTrigger, + yBrushTrigger, +} from './triggers'; +export { clampViewportStart, mountInteractiveChartSurface } from './surface'; + +export function buildInteractiveChart( + container: HTMLElement, + input: ChartAssemblyInput, + options: BuildInteractiveChartOptions, +): InteractiveChartSurface { + const { + backend, renderer, expressionInterpreter, background, + className, ariaLabel, chartId, updates, assistedTargeting, keyboardTargeting, dismiss, + } = options; + const interactions = normalizeInteractions(options.interactions); + const canvasInteractions = interactions.filter(isCanvasInteraction); + const hoverTolerance = Math.max(0, ...canvasInteractions + .filter((interaction) => interaction.eventSource.gesture === 'hover') + .map((interaction) => interaction.eventSource.targetTolerance ?? 0)); + if (backend !== 'vegalite' && interactions.length > 0) { + return mountInteractiveChartSurface( + container, + input, + { + async mount() { + throw new Error(`Semantic interactions are not supported by backend "${backend}".`); + }, + }, + { className, ariaLabel, chartId, updates }, + ); + } + switch (backend) { + case 'vegalite': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createVegaInteractiveRenderer } = await import('../vegalite/interactive'); + return createVegaInteractiveRenderer({ + renderer, + interactions: canvasInteractions, + enableSemanticUpdates: true, + expressionInterpreter, + background, + assistDistance: assistedTargeting === false + ? 0 + : typeof assistedTargeting === 'object' + && assistedTargeting.maxDistance !== undefined + ? Math.max(0, assistedTargeting.maxDistance) + : undefined, + hoverTolerance, + targetFeedback: { + assisted: typeof assistedTargeting === 'object' ? assistedTargeting : assistedTargeting ? {} : false, + keyboard: keyboardTargeting ? {} : false, + }, + keyboardTargeting, + dismiss, + }).mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel, chartId, updates, interactions }, + ); + case 'echarts': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createEChartsInteractiveRenderer } = await import('../echarts/interactive'); + return createEChartsInteractiveRenderer({ renderer }).mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel, chartId, updates, interactions }, + ); + case 'chartjs': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createChartjsInteractiveRenderer } = await import('../chartjs/interactive'); + return createChartjsInteractiveRenderer().mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel, chartId, updates, interactions }, + ); + case 'plotly': + return mountInteractiveChartSurface( + container, + input, + { + async mount(chartContainer, chartInput) { + const { createPlotlyInteractiveRenderer } = await import('../plotly/interactive'); + return createPlotlyInteractiveRenderer().mount(chartContainer, chartInput); + }, + }, + { className, ariaLabel, chartId, updates, interactions }, + ); + } +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts new file mode 100644 index 00000000..b83a91a0 --- /dev/null +++ b/packages/flint-js/src/interactive/interactions.ts @@ -0,0 +1,359 @@ +import type { + ChartUpdate, + InteractionContext, + NavigationDomainGuard, + SemanticElement, + SemanticTargetSelector, +} from '../core/interaction-contracts'; +import type { InteractionEventSource } from './triggers'; +import type { InspectIndexShow, InspectMode } from './triggers'; +import type { InspectGuideOptions, RegionGuideOptions } from './guides'; +import type { InteractionAffordance } from './affordances'; +import type { + NavigationAxes, +} from './language/events'; +import { + createBrushInteraction, + createBrushZoomInteraction, + createAngularBrushInteraction, + createAxisHighlightInteraction, + createClickAnnotateInteraction, + createClickGroupFocusInteraction, + createClickHighlightInteraction, + createContextActivateInteraction, + createDoubleActivateInteraction, + createInspectInteraction, + createInspectIndexInteraction, + createLongPressInteraction, + createLassoSelectInteraction, + createLegendToggleInteraction, + createSelectInteraction, + createNavigateInteraction, + createDragReorderInteraction, + createLinkedBrushInteraction, + createHoverGroupFocusInteraction, +} from './presets'; +import type { CanvasInteractionEvent } from './language/events'; +export type { + ChartUpdatePresenter, + InteractionContext, + NavigationDomainGuard, + NavigationRequest, + NavigationUpdate, + RenderHit, + SemanticElement, + SemanticTarget, +} from '../core/interaction-contracts'; + +export interface FlintInteractionEventDetail { + chartId: string; + interactionId: string; + timestamp: number; + transactionId?: string; + event: CanvasInteractionEvent; +} + +export type { + AxisProjection, + CanvasInteractionAction, + CanvasInteractionEvent, + DomainCoordinate, + DomainGeometry, + PlotGeometry, +} from './language/events'; + +export type { + ElementInteractionEvent, + InteractionModifiers, + InteractionPhase, + NavigationAxes, + NavigationInteractionEvent, + NavigationOperation, + PlotPoint, + PlotAngularSector, + PlotPolygon, + PlotRect, + RegionAxis, + RegionOperation, + RegionInteractionEvent, + SemanticInteractionEvent, +} from './language/events'; + +export type { + AnnotationCandidate, + AnnotationConnection, + AnnotationConnectorAnchor, + AnnotationSpec, + ChartOverlaySpec, + ChartUpdate, + ChartUpdateOp, + OverlayFieldEncoding, + OverlayMark, + OverlayStyleSpec, + StyleSpec, + SemanticTargetRef, + SemanticTargetSelector, + UpdateDomain, + UpdateTarget, +} from './language/updates'; + +export interface CanvasInteractionDef { + readonly id: string; + readonly eventSource: InteractionEventSource; + readonly affordances?: readonly InteractionAffordance[]; + /** Retained updates from interactions in the same group replace one another. */ + readonly retainedStateGroup?: string; + readonly navigationDomainGuard?: NavigationDomainGuard; + /** Claims legend activations exclusively, so a legend click never also reads as an element click. */ + readonly claimsLegendActivation?: boolean; + /** Claims native axis tick activations instead of treating them as mark activations. */ + readonly claimsAxisActivation?: boolean; + handle?(event: CanvasInteractionEvent, context: InteractionContext): ChartUpdate | null; +} + +export interface ExternalInteractionDef { + readonly id: string; + readonly external: true; + handle(payload: TPayload, context: InteractionContext): ChartUpdate | null; +} + +export type InteractionDef = CanvasInteractionDef | ExternalInteractionDef; + +export function externalInteraction(definition: { + id: string; + handle(payload: TPayload, context: InteractionContext): ChartUpdate | null; +}): ExternalInteractionDef { + return { ...definition, external: true }; +} + +export function isCanvasInteraction(interaction: InteractionDef): interaction is CanvasInteractionDef { + return !('external' in interaction); +} + +export function isExternalInteraction(interaction: InteractionDef): interaction is ExternalInteractionDef { + return 'external' in interaction; +} + +export type GroupBy = + | string + | readonly string[]; + +export interface AxisHighlightOptions { + id?: string; + axis?: 'x' | 'y'; + event?: 'hover' | 'click'; + dimOpacity?: number; +} + +export type ClickHighlightTarget = 'mark' | 'legend' | 'discreteAxis'; + +export interface ClickHighlightOptions { + id?: string; + dimOpacity?: number; + /** Semantic surfaces activated by this preset. Defaults to all three targets. */ + targets?: readonly ClickHighlightTarget[]; +} + +export interface ClickGroupFocusOptions { + id?: string; + dimOpacity?: number; + groupBy?: GroupBy; +} + +export interface ClickAnnotateOptions { + id?: string; + dimOpacity?: number; + format?: (element: SemanticElement, context: InteractionContext) => string; +} + +export interface LinkedBrushOptions extends SelectOptions { + groupBy: GroupBy; + brush?: 'rectangle' | 'lasso'; +} + +export interface HoverGroupFocusOptions { + id?: string; + groupBy: string | readonly string[]; + dimOpacity?: number; + /** Nearest-mark hover radius in renderer pixels. Defaults to 8. */ + tolerance?: number; +} + +export interface SelectOptions { + id?: string; + match?: 'intersect' | 'contain'; + dimOpacity?: number; + /** Transient region shown during the gesture; false disables visual feedback. */ + guide?: RegionGuideOptions | false; +} + +export interface BrushOptions extends SelectOptions { + mode?: 'ephemeral' | 'stateful'; +} + +export type AngularBrushOptions = SelectOptions & { mode?: 'ephemeral' | 'stateful' }; + +export type LassoSelectOptions = SelectOptions; + +export interface LegendToggleOptions { + id?: string; + mutedOpacity?: number; +} + +export interface ContextActivateOptions { + id?: string; +} + +export interface InspectOptions { + id?: string; + mode?: InspectMode; + /** Ordered modes cycled by wheel or context-menu gestures; mode is included automatically. */ + cycle?: readonly InspectMode[]; + /** Hit tolerance as a plot-size fraction. Defaults to 0.02 for XY and 0.01 otherwise. */ + tolerance?: number; + /** Transient guide shown while inspecting; false disables visual feedback. */ + guide?: InspectGuideOptions | false; + selector?: SemanticTargetSelector; + dimOpacity?: number; +} + +export interface InspectIndexOptions { + id?: string; + /** Independent chart axis used to acquire one index slice. */ + axis?: 'x' | 'y'; + /** Near-axis acquisition radius as a plot-size fraction. Defaults to 0.01. */ + tolerance?: number; + /** Which series to present: all, the first series, or a preferred initial series. */ + show?: InspectIndexShow; + /** Record field identifying a series; single-series policies switch through the legend. */ + seriesBy?: string; + guide?: InspectGuideOptions | false; + selector?: SemanticTargetSelector; +} + +export interface BrushZoomOptions { + id?: string; + axes?: 'x' | 'y' | 'xy'; + guide?: RegionGuideOptions | false; +} + +export interface LongPressOptions { + id?: string; + holdMs?: number; + dimOpacity?: number; +} + +export interface DoubleActivateOptions { + id?: string; + dimOpacity?: number; +} + +export interface NavigateOptions { + id?: string; + axes?: NavigationAxes | 'available'; + pan?: boolean; + zoom?: boolean; + wheelSensitivity?: number; + domainGuard?: Partial; +} + +export interface DragReorderOptions { + id?: string; +} + +export function clickHighlight(options: ClickHighlightOptions = {}): CanvasInteractionDef { + return createClickHighlightInteraction(options); +} + +export function axisHighlight(options: AxisHighlightOptions = {}): CanvasInteractionDef { + return createAxisHighlightInteraction(options); +} + +export function clickGroupFocus(options: ClickGroupFocusOptions = {}): CanvasInteractionDef { + return createClickGroupFocusInteraction({ + id: options.id ?? 'click-group-focus', + dimOpacity: options.dimOpacity, + groupBy: options.groupBy, + }); +} + +export function clickAnnotate(options: ClickAnnotateOptions = {}): CanvasInteractionDef { + return createClickAnnotateInteraction(options); +} + +export function linkedBrush(options: LinkedBrushOptions): CanvasInteractionDef { + return createLinkedBrushInteraction(options); +} + +export function hoverGroupFocus(options: HoverGroupFocusOptions): CanvasInteractionDef { + return createHoverGroupFocusInteraction({ ...options, id: options.id ?? 'hover-group-focus' }); +} + +export function select(options: SelectOptions = {}): CanvasInteractionDef { + return createSelectInteraction(options); +} + +export function lassoSelect(options: LassoSelectOptions = {}): CanvasInteractionDef { + return createLassoSelectInteraction(options); +} + +export function legendToggle(options: LegendToggleOptions = {}): CanvasInteractionDef { + return createLegendToggleInteraction(options); +} + +export function contextActivate(options: ContextActivateOptions = {}): CanvasInteractionDef { + return createContextActivateInteraction(options); +} + +export function inspect(options: InspectOptions = {}): CanvasInteractionDef { + return createInspectInteraction(options); +} + +export function inspectIndex(options: InspectIndexOptions = {}): CanvasInteractionDef { + return createInspectIndexInteraction(options); +} + +export function brushZoom(options: BrushZoomOptions = {}): CanvasInteractionDef { + return createBrushZoomInteraction(options); +} + +export function longPress(options: LongPressOptions = {}): CanvasInteractionDef { + return createLongPressInteraction(options); +} + +export function doubleActivate(options: DoubleActivateOptions = {}): CanvasInteractionDef { + return createDoubleActivateInteraction(options); +} + +export function brushX(options: BrushOptions = {}): CanvasInteractionDef { + return createBrushInteraction('x', options); +} + +export function brushY(options: BrushOptions = {}): CanvasInteractionDef { + return createBrushInteraction('y', options); +} + +/** Select an angular interval on a polar chart. */ +export function brushAngle(options: AngularBrushOptions = {}): CanvasInteractionDef { + return createAngularBrushInteraction(options); +} + +export function navigate(options: NavigateOptions = {}): CanvasInteractionDef { + return createNavigateInteraction(options); +} + +export function dragReorder(options: DragReorderOptions = {}): CanvasInteractionDef { + return createDragReorderInteraction(options); +} + +export function normalizeInteractions( + interactions: readonly InteractionDef[] | undefined, +): readonly InteractionDef[] { + const normalized = [...(interactions ?? [])]; + const ids = new Set(); + for (const interaction of normalized) { + if (ids.has(interaction.id)) throw new Error(`Duplicate interaction id: "${interaction.id}".`); + ids.add(interaction.id); + } + return normalized; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/language/events.ts b/packages/flint-js/src/interactive/language/events.ts new file mode 100644 index 00000000..88e27574 --- /dev/null +++ b/packages/flint-js/src/interactive/language/events.ts @@ -0,0 +1,141 @@ +import type { RenderHit, SemanticTarget } from '../../core/interaction-semantics'; +import type { PlotAngularSector, PlotPoint, PlotPolygon, PlotRect } from './geometry'; +import type { VisualProjection } from './projections'; + +export type { PlotAngularSector, PlotPoint, PlotPolygon, PlotRect } from './geometry'; +export type { AxisProjection, PathProjection, VisualProjection } from './projections'; + +export interface InteractionModifiers { + shift: boolean; + ctrl: boolean; + meta: boolean; +} + +export type InteractionPhase = 'start' | 'preview' | 'commit' | 'cancel'; +export type RegionAxis = 'x' | 'y' | 'xy' | 'angle'; +export type RegionOperation = 'create' | 'move' | 'resize-leading' | 'resize-trailing' | 'clear'; +export type NavigationAxes = 'x' | 'y' | 'xy'; +export type NavigationOperation = 'pan' | 'zoom' | 'reset'; + +export interface ElementInteractionEvent { + type: 'element'; + phase: 'preview' | 'commit' | 'cancel'; + hits: readonly RenderHit[]; + point?: PlotPoint; + modifiers?: InteractionModifiers; +} + +export interface RegionInteractionEvent { + type: 'region'; + phase: InteractionPhase; + axis: RegionAxis; + operation?: RegionOperation; + region: PlotRect | PlotPolygon | PlotAngularSector; + hits: readonly RenderHit[]; + match: 'intersect' | 'contain'; + modifiers?: InteractionModifiers; +} + +export interface NavigationInteractionEvent { + type: 'navigation'; + phase: InteractionPhase; + operation: NavigationOperation; + axes: NavigationAxes; + /** Incremental translation as a fraction of the plot width and height. */ + delta?: PlotPoint; + /** Multiplicative zoom where values greater than one zoom in. */ + factor?: number; + /** Zoom anchor as a fraction of the plot width and height. */ + anchor?: PlotPoint; + modifiers?: InteractionModifiers; +} + +export interface SemanticInteractionEvent { + type: 'semantic'; + source: 'element' | 'region'; + phase: InteractionPhase; + target: SemanticTarget | null; + point?: PlotPoint; + region?: PlotRect | PlotPolygon | PlotAngularSector; + axis?: RegionAxis; + operation?: RegionOperation; + modifiers?: InteractionModifiers; +} + +export type CanvasInteractionAction = + | 'hover-element' + | 'click-element' + | 'hover-legend' + | 'click-legend' + | 'hover-axis' + | 'click-axis' + | 'hover-facet' + | 'click-facet' + | 'hover-annotation' + | 'click-annotation' + | 'context-element' + | 'long-press-element' + | 'double-activate-element' + | 'context-legend' + | 'long-press-legend' + | 'double-activate-legend' + | 'context-axis' + | 'long-press-axis' + | 'double-activate-axis' + | 'context-facet' + | 'long-press-facet' + | 'double-activate-facet' + | 'context-annotation' + | 'long-press-annotation' + | 'double-activate-annotation' + | 'drag' + | 'select-region' + | 'brush-x' + | 'brush-y' + | 'brush-angle' + | 'pan-viewport' + | 'zoom-viewport' + | 'reset-viewport' + | 'inspect-x' + | 'inspect-y' + | 'inspect-xy' + | 'select-lasso' + | 'focus-element' + | 'activate-element'; + +export type PlotGeometry = + | { kind: 'point'; point: PlotPoint } + | { kind: 'drag'; start: PlotPoint; current: PlotPoint; delta: PlotPoint; axis?: 'x' | 'y' } + | { kind: 'rect'; rect: PlotRect; axis: Exclude } + | { kind: 'polygon'; polygon: PlotPolygon } + | { kind: 'angular-sector'; sector: PlotAngularSector } + | { + kind: 'viewport'; + axes: 'x' | 'y' | 'xy'; + delta?: PlotPoint; + factor?: number; + anchor?: PlotPoint; + }; + +export interface DomainGeometry { + x?: DomainCoordinate; + y?: DomainCoordinate; +} + +export type DomainCoordinate = + | { kind: 'value'; value: unknown } + | { kind: 'interval'; start: unknown; end: unknown }; + +export interface CanvasInteractionEvent { + action: CanvasInteractionAction; + phase: InteractionPhase; + operation?: RegionOperation | 'pan' | 'zoom' | 'reset'; + geometry: { + plot?: PlotGeometry; + domain?: DomainGeometry; + projection?: VisualProjection; + }; + target: SemanticTarget | null; + dropTarget?: SemanticTarget | null; + modifiers?: InteractionModifiers; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/language/geometry.ts b/packages/flint-js/src/interactive/language/geometry.ts new file mode 100644 index 00000000..1dbd1b10 --- /dev/null +++ b/packages/flint-js/src/interactive/language/geometry.ts @@ -0,0 +1,23 @@ +export interface PlotPoint { + x: number; + y: number; +} + +export interface PlotRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface PlotPolygon { + points: readonly PlotPoint[]; +} + +export interface PlotAngularSector { + center: PlotPoint; + innerRadius: number; + outerRadius: number; + startAngle: number; + endAngle: number; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/language/index.ts b/packages/flint-js/src/interactive/language/index.ts new file mode 100644 index 00000000..a79a8a3d --- /dev/null +++ b/packages/flint-js/src/interactive/language/index.ts @@ -0,0 +1,2 @@ +export * from './events'; +export * from './updates'; \ No newline at end of file diff --git a/packages/flint-js/src/interactive/language/projections.ts b/packages/flint-js/src/interactive/language/projections.ts new file mode 100644 index 00000000..9f4a2446 --- /dev/null +++ b/packages/flint-js/src/interactive/language/projections.ts @@ -0,0 +1,27 @@ +import type { SemanticElement } from '../../core/interaction-semantics'; +import type { PlotPoint } from './geometry'; +import type { PlotRect } from './geometry'; + +/** Projection supplied by a rendered path visual for a freeform pointer position. */ +export interface PathProjection { + kind: 'path'; + point: PlotPoint; + distance: number; + segment: { + start: SemanticElement; + end: SemanticElement; + t: number; + }; +} + +/** Projection of a pointer and semantic drop target onto one chart axis. */ +export interface AxisProjection { + kind: 'axis'; + axis: 'x' | 'y'; + point: PlotPoint; + targetBounds: PlotRect; + plotBounds: PlotRect; +} + +/** Backend-neutral projection supplied by the visual acquired for a gesture. */ +export type VisualProjection = PathProjection | AxisProjection; \ No newline at end of file diff --git a/packages/flint-js/src/interactive/language/updates.ts b/packages/flint-js/src/interactive/language/updates.ts new file mode 100644 index 00000000..090442e6 --- /dev/null +++ b/packages/flint-js/src/interactive/language/updates.ts @@ -0,0 +1,46 @@ +import type { + ChartUpdateOp, + SemanticTargetSelector, + UpdateTarget, +} from '../../core/interaction-contracts'; + +export type { + AnnotationCandidate, + AnnotationConnection, + AnnotationConnectorAnchor, + AnnotationSpec, + ChartUpdate, + ChartUpdateOp, + ChartOverlaySpec, + FreeformOverlayBody, + FreeformCloneBody, + FreeformOverlaySpec, + FreeformOverlayTransform, + FreeformSvgBody, + OverlayFieldEncoding, + OverlayMark, + OverlayStyleSpec, + StyleSpec, + SemanticTargetRef, + SemanticTargetSelector, + UpdateDomain, + UpdateTarget, +} from '../../core/interaction-contracts'; + +export interface ChartUpdateResult { + status: 'applied' | 'partially-applied' | 'unsupported'; + resolvedTargets: number; + unresolvedTargets: readonly UpdateTarget[]; + unsupportedOps: readonly ChartUpdateOp['op'][]; +} + +export function matchesSemanticTargetSelector( + selector: SemanticTargetSelector, + declaredFields: readonly string[], + value: Readonly>, +): boolean { + const entries = Object.entries(selector.select.key); + return entries.length > 0 + && entries.every(([field]) => declaredFields.includes(field)) + && entries.every(([field, expected]) => Object.is(value[field], expected)); +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presentation/annotation.ts b/packages/flint-js/src/interactive/presentation/annotation.ts new file mode 100644 index 00000000..5aadb421 --- /dev/null +++ b/packages/flint-js/src/interactive/presentation/annotation.ts @@ -0,0 +1,223 @@ +import type { + AnnotationCandidate, + AnnotationConnection, + ChartUpdatePresenter, + InteractionContext, + SemanticElement, +} from '../interactions'; + +export function annotationCandidates( + ...connections: readonly AnnotationConnection[] +): readonly AnnotationCandidate[] { + return connections.map((connection, priority) => ({ + connection, + priority, + })); +} + +export function valueEndAnnotationCandidates( + valueAxis: 'x' | 'y', + ...fallbacks: readonly AnnotationConnection[] +): readonly AnnotationCandidate[] { + return [ + { connection: 'value-end', valueAxis, priority: 0 }, + ...fallbacks.map((connection, index) => ({ connection, priority: index + 1 })), + ]; +} + +export function lollipopAnnotationCandidates( + valueAxis: 'x' | 'y', +): readonly AnnotationCandidate[] { + const sideConnections: readonly AnnotationConnection[] = valueAxis === 'y' + ? ['right', 'left'] + : ['top', 'bottom']; + return [ + { connection: 'value-end', valueAxis, anglePreference: 'oblique', priority: 0 }, + ...sideConnections.map((connection, index) => ({ + connection, + anglePreference: 'oblique' as const, + priority: index + 1, + })), + ]; +} + +export function barAnnotationCandidates( + valueAxis: 'x' | 'y', +): readonly AnnotationCandidate[] { + const crossAxisEdges: readonly AnnotationConnection[] = valueAxis === 'y' + ? ['top', 'bottom'] + : ['right', 'left']; + return [ + { connection: 'value-end', valueAxis, priority: 0 }, + { connection: 'value-side', valueAxis, crossSide: 'start', valueInset: 1 / 8, priority: 1 }, + { connection: 'value-side', valueAxis, crossSide: 'end', valueInset: 1 / 8, priority: 1 }, + ...crossAxisEdges.map((connection) => ({ connection, priority: 2 })), + ]; +} + +export function presentAnnotationUpdate( + presentAnnotation: ( + element: SemanticElement, + context: InteractionContext, + visual?: Partial, + ) => AnnotationCandidate | readonly AnnotationCandidate[], + formatAnnotation: ( + element: SemanticElement, + context: InteractionContext, + visual?: Partial, + ) => string | undefined = defaultAnnotationText, +): ChartUpdatePresenter { + return (update, context) => ({ + id: update.id, + ops: update.ops.flatMap((op) => { + if (op.op !== 'set-annotation' || op.value === null || 'select' in op.target) return op; + const element = op.target.elements[0]; + if (!element) return []; + const presentation = presentAnnotation(element, context, op.target.visual); + const text = op.value.text ?? formatAnnotation(element, context, op.target.visual); + if (!text) return []; + return { + ...op, + value: { + ...op.value, + text, + candidates: Array.isArray(presentation) ? presentation : [presentation], + subject: op.target.visual, + }, + }; + }), + }); +} + +export const suppressAnnotationUpdate: ChartUpdatePresenter = (update) => ({ + id: update.id, + ops: update.ops.filter((op) => op.op !== 'set-annotation'), +}); + +function displayValue(field: string | undefined, value: unknown): string | undefined { + if (value === null || value === undefined) return undefined; + if (value instanceof Date) return value.toLocaleString(); + if (typeof value === 'number' && Number.isFinite(value)) { + if (field && /date|time|start|end/i.test(field)) { + const date = new Date(value); + if (date.getFullYear() >= 1900 && date.getFullYear() <= 2200) { + return date.toLocaleDateString(); + } + } + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 3 }).format(value); + } + return String(value); +} + +export function rangeAnnotationText( + startField: string | undefined, + endField: string | undefined, +): (element: SemanticElement) => string | undefined { + return (element) => { + if (!startField || !endField) return undefined; + const value = element.value ?? {}; + const record = startField in value || endField in value + ? value + : element.records?.[0] ?? value; + const start = displayValue(startField, record[startField]); + const end = displayValue(endField, record[endField]); + return start && end ? `${start} → ${end}` : start ?? end; + }; +} + +export function transitionAnnotationText( + field: string | undefined, +): (element: SemanticElement) => string | undefined { + return (element) => { + if (!field) return undefined; + const records = element.records ?? (element.value ? [element.value] : []); + const start = displayValue(field, records[0]?.[field]); + const end = displayValue(field, records[1]?.[field]); + return start && end ? `${start} → ${end}` : start ?? end; + }; +} + +export function seriesValuesAnnotationText( + seriesField: string | undefined, + valueField: string | undefined, +): (element: SemanticElement) => string | undefined { + return (element) => { + if (!seriesField || !valueField) return undefined; + const records = element.records ?? (element.value ? [element.value] : []); + const values = records.flatMap((record) => { + const series = displayValue(seriesField, record[seriesField]); + const value = displayValue(valueField, record[valueField]); + return series && value ? [`${series}: ${value}`] : []; + }); + return values.length > 0 ? [...new Set(values)].join(', ') : undefined; + }; +} + +export function categoryValueAnnotationText( + categoryField: string | undefined, + valueField: string | undefined, +): (element: SemanticElement) => string | undefined { + return (element) => { + const record = element.records?.[0] ?? element.value ?? {}; + const category = displayValue(categoryField, categoryField ? record[categoryField] : undefined); + const value = displayValue(valueField, valueField ? record[valueField] : undefined); + return category && value ? `${category}: ${value}` : category ?? value; + }; +} + +export function valueAnnotationText( + valueField: string | undefined, +): (element: SemanticElement) => string | undefined { + return (element) => { + if (!valueField) return undefined; + const record = element.records?.[0] ?? element.value ?? {}; + return displayValue(valueField, record[valueField]); + }; +} + +export function comparisonAnnotationText( + actualField: string | undefined, + expectedField: string | undefined, +): (element: SemanticElement) => string | undefined { + return (element) => { + if (!actualField || !expectedField) return undefined; + const record = element.records?.[0] ?? element.value ?? {}; + const actual = displayValue(actualField, record[actualField]); + const expected = displayValue(expectedField, record[expectedField]); + if (!actual || !expected) return actual ?? expected; + return `Actual: ${actual}\nExpected: ${expected}`; + }; +} + +function defaultAnnotationText(element: SemanticElement, context: InteractionContext): string | undefined { + const record = element.records?.[0] ?? element.value ?? {}; + const candidates = Object.entries(record).filter(([field, value]) => !field.startsWith('__') + && field !== context.categoryField && field !== context.seriesField + && value !== null && value !== undefined); + const selected = [...candidates].reverse().find(([, value]) => typeof value === 'number' && Number.isFinite(value)) + ?? candidates[candidates.length - 1]; + return displayValue(selected?.[0], selected?.[1]); +} + +export function countAnnotationText(element: SemanticElement): string | undefined { + const count = [element.value, ...(element.records ?? [])] + .flatMap((record) => Object.entries(record ?? {})) + .find(([field, value]) => /count/i.test(field) + && typeof value === 'number' && Number.isFinite(value)); + return displayValue(count?.[0], count?.[1]); +} + +export function boxplotAnnotationText(element: SemanticElement): string | undefined { + const record = element.value ?? element.records?.[0] ?? {}; + const summaryValue = (prefix: string): [string, unknown] | undefined => + Object.entries(record).find(([field, value]) => field.startsWith(prefix) + && typeof value === 'number' && Number.isFinite(value)); + const median = summaryValue('mid_box_'); + const lower = summaryValue('lower_box_'); + const upper = summaryValue('upper_box_'); + const medianText = displayValue(median?.[0], median?.[1]); + const lowerText = displayValue(lower?.[0], lower?.[1]); + const upperText = displayValue(upper?.[0], upper?.[1]); + const iqrText = lowerText && upperText ? `IQR: ${lowerText} → ${upperText}` : undefined; + return [medianText ? `Median: ${medianText}` : undefined, iqrText].filter(Boolean).join('\n') || undefined; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/README.md b/packages/flint-js/src/interactive/presets/README.md new file mode 100644 index 00000000..e82a3f4f --- /dev/null +++ b/packages/flint-js/src/interactive/presets/README.md @@ -0,0 +1,94 @@ +# Interaction Presets + +Presets translate `CanvasInteractionEvent` values into renderer-neutral +`ChartUpdate` JSON. They decide what action to take, not how a particular mark should +draw that action. Returned updates use the same retained state and private gesture +preview state as other canvas interactions. + +Presets are a small convenience layer, not the primary interaction API. Flint separately +provides canvas definitions that emit resolved events, external definitions that bind +application payloads, and the `ChartUpdateOp` data language. Applications compose these +contracts with ordinary JavaScript when behavior is product-specific. + +A preset earns a built-in when the action-to-update pairing is broadly useful and has +non-trivial lifecycle behavior that Flint should implement consistently. The intended +core set is: + +- hover highlight with preview and cancel restoration; +- click highlight or toggle selection; +- rectangle, axis, and angular selection highlight; +- guarded pan, zoom, and viewport reset. + +Flint should not add a preset for every combination of action and update. Legend +toggle/isolate, annotation text formatting, group expansion, linked views, tooltips, +drilldown, and application-specific relationships are usually recipes. Built-ins remain +only when they represent broadly reusable canvas policies. + +```ts +const legendSelection: CanvasInteractionDef = { + id: 'legend-selection', + eventSource: clickTrigger, + handle: (event) => { + if (event.action !== 'click-legend' || !event.target) return null; + return { + id: 'legend-selection', + ops: [ + { + op: 'set-style', targets: [event.target], + value: { state: 'emphasized' }, + }, + { op: 'set-annotation', target: event.target, value: { text: 'Selected series' } }, + ], + }; + }, +}; +``` + +`CanvasInteractionEvent`, `externalInteraction()`, `surface.dispatch()`, direct update +state APIs, and output-only canvas definitions are implemented for the Vega-Lite +semantic runtime. Current presets consume the public canvas event shape and produce +public update JSON; target resolution and backend application remain internal. Other +interactive backends and additional appearance/visibility operations remain future work. + +Region presets follow chart geometry. `brushX()` and `brushY()` consume Cartesian intervals, while `brushAngle()` consumes an annular sector and is admitted only by polar ChartDefs such as pie, donut, rose, and radar. Angular brushing resolves arc slices as well as Radar line segments and points contained by or intersecting the sector. All three produce the same semantic `set-style` operation after the owning ChartDef resolves physical hits. + +## Emphasis Behavior + +Built-in selection presets use one clear opacity rule: + +- focused elements retain their authored opacity; +- unfocused elements use `0.25` opacity; +- categorical color does not introduce a separate dimming range. + +Keeping one value makes linked views predictable. A bar, point, arc, line, or cell receives the same emphasized presentation state even when its chart presents focus differently. + +## Representation-Aware Presentation + +The owning ChartDef may add a focus treatment when opacity alone is insufficient: + +| Representation | Focus presentation | +| --- | --- | +| Filled categorical marks | Full opacity; unfocused peers at `0.25` | +| Lines | Full opacity and authored stroke width multiplied by `1.2`; unfocused peers at `0.25` | +| Continuous-color cells | Full opacity plus a contiguous-region boundary; unfocused cells at `0.25` | + +Line width is proportional rather than fixed, so a theme's authored hierarchy survives interaction: + +$$ +w_{focus} = 1.2 w_{authored} +$$ + +For continuous-color grids, the boundary is drawn once around each contiguous selected region rather than around every cell. This preserves the heatmap as a field instead of introducing a competing internal grid. Its paint comes from the grounded ThemeSpec `interaction.selectionBoundary` role. By default, the foreground borrows the theme accent and the halo borrows the plot surface, keeping the treatment visible over both ends of a color ramp without introducing renderer-owned colors. + +## Ownership + +The stages remain separate: + +1. Trigger normalization reports physical hits. +2. ChartDef resolution converts hits into semantic elements. +3. The coordinator emits the resolved event whether or not a preset is configured. +4. An optional preset emits `set-style` with selected elements and muted-peer opacity. +5. ChartDef presentation declares representation-specific focus styling. +6. The renderer applies opacity, proportional line width, or region boundaries mechanically. + +Presets must not inspect SVG, scenegraph geometry, color scales, or authored stroke widths. Those are renderer and ChartDef presentation concerns. \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/angular-brush.ts b/packages/flint-js/src/interactive/presets/angular-brush.ts new file mode 100644 index 00000000..846cd6ab --- /dev/null +++ b/packages/flint-js/src/interactive/presets/angular-brush.ts @@ -0,0 +1,17 @@ +import type { AngularBrushOptions, CanvasInteractionDef } from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from './utils'; +import { angularBrushTrigger } from '../triggers'; + +export function createAngularBrushInteraction(options: AngularBrushOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'brush-angle'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: angularBrushTrigger(options.match ?? 'intersect', options.mode ?? 'ephemeral', options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], + handle(event, context) { + if (event.action !== 'brush-angle' || event.phase === 'start' || event.phase === 'cancel') return null; + return emphasisUpdate(id, event, event.target, dimOpacity, context); + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/axis-highlight.ts b/packages/flint-js/src/interactive/presets/axis-highlight.ts new file mode 100644 index 00000000..f272122e --- /dev/null +++ b/packages/flint-js/src/interactive/presets/axis-highlight.ts @@ -0,0 +1,27 @@ +import type { AxisHighlightOptions, CanvasInteractionDef } from '../interactions'; +import { clickTrigger, hoverTrigger } from '../triggers'; +import { emphasisUpdate, normalizedOpacity } from './utils'; + +export function createAxisHighlightInteraction(options: AxisHighlightOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'axis-highlight'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: options.event === 'hover' ? hoverTrigger : clickTrigger, + claimsAxisActivation: true, + affordances: [{ + target: 'axis-label', + ...(options.event === 'hover' ? {} : { cursor: 'activate' as const }), + hover: 'cohort', + }], + handle(event, context) { + if (event.action !== 'hover-axis' && event.action !== 'click-axis') return null; + if (event.phase === 'start') return null; + const target = event.target?.visual.kind === 'axis' + && (!options.axis || event.target.elements.some((element) => element.value.axis === options.axis)) + ? event.target + : null; + return emphasisUpdate(id, event, target, dimOpacity, context); + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/brush-zoom.ts b/packages/flint-js/src/interactive/presets/brush-zoom.ts new file mode 100644 index 00000000..faa20d53 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/brush-zoom.ts @@ -0,0 +1,38 @@ +import type { BrushZoomOptions, CanvasInteractionDef, UpdateDomain } from '../interactions'; +import { brushZoomTrigger } from '../triggers'; + +const REGION_ACTIONS = new Set(['select-region', 'brush-x', 'brush-y']); + +/** Reduces the viewport to the brushed region, as an exact absolute domain. */ +export function createBrushZoomInteraction(options: BrushZoomOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'brush-zoom'; + const axes = options.axes ?? 'xy'; + return { + id, + eventSource: brushZoomTrigger(axes, options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], + handle(event) { + if (!REGION_ACTIONS.has(event.action) || event.phase !== 'commit' || event.operation === 'clear') return null; + const domain = event.geometry.domain; + if (!domain) return null; + const value: { x?: UpdateDomain; y?: UpdateDomain } = {}; + for (const axis of ['x', 'y'] as const) { + if (axes !== 'xy' && axes !== axis) continue; + const coordinate = domain[axis]; + if (coordinate?.kind !== 'interval') continue; + if (Object.is(coordinate.start, coordinate.end)) continue; + value[axis] = [coordinate.start, coordinate.end]; + } + const resolved = (['x', 'y'] as const).filter((axis) => value[axis]); + if (resolved.length === 0) return null; + return { + id, + ops: [{ + op: 'set-viewport', + axes: resolved.length === 2 ? 'xy' : resolved[0], + value, + }], + }; + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/brush.ts b/packages/flint-js/src/interactive/presets/brush.ts new file mode 100644 index 00000000..98783439 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/brush.ts @@ -0,0 +1,23 @@ +import type { BrushOptions, CanvasInteractionDef } from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from './utils'; +import { axisBrushTrigger } from '../triggers'; +import { expandRangedDotTarget } from './ranged-dot-target'; + +export function createBrushInteraction(axis: 'x' | 'y', options: BrushOptions = {}): CanvasInteractionDef { + const id = options.id ?? `brush-${axis}`; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + axis, + eventSource: axisBrushTrigger(axis, options.match ?? 'intersect', options.mode ?? 'ephemeral', options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], + handle(event, context) { + const acceptsAngular = axis === 'x' && event.action === 'brush-angle'; + if ((event.action !== `brush-${axis}` && !acceptsAngular) + || event.phase === 'start' + || event.phase === 'cancel') return null; + const target = expandRangedDotTarget(event.target, context); + return emphasisUpdate(id, event, target, dimOpacity, context); + }, + } as CanvasInteractionDef & { axis: 'x' | 'y' }; +} diff --git a/packages/flint-js/src/interactive/presets/click-annotate.ts b/packages/flint-js/src/interactive/presets/click-annotate.ts new file mode 100644 index 00000000..c70b791b --- /dev/null +++ b/packages/flint-js/src/interactive/presets/click-annotate.ts @@ -0,0 +1,41 @@ +import type { + ClickAnnotateOptions, + CanvasInteractionDef, +} from '../interactions'; +import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; +import { assistedElementTrigger, clickTrigger } from '../triggers'; + +export function createClickAnnotateInteraction(options: ClickAnnotateOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'click-annotate'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: assistedElementTrigger(clickTrigger, 8), + affordances: [{ target: 'mark', cursor: 'activate' }], + handle(event, context) { + if (!isActivationAction(event.action) || event.phase !== 'commit') return null; + if (event.target?.visual.role === 'legend-item') return null; + if (!event.target) { + return { + id, + ops: [ + { op: 'set-annotation', target: { select: { key: {} } }, value: null }, + { op: 'set-style', targets: [], value: { state: 'normal' } }, + ], + }; + } + const element = event.target.elements[0]; + if (!element) return null; + const emphasis = emphasisUpdate(id, event, event.target, dimOpacity, context); + const text = options.format?.(element, context); + return { + id, + ops: [{ + op: 'set-annotation', + target: { visual: event.target.visual, elements: [element] }, + value: text === undefined ? {} : { text }, + }, ...(emphasis?.ops ?? [])], + }; + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/click-group-highlight.ts b/packages/flint-js/src/interactive/presets/click-group-highlight.ts new file mode 100644 index 00000000..c7c2138d --- /dev/null +++ b/packages/flint-js/src/interactive/presets/click-group-highlight.ts @@ -0,0 +1,75 @@ +import type { + ClickGroupFocusOptions, + InteractionContext, + CanvasInteractionDef, + SemanticElement, + SemanticTarget, + GroupBy, +} from '../interactions'; +import type { InteractionAffordance } from '../affordances'; +import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; +import { assistedElementTrigger, clickTrigger } from '../triggers'; +import { expandElementsByFields } from './semantic-cohort'; + +type GroupFocusEngineOptions = ClickGroupFocusOptions; + +function groupValue( + element: SemanticElement, + context: InteractionContext, + groupBy: GroupBy | undefined, +): unknown { + const record = element.records?.[0]; + if (!record) return undefined; + if (typeof groupBy === 'string') return record[groupBy]; + if (context.resolveGroupValue) return context.resolveGroupValue(element); + + const field = context.chartType === 'Strip Plot' ? context.categoryField : context.seriesField; + return field ? record[field] : undefined; +} + +function groupElements( + target: SemanticTarget, + context: InteractionContext, + groupBy: GroupBy | undefined, +): readonly SemanticElement[] { + if (groupBy !== undefined) { + return expandElementsByFields(target.elements, context.available, groupBy); + } + const source = target.elements[0]; + if (!source) return target.elements; + const value = groupValue(source, context, groupBy); + if (value === undefined) return target.elements; + + const available = context.available ?? []; + const values = new Set(available.map((element) => groupValue(element, context, groupBy))); + if (values.size < 2) return target.elements; + + const cohort = available.filter((element) => Object.is( + groupValue(element, context, groupBy), value, + )); + return cohort.length > 1 ? cohort : target.elements; +} + +export function createClickGroupFocusInteraction(options: GroupFocusEngineOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'click-group-focus'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + const affordances: InteractionAffordance[] = [ + { target: 'mark', cursor: 'activate', hover: 'cohort' }, + ]; + return { + id, + eventSource: assistedElementTrigger(clickTrigger, 8), + retainedStateGroup: 'focus', + affordances, + handle(event, context) { + if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; + if (event.target?.visual.role === 'legend-item') return null; + const target = event.target + ? { ...event.target, elements: groupElements( + event.target, context, options.groupBy, + ) } + : null; + return emphasisUpdate(id, event, target, dimOpacity, context); + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/click-highlight.ts b/packages/flint-js/src/interactive/presets/click-highlight.ts new file mode 100644 index 00000000..d4f12f3b --- /dev/null +++ b/packages/flint-js/src/interactive/presets/click-highlight.ts @@ -0,0 +1,38 @@ +import type { CanvasInteractionDef, ClickHighlightOptions, ClickHighlightTarget } from '../interactions'; +import type { InteractionAffordance } from '../affordances'; +import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; +import { assistedElementTrigger, clickTrigger } from '../triggers'; +import { expandRangedDotTarget } from './ranged-dot-target'; + +const DEFAULT_TARGETS: readonly ClickHighlightTarget[] = ['mark', 'legend', 'discreteAxis']; + +export function createClickHighlightInteraction(options: ClickHighlightOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'click-highlight'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + const targets = new Set(options.targets ?? DEFAULT_TARGETS); + const affordances: InteractionAffordance[] = []; + if (targets.has('mark')) affordances.push({ target: 'mark', cursor: 'activate', hover: 'target' }); + if (targets.has('legend')) affordances.push({ target: 'legend-item', cursor: 'activate', hover: 'cohort' }); + if (targets.has('discreteAxis')) affordances.push({ target: 'axis-label', cursor: 'activate', hover: 'cohort' }); + return { + id, + eventSource: assistedElementTrigger(clickTrigger, 8), + retainedStateGroup: 'focus', + claimsLegendActivation: targets.has('legend'), + claimsAxisActivation: targets.has('discreteAxis'), + affordances, + handle(event, context) { + if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; + if (!event.target) return emphasisUpdate(id, event, null, dimOpacity, context); + const isLegend = event.target.visual.role === 'legend-item'; + const isAxis = event.target.visual.kind === 'axis'; + if (isLegend && !targets.has('legend')) return null; + if (isAxis && !targets.has('discreteAxis')) return null; + if (!isLegend && !isAxis && !targets.has('mark')) return null; + const target = isLegend || isAxis + ? event.target + : expandRangedDotTarget(event.target, context); + return emphasisUpdate(id, event, target, dimOpacity, context); + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/context-activate.ts b/packages/flint-js/src/interactive/presets/context-activate.ts new file mode 100644 index 00000000..ab7067b0 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/context-activate.ts @@ -0,0 +1,16 @@ +import type { CanvasInteractionDef, ContextActivateOptions } from '../interactions'; +import { assistedElementTrigger, contextTrigger } from '../triggers'; + +/** + * Reports a context request on the chart target and leaves the chart unchanged; + * opening a menu is the application's decision. + */ +export function createContextActivateInteraction( + options: ContextActivateOptions = {}, +): CanvasInteractionDef { + return { + id: options.id ?? 'context-activate', + eventSource: assistedElementTrigger(contextTrigger, 8), + affordances: [{ target: 'mark', cursor: 'activate' }], + }; +} diff --git a/packages/flint-js/src/interactive/presets/drag-reorder.ts b/packages/flint-js/src/interactive/presets/drag-reorder.ts new file mode 100644 index 00000000..a0e1acff --- /dev/null +++ b/packages/flint-js/src/interactive/presets/drag-reorder.ts @@ -0,0 +1,171 @@ +import type { CanvasInteractionDef, DragReorderOptions, SemanticElement } from '../interactions'; +import { dragTrigger } from '../triggers'; + +function categoryValue(element: SemanticElement | undefined, field: string): unknown { + return element?.records?.[0]?.[field] + ?? element?.value?.[field] + ?? (element?.value?.field === field ? element.value.value : undefined); +} + +export function reorderValues( + values: readonly unknown[], + source: unknown, + destination: unknown, +): unknown[] { + const sourceIndex = values.findIndex((value) => Object.is(value, source)); + const destinationIndex = values.findIndex((value) => Object.is(value, destination)); + if (sourceIndex < 0 || destinationIndex < 0 || sourceIndex === destinationIndex) return [...values]; + const reordered = [...values]; + const [moved] = reordered.splice(sourceIndex, 1); + reordered.splice(destinationIndex, 0, moved); + return reordered; +} + +export function createDragReorderInteraction(options: DragReorderOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'drag-reorder'; + return { + id, + eventSource: dragTrigger(), + affordances: [ + { target: 'mark', cursor: 'drag', hover: 'target' }, + { target: 'axis-label', cursor: 'drag', hover: 'target' }, + ], + handle(event, context) { + if (event.action !== 'drag' || (event.phase !== 'preview' && event.phase !== 'commit') + || !event.target || !event.dropTarget) return null; + const drag = event.geometry.plot?.kind === 'drag' ? event.geometry.plot : undefined; + const axes = context.reorderAxes?.length + ? context.reorderAxes + : context.categoryField && context.categoryAxis + ? [{ axis: context.categoryAxis, field: context.categoryField, order: context.categoryOrder ?? [] }] + : []; + const changedAxes = axes.filter(({ field }) => !Object.is( + categoryValue(event.target?.elements[0], field), + categoryValue(event.dropTarget?.elements[0], field), + )); + const preferredAxis = drag?.axis + ?? (drag && Math.abs(drag.delta.y) > Math.abs(drag.delta.x) ? 'y' : 'x'); + const selectedAxis = drag?.axis + ? axes.find(({ axis }) => axis === drag.axis) + : changedAxes.find(({ axis }) => axis === preferredAxis) ?? changedAxes[0]; + if (!selectedAxis) return null; + const { axis, field } = selectedAxis; + const source = categoryValue(event.target.elements[0], field); + const destination = categoryValue(event.dropTarget.elements[0], field); + if (event.phase === 'preview') { + const available = context.available ?? []; + const axisValues = selectedAxis.order.length > 0 + ? selectedAxis.order + : [...new Set(available.map((element) => categoryValue(element, field)) + .filter((value) => value !== undefined))]; + const sourceMarks = available.filter((element) => + Object.is(categoryValue(element, field), source)); + const otherMarks = available.filter((element) => + !Object.is(categoryValue(element, field), source)); + const axisElement = (value: unknown) => ({ value: { axis, field, value } }); + const sourceTargets = [ + ...(sourceMarks.length > 0 ? [{ + visual: event.target.visual.kind === 'mark' + ? event.target.visual + : { kind: 'mark' as const, role: 'mark' }, + elements: sourceMarks, + }] : []), + { + visual: { kind: 'axis' as const, role: 'axis-label' }, + elements: [axisElement(source)], + }, + ]; + const mutedTargets = [ + ...(otherMarks.length > 0 ? [{ + visual: { kind: 'mark' as const, role: 'mark' }, + elements: otherMarks, + }] : []), + { + visual: { kind: 'axis' as const, role: 'axis-label' }, + elements: axisValues + .filter((value) => !Object.is(value, source)) + .map(axisElement), + }, + ]; + const projection = event.geometry.projection?.kind === 'axis' + ? event.geometry.projection + : undefined; + const delta = drag?.delta ?? { x: 0, y: 0 }; + const translate = axis === 'x' + ? { x: delta.x, y: 0 } + : { x: 0, y: delta.y }; + const edge = (axis === 'x' ? delta.x : delta.y) >= 0 ? 'end' : 'start'; + const guide = projection && projection.axis === axis + ? axis === 'x' + ? { + x1: projection.targetBounds.x + + (edge === 'end' ? projection.targetBounds.width : 0), + y1: projection.plotBounds.y, + x2: projection.targetBounds.x + + (edge === 'end' ? projection.targetBounds.width : 0), + y2: projection.plotBounds.y + projection.plotBounds.height, + } + : { + x1: projection.plotBounds.x, + y1: projection.targetBounds.y + + (edge === 'end' ? projection.targetBounds.height : 0), + x2: projection.plotBounds.x + projection.plotBounds.width, + y2: projection.targetBounds.y + + (edge === 'end' ? projection.targetBounds.height : 0), + } + : undefined; + return { + id, + ops: [ + { op: 'set-style', targets: mutedTargets, value: { state: 'muted', opacity: 0.35 } }, + { op: 'set-style', targets: sourceTargets, value: { state: 'emphasized', opacity: 1 } }, + { + op: 'set-freeform-overlay', + name: 'drag-reorder-preview', + value: { + coordinateSpace: 'plot', + body: [ + { + type: 'clone', + targets: sourceTargets, + transform: { translate }, + opacity: 0.62, + }, + ...(guide ? [{ + type: 'svg' as const, + content: ``, + }] : []), + ], + }, + }, + ], + }; + } + const values = selectedAxis.order.length + ? [...selectedAxis.order] + : [...new Set((context.available ?? []).map((element) => categoryValue(element, field)) + .filter((value) => value !== undefined))]; + const orderedValues = reorderValues(values, source, destination); + // A concrete commit, even when the category did not move, replaces + // and therefore clears the transient drag preview. + if (orderedValues.every((value, index) => Object.is(value, values[index]))) { + return { id, ops: [] }; + } + const orderOps = [ + { op: 'set-order' as const, scope: 'category' as const, field, values: orderedValues }, + ...axes + .filter((candidate) => candidate !== selectedAxis && candidate.field !== field) + .map((candidate) => ({ + op: 'set-order' as const, + scope: 'category' as const, + field: candidate.field, + values: [...candidate.order], + })), + ]; + return { + id, + ops: orderOps, + }; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/hover-group-highlight.ts b/packages/flint-js/src/interactive/presets/hover-group-highlight.ts new file mode 100644 index 00000000..0dc1e8b2 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/hover-group-highlight.ts @@ -0,0 +1,33 @@ +import type { CanvasInteractionDef, HoverGroupFocusOptions } from '../interactions'; +import type { InteractionAffordance } from '../affordances'; +import { assistedElementTrigger, hoverTrigger } from '../triggers'; +import { expandElementsByFields } from './semantic-cohort'; +import { emphasisUpdate, normalizedOpacity } from './utils'; + +type HoverGroupFocusEngineOptions = HoverGroupFocusOptions; + +export function createHoverGroupFocusInteraction(options: HoverGroupFocusEngineOptions): CanvasInteractionDef { + const id = options.id ?? 'hover-group-focus'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + const tolerance = options.tolerance === undefined || !Number.isFinite(options.tolerance) + ? 8 + : Math.max(0, options.tolerance); + const affordances: InteractionAffordance[] = [{ target: 'mark', hover: 'cohort' }]; + return { + id, + eventSource: { + ...assistedElementTrigger(hoverTrigger, 6), + targetTolerance: tolerance, + }, + affordances, + handle(event, context) { + if (!event.action.startsWith('hover-') || event.phase !== 'preview' || !event.target) return null; + if (event.target.visual.role === 'legend-item') return null; + const target = { + ...event.target, + elements: expandElementsByFields(event.target.elements, context.available, options.groupBy), + }; + return emphasisUpdate(id, event, target, dimOpacity, context); + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/index.ts b/packages/flint-js/src/interactive/presets/index.ts new file mode 100644 index 00000000..3834ec39 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/index.ts @@ -0,0 +1,18 @@ +export { createBrushInteraction } from './brush'; +export { createBrushZoomInteraction } from './brush-zoom'; +export { createAngularBrushInteraction } from './angular-brush'; +export { createAxisHighlightInteraction } from './axis-highlight'; +export { createClickAnnotateInteraction } from './click-annotate'; +export { createClickGroupFocusInteraction } from './click-group-highlight'; +export { createClickHighlightInteraction } from './click-highlight'; +export { createContextActivateInteraction } from './context-activate'; +export { createInspectInteraction } from './inspect'; +export { createInspectIndexInteraction } from './inspect-index'; +export { createDoubleActivateInteraction, createLongPressInteraction } from './long-press'; +export { createLassoSelectInteraction } from './lasso-select'; +export { createLegendToggleInteraction } from './legend-toggle'; +export { createSelectInteraction } from './select'; +export { createNavigateInteraction } from './navigate'; +export { createDragReorderInteraction } from './drag-reorder'; +export { createLinkedBrushInteraction } from './linked-brush'; +export { createHoverGroupFocusInteraction } from './hover-group-highlight'; diff --git a/packages/flint-js/src/interactive/presets/inspect-index.ts b/packages/flint-js/src/interactive/presets/inspect-index.ts new file mode 100644 index 00000000..605c57c6 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/inspect-index.ts @@ -0,0 +1,23 @@ +import type { CanvasInteractionDef, InspectIndexOptions } from '../interactions'; +import type { InteractionAffordance } from '../affordances'; +import { inspectIndexTrigger } from '../triggers'; + +/** Reads values at one independent-axis position across one or more series. */ +export function createInspectIndexInteraction(options: InspectIndexOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'inspect-index'; + const axis = options.axis ?? 'x'; + const show = options.show ?? 'all'; + if (show !== 'all' && !options.seriesBy) { + throw new Error('inspectIndex({ show: "single" | { series } }) requires seriesBy.'); + } + const affordances: InteractionAffordance[] = show !== 'all' + ? [{ target: 'legend-item', cursor: 'activate', hover: 'cohort' }] + : [{ target: 'plot', cursor: 'inspect' }]; + return { + id, + eventSource: inspectIndexTrigger( + axis, show, options.seriesBy, options.selector, options.guide, options.tolerance, + ), + affordances, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/inspect.ts b/packages/flint-js/src/interactive/presets/inspect.ts new file mode 100644 index 00000000..2f028f44 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/inspect.ts @@ -0,0 +1,33 @@ +import type { CanvasInteractionDef, InspectOptions } from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from './utils'; +import { inspectTrigger } from '../triggers'; + +const INSPECT_ACTIONS = new Set(['inspect-x', 'inspect-y', 'inspect-xy']); + +/** + * Reads the value under the pointer without committing a selection, so it is + * the binding point for a crosshair or shared tooltip. + */ +export function createInspectInteraction(options: InspectOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'inspect'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: inspectTrigger( + options.mode ?? 'xy', options.selector, options.tolerance, options.guide, options.cycle, + ), + affordances: [{ target: 'plot', cursor: 'inspect' }], + handle(event, context) { + if (!INSPECT_ACTIONS.has(event.action) || event.phase === 'cancel') return null; + if (!event.target) return { + id, + ops: [{ + op: 'set-style', + targets: [], + value: { state: 'emphasized', mutedOpacity: dimOpacity }, + }], + }; + return emphasisUpdate(id, event, event.target, dimOpacity, context); + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/lasso-select.ts b/packages/flint-js/src/interactive/presets/lasso-select.ts new file mode 100644 index 00000000..838471d1 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/lasso-select.ts @@ -0,0 +1,17 @@ +import type { CanvasInteractionDef, LassoSelectOptions } from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from './utils'; +import { lassoTrigger } from '../triggers'; + +export function createLassoSelectInteraction(options: LassoSelectOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'lasso-select'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: lassoTrigger(options.match ?? 'intersect', options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], + handle(event, context) { + if (event.action !== 'select-lasso' || event.phase === 'start' || event.phase === 'cancel') return null; + return emphasisUpdate(id, event, event.target, dimOpacity, context); + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/legend-toggle.ts b/packages/flint-js/src/interactive/presets/legend-toggle.ts new file mode 100644 index 00000000..7dff9d65 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/legend-toggle.ts @@ -0,0 +1,98 @@ +import type { + CanvasInteractionDef, + CanvasInteractionEvent, + LegendToggleOptions, + SemanticElement, +} from '../interactions'; +import type { LegendTargetValue } from '../../core/interaction-contracts'; +import { isActivationAction, normalizedOpacity, semanticElementIdentity } from './utils'; +import { clickTrigger } from '../triggers'; + +function sameElement(left: SemanticElement, right: SemanticElement): boolean { + const leftLegend = left.value as LegendTargetValue; + const rightLegend = right.value as LegendTargetValue; + if (leftLegend.channel && leftLegend.domain && rightLegend.channel && rightLegend.domain) { + return JSON.stringify([leftLegend.channel, leftLegend.field, leftLegend.domain]) + === JSON.stringify([rightLegend.channel, rightLegend.field, rightLegend.domain]); + } + return semanticElementIdentity(left) === semanticElementIdentity(right); +} + +function withoutElements( + hidden: readonly SemanticElement[], + elements: readonly SemanticElement[], +): SemanticElement[] { + return hidden.filter((candidate) => !elements.some((element) => sameElement(candidate, element))); +} + +function legendElementMatches( + legendElement: SemanticElement, + candidate: SemanticElement, +): boolean { + const legend = legendElement.value as LegendTargetValue; + if (!legend.field || legend.domain?.kind !== 'value') return sameElement(legendElement, candidate); + const domainValue = legend.domain.value; + const records = candidate.records?.length ? candidate.records : [candidate.value]; + return records.some((record) => Object.is(record[legend.field!], domainValue)); +} + +function hidesFullLegendDomain( + elements: readonly SemanticElement[], + legendDomains: Readonly> | undefined, +): boolean { + if (!legendDomains) return false; + return Object.entries(legendDomains).some(([channel, domain]) => + domain.length > 0 && domain.every((value) => elements.some((element) => { + const legend = element.value as LegendTargetValue; + return legend.channel === channel + && legend.domain?.kind === 'value' + && Object.is(legend.domain.value, value); + }))); +} + +/** Only legend activations toggle series, so these presets compose with mark-click presets. */ +function legendActivation(event: CanvasInteractionEvent): boolean { + return isActivationAction(event.action) + && event.phase === 'commit' + && event.target?.visual.role === 'legend-item'; +} + +/** Hides or restores the activated series, the way a legend key normally behaves. */ +export function createLegendToggleInteraction(options: LegendToggleOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'legend-toggle'; + const mutedOpacity = normalizedOpacity(options.mutedOpacity); + let hidden: SemanticElement[] = []; + return { + id, + eventSource: clickTrigger, + claimsLegendActivation: true, + affordances: [{ target: 'legend-item', cursor: 'activate', hover: 'cohort' }], + handle(event, context) { + if (!legendActivation(event)) return null; + const elements = event.target?.elements ?? []; + if (elements.length === 0) return null; + const remaining = withoutElements(hidden, elements); + const hiding = remaining.length === hidden.length; + const next = hiding ? [...hidden, ...elements] : remaining; + const available = context.available ?? []; + const hasStableLegendDomain = context.legendDomains + && Object.values(context.legendDomains).some((domain) => domain.length > 0); + const hidesEverySeries = hiding && ( + hidesFullLegendDomain(next, context.legendDomains) + || (!hasStableLegendDomain && available.length > 0 && available.every((candidate) => + elements.some((element) => legendElementMatches(element, candidate)))) + ); + hidden = hidesEverySeries ? [] : next; + return { + id, + ops: [{ + op: 'set-style', + targets: hidden.length > 0 + ? [{ visual: { kind: 'legend', role: 'legend-item' }, elements: hidden }] + : [], + value: { visible: false, mutedOpacity }, + }], + }; + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/linked-brush.ts b/packages/flint-js/src/interactive/presets/linked-brush.ts new file mode 100644 index 00000000..2e9bc73f --- /dev/null +++ b/packages/flint-js/src/interactive/presets/linked-brush.ts @@ -0,0 +1,27 @@ +import type { CanvasInteractionDef, LinkedBrushOptions } from '../interactions'; +import { lassoTrigger, rectangleTrigger } from '../triggers'; +import { expandElementsByFields } from './semantic-cohort'; +import { emphasisUpdate, normalizedOpacity } from './utils'; + +export function createLinkedBrushInteraction(options: LinkedBrushOptions): CanvasInteractionDef { + const id = options.id ?? 'linked-brush'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + const lasso = options.brush === 'lasso'; + return { + id, + eventSource: lasso + ? lassoTrigger(options.match ?? 'intersect', options.guide) + : rectangleTrigger(options.match ?? 'intersect', options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], + handle(event, context) { + const expectedAction = lasso ? 'select-lasso' : 'select-region'; + if (event.action !== expectedAction || event.phase === 'start' || event.phase === 'cancel') return null; + if (!event.target) return emphasisUpdate(id, event, null, dimOpacity, context); + const target = { + ...event.target, + elements: expandElementsByFields(event.target.elements, context.available, options.groupBy), + }; + return emphasisUpdate(id, event, target, dimOpacity, context); + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/long-press.ts b/packages/flint-js/src/interactive/presets/long-press.ts new file mode 100644 index 00000000..bf5bf91e --- /dev/null +++ b/packages/flint-js/src/interactive/presets/long-press.ts @@ -0,0 +1,41 @@ +import type { + CanvasInteractionDef, + DoubleActivateOptions, + LongPressOptions, +} from '../interactions'; +import { assistedElementTrigger, doubleActivateTrigger, longPressTrigger } from '../triggers'; +import { emphasisUpdate, normalizedOpacity } from './utils'; + +/** Highlights and reports a sustained press on a chart target. */ +export function createLongPressInteraction(options: LongPressOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'long-press'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: assistedElementTrigger(longPressTrigger(options.holdMs ?? 500), 12), + affordances: [{ target: 'mark', cursor: 'activate', hover: 'target' }], + handle(event, context) { + if (!event.action.startsWith('long-press-') + || (event.phase !== 'preview' && event.phase !== 'commit')) return null; + return emphasisUpdate(id, event, event.target, dimOpacity, context); + }, + }; +} + +/** Highlights and reports a double activation for drill-down-style workflows. */ +export function createDoubleActivateInteraction( + options: DoubleActivateOptions = {}, +): CanvasInteractionDef { + const id = options.id ?? 'double-activate'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: assistedElementTrigger(doubleActivateTrigger, 8), + affordances: [{ target: 'mark', cursor: 'activate', hover: 'target' }], + handle(event, context) { + if (!event.action.startsWith('double-activate-') + || (event.phase !== 'preview' && event.phase !== 'commit')) return null; + return emphasisUpdate(id, event, event.target, dimOpacity, context); + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/navigate.ts b/packages/flint-js/src/interactive/presets/navigate.ts new file mode 100644 index 00000000..5a2fc2e6 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/navigate.ts @@ -0,0 +1,64 @@ +import type { + CanvasInteractionDef, + NavigateOptions, + NavigationDomainGuard, +} from '../interactions'; +import { navigationTrigger } from '../triggers'; + +const DEFAULT_DOMAIN_GUARD: NavigationDomainGuard = { + minVisibleFraction: 0.02, + maxVisibleFraction: 1, + overscrollFraction: 0, +}; + +function normalizedFraction(value: number | undefined, fallback: number, min: number): number { + return Number.isFinite(value) ? Math.max(min, value!) : fallback; +} + +export function createNavigateInteraction(options: NavigateOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'navigate'; + const domainGuard = { + minVisibleFraction: normalizedFraction( + options.domainGuard?.minVisibleFraction, + DEFAULT_DOMAIN_GUARD.minVisibleFraction, + Number.EPSILON, + ), + maxVisibleFraction: normalizedFraction( + options.domainGuard?.maxVisibleFraction, + DEFAULT_DOMAIN_GUARD.maxVisibleFraction, + Number.EPSILON, + ), + overscrollFraction: normalizedFraction( + options.domainGuard?.overscrollFraction, + DEFAULT_DOMAIN_GUARD.overscrollFraction, + 0, + ), + }; + if (domainGuard.maxVisibleFraction < domainGuard.minVisibleFraction) { + throw new Error('navigate() requires maxVisibleFraction >= minVisibleFraction.'); + } + return { + id, + navigationDomainGuard: domainGuard, + eventSource: navigationTrigger({ + axes: options.axes ?? 'available', + pan: options.pan ?? true, + zoom: options.zoom ?? true, + wheelSensitivity: options.wheelSensitivity ?? 0.002, + }), + affordances: options.pan === false ? [] : [{ target: 'plot', cursor: 'navigate' }], + handle(event, context) { + const viewport = event.geometry.plot; + if (!context.resolveNavigation || viewport?.kind !== 'viewport' || !event.operation) return null; + const op = context.resolveNavigation({ + phase: event.phase, + operation: event.operation as 'pan' | 'zoom' | 'reset', + axes: viewport.axes, + delta: viewport.delta, + factor: viewport.factor, + anchor: viewport.anchor, + }, domainGuard); + return op ? { id, ops: [op] } : null; + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/ranged-dot-target.ts b/packages/flint-js/src/interactive/presets/ranged-dot-target.ts new file mode 100644 index 00000000..a7d17506 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/ranged-dot-target.ts @@ -0,0 +1,15 @@ +import type { InteractionContext, SemanticTarget } from '../interactions'; + +export function expandRangedDotTarget( + target: SemanticTarget | null, + context: InteractionContext, +): SemanticTarget | null { + if (!target || context.chartType !== 'Ranged Dot Plot' + || target.visual.role === 'legend-item' || !context.categoryField) return target; + const categories = new Set(target.elements.flatMap((element) => + element.records?.map((record) => record[context.categoryField!]) ?? [])); + if (categories.size === 0) return target; + const elements = context.available?.filter((element) => + element.records?.some((record) => categories.has(record[context.categoryField!]))); + return elements?.length ? { ...target, elements } : target; +} diff --git a/packages/flint-js/src/interactive/presets/select.ts b/packages/flint-js/src/interactive/presets/select.ts new file mode 100644 index 00000000..c9a90aa3 --- /dev/null +++ b/packages/flint-js/src/interactive/presets/select.ts @@ -0,0 +1,17 @@ +import type { CanvasInteractionDef, SelectOptions } from '../interactions'; +import { emphasisUpdate, normalizedOpacity } from './utils'; +import { rectangleTrigger } from '../triggers'; + +export function createSelectInteraction(options: SelectOptions = {}): CanvasInteractionDef { + const id = options.id ?? 'select'; + const dimOpacity = normalizedOpacity(options.dimOpacity); + return { + id, + eventSource: rectangleTrigger(options.match ?? 'intersect', options.guide), + affordances: [{ target: 'plot', cursor: 'region' }], + handle(event, context) { + if (event.action !== 'select-region' || event.phase === 'start' || event.phase === 'cancel') return null; + return emphasisUpdate(id, event, event.target, dimOpacity, context); + }, + }; +} diff --git a/packages/flint-js/src/interactive/presets/semantic-cohort.ts b/packages/flint-js/src/interactive/presets/semantic-cohort.ts new file mode 100644 index 00000000..4de45cac --- /dev/null +++ b/packages/flint-js/src/interactive/presets/semantic-cohort.ts @@ -0,0 +1,32 @@ +import type { SemanticElement } from '../interactions'; + +function fieldValue(element: SemanticElement, field: string): unknown { + const record = element.records?.[0]; + return record && Object.prototype.hasOwnProperty.call(record, field) ? record[field] : undefined; +} + +function fieldKey(element: SemanticElement, fields: readonly string[]): readonly unknown[] | undefined { + const values = fields.map((field) => fieldValue(element, field)); + return values.some((value) => value === undefined) ? undefined : values; +} + +function sameKey(left: readonly unknown[], right: readonly unknown[]): boolean { + return left.length === right.length && left.every((value, index) => Object.is(value, right[index])); +} + +export function expandElementsByFields( + source: readonly SemanticElement[], + available: readonly SemanticElement[] | undefined, + fields: string | readonly string[], +): readonly SemanticElement[] { + const fieldList = typeof fields === 'string' ? [fields] : fields; + if (fieldList.length === 0 || !available?.length) return source; + const keys = source.map((element) => fieldKey(element, fieldList)) + .filter((key): key is readonly unknown[] => key !== undefined); + if (keys.length === 0) return source; + const cohort = available.filter((element) => { + const candidate = fieldKey(element, fieldList); + return candidate !== undefined && keys.some((key) => sameKey(candidate, key)); + }); + return cohort.length > 0 ? cohort : source; +} \ No newline at end of file diff --git a/packages/flint-js/src/interactive/presets/utils.ts b/packages/flint-js/src/interactive/presets/utils.ts new file mode 100644 index 00000000..362b92df --- /dev/null +++ b/packages/flint-js/src/interactive/presets/utils.ts @@ -0,0 +1,56 @@ +import type { + CanvasInteractionEvent, + ChartUpdate, + InteractionModifiers, + InteractionContext, + SemanticTarget, +} from '../interactions'; + +export const DEFAULT_DIM_OPACITY = 0.25; + +export function normalizedOpacity(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return DEFAULT_DIM_OPACITY; + return Math.min(1, Math.max(0, value)); +} + +/** Keyboard activation reaches the same presets as a click on the target. */ +export function isActivationAction(action: string): boolean { + return action.startsWith('click-') || action === 'activate-element'; +} + +export function semanticElementIdentity(element: SemanticTarget['elements'][number]): string { + return JSON.stringify([element.value, element.records ?? []]); +} + +function selectionMode(modifiers: InteractionModifiers | undefined): 'replace' | 'toggle' { + return modifiers?.shift || modifiers?.ctrl || modifiers?.meta ? 'toggle' : 'replace'; +} + +export function emphasisUpdate( + id: string, + event: Pick, + target: SemanticTarget | null, + dimOpacity: number, + context: InteractionContext, +): ChartUpdate | null { + if (!target) return { id, ops: [{ op: 'set-style', targets: [], value: { state: 'normal' } }] }; + if (target.elements.length === 0) return null; + const toggle = selectionMode(event.modifiers) === 'toggle'; + const targetKeys = new Set(target.elements.map(semanticElementIdentity)); + const allSelected = target.elements.every((element) => + context.selected.some((selected) => semanticElementIdentity(selected) === semanticElementIdentity(element))); + const elements = !toggle + ? target.elements + : allSelected + ? context.selected.filter((element) => !targetKeys.has(semanticElementIdentity(element))) + : [...context.selected, ...target.elements.filter((element) => + !context.selected.some((selected) => semanticElementIdentity(selected) === semanticElementIdentity(element)))]; + return { + id, + ops: [{ + op: 'set-style', + targets: elements.length > 0 ? [{ visual: target.visual, elements }] : [], + value: { state: elements.length > 0 ? 'emphasized' : 'normal', mutedOpacity: dimOpacity }, + }], + }; +} \ 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..647f0006 --- /dev/null +++ b/packages/flint-js/src/interactive/surface.ts @@ -0,0 +1,316 @@ +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { + InteractiveChartSurface, + InteractiveChartSurfaceOptions, + InteractiveRenderer, + InteractiveRendererAdapter, + ViewportChannel, + ViewportState, +} from './types'; +import { isExternalInteraction } from './interactions'; +import type { ChartUpdate, ChartUpdateResult } from './language/updates'; + +let generatedChartId = 0; + +function nextChartId(): string { + generatedChartId += 1; + return `flint-chart-${generatedChartId}`; +} + +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 chartId = options.chartId ?? nextChartId(); + const root = document.createElement('div'); + const chart = document.createElement('div'); + const state: ViewportState = {}; + const rails = new Map>(); + const interactions = options.interactions ?? []; + const externalInteractions = new Map( + interactions.filter(isExternalInteraction).map((interaction) => [interaction.id, interaction]), + ); + 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'); + root.dataset.flintChartId = chartId; + applyStyles(root, { + display: 'grid', gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateRows: 'minmax(0, auto) auto', + alignItems: 'stretch', rowGap: '6px', minWidth: '0', + }); + chart.dataset.flintChart = ''; + // The chart keeps its compiled width; handling any overflow is the host's decision. + applyStyles(chart, { gridColumn: '1', gridRow: '1', minWidth: '0' }); + 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 unsupportedUpdate = (update: ChartUpdate): ChartUpdateResult => ({ + status: 'unsupported', + resolvedTargets: 0, + unresolvedTargets: [], + unsupportedOps: [...new Set(update.ops.map((op) => op.op))], + }); + + const ready = adapter.mount(chart, input).then(async (mounted) => { + if (destroyed) { + mounted.destroy(); + return; + } + renderer = mounted; + if ((options.updates?.length ?? 0) > 0) { + if (!mounted.setUpdates) throw new Error('This interactive backend does not support chart updates.'); + await mounted.setUpdates(options.updates ?? []); + } + 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); + // Sizing to content keeps the surface honest when the chart is wider than its host. + root.style.width = 'max-content'; + }; + syncRailExtents(); + window.setTimeout(syncRailExtents, 0); + }); + + return { + element: root, + chartId, + ready, + getViewportState: () => ({ ...state }), + setViewport, + dispatch: async (interactionId, payload) => { + await ready; + if (destroyed) return null; + const interaction = externalInteractions.get(interactionId); + if (!interaction) { + const definition = interactions.find((candidate) => candidate.id === interactionId); + throw new Error(definition + ? `Interaction "${interactionId}" is a canvas interaction and cannot receive external payloads.` + : `External interaction "${interactionId}" is not defined.`); + } + if (!renderer?.getInteractionContext) { + throw new Error('This interactive backend does not provide interaction context.'); + } + const update = interaction.handle(payload, renderer.getInteractionContext()); + if (!update) return null; + if (!renderer.applyUpdate) return unsupportedUpdate(update); + return renderer.applyUpdate(update); + }, + applyUpdate: async (update, applyOptions) => { + await ready; + if (destroyed || !renderer?.applyUpdate) return unsupportedUpdate(update); + return renderer.applyUpdate(update, applyOptions); + }, + setUpdates: async (updates) => { + await ready; + if (destroyed || !renderer?.setUpdates) { + return updates.map(unsupportedUpdate); + } + return renderer.setUpdates(updates); + }, + clearUpdate: async (id) => { + await ready; + if (!destroyed) await renderer?.clearUpdate?.(id); + }, + refresh: () => { + if (!destroyed) renderer?.refresh?.(); + }, + 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/triggers.ts b/packages/flint-js/src/interactive/triggers.ts new file mode 100644 index 00000000..c9294a98 --- /dev/null +++ b/packages/flint-js/src/interactive/triggers.ts @@ -0,0 +1,231 @@ +import type { NavigationAxes } from './language/events'; +import type { SemanticTargetSelector } from '../core/interaction-contracts'; +import type { InspectGuideOptions, RegionGuideOptions } from './guides'; +import { normalizeInspectGuideOptions, normalizeRegionGuideOptions } from './guides'; + +export type InspectOperator = '<' | '<=' | '=' | '>=' | '>'; +export type InspectMode = + | 'x' | 'y' | 'xy' + | `x${InspectOperator}` | `y${InspectOperator}` | `xy${InspectOperator}` + | `x${InspectOperator};y${InspectOperator}`; + +export type InspectIndexShow = 'all' | 'single' | { series: unknown }; + +export interface InspectPredicate { + readonly x?: InspectOperator; + readonly y?: InspectOperator; +} + +export interface InteractionEventSource { + readonly type: 'element' | 'region' | (string & {}); + readonly gesture?: 'click' | 'hover' | 'drag' | 'navigate' | 'keyboard' | 'context' | 'inspect' | 'long-press' | 'double'; + readonly match?: 'intersect' | 'contain'; + readonly axis?: 'x' | 'y' | 'xy'; + readonly mode?: 'ephemeral' | 'stateful'; + readonly regionGeometry?: 'cartesian' | 'angular' | 'lasso'; + readonly inspect?: 'x' | 'y' | 'xy'; + readonly inspectPredicate?: InspectPredicate; + readonly inspectCycle?: readonly ReturnType[]; + readonly inspectTolerance?: number; + readonly inspectIndex?: { + readonly axis: 'x' | 'y'; + readonly show: InspectIndexShow; + readonly seriesBy?: string; + }; + /** Nearest-mark acquisition radius for hover gestures, in renderer pixels. */ + readonly targetTolerance?: number; + /** Preset-owned nearest-mark acquisition radius, in renderer pixels. */ + readonly defaultAssistDistance?: number; + readonly inspectGuide?: ReturnType; + readonly regionGuide?: ReturnType; + readonly selector?: SemanticTargetSelector; + readonly holdMs?: number; + readonly viewport?: boolean; + readonly axes?: NavigationAxes | 'available'; + readonly pan?: boolean; + readonly zoom?: boolean; + readonly wheelSensitivity?: number; +} + +/** Element drag locked to the semantic visual acquired at pointer-down. */ +export function dragTrigger(targetTolerance = 12): InteractionEventSource { + return { + type: 'element', + gesture: 'drag', + targetTolerance: Math.max(0, targetTolerance), + }; +} + +export const clickTrigger = Object.freeze({ + type: 'element', + gesture: 'click', +} as const satisfies InteractionEventSource); + +export const hoverTrigger = Object.freeze({ + type: 'element', + gesture: 'hover', +} as const satisfies InteractionEventSource); + +export function assistedElementTrigger( + source: InteractionEventSource, + defaultAssistDistance: number, +): InteractionEventSource { + return { ...source, defaultAssistDistance: Math.max(0, defaultAssistDistance) }; +} + +export function rectangleTrigger( + match: 'intersect' | 'contain' = 'intersect', + guide?: RegionGuideOptions | false, +): InteractionEventSource { + return { type: 'region', gesture: 'drag', match, regionGuide: normalizeRegionGuideOptions(guide) }; +} + +export function lassoTrigger( + match: 'intersect' | 'contain' = 'intersect', + guide?: RegionGuideOptions | false, +): InteractionEventSource { + return { + type: 'region', gesture: 'drag', regionGeometry: 'lasso', match, mode: 'ephemeral', + regionGuide: normalizeRegionGuideOptions(guide), + }; +} + +export function brushZoomTrigger( + axis: 'x' | 'y' | 'xy' = 'xy', + guide?: RegionGuideOptions | false, +): InteractionEventSource { + return { + type: 'region', gesture: 'drag', axis, match: 'intersect', mode: 'ephemeral', viewport: true, + regionGuide: normalizeRegionGuideOptions(guide), + }; +} + +export const keyboardTrigger = Object.freeze({ + type: 'element', + gesture: 'keyboard', +} as const satisfies InteractionEventSource); + +export const contextTrigger = Object.freeze({ + type: 'element', + gesture: 'context', +} as const satisfies InteractionEventSource); + +export function parseInspectMode(mode: InspectMode): { inspect: 'x' | 'y' | 'xy'; predicate: InspectPredicate } { + const shorthand = /^(x|y|xy)(<=|>=|=|<|>)?$/.exec(mode); + if (shorthand) { + const axes = shorthand[1]; + const operator = (shorthand[2] ?? '=') as InspectOperator; + return { + inspect: axes, + predicate: { + ...(axes.includes('x') ? { x: operator } : {}), + ...(axes.includes('y') ? { y: operator } : {}), + }, + } as { inspect: 'x' | 'y' | 'xy'; predicate: InspectPredicate }; + } + const mixed = /^x(<=|>=|=|<|>);y(<=|>=|=|<|>)$/.exec(mode); + if (!mixed) throw new Error(`Invalid inspect mode: ${mode}`); + return { inspect: 'xy', predicate: { x: mixed[1] as InspectOperator, y: mixed[2] as InspectOperator } }; +} + +/** Inspection modes are parsed once so renderer mounts consume structured predicates. */ +export function inspectTrigger( + mode: InspectMode = 'xy', + selector?: SemanticTargetSelector, + tolerance?: number, + guide?: InspectGuideOptions | false, + cycle: readonly InspectMode[] = [], +): InteractionEventSource { + const parsed = parseInspectMode(mode); + const cycleModes = [...new Set([mode, ...cycle])].map(parseInspectMode); + const defaultTolerance = parsed.inspect === 'xy' + && parsed.predicate.x === '=' + && parsed.predicate.y === '=' + ? 0.02 + : 0.01; + const inspectTolerance = tolerance === undefined || !Number.isFinite(tolerance) + ? defaultTolerance + : Math.min(0.5, Math.max(0, tolerance)); + return { + type: 'element', gesture: 'inspect', ...parsed, inspectTolerance, + inspectGuide: normalizeInspectGuideOptions(guide), + ...(cycle.length > 0 ? { inspectCycle: cycleModes } : {}), + ...(selector ? { selector } : {}), + }; +} + +export function inspectIndexTrigger( + axis: 'x' | 'y' = 'x', + show: InspectIndexShow = 'all', + seriesBy?: string, + selector?: SemanticTargetSelector, + guide?: InspectGuideOptions | false, + tolerance?: number, +): InteractionEventSource { + return { + ...inspectTrigger(axis, selector, tolerance, guide), + inspectIndex: { axis, show, ...(seriesBy ? { seriesBy } : {}) }, + }; +} + +/** Touch equivalent of a context request. */ +export function longPressTrigger(holdMs = 500): InteractionEventSource { + return { type: 'element', gesture: 'long-press', holdMs }; +} + +export const doubleActivateTrigger = Object.freeze({ + type: 'element', + gesture: 'double', +} as const satisfies InteractionEventSource); + +export function axisBrushTrigger( + axis: 'x' | 'y', + match: 'intersect' | 'contain' = 'intersect', + mode: 'ephemeral' | 'stateful' = 'ephemeral', + guide?: RegionGuideOptions | false, +): InteractionEventSource { + return { type: 'region', gesture: 'drag', axis, match, mode, regionGuide: normalizeRegionGuideOptions(guide) }; +} + +export function xBrushTrigger( + match: 'intersect' | 'contain' = 'intersect', + mode: 'ephemeral' | 'stateful' = 'ephemeral', + guide?: RegionGuideOptions | false, +): InteractionEventSource { + return axisBrushTrigger('x', match, mode, guide); +} + +export function yBrushTrigger( + match: 'intersect' | 'contain' = 'intersect', + mode: 'ephemeral' | 'stateful' = 'ephemeral', + guide?: RegionGuideOptions | false, +): InteractionEventSource { + return axisBrushTrigger('y', match, mode, guide); +} + +export function angularBrushTrigger( + match: 'intersect' | 'contain' = 'intersect', + mode: 'ephemeral' | 'stateful' = 'ephemeral', + guide?: RegionGuideOptions | false, +): InteractionEventSource { + return { + type: 'region', gesture: 'drag', regionGeometry: 'angular', match, mode, + regionGuide: normalizeRegionGuideOptions(guide), + }; +} + +export function navigationTrigger(options: { + axes?: NavigationAxes | 'available'; + pan?: boolean; + zoom?: boolean; + wheelSensitivity?: number; +} = {}): InteractionEventSource { + return { + type: 'navigation', + gesture: 'navigate', + axes: options.axes ?? 'available', + pan: options.pan ?? true, + zoom: options.zoom ?? true, + wheelSensitivity: options.wheelSensitivity ?? 0.002, + }; +} diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts new file mode 100644 index 00000000..42a6dd6f --- /dev/null +++ b/packages/flint-js/src/interactive/types.ts @@ -0,0 +1,92 @@ +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { InteractionContext, InteractionDef } from './interactions'; +import type { ChartUpdate, ChartUpdateResult } from './language/updates'; + +export type ViewportChannel = 'x' | 'y'; +export type ViewportState = Partial>; + +export interface ViewportGeometry { + offset: number; + extent: number; +} + +export type ChartUpdateComposition = 'auto'; + +/** Pointer acquisition that snaps to a nearby mark instead of requiring a direct hit. */ +export interface TargetDetailsOptions { + fields?: readonly string[]; + maxRows?: number; +} + +export interface TargetFeedbackOptions { + indicator?: boolean; + details?: boolean | TargetDetailsOptions; +} + +export interface AssistedTargetingOptions extends TargetFeedbackOptions { + /** Hard override for eligible preset distances, in renderer pixels. */ + maxDistance?: number; +} + +export interface ChartUpdateApplyOptions { + composition?: ChartUpdateComposition; +} + +export interface InteractionDismissPolicy { + click?: 'any' | 'non-element' | 'plot-background' | false; + escape?: boolean; +} + +export interface InteractiveRenderer { + viewports: CategoryViewport[]; + setViewports(starts: ViewportState): void | Promise; + getViewportGeometry?(channel: ViewportChannel): ViewportGeometry | undefined; + getInteractionContext?(): InteractionContext; + resize?(size: { width: number; height: number }): void | Promise; + /** Re-project overlays after the host rescales the chart in a way CSS cannot report. */ + refresh?(): void; + applyUpdate?(update: ChartUpdate, options?: ChartUpdateApplyOptions): Promise; + setUpdates?(updates: readonly ChartUpdate[]): Promise; + clearUpdate?(id: string): Promise; + destroy(): void; +} + +export interface InteractiveRendererAdapter { + mount(container: HTMLElement, input: ChartAssemblyInput): Promise; +} + +export interface InteractiveChartSurfaceOptions { + className?: string; + ariaLabel?: string; + chartId?: string; + updates?: readonly ChartUpdate[]; + interactions?: readonly InteractionDef[]; + /** Presets assist by default; false disables it and maxDistance overrides eligible presets. */ + assistedTargeting?: boolean | AssistedTargetingOptions; + keyboardTargeting?: boolean; + /** How committed presentation and annotation state is cleared. */ + dismiss?: InteractionDismissPolicy | false; +} + +export type InteractiveBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; + +export interface BuildInteractiveChartOptions extends InteractiveChartSurfaceOptions { + backend: InteractiveBackend; + renderer?: 'canvas' | 'svg'; + expressionInterpreter?: unknown; + background?: string; +} + +export interface InteractiveChartSurface { + readonly element: HTMLElement; + readonly chartId: string; + readonly ready: Promise; + getViewportState(): ViewportState; + setViewport(channel: ViewportChannel, start: number): void; + dispatch(interactionId: string, payload: unknown): Promise; + applyUpdate(update: ChartUpdate, options?: ChartUpdateApplyOptions): Promise; + setUpdates(updates: readonly ChartUpdate[]): Promise; + clearUpdate(id: string): Promise; + refresh(): 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..eaf63a64 100644 --- a/packages/flint-js/src/plotly/assemble.ts +++ b/packages/flint-js/src/plotly/assemble.ts @@ -136,7 +136,7 @@ export function assemblePlotly(input: ChartAssemblyInput): any { // ═══════════════════════════════════════════════════════════════════════ const rawData = input.data.values ?? []; const normalized = normalizeStaticSeries( - input.chart_spec.encodings, rawData, semanticTypes, + input.chart_spec.encodings, rawData, semanticTypes, chartType, ); let data = normalized.data; const staticSeries = normalized.staticSeries; @@ -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..e4fb1088 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'; @@ -193,7 +193,7 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { // Detect array-valued encodings (static series), validate, and fold data. const rawData = input.data.values ?? []; const normalized = normalizeStaticSeries( - input.chart_spec.encodings, rawData, semanticTypes, + input.chart_spec.encodings, rawData, semanticTypes, chartType, ); let data = normalized.data; const staticSeries = normalized.staticSeries; @@ -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,88 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { if (warnings.length > 0) { result._warnings = warnings; } + if (overflowResult.viewports.length > 0) { + result._viewports = overflowResult.viewports; + } + const navigationAxes = chartTemplate.navigation && !resolvedEncodings.column?.field && !resolvedEncodings.row?.field + ? (chartTemplate.navigation.axes ?? ['x', 'y']).filter((axis) => { + const encoding = resolvedEncodings[axis]; + return !!encoding?.field && (encoding.type === 'quantitative' || encoding.type === 'temporal'); + }) + : []; + if (chartTemplate.semanticInteractions || navigationAxes.length > 0) { + const templateSemantics = chartTemplate.semanticInteractions?.({ resolvedEncodings }) ?? { + fields: [], + provenanceFields: undefined, + temporalProvenanceFields: undefined, + rangeProvenance: undefined, + selectableMarks: [], + reorderAxis: undefined, + reorderAxes: undefined, + }; + const semanticEncodings = Object.values(resolvedEncodings) + .filter((encoding: any) => typeof encoding?.field === 'string') as any[]; + const hasAggregate = semanticEncodings.some((encoding) => encoding.aggregate); + const provenanceFields = [...new Set(semanticEncodings + .filter((encoding) => !hasAggregate || !encoding.aggregate) + .map((encoding) => encoding.field as string))]; + const temporalProvenanceFields = [...new Set(semanticEncodings + .filter((encoding) => encoding.type === 'temporal') + .map((encoding) => encoding.field as string))]; + const allowedReorderAxes: readonly ('x' | 'y')[] = chartTemplate.reorder === false + ? [] + : chartTemplate.reorder?.axes ?? ['x', 'y']; + const defaultReorderAxes = allowedReorderAxes.length > 0 + && !resolvedEncodings.column?.field && !resolvedEncodings.row?.field + ? (['x', 'y'] as const).flatMap((axis) => { + const encoding = resolvedEncodings[axis]; + return allowedReorderAxes.includes(axis) + && encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal') + ? [{ + axis, + field: encoding.field, + ...(chartTemplate.reorder && chartTemplate.reorder.includeConnectiveMarks + ? { includeConnectiveMarks: true } + : {}), + ...(chartTemplate.reorder && chartTemplate.reorder.markTypes + ? { markTypes: chartTemplate.reorder.markTypes } + : {}), + }] + : []; + }) + : []; + const explicitReorderAxes = templateSemantics.reorderAxes + ?? (templateSemantics.reorderAxis ? [templateSemantics.reorderAxis] : []); + const legendFields = 'legendFields' in templateSemantics ? templateSemantics.legendFields : undefined; + const rangeLegendChannels = Object.keys(legendFields ?? {}) + .filter((channel) => { + const type = resolvedEncodings[channel]?.type; + return type === 'quantitative' || type === 'temporal'; + }); + const reorderAxes = [...explicitReorderAxes, ...defaultReorderAxes] + .filter((candidate, index, candidates) => candidates.findIndex( + (axis) => axis.axis === candidate.axis && axis.field === candidate.field, + ) === index); + result._interactionSemantics = { + ...templateSemantics, + axisFields: Object.fromEntries((['x', 'y'] as const).flatMap((axis) => { + const encoding = resolvedEncodings[axis]; + return encoding?.field + ? [[axis, { field: encoding.field, type: encoding.type ?? 'nominal' }]] + : []; + })), + sourceRecords: values.map((record) => ({ ...record })), + provenanceFields: templateSemantics.provenanceFields ?? provenanceFields, + temporalProvenanceFields: templateSemantics.temporalProvenanceFields ?? temporalProvenanceFields, + rangeLegendChannels, + navigationAxes, + reorderAxis: reorderAxes[0], + reorderAxes, + selectionBoundary: design.interaction.selectionBoundary, + continuousColorFocus: design.interaction.continuousColorFocus, + neutralizeContinuousColor: chartTemplate.chart === 'Map' || chartTemplate.chart === 'Choropleth', + }; + } result._width = layoutResult.subplotWidth; result._height = layoutResult.subplotHeight; // Annotated option catalog: every configurable property this template @@ -916,8 +1000,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 +1396,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..ffce5380 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. @@ -759,7 +811,16 @@ function vlApplyFieldContext( // Example: individual percentages range 20–50% (no snap), but // they sum to ~100% per group → should snap to 100%. let skipDomain = false; - let effectiveDomainConstraint = cs.domainConstraint; + // Size and color scales must not silently re-normalize when a + // mounted chart replaces its rows. An explicit intrinsic domain is + // the author's stable reference frame for both symbol area and + // quantitative color (including a diverging scale's center). + const declaredScaleDomain = ch === 'size' || ch === 'color' + ? cs.semanticAnnotation?.intrinsicDomain + : undefined; + let effectiveDomainConstraint = declaredScaleDomain + ? { min: declaredScaleDomain[0], max: declaredScaleDomain[1], clamp: true } + : cs.domainConstraint; if (isSumStacked) { // Use explicit intrinsicDomain from annotation, or infer from @@ -770,9 +831,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 @@ -823,7 +882,7 @@ function vlApplyFieldContext( } } - if (effectiveDomainConstraint && enc.type === 'quantitative' && (ch === 'x' || ch === 'y') && !enc.bin && !skipDomain) { + if (effectiveDomainConstraint && enc.type === 'quantitative' && (ch === 'x' || ch === 'y' || ch === 'size' || ch === 'color') && !enc.bin && !skipDomain) { if (!enc.scale) enc.scale = {}; let { min } = effectiveDomainConstraint; const { max, clamp } = effectiveDomainConstraint; @@ -866,6 +925,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/interaction-provenance.ts b/packages/flint-js/src/vegalite/interaction-provenance.ts new file mode 100644 index 00000000..b03d8bbb --- /dev/null +++ b/packages/flint-js/src/vegalite/interaction-provenance.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export const INTERACTION_PROVENANCE = '__flintInteractionProvenance'; + +export interface InteractionProvenance { + role: 'text-label' | 'legend-label' | 'decorative'; + identity: 'inherit' | { fields: readonly string[] }; + presentation: 'on-mark' | 'independent'; + legend?: { channel: string; field: string }; +} + +/** Declare a generated series label that acts as a direct legend entry. */ +export function withInteractionLegendLabel>( + node: T, + legend: { channel: string; field: string }, +): T { + return { + ...node, + [INTERACTION_PROVENANCE]: { + role: 'legend-label', + identity: 'inherit', + presentation: 'independent', + legend, + } satisfies InteractionProvenance, + }; +} + +/** Exclude a structural or ornamental mark from semantic hit instrumentation. */ +export function withInteractionDecorative>(node: T): T { + return { + ...node, + [INTERACTION_PROVENANCE]: { + role: 'decorative', + identity: 'inherit', + presentation: 'independent', + } satisfies InteractionProvenance, + }; +} + +/** Declare a generated text mark and the data identity it represents. */ +export function withInteractionTextLabel>( + node: T, + options: { + fields?: readonly string[]; + presentation: InteractionProvenance['presentation']; + }, +): T { + return { + ...node, + [INTERACTION_PROVENANCE]: { + role: 'text-label', + identity: options.fields ? { fields: options.fields } : 'inherit', + presentation: options.presentation, + } satisfies InteractionProvenance, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/compile.ts b/packages/flint-js/src/vegalite/interactions/compile.ts new file mode 100644 index 00000000..fc876fbf --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/compile.ts @@ -0,0 +1,835 @@ +import type { ChartInteractionResolver } from '../../core/interaction-semantics'; +import { + isCanvasInteraction, + type ChartUpdatePresenter, + type InteractionContext, + type InteractionDef, +} from '../../interactive/interactions'; +import { toCanvasInteractionEvent } from '../../interactive/canvas-interaction'; +import { DEFAULT_DIM_OPACITY } from '../../interactive/presets/utils'; +import { INTERACTION_PROVENANCE, type InteractionProvenance } from '../interaction-provenance'; +import type { + HoverStyle, + SelectionBoundaryStyle, + ContinuousColorFocusStyle, + SelectionStyle, + VegaInteractionPlan, +} from './contracts'; +import { + INTERACTION_KEY, + INTERACTION_LEGEND_CHANNEL, + INTERACTION_LEGEND_FIELD, + INTERACTION_ROLE, + PATH_KEY_SUFFIX, +} from './hit-adapter'; +import { + HIDDEN_STORE, + LEGEND_HIDDEN_STORE, + HOVER_STORE, + INTERACTION_STORE, + LEGEND_HOVER_STORE, + AXIS_HOVER_STORE, + LEGEND_SELECTION_STORE, + STYLE_SIGNAL, +} from './stores'; + +const CLEAR_MARK = '__flint_interaction_clear'; +const LEGEND_ENTRY_MARK = '__flint_legend_entry'; +const SUPPORTED_SPEC_MARKS = new Set(['arc', 'area', 'bar', 'boxplot', 'circle', 'geoshape', 'line', 'point', 'rect', 'rule', 'tick']); + +interface TemplateInteractionSemantics { + fields: string[]; + sourceRecords?: readonly Record[]; + provenanceFields?: readonly string[]; + temporalProvenanceFields?: readonly string[]; + rangeProvenance?: readonly { field: string; startField: string; endField: string }[]; + categoryField?: string; + seriesField?: string; + resolveGroupValue?: InteractionContext['resolveGroupValue']; + legendFields?: Record; + axisFields?: Partial>; + rangeLegendChannels?: readonly string[]; + selectableMarks: string[]; + annotationMarkType?: string; + supportedRegionGestures?: ('cartesian' | 'angular')[]; + navigationAxes?: ('x' | 'y')[]; + reorderAxis?: { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean; markTypes?: readonly string[] }; + reorderAxes?: readonly { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean; markTypes?: readonly string[] }[]; + renderHoverStyles?: Record; + renderSelectionStyles?: Record; + selectionBoundary?: SelectionBoundaryStyle; + continuousColorFocus?: ContinuousColorFocusStyle; + neutralizeContinuousColor?: boolean; + resolve?: ChartInteractionResolver; + presentUpdate?: ChartUpdatePresenter; +} + +export function withoutSemanticInteractionField(value: unknown): unknown { + if (!value || typeof value !== 'object' || Array.isArray(value)) return value; + return Object.fromEntries(Object.entries(value as Record) + .filter(([field]) => field !== '_vgsid_' && !field.startsWith('__'))); +} + +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 expandInteractiveLinePoints(spec: Record): void { + const type = markType(spec.mark); + if (type === 'line' && typeof spec.mark === 'object' && spec.mark.point) { + const lineMark = { ...spec.mark }; + const point = lineMark.point; + delete lineMark.point; + spec.layer = [ + { mark: lineMark }, + { mark: typeof point === 'object' ? { type: 'point', ...point } : { type: 'point', filled: true } }, + ]; + delete spec.mark; + } + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) expandInteractiveLinePoints(child); + } +} + +function keyExpression(fields: readonly string[]): string { + return fields + .map((field) => `replace(toString(datum[${JSON.stringify(field)}]), '|', '\\|')`) + .join(` + '|' + `); +} + +function instrumentNode( + node: Record, + inherited: Record, + semanticFields: readonly string[], + dimOpacity: number, + continuousColorFocus: ContinuousColorFocusStyle | undefined, + selectableMarks: ReadonlySet, +): boolean { + const type = markType(node.mark); + const selectable = !!type && SUPPORTED_SPEC_MARKS.has(type) && selectableMarks.has(type); + const provenance = node[INTERACTION_PROVENANCE] as InteractionProvenance | undefined; + if (provenance?.role === 'decorative') return false; + const textLabel = provenance?.role === 'text-label'; + const legendLabel = provenance?.role === 'legend-label'; + if (!selectable && !textLabel && !legendLabel) return false; + if (textLabel || legendLabel) { + const identityFields = provenance.identity === 'inherit' + ? semanticFields + : provenance.identity.fields; + node.transform = [ + ...(Array.isArray(node.transform) ? node.transform : []), + ...keyTransforms(identityFields), + { calculate: `'${provenance.role}'`, as: INTERACTION_ROLE }, + ...(legendLabel && provenance.legend ? [ + { calculate: JSON.stringify(provenance.legend.channel), as: INTERACTION_LEGEND_CHANNEL }, + { calculate: JSON.stringify(provenance.legend.field), as: INTERACTION_LEGEND_FIELD }, + ] : []), + ]; + } + const encoding = { ...inherited, ...(node.encoding ?? {}) }; + const encodedOpacity = encoding.opacity; + const encodedColor = encoding.color; + const continuousColor = continuousColorFocus + && encodedColor?.field + && (encodedColor.type === 'quantitative' || encodedColor.type === 'temporal') + && !encodedColor.condition; + const dataDrivenOpacity = encodedOpacity?.field && !encodedOpacity.condition; + if ((textLabel || legendLabel) && provenance.presentation === 'on-mark') return true; + if ((encodedOpacity && typeof encodedOpacity.value !== 'number' && !dataDrivenOpacity) + || encoding.fillOpacity || encoding.strokeOpacity) return false; + const authoredOpacity = typeof encodedOpacity?.value === 'number' + ? encodedOpacity.value + : typeof node.mark === 'object' && typeof node.mark.opacity === 'number' + ? node.mark.opacity + : 1; + if (typeof node.mark === 'object' && typeof node.mark.opacity === 'number') { + node.mark = { ...node.mark }; + delete node.mark.opacity; + } + const isPath = type === 'line' || type === 'area'; + const hoverTest = `indata('${HOVER_STORE}', 'key', datum.${INTERACTION_KEY})`; + const existingDetail = node.encoding?.detail; + const selectionTest = isPath + ? `!length(data('${INTERACTION_STORE}'))` + : `!length(data('${INTERACTION_STORE}')) || indata('${INTERACTION_STORE}', 'key', datum.${INTERACTION_KEY})`; + node.encoding = { + ...(node.encoding ?? {}), + ...(isPath ? {} : { + detail: existingDetail == null + ? { field: INTERACTION_KEY, type: 'nominal' } + : [...(Array.isArray(existingDetail) ? existingDetail : [existingDetail]), { field: INTERACTION_KEY, type: 'nominal' }], + }), + ...(continuousColor ? { + color: { + ...(encodedColor.legend !== undefined ? { legend: encodedColor.legend } : {}), + condition: { + test: `${selectionTest} || ${hoverTest}`, + ...Object.fromEntries(Object.entries(encodedColor).filter(([key]) => key !== 'legend')), + }, + value: continuousColorFocus.mutedFill, + }, + } : {}), + opacity: continuousColor ? { value: authoredOpacity } : dataDrivenOpacity ? { + condition: { test: selectionTest, ...encodedOpacity }, + value: dimOpacity, + } : { + condition: { + test: `${selectionTest} || ${hoverTest}`, + value: authoredOpacity, + }, + value: Math.min(dimOpacity, authoredOpacity), + }, + }; + return true; +} + +function instrumentMarks( + spec: Record, + inherited: Record, + semanticFields: readonly string[], + dimOpacity: number, + continuousColorFocus: ContinuousColorFocusStyle | undefined, + selectableMarks: ReadonlySet, +): boolean { + const encoding = { ...inherited, ...(spec.encoding ?? {}) }; + let instrumented = instrumentNode( + spec, + inherited, + semanticFields, + dimOpacity, + continuousColorFocus, + selectableMarks, + ); + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) { + instrumented = instrumentMarks( + child, + encoding, + semanticFields, + dimOpacity, + continuousColorFocus, + selectableMarks, + ) || instrumented; + } + } + if (spec.spec && typeof spec.spec === 'object') { + instrumented = instrumentMarks( + spec.spec, + encoding, + semanticFields, + dimOpacity, + continuousColorFocus, + selectableMarks, + ) || instrumented; + } + return instrumented; +} + +function inlineRows(spec: Record): Record[] { + if (Array.isArray(spec?.data?.values)) return spec.data.values; + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) { + const rows = inlineRows(child); + if (rows.length > 0) return rows; + } + } + return spec.spec && typeof spec.spec === 'object' ? inlineRows(spec.spec) : []; +} + +function pinChannelDomain( + spec: Record, + channel: string, + field: string, + rows: readonly Record[], +): void { + const encoding = spec.encoding?.[channel]; + if (encoding && encoding.field === field && encoding.scale?.domain === undefined) { + const values = [...new Set(rows.map((row) => row?.[field]).filter((value) => value !== undefined))]; + const sort = encoding.sort; + if (Array.isArray(sort)) { + const order = new Map(sort.map((value: unknown, index: number) => [value, index])); + values.sort((left, right) => (order.get(left) ?? Number.POSITIVE_INFINITY) + - (order.get(right) ?? Number.POSITIVE_INFINITY)); + } else if (sort && typeof sort === 'object') { + const grouped = new Map(); + for (const row of rows) { + const key = row?.[field]; + const value = sort.op === 'count' ? 1 : Number(row?.[sort.field]); + if (key === undefined || !Number.isFinite(value)) continue; + grouped.set(key, [...(grouped.get(key) ?? []), value]); + } + const aggregate = (key: unknown): number => { + const entries = grouped.get(key) ?? []; + if (sort.op === 'count') return entries.length; + if (sort.op === 'min') return Math.min(...entries); + if (sort.op === 'max') return Math.max(...entries); + if (sort.op === 'mean' || sort.op === 'average') { + return entries.reduce((sum, value) => sum + value, 0) / entries.length; + } + return entries.reduce((sum, value) => sum + value, 0); + }; + const direction = sort.order === 'ascending' ? 1 : -1; + values.sort((left, right) => direction * (aggregate(left) - aggregate(right))); + } else { + values.sort((left, right) => (left as any) < (right as any) ? -1 : (left as any) > (right as any) ? 1 : 0); + if (sort === 'descending') values.reverse(); + } + const continuous = encoding.type === 'quantitative' || encoding.type === 'temporal'; + const domain = continuous && values.length > 1 + ? [values[0], values[values.length - 1]] + : values; + encoding.scale = { ...(encoding.scale ?? {}), domain }; + } + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) pinChannelDomain(child, channel, field, rows); + } + if (spec.spec && typeof spec.spec === 'object') pinChannelDomain(spec.spec, channel, field, rows); +} + +/** + * Hiding filters rows, which would otherwise shrink the legend and strand the hidden series + * with no key left to click. Pinning the domain keeps every series listed. + */ +function pinLegendDomains( + spec: Record, + legendFields: Readonly> | undefined, +): void { + const rows = inlineRows(spec); + if (rows.length === 0) return; + for (const [channel, field] of Object.entries(legendFields ?? {})) { + if (channel === 'size' || channel === 'opacity') continue; + const values = [...new Set(rows.map((row) => row?.[field]).filter((value) => value !== undefined))]; + if (values.length === 0 || values.some((value) => typeof value !== 'string' && typeof value !== 'number')) continue; + pinChannelDomain(spec, channel === 'color' ? 'color' : channel, field, rows); + } +} + +function keyTransforms(fields: readonly string[]): Record[] { + return [ + { calculate: keyExpression(fields), as: INTERACTION_KEY }, + // Hiding filters rows rather than blanking marks, so implicit domains, stacks and + // aggregates redraw against what is left. Domains Flint pinned explicitly are unaffected. + { filter: `!(length(data('${HIDDEN_STORE}')) && indata('${HIDDEN_STORE}', 'key', datum.${INTERACTION_KEY}))` }, + ]; +} + +function addLocalKeyTransforms( + spec: Record, + fields: readonly string[], + selectableMarks: ReadonlySet, +): void { + const type = markType(spec.mark); + const provenance = spec[INTERACTION_PROVENANCE] as InteractionProvenance | undefined; + if (provenance?.role === 'decorative') return; + // A composition can hoist `data` to an ancestor, so a unit is keyed on its + // own mark rather than on owning a data source. + if (type && SUPPORTED_SPEC_MARKS.has(type) && selectableMarks.has(type)) { + spec.transform = [ + ...(Array.isArray(spec.transform) ? spec.transform : []), + ...keyTransforms(fields), + ]; + } + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) addLocalKeyTransforms(child, fields, selectableMarks); + } + if (spec.spec && typeof spec.spec === 'object') { + addLocalKeyTransforms(spec.spec, fields, selectableMarks); + } +} + +function stripInteractionProvenance(spec: Record): void { + delete spec[INTERACTION_PROVENANCE]; + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) stripInteractionProvenance(child); + } + if (spec.spec && typeof spec.spec === 'object') stripInteractionProvenance(spec.spec); +} + +function clipNavigableMarks(spec: Record): void { + if (spec.mark !== undefined) { + spec.mark = typeof spec.mark === 'string' + ? { type: spec.mark, clip: true } + : { ...spec.mark, clip: true }; + } + for (const property of ['layer', 'hconcat', 'vconcat', 'concat'] as const) { + if (!Array.isArray(spec[property])) continue; + for (const child of spec[property]) clipNavigableMarks(child); + } +} + +export function addVegaLiteInteractions( + spec: Record, + interactions: readonly InteractionDef[], + enableSemanticUpdates = false, +): VegaInteractionPlan | null { + if (interactions.length === 0 && !enableSemanticUpdates) return null; + const canvasInteractions = interactions.filter(isCanvasInteraction); + const templateSemantics = spec._interactionSemantics as TemplateInteractionSemantics | undefined; + delete spec._interactionSemantics; + if (!templateSemantics) { + const builtInInteraction = canvasInteractions[0]; + if (builtInInteraction) { + throw new Error(`Interaction "${builtInInteraction.id}" requires chart interaction semantics.`); + } + return null; + } + const navigationInteraction = canvasInteractions.find( + (interaction) => interaction.eventSource.type === 'navigation', + ); + const declaredReorderAxes = templateSemantics.reorderAxes + ?? (templateSemantics.reorderAxis ? [templateSemantics.reorderAxis] : []); + const hasElementDrag = canvasInteractions.some( + (interaction) => interaction.eventSource.type === 'element' + && interaction.eventSource.gesture === 'drag', + ); + const semanticGestureInteraction = canvasInteractions.find( + (interaction) => interaction.eventSource.type === 'element' + || interaction.eventSource.type === 'region', + ); + if (semanticGestureInteraction + && !templateSemantics.resolve + && templateSemantics.fields.length === 0 + && templateSemantics.selectableMarks.length === 0) { + throw new Error(`Interaction "${semanticGestureInteraction.id}" requires chart element semantics.`); + } + const semanticInteractions = canvasInteractions.filter( + (interaction) => interaction.eventSource.type !== 'navigation', + ); + const presentationInteractions = semanticInteractions.filter( + (interaction) => !interaction.eventSource.viewport, + ); + const needsSemanticPresentation = enableSemanticUpdates + || presentationInteractions.length > 0 + || canvasInteractions.length < interactions.length; + if (navigationInteraction?.eventSource.pan + && semanticInteractions.some((interaction) => interaction.eventSource.gesture === 'drag')) { + throw new Error('Pan navigation cannot share an unmodified drag gesture with a region interaction.'); + } + const availableNavigationAxes = templateSemantics.navigationAxes ?? []; + // A region interaction can drive the viewport, which still needs domain signals. + const viewportRegion = canvasInteractions.find((interaction) => interaction.eventSource.viewport); + const requestedNavigationAxes = navigationInteraction + ? navigationInteraction.eventSource.axes === 'available' + ? availableNavigationAxes + : navigationInteraction.eventSource.axes === 'xy' + ? ['x', 'y'] as const + : [navigationInteraction.eventSource.axes as 'x' | 'y'] + : viewportRegion + ? availableNavigationAxes + : []; + const unsupportedNavigationAxes = requestedNavigationAxes.filter( + (axis) => !availableNavigationAxes.includes(axis), + ); + if (navigationInteraction && requestedNavigationAxes.length === 0) { + throw new Error(`Interaction "${navigationInteraction.id}" requires a chart with a navigable continuous axis.`); + } + if (unsupportedNavigationAxes.length > 0) { + throw new Error( + `Interaction "${navigationInteraction?.id}" requested unsupported navigation axis: ${unsupportedNavigationAxes.join(', ')}.`, + ); + } + const angularInteraction = canvasInteractions.find( + (interaction) => interaction.eventSource.regionGeometry === 'angular', + ); + if (angularInteraction && !templateSemantics.supportedRegionGestures?.includes('angular')) { + throw new Error( + `Interaction "${angularInteraction.id}" requires a polar chart with angular-region support.`, + ); + } + const selectableMarks = new Set(templateSemantics.selectableMarks ?? SUPPORTED_SPEC_MARKS); + const fields = templateSemantics.fields ?? []; + if (needsSemanticPresentation) expandInteractiveLinePoints(spec); + if (navigationInteraction || viewportRegion) clipNavigableMarks(spec); + + const dimOpacity = presentationInteractions.reduce((value, interaction) => { + if (!interaction.handle) return value; + const semanticEvent = { + type: 'semantic', + source: interaction.eventSource.type === 'region' ? 'region' : 'element', + phase: 'commit', + target: { + visual: { kind: 'mark', role: 'probe' }, + elements: [{ value: {} }], + }, + } as const; + const interactionContext = { chartType: 'Unknown', selected: [] }; + const update = interaction.handle(toCanvasInteractionEvent(semanticEvent, interaction.eventSource), interactionContext); + const style = update?.ops.find((op) => op.op === 'set-style'); + return style?.op === 'set-style' + ? Math.min(value, style.value.mutedOpacity ?? DEFAULT_DIM_OPACITY) + : value; + }, DEFAULT_DIM_OPACITY); + + const instrumented = needsSemanticPresentation + ? instrumentMarks( + spec, {}, fields, dimOpacity, + templateSemantics.neutralizeContinuousColor ? templateSemantics.continuousColorFocus : undefined, + selectableMarks, + ) + : false; + if (needsSemanticPresentation && !instrumented) return null; + if (instrumented) addLocalKeyTransforms(spec, fields, selectableMarks); + if (instrumented && canvasInteractions.some((interaction) => interaction.claimsLegendActivation)) { + pinLegendDomains(spec, templateSemantics.legendFields); + } + stripInteractionProvenance(spec); + if (instrumented) { + spec.transform = [ + ...(Array.isArray(spec.transform) ? spec.transform : []), + ...keyTransforms(fields), + ]; + } + return { + fields, + sourceRecords: templateSemantics.sourceRecords ?? inlineRows(spec).map((record) => ({ ...record })), + provenanceFields: templateSemantics.provenanceFields ?? fields, + temporalProvenanceFields: templateSemantics.temporalProvenanceFields ?? [], + rangeProvenance: templateSemantics.rangeProvenance ?? [], + categoryField: templateSemantics.categoryField, + seriesField: templateSemantics.seriesField, + resolveGroupValue: templateSemantics.resolveGroupValue, + legendFields: templateSemantics.legendFields, + axisFields: templateSemantics.axisFields, + rangeLegendChannels: templateSemantics.rangeLegendChannels, + annotationMarkType: templateSemantics.annotationMarkType, + semanticStores: instrumented, + dimOpacity, + renderHoverStyles: templateSemantics.renderHoverStyles, + renderSelectionStyles: templateSemantics.renderSelectionStyles, + selectionBoundary: templateSemantics.selectionBoundary, + continuousColorFocus: templateSemantics.continuousColorFocus, + navigationChannels: [...requestedNavigationAxes], + angularXBrush: templateSemantics.supportedRegionGestures?.includes('angular') ?? false, + reorderAxis: hasElementDrag && declaredReorderAxes[0] + ? { ...declaredReorderAxes[0], scale: '', signal: '' } + : undefined, + reorderAxes: hasElementDrag + ? declaredReorderAxes.map((axis) => ({ ...axis, scale: '', signal: '' })) + : [], + resolve: templateSemantics.resolve, + presentUpdate: templateSemantics.presentUpdate, + }; +} + +/** + * Composed specs (a themed `vconcat`, for example) rename `x` to `concat_0_x`, + * so an axis is matched by suffix when it is unambiguous. + */ +export function findVegaAxisScale( + vegaSpec: Record, + axis: 'x' | 'y', +): Record | undefined { + const scales: any[] = vegaSpec.scales ?? []; + const exact = scales.find((candidate) => candidate.name === axis); + if (exact) return exact; + const suffixed = scales.filter((candidate) => typeof candidate.name === 'string' + && candidate.name.endsWith(`_${axis}`)); + return suffixed.length === 1 ? suffixed[0] : undefined; +} + +export function injectVegaReorderSignal( + vegaSpec: Record, + reorderAxis: { axis: 'x' | 'y'; field: string; includeConnectiveMarks?: boolean; markTypes?: readonly string[] } | undefined, +): import('./contracts').VegaReorderAxis | undefined { + if (!reorderAxis) return undefined; + const scale = findVegaAxisScale(vegaSpec, reorderAxis.axis); + if (!scale || !['band', 'point', 'ordinal'].includes(scale.type)) { + throw new Error(`Vega category reorder requires a top-level discrete "${reorderAxis.axis}" scale.`); + } + const signal = `__flint_reorder_${reorderAxis.axis}_domain`; + vegaSpec.signals = [...(vegaSpec.signals ?? []), { name: signal, value: null }]; + scale.domainRaw = { signal }; + return { ...reorderAxis, scale: scale.name, signal }; +} + +export function injectVegaNavigationSignals( + vegaSpec: Record, + channels: readonly ('x' | 'y')[] = [], +): Partial> { + const result: Partial> = {}; + for (const channel of channels) { + const scale = findVegaAxisScale(vegaSpec, channel); + if (!scale || !['linear', 'log', 'time', 'utc'].includes(scale.type)) { + throw new Error(`Vega navigation requires a top-level continuous "${channel}" scale.`); + } + const signal = `__flint_navigation_${channel}_domain`; + vegaSpec.signals = [...(vegaSpec.signals ?? []), { name: signal, value: null }]; + scale.domainRaw = { signal }; + result[channel] = { scale: scale.name, signal, type: scale.type }; + } + return result; +} + +export function collectVegaAxisTargets( + vegaSpec: Record, + axisFields: VegaInteractionPlan['axisFields'], + reorderAxes: readonly Pick[] = [], + hoverColor?: string, +): Record { + const targets: Record = {}; + const visit = (scope: Record): void => { + for (const axis of scope.axes ?? []) { + const channel = axis.orient === 'top' || axis.orient === 'bottom' ? 'x' + : axis.orient === 'left' || axis.orient === 'right' ? 'y' : undefined; + const field = channel ? axisFields?.[channel] : undefined; + if (!channel || !field || typeof axis.scale !== 'string') continue; + targets[axis.scale] = { axis: channel, ...field }; + const hoveredAxisLabel = hoverColor + ? `length(data('${AXIS_HOVER_STORE}')) && ` + + `data('${AXIS_HOVER_STORE}')[0].scale === ${JSON.stringify(axis.scale)} && ` + + `data('${AXIS_HOVER_STORE}')[0].value === datum.value` + : undefined; + const existingLabelFill = hoverColor + ? axis.encode?.labels?.update?.fill ?? axis.encode?.labels?.enter?.fill ?? { value: '#4a4a4a' } + : undefined; + const existingFontWeight = hoverColor + ? axis.encode?.labels?.update?.fontWeight ?? axis.encode?.labels?.enter?.fontWeight ?? { value: 'normal' } + : undefined; + axis.encode = { + ...(axis.encode ?? {}), + labels: { + ...(axis.encode?.labels ?? {}), + interactive: true, + update: { + ...(axis.encode?.labels?.update ?? {}), + ...(hoveredAxisLabel && existingLabelFill && existingFontWeight ? { + fill: [ + { test: hoveredAxisLabel, value: hoverColor }, + ...(Array.isArray(existingLabelFill) ? existingLabelFill : [existingLabelFill]), + ], + fontWeight: [ + { test: hoveredAxisLabel, value: 600 }, + ...(Array.isArray(existingFontWeight) ? existingFontWeight : [existingFontWeight]), + ], + } : {}), + }, + }, + ticks: { + ...(axis.encode?.ticks ?? {}), + interactive: true, + update: { ...(axis.encode?.ticks?.update ?? {}) }, + }, + }; + } + for (const mark of scope.marks ?? []) visit(mark); + }; + visit(vegaSpec); + return targets; +} + +function applyCompiledHoverStyles( + marks: Record[], + renderHoverStyles: Readonly>, +): void { + for (const mark of marks) { + if (Array.isArray(mark.marks)) applyCompiledHoverStyles(mark.marks, renderHoverStyles); + const style = renderHoverStyles[mark.type]; + const update = mark.encode?.update; + if (!style || !update || !JSON.stringify(mark.encode).includes(INTERACTION_KEY)) continue; + if (mark.type === 'line') continue; + const hoverKey = mark.type === 'line' || mark.type === 'area' + ? `datum.${INTERACTION_KEY} + '${PATH_KEY_SUFFIX}'` + : `datum.${INTERACTION_KEY}`; + const hoverTest = `indata('${HOVER_STORE}', 'key', ${hoverKey})`; + for (const [channel, value] of Object.entries(style)) { + if (channel === 'opacity' && (value === 'contrast' || value === 'spotlight')) { + const currentOpacity = Array.isArray(update.opacity) ? update.opacity : [update.opacity]; + if (value === 'spotlight' && currentOpacity.some((entry: any) => + entry?.field !== undefined || entry?.signal !== undefined || entry?.condition?.field !== undefined + )) continue; + const numericValues = currentOpacity + .map((entry: any) => entry?.value) + .filter((entry: unknown): entry is number => typeof entry === 'number'); + const authoredOpacity = numericValues.length > 0 ? Math.max(...numericValues) : 1; + update.opacity = [ + { + test: value === 'spotlight' && mark.type === 'area' + ? `!length(data('${INTERACTION_STORE}')) && ${hoverTest}` + : hoverTest, + value: value === 'spotlight' + ? Math.min(authoredOpacity, 0.9) + : authoredOpacity < 1 ? 1 : 0.9, + }, + ...currentOpacity, + ]; + continue; + } + const existing = update[channel] ?? mark.encode?.enter?.[channel] ?? ( + channel === 'stroke' + ? { value: mark.type === 'line' || mark.type === 'rule' ? 'black' : 'transparent' } + : channel === 'strokeWidth' + ? { value: mark.type === 'line' ? 2 : mark.type === 'rule' ? 1 : mark.type === 'symbol' ? 1.5 : 0 } + : undefined + ); + if (existing === undefined) continue; + update[channel] = [ + { test: hoverTest, value }, + ...(Array.isArray(existing) ? existing : [existing]), + ]; + } + } +} + +function applyCompiledStyleChannels(marks: Record[]): void { + for (const mark of marks) { + if (Array.isArray(mark.marks)) applyCompiledStyleChannels(mark.marks); + const update = mark.encode?.update; + if (!update || !JSON.stringify(mark.encode).includes(INTERACTION_KEY)) continue; + const key = `datum.${INTERACTION_KEY}`; + for (const channel of ['opacity', 'fill', 'stroke', 'strokeWidth'] as const) { + const existing = update[channel] ?? mark.encode?.enter?.[channel]; + if (existing === undefined) continue; + const styleValue = `${STYLE_SIGNAL}[${key}] && ${STYLE_SIGNAL}[${key}].${channel}`; + update[channel] = [ + { test: `isValid(${styleValue})`, signal: styleValue }, + ...(Array.isArray(existing) ? existing : [existing]), + ]; + } + } +} + +export function injectVegaInteractionStore( + vegaSpec: Record, + plan?: Pick, +): void { + // Stores go first: transforms are parsed in data order, so a filter that reads a store + // cannot resolve one declared after it. + vegaSpec.data = [ + { name: INTERACTION_STORE, values: [] }, + { name: HOVER_STORE, values: [] }, + { name: HIDDEN_STORE, values: [] }, + { name: LEGEND_HIDDEN_STORE, values: [] }, + { name: LEGEND_HOVER_STORE, values: [] }, + { name: AXIS_HOVER_STORE, values: [] }, + { name: LEGEND_SELECTION_STORE, values: [] }, + ...(Array.isArray(vegaSpec.data) ? vegaSpec.data : []), + ]; + vegaSpec.signals = [ + ...(Array.isArray(vegaSpec.signals) ? vegaSpec.signals : []), + { name: STYLE_SIGNAL, value: {} }, + ]; + const instrumentLegends = (scope: Record): void => { + for (const legend of scope.legends ?? []) { + const scaleChannel = ['fill', 'stroke', 'size', 'shape', 'opacity'] + .find((channel) => legend[channel] !== undefined); + const channel = scaleChannel === 'fill' || scaleChannel === 'stroke' ? 'color' : scaleChannel; + const peerOfSelectedLegend = channel + ? `isValid(datum.value) && length(data('${LEGEND_SELECTION_STORE}')) && ` + + `data('${LEGEND_SELECTION_STORE}')[0].channel === ${JSON.stringify(channel)} && ` + + `data('${LEGEND_SELECTION_STORE}')[0].value !== datum.value` + : undefined; + const selectedLegendItem = channel + ? `isValid(datum.value) && length(data('${LEGEND_SELECTION_STORE}')) && ` + + `data('${LEGEND_SELECTION_STORE}')[0].channel === ${JSON.stringify(channel)} && ` + + `data('${LEGEND_SELECTION_STORE}')[0].value === datum.value` + : undefined; + const hoveredLegendItem = channel + ? `isValid(datum.value) && length(data('${LEGEND_HOVER_STORE}')) && ` + + `data('${LEGEND_HOVER_STORE}')[0].channel === ${JSON.stringify(channel)} && ` + + `data('${LEGEND_HOVER_STORE}')[0].value === datum.value` + : undefined; + const hiddenLegendItem = channel + ? `isValid(datum.value) && length(data('${LEGEND_HIDDEN_STORE}')) && ` + + `indata('${LEGEND_HIDDEN_STORE}', 'identity', ${JSON.stringify(channel)} + ':' + datum.value)` + : undefined; + const interactiveItem = ( + encode: Record | undefined, + kind: 'gradient' | 'symbol' | 'label', + ): Record => { + const existingOpacity = encode?.update?.opacity ?? encode?.enter?.opacity ?? { value: 1 }; + const existingStroke = encode?.update?.stroke ?? encode?.enter?.stroke ?? { value: null }; + const existingStrokeWidth = encode?.update?.strokeWidth ?? encode?.enter?.strokeWidth ?? { value: 0 }; + const existingStrokeOpacity = encode?.update?.strokeOpacity ?? encode?.enter?.strokeOpacity ?? { value: 1 }; + const existingFill = encode?.update?.fill ?? encode?.enter?.fill; + const existingFontWeight = encode?.update?.fontWeight ?? encode?.enter?.fontWeight ?? { value: 'normal' }; + const selectionBoundary = plan?.selectionBoundary; + return { + ...(encode ?? {}), + interactive: true, + update: { + ...(encode?.update ?? {}), + opacity: hiddenLegendItem ? [ + { test: hiddenLegendItem, signal: `data('${LEGEND_HIDDEN_STORE}')[0].opacity` }, + ...(peerOfSelectedLegend ? [ + { test: peerOfSelectedLegend, value: plan?.dimOpacity ?? DEFAULT_DIM_OPACITY }, + ] : []), + ...(kind === 'symbol' && hoveredLegendItem + ? [{ test: hoveredLegendItem, value: 0.72 }] + : []), + ...(Array.isArray(existingOpacity) ? existingOpacity : [existingOpacity]), + ] : kind === 'symbol' && hoveredLegendItem ? [ + { test: hoveredLegendItem, value: 0.72 }, + ...(Array.isArray(existingOpacity) ? existingOpacity : [existingOpacity]), + ] : existingOpacity, + ...(kind === 'gradient' && selectedLegendItem ? { + stroke: [ + { test: selectedLegendItem, value: selectionBoundary?.color ?? '#20262c' }, + ...(Array.isArray(existingStroke) ? existingStroke : [existingStroke]), + ], + strokeWidth: [ + { test: selectedLegendItem, value: selectionBoundary?.width ?? 1.25 }, + ...(Array.isArray(existingStrokeWidth) ? existingStrokeWidth : [existingStrokeWidth]), + ], + strokeOpacity: [ + { test: selectedLegendItem, value: selectionBoundary?.opacity ?? 0.68 }, + ...(Array.isArray(existingStrokeOpacity) ? existingStrokeOpacity : [existingStrokeOpacity]), + ], + } : {}), + ...(kind === 'label' && hoveredLegendItem ? { + ...(existingFill ? { + fill: [ + { test: hoveredLegendItem, value: selectionBoundary?.color ?? '#20262c' }, + ...(Array.isArray(existingFill) ? existingFill : [existingFill]), + ], + } : {}), + fontWeight: [ + { test: hoveredLegendItem, value: 600 }, + ...(Array.isArray(existingFontWeight) ? existingFontWeight : [existingFontWeight]), + ], + } : {}), + }, + }; + }; + legend.encode = { + ...(legend.encode ?? {}), + entries: { + ...(legend.encode?.entries ?? {}), + name: legend.encode?.entries?.name ?? LEGEND_ENTRY_MARK, + interactive: true, + update: { + ...(legend.encode?.entries?.update ?? {}), + }, + }, + gradient: interactiveItem(legend.encode?.gradient, 'gradient'), + symbols: interactiveItem(legend.encode?.symbols, 'symbol'), + labels: interactiveItem(legend.encode?.labels, 'label'), + }; + } + for (const mark of scope.marks ?? []) instrumentLegends(mark); + }; + instrumentLegends(vegaSpec); + if (!Array.isArray(vegaSpec.marks)) return; + if (plan?.renderHoverStyles) applyCompiledHoverStyles(vegaSpec.marks, plan.renderHoverStyles); + applyCompiledStyleChannels(vegaSpec.marks); + 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 }, + }, + }, + }); +} diff --git a/packages/flint-js/src/vegalite/interactions/contracts.ts b/packages/flint-js/src/vegalite/interactions/contracts.ts new file mode 100644 index 00000000..42e3d549 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/contracts.ts @@ -0,0 +1,89 @@ +import type { ChartInteractionResolver } from '../../core/interaction-semantics'; +import type { ChartUpdatePresenter, InteractionContext } from '../../interactive/interactions'; + +export interface HoverStyle { + fill?: string; + fillOpacity?: number; + opacity?: 'contrast' | 'spotlight'; + stroke?: string; + strokeWidth?: number; +} + +export interface SelectionStyle { + strokeWidthMultiplier?: number; + boundary?: 'contiguous-region'; +} + +export interface SelectionBoundaryStyle { + color: string; + width: number; + opacity: number; + haloColor: string; + haloWidth: number; + haloOpacity: number; +} + +export interface ContinuousColorFocusStyle { + mutedFill: string; + boundaryWidth: number; + boundaryOpacity: number; + haloWidth: number; + haloOpacity: number; +} + +export interface VegaNavigationAxis { + scale: string; + signal: string; + type: 'linear' | 'log' | 'time' | 'utc'; +} + +export interface VegaReorderAxis { + axis: 'x' | 'y'; + field: string; + includeConnectiveMarks?: boolean; + markTypes?: readonly string[]; + scale: string; + signal: string; +} + +export interface VegaAxisTarget { + axis: 'x' | 'y'; + field: string; + type: string; +} + +export interface VegaInteractionPlan { + fields: readonly string[]; + sourceRecords: readonly Record[]; + provenanceFields: readonly string[]; + temporalProvenanceFields: readonly string[]; + rangeProvenance: readonly { field: string; startField: string; endField: string }[]; + categoryField?: string; + seriesField?: string; + resolveGroupValue?: InteractionContext['resolveGroupValue']; + legendFields?: Readonly>; + axisFields?: Partial>; + axisTargets?: Readonly>; + rangeLegendChannels?: readonly string[]; + annotationMarkType?: string; + /** The compiled spec carries the semantic selection stores. */ + semanticStores?: boolean; + dimOpacity: number; + renderHoverStyles?: Readonly>; + renderSelectionStyles?: Readonly>; + selectionBoundary?: Readonly; + continuousColorFocus?: Readonly; + navigationChannels?: readonly ('x' | 'y')[]; + /** Polar templates realize the primary X brush as an angular sector. */ + angularXBrush?: boolean; + navigationAxes?: Partial>; + /** Unambiguous existing Cartesian scales available to external overlays. */ + overlayScales?: Partial>; + /** Mutable compiled inline source used by `set-data`. */ + mutableDataSource?: string; + initialDataRows?: readonly Record[]; + reorderAxis?: VegaReorderAxis; + reorderAxes?: readonly VegaReorderAxis[]; + resolve?: ChartInteractionResolver; + presentUpdate?: ChartUpdatePresenter; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts b/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts new file mode 100644 index 00000000..e11992fe --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/gestures/navigation.ts @@ -0,0 +1,263 @@ +import type { + CanvasInteractionDef, + NavigationAxes, + NavigationInteractionEvent, + PlotPoint, +} from '../../../interactive/interactions'; +import { PanSession, PinchSession, wheelZoomFactor } from '../../../interactive/gestures/navigation'; +import { clientToPlotPoint, interactionModifiers, type RendererCoordinateSpace } from '../hit-adapter'; + +export interface VegaNavigationGestureOptions { + container: HTMLElement; + interaction: CanvasInteractionDef; + availableAxes: readonly ('x' | 'y')[]; + coordinateSpace(): RendererCoordinateSpace; + dispatch(event: NavigationInteractionEvent): Promise; + setSuppressClick(suppress: boolean): void; + setDragging(dragging: boolean): void; +} + +export interface VegaNavigationGestureController { + destroy(): void; +} + +function resolvedAxes(requested: unknown, available: readonly ('x' | 'y')[]): NavigationAxes { + const axes = requested === 'available' + ? available + : requested === 'xy' + ? available.filter((axis) => axis === 'x' || axis === 'y') + : available.filter((axis) => axis === requested); + return axes.length === 2 ? 'xy' : axes[0] ?? 'xy'; +} + +export function mountVegaNavigationGesture( + options: VegaNavigationGestureOptions, +): VegaNavigationGestureController { + const { + container, + interaction, + availableAxes, + coordinateSpace, + dispatch, + setSuppressClick, + setDragging, + } = options; + const source = interaction.eventSource; + const axes = resolvedAxes(source.axes, availableAxes); + let pointerId: number | undefined; + let session: PanSession | undefined; + let pendingDelta: PlotPoint = { x: 0, y: 0 }; + let dragged = false; + const touchPointers = new Map(); + let pinchSession: PinchSession | undefined; + + const previousCursor = container.style.cursor; + const previousTouchAction = container.style.touchAction; + const previousUserSelect = container.style.userSelect; + if (source.pan || source.zoom) { + container.style.touchAction = 'none'; + container.style.userSelect = 'none'; + } + + const localPoint = (event: PointerEvent): PlotPoint => clientToPlotPoint( + { x: event.clientX, y: event.clientY }, + coordinateSpace(), + ); + const emit = (event: NavigationInteractionEvent): void => { void dispatch(event); }; + + const beginPinch = (event: PointerEvent): void => { + const points = [...touchPointers.values()]; + if (!source.zoom || points.length !== 2) return; + if (session) { + emit({ + type: 'navigation', phase: 'cancel', operation: 'pan', axes, + modifiers: interactionModifiers(event), + }); + session = undefined; + pointerId = undefined; + pendingDelta = { x: 0, y: 0 }; + } + const space = coordinateSpace(); + pinchSession = new PinchSession(points[0]!, points[1]!, { + width: space.plotWidth, + height: space.plotHeight, + }); + dragged = true; + setDragging(true); + setSuppressClick(true); + emit({ + type: 'navigation', phase: 'start', operation: 'zoom', axes, + modifiers: interactionModifiers(event), + }); + }; + + const pointerDown = (event: PointerEvent): void => { + if (event.pointerType === 'touch' && source.zoom) { + if (pinchSession && touchPointers.size >= 2) return; + touchPointers.set(event.pointerId, localPoint(event)); + container.setPointerCapture(event.pointerId); + if (touchPointers.size === 2) beginPinch(event); + if (pinchSession || !source.pan) return; + } + if (!source.pan || event.button !== 0 || session) return; + const space = coordinateSpace(); + session = new PanSession(localPoint(event), { width: space.plotWidth, height: space.plotHeight }); + pointerId = event.pointerId; + pendingDelta = { x: 0, y: 0 }; + dragged = false; + setDragging(true); + container.style.cursor = 'grabbing'; + container.setPointerCapture(event.pointerId); + emit({ + type: 'navigation', phase: 'start', operation: 'pan', axes, + modifiers: interactionModifiers(event), + }); + }; + const pointerMove = (event: PointerEvent): void => { + if (event.pointerType === 'touch' && touchPointers.has(event.pointerId)) { + touchPointers.set(event.pointerId, localPoint(event)); + if (pinchSession) { + const points = [...touchPointers.values()]; + if (points.length !== 2) return; + const update = pinchSession.move(points[0]!, points[1]!); + if (update) emit({ + type: 'navigation', phase: 'preview', operation: 'zoom', axes, + factor: update.factor, anchor: update.anchor, + modifiers: interactionModifiers(event), + }); + return; + } + } + if (!session || pointerId !== event.pointerId) return; + const delta = session.move(localPoint(event)); + pendingDelta = { x: pendingDelta.x + delta.x, y: pendingDelta.y + delta.y }; + if (session.dragDistance() < 4) return; + dragged = true; + setSuppressClick(true); + emit({ + type: 'navigation', phase: 'preview', operation: 'pan', axes, + delta: pendingDelta, modifiers: interactionModifiers(event), + }); + pendingDelta = { x: 0, y: 0 }; + }; + const finish = (event: PointerEvent): void => { + if (event.pointerType === 'touch' && touchPointers.has(event.pointerId)) { + touchPointers.delete(event.pointerId); + if (pinchSession) { + emit({ + type: 'navigation', phase: 'commit', operation: 'zoom', axes, + modifiers: interactionModifiers(event), + }); + pinchSession = undefined; + touchPointers.clear(); + session = undefined; + pointerId = undefined; + pendingDelta = { x: 0, y: 0 }; + setDragging(false); + container.style.cursor = source.pan ? 'grab' : previousCursor; + if (dragged) window.setTimeout(() => { setSuppressClick(false); }, 0); + return; + } + } + if (!session || pointerId !== event.pointerId) return; + if (pendingDelta.x !== 0 || pendingDelta.y !== 0) { + emit({ + type: 'navigation', phase: 'preview', operation: 'pan', axes, + delta: pendingDelta, modifiers: interactionModifiers(event), + }); + } + emit({ + type: 'navigation', phase: 'commit', operation: 'pan', axes, + modifiers: interactionModifiers(event), + }); + session = undefined; + pointerId = undefined; + pendingDelta = { x: 0, y: 0 }; + setDragging(false); + container.style.cursor = source.pan ? 'grab' : previousCursor; + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + if (dragged) window.setTimeout(() => { setSuppressClick(false); }, 0); + }; + const cancel = (event: PointerEvent): void => { + if (event.pointerType === 'touch' && touchPointers.has(event.pointerId)) { + touchPointers.delete(event.pointerId); + if (pinchSession) { + emit({ + type: 'navigation', phase: 'cancel', operation: 'zoom', axes, + modifiers: interactionModifiers(event), + }); + pinchSession = undefined; + touchPointers.clear(); + session = undefined; + pointerId = undefined; + pendingDelta = { x: 0, y: 0 }; + setDragging(false); + container.style.cursor = source.pan ? 'grab' : previousCursor; + setSuppressClick(false); + return; + } + } + if (!session || pointerId !== event.pointerId) return; + emit({ + type: 'navigation', phase: 'cancel', operation: 'pan', axes, + modifiers: interactionModifiers(event), + }); + session = undefined; + pointerId = undefined; + pendingDelta = { x: 0, y: 0 }; + setDragging(false); + container.style.cursor = source.pan ? 'grab' : previousCursor; + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + }; + const wheel = (event: WheelEvent): void => { + if (!source.zoom) return; + event.preventDefault(); + const space = coordinateSpace(); + const point = clientToPlotPoint({ x: event.clientX, y: event.clientY }, space); + emit({ + type: 'navigation', phase: 'commit', operation: 'zoom', axes, + factor: wheelZoomFactor( + event.deltaY, + event.deltaMode, + space.plotHeight, + source.wheelSensitivity ?? 0.002, + ), + anchor: { + x: space.plotWidth > 0 ? point.x / space.plotWidth : 0.5, + y: space.plotHeight > 0 ? point.y / space.plotHeight : 0.5, + }, + modifiers: interactionModifiers(event), + }); + }; + const doubleClick = (event: MouseEvent): void => { + event.preventDefault(); + emit({ + type: 'navigation', phase: 'commit', operation: 'reset', axes, + modifiers: interactionModifiers(event), + }); + }; + + container.addEventListener('pointerdown', pointerDown, true); + container.addEventListener('pointermove', pointerMove, true); + container.addEventListener('pointerup', finish, true); + container.addEventListener('pointercancel', cancel, true); + container.addEventListener('wheel', wheel, { passive: false }); + container.addEventListener('dblclick', doubleClick); + + return { + destroy(): void { + container.removeEventListener('pointerdown', pointerDown, true); + container.removeEventListener('pointermove', pointerMove, true); + container.removeEventListener('pointerup', finish, true); + container.removeEventListener('pointercancel', cancel, true); + container.removeEventListener('wheel', wheel); + container.removeEventListener('dblclick', doubleClick); + container.style.cursor = previousCursor; + container.style.touchAction = previousTouchAction; + container.style.userSelect = previousUserSelect; + touchPointers.clear(); + pinchSession = undefined; + setDragging(false); + }, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/gestures/region.ts b/packages/flint-js/src/vegalite/interactions/gestures/region.ts new file mode 100644 index 00000000..68a2f106 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/gestures/region.ts @@ -0,0 +1,649 @@ +import type { + CanvasInteractionDef, + PlotAngularSector, + PlotPoint, + RenderHit, + SemanticInteractionEvent, + SemanticTarget, +} from '../../../interactive/interactions'; +import { angularSectorPath } from '../../../interactive/geometry/angular'; +import { normalizeRegionGuideOptions } from '../../../interactive/guides'; +import { AngularRegionSession, polarPointerAngle, type PolarFrame } from '../../../interactive/gestures/angular-region'; +import { + axisValue, + cartesianDragDistance, + constrainCartesianRegion, + intervalPoints, + updateInterval, + type CartesianRegionAxis, + type Interval, + type IntervalOperation, + type PlotFrame, +} from '../../../interactive/gestures/cartesian-region'; +import { + clientRectToLayoutRect, + clientToLayoutPoint, + clientToPlotPoint, + interactionModifiers, + facetPlotFrameAt, + normalizeVegaAngularRegionEvent, + normalizeVegaLassoEvent, + normalizeVegaRegionEvent, + polarFrameFromRadarGrid, + plotToClientPoint, + sceneItems, + type RendererCoordinateSpace, +} from '../hit-adapter'; + +export interface VegaRegionGestureOptions { + view: any; + container: HTMLElement; + interaction: CanvasInteractionDef; + getSelected(): ReadonlySet; + setSelected(selected: Set): void; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; + resolveTarget( + gesture: 'rectangle' | 'angular', + role: 'region', + hits: readonly RenderHit[], + ): SemanticTarget | null; + dispatch(event: SemanticInteractionEvent): Promise; + clearHover(): void; + clearAnnotation(): void; + sync(): Promise; + setSuppressClick(suppress: boolean): void; + setDragging(dragging: boolean): void; + resetViewport?(): void; +} + +export interface VegaRegionGestureController { + sync(): void; + destroy(): void; +} + +export function isInteractiveControlTarget(target: EventTarget | null): boolean { + const closest = (target as { closest?: (selector: string) => unknown } | null)?.closest; + return typeof closest === 'function' + && Boolean(closest.call(target, 'button, input, select, textarea, a[href], [role="button"]')); +} + +const circularAngleDistance = (left: number, right: number): number => + Math.abs(Math.atan2(Math.sin(left - right), Math.cos(left - right))); + +function angleInAngularSector(angle: number, sector: PlotAngularSector): boolean { + const sweep = sector.endAngle - sector.startAngle; + if (Math.abs(sweep) >= Math.PI * 2) return true; + const directedDistance = sweep >= 0 + ? (angle - sector.startAngle + Math.PI * 2) % (Math.PI * 2) + : (sector.startAngle - angle + Math.PI * 2) % (Math.PI * 2); + return directedDistance <= Math.abs(sweep); +} + +export function angularEditAction( + angle: number, + sector: PlotAngularSector, + edgeTolerance = 0.1, +): IntervalOperation | undefined { + if (circularAngleDistance(angle, sector.startAngle) <= edgeTolerance) return 'resize-leading'; + if (circularAngleDistance(angle, sector.endAngle) <= edgeTolerance) return 'resize-trailing'; + return angleInAngularSector(angle, sector) ? 'move' : undefined; +} + +export function pointInAngularSector(point: PlotPoint, sector: PlotAngularSector): boolean { + const radius = Math.hypot(point.x - sector.center.x, point.y - sector.center.y); + if (radius < sector.innerRadius || radius > sector.outerRadius) return false; + return angleInAngularSector(polarPointerAngle(point, sector), sector); +} + +export function mountVegaRegionGesture(options: VegaRegionGestureOptions): VegaRegionGestureController { + const { + view, + container, + interaction, + getSelected, + setSelected, + coordinateSpace, + containerLayoutSize, + resolveTarget, + dispatch, + clearHover, + clearAnnotation, + sync, + setSuppressClick, + setDragging, + resetViewport, + } = options; + const regionAxis: CartesianRegionAxis = interaction.eventSource.axis ?? 'xy'; + const angularBrush = interaction.eventSource.regionGeometry === 'angular'; + const lassoBrush = interaction.eventSource.regionGeometry === 'lasso'; + const statefulBrush = !angularBrush && !lassoBrush + && interaction.eventSource.mode === 'stateful' && regionAxis !== 'xy'; + const statefulAngular = angularBrush && interaction.eventSource.mode === 'stateful'; + const guide = interaction.eventSource.regionGuide ?? normalizeRegionGuideOptions(undefined); + let activeSector: PlotAngularSector | undefined; + let initialSector: PlotAngularSector | undefined; + let angularAction: IntervalOperation = 'create'; + let angularGrabAngle = 0; + let committed = new Set(); + let dragStart: PlotPoint | undefined; + let pointerId: number | undefined; + let dragAction: IntervalOperation = 'create'; + let activeInterval: Interval | undefined; + let initialInterval: Interval | undefined; + let angularSession: AngularRegionSession | undefined; + let lassoPoints: PlotPoint[] = []; + let activePlotFrame: PlotFrame | undefined; + let dragPlotFrame: PlotFrame | undefined; + + const overlay = document.createElement('div'); + Object.assign(overlay.style, { + position: 'absolute', display: 'none', zIndex: '5', pointerEvents: 'none', + boxSizing: 'border-box', + border: `${guide.style.strokeWidth}px solid ${guide.style.stroke}`, + borderColor: `color-mix(in srgb, ${guide.style.stroke} ${guide.style.strokeOpacity * 100}%, transparent)`, + background: `color-mix(in srgb, ${guide.style.fill} ${guide.style.fillOpacity * 100}%, transparent)`, + }); + const angularOverlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const angularPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + angularPath.setAttribute('fill', guide.style.fill); + angularPath.setAttribute('fill-opacity', `${guide.style.fillOpacity}`); + angularPath.setAttribute('stroke', guide.style.stroke); + angularPath.setAttribute('stroke-opacity', `${guide.style.strokeOpacity}`); + angularPath.setAttribute('stroke-width', `${guide.style.strokeWidth}`); + angularPath.setAttribute('vector-effect', 'non-scaling-stroke'); + angularOverlay.append(angularPath); + Object.assign(angularOverlay.style, { + position: 'absolute', display: 'none', zIndex: '5', pointerEvents: 'none', overflow: 'visible', + }); + + const lassoOverlay = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const lassoFill = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + // The region is filled as if closed because that is what gets captured, but the + // closing chord is never stroked while the path is still being drawn. + lassoFill.setAttribute('fill', guide.style.fill); + lassoFill.setAttribute('fill-opacity', `${guide.style.fillOpacity}`); + lassoFill.setAttribute('fill-rule', 'evenodd'); + lassoFill.setAttribute('stroke', 'none'); + const lassoPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + lassoPath.setAttribute('fill', 'none'); + lassoPath.setAttribute('stroke', guide.style.stroke); + lassoPath.setAttribute('stroke-opacity', `${guide.style.strokeOpacity}`); + lassoPath.setAttribute('stroke-width', `${guide.style.strokeWidth}`); + lassoPath.setAttribute('stroke-linejoin', 'round'); + lassoPath.setAttribute('stroke-linecap', 'round'); + lassoPath.setAttribute('vector-effect', 'non-scaling-stroke'); + lassoOverlay.append(lassoFill, lassoPath); + Object.assign(lassoOverlay.style, { + position: 'absolute', display: 'none', zIndex: '5', pointerEvents: 'none', overflow: 'visible', + }); + + const previousPosition = container.style.position; + const previousUserSelect = container.style.userSelect; + const previousCursor = container.style.cursor; + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + container.style.userSelect = 'none'; + container.append(angularBrush ? angularOverlay : lassoBrush ? lassoOverlay : overlay); + container.tabIndex = container.tabIndex >= 0 ? container.tabIndex : 0; + + const localPoint = (event: PointerEvent): PlotPoint => { + return clientToPlotPoint({ x: event.clientX, y: event.clientY }, coordinateSpace()); + }; + const rootPlotFrame = (): PlotFrame => { + const space = coordinateSpace(); + return { x: 0, y: 0, width: space.plotWidth, height: space.plotHeight }; + }; + const brushPlotFrame = (): PlotFrame => dragPlotFrame ?? activePlotFrame ?? rootPlotFrame(); + const intervalAxis = (): 'x' | 'y' => regionAxis === 'y' ? 'y' : 'x'; + const intervalForDrag = (point: PlotPoint): Interval => { + const frame = brushPlotFrame(); + const axis = intervalAxis(); + const origin = axis === 'y' ? frame.y : frame.x; + const limit = axis === 'y' ? frame.height : frame.width; + const localPoint = { ...point, [axis]: axisValue(point, axis) - origin }; + const localStart = { ...dragStart!, [axis]: axisValue(dragStart!, axis) - origin }; + const localInitial = initialInterval && { + leading: initialInterval.leading - origin, + trailing: initialInterval.trailing - origin, + }; + const interval = updateInterval(localPoint, localStart, axis, limit, dragAction, localInitial); + return { leading: interval.leading + origin, trailing: interval.trailing + origin }; + }; + const showRegion = (a: PlotPoint, b: PlotPoint): void => { + if (!guide.visible) return; + const constrained = constrainCartesianRegion(a, b, regionAxis, brushPlotFrame()); + const space = coordinateSpace(); + const leading = plotToClientPoint({ + x: Math.min(constrained.start.x, constrained.end.x), + y: Math.min(constrained.start.y, constrained.end.y), + }, space); + const trailing = plotToClientPoint({ + x: Math.max(constrained.start.x, constrained.end.x), + y: Math.max(constrained.start.y, constrained.end.y), + }, space); + const containerRect = container.getBoundingClientRect(); + const layoutSize = containerLayoutSize(); + const localLeading = clientToLayoutPoint(leading, containerRect, layoutSize); + const localTrailing = clientToLayoutPoint(trailing, containerRect, layoutSize); + Object.assign(overlay.style, { + display: 'block', + left: `${localLeading.x}px`, + top: `${localLeading.y}px`, + width: `${localTrailing.x - localLeading.x}px`, + height: `${localTrailing.y - localLeading.y}px`, + }); + }; + const showInterval = (interval: Interval): void => { + const points = intervalPoints(interval, intervalAxis()); + showRegion(points.start, points.end); + }; + const frameAt = (point: PlotPoint, plotFrame: PlotFrame): PolarFrame => { + const frames = new Map(); + for (const item of sceneItems(view)) { + if (item.mark?.marktype !== 'arc' || typeof item.x !== 'number' || typeof item.y !== 'number' + || typeof item.innerRadius !== 'number' || typeof item.outerRadius !== 'number') continue; + const key = `${item.x}\u0000${item.y}`; + const existing = frames.get(key); + frames.set(key, existing ? { + center: existing.center, + innerRadius: Math.min(existing.innerRadius, item.innerRadius), + outerRadius: Math.max(existing.outerRadius, item.outerRadius), + } : { + center: { x: item.x, y: item.y }, + innerRadius: item.innerRadius, + outerRadius: item.outerRadius, + }); + } + const arcFrame = [...frames.values()].sort((left, right) => + Math.hypot(point.x - left.center.x, point.y - left.center.y) + - Math.hypot(point.x - right.center.x, point.y - right.center.y))[0]; + return arcFrame ?? polarFrameFromRadarGrid(view, point) ?? { + center: { + x: plotFrame.x + plotFrame.width / 2, + y: plotFrame.y + plotFrame.height / 2, + }, + innerRadius: 0, + outerRadius: Math.min(plotFrame.width, plotFrame.height) / 2, + }; + }; + const showAngularSector = (sector: PlotAngularSector): void => { + if (!guide.visible) return; + const space = coordinateSpace(); + const renderer = container.querySelector('svg') as SVGSVGElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + Object.assign(angularOverlay.style, { + display: 'block', + left: `${rendererLayout.left}px`, + top: `${rendererLayout.top}px`, + width: `${rendererLayout.width}px`, + height: `${rendererLayout.height}px`, + }); + angularOverlay.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + angularPath.setAttribute('d', angularSectorPath({ + ...sector, + center: { x: sector.center.x + space.originX, y: sector.center.y + space.originY }, + })); + }; + const angleDelta = (from: number, to: number): number => + Math.atan2(Math.sin(from - to), Math.cos(from - to)); + const sectorForEdit = (angle: number): PlotAngularSector | undefined => { + if (!initialSector) return undefined; + const delta = angleDelta(angle, angularGrabAngle); + if (angularAction === 'move') { + return { + ...initialSector, + startAngle: initialSector.startAngle + delta, + endAngle: initialSector.endAngle + delta, + }; + } + if (angularAction === 'resize-leading') { + return { ...initialSector, startAngle: initialSector.startAngle + delta }; + } + if (angularAction === 'resize-trailing') { + return { ...initialSector, endAngle: initialSector.endAngle + delta }; + } + return undefined; + }; + const dispatchAngularRegion = ( + phase: 'preview' | 'commit', + sector: PlotAngularSector, + event: PointerEvent, + operation: IntervalOperation | 'clear' = 'create', + target: SemanticTarget | null | undefined = undefined, + ): void => { + const normalized = normalizeVegaAngularRegionEvent( + view, sector, phase, interaction.eventSource.match ?? 'intersect', + interactionModifiers(event), operation, + ); + setSelected(new Set(committed)); + void dispatch({ + type: 'semantic', source: 'region', phase, + target: target === undefined ? resolveTarget('angular', 'region', normalized.hits) : target, + region: normalized.region, axis: normalized.axis, operation: normalized.operation, + modifiers: normalized.modifiers, + }); + }; + const showLasso = (points: readonly PlotPoint[]): void => { + if (!guide.visible) return; + const space = coordinateSpace(); + const renderer = container.querySelector('svg') as SVGSVGElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + Object.assign(lassoOverlay.style, { + display: 'block', + left: `${rendererLayout.left}px`, + top: `${rendererLayout.top}px`, + width: `${rendererLayout.width}px`, + height: `${rendererLayout.height}px`, + }); + lassoOverlay.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + const outline = points + .map((point, index) => `${index === 0 ? 'M' : 'L'}${point.x + space.originX} ${point.y + space.originY}`) + .join(' '); + lassoFill.setAttribute('d', `${outline} Z`); + lassoPath.setAttribute('d', outline); + }; + const dispatchLasso = ( + phase: 'preview' | 'commit', + points: readonly PlotPoint[], + event: PointerEvent, + ): void => { + const normalized = normalizeVegaLassoEvent( + view, points, phase, interaction.eventSource.match ?? 'intersect', interactionModifiers(event), + ); + setSelected(new Set(committed)); + void dispatch({ + type: 'semantic', source: 'region', phase, + target: resolveTarget('rectangle', 'region', normalized.hits), + region: normalized.region, axis: normalized.axis, operation: normalized.operation, + modifiers: normalized.modifiers, + }); + }; + const dispatchRegion = ( + phase: 'preview' | 'commit', + start: PlotPoint, + end: PlotPoint, + event: PointerEvent, + operation: IntervalOperation | 'clear', + target: SemanticTarget | null | undefined = undefined, + ): void => { + const normalized = normalizeVegaRegionEvent( + view, start, end, phase, interaction.eventSource.match ?? 'intersect', + interactionModifiers(event), regionAxis, brushPlotFrame(), operation, + !interaction.eventSource.viewport, + ); + setSelected(new Set(committed)); + void dispatch({ + type: 'semantic', source: 'region', phase, + target: target === undefined + ? interaction.eventSource.viewport ? null : resolveTarget('rectangle', 'region', normalized.hits) + : target, + region: normalized.region, axis: normalized.axis, operation: normalized.operation, + modifiers: normalized.modifiers, + }); + }; + const pointerDown = (event: PointerEvent): void => { + if (event.button !== 0 || isInteractiveControlTarget(event.target)) return; + clearHover(); + const point = localPoint(event); + const candidateFrame = facetPlotFrameAt(view, point, rootPlotFrame()); + if (angularBrush) { + const frame = frameAt(point, candidateFrame); + angularSession = new AngularRegionSession(point, frame); + angularAction = 'create'; + initialSector = undefined; + if (statefulAngular && activeSector) { + const angle = polarPointerAngle(point, frame); + angularAction = pointInAngularSector(point, activeSector) + ? angularEditAction(angle, activeSector) ?? 'create' + : 'create'; + if (angularAction !== 'create') { + initialSector = { ...activeSector }; + angularGrabAngle = angle; + } + } + } + dragAction = 'create'; + initialInterval = activeInterval ? { ...activeInterval } : undefined; + const insideActiveFrame = !activePlotFrame + || point.x >= activePlotFrame.x && point.x <= activePlotFrame.x + activePlotFrame.width + && point.y >= activePlotFrame.y && point.y <= activePlotFrame.y + activePlotFrame.height; + dragPlotFrame = candidateFrame; + if (lassoBrush) lassoPoints = [point]; + if (statefulBrush && activeInterval && insideActiveFrame) { + const value = axisValue(point, intervalAxis()); + const edgeTolerance = 8; + if (Math.abs(value - activeInterval.leading) <= edgeTolerance) dragAction = 'resize-leading'; + else if (Math.abs(value - activeInterval.trailing) <= edgeTolerance) dragAction = 'resize-trailing'; + else if (value > activeInterval.leading && value < activeInterval.trailing) dragAction = 'move'; + if (dragAction !== 'create') dragPlotFrame = activePlotFrame; + } + dragStart = point; + pointerId = event.pointerId; + committed = new Set(getSelected()); + setDragging(true); + container.setPointerCapture(event.pointerId); + }; + const pointerMove = (event: PointerEvent): void => { + if (!dragStart || pointerId !== event.pointerId) { + if (statefulAngular && activeSector) { + const point = localPoint(event); + const action = pointInAngularSector(point, activeSector) + ? angularEditAction(polarPointerAngle(point, activeSector), activeSector) + : undefined; + container.style.cursor = action?.startsWith('resize') ? 'ew-resize' + : action === 'move' ? 'grab' : 'crosshair'; + return; + } + if (statefulBrush && activeInterval) { + const point = localPoint(event); + const insideFrame = !activePlotFrame + || point.x >= activePlotFrame.x && point.x <= activePlotFrame.x + activePlotFrame.width + && point.y >= activePlotFrame.y && point.y <= activePlotFrame.y + activePlotFrame.height; + const value = axisValue(point, intervalAxis()); + const nearEdge = Math.abs(value - activeInterval.leading) <= 8 + || Math.abs(value - activeInterval.trailing) <= 8; + container.style.cursor = insideFrame && nearEdge + ? regionAxis === 'x' ? 'ew-resize' : 'ns-resize' + : insideFrame && value > activeInterval.leading && value < activeInterval.trailing ? 'grab' : 'crosshair'; + } + return; + } + const point = localPoint(event); + if (lassoBrush) { + const last = lassoPoints[lassoPoints.length - 1]; + if (last && Math.hypot(point.x - last.x, point.y - last.y) < 2) return; + lassoPoints.push(point); + if (lassoPoints.length < 3) return; + setSuppressClick(true); + showLasso(lassoPoints); + dispatchLasso('preview', lassoPoints, event); + return; + } + if (angularBrush) { + if (initialSector && angularSession) { + const edited = sectorForEdit(polarPointerAngle(point, angularSession.frame)); + if (!edited) return; + setSuppressClick(true); + showAngularSector(edited); + dispatchAngularRegion('preview', edited, event, angularAction); + return; + } + angularSession?.move(point); + if (!angularSession || angularSession.dragDistance() < 4) return; + setSuppressClick(true); + const sector = angularSession.sector(); + showAngularSector(sector); + dispatchAngularRegion('preview', sector, event); + return; + } + if (cartesianDragDistance(dragStart, point, regionAxis) < 4) return; + setSuppressClick(true); + const interval = regionAxis === 'xy' ? undefined : intervalForDrag(point); + const points = interval ? intervalPoints(interval, intervalAxis()) : { start: dragStart, end: point }; + if (interval) showInterval(interval); + else showRegion(dragStart, point); + dispatchRegion('preview', points.start, points.end, event, dragAction); + }; + const finishDrag = (event: PointerEvent): void => { + if (!dragStart || pointerId !== event.pointerId) return; + const point = localPoint(event); + if (lassoBrush) { + if (lassoPoints.length >= 3) dispatchLasso('commit', lassoPoints, event); + else { + committed.clear(); + dispatchLasso('commit', [], event); + } + lassoPoints = []; + lassoOverlay.style.display = 'none'; + dragStart = undefined; + pointerId = undefined; + setDragging(false); + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + window.setTimeout(() => { setSuppressClick(false); }, 0); + return; + } + if (angularBrush && !initialSector) angularSession?.move(point); + const editedSector = initialSector && angularSession + ? sectorForEdit(polarPointerAngle(point, angularSession.frame)) + : undefined; + const dragged = editedSector + ? true + : angularBrush && angularSession + ? angularSession.dragDistance() >= 4 + : cartesianDragDistance(dragStart, point, regionAxis) >= 4; + if (dragged) { + if (angularBrush) { + const sector = editedSector ?? angularSession!.sector(); + dispatchAngularRegion('commit', sector, event, editedSector ? angularAction : 'create'); + if (statefulAngular) { + activeSector = sector; + showAngularSector(sector); + } + } else { + const interval = regionAxis === 'xy' ? undefined : intervalForDrag(point); + const points = interval ? intervalPoints(interval, intervalAxis()) : { start: dragStart, end: point }; + dispatchRegion('commit', points.start, points.end, event, dragAction); + if (statefulBrush && interval) { + activeInterval = interval; + activePlotFrame = dragPlotFrame; + showInterval(interval); + } + } + } else if (!interaction.eventSource.viewport) { + if (statefulAngular) { + const clickedOutside = !activeSector || !pointInAngularSector(point, activeSector); + if (clickedOutside) { + const clearSector = activeSector ?? angularSession?.sector(); + activeSector = undefined; + committed.clear(); + if (clearSector) dispatchAngularRegion('commit', clearSector, event, 'clear', null); + } + } else { + const clickedOutside = !activeInterval || axisValue(point, intervalAxis()) < activeInterval.leading + || axisValue(point, intervalAxis()) > activeInterval.trailing; + if (!statefulBrush || clickedOutside) { + activeInterval = undefined; + activePlotFrame = undefined; + committed.clear(); + dispatchRegion('commit', dragStart, point, event, 'clear', null); + } + } + } + dragStart = undefined; + pointerId = undefined; + initialInterval = undefined; + initialSector = undefined; + angularAction = 'create'; + angularSession = undefined; + dragPlotFrame = undefined; + setDragging(false); + if (!statefulBrush || !activeInterval) overlay.style.display = 'none'; + if (!statefulAngular || !activeSector) angularOverlay.style.display = 'none'; + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + if (dragged) window.setTimeout(() => { setSuppressClick(false); }, 0); + }; + const cancelDrag = (event: PointerEvent): void => { + if (!dragStart || pointerId !== event.pointerId) return; + setSelected(new Set(committed)); + dragStart = undefined; + pointerId = undefined; + initialInterval = undefined; + angularSession = undefined; + lassoPoints = []; + dragPlotFrame = undefined; + lassoOverlay.style.display = 'none'; + setDragging(false); + if (statefulBrush && activeInterval) showInterval(activeInterval); + else overlay.style.display = 'none'; + if (statefulAngular && initialSector) { + activeSector = initialSector; + showAngularSector(activeSector); + } else if (!statefulAngular || !activeSector) { + angularOverlay.style.display = 'none'; + } + initialSector = undefined; + angularAction = 'create'; + if (container.hasPointerCapture(event.pointerId)) container.releasePointerCapture(event.pointerId); + void sync(); + }; + const keyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return; + if (interaction.eventSource.viewport) resetViewport?.(); + if (dragStart) { + setSelected(new Set(committed)); + if (statefulBrush && initialInterval) activeInterval = initialInterval; + } else { + setSelected(new Set()); + activeInterval = undefined; + activePlotFrame = undefined; + activeSector = undefined; + clearAnnotation(); + } + dragStart = undefined; + pointerId = undefined; + initialInterval = undefined; + dragPlotFrame = undefined; + setDragging(false); + overlay.style.display = 'none'; + angularOverlay.style.display = 'none'; + void sync(); + }; + const doubleClick = (event: MouseEvent): void => { + if (!interaction.eventSource.viewport) return; + event.preventDefault(); + resetViewport?.(); + }; + + container.addEventListener('pointerdown', pointerDown, true); + container.addEventListener('pointermove', pointerMove, true); + container.addEventListener('pointerup', finishDrag, true); + container.addEventListener('pointercancel', cancelDrag, true); + container.addEventListener('keydown', keyDown); + container.addEventListener('dblclick', doubleClick); + + return { + sync(): void { + if (statefulBrush && activeInterval) showInterval(activeInterval); + if (statefulAngular && activeSector) showAngularSector(activeSector); + }, + destroy(): void { + container.removeEventListener('pointerdown', pointerDown, true); + container.removeEventListener('pointermove', pointerMove, true); + container.removeEventListener('pointerup', finishDrag, true); + container.removeEventListener('pointercancel', cancelDrag, true); + container.removeEventListener('keydown', keyDown); + container.removeEventListener('dblclick', doubleClick); + overlay.remove(); + angularOverlay.remove(); + lassoOverlay.remove(); + setDragging(false); + container.style.position = previousPosition; + container.style.userSelect = previousUserSelect; + container.style.cursor = previousCursor; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactions/hit-adapter.ts b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts new file mode 100644 index 00000000..62f5e041 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/hit-adapter.ts @@ -0,0 +1,1504 @@ +import { + semanticVisualFamily, + type RenderHit, + type SemanticTarget, + type LegendTargetValue, +} from '../../core/interaction-semantics'; +import type { + ElementInteractionEvent, + InteractionModifiers, + InteractionPhase, + PlotPoint, + PlotAngularSector, + RegionInteractionEvent, + RegionOperation, +} from '../../interactive/language/events'; +import { angularSegments } from '../../interactive/geometry/angular'; +import { + constrainCartesianRegion, + type CartesianRegionAxis, + type PlotFrame, + type PlotSize, +} from '../../interactive/gestures/cartesian-region'; +export { + clientRectToLayoutRect, + clientToLayoutPoint, + clientToPlotPoint, + clientToRendererPoint, + interactionModifiers, + plotToClientPoint, + rendererPlotOrigin, + type RendererCoordinateSpace, +} from '../../interactive/geometry/coordinate-space'; + +export const INTERACTION_KEY = '__flint_interaction_key'; +export const INTERACTION_ROLE = '__flint_interaction_role'; +export const INTERACTION_LEGEND_CHANNEL = '__flint_legend_channel'; +export const INTERACTION_LEGEND_FIELD = '__flint_legend_field'; +export const PATH_KEY_SUFFIX = '|__flint_path'; + +const SUPPORTED_RENDER_MARKS = new Set(['arc', 'area', 'bar', 'line', 'rect', 'rule', 'shape', 'symbol', 'text']); + +export interface SelectionRect { + x1: number; + y1: number; + x2: number; + y2: number; +} + +export interface LegendHitIdentity extends LegendTargetValue { + value: unknown; + visualBounds?: SelectionRect; +} + +interface PathGeometry { + kind: 'segment' | 'slice'; + points: PlotPoint[]; + annotationPoints?: PlotPoint[]; + offset: PlotPoint; + endDatum?: Record; + closed?: boolean; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +const LEGEND_SEGMENT_TARGET_PX = 44; +const MIN_LEGEND_SEGMENTS = 3; +const MAX_LEGEND_SEGMENTS = 7; + +export function continuousLegendSegmentCount(span: number, distinctValues = Infinity): number { + const physical = clamp( + Math.round(Math.max(0, span) / LEGEND_SEGMENT_TARGET_PX), + MIN_LEGEND_SEGMENTS, + MAX_LEGEND_SEGMENTS, + ); + return Math.max(1, Math.min(physical, Math.max(1, Math.floor(distinctValues)))); +} + +function signedPow(value: number, exponent: number): number { + return Math.sign(value) * Math.abs(value) ** exponent; +} + +function continuousScaleValue(scale: any, fraction: number): number | undefined { + const domain = typeof scale?.domain === 'function' ? scale.domain() : undefined; + if (!Array.isArray(domain) || domain.length < 2) return undefined; + const values = domain.map((entry: unknown) => entry instanceof Date ? entry.getTime() : Number(entry)); + if (values.some((entry: number) => !Number.isFinite(entry))) return undefined; + const position = clamp(fraction, 0, 1) * (values.length - 1); + const index = Math.min(values.length - 2, Math.floor(position)); + const local = position - index; + const start = values[index]; + const end = values[index + 1]; + const type = String(scale?.type ?? 'linear'); + let transform = (value: number): number => value; + let untransform = (value: number): number => value; + if (type.includes('symlog')) { + const constant = typeof scale.constant === 'function' ? scale.constant() : 1; + transform = (value) => Math.sign(value) * Math.log1p(Math.abs(value / constant)); + untransform = (value) => Math.sign(value) * Math.expm1(Math.abs(value)) * constant; + } else if (type.includes('log') && start !== 0 && end !== 0 && Math.sign(start) === Math.sign(end)) { + transform = (value) => Math.sign(value) * Math.log(Math.abs(value)); + untransform = (value) => Math.sign(value) * Math.exp(Math.abs(value)); + } else if (type.includes('sqrt')) { + transform = (value) => signedPow(value, 0.5); + untransform = (value) => signedPow(value, 2); + } else if (type.includes('pow')) { + const exponent = typeof scale.exponent === 'function' ? scale.exponent() : 1; + transform = (value) => signedPow(value, exponent); + untransform = (value) => signedPow(value, 1 / exponent); + } + return untransform(transform(start) + (transform(end) - transform(start)) * local); +} + +function keyOfDatum(datum: unknown): string | undefined { + if (!datum || typeof datum !== 'object') return undefined; + const key = (datum as Record)[INTERACTION_KEY]; + return typeof key === 'string' ? key : undefined; +} + +export function pathHoverPresentationKey(items: readonly any[], semanticKey: string): string { + if (!semanticKey.endsWith(PATH_KEY_SUFFIX)) return semanticKey; + const segmentKey = semanticKey.slice(0, -PATH_KEY_SUFFIX.length); + const segment = items.find((item) => + (item.mark?.marktype === 'line' || item.mark?.marktype === 'area') + && keyOfDatum(item.datum) === segmentKey, + ); + if (segment?.mark?.marktype === 'line') return semanticKey; + const pathKey = segment?.mark?.items + ?.map((item: any) => keyOfDatum(item.datum)) + .find((key: string | undefined): key is string => typeof key === 'string'); + return pathKey ? `${pathKey}${PATH_KEY_SUFFIX}` : semanticKey; +} + +function pathGeometry(item: any, offsetX: number, offsetY: number, siblingIndex?: number): PathGeometry | null { + const items = item?.mark?.items; + if (!Array.isArray(items)) return null; + const index = siblingIndex ?? items.indexOf(item); + if (index < 0) return null; + const point = (candidate: any): PlotPoint => ({ x: candidate.x + offsetX, y: candidate.y + offsetY }); + if (item.mark.marktype === 'line') { + const closed = typeof item.interpolate === 'string' && item.interpolate.endsWith('-closed'); + const next = index < items.length - 1 ? items[index + 1] : closed ? items[0] : undefined; + if (!next) return null; + return { + kind: 'segment', + points: [point(item), point(next)], + annotationPoints: [point(item), point(next)], + offset: { x: offsetX, y: offsetY }, + endDatum: next.datum, + closed, + }; + } + if (item.mark.marktype !== 'area' + || (typeof item.y2 !== 'number' && typeof item.x2 !== 'number')) return null; + const next = items[index + 1]; + if (!next) return null; + const annotationPoints = [point(item), point(next)]; + const secondaryPoints = typeof item.y2 === 'number' && typeof next.y2 === 'number' + ? [ + { x: next.x + offsetX, y: next.y2 + offsetY }, + { x: item.x + offsetX, y: item.y2 + offsetY }, + ] + : typeof item.x2 === 'number' && typeof next.x2 === 'number' + ? [ + { x: next.x2 + offsetX, y: next.y + offsetY }, + { x: item.x2 + offsetX, y: item.y + offsetY }, + ] + : undefined; + if (!secondaryPoints) return null; + return { + kind: 'slice', + points: [ + point(item), + point(next), + ...secondaryPoints, + ], + annotationPoints, + offset: { x: offsetX, y: offsetY }, + endDatum: next.datum, + }; +} + +export function sceneItems(view: any): any[] { + const result: any[] = []; + const visit = (item: any, offsetX: number, offsetY: number, siblingIndex?: number): void => { + if (!item) return; + if (SUPPORTED_RENDER_MARKS.has(item.mark?.marktype) && keyOfDatum(item.datum) && item.bounds) { + const interactionGeometry = pathGeometry(item, offsetX, offsetY, siblingIndex); + if ((item.mark.marktype === 'line' || item.mark.marktype === 'area') && !interactionGeometry) return; + const points = interactionGeometry?.points; + result.push({ + ...item, + x: typeof item.x === 'number' ? item.x + offsetX : item.x, + y: typeof item.y === 'number' ? item.y + offsetY : item.y, + bounds: points ? { + x1: Math.min(...points.map((point) => point.x)), + x2: Math.max(...points.map((point) => point.x)), + y1: Math.min(...points.map((point) => point.y)), + y2: Math.max(...points.map((point) => point.y)), + } : { + x1: item.bounds.x1 + offsetX, + x2: item.bounds.x2 + offsetX, + y1: item.bounds.y1 + offsetY, + y2: item.bounds.y2 + offsetY, + }, + interactionGeometry, + }); + } + const isGroup = item.mark?.marktype === 'group'; + const childOffsetX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const childOffsetY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (Array.isArray(item.items)) { + item.items.forEach((child: any, index: number) => visit(child, childOffsetX, childOffsetY, index)); + } + }; + visit(view.scenegraph()?.root, 0, 0); + return result; +} + +export function facetPlotFrameAt(view: any, point: PlotPoint, fallback: PlotFrame): PlotFrame { + const frames: PlotFrame[] = []; + const visit = (item: any, offsetX: number, offsetY: number): void => { + if (!item) return; + const isGroup = item.mark?.marktype === 'group'; + const x = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const y = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (isGroup && (item.mark?.role === 'cell' || item.mark?.name === 'cell') + && typeof item.width === 'number' && typeof item.height === 'number' + && point.x >= x && point.x <= x + item.width + && point.y >= y && point.y <= y + item.height) { + frames.push({ x, y, width: item.width, height: item.height }); + } + if (Array.isArray(item.items)) item.items.forEach((child: any) => visit(child, x, y)); + }; + visit(view.scenegraph()?.root, 0, 0); + return frames.sort((left, right) => left.width * left.height - right.width * right.height)[0] ?? fallback; +} + +/** Bounding plot frame of all Vega facet cells, or the root plot for a unit chart. */ +export function facetPlotBounds(view: any, fallback: PlotFrame): PlotFrame { + const frames: PlotFrame[] = []; + const visit = (item: any, offsetX: number, offsetY: number): void => { + if (!item) return; + const isGroup = item.mark?.marktype === 'group'; + const x = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const y = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (isGroup && (item.mark?.role === 'cell' || item.mark?.name === 'cell') + && typeof item.width === 'number' && typeof item.height === 'number') { + frames.push({ x, y, width: item.width, height: item.height }); + } + if (Array.isArray(item.items)) item.items.forEach((child: any) => visit(child, x, y)); + }; + visit(view.scenegraph()?.root, 0, 0); + if (frames.length === 0) return fallback; + const x1 = Math.min(...frames.map((frame) => frame.x)); + const y1 = Math.min(...frames.map((frame) => frame.y)); + const x2 = Math.max(...frames.map((frame) => frame.x + frame.width)); + const y2 = Math.max(...frames.map((frame) => frame.y + frame.height)); + return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 }; +} + +export function boundsIntersectRect( + bounds: SelectionRect, + rect: SelectionRect, + minimumOverlap = 0.5, +): boolean { + const overlapX = Math.min(bounds.x2, rect.x2) - Math.max(bounds.x1, rect.x1); + const overlapY = Math.min(bounds.y2, rect.y2) - Math.max(bounds.y1, rect.y1); + return overlapX > minimumOverlap && overlapY > minimumOverlap; +} + +function pointInRect(point: PlotPoint, rect: SelectionRect): boolean { + return point.x >= rect.x1 && point.x <= rect.x2 && point.y >= rect.y1 && point.y <= rect.y2; +} + +function orientation(a: PlotPoint, b: PlotPoint, c: PlotPoint): number { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +} + +function segmentsIntersect(a: PlotPoint, b: PlotPoint, c: PlotPoint, d: PlotPoint): boolean { + const abC = orientation(a, b, c); + const abD = orientation(a, b, d); + const cdA = orientation(c, d, a); + const cdB = orientation(c, d, b); + const epsilon = 1e-9; + const onSegment = (start: PlotPoint, end: PlotPoint, point: PlotPoint): boolean => + point.x >= Math.min(start.x, end.x) - epsilon + && point.x <= Math.max(start.x, end.x) + epsilon + && point.y >= Math.min(start.y, end.y) - epsilon + && point.y <= Math.max(start.y, end.y) + epsilon; + if (Math.abs(abC) <= epsilon && onSegment(a, b, c)) return true; + if (Math.abs(abD) <= epsilon && onSegment(a, b, d)) return true; + if (Math.abs(cdA) <= epsilon && onSegment(c, d, a)) return true; + if (Math.abs(cdB) <= epsilon && onSegment(c, d, b)) return true; + return (abC > 0) !== (abD > 0) && (cdA > 0) !== (cdB > 0); +} + +function pointInPolygon(point: PlotPoint, polygon: readonly PlotPoint[]): boolean { + let inside = false; + for (let current = 0, previous = polygon.length - 1; current < polygon.length; previous = current++) { + const a = polygon[current]; + const b = polygon[previous]; + if ((a.y > point.y) !== (b.y > point.y) + && point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x) inside = !inside; + } + return inside; +} + +/** + * Lasso capture matches the rectangle brush: `intersect` is a real area overlap + * rather than a sample of the mark's centre and corners. + */ +function polygonIntersectsRect(polygon: readonly PlotPoint[], rect: SelectionRect): boolean { + const corners: PlotPoint[] = [ + { x: rect.x1, y: rect.y1 }, { x: rect.x2, y: rect.y1 }, + { x: rect.x2, y: rect.y2 }, { x: rect.x1, y: rect.y2 }, + ]; + if (corners.some((corner) => pointInPolygon(corner, polygon))) return true; + if (polygon.some((vertex) => pointInRect(vertex, rect))) return true; + for (let current = 0, previous = polygon.length - 1; current < polygon.length; previous = current++) { + for (let corner = 0; corner < corners.length; corner++) { + if (segmentsIntersect( + polygon[previous], polygon[current], + corners[corner], corners[(corner + 1) % corners.length], + )) return true; + } + } + return false; +} + +export function geometryIntersectsRect(geometry: PathGeometry, rect: SelectionRect, contain: boolean): boolean { + if (contain) return geometry.points.every((point) => pointInRect(point, rect)); + if (geometry.points.some((point) => pointInRect(point, rect))) return true; + const corners: PlotPoint[] = [ + { x: rect.x1, y: rect.y1 }, { x: rect.x2, y: rect.y1 }, + { x: rect.x2, y: rect.y2 }, { x: rect.x1, y: rect.y2 }, + ]; + if (geometry.kind === 'slice' && corners.some((point) => pointInPolygon(point, geometry.points))) return true; + const geometryEdges = geometry.kind === 'segment' + ? [[geometry.points[0], geometry.points[1]] as const] + : geometry.points.map((point, index) => [point, geometry.points[(index + 1) % geometry.points.length]] as const); + const rectEdges = corners.map((point, index) => [point, corners[(index + 1) % corners.length]] as const); + return geometryEdges.some(([a, b]) => rectEdges.some(([c, d]) => segmentsIntersect(a, b, c, d))); +} + +function arcPolygon(item: any): PlotPoint[] | null { + if (item?.mark?.marktype !== 'arc') return null; + const values = [item.x, item.y, item.innerRadius, item.outerRadius, item.startAngle, item.endAngle]; + if (!values.every((value) => typeof value === 'number' && Number.isFinite(value))) return null; + const delta = item.endAngle - item.startAngle; + const steps = Math.max(8, Math.ceil(Math.abs(delta) * item.outerRadius / 4)); + const pointAt = (radius: number, angle: number): PlotPoint => ({ + x: item.x + radius * Math.sin(angle), + y: item.y - radius * Math.cos(angle), + }); + const polygon: PlotPoint[] = []; + for (let index = 0; index <= steps; index += 1) { + polygon.push(pointAt(item.outerRadius, item.startAngle + delta * index / steps)); + } + for (let index = steps; index >= 0; index -= 1) { + polygon.push(pointAt(item.innerRadius, item.startAngle + delta * index / steps)); + } + return polygon; +} + +export function arcIntersectsRect(item: any, rect: SelectionRect, contain = false): boolean { + const polygon = arcPolygon(item); + if (!polygon) return false; + if (contain) return polygon.every((point) => pointInRect(point, rect)); + if (polygon.some((point) => pointInRect(point, rect))) return true; + const corners: PlotPoint[] = [ + { x: rect.x1, y: rect.y1 }, { x: rect.x2, y: rect.y1 }, + { x: rect.x2, y: rect.y2 }, { x: rect.x1, y: rect.y2 }, + ]; + if (corners.some((point) => pointInPolygon(point, polygon))) return true; + const rectEdges = corners.map((point, index) => [point, corners[(index + 1) % corners.length]] as const); + for (let index = 0; index < polygon.length; index += 1) { + const a = polygon[index]; + const b = polygon[(index + 1) % polygon.length]; + if (rectEdges.some(([c, d]) => segmentsIntersect(a, b, c, d))) return true; + } + return false; +} + +export function arcIntersectsAngularSector( + item: any, + sector: PlotAngularSector, + contain = false, +): boolean { + if (item?.mark?.marktype !== 'arc') return false; + const values = [item.x, item.y, item.innerRadius, item.outerRadius, item.startAngle, item.endAngle]; + if (!values.every((value) => typeof value === 'number' && Number.isFinite(value))) return false; + if (Math.hypot(item.x - sector.center.x, item.y - sector.center.y) > 1) return false; + const radialMatch = contain + ? item.innerRadius >= sector.innerRadius && item.outerRadius <= sector.outerRadius + : item.outerRadius > sector.innerRadius && item.innerRadius < sector.outerRadius; + if (!radialMatch) return false; + const selection = angularSegments(sector.startAngle, sector.endAngle); + const arc = angularSegments(item.startAngle, item.endAngle); + if (contain) { + return arc.every(([arcStart, arcEnd]) => selection.some( + ([selectionStart, selectionEnd]) => arcStart >= selectionStart - 1e-9 + && arcEnd <= selectionEnd + 1e-9, + )); + } + return arc.some(([arcStart, arcEnd]) => selection.some( + ([selectionStart, selectionEnd]) => Math.min(arcEnd, selectionEnd) - Math.max(arcStart, selectionStart) > 1e-9, + )); +} + +function pointInSector(point: PlotPoint, sector: PlotAngularSector): boolean { + const dx = point.x - sector.center.x; + const dy = point.y - sector.center.y; + const radius = Math.hypot(dx, dy); + if (radius < sector.innerRadius - 1e-9 || radius > sector.outerRadius + 1e-9) return false; + const angle = ((Math.atan2(dx, -dy) % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); + return angularSegments(sector.startAngle, sector.endAngle) + .some(([start, end]) => angle >= start - 1e-9 && angle <= end + 1e-9); +} + +function angularSectorPolygon(sector: PlotAngularSector): PlotPoint[] { + const sweep = Math.max(-2 * Math.PI, Math.min(2 * Math.PI, sector.endAngle - sector.startAngle)); + const steps = Math.max(8, Math.ceil(Math.abs(sweep) * sector.outerRadius / 4)); + const pointAt = (radius: number, angle: number): PlotPoint => ({ + x: sector.center.x + radius * Math.sin(angle), + y: sector.center.y - radius * Math.cos(angle), + }); + const points: PlotPoint[] = []; + for (let index = 0; index <= steps; index += 1) { + points.push(pointAt(sector.outerRadius, sector.startAngle + sweep * index / steps)); + } + if (sector.innerRadius <= 0) { + points.push(sector.center); + } else { + for (let index = steps; index >= 0; index -= 1) { + points.push(pointAt(sector.innerRadius, sector.startAngle + sweep * index / steps)); + } + } + return points; +} + +export function pathIntersectsAngularSector( + points: readonly PlotPoint[], + sector: PlotAngularSector, + contain = false, +): boolean { + if (points.length === 0) return false; + if (contain) { + for (let index = 0; index < points.length - 1; index += 1) { + const start = points[index]; + const end = points[index + 1]; + const steps = Math.max(1, Math.ceil(Math.hypot(end.x - start.x, end.y - start.y) / 4)); + for (let step = 0; step <= steps; step += 1) { + const fraction = step / steps; + if (!pointInSector({ + x: start.x + (end.x - start.x) * fraction, + y: start.y + (end.y - start.y) * fraction, + }, sector)) return false; + } + } + return points.length === 1 ? pointInSector(points[0], sector) : true; + } + if (points.some((point) => pointInSector(point, sector))) return true; + const boundary = angularSectorPolygon(sector); + for (let index = 0; index < points.length - 1; index += 1) { + const start = points[index]; + const end = points[index + 1]; + for (let edge = 0; edge < boundary.length; edge += 1) { + if (segmentsIntersect(start, end, boundary[edge], boundary[(edge + 1) % boundary.length])) return true; + } + } + return false; +} + +export function polarFrameFromItems( + items: readonly any[], + point?: PlotPoint, +): { center: PlotPoint; innerRadius: number; outerRadius: number } | undefined { + const frames = new Map(); + for (const item of items) { + if (item?.mark?.marktype !== 'arc' || typeof item.x !== 'number' || typeof item.y !== 'number' + || typeof item.innerRadius !== 'number' || typeof item.outerRadius !== 'number') continue; + const key = `${item.x}\u0000${item.y}`; + const existing = frames.get(key); + frames.set(key, existing ? { + center: existing.center, + innerRadius: Math.min(existing.innerRadius, item.innerRadius), + outerRadius: Math.max(existing.outerRadius, item.outerRadius), + } : { + center: { x: item.x, y: item.y }, + innerRadius: item.innerRadius, + outerRadius: item.outerRadius, + }); + } + const available = [...frames.values()]; + if (!point) return available[0]; + return available.sort((left, right) => + Math.hypot(point.x - left.center.x, point.y - left.center.y) + - Math.hypot(point.x - right.center.x, point.y - right.center.y))[0]; +} + +/** Infer each rendered Radar frame from its grid spokes, excluding labels and legends. */ +export function polarFrameFromRadarGrid( + view: any, + point?: PlotPoint, +): { center: PlotPoint; innerRadius: number; outerRadius: number } | undefined { + const frames = new Map(); + const visit = (item: any, offsetX: number, offsetY: number): void => { + if (!item) return; + const isGroup = item.mark?.marktype === 'group'; + const childOffsetX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const childOffsetY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (item.mark?.marktype === 'rule' && item.datum?.__type === 'spoke' + && [item.x, item.y, item.x2, item.y2].every( + (value) => typeof value === 'number' && Number.isFinite(value), + )) { + const center = { x: item.x + offsetX, y: item.y + offsetY }; + const end = { x: item.x2 + offsetX, y: item.y2 + offsetY }; + const key = `${center.x}\u0000${center.y}`; + const outerRadius = Math.hypot(end.x - center.x, end.y - center.y); + const existing = frames.get(key); + frames.set(key, { + center, + innerRadius: 0, + outerRadius: Math.max(existing?.outerRadius ?? 0, outerRadius), + }); + } + if (Array.isArray(item.items)) { + for (const child of item.items) visit(child, childOffsetX, childOffsetY); + } + }; + visit(view.scenegraph()?.root, 0, 0); + const available = [...frames.values()].filter((frame) => frame.outerRadius > 0); + if (!point) return available[0]; + return available.sort((left, right) => + Math.hypot(point.x - left.center.x, point.y - left.center.y) + - Math.hypot(point.x - right.center.x, point.y - right.center.y))[0]; +} + +export function polarGuideSegment( + frame: { center: PlotPoint; outerRadius: number }, + point: PlotPoint, +): { start: PlotPoint; end: PlotPoint } { + const dx = point.x - frame.center.x; + const dy = point.y - frame.center.y; + const distance = Math.hypot(dx, dy); + const scale = distance > 0 ? frame.outerRadius / distance : 0; + return { + start: frame.center, + end: distance > 0 + ? { x: frame.center.x + dx * scale, y: frame.center.y + dy * scale } + : { x: frame.center.x, y: frame.center.y - frame.outerRadius }, + }; +} + +export function polarInspectHits( + items: readonly any[], + point: PlotPoint, + frame: { center: PlotPoint }, +): RenderHit[] { + const angle = ((Math.atan2(point.x - frame.center.x, frame.center.y - point.y) % (2 * Math.PI)) + + 2 * Math.PI) % (2 * Math.PI); + return items + .filter((item) => item?.mark?.marktype === 'arc' + && Number.isFinite(item.startAngle) && Number.isFinite(item.endAngle) + && Math.hypot(item.x - frame.center.x, item.y - frame.center.y) <= 1 + && angularSegments(item.startAngle, item.endAngle).some( + ([start, end]) => angle >= start - 1e-9 && angle <= end + 1e-9, + )) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); +} + +export function angularRegionHits( + view: any, + sector: PlotAngularSector, + contain = false, +): RenderHit[] { + return sceneItems(view) + .filter((item) => { + if (arcIntersectsAngularSector(item, sector, contain)) return true; + const markType = item?.mark?.marktype; + if (markType === 'symbol' && typeof item.x === 'number' && typeof item.y === 'number') { + return pointInSector({ x: item.x, y: item.y }, sector); + } + const points = item?.interactionGeometry?.points as readonly PlotPoint[] | undefined; + return markType === 'line' && points + ? pathIntersectsAngularSector(points, sector, contain) + : false; + }) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); +} + +export function normalizeVegaAngularRegionEvent( + view: any, + sector: PlotAngularSector, + phase: InteractionPhase, + match: 'intersect' | 'contain', + modifiers: InteractionModifiers, + operation: RegionOperation = 'create', +): RegionInteractionEvent { + return { + type: 'region', + phase, + axis: 'angle', + operation, + region: sector, + hits: angularRegionHits(view, sector, match === 'contain'), + match, + modifiers, + }; +} + +export function renderHit(item: any): RenderHit | null { + const markType = item?.mark?.marktype; + const taggedText = markType !== 'text' || item?.datum?.[INTERACTION_ROLE] === 'text-label'; + if (!SUPPORTED_RENDER_MARKS.has(markType) || !taggedText || !keyOfDatum(item?.datum)) return null; + const datum = item.mark.marktype === 'line' || item.mark.marktype === 'area' + ? { ...item.datum, [INTERACTION_KEY]: `${keyOfDatum(item.datum)}${PATH_KEY_SUFFIX}` } + : item.datum; + return { + datum, + endDatum: item.interactionGeometry?.endDatum, + pathData: markType === 'line' || markType === 'area' + ? item.mark.items?.map((pathItem: any) => pathItem.datum).filter(Boolean) + : undefined, + source: 'mark', + markType: item.mark?.marktype, + markName: item.mark?.name, + layerRole: item?.datum?.[INTERACTION_ROLE] ?? item.mark?.role, + }; +} + +export function physicalItemAt(view: any, item: any, point: PlotPoint): any { + const pathItems = item?.mark?.marktype === 'line' || item?.mark?.marktype === 'area' + ? sceneItems(view).filter((candidate) => + candidate.interactionGeometry + && candidate.mark?.marktype === item.mark.marktype + && (candidate.mark === item.mark + || (item.mark?.name && candidate.mark?.name === item.mark.name))) + : []; + if (item?.mark?.marktype === 'area') { + return pathItems.find((candidate) => pointInPolygon(point, candidate.interactionGeometry.points)); + } + if (item?.mark?.marktype === 'line') { + return pathItems.reduce((nearest, candidate) => { + const [a, b] = candidate.interactionGeometry.points; + const lengthSquared = (b.x - a.x) ** 2 + (b.y - a.y) ** 2; + const ratio = lengthSquared === 0 ? 0 : clamp( + ((point.x - a.x) * (b.x - a.x) + (point.y - a.y) * (b.y - a.y)) / lengthSquared, + 0, + 1, + ); + const distance = Math.hypot(point.x - (a.x + ratio * (b.x - a.x)), point.y - (a.y + ratio * (b.y - a.y))); + return !nearest || distance < nearest.distance ? { item: candidate, distance } : nearest; + }, null)?.item; + } + return item; +} + +export function legendTarget( + item: any, + legendFields?: Readonly>, + rangeLegendChannels: readonly string[] = [], + view?: any, + rootPoint?: PlotPoint, +): LegendHitIdentity | null { + if (item?.datum?.[INTERACTION_ROLE] === 'legend-label') { + const channel = item.datum[INTERACTION_LEGEND_CHANNEL]; + const field = item.datum[INTERACTION_LEGEND_FIELD]; + const value = typeof field === 'string' ? item.datum[field] : undefined; + if (typeof channel !== 'string' || typeof field !== 'string' || value === undefined) return null; + return { channel, field, value, domain: { kind: 'value', value } }; + } + const isLegend = semanticVisualFamily(item?.mark?.role) === 'legend'; + if (!isLegend) return null; + let legendEntry = item?.mark?.group; + while (legendEntry && !legendEntry.datum?.scales) legendEntry = legendEntry.mark?.group; + const scales = legendEntry?.datum?.scales; + const channel = scales && typeof scales === 'object' + ? Object.keys(scales).map((key) => key === 'fill' || key === 'stroke' ? 'color' : key)[0] + : undefined; + let value = item?.datum?.value; + let range: { min?: number; max?: number } | undefined; + let visualBounds: SelectionRect | undefined; + if (channel && rangeLegendChannels.includes(channel)) { + const anchors: { index: number; value: number; perc?: number }[] = []; + const visit = (candidate: any): void => { + const anchor = candidate?.datum?.value; + const numeric = anchor instanceof Date ? anchor.getTime() : anchor; + if (typeof numeric === 'number') { + anchors.push({ + index: typeof candidate.datum.index === 'number' ? candidate.datum.index : anchors.length, + value: numeric, + ...(typeof candidate.datum.perc === 'number' ? { perc: candidate.datum.perc } : {}), + }); + } + if (Array.isArray(candidate?.items)) candidate.items.forEach(visit); + }; + visit(legendEntry); + const unique = [...new Map(anchors.map((anchor) => [anchor.index, anchor])).values()] + .sort((left, right) => left.index - right.index); + if (item.mark.role === 'legend-gradient' && rootPoint && view) { + const bounds = rootBoundsForItem(view, item); + const scaleName = scales && typeof scales === 'object' + ? Object.entries(scales).find(([key]) => + (key === 'fill' || key === 'stroke' ? 'color' : key) === channel)?.[1] + : undefined; + const scale = typeof scaleName === 'string' && typeof view.scale === 'function' + ? view.scale(scaleName) + : undefined; + if (bounds && unique.length > 0) { + const vertical = legendEntry?.datum?.vgrad === true; + const span = vertical ? bounds.y2 - bounds.y1 : bounds.x2 - bounds.x1; + const position = vertical ? bounds.y2 - rootPoint.y : rootPoint.x - bounds.x1; + const fraction = span > 0 ? clamp(position / span, 0, 1) : 0; + const segmentCount = continuousLegendSegmentCount(span); + const index = Math.min(segmentCount - 1, Math.floor(fraction * segmentCount)); + const lower = index / segmentCount; + const upper = (index + 1) / segmentCount; + const fallbackValue = (unique[0].value + + (unique[unique.length - 1].value - unique[0].value) * ((lower + upper) / 2)); + value = continuousScaleValue(scale, (lower + upper) / 2) ?? fallbackValue; + range = { + ...(index > 0 ? { + min: continuousScaleValue(scale, lower) + ?? unique[0].value + (unique[unique.length - 1].value - unique[0].value) * lower, + } : {}), + ...(index < segmentCount - 1 ? { + max: continuousScaleValue(scale, upper) + ?? unique[0].value + (unique[unique.length - 1].value - unique[0].value) * upper, + } : {}), + }; + const width = bounds.x2 - bounds.x1; + const height = bounds.y2 - bounds.y1; + visualBounds = vertical + ? { x1: bounds.x1, x2: bounds.x2, y1: bounds.y2 - upper * height, y2: bounds.y2 - lower * height } + : { x1: bounds.x1 + lower * width, x2: bounds.x1 + upper * width, y1: bounds.y1, y2: bounds.y2 }; + } + } + const numericValue = value instanceof Date ? value.getTime() : value; + const index = unique.findIndex((anchor) => anchor.value === numericValue); + if (item.mark.role === 'legend-band' && index >= 0) { + const min = unique[index].value; + const max = unique[index + 1]?.value; + range = { + ...(Number.isFinite(min) ? { min } : {}), + ...(Number.isFinite(max) ? { max } : {}), + }; + value = min; + } + else if (!range && index >= 0 && unique.length > 1) { + range = { + ...(index > 0 ? { min: (unique[index - 1].value + unique[index].value) / 2 } : {}), + ...(index < unique.length - 1 ? { max: (unique[index].value + unique[index + 1].value) / 2 } : {}), + }; + } + } + if (value === undefined) return null; + return { + channel, + value, + field: channel ? legendFields?.[channel] : undefined, + domain: range + ? { kind: 'interval', ...(range.min !== undefined ? { start: range.min } : {}), ...(range.max !== undefined ? { end: range.max } : {}) } + : { kind: 'value', value }, + ...(visualBounds ? { visualBounds } : {}), + }; +} + +function rootBoundsForItem(view: any, target: any): SelectionRect | undefined { + const element = target?._svg as SVGGraphicsElement | undefined; + const svg = element?.ownerSVGElement; + if (svg && typeof element.getBoundingClientRect === 'function') { + const itemRect = element.getBoundingClientRect(); + const svgRect = svg.getBoundingClientRect(); + const viewBox = svg.viewBox.baseVal; + if (svgRect.width > 0 && svgRect.height > 0 && viewBox.width > 0 && viewBox.height > 0) { + const scaleX = viewBox.width / svgRect.width; + const scaleY = viewBox.height / svgRect.height; + return { + x1: viewBox.x + (itemRect.left - svgRect.left) * scaleX, + y1: viewBox.y + (itemRect.top - svgRect.top) * scaleY, + x2: viewBox.x + (itemRect.right - svgRect.left) * scaleX, + y2: viewBox.y + (itemRect.bottom - svgRect.top) * scaleY, + }; + } + } + let result: SelectionRect | undefined; + const visit = (item: any, offsetX: number, offsetY: number): void => { + if (!item || result) return; + if (item === target && item.bounds) { + result = { + x1: item.bounds.x1 + offsetX, + y1: item.bounds.y1 + offsetY, + x2: item.bounds.x2 + offsetX, + y2: item.bounds.y2 + offsetY, + }; + return; + } + const isGroup = item.mark?.marktype === 'group'; + const childOffsetX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const childOffsetY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (Array.isArray(item.items)) item.items.forEach((child: any) => visit(child, childOffsetX, childOffsetY)); + }; + visit(view.scenegraph()?.root, 0, 0); + return result; +} + +function legendOwner(item: any): any { + let owner = item?.mark?.group; + while (owner && !owner.datum?.scales) owner = owner.mark?.group; + return owner; +} + +function legendEntryCandidates(view: any): { item: any; bounds: SelectionRect }[] { + const entries = new Map>(); + const visit = (item: any): void => { + const role = item?.mark?.role; + const owner = (role === 'legend-symbol' || role === 'legend-label') + ? legendOwner(item) + : undefined; + if (owner && item.datum?.value !== undefined) { + const value = item.datum.value instanceof Date ? item.datum.value.getTime() : item.datum.value; + const bounds = rootBoundsForItem(view, item); + if (bounds) { + let byValue = entries.get(owner); + if (!byValue) { + byValue = new Map(); + entries.set(owner, byValue); + } + const existing = byValue.get(value); + byValue.set(value, existing ? { + item: existing.item, + bounds: { + x1: Math.min(existing.bounds.x1, bounds.x1), + y1: Math.min(existing.bounds.y1, bounds.y1), + x2: Math.max(existing.bounds.x2, bounds.x2), + y2: Math.max(existing.bounds.y2, bounds.y2), + }, + } : { item, bounds }); + } + } + if (Array.isArray(item?.items)) item.items.forEach(visit); + }; + visit(view.scenegraph()?.root); + return [...entries.values()].flatMap((byValue) => [...byValue.values()]); +} + +export function legendEntryItemAtPoint( + view: any, + point: PlotPoint, + padding = 3, +): any | null { + return nearestItemByBounds(legendEntryCandidates(view), point, padding)?.item ?? null; +} + +/** Nearest keyed mark or native legend entry within the same physical assist radius. */ +export function nearestInteractiveSceneItem( + view: any, + plotPoint: PlotPoint, + maxDistance: number, + rootPoint: PlotPoint = plotPoint, + includeMarks = true, +): any | undefined { + const items = sceneItems(view); + const generatedLegend = nearestItemByBounds( + items.filter((item) => item.datum?.[INTERACTION_ROLE] === 'legend-label'), + plotPoint, + maxDistance, + ); + const mark = includeMarks + ? nearestItemByBounds(items.filter((item) => item.datum?.[INTERACTION_ROLE] !== 'legend-label'), plotPoint, maxDistance) + : undefined; + const nativeLegend = nearestItemByBounds(legendEntryCandidates(view), rootPoint, maxDistance); + const candidates = [ + ...(mark ? [{ item: mark, distance: distanceToItem(plotPoint, mark) }] : []), + ...(generatedLegend ? [{ item: generatedLegend, distance: distanceToItem(plotPoint, generatedLegend) }] : []), + ...(nativeLegend ? [{ item: nativeLegend.item, distance: distanceToItem(rootPoint, nativeLegend) }] : []), + ]; + return candidates.sort((left, right) => left.distance - right.distance)[0]?.item; +} + +export function legendSemanticTarget( + legend: LegendHitIdentity | null, +): SemanticTarget | null { + if (!legend) return null; + const value: LegendTargetValue = { + ...(legend.channel ? { channel: legend.channel } : {}), + ...(legend.field ? { field: legend.field } : {}), + domain: legend.domain, + }; + return { + visual: { kind: 'legend', role: 'legend-item' }, + elements: [{ value }], + }; +} + +export function axisTargetIdentity( + item: any, + targets: Readonly> | undefined, +): (import('./contracts').VegaAxisTarget & { scale: string; value: unknown; role: string }) | null { + const role = item?.mark?.role; + if (role !== 'axis-label' && role !== 'axis-tick') return null; + let group = item?.mark?.group; + while (group && group.mark?.role !== 'axis') group = group.mark?.group; + const scale = group?.datum?.scale; + const target = typeof scale === 'string' ? targets?.[scale] : undefined; + if (!target || (target.type !== 'nominal' && target.type !== 'ordinal') || item?.datum?.value === undefined) { + return null; + } + return { ...target, scale, value: item.datum.value, role }; +} + +export function axisItems( + view: any, + targets: Readonly> | undefined, +): any[] { + const result: any[] = []; + const visit = (item: any): void => { + if (!item) return; + if (axisTargetIdentity(item, targets)) result.push(item); + if (Array.isArray(item.items)) item.items.forEach(visit); + }; + visit(view.scenegraph()?.root); + return result; +} + +export function axisItemAt( + view: any, + point: PlotPoint, + targets: Readonly> | undefined, +): any | undefined { + let found: any | undefined; + const visit = (item: any, offsetX: number, offsetY: number): void => { + if (!item) return; + const bounds = item.bounds; + if (axisTargetIdentity(item, targets) && bounds + && point.x >= bounds.x1 + offsetX && point.x <= bounds.x2 + offsetX + && point.y >= bounds.y1 + offsetY && point.y <= bounds.y2 + offsetY) { + found = item; + } + const isGroup = item.mark?.marktype === 'group'; + const childOffsetX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const childOffsetY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (Array.isArray(item.items)) { + for (const child of item.items) visit(child, childOffsetX, childOffsetY); + } + }; + visit(view.scenegraph()?.root, 0, 0); + return found; +} + +export interface NormalizedVegaElement { + event: ElementInteractionEvent; + role: 'mark' | 'legend-item' | 'text-label'; + legend: LegendHitIdentity | null; +} + +export function normalizeVegaElementEvent( + view: any, + item: any, + point: PlotPoint, + phase: 'preview' | 'commit' | 'cancel', + modifiers: InteractionModifiers, + legendFields?: Readonly>, + rangeLegendChannels?: readonly string[], + rootPoint?: PlotPoint, +): NormalizedVegaElement { + const directLegend = legendTarget(item, legendFields, rangeLegendChannels, view, rootPoint); + const legendItem = directLegend || !rootPoint + ? item + : legendEntryItemAtPoint(view, rootPoint) ?? item; + const legend = directLegend + ?? legendTarget(legendItem, legendFields, rangeLegendChannels, view, rootPoint); + const physicalItem = physicalItemAt(view, item, point); + const hit = renderHit(physicalItem ?? item); + return { + event: { + type: 'element', + phase, + hits: hit ? [hit] : legend ? [{ datum: legendItem?.datum ?? {}, source: 'legend-item' }] : [], + point, + modifiers, + }, + role: legend + ? 'legend-item' + : hit?.layerRole === 'text-label' + ? 'text-label' + : 'mark', + legend, + }; +} + +/** Nearest item to a plot point, for pointer acquisition that does not require a direct hit. */ +export function nearestItemByBounds( + items: readonly any[], + point: PlotPoint, + maxDistance: number, +): any | undefined { + let best: { item: any; distance: number } | undefined; + for (const item of items) { + const bounds = item?.bounds; + if (!bounds) continue; + const distance = distanceToItem(point, item); + if (distance > maxDistance) continue; + if (!best || distance < best.distance) best = { item, distance }; + } + return best?.item; +} + +function distanceToBounds(point: PlotPoint, bounds: SelectionRect): number { + const dx = point.x < bounds.x1 ? bounds.x1 - point.x : point.x > bounds.x2 ? point.x - bounds.x2 : 0; + const dy = point.y < bounds.y1 ? bounds.y1 - point.y : point.y > bounds.y2 ? point.y - bounds.y2 : 0; + return Math.hypot(dx, dy); +} + +function distanceToSegment(point: PlotPoint, start: PlotPoint, end: PlotPoint): number { + const dx = end.x - start.x; + const dy = end.y - start.y; + const lengthSquared = dx * dx + dy * dy; + const ratio = lengthSquared === 0 ? 0 : clamp( + ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared, + 0, + 1, + ); + return Math.hypot( + point.x - (start.x + ratio * dx), + point.y - (start.y + ratio * dy), + ); +} + +function distanceToItem(point: PlotPoint, item: any): number { + const geometry = item?.interactionGeometry as PathGeometry | undefined; + if (geometry?.points.length) { + if (geometry.kind === 'slice' && pointInPolygon(point, geometry.points)) return 0; + const segmentCount = geometry.kind === 'slice' ? geometry.points.length : geometry.points.length - 1; + let geometryDistance = Number.POSITIVE_INFINITY; + for (let index = 0; index < segmentCount; index += 1) { + geometryDistance = Math.min(geometryDistance, distanceToSegment( + point, + geometry.points[index], + geometry.points[(index + 1) % geometry.points.length], + )); + } + return geometryDistance; + } + const polygon = arcPolygon(item); + if (!polygon) return item?.bounds ? distanceToBounds(point, item.bounds) : Number.POSITIVE_INFINITY; + if (pointInPolygon(point, polygon)) return 0; + let distance = Number.POSITIVE_INFINITY; + for (let index = 0; index < polygon.length; index += 1) { + distance = Math.min(distance, distanceToSegment(point, polygon[index], polygon[(index + 1) % polygon.length])); + } + return distance; +} + +export function nearestSceneItem(view: any, point: PlotPoint, maxDistance: number): any | undefined { + return nearestItemByBounds(sceneItems(view), point, maxDistance); +} + +export type SpatialDirection = 'left' | 'right' | 'up' | 'down'; + +function itemCenter(item: any): PlotPoint { + return { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }; +} + +/** + * Nearest item strictly in one direction, preferring candidates aligned with the + * travel axis so arrows read as left/right and up/down rather than list order. + */ +export function nextItemInDirection( + items: readonly any[], + from: PlotPoint, + direction: SpatialDirection, + discreteAxis?: 'x' | 'y', +): any | undefined { + const horizontal = direction === 'left' || direction === 'right'; + const followsDiscreteAxis = discreteAxis === (horizontal ? 'x' : 'y'); + let best: { item: any; score: number } | undefined; + for (const item of items) { + if (!item?.bounds) continue; + const center = itemCenter(item); + const dx = center.x - from.x; + const dy = center.y - from.y; + const along = direction === 'right' ? dx : direction === 'left' ? -dx : direction === 'down' ? dy : -dy; + if (along <= 0.5) continue; + const across = Math.abs(horizontal ? dy : dx); + const score = followsDiscreteAxis ? along + across * 0.001 : along + across * 3; + if (!best || score < best.score) best = { item, score }; + } + return best?.item; +} + +export function axisIntersectingHits( + items: readonly any[], + coordinate: number, + mode: 'x' | 'y', +): RenderHit[] { + const segmentCrosses = (start: PlotPoint, end: PlotPoint): boolean => { + const leading = mode === 'x' ? start.x : start.y; + const trailing = mode === 'x' ? end.x : end.y; + const minimum = Math.min(leading, trailing); + const maximum = Math.max(leading, trailing); + return minimum === maximum + ? Math.abs(coordinate - minimum) <= 1e-6 + : coordinate >= minimum && coordinate < maximum; + }; + return items + .filter((item) => { + const geometry = item.interactionGeometry?.points as readonly PlotPoint[] | undefined; + if (geometry?.length) { + const closed = item.interactionGeometry.kind === 'slice'; + const segmentCount = closed ? geometry.length : geometry.length - 1; + for (let index = 0; index < segmentCount; index += 1) { + if (segmentCrosses(geometry[index], geometry[(index + 1) % geometry.length])) return true; + } + return false; + } + const polygon = arcPolygon(item); + if (polygon) { + return polygon.some((point, index) => + segmentCrosses(point, polygon[(index + 1) % polygon.length])); + } + if (!item.bounds) return false; + return mode === 'x' + ? coordinate >= item.bounds.x1 && coordinate < item.bounds.x2 + : coordinate >= item.bounds.y1 && coordinate < item.bounds.y2; + }) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); +} + +type InspectComparison = '<' | '<=' | '=' | '>=' | '>'; + +/** Acquires marks around a raw inspect point without changing the guide position. */ +export function tolerantInspectHits( + items: readonly any[], + point: PlotPoint, + mode: 'x' | 'y' | 'xy', + predicate: { x?: InspectComparison; y?: InspectComparison }, + tolerance: { x: number; y: number }, +): RenderHit[] { + const xComparison = predicate.x; + const yComparison = predicate.y; + const directionalQuarter = mode === 'xy' + && xComparison !== undefined && xComparison !== '=' + && yComparison !== undefined && yComparison !== '='; + if (directionalQuarter) { + const intersectsOnAxis = ( + start: number, + end: number, + comparison: Exclude') return end > boundary; + return end >= boundary; + }; + return items + .filter((item) => { + return item?.bounds + && intersectsOnAxis(item.bounds.x1, item.bounds.x2, xComparison, point.x) + && intersectsOnAxis(item.bounds.y1, item.bounds.y2, yComparison, point.y); + }) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); + } + const axisMatches = ( + item: any, + axis: 'x' | 'y', + comparison: InspectComparison, + boundary: number, + distance: number, + ): boolean => { + if (!item?.bounds) return false; + const start = axis === 'x' ? item.bounds.x1 : item.bounds.y1; + const end = axis === 'x' ? item.bounds.x2 : item.bounds.y2; + if (comparison === '=') return boundary >= start - distance && boundary <= end + distance; + if (comparison === '<') return start < boundary + distance; + if (comparison === '<=') return start <= boundary + distance; + if (comparison === '>') return end > boundary - distance; + return end >= boundary - distance; + }; + const acquire = (distance: { x: number; y: number }): any[] => items + .filter((item) => { + if (mode === 'xy' && (predicate.x ?? '=') === '=' && (predicate.y ?? '=') === '=') { + const rect = { + x1: point.x - distance.x, + y1: point.y - distance.y, + x2: point.x + distance.x, + y2: point.y + distance.y, + }; + if (item.interactionGeometry) return geometryIntersectsRect(item.interactionGeometry, rect, false); + if (arcPolygon(item)) return arcIntersectsRect(item, rect); + return item?.bounds + && point.x >= item.bounds.x1 - distance.x && point.x <= item.bounds.x2 + distance.x + && point.y >= item.bounds.y1 - distance.y && point.y <= item.bounds.y2 + distance.y; + } + const matchesX = mode === 'y' || axisMatches(item, 'x', predicate.x ?? '=', point.x, distance.x); + const matchesY = mode === 'x' || axisMatches(item, 'y', predicate.y ?? '=', point.y, distance.y); + return matchesX && matchesY; + }); + const render = (matchedItems: readonly any[]): RenderHit[] => matchedItems + .map(renderHit).filter((hit): hit is RenderHit => hit !== null); + const equalityAxis = (mode === 'x' && (predicate.x ?? '=') === '=') + || (mode === 'y' && (predicate.y ?? '=') === '='); + const inspectAxisSlice = (): RenderHit[] => { + const axis = mode as 'x' | 'y'; + const coordinate = axis === 'x' ? point.x : point.y; + const exactHits = axisIntersectingHits(items, coordinate, axis); + if (exactHits.length > 0) return exactHits; + const distance = axis === 'x' ? tolerance.x : tolerance.y; + const candidates = items.filter((item) => item?.bounds && axisMatches(item, axis, '=', coordinate, distance)); + if (candidates.length === 0) return []; + const range = (item: any): { start: number; end: number } => axis === 'x' + ? { start: item.bounds.x1, end: item.bounds.x2 } + : { start: item.bounds.y1, end: item.bounds.y2 }; + const gap = (item: any): number => { + const { start, end } = range(item); + return coordinate < start ? start - coordinate : coordinate >= end ? coordinate - end : 0; + }; + const winner = candidates.reduce((best, candidate) => { + const difference = gap(candidate) - gap(best); + if (difference < -1e-6) return candidate; + if (Math.abs(difference) > 1e-6) return best; + const candidateRange = range(candidate); + const bestRange = range(best); + const candidateCenter = (candidateRange.start + candidateRange.end) / 2; + const bestCenter = (bestRange.start + bestRange.end) / 2; + return Math.abs(candidateCenter - coordinate) <= Math.abs(bestCenter - coordinate) ? candidate : best; + }); + const { start, end } = range(winner); + const inset = Math.min(0.25, Math.max(0, end - start) / 2); + const selectedCoordinate = coordinate <= start + ? start + inset + : coordinate >= end + ? end - inset + : coordinate; + return axisIntersectingHits(items, selectedCoordinate, axis); + }; + + if (equalityAxis) return inspectAxisSlice(); + + const exact = acquire({ x: 0, y: 0 }); + if (exact.length > 0) return render(exact); + + const candidates = acquire(tolerance); + if (candidates.length === 0) return []; + if (mode === 'xy' && (predicate.x ?? '=') === '=' && (predicate.y ?? '=') === '=') { + const winner = candidates.reduce((best, candidate) => + distanceToItem(point, candidate) <= distanceToItem(point, best) ? candidate : best); + const hit = renderHit(winner); + return hit ? [hit] : []; + } + return render(candidates); +} + +export interface IndexInspectAcquisition { + hits: RenderHit[]; + coordinate: number; + valueCoordinates: number[]; +} + +export function indexInspectAcquisition( + items: readonly any[], + point: PlotPoint, + axis: 'x' | 'y', + policy: { show: 'all' | { series: unknown }; seriesBy?: string }, + continuousIndex = false, + discreteCoordinates?: readonly number[], + assistDistance = 0, +): IndexInspectAcquisition { + const specificSeries = typeof policy.show === 'object' ? policy.show.series : undefined; + const eligibleItems = typeof policy.show === 'object' + ? items.filter((item) => policy.seriesBy + && Object.is(item?.datum?.[policy.seriesBy], specificSeries)) + : items; + + const itemAnchors = eligibleItems.flatMap((item) => { + const geometry = item?.interactionGeometry; + const points = geometry?.points as readonly PlotPoint[] | undefined; + if (geometry?.kind === 'segment' && points?.length && item.endDatum) { + return [ + { coordinate: axis === 'x' ? points[0].x : points[0].y }, + { coordinate: axis === 'x' ? points[points.length - 1].x : points[points.length - 1].y }, + ]; + } + if (!item?.bounds) return []; + return [{ + item, + coordinate: axis === 'x' + ? (item.bounds.x1 + item.bounds.x2) / 2 + : (item.bounds.y1 + item.bounds.y2) / 2, + }]; + }).filter((anchor) => Number.isFinite(anchor.coordinate)); + const anchors: { item?: any; coordinate: number }[] = !continuousIndex && discreteCoordinates?.length + ? discreteCoordinates.map((coordinate) => ({ coordinate })) + : itemAnchors; + const pointerCoordinate = axis === 'x' ? point.x : point.y; + if (anchors.length === 0) return { hits: [], coordinate: pointerCoordinate, valueCoordinates: [] }; + + const hitsAtCoordinate = (coordinate: number): RenderHit[] => { + const intersecting = axisIntersectingHits(eligibleItems, coordinate, axis); + if (intersecting.length > 0) return intersecting; + return eligibleItems.flatMap((item) => { + const points = item?.interactionGeometry?.points as readonly PlotPoint[] | undefined; + if (!points?.length) return []; + const endpoint = points[points.length - 1]; + const endpointCoordinate = axis === 'x' ? endpoint.x : endpoint.y; + const hit = Math.abs(endpointCoordinate - coordinate) <= 1e-6 ? renderHit(item) : null; + return hit ? [hit] : []; + }); + }; + + const anchor = anchors.reduce((best, candidate) => + Math.abs(candidate.coordinate - pointerCoordinate) < Math.abs(best.coordinate - pointerCoordinate) + ? candidate + : best); + const directHits = continuousIndex ? hitsAtCoordinate(pointerCoordinate) : []; + const anchorDistance = anchor.item?.bounds + ? (() => { + const start = axis === 'x' ? anchor.item.bounds.x1 : anchor.item.bounds.y1; + const end = axis === 'x' ? anchor.item.bounds.x2 : anchor.item.bounds.y2; + return pointerCoordinate < start ? start - pointerCoordinate + : pointerCoordinate > end ? pointerCoordinate - end : 0; + })() + : Math.abs(anchor.coordinate - pointerCoordinate); + if (continuousIndex && directHits.length === 0 && anchorDistance > assistDistance) { + return { hits: [], coordinate: pointerCoordinate, valueCoordinates: [] }; + } + const coordinate = directHits.length > 0 ? pointerCoordinate : anchor.coordinate; + const hits = directHits.length > 0 ? directHits : hitsAtCoordinate(coordinate); + + const hitKeys = new Set(hits.map((hit) => hit.datum[INTERACTION_KEY])); + let candidates = eligibleItems.filter((item) => { + const hit = renderHit(item); + return hit && hitKeys.has(hit.datum[INTERACTION_KEY]); + }); + if (candidates.some((item) => item.interactionGeometry)) { + candidates = candidates.filter((item) => item.interactionGeometry); + } + const finalKeys = new Set(hits.map((hit) => hit.datum[INTERACTION_KEY])); + const valueCoordinates = candidates.flatMap((item) => { + const hit = renderHit(item); + if (!hit || !finalKeys.has(hit.datum[INTERACTION_KEY])) return []; + const points = item.interactionGeometry?.points as readonly PlotPoint[] | undefined; + if (item.interactionGeometry?.kind === 'segment' && points && points.length >= 2) { + const start = points[0]; + const end = points[points.length - 1]; + const alongStart = axis === 'x' ? start.x : start.y; + const alongEnd = axis === 'x' ? end.x : end.y; + if (coordinate < Math.min(alongStart, alongEnd) || coordinate > Math.max(alongStart, alongEnd)) return []; + const ratio = alongEnd === alongStart ? 0 : (coordinate - alongStart) / (alongEnd - alongStart); + return [axis === 'x' + ? start.y + ratio * (end.y - start.y) + : start.x + ratio * (end.x - start.x)]; + } + if (!item.bounds) return []; + return [axis === 'x' + ? (item.bounds.y1 + item.bounds.y2) / 2 + : (item.bounds.x1 + item.bounds.x2) / 2]; + }).filter((value, index, values) => Number.isFinite(value) + && values.findIndex((candidate) => Math.abs(candidate - value) < 0.5) === index); + return { hits, coordinate, valueCoordinates }; +} + +export function indexInspectHits( + items: readonly any[], + point: PlotPoint, + axis: 'x' | 'y', + policy: { show: 'all' | { series: unknown }; seriesBy?: string }, +): RenderHit[] { + return indexInspectAcquisition(items, point, axis, policy).hits; +} + +export function nearestItemOnInspectAxis( + items: readonly any[], + point: PlotPoint, + mode: 'x' | 'y', +): any | undefined { + const coordinate = (item: any) => mode === 'x' + ? (item.bounds.x1 + item.bounds.x2) / 2 + : (item.bounds.y1 + item.bounds.y2) / 2; + const crossCoordinate = (item: any) => mode === 'x' + ? (item.bounds.y1 + item.bounds.y2) / 2 + : (item.bounds.x1 + item.bounds.x2) / 2; + const target = mode === 'x' ? point.x : point.y; + const crossTarget = mode === 'x' ? point.y : point.x; + return items.reduce<{ item: any; axisDistance: number; crossDistance: number } | undefined>((best, item) => { + if (!item?.bounds) return best; + const axisDistance = Math.abs(coordinate(item) - target); + const crossDistance = Math.abs(crossCoordinate(item) - crossTarget); + if (!best || axisDistance < best.axisDistance - 0.5 + || (Math.abs(axisDistance - best.axisDistance) <= 0.5 && crossDistance < best.crossDistance)) { + return { item, axisDistance, crossDistance }; + } + return best; + }, undefined)?.item; +} + +export function regionHits( + view: any, + a: PlotPoint, + b: PlotPoint, + contain = false, +): RenderHit[] { + const rect = { + x1: Math.min(a.x, b.x), x2: Math.max(a.x, b.x), + y1: Math.min(a.y, b.y), y2: Math.max(a.y, b.y), + }; + return sceneItems(view) + .filter((item) => item.interactionGeometry + ? geometryIntersectsRect(item.interactionGeometry, rect, contain) + : item.mark?.marktype === 'arc' + ? arcIntersectsRect(item, rect, contain) + : contain + ? item.bounds.x1 >= rect.x1 && item.bounds.x2 <= rect.x2 + && item.bounds.y1 >= rect.y1 && item.bounds.y2 <= rect.y2 + : boundsIntersectRect(item.bounds, rect)) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); +} + +/** Marks captured by a freeform lasso path. */ +export function polygonHits( + view: any, + polygon: readonly PlotPoint[], + contain = false, +): RenderHit[] { + if (polygon.length < 3) return []; + return sceneItems(view) + .filter((item) => { + const bounds = item.bounds; + if (!bounds) return false; + if (contain) { + return [ + { x: bounds.x1, y: bounds.y1 }, + { x: bounds.x2, y: bounds.y1 }, + { x: bounds.x2, y: bounds.y2 }, + { x: bounds.x1, y: bounds.y2 }, + ].every((corner) => pointInPolygon(corner, polygon)); + } + return polygonIntersectsRect(polygon, bounds); + }) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); +} + +export function normalizeVegaLassoEvent( + view: any, + points: readonly PlotPoint[], + phase: InteractionPhase, + match: 'intersect' | 'contain', + modifiers: InteractionModifiers, +): RegionInteractionEvent { + return { + type: 'region', + phase, + axis: 'xy', + operation: 'create', + region: { points: [...points] }, + hits: phase === 'cancel' ? [] : polygonHits(view, points, match === 'contain'), + match, + modifiers, + }; +} + +export function normalizeVegaRegionEvent( + view: any, + start: PlotPoint, + end: PlotPoint, + phase: InteractionPhase, + match: 'intersect' | 'contain', + modifiers: InteractionModifiers, + axis: CartesianRegionAxis = 'xy', + plotSize: PlotSize | PlotFrame = { width: view.width(), height: view.height() }, + operation: RegionOperation = 'create', + collectHits = true, +): RegionInteractionEvent { + const { start: constrainedStart, end: constrainedEnd } = + constrainCartesianRegion(start, end, axis, plotSize); + return { + type: 'region', + phase, + axis, + operation, + region: { + x: Math.min(constrainedStart.x, constrainedEnd.x), + y: Math.min(constrainedStart.y, constrainedEnd.y), + width: Math.abs(constrainedEnd.x - constrainedStart.x), + height: Math.abs(constrainedEnd.y - constrainedStart.y), + }, + hits: collectHits ? regionHits(view, constrainedStart, constrainedEnd, match === 'contain') : [], + match, + modifiers, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/navigation-scale.ts b/packages/flint-js/src/vegalite/interactions/navigation-scale.ts new file mode 100644 index 00000000..6bbb6f64 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/navigation-scale.ts @@ -0,0 +1,131 @@ +import type { + NavigationDomainGuard, + NavigationRequest, + NavigationUpdate, +} from '../../interactive/interactions'; +import type { VegaNavigationAxis } from './contracts'; + +type Axis = 'x' | 'y'; + +interface AxisState extends VegaNavigationAxis { + initialDomain: [unknown, unknown]; +} + +function numericValue(value: unknown): number { + return value instanceof Date ? value.getTime() : Number(value); +} + +function transformedValue(value: unknown, type: VegaNavigationAxis['type'], logSign: number): number { + const numeric = numericValue(value); + return type === 'log' ? logSign * Math.log(logSign * numeric) : numeric; +} + +function domainValue(value: number, type: VegaNavigationAxis['type'], initial: unknown, logSign: number): unknown { + const numeric = type === 'log' ? logSign * Math.exp(logSign * value) : value; + return initial instanceof Date ? new Date(numeric) : numeric; +} + +export function guardNavigationDomain( + proposed: readonly [unknown, unknown], + initial: readonly [unknown, unknown], + type: VegaNavigationAxis['type'], + guard: NavigationDomainGuard, +): [unknown, unknown] { + const logSign = type === 'log' && numericValue(initial[0]) < 0 ? -1 : 1; + const initialValues = initial.map((value) => transformedValue(value, type, logSign)); + const proposedValues = proposed.map((value) => transformedValue(value, type, logSign)); + const initialMin = Math.min(...initialValues); + const initialMax = Math.max(...initialValues); + const initialSpan = initialMax - initialMin; + if (!Number.isFinite(initialSpan) || initialSpan <= 0 || proposedValues.some((value) => !Number.isFinite(value))) { + return [...initial] as [unknown, unknown]; + } + + const direction = proposedValues[1] >= proposedValues[0] ? 1 : -1; + const requestedSpan = Math.abs(proposedValues[1] - proposedValues[0]); + const minimumSpan = initialSpan * guard.minVisibleFraction; + const maximumSpan = initialSpan * guard.maxVisibleFraction; + const span = Math.min(maximumSpan, Math.max(minimumSpan, requestedSpan)); + let center = (proposedValues[0] + proposedValues[1]) / 2; + const zoomOutMargin = Math.max(0, guard.maxVisibleFraction - 1) / 2; + const allowedMargin = guard.overscrollFraction + zoomOutMargin; + const allowedMin = initialMin - initialSpan * allowedMargin; + const allowedMax = initialMax + initialSpan * allowedMargin; + const allowedSpan = allowedMax - allowedMin; + const boundedSpan = Math.min(span, allowedSpan); + center = Math.max(allowedMin + boundedSpan / 2, Math.min(allowedMax - boundedSpan / 2, center)); + const lower = center - boundedSpan / 2; + const upper = center + boundedSpan / 2; + const values = direction > 0 ? [lower, upper] : [upper, lower]; + return values.map((value, index) => domainValue(value, type, initial[index], logSign)) as [unknown, unknown]; +} + +export interface VegaNavigationController { + resolve(event: NavigationRequest, guard: NavigationDomainGuard): NavigationUpdate | null; + apply(update: NavigationUpdate): boolean; +} + +export function createVegaNavigationController( + view: any, + axes: Partial>, +): VegaNavigationController { + const states = Object.fromEntries(Object.entries(axes).map(([axis, config]) => { + const domain = view.scale(config.scale).domain(); + return [axis, { ...config, initialDomain: [domain[0], domain[domain.length - 1]] }]; + })) as Partial>; + const affectedAxes = (axesValue: NavigationUpdate['axes']): Axis[] => { + const requested: Axis[] = axesValue === 'xy' ? ['x', 'y'] : [axesValue]; + return requested.filter((axis) => states[axis]); + }; + + return { + resolve(event, guard): NavigationUpdate | null { + if (event.phase === 'start' || event.phase === 'cancel' + || (event.phase === 'commit' && event.operation === 'pan' && !event.delta)) return null; + if (event.operation === 'reset') return { op: 'set-viewport', axes: event.axes, value: {} }; + const value: { x?: [unknown, unknown]; y?: [unknown, unknown] } = {}; + for (const axis of affectedAxes(event.axes)) { + const state = states[axis]!; + const scale = view.scale(state.scale); + const domain = scale.domain(); + const current: [unknown, unknown] = [domain[0], domain[domain.length - 1]]; + const range = scale.range(); + const rangeStart = Number(range[0]); + const rangeEnd = Number(range[range.length - 1]); + const rangeExtent = Math.abs(rangeEnd - rangeStart); + let proposed: [unknown, unknown] | undefined; + if (event.operation === 'pan' && event.delta) { + const fraction = axis === 'x' ? event.delta.x : event.delta.y; + const pixelDelta = fraction * rangeExtent; + proposed = [scale.invert(rangeStart - pixelDelta), scale.invert(rangeEnd - pixelDelta)]; + } else if (event.operation === 'zoom' && event.factor && event.factor > 0 && event.anchor) { + const fraction = axis === 'x' ? event.anchor.x : event.anchor.y; + const anchor = Math.min(rangeStart, rangeEnd) + fraction * rangeExtent; + proposed = [ + scale.invert(anchor + (rangeStart - anchor) / event.factor), + scale.invert(anchor + (rangeEnd - anchor) / event.factor), + ]; + } + if (!proposed) continue; + value[axis] = guardNavigationDomain( + proposed, + state.initialDomain, + state.type, + guard, + ); + } + return Object.keys(value).length > 0 + ? { op: 'set-viewport', axes: event.axes, value } + : null; + }, + apply(update): boolean { + let changed = false; + for (const axis of affectedAxes(update.axes)) { + const state = states[axis]!; + view.signal(state.signal, update.value[axis] ?? null); + changed = true; + } + return changed; + }, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/annotation-leader-routing.ts b/packages/flint-js/src/vegalite/interactions/presentation/annotation-leader-routing.ts new file mode 100644 index 00000000..fff3524c --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/annotation-leader-routing.ts @@ -0,0 +1,277 @@ +import type { PlotPoint } from '../../../interactive/interactions'; + +export type AnnotationPortEdge = 'top' | 'right' | 'bottom' | 'left'; + +export interface AnnotationRouteRect { + left: number; + top: number; + width: number; + height: number; +} + +export interface AnnotationLeaderPort extends PlotPoint { + edge: AnnotationPortEdge; + fraction: number; +} + +export interface AnnotationLeaderRoute { + source: PlotPoint; + port: AnnotationLeaderPort; + points: readonly PlotPoint[]; +} + +const SIDE_PORT_FRACTIONS = [0.25, 0.5, 0.75] as const; +const HORIZONTAL_PORT_FRACTIONS = [0.25, 0.75] as const; +const EDGE_ORDER: readonly AnnotationPortEdge[] = ['top', 'right', 'bottom', 'left']; +const AXIS_DOMINANCE_RATIO = 1.5; +const EPSILON = 1e-6; + +export function annotationLeaderPorts(card: AnnotationRouteRect): readonly AnnotationLeaderPort[] { + return EDGE_ORDER.flatMap((edge) => { + const fractions = edge === 'top' || edge === 'bottom' + ? HORIZONTAL_PORT_FRACTIONS + : SIDE_PORT_FRACTIONS; + return fractions.map((fraction) => { + if (edge === 'top' || edge === 'bottom') { + return { + edge, + fraction, + x: card.left + card.width * fraction, + y: edge === 'top' ? card.top : card.top + card.height, + }; + } + return { + edge, + fraction, + x: edge === 'left' ? card.left : card.left + card.width, + y: card.top + card.height * fraction, + }; + }); + }); +} + +export function annotationFacingEdges( + source: PlotPoint, + card: AnnotationRouteRect, +): readonly AnnotationPortEdge[] { + const edges: AnnotationPortEdge[] = []; + const horizontalGap = source.x < card.left + ? card.left - source.x + : Math.max(0, source.x - card.left - card.width); + const verticalGap = source.y < card.top + ? card.top - source.y + : Math.max(0, source.y - card.top - card.height); + const horizontalEdge = source.x < card.left ? 'left' : 'right'; + const verticalEdge = source.y < card.top ? 'top' : 'bottom'; + if (verticalGap > EPSILON && verticalGap >= horizontalGap * AXIS_DOMINANCE_RATIO) { + return [verticalEdge]; + } + if (horizontalGap > EPSILON && horizontalGap >= verticalGap * AXIS_DOMINANCE_RATIO) { + return [horizontalEdge]; + } + if (horizontalGap > EPSILON) edges.push(horizontalEdge); + if (verticalGap > EPSILON) edges.push(verticalEdge); + if (edges.length > 0) return edges; + + const distances: readonly [AnnotationPortEdge, number][] = [ + ['top', Math.abs(source.y - card.top)], + ['right', Math.abs(source.x - card.left - card.width)], + ['bottom', Math.abs(source.y - card.top - card.height)], + ['left', Math.abs(source.x - card.left)], + ]; + const nearest = Math.min(...distances.map(([, distance]) => distance)); + return distances.filter(([, distance]) => Math.abs(distance - nearest) < EPSILON).map(([edge]) => edge); +} + +function pointEqual(a: PlotPoint, b: PlotPoint): boolean { + return Math.abs(a.x - b.x) < EPSILON && Math.abs(a.y - b.y) < EPSILON; +} + +function simplifyPoints(points: readonly PlotPoint[]): PlotPoint[] { + const unique = points.filter((point, index) => index === 0 || !pointEqual(point, points[index - 1])); + return unique.filter((point, index) => { + if (index === 0 || index === unique.length - 1) return true; + const before = unique[index - 1]; + const after = unique[index + 1]; + return Math.abs((point.x - before.x) * (after.y - point.y) + - (point.y - before.y) * (after.x - point.x)) > EPSILON; + }); +} + +function pointInsideRect(point: PlotPoint, card: AnnotationRouteRect): boolean { + return point.x > card.left + EPSILON + && point.x < card.left + card.width - EPSILON + && point.y > card.top + EPSILON + && point.y < card.top + card.height - EPSILON; +} + +function segmentEntersRect(start: PlotPoint, end: PlotPoint, card: AnnotationRouteRect): boolean { + if (pointInsideRect(start, card) || pointInsideRect(end, card)) return true; + const left = card.left + EPSILON; + const right = card.left + card.width - EPSILON; + const top = card.top + EPSILON; + const bottom = card.top + card.height - EPSILON; + if (left >= right || top >= bottom) return false; + const deltaX = end.x - start.x; + const deltaY = end.y - start.y; + let entry = 0; + let exit = 1; + for (const [direction, offset] of [ + [-deltaX, start.x - left], + [deltaX, right - start.x], + [-deltaY, start.y - top], + [deltaY, bottom - start.y], + ] as const) { + if (Math.abs(direction) < EPSILON) { + if (offset < 0) return false; + continue; + } + const ratio = offset / direction; + if (direction < 0) entry = Math.max(entry, ratio); + else exit = Math.min(exit, ratio); + if (entry > exit) return false; + } + return exit > EPSILON && entry < 1 - EPSILON; +} + +function routeIsValid(points: readonly PlotPoint[], card: AnnotationRouteRect): boolean { + return points.slice(1).every((point, index) => !segmentEntersRect(points[index], point, card)); +} + +function routeCandidates( + source: PlotPoint, + port: AnnotationLeaderPort, + card: AnnotationRouteRect, +): readonly AnnotationLeaderRoute[] { + const middleX = (source.x + port.x) / 2; + const middleY = (source.y + port.y) / 2; + const pointSets: PlotPoint[][] = [ + [source, port], + [source, { x: source.x, y: port.y }, port], + [source, { x: port.x, y: source.y }, port], + [source, { x: middleX, y: source.y }, { x: middleX, y: port.y }, port], + [source, { x: source.x, y: middleY }, { x: port.x, y: middleY }, port], + ]; + const seen = new Set(); + return pointSets.flatMap((points) => { + const simplified = simplifyPoints(points); + const key = simplified.map((point) => `${point.x},${point.y}`).join(';'); + if (seen.has(key) || !routeIsValid(simplified, card)) return []; + seen.add(key); + return [{ source, port, points: simplified }]; + }); +} + +function orientation(a: PlotPoint, b: PlotPoint, c: PlotPoint): number { + return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); +} + +function segmentsCross(a: PlotPoint, b: PlotPoint, c: PlotPoint, d: PlotPoint): boolean { + if (pointEqual(a, c) || pointEqual(a, d) || pointEqual(b, c) || pointEqual(b, d)) return false; + const abC = orientation(a, b, c); + const abD = orientation(a, b, d); + const cdA = orientation(c, d, a); + const cdB = orientation(c, d, b); + return ((abC > EPSILON && abD < -EPSILON) || (abC < -EPSILON && abD > EPSILON)) + && ((cdA > EPSILON && cdB < -EPSILON) || (cdA < -EPSILON && cdB > EPSILON)); +} + +function routesCross(a: AnnotationLeaderRoute, b: AnnotationLeaderRoute): boolean { + for (let ai = 1; ai < a.points.length; ai += 1) { + for (let bi = 1; bi < b.points.length; bi += 1) { + if (segmentsCross(a.points[ai - 1], a.points[ai], b.points[bi - 1], b.points[bi])) return true; + } + } + return false; +} + +function sameEdgeOrderIsValid(routes: readonly AnnotationLeaderRoute[]): boolean { + for (let first = 0; first < routes.length; first += 1) { + for (let second = first + 1; second < routes.length; second += 1) { + const a = routes[first]; + const b = routes[second]; + if (a.port.edge !== b.port.edge) continue; + const sourceOrder = a.port.edge === 'top' || a.port.edge === 'bottom' + ? a.source.x - b.source.x + : a.source.y - b.source.y; + const portOrder = a.port.fraction - b.port.fraction; + if (sourceOrder * portOrder < -EPSILON) return false; + } + } + return true; +} + +function routeLength(route: AnnotationLeaderRoute): number { + return route.points.slice(1).reduce((sum, point, index) => + sum + Math.hypot(point.x - route.points[index].x, point.y - route.points[index].y), 0); +} + +function sharedSegmentCount(routes: readonly AnnotationLeaderRoute[]): number { + const keys = new Map(); + for (const route of routes) { + for (let index = 1; index < route.points.length; index += 1) { + const a = route.points[index - 1]; + const b = route.points[index]; + const key = [`${a.x},${a.y}`, `${b.x},${b.y}`].sort().join('|'); + keys.set(key, (keys.get(key) ?? 0) + 1); + } + } + return [...keys.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0); +} + +function compareRank(a: readonly number[], b: readonly number[]): number { + for (let index = 0; index < a.length; index += 1) { + if (Math.abs(a[index] - b[index]) > EPSILON) return a[index] - b[index]; + } + return 0; +} + +function assignmentRank(routes: readonly AnnotationLeaderRoute[], ports: readonly AnnotationLeaderPort[]): number[] { + let crossings = 0; + for (let first = 0; first < routes.length; first += 1) { + for (let second = first + 1; second < routes.length; second += 1) { + if (routesCross(routes[first], routes[second])) crossings += 1; + } + } + const bends = routes.reduce((sum, route) => sum + Math.max(0, route.points.length - 2), 0); + const length = routes.reduce((sum, route) => sum + routeLength(route), 0); + const deterministic = routes.reduce((sum, route, index) => + sum + ports.indexOf(route.port) * Math.pow(ports.length, routes.length - index - 1), 0); + return [crossings, sharedSegmentCount(routes), bends, length, deterministic]; +} + +export function routeAnnotationLeaders({ + card, + sources, +}: { + card: AnnotationRouteRect; + sources: readonly PlotPoint[]; +}): readonly AnnotationLeaderRoute[] { + if (sources.length === 0) return []; + const ports = annotationLeaderPorts(card); + const choices = sources.map((source) => ports + .filter((port) => annotationFacingEdges(source, card).includes(port.edge)) + .flatMap((port) => routeCandidates(source, port, card))); + let best: readonly AnnotationLeaderRoute[] | undefined; + let bestRank: readonly number[] | undefined; + + const visit = (index: number, routes: AnnotationLeaderRoute[]): void => { + if (index === choices.length) { + if (!sameEdgeOrderIsValid(routes)) return; + const rank = assignmentRank(routes, ports); + if (!bestRank || compareRank(rank, bestRank) < 0) { + best = [...routes]; + bestRank = rank; + } + return; + } + for (const route of choices[index]) { + if (routes.some((existing) => pointEqual(existing.port, route.port))) continue; + routes.push(route); + visit(index + 1, routes); + routes.pop(); + } + }; + visit(0, []); + return best ?? []; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts new file mode 100644 index 00000000..0912019d --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/annotation-overlay.ts @@ -0,0 +1,755 @@ +import { semanticElementRenderKeys, type SemanticElement, type SemanticTarget } from '../../../core/interaction-semantics'; +import type { + AnnotationCandidate, + AnnotationConnection, + AnnotationSpec, + PlotPoint, +} from '../../../interactive/interactions'; + +type RenderableAnnotation = AnnotationSpec & { + text: string; + candidates: readonly AnnotationCandidate[]; +}; +import { + INTERACTION_KEY, + INTERACTION_ROLE, + PATH_KEY_SUFFIX, + clientToLayoutPoint, + plotToClientPoint, + sceneItems, + type RendererCoordinateSpace, +} from '../hit-adapter'; +import { + routeAnnotationLeaders, + type AnnotationLeaderRoute, + type AnnotationPortEdge, +} from './annotation-leader-routing'; + +function keyOfDatum(datum: unknown): string | undefined { + if (!datum || typeof datum !== 'object') return undefined; + const key = (datum as Record)[INTERACTION_KEY]; + return typeof key === 'string' ? key : undefined; +} + +interface LayoutRect { + left: number; + top: number; + width: number; + height: number; +} + +interface LayoutObstacle { + item: any; + rect: LayoutRect; + tier: 1 | 2 | 3; +} + +interface ConnectionPoint { + point: PlotPoint; + preferredAngle?: number; +} + +interface AnnotationLayout { + candidate: AnnotationCandidate; + connection: ConnectionPoint; + angle: number; + distance: number; + align: 'left' | 'center' | 'right'; + maxWidth: number; + card: LayoutRect; + end: PlotPoint; + score: number; +} + +const TAU = Math.PI * 2; +const ANGLE_STEP = Math.PI / 6; +const FREE_ANGLES = Array.from({ length: 12 }, (_, index) => index * ANGLE_STEP); +const OBSTACLE_WEIGHT = { 1: 1, 2: 20, 3: 1_000 } as const; +const PLOT_ESCAPE_WEIGHT = 10; +const CONNECTOR_CROSSING_AREA = 20; + +export function annotationObstacleOverlapCost(tier: 1 | 2 | 3, overlap: number): number { + if (overlap <= 0) return 0; + return overlap * OBSTACLE_WEIGHT[tier]; +} + +export function annotationObstacleTier(item: any): 1 | 2 | 3 { + const role = String(item?.mark?.role ?? ''); + if (role.startsWith('legend') || role.startsWith('axis')) return 3; + const opacity = typeof item?.opacity === 'number' ? item.opacity : 1; + return opacity < 0.5 ? 1 : 2; +} + +export function isAnnotationObstacle(item: any): boolean { + return !!item?.mark?.marktype && item.mark.role !== 'axis-grid'; +} + +export function isAnnotationSourceItem(candidate: any, source: any): boolean { + if (candidate.mark !== source.mark) return false; + return source.mark?.marktype === 'area' && source.orient === 'horizontal' + ? keyOfDatum(candidate.datum) === keyOfDatum(source.datum) + : candidate.datum === source.datum; +} + +export function annotationSourceBounds(items: readonly any[], source: any): { + x1: number; x2: number; y1: number; y2: number; +} { + const sourceBounds = annotationBounds(source); + if (source.mark?.marktype !== 'area' || source.orient !== 'horizontal') return sourceBounds; + const sourceItems = items.filter((candidate) => isAnnotationSourceItem(candidate, source)); + if (sourceItems.length < 2) return sourceBounds; + return sourceItems.reduce((bounds, candidate) => ({ + x1: Math.min(bounds.x1, candidate.bounds.x1), + x2: Math.max(bounds.x2, candidate.bounds.x2), + y1: Math.min(bounds.y1, candidate.bounds.y1), + y2: Math.max(bounds.y2, candidate.bounds.y2), + }), { ...sourceBounds }); +} + +function sceneObstacles(view: any): any[] { + const obstacles: any[] = []; + const visit = (item: any, offsetX: number, offsetY: number): void => { + if (!item) return; + const isGroup = item.mark?.marktype === 'group'; + if (!isGroup && isAnnotationObstacle(item) && item.bounds && (item.opacity ?? 1) > 0) { + obstacles.push({ + ...item, + bounds: { + x1: item.bounds.x1 + offsetX, + x2: item.bounds.x2 + offsetX, + y1: item.bounds.y1 + offsetY, + y2: item.bounds.y2 + offsetY, + }, + }); + } + const childOffsetX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const childOffsetY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + if (Array.isArray(item.items)) { + for (const child of item.items) visit(child, childOffsetX, childOffsetY); + } + }; + visit(view.scenegraph()?.root, 0, 0); + return obstacles; +} + +function connectorFor(candidate: AnnotationCandidate): 'line' | 'none' { + return candidate.connector ?? 'line'; +} + +function overlapArea(a: LayoutRect, b: LayoutRect): number { + const width = Math.max(0, Math.min(a.left + a.width, b.left + b.width) - Math.max(a.left, b.left)); + const height = Math.max(0, Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top)); + return width * height; +} + +function rectDistance(a: LayoutRect, b: LayoutRect): number { + const deltaX = Math.max(a.left - b.left - b.width, b.left - a.left - a.width, 0); + const deltaY = Math.max(a.top - b.top - b.height, b.top - a.top - a.height, 0); + return Math.hypot(deltaX, deltaY); +} + +function overflowDistance(inner: LayoutRect, outer: LayoutRect, padding: number): number { + return Math.max(0, outer.left + padding - inner.left) + + Math.max(0, inner.left + inner.width + padding - outer.left - outer.width) + + Math.max(0, outer.top + padding - inner.top) + + Math.max(0, inner.top + inner.height + padding - outer.top - outer.height); +} + +function segmentIntersectsRect( + start: PlotPoint, + end: PlotPoint, + rect: LayoutRect, + ignoreStartTouch = false, +): boolean { + const deltaX = end.x - start.x; + const deltaY = end.y - start.y; + let entry = 0; + let exit = 1; + const clip = (direction: number, offset: number): boolean => { + if (direction === 0) return offset >= 0; + const ratio = offset / direction; + if (direction < 0) entry = Math.max(entry, ratio); + else exit = Math.min(exit, ratio); + return entry <= exit; + }; + const intersects = clip(-deltaX, start.x - rect.left) + && clip(deltaX, rect.left + rect.width - start.x) + && clip(-deltaY, start.y - rect.top) + && clip(deltaY, rect.top + rect.height - start.y); + if (!intersects) return false; + return ignoreStartTouch ? exit > 0.02 && entry < 0.98 : exit >= 0 && entry <= 1; +} + +function textAlignForPort(edge: AnnotationPortEdge): 'left' | 'center' | 'right' { + if (edge === 'left') return 'left'; + if (edge === 'right') return 'right'; + return 'center'; +} + +export function sourceEdgeAttachment( + source: LayoutRect, + card: LayoutRect, + connection: AnnotationConnection, + fallback: PlotPoint, +): PlotPoint { + const cardCenterX = card.left + card.width / 2; + const cardCenterY = card.top + card.height / 2; + const sourceCenterX = source.left + source.width / 2; + const sourceCenterY = source.top + source.height / 2; + if (connection === 'top' || connection === 'bottom') { + return { + x: source.left + source.width * (cardCenterX < sourceCenterX ? 0.25 : 0.75), + y: connection === 'top' ? source.top : source.top + source.height, + }; + } + if (connection === 'left' || connection === 'right') { + return { + x: connection === 'left' ? source.left : source.left + source.width, + y: source.top + source.height * (cardCenterY < sourceCenterY ? 0.25 : 0.75), + }; + } + return fallback; +} + +export function annotationPrimaryAnchor( + item: any, + source: LayoutRect, + card: LayoutRect, + connection: AnnotationConnection, + fallback: PlotPoint, +): PlotPoint { + if (item?.interactionGeometry + && ['top', 'right', 'bottom', 'left', 'segment-midpoint'].includes(connection)) { + return fallback; + } + return sourceEdgeAttachment(source, card, connection, fallback); +} + +function routeIntersectsRect(route: AnnotationLeaderRoute, rect: LayoutRect, ignoreStartTouch = false): boolean { + return route.points.slice(1).some((point, index) => + segmentIntersectsRect(route.points[index], point, rect, ignoreStartTouch && index === 0)); +} + +function vectorAngle(deltaX: number, deltaY: number): number { + return (Math.atan2(deltaY, deltaX) + TAU) % TAU; +} + +function angularDistance(a: number, b: number): number { + const difference = Math.abs(a - b) % TAU; + return Math.min(difference, TAU - difference); +} + +export function valueEndConnectionPoint( + item: any, + items: readonly any[], + valueAxis?: 'x' | 'y', +): { point: PlotPoint; preferredAngle: number } { + const center = { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }; + const horizontal = valueAxis ? valueAxis === 'x' + : item.bounds.x2 - item.bounds.x1 >= item.bounds.y2 - item.bounds.y1; + const countAt = (edge: 'x1' | 'x2' | 'y1' | 'y2', value: number): number => items + .filter((candidate) => Math.abs(candidate.bounds[edge] - value) < 0.5) + .length; + if (horizontal) { + const leftIsBaseline = countAt('x1', item.bounds.x1) >= countAt('x2', item.bounds.x2); + return { + point: { x: leftIsBaseline ? item.bounds.x2 : item.bounds.x1, y: center.y }, + preferredAngle: leftIsBaseline ? 0 : TAU * 0.5, + }; + } + const topIsBaseline = countAt('y1', item.bounds.y1) > countAt('y2', item.bounds.y2); + return { + point: { x: center.x, y: topIsBaseline ? item.bounds.y2 : item.bounds.y1 }, + preferredAngle: topIsBaseline ? TAU * 0.25 : TAU * 0.75, + }; +} + +export function valueSideConnectionPoint( + item: any, + items: readonly any[], + valueAxis: 'x' | 'y', + crossSide: 'start' | 'end', + valueInset = 1 / 8, +): { point: PlotPoint; preferredAngle: number } { + const valueEnd = valueEndConnectionPoint(item, items, valueAxis).point; + const inset = Math.max(0, Math.min(1, valueInset)); + if (valueAxis === 'x') { + const baseline = valueEnd.x === item.bounds.x1 ? item.bounds.x2 : item.bounds.x1; + return { + point: { + x: valueEnd.x + (baseline - valueEnd.x) * inset, + y: crossSide === 'start' ? item.bounds.y1 : item.bounds.y2, + }, + preferredAngle: crossSide === 'start' ? TAU * 0.75 : TAU * 0.25, + }; + } + const baseline = valueEnd.y === item.bounds.y1 ? item.bounds.y2 : item.bounds.y1; + return { + point: { + x: crossSide === 'start' ? item.bounds.x1 : item.bounds.x2, + y: valueEnd.y + (baseline - valueEnd.y) * inset, + }, + preferredAngle: crossSide === 'start' ? TAU * 0.5 : 0, + }; +} + +export function annotationCandidateAngles( + preferredAngle: number | undefined, + preference: AnnotationCandidate['anglePreference'] = 'normal', +): readonly number[] { + if (preferredAngle === undefined) return FREE_ANGLES; + const offsets = preference === 'oblique' + ? [-1, 1, -2, 2] + : [0, -1, 1, -2, 2, -3, 3, -4, 4, -5, 5, 6]; + return offsets.map((offset) => (preferredAngle + offset * ANGLE_STEP + TAU) % TAU); +} + +export function annotationItem( + items: readonly any[], + key: string, + subject?: Partial, + preferredMarktype?: string, + preferredRole?: string, + preferredRecord?: Readonly>, +): any | undefined { + const pathKey = key.endsWith(PATH_KEY_SUFFIX); + const pathTarget = subject?.kind === 'path' || (subject?.kind === undefined && pathKey); + const sceneKey = pathKey + ? key.slice(0, -PATH_KEY_SUFFIX.length) + : key; + const matching = items.filter((candidate) => candidate.bounds && keyOfDatum(candidate.datum) === sceneKey); + const semanticCandidates = pathTarget + ? matching.filter((candidate) => candidate.interactionGeometry) + : matching.filter((candidate) => !candidate.interactionGeometry); + const roleCandidates = preferredRole + ? semanticCandidates.filter((candidate) => candidate.datum?.[INTERACTION_ROLE] === preferredRole) + : semanticCandidates; + if (preferredRole && roleCandidates.length === 0) return undefined; + const candidates = preferredMarktype + ? roleCandidates.filter((candidate) => candidate.mark?.marktype === preferredMarktype) + : roleCandidates; + const available = candidates.length > 0 ? candidates : preferredRole ? roleCandidates : matching; + const recordFields = preferredRecord + ? Object.entries(preferredRecord).filter(([field, value]) => !field.startsWith('__') + && value !== undefined && value !== null && typeof value !== 'object') + : []; + const recordMatches = recordFields.length > 0 + ? available.filter((candidate) => recordFields.every(([field, value]) => + candidate.datum?.[field] === undefined || Object.is(candidate.datum[field], value))) + : []; + const resolved = recordMatches.length > 0 ? recordMatches : available; + const preferRepresentativePath = pathTarget && recordMatches.length === 0 && resolved.length > 1; + return resolved + .sort((a, b) => { + const aSpan = Math.max(a.bounds.x2 - a.bounds.x1, a.bounds.y2 - a.bounds.y1); + const bSpan = Math.max(b.bounds.x2 - b.bounds.x1, b.bounds.y2 - b.bounds.y1); + return preferRepresentativePath ? bSpan - aSpan : aSpan - bSpan; + })[0]; +} + +export function annotationBounds(item: any): { x1: number; x2: number; y1: number; y2: number } { + const points = item.interactionGeometry?.annotationPoints as readonly PlotPoint[] | undefined; + if (!points?.length) return item.bounds; + return { + x1: Math.min(...points.map((point) => point.x)), + x2: Math.max(...points.map((point) => point.x)), + y1: Math.min(...points.map((point) => point.y)), + y2: Math.max(...points.map((point) => point.y)), + }; +} + +export function segmentMidpointConnectionPoint( + item: any, + plotCenter: PlotPoint, +): ConnectionPoint { + const points = (item.interactionGeometry?.annotationPoints + ?? item.interactionGeometry?.points) as readonly PlotPoint[] | undefined; + if (!points || points.length < 2) return { point: plotCenter }; + const point = { + x: (points[0].x + points[1].x) / 2, + y: (points[0].y + points[1].y) / 2, + }; + if (item.interactionGeometry?.kind === 'slice' && item.interactionGeometry.points?.length >= 4) { + const deltaX = points[1].x - points[0].x; + const deltaY = points[1].y - points[0].y; + const fillCenter = { + x: (item.interactionGeometry.points[2].x + item.interactionGeometry.points[3].x) / 2, + y: (item.interactionGeometry.points[2].y + item.interactionGeometry.points[3].y) / 2, + }; + const normal = { x: deltaY, y: -deltaX }; + const towardFill = (fillCenter.x - point.x) * normal.x + (fillCenter.y - point.y) * normal.y; + const outward = towardFill > 0 ? { x: -normal.x, y: -normal.y } : normal; + return { point, preferredAngle: vectorAngle(outward.x, outward.y) }; + } + return { point, preferredAngle: vectorAngle(point.x - plotCenter.x, point.y - plotCenter.y) }; +} + +export function annotationConnectionPoint( + item: any, + connection: AnnotationConnection, + items: readonly any[], + plotCenter: PlotPoint, + valueAxis?: 'x' | 'y', + crossSide?: 'start' | 'end', + valueInset?: number, +): ConnectionPoint { + const center = { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }; + if (item.interactionGeometry && ['top', 'right', 'bottom', 'left'].includes(connection)) { + const segment = segmentMidpointConnectionPoint(item, plotCenter); + const preferredAngle = { + top: TAU * 0.75, + right: 0, + bottom: TAU * 0.25, + left: TAU * 0.5, + }[connection as 'top' | 'right' | 'bottom' | 'left']; + return { point: segment.point, preferredAngle }; + } + if (connection === 'top') return { point: { x: center.x, y: item.bounds.y1 }, preferredAngle: TAU * 0.75 }; + if (connection === 'right') return { point: { x: item.bounds.x2, y: center.y }, preferredAngle: 0 }; + if (connection === 'bottom') return { point: { x: center.x, y: item.bounds.y2 }, preferredAngle: TAU * 0.25 }; + if (connection === 'left') return { point: { x: item.bounds.x1, y: center.y }, preferredAngle: TAU * 0.5 }; + if (connection === 'segment-midpoint') { + return segmentMidpointConnectionPoint(item, plotCenter); + } + if (connection === 'outer-radial' || connection === 'radial-midpoint') { + const angle = typeof item.startAngle === 'number' && typeof item.endAngle === 'number' + ? (item.startAngle + item.endAngle) / 2 + : undefined; + const outerRadius = typeof item.outerRadius === 'number' ? item.outerRadius : undefined; + const innerRadius = typeof item.innerRadius === 'number' ? item.innerRadius : 0; + const radius = connection === 'radial-midpoint' && outerRadius !== undefined + ? (innerRadius + outerRadius) / 2 + : outerRadius; + if (angle !== undefined && radius !== undefined) { + const point = { x: item.x + radius * Math.sin(angle), y: item.y - radius * Math.cos(angle) }; + return { point, preferredAngle: vectorAngle(point.x - item.x, point.y - item.y) }; + } + return { point: center }; + } + if (connection === 'value-end') { + return valueEndConnectionPoint(item, items, valueAxis); + } + if (connection === 'value-side' && valueAxis && crossSide) { + return valueSideConnectionPoint(item, items, valueAxis, crossSide, valueInset); + } + return { point: center, preferredAngle: vectorAngle(center.x - plotCenter.x, center.y - plotCenter.y) }; +} + +export interface AnnotationOverlayController { + render(element: SemanticElement, annotation: RenderableAnnotation): void; + clear(): void; + sync(): void; + destroy(): void; +} + +export interface AnnotationOverlayOptions { + view: any; + container: HTMLElement; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; + /** Vega marktype the chart anchors annotations to when a key matches several marks. */ + annotationMarkType?: string; +} + +export function createAnnotationOverlay({ + view, + container, + coordinateSpace, + containerLayoutSize, + annotationMarkType, +}: AnnotationOverlayOptions): AnnotationOverlayController { + const annotationLayer = document.createElement('div'); + annotationLayer.dataset.flintAnnotation = ''; + Object.assign(annotationLayer.style, { + position: 'absolute', inset: '0', zIndex: '4', pointerEvents: 'none', overflow: 'hidden', + }); + const annotationSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + Object.assign(annotationSvg.style, { position: 'absolute', inset: '0', width: '100%', height: '100%' }); + const annotationPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + annotationPath.setAttribute('fill', 'none'); + annotationPath.setAttribute('stroke', 'var(--flint-annotation-line-color, #808080)'); + annotationPath.setAttribute('stroke-width', '1.25'); + annotationPath.setAttribute('stroke-linecap', 'round'); + annotationSvg.append(annotationPath); + const annotationCard = document.createElement('div'); + Object.assign(annotationCard.style, { + position: 'absolute', color: 'var(--flint-annotation-color, #000)', + fontFamily: 'var(--flint-annotation-font-family, sans-serif)', + fontSize: 'var(--flint-annotation-font-size, 11px)', + fontWeight: 'var(--flint-annotation-font-weight, 400)', lineHeight: 'normal', letterSpacing: '0', + whiteSpace: 'normal', width: 'max-content', overflowWrap: 'break-word', boxSizing: 'border-box', + padding: '8px', border: '1px solid var(--flint-annotation-border-color, #d9d9d9)', + borderRadius: 'var(--flint-annotation-border-radius, 3px)', + background: 'var(--flint-annotation-surface, rgba(255, 255, 255, 0.95))', + boxShadow: 'var(--flint-annotation-shadow, 2px 2px 4px rgba(0, 0, 0, 0.1))', + }); + annotationLayer.append(annotationSvg, annotationCard); + + // Placement is derived from rendered geometry, so the runtime re-syncs it + // whenever the renderer is resized or the host rescales the chart. + let current: { element: SemanticElement; annotation: RenderableAnnotation } | undefined; + + const clear = (): void => { + current = undefined; + annotationLayer.remove(); + }; + const render = (element: SemanticElement, annotation: RenderableAnnotation): void => { + current = { element, annotation }; + const key = semanticElementRenderKeys(element)[0]; + const items = sceneItems(view); + const item = typeof key === 'string' + ? annotationItem( + items, + key, + annotation.subject, + annotationMarkType, + undefined, + element.records?.[0], + ) + : undefined; + if (!item?.bounds) { + clear(); + return; + } + if (!annotationLayer.isConnected) container.append(annotationLayer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + + annotationCard.textContent = annotation.text; + annotationCard.style.whiteSpace = annotation.text.includes('\n') ? 'pre-line' : 'normal'; + const directValue = annotation.text.length <= 18 && !annotation.text.includes('\n'); + + const containerRect = container.getBoundingClientRect(); + const space = coordinateSpace(); + const layoutSize = containerLayoutSize(); + const width = layoutSize.width; + const height = layoutSize.height; + const toLayout = (point: PlotPoint): PlotPoint => clientToLayoutPoint( + plotToClientPoint(point, space), containerRect, layoutSize, + ); + const obstacles: LayoutObstacle[] = sceneObstacles(view).flatMap((candidate) => { + if (isAnnotationSourceItem(candidate, item)) return []; + const leading = toLayout({ x: candidate.bounds.x1, y: candidate.bounds.y1 }); + const trailing = toLayout({ x: candidate.bounds.x2, y: candidate.bounds.y2 }); + return [{ + item: candidate, + rect: { + left: Math.min(leading.x, trailing.x), + top: Math.min(leading.y, trailing.y), + width: Math.abs(trailing.x - leading.x), + height: Math.abs(trailing.y - leading.y), + }, + tier: annotationObstacleTier(candidate), + }]; + }); + const plotLeading = toLayout({ x: 0, y: 0 }); + const plotTrailing = toLayout({ x: space.plotWidth, y: space.plotHeight }); + const plotRect: LayoutRect = { + left: Math.min(plotLeading.x, plotTrailing.x), + top: Math.min(plotLeading.y, plotTrailing.y), + width: Math.abs(plotTrailing.x - plotLeading.x), + height: Math.abs(plotTrailing.y - plotLeading.y), + }; + const sourceBounds = annotationSourceBounds(items, item); + const sourceLeading = toLayout({ x: sourceBounds.x1, y: sourceBounds.y1 }); + const sourceTrailing = toLayout({ x: sourceBounds.x2, y: sourceBounds.y2 }); + const markSourceRect: LayoutRect = { + left: Math.min(sourceLeading.x, sourceTrailing.x), + top: Math.min(sourceLeading.y, sourceTrailing.y), + width: Math.abs(sourceTrailing.x - sourceLeading.x), + height: Math.abs(sourceTrailing.y - sourceLeading.y), + }; + const canvasRect = { left: 0, top: 0, width, height }; + const plotCenter = { x: space.plotWidth / 2, y: space.plotHeight / 2 }; + const sourceGap = 10; + let best: AnnotationLayout | undefined; + let fallback: AnnotationLayout | undefined; + for (const candidate of annotation.candidates) { + const connection = annotationConnectionPoint( + item, + candidate.connection, + items, + plotCenter, + candidate.valueAxis, + candidate.crossSide, + candidate.valueInset, + ); + const anchor = toLayout(connection.point); + const boundarySourceRect = { left: anchor.x - 0.5, top: anchor.y - 0.5, width: 1, height: 1 }; + const sourceRect = annotation.subject?.kind === 'region' + || (candidate.connection === 'segment-midpoint' + && !(item.mark?.marktype === 'area' && item.orient === 'horizontal')) + || candidate.connection === 'outer-radial' + || candidate.connection === 'radial-midpoint' + ? { left: anchor.x - 0.5, top: anchor.y - 0.5, width: 1, height: 1 } + : markSourceRect; + const connectorSourceRect = annotation.subject?.kind === 'region' + || candidate.connection === 'outer-radial' + || candidate.connection === 'radial-midpoint' + ? boundarySourceRect + : sourceRect; + const maxWidths = candidate.maxWidth ? [candidate.maxWidth] : directValue ? [120] : [200, 160, 120]; + const maxDistance = candidate.maxDistance ?? 72; + const distances = (directValue ? [12, 20, 32, 48, maxDistance] : [28, 44, 60, maxDistance]) + .filter((distance, index, values) => distance <= maxDistance && values.indexOf(distance) === index); + const angles = annotationCandidateAngles(connection.preferredAngle, candidate.anglePreference); + for (const angle of angles) { + for (const distance of distances) { + for (const maxWidth of maxWidths) { + annotationCard.style.maxWidth = `${maxWidth}px`; + const cardWidth = annotationCard.offsetWidth; + const cardHeight = annotationCard.offsetHeight; + const center = { + x: anchor.x + Math.cos(angle) * distance, + y: anchor.y + Math.sin(angle) * distance, + }; + const card = { + left: center.x - cardWidth / 2, + top: center.y - cardHeight / 2, + width: cardWidth, + height: cardHeight, + }; + const route = routeAnnotationLeaders({ card, sources: [anchor] })[0]; + if (!route) continue; + const align = candidate.textAlign ?? textAlignForPort(route.port.edge); + annotationCard.style.textAlign = align; + const end = route.port; + const canvasOverflow = overflowDistance(card, canvasRect, 8); + const plotOverflow = overflowDistance(card, plotRect, 6); + const sourceCollision = overlapArea(card, sourceRect); + const sourceClearance = rectDistance(card, sourceRect); + const obstacleOverlapPenalty = obstacles.reduce((sum, obstacle) => sum + + annotationObstacleOverlapCost(obstacle.tier, overlapArea(card, obstacle.rect)), 0); + const connectorLength = route.points.slice(1).reduce((sum, point, index) => + sum + Math.hypot(point.x - route.points[index].x, point.y - route.points[index].y), 0); + const drawsConnector = connectorFor(candidate) === 'line'; + const leavesInward = connection.preferredAngle !== undefined + && angularDistance(angle, connection.preferredAngle) > Math.PI / 2 + 1e-6; + const crossesSource = drawsConnector + && annotation.subject?.kind !== 'region' + && candidate.connection !== 'outer-radial' + && candidate.connection !== 'radial-midpoint' + && routeIntersectsRect(route, connectorSourceRect, true); + const obstacleCrossingPenalty = drawsConnector + ? obstacles.reduce((sum, obstacle) => sum + ( + routeIntersectsRect(route, obstacle.rect) + ? annotationObstacleOverlapCost(obstacle.tier, CONNECTOR_CROSSING_AREA) + : 0 + ), 0) + : 0; + const directionPenalty = connection.preferredAngle === undefined + ? 0 + : angularDistance(angle, connection.preferredAngle); + const inwardPenalty = leavesInward ? 50 : 0; + const lineCount = Math.max(1, Math.round((cardHeight - 4) / 15)); + const wrappingPenalty = Math.max(0, lineCount - 1) * 10; + const score = plotOverflow * PLOT_ESCAPE_WEIGHT + + obstacleCrossingPenalty + obstacleOverlapPenalty + + sourceCollision * OBSTACLE_WEIGHT[2] + connectorLength / 100 + + directionPenalty + inwardPenalty + wrappingPenalty + (candidate.priority ?? 0) / 100; + const fallbackScore = score + canvasOverflow * 10_000 + + (crossesSource ? 100_000 : 0) + + Math.max(0, sourceGap - sourceClearance) * 1_000; + if (!fallback || fallbackScore < fallback.score) { + fallback = { + candidate, connection, angle, distance, align, maxWidth, card, end, + score: fallbackScore, + }; + } + if (canvasOverflow > 0 || crossesSource || sourceClearance < sourceGap) continue; + if (!best || score < best.score) { + best = { candidate, connection, angle, distance, align, maxWidth, card, end, score }; + } + } + } + } + } + if (!best && fallback) { + const card = { + ...fallback.card, + left: Math.min(width - fallback.card.width - 8, Math.max(8, fallback.card.left)), + top: Math.min(height - fallback.card.height - 8, Math.max(8, fallback.card.top)), + }; + const anchor = toLayout(fallback.connection.point); + const angle = vectorAngle( + card.left + card.width / 2 - anchor.x, + card.top + card.height / 2 - anchor.y, + ); + const route = routeAnnotationLeaders({ card, sources: [anchor] })[0]; + if (!route) { + clear(); + return; + } + best = { + ...fallback, + card, + angle, + align: fallback.candidate.textAlign ?? textAlignForPort(route.port.edge), + end: route.port, + }; + } + if (!best) { + clear(); + return; + } + annotationCard.style.maxWidth = `${best.maxWidth}px`; + annotationCard.style.textAlign = best.align; + annotationCard.style.left = `${best.card.left}px`; + annotationCard.style.top = `${best.card.top}px`; + const connector = connectorFor(best.candidate); + const connectorAnchors = best.candidate.connectorAnchors?.flatMap((connectorAnchor) => { + const connectorItem = typeof key === 'string' + ? annotationItem(items, key, annotation.subject, undefined, connectorAnchor.role) + : undefined; + if (!connectorItem) return []; + const connection = annotationConnectionPoint( + connectorItem, + connectorAnchor.connection, + items, + plotCenter, + connectorAnchor.valueAxis, + ); + return [toLayout(connection.point)]; + }); + const fallbackAnchor = toLayout(best.connection.point); + const primaryAnchor = annotationPrimaryAnchor( + item, + markSourceRect, + best.card, + best.candidate.connection, + fallbackAnchor, + ); + const anchors = connectorAnchors?.length ? connectorAnchors : [primaryAnchor]; + const showConnector = connector === 'line' && anchors.length > 0; + const routes = showConnector ? routeAnnotationLeaders({ card: best.card, sources: anchors }) : []; + annotationSvg.setAttribute('viewBox', `0 0 ${width} ${height}`); + annotationPath.setAttribute('d', showConnector && routes.length === anchors.length + ? routes.map((route) => route.points + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) + .join(' ')).join(' ') + : ''); + annotationPath.style.display = showConnector && routes.length === anchors.length ? '' : 'none'; + annotationLayer.dataset.connection = best.candidate.connection; + annotationLayer.dataset.angle = String(Math.round(best.angle * 180 / Math.PI)); + annotationLayer.dataset.distance = String(best.distance); + annotationLayer.dataset.align = best.align; + annotationLayer.dataset.connector = connector; + annotationLayer.dataset.score = String(Math.round(best.score)); + }; + + return { + render, + clear, + sync: () => { + if (current) render(current.element, current.annotation); + }, + destroy: () => { + clear(); + }, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/annotation-text.ts b/packages/flint-js/src/vegalite/interactions/presentation/annotation-text.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/flint-js/src/vegalite/interactions/presentation/data-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/data-overlay.ts new file mode 100644 index 00000000..4ff55cb2 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/data-overlay.ts @@ -0,0 +1,299 @@ +import type { ChartOverlaySpec } from '../../../core/interaction-contracts'; +import type { SemanticTarget } from '../../../core/interaction-semantics'; +import type { PlotPoint } from '../../../interactive/language/geometry'; +import type { PathProjection } from '../../../interactive/language/projections'; +import type { RendererCoordinateSpace } from '../hit-adapter'; + +export interface DataOverlayController { + render(overlays: ReadonlyMap): void; + targetForElement(element: EventTarget | null): { name: string; target: SemanticTarget } | null; + targetAt(point: PlotPoint, maxDistance: number): { name: string; target: SemanticTarget } | null; + project(name: string, point: PlotPoint): PathProjection | undefined; + sync(): void; + destroy(): void; +} + +export interface DataOverlayOptions { + view: any; + container: HTMLElement; + scales: Partial>; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +} + +export function orderedOverlayRows(spec: ChartOverlaySpec): readonly Record[] { + const rows = [...spec.data.values]; + const field = spec.encodings.order?.field; + if (!field) return rows; + return rows.sort((left, right) => { + const a = left[field]; + const b = right[field]; + if (typeof a === 'number' && typeof b === 'number') return a - b; + return String(a ?? '').localeCompare(String(b ?? '')); + }); +} + +export function projectPointToPath( + point: PlotPoint, + vertices: readonly { point: PlotPoint; record: Record }[], +): PathProjection | undefined { + let nearest: PathProjection | undefined; + for (let index = 0; index < vertices.length - 1; index += 1) { + const start = vertices[index]; + const end = vertices[index + 1]; + const dx = end.point.x - start.point.x; + const dy = end.point.y - start.point.y; + const lengthSquared = dx * dx + dy * dy; + const rawT = lengthSquared > 0 + ? ((point.x - start.point.x) * dx + (point.y - start.point.y) * dy) / lengthSquared + : 0; + const t = Math.max(0, Math.min(1, rawT)); + const projected = { x: start.point.x + dx * t, y: start.point.y + dy * t }; + const distance = Math.hypot(point.x - projected.x, point.y - projected.y); + if (nearest && nearest.distance <= distance) continue; + nearest = { + kind: 'path', + point: projected, + distance, + segment: { + start: { value: start.record, records: [start.record] }, + end: { value: end.record, records: [end.record] }, + t, + }, + }; + } + return nearest; +} + +/** + * Renders data overlays in a sibling SVG plane. It never mutates the assembled + * Vega/Vega-Lite mark tree, so template scale resolution and composition remain intact. + */ +export function createDataOverlay({ + view, + container, + scales, + coordinateSpace, + containerLayoutSize, +}: DataOverlayOptions): DataOverlayController { + const layer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + let current = new Map(); + const projectedVertices = new Map }[]>(); + const targetFor = ( + name: string, + spec: ChartOverlaySpec, + records: readonly Record[], + ): { name: string; target: SemanticTarget } => ({ + name, + target: { + visual: { kind: spec.mark === 'line' ? 'path' : 'mark', role: spec.role }, + elements: [{ value: { overlay: name }, records }], + }, + }); + Object.assign(layer.style, { + position: 'absolute', inset: '0', zIndex: '4', width: '100%', height: '100%', + pointerEvents: 'none', overflow: 'hidden', + }); + + const draw = (): void => { + layer.replaceChildren(); + projectedVertices.clear(); + if (current.size === 0 || !scales.x || !scales.y) { + layer.remove(); + return; + } + const xScale = view.scale(scales.x); + const yScale = view.scale(scales.y); + const colorScale = scales.color ? view.scale(scales.color) : undefined; + if (typeof xScale !== 'function' || typeof yScale !== 'function') { + layer.remove(); + return; + } + const space = coordinateSpace(); + const renderer = container.querySelector('canvas, svg') as HTMLElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const size = containerLayoutSize(); + const scaleX = containerRect.width > 0 ? size.width / containerRect.width : 1; + const scaleY = containerRect.height > 0 ? size.height / containerRect.height : 1; + Object.assign(layer.style, { + inset: 'auto', + left: `${(rendererRect.left - containerRect.left) * scaleX}px`, + top: `${(rendererRect.top - containerRect.top) * scaleY}px`, + width: `${rendererRect.width * scaleX}px`, + height: `${rendererRect.height * scaleY}px`, + }); + layer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + + for (const [name, spec] of current) { + const rows = orderedOverlayRows(spec); + const projected = (row: Record, xField: string, yField: string) => { + const x = xScale(row[xField]); + const y = yScale(row[yField]); + return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : undefined; + }; + const points = rows.flatMap((row) => { + const x = xScale(row[spec.encodings.x.field]); + const y = yScale(row[spec.encodings.y.field]); + return Number.isFinite(x) && Number.isFinite(y) + ? [{ x: x + space.originX, y: y + space.originY }] + : []; + }); + if (points.length === 0) continue; + const identify = (element: SVGElement, rowIndex?: number): void => { + element.setAttribute('data-flint-overlay', name); + element.setAttribute('data-flint-role', spec.role); + if (rowIndex !== undefined) element.setAttribute('data-flint-row', String(rowIndex)); + element.setAttribute('opacity', String(spec.style?.opacity ?? 1)); + // Overlay marks stay click-through. Gesture acquisition uses + // targetAt() against rendered geometry, so the underlying chart + // retains authoritative click/hover semantics. + element.style.pointerEvents = 'none'; + }; + + if (spec.mark === 'line') { + const vertices = rows.flatMap((row) => { + const point = projected(row, spec.encodings.x.field, spec.encodings.y.field); + return point ? [{ point, record: row }] : []; + }); + projectedVertices.set(name, vertices); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + identify(path); + path.setAttribute('d', points.map((point, index) => + `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' ')); + path.setAttribute('fill', spec.style?.fill ?? 'none'); + path.setAttribute('fill-opacity', String(spec.style?.fillOpacity ?? 1)); + const colorValue = spec.encodings.color + ? colorScale?.(rows[0]?.[spec.encodings.color.field]) + : undefined; + path.setAttribute('stroke', spec.style?.stroke ?? colorValue ?? '#4c78a8'); + path.setAttribute('stroke-width', String(spec.style?.strokeWidth ?? 2)); + if (spec.style?.strokeDash?.length) { + path.setAttribute('stroke-dasharray', spec.style.strokeDash.join(' ')); + } + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + path.setAttribute('vector-effect', 'non-scaling-stroke'); + layer.append(path); + continue; + } + + rows.forEach((row, rowIndex) => { + const point = projected(row, spec.encodings.x.field, spec.encodings.y.field); + if (!point) return; + const x = point.x + space.originX; + const y = point.y + space.originY; + if (spec.mark === 'point') { + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + identify(circle, rowIndex); + circle.setAttribute('cx', String(x)); + circle.setAttribute('cy', String(y)); + circle.setAttribute('r', String(spec.style?.pointRadius ?? 4)); + circle.setAttribute('fill', spec.style?.fill ?? '#4c78a8'); + circle.setAttribute('fill-opacity', String(spec.style?.fillOpacity ?? 1)); + if (spec.style?.stroke) circle.setAttribute('stroke', spec.style.stroke); + if (spec.style?.strokeWidth !== undefined) circle.setAttribute('stroke-width', String(spec.style.strokeWidth)); + layer.append(circle); + return; + } + if (spec.mark === 'text') { + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + identify(text, rowIndex); + text.setAttribute('x', String(x + (spec.style?.dx ?? 0))); + text.setAttribute('y', String(y + (spec.style?.dy ?? 0))); + text.setAttribute('text-anchor', spec.style?.textAlign ?? 'middle'); + text.setAttribute('font-size', String(spec.style?.fontSize ?? 11)); + text.setAttribute('font-weight', String(spec.style?.fontWeight ?? 'normal')); + const colorValue = spec.encodings.color + ? colorScale?.(row[spec.encodings.color.field]) + : undefined; + text.setAttribute('fill', spec.style?.fill ?? colorValue ?? '#333333'); + text.textContent = String(spec.encodings.text ? row[spec.encodings.text.field] ?? '' : ''); + layer.append(text); + return; + } + + const endPoint = spec.encodings.x2 && spec.encodings.y2 + ? projected(row, spec.encodings.x2.field, spec.encodings.y2.field) + : undefined; + if (!endPoint) return; + const x2 = endPoint.x + space.originX; + const y2 = endPoint.y + space.originY; + if (spec.mark === 'rule') { + const rule = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + identify(rule, rowIndex); + rule.setAttribute('x1', String(x)); + rule.setAttribute('y1', String(y)); + rule.setAttribute('x2', String(x2)); + rule.setAttribute('y2', String(y2)); + rule.setAttribute('stroke', spec.style?.stroke ?? '#4c78a8'); + rule.setAttribute('stroke-width', String(spec.style?.strokeWidth ?? 1)); + layer.append(rule); + return; + } + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + identify(rect, rowIndex); + rect.setAttribute('x', String(Math.min(x, x2))); + rect.setAttribute('y', String(Math.min(y, y2))); + rect.setAttribute('width', String(Math.abs(x2 - x))); + rect.setAttribute('height', String(Math.abs(y2 - y))); + rect.setAttribute('fill', spec.style?.fill ?? '#4c78a8'); + rect.setAttribute('fill-opacity', String(spec.style?.fillOpacity ?? 0.2)); + if (spec.style?.stroke) rect.setAttribute('stroke', spec.style.stroke); + if (spec.style?.strokeWidth !== undefined) rect.setAttribute('stroke-width', String(spec.style.strokeWidth)); + layer.append(rect); + }); + } + if (layer.childElementCount === 0) layer.remove(); + else { + if (!layer.isConnected) container.append(layer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + } + }; + + return { + render(overlays) { + current = new Map(overlays); + draw(); + }, + targetForElement(element) { + if (!element || typeof (element as Element).getAttribute !== 'function') return null; + const visual = element as Element; + const name = visual.getAttribute('data-flint-overlay') ?? undefined; + const spec = name ? current.get(name) : undefined; + if (!name || !spec?.interactive) return null; + const rows = orderedOverlayRows(spec); + const rowIndex = Number(visual.getAttribute('data-flint-row')); + const records = Number.isInteger(rowIndex) && rowIndex >= 0 && rowIndex < rows.length + ? [rows[rowIndex]] + : rows; + return targetFor(name, spec, records); + }, + targetAt(point, maxDistance) { + let nearest: { name: string; spec: ChartOverlaySpec; distance: number } | undefined; + for (const [name, spec] of current) { + if (!spec.interactive || spec.mark !== 'line') continue; + const projection = projectPointToPath(point, projectedVertices.get(name) ?? []); + if (!projection || projection.distance > maxDistance) continue; + if (!nearest || projection.distance < nearest.distance) { + nearest = { name, spec, distance: projection.distance }; + } + } + return nearest + ? targetFor(nearest.name, nearest.spec, orderedOverlayRows(nearest.spec)) + : null; + }, + project(name, point) { + const spec = current.get(name); + return spec?.mark === 'line' && spec.projectable + ? projectPointToPath(point, projectedVertices.get(name) ?? []) + : undefined; + }, + sync: draw, + destroy() { + current.clear(); + projectedVertices.clear(); + layer.remove(); + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts new file mode 100644 index 00000000..d80ec6ce --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/drag-reorder-overlay.ts @@ -0,0 +1,364 @@ +import type { RenderHit, SemanticTarget } from '../../../core/interaction-semantics'; +import type { PlotPoint } from '../../../interactive/interactions'; +import type { VegaReorderAxis } from '../contracts'; +import { + axisItems, + axisTargetIdentity, + clientRectToLayoutRect, + renderHit, + sceneItems, + type RendererCoordinateSpace, +} from '../hit-adapter'; + +export interface DragReorderPreview { + start: PlotPoint; + current: PlotPoint; + axis?: 'x' | 'y'; + field?: string; + source: SemanticTarget; + destination: SemanticTarget; + includeControl?: boolean; + ghostOpacity?: number; + dimmerOpacity?: number; + sourceDimmerOpacity?: number; +} + +export interface DragReorderOverlayController { + render(preview: DragReorderPreview): void; + clear(): void; + destroy(): void; +} + +export interface DragReorderOverlayOptions { + view: any; + container: HTMLElement; + reorderAxes: readonly VegaReorderAxis[]; + axisTargets?: Readonly>; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +} + +export function dragGhostDelta( + preview: Pick, +): PlotPoint { + return { + x: preview.current.x - preview.start.x, + y: preview.current.y - preview.start.y, + }; +} + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function hasOneValue(values: readonly unknown[]): boolean { + return values.length > 0 && values.every((value) => Object.is(value, values[0])); +} + +export function eligibleReorderAxes>( + axes: readonly T[], + target: SemanticTarget, +): T[] { + return axes.filter(({ field }) => hasOneValue(target.elements.flatMap((element) => { + const records = element.records?.length ? element.records : [element.value]; + return records.flatMap((record) => record[field] === undefined ? [] : [record[field]]); + }))); +} + +export function eligibleReorderAxesForHit>( + axes: readonly T[], + hit: RenderHit, +): T[] { + const records = hit.pathData?.length ? hit.pathData : [hit.datum]; + return axes.filter(({ field }) => hasOneValue( + records.flatMap((record) => record[field] === undefined ? [] : [record[field]]), + )); +} + +export function eligibleReorderAxesForAxis>( + axes: readonly T[], + identity: Pick, +): T[] { + return axes.filter(({ axis, field }) => axis === identity.axis && field === identity.field); +} + +function targetValue(target: SemanticTarget, field: string): unknown { + const element = target.elements[0]; + return element?.records?.find((record) => record[field] !== undefined)?.[field] + ?? element?.value[field] + ?? (element?.value.field === field ? element.value.value : undefined); +} + +export function activeReorderAxis>( + axes: readonly T[], + preview: DragReorderPreview, +): T | undefined { + const changed = axes.filter(({ field }) => !Object.is( + targetValue(preview.source, field), + targetValue(preview.destination, field), + )); + const delta = { + x: preview.current.x - preview.start.x, + y: preview.current.y - preview.start.y, + }; + const preferred = preview.axis ?? (Math.abs(delta.y) > Math.abs(delta.x) ? 'y' : 'x'); + return preview.axis + ? axes.find(({ axis }) => axis === preview.axis) + : changed.find(({ axis }) => axis === preferred) ?? changed[0] ?? axes.find(({ axis }) => axis === preferred); +} + +export function reorderOwnedItems( + items: readonly any[], + axis: Pick, + value: unknown, +): any[] { + return items.filter((item) => renderHit(item)?.datum[axis.field] === value + && (!axis.markTypes || axis.markTypes.includes(item.mark?.marktype))); +} + +export function reorderPreviewItems( + items: readonly any[], + axis: Pick, + value: unknown, +): any[] { + const candidates = reorderOwnedItems(items, axis, value) + .filter((item) => item.interactionGeometry?.points?.length >= 2 || item.bounds); + const discrete = candidates.filter((item) => { + const markType = item.mark?.marktype; + return markType !== 'line' && markType !== 'area'; + }); + return axis.includeConnectiveMarks || discrete.length === 0 ? candidates : discrete; +} + +export function createDragReorderOverlay({ + view, + container, + reorderAxes, + axisTargets, + coordinateSpace, + containerLayoutSize, +}: DragReorderOverlayOptions): DragReorderOverlayController { + const layer = document.createElementNS(SVG_NS, 'svg'); + type GhostItem = { + element?: SVGGraphicsElement; + matrix?: { a: number; b: number; c: number; d: number; e: number; f: number }; + points: PlotPoint[]; + fill: string; + fillOpacity: number; + stroke: string; + strokeWidth: number; + }; + let ghostSnapshot: { + axis: 'x' | 'y'; + field: string; + sourceValue: unknown; + start: PlotPoint; + items: GhostItem[]; + } | undefined; + Object.assign(layer.style, { + position: 'absolute', zIndex: '4', pointerEvents: 'none', overflow: 'hidden', + }); + + const clear = (): void => { + layer.remove(); + ghostSnapshot = undefined; + }; + const render = (preview: DragReorderPreview): void => { + const candidateAxes = preview.field + ? reorderAxes.filter((axis) => axis.field === preview.field) + : reorderAxes; + const active = activeReorderAxis(candidateAxes, preview); + if (!active) return clear(); + const sourceValue = targetValue(preview.source, active.field); + const destinationValue = targetValue(preview.destination, active.field); + const scene = sceneItems(view); + const sourceItems = reorderPreviewItems(scene, active, sourceValue); + const destinationItems = reorderOwnedItems(scene, active, destinationValue) + .filter((item) => item.bounds); + if (sourceItems.length === 0 || destinationItems.length === 0) return clear(); + + layer.replaceChildren(); + if (!layer.isConnected) container.append(layer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + const space = coordinateSpace(); + const renderer = container.querySelector('svg') as SVGSVGElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + Object.assign(layer.style, { + left: `${rendererLayout.left}px`, top: `${rendererLayout.top}px`, + width: `${rendererLayout.width}px`, height: `${rendererLayout.height}px`, + }); + layer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + + const renderedElement = (item: any): SVGGraphicsElement | undefined => renderer + ? [...renderer.querySelectorAll('[role="graphics-symbol"], text')] + .find((candidate) => { + const datum = (candidate as any).__data__; + return datum?.mark === item.mark && datum?.datum === item.datum; + }) + : undefined; + const cloneRenderedElement = (item: any, delta = { x: 0, y: 0 }): SVGGraphicsElement | undefined => { + const rendered = renderedElement(item); + const matrix = rendered?.getCTM(); + if (!rendered || !matrix) return undefined; + const clone = rendered.cloneNode(true) as SVGGraphicsElement; + clone.removeAttribute('role'); + clone.removeAttribute('aria-label'); + clone.setAttribute('aria-hidden', 'true'); + clone.setAttribute('transform', `matrix(${matrix.a} ${matrix.b} ${matrix.c} ${matrix.d} ${matrix.e + delta.x} ${matrix.f + delta.y})`); + return clone; + }; + const sourceAxisValue = preview.source.visual.kind === 'axis' + ? preview.source.elements[0]?.value + : undefined; + const sourceAxisItem = sourceAxisValue + ? axisItems(view, axisTargets).find((item) => { + const identity = axisTargetIdentity(item, axisTargets); + return identity?.role === 'axis-label' + && identity.axis === sourceAxisValue.axis + && identity.field === sourceAxisValue.field + && Object.is(identity.value, sourceAxisValue.value); + }) + : undefined; + + const sameGhost = ghostSnapshot + && ghostSnapshot.axis === active.axis + && ghostSnapshot.field === active.field + && Object.is(ghostSnapshot.sourceValue, sourceValue) + && ghostSnapshot.start.x === preview.start.x + && ghostSnapshot.start.y === preview.start.y; + if (!sameGhost) { + ghostSnapshot = { + axis: active.axis, + field: active.field, + sourceValue, + start: { ...preview.start }, + items: sourceItems.map((item): GhostItem => { + const rendered = renderedElement(item); + const matrix = rendered?.getCTM(); + const element = rendered && matrix + ? rendered.cloneNode(true) as SVGGraphicsElement + : undefined; + if (element) { + element.removeAttribute('role'); + element.removeAttribute('aria-label'); + element.setAttribute('aria-hidden', 'true'); + } + const points: PlotPoint[] = (item.interactionGeometry?.points ?? [ + { x: item.bounds.x1, y: item.bounds.y1 }, + { x: item.bounds.x2, y: item.bounds.y1 }, + { x: item.bounds.x2, y: item.bounds.y2 }, + { x: item.bounds.x1, y: item.bounds.y2 }, + ]).map((point: PlotPoint) => ({ ...point })); + return { + element, + matrix: matrix ? { + a: matrix.a, b: matrix.b, c: matrix.c, + d: matrix.d, e: matrix.e, f: matrix.f, + } : undefined, + points, + fill: item.fill ?? '#4c78a8', + fillOpacity: (item.opacity ?? 1) * (item.fillOpacity ?? 1), + stroke: item.stroke ?? '#ffffff', + strokeWidth: Math.max(1, item.strokeWidth ?? 0), + }; + }), + }; + } + + const dimmedItems = scene.filter((item) => { + const value = renderHit(item)?.datum[active.field]; + return value !== undefined && item.bounds; + }); + const dimmedElements = new Set(); + for (const item of dimmedItems) { + const sourceItem = Object.is(renderHit(item)?.datum[active.field], sourceValue); + const dimOpacity = String(sourceItem + ? preview.sourceDimmerOpacity ?? 0.5 + : preview.dimmerOpacity ?? 0.68); + const rendered = renderedElement(item); + if (rendered && !dimmedElements.has(rendered)) { + dimmedElements.add(rendered); + const dimmer = cloneRenderedElement(item)!; + dimmer.setAttribute('fill', '#ffffff'); + dimmer.setAttribute('stroke', '#ffffff'); + dimmer.setAttribute('opacity', dimOpacity); + layer.append(dimmer); + continue; + } + if (rendered) continue; + const dimmer = document.createElementNS(SVG_NS, 'polygon'); + dimmer.setAttribute('points', [ + `${item.bounds.x1 + space.originX},${item.bounds.y1 + space.originY}`, + `${item.bounds.x2 + space.originX},${item.bounds.y1 + space.originY}`, + `${item.bounds.x2 + space.originX},${item.bounds.y2 + space.originY}`, + `${item.bounds.x1 + space.originX},${item.bounds.y2 + space.originY}`, + ].join(' ')); + dimmer.setAttribute('fill', '#ffffff'); + dimmer.setAttribute('fill-opacity', dimOpacity); + layer.append(dimmer); + } + + // The category axis chooses the drop slot; it must not constrain the + // visual ghost. Preserve the grab offset and follow the pointer freely. + const delta = dragGhostDelta(preview); + // Render from the gesture-start snapshot, not from the live scene. The + // chart may have reflowed since pointer-down (for example after a prior + // order update), but the ghost must remain attached to this pointer. + for (const item of ghostSnapshot?.items ?? []) { + if (item.element && item.matrix) { + const { matrix } = item; + item.element.setAttribute('transform', `matrix(${matrix.a} ${matrix.b} ${matrix.c} ${matrix.d} ${matrix.e + delta.x} ${matrix.f + delta.y})`); + item.element.setAttribute('opacity', String(preview.ghostOpacity ?? 0.62)); + layer.append(item.element); + continue; + } + const shape = document.createElementNS(SVG_NS, 'polygon'); + shape.setAttribute('points', item.points + .map((point: PlotPoint) => `${point.x + space.originX + delta.x},${point.y + space.originY + delta.y}`) + .join(' ')); + shape.setAttribute('fill', item.fill); + shape.setAttribute('fill-opacity', String(item.fillOpacity * (preview.ghostOpacity ?? 0.62))); + shape.setAttribute('stroke', item.stroke); + shape.setAttribute('stroke-width', String(item.strokeWidth)); + layer.append(shape); + } + + if (preview.includeControl !== false && sourceAxisItem) { + const labelGhost = cloneRenderedElement(sourceAxisItem, delta); + if (labelGhost) { + labelGhost.setAttribute('opacity', String(preview.ghostOpacity ?? 0.72)); + layer.append(labelGhost); + } + } + + if (!Object.is(sourceValue, destinationValue)) { + const bounds = destinationItems.reduce((result, item) => ({ + x1: Math.min(result.x1, item.bounds.x1), y1: Math.min(result.y1, item.bounds.y1), + x2: Math.max(result.x2, item.bounds.x2), y2: Math.max(result.y2, item.bounds.y2), + }), { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity }); + const indicator = document.createElementNS(SVG_NS, 'line'); + if (active.axis === 'x') { + const edge = delta.x >= 0 ? 'end' : 'start'; + const x = (edge === 'end' ? bounds.x2 : bounds.x1) + space.originX; + indicator.setAttribute('x1', String(x)); + indicator.setAttribute('x2', String(x)); + indicator.setAttribute('y1', String(space.originY)); + indicator.setAttribute('y2', String(space.originY + space.plotHeight)); + } else { + const edge = delta.y >= 0 ? 'end' : 'start'; + const y = (edge === 'end' ? bounds.y2 : bounds.y1) + space.originY; + indicator.setAttribute('x1', String(space.originX)); + indicator.setAttribute('x2', String(space.originX + space.plotWidth)); + indicator.setAttribute('y1', String(y)); + indicator.setAttribute('y2', String(y)); + } + indicator.setAttribute('stroke', '#b85c5c'); + indicator.setAttribute('stroke-opacity', '0.88'); + indicator.setAttribute('stroke-width', '1.5'); + indicator.setAttribute('stroke-linecap', 'round'); + layer.append(indicator); + } + }; + + return { render, clear, destroy: clear }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts new file mode 100644 index 00000000..cae1845a --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/focus-overlay.ts @@ -0,0 +1,284 @@ +import type { PlotPoint } from '../../../interactive/interactions'; +import type { VegaInteractionPlan } from '../contracts'; +import { + INTERACTION_KEY, + clientRectToLayoutRect, + renderHit, + sceneItems, + type RendererCoordinateSpace, +} from '../hit-adapter'; + +export function mergeContiguousSelectionBounds( + bounds: readonly { x1: number; y1: number; x2: number; y2: number }[], + gap = 2, +): { x1: number; y1: number; x2: number; y2: number }[] { + const merged = bounds.map((bound) => ({ ...bound })); + const connected = (a: typeof merged[number], b: typeof merged[number]): boolean => { + const overlapX = Math.min(a.x2, b.x2) - Math.max(a.x1, b.x1); + const overlapY = Math.min(a.y2, b.y2) - Math.max(a.y1, b.y1); + return (overlapX > 0 && overlapY >= -gap) || (overlapY > 0 && overlapX >= -gap); + }; + for (let left = 0; left < merged.length; left += 1) { + for (let right = left + 1; right < merged.length;) { + if (!connected(merged[left], merged[right])) { + right += 1; + continue; + } + merged[left] = { + x1: Math.min(merged[left].x1, merged[right].x1), + y1: Math.min(merged[left].y1, merged[right].y1), + x2: Math.max(merged[left].x2, merged[right].x2), + y2: Math.max(merged[left].y2, merged[right].y2), + }; + merged.splice(right, 1); + left = -1; + break; + } + } + return merged; +} + +export interface SelectionBoundarySegment { + x1: number; + y1: number; + x2: number; + y2: number; +} + +export function selectionBoundarySegments( + bounds: readonly { x1: number; y1: number; x2: number; y2: number }[], + gap = 2, +): SelectionBoundarySegment[] { + const overlaps = (a1: number, a2: number, b1: number, b2: number): boolean => + Math.min(a2, b2) - Math.max(a1, b1) > 0; + const adjacent = ( + bound: typeof bounds[number], + side: 'left' | 'right' | 'top' | 'bottom', + ): boolean => bounds.some((candidate) => { + if (candidate === bound) return false; + if (side === 'left' || side === 'right') { + const distance = side === 'left' + ? Math.abs(candidate.x2 - bound.x1) + : Math.abs(candidate.x1 - bound.x2); + return distance <= gap && overlaps(bound.y1, bound.y2, candidate.y1, candidate.y2); + } + const distance = side === 'top' + ? Math.abs(candidate.y2 - bound.y1) + : Math.abs(candidate.y1 - bound.y2); + return distance <= gap && overlaps(bound.x1, bound.x2, candidate.x1, candidate.x2); + }); + return bounds.flatMap((bound) => [ + ...(!adjacent(bound, 'top') ? [{ x1: bound.x1, y1: bound.y1, x2: bound.x2, y2: bound.y1 }] : []), + ...(!adjacent(bound, 'right') ? [{ x1: bound.x2, y1: bound.y1, x2: bound.x2, y2: bound.y2 }] : []), + ...(!adjacent(bound, 'bottom') ? [{ x1: bound.x1, y1: bound.y2, x2: bound.x2, y2: bound.y2 }] : []), + ...(!adjacent(bound, 'left') ? [{ x1: bound.x1, y1: bound.y1, x2: bound.x1, y2: bound.y2 }] : []), + ]); +} + +export interface FocusOverlayController { + render(selected: ReadonlySet, hoveredPathKeys: ReadonlySet): void; + destroy(): void; +} + +export interface FocusOverlayOptions { + view: any; + container: HTMLElement; + plan: VegaInteractionPlan; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +} + +export function hoverContrastOpacity(authoredOpacity: number): number { + return authoredOpacity < 1 ? 1 : 0.9; +} + +export function areaSpotlightOpacity( + authoredOpacity: number, + currentOpacity: number, + selected: boolean, + hasSelection: boolean, +): number { + if (!hasSelection) return 1; + return selected ? authoredOpacity * 0.9 : currentOpacity; +} + +export function createFocusOverlay({ + view, + container, + plan, + coordinateSpace, + containerLayoutSize, +}: FocusOverlayOptions): FocusOverlayController { + const focusLayer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const pathVisuals = new Map(); + Object.assign(focusLayer.style, { + position: 'absolute', inset: '0', zIndex: '3', width: '100%', height: '100%', + pointerEvents: 'none', overflow: 'hidden', + }); + + const render = (selected: ReadonlySet, hoveredPathKeys: ReadonlySet): void => { + focusLayer.replaceChildren(); + const scene = sceneItems(view); + for (const item of scene) { + if (!item.interactionGeometry) continue; + const hit = renderHit(item); + const key = hit?.datum[INTERACTION_KEY]; + if (typeof key !== 'string' || pathVisuals.has(key)) continue; + pathVisuals.set(key, { + fill: item.fill, + fillOpacity: (typeof item.opacity === 'number' ? item.opacity : 1) + * (typeof item.fillOpacity === 'number' ? item.fillOpacity : 1), + stroke: item.stroke, + strokeWidth: typeof item.strokeWidth === 'number' ? item.strokeWidth : 2, + }); + } + const items = scene.filter((item) => { + const hit = renderHit(item); + const key = String(hit?.datum[INTERACTION_KEY]); + const boundaryMode = plan.renderSelectionStyles?.[item.mark.marktype]?.boundary === 'contiguous-region'; + return hit + && (selected.has(key) || hoveredPathKeys.has(key)) + && item.interactionGeometry + && !boundaryMode; + }); + const boundaryItems = scene.filter((item) => { + const hit = renderHit(item); + const key = hit?.datum[INTERACTION_KEY]; + return typeof key === 'string' + && (selected.has(key) || hoveredPathKeys.has(key)) + && plan.renderSelectionStyles?.[item.mark.marktype]?.boundary === 'contiguous-region'; + }); + const boundarySegments = selectionBoundarySegments(boundaryItems.map((item) => item.bounds)); + if (items.length === 0 && boundarySegments.length === 0) { + focusLayer.remove(); + return; + } + if (!focusLayer.isConnected) container.append(focusLayer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + const space = coordinateSpace(); + const renderer = container.querySelector('svg') as SVGSVGElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + Object.assign(focusLayer.style, { + inset: 'auto', + left: `${rendererLayout.left}px`, + top: `${rendererLayout.top}px`, + width: `${rendererLayout.width}px`, + height: `${rendererLayout.height}px`, + }); + focusLayer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + const filledClosedMarks = new Set(); + for (const item of items) { + if (!item.interactionGeometry.closed || filledClosedMarks.has(item.mark)) continue; + const closedItems = scene.filter((candidate) => + candidate.mark === item.mark && candidate.interactionGeometry?.closed); + const visual = closedItems + .map((candidate) => renderHit(candidate)?.datum[INTERACTION_KEY]) + .find((key): key is string => typeof key === 'string' && selected.has(key)); + if (!visual) continue; + const style = pathVisuals.get(visual); + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); + polygon.setAttribute('points', closedItems.map((candidate) => { + const point = candidate.interactionGeometry.points[0] as PlotPoint; + return `${point.x + space.originX},${point.y + space.originY}`; + }).join(' ')); + polygon.setAttribute('fill', style?.fill ?? item.fill ?? '#4c78a8'); + polygon.setAttribute('fill-opacity', String(style?.fillOpacity ?? 1)); + focusLayer.append(polygon); + filledClosedMarks.add(item.mark); + } + for (const item of items) { + const key = renderHit(item)?.datum[INTERACTION_KEY]; + const visual = typeof key === 'string' ? pathVisuals.get(key) : undefined; + const hovered = typeof key === 'string' && hoveredPathKeys.has(key); + const hoverStyle = hovered ? plan.renderHoverStyles?.[item.mark.marktype] : undefined; + const selectionStyle = typeof key === 'string' && selected.has(key) + ? plan.renderSelectionStyles?.[item.mark.marktype] + : undefined; + // Points already carry nested facet/group offsets, so renderer + // coordinates are just the plot point plus the plot origin. + const points = item.interactionGeometry.points.map((plotPoint: PlotPoint) => ({ + x: plotPoint.x + space.originX, + y: plotPoint.y + space.originY, + })); + const segment = item.interactionGeometry.kind === 'segment'; + const shape = document.createElementNS('http://www.w3.org/2000/svg', segment ? 'path' : 'polygon'); + if (segment) { + shape.setAttribute('d', `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`); + shape.setAttribute('fill', 'none'); + shape.setAttribute('stroke', hoverStyle?.stroke ?? visual?.stroke ?? item.stroke ?? '#4c78a8'); + const authoredWidth = visual?.strokeWidth ?? item.strokeWidth ?? 2; + shape.setAttribute('stroke-width', String( + hoverStyle?.strokeWidth + ?? authoredWidth * (selectionStyle?.strokeWidthMultiplier ?? 1), + )); + shape.setAttribute('stroke-linecap', 'round'); + } else { + shape.setAttribute('points', points.map((plotPoint: PlotPoint) => `${plotPoint.x},${plotPoint.y}`).join(' ')); + shape.setAttribute('fill', hoverStyle?.fill ?? visual?.fill ?? item.fill ?? '#4c78a8'); + const authoredFillOpacity = visual?.fillOpacity ?? 1; + const currentFillOpacity = (typeof item.opacity === 'number' ? item.opacity : 1) + * (typeof item.fillOpacity === 'number' ? item.fillOpacity : 1); + const fillOpacity = hoverStyle?.opacity === 'spotlight' + ? areaSpotlightOpacity( + authoredFillOpacity, + currentFillOpacity, + typeof key === 'string' && selected.has(key), + selected.size > 0, + ) + : hoverStyle?.opacity === 'contrast' + ? hoverContrastOpacity(authoredFillOpacity) + : hoverStyle?.fillOpacity ?? authoredFillOpacity; + shape.setAttribute('fill-opacity', String(fillOpacity)); + if (hoverStyle?.stroke) shape.setAttribute('stroke', hoverStyle.stroke); + if (hoverStyle?.strokeWidth !== undefined) shape.setAttribute('stroke-width', String(hoverStyle.strokeWidth)); + } + focusLayer.append(shape); + } + if (boundarySegments.length > 0) { + const boundaryStyle = plan.selectionBoundary ?? { + color: '#20262c', + width: 1.25, + opacity: 0.68, + haloColor: '#ffffff', + haloWidth: 2.5, + haloOpacity: 0.35, + }; + const continuousStyle = plan.continuousColorFocus; + for (const [stroke, width, opacity] of [ + [ + boundaryStyle.haloColor, + continuousStyle?.haloWidth ?? boundaryStyle.haloWidth, + continuousStyle?.haloOpacity ?? boundaryStyle.haloOpacity, + ], + [ + boundaryStyle.color, + continuousStyle?.boundaryWidth ?? boundaryStyle.width, + continuousStyle?.boundaryOpacity ?? boundaryStyle.opacity, + ], + ] as const) { + const boundary = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + boundary.setAttribute('d', boundarySegments.map((segment) => + `M ${segment.x1 + space.originX} ${segment.y1 + space.originY} ` + + `L ${segment.x2 + space.originX} ${segment.y2 + space.originY}`).join(' ')); + boundary.setAttribute('fill', 'none'); + boundary.setAttribute('stroke', stroke); + boundary.setAttribute('stroke-width', String(width)); + boundary.setAttribute('stroke-opacity', String(opacity)); + boundary.setAttribute('vector-effect', 'non-scaling-stroke'); + focusLayer.append(boundary); + } + } + }; + + return { + render, + destroy: () => focusLayer.remove(), + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/freeform-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/freeform-overlay.ts new file mode 100644 index 00000000..0d6ab64e --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/freeform-overlay.ts @@ -0,0 +1,107 @@ +import type { + FreeformOverlaySpec, + FreeformOverlayTransform, +} from '../../../core/interaction-contracts'; +import { clientRectToLayoutRect, type RendererCoordinateSpace } from '../hit-adapter'; + +export interface FreeformOverlayController { + render(overlays: ReadonlyMap): void; + clear(): void; + destroy(): void; +} + +export interface FreeformOverlayOptions { + container: HTMLElement; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +} + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function transformAttribute(transform: FreeformOverlayTransform | undefined): string | undefined { + if (!transform) return undefined; + const parts: string[] = []; + if (transform.translate) parts.push(`translate(${transform.translate.x} ${transform.translate.y})`); + if (transform.rotate !== undefined) parts.push(`rotate(${transform.rotate})`); + if (typeof transform.scale === 'number') parts.push(`scale(${transform.scale})`); + else if (transform.scale) parts.push(`scale(${transform.scale.x} ${transform.scale.y})`); + return parts.length > 0 ? parts.join(' ') : undefined; +} + +function sanitizeSvg(element: SVGElement): SVGElement { + const descendants = Array.from(element.querySelectorAll('*')) as unknown as SVGElement[]; + const nodes = [element, ...descendants]; + for (const node of nodes) { + if (node.localName === 'script' || node.localName === 'foreignObject') { + node.remove(); + continue; + } + for (const attribute of [...node.attributes]) { + const name = attribute.name.toLowerCase(); + const value = attribute.value.trim().toLowerCase(); + if (name.startsWith('on') + || ((name === 'href' || name === 'xlink:href') && value.startsWith('javascript:'))) { + node.removeAttribute(attribute.name); + } + } + } + return element; +} + +export function freeformSvgElement(content: string | SVGElement): SVGElement | null { + if (typeof content !== 'string') { + return sanitizeSvg(content.cloneNode(true) as SVGElement); + } + const parsed = new DOMParser().parseFromString(content, 'image/svg+xml'); + if (parsed.querySelector('parsererror') || parsed.documentElement.namespaceURI !== SVG_NS) return null; + return sanitizeSvg(document.importNode(parsed.documentElement, true) as unknown as SVGElement); +} + +export function createFreeformOverlay({ + container, + coordinateSpace, + containerLayoutSize, +}: FreeformOverlayOptions): FreeformOverlayController { + const layer = document.createElementNS(SVG_NS, 'svg'); + Object.assign(layer.style, { + position: 'absolute', zIndex: '4', pointerEvents: 'none', overflow: 'hidden', + }); + + const clear = (): void => layer.remove(); + const render = (overlays: ReadonlyMap): void => { + const renderable = [...overlays].filter(([, spec]) => + spec.body.some((component) => component.type === 'svg')); + if (renderable.length === 0) return clear(); + layer.replaceChildren(); + if (!layer.isConnected) container.append(layer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + const space = coordinateSpace(); + const renderer = container.querySelector('canvas, svg') as HTMLElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + Object.assign(layer.style, { + left: `${rendererLayout.left}px`, top: `${rendererLayout.top}px`, + width: `${rendererLayout.width}px`, height: `${rendererLayout.height}px`, + }); + layer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + + for (const [name, spec] of renderable) { + for (const component of spec.body) { + if (component.type !== 'svg') continue; + const group = document.createElementNS(SVG_NS, 'g'); + group.setAttribute('data-flint-freeform-overlay', name); + const transforms: string[] = []; + if (spec.coordinateSpace === 'plot') transforms.push(`translate(${space.originX} ${space.originY})`); + const bodyTransform = transformAttribute(component.transform); + if (bodyTransform) transforms.push(bodyTransform); + if (transforms.length > 0) group.setAttribute('transform', transforms.join(' ')); + const element = freeformSvgElement(component.content); + if (element) group.append(element); + layer.append(group); + } + } + }; + + return { render, clear, destroy: clear }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/inspect-guide-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/inspect-guide-overlay.ts new file mode 100644 index 00000000..41ec96c0 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/inspect-guide-overlay.ts @@ -0,0 +1,164 @@ +import { facetPlotBounds, type RendererCoordinateSpace } from '../hit-adapter'; +import { clientToLayoutPoint, plotToClientPoint } from '../../../interactive/geometry/coordinate-space'; +import type { GestureGuideController, InspectGestureGuideStyle } from '../../../interactive/guides'; + +export interface InspectGuideOverlay extends GestureGuideController { + renderAxes( + point: { x: number; y: number }, + axes: 'x' | 'y' | 'xy', + style: InspectGestureGuideStyle, + ): void; + renderSegment( + start: { x: number; y: number }, + end: { x: number; y: number }, + style: InspectGestureGuideStyle, + ): void; + renderValueRules( + coordinates: readonly number[], + indexAxis: 'x' | 'y', + style: InspectGestureGuideStyle, + ): void; +} + +export interface InspectGuideOverlayOptions { + view: any; + container: HTMLElement; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +} + +export function inspectGuideLine( + mode: 'x' | 'y', + coordinate: number, + plotSize: { width: number; height: number }, +): { x1: number; y1: number; x2: number; y2: number } { + const bounded = Math.min(mode === 'x' ? plotSize.width : plotSize.height, Math.max(0, coordinate)); + return mode === 'x' + ? { x1: bounded, y1: 0, x2: bounded, y2: plotSize.height } + : { x1: 0, y1: bounded, x2: plotSize.width, y2: bounded }; +} + +export function createInspectGuideOverlay({ + view, + container, + coordinateSpace, + containerLayoutSize, +}: InspectGuideOverlayOptions): InspectGuideOverlay { + const previousPosition = container.style.position; + const line = document.createElement('div'); + const crossLine = document.createElement('div'); + const valueLines: HTMLDivElement[] = []; + const baseStyle = { + position: 'absolute', display: 'none', zIndex: '4', pointerEvents: 'none', + } as const; + Object.assign(line.style, baseStyle); + Object.assign(crossLine.style, baseStyle); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + container.append(line, crossLine); + + const haloShadow = (style: InspectGestureGuideStyle): string => + style.haloWidth > 0 && style.haloOpacity > 0 + ? `0 0 0 ${style.haloWidth}px color-mix(in srgb, ${style.haloColor} ${style.haloOpacity * 100}%, transparent)` + : 'none'; + + const renderLine = ( + element: HTMLDivElement, + mode: 'x' | 'y', + coordinate: number, + style: InspectGestureGuideStyle, + ): void => { + const space = coordinateSpace(); + const frame = facetPlotBounds(view, { x: 0, y: 0, width: space.plotWidth, height: space.plotHeight }); + const localCoordinate = coordinate - (mode === 'x' ? frame.x : frame.y); + const localGuide = inspectGuideLine(mode, localCoordinate, frame); + const guide = { + x1: localGuide.x1 + frame.x, + y1: localGuide.y1 + frame.y, + x2: localGuide.x2 + frame.x, + y2: localGuide.y2 + frame.y, + }; + const containerRect = container.getBoundingClientRect(); + const layoutSize = containerLayoutSize(); + const start = clientToLayoutPoint(plotToClientPoint({ x: guide.x1, y: guide.y1 }, space), containerRect, layoutSize); + const end = clientToLayoutPoint(plotToClientPoint({ x: guide.x2, y: guide.y2 }, space), containerRect, layoutSize); + const halo = haloShadow(style); + Object.assign(element.style, mode === 'x' ? { + display: 'block', left: `${start.x - style.width / 2}px`, top: `${start.y}px`, + width: `${style.width}px`, height: `${end.y - start.y}px`, transform: 'none', + transformOrigin: '50% 50%', background: style.color, opacity: `${style.opacity}`, boxShadow: halo, + } : { + display: 'block', left: `${start.x}px`, top: `${start.y - style.width / 2}px`, + width: `${end.x - start.x}px`, height: `${style.width}px`, transform: 'none', + transformOrigin: '50% 50%', background: style.color, opacity: `${style.opacity}`, boxShadow: halo, + }); + }; + + const renderAxes = ( + point: { x: number; y: number }, + axes: 'x' | 'y' | 'xy', + style: InspectGestureGuideStyle, + ): void => { + renderLine(line, axes === 'y' ? 'y' : 'x', axes === 'y' ? point.y : point.x, style); + if (axes === 'xy') renderLine(crossLine, 'y', point.y, style); + else crossLine.style.display = 'none'; + }; + + const renderSegment = ( + segmentStart: { x: number; y: number }, + segmentEnd: { x: number; y: number }, + style: InspectGestureGuideStyle, + ): void => { + crossLine.style.display = 'none'; + const space = coordinateSpace(); + const containerRect = container.getBoundingClientRect(); + const layoutSize = containerLayoutSize(); + const start = clientToLayoutPoint(plotToClientPoint(segmentStart, space), containerRect, layoutSize); + const end = clientToLayoutPoint(plotToClientPoint(segmentEnd, space), containerRect, layoutSize); + const length = Math.hypot(end.x - start.x, end.y - start.y); + const angle = Math.atan2(end.y - start.y, end.x - start.x); + Object.assign(line.style, { + display: 'block', left: `${start.x}px`, top: `${start.y - style.width / 2}px`, + width: `${length}px`, height: `${style.width}px`, transformOrigin: '0 50%', + transform: `rotate(${angle}rad)`, background: style.color, opacity: `${style.opacity}`, + boxShadow: haloShadow(style), + }); + }; + + const renderValueRules = ( + coordinates: readonly number[], + indexAxis: 'x' | 'y', + style: InspectGestureGuideStyle, + ): void => { + crossLine.style.display = 'none'; + while (valueLines.length < coordinates.length) { + const valueLine = document.createElement('div'); + Object.assign(valueLine.style, baseStyle); + valueLines.push(valueLine); + container.append(valueLine); + } + valueLines.forEach((valueLine, index) => { + if (index >= coordinates.length) { + valueLine.style.display = 'none'; + return; + } + renderLine(valueLine, indexAxis === 'x' ? 'y' : 'x', coordinates[index], style); + }); + }; + + return { + renderAxes, + renderSegment, + renderValueRules, + clear(): void { + line.style.display = 'none'; + crossLine.style.display = 'none'; + valueLines.forEach((valueLine) => { valueLine.style.display = 'none'; }); + }, + destroy(): void { + line.remove(); + crossLine.remove(); + valueLines.forEach((valueLine) => valueLine.remove()); + container.style.position = previousPosition; + }, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/legend-range-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/legend-range-overlay.ts new file mode 100644 index 00000000..daa5db9a --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/legend-range-overlay.ts @@ -0,0 +1,58 @@ +import type { LegendHitIdentity, RendererCoordinateSpace } from '../hit-adapter'; +import { clientRectToLayoutRect } from '../hit-adapter'; + +export interface LegendRangeOverlayController { + render(selected: LegendHitIdentity | null, hovered: LegendHitIdentity | null): void; + destroy(): void; +} + +export function createLegendRangeOverlay(options: { + container: HTMLElement; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +}): LegendRangeOverlayController { + const { container, coordinateSpace, containerLayoutSize } = options; + const layer = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + Object.assign(layer.style, { + position: 'absolute', zIndex: '4', pointerEvents: 'none', overflow: 'visible', + }); + + const render = (selected: LegendHitIdentity | null, hovered: LegendHitIdentity | null): void => { + layer.replaceChildren(); + const visible = [ + selected?.visualBounds ? { target: selected, selected: true } : undefined, + hovered?.visualBounds ? { target: hovered, selected: false } : undefined, + ].filter(Boolean) as { target: LegendHitIdentity; selected: boolean }[]; + if (visible.length === 0) { + layer.remove(); + return; + } + if (!layer.isConnected) container.append(layer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + const space = coordinateSpace(); + const renderer = container.querySelector('svg') as SVGSVGElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const layout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + Object.assign(layer.style, { + left: `${layout.left}px`, top: `${layout.top}px`, + width: `${layout.width}px`, height: `${layout.height}px`, + }); + layer.setAttribute('viewBox', `0 0 ${space.logicalWidth} ${space.logicalHeight}`); + for (const { target, selected: pinned } of visible) { + const bounds = target.visualBounds!; + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('x', String(bounds.x1)); + rect.setAttribute('y', String(bounds.y1)); + rect.setAttribute('width', String(Math.max(0, bounds.x2 - bounds.x1))); + rect.setAttribute('height', String(Math.max(0, bounds.y2 - bounds.y1))); + rect.setAttribute('fill', pinned ? 'rgba(255,255,255,0.14)' : 'rgba(255,255,255,0.2)'); + rect.setAttribute('stroke', pinned ? 'rgba(32,38,44,0.68)' : 'rgba(32,38,44,0.48)'); + rect.setAttribute('stroke-width', pinned ? '1.25' : '1'); + rect.setAttribute('vector-effect', 'non-scaling-stroke'); + layer.append(rect); + } + }; + + return { render, destroy: () => layer.remove() }; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactions/presentation/overlay-icon-button.ts b/packages/flint-js/src/vegalite/interactions/presentation/overlay-icon-button.ts new file mode 100644 index 00000000..21fa7144 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/overlay-icon-button.ts @@ -0,0 +1,77 @@ +export interface OverlayIconButton { + element: HTMLButtonElement; + setVisible(visible: boolean): void; + destroy(): void; +} + +export interface OverlayIconButtonOptions { + container: HTMLElement; + label: string; + icon: string; + onActivate(): void; + zIndex?: number; +} + +export const RESET_ICON = [ + '', +].join(''); + +/** Shared unobtrusive control for chart-local actions. */ +export function createOverlayIconButton({ + container, + label, + icon, + onActivate, + zIndex = 5, +}: OverlayIconButtonOptions): OverlayIconButton { + const button = document.createElement('button'); + button.type = 'button'; + button.innerHTML = icon; + button.title = label; + button.setAttribute('aria-label', label); + Object.assign(button.style, { + position: 'absolute', zIndex: String(zIndex), width: '24px', height: '24px', padding: '5px', + display: 'none', alignItems: 'center', justifyContent: 'center', + border: '1px solid rgba(104, 117, 128, 0.2)', borderRadius: '5px', + background: 'rgba(255, 255, 255, 0.94)', boxShadow: '0 1px 3px rgba(30, 42, 50, 0.14)', + color: '#66727c', cursor: 'pointer', opacity: '1', + transition: 'color 120ms ease, background 120ms ease, border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease', + }); + button.addEventListener('pointerenter', () => { + button.style.color = '#2f6f62'; + button.style.background = '#ffffff'; + button.style.borderColor = 'rgba(47, 111, 98, 0.32)'; + button.style.boxShadow = '0 2px 5px rgba(30, 42, 50, 0.18)'; + }); + button.addEventListener('pointerleave', () => { + button.style.color = '#66727c'; + button.style.background = 'rgba(255, 255, 255, 0.94)'; + button.style.borderColor = 'rgba(104, 117, 128, 0.2)'; + button.style.boxShadow = '0 1px 3px rgba(30, 42, 50, 0.14)'; + button.style.transform = ''; + }); + button.addEventListener('pointerdown', () => { button.style.transform = 'scale(0.94)'; }); + button.addEventListener('pointerup', () => { button.style.transform = ''; }); + button.addEventListener('focus', () => { + button.style.outline = '2px solid rgba(47, 111, 98, 0.45)'; + button.style.outlineOffset = '2px'; + }); + button.addEventListener('blur', () => { button.style.outline = 'none'; }); + button.addEventListener('click', onActivate); + container.append(button); + + return { + element: button, + setVisible(visible): void { + button.hidden = !visible; + button.style.display = visible ? 'inline-flex' : 'none'; + }, + destroy(): void { + button.removeEventListener('click', onActivate); + button.remove(); + }, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/reorder-reset-controls.ts b/packages/flint-js/src/vegalite/interactions/presentation/reorder-reset-controls.ts new file mode 100644 index 00000000..5e1cbac2 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/reorder-reset-controls.ts @@ -0,0 +1,81 @@ +import type { VegaReorderAxis } from '../contracts'; +import { clientRectToLayoutRect } from '../hit-adapter'; +import { createOverlayIconButton, RESET_ICON } from './overlay-icon-button'; + +export interface ReorderResetControls { + layout(): void; + destroy(): void; +} + +export interface ReorderResetControlsOptions { + container: HTMLElement; + axes: readonly VegaReorderAxis[]; + containerLayoutSize(): { width: number; height: number }; + isActive(axis: VegaReorderAxis): boolean; + reset(axis: VegaReorderAxis): void; +} + +function axisTitle(renderer: SVGSVGElement, axis: 'x' | 'y'): SVGGraphicsElement | undefined { + const titleGroup = [...renderer.querySelectorAll('.mark-text.role-axis-title')] + .find((title) => title.closest('.mark-group.role-axis') + ?.getAttribute('aria-label')?.startsWith(`${axis.toUpperCase()}-axis`)); + // Vega's title mark group can inherit scenegraph bounds spanning much of + // the plot. Position controls from the actual glyph, not that outer group. + return titleGroup?.querySelector('text') ?? titleGroup; +} + +export function createReorderResetControls({ + container, + axes, + containerLayoutSize, + isActive, + reset, +}: ReorderResetControlsOptions): ReorderResetControls { + const previousPosition = container.style.position; + const controls = axes.map((axis) => { + const control = createOverlayIconButton({ + container, + label: `Reset ${axis.field} order`, + icon: RESET_ICON, + onActivate: () => reset(axis), + }); + return { axis, control }; + }); + + const layout = (): void => { + const renderer = container.querySelector('svg.marks') as SVGSVGElement | null; + if (!renderer) { + for (const { control } of controls) control.setVisible(false); + return; + } + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + const containerRect = container.getBoundingClientRect(); + const layoutSize = containerLayoutSize(); + for (const { axis, control } of controls) { + const button = control.element; + const title = axisTitle(renderer, axis.axis); + if (!title || !isActive(axis)) { + control.setVisible(false); + continue; + } + control.setVisible(true); + const titleRect = clientRectToLayoutRect(title.getBoundingClientRect(), containerRect, layoutSize); + if (axis.axis === 'x') { + button.style.left = `${titleRect.left + titleRect.width + 4}px`; + button.style.top = `${titleRect.top + (titleRect.height - 24) / 2}px`; + } else { + button.style.left = `${titleRect.left + (titleRect.width - 24) / 2}px`; + button.style.top = `${titleRect.top - 26}px`; + } + } + }; + + layout(); + return { + layout, + destroy(): void { + for (const { control } of controls) control.destroy(); + container.style.position = previousPosition; + }, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/target-feedback-overlay.ts b/packages/flint-js/src/vegalite/interactions/presentation/target-feedback-overlay.ts new file mode 100644 index 00000000..92eaa6f9 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/target-feedback-overlay.ts @@ -0,0 +1,159 @@ +import type { SemanticTarget } from '../../../core/interaction-contracts'; +import type { TargetFeedbackOptions } from '../../../interactive/types'; +import { withoutSemanticInteractionField } from '../compile'; +import { clientRectToLayoutRect, type RendererCoordinateSpace } from '../hit-adapter'; + +export interface TargetFeedbackOverlayController { + render(item: any, target: SemanticTarget | null, source: 'assisted' | 'keyboard'): void; + clear(): void; + destroy(): void; +} + +export function targetFeedbackPoint(item: any): { x: number; y: number } | null { + if (!item?.bounds) return null; + if (item.mark?.marktype === 'arc' + && [item.x, item.y, item.innerRadius, item.outerRadius, item.startAngle, item.endAngle] + .every((value) => typeof value === 'number' && Number.isFinite(value))) { + const angle = (item.startAngle + item.endAngle) / 2; + const radius = (item.innerRadius + item.outerRadius) / 2; + return { + x: item.x + radius * Math.sin(angle), + y: item.y - radius * Math.cos(angle), + }; + } + return { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }; +} + +export function targetFeedbackDetailsPosition( + anchor: { x: number; y: number }, + size: { width: number; height: number }, + viewport: { width: number; height: number }, +): { left: number; top: number } { + const gap = 14; + const margin = 8; + const left = anchor.x + gap + size.width <= viewport.width - margin + ? anchor.x + gap + : Math.max(margin, anchor.x - gap - size.width); + const top = anchor.y + gap + size.height <= viewport.height - margin + ? anchor.y + gap + : Math.max(margin, anchor.y - gap - size.height); + return { left, top }; +} + +export function targetFeedbackEntries( + item: any, + fallback: Record, +): [string, unknown][] { + const value = withoutSemanticInteractionField(item?.tooltip ?? fallback); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return value === undefined || value === null ? [] : [['Value', value]]; + } + return Object.entries(value as Record); +} + +export function createTargetFeedbackOverlay(options: { + container: HTMLElement; + feedback: TargetFeedbackOptions; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; +}): TargetFeedbackOverlayController { + const { container, feedback, coordinateSpace, containerLayoutSize } = options; + const layer = document.createElement('div'); + const indicator = document.createElement('div'); + const details = document.createElement('div'); + layer.dataset.flintTargetFeedback = ''; + indicator.dataset.flintTargetIndicator = ''; + details.dataset.flintTargetDetails = ''; + details.setAttribute('role', 'status'); + details.setAttribute('aria-live', 'polite'); + Object.assign(layer.style, { + position: 'absolute', inset: '0', zIndex: '4', pointerEvents: 'none', overflow: 'visible', + }); + Object.assign(indicator.style, { + position: 'absolute', width: '14px', height: '14px', border: '2px solid #20262c', + borderRadius: '50%', background: 'rgba(255,255,255,0.72)', boxSizing: 'border-box', + transform: 'translate(-50%, -50%)', boxShadow: '0 0 0 2px rgba(255,255,255,0.78)', + }); + Object.assign(details.style, { + position: 'absolute', zIndex: '1000', padding: '8px', border: '1px solid #d9d9d9', + borderRadius: '3px', background: 'rgba(255,255,255,0.95)', color: '#000', + font: '11px sans-serif', boxShadow: '2px 2px 4px rgba(0,0,0,0.1)', + }); + layer.append(indicator); + + const clear = (): void => { + layer.remove(); + details.remove(); + }; + const render = (item: any, target: SemanticTarget | null, source: 'assisted' | 'keyboard'): void => { + const element = target?.elements[0]; + const point = targetFeedbackPoint(item); + if (!point || !element) { + clear(); + return; + } + document.querySelectorAll('[data-flint-target-feedback], [data-flint-target-details]') + .forEach((node) => { + if (node !== layer && node !== details) node.remove(); + }); + if (!layer.isConnected) container.append(layer); + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + const space = coordinateSpace(); + const renderer = container.querySelector('svg, canvas') as HTMLElement | null; + const containerRect = container.getBoundingClientRect(); + const rendererRect = renderer?.getBoundingClientRect() ?? space.rect; + const rendererLayout = clientRectToLayoutRect(rendererRect, containerRect, containerLayoutSize()); + const scaleX = rendererLayout.width / space.logicalWidth; + const scaleY = rendererLayout.height / space.logicalHeight; + const centerX = rendererLayout.left + (point.x + space.originX) * scaleX; + const centerY = rendererLayout.top + (point.y + space.originY) * scaleY; + const clientX = containerRect.left + centerX; + const clientY = containerRect.top + centerY; + indicator.style.display = feedback.indicator === false ? 'none' : 'block'; + indicator.style.left = `${centerX}px`; + indicator.style.top = `${centerY}px`; + indicator.style.borderStyle = source === 'keyboard' ? 'solid' : 'dashed'; + + const detailsOptions = typeof feedback.details === 'object' ? feedback.details : {}; + const showDetails = feedback.details !== false; + details.style.display = showDetails ? 'block' : 'none'; + if (!showDetails) { + details.remove(); + return; + } + const entries = targetFeedbackEntries(item, element.value) + .filter(([field]) => !detailsOptions.fields || detailsOptions.fields.includes(field)) + .slice(0, detailsOptions.maxRows ?? 4); + details.replaceChildren(...entries.map(([field, value]) => { + const row = document.createElement('div'); + const label = document.createElement('span'); + const content = document.createElement('span'); + Object.assign(row.style, { + display: 'grid', gridTemplateColumns: 'max-content 1fr', columnGap: '4px', alignItems: 'baseline', + padding: '2px 0', + }); + Object.assign(label.style, { color: '#808080', maxWidth: '150px', textAlign: 'right' }); + Object.assign(content.style, { + display: 'block', maxWidth: '300px', maxHeight: '7em', overflow: 'hidden', textOverflow: 'ellipsis', + }); + label.textContent = field; + content.textContent = String(value); + row.append(label, content); + return row; + })); + if (!details.isConnected) document.body.append(details); + const detailsRect = details.getBoundingClientRect(); + const position = targetFeedbackDetailsPosition( + { x: clientX, y: clientY }, + { width: detailsRect.width, height: detailsRect.height }, + { width: window.innerWidth, height: window.innerHeight }, + ); + details.style.left = `${position.left + window.scrollX}px`; + details.style.top = `${position.top + window.scrollY}px`; + }; + + return { render, clear, destroy: clear }; +} diff --git a/packages/flint-js/src/vegalite/interactions/presentation/viewport-reset-control.ts b/packages/flint-js/src/vegalite/interactions/presentation/viewport-reset-control.ts new file mode 100644 index 00000000..8fd6447e --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/presentation/viewport-reset-control.ts @@ -0,0 +1,62 @@ +import type { RendererCoordinateSpace } from '../hit-adapter'; +import { clientToLayoutPoint } from '../../../interactive/geometry/coordinate-space'; +import { createOverlayIconButton, RESET_ICON } from './overlay-icon-button'; + +export interface ViewportResetControl { + layout(): void; + destroy(): void; +} + +export interface ViewportResetControlOptions { + container: HTMLElement; + coordinateSpace(): RendererCoordinateSpace; + containerLayoutSize(): { width: number; height: number }; + isActive(): boolean; + reset(): void; +} + +export function createViewportResetControl({ + container, + coordinateSpace, + containerLayoutSize, + isActive, + reset, +}: ViewportResetControlOptions): ViewportResetControl { + const previousPosition = container.style.position; + const control = createOverlayIconButton({ + container, + label: 'Reset zoom', + icon: RESET_ICON, + onActivate: reset, + zIndex: 6, + }); + const { element: button } = control; + + const layout = (): void => { + if (!isActive()) { + control.setVisible(false); + return; + } + if (getComputedStyle(container).position === 'static') container.style.position = 'relative'; + control.setVisible(true); + const space = coordinateSpace(); + const containerRect = container.getBoundingClientRect(); + const scaleX = space.rect.width / space.logicalWidth; + const scaleY = space.rect.height / space.logicalHeight; + const plotTopRight = clientToLayoutPoint({ + x: space.rect.left + (space.originX + space.plotWidth) * scaleX, + y: space.rect.top + space.originY * scaleY, + }, containerRect, containerLayoutSize()); + button.style.left = `${plotTopRight.x - 34}px`; + button.style.top = `${plotTopRight.y + 6}px`; + }; + + layout(); + return { + layout, + destroy(): void { + control.destroy(); + container.style.position = previousPosition; + }, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/runtime.ts b/packages/flint-js/src/vegalite/interactions/runtime.ts new file mode 100644 index 00000000..c4fe1d9f --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/runtime.ts @@ -0,0 +1,2452 @@ +import { changeset } from 'vega'; +import { + associateSemanticElementRenderKeys, + type AxisTargetValue, + semanticElementRenderKeys, + sourceRecordsForRenderedRecords, + type ChartInteractionResolver, + type LegendTargetValue, + type SemanticResolveContext, +} from '../../core/interaction-semantics'; +import type { + CanvasInteractionDef, + ChartOverlaySpec, + ChartUpdate, + ChartUpdateOp, + ChartUpdatePresenter, + FlintInteractionEventDetail, + InteractionDef, + NavigationInteractionEvent, + RenderHit, + SemanticElement, + SemanticTarget, + SemanticInteractionEvent, +} from '../../interactive/interactions'; +import { isCanvasInteraction } from '../../interactive/interactions'; +import { + affordanceCursor, + resolveInteractionAffordance, + type InteractionAffordanceTarget, +} from '../../interactive/affordances'; +import type { + ChartUpdateResult, + SemanticTargetRef, + UpdateTarget, +} from '../../interactive/language/updates'; +import { matchesSemanticTargetSelector } from '../../interactive/language/updates'; +import type { VegaInteractionPlan, VegaReorderAxis } from './contracts'; +import { toCanvasInteractionEvent } from '../../interactive/canvas-interaction'; +import { keyboardTrigger } from '../../interactive/triggers'; +import { normalizeInspectGuideOptions } from '../../interactive/guides'; +import { wheelZoomFactor } from '../../interactive/gestures/navigation'; +import type { CanvasInteractionEvent, DomainGeometry } from '../../interactive/language/events'; +import type { ChartUpdateApplyOptions } from '../../interactive/types'; +import { + INTERACTION_KEY, + PATH_KEY_SUFFIX, + axisItemAt, + axisItems, + axisTargetIdentity, + clientToPlotPoint, + clientToRendererPoint, + interactionModifiers, + normalizeVegaElementEvent, + nearestInteractiveSceneItem, + nearestSceneItem, + nextItemInDirection, + pathHoverPresentationKey, + polarFrameFromItems, + polarGuideSegment, + polarInspectHits, + tolerantInspectHits, + indexInspectAcquisition, + legendSemanticTarget, + renderHit, + rendererPlotOrigin, + sceneItems, + type RendererCoordinateSpace, + type LegendHitIdentity, + type SpatialDirection, +} from './hit-adapter'; +import { isInteractiveControlTarget, mountVegaRegionGesture } from './gestures/region'; +import { mountVegaNavigationGesture } from './gestures/navigation'; +import { createVegaNavigationController } from './navigation-scale'; +import { createAnnotationOverlay } from './presentation/annotation-overlay'; +import { + createDragReorderOverlay, + eligibleReorderAxesForAxis, + eligibleReorderAxesForHit, +} from './presentation/drag-reorder-overlay'; +import { createFocusOverlay } from './presentation/focus-overlay'; +import { createFreeformOverlay } from './presentation/freeform-overlay'; +import { createTargetFeedbackOverlay } from './presentation/target-feedback-overlay'; +import { createLegendRangeOverlay } from './presentation/legend-range-overlay'; +import { createReorderResetControls } from './presentation/reorder-reset-controls'; +import { createViewportResetControl } from './presentation/viewport-reset-control'; +import { createInspectGuideOverlay } from './presentation/inspect-guide-overlay'; +import { createDataOverlay } from './presentation/data-overlay'; +import { + HIDDEN_STORE, + LEGEND_HIDDEN_STORE, + HOVER_STORE, + INTERACTION_STORE, + LEGEND_HOVER_STORE, + AXIS_HOVER_STORE, + LEGEND_SELECTION_STORE, + STYLE_SIGNAL, +} from './stores'; + +const EMPTY_SEMANTIC_SELECTION_KEY = '__flint_empty_semantic_selection'; + +export { mergeContiguousSelectionBounds } from './presentation/focus-overlay'; + +export function resolveLegendPresentationTarget( + legend: LegendTargetValue, + resolve: ChartInteractionResolver, + context: SemanticResolveContext, +): SemanticTarget { + const resolved = resolve({ + gesture: 'click', role: 'legend-item', hits: [], legend, + }, context); + if (resolved) return resolved; + return { + visual: { kind: 'legend', role: 'legend-item' }, + elements: [associateSemanticElementRenderKeys( + { value: legend }, + [EMPTY_SEMANTIC_SELECTION_KEY], + )], + }; +} + +function legendDomainIdentity(legend: LegendTargetValue): string { + return JSON.stringify([legend.channel, legend.field, legend.domain]); +} + +export function resolveRetainedLegendPresentationTarget( + legend: LegendTargetValue, + resolve: ChartInteractionResolver, + context: SemanticResolveContext, + retained: Map, +): SemanticTarget { + const identity = legendDomainIdentity(legend); + const resolved = resolveLegendPresentationTarget(legend, resolve, context); + const hasConcreteKeys = resolved.elements.some((element) => + semanticElementRenderKeys(element).some((key) => key !== EMPTY_SEMANTIC_SELECTION_KEY)); + if (hasConcreteKeys) { + retained.set(identity, resolved); + return resolved; + } + return retained.get(identity) ?? resolved; +} + +export function resolveRetainedLegendPresentationTargets( + legends: readonly LegendTargetValue[], + resolve: ChartInteractionResolver, + context: SemanticResolveContext, + retained: Map, +): SemanticTarget { + return { + visual: { kind: 'legend', role: 'legend-item' }, + elements: legends.flatMap((legend) => + resolveRetainedLegendPresentationTarget(legend, resolve, context, retained).elements), + }; +} + +export function resolvedLegendInteractionTarget( + legend: LegendTargetValue, + resolved: SemanticTarget | null, +): SemanticTarget { + const records = [...new Set(resolved?.elements.flatMap((element) => element.records ?? []) ?? [])]; + const renderKeys = resolved?.elements.flatMap(semanticElementRenderKeys) ?? []; + return { + visual: { kind: 'legend', role: 'legend-item' }, + elements: [associateSemanticElementRenderKeys({ + value: legend, + ...(records.length > 0 ? { records } : {}), + }, renderKeys.length > 0 ? renderKeys : [EMPTY_SEMANTIC_SELECTION_KEY])], + }; +} + +export function resolveSupportedOperation( + op: ChartUpdateOp, + plan: Pick, +): { op: ChartUpdateOp | null; unsupported: boolean } { + if (op.op === 'set-viewport') { + const requestedAxes = op.axes === 'xy' ? ['x', 'y'] as const : [op.axes]; + const supportedAxes = requestedAxes.filter((axis) => plan.navigationAxes?.[axis]); + if (supportedAxes.length === 0) return { op: null, unsupported: true }; + const axes = supportedAxes.length === 2 ? 'xy' : supportedAxes[0]; + return { + op: { + ...op, + axes, + value: Object.fromEntries(supportedAxes + .filter((axis) => op.value[axis] !== undefined) + .map((axis) => [axis, op.value[axis]])), + }, + unsupported: supportedAxes.length < requestedAxes.length, + }; + } + if (op.op === 'set-order') { + const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + const supported = op.scope === 'category' + && reorderAxes.some((axis) => axis.field === op.field); + return { op: supported ? op : null, unsupported: !supported }; + } + return { op, unsupported: false }; +} + +export function domainForPlotGeometry( + plot: CanvasInteractionEvent['geometry']['plot'], + axes: VegaInteractionPlan['navigationAxes'], + scaleFor: (name: string) => { + invert?(value: number): unknown; + } | undefined, +): DomainGeometry | undefined { + if (!plot || plot.kind !== 'rect') return undefined; + const domain: DomainGeometry = {}; + for (const axis of ['x', 'y'] as const) { + const config = axes?.[axis]; + if (!config) continue; + const scale = scaleFor(config.scale); + if (typeof scale?.invert !== 'function') continue; + const lower = axis === 'x' ? plot.rect.x : plot.rect.y; + const upper = lower + (axis === 'x' ? plot.rect.width : plot.rect.height); + const lowerValue = scale.invert(lower); + const upperValue = scale.invert(upper); + const start = axis === 'y' ? upperValue : lowerValue; + const end = axis === 'y' ? lowerValue : upperValue; + if (start === undefined || end === undefined) continue; + domain[axis] = { kind: 'interval', start, end }; + } + return domain.x || domain.y ? domain : undefined; +} + +export function nearestReorderHit( + items: readonly any[], + axis: 'x' | 'y', + field: string, + coordinate: number, +): RenderHit | null { + const start = axis === 'x' ? 'x1' : 'y1'; + const end = axis === 'x' ? 'x2' : 'y2'; + const slots = new Map(); + for (const item of items) { + const hit = renderHit(item); + const value = hit?.datum[field]; + if (!hit || value === undefined || !item.bounds || slots.has(value)) continue; + slots.set(value, { hit, center: (item.bounds[start] + item.bounds[end]) / 2 }); + } + let nearest: { hit: RenderHit; distance: number } | undefined; + for (const slot of slots.values()) { + const distance = Math.abs(coordinate - slot.center); + if (!nearest || distance < nearest.distance) nearest = { hit: slot.hit, distance }; + } + return nearest?.hit ?? null; +} + +/** Outer boundary of the visual primitives that represent one categorical slot. */ +export function reorderProjectionBounds( + items: readonly any[], + axis: Pick, + value: unknown, +): { x1: number; y1: number; x2: number; y2: number } | undefined { + const candidates = items.filter((item) => { + const hit = renderHit(item); + return item.bounds && hit && Object.is(hit.datum[axis.field], value) + && (!axis.markTypes || axis.markTypes.includes(item.mark?.marktype)); + }); + // Bars define their slot more authoritatively than decorative dots or + // target symbols. For grouped bars, combine every bar in the category. + const bars = candidates.filter((item) => item.mark?.marktype === 'rect'); + const owned = bars.length > 0 ? bars : candidates; + if (owned.length === 0) return undefined; + return owned.reduce((bounds, item) => ({ + x1: Math.min(bounds.x1, item.bounds.x1), + y1: Math.min(bounds.y1, item.bounds.y1), + x2: Math.max(bounds.x2, item.bounds.x2), + y2: Math.max(bounds.y2, item.bounds.y2), + }), { x1: Infinity, y1: Infinity, x2: -Infinity, y2: -Infinity }); +} + +function previewOpIdentity(op: ChartUpdateOp): string { + if (op.op === 'set-order') return `${op.op}:${op.scope}:${op.field}`; + if (op.op === 'set-overlay') return `${op.op}:${op.name}`; + if (op.op === 'set-freeform-overlay') return `${op.op}:${op.name}`; + if (op.op === 'set-data') return `${op.op}:${op.source}`; + // Styles, annotations, and viewports are complete presentations for one + // interaction. A preview of the same kind replaces its retained version. + return op.op; +} + +/** Overlay a live preview on retained state, replacing only operations it updates. */ +export function mergeRetainedPreview( + retained: ChartUpdate | undefined, + preview: ChartUpdate | undefined, +): ChartUpdate | undefined { + if (!preview) return retained; + if (!retained) return preview; + const replaced = new Set(preview.ops.map(previewOpIdentity)); + return { + id: preview.id, + ops: [ + ...retained.ops.filter((op) => !replaced.has(previewOpIdentity(op))), + ...preview.ops, + ], + }; +} + +export interface VegaInteractionController { + getInteractionContext(): import('../../interactive/interactions').InteractionContext; + applyUpdate(update: ChartUpdate, options?: ChartUpdateApplyOptions): Promise; + setUpdates(updates: readonly ChartUpdate[]): Promise; + clearUpdate(id: string): Promise; + refresh(): void; + destroy(): void; +} + +export function interactionsForHoverPresentation( + clickInteractions: readonly CanvasInteractionDef[], + hoverInteractions: readonly CanvasInteractionDef[], + elementDragInteractions: readonly CanvasInteractionDef[] = [], + inspectInteractions: readonly CanvasInteractionDef[] = [], +): CanvasInteractionDef[] { + return [ + ...hoverInteractions, + ...clickInteractions, + ...elementDragInteractions, + ...inspectInteractions, + ].filter((interaction, index, candidates) => interaction.affordances?.some((affordance) => affordance.hover) + && candidates.findIndex((candidate) => candidate.id === interaction.id) === index); +} + +export function initialInspectSeries( + items: readonly any[], + seriesBy: string, + preferred?: unknown, +): unknown { + const values = items.flatMap((item) => item?.datum?.[seriesBy] === undefined + ? [] + : [item.datum[seriesBy]]); + return preferred !== undefined && values.some((value) => Object.is(value, preferred)) + ? preferred + : values[0]; +} + +/** Visual identity used to avoid reapplying an unchanged inspect emphasis. */ +export function inspectEmphasisSignature( + target: SemanticTarget | null, + modifiers: SemanticInteractionEvent['modifiers'], +): string { + const modifierKey = `${modifiers?.shift ? 1 : 0}${modifiers?.ctrl ? 1 : 0}${modifiers?.meta ? 1 : 0}`; + if (!target) return `none\u0000${modifierKey}`; + const keys = target.elements.flatMap((element) => { + const renderKeys = semanticElementRenderKeys(element); + return renderKeys.length > 0 ? renderKeys : [JSON.stringify(element.value)]; + }).sort(); + return `${target.visual.kind}\u0000${target.visual.role}\u0000${keys.join('\u0000')}\u0000${modifierKey}`; +} + +export function inspectSeriesPresentationKeys( + items: readonly any[], + seriesBy: string, + series: unknown, +): string[] { + return [...new Set(items.flatMap((item) => { + if (!Object.is(item?.datum?.[seriesBy], series)) return []; + const hit = renderHit(item); + const key = hit?.datum?.[INTERACTION_KEY]; + return typeof key === 'string' ? [key] : []; + }))]; +} + +export function longPressMovedBeyond( + start: { x: number; y: number }, + current: { x: number; y: number }, + tolerance = 6, +): boolean { + return Math.hypot(current.x - start.x, current.y - start.y) > tolerance; +} + +type AnnotationUpdate = Extract; + +export interface EffectiveAnnotationEntry { + key: string; + element: SemanticTarget['elements'][number]; + value: NonNullable; +} + +export function effectiveAnnotationEntries(updates: readonly ChartUpdate[]): EffectiveAnnotationEntry[] { + const entries = new Map(); + for (const update of updates) { + for (const op of update.ops) { + if (op.op !== 'set-annotation' || 'select' in op.target) continue; + const element = op.target.elements[0]; + if (!element) continue; + const renderKeys = semanticElementRenderKeys(element); + const targetIdentity = renderKeys.length > 0 + ? renderKeys.join('\u001f') + : JSON.stringify([element.value, element.records ?? []]); + const key = `${update.id}\u001e${targetIdentity}`; + if (op.value === null) entries.delete(key); + else entries.set(key, { key, element, value: op.value }); + } + } + return [...entries.values()]; +} + +function keyboardRepresentativeRank(item: any): [number, number] { + const markType = item?.mark?.marktype; + const width = Math.max(0, (item?.bounds?.x2 ?? 0) - (item?.bounds?.x1 ?? 0)); + const height = Math.max(0, (item?.bounds?.y2 ?? 0) - (item?.bounds?.y1 ?? 0)); + const markRank = markType === 'rect' ? 3 : markType === 'arc' ? 2 : markType === 'rule' ? 0 : 1; + return [markRank, width * height]; +} + +export function keyboardTargetItems(scene: readonly any[]): any[] { + const itemsByKey = new Map(); + for (const item of scene) { + const key = renderHit(item)?.datum[INTERACTION_KEY]; + if (typeof key !== 'string' || !item.bounds) continue; + const existing = itemsByKey.get(key); + if (!existing) { + itemsByKey.set(key, item); + continue; + } + const [rank, area] = keyboardRepresentativeRank(item); + const [existingRank, existingArea] = keyboardRepresentativeRank(existing); + if (rank > existingRank || (rank === existingRank && area > existingArea)) { + itemsByKey.set(key, item); + } + } + return [...itemsByKey.values()].sort((left, right) => + (left.bounds.x1 - right.bounds.x1) || (left.bounds.y1 - right.bounds.y1)); +} + +export function enrichTargetWithSourceProvenance( + target: SemanticTarget | null, + plan: Pick, +): SemanticTarget | null { + if (!target) return null; + const elements = target.elements.map((element) => { + const renderedRecords = element.records?.length ? element.records : [element.value]; + const records = sourceRecordsForRenderedRecords( + renderedRecords, + plan.sourceRecords, + plan.provenanceFields, + plan.temporalProvenanceFields, + plan.rangeProvenance, + ); + const value = plan.rangeProvenance.length > 0 + ? { ...element.value, count: records.length } + : element.value; + const publicElement = { + value, + ...(records.length > 0 ? { records } : {}), + }; + return associateSemanticElementRenderKeys(publicElement, semanticElementRenderKeys(element)); + }); + return { ...target, elements }; +} + +const ASSISTED_GESTURES = new Set(['click', 'hover', 'context', 'long-press', 'double']); + +export function resolveAssistDistance( + interactions: readonly CanvasInteractionDef[], + override?: number, +): number { + const eligible = interactions.filter((interaction) => + interaction.eventSource.type === 'element' + && ASSISTED_GESTURES.has(interaction.eventSource.gesture ?? '')); + if (eligible.length === 0) return 0; + return override ?? Math.max(0, ...eligible.map((interaction) => + interaction.eventSource.defaultAssistDistance ?? 0)); +} + +export function evictRetainedStateSiblings( + interaction: CanvasInteractionDef, + interactions: readonly CanvasInteractionDef[], + retained: Map, + preview: Map, +): CanvasInteractionDef[] { + if (!interaction.retainedStateGroup) return []; + const siblings = interactions.filter((candidate) => candidate.id !== interaction.id + && candidate.retainedStateGroup === interaction.retainedStateGroup); + for (const sibling of siblings) { + retained.delete(sibling.id); + preview.delete(sibling.id); + } + return siblings; +} + +export function mountVegaInteractions( + view: any, + container: HTMLElement, + chartType: string, + plan: VegaInteractionPlan, + interactions: readonly InteractionDef[], + resolve: ChartInteractionResolver | undefined, + presentUpdate: ChartUpdatePresenter, + assistDistance: number | undefined = undefined, + hoverTolerance = 0, + keyboardTargeting = false, + targetFeedback: { + assisted: import('../../interactive/types').TargetFeedbackOptions | false; + keyboard: import('../../interactive/types').TargetFeedbackOptions | false; + } | undefined = undefined, + dismiss: import('../../interactive/types').InteractionDismissPolicy | false | undefined = undefined, +): VegaInteractionController { + const canvasInteractions = interactions.filter(isCanvasInteraction); + const clickInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'click') + : []; + const hoverInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'hover') + : []; + const axisClickInteractions = clickInteractions.filter((interaction) => interaction.claimsAxisActivation); + const markClickInteractions = clickInteractions.filter((interaction) => + resolveInteractionAffordance([interaction], 'mark') + || resolveInteractionAffordance([interaction], 'legend-item')); + const axisHoverInteractions = hoverInteractions.filter((interaction) => interaction.claimsAxisActivation); + const markHoverInteractions = hoverInteractions.filter((interaction) => !interaction.claimsAxisActivation); + const axisHoverPresentationInteractions = [...axisClickInteractions, ...axisHoverInteractions] + .filter((interaction) => interaction.affordances?.some((affordance) => + affordance.target === 'axis-label' && affordance.hover)); + const contextInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'context') + : []; + const inspectInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'inspect') + : []; + const longPressInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'long-press') + : []; + const doubleInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'double') + : []; + const elementDragInteractions = resolve + ? canvasInteractions.filter((interaction) => interaction.eventSource.type === 'element' + && interaction.eventSource.gesture === 'drag') + : []; + const hoverPresentationInteractions = interactionsForHoverPresentation( + [...markClickInteractions, ...longPressInteractions, ...doubleInteractions], + markHoverInteractions, + elementDragInteractions, + inspectInteractions, + ); + const hoverPresentationForTarget = (target: InteractionAffordanceTarget): CanvasInteractionDef[] => + hoverPresentationInteractions.filter((interaction) => + resolveInteractionAffordance([interaction], target)?.hover); + const regionInteraction = resolve + ? canvasInteractions.find((interaction) => interaction.eventSource.type === 'region' + && interaction.eventSource.gesture === 'drag') + : undefined; + const navigationInteraction = canvasInteractions.find( + (interaction) => interaction.eventSource.type === 'navigation', + ); + const elementDragInteraction = elementDragInteractions[0]; + const assistDistanceFor = (eligible: readonly CanvasInteractionDef[]): number => + resolveAssistDistance(eligible, assistDistance); + const retainedUpdates = new Map(); + const previewUpdates = new Map(); + const selectedElements = new Map(); + const hiddenKeys = new Set(); + const retainedLegendTargets = new Map(); + let selectedLegend: LegendHitIdentity | null = null; + let hoveredLegend: LegendHitIdentity | null = null; + let hoveredPathKeys = new Set(); + let suppressClick = false; + let regionDragging = false; + const inspectSeriesLocks = new Map(); + const inspectSeriesPresentation = new Map>(); + + const containerLayoutSize = (): { width: number; height: number } => { + const rect = container.getBoundingClientRect(); + return { + width: container.offsetWidth || rect.width, + height: container.offsetHeight || rect.height, + }; + }; + + const coordinateSpace = (): RendererCoordinateSpace => { + const renderer = container.querySelector('canvas, svg') as HTMLElement | null; + const rect = (renderer ?? container).getBoundingClientRect(); + const [viewOriginX, viewOriginY] = view.origin(); + const svg = renderer instanceof SVGSVGElement ? renderer : undefined; + // SVG autosize/padding can make View#origin differ from the renderer's + // final plot translation. The rendered root-frame CTM is authoritative. + const rootFrame = svg?.querySelector('.mark-group.role-frame.root'); + const rootMatrix = rootFrame?.getCTM(); + const logicalWidth = svg?.viewBox.baseVal.width || rect.width; + const logicalHeight = svg?.viewBox.baseVal.height || rect.height; + const origin = rendererPlotOrigin(rootMatrix, { x: viewOriginX, y: viewOriginY }); + const originX = origin.x; + const originY = origin.y; + const viewWidth = view.width(); + const viewHeight = view.height(); + return { + rect, + logicalWidth, + logicalHeight, + originX, + originY, + plotWidth: viewWidth > 0 ? viewWidth : Math.max(0, logicalWidth - originX), + plotHeight: viewHeight > 0 ? viewHeight : Math.max(0, logicalHeight - originY), + }; + }; + + const focusOverlay = createFocusOverlay({ view, container, plan, coordinateSpace, containerLayoutSize }); + const targetFeedbackOverlay = createTargetFeedbackOverlay({ + container, + feedback: targetFeedback?.assisted || targetFeedback?.keyboard || {}, + coordinateSpace, + containerLayoutSize, + }); + const legendRangeOverlay = createLegendRangeOverlay({ container, coordinateSpace, containerLayoutSize }); + const annotationOverlayOptions = { + view, + container, + coordinateSpace, + containerLayoutSize, + annotationMarkType: plan.annotationMarkType, + }; + const annotationOverlays = new Map>(); + const clearAnnotations = (): void => { + for (const overlay of annotationOverlays.values()) overlay.clear(); + }; + const inspectGuideOverlay = createInspectGuideOverlay({ + view, + container, + coordinateSpace, + containerLayoutSize, + }); + const dragPreviewOverlay = createDragReorderOverlay({ + view, container, + reorderAxes: plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), + axisTargets: plan.axisTargets, + coordinateSpace, containerLayoutSize, + }); + const freeformOverlay = createFreeformOverlay({ + container, coordinateSpace, containerLayoutSize, + }); + const styledAxisElements = new Map>(); + const restoreAxisStyles = (): void => { + for (const [element, attributes] of styledAxisElements) { + for (const [name, value] of attributes) { + if (value === null) element.removeAttribute(name); + else element.setAttribute(name, value); + } + } + styledAxisElements.clear(); + }; + const dataOverlay = createDataOverlay({ + view, container, scales: plan.overlayScales ?? {}, coordinateSpace, containerLayoutSize, + }); + const initialDataRows = plan.initialDataRows ?? plan.sourceRecords; + let renderedDataRows: readonly Record[] = initialDataRows; + const reorderResetControls = createReorderResetControls({ + container, + axes: plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), + containerLayoutSize, + isActive: (axis) => [retainedUpdates, previewUpdates].some((layer) => + [...layer.values()].some((update) => update.ops.some((op) => + op.op === 'set-order' + && op.scope === 'category' + && op.field === axis.field))), + reset: (axis) => { + for (const layer of [retainedUpdates, previewUpdates]) { + for (const [id, update] of layer) { + const ops = update.ops.filter((op) => + op.op !== 'set-order' || op.scope !== 'category' || op.field !== axis.field); + if (ops.length > 0) layer.set(id, { id, ops }); + else layer.delete(id); + } + } + void renderUpdates(); + }, + }); + const resetViewportRegion = (): void => { + if (!regionInteraction?.eventSource.viewport) return; + retainedUpdates.delete(regionInteraction.id); + previewUpdates.delete(regionInteraction.id); + void renderUpdates(); + }; + const viewportResetControl = createViewportResetControl({ + container, + coordinateSpace, + containerLayoutSize, + isActive: () => Boolean(regionInteraction?.eventSource.viewport + && [retainedUpdates, previewUpdates].some((layer) => + layer.get(regionInteraction.id)?.ops.some((op) => op.op === 'set-viewport'))), + reset: resetViewportRegion, + }); + const navigationController = createVegaNavigationController(view, plan.navigationAxes ?? {}); + const selectedKeys = (): Set => new Set(selectedElements.keys()); + const renderPathFocus = (): void => focusOverlay.render(selectedKeys(), hoveredPathKeys); + const renderLegendRange = (): void => legendRangeOverlay.render(selectedLegend, hoveredLegend); + renderPathFocus(); + + const allHits = (): RenderHit[] => sceneItems(view) + .map(renderHit) + .filter((hit): hit is RenderHit => hit !== null); + const resolveContext = (hits: readonly RenderHit[]) => ({ + allHits: hits, + keyField: INTERACTION_KEY, + categoryField: plan.categoryField, + seriesField: plan.seriesField, + }); + const withSourceProvenance = (target: SemanticTarget | null): SemanticTarget | null => + enrichTargetWithSourceProvenance(target, plan); + const selectedForInteraction = (interaction: CanvasInteractionDef): SemanticElement[] => { + if (!interaction.retainedStateGroup) return [...selectedElements.values()]; + const keys = new Set(); + const update = mergeRetainedPreview( + retainedUpdates.get(interaction.id), + previewUpdates.get(interaction.id), + ); + if (update) { + for (const op of update.ops) { + if (op.op !== 'set-style' + || (op.value.state !== 'emphasized' && op.value.state !== 'focused')) continue; + for (const target of op.targets) { + if ('select' in target) continue; + for (const element of target.elements) { + for (const key of semanticElementRenderKeys(element)) keys.add(key); + } + } + } + } + return [...selectedElements].flatMap(([key, element]) => keys.has(key) ? [element] : []); + }; + const context = (includeAvailable = true, interaction?: CanvasInteractionDef) => { + // Navigation resolves per gesture frame, so the scenegraph scan stays behind this flag. + const available = includeAvailable + ? (() => { + const hits = allHits(); + return withSourceProvenance(resolve?.( + { gesture: 'rectangle', role: 'region', hits }, + resolveContext(hits), + ) ?? null)?.elements; + })() + : undefined; + const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + const currentReorderAxes = reorderAxes.map((axis) => { + const signaledOrder = view.signal(axis.signal); + return { + axis: axis.axis, + field: axis.field, + order: Array.isArray(signaledOrder) ? signaledOrder : view.scale(axis.scale).domain(), + }; + }); + const reorderAxis = currentReorderAxes[0]; + const categoryOrder = reorderAxis + ? reorderAxis.order + : undefined; + const legendDomains = Object.fromEntries(Object.entries(plan.legendFields ?? {}).map(([channel, field]) => [ + channel, + [...new Set(plan.sourceRecords + .map((record) => record[field]) + .filter((value) => value !== undefined))], + ])); + return { + chartType, + selected: interaction ? selectedForInteraction(interaction) : [...selectedElements.values()], + available, + resolveGroupValue: plan.resolveGroupValue, + resolveNavigation: navigationController.resolve, + categoryField: plan.categoryField, + seriesField: plan.seriesField, + legendDomains, + categoryAxis: reorderAxis?.axis, + categoryOrder, + reorderAxes: currentReorderAxes, + }; + }; + const resolveUpdateTarget = (target: UpdateTarget): SemanticTargetRef | null => { + if (!('select' in target)) { + if (target.visual.kind === 'axis') { + const elements = target.elements.filter((element) => { + const value = element.value as AxisTargetValue; + return Object.values(plan.axisTargets ?? {}).some((axisTarget) => + axisTarget.axis === value.axis && axisTarget.field === value.field); + }); + return elements.length > 0 ? { ...target, elements } : null; + } + if (target.visual.kind === 'legend') { + if (!resolve) return null; + const hits = allHits(); + const legends = target.elements + .map((element) => element.value as LegendTargetValue) + .filter((legend) => Boolean(legend.domain)); + const resolved = withSourceProvenance(resolveRetainedLegendPresentationTargets( + legends, resolve, resolveContext(hits), retainedLegendTargets, + )); + return resolved && resolved.elements.length > 0 ? { + visual: target.visual, + elements: [...resolved.elements, ...target.elements], + } : null; + } + const renderedKeys = new Set(allHits() + .map((hit) => hit.datum[INTERACTION_KEY]) + .filter((key): key is string => typeof key === 'string')); + const hits = allHits(); + const elements = target.elements.flatMap((element) => { + const associated = semanticElementRenderKeys(element).filter((key) => renderedKeys.has(key)); + if (associated.length > 0) return [element]; + const semanticRecords = element.records?.length ? element.records : [element.value]; + const matched = hits.flatMap((hit) => semanticRecords.some((record) => + Object.entries(record).every(([field, value]) => Object.is(hit.datum[field], value))) + ? [hit.datum[INTERACTION_KEY]] : []); + const keys = matched.filter((key): key is string => typeof key === 'string'); + return keys.length > 0 ? [associateSemanticElementRenderKeys(element, keys)] : []; + }); + return elements.length > 0 ? { ...target, elements } : null; + } + + const entries = Object.entries(target.select.key); + if (entries.length === 0) return null; + const hits = allHits().filter((hit) => matchesSemanticTargetSelector(target, plan.fields, hit.datum)); + if (hits.length === 0 || !resolve) return null; + const resolved = withSourceProvenance(resolve({ + gesture: 'rectangle', + role: target.select.visual?.role ?? 'external-selection', + hits, + }, resolveContext(hits))); + if (!resolved) return null; + if (target.select.visual?.kind && target.select.visual.kind !== resolved.visual.kind) return null; + if (target.select.visual?.role && target.select.visual.role !== resolved.visual.role) return null; + return resolved; + }; + + const resolveUpdate = ( + update: ChartUpdate, + ): { update: ChartUpdate; result: ChartUpdateResult } => { + const unresolvedTargets: UpdateTarget[] = []; + const unsupportedOps: ChartUpdateOp['op'][] = []; + let resolvedTargets = 0; + const ops: ChartUpdateOp[] = []; + for (const op of update.ops) { + if (op.op === 'set-style') { + const targets = op.targets.flatMap((target) => { + const resolved = resolveUpdateTarget(target); + if (!resolved) { + unresolvedTargets.push(target); + return []; + } + resolvedTargets += resolved.elements.length; + return [resolved]; + }); + if (targets.length > 0 || op.targets.length === 0) ops.push({ ...op, targets }); + } else if (op.op === 'set-annotation' && op.value !== null) { + const target = resolveUpdateTarget(op.target); + if (!target || target.elements.length !== 1) unresolvedTargets.push(op.target); + else { + resolvedTargets += 1; + ops.push({ ...op, target }); + } + } else if (op.op === 'set-viewport') { + const supported = resolveSupportedOperation(op, plan); + if (supported.unsupported) unsupportedOps.push(op.op); + if (supported.op) ops.push(supported.op); + } else if (op.op === 'set-order') { + const supported = resolveSupportedOperation(op, plan); + if (supported.unsupported) unsupportedOps.push(op.op); + if (supported.op) ops.push(supported.op); + } else if (op.op === 'set-overlay') { + if (!plan.overlayScales?.x || !plan.overlayScales?.y) unsupportedOps.push(op.op); + else ops.push(op); + } else if (op.op === 'set-freeform-overlay' && op.value !== null) { + const body: (typeof op.value.body)[number][] = []; + for (const component of op.value.body) { + if (component.type === 'svg') { + body.push(component); + continue; + } + const targets = component.targets.flatMap((unresolved) => { + const target = resolveUpdateTarget(unresolved); + if (!target) { + unresolvedTargets.push(unresolved); + return []; + } + resolvedTargets += target.elements.length; + return [target]; + }); + if (targets.length > 0) { + body.push({ ...component, targets }); + } + } + if (body.length > 0) { + ops.push({ ...op, value: { ...op.value, body } }); + } + } else if (op.op === 'set-freeform-overlay') { + ops.push(op); + } else if (op.op === 'set-data') { + if (!plan.mutableDataSource || op.source !== 'main') unsupportedOps.push(op.op); + else ops.push(op); + } else { + ops.push(op); + } + } + const hasUnsupported = unresolvedTargets.length > 0 || unsupportedOps.length > 0; + return { + update: { id: update.id, ops }, + result: { + status: !hasUnsupported + ? 'applied' + : ops.length > 0 ? 'partially-applied' : 'unsupported', + resolvedTargets, + unresolvedTargets, + unsupportedOps: [...new Set(unsupportedOps)], + }, + }; + }; + + const renderUpdates = async (): Promise => { + // A preview overlays the same interaction's retained state. It replaces + // only operation identities that it supplies, so a transient drag ghost + // does not discard the category order committed by an earlier drag. + const displayUpdates = [ + ...[...retainedUpdates] + .map(([id, update]) => mergeRetainedPreview(update, previewUpdates.get(id))!), + ...[...previewUpdates] + .filter(([id]) => !retainedUpdates.has(id)) + .map(([, update]) => update), + ]; + const hiddenLegendDomains = new Map(); + const activeHiddenLegendDomains = new Set(); + const stylesByKey: Record> = {}; + const overlays = new Map(); + const axisStyles: { value: AxisTargetValue; style: import('../../core/interaction-contracts').StyleSpec }[] = []; + let dataRows = initialDataRows; + let emptyEmphasisActive = false; + const freeformOverlays = new Map(); + selectedElements.clear(); + hiddenKeys.clear(); + const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + for (const axis of reorderAxes) view.signal(axis.signal, null); + for (const axis of Object.keys(plan.navigationAxes ?? {}) as ('x' | 'y')[]) { + navigationController.apply({ op: 'set-viewport', axes: axis, value: {} }); + } + for (const update of displayUpdates) { + for (const op of update.ops) { + if (op.op === 'set-overlay') { + if (op.value === null) overlays.delete(op.name); + else overlays.set(op.name, op.value); + continue; + } + if (op.op === 'set-freeform-overlay') { + if (op.value === null) freeformOverlays.delete(op.name); + else freeformOverlays.set(op.name, op.value); + continue; + } + if (op.op === 'set-data') { + dataRows = op.value.rows; + continue; + } + if (op.op === 'set-style' && op.value.visible === false) { + for (const target of op.targets) { + if ('select' in target) continue; + for (const element of target.elements) { + for (const key of semanticElementRenderKeys(element)) { + hiddenKeys.add(key.endsWith(PATH_KEY_SUFFIX) + ? key.slice(0, -PATH_KEY_SUFFIX.length) + : key); + } + const legend = element.value as LegendTargetValue; + if (target.visual.kind === 'legend' + && legend.domain?.kind === 'value' + && legend.channel) { + activeHiddenLegendDomains.add(legendDomainIdentity(legend)); + } + if (target.visual.kind === 'legend' + && legend.domain?.kind === 'value' + && legend.channel + && op.value.mutedOpacity !== undefined) { + const identity = `${legend.channel}:${String(legend.domain.value)}`; + hiddenLegendDomains.set(identity, { legend, opacity: op.value.mutedOpacity }); + } + } + } + } + if (op.op === 'set-style') { + if (op.targets.length === 0 + && (op.value.state === 'emphasized' || op.value.state === 'focused')) { + emptyEmphasisActive = true; + } + for (const target of op.targets) { + if ('select' in target) continue; + if (target.visual.kind === 'axis') { + for (const element of target.elements) { + axisStyles.push({ + value: element.value as AxisTargetValue, + style: op.value, + }); + } + continue; + } + for (const element of target.elements) { + for (const key of semanticElementRenderKeys(element)) { + if (op.value.state === 'emphasized' || op.value.state === 'focused') { + selectedElements.set(key, element); + } + const style = Object.fromEntries( + (['opacity', 'fill', 'stroke', 'strokeWidth'] as const) + .filter((channel) => op.value[channel] !== undefined) + .map((channel) => [channel, op.value[channel]]), + ); + if (Object.keys(style).length > 0) { + stylesByKey[key] = { ...stylesByKey[key], ...style }; + } + } + } + } + } else if (op.op === 'set-viewport') { + navigationController.apply(op); + } else if (op.op === 'set-order' && op.scope === 'category') { + const axis = reorderAxes.find((candidate) => candidate.field === op.field); + if (axis) view.signal(axis.signal, op.values); + } + } + } + const clone = [...freeformOverlays.values()] + .flatMap((overlay) => overlay.body) + .find((component) => component.type === 'clone'); + const cloneTargets = clone?.targets.filter((target): target is SemanticTargetRef => !('select' in target)) ?? []; + const markTarget = cloneTargets.find((target) => target.visual.kind !== 'axis'); + const axisTarget = cloneTargets.find((target) => target.visual.kind === 'axis'); + const axisValue = axisTarget?.elements[0]?.value as AxisTargetValue | undefined; + const cloneTarget = markTarget ?? axisTarget; + if (clone?.type === 'clone' && cloneTarget) { + const translate = clone.transform?.translate ?? { x: 0, y: 0 }; + dragPreviewOverlay.render({ + source: cloneTarget, + destination: cloneTarget, + start: { x: 0, y: 0 }, + current: translate, + axis: axisValue?.axis, + field: axisValue?.field, + includeControl: Boolean(axisTarget), + ghostOpacity: clone.opacity, + dimmerOpacity: 0, + sourceDimmerOpacity: 0, + }); + } + else dragPreviewOverlay.clear(); + const keys = [...selectedKeys()]; + if (plan.mutableDataSource && dataRows !== renderedDataRows) { + view.change( + plan.mutableDataSource, + changeset().remove(() => true).insert([...dataRows]), + ); + renderedDataRows = dataRows; + plan.sourceRecords = dataRows; + } + for (const identity of retainedLegendTargets.keys()) { + if (!activeHiddenLegendDomains.has(identity)) retainedLegendTargets.delete(identity); + } + if (keys.length === 0) selectedLegend = null; + // A navigation-only chart compiles without the selection stores. + if (plan.semanticStores !== false) { + view.signal(STYLE_SIGNAL, stylesByKey); + view.change( + INTERACTION_STORE, + changeset().remove(() => true).insert(emptyEmphasisActive && keys.length === 0 + ? [{}] + : keys.map((key) => ({ key }))), + ); + view.change( + HIDDEN_STORE, + changeset().remove(() => true).insert([...hiddenKeys].map((key) => ({ key }))), + ); + view.change( + LEGEND_HIDDEN_STORE, + changeset().remove(() => true).insert([...hiddenLegendDomains] + .map(([identity, { opacity }]) => ({ identity, opacity }))), + ); + view.change( + LEGEND_SELECTION_STORE, + changeset().remove(() => true).insert(selectedLegend ? [selectedLegend] : []), + ); + } + // An overlay installed by a click must become acquireable before a + // following pointer-down, even while unrelated Vega work is pending. + dataOverlay.render(overlays); + await view.runAsync(); + restoreAxisStyles(); + const rendererSvg = container.querySelector('svg') as SVGSVGElement | null; + if (rendererSvg) { + for (const item of axisItems(view, plan.axisTargets)) { + const identity = axisTargetIdentity(item, plan.axisTargets); + if (!identity || identity.role !== 'axis-label') continue; + const presentation = axisStyles.find(({ value }) => + value.axis === identity.axis + && value.field === identity.field + && Object.is(value.value, identity.value)); + if (!presentation) continue; + const rendered = [...rendererSvg.querySelectorAll('text')] + .find((candidate) => { + const datum = (candidate as any).__data__; + return datum?.mark === item.mark && datum?.datum === item.datum; + }); + if (!rendered) continue; + const values: [string, string | number | undefined][] = [ + ['opacity', presentation.style.opacity], + ['fill', presentation.style.fill], + ['stroke', presentation.style.stroke], + ['stroke-width', presentation.style.strokeWidth], + ]; + const originals = new Map(); + for (const [name, value] of values) { + if (value === undefined) continue; + originals.set(name, rendered.getAttribute(name)); + rendered.setAttribute(name, String(value)); + } + styledAxisElements.set(rendered, originals); + } + } + dataOverlay.render(overlays); + freeformOverlay.render(freeformOverlays); + observeRenderer(); + renderPathFocus(); + renderLegendRange(); + reorderResetControls.layout(); + viewportResetControl.layout(); + const annotations = effectiveAnnotationEntries(displayUpdates) + .filter((entry) => entry.value.text && entry.value.candidates); + const annotationKeys = new Set(annotations.map((entry) => entry.key)); + for (const [key, overlay] of annotationOverlays) { + if (annotationKeys.has(key)) continue; + overlay.destroy(); + annotationOverlays.delete(key); + } + for (const annotation of annotations) { + let overlay = annotationOverlays.get(annotation.key); + if (!overlay) { + overlay = createAnnotationOverlay(annotationOverlayOptions); + annotationOverlays.set(annotation.key, overlay); + } + overlay.render(annotation.element, { + ...annotation.value, + text: annotation.value.text!, + candidates: annotation.value.candidates!, + }); + } + }; + + const storeUpdate = async ( + update: ChartUpdate, + destination: Map, + legendSelection: LegendHitIdentity | null = null, + ): Promise => { + const resolved = resolveUpdate(update); + const presented = presentUpdate(resolved.update, context()); + destination.set(update.id, presented); + if (legendSelection) selectedLegend = legendSelection; + await renderUpdates(); + return resolved.result; + }; + + const applyInteractionUpdate = async ( + interaction: CanvasInteractionDef, + phase: import('../../interactive/interactions').InteractionPhase, + update: ChartUpdate | null, + legendSelection: LegendHitIdentity | null = null, + ): Promise => { + if (phase === 'cancel') { + if (previewUpdates.delete(interaction.id)) await renderUpdates(); + return; + } + if (update) { + const preview = phase === 'start' || phase === 'preview'; + if (!preview) previewUpdates.delete(interaction.id); + if (!preview && interaction.retainedStateGroup) { + for (const sibling of evictRetainedStateSiblings( + interaction, canvasInteractions, retainedUpdates, previewUpdates, + )) { + if (sibling.claimsLegendActivation) selectedLegend = null; + } + } + await storeUpdate(update, preview ? previewUpdates : retainedUpdates, legendSelection); + return; + } + if (phase === 'commit') { + const pending = previewUpdates.get(interaction.id); + if (pending) { + retainedUpdates.set(interaction.id, pending); + previewUpdates.delete(interaction.id); + await renderUpdates(); + } + } + }; + const emitCanvasInteractionEvent = ( + interaction: CanvasInteractionDef, + event: CanvasInteractionEvent, + transactionId?: string, + ): void => { + const root = container.closest('[data-flint-chart-id]'); + const detail: FlintInteractionEventDetail = { + chartId: root?.dataset.flintChartId ?? '', + interactionId: interaction.id, + timestamp: Date.now(), + transactionId, + event, + }; + container.dispatchEvent(new CustomEvent('flint-interaction', { + detail, + bubbles: true, + composed: true, + })); + }; + const emitInteractionEvent = ( + interaction: CanvasInteractionDef, + event: SemanticInteractionEvent | NavigationInteractionEvent, + transactionId?: string, + ): void => emitCanvasInteractionEvent( + interaction, + toCanvasInteractionEvent(event, interaction.eventSource), + transactionId, + ); + // A region can be read as data domains, which is what viewport updates need. + const domainForGeometry = (plot: CanvasInteractionEvent['geometry']['plot']) => + domainForPlotGeometry(plot, plan.navigationAxes, (name) => view.scale(name)); + const dispatch = async ( + interaction: CanvasInteractionDef, + event: SemanticInteractionEvent, + legendSelection: LegendHitIdentity | null = null, + actionOverride?: CanvasInteractionEvent['action'], + applyHandler = true, + ): Promise => { + const base = toCanvasInteractionEvent(event, interaction.eventSource); + const domain = domainForGeometry(base.geometry.plot); + const withDomain = domain + ? { ...base, geometry: { ...base.geometry, domain } } + : base; + const canvasEvent = actionOverride ? { ...withDomain, action: actionOverride } : withDomain; + emitCanvasInteractionEvent(interaction, canvasEvent); + const request = applyHandler && interaction.handle + ? interaction.handle(canvasEvent, context(!interaction.eventSource.viewport, interaction)) + : null; + await applyInteractionUpdate(interaction, event.phase, request, legendSelection); + }; + let navigationDispatch = Promise.resolve(); + const dispatchNavigation = ( + interaction: CanvasInteractionDef, + event: NavigationInteractionEvent, + ): Promise => { + const run = async (): Promise => { + const canvasEvent = toCanvasInteractionEvent(event, interaction.eventSource); + emitCanvasInteractionEvent(interaction, canvasEvent); + const request = interaction.handle?.(canvasEvent, context(false)) ?? null; + await applyInteractionUpdate(interaction, event.phase, request); + }; + navigationDispatch = navigationDispatch.then(run, run); + return navigationDispatch; + }; + const resolveTarget = ( + gesture: 'click' | 'hover' | 'rectangle' | 'angular', + role: string, + hits: readonly RenderHit[], + legend?: LegendTargetValue, + ): SemanticTarget | null => { + if (!resolve) return null; + const availableHits = allHits(); + return withSourceProvenance(resolve( + { gesture, role, hits, legend }, + resolveContext(availableHits), + )); + }; + const resolveAxisTarget = (item: any): SemanticTarget | null => { + const identity = axisTargetIdentity(item, plan.axisTargets); + if (!identity) return null; + const hits = allHits().filter((hit) => Object.is(hit.datum[identity.field], identity.value)); + if (hits.length === 0) return null; + const represented = resolveTarget('click', 'axis-tick', hits); + const records = [...new Set(represented?.elements.flatMap((element) => element.records ?? []) ?? [])]; + const keys = [...new Set(represented?.elements.flatMap(semanticElementRenderKeys) ?? [])]; + return { + visual: { kind: 'axis', role: identity.role }, + elements: [associateSemanticElementRenderKeys({ + value: { axis: identity.axis, field: identity.field, value: identity.value }, + ...(records.length > 0 ? { records } : {}), + }, keys)], + }; + }; + let hoveredKeys = '\u0001\u0000'; + let hoverActive = false; + let lastHoverTarget: SemanticTarget | null = null; + let lastHoverPoint: import('../../interactive/interactions').PlotPoint | null = null; + let hoverClearTimer: ReturnType | undefined; + const setHover = async ( + keys: readonly string[], + legend: LegendHitIdentity | null = null, + axis: { scale: string; value: unknown } | null = null, + ): Promise => { + const tracked = [...inspectSeriesPresentation.values()].flatMap((seriesKeys) => [...seriesKeys]); + const next = [...new Set([...tracked, ...keys])].sort(); + const signature = `${next.join('\u0000')}\u0001${legend?.channel ?? ''}\u0000${String(legend?.value ?? '')}` + + `\u0001${axis?.scale ?? ''}\u0000${String(axis?.value ?? '')}`; + if (signature === hoveredKeys) return; + hoveredKeys = signature; + hoveredPathKeys = new Set(next.filter((key) => key.endsWith(PATH_KEY_SUFFIX))); + hoveredLegend = legend; + const renderedItems = hoveredPathKeys.size > 0 ? sceneItems(view) : []; + const presentationKeys = [...new Set(next.map( + (key) => pathHoverPresentationKey(renderedItems, key), + ))]; + view.change( + HOVER_STORE, + changeset().remove(() => true).insert(presentationKeys.map((key) => ({ key }))), + ); + view.change( + LEGEND_HOVER_STORE, + changeset().remove(() => true).insert(legend ? [legend] : []), + ); + view.change( + AXIS_HOVER_STORE, + changeset().remove(() => true).insert(axis ? [axis] : []), + ); + await view.runAsync(); + renderPathFocus(); + renderLegendRange(); + }; + const setTrackedInspectSeries = ( + interaction: CanvasInteractionDef, + series: unknown, + items = sceneItems(view), + ): void => { + const seriesBy = interaction.eventSource.inspectIndex?.seriesBy; + if (!seriesBy) return; + inspectSeriesLocks.set(interaction.id, series); + inspectSeriesPresentation.set( + interaction.id, + new Set(inspectSeriesPresentationKeys(items, seriesBy, series)), + ); + }; + for (const interaction of inspectInteractions) { + const policy = interaction.eventSource.inspectIndex; + if (!policy?.seriesBy || (policy.show !== 'single' && typeof policy.show !== 'object')) continue; + const items = sceneItems(view); + const preferred = typeof policy.show === 'object' ? policy.show.series : undefined; + setTrackedInspectSeries( + interaction, + initialInspectSeries(items, policy.seriesBy, preferred), + items, + ); + } + if (inspectSeriesPresentation.size > 0) void setHover([]); + const clearHover = (): void => { + if (hoverClearTimer !== undefined) { + clearTimeout(hoverClearTimer); + hoverClearTimer = undefined; + } + targetFeedbackOverlay.clear(); + lastHoverTarget = null; + lastHoverPoint = null; + void setHover([]); + if (hoverInteractions.length > 0 && hoverActive) { + hoverActive = false; + for (const interaction of hoverInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'cancel', target: null, + }); + } + } + if (!regionInteraction && !navigationInteraction) container.style.cursor = previousCursor; + }; + const scheduleHoverClear = (): void => { + if (hoverClearTimer !== undefined) clearTimeout(hoverClearTimer); + hoverClearTimer = setTimeout(() => { + hoverClearTimer = undefined; + clearHover(); + }, 16); + }; + // A pointer that misses every mark still acquires the nearest one, so small + // marks stay reachable without changing which action the preset receives. + const acquire = ( + item: any, + point: import('../../interactive/interactions').PlotPoint, + rootPoint: import('../../interactive/interactions').PlotPoint, + phase: 'preview' | 'commit', + modifiers: ReturnType, + tolerance = 0, + ) => { + const space = coordinateSpace(); + const direct = normalizeVegaElementEvent( + view, item, point, phase, modifiers, plan.legendFields, plan.rangeLegendChannels, rootPoint, + ); + if (tolerance <= 0 || direct.legend || direct.event.hits.length > 0) return { ...direct, feedbackItem: null }; + const rawPlotPoint = { x: rootPoint.x - space.originX, y: rootPoint.y - space.originY }; + const overPlot = rawPlotPoint.x >= 0 && rawPlotPoint.x <= space.plotWidth + && rawPlotPoint.y >= 0 && rawPlotPoint.y <= space.plotHeight; + const snapped = nearestInteractiveSceneItem( + view, rawPlotPoint, tolerance, rootPoint, overPlot, + ); + return snapped + ? { ...normalizeVegaElementEvent( + view, snapped, point, phase, modifiers, plan.legendFields, plan.rangeLegendChannels, rootPoint, + ), feedbackItem: snapped } + : { ...direct, feedbackItem: null }; + }; + const hoverHandler = (event: MouseEvent, item: any): void => { + if ((hoverPresentationInteractions.length === 0 && axisHoverPresentationInteractions.length === 0) + || regionDragging) return; + if (hoverClearTimer !== undefined) { + clearTimeout(hoverClearTimer); + hoverClearTimer = undefined; + } + const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); + const axisTarget = resolveAxisTarget(item); + if (axisTarget) { + const identity = axisTargetIdentity(item, plan.axisTargets); + if (!identity) return clearHover(); + const reorderEligible = !!elementDragInteraction && !!identity + && eligibleReorderAxesForAxis( + plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), + identity, + ).length > 0; + if (axisHoverPresentationInteractions.length === 0 && !reorderEligible) return clearHover(); + hoverActive = true; + for (const interaction of axisHoverInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'preview', target: axisTarget, point, + modifiers: interactionModifiers(event), + }); + } + void setHover( + axisTarget.elements.flatMap(semanticElementRenderKeys), + null, + { scale: identity.scale, value: identity.value }, + ); + return; + } + const markHoverPresentationInteractions = hoverPresentationForTarget('mark'); + const normalized = acquire( + item, point, rootPoint, 'preview', interactionModifiers(event), + assistDistanceFor(markHoverPresentationInteractions), + ); + const legend = normalized.legend; + if (legend) { + const legendHoverInteractions = hoverPresentationForTarget('legend-item'); + if (legendHoverInteractions.length === 0) return clearHover(); + const resolved = legendSemanticTarget(legend); + hoverActive = true; + for (const interaction of markHoverInteractions.filter((candidate) => + legendHoverInteractions.includes(candidate))) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'preview', target: resolved, point, + modifiers: normalized.event.modifiers, + }); + } + void setHover([], legend); + return; + } + const hovered = normalized.event.hits[0]; + const reorderAxes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + const reorderEligible = !!hovered && !!elementDragInteraction + && eligibleReorderAxesForHit(reorderAxes, hovered).length > 0; + const directResolved = resolveTarget('hover', normalized.role, normalized.event.hits); + let resolved = directResolved; + if (!resolved && hoverTolerance > 0 && lastHoverTarget && lastHoverPoint + && Math.hypot(rootPoint.x - lastHoverPoint.x, rootPoint.y - lastHoverPoint.y) <= hoverTolerance) { + resolved = lastHoverTarget; + } + if (!resolved) { + clearHover(); + return; + } + if (directResolved) { + lastHoverTarget = directResolved; + lastHoverPoint = rootPoint; + } + if (normalized.feedbackItem && targetFeedback?.assisted) { + targetFeedbackOverlay.render(normalized.feedbackItem, resolved, 'assisted'); + } else { + targetFeedbackOverlay.clear(); + } + hoverActive = true; + const interactionContext = context(); + const presentationElements = markHoverPresentationInteractions.flatMap((interaction) => { + if (interaction.eventSource.type === 'element' && interaction.eventSource.gesture === 'drag') { + return reorderEligible ? resolved?.elements ?? [] : []; + } + if (!interaction.handle) return resolved?.elements ?? []; + const preview = interaction.handle(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', target: resolved, point, + modifiers: normalized.event.modifiers, + }, interaction.eventSource), interactionContext); + return preview?.ops.flatMap((op) => op.op === 'set-style' + && (op.value.state === 'emphasized' || op.value.state === 'focused') + ? op.targets.flatMap((target) => 'select' in target ? [] : target.elements) + : []) ?? []; + }); + for (const interaction of markHoverInteractions.filter((candidate) => + markHoverPresentationInteractions.includes(candidate))) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'preview', target: resolved, point, + modifiers: normalized.event.modifiers, + }); + } + void setHover(presentationElements + .flatMap(semanticElementRenderKeys)); + }; + + const singleSeriesInspectInteractions = inspectInteractions.filter((interaction) => { + const show = interaction.eventSource.inspectIndex?.show; + return show === 'single' || typeof show === 'object'; + }); + const clickHandler = (event: MouseEvent, item: any): void => { + if ((clickInteractions.length === 0 && singleSeriesInspectInteractions.length === 0) || suppressClick) return; + const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); + const axisTarget = resolveAxisTarget(item); + if (axisTarget) { + for (const interaction of axisClickInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', target: axisTarget, point, + modifiers: interactionModifiers(event), + }); + } + return; + } + const normalized = acquire( + item, point, rootPoint, 'commit', interactionModifiers(event), + assistDistanceFor(markClickInteractions), + ); + const { legend } = normalized; + const target = legend ? resolvedLegendInteractionTarget( + { channel: legend.channel, field: legend.field, domain: legend.domain }, + resolveTarget('click', 'legend-item', [], legend), + ) + : resolveTarget('click', normalized.role, normalized.event.hits); + for (const interaction of markClickInteractions) { + const affordanceTarget = legend ? 'legend-item' : 'mark'; + if (!resolveInteractionAffordance([interaction], affordanceTarget)) continue; + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', target, point, + modifiers: normalized.event.modifiers, + }, legend); + } + if (legend) { + for (const interaction of singleSeriesInspectInteractions) { + const policy = interaction.eventSource.inspectIndex!; + if (!policy.seriesBy || legend.field !== policy.seriesBy) continue; + setTrackedInspectSeries(interaction, legend.value); + void setHover([], legend); + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', target, point, + modifiers: normalized.event.modifiers, + }, legend); + inspectHandler(event); + } + } + }; + const contextHandler = (event: MouseEvent): void => { + if (contextInteractions.length === 0) return; + event.preventDefault(); + const point = localPoint(event as unknown as PointerEvent); + // A zero radius resolves the mark under the pointer; assist widens it. + const item = nearestSceneItem(view, point, assistDistanceFor(contextInteractions)); + const normalized = normalizeVegaElementEvent( + view, item, point, 'commit', interactionModifiers(event), plan.legendFields, plan.rangeLegendChannels, + { x: point.x + coordinateSpace().originX, y: point.y + coordinateSpace().originY }, + ); + const { legend } = normalized; + const target = legend ? legendSemanticTarget(legend) + : resolveTarget('click', normalized.role, normalized.event.hits); + for (const interaction of contextInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', target, point, + modifiers: normalized.event.modifiers, + }); + } + }; + const inspectModeIndices = new Map(inspectInteractions.map((interaction) => [interaction.id, 0])); + const inspectModes = (interaction: CanvasInteractionDef) => interaction.eventSource.inspectCycle ?? [{ + inspect: interaction.eventSource.inspect ?? 'xy', + predicate: interaction.eventSource.inspectPredicate ?? {}, + }]; + const activeInspectMode = (interaction: CanvasInteractionDef) => { + const modes = inspectModes(interaction); + return modes[inspectModeIndices.get(interaction.id) ?? 0] ?? modes[0]; + }; + const inspectHandler = (event: MouseEvent): void => { + if (inspectInteractions.length === 0) return; + const point = localPoint(event as unknown as PointerEvent); + const space = coordinateSpace(); + const items = sceneItems(view); + const polarFrame = polarFrameFromItems(items, point); + let guideRendered = false; + for (const interaction of inspectInteractions) { + const activeMode = activeInspectMode(interaction); + const mode = activeMode.inspect; + const eligibleItems = interaction.eventSource.selector + ? items.filter((item) => matchesSemanticTargetSelector( + interaction.eventSource.selector!, plan.fields, item.datum ?? {}, + )) + : items; + const tolerance = interaction.eventSource.inspectTolerance ?? 0.01; + const guide = interaction.eventSource.inspectGuide ?? normalizeInspectGuideOptions(undefined); + const indexPolicy = interaction.eventSource.inspectIndex; + const singleSeries = indexPolicy?.show === 'single' || typeof indexPolicy?.show === 'object'; + if (singleSeries && indexPolicy?.seriesBy && !inspectSeriesLocks.has(interaction.id)) { + const preferred = typeof indexPolicy.show === 'object' ? indexPolicy.show.series : undefined; + setTrackedInspectSeries( + interaction, + initialInspectSeries(eligibleItems, indexPolicy.seriesBy, preferred), + eligibleItems, + ); + void setHover([]); + } + const effectiveShow = singleSeries + ? { series: inspectSeriesLocks.get(interaction.id) } + : indexPolicy?.show; + const indexField = indexPolicy ? plan.axisFields?.[indexPolicy.axis] : undefined; + const continuousIndex = indexField?.type === 'temporal' || indexField?.type === 'quantitative'; + const indexScaleName = indexPolicy && indexField + ? Object.entries(plan.axisTargets ?? {}).find(([, target]) => + target.axis === indexPolicy.axis && target.field === indexField.field)?.[0] + : undefined; + const indexScale = indexScaleName ? view.scale(indexScaleName) : undefined; + const discreteCoordinates = !continuousIndex && indexScale?.domain + ? indexScale.domain().map((value: unknown) => + Number(indexScale(value)) + (Number(indexScale.bandwidth?.()) || 0) / 2) + : undefined; + const indexAcquisition = indexPolicy && !polarFrame + ? indexInspectAcquisition( + eligibleItems, + point, + indexPolicy.axis, + { show: effectiveShow as 'all' | { series: unknown }, seriesBy: indexPolicy.seriesBy }, + continuousIndex, + discreteCoordinates, + (indexPolicy.axis === 'x' ? space.plotWidth : space.plotHeight) * tolerance, + ) + : undefined; + const hits = indexAcquisition + ? indexAcquisition.hits + : polarFrame + ? polarInspectHits(eligibleItems, point, polarFrame) + : tolerantInspectHits( + eligibleItems, + point, + mode, + activeMode.predicate, + { x: space.plotWidth * tolerance, y: space.plotHeight * tolerance }, + ); + if (guide.visible && indexAcquisition) { + const guidePoint = indexPolicy!.axis === 'x' + ? { x: indexAcquisition.coordinate, y: point.y } + : { x: point.x, y: indexAcquisition.coordinate }; + inspectGuideOverlay.renderAxes(guidePoint, indexPolicy!.axis, guide.style); + inspectGuideOverlay.renderValueRules( + indexAcquisition.valueCoordinates, + indexPolicy!.axis, + guide.style, + ); + guideRendered = true; + } else if (guide.visible && polarFrame) { + const segment = polarGuideSegment(polarFrame, point); + inspectGuideOverlay.renderSegment(segment.start, segment.end, guide.style); + guideRendered = true; + } else if (guide.visible) { + inspectGuideOverlay.renderAxes(point, mode, guide.style); + guideRendered = true; + } + const target = hits.length > 0 ? resolveTarget('hover', 'mark', hits) : null; + const modifiers = interactionModifiers(event); + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'preview', + target, + point, + modifiers, + }); + } + if (!guideRendered) inspectGuideOverlay.clear(); + }; + let lastInspectWheelAt = 0; + const cycleInspect = (event: MouseEvent, direction: 1 | -1): void => { + const cycling = inspectInteractions.filter((interaction) => inspectModes(interaction).length > 1); + if (cycling.length === 0) return; + event.preventDefault(); + event.stopImmediatePropagation(); + for (const interaction of cycling) { + const modes = inspectModes(interaction); + const current = inspectModeIndices.get(interaction.id) ?? 0; + inspectModeIndices.set(interaction.id, (current + direction + modes.length) % modes.length); + } + inspectHandler(event); + }; + const inspectWheel = (event: WheelEvent): void => { + const zooming = inspectInteractions.filter((interaction) => interaction.eventSource.zoom); + if (zooming.length > 0) { + event.preventDefault(); + const space = coordinateSpace(); + const point = clientToPlotPoint({ x: event.clientX, y: event.clientY }, space); + for (const interaction of zooming) { + void dispatchNavigation(interaction, { + type: 'navigation', phase: 'preview', operation: 'zoom', axes: 'xy', + factor: wheelZoomFactor( + event.deltaY, + event.deltaMode, + space.plotHeight, + interaction.eventSource.wheelSensitivity ?? 0.002, + ), + anchor: { + x: space.plotWidth > 0 ? point.x / space.plotWidth : 0.5, + y: space.plotHeight > 0 ? point.y / space.plotHeight : 0.5, + }, + modifiers: interactionModifiers(event), + }); + } + return; + } + const now = performance.now(); + event.preventDefault(); + if (now - lastInspectWheelAt < 160 || event.deltaY === 0) return; + lastInspectWheelAt = now; + cycleInspect(event, event.deltaY > 0 ? 1 : -1); + }; + const inspectContext = (event: MouseEvent): void => cycleInspect(event, 1); + const inspectLeave = (): void => { + inspectGuideOverlay.clear(); + for (const interaction of inspectInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'cancel', target: null, + }); + } + }; + if (inspectInteractions.length > 0) { + container.addEventListener('pointermove', inspectHandler); + container.addEventListener('pointerleave', inspectLeave); + if (inspectInteractions.some((interaction) => + interaction.eventSource.zoom || inspectModes(interaction).length > 1)) { + container.addEventListener('wheel', inspectWheel, { passive: false }); + container.addEventListener('contextmenu', inspectContext); + } + } + const pointerTarget = (event: MouseEvent, eligible: readonly CanvasInteractionDef[]) => { + const point = localPoint(event as unknown as PointerEvent); + const item = nearestSceneItem(view, point, assistDistanceFor(eligible)); + const normalized = normalizeVegaElementEvent( + view, item, point, 'commit', interactionModifiers(event), plan.legendFields, plan.rangeLegendChannels, + { x: point.x + coordinateSpace().originX, y: point.y + coordinateSpace().originY }, + ); + return { + point, + modifiers: normalized.event.modifiers, + legend: normalized.legend, + target: normalized.legend ? legendSemanticTarget(normalized.legend) + : resolveTarget('click', normalized.role, normalized.event.hits), + }; + }; + let longPressTimer: number | undefined; + let longPressPointer: { id: number; x: number; y: number } | undefined; + const dismissPolicy = dismiss === false ? { click: false as const, escape: false } : { + click: dismiss?.click ?? 'non-element' as const, + escape: dismiss?.escape ?? true, + }; + let dismissTimer: number | undefined; + let consumeDismissClick = false; + const cancelPendingDismiss = (): void => { + if (dismissTimer === undefined) return; + window.clearTimeout(dismissTimer); + dismissTimer = undefined; + }; + const clearDismissibleState = (): void => { + cancelPendingDismiss(); + let changed = false; + for (const layer of [retainedUpdates, previewUpdates]) { + for (const [id, update] of layer) { + const ops = update.ops.filter((op) => + op.op !== 'set-style' && op.op !== 'set-annotation'); + if (ops.length > 0) layer.set(id, { id, ops }); + else layer.delete(id); + changed = changed || ops.length !== update.ops.length; + } + } + if (changed) void renderUpdates(); + }; + const dismissOnClick = (event: MouseEvent, item: any): void => { + if (!dismissPolicy.click || suppressClick || isInteractiveControlTarget(event.target)) return; + if (consumeDismissClick) { + consumeDismissClick = false; + return; + } + const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); + const normalized = acquire( + item, point, rootPoint, 'commit', interactionModifiers(event), + assistDistanceFor(clickInteractions), + ); + const target = normalized.legend + ? resolvedLegendInteractionTarget( + { channel: normalized.legend.channel, field: normalized.legend.field, domain: normalized.legend.domain }, + resolveTarget('click', 'legend-item', [], normalized.legend), + ) + : resolveTarget('click', normalized.role, normalized.event.hits); + const space = coordinateSpace(); + const inPlot = point.x >= 0 && point.x <= space.plotWidth + && point.y >= 0 && point.y <= space.plotHeight; + if (dismissPolicy.click === 'non-element' && target) return; + if (dismissPolicy.click === 'plot-background' && (!inPlot || target)) return; + cancelPendingDismiss(); + if (doubleInteractions.length > 0) { + dismissTimer = window.setTimeout(() => { + dismissTimer = undefined; + clearDismissibleState(); + }, 250); + } else { + clearDismissibleState(); + } + }; + const cancelLongPress = (): void => { + if (longPressTimer !== undefined) window.clearTimeout(longPressTimer); + longPressTimer = undefined; + longPressPointer = undefined; + }; + const longPressStart = (event: PointerEvent): void => { + if (longPressInteractions.length === 0 || event.button !== 0) return; + event.preventDefault(); + cancelLongPress(); + longPressPointer = { id: event.pointerId, x: event.clientX, y: event.clientY }; + const holdMs = longPressInteractions[0].eventSource.holdMs ?? 500; + longPressTimer = window.setTimeout(() => { + longPressTimer = undefined; + longPressPointer = undefined; + const acquired = pointerTarget(event, longPressInteractions); + if (!acquired.target) return; + consumeDismissClick = true; + suppressClick = true; + window.setTimeout(() => { suppressClick = false; }, 0); + for (const interaction of longPressInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', + target: acquired.target, point: acquired.point, modifiers: acquired.modifiers, + }); + } + }, holdMs); + }; + const longPressMove = (event: PointerEvent): void => { + if (!longPressPointer || event.pointerId !== longPressPointer.id) return; + if (longPressMovedBeyond( + longPressPointer, + { x: event.clientX, y: event.clientY }, + )) cancelLongPress(); + }; + const doubleHandler = (event: MouseEvent): void => { + if (doubleInteractions.length === 0) return; + event.preventDefault(); + cancelPendingDismiss(); + const acquired = pointerTarget(event, doubleInteractions); + for (const interaction of doubleInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', + target: acquired.target, point: acquired.point, modifiers: acquired.modifiers, + }); + } + }; + if (longPressInteractions.length > 0) { + container.addEventListener('pointerdown', longPressStart, true); + container.addEventListener('pointerup', cancelLongPress, true); + container.addEventListener('pointermove', longPressMove, true); + container.addEventListener('pointercancel', cancelLongPress, true); + } + if (doubleInteractions.length > 0) container.addEventListener('dblclick', doubleHandler); + if (dismissPolicy.click) view.addEventListener('click', dismissOnClick); + if (contextInteractions.length > 0) { + container.addEventListener('contextmenu', contextHandler); + } + if (clickInteractions.length > 0 || singleSeriesInspectInteractions.length > 0) { + view.addEventListener('click', clickHandler); + } + if (hoverPresentationInteractions.length > 0) { + view.addEventListener('mousemove', hoverHandler); + view.addEventListener('mouseout', scheduleHoverClear); + } + + const previousCursor = container.style.cursor; + const previousUserSelect = container.style.userSelect; + const previousTouchAction = container.style.touchAction; + if (longPressInteractions.length > 0) container.style.touchAction = 'none'; + const suppressTextSelection = doubleInteractions.length > 0 + || canvasInteractions.some((interaction) => interaction.claimsLegendActivation); + if (suppressTextSelection) container.style.userSelect = 'none'; + const localPoint = (event: PointerEvent): { x: number; y: number } => { + return clientToPlotPoint({ x: event.clientX, y: event.clientY }, coordinateSpace()); + }; + const pointerPoints = (event: PointerEvent) => { + const space = coordinateSpace(); + const client = { x: event.clientX, y: event.clientY }; + return { + point: clientToPlotPoint(client, space), + rootPoint: clientToRendererPoint(client, space), + }; + }; + const cursorInteractions = canvasInteractions.filter((interaction) => + interaction.affordances?.some((affordance) => affordance.cursor)); + const setAffordanceCursor = ( + target: InteractionAffordanceTarget, + reorderEligible: boolean, + ): void => { + const eligibleIds = new Set(cursorInteractions + .filter((interaction) => reorderEligible || interaction !== elementDragInteraction) + .map((interaction) => interaction.id)); + container.style.cursor = affordanceCursor( + resolveInteractionAffordance(cursorInteractions, target, eligibleIds), + ) ?? previousCursor; + }; + const affordanceHandler = (event: MouseEvent, item: any): void => { + if (regionDragging) return; + const axisTarget = resolveAxisTarget(item); + if (axisTarget) { + const identity = axisTargetIdentity(item, plan.axisTargets); + const reorderEligible = !!elementDragInteraction && !!identity + && eligibleReorderAxesForAxis( + plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), + identity, + ).length > 0; + setAffordanceCursor('axis-label', reorderEligible); + return; + } + const { point, rootPoint } = pointerPoints(event as unknown as PointerEvent); + const normalized = acquire( + item, point, rootPoint, 'preview', interactionModifiers(event), + assistDistanceFor(cursorInteractions), + ); + if (normalized.legend) { + setAffordanceCursor('legend-item', false); + return; + } + const hit = normalized.event.hits[0]; + const reorderEligible = !!hit && !!elementDragInteraction + && eligibleReorderAxesForHit( + plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []), hit, + ).length > 0; + setAffordanceCursor(hit ? 'mark' : 'plot', reorderEligible); + }; + if (cursorInteractions.length > 0) view.addEventListener('mousemove', affordanceHandler); + let elementDrag: { + start: { x: number; y: number }; + source: SemanticTarget; + destination: SemanticTarget; + projection?: import('../../interactive/interactions').AxisProjection; + moved: boolean; + axis?: 'x' | 'y'; + eligibleAxes: readonly ('x' | 'y')[]; + overlayName?: string; + } | undefined; + const reorderItemAt = (event: PointerEvent): any => { + const eventItem = (event.target as any)?.__data__; + if (renderHit(eventItem)) return eventItem; + const point = localPoint(event); + return sceneItems(view).find((item) => { + const bounds = item.bounds; + return renderHit(item) && bounds + && point.x >= bounds.x1 && point.x <= bounds.x2 + && point.y >= bounds.y1 && point.y <= bounds.y2; + }); + }; + const resolveDraggedTarget = ( + event: PointerEvent, + ): { target: SemanticTarget; item?: any; eligibleAxes: readonly ('x' | 'y')[]; overlayName?: string } | null => { + const axes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + const eventItem = (event.target as any)?.__data__; + const axisItem = axisTargetIdentity(eventItem, plan.axisTargets) + ? eventItem + : axisItemAt(view, pointerPoints(event).rootPoint, plan.axisTargets); + const axisIdentity = axisTargetIdentity(axisItem, plan.axisTargets); + if (axisIdentity) { + const eligibleAxes = eligibleReorderAxesForAxis(axes, axisIdentity).map(({ axis }) => axis); + const target = eligibleAxes.length > 0 ? resolveAxisTarget(axisItem) : null; + if (target) return { target, item: axisItem, eligibleAxes }; + } + // A retained overlay is a visual enhancement, not a replacement for an + // underlying semantic mark. Prefer a real mark whenever pointer-down + // lands on or near one; fall back to the overlay for its remaining path. + const point = localPoint(event); + const exactItem = reorderItemAt(event); + const item = renderHit(exactItem) + ? exactItem + : nearestSceneItem( + view, + point, + elementDragInteraction?.eventSource.targetTolerance ?? 0, + ); + const hit = renderHit(item); + if (hit) { + const target = resolveTarget('click', hit.layerRole ?? hit.markType ?? 'mark', [hit]); + if (target) { + return { + target, + item, + eligibleAxes: eligibleReorderAxesForHit(axes, hit).map(({ axis }) => axis), + }; + } + } + const overlay = dataOverlay.targetForElement(event.target) + ?? dataOverlay.targetAt( + point, + elementDragInteraction?.eventSource.targetTolerance ?? 0, + ); + return overlay + ? { target: overlay.target, eligibleAxes: [], overlayName: overlay.name } + : null; + }; + const resolveReorderDestination = ( + current: { x: number; y: number }, + axis: 'x' | 'y', + ): { target: SemanticTarget; projection: import('../../interactive/interactions').AxisProjection } | null => { + const axes = plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : []); + const active = axes.find((candidate) => candidate.axis === axis) ?? axes[0]; + if (!active) return null; + const items = sceneItems(view); + const hit = nearestReorderHit(items, active.axis, active.field, current[active.axis]); + const target = hit ? resolveTarget('click', hit.layerRole ?? hit.markType ?? 'mark', [hit]) : null; + if (!hit || !target) return null; + const bounds = reorderProjectionBounds(items, active, hit.datum[active.field]); + if (!bounds) return null; + const space = coordinateSpace(); + return { + target, + projection: { + kind: 'axis', + axis: active.axis, + point: active.axis === 'x' + ? { x: current.x, y: elementDrag?.start.y ?? current.y } + : { x: elementDrag?.start.x ?? current.x, y: current.y }, + targetBounds: { + x: bounds.x1, + y: bounds.y1, + width: bounds.x2 - bounds.x1, + height: bounds.y2 - bounds.y1, + }, + plotBounds: { x: 0, y: 0, width: space.plotWidth, height: space.plotHeight }, + }, + }; + }; + const dispatchElementDrag = async ( + phase: 'start' | 'preview' | 'commit' | 'cancel', + event: PointerEvent, + current: { x: number; y: number }, + invokeHandler = true, + ): Promise => { + if (!elementDragInteraction || !elementDrag) return; + if (!elementDrag.overlayName && elementDrag.eligibleAxes.length === 0) { + elementDrag.overlayName = dataOverlay.targetAt( + current, + elementDragInteraction.eventSource.targetTolerance ?? 0, + )?.name; + } + const canvasEvent: CanvasInteractionEvent = { + action: 'drag', + phase, + geometry: { + plot: { + kind: 'drag', + start: elementDrag.start, + current, + delta: { x: current.x - elementDrag.start.x, y: current.y - elementDrag.start.y }, + axis: elementDrag.axis, + }, + }, + target: elementDrag.source, + dropTarget: elementDrag.destination, + modifiers: interactionModifiers(event), + }; + if (elementDrag.overlayName) { + canvasEvent.geometry.projection = dataOverlay.project(elementDrag.overlayName, current); + } else if (elementDrag.projection) { + canvasEvent.geometry.projection = elementDrag.projection; + } + emitCanvasInteractionEvent(elementDragInteraction, canvasEvent); + const request = invokeHandler + ? elementDragInteraction.handle?.(canvasEvent, context()) ?? null + : null; + await applyInteractionUpdate(elementDragInteraction, phase, request); + }; + const elementDragStart = (event: PointerEvent): void => { + if (!elementDragInteraction + || (event.button !== undefined && event.button !== 0) + || isInteractiveControlTarget(event.target)) return; + const source = resolveDraggedTarget(event); + if (!source) return; + const start = localPoint(event); + elementDrag = { + start, source: source.target, destination: source.target, + moved: false, eligibleAxes: source.eligibleAxes, + overlayName: source.overlayName, + }; + try { + container.setPointerCapture?.(event.pointerId); + } catch { + // Synthetic pointer events have no active pointer to capture. + } + clearHover(); + clearAnnotations(); + container.style.cursor = 'grab'; + void dispatchElementDrag('start', event, start); + }; + const elementDragMove = (event: PointerEvent): void => { + if (!elementDrag) { + const source = resolveDraggedTarget(event); + setAffordanceCursor( + source?.target.visual.kind === 'axis' ? 'axis-label' : source ? 'mark' : 'plot', + Boolean(source), + ); + return; + } + const current = localPoint(event); + if (!elementDrag.moved && Math.hypot( + current.x - elementDrag.start.x, + current.y - elementDrag.start.y, + ) < 4) return; + if (!elementDrag.axis && elementDrag.eligibleAxes.length > 0) { + const deltaX = Math.abs(current.x - elementDrag.start.x); + const deltaY = Math.abs(current.y - elementDrag.start.y); + const preferred = deltaY > deltaX ? 'y' : 'x'; + const axes = (plan.reorderAxes ?? (plan.reorderAxis ? [plan.reorderAxis] : [])) + .filter(({ axis }) => elementDrag?.eligibleAxes.includes(axis)); + elementDrag.axis = axes.find((axis) => axis.axis === preferred)?.axis ?? axes[0]?.axis; + } + if (elementDrag.axis) { + const destination = resolveReorderDestination(current, elementDrag.axis); + if (destination) { + elementDrag.destination = destination.target; + elementDrag.projection = destination.projection; + } + } + elementDrag.moved = true; + suppressClick = true; + regionDragging = true; + container.style.cursor = 'grabbing'; + void dispatchElementDrag('preview', event, current); + }; + const elementDragEnd = (event: PointerEvent): void => { + if (!elementDrag) return; + const drag = elementDrag; + const current = localPoint(event); + const destination = drag.axis + ? resolveReorderDestination(current, drag.axis) + : null; + if (destination) { + drag.destination = destination.target; + drag.projection = destination.projection; + } + if (drag.moved) void dispatchElementDrag('commit', event, current); + else void dispatchElementDrag('commit', event, current, false); + elementDrag = undefined; + regionDragging = false; + container.style.cursor = previousCursor; + if (drag.moved) window.setTimeout(() => { suppressClick = false; }, 0); + }; + const elementDragCancel = (event: PointerEvent): void => { + if (!elementDrag) return; + const current = localPoint(event); + void dispatchElementDrag('cancel', event, current); + elementDrag = undefined; + regionDragging = false; + container.style.cursor = previousCursor; + }; + if (elementDragInteraction) { + container.style.userSelect = 'none'; + container.addEventListener('pointerdown', elementDragStart, true); + container.addEventListener('pointermove', elementDragMove, true); + container.addEventListener('pointerup', elementDragEnd, true); + container.addEventListener('pointercancel', elementDragCancel, true); + } + const mountedRegionInteraction = regionInteraction + && plan.angularXBrush + && regionInteraction.eventSource.type === 'region' + && regionInteraction.eventSource.axis === 'x' + && regionInteraction.eventSource.regionGeometry === undefined + ? { + ...regionInteraction, + eventSource: { ...regionInteraction.eventSource, regionGeometry: 'angular' as const }, + } + : regionInteraction; + const regionGesture = mountedRegionInteraction ? mountVegaRegionGesture({ + view, + container, + interaction: mountedRegionInteraction, + getSelected: selectedKeys, + setSelected: (next) => { + if (mountedRegionInteraction.eventSource.viewport) return; + previewUpdates.set(mountedRegionInteraction.id, { + id: mountedRegionInteraction.id, + ops: [{ + op: 'set-style', + targets: next.size > 0 ? [{ + visual: { kind: 'region', role: 'selection' }, + elements: [...next].map((key) => associateSemanticElementRenderKeys({ value: {} }, [key])), + }] : [], + value: { state: next.size > 0 ? 'emphasized' : 'normal' }, + }], + }); + }, + coordinateSpace, + containerLayoutSize, + resolveTarget: (gesture, role, hits) => resolveTarget(gesture, role, hits), + dispatch: (event) => dispatch(mountedRegionInteraction, event), + clearHover, + clearAnnotation: clearAnnotations, + sync: renderUpdates, + setSuppressClick: (suppress) => { suppressClick = suppress; }, + setDragging: (dragging) => { regionDragging = dragging; }, + resetViewport: resetViewportRegion, + }) : undefined; + const navigationGesture = navigationInteraction ? mountVegaNavigationGesture({ + container, + interaction: navigationInteraction, + availableAxes: Object.keys(plan.navigationAxes ?? {}) as ('x' | 'y')[], + coordinateSpace, + dispatch: (event) => dispatchNavigation(navigationInteraction, event), + setSuppressClick: (suppress) => { suppressClick = suppress; }, + setDragging: (dragging) => { regionDragging = dragging; }, + }) : undefined; + setAffordanceCursor('plot', false); + const dismissKeyDown = (event: KeyboardEvent): void => { + if (regionInteraction || event.key !== 'Escape' || !dismissPolicy.escape) return; + selectedLegend = null; + clearDismissibleState(); + }; + if (dismissPolicy.escape && !regionInteraction) { + container.addEventListener('keydown', dismissKeyDown); + } + + // One tab stop enters the chart; arrows move to the nearest target in that direction. + let activeKeyboardKey: string | undefined; + const keyboardTargets = (): any[] => keyboardTargetItems(sceneItems(view)); + const keyboardFocus = (item: any) => { + const hit = renderHit(item); + if (!hit) return undefined; + return { + point: { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }, + target: resolveTarget('click', 'mark', [hit]), + key: hit.datum[INTERACTION_KEY], + }; + }; + const keyboardInteraction: CanvasInteractionDef = { + id: 'keyboard-targeting', + eventSource: keyboardTrigger, + }; + const moveKeyboardTarget = (direction: SpatialDirection): void => { + const items = keyboardTargets(); + if (items.length === 0) return; + const movementAxis = direction === 'left' || direction === 'right' ? 'x' : 'y'; + const movementType = plan.axisFields?.[movementAxis]?.type; + const discreteAxis = movementType === 'nominal' || movementType === 'ordinal' + ? movementAxis + : undefined; + const current = activeKeyboardKey === undefined + ? undefined + : items.find((item) => renderHit(item)?.datum[INTERACTION_KEY] === activeKeyboardKey); + const next = current + ? nextItemInDirection(items, { + x: (current.bounds.x1 + current.bounds.x2) / 2, + y: (current.bounds.y1 + current.bounds.y2) / 2, + }, direction, discreteAxis) + : direction === 'right' || direction === 'down' ? items[0] : items[items.length - 1]; + if (!next) return; + const active = keyboardFocus(next); + if (!active) return; + activeKeyboardKey = typeof active.key === 'string' ? active.key : undefined; + if (targetFeedback?.keyboard) targetFeedbackOverlay.render(next, active.target, 'keyboard'); + emitCanvasInteractionEvent(keyboardInteraction, toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', + target: active.target, point: active.point, + }, keyboardTrigger)); + void setHover(activeKeyboardKey ? [activeKeyboardKey] : []); + }; + const activateKeyboardTarget = (): void => { + if (activeKeyboardKey === undefined) return; + const item = keyboardTargets() + .find((candidate) => renderHit(candidate)?.datum[INTERACTION_KEY] === activeKeyboardKey); + const active = item ? keyboardFocus(item) : undefined; + if (!active) return; + for (const interaction of clickInteractions) { + void dispatch(interaction, { + type: 'semantic', source: 'element', phase: 'commit', + target: active.target, point: active.point, + }, null, 'activate-element'); + } + }; + const keyboardKeyDown = (event: KeyboardEvent): void => { + switch (event.key) { + case 'ArrowRight': + event.preventDefault(); + moveKeyboardTarget('right'); + return; + case 'ArrowLeft': + event.preventDefault(); + moveKeyboardTarget('left'); + return; + case 'ArrowDown': + event.preventDefault(); + moveKeyboardTarget('down'); + return; + case 'ArrowUp': + event.preventDefault(); + moveKeyboardTarget('up'); + return; + case 'Enter': + case ' ': + event.preventDefault(); + activateKeyboardTarget(); + return; + case 'Escape': + activeKeyboardKey = undefined; + targetFeedbackOverlay.clear(); + void setHover([]); + return; + default: + } + }; + const keyboardEnabled = keyboardTargeting && !!resolve; + const keyboardFocusOut = (event: FocusEvent): void => { + if (event.relatedTarget instanceof Node && container.contains(event.relatedTarget)) return; + activeKeyboardKey = undefined; + targetFeedbackOverlay.clear(); + void setHover([]); + }; + if (keyboardEnabled) { + container.tabIndex = container.tabIndex >= 0 ? container.tabIndex : 0; + container.addEventListener('keydown', keyboardKeyDown); + container.addEventListener('focusout', keyboardFocusOut); + } + + // Overlays project scenegraph geometry into screen pixels, so every one of + // them is re-projected whenever the rendered size changes. + const syncOverlays = (): void => { + renderPathFocus(); + renderLegendRange(); + for (const overlay of annotationOverlays.values()) overlay.sync(); + dataOverlay.sync(); + regionGesture?.sync(); + reorderResetControls.layout(); + }; + let observedRenderer: Element | undefined; + // A drag-resize fires per frame, so repeated observations collapse into one pass. + let syncFrame: number | undefined; + const scheduleSync = (): void => { + if (typeof requestAnimationFrame === 'undefined') { + syncOverlays(); + return; + } + if (syncFrame !== undefined) return; + syncFrame = requestAnimationFrame(() => { + syncFrame = undefined; + syncOverlays(); + }); + }; + const resizeObserver = typeof ResizeObserver === 'undefined' + ? undefined + : new ResizeObserver(() => scheduleSync()); + // The container catches responsive layout; the renderer catches the chart + // itself being sized independently of it. + resizeObserver?.observe(container); + const observeRenderer = (): void => { + const renderer = container.querySelector('canvas, svg'); + if (!renderer || renderer === observedRenderer) return; + if (observedRenderer) resizeObserver?.unobserve(observedRenderer); + resizeObserver?.observe(renderer); + observedRenderer = renderer; + }; + observeRenderer(); + + const destroy = (): void => { + if (clickInteractions.length > 0 || singleSeriesInspectInteractions.length > 0) { + view.removeEventListener('click', clickHandler); + } + if (hoverPresentationInteractions.length > 0) { + view.removeEventListener('mousemove', hoverHandler); + view.removeEventListener('mouseout', scheduleHoverClear); + } + if (cursorInteractions.length > 0) view.removeEventListener('mousemove', affordanceHandler); + if (hoverClearTimer !== undefined) clearTimeout(hoverClearTimer); + if (dismissPolicy.escape && !regionInteraction) { + container.removeEventListener('keydown', dismissKeyDown); + } + if (keyboardEnabled) { + container.removeEventListener('keydown', keyboardKeyDown); + container.removeEventListener('focusout', keyboardFocusOut); + } + if (contextInteractions.length > 0) container.removeEventListener('contextmenu', contextHandler); + if (longPressInteractions.length > 0) { + cancelLongPress(); + container.removeEventListener('pointerdown', longPressStart, true); + container.removeEventListener('pointerup', cancelLongPress, true); + container.removeEventListener('pointermove', longPressMove, true); + container.removeEventListener('pointercancel', cancelLongPress, true); + } + if (doubleInteractions.length > 0) container.removeEventListener('dblclick', doubleHandler); + if (dismissPolicy.click) { + cancelPendingDismiss(); + view.removeEventListener('click', dismissOnClick); + } + if (inspectInteractions.length > 0) { + container.removeEventListener('pointermove', inspectHandler); + container.removeEventListener('pointerleave', inspectLeave); + container.removeEventListener('wheel', inspectWheel); + container.removeEventListener('contextmenu', inspectContext); + } + regionGesture?.destroy(); + navigationGesture?.destroy(); + if (elementDragInteraction) { + container.removeEventListener('pointerdown', elementDragStart, true); + container.removeEventListener('pointermove', elementDragMove, true); + container.removeEventListener('pointerup', elementDragEnd, true); + container.removeEventListener('pointercancel', elementDragCancel, true); + } + focusOverlay.destroy(); + targetFeedbackOverlay.destroy(); + legendRangeOverlay.destroy(); + for (const overlay of annotationOverlays.values()) overlay.destroy(); + annotationOverlays.clear(); + inspectGuideOverlay.destroy(); + dragPreviewOverlay.destroy(); + freeformOverlay.destroy(); + restoreAxisStyles(); + dataOverlay.destroy(); + reorderResetControls.destroy(); + viewportResetControl.destroy(); + resizeObserver?.disconnect(); + observedRenderer = undefined; + if (syncFrame !== undefined && typeof cancelAnimationFrame !== 'undefined') { + cancelAnimationFrame(syncFrame); + syncFrame = undefined; + } + if (elementDragInteraction || suppressTextSelection) container.style.userSelect = previousUserSelect; + if (longPressInteractions.length > 0) container.style.touchAction = previousTouchAction; + if (!regionInteraction && !navigationInteraction) container.style.cursor = previousCursor; + }; + const clearUpdate = async (id: string): Promise => { + if (retainedUpdates.delete(id)) await renderUpdates(); + }; + const replaceUpdates = async ( + nextUpdates: readonly ChartUpdate[], + ): Promise => { + retainedUpdates.clear(); + const results: ChartUpdateResult[] = []; + for (const update of nextUpdates) { + const resolved = resolveUpdate(update); + retainedUpdates.set(update.id, presentUpdate(resolved.update, context())); + results.push(resolved.result); + } + await renderUpdates(); + return results; + }; + return { + getInteractionContext: context, + applyUpdate: (update, _options) => storeUpdate(update, retainedUpdates), + setUpdates: replaceUpdates, + clearUpdate, + refresh: () => { + observeRenderer(); + syncOverlays(); + }, + destroy, + }; +} diff --git a/packages/flint-js/src/vegalite/interactions/stores.ts b/packages/flint-js/src/vegalite/interactions/stores.ts new file mode 100644 index 00000000..ec9ce1a5 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactions/stores.ts @@ -0,0 +1,18 @@ +export const INTERACTION_STORE = '__flint_interaction_store'; +export const HOVER_STORE = '__flint_hover_store'; +export const HIDDEN_STORE = '__flint_hidden_store'; +export const LEGEND_HIDDEN_STORE = '__flint_legend_hidden_store'; +export const LEGEND_HOVER_STORE = '__flint_legend_hover_store'; +export const AXIS_HOVER_STORE = '__flint_axis_hover_store'; +export const LEGEND_SELECTION_STORE = '__flint_legend_selection_store'; +export const STYLE_SIGNAL = '__flint_style_by_key'; + +export const INTERACTION_STORES: readonly string[] = [ + INTERACTION_STORE, + HOVER_STORE, + HIDDEN_STORE, + LEGEND_HIDDEN_STORE, + LEGEND_HOVER_STORE, + AXIS_HOVER_STORE, + LEGEND_SELECTION_STORE, +]; \ No newline at end of file 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..75660663 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactive-focus.ts @@ -0,0 +1,137 @@ +import { DEFAULT_DIM_OPACITY } from '../interactive/presets/utils'; + +const FOCUS_PARAM = '__flint_focus'; +const FOCUS_KEY = '__flint_focus_key'; +const CLEAR_MARK = '__flint_focus_clear'; +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(DEFAULT_DIM_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..e7524ae1 --- /dev/null +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -0,0 +1,240 @@ +import { applyCategoryViewports } from '../core/filter-overflow'; +import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import { isCanvasInteraction, type InteractionDef } from '../interactive/interactions'; +import type { InteractionDismissPolicy, InteractiveRendererAdapter, TargetFeedbackOptions, ViewportState } from '../interactive/types'; +import { assembleVegaLite } from './assemble'; +import { + addVegaLiteInteractions, + collectVegaAxisTargets, + injectVegaInteractionStore, + injectVegaNavigationSignals, + injectVegaReorderSignal, + findVegaAxisScale, + withoutSemanticInteractionField, +} from './interactions/compile'; +import { mountVegaInteractions } from './interactions/runtime'; +import { INTERACTION_STORES } from './interactions/stores'; +import { compile } from 'vega-lite'; +import { Error as VegaError, parse, View } from 'vega'; +import { Handler } from 'vega-tooltip'; + +export interface VegaInteractiveRendererOptions { + renderer?: 'canvas' | 'svg'; + interactions?: readonly InteractionDef[]; + enableSemanticUpdates?: boolean; + expressionInterpreter?: unknown; + background?: string; + assistDistance?: number; + hoverTolerance?: number; + keyboardTargeting?: boolean; + targetFeedback?: { assisted: TargetFeedbackOptions | false; keyboard: TargetFeedbackOptions | false }; + dismiss?: InteractionDismissPolicy | false; +} + +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 interactions = options.interactions ?? []; + const canvasInteractions = interactions.filter(isCanvasInteraction); + const interactionPlan = addVegaLiteInteractions( + vlSpec, + interactions, + options.enableSemanticUpdates, + ); + const vegaSpec = compile(vlSpec).spec as any; + if (interactionPlan) { + interactionPlan.axisTargets = collectVegaAxisTargets( + vegaSpec, + interactionPlan.axisFields, + interactionPlan.reorderAxes, + canvasInteractions.some((interaction) => interaction.affordances?.some((affordance) => + affordance.target === 'axis-label' && affordance.hover)) + ? interactionPlan.selectionBoundary?.color ?? '#20262c' + : undefined, + ); + if (interactionPlan.semanticStores) { + injectVegaInteractionStore(vegaSpec, interactionPlan); + } + interactionPlan.navigationAxes = injectVegaNavigationSignals( + vegaSpec, + interactionPlan.navigationChannels, + ); + interactionPlan.reorderAxes = (interactionPlan.reorderAxes ?? []) + .map((axis) => injectVegaReorderSignal(vegaSpec, axis)) + .filter((axis): axis is NonNullable => !!axis); + interactionPlan.reorderAxis = interactionPlan.reorderAxes[0]; + } + const source = vegaSpec.data + ?.find((entry: any) => Array.isArray(entry.values) && !INTERACTION_STORES.includes(entry.name)) + ?.name as string | undefined; + if (viewports.length > 0 && !source) { + throw new Error('Compiled chart has no mutable inline data source.'); + } + if (interactionPlan) { + interactionPlan.overlayScales = { + x: findVegaAxisScale(vegaSpec, 'x')?.name, + y: findVegaAxisScale(vegaSpec, 'y')?.name, + color: vegaSpec.scales?.find((scale: any) => scale.name === 'color')?.name + ?? (() => { + const matches = (vegaSpec.scales ?? []).filter((scale: any) => + typeof scale.name === 'string' && scale.name.endsWith('_color')); + return matches.length === 1 ? matches[0].name : undefined; + })(), + }; + interactionPlan.mutableDataSource = source; + interactionPlan.initialDataRows = firstInput.data.values ?? []; + } + 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, withoutSemanticInteractionField(value)); + }); + await view.runAsync(); + const interactionController = interactionPlan + ? mountVegaInteractions( + view, + container, + input.chart_spec.chartType, + interactionPlan, + interactions, + interactionPlan.resolve, + interactionPlan.presentUpdate ?? ((update) => update), + options.assistDistance, + options.hoverTolerance ?? 0, + options.keyboardTargeting ?? false, + options.targetFeedback, + options.dismiss, + ) + : undefined; + + 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, + getInteractionContext() { + return interactionController?.getInteractionContext() ?? { + chartType: input.chart_spec.chartType, + selected: [], + }; + }, + async applyUpdate(update, options) { + if (interactionController) return interactionController.applyUpdate(update, options); + return { + status: 'unsupported', + resolvedTargets: 0, + unresolvedTargets: [], + unsupportedOps: [...new Set(update.ops.map((op) => op.op))], + }; + }, + async setUpdates(updates) { + if (interactionController) return interactionController.setUpdates(updates); + return updates.map((update) => ({ + status: 'unsupported' as const, + resolvedTargets: 0, + unresolvedTargets: [], + unsupportedOps: [...new Set(update.ops.map((op) => op.op))], + })); + }, + async clearUpdate(id) { + await interactionController?.clearUpdate(id); + }, + refresh() { + interactionController?.refresh(); + }, + 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); + interactionController?.destroy(); + view.finalize(); + container.replaceChildren(); + }, + }; + }, + }; +} \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts index 57518511..50718017 100644 --- a/packages/flint-js/src/vegalite/templates/area.ts +++ b/packages/flint-js/src/vegalite/templates/area.ts @@ -4,6 +4,13 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { makeCartesianPivot } from '../../core/pivot'; import { defaultBuildEncodings, setMarkProp, alignStackOrderToColorOrder } from './utils'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + resolveSeriesTarget, + targetFromHits, +} from '../../core/interaction-semantics'; +import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/presentation/annotation'; const interpolateConfigProperty: ChartPropertyDef = { key: "interpolate", label: "Curve", type: "discrete", options: [ @@ -24,6 +31,16 @@ function applyInterpolate(vgSpec: any, config?: Record): void { vgSpec.mark = setMarkProp(vgSpec.mark, 'interpolate', config.interpolate); } +function resolveAreaTarget(event: any, context: any, seriesField: string | undefined) { + if (event.role === 'text-label' && seriesField) { + const value = event.hits[0]?.datum?.[seriesField]; + const hits = context.allHits.filter((hit: any) => + hit.markType === 'area' && hit.datum?.[seriesField] === value); + return targetFromHits(hits, context.keyField, { kind: 'path', role: 'text-label' }); + } + return resolveSeriesTarget(event, context, seriesField); +} + /** * A single-series area (no `color` to stack) still gets an implicit zero-offset * stack from Vega-Lite. When such an area is FACETED (`column`/`row`, sharing the @@ -116,8 +133,27 @@ export const areaChartDef: ChartTemplateDef = { chart: "Area Chart", template: { mark: "area", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], + navigation: {}, markCognitiveChannel: 'area', geometryKinds: ['area', 'line', 'point'], + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color']), + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['area'], + renderHoverStyles: { area: { opacity: 'spotlight' } }, + resolve: (event, context) => resolveAreaTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint'), + transitionAnnotationText(resolvedEncodings.y?.field), + ), + }; + }, declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.5 }, }), @@ -180,7 +216,25 @@ export const streamgraphDef: ChartTemplateDef = { chart: "Streamgraph", template: { mark: "area", encoding: {} }, channels: ["x", "y", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'area', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color']), + categoryField: firstDiscreteEncodingField(resolvedEncodings, ['x']), + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['area'], + renderHoverStyles: { area: { opacity: 'spotlight' } }, + resolve: (event, context) => resolveAreaTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint', 'center', 'right', 'left'), + transitionAnnotationText(resolvedEncodings.y?.field), + ), + }; + }, declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.5 }, }), diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts index eaab140a..c797c7b7 100644 --- a/packages/flint-js/src/vegalite/templates/bar-table.ts +++ b/packages/flint-js/src/vegalite/templates/bar-table.ts @@ -3,7 +3,18 @@ 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 { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + targetFromHits, +} from '../../core/interaction-semantics'; +import { + barAnnotationCandidates, + presentAnnotationUpdate, + valueAnnotationText, +} from '../../interactive/presentation/annotation'; import { formatSpecToVegaExpr } from '../format'; /** @@ -36,6 +47,32 @@ export const barTableDef: ChartTemplateDef = { }, channels: ["y", "x", "color", "column", "row"], markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['y']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + const valueField = resolvedEncodings.x?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['y', 'color']), + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: { rect: { opacity: 'contrast' } }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'bar-table-row' }); + }, + presentUpdate: presentAnnotationUpdate( + () => barAnnotationCandidates('x'), + valueAnnotationText(valueField), + ), + }; + }, + 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 +152,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 +240,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 +318,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 +333,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 +479,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 +546,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 +686,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 +779,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/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts index f7c8467e..3c91ce63 100644 --- a/packages/flint-js/src/vegalite/templates/bar.ts +++ b/packages/flint-js/src/vegalite/templates/bar.ts @@ -6,6 +6,24 @@ import { makeSortAction } from '../../core/encoding-actions'; import { makeCartesianPivot } from '../../core/pivot'; import { planBandDodge, resolveDodge } from '../../core/band-dodge'; import { snapToBoundHeuristic } from '../../core/field-semantics'; +import { + associateSemanticElementRenderKeys, + elementsFromHits, + legendMatchedHits, + MUTED_HOVER_STROKE, + semanticElementRenderKeys, + type SemanticResolveContext, + type SemanticResolveEvent, + type SemanticTarget, +} from '../../core/interaction-semantics'; +import { + annotationCandidates, + barAnnotationCandidates, + countAnnotationText, + presentAnnotationUpdate, + valueAnnotationText, +} from '../../interactive/presentation/annotation'; +import { withInteractionTextLabel } from '../interaction-provenance'; import { detectBandedAxisFromSemantics, detectBandedAxisForceDiscrete, } from '../../core/axis-detection'; @@ -14,6 +32,12 @@ import { resolveAsDiscrete, alignStackOrderToColorOrder, } from './utils'; +const rectHoverStyle = (resolvedEncodings: Readonly>) => ({ + rect: resolvedEncodings.opacity?.field + ? { stroke: MUTED_HOVER_STROKE, strokeWidth: 1.5 } + : { opacity: 'contrast' as const }, +}); + /** * Fraction of a lane's pitch a locally-dodged bar fills, leaving a small gap * between the bars inside one band. A house that states its own @@ -38,6 +62,77 @@ const HEATMAP_SCHEME_COLORS: Record = { const DEFAULT_HEATMAP_SCHEME = 'blues'; +function discreteField( + resolvedEncodings: Readonly>, + channels: readonly string[], +): string | undefined { + return channels + .map((channel) => resolvedEncodings[channel]) + .find((encoding) => encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal')) + ?.field; +} + +function primaryMetric( + resolvedEncodings: Readonly>, +): { axis: 'x' | 'y'; field: string | undefined } { + const axis = resolvedEncodings.x?.type === 'quantitative' ? 'x' : 'y'; + return { axis, field: resolvedEncodings[axis]?.field }; +} + +function tooltipForChannels(encoding: Record, channels: readonly string[]): any[] { + const tooltipKeys = ['field', 'type', 'title', 'aggregate', 'bin', 'timeUnit', 'format', 'formatType']; + return channels.flatMap((channel) => { + const source = encoding[channel]; + if (!source?.field) return []; + const tooltip: Record = {}; + for (const key of tooltipKeys) { + if (source[key] !== undefined) tooltip[key] = source[key]; + } + return [tooltip]; + }); +} + +function resolveBarTarget( + event: SemanticResolveEvent, + context: SemanticResolveContext, + seriesField: string | undefined, +): SemanticTarget | null { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField && event.legend + ? legendMatchedHits(event, context, legendField) + : event.hits; + const elements = elementsFromHits(hits, context.keyField); + return elements.length > 0 + ? { visual: { kind: 'mark', role: event.role }, elements } + : null; +} + +function resolveHistogramTarget( + event: SemanticResolveEvent, + context: SemanticResolveContext, + sourceField: string | undefined, + seriesField: string | undefined, +): SemanticTarget | null { + const target = resolveBarTarget(event, context, seriesField); + if (!target || !sourceField) return target; + return { + ...target, + elements: target.elements.map((element) => associateSemanticElementRenderKeys({ + ...element, + value: { + field: sourceField, + range: { + start: element.value.__bin_start, + end: element.value.__bin_end, + }, + ...(seriesField && element.value[seriesField] !== undefined + ? { [seriesField]: element.value[seriesField] } + : {}), + }, + }, semanticElementRenderKeys(element))), + }; +} + function isDivergingHeatmapScheme(scheme: string | undefined): boolean { return scheme === 'blueorange' || scheme === 'redblue'; } @@ -76,7 +171,37 @@ export const barChartDef: ChartTemplateDef = { chart: "Bar Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], + navigation: {}, markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y', 'color'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const categoryField = discreteField(resolvedEncodings, ['x', 'y']); + const seriesField = discreteField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + const metric = primaryMetric(resolvedEncodings); + const categoryAxis = metric.axis === 'x' ? 'y' : 'x'; + const reorderAxis: { axis: 'x' | 'y'; field: string } | undefined = categoryField + && !resolvedEncodings.column?.field && !resolvedEncodings.row?.field + ? { axis: categoryAxis, field: categoryField } + : undefined; + return { + fields: [...new Set(fields)], + categoryField, + reorderAxis, + reorderAxes: reorderAxis ? [reorderAxis] : undefined, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + resolve: (event, context) => resolveBarTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => barAnnotationCandidates(metric.axis), + valueAnnotationText(metric.field), + ), + }; + }, geometryKinds: ['band'], declareLayoutMode: (cs, table) => { const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' }); @@ -135,6 +260,28 @@ export const pyramidChartDef: ChartTemplateDef = { }, channels: ["x", "y", "color"], markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y', 'color'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const categoryField = discreteField(resolvedEncodings, ['y']); + const seriesField = discreteField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + const metric = primaryMetric(resolvedEncodings); + return { + fields: [...new Set(fields)], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + resolve: (event, context) => resolveBarTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => barAnnotationCandidates(metric.axis), + valueAnnotationText(metric.field), + ), + }; + }, declareLayoutMode: () => ({ axisFlags: { y: { banded: true } }, }), @@ -250,8 +397,31 @@ export const pyramidChartDef: ChartTemplateDef = { export const groupedBarChartDef: ChartTemplateDef = { chart: "Grouped Bar Chart", template: { mark: "bar", encoding: {} }, - channels: ["x", "y", "group", "column", "row"], + channels: ["x", "y", "group", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y', 'color'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const categoryField = discreteField(resolvedEncodings, ['x', 'y']); + const seriesField = discreteField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + const metric = primaryMetric(resolvedEncodings); + return { + fields: [...new Set(fields)], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + resolve: (event, context) => resolveBarTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => barAnnotationCandidates(metric.axis), + valueAnnotationText(metric.field), + ), + }; + }, declareLayoutMode: (cs, table, chartProperties) => { const result = detectBandedAxisForceDiscrete(cs, table, { preferAxis: 'x' }); const axis = result?.axis || 'x'; @@ -353,7 +523,30 @@ export const stackedBarChartDef: ChartTemplateDef = { chart: "Stacked Bar Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y', 'color'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const categoryField = discreteField(resolvedEncodings, ['x', 'y']); + const seriesField = discreteField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + const metric = primaryMetric(resolvedEncodings); + return { + fields: [...new Set(fields)], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + resolve: (event, context) => resolveBarTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => barAnnotationCandidates(metric.axis), + valueAnnotationText(metric.field), + ), + }; + }, declareLayoutMode: (cs, table) => { const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' }); return { @@ -364,6 +557,7 @@ export const stackedBarChartDef: ChartTemplateDef = { }, instantiate: (spec, ctx) => { defaultBuildEncodings(spec, ctx.resolvedEncodings); + spec.encoding.tooltip = tooltipForChannels(spec.encoding, ['x', 'y', 'color']); // Apply stack mode const config = ctx.chartProperties; const hasStackSeries = !!ctx.channelSemantics.color?.field; @@ -415,7 +609,29 @@ export const histogramDef: ChartTemplateDef = { }, }, channels: ["x", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const sourceField = resolvedEncodings.x?.field; + const colorField = resolvedEncodings.color?.field; + const seriesField = discreteField(resolvedEncodings, ['color']); + return { + fields: [...new Set([colorField, '__bin_start', '__bin_end'].filter((field): field is string => !!field))], + provenanceFields: colorField ? [colorField] : [], + rangeProvenance: sourceField + ? [{ field: sourceField, startField: '__bin_start', endField: '__bin_end' }] + : [], + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + resolve: (event, context) => resolveHistogramTarget(event, context, sourceField, seriesField), + presentUpdate: presentAnnotationUpdate( + () => barAnnotationCandidates('y'), + countAnnotationText, + ), + }; + }, // A binned x is an index axis, not a measure: the reader keys counts off // its intervals, and its identity comes from banding even though the field // is quantitative. Declaring it banded keeps the count off it and stops a @@ -431,6 +647,21 @@ export const histogramDef: ChartTemplateDef = { if (binCount && spec.encoding?.x) { spec.encoding.x.bin = { maxbins: binCount }; } + const x = spec.encoding?.x; + if (x?.field) { + const sourceField = x.field; + spec.transform = [ + ...(spec.transform ?? []), + { bin: x.bin ?? true, field: sourceField, as: ['__bin_start', '__bin_end'] }, + ]; + spec.encoding.x = { + ...x, + field: '__bin_start', + bin: 'binned', + title: x.title ?? sourceField, + }; + spec.encoding.x2 = { field: '__bin_end' }; + } adjustBarMarks(spec, ctx); }, properties: [ @@ -453,7 +684,38 @@ export const heatmapDef: ChartTemplateDef = { chart: "Heatmap", template: { mark: "rect", encoding: {} }, channels: ["x", "y", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'color', + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y', 'color'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const colorField = resolvedEncodings.color?.field; + const categoryField = discreteField(resolvedEncodings, ['x', 'y']); + const reorderAxes = (['x', 'y'] as const).flatMap((axis) => { + const encoding = resolvedEncodings[axis]; + return encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal') + ? [{ axis, field: encoding.field }] + : []; + }); + return { + fields: [...new Set(fields)], + categoryField, + legendFields: colorField ? { color: colorField } : undefined, + reorderAxis: reorderAxes[0], + reorderAxes, + selectableMarks: ['rect'], + renderHoverStyles: rectHoverStyle(resolvedEncodings), + renderSelectionStyles: resolvedEncodings.color?.type === 'quantitative' + || resolvedEncodings.color?.type === 'temporal' + ? { rect: { boundary: 'contiguous-region' } } + : undefined, + resolve: (event, context) => resolveBarTarget(event, context, undefined), + presentUpdate: presentAnnotationUpdate(() => annotationCandidates( + 'center', 'top', 'bottom', 'right', 'left', + )), + }; + }, ownsValueLabels: true, declareLayoutMode: (_channelSemantics, _table, chartProperties) => { const showTextLabels = !!chartProperties?.showTextLabels; @@ -626,7 +888,7 @@ export const heatmapDef: ChartTemplateDef = { ...(baseEncoding.color ? { color: spec.encoding.color } : {}), ...(spec.encoding.opacity ? { opacity: spec.encoding.opacity } : {}), }, - }, { + }, withInteractionTextLabel({ mark: { type: 'text', align: 'center', @@ -649,7 +911,7 @@ export const heatmapDef: ChartTemplateDef = { ? { condition: textColorConditions, value: defaultTextColor } : { value: defaultTextColor }, }, - }]; + }, { presentation: 'on-mark' })]; spec.layer = layers; delete spec.mark; diff --git a/packages/flint-js/src/vegalite/templates/bullet.ts b/packages/flint-js/src/vegalite/templates/bullet.ts index a31bf7eb..b17089ab 100644 --- a/packages/flint-js/src/vegalite/templates/bullet.ts +++ b/packages/flint-js/src/vegalite/templates/bullet.ts @@ -2,6 +2,17 @@ // Licensed under the MIT License. import { ChartTemplateDef } from '../../core/types'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + resolveSeriesTarget, +} from '../../core/interaction-semantics'; +import { + annotationCandidates, + comparisonAnnotationText, + presentAnnotationUpdate, +} from '../../interactive/presentation/annotation'; +import { INTERACTION_ROLE } from '../interactions/hit-adapter'; /** * Bullet chart — a compact KPI panel: one row per label, each showing a measure @@ -41,6 +52,38 @@ export const bulletChartDef: ChartTemplateDef = { }, channels: ["y", "x", "goal", "color", "column", "row"], markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = resolvedEncodings.y?.field; + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + const actualField = resolvedEncodings.x?.field; + const expectedField = resolvedEncodings.goal?.field; + const statusField = !colorField && actualField && expectedField ? '__status' : undefined; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['y', 'x', 'goal', 'color', 'column', 'row']), + categoryField, + seriesField, + legendFields: colorField || statusField ? { color: colorField ?? statusField! } : undefined, + selectableMarks: ['bar', 'tick'], + renderHoverStyles: { + // Vega compiles a Vega-Lite bar to a rect. Treat that filled + // area like every other bar: preserve its colour, add no + // border, and distinguish it through opacity contrast. + rect: { opacity: 'contrast' }, + }, + resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('right', 'left', 'top', 'bottom', 'center').map((candidate) => ({ + ...candidate, + connectorAnchors: [ + { role: 'bullet-actual', connection: 'value-end' as const, valueAxis: 'x' as const }, + { role: 'bullet-expected', connection: 'center' as const }, + ], + })), + comparisonAnnotationText(actualField, expectedField), + ), + }; + }, declareLayoutMode: () => ({ axisFlags: { y: { banded: true } }, }), @@ -120,6 +163,10 @@ export const bulletChartDef: ChartTemplateDef = { title: null, }; } + barLayer.transform = [ + ...(barLayer.transform ?? []), + { calculate: "'bullet-actual'", as: INTERACTION_ROLE }, + ]; layers.push(barLayer); // --- Target marker — a dark tick at the goal, sized to the row band --- @@ -134,6 +181,7 @@ export const bulletChartDef: ChartTemplateDef = { ? Math.min(band, Math.max(8, Math.round(band * 0.72))) : 22; layers.push({ + transform: [{ calculate: "'bullet-expected'", as: INTERACTION_ROLE }], mark: { type: 'tick', color: '#1a1a1a', thickness: 3, opacity: 1, size: tickSize }, encoding: { x: { field: goal.field, type: 'quantitative', axis: xAxis }, diff --git a/packages/flint-js/src/vegalite/templates/bump.ts b/packages/flint-js/src/vegalite/templates/bump.ts index 351dfe09..0f975ee0 100644 --- a/packages/flint-js/src/vegalite/templates/bump.ts +++ b/packages/flint-js/src/vegalite/templates/bump.ts @@ -4,6 +4,13 @@ import { ChartTemplateDef } from '../../core/types'; import { defaultBuildEncodings } from './utils'; import { interpolateConfigProperty, applyInterpolate } from './line'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + MUTED_HOVER_STROKE, + resolveSeriesTarget, +} from '../../core/interaction-semantics'; +import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/presentation/annotation'; /** Semantic types that indicate a rank-like field */ const RANK_SEMANTIC_TYPES = new Set(['Rank', 'Score', 'Level']); @@ -18,7 +25,29 @@ export const bumpChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "detail", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color', 'detail']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color', 'detail']), + categoryField: firstDiscreteEncodingField(resolvedEncodings, ['x']), + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['line', 'point'], + renderHoverStyles: { + line: { strokeWidth: 3 }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + renderSelectionStyles: { line: { strokeWidthMultiplier: 1.2 } }, + resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint', 'center', 'right', 'left'), + transitionAnnotationText(resolvedEncodings.y?.field), + ), + }; + }, properties: [interpolateConfigProperty], declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 80, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.4 }, diff --git a/packages/flint-js/src/vegalite/templates/calendar.ts b/packages/flint-js/src/vegalite/templates/calendar.ts index 4fa11c46..5fb851f8 100644 --- a/packages/flint-js/src/vegalite/templates/calendar.ts +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -18,6 +18,16 @@ */ import { ChartTemplateDef, ChartPropertyDef, EncodingActionDef } from '../../core/types'; +import { + legendMatchedHits, + MUTED_HOVER_STROKE, + targetFromHits, + type SemanticElement, +} from '../../core/interaction-semantics'; +import { + annotationCandidates, + presentAnnotationUpdate, +} from '../../interactive/presentation/annotation'; /** Weekday row order, Monday-first — mirrors the ECharts template's dayLabel.firstDay = 1. */ const WEEKDAY_ORDER = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; @@ -34,6 +44,21 @@ const DATE_FIELD = '__flintCalendarDate'; const WEEK_FIELD = '__flintCalendarWeek'; const WEEKDAY_FIELD = '__flintCalendarWeekday'; +function calendarAnnotationText(valueField: string): (element: SemanticElement) => string | undefined { + return (element) => { + const record = element.value ?? element.records?.[0] ?? {}; + const rawDate = record[DATE_FIELD]; + const rawValue = record[`sum_${valueField}`]; + const date = typeof rawDate === 'number' && Number.isFinite(rawDate) + ? new Intl.DateTimeFormat(undefined, { timeZone: 'UTC' }).format(new Date(rawDate)) + : undefined; + const value = typeof rawValue === 'number' && Number.isFinite(rawValue) + ? new Intl.NumberFormat(undefined, { maximumFractionDigits: 3 }).format(rawValue) + : rawValue === null || rawValue === undefined ? undefined : String(rawValue); + return date && value ? `${date}: ${value}` : date ?? value; + }; +} + function calendarDate(raw: unknown): Date | undefined { if (raw instanceof Date) { return Number.isFinite(raw.getTime()) ? new Date(raw.getTime()) : undefined; @@ -81,6 +106,31 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { template: { mark: { type: 'rect', cornerRadius: 2 }, encoding: {} }, channels: ['x', 'color'], markCognitiveChannel: 'color', + semanticInteractions: ({ resolvedEncodings }) => { + const valueField = resolvedEncodings.color?.field ?? COUNT_FIELD; + return { + fields: [WEEK_FIELD, WEEKDAY_FIELD, DATE_FIELD], + categoryField: WEEK_FIELD, + legendFields: { color: valueField }, + selectableMarks: ['rect'], + renderHoverStyles: { rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 } }, + renderSelectionStyles: { rect: { boundary: 'contiguous-region' } }, + resolve: (event, context) => targetFromHits( + event.role === 'legend-item' + ? legendMatchedHits(event, context, `sum_${valueField}`) + : event.hits, + context.keyField, + { + kind: 'mark', + role: 'calendar-day', + }, + ), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('center', 'top', 'right', 'bottom', 'left'), + calendarAnnotationText(valueField), + ), + }; + }, declareLayoutMode: () => ({ // Both axes are ordinal bands (week columns × weekday rows); square-ish // cells read as a calendar rather than a stretched grid. @@ -163,6 +213,12 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { legend: { title: null }, scale: colorScale, }, + tooltip: [ + { field: DATE_FIELD, type: 'temporal', title: 'Date' }, + valueField + ? { field: valueField, aggregate: 'sum', type: 'quantitative' } + : { field: COUNT_FIELD, aggregate: 'sum', type: 'quantitative' }, + ], }; }, encodingActions: [ diff --git a/packages/flint-js/src/vegalite/templates/candlestick.ts b/packages/flint-js/src/vegalite/templates/candlestick.ts index 0fdb598d..f865259a 100644 --- a/packages/flint-js/src/vegalite/templates/candlestick.ts +++ b/packages/flint-js/src/vegalite/templates/candlestick.ts @@ -3,6 +3,8 @@ import { ChartTemplateDef } from '../../core/types'; import { adjustBarMarks } from './utils'; +import { elementsFromHits, fieldsFromEncodingChannels } from '../../core/interaction-semantics'; +import { annotationCandidates, presentAnnotationUpdate, rangeAnnotationText } from '../../interactive/presentation/annotation'; export const candlestickChartDef: ChartTemplateDef = { chart: "Candlestick Chart", @@ -14,7 +16,30 @@ export const candlestickChartDef: ChartTemplateDef = { ], }, channels: ["x", "open", "high", "low", "close", "column", "row"], + navigation: { axes: ['x'] }, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = resolvedEncodings.x?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'open', 'high', 'low', 'close']), + categoryField, + selectableMarks: ['rule', 'bar', 'tick'], + renderHoverStyles: { + rule: { strokeWidth: 2.5 }, + rect: { opacity: 'contrast' }, + }, + resolve: (event, context) => { + const elements = elementsFromHits(event.hits, context.keyField); + return elements.length > 0 + ? { visual: { kind: 'mark', role: 'candlestick' }, elements } + : null; + }, + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('top', 'center', 'right', 'left', 'bottom'), + rangeAnnotationText(resolvedEncodings.open?.field, resolvedEncodings.close?.field), + ), + }; + }, declareLayoutMode: () => ({ axisFlags: { x: { banded: true } }, }), diff --git a/packages/flint-js/src/vegalite/templates/connected-scatter.ts b/packages/flint-js/src/vegalite/templates/connected-scatter.ts index cf17b03d..f9068998 100644 --- a/packages/flint-js/src/vegalite/templates/connected-scatter.ts +++ b/packages/flint-js/src/vegalite/templates/connected-scatter.ts @@ -29,6 +29,14 @@ import { ChartTemplateDef } from '../../core/types'; import { defaultBuildEncodings } from './utils'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + MUTED_HOVER_STROKE, + targetFromHits, +} from '../../core/interaction-semantics'; +import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/presentation/annotation'; /** * Pick a *sortable* Vega-Lite type for the order encoding. The order channel @@ -63,7 +71,38 @@ export const connectedScatterDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "order", "color", "detail", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color', 'detail']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'order', 'color', 'detail']), + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['line', 'point'], + renderHoverStyles: { + line: { strokeWidth: 3 }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + const markType = event.hits[0]?.markType; + const kind = markType === 'line' ? 'path' : 'mark'; + const role = event.role === 'legend-item' ? 'legend-item' : markType ?? event.role; + return targetFromHits(hits, context.keyField, { kind, role }); + }, + presentUpdate: presentAnnotationUpdate( + (_element, _context, visual) => visual?.kind === 'path' + ? annotationCandidates('segment-midpoint', 'center', 'top', 'bottom', 'right', 'left') + : annotationCandidates('center', 'top', 'right', 'bottom', 'left'), + transitionAnnotationText(resolvedEncodings.y?.field), + ), + }; + }, instantiate: (spec, ctx) => { defaultBuildEncodings(spec, ctx.resolvedEncodings); diff --git a/packages/flint-js/src/vegalite/templates/density.ts b/packages/flint-js/src/vegalite/templates/density.ts index d1ed1b54..59b61e04 100644 --- a/packages/flint-js/src/vegalite/templates/density.ts +++ b/packages/flint-js/src/vegalite/templates/density.ts @@ -3,6 +3,15 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { makeCartesianPivot } from '../../core/pivot'; +import { + firstDiscreteEncodingField, + resolveSeriesTarget, +} from '../../core/interaction-semantics'; +import { + annotationCandidates, + categoryValueAnnotationText, + presentAnnotationUpdate, +} from '../../interactive/presentation/annotation'; /** * Silverman/Scott rule-of-thumb bandwidth, matching vega-statistics' bandwidth.js @@ -63,7 +72,27 @@ export const densityPlotDef: ChartTemplateDef = { }, }, channels: ["x", "color", "column", "row"], + navigation: { axes: ['x'] }, markCognitiveChannel: 'area', + semanticInteractions: ({ resolvedEncodings }) => { + const groupFields = ['color', 'column', 'row'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: [...new Set(['value', 'density', ...groupFields])], + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['area'], + renderHoverStyles: { area: { opacity: 'spotlight' } }, + resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint'), + categoryValueAnnotationText('value', 'density'), + ), + }; + }, instantiate: (spec, ctx) => { const { x, color, column, row } = ctx.resolvedEncodings; if (x?.field) { diff --git a/packages/flint-js/src/vegalite/templates/ecdf.ts b/packages/flint-js/src/vegalite/templates/ecdf.ts index 7972da76..d724acc8 100644 --- a/packages/flint-js/src/vegalite/templates/ecdf.ts +++ b/packages/flint-js/src/vegalite/templates/ecdf.ts @@ -29,6 +29,17 @@ */ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + MUTED_HOVER_STROKE, + resolveSeriesTarget, +} from '../../core/interaction-semantics'; +import { + annotationCandidates, + presentAnnotationUpdate, + valueAnnotationText, +} from '../../interactive/presentation/annotation'; import { setMarkProp } from './utils'; const showPointsProperty: ChartPropertyDef = { @@ -55,7 +66,29 @@ export const ecdfPlotDef: ChartTemplateDef = { encoding: {}, }, channels: ['x', 'color', 'detail', 'column', 'row'], + navigation: { axes: ['x'] }, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const valueField = resolvedEncodings.x?.field; + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color', 'detail']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'color', 'detail', 'column', 'row']), + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['line', 'point'], + renderHoverStyles: { + line: { strokeWidth: 3 }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + renderSelectionStyles: { line: { strokeWidthMultiplier: 1.2 } }, + resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint'), + valueAnnotationText(valueField), + ), + }; + }, declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, diff --git a/packages/flint-js/src/vegalite/templates/gantt.ts b/packages/flint-js/src/vegalite/templates/gantt.ts index 4451301a..01cdbcd8 100644 --- a/packages/flint-js/src/vegalite/templates/gantt.ts +++ b/packages/flint-js/src/vegalite/templates/gantt.ts @@ -2,6 +2,14 @@ // Licensed under the MIT License. import { ChartTemplateDef } from '../../core/types'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + targetFromHits, +} from '../../core/interaction-semantics'; +import { presentAnnotationUpdate, rangeAnnotationText, valueEndAnnotationCandidates } from '../../interactive/presentation/annotation'; +import { withInteractionTextLabel } from '../interaction-provenance'; import { coerceGanttEndpoint, ganttDurationLabelExpression, @@ -31,7 +39,35 @@ export const ganttChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["y", "x", "x2", "color", "detail", "column", "row"], + navigation: { axes: ['x'] }, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['y']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['y', 'x', 'x2', 'color', 'detail']), + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['bar'], + renderHoverStyles: { rect: { opacity: 'contrast' } }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { + kind: 'mark', + role: event.role === 'text-label' ? 'text-label' : 'task', + }); + }, + presentUpdate: presentAnnotationUpdate( + () => valueEndAnnotationCandidates('x', 'top', 'bottom'), + rangeAnnotationText(resolvedEncodings.x?.field, resolvedEncodings.x2?.field), + ), + }; + }, declareLayoutMode: () => ({ axisFlags: { y: { banded: true } }, }), @@ -100,7 +136,7 @@ export const ganttChartDef: ChartTemplateDef = { spec.encoding = facetEncoding; spec.layer = [ { mark: spec.mark, encoding: barEncoding }, - { + withInteractionTextLabel({ mark: { type: 'text', align: 'left', baseline: 'middle', dx: 4, fontSize: 10 }, encoding: { y: { ...y }, @@ -111,7 +147,7 @@ export const ganttChartDef: ChartTemplateDef = { }, text: { field: labelField, type: 'nominal' }, }, - }, + }, { presentation: 'independent' }), ]; delete spec.mark; } diff --git a/packages/flint-js/src/vegalite/templates/jitter.ts b/packages/flint-js/src/vegalite/templates/jitter.ts index 64ce922c..e5bea01b 100644 --- a/packages/flint-js/src/vegalite/templates/jitter.ts +++ b/packages/flint-js/src/vegalite/templates/jitter.ts @@ -4,6 +4,14 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { defaultBuildEncodings } from './utils'; import { makeCartesianPivot } from '../../core/pivot'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + MUTED_HOVER_STROKE, + targetFromHits, +} from '../../core/interaction-semantics'; +import { annotationCandidates, presentAnnotationUpdate } from '../../interactive/presentation/annotation'; export const stripPlotDef: ChartTemplateDef = { chart: "Strip Plot", @@ -12,7 +20,35 @@ export const stripPlotDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "size", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + const sizeField = resolvedEncodings.size?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color', 'size']), + categoryField, + seriesField, + legendFields: { + ...(colorField ? { color: colorField } : {}), + ...(sizeField ? { size: sizeField } : {}), + }, + selectableMarks: ['circle'], + renderHoverStyles: { symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 } }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'point' }); + }, + presentUpdate: presentAnnotationUpdate(() => annotationCandidates( + 'center', 'top', 'right', 'bottom', 'left', + )), + }; + }, declareLayoutMode: () => ({ paramOverrides: { defaultBandSize: 50, minStep: 16 }, }), diff --git a/packages/flint-js/src/vegalite/templates/kpi-card.ts b/packages/flint-js/src/vegalite/templates/kpi-card.ts index e68df82e..61accc7e 100644 --- a/packages/flint-js/src/vegalite/templates/kpi-card.ts +++ b/packages/flint-js/src/vegalite/templates/kpi-card.ts @@ -2,6 +2,12 @@ // Licensed under the MIT License. import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { + fieldsFromEncodingChannels, + targetFromHits, +} from '../../core/interaction-semantics'; +import { annotationCandidates, presentAnnotationUpdate } from '../../interactive/presentation/annotation'; +import { withInteractionDecorative, withInteractionTextLabel } from '../interaction-provenance'; /** * KPI Card — "big number" dashboard tile, one row per tile. @@ -57,6 +63,18 @@ export const kpiCardDef: ChartTemplateDef = { template: { layer: [] }, channels: ["metric", "value", "goal"], markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => ({ + fields: fieldsFromEncodingChannels(resolvedEncodings, ['metric', 'value', 'goal']), + categoryField: resolvedEncodings.metric?.field, + selectableMarks: ['rect'], + resolve: (event, context) => targetFromHits(event.hits, context.keyField, { + kind: 'widget', + role: 'kpi-tile', + }), + presentUpdate: presentAnnotationUpdate(() => annotationCandidates( + 'right', 'left', 'top', 'bottom', 'center', + )), + }), instantiate: (spec, ctx) => { const { metric, value, goal } = ctx.resolvedEncodings; const config = ctx.chartProperties || {}; @@ -78,6 +96,7 @@ export const kpiCardDef: ChartTemplateDef = { caption: string; valueText: string; goalText?: string; + record: Record; // Progress is shown only when both value & goal are numeric. progress?: { fraction: number; valueNum: number; goalNum: number }; }; @@ -111,12 +130,12 @@ export const kpiCardDef: ChartTemplateDef = { }; } - tiles.push({ caption, valueText, goalText, progress }); + tiles.push({ caption, valueText, goalText, record: row, progress }); } } if (tiles.length === 0) { - tiles.push({ caption: 'Value', valueText: '—' }); + tiles.push({ caption: 'Value', valueText: '—', record: {} }); } // ── Layout ───────────────────────────────────────────────────────── @@ -221,7 +240,10 @@ export const kpiCardDef: ChartTemplateDef = { Math.floor(cardInnerW / Math.max(1, chars * charW)); const valueFontByWidth = fontFitsWidth(maxValueChars, CHAR_W_BOLD); - const captionFontByWidth = fontFitsWidth(maxCaptionChars, CHAR_W_REGULAR); + // Captions may use two lines. Size them for roughly half the longest + // caption rather than shrinking a long title to an unreadable single + // line; the final text mark also has a hard pixel limit as a safety net. + const captionFontByWidth = fontFitsWidth(Math.ceil(maxCaptionChars / 2), CHAR_W_REGULAR); const subFontByWidth = fontFitsWidth(maxSubChars, CHAR_W_REGULAR); // Detect sub-line presence early — used both to size value (more @@ -245,6 +267,42 @@ export const kpiCardDef: ChartTemplateDef = { const captionFont = Math.max(11, Math.min(22, Math.floor(Math.min(valueFont / 3.0, captionFontByWidth)))); const subFont = Math.max(10, Math.min(18, Math.floor(Math.min(captionFont, subFontByWidth)))); + const captionCharsPerLine = Math.max( + 1, + Math.floor(cardInnerW / Math.max(1, captionFont * CHAR_W_REGULAR)), + ); + const wrapCaption = (text: string): { text: string; lines: number } => { + if (text.length <= captionCharsPerLine) return { text, lines: 1 }; + + const words = text.trim().split(/\s+/); + let first = ''; + let splitAt = 0; + for (; splitAt < words.length; splitAt++) { + const candidate = first ? `${first} ${words[splitAt]}` : words[splitAt]; + if (candidate.length > captionCharsPerLine && first) break; + first = candidate; + } + + // A single unbroken token still needs a deterministic hard wrap. + if (splitAt === words.length && first.length > captionCharsPerLine) { + const firstLine = first.slice(0, captionCharsPerLine); + const remainder = first.slice(captionCharsPerLine); + const secondLine = remainder.length > captionCharsPerLine + ? `${remainder.slice(0, Math.max(1, captionCharsPerLine - 1))}…` + : remainder; + return { text: `${firstLine}\n${secondLine}`, lines: 2 }; + } + + const remainder = words.slice(splitAt).join(' '); + const second = remainder.length > captionCharsPerLine + ? `${remainder.slice(0, Math.max(1, captionCharsPerLine - 1)).trimEnd()}…` + : remainder; + return { text: `${first}\n${second}`, lines: 2 }; + }; + const wrappedCaptions = tiles.map(t => wrapCaption(t.caption)); + const captionLines = wrappedCaptions.some(caption => caption.lines === 2) ? 2 : 1; + const captionLineHeight = Math.ceil(captionFont * 1.15); + const padTop = Math.max(4, Math.floor(captionFont * 0.55)); const padBot = Math.max(4, Math.floor(subFont * 0.6)); const gapCV = Math.max(6, Math.floor(captionFont * 0.55)); // caption → value @@ -253,7 +311,7 @@ export const kpiCardDef: ChartTemplateDef = { const barHeight = Math.max(2, Math.floor(subFont * 0.4)); const captionTop = padTop; - const captionBot = captionTop + captionFont; + const captionBot = captionTop + captionFont + (captionLines - 1) * captionLineHeight; const valueTop = captionBot + gapCV; const valueMid = valueTop + Math.floor(valueFont / 2); const valueBot = valueTop + valueFont; @@ -291,13 +349,14 @@ export const kpiCardDef: ChartTemplateDef = { const showCardFrame = config.style !== false; // ── Per-tile spec builder ────────────────────────────────────────── - const buildTile = (t: Tile): any => { + const buildTile = (t: Tile, tileIndex: number): any => { const layers: any[] = []; + const wrappedCaption = wrappedCaptions[tileIndex]; // Card frame (bottom layer) — sized to content, centered with it. if (showCardFrame) { layers.push({ - data: { values: [{}] }, + data: { values: [t.record] }, mark: { type: 'rect', fill: CARD_FILL, @@ -316,8 +375,8 @@ export const kpiCardDef: ChartTemplateDef = { } // Caption - layers.push({ - data: { values: [{}] }, + layers.push(withInteractionTextLabel({ + data: { values: [t.record] }, mark: { type: 'text', fontSize: captionFont, @@ -325,18 +384,22 @@ export const kpiCardDef: ChartTemplateDef = { fill: '#4a4a4a', align: 'center', baseline: 'top', - text: t.caption, + text: wrappedCaption.text, + lineBreak: '\n', + lineHeight: captionLineHeight, + limit: cardInnerW, + ellipsis: '…', tooltip: null, }, encoding: { x: { value: tileW / 2 }, y: { value: captionY }, }, - }); + }, { presentation: 'on-mark' })); // Big number - layers.push({ - data: { values: [{}] }, + layers.push(withInteractionTextLabel({ + data: { values: [t.record] }, mark: { type: 'text', fontSize: valueFont, @@ -351,7 +414,7 @@ export const kpiCardDef: ChartTemplateDef = { x: { value: tileW / 2 }, y: { value: valueY }, }, - }); + }, { presentation: 'on-mark' })); // Optional goal / progress line if (t.progress) { @@ -371,12 +434,12 @@ export const kpiCardDef: ChartTemplateDef = { ? PROGRESS_BEHIND : PROGRESS_ON_TRACK; - layers.push({ + layers.push(withInteractionTextLabel({ // Only the exceeded state paints this line a status hue; // otherwise it is ordinary caption grey and re-tones with // the rest of the card's text. ...(isExceeded ? { __themeRole: 'positive' } : {}), - data: { values: [{}] }, + data: { values: [t.record] }, mark: { type: 'text', fontSize: subFont, @@ -391,10 +454,10 @@ export const kpiCardDef: ChartTemplateDef = { x: { value: tileW / 2 }, y: { value: subY }, }, - }); + }, { presentation: 'on-mark' })); // Track - layers.push({ + layers.push(withInteractionDecorative({ data: { values: [{}] }, mark: { type: 'rect', @@ -408,7 +471,7 @@ export const kpiCardDef: ChartTemplateDef = { y: { value: barY }, y2: { value: barY + barHeight }, }, - }); + })); // Fill — clamped to track width; overshoot capped visually // at 100% of the track, but the % label and color reveal // that the goal was exceeded. @@ -419,7 +482,7 @@ export const kpiCardDef: ChartTemplateDef = { // where the reading is simply in progress, and the // house's status inks where the reading has a verdict. __themeRole: isExceeded ? 'positive' : isBehind ? 'negative' : 'accent', - data: { values: [{}] }, + data: { values: [t.record] }, mark: { type: 'rect', fill: fillColor, @@ -435,8 +498,8 @@ export const kpiCardDef: ChartTemplateDef = { }); } else if (t.goalText != null) { // Non-numeric goal (or non-numeric value) → just show "Goal: …". - layers.push({ - data: { values: [{}] }, + layers.push(withInteractionTextLabel({ + data: { values: [t.record] }, mark: { type: 'text', fontSize: subFont, @@ -450,7 +513,7 @@ export const kpiCardDef: ChartTemplateDef = { x: { value: tileW / 2 }, y: { value: subY }, }, - }); + }, { presentation: 'on-mark' })); } return { diff --git a/packages/flint-js/src/vegalite/templates/line.ts b/packages/flint-js/src/vegalite/templates/line.ts index d8597621..15afa38a 100644 --- a/packages/flint-js/src/vegalite/templates/line.ts +++ b/packages/flint-js/src/vegalite/templates/line.ts @@ -4,6 +4,11 @@ import { ChartTemplateDef, ChartPropertyDef, type InstantiateContext } from '../../core/types'; import { defaultBuildEncodings, setMarkProp } from './utils'; import { makeCartesianPivot } from '../../core/pivot'; +import { + MUTED_HOVER_STROKE, + resolveSeriesTarget, +} from '../../core/interaction-semantics'; +import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/presentation/annotation'; export const interpolateConfigProperty: ChartPropertyDef = { key: "interpolate", label: "Curve", type: "discrete", options: [ @@ -67,6 +72,16 @@ function isContinuousColor(ctx: InstantiateContext): boolean { return type === 'quantitative' || type === 'temporal'; } +function discreteField( + resolvedEncodings: Readonly>, + channels: readonly string[], +): string | undefined { + return channels + .map((channel) => resolvedEncodings[channel]) + .find((encoding) => encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal')) + ?.field; +} + /** * Vega-Lite splits a line into one segment per datum when color is quantitative, * so nothing visible connects. Mirror ECharts: a neutral line + colored points. @@ -117,8 +132,36 @@ export const lineChartDef: ChartTemplateDef = { chart: "Line Chart", template: { mark: "line", encoding: {} }, channels: ["x", "y", "color", "strokeDash", "detail", "opacity", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', geometryKinds: ['line', 'point'], + semanticInteractions: ({ resolvedEncodings }) => { + const fields = ['x', 'y', 'color', 'detail'] + .map((channel) => resolvedEncodings[channel]?.field) + .filter((field): field is string => !!field); + const categoryField = discreteField(resolvedEncodings, ['x']); + const seriesField = discreteField(resolvedEncodings, ['color', 'detail']); + const colorField = resolvedEncodings.color?.field; + return { + fields: [...new Set(fields)], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['line', 'point'], + renderHoverStyles: { + line: { strokeWidth: 3 }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + renderSelectionStyles: { line: { strokeWidthMultiplier: 1.2 } }, + resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + (_element, _context, visual) => visual?.kind === 'path' + ? annotationCandidates('segment-midpoint', 'center', 'top', 'bottom', 'right', 'left') + : annotationCandidates('center', 'top', 'right', 'bottom', 'left'), + transitionAnnotationText(resolvedEncodings.y?.field), + ), + }; + }, declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.5 }, }), diff --git a/packages/flint-js/src/vegalite/templates/lollipop.ts b/packages/flint-js/src/vegalite/templates/lollipop.ts index 11f94086..96930b06 100644 --- a/packages/flint-js/src/vegalite/templates/lollipop.ts +++ b/packages/flint-js/src/vegalite/templates/lollipop.ts @@ -6,6 +6,14 @@ import { makeSortAction } from '../../core/encoding-actions'; import { makeCartesianPivot } from '../../core/pivot'; import { detectBandedAxisFromSemantics } from '../../core/axis-detection'; import { setMarkProp } from './utils'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + MUTED_HOVER_STROKE, + targetFromHits, +} from '../../core/interaction-semantics'; +import { lollipopAnnotationCandidates, presentAnnotationUpdate } from '../../interactive/presentation/annotation'; export const lollipopChartDef: ChartTemplateDef = { chart: "Lollipop Chart", @@ -17,7 +25,32 @@ export const lollipopChartDef: ChartTemplateDef = { ], }, channels: ["x", "y", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color']), + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['rule', 'circle'], + renderHoverStyles: { + rule: { stroke: MUTED_HOVER_STROKE }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'lollipop' }); + }, + presentUpdate: presentAnnotationUpdate(() => lollipopAnnotationCandidates( + resolvedEncodings.x?.type === 'quantitative' ? 'x' : 'y', + )), + }; + }, declareLayoutMode: (cs, table) => { const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' }); return { diff --git a/packages/flint-js/src/vegalite/templates/map.ts b/packages/flint-js/src/vegalite/templates/map.ts index de63983a..0871f139 100644 --- a/packages/flint-js/src/vegalite/templates/map.ts +++ b/packages/flint-js/src/vegalite/templates/map.ts @@ -7,6 +7,18 @@ import { type MapScope, inferBubbleScope, inferChoroplethScope, semanticScope, pickMapScope, } from '../../chart-types/geo'; import { toTypeString } from '../../core/field-semantics'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + MUTED_HOVER_STROKE, + targetFromHits, +} from '../../core/interaction-semantics'; +import { + annotationCandidates, + categoryValueAnnotationText, + presentAnnotationUpdate, +} from '../../interactive/presentation/annotation'; const mapProjections = [ { value: "mercator", label: "Mercator" }, @@ -158,6 +170,32 @@ export const mapDef: ChartTemplateDef = { }, channels: ["longitude", "latitude", "color", "size", "opacity"], markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + const sizeField = resolvedEncodings.size?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['longitude', 'latitude', 'color', 'size', 'opacity']), + seriesField, + legendFields: colorField || sizeField + ? { ...(colorField ? { color: colorField } : {}), ...(sizeField ? { size: sizeField } : {}) } + : undefined, + selectableMarks: ['circle'], + renderHoverStyles: { + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'point' }); + }, + presentUpdate: presentAnnotationUpdate(() => annotationCandidates( + 'center', 'top', 'right', 'bottom', 'left', + )), + }; + }, instantiate: (spec, ctx) => { const rows = ctx.fullTable ?? ctx.table ?? []; const lonField = ctx.resolvedEncodings.longitude?.field; @@ -248,7 +286,7 @@ function buildChoroplethJoin(spec: any, ctx: any, resolver: GeoResolver): void { if (idField) { const joined = rows.map((r) => ({ ...r, __geo_id: resolver(r[idField]) })); - const lookupFields = [valueField, labelField].filter(Boolean) as string[]; + const lookupFields = [idField, valueField, labelField].filter(Boolean) as string[]; spec.transform = [ { lookup: 'id', @@ -276,6 +314,30 @@ export const choroplethDef: ChartTemplateDef = { }, channels: ["id", "color", "detail"], markCognitiveChannel: 'color', + semanticInteractions: ({ resolvedEncodings }) => { + const idField = resolvedEncodings.id?.field; + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['id', 'color', 'detail']), + categoryField: idField, + selectableMarks: ['geoshape'], + renderHoverStyles: { shape: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 } }, + resolve: (event, context) => { + const hits = event.role === 'legend-item' && colorField + ? legendMatchedHits(event, context, colorField) + : event.hits; + return targetFromHits(hits, context.keyField, { + kind: 'region', + role: event.role === 'legend-item' ? 'legend-item' : 'geographic-region', + }); + }, + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('center'), + categoryValueAnnotationText(idField, colorField), + ), + legendFields: colorField ? { color: colorField } : undefined, + }; + }, instantiate: (spec, ctx) => { const rows = ctx.fullTable ?? ctx.table ?? []; const idField = ctx.resolvedEncodings.id?.field; diff --git a/packages/flint-js/src/vegalite/templates/pie.ts b/packages/flint-js/src/vegalite/templates/pie.ts index 02616fb8..bb9f65e9 100644 --- a/packages/flint-js/src/vegalite/templates/pie.ts +++ b/packages/flint-js/src/vegalite/templates/pie.ts @@ -3,6 +3,17 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { computeCircumferencePressure, computeEffectiveBarCount } from '../../core/decisions'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + targetFromHits, +} from '../../core/interaction-semantics'; +import { + annotationCandidates, + categoryValueAnnotationText, + presentAnnotationUpdate, +} from '../../interactive/presentation/annotation'; import { setMarkProp } from './utils'; export const pieChartDef: ChartTemplateDef = { @@ -10,6 +21,31 @@ export const pieChartDef: ChartTemplateDef = { template: { mark: "arc", encoding: {} }, channels: ["size", "color", "column", "row"], markCognitiveChannel: 'area', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const valueField = resolvedEncodings.size?.field; + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['color']), + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['arc'], + annotationMarkType: 'arc', + supportedRegionGestures: ['angular'], + renderHoverStyles: { arc: { opacity: 'contrast' } }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'slice' }); + }, + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('radial-midpoint', 'outer-radial'), + categoryValueAnnotationText(seriesField, valueField), + ), + }; + }, geometryKinds: ['arc'], instantiate: (spec, ctx) => { // Remap abstract channels to VL channels: @@ -91,6 +127,7 @@ export const pieChartDef: ChartTemplateDef = { margin: 50, // room for labels around pie }); + spec.mark = setMarkProp(spec.mark, 'outerRadius', radius); // Set explicit width/height — overrides config.view defaults spec.width = canvasW; spec.height = canvasH; diff --git a/packages/flint-js/src/vegalite/templates/radar.ts b/packages/flint-js/src/vegalite/templates/radar.ts index 0e7ff79c..84e96870 100644 --- a/packages/flint-js/src/vegalite/templates/radar.ts +++ b/packages/flint-js/src/vegalite/templates/radar.ts @@ -2,6 +2,8 @@ // Licensed under the MIT License. import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { resolveSeriesTarget } from '../../core/interaction-semantics'; +import { annotationCandidates, presentAnnotationUpdate } from '../../interactive/presentation/annotation'; /** * Radar / Spider Chart @@ -96,6 +98,9 @@ function buildRadarLayers( const rawVal = Math.round((v.rawSum / v.count) * 100) / 100; const rad = (angle * Math.PI) / 180; finalData.push({ + [axisField]: axis, + [valueField]: rawVal, + ...(groupField ? { [groupField]: grp } : {}), __group: grp, __axis: axis, __value: normVal, @@ -274,6 +279,7 @@ function buildRadarLayers( // --------------------------------------------------------------------------- export const radarChartDef: ChartTemplateDef = { chart: "Radar Chart", + reorder: false, template: { description: "Radar / Spider chart", mark: "point", @@ -281,6 +287,28 @@ export const radarChartDef: ChartTemplateDef = { }, channels: ["x", "y", "color", "column", "row"], markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const axisField = resolvedEncodings.x?.field; + const valueField = resolvedEncodings.y?.field; + const groupField = resolvedEncodings.color?.field; + return { + fields: [axisField, valueField, groupField].filter((field): field is string => !!field), + categoryField: axisField, + seriesField: groupField, + legendFields: groupField ? { color: groupField } : undefined, + selectableMarks: ['line', 'point'], + supportedRegionGestures: ['angular'], + renderHoverStyles: { + line: { strokeWidth: 3 }, + symbol: { strokeWidth: 2 }, + }, + renderSelectionStyles: { line: { strokeWidthMultiplier: 1.2 } }, + resolve: (event, context) => resolveSeriesTarget(event, context, groupField), + presentUpdate: presentAnnotationUpdate(() => annotationCandidates( + 'right', 'left', 'top', 'bottom', 'center', + )), + }; + }, instantiate: (spec, ctx) => { const axisField: string | undefined = ctx.resolvedEncodings.x?.field; const valueField: string | undefined = ctx.resolvedEncodings.y?.field; diff --git a/packages/flint-js/src/vegalite/templates/range-area.ts b/packages/flint-js/src/vegalite/templates/range-area.ts index e95d418f..397d94bf 100644 --- a/packages/flint-js/src/vegalite/templates/range-area.ts +++ b/packages/flint-js/src/vegalite/templates/range-area.ts @@ -25,6 +25,13 @@ */ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + targetFromHits, +} from '../../core/interaction-semantics'; +import { annotationCandidates, presentAnnotationUpdate, rangeAnnotationText } from '../../interactive/presentation/annotation'; import { defaultBuildEncodings, setMarkProp } from './utils'; const interpolateConfigProperty: ChartPropertyDef = { @@ -41,9 +48,36 @@ const interpolateConfigProperty: ChartPropertyDef = { export const rangeAreaChartDef: ChartTemplateDef = { chart: 'Range Area Chart', + reorder: false, template: { mark: { type: 'area', opacity: 0.5, line: { strokeWidth: 1 } }, encoding: {} }, channels: ['x', 'y', 'y2', 'color', 'column', 'row'], + navigation: {}, markCognitiveChannel: 'area', + geometryKinds: ['area'], + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'y2', 'color']), + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['area'], + renderHoverStyles: { area: { opacity: 'spotlight' } }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'path', role: event.role }); + }, + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint', 'center', 'top', 'bottom', 'right', 'left'), + rangeAnnotationText(resolvedEncodings.y?.field, resolvedEncodings.y2?.field), + ), + }; + }, declareLayoutMode: () => ({ paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, diff --git a/packages/flint-js/src/vegalite/templates/rose.ts b/packages/flint-js/src/vegalite/templates/rose.ts index d7517fb0..99bac966 100644 --- a/packages/flint-js/src/vegalite/templates/rose.ts +++ b/packages/flint-js/src/vegalite/templates/rose.ts @@ -17,10 +17,23 @@ */ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + targetFromHits, +} from '../../core/interaction-semantics'; +import { + annotationCandidates, + presentAnnotationUpdate, + valueAnnotationText, +} from '../../interactive/presentation/annotation'; +import { withInteractionTextLabel } from '../interaction-provenance'; import { setMarkProp } from './utils'; export const roseChartDef: ChartTemplateDef = { chart: "Rose Chart", + reorder: false, template: { mark: { type: "arc", @@ -31,6 +44,40 @@ export const roseChartDef: ChartTemplateDef = { }, channels: ["x", "y", "color", "column", "row"], markCognitiveChannel: 'area', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const valueField = resolvedEncodings.y?.field; + const colorLegendField = resolvedEncodings.color?.field ?? resolvedEncodings.x?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'color']), + categoryField, + seriesField, + legendFields: colorLegendField ? { color: colorLegendField } : undefined, + selectableMarks: ['arc'], + annotationMarkType: 'arc', + supportedRegionGestures: ['angular'], + renderHoverStyles: { arc: { opacity: 'contrast' } }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField ?? categoryField; + let hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + if (event.role === 'text-label' && categoryField) { + const category = event.hits[0]?.datum[categoryField]; + hits = context.allHits.filter((hit) => hit.datum[categoryField] === category); + } + return targetFromHits(hits, context.keyField, { + kind: 'mark', + role: event.role === 'text-label' ? 'text-label' : 'polar-bar', + }); + }, + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('outer-radial'), + valueAnnotationText(valueField), + ), + }; + }, // Polar charts have no positional axes — declare no banded axes // so the layout pipeline won't produce step-based sizing. @@ -152,10 +199,13 @@ export const roseChartDef: ChartTemplateDef = { const arcMark = spec.mark; spec.layer = [ { mark: arcMark, encoding: {} as any }, - { + withInteractionTextLabel({ mark: { type: "text", radiusOffset: 15, fontSize: 11 }, encoding: {} as any, - }, + }, { + fields: x?.field ? [x.field] : undefined, + presentation: 'independent', + }), ]; delete spec.mark; diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index 29a21f43..6674251a 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -8,6 +8,20 @@ import { defaultBuildEncodings, applyPointSizeScaling, setMarkProp, } from './utils'; import { makeCartesianPivot } from '../../core/pivot'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + MUTED_HOVER_FILL, + MUTED_HOVER_STROKE, + targetFromHits, +} from '../../core/interaction-semantics'; +import { + annotationCandidates, + boxplotAnnotationText, + presentAnnotationUpdate, + seriesValuesAnnotationText, +} from '../../interactive/presentation/annotation'; const isDiscreteType = (t: string | undefined) => t === 'nominal' || t === 'ordinal'; @@ -35,8 +49,39 @@ const USABLE_BAND_FRACTION = 0.8; export const scatterPlotDef: ChartTemplateDef = { chart: "Scatter Plot", template: { mark: "circle", encoding: {} }, - channels: ["x", "y", "color", "size", "shape", "opacity", "column", "row"], + channels: ["x", "y", "color", "size", "shape", "detail", "opacity", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const shapeOnlyHover = resolvedEncodings.shape?.field && !resolvedEncodings.color?.field + ? { fill: MUTED_HOVER_FILL } + : {}; + const legendFields = Object.fromEntries( + ['color', 'size', 'shape'] + .map((channel) => [channel, resolvedEncodings[channel]?.field]) + .filter((entry): entry is [string, string] => !!entry[1]), + ); + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color', 'size', 'shape', 'detail']), + seriesField, + legendFields, + selectableMarks: ['circle', 'point'], + renderHoverStyles: { + symbol: { ...shapeOnlyHover, stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'point' }); + }, + presentUpdate: presentAnnotationUpdate(() => annotationCandidates( + 'center', 'top', 'right', 'bottom', 'left', + )), + }; + }, instantiate: (spec, ctx) => { defaultBuildEncodings(spec, ctx.resolvedEncodings); // A `shape` encoding only renders distinct glyphs on the `point` mark; @@ -85,7 +130,34 @@ export const regressionDef: ChartTemplateDef = { ], }, channels: ["x", "y", "size", "color", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + const sizeField = resolvedEncodings.size?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color', 'size']), + seriesField, + legendFields: colorField || sizeField + ? { ...(colorField ? { color: colorField } : {}), ...(sizeField ? { size: sizeField } : {}) } + : undefined, + selectableMarks: ['circle'], + renderHoverStyles: { + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'point' }); + }, + presentUpdate: presentAnnotationUpdate(() => annotationCandidates( + 'center', 'top', 'right', 'bottom', 'left', + )), + }; + }, instantiate: (spec, ctx) => { const { x, y, color, size, column, row } = ctx.resolvedEncodings; const config = ctx.chartProperties; @@ -159,6 +231,7 @@ export const regressionDef: ChartTemplateDef = { export const rangedDotPlotDef: ChartTemplateDef = { chart: "Ranged Dot Plot", + reorder: { includeConnectiveMarks: true }, template: { encoding: {}, layer: [ @@ -167,7 +240,53 @@ export const rangedDotPlotDef: ChartTemplateDef = { ], }, channels: ["x", "y", "color"], + navigation: {}, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const valueField = ['x', 'y'] + .map((channel) => resolvedEncodings[channel]?.field) + .find((field) => field && field !== categoryField); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color']), + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['line', 'point'], + renderHoverStyles: { + line: { strokeWidth: 3 }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + if (event.gesture === 'click' && event.role !== 'legend-item' && categoryField) { + const category = hits[0]?.datum[categoryField]; + const unitHits = context.allHits + .filter((hit) => hit.datum[categoryField] === category) + .sort((a, b) => Number(b.markType === 'line') - Number(a.markType === 'line')); + const unit = targetFromHits(unitHits, context.keyField, { kind: 'path', role: 'line' }); + if (unit) return unit; + } + const markType = event.hits[0]?.markType; + const kind = markType === 'line' ? 'path' : 'mark'; + const role = event.role === 'legend-item' + ? 'legend-item' + : markType === 'symbol' + ? 'point' + : markType ?? event.role; + return targetFromHits(hits, context.keyField, { kind, role }); + }, + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint'), + seriesValuesAnnotationText(seriesField, valueField), + ), + }; + }, instantiate: (spec, ctx) => { const { color, ...rest } = ctx.resolvedEncodings; if (!spec.encoding) spec.encoding = {}; @@ -191,7 +310,51 @@ export const boxplotDef: ChartTemplateDef = { chart: "Boxplot", template: { mark: "boxplot", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + return { + fields: [...new Set([ + ...fieldsFromEncodingChannels(resolvedEncodings, ['color']), + ...(categoryField ? [categoryField] : []), + ])], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['boxplot'], + renderHoverStyles: { + rect: { + opacity: 'contrast', + stroke: MUTED_HOVER_STROKE, + strokeWidth: 2, + }, + rule: { + opacity: 'contrast', + stroke: MUTED_HOVER_STROKE, + strokeWidth: 2, + }, + symbol: { + opacity: 'contrast', + stroke: MUTED_HOVER_STROKE, + strokeWidth: 2, + }, + }, + resolve: (event, context) => { + const legendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && legendField + ? legendMatchedHits(event, context, legendField) + : event.hits; + return targetFromHits(hits, context.keyField, { kind: 'mark', role: 'distribution' }); + }, + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('center', 'top', 'right', 'left'), + boxplotAnnotationText, + ), + }; + }, declareLayoutMode: (cs, table, chartProperties) => { if (!cs.x?.field || !cs.y?.field) return {}; const result = detectBandedAxisForceDiscrete(cs, table, { preferAxis: 'x' }); diff --git a/packages/flint-js/src/vegalite/templates/slope.ts b/packages/flint-js/src/vegalite/templates/slope.ts index 2530cf9c..5c9ac1a4 100644 --- a/packages/flint-js/src/vegalite/templates/slope.ts +++ b/packages/flint-js/src/vegalite/templates/slope.ts @@ -28,6 +28,13 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { resolveDiscreteType } from '../../core/axis-detection'; import { defaultBuildEncodings } from './utils'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + MUTED_HOVER_STROKE, + resolveSeriesTarget, +} from '../../core/interaction-semantics'; +import { annotationCandidates, presentAnnotationUpdate, transitionAnnotationText } from '../../interactive/presentation/annotation'; const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal'; @@ -67,7 +74,29 @@ export const slopeChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "detail", "column", "row"], + navigation: {}, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color', 'detail']); + const colorField = resolvedEncodings.color?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'color', 'detail']), + categoryField: firstDiscreteEncodingField(resolvedEncodings, ['x']), + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['line', 'point'], + renderHoverStyles: { + line: { strokeWidth: 3 }, + symbol: { stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + renderSelectionStyles: { line: { strokeWidthMultiplier: 1.2 } }, + resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint', 'center', 'right', 'left'), + transitionAnnotationText(resolvedEncodings.y?.field), + ), + }; + }, declareLayoutMode: (cs, table) => { // Force the period axis to a discrete band so the two periods sit at two // equally-spaced positions regardless of the field's native type. diff --git a/packages/flint-js/src/vegalite/templates/sparkline.ts b/packages/flint-js/src/vegalite/templates/sparkline.ts index 30875495..2ac4f760 100644 --- a/packages/flint-js/src/vegalite/templates/sparkline.ts +++ b/packages/flint-js/src/vegalite/templates/sparkline.ts @@ -3,8 +3,18 @@ import { ChartTemplateDef, ChartPropertyDef, ChartEncoding } from '../../core/types'; import type { FormatSpec } from '../../core/field-semantics'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + resolveSeriesTarget, +} from '../../core/interaction-semantics'; import { formatSpecToVegaExpr } from '../format'; import { interpolateConfigProperty, applyInterpolate } from './line'; +import { + annotationCandidates, + presentAnnotationUpdate, + transitionAnnotationText, +} from '../../interactive/presentation/annotation'; /** * Sparkline — a "sparkline table" / small-multiples strip layout. @@ -106,7 +116,25 @@ export const sparklineDef: ChartTemplateDef = { chart: 'Sparkline', template: { mark: 'line', encoding: {} }, channels: ['x', 'y', 'color', 'detail', 'row', 'column'], + navigation: { axes: ['x'] }, markCognitiveChannel: 'position', + semanticInteractions: ({ resolvedEncodings }) => { + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['row', 'color', 'detail']); + const valueField = resolvedEncodings.y?.field; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'y', 'row', 'color', 'detail']), + categoryField: seriesField, + seriesField, + selectableMarks: ['line'], + renderHoverStyles: { line: { strokeWidth: 3 } }, + renderSelectionStyles: { line: { strokeWidthMultiplier: 1.2 } }, + resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => annotationCandidates('segment-midpoint', 'right', 'left', 'top', 'bottom'), + transitionAnnotationText(valueField), + ), + }; + }, // Remap the series field onto `row` so each series becomes its own table // row. The series is the bound `color` field if present, else `detail`. diff --git a/packages/flint-js/src/vegalite/templates/violin.ts b/packages/flint-js/src/vegalite/templates/violin.ts index dad02846..9fa6409c 100644 --- a/packages/flint-js/src/vegalite/templates/violin.ts +++ b/packages/flint-js/src/vegalite/templates/violin.ts @@ -32,6 +32,11 @@ */ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { resolveSeriesTarget } from '../../core/interaction-semantics'; +import { + categoryValueAnnotationText, + presentAnnotationUpdate, +} from '../../interactive/presentation/annotation'; import { detectBandedAxisForceDiscrete } from '../../core/axis-detection'; import { planBandDodge } from '../../core/band-dodge'; @@ -125,6 +130,7 @@ function maxGroupBandwidth(table: any[], measure: string, groupby: string[]): nu } export const violinPlotDef: ChartTemplateDef = { + reorder: false, chart: 'Violin Plot', template: { mark: { type: 'area', orient: 'horizontal' }, @@ -144,6 +150,27 @@ export const violinPlotDef: ChartTemplateDef = { // is exposed as an additional outer facet. channels: ['x', 'y', 'color', 'row'], markCognitiveChannel: 'area', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = resolvedEncodings.x?.field; + const measureField = resolvedEncodings.y?.field; + const colorField = resolvedEncodings.color?.field; + const rowField = resolvedEncodings.row?.field; + const seriesField = colorField ?? categoryField; + return { + fields: [...new Set([categoryField, colorField, rowField] + .filter((field): field is string => !!field))], + categoryField, + seriesField, + legendFields: colorField ? { color: colorField } : undefined, + selectableMarks: ['area'], + renderHoverStyles: { area: { opacity: 'spotlight' } }, + resolve: (event, context) => resolveSeriesTarget(event, context, seriesField), + presentUpdate: presentAnnotationUpdate( + () => ({ connection: 'segment-midpoint', maxDistance: 120, maxWidth: 120 }), + categoryValueAnnotationText(categoryField, measureField), + ), + }; + }, declareLayoutMode: (cs, table) => { // The category lives on `x`; force it discrete (boxplot-style) so a // numeric/temporal category still resolves to clean bands/panels. diff --git a/packages/flint-js/src/vegalite/templates/waterfall.ts b/packages/flint-js/src/vegalite/templates/waterfall.ts index 93b33dcb..0d66e259 100644 --- a/packages/flint-js/src/vegalite/templates/waterfall.ts +++ b/packages/flint-js/src/vegalite/templates/waterfall.ts @@ -3,8 +3,25 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { resolveDiscreteType } from '../../core/axis-detection'; +import { + fieldsFromEncodingChannels, + firstDiscreteEncodingField, + legendMatchedHits, + targetFromHits, +} from '../../core/interaction-semantics'; +import type { AnnotationCandidate } from '../../interactive/interactions'; +import { presentAnnotationUpdate, rangeAnnotationText } from '../../interactive/presentation/annotation'; +import { withInteractionTextLabel } from '../interaction-provenance'; import { resolveTotalsMode } from '../../chart-types/waterfall'; +function waterfallAnnotationCandidates(): readonly AnnotationCandidate[] { + return [ + { connection: 'value-end', valueAxis: 'y', priority: 0 }, + { connection: 'value-side', valueAxis: 'y', crossSide: 'start', valueInset: 1 / 2, priority: 1 }, + { connection: 'value-side', valueAxis: 'y', crossSide: 'end', valueInset: 1 / 2, priority: 1 }, + ]; +} + /** * Waterfall Chart template. * @@ -20,7 +37,39 @@ export const waterfallChartDef: ChartTemplateDef = { chart: "Waterfall Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], + navigation: {}, + reorder: { markTypes: ['rect'] }, markCognitiveChannel: 'length', + semanticInteractions: ({ resolvedEncodings }) => { + const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); + const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); + const colorField = resolvedEncodings.color?.field; + const legendField = colorField ?? '__wf_color'; + return { + fields: fieldsFromEncodingChannels(resolvedEncodings, ['x', 'color']), + categoryField, + seriesField, + resolveGroupValue: (element) => element.records?.[0]?.__wf_color, + legendFields: { color: legendField }, + selectableMarks: ['bar'], + annotationMarkType: 'rect', + renderHoverStyles: { rect: { opacity: 'contrast' } }, + resolve: (event, context) => { + const resolvedLegendField = event.legend?.field ?? seriesField; + const hits = event.role === 'legend-item' && resolvedLegendField + ? legendMatchedHits(event, context, resolvedLegendField) + : event.hits; + return targetFromHits(hits, context.keyField, { + kind: 'mark', + role: event.role === 'text-label' ? 'text-label' : 'waterfall-step', + }); + }, + presentUpdate: presentAnnotationUpdate( + waterfallAnnotationCandidates, + rangeAnnotationText('__wf_prev_sum', '__wf_sum'), + ), + }; + }, ownsValueLabels: true, // The steps are drawn on a band scale whatever the column holds — a month // is a step here, not a date. Saying so keeps the layout's category sizing @@ -45,6 +94,7 @@ export const waterfallChartDef: ChartTemplateDef = { // rather than falling back to the raw field names. const xTitle = x?.title ?? xField; const yTitle = y?.title ?? yField; + const colorTitle = color?.title ?? colorField ?? "Type"; if (!spec.encoding) spec.encoding = {}; if (column) spec.encoding.column = column; @@ -188,6 +238,13 @@ export const waterfallChartDef: ChartTemplateDef = { // extreme top/bottom bar tips isn't clipped by the plot edge. const labelPad = showLabels && labelFits ? ((labelFontSize + 8) / plotH) * ySpan : 0; const yDomain = labelPad > 0 ? [yMin - labelPad, yMax + labelPad] : null; + const tooltip = [ + { field: xField, type: x?.type ?? "ordinal", title: xTitle }, + { field: yField, type: y?.type ?? "quantitative", title: yTitle }, + hasTypeCol + ? { field: colorField, type: color?.type ?? "nominal", title: colorTitle } + : { field: "__wf_color", type: "nominal", title: "Type" }, + ]; spec.encoding = { x: xEnc, @@ -217,6 +274,7 @@ export const waterfallChartDef: ChartTemplateDef = { }, legend: { title: "Type" }, }, + tooltip, }, }, // Thin connector lines bridging each bar to the next at the running @@ -236,6 +294,7 @@ export const waterfallChartDef: ChartTemplateDef = { x: { field: xField, type: "ordinal", sort: null, bandPosition: 0 }, x2: { field: "__wf_lead", bandPosition: 1 }, y: { field: "__wf_connector_y", type: "quantitative", title: yTitle }, + tooltip: null, }, }, ]; @@ -253,7 +312,7 @@ export const waterfallChartDef: ChartTemplateDef = { spec.layer.push( // Running total outside the bar (above increases / below decreases). - { + withInteractionTextLabel({ mark: { type: "text", align: "center", @@ -265,11 +324,12 @@ export const waterfallChartDef: ChartTemplateDef = { encoding: { y: { field: "__wf_sum", type: "quantitative", title: yTitle }, text: { field: "__wf_sum", type: "quantitative", format: labelFormat }, + tooltip: null, }, - }, + }, { presentation: 'independent' }), // Delta inside the bar, muted in the bar's own hue. Skipped when the // bar is too short to hold the text. - { + withInteractionTextLabel({ transform: [{ filter: `abs(datum.__wf_sum - datum.__wf_prev_sum) >= ${minDataHeight}` }], mark: { type: "text", @@ -284,8 +344,9 @@ export const waterfallChartDef: ChartTemplateDef = { condition: { test: "datum.__wf_color === 'total'", value: "#725a30" }, value: "white", }, + tooltip: null, }, - }, + }, { presentation: 'on-mark' }), ); } diff --git a/packages/flint-js/src/vegalite/theme.ts b/packages/flint-js/src/vegalite/theme.ts index 21a32be1..0549f7e3 100644 --- a/packages/flint-js/src/vegalite/theme.ts +++ b/packages/flint-js/src/vegalite/theme.ts @@ -19,6 +19,7 @@ import { contrastingInk, parseColor, luminance, mixHex, toHex } from '../core/th import { CONTINUOUS_BAR_STEP_FILL, coverageSizedMarks } from './templates/utils.js'; import { LOCAL_DODGE_LANE_FILL } from './templates/bar.js'; import { CANVAS_FURNITURE_KEY, readCanvasFurniture, type CanvasFurnitureItem } from './canvas-furniture.js'; +import { withInteractionLegendLabel, withInteractionTextLabel } from './interaction-provenance.js'; /** Mark families that carry data values (as opposed to chrome). */ const DATA_MARKS = new Set([ @@ -275,12 +276,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 +739,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 +1448,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 @@ -1620,9 +1636,10 @@ function applyMarks(spec: any, d: DesignDecisions, table: any[], say: (p: string if (fittedDots.has(node)) return; const mark = normalizeMark(node.mark); if (!mark.point) return; + const point = typeof mark.point === 'object' ? mark.point : {}; mark.point = { - ...(typeof mark.point === 'object' ? mark.point : {}), - ...(dot.filled != null ? { filled: dot.filled } : {}), + ...point, + ...(point.filled == null ? { filled: dot.filled !== false } : {}), ...(dot.size != null ? { size: dot.size } : {}), stroke: m.outline?.color ?? dot.haloColor, strokeWidth: m.outline?.width ?? dot.haloWidth ?? 1, @@ -1993,6 +2010,14 @@ function applyMarks(spec: any, d: DesignDecisions, table: any[], say: (p: string if (isLiteralMark(node)) return; const enc = node.encoding ?? {}; if (isGridCell(node, enc)) return; + if (enc.x2 != null || enc.y2 != null) { + // Ranged bars such as histogram bins are authored with a start + // and end position. Rounding only the value end can collapse + // them into zero-width paths once interactive instrumentation + // wraps the marks, so keep these bars square. + node.mark = { ...normalizeMark(node.mark), cornerRadiusEnd: 0 }; + return; + } const barW = estimateBarExtent(node, enc, table, plotWidth, plotHeight); const capped = Math.round(barW * MAX_CORNER_FRACTION * 10) / 10; if (capped >= m.cornerRadius!) return; @@ -4533,7 +4558,11 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa markDef.color = t.color ?? d.text.primary; } - const layer: any = { __themeSynthetic: true, mark: markDef, encoding: labelEncoding }; + const layer: any = withInteractionTextLabel({ + __themeSynthetic: true, + mark: markDef, + encoding: labelEncoding, + }, { presentation: inside ? 'on-mark' : 'independent' }); if (radialLabelKeepTest) { labelEncoding.opacity = { condition: { test: radialLabelKeepTest, value: 1 }, value: 0 }; } @@ -4594,9 +4623,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 => { @@ -4609,24 +4638,17 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa const v = `abs(datum[${JSON.stringify(measure.field)}])`; const flipped = !inside; layer.transform = [{ filter: `${v} ${comparison === '<' ? '>=' : '<='} ${threshold}` }]; - const other: any = { + const other: any = withInteractionTextLabel({ __themeSynthetic: true, transform: [{ filter: `${v} ${comparison} ${threshold}` }], mark: { ...markDef, ...geometry(flipped), color: flipInk(flipped) }, encoding: labelEncoding, - }; + }, { presentation: flipped ? 'on-mark' : 'independent' }); if (other.mark.color === undefined) delete other.mark.color; appendLayer(body, other); say('dataLabels.placement', message); }; - // A vertical bar's outside label is cleared by giving the measure scale - // headroom (below); a horizontal one by reserving right margin. The - // scale-end flip — printing the tallest bars' labels inside instead — - // solves the same "no room past the end" problem, so it is only needed - // where headroom is not the remedy: on horizontal bars. - const headroomClears = !inside && onMarkBody && !horizontal && !radial && !cells; - // A stacked segment is exempt: "outside" a segment is the top of the // stack, a different quantity. Segments too short for their number drop it // instead, which the keep test above already arranges. @@ -4634,8 +4656,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 +4798,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 +4846,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 +5020,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,7 +5139,19 @@ function applySeriesEndLabels( 'series name and final value merged into one label — they compete for the same space'); } - const labelLayer: any = { + 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 = withInteractionLegendLabel({ __themeSynthetic: true, transform, mark: { @@ -4974,17 +5161,17 @@ 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 } } : {}), }, - }; + }, { channel: 'color', field: seriesField }); appendLayer(body, labelLayer); // The names sit outside the plot rectangle, and no room is reserved for @@ -5173,7 +5360,7 @@ function bandEndLabels( { window: [{ op: 'row_number', as: '__bandDataOrder' }] }, { window: [{ op: 'row_number', as: '__bandEndRank' }], sort: [{ field: '__bandDataOrder', order: 'descending' }], groupby: [seriesField] }, ]; - const endLayer = (inside: boolean): any => ({ + const endLayer = (inside: boolean): any => withInteractionTextLabel({ __themeSynthetic: true, transform: [ ...rankTf, @@ -5199,7 +5386,7 @@ function bandEndLabels( text: { field: '__bandEndLabel', type: 'nominal' }, ...(inside ? knockedOut : inSeriesInk), }, - }); + }, { fields: [seriesField], presentation: 'independent' }); // Exactly one of the two layers is drawn: `outside` is now all of the // series or none of them, never a subset. if (!outside.length) appendLayer(body, endLayer(true)); 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/calendar-vegalite.test.ts b/packages/flint-js/tests/calendar-vegalite.test.ts index 091dda6a..f4e1965a 100644 --- a/packages/flint-js/tests/calendar-vegalite.test.ts +++ b/packages/flint-js/tests/calendar-vegalite.test.ts @@ -169,6 +169,8 @@ describe('Vega-Lite Calendar Heatmap', () => { expect(spec.encoding.color.field).toBe('value'); expect(spec.encoding.color.aggregate).toBe('sum'); expect(spec.encoding.color.type).toBe('quantitative'); + expect(spec._interactionSemantics.legendFields).toEqual({ color: 'value' }); + expect(spec._interactionSemantics.rangeLegendChannels).toEqual(['color']); }); it('falls back to a derived per-day count when no value field is given', () => { diff --git a/packages/flint-js/tests/data-overlay.test.ts b/packages/flint-js/tests/data-overlay.test.ts new file mode 100644 index 00000000..3b859fbb --- /dev/null +++ b/packages/flint-js/tests/data-overlay.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import type { ChartOverlaySpec } from '../src/interactive/language/updates'; +import { orderedOverlayRows, projectPointToPath } from '../src/vegalite/interactions/presentation/data-overlay'; + +describe('retained data overlays', () => { + it('orders a path without mutating application rows', () => { + const values = [ + { Year: 2000, x: 3, y: 4 }, + { Year: 1980, x: 1, y: 2 }, + { Year: 1990, x: 2, y: 3 }, + ]; + const spec: ChartOverlaySpec = { + mark: 'line', + data: { values }, + encodings: { x: { field: 'x' }, y: { field: 'y' }, order: { field: 'Year' } }, + role: 'trajectory', + }; + + expect(orderedOverlayRows(spec).map((row) => row.Year)).toEqual([1980, 1990, 2000]); + expect(values.map((row) => row.Year)).toEqual([2000, 1980, 1990]); + }); + + it('projects a free pointer onto the nearest semantic path segment', () => { + const projection = projectPointToPath( + { x: 7, y: 2 }, + [ + { point: { x: 0, y: 0 }, record: { Year: 1980 } }, + { point: { x: 10, y: 0 }, record: { Year: 1990 } }, + { point: { x: 10, y: 10 }, record: { Year: 2000 } }, + ], + ); + + expect(projection?.point).toEqual({ x: 7, y: 0 }); + expect(projection?.distance).toBe(2); + expect(projection?.segment.start.value.Year).toBe(1980); + expect(projection?.segment.end.value.Year).toBe(1990); + expect(projection?.segment.t).toBeCloseTo(0.7); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/encoding-shorthand.test.ts b/packages/flint-js/tests/encoding-shorthand.test.ts index 724e3b73..4c8c149a 100644 --- a/packages/flint-js/tests/encoding-shorthand.test.ts +++ b/packages/flint-js/tests/encoding-shorthand.test.ts @@ -13,6 +13,9 @@ const DATA = [ { weight: 2.1, mpg: 27, origin: 'US' }, { weight: 1.9, mpg: 29, origin: 'EU' }, ]; +function serializableSpec(spec: unknown): unknown { + return JSON.parse(JSON.stringify(spec)); +} const SEMANTIC = { weight: 'Quantity', mpg: 'Quantity', origin: 'Country' }; @@ -70,7 +73,7 @@ describe('channel field shorthand', () => { }, }); - expect(shorthand).toEqual(explicit); + expect(serializableSpec(shorthand)).toEqual(serializableSpec(explicit)); }); it('supports shorthand strings inside a static-series array', () => { @@ -101,6 +104,6 @@ describe('channel field shorthand', () => { }, }); - expect(shorthand).toEqual(explicit); + expect(serializableSpec(shorthand)).toEqual(serializableSpec(explicit)); }); }); 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/gantt-bullet.test.ts b/packages/flint-js/tests/gantt-bullet.test.ts index 000b2ae4..50fc6f51 100644 --- a/packages/flint-js/tests/gantt-bullet.test.ts +++ b/packages/flint-js/tests/gantt-bullet.test.ts @@ -6,6 +6,7 @@ import { assembleVegaLite, getChartOptions } from '../src'; import { coerceGanttEndpoint, formatGanttDuration, sortGanttRows } from '../src/chart-types/gantt'; import { genGanttTests, genBulletTests } from '../src/test-data'; import type { TestCase } from '../src/test-data/types'; +import { bulletChartDef } from '../src/vegalite/templates/bullet'; /** * Gantt and Bullet chart types. @@ -224,6 +225,20 @@ describe('Bullet chart', () => { expect(typeof step).toBe('number'); expect(tick.mark.size).toBeLessThanOrEqual(step); }); + + it('uses the rendered rect mark for a clear value-bar hover affordance', () => { + const semantics = bulletChartDef.semanticInteractions!({ + resolvedEncodings: { + y: { field: 'Country', type: 'nominal' }, + x: { field: 'Share', type: 'quantitative' }, + goal: { field: 'Target', type: 'quantitative' }, + }, + } as any); + expect(semantics.renderHoverStyles).toMatchObject({ + rect: { opacity: 'contrast' }, + }); + expect(semantics.renderHoverStyles).not.toHaveProperty('bar'); + }); }); describe('gallery examples compile', () => { diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts new file mode 100644 index 00000000..69595d33 --- /dev/null +++ b/packages/flint-js/tests/interactions.test.ts @@ -0,0 +1,3366 @@ +import { describe, expect, it, vi } from 'vitest'; +import { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupFocus, clickHighlight, doubleActivate, dragReorder, externalInteraction, hoverGroupFocus, inspect, inspectIndex, lassoSelect, legendToggle, linkedBrush, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; +import type { ClickHighlightOptions } from '../src/interactive/interactions'; +import { affordanceCursor, resolveInteractionAffordance } from '../src/interactive/affordances'; +import { reorderValues } from '../src/interactive/presets/drag-reorder'; +import { annotationCandidates, countAnnotationText, presentAnnotationUpdate } from '../src/interactive/presentation/annotation'; +import { toCanvasInteractionEvent } from '../src/interactive/canvas-interaction'; +import { associateSemanticElementRenderKeys, semanticVisualFamily } from '../src/core/interaction-semantics'; +import { normalizeInspectGuideOptions, normalizeRegionGuideOptions } from '../src/interactive/guides'; +import { + matchesSemanticTargetSelector, +} from '../src/interactive/language/updates'; +import { + axisBrushTrigger, + angularBrushTrigger, + clickTrigger, + contextTrigger, + doubleActivateTrigger, + dragTrigger, + hoverTrigger, + inspectTrigger, + parseInspectMode, + keyboardTrigger, + lassoTrigger, + longPressTrigger, + navigationTrigger, + rectangleTrigger, + xBrushTrigger, + yBrushTrigger, +} from '../src/interactive/triggers'; + +describe('drag trigger', () => { + it('uses a forgiving visual acquisition tolerance', () => { + expect(dragTrigger().targetTolerance).toBe(12); + expect(dragTrigger(20).targetTolerance).toBe(20); + expect(dragTrigger(-1).targetTolerance).toBe(0); + }); + + it('gives the reorder preset the same drag source', () => { + const interaction = dragReorder(); + expect(interaction.eventSource).toEqual(dragTrigger()); + }); +}); +import { AngularRegionSession } from '../src/interactive/gestures/angular-region'; + +const clickMark = (options: Omit = {}) => + clickHighlight({ ...options, id: options.id ?? 'click-mark', targets: ['mark'] }); +import { angularEditAction, isInteractiveControlTarget, pointInAngularSector } from '../src/vegalite/interactions/gestures/region'; +import { + cartesianDragDistance, + constrainCartesianRegion, + intervalPoints, + updateInterval, +} from '../src/interactive/gestures/cartesian-region'; +import { PanSession, PinchSession, wheelZoomFactor } from '../src/interactive/gestures/navigation'; +import { guardNavigationDomain } from '../src/vegalite/interactions/navigation-scale'; +import { + geometryIntersectsRect, + axisItemAt, + axisIntersectingHits, + axisItems, + facetPlotBounds, + facetPlotFrameAt, + INTERACTION_KEY, + nearestItemByBounds, + nearestItemOnInspectAxis, + nextItemInDirection, + PATH_KEY_SUFFIX, + pathHoverPresentationKey, + polarGuideSegment, + polarInspectHits, + tolerantInspectHits, + normalizeVegaRegionEvent, + polygonHits, + renderHit, + sceneItems, +} from '../src/vegalite/interactions/hit-adapter'; +import { + effectiveAnnotationEntries, + evictRetainedStateSiblings, + initialInspectSeries, + inspectEmphasisSignature, + inspectSeriesPresentationKeys, + interactionsForHoverPresentation, + longPressMovedBeyond, + domainForPlotGeometry, + keyboardTargetItems, + mergeRetainedPreview, + nearestReorderHit, + resolveAssistDistance, + reorderProjectionBounds, + resolveSupportedOperation, +} from '../src/vegalite/interactions/runtime'; +import { + activeReorderAxis, + dragGhostDelta, + eligibleReorderAxes, + eligibleReorderAxesForAxis, + eligibleReorderAxesForHit, + reorderOwnedItems, +} from '../src/vegalite/interactions/presentation/drag-reorder-overlay'; +import { areaSpotlightOpacity, hoverContrastOpacity } from '../src/vegalite/interactions/presentation/focus-overlay'; +import { + targetFeedbackDetailsPosition, + targetFeedbackEntries, + targetFeedbackPoint, +} from '../src/vegalite/interactions/presentation/target-feedback-overlay'; +import { inspectGuideLine } from '../src/vegalite/interactions/presentation/inspect-guide-overlay'; +import { + annotationFacingEdges, + annotationLeaderPorts, + routeAnnotationLeaders, +} from '../src/vegalite/interactions/presentation/annotation-leader-routing'; +import { + annotationCandidateAngles, + annotationItem, + annotationObstacleOverlapCost, + annotationObstacleTier, + annotationSourceBounds, + sourceEdgeAttachment, + isAnnotationSourceItem, + isAnnotationObstacle, + segmentMidpointConnectionPoint, + valueEndConnectionPoint, + valueSideConnectionPoint, +} from '../src/vegalite/interactions/presentation/annotation-overlay'; +import { withoutSemanticInteractionField } from '../src/vegalite/interactions/compile'; +import { histogramDef } from '../src/vegalite/templates/bar'; +import { areaChartDef } from '../src/vegalite/templates/area'; +import { candlestickChartDef } from '../src/vegalite/templates/candlestick'; +import { connectedScatterDef } from '../src/vegalite/templates/connected-scatter'; +import { ganttChartDef } from '../src/vegalite/templates/gantt'; +import { lineChartDef } from '../src/vegalite/templates/line'; +import { lollipopChartDef } from '../src/vegalite/templates/lollipop'; +import { rangeAreaChartDef } from '../src/vegalite/templates/range-area'; +import { boxplotDef } from '../src/vegalite/templates/scatter'; +import { waterfallChartDef } from '../src/vegalite/templates/waterfall'; +import type { + CanvasInteractionDef, + InteractionContext, + InteractionDef, + InteractionModifiers, + InteractionPhase, + ChartUpdate, + ChartUpdateOp, + SemanticInteractionEvent, + SemanticElement, + SemanticTarget, +} from '../src/interactive/interactions'; + +function annotationUpdate( + element: SemanticElement, + visual: SemanticTarget['visual'] = { kind: 'mark', role: 'test' }, + text?: string, +) { + return { + id: 'test-annotation', + ops: [{ + op: 'set-annotation' as const, + target: { visual, elements: [element] }, + value: text === undefined ? {} : { text }, + }], + }; +} + +function handleSemanticEvent( + interaction: CanvasInteractionDef, + event: SemanticInteractionEvent, + context: InteractionContext, +) { + return interaction.handle!(toCanvasInteractionEvent(event, interaction.eventSource), context); +} + +function semanticUpdate( + interaction: CanvasInteractionDef, + target: SemanticTarget | null, + context: InteractionContext, + options: { + source?: 'element' | 'region'; + phase?: InteractionPhase; + modifiers?: InteractionModifiers; + } = {}, +) { + return handleSemanticEvent(interaction, { + type: 'semantic', + source: options.source ?? 'element', + phase: options.phase ?? 'commit', + target, + modifiers: options.modifiers, + }, context); +} + +describe('physical region gestures', () => { + it('does not start a region gesture from an interactive control', () => { + const icon = { closest: () => ({ tagName: 'BUTTON' }) } as unknown as EventTarget; + const plot = { closest: () => null } as unknown as EventTarget; + + expect(isInteractiveControlTarget(icon)).toBe(true); + expect(isInteractiveControlTarget(plot)).toBe(false); + }); + + it('projects Cartesian regions and measures only the configured axis', () => { + const start = { x: 20, y: 30 }; + const end = { x: 80, y: 90 }; + const plotSize = { width: 300, height: 180 }; + + expect(constrainCartesianRegion(start, end, 'x', plotSize)).toEqual({ + start: { x: 20, y: 0 }, end: { x: 80, y: 180 }, + }); + expect(constrainCartesianRegion(start, end, 'y', plotSize)).toEqual({ + start: { x: 0, y: 30 }, end: { x: 300, y: 90 }, + }); + expect(cartesianDragDistance(start, end, 'x')).toBe(60); + expect(cartesianDragDistance(start, end, 'y')).toBe(60); + expect(cartesianDragDistance(start, end, 'xy')).toBeCloseTo(Math.hypot(60, 60)); + }); + + it('projects a Cartesian region within its containing facet frame', () => { + const frame = { x: 15, y: 77, width: 80, height: 40 }; + const view = { + scenegraph: () => ({ + root: { + mark: { marktype: 'group' }, x: 5, y: 7, + items: [ + { mark: { marktype: 'group', role: 'scope', name: 'cell' }, x: 10, y: 20, width: 80, height: 40 }, + { mark: { marktype: 'group', role: 'scope', name: 'cell' }, x: 10, y: 70, width: 80, height: 40 }, + ], + }, + }), + }; + + expect(facetPlotFrameAt(view, { x: 30, y: 90 }, { x: 0, y: 0, width: 200, height: 150 })) + .toEqual(frame); + expect(constrainCartesianRegion({ x: 25, y: 90 }, { x: 70, y: 130 }, 'x', frame)).toEqual({ + start: { x: 25, y: 77 }, end: { x: 70, y: 117 }, + }); + expect(normalizeVegaRegionEvent( + view, { x: 25, y: 90 }, { x: 70, y: 130 }, 'commit', 'intersect', + { shift: false, ctrl: false, meta: false }, 'x', frame, 'create', false, + ).region).toEqual({ x: 25, y: 77, width: 45, height: 40 }); + }); + + it('creates, moves, and resizes stateful Cartesian intervals', () => { + expect(updateInterval({ x: 30, y: 0 }, { x: 70, y: 0 }, 'x', 100, 'create')).toEqual({ + leading: 30, trailing: 70, + }); + expect(updateInterval( + { x: 95, y: 0 }, { x: 50, y: 0 }, 'x', 100, 'move', { leading: 30, trailing: 70 }, + )).toEqual({ leading: 60, trailing: 100 }); + expect(updateInterval( + { x: 90, y: 0 }, { x: 0, y: 0 }, 'x', 100, 'resize-leading', { leading: 30, trailing: 70 }, + )).toEqual({ leading: 70, trailing: 90 }); + expect(intervalPoints({ leading: 20, trailing: 60 }, 'y')).toEqual({ + start: { x: 0, y: 20 }, end: { x: 0, y: 60 }, + }); + }); + + it('accumulates angular movement continuously across the zero-angle seam', () => { + const frame = { center: { x: 0, y: 0 }, innerRadius: 10, outerRadius: 100 }; + const pointAt = (angle: number) => ({ x: 100 * Math.sin(angle), y: -100 * Math.cos(angle) }); + const session = new AngularRegionSession(pointAt(Math.PI * 1.9), frame); + + session.move(pointAt(Math.PI * 0.1)); + + expect(session.sector().endAngle - session.sector().startAngle).toBeCloseTo(Math.PI * 0.2); + expect(session.dragDistance()).toBeCloseTo(Math.PI * 20); + }); + + it('classifies both handles and the interior of a wrapped angular edit', () => { + const sector = { + center: { x: 0, y: 0 }, innerRadius: 20, outerRadius: 100, + startAngle: Math.PI * 1.75, endAngle: Math.PI * 2.25, + }; + + expect(angularEditAction(sector.startAngle + 0.02, sector)).toBe('resize-leading'); + expect(angularEditAction(sector.endAngle - 0.02, sector)).toBe('resize-trailing'); + expect(angularEditAction(0, sector)).toBe('move'); + expect(angularEditAction(Math.PI, sector)).toBeUndefined(); + }); + + it('requires a stateful angular edit pointer to remain inside the annulus', () => { + const sector = { + center: { x: 100, y: 100 }, innerRadius: 30, outerRadius: 80, + startAngle: 0, endAngle: Math.PI / 2, + }; + const pointAt = (radius: number, angle: number) => ({ + x: sector.center.x + radius * Math.sin(angle), + y: sector.center.y - radius * Math.cos(angle), + }); + + expect(pointInAngularSector(pointAt(60, Math.PI / 4), sector)).toBe(true); + expect(pointInAngularSector(pointAt(60, sector.startAngle), sector)).toBe(true); + expect(pointInAngularSector(pointAt(60, sector.endAngle), sector)).toBe(true); + expect(pointInAngularSector(pointAt(10, Math.PI / 4), sector)).toBe(false); + expect(pointInAngularSector(pointAt(90, Math.PI / 4), sector)).toBe(false); + expect(pointInAngularSector(pointAt(60, Math.PI), sector)).toBe(false); + }); +}); + +describe('public canvas interaction events', () => { + it('classifies semantic visual roles consistently', () => { + expect(semanticVisualFamily('legend-symbol')).toBe('legend'); + expect(semanticVisualFamily('axis-label')).toBe('axis'); + expect(semanticVisualFamily('facet-header')).toBe('facet'); + expect(semanticVisualFamily('annotation-label')).toBe('annotation'); + expect(semanticVisualFamily('bar')).toBe('element'); + }); + + it('projects element and legend actions with point geometry', () => { + const element = toCanvasInteractionEvent({ + type: 'semantic', + source: 'element', + phase: 'commit', + point: { x: 20, y: 30 }, + target: { + visual: { kind: 'mark', role: 'bar' }, + elements: [{ value: { Country: 'Japan' } }], + }, + }, clickTrigger); + const legend = toCanvasInteractionEvent({ + type: 'semantic', + source: 'element', + phase: 'preview', + point: { x: 200, y: 30 }, + target: { + visual: { kind: 'widget', role: 'legend-symbol' }, + elements: [{ value: { Country: 'Japan' } }], + }, + }, hoverTrigger); + + expect(element.action).toBe('click-element'); + expect(element.geometry.plot).toEqual({ kind: 'point', point: { x: 20, y: 30 } }); + expect(legend.action).toBe('hover-legend'); + }); + + it('projects region and navigation events', () => { + const brush = toCanvasInteractionEvent({ + type: 'semantic', + source: 'region', + phase: 'preview', + axis: 'x', + operation: 'resize-trailing', + region: { x: 10, y: 0, width: 40, height: 100 }, + target: null, + }, xBrushTrigger()); + const zoom = toCanvasInteractionEvent({ + type: 'navigation', + phase: 'commit', + operation: 'zoom', + axes: 'xy', + factor: 1.2, + anchor: { x: 0.5, y: 0.4 }, + }, navigationTrigger({ axes: 'xy' })); + + expect(brush).toMatchObject({ + action: 'brush-x', + operation: 'resize-trailing', + geometry: { plot: { kind: 'rect', axis: 'x' } }, + }); + expect(zoom).toMatchObject({ + action: 'zoom-viewport', + operation: 'zoom', + geometry: { + plot: { + kind: 'viewport', + axes: 'xy', + factor: 1.2, + anchor: { x: 0.5, y: 0.4 }, + }, + }, + target: null, + }); + }); +}); + +describe('hover presentation policy', () => { + it('maps area segments to their path but keeps line segments local', () => { + const areaMark = { + marktype: 'area', + items: [ + { datum: { [INTERACTION_KEY]: 'first' } }, + { datum: { [INTERACTION_KEY]: 'second' } }, + ], + }; + const areaItems = areaMark.items.map((item) => ({ ...item, mark: areaMark })); + const lineMark = { ...areaMark, marktype: 'line' }; + const lineItems = lineMark.items.map((item) => ({ ...item, mark: lineMark })); + + expect(pathHoverPresentationKey(areaItems, `second${PATH_KEY_SUFFIX}`)) + .toBe(`first${PATH_KEY_SUFFIX}`); + expect(pathHoverPresentationKey(lineItems, `second${PATH_KEY_SUFFIX}`)) + .toBe(`second${PATH_KEY_SUFFIX}`); + expect(pathHoverPresentationKey(areaItems, 'ordinary-mark')).toBe('ordinary-mark'); + }); + + it('computes generic overlay opacity contrast', () => { + expect(hoverContrastOpacity(0.6)).toBe(1); + expect(hoverContrastOpacity(1)).toBe(0.9); + expect(areaSpotlightOpacity(1, 0.25, true, true)).toBe(0.9); + expect(areaSpotlightOpacity(1, 0.25, false, true)).toBe(0.25); + expect(areaSpotlightOpacity(1, 1, false, false)).toBe(1); + }); + + it('includes only interactions that register hover presentation', () => { + const preset = clickMark(); + const observer: InteractionDef = { id: 'click-observer', eventSource: clickTrigger }; + const hover: InteractionDef = { id: 'hover-observer', eventSource: hoverTrigger }; + const reorder = dragReorder(); + const indexReader = inspectIndex({ show: 'single', seriesBy: 'Series' }); + const sustained = longPress(); + const doubled = doubleActivate(); + + expect(interactionsForHoverPresentation( + [preset, observer, sustained, doubled], [hover], [reorder], [indexReader], + ).map(({ id }) => id)).toEqual([ + 'click-mark', 'long-press', 'double-activate', 'drag-reorder', 'inspect-index', + ]); + }); + + it('expands group hover presentation to the committed cohort', () => { + const interaction = clickGroupFocus(); + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { key: 'west-a' }, records: [{ Region: 'West', Segment: 'A' }] }], + }; + const context = { + chartType: 'Grouped Bar Chart', + selected: [], + seriesField: 'Segment', + available: [ + ...target.elements, + { value: { key: 'east-a' }, records: [{ Region: 'East', Segment: 'A' }] }, + { value: { key: 'west-b' }, records: [{ Region: 'West', Segment: 'B' }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context, { phase: 'preview' })?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'west-a' } }, + { value: { key: 'east-a' } }, + ] }], + }); + }); +}); + +describe('public chart updates', () => { + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { __flint_interaction_key: 'japan' } }], + }; + + it('uses direct declarative operation JSON', () => { + const ops: ChartUpdateOp[] = [{ + op: 'set-style', + targets: [target, { select: { key: { Country: 'Japan' } } }], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }, { + op: 'set-annotation', target, value: { text: 'Selected' }, + }, { + op: 'set-viewport', axes: 'x', value: { x: [0, 10] }, + }, { + op: 'set-order', scope: 'category', field: 'Country', values: ['Japan'], + }]; + expect(ops).toEqual([{ + op: 'set-style', + targets: [target, { select: { key: { Country: 'Japan' } } }], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }, { + op: 'set-annotation', target, value: { text: 'Selected' }, + }, { + op: 'set-viewport', axes: 'x', value: { x: [0, 10] }, + }, { + op: 'set-order', scope: 'category', field: 'Country', values: ['Japan'], + }]); + }); + + it('matches selectors only against declared semantic fields', () => { + const selector = { select: { key: { Country: 'Japan', Year: 2024 } } }; + const row = { Country: 'Japan', Year: 2024, Revenue: 42 }; + + expect(matchesSemanticTargetSelector(selector, ['Country', 'Year'], row)).toBe(true); + expect(matchesSemanticTargetSelector(selector, ['Country'], row)).toBe(false); + expect(matchesSemanticTargetSelector( + { select: { key: {} } }, + ['Country'], + row, + )).toBe(false); + }); +}); + +describe('viewport navigation', () => { + it('allows an interaction observer without a handler', () => { + const interaction: InteractionDef = { + id: 'click-observer', + eventSource: clickTrigger, + }; + + expect(interaction.handle).toBeUndefined(); + }); + + it('normalizes pan movement and wheel deltas without renderer state', () => { + const pan = new PanSession({ x: 20, y: 30 }, { width: 200, height: 100 }); + expect(pan.move({ x: 40, y: 20 })).toEqual({ x: 0.1, y: -0.1 }); + expect(pan.move({ x: 50, y: 40 })).toEqual({ x: 0.05, y: 0.2 }); + expect(pan.dragDistance()).toBeCloseTo(Math.hypot(20, -10) + Math.hypot(10, 20)); + expect(wheelZoomFactor(-100, 0, 400, 0.002)).toBeCloseTo(Math.exp(0.2)); + expect(wheelZoomFactor(1, 1, 400, 0.002)).toBeCloseTo(Math.exp(-0.032)); + }); + + it('normalizes incremental pinch distance around the moving midpoint', () => { + const pinch = new PinchSession( + { x: 20, y: 20 }, + { x: 80, y: 20 }, + { width: 100, height: 200 }, + ); + expect(pinch.move({ x: 10, y: 30 }, { x: 90, y: 30 })).toEqual({ + factor: 4 / 3, + anchor: { x: 0.5, y: 0.15 }, + }); + expect(pinch.move({ x: 30, y: 50 }, { x: 70, y: 50 })).toEqual({ + factor: 0.5, + anchor: { x: 0.5, y: 0.25 }, + }); + }); + + it('ignores a collapsed pinch until the pointers separate', () => { + const pinch = new PinchSession( + { x: 50, y: 50 }, + { x: 50, y: 50 }, + { width: 100, height: 100 }, + ); + expect(pinch.move({ x: 50, y: 50 }, { x: 50, y: 50 })).toBeNull(); + expect(pinch.move({ x: 40, y: 50 }, { x: 60, y: 50 })).toBeNull(); + expect(pinch.move({ x: 30, y: 50 }, { x: 70, y: 50 })).toEqual({ + factor: 2, + anchor: { x: 0.5, y: 0.5 }, + }); + }); + + it('resolves normalized navigation input through its viewport handler', () => { + const interaction = navigate({ axes: 'xy' }); + expect(interaction.eventSource).toEqual(navigationTrigger({ axes: 'xy' })); + expect(interaction.handle).toBeTypeOf('function'); + expect(interaction.navigationDomainGuard).toEqual({ + minVisibleFraction: 0.02, + maxVisibleFraction: 1, + overscrollFraction: 0, + }); + const resolveNavigation = vi.fn(() => ({ + op: 'set-viewport' as const, + axes: 'x' as const, + value: { x: [10, 20] as const }, + })); + const update = interaction.handle!(toCanvasInteractionEvent({ + type: 'navigation', phase: 'commit', operation: 'zoom', axes: 'x', + factor: 2, anchor: { x: 0.5, y: 0.5 }, + }, interaction.eventSource), { + chartType: 'Line Chart', selected: [], resolveNavigation, + }); + expect(update).toEqual({ id: 'navigate', ops: [{ + op: 'set-viewport', axes: 'x', value: { x: [10, 20] }, + }] }); + expect(resolveNavigation).toHaveBeenCalledWith(expect.objectContaining({ + operation: 'zoom', axes: 'x', factor: 2, + }), interaction.navigationDomainGuard); + expect(() => navigate({ + domainGuard: { minVisibleFraction: 0.5, maxVisibleFraction: 0.25 }, + })).toThrow(/maxVisibleFraction/); + }); + + it('filters update operations against compiled chart capabilities', () => { + const plan = { + navigationAxes: { x: { scale: 'x', signal: 'xDomain', type: 'linear' as const } }, + reorderAxes: [{ axis: 'x' as const, field: 'Month', scale: 'x', signal: 'xOrder' }], + }; + expect(resolveSupportedOperation({ + op: 'set-viewport', axes: 'xy', value: { x: [0, 5], y: [0, 10] }, + }, plan)).toEqual({ + op: { op: 'set-viewport', axes: 'x', value: { x: [0, 5] } }, + unsupported: true, + }); + expect(resolveSupportedOperation({ + op: 'set-order', scope: 'series', field: 'Month', values: ['Jan'], + }, plan)).toEqual({ op: null, unsupported: true }); + expect(resolveSupportedOperation({ + op: 'set-order', scope: 'category', field: 'Month', values: ['Jan'], + }, plan).unsupported).toBe(false); + }); + + it('guards linear, temporal, and logarithmic domains against the initial extent', () => { + const guard = { minVisibleFraction: 0.1, maxVisibleFraction: 1, overscrollFraction: 0 }; + expect(guardNavigationDomain([45, 46], [0, 100], 'linear', guard)).toEqual([40.5, 50.5]); + expect(guardNavigationDomain([-20, 80], [0, 100], 'linear', guard)).toEqual([0, 100]); + const temporal = guardNavigationDomain( + [new Date('2020-05-01'), new Date('2020-05-02')], + [new Date('2020-01-01'), new Date('2021-01-01')], + 'time', + guard, + ); + expect(temporal[0]).toBeInstanceOf(Date); + expect((temporal[1] as Date).getTime() - (temporal[0] as Date).getTime()) + .toBeCloseTo((Date.UTC(2021, 0, 1) - Date.UTC(2020, 0, 1)) * 0.1, -2); + const logarithmic = guardNavigationDomain([10, 11], [1, 1000], 'log', guard).map(Number); + expect(logarithmic[1] / logarithmic[0]).toBeCloseTo(Math.pow(1000, 0.1)); + expect(guardNavigationDomain([-100, 200], [0, 100], 'linear', { + minVisibleFraction: 0.1, maxVisibleFraction: 1.5, overscrollFraction: 0, + })).toEqual([-25, 125]); + expect(guardNavigationDomain([-50, 50], [0, 100], 'linear', { + minVisibleFraction: 0.1, maxVisibleFraction: 1, overscrollFraction: 0.2, + })).toEqual([-20, 80]); + }); +}); + +describe('interaction definitions', () => { + it('resolves a reorder guide from an aggregate target without source records', () => { + const axis = { axis: 'x' as const, field: 'Species' }; + const preview = { + start: { x: 10, y: 20 }, current: { x: 40, y: 20 }, axis: 'x' as const, + source: { + visual: { kind: 'mark' as const, role: 'distribution' }, + elements: [{ value: { Species: 'Adelie' }, records: [] }], + }, + destination: { + visual: { kind: 'mark' as const, role: 'distribution' }, + elements: [{ value: { Species: 'Chinstrap' }, records: [{ Species: 'Chinstrap' }] }], + }, + }; + + expect(activeReorderAxis([axis], preview)).toEqual(axis); + }); + + it('does not start category reorder from a line spanning multiple axis values', () => { + const axes = [{ axis: 'x' as const, field: 'Period' }]; + const line = { + visual: { kind: 'path' as const, role: 'line' }, + elements: [{ + value: { Product: 'Laptop' }, + records: [ + { Period: 2019, Product: 'Laptop', Revenue: 20 }, + { Period: 2024, Product: 'Laptop', Revenue: 62 }, + ], + }], + }; + const point = { + visual: { kind: 'mark' as const, role: 'symbol' }, + elements: [{ + value: { Period: 2019, Product: 'Laptop', Revenue: 20 }, + records: [{ Period: 2019, Product: 'Laptop', Revenue: 20 }], + }], + }; + + expect(eligibleReorderAxes(axes, line)).toEqual([]); + expect(eligibleReorderAxes(axes, point)).toEqual(axes); + expect(eligibleReorderAxesForHit(axes, { + datum: { Period: 2019 }, source: 'mark', markType: 'symbol', + })).toEqual(axes); + expect(eligibleReorderAxesForHit(axes, { + datum: { Period: 2019 }, source: 'mark', markType: 'line', + pathData: line.elements[0].records, + })).toEqual([]); + }); + + it('matches an axis drag source by its exact axis and field', () => { + const axes = [ + { axis: 'x' as const, field: 'Category' }, + { axis: 'y' as const, field: 'Category' }, + { axis: 'x' as const, field: 'Series' }, + ]; + + expect(eligibleReorderAxesForAxis(axes, { axis: 'x', field: 'Category' })) + .toEqual([axes[0]]); + }); + + it('finds a categorical axis label in a nested Canvas scenegraph', () => { + const axisGroup = { mark: { role: 'axis' }, datum: { scale: 'x' } }; + const label = { + mark: { role: 'axis-label', group: axisGroup }, + datum: { value: 'B' }, + bounds: { x1: 10, x2: 30, y1: 40, y2: 55 }, + }; + const view = { + scenegraph: () => ({ + root: { + mark: { marktype: 'group' }, x: 5, y: 7, + items: [label], + }, + }), + }; + + expect(axisItemAt(view, { x: 20, y: 52 }, { + x: { axis: 'x', field: 'Category', type: 'nominal' }, + })).toBe(label); + expect(axisItems(view, { + x: { axis: 'x', field: 'Category', type: 'nominal' }, + })).toEqual([label]); + expect(axisItemAt(view, { x: 2, y: 2 }, { + x: { axis: 'x', field: 'Category', type: 'nominal' }, + })).toBeUndefined(); + }); + + it('resolves reorder destinations by nearest axis slot, including gaps and plot edges', () => { + const items = ['A', 'B', 'C'].map((Category, index) => ({ + datum: { [INTERACTION_KEY]: Category, Category }, + mark: { marktype: 'rect', name: 'bars' }, + bounds: { x1: index * 100, x2: index * 100 + 40, y1: 0, y2: 80 }, + })); + + expect(nearestReorderHit(items, 'x', 'Category', 78)?.datum.Category).toBe('B'); + expect(nearestReorderHit(items, 'x', 'Category', -200)?.datum.Category).toBe('A'); + expect(nearestReorderHit(items, 'x', 'Category', 500)?.datum.Category).toBe('C'); + }); + + it('anchors a reorder projection to the bar rather than a colocated dot', () => { + const items = [ + { + datum: { [INTERACTION_KEY]: 'dot-B', Category: 'B' }, + mark: { marktype: 'symbol', name: 'dots' }, + bounds: { x1: 198, x2: 202, y1: 48, y2: 52 }, + }, + { + datum: { [INTERACTION_KEY]: 'bar-B', Category: 'B' }, + mark: { marktype: 'rect', name: 'bars' }, + bounds: { x1: 0, x2: 160, y1: 40, y2: 60 }, + }, + ]; + + expect(reorderProjectionBounds(items, { field: 'Category' }, 'B')).toEqual({ + x1: 0, x2: 160, y1: 40, y2: 60, + }); + }); + + it('uses the outer boundary of grouped bars for a reorder projection', () => { + const items = [ + { + datum: { [INTERACTION_KEY]: 'A/one', Category: 'A' }, + mark: { marktype: 'rect', name: 'bars' }, + bounds: { x1: 0, x2: 30, y1: 20, y2: 40 }, + }, + { + datum: { [INTERACTION_KEY]: 'A/two', Category: 'A' }, + mark: { marktype: 'rect', name: 'bars' }, + bounds: { x1: 0, x2: 55, y1: 42, y2: 62 }, + }, + ]; + + expect(reorderProjectionBounds(items, { field: 'Category' }, 'A')).toEqual({ + x1: 0, x2: 55, y1: 20, y2: 62, + }); + }); + + it('uses the outer boundary of a lollipop stem and dot', () => { + const items = [ + { + datum: { [INTERACTION_KEY]: 'A/stem', Category: 'A' }, + mark: { marktype: 'rule', name: 'stems' }, + bounds: { x1: 10, x2: 90, y1: 29, y2: 31 }, + }, + { + datum: { [INTERACTION_KEY]: 'A/dot', Category: 'A' }, + mark: { marktype: 'symbol', name: 'dots' }, + bounds: { x1: 85, x2: 95, y1: 25, y2: 35 }, + }, + ]; + + expect(reorderProjectionBounds(items, { field: 'Category' }, 'A')).toEqual({ + x1: 10, x2: 95, y1: 25, y2: 35, + }); + }); + + it('moves category values to the destination slot in either direction', () => { + expect(reorderValues(['A', 'B', 'C', 'D'], 'A', 'C')).toEqual(['B', 'C', 'A', 'D']); + expect(reorderValues(['A', 'B', 'C', 'D'], 'D', 'B')).toEqual(['A', 'D', 'B', 'C']); + expect(reorderValues(['A', 'B'], 'A', 'A')).toEqual(['A', 'B']); + }); + + it('keeps a committed order underneath a drag-only preview', () => { + const retained = { + id: 'drag-reorder', + ops: [{ + op: 'set-order' as const, + scope: 'category' as const, + field: 'Country', + values: ['China', 'United States'], + }], + }; + const preview = { + id: 'drag-reorder', + ops: [{ + op: 'set-freeform-overlay' as const, + name: 'drag-reorder-preview', + value: { + coordinateSpace: 'plot' as const, + body: [{ type: 'svg' as const, content: '' }], + }, + }], + }; + + expect(mergeRetainedPreview(retained, preview)?.ops).toEqual([ + retained.ops[0], + preview.ops[0], + ]); + }); + + it('lets a preview replace retained state with the same operation identity', () => { + const retained = { + id: 'drag-reorder', + ops: [{ + op: 'set-order' as const, scope: 'category' as const, + field: 'Country', values: ['A', 'B'], + }], + }; + const preview = { + id: 'drag-reorder', + ops: [{ + op: 'set-order' as const, scope: 'category' as const, + field: 'Country', values: ['B', 'A'], + }], + }; + + expect(mergeRetainedPreview(retained, preview)?.ops).toEqual(preview.ops); + }); + + it('moves the drag ghost with the pointer on both axes', () => { + expect(dragGhostDelta({ + start: { x: 20, y: 30 }, + current: { x: 75, y: 110 }, + })).toEqual({ x: 55, y: 80 }); + }); + + it('lowers a committed bar drag to a category-order update', () => { + const interaction = dragReorder(); + const elements = ['A', 'B', 'C'].map((Category) => ({ + value: { key: Category }, records: [{ Category }], + })); + const update = interaction.handle!({ + action: 'drag', + phase: 'commit', + geometry: { plot: { kind: 'drag', start: { x: 10, y: 20 }, current: { x: 80, y: 20 }, delta: { x: 70, y: 0 } } }, + target: { visual: { kind: 'mark', role: 'bar' }, elements: [elements[0]] }, + dropTarget: { visual: { kind: 'mark', role: 'bar' }, elements: [elements[2]] }, + }, { + chartType: 'Bar Chart', selected: [], available: elements, + categoryField: 'Category', categoryAxis: 'x', + }); + + expect(update).toEqual({ + id: 'drag-reorder', + ops: [{ op: 'set-order', scope: 'category', field: 'Category', values: ['B', 'C', 'A'] }], + }); + }); + + it('requests reorder presentation during drag preview', () => { + const interaction = dragReorder(); + const elements = ['A', 'B', 'C'].map((Category) => ({ + value: { key: Category }, records: [{ Category }], + })); + const target = { visual: { kind: 'mark' as const, role: 'bar' }, elements: [elements[0]] }; + const dropTarget = { visual: { kind: 'mark' as const, role: 'bar' }, elements: [elements[2]] }; + const update = interaction.handle!({ + action: 'drag', phase: 'preview', + geometry: { + plot: { + kind: 'drag', start: { x: 10, y: 20 }, current: { x: 80, y: 20 }, + delta: { x: 70, y: 0 }, axis: 'x', + }, + projection: { + kind: 'axis', axis: 'x', point: { x: 80, y: 20 }, + targetBounds: { x: 60, y: 0, width: 20, height: 100 }, + plotBounds: { x: 0, y: 0, width: 100, height: 100 }, + }, + }, + target, + dropTarget, + }, { + chartType: 'Bar Chart', selected: [], available: elements, + categoryField: 'Category', categoryAxis: 'x', + }); + + expect(update?.ops).toEqual([ + { + op: 'set-style', + targets: [ + { visual: { kind: 'mark', role: 'mark' }, elements: [elements[1], elements[2]] }, + { + visual: { kind: 'axis', role: 'axis-label' }, + elements: [ + { value: { axis: 'x', field: 'Category', value: 'B' } }, + { value: { axis: 'x', field: 'Category', value: 'C' } }, + ], + }, + ], + value: { state: 'muted', opacity: 0.35 }, + }, + { + op: 'set-style', + targets: [ + { visual: { kind: 'mark', role: 'bar' }, elements: [elements[0]] }, + { + visual: { kind: 'axis', role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }, + ], + value: { state: 'emphasized', opacity: 1 }, + }, + { + op: 'set-freeform-overlay', + name: 'drag-reorder-preview', + value: { + coordinateSpace: 'plot', + body: [ + { + type: 'clone', + targets: [ + { visual: { kind: 'mark', role: 'bar' }, elements: [elements[0]] }, + { + visual: { kind: 'axis', role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }, + ], + transform: { translate: { x: 70, y: 0 } }, + opacity: 0.62, + }, + { + type: 'svg', + content: '', + }, + ], + }, + }, + ]); + }); + + it('clears the preview without reordering when released over the source slot', () => { + const interaction = dragReorder(); + const elements = ['A', 'B', 'C'].map((Category) => ({ + value: { key: Category }, records: [{ Category }], + })); + const target = { visual: { kind: 'mark' as const, role: 'bar' }, elements: [elements[0]] }; + const update = interaction.handle!({ + action: 'drag', phase: 'commit', + geometry: { + plot: { + kind: 'drag', start: { x: 10, y: 20 }, current: { x: 12, y: 20 }, + delta: { x: 2, y: 0 }, axis: 'x', + }, + }, + target, + dropTarget: target, + }, { + chartType: 'Bar Chart', selected: [], available: elements, + categoryField: 'Category', categoryAxis: 'x', + }); + + expect(update).toEqual({ id: 'drag-reorder', ops: [] }); + }); + + it('lowers an axis-label drag to the same category-order update', () => { + const interaction = dragReorder(); + const elements = ['A', 'B', 'C'].map((Category) => ({ + value: { key: Category }, records: [{ Category }], + })); + const update = interaction.handle!({ + action: 'drag', phase: 'commit', + geometry: { + plot: { + kind: 'drag', start: { x: 10, y: 20 }, current: { x: 80, y: 20 }, + delta: { x: 70, y: 0 }, axis: 'x', + }, + }, + target: { + visual: { kind: 'axis', role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }, + dropTarget: { visual: { kind: 'mark', role: 'bar' }, elements: [elements[2]] }, + }, { + chartType: 'Bar Chart', selected: [], available: elements, + categoryField: 'Category', categoryAxis: 'x', + }); + + expect(update).toEqual({ + id: 'drag-reorder', + ops: [{ op: 'set-order', scope: 'category', field: 'Category', values: ['B', 'C', 'A'] }], + }); + }); + + it('composes sequential category reorders against the current order', () => { + const interaction = dragReorder(); + const elements = ['1', '2', '3', '4', '5'].map((Category) => ({ + value: { key: Category }, records: [{ Category }], + })); + const drag = (source: number, destination: number, categoryOrder: readonly string[]) => + interaction.handle!({ + action: 'drag', phase: 'commit', + geometry: { plot: { kind: 'drag', start: { x: 0, y: 0 }, current: { x: 1, y: 0 }, delta: { x: 1, y: 0 } } }, + target: { visual: { kind: 'mark', role: 'bar' }, elements: [elements[source]] }, + dropTarget: { visual: { kind: 'mark', role: 'bar' }, elements: [elements[destination]] }, + }, { + chartType: 'Bar Chart', selected: [], available: elements, + categoryField: 'Category', categoryAxis: 'x', categoryOrder, + })?.ops[0]; + + const first = drag(4, 2, ['1', '2', '3', '4', '5']); + expect(first).toMatchObject({ values: ['1', '2', '5', '3', '4'] }); + const second = drag(3, 4, first?.op === 'set-order' ? first.values as string[] : []); + expect(second).toMatchObject({ values: ['1', '2', '4', '5', '3'] }); + }); + + it.each([ + [{ x: 70, y: 10 }, 'x', 'column', ['B', 'A']], + [{ x: 10, y: 70 }, 'y', 'row', ['R2', 'R1']], + ] as const)('selects a Heatmap reorder axis from drag direction', (delta, axis, field, orderedValues) => { + const interaction = dragReorder(); + const source = { value: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; + const destination = { value: { key: 'B/R2' }, records: [{ column: 'B', row: 'R2' }] }; + const update = interaction.handle!({ + action: 'drag', phase: 'commit', + geometry: { plot: { kind: 'drag', start: { x: 0, y: 0 }, current: delta, delta } }, + target: { visual: { kind: 'mark', role: 'cell' }, elements: [source] }, + dropTarget: { visual: { kind: 'mark', role: 'cell' }, elements: [destination] }, + }, { + chartType: 'Heatmap', selected: [], + reorderAxes: [ + { axis: 'x', field: 'column', order: ['A', 'B'] }, + { axis: 'y', field: 'row', order: ['R1', 'R2'] }, + ], + }); + + expect(update?.ops[0]).toEqual({ op: 'set-order', scope: 'category', field, values: orderedValues }); + }); + + it('preserves the column order when a Heatmap row reorder replaces the same retained update', () => { + const interaction = dragReorder(); + const source = { value: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; + const destination = { value: { key: 'B/R2' }, records: [{ column: 'B', row: 'R2' }] }; + const drag = (axis: 'x' | 'y', columnOrder: readonly string[], rowOrder: readonly string[]) => + interaction.handle!({ + action: 'drag', phase: 'commit', + geometry: { + plot: { + kind: 'drag', start: { x: 0, y: 0 }, current: { x: 70, y: 70 }, + delta: { x: 70, y: 70 }, axis, + }, + }, + target: { visual: { kind: 'mark', role: 'cell' }, elements: [source] }, + dropTarget: { visual: { kind: 'mark', role: 'cell' }, elements: [destination] }, + }, { + chartType: 'Heatmap', selected: [], + reorderAxes: [ + { axis: 'x', field: 'column', order: columnOrder }, + { axis: 'y', field: 'row', order: rowOrder }, + ], + }); + + const columnUpdate = drag('x', ['A', 'B'], ['R1', 'R2']); + expect(columnUpdate?.ops).toEqual([ + { op: 'set-order', scope: 'category', field: 'column', values: ['B', 'A'] }, + { op: 'set-order', scope: 'category', field: 'row', values: ['R1', 'R2'] }, + ]); + + const rowUpdate = drag('y', ['B', 'A'], ['R1', 'R2']); + expect(rowUpdate?.ops).toEqual([ + { op: 'set-order', scope: 'category', field: 'row', values: ['R2', 'R1'] }, + { op: 'set-order', scope: 'category', field: 'column', values: ['B', 'A'] }, + ]); + }); + + it('keeps a Heatmap drag on its locked axis after the pointer changes direction', () => { + const interaction = dragReorder(); + const source = { value: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; + const destination = { value: { key: 'B/R2' }, records: [{ column: 'B', row: 'R2' }] }; + const update = interaction.handle!({ + action: 'drag', phase: 'commit', + geometry: { + plot: { + kind: 'drag', start: { x: 0, y: 0 }, current: { x: 10, y: 100 }, + delta: { x: 10, y: 100 }, axis: 'x', + }, + }, + target: { visual: { kind: 'mark', role: 'cell' }, elements: [source] }, + dropTarget: { visual: { kind: 'mark', role: 'cell' }, elements: [destination] }, + }, { + chartType: 'Heatmap', selected: [], + reorderAxes: [ + { axis: 'x', field: 'column', order: ['A', 'B'] }, + { axis: 'y', field: 'row', order: ['R1', 'R2'] }, + ], + }); + + expect(update?.ops[0]).toEqual({ + op: 'set-order', scope: 'category', field: 'column', values: ['B', 'A'], + }); + }); + + it('keeps a locked Heatmap drag active but commits no reorder over its source slot', () => { + const interaction = dragReorder(); + const source = { value: { key: 'A/R1' }, records: [{ column: 'A', row: 'R1' }] }; + const destination = { value: { key: 'A/R2' }, records: [{ column: 'A', row: 'R2' }] }; + const update = interaction.handle!({ + action: 'drag', phase: 'commit', + geometry: { + plot: { + kind: 'drag', start: { x: 0, y: 0 }, current: { x: 0, y: 100 }, + delta: { x: 0, y: 100 }, axis: 'x', + }, + }, + target: { visual: { kind: 'mark', role: 'cell' }, elements: [source] }, + dropTarget: { visual: { kind: 'mark', role: 'cell' }, elements: [destination] }, + }, { + chartType: 'Heatmap', selected: [], + reorderAxes: [ + { axis: 'x', field: 'column', order: ['A', 'B'] }, + { axis: 'y', field: 'row', order: ['R1', 'R2'] }, + ], + }); + + expect(update).toEqual({ id: 'drag-reorder', ops: [] }); + }); + it('declares normalized event sources for built-in presets', () => { + expect(clickMark().eventSource).toEqual({ ...clickTrigger, defaultAssistDistance: 8 }); + expect(clickGroupFocus().eventSource).toEqual({ ...clickTrigger, defaultAssistDistance: 8 }); + expect(clickAnnotate().eventSource).toEqual({ ...clickTrigger, defaultAssistDistance: 8 }); + expect(select().eventSource).toEqual(rectangleTrigger('intersect')); + expect(brushX().eventSource).toEqual(xBrushTrigger('intersect', 'ephemeral')); + expect(brushY().eventSource).toEqual(yBrushTrigger('intersect', 'ephemeral')); + expect(brushAngle().eventSource).toEqual(angularBrushTrigger('intersect')); + expect(navigate().eventSource).toEqual(navigationTrigger()); + }); + + it('resolves composed affordances by target and interaction priority', () => { + expect(affordanceCursor(resolveInteractionAffordance([clickMark()], 'mark'))).toBe('pointer'); + expect(affordanceCursor(resolveInteractionAffordance( + [clickMark(), select()], 'mark', + ))).toBe('pointer'); + expect(affordanceCursor(resolveInteractionAffordance( + [clickMark(), select()], 'plot', + ))).toBe('crosshair'); + expect(resolveInteractionAffordance([select()], 'mark')).toMatchObject({ + target: 'mark', cursor: 'region', + }); + expect(affordanceCursor(resolveInteractionAffordance( + [clickMark(), dragReorder()], 'mark', new Set(['click-mark']), + ))).toBe('pointer'); + expect(resolveInteractionAffordance([hoverGroupFocus({ groupBy: 'Series' })], 'mark')) + .toMatchObject({ hover: 'cohort' }); + }); + + it('declares affordances only for configured click highlight targets', () => { + expect(resolveInteractionAffordance([axisHighlight()], 'axis-label')) + .toMatchObject({ cursor: 'activate', hover: 'cohort' }); + expect(resolveInteractionAffordance([clickHighlight({ targets: ['discreteAxis'] })], 'axis-label')) + .toMatchObject({ cursor: 'activate', hover: 'cohort' }); + expect(resolveInteractionAffordance([clickHighlight({ targets: ['legend'] })], 'legend-item')) + .toMatchObject({ cursor: 'activate', hover: 'cohort' }); + expect(resolveInteractionAffordance([clickHighlight({ targets: ['legend'] })], 'axis-label')) + .toBeUndefined(); + expect(resolveInteractionAffordance([clickMark()], 'legend-item')) + .toBeUndefined(); + expect(resolveInteractionAffordance([navigate({ pan: false })], 'plot')) + .toBeUndefined(); + }); + + it('owns all configured focus targets in one click highlight preset', () => { + const interaction = clickHighlight({ targets: ['mark', 'legend', 'discreteAxis'] }); + const markAndAxis = clickHighlight({ targets: ['mark', 'discreteAxis'] }); + + expect(normalizeInteractions([interaction]).map((candidate) => candidate.id)) + .toEqual(['click-highlight']); + expect(resolveInteractionAffordance([interaction], 'mark')).toBeDefined(); + expect(resolveInteractionAffordance([interaction], 'legend-item')).toBeDefined(); + expect(resolveInteractionAffordance([interaction], 'axis-label')).toBeDefined(); + expect(interaction).toMatchObject({ + retainedStateGroup: 'focus', + claimsLegendActivation: true, + claimsAxisActivation: true, + }); + expect(resolveInteractionAffordance([markAndAxis], 'mark')).toBeDefined(); + expect(resolveInteractionAffordance([markAndAxis], 'axis-label')).toBeDefined(); + expect(resolveInteractionAffordance([markAndAxis], 'legend-item')).toBeUndefined(); + }); + + it('provides reusable trigger descriptors', () => { + expect(clickTrigger).toEqual({ type: 'element', gesture: 'click' }); + expect(hoverTrigger).toEqual({ type: 'element', gesture: 'hover' }); + expect(rectangleTrigger('contain')).toEqual({ + type: 'region', + gesture: 'drag', + match: 'contain', + regionGuide: normalizeRegionGuideOptions(undefined), + }); + expect(axisBrushTrigger('x', 'contain')).toEqual({ + type: 'region', gesture: 'drag', axis: 'x', match: 'contain', mode: 'ephemeral', + regionGuide: normalizeRegionGuideOptions(undefined), + }); + expect(xBrushTrigger()).toEqual({ + type: 'region', gesture: 'drag', axis: 'x', match: 'intersect', mode: 'ephemeral', + regionGuide: normalizeRegionGuideOptions(undefined), + }); + expect(yBrushTrigger('intersect', 'stateful')).toEqual({ + type: 'region', gesture: 'drag', axis: 'y', match: 'intersect', mode: 'stateful', + regionGuide: normalizeRegionGuideOptions(undefined), + }); + expect(angularBrushTrigger('contain')).toEqual({ + type: 'region', gesture: 'drag', regionGeometry: 'angular', match: 'contain', mode: 'ephemeral', + regionGuide: normalizeRegionGuideOptions(undefined), + }); + const external = externalInteraction<{ selected: boolean }>({ + id: 'story-scroll', + handle: (payload) => payload.selected ? { id: 'story-scroll', ops: [] } : null, + }); + expect(external.external).toBe(true); + expect(external.handle({ selected: true }, { chartType: 'Bar Chart', selected: [] })) + .toEqual({ id: 'story-scroll', ops: [] }); + }); + + it('processes resolved semantic events through normalized update policies', () => { + const context = { chartType: 'Bar Chart', selected: [] }; + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { category: 'A' }, records: [{ category: 'A', value: 4 }] }], + }; + + expect(handleSemanticEvent(clickMark(), { + type: 'semantic', source: 'element', phase: 'commit', target, + }, context)).toEqual({ + id: 'click-mark', + ops: [{ + op: 'set-style', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], + }); + expect(handleSemanticEvent(select(), { + type: 'semantic', source: 'region', phase: 'preview', target, + }, context)).toEqual({ + id: 'select', + ops: [{ + op: 'set-style', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], + }); + }); + + it('creates preset definitions with stable defaults', () => { + expect(clickHighlight()).toMatchObject({ + id: 'click-highlight', + eventSource: { ...clickTrigger, defaultAssistDistance: 8 }, + claimsLegendActivation: true, + claimsAxisActivation: true, + }); + expect(clickMark()).toMatchObject({ + id: 'click-mark', eventSource: { ...clickTrigger, defaultAssistDistance: 8 }, + }); + expect(clickGroupFocus()).toMatchObject({ + id: 'click-group-focus', eventSource: { ...clickTrigger, defaultAssistDistance: 8 }, + }); + expect(hoverGroupFocus({ groupBy: 'Series' })).toMatchObject({ + id: 'hover-group-focus', eventSource: { ...hoverTrigger, defaultAssistDistance: 6 }, + }); + expect(axisHighlight()).toMatchObject({ + id: 'axis-highlight', eventSource: clickTrigger, claimsAxisActivation: true, + }); + expect(axisHighlight({ event: 'hover' }).eventSource).toBe(hoverTrigger); + expect(clickAnnotate()).toMatchObject({ + id: 'click-annotate', eventSource: { ...clickTrigger, defaultAssistDistance: 8 }, + }); + expect(select()).toMatchObject({ + id: 'select', + eventSource: rectangleTrigger('intersect'), + }); + expect(brushX()).toMatchObject({ id: 'brush-x', axis: 'x', eventSource: xBrushTrigger() }); + expect(brushY()).toMatchObject({ id: 'brush-y', axis: 'y', eventSource: yBrushTrigger() }); + expect(brushX({ mode: 'stateful' }).eventSource).toEqual(xBrushTrigger('intersect', 'stateful')); + expect(brushAngle()).toMatchObject({ id: 'brush-angle', eventSource: angularBrushTrigger() }); + expect(lassoSelect().eventSource.defaultAssistDistance).toBeUndefined(); + expect(dragReorder().eventSource.defaultAssistDistance).toBeUndefined(); + expect(longPress().eventSource.defaultAssistDistance).toBe(12); + expect(doubleActivate().eventSource.defaultAssistDistance).toBe(8); + expect(resolveAssistDistance([clickMark()])).toBe(8); + expect(resolveAssistDistance([clickMark()], 0)).toBe(0); + expect(resolveAssistDistance([clickMark()], 20)).toBe(20); + expect(resolveAssistDistance([select()], 20)).toBe(0); + expect(resolveAssistDistance([dragReorder()], 20)).toBe(0); + expect(resolveAssistDistance( + interactionsForHoverPresentation([clickMark()], [], []), + )).toBe(8); + }); + + it('applies brush updates only for its configured axis', () => { + const target = { + visual: { kind: 'region' as const, role: 'region' }, + elements: [{ value: { category: 'A' } }], + }; + const context = { chartType: 'Scatter Plot', selected: [] }; + const event = { + type: 'semantic' as const, + source: 'region' as const, + phase: 'preview' as const, + target, + }; + expect(handleSemanticEvent(brushX(), { ...event, axis: 'x' }, context)).toEqual({ + id: 'brush-x', + ops: [{ + op: 'set-style', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], + }); + expect(handleSemanticEvent(brushX(), { ...event, axis: 'y' }, context)).toBeNull(); + expect(handleSemanticEvent(brushX(), { ...event, axis: 'angle' }, context)).toEqual({ + id: 'brush-x', + ops: [{ + op: 'set-style', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], + }); + expect(handleSemanticEvent(brushX({ mode: 'stateful' }), { + ...event, axis: 'angle', phase: 'commit', operation: 'clear', target: null, + }, context)).toEqual({ + id: 'brush-x', + ops: [{ op: 'set-style', targets: [], value: { state: 'normal' } }], + }); + expect(handleSemanticEvent(brushAngle(), { ...event, axis: 'angle' }, context)).toEqual({ + id: 'brush-angle', + ops: [{ + op: 'set-style', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], + }); + expect(handleSemanticEvent(brushAngle(), { ...event, axis: 'x' }, context)).toBeNull(); + }); + + it('normalizes axis brushes across the orthogonal plot extent', () => { + const mark = { marktype: 'symbol', name: 'points' }; + const item = (key: string, x: number, y: number) => ({ + mark, + datum: { [INTERACTION_KEY]: key }, + bounds: { x1: x - 5, x2: x + 5, y1: y - 5, y2: y + 5 }, + }); + const view = { + width: () => 300, + height: () => 180, + scenegraph: () => ({ root: { items: [ + item('same-x-top', 60, 20), + item('same-x-bottom', 60, 150), + item('same-y-right', 220, 60), + ] } }), + }; + const modifiers = { shift: false, ctrl: false, meta: false }; + const x = normalizeVegaRegionEvent( + view, { x: 40, y: 80 }, { x: 120, y: 90 }, 'commit', 'intersect', modifiers, 'x', + ); + const y = normalizeVegaRegionEvent( + view, { x: 40, y: 30 }, { x: 50, y: 100 }, 'commit', 'intersect', modifiers, 'y', + ); + expect(x).toMatchObject({ axis: 'x', operation: 'create', region: { x: 40, y: 0, width: 80, height: 180 } }); + expect(y).toMatchObject({ axis: 'y', operation: 'create', region: { x: 0, y: 30, width: 300, height: 70 } }); + expect(x.hits.map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['same-x-top', 'same-x-bottom']); + expect(y.hits.map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['same-y-right']); + }); + + it('does not intersect disjoint collinear area and brush edges', () => { + const slice = { + kind: 'slice' as const, + points: [ + { x: 0, y: 240 }, { x: 30, y: 225 }, + { x: 30, y: 260 }, { x: 0, y: 260 }, + ], + offset: { x: 0, y: 0 }, + }; + expect(geometryIntersectsRect(slice, { x1: 0, x2: 300, y1: 80, y2: 160 }, false)).toBe(false); + expect(geometryIntersectsRect(slice, { x1: 0, x2: 300, y1: 220, y2: 250 }, false)).toBe(true); + }); + + it('clears selection for an empty rectangle commit', () => { + const interaction = select(); + const context = { chartType: 'Waterfall Chart', selected: [{ value: { Step: 'Revenue' } }] }; + expect(semanticUpdate(interaction, null, context, { source: 'region' })) + .toEqual({ + id: 'select', + ops: [{ op: 'set-style', targets: [], value: { state: 'normal' } }], + }); + }); + + it('normalizes omitted interactions to an empty collection', () => { + expect(normalizeInteractions(undefined)).toEqual([]); + }); + + it('rejects duplicate interaction ids', () => { + expect(() => normalizeInteractions([ + clickMark({ id: 'selection' }), + select({ id: 'selection' }), + ])).toThrow('Duplicate interaction id: "selection".'); + }); + + it('produces replace and toggle emphasis updates', () => { + const interaction = clickMark({ dimOpacity: 0.2 }); + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { Region: 'West' } }], + }; + const context = { chartType: 'Bar Chart', selected: [] }; + const replace = semanticUpdate(interaction, target, context, { + modifiers: { shift: false, ctrl: false, meta: false }, + }); + const toggle = semanticUpdate(interaction, target, context, { + modifiers: { shift: true, ctrl: false, meta: false }, + }); + + expect(replace?.ops[0]).toMatchObject({ + op: 'set-style', value: { state: 'emphasized', mutedOpacity: 0.2 }, + }); + expect(toggle?.ops[0]).toMatchObject({ + op: 'set-style', value: { state: 'emphasized' }, + }); + }); + + it('keeps mark highlight local while group focus supports explicit and automatic partitions', () => { + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { key: 'west-consumer' }, records: [{ auto: 'West', Region: 'West', Segment: 'Consumer' }] }], + }; + const context = { + chartType: 'Grouped Bar Chart', + selected: [], + seriesField: 'Segment', + available: [ + { value: { key: 'west-consumer' }, records: [{ auto: 'West', Region: 'West', Segment: 'Consumer' }] }, + { value: { key: 'east-consumer' }, records: [{ auto: 'East', Region: 'East', Segment: 'Consumer' }] }, + { value: { key: 'west-corporate' }, records: [{ auto: 'West', Region: 'West', Segment: 'Corporate' }] }, + ], + }; + + expect(semanticUpdate(clickMark(), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [{ value: { key: 'west-consumer' } }] }], + }); + expect(semanticUpdate(clickMark(), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [{ value: { key: 'west-consumer' } }] }], + }); + expect(semanticUpdate(clickGroupFocus({ groupBy: ['Segment'] }), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'west-consumer' } }, + { value: { key: 'east-consumer' } }, + ] }], + }); + expect(semanticUpdate(clickGroupFocus({ groupBy: ['Region', 'Segment'] }), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [{ value: { key: 'west-consumer' } }] }], + }); + expect(semanticUpdate(clickGroupFocus(), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'west-consumer' } }, + { value: { key: 'east-consumer' } }, + ] }], + }); + expect(semanticUpdate(clickGroupFocus({ groupBy: 'auto' }), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'west-consumer' } }, + { value: { key: 'west-corporate' } }, + ] }], + }); + expect(semanticUpdate(clickGroupFocus({ groupBy: 'Segment' }), target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'west-consumer' } }, + { value: { key: 'east-consumer' } }, + ] }], + }); + }); + + it('expands a Ranged Dot Plot unit to its complete interval in element mode', () => { + const interaction = clickMark(); + const target = { + visual: { kind: 'mark' as const, role: 'mark' }, + elements: [{ value: { key: 'us-male' }, records: [{ Country: 'United States', Sex: 'Male' }] }], + }; + const context = { + chartType: 'Ranged Dot Plot', + selected: [], + categoryField: 'Country', + seriesField: 'Sex', + available: [ + ...target.elements, + { value: { key: 'us-female' }, records: [{ Country: 'United States', Sex: 'Female' }] }, + { value: { key: 'us-connector' }, records: [{ Country: 'United States' }] }, + { value: { key: 'japan-male' }, records: [{ Country: 'Japan', Sex: 'Male' }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: context.available.slice(0, 3) }], + }); + }); + + it('expands every brushed Ranged Dot Plot category to its complete interval', () => { + const target = { + visual: { kind: 'mark' as const, role: 'region' }, + elements: [ + { value: { key: 'us-male' }, records: [{ Country: 'United States', Sex: 'Male' }] }, + { value: { key: 'japan-connector' }, records: [{ Country: 'Japan' }] }, + ], + }; + const selected = [ + target.elements[0], + { value: { key: 'us-female' }, records: [{ Country: 'United States', Sex: 'Female' }] }, + { value: { key: 'us-connector' }, records: [{ Country: 'United States' }] }, + { value: { key: 'japan-male' }, records: [{ Country: 'Japan', Sex: 'Male' }] }, + { value: { key: 'japan-female' }, records: [{ Country: 'Japan', Sex: 'Female' }] }, + target.elements[1], + ]; + const context = { + chartType: 'Ranged Dot Plot', + selected: [], + categoryField: 'Country', + seriesField: 'Sex', + available: [ + ...selected, + { value: { key: 'brazil-male' }, records: [{ Country: 'Brazil', Sex: 'Male' }] }, + ], + }; + + expect(handleSemanticEvent(brushX(), { + type: 'semantic', source: 'region', phase: 'commit', axis: 'x', target, + }, context)?.ops[0]).toMatchObject({ targets: [{ elements: selected }] }); + }); + + it('uses implicit rendered color for Waterfall grouping', () => { + const interaction = clickGroupFocus(); + const semantics = waterfallChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'ordinal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Type', type: 'nominal' }, + }, + }); + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { key: 'asia' }, records: [{ Type: 'delta', __wf_color: 'increase' }] }], + }; + const context = { + chartType: 'Waterfall Chart', + selected: [], + seriesField: 'Type', + resolveGroupValue: semantics.resolveGroupValue, + available: [ + ...target.elements, + { value: { key: 'africa' }, records: [{ Type: 'delta', __wf_color: 'increase' }] }, + { value: { key: 'oceania' }, records: [{ Type: 'delta', __wf_color: 'decrease' }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'asia' } }, + { value: { key: 'africa' } }, + ] }], + }); + }); + + it('does not infer Waterfall grouping from a field name on another chart', () => { + const interaction = clickGroupFocus(); + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ + value: { key: 'west-consumer' }, + records: [{ Segment: 'Consumer', __wf_color: 'increase' }], + }], + }; + const context = { + chartType: 'Grouped Bar Chart', + selected: [], + seriesField: 'Segment', + available: [ + ...target.elements, + { value: { key: 'east-consumer' }, records: [{ Segment: 'Consumer', __wf_color: 'decrease' }] }, + { value: { key: 'west-corporate' }, records: [{ Segment: 'Corporate', __wf_color: 'increase' }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'west-consumer' } }, + { value: { key: 'east-consumer' } }, + ] }], + }); + }); + + it('groups Strip Plot points by their categorical jitter lane', () => { + const interaction = clickGroupFocus(); + const target = { + visual: { kind: 'mark' as const, role: 'circle' }, + elements: [{ value: { key: 'control-4.1' }, records: [{ Group: 'Control', Value: 4.1, Color: 'Low' }] }], + }; + const context = { + chartType: 'Strip Plot', + selected: [], + categoryField: 'Group', + seriesField: 'Color', + available: [ + ...target.elements, + { value: { key: 'control-5.2' }, records: [{ Group: 'Control', Value: 5.2, Color: 'High' }] }, + { value: { key: 'treatment-4.1' }, records: [{ Group: 'Treatment', Value: 4.1, Color: 'Low' }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'control-4.1' } }, + { value: { key: 'control-5.2' } }, + ] }], + }); + }); + + it('groups by a partition derived in the input records', () => { + const interaction = clickGroupFocus({ + groupBy: 'Partition', + }); + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { key: 'west-a' }, records: [{ Partition: 'West', Segment: 'A' }] }], + }; + const context = { + chartType: 'Grouped Bar Chart', + selected: [], + seriesField: 'Segment', + available: [ + ...target.elements, + { value: { key: 'west-b' }, records: [{ Partition: 'West', Segment: 'B' }] }, + { value: { key: 'east-a' }, records: [{ Partition: 'East', Segment: 'A' }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { key: 'west-a' } }, + { value: { key: 'west-b' } }, + ] }], + }); + }); + + it('broadcasts a brushed semantic group across available views', () => { + const interaction = linkedBrush({ groupBy: 'Country' }); + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { Country: 'France', Source: 'Fossil' }, records: [{ Country: 'France', Source: 'Fossil', Share: 8 }] }], + }; + const context = { + chartType: 'Bar Chart', + selected: [], + available: [ + ...target.elements, + { value: { Country: 'France', View: 'detail' }, records: [{ Country: 'France', View: 'detail', Share: 65 }] }, + { value: { Country: 'France', View: 'summary' }, records: [{ Country: 'France', View: 'summary', Share: 27 }] }, + { value: { Country: 'Germany', View: 'detail' }, records: [{ Country: 'Germany', View: 'detail', Share: 45 }] }, + ], + }; + + expect(semanticUpdate(interaction, target, context, { source: 'region' })?.ops[0]).toMatchObject({ + targets: [{ elements: context.available.slice(0, 3) }], + }); + }); + + it('supports compound link keys and lasso acquisition', () => { + const interaction = linkedBrush({ groupBy: ['Country', 'Product'], brush: 'lasso' }); + const target = { + visual: { kind: 'mark' as const, role: 'circle' }, + elements: [{ value: {}, records: [{ Country: 'France', Product: 'A', Year: 2020 }] }], + }; + const linked = { value: {}, records: [{ Country: 'France', Product: 'A', Year: 2024 }] }; + const context = { + chartType: 'Scatter Plot', + selected: [], + available: [ + ...target.elements, + linked, + { value: {}, records: [{ Country: 'France', Product: 'B', Year: 2024 }] }, + ], + }; + + expect(interaction.eventSource.regionGeometry).toBe('lasso'); + expect(handleSemanticEvent(interaction, { + type: 'semantic', + source: 'region', + phase: 'commit', + target, + region: { points: [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }] }, + }, context)?.ops[0]).toMatchObject({ + targets: [{ elements: [target.elements[0], linked] }], + }); + }); + + it('keeps the local brush target when the link key is unavailable', () => { + const interaction = linkedBrush({ groupBy: 'Country' }); + const target = { + visual: { kind: 'mark' as const, role: 'circle' }, + elements: [{ value: { X: 10 }, records: [{ X: 10, Y: 8.04 }] }], + }; + + expect(semanticUpdate(interaction, target, { + chartType: 'Scatter Plot', + selected: [], + available: target.elements, + }, { source: 'region' })?.ops[0]).toMatchObject({ + targets: [{ elements: target.elements }], + }); + }); + + it('previews a semantic cohort on hover', () => { + const interaction = hoverGroupFocus({ groupBy: 'Country' }); + const target = { + visual: { kind: 'mark' as const, role: 'circle' }, + elements: [{ value: {}, records: [{ Country: 'France', Year: 1952 }] }], + }; + const linked = { value: {}, records: [{ Country: 'France', Year: 2007 }] }; + const context = { + chartType: 'Scatter Plot', selected: [], + available: [...target.elements, linked, { value: {}, records: [{ Country: 'Germany', Year: 2007 }] }], + }; + + expect(semanticUpdate(interaction, target, context, { phase: 'preview' })?.ops[0]).toMatchObject({ + targets: [{ elements: [target.elements[0], linked] }], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }); + expect(semanticUpdate(interaction, target, context, { phase: 'commit' })).toBeNull(); + expect(interaction.eventSource.targetTolerance).toBe(8); + expect(hoverGroupFocus({ groupBy: 'Country', tolerance: 14 }).eventSource.targetTolerance).toBe(14); + expect(hoverGroupFocus({ groupBy: 'Country', tolerance: -1 }).eventSource.targetTolerance).toBe(0); + }); + + it('creates element-level annotation intent without selecting the mark', () => { + const interaction = clickAnnotate(); + const target = { + visual: { kind: 'mark' as const, role: 'circle' }, + elements: [{ + value: { key: 'setosa-1.4' }, + records: [{ Species: 'Setosa', Length: 1.4, __jitter: -2.1 }], + }], + }; + const context = { chartType: 'Strip Plot', selected: [] }; + + expect(semanticUpdate(interaction, target, context)).toEqual({ + id: 'click-annotate', + ops: [ + { + op: 'set-annotation', + target: { visual: target.visual, elements: target.elements }, + value: {}, + }, + { + op: 'set-style', targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }, + ], + }); + expect(semanticUpdate(interaction, null, context)).toEqual({ + id: 'click-annotate', + ops: [ + { op: 'set-annotation', target: { select: { key: {} } }, value: null }, + { op: 'set-style', targets: [], value: { state: 'normal' } }, + ], + }); + }); + + it('leaves default annotation formatting to the ChartDef', () => { + const interaction = clickAnnotate(); + const end = Date.UTC(2024, 3, 15); + const target = { + visual: { kind: 'mark' as const, role: 'task' }, + elements: [{ + value: { key: 'launch' }, + records: [{ task: 'Launch', start: Date.UTC(2024, 3, 1), end, phase: 'Release' }], + }], + }; + + expect(semanticUpdate(interaction, target, { + chartType: 'Gantt Chart', + selected: [], + categoryField: 'task', + seriesField: 'phase', + })?.ops[0]).toMatchObject({ + op: 'set-annotation', + }); + expect(semanticUpdate(interaction, target, { + chartType: 'Gantt Chart', selected: [], categoryField: 'task', seriesField: 'phase', + })?.ops[0]).toMatchObject({ value: {} }); + }); + + it('lets the chart turn annotation intent into a render plan', () => { + const element = { + value: { key: 'setosa-1.4' }, + records: [{ Species: 'Setosa', Length: 1.4, __jitter: -2.1 }], + }; + const presentUpdate = presentAnnotationUpdate(() => ({ + connection: 'center', + })); + + expect(presentUpdate( + annotationUpdate(element, undefined, '1.4'), + { chartType: 'Strip Plot', selected: [] }, + )).toEqual({ + id: 'test-annotation', + ops: [{ + op: 'set-annotation', + target: { visual: { kind: 'mark', role: 'test' }, elements: [element] }, + value: { + text: '1.4', + candidates: [{ + connection: 'center', + }], + subject: { kind: 'mark', role: 'test' }, + }, + }], + }); + }); + + it('lets the chart supply default annotation text', () => { + const element = { + value: { key: 'setosa-1.4' }, + records: [{ Species: 'Setosa', Length: 1.4, __jitter: -2.1 }], + }; + const presentUpdate = presentAnnotationUpdate(() => ({ connection: 'center' })); + + expect(presentUpdate( + annotationUpdate(element), + { chartType: 'Strip Plot', selected: [], categoryField: 'Species' }, + ).ops[0]).toMatchObject({ + op: 'set-annotation', + value: { text: '1.4' }, + }); + }); + + it('uses a rendered histogram count instead of an empty raw-field fallback', () => { + const element = { + value: { key: '4|4.5' }, + records: [{ __bin_start: 4, __bin_end: 4.5, __count: 8 }], + }; + const presentUpdate = presentAnnotationUpdate( + () => ({ connection: 'value-end' }), + countAnnotationText, + ); + + expect(presentUpdate( + annotationUpdate(element), + { chartType: 'Histogram', selected: [] }, + ).ops[0]).toMatchObject({ + op: 'set-annotation', + value: { text: '8', candidates: [{ connection: 'value-end' }] }, + }); + }); + + it('gives histogram counts focal side ports when the value end cannot fit', () => { + const semantics = histogramDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'Duration', type: 'quantitative' } }, + } as any); + const element = { + value: { key: '1.5|2' }, + records: [{ __bin_start: 1.5, __bin_end: 2, __count: 9 }], + }; + + expect(semantics.presentUpdate!( + annotationUpdate(element), + { chartType: 'Histogram', selected: [] }, + ).ops[0]).toEqual({ + op: 'set-annotation', + target: { visual: { kind: 'mark', role: 'test' }, elements: [element] }, + value: { + text: '9', + subject: { kind: 'mark', role: 'test' }, + candidates: [ + { connection: 'value-end', valueAxis: 'y', priority: 0 }, + { + connection: 'value-side', + valueAxis: 'y', + crossSide: 'start', + valueInset: 1 / 8, + priority: 1, + }, + { + connection: 'value-side', + valueAxis: 'y', + crossSide: 'end', + valueInset: 1 / 8, + priority: 1, + }, + { connection: 'top', priority: 2 }, + { connection: 'bottom', priority: 2 }, + ], + }, + }); + }); + + it('keeps a short vertical histogram bin anchored at its top value end', () => { + const shortBin = { bounds: { x1: 0, x2: 40, y1: 80, y2: 100 } }; + const tallerBin = { bounds: { x1: 41, x2: 81, y1: 20, y2: 100 } }; + + expect(valueEndConnectionPoint(shortBin, [shortBin, tallerBin], 'y')).toEqual({ + point: { x: 20, y: 80 }, + preferredAngle: Math.PI * 1.5, + }); + }); + + it('places vertical bar side ports one-eighth below the top value end', () => { + const bar = { bounds: { x1: 10, x2: 30, y1: 20, y2: 100 } }; + const peer = { bounds: { x1: 40, x2: 60, y1: 50, y2: 100 } }; + + expect(valueSideConnectionPoint(bar, [bar, peer], 'y', 'end')).toEqual({ + point: { x: 30, y: 30 }, + preferredAngle: 0, + }); + }); + + it('places horizontal bar side ports one-eighth before the right value end', () => { + const bar = { bounds: { x1: 10, x2: 90, y1: 20, y2: 40 } }; + const peer = { bounds: { x1: 10, x2: 60, y1: 50, y2: 70 } }; + + expect(valueSideConnectionPoint(bar, [bar, peer], 'x', 'start')).toEqual({ + point: { x: 80, y: 20 }, + preferredAngle: Math.PI * 1.5, + }); + }); + + it('allows a vertical lollipop annotation to route sideways near the canvas edge', () => { + const semantics = lollipopChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Country', type: 'nominal' }, + y: { field: 'Tonnes', type: 'quantitative' }, + }, + } as any); + const element = { + value: { key: '37' }, + records: [{ Country: 'A', Tonnes: 37 }], + }; + + expect(semantics.presentUpdate!( + annotationUpdate(element), + { chartType: 'Lollipop Chart', selected: [], categoryField: 'Country' }, + ).ops[0]).toMatchObject({ + value: { + text: '37', + candidates: [ + { connection: 'value-end', valueAxis: 'y', anglePreference: 'oblique', priority: 0 }, + { connection: 'right', anglePreference: 'oblique', priority: 1 }, + { connection: 'left', anglePreference: 'oblique', priority: 2 }, + ], + }, + }); + }); + + it('anchors Waterfall annotations around the bar body', () => { + const semantics = waterfallChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Step', type: 'nominal' }, + y: { field: 'Population', type: 'quantitative' }, + }, + } as any); + const element = { + value: { key: 'Asia' }, + records: [{ Step: 'Asia', __wf_prev_sum: 2536, __wf_sum: 5773 }], + }; + + expect(semantics.presentUpdate!( + annotationUpdate(element), + { chartType: 'Waterfall Chart', selected: [] }, + ).ops[0]).toMatchObject({ + value: { + text: '2,536 → 5,773', + candidates: [ + { connection: 'value-end', valueAxis: 'y', priority: 0 }, + { connection: 'value-side', valueAxis: 'y', crossSide: 'start', valueInset: 1 / 2, priority: 1 }, + { connection: 'value-side', valueAxis: 'y', crossSide: 'end', valueInset: 1 / 2, priority: 1 }, + ], + }, + }); + }); + + it('does not count decorative grid lines as annotation obstacles', () => { + expect(isAnnotationObstacle({ mark: { role: 'axis-grid', marktype: 'rule' } })).toBe(false); + expect(isAnnotationObstacle({ bounds: { x1: 0, x2: 100, y1: 0, y2: 100 } })).toBe(false); + expect(isAnnotationObstacle({ mark: { role: 'axis-label', marktype: 'text' } })).toBe(true); + expect(isAnnotationObstacle({ mark: { role: 'mark', marktype: 'rule' } })).toBe(true); + }); + + it('excludes the stem direction from lollipop leader angles', () => { + const stemDirection = Math.PI * 1.5; + const angles = annotationCandidateAngles(stemDirection, 'oblique'); + + expect(angles).toHaveLength(4); + expect(angles.every((angle) => Math.abs(angle - stemDirection) > 0.001)).toBe(true); + }); + + it('searches the full circle for normal edge-target annotations', () => { + const preferred = Math.PI / 3; + const angles = annotationCandidateAngles(preferred); + + expect(angles).toHaveLength(12); + expect(angles.some((angle) => Math.abs(angle - (preferred + Math.PI)) < 1e-10)).toBe(true); + }); + + it('ranks legend, solid, and dimmed annotation obstacles', () => { + expect(annotationObstacleTier({ mark: { role: 'legend-label' }, opacity: 0.25 })).toBe(3); + expect(annotationObstacleTier({ mark: { role: 'axis-label' }, opacity: 1 })).toBe(3); + expect(annotationObstacleTier({ mark: { role: 'mark' }, opacity: 1 })).toBe(2); + expect(annotationObstacleTier({ mark: { role: 'mark' }, opacity: 0.25 })).toBe(1); + expect(annotationObstacleOverlapCost(1, 10)).toBeLessThan(annotationObstacleOverlapCost(2, 10)); + expect(annotationObstacleOverlapCost(2, 10)).toBeLessThan(annotationObstacleOverlapCost(3, 10)); + }); + + describe('annotation leader routing', () => { + const card = { left: 100, top: 100, width: 120, height: 80 }; + + it.each([ + [{ x: 80, y: 140 }, ['left']], + [{ x: 240, y: 140 }, ['right']], + [{ x: 160, y: 80 }, ['top']], + [{ x: 160, y: 200 }, ['bottom']], + [{ x: 80, y: 80 }, ['left', 'top']], + [{ x: 240, y: 80 }, ['right', 'top']], + [{ x: 80, y: 200 }, ['left', 'bottom']], + [{ x: 240, y: 200 }, ['right', 'bottom']], + [{ x: 225, y: 230 }, ['bottom']], + [{ x: 260, y: 185 }, ['right']], + ] as const)('uses only card edges facing source %j', (source, edges) => { + expect(annotationFacingEdges(source, card)).toEqual(edges); + expect(edges).toContain(routeAnnotationLeaders({ card, sources: [source] })[0].port.edge); + }); + + it('avoids top-center and bottom-center ports on the text box', () => { + const ports = annotationLeaderPorts(card); + expect(ports).toHaveLength(10); + expect(ports.filter((port) => port.edge === 'top').map((port) => port.fraction)) + .toEqual([0.25, 0.75]); + expect(ports.filter((port) => port.edge === 'bottom').map((port) => port.fraction)) + .toEqual([0.25, 0.75]); + expect(ports.filter((port) => port.edge === 'left').map((port) => port.fraction)) + .toEqual([0.25, 0.5, 0.75]); + expect(ports.filter((port) => port.edge === 'right').map((port) => port.fraction)) + .toEqual([0.25, 0.5, 0.75]); + }); + + it('preserves source order and assigns distinct ports on a shared edge', () => { + const sources = [{ x: 60, y: 112 }, { x: 55, y: 165 }]; + const routes = routeAnnotationLeaders({ card, sources }); + + expect(routes.map((route) => route.port.edge)).toEqual(['left', 'left']); + expect(routes[0].port.fraction).toBeLessThan(routes[1].port.fraction); + expect(routes[0].port).not.toEqual(routes[1].port); + }); + + it('is stable for a diagonal multi-source assignment', () => { + const sources = [{ x: 70, y: 70 }, { x: 250, y: 72 }, { x: 255, y: 205 }]; + const first = routeAnnotationLeaders({ card, sources }); + const second = routeAnnotationLeaders({ card, sources }); + + expect(first).toEqual(second); + expect(first).toHaveLength(sources.length); + expect(new Set(first.map((route) => `${route.port.x},${route.port.y}`)).size).toBe(sources.length); + }); + + it('avoids the top-middle of a rectangular source mark', () => { + const source = { left: 240, top: 144, width: 32, height: 52 }; + const upperLeftCard = { left: 20, top: 40, width: 270, height: 66 }; + + expect(sourceEdgeAttachment(source, upperLeftCard, 'top', { x: 256, y: 144 })) + .toEqual({ x: 248, y: 144 }); + }); + }); + + it('routes an area segment annotation normal to and away from the fill', () => { + const item = { + interactionGeometry: { + kind: 'slice', + annotationPoints: [{ x: 0, y: 60 }, { x: 40, y: 20 }], + points: [ + { x: 0, y: 60 }, + { x: 40, y: 20 }, + { x: 40, y: 100 }, + { x: 0, y: 100 }, + ], + }, + }; + + const connection = segmentMidpointConnectionPoint(item, { x: 20, y: 50 }); + expect(connection.point).toEqual({ x: 20, y: 40 }); + expect(connection.preferredAngle).toBeCloseTo(Math.PI * 1.25); + }); + + it.each([ + ['Line Chart', lineChartDef, { x: { field: 'Month', type: 'nominal' }, y: { field: 'Sales', type: 'quantitative' } }], + ['Area Chart', areaChartDef, { x: { field: 'Month', type: 'nominal' }, y: { field: 'Sales', type: 'quantitative' } }], + ] as const)('formats a clicked %s segment as an endpoint transition', (chartType, chartDef, resolvedEncodings) => { + const semantics = chartDef.semanticInteractions!({ resolvedEncodings } as any); + const element = { + value: { key: 'Jan' }, + records: [{ Month: 'Jan', Sales: 10 }, { Month: 'Feb', Sales: 14 }], + }; + + expect(semantics.presentUpdate!( + annotationUpdate(element), + { chartType, selected: [], categoryField: 'Month' }, + ).ops[0]).toMatchObject({ value: { text: '10 → 14' } }); + }); + + it('gives line paths segment presentation and line points glyph presentation', () => { + const semantics = lineChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Miles', type: 'quantitative' }, + y: { field: 'Price', type: 'quantitative' }, + }, + } as any); + const pathElement = { + value: { [INTERACTION_KEY]: `A${PATH_KEY_SUFFIX}` }, + records: [{ Miles: 7200, Price: 2.36 }, { Miles: 7600, Price: 1.78 }], + }; + const pointElement = { + value: { [INTERACTION_KEY]: 'A' }, + records: [{ Miles: 7200, Price: 2.36 }], + }; + + expect(semantics.presentUpdate!( + annotationUpdate(pathElement, { kind: 'path', role: 'line' }), + { chartType: 'Line Chart', selected: [] }, + ).ops[0]).toMatchObject({ value: { text: '2.36 → 1.78' } }); + expect((semantics.presentUpdate!( + annotationUpdate(pathElement, { kind: 'path', role: 'line' }), + { chartType: 'Line Chart', selected: [] }, + ).ops[0] as any).value.candidates[0]).toEqual({ connection: 'segment-midpoint', priority: 0 }); + expect(semantics.presentUpdate!( + annotationUpdate(pointElement, { kind: 'mark', role: 'symbol' }), + { chartType: 'Line Chart', selected: [] }, + ).ops[0]).toMatchObject({ value: { text: '2.36' } }); + expect((semantics.presentUpdate!( + annotationUpdate(pointElement, { kind: 'mark', role: 'symbol' }), + { chartType: 'Line Chart', selected: [] }, + ).ops[0] as any).value.candidates[0]).toEqual({ connection: 'center', priority: 0 }); + }); + + it('gives connected-scatter paths transitions and vertices single-value glyph presentation', () => { + const semantics = connectedScatterDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Miles', type: 'quantitative' }, + y: { field: 'Price', type: 'quantitative' }, + order: { field: 'Year', type: 'temporal' }, + }, + } as any); + const segment = { + value: { [INTERACTION_KEY]: `A${PATH_KEY_SUFFIX}` }, + records: [{ Miles: 9800, Price: 2.14 }, { Miles: 10000, Price: 2.53 }], + }; + const vertex = { + value: { [INTERACTION_KEY]: 'A' }, + records: [{ Miles: 9800, Price: 2.14 }], + }; + const pathUpdate = semantics.presentUpdate!( + annotationUpdate(segment, { kind: 'path', role: 'line' }), + { chartType: 'Connected Scatter Plot', selected: [] }, + ); + const pointUpdate = semantics.presentUpdate!( + annotationUpdate(vertex, { kind: 'mark', role: 'symbol' }), + { chartType: 'Connected Scatter Plot', selected: [] }, + ); + + expect(pathUpdate.ops[0]).toMatchObject({ value: { text: '2.14 → 2.53' } }); + expect((pathUpdate.ops[0] as any).value.candidates[0]).toEqual({ connection: 'segment-midpoint', priority: 0 }); + expect(pointUpdate.ops[0]).toMatchObject({ value: { text: '2.14' } }); + expect((pointUpdate.ops[0] as any).value.candidates[0]).toEqual({ connection: 'center', priority: 0 }); + }); + + it('resolves a path-suffixed annotation key to segment geometry instead of its point glyph', () => { + const point = { datum: { [INTERACTION_KEY]: 'A' }, bounds: { x1: 9, x2: 11, y1: 9, y2: 11 } }; + const segment = { + datum: { [INTERACTION_KEY]: 'A' }, + bounds: { x1: 10, x2: 40, y1: 10, y2: 30 }, + interactionGeometry: { kind: 'segment', points: [{ x: 10, y: 10 }, { x: 40, y: 30 }] }, + }; + + expect(annotationItem([point, segment], `A${PATH_KEY_SUFFIX}`)).toBe(segment); + expect(annotationItem([point, segment], 'A')).toBe(point); + }); + + it('resolves the clicked generated segment when path points share a series key', () => { + const first = { + datum: { [INTERACTION_KEY]: 'Setosa', Species: 'Setosa', value: 4.8, density: 0.3 }, + bounds: { x1: 10, x2: 20, y1: 30, y2: 50 }, + interactionGeometry: { kind: 'segment', points: [{ x: 10, y: 50 }, { x: 20, y: 30 }] }, + }; + const selected = { + datum: { [INTERACTION_KEY]: 'Setosa', Species: 'Setosa', value: 5.1, density: 0.4 }, + bounds: { x1: 20, x2: 30, y1: 20, y2: 30 }, + interactionGeometry: { kind: 'segment', points: [{ x: 20, y: 30 }, { x: 30, y: 20 }] }, + }; + + expect(annotationItem( + [first, selected], + `Setosa${PATH_KEY_SUFFIX}`, + { kind: 'path' }, + undefined, + undefined, + { Species: 'Setosa', value: 5.1, density: 0.4 }, + )).toBe(selected); + }); + + it('uses the widest generated slice for a record-free series annotation', () => { + const tail = { + datum: { [INTERACTION_KEY]: 'Class A' }, + bounds: { x1: 19, x2: 21, y1: 20, y2: 30 }, + interactionGeometry: { kind: 'slice', points: [] }, + }; + const mode = { + datum: { [INTERACTION_KEY]: 'Class A' }, + bounds: { x1: 8, x2: 32, y1: 30, y2: 40 }, + interactionGeometry: { kind: 'slice', points: [] }, + }; + + expect(annotationItem([tail, mode], `Class A${PATH_KEY_SUFFIX}`, { kind: 'path' })) + .toBe(mode); + }); + + it('treats sibling area slices as one annotation source shape', () => { + const mark = { marktype: 'area' }; + const sourceDatum = { [INTERACTION_KEY]: 'Class A', value: 72 }; + const source = { + mark, orient: 'horizontal', datum: sourceDatum, bounds: { x1: 20, x2: 30, y1: 30, y2: 40 }, + interactionGeometry: { annotationPoints: [{ x: 25, y: 30 }, { x: 26, y: 40 }] }, + }; + const sibling = { + mark, orient: 'horizontal', datum: { [INTERACTION_KEY]: 'Class A', value: 73 }, + bounds: { x1: 8, x2: 42, y1: 40, y2: 50 }, + }; + + expect(isAnnotationSourceItem({ mark, datum: sourceDatum }, source)).toBe(true); + expect(isAnnotationSourceItem(sibling, source)).toBe(true); + expect(annotationSourceBounds([source, sibling], source)).toEqual({ + x1: 8, x2: 42, y1: 30, y2: 50, + }); + }); + + it('indexes horizontal area segments used by Violin plots', () => { + const mark: any = { marktype: 'area', items: [] }; + const first = { + mark, datum: { [INTERACTION_KEY]: 'Class A', value: 4.8, density: 0.3 }, + x: 20, x2: 10, y: 50, bounds: { x1: 10, x2: 20, y1: 50, y2: 50 }, + }; + const second = { + mark, datum: { [INTERACTION_KEY]: 'Class A', value: 5.1, density: 0.4 }, + x: 25, x2: 5, y: 30, bounds: { x1: 5, x2: 25, y1: 30, y2: 30 }, + }; + mark.items = [first, second]; + const view = { scenegraph: () => ({ root: { items: [first, second] } }) }; + + const segments = sceneItems(view); + + expect(segments).toHaveLength(1); + expect(segments[0].interactionGeometry).toMatchObject({ + kind: 'slice', + points: [ + { x: 20, y: 50 }, { x: 25, y: 30 }, + { x: 5, y: 30 }, { x: 10, y: 50 }, + ], + }); + expect(renderHit(segments[0])?.datum[INTERACTION_KEY]) + .toBe(`Class A${PATH_KEY_SUFFIX}`); + }); + + it('excludes connective rules from reorder-owned destination geometry', () => { + const items = [ + { mark: { marktype: 'rect' }, datum: { [INTERACTION_KEY]: 'B', step: 'B' } }, + { mark: { marktype: 'rule' }, datum: { [INTERACTION_KEY]: 'B', step: 'B' } }, + ]; + + expect(reorderOwnedItems(items, { field: 'step', markTypes: ['rect'] }, 'B')) + .toEqual([items[0]]); + }); + + it('resolves radial annotations to the slice instead of a same-key text label', () => { + const arc = { + mark: { marktype: 'arc' }, + datum: { [INTERACTION_KEY]: 'Jan' }, + bounds: { x1: 20, x2: 80, y1: 20, y2: 80 }, + }; + const label = { + mark: { marktype: 'text' }, + datum: { [INTERACTION_KEY]: 'Jan' }, + bounds: { x1: 45, x2: 55, y1: 10, y2: 20 }, + }; + + expect(annotationItem([arc, label], 'Jan', undefined, 'arc')).toBe(arc); + }); + + it('resolves bar annotations to the rect instead of a same-key connector rule', () => { + const bar = { + mark: { marktype: 'rect' }, + datum: { [INTERACTION_KEY]: 'Asia' }, + bounds: { x1: 20, x2: 60, y1: 80, y2: 220 }, + }; + const connector = { + mark: { marktype: 'rule' }, + datum: { [INTERACTION_KEY]: 'Asia' }, + bounds: { x1: 20, x2: 100, y1: 80, y2: 80 }, + }; + + expect(annotationItem([bar, connector], 'Asia', undefined, 'rect')).toBe(bar); + }); + + it.each([ + [ + 'Range Area Chart', + rangeAreaChartDef, + { x: { field: 'Month', type: 'nominal' }, y: { field: 'Low', type: 'quantitative' }, y2: { field: 'High', type: 'quantitative' } }, + { Month: 'Jan', Low: 8, High: 13 }, + '8 → 13', + ], + [ + 'Candlestick Chart', + candlestickChartDef, + { x: { field: 'Day', type: 'temporal' }, open: { field: 'Open', type: 'quantitative' }, close: { field: 'Close', type: 'quantitative' } }, + { Day: '2026-08-25', Open: 101, Close: 106 }, + '101 → 106', + ], + [ + 'Gantt Chart', + ganttChartDef, + { y: { field: 'Task', type: 'nominal' }, x: { field: 'Start', type: 'temporal' }, x2: { field: 'End', type: 'temporal' } }, + { Task: 'Build', Start: '2026-08-25', End: '2026-08-27' }, + '2026-08-25 → 2026-08-27', + ], + [ + 'Waterfall Chart', + waterfallChartDef, + { x: { field: 'Step', type: 'nominal' }, y: { field: 'Delta', type: 'quantitative' } }, + { Step: 'Revenue', Delta: 15, __wf_prev_sum: 100, __wf_sum: 115, __wf_color: 'increase' }, + '100 → 115', + ], + ] as const)('formats a clicked %s interval from its semantic endpoints', (chartType, chartDef, resolvedEncodings, record, text) => { + const semantics = chartDef.semanticInteractions!({ resolvedEncodings } as any); + const element = { value: { key: chartType }, records: [record] }; + + expect(semantics.presentUpdate!( + annotationUpdate(element), + { chartType, selected: [] }, + ).ops[0]).toMatchObject({ value: { text } }); + }); + + it('suppresses boxplot annotation until composite roles and statistics are semantic', () => { + const semantics = boxplotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Species', type: 'nominal' }, + y: { field: 'Body mass (g)', type: 'quantitative' }, + }, + } as any); + const element = { + value: { key: 'Gentoo' }, + records: [{ Species: 'Gentoo', 'Body mass (g)': 5950 }], + }; + + expect(semantics.presentUpdate!( + annotationUpdate(element), + { chartType: 'Boxplot', selected: [], categoryField: 'Species' }, + ).ops).toEqual([]); + }); + + it.each([ + 'value-end', + 'outer-radial', + 'center', + ] as const)('preserves the ChartDef %s annotation candidate', (connection) => { + const element = { value: { key: 'datum' } }; + const presentUpdate = presentAnnotationUpdate(() => ({ connection })); + const update = presentUpdate( + annotationUpdate(element, undefined, 'Value'), + { chartType: 'Test', selected: [] }, + ); + + expect(update.ops[0]).toMatchObject({ + op: 'set-annotation', + value: { candidates: [{ connection }] }, + }); + }); + + it('lets a ChartDef offer an ordered space of annotation connections', () => { + expect(annotationCandidates('value-end', 'center', 'top')).toEqual([ + { connection: 'value-end', priority: 0 }, + { connection: 'center', priority: 1 }, + { connection: 'top', priority: 2 }, + ]); + }); +}); +describe('assisted, keyboard, and lasso acquisition', () => { + const boundedItem = (key: string, x1: number, y1: number, x2: number, y2: number) => ({ + mark: { marktype: 'rect' }, + datum: { [INTERACTION_KEY]: key }, + bounds: { x1, y1, x2, y2 }, + }); + const fakeView = (items: readonly any[]) => ({ + scenegraph: () => ({ root: { mark: { marktype: 'group' }, items } }), + }); + + it('acquires the nearest mark within the assist radius', () => { + const near = boundedItem('near', 100, 100, 104, 104); + const far = boundedItem('far', 200, 200, 204, 204); + + expect(nearestItemByBounds([near, far], { x: 110, y: 102 }, 12)).toBe(near); + expect(nearestItemByBounds([near, far], { x: 150, y: 150 }, 12)).toBeUndefined(); + }); + + it('prefers a mark the pointer is already inside over a nearer edge', () => { + const inside = boundedItem('inside', 0, 0, 50, 50); + const edge = boundedItem('edge', 52, 20, 56, 24); + + expect(nearestItemByBounds([inside, edge], { x: 49, y: 22 }, 12)).toBe(inside); + }); + + it('keeps axis inspection on the nearest axis coordinate', () => { + const sameX = boundedItem('same-x', 18, 0, 22, 4); + const nearbyIn2d = boundedItem('nearby-2d', 38, 88, 42, 92); + + expect(nearestItemOnInspectAxis( + [sameX, nearbyIn2d], + { x: 21, y: 90 }, + 'x', + )).toBe(sameX); + }); + + it('inspects every bar crossed by the axis guide', () => { + const short = boundedItem('short', 0, 0, 20, 10); + const long = boundedItem('long', 0, 20, 80, 30); + const later = boundedItem('later', 60, 40, 100, 50); + + expect(axisIntersectingHits([short, long, later], 40, 'x') + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['long']); + expect(axisIntersectingHits([short, long, later], 70, 'x') + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['long', 'later']); + }); + + it('uses tolerance to choose one nearest axis value when exact acquisition is empty', () => { + const left = boundedItem('left', 0, 0, 20, 20); + const right = boundedItem('right', 22, 0, 42, 20); + + expect(tolerantInspectHits([left, right], { x: 21, y: 10 }, 'x', { x: '=' }, { x: 3, y: 0 }) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right']); + expect(tolerantInspectHits([left, right], { x: 21, y: 10 }, 'x', { x: '=' }, { x: 0, y: 0 })) + .toEqual([]); + expect(tolerantInspectHits([left, right], { x: 50, y: 10 }, 'x', { x: '=' }, { x: 3, y: 0 })) + .toEqual([]); + }); + + it('returns every series mark sharing the chosen axis value', () => { + const left = boundedItem('left', 0, 0, 20, 10); + const rightA = boundedItem('right-a', 22, 0, 42, 10); + const rightB = boundedItem('right-b', 22, 20, 42, 30); + + expect(tolerantInspectHits( + [left, rightA, rightB], { x: 21, y: 5 }, 'x', { x: '=' }, { x: 3, y: 0 }, + ).map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right-a', 'right-b']); + }); + + it('returns every mark intersected by the chosen axis slice', () => { + const long = boundedItem('long', 0, 0, 80, 10); + const short = boundedItem('short', 0, 20, 60, 30); + const missed = boundedItem('missed', 0, 40, 40, 50); + + expect(tolerantInspectHits( + [long, short, missed], { x: 50, y: 45 }, 'x', { x: '=' }, { x: 3, y: 0 }, + ).map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['long', 'short']); + }); + + it('intersects every continuous path at the selected axis slice', () => { + const segment = (key: string, y: number) => ({ + bounds: { x1: 20, y1: y, x2: 40, y2: y + 10 }, + interactionGeometry: { + kind: 'segment', + points: [{ x: 20, y }, { x: 40, y: y + 10 }], + }, + datum: { [INTERACTION_KEY]: key }, + mark: { marktype: 'line', name: key }, + }); + + expect(tolerantInspectHits( + [segment('a', 10), segment('b', 30)], { x: 30, y: 0 }, 'x', { x: '=' }, { x: 3, y: 0 }, + ).map((hit) => hit.datum[INTERACTION_KEY])).toEqual([ + `a${PATH_KEY_SUFFIX}`, + `b${PATH_KEY_SUFFIX}`, + ]); + }); + + it('chooses one axis value when adjacent bins share an exact boundary', () => { + const left = boundedItem('left', 0, 0, 20, 20); + const right = boundedItem('right', 20, 0, 40, 20); + + expect(tolerantInspectHits([left, right], { x: 20, y: 10 }, 'x', { x: '=' }, { x: 3, y: 0 }) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right']); + }); + + it('chooses one nearest xy mark but preserves exact overlaps', () => { + const left = boundedItem('left', 0, 0, 20, 20); + const right = boundedItem('right', 22, 0, 42, 20); + const overlap = boundedItem('overlap', 22, 0, 42, 20); + + expect(tolerantInspectHits([left, right], { x: 21, y: 10 }, 'xy', { x: '=', y: '=' }, { x: 3, y: 3 }) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right']); + expect(tolerantInspectHits([right, overlap], { x: 30, y: 10 }, 'xy', { x: '=', y: '=' }, { x: 3, y: 3 }) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right', 'overlap']); + }); + + it('acquires one quarter of the plot with mixed xy inspect predicates', () => { + const items = [ + boundedItem('upper-left', 10, 10, 20, 20), + boundedItem('upper-right', 70, 10, 80, 20), + boundedItem('crosses-right-edge', 45, 10, 55, 20), + boundedItem('lower-left', 10, 70, 20, 80), + boundedItem('lower-right', 70, 70, 80, 80), + boundedItem('nearest-outside', 48, 48, 49, 49), + ]; + + expect(tolerantInspectHits( + items, { x: 50, y: 50 }, 'xy', { x: '>=', y: '<=' }, { x: 0, y: 0 }, + ).map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['upper-right', 'crosses-right-edge']); + + expect(tolerantInspectHits( + [boundedItem('nearest', 51, 51, 52, 52)], + { x: 50, y: 50 }, 'xy', { x: '>=', y: '<=' }, { x: 10, y: 10 }, + )).toEqual([]); + }); + + it('bounds an x inspection guide to the plot height', () => { + expect(inspectGuideLine('x', 40, { width: 300, height: 180 })).toEqual({ + x1: 40, y1: 0, x2: 40, y2: 180, + }); + }); + + it('clamps a y inspection guide to the plot baseline', () => { + expect(inspectGuideLine('y', 220, { width: 300, height: 180 })).toEqual({ + x1: 0, y1: 180, x2: 300, y2: 180, + }); + }); + + it('derives a shared faceted plot boundary from scenegraph cells', () => { + const cell = (x: number, width: number, height: number) => ({ + mark: { marktype: 'group', role: 'cell' }, x, y: 0, width, height, items: [], + }); + const view = { scenegraph: () => ({ root: { items: [cell(0, 80, 120), cell(90, 80, 120)] } }) }; + + expect(facetPlotBounds(view, { x: 0, y: 0, width: 200, height: 180 })).toEqual({ + x: 0, y: 0, width: 170, height: 120, + }); + }); + + it('draws a polar inspection guide from center to outer radius', () => { + const frame = { center: { x: 100, y: 80 }, outerRadius: 60 }; + + expect(polarGuideSegment(frame, { x: 130, y: 120 })).toEqual({ + start: { x: 100, y: 80 }, + end: { x: 136, y: 128 }, + }); + expect(polarGuideSegment(frame, frame.center)).toEqual({ + start: frame.center, + end: { x: 100, y: 20 }, + }); + }); + + it('normalizes compositional inspect modes', () => { + expect(parseInspectMode('x')).toEqual({ inspect: 'x', predicate: { x: '=' } }); + expect(parseInspectMode('xy<=')).toEqual({ inspect: 'xy', predicate: { x: '<=', y: '<=' } }); + expect(parseInspectMode('x<=;y>=')).toEqual({ inspect: 'xy', predicate: { x: '<=', y: '>=' } }); + expect(() => parseInspectMode('y>=;x<=' as any)).toThrow('Invalid inspect mode'); + }); + + it('acquires the arc crossed by a polar inspection guide', () => { + const arc = (key: string, startAngle: number, endAngle: number) => ({ + x: 100, y: 80, innerRadius: 20, outerRadius: 60, startAngle, endAngle, + datum: { [INTERACTION_KEY]: key }, + mark: { marktype: 'arc', name: key }, + }); + const frame = { center: { x: 100, y: 80 } }; + const items = [arc('right', 0, Math.PI), arc('left', Math.PI, 2 * Math.PI)]; + + expect(polarInspectHits(items, { x: 140, y: 80 }, frame) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['right']); + expect(polarInspectHits(items, { x: 60, y: 80 }, frame) + .map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['left']); + }); + + it('captures marks inside a freeform lasso path', () => { + const view = fakeView([ + boundedItem('in', 20, 20, 30, 30), + boundedItem('out', 200, 200, 210, 210), + ]); + const square = [ + { x: 0, y: 0 }, { x: 60, y: 0 }, { x: 60, y: 60 }, { x: 0, y: 60 }, + ]; + + const hits = polygonHits(view, square); + expect(hits.map((hit) => hit.datum[INTERACTION_KEY])).toEqual(['in']); + expect(polygonHits(view, square.slice(0, 2))).toEqual([]); + }); + + it('reports a polygon region as a lasso selection', () => { + const event = toCanvasInteractionEvent({ + type: 'semantic', + source: 'region', + phase: 'commit', + target: null, + region: { points: [{ x: 0, y: 0 }, { x: 4, y: 0 }, { x: 4, y: 4 }] }, + }, lassoTrigger()); + + expect(event.action).toBe('select-lasso'); + expect(event.geometry.plot).toMatchObject({ kind: 'polygon' }); + }); + + it('reports keyboard target movement as focus without activating', () => { + const event = toCanvasInteractionEvent({ + type: 'semantic', + source: 'element', + phase: 'preview', + target: { visual: { kind: 'mark', role: 'bar' }, elements: [{ value: { key: 'a' } }] }, + }, keyboardTrigger); + + expect(event.action).toBe('focus-element'); + }); + + it('turns a lasso selection into an emphasis update', () => { + const interaction = lassoSelect(); + const target = { + visual: { kind: 'mark' as const, role: 'symbol' }, + elements: [{ value: { key: 'a' } }, { value: { key: 'b' } }], + }; + const context = { chartType: 'Scatter Plot', selected: [] }; + + const update = interaction.handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'region', phase: 'commit', target, + region: { points: [{ x: 0, y: 0 }, { x: 9, y: 0 }, { x: 9, y: 9 }] }, + }, interaction.eventSource), context); + + expect(update?.ops[0]).toMatchObject({ + op: 'set-style', + targets: [{ elements: target.elements }], + }); + expect(interaction.handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'region', phase: 'commit', target, axis: 'x', + }, interaction.eventSource), context)).toBeNull(); + }); + + it('lets keyboard activation reach the same click presets', () => { + const target = { + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { key: 'a' } }], + }; + const context = { chartType: 'Bar Chart', selected: [] }; + const activation = { + ...toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target, + }, clickTrigger), + action: 'activate-element' as const, + }; + + expect(clickMark().handle!(activation, context)?.ops[0]).toMatchObject({ + op: 'set-style', + }); + expect(clickAnnotate().handle!(activation, context)?.ops[0]).toMatchObject({ + op: 'set-annotation', + }); + }); +}); + +describe('keyboard spatial navigation', () => { + const at = (key: string, x: number, y: number) => ({ + mark: { marktype: 'symbol' }, + datum: { [INTERACTION_KEY]: key }, + bounds: { x1: x - 3, y1: y - 3, x2: x + 3, y2: y + 3 }, + }); + const grid = [ + at('left', 10, 50), + at('centre', 50, 50), + at('right', 90, 50), + at('above', 50, 10), + at('below', 50, 90), + ]; + const from = { x: 50, y: 50 }; + const keyOf = (item: any) => item?.datum[INTERACTION_KEY]; + + it('moves to the neighbour on the axis the arrow names', () => { + expect(keyOf(nextItemInDirection(grid, from, 'right'))).toBe('right'); + expect(keyOf(nextItemInDirection(grid, from, 'left'))).toBe('left'); + expect(keyOf(nextItemInDirection(grid, from, 'up'))).toBe('above'); + expect(keyOf(nextItemInDirection(grid, from, 'down'))).toBe('below'); + }); + + it('prefers an aligned neighbour over a closer diagonal one', () => { + const items = [at('diagonal', 62, 26), at('aligned', 90, 50)]; + + expect(keyOf(nextItemInDirection(items, from, 'right'))).toBe('aligned'); + }); + + it('follows the next discrete row even when bar lengths differ', () => { + const bars = [ + { ...at('long', 100, 10), bounds: { x1: 0, y1: 7, x2: 200, y2: 13 } }, + { ...at('short', 25, 30), bounds: { x1: 0, y1: 27, x2: 50, y2: 33 } }, + { ...at('aligned-later', 100, 50), bounds: { x1: 0, y1: 47, x2: 200, y2: 53 } }, + ]; + + expect(keyOf(nextItemInDirection(bars, { x: 100, y: 10 }, 'down', 'y'))).toBe('short'); + }); + + it('uses the box body as the single target for a composite boxplot', () => { + const datum = { [INTERACTION_KEY]: 'Adele' }; + const component = (marktype: string, bounds: Record) => ({ + mark: { marktype }, datum, bounds, + }); + const box = component('rect', { x1: 3, y1: 80, x2: 17, y2: 115 }); + const targets = keyboardTargetItems([ + component('rule', { x1: 9, y1: 64, x2: 11, y2: 121 }), + box, + component('rect', { x1: 3, y1: 109.5, x2: 17, y2: 110.5 }), + component('symbol', { x1: 7, y1: 52, x2: 13, y2: 58 }), + ]); + + expect(targets).toEqual([box]); + expect(targetFeedbackPoint(targets[0])).toEqual({ x: 10, y: 97.5 }); + }); + + it('stops at the edge instead of wrapping around', () => { + expect(nextItemInDirection(grid, { x: 90, y: 50 }, 'right')).toBeUndefined(); + expect(nextItemInDirection(grid, { x: 10, y: 50 }, 'left')).toBeUndefined(); + }); +}); + +describe('effective annotation composition', () => { + const target = (category: string) => ({ + visual: { kind: 'mark' as const, role: 'bar' }, + elements: [{ value: { category } }], + }); + const annotation = (id: string, category: string) => ({ + id, + ops: [{ + op: 'set-annotation' as const, + target: target(category), + value: { text: category, candidates: [{ connection: 'top' as const }] }, + }], + }); + + it('keeps annotations from independent updates and targets', () => { + const entries = effectiveAnnotationEntries([ + annotation('first', 'A'), + annotation('second', 'B'), + ]); + + expect(entries.map((entry) => entry.value.text)).toEqual(['A', 'B']); + expect(new Set(entries.map((entry) => entry.key)).size).toBe(2); + }); + + it('lets a null operation clear the same update and target', () => { + const retained = annotation('note', 'A'); + expect(effectiveAnnotationEntries([{ + id: retained.id, + ops: [...retained.ops, { + op: 'set-annotation' as const, + target: target('A'), + value: null, + }], + }])).toEqual([]); + }); +}); + +describe('lasso capture semantics', () => { + const mark = (key: string, x1: number, y1: number, x2: number, y2: number) => ({ + mark: { marktype: 'rect' }, + datum: { [INTERACTION_KEY]: key }, + bounds: { x1, y1, x2, y2 }, + }); + const view = (items: readonly any[]) => ({ + scenegraph: () => ({ root: { mark: { marktype: 'group' }, items } }), + }); + const square = [ + { x: 100, y: 100 }, { x: 200, y: 100 }, { x: 200, y: 200 }, { x: 100, y: 200 }, + ]; + const keys = (hits: readonly any[]) => hits.map((hit) => hit.datum[INTERACTION_KEY]).sort(); + + it('captures a mark whose area overlaps the lasso', () => { + const scene = view([ + mark('inside', 120, 120, 140, 140), + mark('straddling', 190, 140, 260, 160), + mark('outside', 300, 300, 320, 320), + ]); + + expect(keys(polygonHits(scene, square))).toEqual(['inside', 'straddling']); + }); + + it('captures a mark the lasso is drawn entirely inside', () => { + // A small loop within one long bar: no corner or centre of the bar is inside. + const scene = view([mark('long-bar', 0, 130, 600, 170)]); + + expect(keys(polygonHits(scene, square))).toEqual(['long-bar']); + }); + + it('requires the whole mark for contain', () => { + const scene = view([ + mark('inside', 120, 120, 140, 140), + mark('straddling', 190, 140, 260, 160), + ]); + + expect(keys(polygonHits(scene, square, true))).toEqual(['inside']); + }); +}); + +describe('legend, inspect, zoom, and touch presets', () => { + it('removes renderer and template-derived fields from tooltip values', () => { + expect(withoutSemanticInteractionField({ + Country: 'United States', + 'GDP ($T)': 27.4, + __flint_interaction_key: 'mark:0', + __bt_sort: 0, + __bt_others: false, + _vgsid_: 3, + })).toEqual({ Country: 'United States', 'GDP ($T)': 27.4 }); + }); + + it('uses the compiled hover tooltip fields for keyboard details', () => { + expect(targetFeedbackEntries({ + tooltip: { Country: 'Norway', Share: 98.6 }, + }, { + Country: 'Norway', Share: 98.6, start: 0, end: 98.6, + })).toEqual([['Country', 'Norway'], ['Share', 98.6]]); + }); + + it('centers target feedback within an arc wedge', () => { + expect(targetFeedbackPoint({ + mark: { marktype: 'arc' }, + x: 100, + y: 100, + innerRadius: 20, + outerRadius: 80, + startAngle: 0, + endAngle: Math.PI / 2, + bounds: { x1: 100, y1: 20, x2: 180, y2: 100 }, + })).toEqual({ + x: 100 + 50 * Math.sin(Math.PI / 4), + y: 100 - 50 * Math.cos(Math.PI / 4), + }); + }); + + it('places target details away from the target and flips at viewport edges', () => { + expect(targetFeedbackDetailsPosition( + { x: 100, y: 80 }, + { width: 120, height: 50 }, + { width: 400, height: 300 }, + )).toEqual({ left: 114, top: 94 }); + expect(targetFeedbackDetailsPosition( + { x: 390, y: 290 }, + { width: 120, height: 50 }, + { width: 400, height: 300 }, + )).toEqual({ left: 256, top: 226 }); + }); + + const context = { chartType: 'Line Chart', selected: [] }; + const seriesTarget = (name: string) => ({ + visual: { kind: 'legend' as const, role: 'legend-item' }, + elements: [{ + value: { channel: 'color', field: 'Series', value: name }, + records: [{ Series: name }], + }], + }); + const activate = (interaction: CanvasInteractionDef, target: SemanticTarget | null, ctx: InteractionContext = context) => + interaction.handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target, + }, interaction.eventSource), ctx); + + it('hides an activated series and restores it when activated again', () => { + const interaction = legendToggle(); + + expect(activate(interaction, seriesTarget('A'))?.ops[0]).toMatchObject({ + op: 'set-style', + targets: [{ elements: [{ value: { channel: 'color', field: 'Series', value: 'A' } }] }], + value: { visible: false, mutedOpacity: 0.25 }, + }); + expect(activate(interaction, seriesTarget('A'))?.ops[0]).toMatchObject({ + targets: [], + value: { visible: false, mutedOpacity: 0.25 }, + }); + }); + + it('owns the hidden legend affordance opacity', () => { + const interaction = legendToggle({ mutedOpacity: 0.4 }); + + expect(activate(interaction, seriesTarget('A'))?.ops[0]).toMatchObject({ + value: { visible: false, mutedOpacity: 0.4 }, + }); + }); + + it('accumulates several hidden series', () => { + const interaction = legendToggle(); + activate(interaction, seriesTarget('A')); + + expect(activate(interaction, seriesTarget('B'))?.ops[0]).toMatchObject({ + targets: [{ elements: [ + { value: { channel: 'color', field: 'Series', value: 'A' } }, + { value: { channel: 'color', field: 'Series', value: 'B' } }, + ] }], + }); + }); + + it('restores all series when the last visible series is disabled', () => { + const interaction = legendToggle(); + const initialContext = { + chartType: 'Line Chart', selected: [], + legendDomains: { color: ['A', 'B'] }, + available: [ + { value: { Series: 'A' }, records: [{ Series: 'A' }] }, + { value: { Series: 'B' }, records: [{ Series: 'B' }] }, + ], + }; + const domainTarget = (name: string) => ({ + visual: { kind: 'legend' as const, role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', field: 'Series', + domain: { kind: 'value' as const, value: name }, + }, + }], + }); + activate(interaction, domainTarget('A'), initialContext); + + expect(activate(interaction, domainTarget('B'), { + chartType: 'Line Chart', selected: [], + legendDomains: { color: ['A', 'B'] }, + available: [{ value: { Series: 'B' }, records: [{ Series: 'B' }] }], + })?.ops[0]).toMatchObject({ + targets: [], + value: { visible: false, mutedOpacity: 0.25 }, + }); + + expect(activate(interaction, domainTarget('A'), initialContext)?.ops[0]).toMatchObject({ + targets: [{ elements: domainTarget('A').elements }], + }); + }); + + it('does not reset early when a Streamgraph exposes collapsed availability', () => { + const interaction = legendToggle(); + const domainTarget = (name: string) => ({ + visual: { kind: 'legend' as const, role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', field: 'Region', + domain: { kind: 'value' as const, value: name }, + }, + }], + }); + const collapsedContext: InteractionContext = { + chartType: 'Streamgraph', selected: [], + legendDomains: { color: ['Asia', 'Africa'] }, + available: [{ value: { Region: 'Asia' }, records: [{ Region: 'Asia' }] }], + }; + + expect(activate(interaction, domainTarget('Asia'), collapsedContext)?.ops[0]).toMatchObject({ + targets: [{ elements: domainTarget('Asia').elements }], + }); + expect(activate(interaction, domainTarget('Africa'), collapsedContext)?.ops[0]).toMatchObject({ + targets: [], + }); + }); + + it('ignores mark activations so it composes with element click presets', () => { + const interaction = legendToggle(); + const markTarget = { visual: { kind: 'mark' as const, role: 'mark' }, elements: [{ value: { key: 'A' } }] }; + + expect(activate(interaction, markTarget)).toBeNull(); + }); + + it('focuses configured discrete-axis and legend targets through one preset', () => { + const axisInteraction = clickHighlight({ targets: ['discreteAxis'], dimOpacity: 0.2 }); + const legendInteraction = clickHighlight({ targets: ['legend'], dimOpacity: 0.2 }); + const axisTarget = { + visual: { kind: 'axis' as const, role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }; + const markTarget = { + visual: { kind: 'mark' as const, role: 'mark' }, + elements: [{ value: { Category: 'A' } }], + }; + + expect(axisInteraction).toMatchObject({ claimsAxisActivation: true }); + expect(legendInteraction).toMatchObject({ claimsLegendActivation: true }); + expect(activate(axisInteraction, axisTarget)?.ops[0]).toMatchObject({ + op: 'set-style', targets: [{ elements: axisTarget.elements }], + value: { state: 'emphasized', mutedOpacity: 0.2 }, + }); + expect(activate(legendInteraction, seriesTarget('A'))?.ops[0]).toMatchObject({ + op: 'set-style', targets: [{ elements: seriesTarget('A').elements }], + value: { state: 'emphasized', mutedOpacity: 0.2 }, + }); + expect(activate(axisInteraction, markTarget)).toBeNull(); + expect(activate(legendInteraction, markTarget)).toBeNull(); + }); + + it('handles mark, legend, and axis activations through one click highlight instance', () => { + const interaction = clickHighlight({ + targets: ['mark', 'legend', 'discreteAxis'], + dimOpacity: 0.2, + }); + const markTarget = { + visual: { kind: 'mark' as const, role: 'mark' }, + elements: [{ value: { Category: 'A' } }], + }; + const axisTarget = { + visual: { kind: 'axis' as const, role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }; + + expect(activate(interaction, markTarget)).toMatchObject({ id: 'click-highlight' }); + expect(activate(interaction, seriesTarget('A'))).toMatchObject({ id: 'click-highlight' }); + expect(activate(interaction, axisTarget)).toMatchObject({ id: 'click-highlight' }); + expect(interaction.handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', target: markTarget, + }, interaction.eventSource), context)).toMatchObject({ + id: 'click-highlight', + ops: [{ op: 'set-style', targets: [{ elements: markTarget.elements }] }], + }); + }); + + it('focuses a continuous legend interval without claiming an axis', () => { + const interaction = clickHighlight({ targets: ['legend'] }); + const intervalTarget = { + visual: { kind: 'legend' as const, role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', field: 'Temperature', + domain: { kind: 'interval' as const, start: 20, end: 30 }, + }, + }], + }; + const axisTarget = { + visual: { kind: 'axis' as const, role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }; + + expect(activate(interaction, intervalTarget)?.ops[0]).toMatchObject({ + op: 'set-style', targets: [{ elements: intervalTarget.elements }], + }); + expect(activate(interaction, axisTarget)).toBeNull(); + }); + + it('assigns observable legend events only to legend interactions', () => { + expect(activate(clickMark(), seriesTarget('A'))).toBeNull(); + expect(activate(clickGroupFocus(), seriesTarget('A'))).toBeNull(); + expect(activate(clickHighlight({ targets: ['legend'] }), seriesTarget('A'))).not.toBeNull(); + expect(activate(clickAnnotate(), seriesTarget('A'))).toBeNull(); + const hover = (interaction: CanvasInteractionDef) => interaction.handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', target: seriesTarget('A'), + }, interaction.eventSource), context); + expect(hover(hoverGroupFocus({ groupBy: 'Series' }))).toBeNull(); + }); + + it('reports the resolved role for context, long-press, and double activation', () => { + const target = seriesTarget('A'); + const semantic = { type: 'semantic' as const, source: 'element' as const, phase: 'commit' as const, target }; + + expect(toCanvasInteractionEvent(semantic, contextTrigger).action).toBe('context-legend'); + expect(toCanvasInteractionEvent(semantic, longPressTrigger()).action).toBe('long-press-legend'); + expect(toCanvasInteractionEvent(semantic, doubleActivateTrigger).action).toBe('double-activate-legend'); + }); + + it('preserves an unresolved legend domain for processor expansion', () => { + const target = { + visual: { kind: 'legend' as const, role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', field: '__status', + domain: { kind: 'value' as const, value: 'Meets target' }, + }, + }], + }; + const event = toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target, + }, clickTrigger); + + expect(event).toMatchObject({ action: 'click-legend', target }); + expect(activate(clickMark(), target)).toBeNull(); + expect(activate(clickGroupFocus(), target)).toBeNull(); + expect(activate(clickHighlight({ targets: ['legend'] }), target)?.ops[0]).toMatchObject({ + op: 'set-style', + targets: [{ visual: target.visual, elements: target.elements }], + value: { state: 'emphasized' }, + }); + expect(activate(legendToggle(), target)?.ops[0]).toMatchObject({ + op: 'set-style', + targets: [{ visual: target.visual, elements: target.elements }], + value: { visible: false }, + }); + }); + + it('reports inspection modes as their own actions', () => { + expect(inspect().eventSource).toEqual(inspectTrigger('xy')); + expect(inspectTrigger('xy').inspectTolerance).toBe(0.02); + expect(inspectTrigger('x').inspectTolerance).toBe(0.01); + expect(inspectTrigger('xy<=').inspectTolerance).toBe(0.01); + expect(inspectTrigger('xy', undefined, 0.03).inspectTolerance).toBe(0.03); + expect(inspect({ + mode: 'x>=;y<=', cycle: ['x>=;y<=', 'x>=;y>=', 'x<=;y>=', 'x<=;y<='], + }).eventSource.inspectCycle).toEqual([ + { inspect: 'xy', predicate: { x: '>=', y: '<=' } }, + { inspect: 'xy', predicate: { x: '>=', y: '>=' } }, + { inspect: 'xy', predicate: { x: '<=', y: '>=' } }, + { inspect: 'xy', predicate: { x: '<=', y: '<=' } }, + ]); + expect(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', target: null, + }, inspectTrigger('x')).action).toBe('inspect-x'); + expect(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', target: null, + }, inspectTrigger('y')).action).toBe('inspect-y'); + expect(inspect({ mode: 'x>=;y<=' }).handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'preview', target: null, + }, inspectTrigger('x>=;y<=')), { chartType: 'Scatter Plot', selected: [] })) + .toEqual({ + id: 'inspect', + ops: [{ + op: 'set-style', targets: [], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }], + }); + }); + + it('declares all and legend-switchable single-series index inspection policies', () => { + expect(inspectIndex()).toMatchObject({ + id: 'inspect-index', + eventSource: { inspectIndex: { axis: 'x', show: 'all' } }, + }); + expect(inspectIndex().handle).toBeUndefined(); + const single = inspectIndex({ axis: 'y', show: 'single', seriesBy: 'Series', tolerance: 0.03 }); + expect(single.eventSource.inspectIndex).toEqual({ axis: 'y', show: 'single', seriesBy: 'Series' }); + expect(single.eventSource.inspectTolerance).toBe(0.03); + expect(single.affordances).toEqual([ + { target: 'legend-item', cursor: 'activate', hover: 'cohort' }, + ]); + expect(inspectIndex({ show: { series: 'Forecast' }, seriesBy: 'Series' }).eventSource.inspectIndex) + .toEqual({ axis: 'x', show: { series: 'Forecast' }, seriesBy: 'Series' }); + expect(() => inspectIndex({ show: 'single' })).toThrow('requires seriesBy'); + expect(() => inspectIndex({ show: { series: 'Forecast' } })) + .toThrow('requires seriesBy'); + }); + + it('starts single-series inspection from the first or preferred available series', () => { + const items = [ + { datum: { Series: 'Bananas' } }, + { datum: { Series: 'Eggs' } }, + { datum: { Series: 'Bananas' } }, + ]; + expect(initialInspectSeries(items, 'Series')).toBe('Bananas'); + expect(initialInspectSeries(items, 'Series', 'Eggs')).toBe('Eggs'); + expect(initialInspectSeries(items, 'Series', 'Missing')).toBe('Bananas'); + }); + + it('collects authored mark keys for the tracked series presentation', () => { + const mark = { marktype: 'symbol', role: 'mark' }; + const items = [ + { mark, datum: { [INTERACTION_KEY]: 'banana-1', Series: 'Bananas' } }, + { mark, datum: { [INTERACTION_KEY]: 'eggs-1', Series: 'Eggs' } }, + { mark, datum: { [INTERACTION_KEY]: 'banana-2', Series: 'Bananas' } }, + ]; + expect(inspectSeriesPresentationKeys(items, 'Series', 'Bananas')) + .toEqual(['banana-1', 'banana-2']); + }); + + it('deduplicates inspect emphasis across segments of the same rendered path', () => { + const target = (records: readonly Record[]) => ({ + visual: { kind: 'path' as const, role: 'line' }, + elements: [associateSemanticElementRenderKeys({ + value: { Series: 'Bananas' }, + records, + }, [`Bananas${PATH_KEY_SUFFIX}`])], + }); + const modifiers = { shift: false, ctrl: false, meta: false }; + + expect(inspectEmphasisSignature(target([{ Month: 1 }, { Month: 2 }]), modifiers)) + .toBe(inspectEmphasisSignature(target([{ Month: 2 }, { Month: 3 }]), modifiers)); + expect(inspectEmphasisSignature(target([{ Month: 1 }]), modifiers)) + .not.toBe(inspectEmphasisSignature(null, modifiers)); + expect(inspectEmphasisSignature(target([{ Month: 1 }]), modifiers)) + .not.toBe(inspectEmphasisSignature(target([{ Month: 1 }]), { ...modifiers, shift: true })); + }); + + it('tolerates small pointer jitter during a long press', () => { + expect(longPressMovedBeyond({ x: 10, y: 10 }, { x: 13, y: 14 })).toBe(false); + expect(longPressMovedBeyond({ x: 10, y: 10 }, { x: 17, y: 10 })).toBe(true); + }); + + it('normalizes gesture guide visibility and renderer-neutral styles', () => { + expect(normalizeInspectGuideOptions(false)).toMatchObject({ visible: false }); + expect(normalizeInspectGuideOptions(undefined)).toMatchObject({ + visible: true, + style: { + color: '#47525c', opacity: 0.58, width: 1, + haloColor: '#ffffff', haloOpacity: 0.64, haloWidth: 0.5, + }, + }); + expect(normalizeInspectGuideOptions({ + style: { + color: '#123456', opacity: 2, width: 2, fillOpacity: -1, + haloColor: '#abcdef', haloOpacity: 2, haloWidth: 0, + }, + })).toEqual({ + visible: true, + style: { + color: '#123456', opacity: 1, width: 2, fillOpacity: 0, + haloColor: '#abcdef', haloOpacity: 1, haloWidth: 0, + }, + }); + expect(normalizeRegionGuideOptions({ + style: { fillOpacity: -1, strokeOpacity: 2, strokeWidth: 3 }, + })).toMatchObject({ + visible: true, + style: { fillOpacity: 0, strokeOpacity: 1, strokeWidth: 3 }, + }); + }); + + it('configures guides without changing gesture semantics', () => { + const hiddenInspect = inspect({ mode: 'x', guide: false }).eventSource; + expect(hiddenInspect).toMatchObject({ + gesture: 'inspect', inspect: 'x', inspectGuide: { visible: false }, + }); + const hiddenRegion = select({ match: 'contain', guide: false }).eventSource; + expect(hiddenRegion).toMatchObject({ + gesture: 'drag', match: 'contain', regionGuide: { visible: false }, + }); + expect(brushAngle({ guide: false }).eventSource.regionGuide?.visible).toBe(false); + expect(lassoSelect({ guide: false }).eventSource.regionGuide?.visible).toBe(false); + expect(brushZoom({ guide: false }).eventSource.regionGuide?.visible).toBe(false); + }); + + it('turns a brushed region into an absolute viewport', () => { + const interaction = brushZoom(); + const event = { + ...toCanvasInteractionEvent({ + type: 'semantic', source: 'region', phase: 'commit', target: null, + region: { x: 0, y: 0, width: 10, height: 10 }, axis: 'xy' as const, + }, interaction.eventSource), + geometry: { + domain: { + x: { kind: 'interval' as const, start: 2, end: 8 }, + y: { kind: 'interval' as const, start: 1, end: 5 }, + }, + }, + }; + + expect(interaction.handle!(event, context)).toEqual({ + id: 'brush-zoom', + ops: [{ op: 'set-viewport', axes: 'xy', value: { x: [2, 8], y: [1, 5] } }], + }); + expect(interaction.handle!({ + ...event, + operation: 'clear', + geometry: { + domain: { + x: { kind: 'interval', start: 5, end: 5 }, + y: { kind: 'interval', start: 3, end: 3 }, + }, + }, + }, context)).toBeNull(); + }); + + it('inverts a log-scale brush in plot-local coordinates', () => { + const inverted: number[] = []; + const domain = domainForPlotGeometry({ + kind: 'rect', axis: 'xy', rect: { x: 100, y: 0, width: 200, height: 80 }, + }, { + x: { scale: 'x', signal: 'xDomain', type: 'log' }, + }, () => ({ + invert: (pixel: number) => { + inverted.push(pixel); + return 10 ** (pixel / 100); + }, + })); + + expect(inverted).toEqual([100, 300]); + expect(domain).toEqual({ x: { kind: 'interval', start: 10, end: 1000 } }); + }); + + it('preserves the scale domain direction for a vertically inverted brush range', () => { + const domain = domainForPlotGeometry({ + kind: 'rect', axis: 'y', rect: { x: 0, y: 20, width: 100, height: 40 }, + }, { + y: { scale: 'y', signal: 'yDomain', type: 'linear' }, + }, () => ({ + domain: () => [20, 40], + invert: (pixel: number) => 40 - pixel / 4, + })); + + expect(domain).toEqual({ y: { kind: 'interval', start: 25, end: 35 } }); + }); + + it('preserves a reversed y axis when the scale increases down the screen', () => { + const domain = domainForPlotGeometry({ + kind: 'rect', axis: 'y', rect: { x: 0, y: 20, width: 100, height: 40 }, + }, { + y: { scale: 'y', signal: 'yDomain', type: 'linear' }, + }, () => ({ + invert: (pixel: number) => 20 + pixel / 4, + })); + + expect(domain).toEqual({ y: { kind: 'interval', start: 35, end: 25 } }); + }); + + it('normalizes viewport brush geometry without scanning marks', () => { + const view = { + width: () => 100, + height: () => 80, + scenegraph: () => { throw new Error('scenegraph should not be read'); }, + }; + + const event = normalizeVegaRegionEvent( + view, { x: 10, y: 20 }, { x: 60, y: 70 }, 'preview', 'intersect', + { shift: false, ctrl: false, meta: false }, + 'xy', { width: 100, height: 80 }, 'create', false, + ); + + expect(event.region).toEqual({ x: 10, y: 20, width: 50, height: 50 }); + expect(event.hits).toEqual([]); + }); + + it('ignores a brush that collapsed to a single value', () => { + const interaction = brushZoom({ axes: 'x' }); + const event = { + ...toCanvasInteractionEvent({ + type: 'semantic', source: 'region', phase: 'commit', target: null, + region: { x: 0, y: 0, width: 0, height: 10 }, axis: 'x' as const, + }, interaction.eventSource), + geometry: { domain: { x: { kind: 'interval' as const, start: 4, end: 4 } } }, + }; + + expect(interaction.handle!(event, context)).toBeNull(); + }); + + it('reports long press and highlights on double activation', () => { + const target = { + visual: { kind: 'mark' as const, role: 'point' }, + elements: [{ value: { category: 'A' } }], + }; + expect(longPress({ holdMs: 250 }).eventSource).toMatchObject({ gesture: 'long-press', holdMs: 250 }); + expect(longPress().affordances).toEqual([ + { target: 'mark', cursor: 'activate', hover: 'target' }, + ]); + expect(doubleActivate().affordances).toEqual([ + { target: 'mark', cursor: 'activate', hover: 'target' }, + ]); + expect(longPress().handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target, + }, longPressTrigger()), context)?.ops[0]).toMatchObject({ + op: 'set-style', + targets: [{ visual: target.visual, elements: target.elements }], + value: { state: 'emphasized' }, + }); + expect(doubleActivate().handle!(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target, + }, doubleActivateTrigger), context)?.ops[0]).toMatchObject({ + op: 'set-style', + targets: [{ visual: target.visual, elements: target.elements }], + value: { state: 'emphasized' }, + }); + expect(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target: null, + }, longPressTrigger()).action).toBe('long-press-element'); + expect(toCanvasInteractionEvent({ + type: 'semantic', source: 'element', phase: 'commit', target: null, + }, doubleActivateTrigger).action).toBe('double-activate-element'); + }); + + it('lets the angular brush be edited once committed', () => { + expect(brushAngle({ mode: 'stateful' }).eventSource).toMatchObject({ + regionGeometry: 'angular', mode: 'stateful', + }); + expect(brushAngle().eventSource).toMatchObject({ mode: 'ephemeral' }); + }); +}); 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..7cc14990 --- /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.25, + }); + }); + + 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/kpi-card.test.ts b/packages/flint-js/tests/kpi-card.test.ts new file mode 100644 index 00000000..00ffdddb --- /dev/null +++ b/packages/flint-js/tests/kpi-card.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src'; + +describe('KPI Card captions', () => { + it('wraps long captions and constrains them to the card width', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { Metric: 'Renewable electricity (%)', Value: 30.3, Goal: 45 }, + { Metric: 'EV share of car sales (%)', Value: 18, Goal: 40 }, + { Metric: 'World online (%)', Value: 67, Goal: 90 }, + { Metric: 'Electricity access (%)', Value: 91, Goal: 100 }, + ], + }, + semantic_types: { Metric: 'Category', Value: 'Quantity', Goal: 'Quantity' }, + chart_spec: { + chartType: 'KPI Card', + encodings: { metric: 'Metric', value: 'Value', goal: 'Goal' }, + chartProperties: { layout: 'horizontal' }, + baseSize: { width: 560, height: 240 }, + }, + } as any) as any; + + const captionMarks = spec.hconcat.map((tile: any) => + tile.layer.find((layer: any) => + layer.mark?.type === 'text' && String(layer.mark.text).includes('%')), + ); + + expect(captionMarks).toHaveLength(4); + expect(captionMarks.every((layer: any) => layer.mark.limit > 0)).toBe(true); + expect(captionMarks.some((layer: any) => layer.mark.text.includes('\n'))).toBe(true); + expect(captionMarks.every((layer: any) => layer.mark.text.split('\n').length <= 2)).toBe(true); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/maps.test.ts b/packages/flint-js/tests/maps.test.ts index 0b096985..b41dc710 100644 --- a/packages/flint-js/tests/maps.test.ts +++ b/packages/flint-js/tests/maps.test.ts @@ -151,6 +151,26 @@ describe('choropleth maps from names', () => { expect(spec.transform?.[0]?.lookup).toBe('id'); expect(spec.transform?.[0]?.from?.key).toBe('__geo_id'); }); + + it('keeps a declared quantitative color domain stable and centered', () => { + const spec = assembleVegaLite({ + data: { values: [{ state: 'CA', margin: 30 }, { state: 'TX', margin: -14 }] }, + semantic_types: { + state: 'State', + margin: { semanticType: 'Quantity', intrinsicDomain: [-100, 100] }, + }, + chart_spec: { + chartType: 'Choropleth', + encodings: { id: 'state', color: { field: 'margin', scheme: 'redblue' } }, + }, + }) as any; + + expect(spec.encoding.color.scale).toMatchObject({ + domain: [-100, 100], + clamp: true, + scheme: 'redblue', + }); + }); }); describe('choropleth lookup robustness', () => { diff --git a/packages/flint-js/tests/point-size.test.ts b/packages/flint-js/tests/point-size.test.ts index 4f7a8d9b..a57309de 100644 --- a/packages/flint-js/tests/point-size.test.ts +++ b/packages/flint-js/tests/point-size.test.ts @@ -64,6 +64,27 @@ const radarInput = (house: string) => ({ } as never); describe('point size', () => { + it('keeps an explicitly bounded bubble-size domain stable', () => { + const spec = assembleVegaLite({ + data: { values: [ + { X: 1, Y: 2, Population: 10 }, + { X: 2, Y: 3, Population: 40 }, + ] }, + semantic_types: { + X: 'Quantity', + Y: 'Quantity', + Population: { semanticType: 'Quantity', intrinsicDomain: [0, 100] }, + }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: 'X', y: 'Y', size: 'Population' }, + }, + } as never) as any; + + expect(spec.encoding.size.scale.domain).toEqual([0, 100]); + expect(spec.encoding.size.scale.clamp).toBe(true); + }); + it('every house says how big its dots are', () => { // Silence is not a style. A house that never names a size inherits // the renderer's own default, which is nobody's design decision, and diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts new file mode 100644 index 00000000..11edb17c --- /dev/null +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -0,0 +1,4189 @@ +import { describe, expect, it } from 'vitest'; +import { changeset, parse, View } from 'vega'; +import { compile } from 'vega-lite'; +import { assembleVegaLite } from '../src/vegalite/assemble'; +import { axisHighlight, brushAngle, brushX, brushZoom, clickAnnotate, clickHighlight, dragReorder, externalInteraction, inspect, legendToggle, navigate, select } from '../src/interactive/interactions'; +import type { CanvasInteractionDef, ClickHighlightOptions, RenderHit, SemanticElement, SemanticTarget } from '../src/interactive/interactions'; +import { dragTrigger } from '../src/interactive/triggers'; +import { + associateSemanticElementRenderKeys, + MUTED_HOVER_FILL, + MUTED_HOVER_STROKE, + legendMatchedHits, + semanticElementRenderKeys, + sourceRecordsForRenderedRecords, +} from '../src/core/interaction-semantics'; +import { areaChartDef, streamgraphDef } from '../src/vegalite/templates/area'; +import { + barChartDef, + groupedBarChartDef, + heatmapDef, + histogramDef, + pyramidChartDef, + stackedBarChartDef, +} from '../src/vegalite/templates/bar'; +import { barTableDef } from '../src/vegalite/templates/bar-table'; +import { pieChartDef } from '../src/vegalite/templates/pie'; +import { roseChartDef } from '../src/vegalite/templates/rose'; +import { boxplotDef, rangedDotPlotDef, scatterPlotDef } from '../src/vegalite/templates/scatter'; +import { + addVegaLiteInteractions, + collectVegaAxisTargets, + injectVegaInteractionStore, + injectVegaNavigationSignals, + injectVegaReorderSignal, +} from '../src/vegalite/interactions/compile'; +import { angularSectorPath } from '../src/interactive/geometry/angular'; +import { + arcIntersectsAngularSector, + axisTargetIdentity, + arcIntersectsRect, + boundsIntersectRect, + clientRectToLayoutRect, + clientToPlotPoint, + clientToRendererPoint, + clientToLayoutPoint, + facetPlotFrameAt, + INTERACTION_KEY, + INTERACTION_LEGEND_CHANNEL, + INTERACTION_LEGEND_FIELD, + INTERACTION_ROLE, + indexInspectAcquisition, + indexInspectHits, + PATH_KEY_SUFFIX, + pathIntersectsAngularSector, + physicalItemAt, + polarFrameFromRadarGrid, + plotToClientPoint, + legendEntryItemAtPoint, + legendSemanticTarget, + legendTarget, + normalizeVegaElementEvent, + nearestItemByBounds, + nearestInteractiveSceneItem, + rendererPlotOrigin, + renderHit, + sceneItems, + tolerantInspectHits, + continuousLegendSegmentCount, +} from '../src/vegalite/interactions/hit-adapter'; +import { + AXIS_HOVER_STORE, + HIDDEN_STORE, + HOVER_STORE, + INTERACTION_STORE, + LEGEND_HIDDEN_STORE, + LEGEND_HOVER_STORE, + LEGEND_SELECTION_STORE, + STYLE_SIGNAL, +} from '../src/vegalite/interactions/stores'; +import { + mergeContiguousSelectionBounds, + selectionBoundarySegments, +} from '../src/vegalite/interactions/presentation/focus-overlay'; +import { + annotationBounds, + annotationConnectionPoint, + annotationPrimaryAnchor, +} from '../src/vegalite/interactions/presentation/annotation-overlay'; +import { createVegaNavigationController } from '../src/vegalite/interactions/navigation-scale'; +import { INTERACTION_PROVENANCE } from '../src/vegalite/interaction-provenance'; +import { THEME_PRESETS } from '../src/core/theme/presets'; +import { lineChartDef } from '../src/vegalite/templates/line'; +import { bumpChartDef } from '../src/vegalite/templates/bump'; +import { slopeChartDef } from '../src/vegalite/templates/slope'; +import { enrichTargetWithSourceProvenance } from '../src/vegalite/interactions/runtime'; +import { regressionDef } from '../src/vegalite/templates/scatter'; +import { mapDef, choroplethDef } from '../src/vegalite/templates/map'; +import { densityPlotDef } from '../src/vegalite/templates/density'; +import { ecdfPlotDef } from '../src/vegalite/templates/ecdf'; +import { vlCalendarHeatmapDef } from '../src/vegalite/templates/calendar'; +import { sparklineDef } from '../src/vegalite/templates/sparkline'; +import { violinPlotDef } from '../src/vegalite/templates/violin'; +import { waterfallChartDef } from '../src/vegalite/templates/waterfall'; +import { bulletChartDef } from '../src/vegalite/templates/bullet'; +import { kpiCardDef } from '../src/vegalite/templates/kpi-card'; +import { radarChartDef } from '../src/vegalite/templates/radar'; +import { createLegendToggleInteraction } from '../src/interactive/presets/legend-toggle'; +import { + resolveLegendPresentationTarget, + resolveRetainedLegendPresentationTarget, + resolveRetainedLegendPresentationTargets, + resolvedLegendInteractionTarget, +} from '../src/vegalite/interactions/runtime'; + +const clickMark = (options: Omit = {}) => + clickHighlight({ ...options, targets: ['mark'] }); + +function annotationUpdate( + element: SemanticElement, + visual: SemanticTarget['visual'] = { kind: 'mark', role: 'test' }, +) { + return { + id: 'test-annotation', + ops: [{ + op: 'set-annotation' as const, + target: { visual, elements: [element] }, + value: {}, + }], + }; +} + +function instrument(spec: Record, interactions = [clickMark()]) { + const plan = addVegaLiteInteractions(spec, interactions); + const compiled = compile(spec as any).spec as Record; + if (plan) injectVegaInteractionStore(compiled, plan); + return { plan, compiled }; +} + +function allSceneItems(view: View): any[] { + const items: any[] = []; + const visit = (item: any): void => { + if (!item) return; + if (item.mark) items.push(item); + if (Array.isArray(item.items)) item.items.forEach(visit); + }; + visit((view.scenegraph() as any).root); + return items; +} + +function rootSceneBounds(view: View, target: any) { + let result: { x1: number; y1: number; x2: number; y2: number } | undefined; + const visit = (item: any, offsetX = 0, offsetY = 0): void => { + if (!item || result) return; + if (item === target && item.bounds) { + result = { + x1: item.bounds.x1 + offsetX, y1: item.bounds.y1 + offsetY, + x2: item.bounds.x2 + offsetX, y2: item.bounds.y2 + offsetY, + }; + return; + } + const isGroup = item.mark?.marktype === 'group'; + const nextX = offsetX + (isGroup && typeof item.x === 'number' ? item.x : 0); + const nextY = offsetY + (isGroup && typeof item.y === 'number' ? item.y : 0); + item.items?.forEach((child: any) => visit(child, nextX, nextY)); + }; + visit((view.scenegraph() as any).root); + return result; +} + +describe('Vega-Lite semantic interactions', () => { + it('keeps transformed values separate from source-record provenance', () => { + const sourceRecords = [ + { OS: 'Android', Share: 71 }, + { OS: 'iOS', Share: 29 }, + ]; + const rendered = [{ OS: 'Android', Share: 71, Share_start: 0, Share_end: 71 }]; + + expect(sourceRecordsForRenderedRecords(rendered, sourceRecords, ['OS', 'Share'])) + .toEqual([{ OS: 'Android', Share: 71 }]); + expect(sourceRecordsForRenderedRecords( + [{ Region: 'West', Sales: 300 }], + [ + { Region: 'West', Sales: 100 }, + { Region: 'West', Sales: 200 }, + { Region: 'East', Sales: 50 }, + ], + ['Region'], + )).toEqual([ + { Region: 'West', Sales: 100 }, + { Region: 'West', Sales: 200 }, + ]); + expect(sourceRecordsForRenderedRecords( + [{ Games: Date.UTC(2012, 0, 1), Country: 'China', Rank: 2 }], + [ + { Games: 2012, Country: 'China', Rank: 2 }, + { Games: 2016, Country: 'China', Rank: 3 }, + ], + ['Games', 'Country', 'Rank'], + ['Games'], + )).toEqual([{ Games: 2012, Country: 'China', Rank: 2 }]); + }); + + it('resolves a histogram bin to its range, count, and contributing records', () => { + const semantics = histogramDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'Duration', type: 'quantitative' } }, + }); + const hit = { + datum: { + [INTERACTION_KEY]: '3|3.5', + __bin_start: 3, + __bin_end: 3.5, + }, + source: 'mark' as const, + }; + const target = semantics.resolve( + { gesture: 'hover', role: 'mark', hits: [hit] }, + { allHits: [hit], keyField: INTERACTION_KEY }, + ); + const enriched = enrichTargetWithSourceProvenance(target, { + sourceRecords: [{ Duration: 2.9 }, { Duration: 3.1 }, { Duration: 3.4 }, { Duration: 3.5 }], + provenanceFields: [], + temporalProvenanceFields: [], + rangeProvenance: [{ field: 'Duration', startField: '__bin_start', endField: '__bin_end' }], + }); + + expect(enriched?.elements[0]).toEqual({ + value: { field: 'Duration', range: { start: 3, end: 3.5 }, count: 2 }, + records: [{ Duration: 3.1 }, { Duration: 3.4 }], + }); + expect(semanticElementRenderKeys(target!.elements[0])).toEqual(['3|3.5']); + expect(semanticElementRenderKeys(enriched!.elements[0])).toEqual(['3|3.5']); + const update = semantics.presentUpdate!( + annotationUpdate(enriched!.elements[0], { kind: 'mark', role: 'mark' }), + { chartType: 'Histogram', selected: [] }, + ); + expect(update.ops[0]).toMatchObject({ + value: { text: '2' }, + }); + expect((update.ops[0] as any).value.candidates[0]).toEqual({ + connection: 'value-end', valueAxis: 'y', priority: 0, + }); + }); + + it('combines color contrast and outlines for Boxplot hover', () => { + const semantics = boxplotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Species', type: 'nominal' }, + y: { field: 'Body mass', type: 'quantitative' }, + }, + }); + + expect(semantics.renderHoverStyles).toEqual({ + rect: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + rule: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + symbol: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }); + }); + + it('presents a Boxplot annotation from its computed summary', () => { + const semantics = boxplotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Species', type: 'nominal' }, + y: { field: 'Body mass', type: 'quantitative' }, + }, + }); + const element = { + value: { + Species: 'Gentoo', + lower_box_Body_mass: 4700, + mid_box_Body_mass: 5000, + upper_box_Body_mass: 5400, + }, + }; + + const update = semantics.presentUpdate!( + annotationUpdate(element, { kind: 'mark', role: 'distribution' }), + { chartType: 'Boxplot', selected: [] }, + ); + expect(update.ops[0]).toMatchObject({ + value: { + text: 'Median: 5,000\nIQR: 4,700 → 5,400', + }, + }); + expect((update.ops[0] as any).value.candidates[0]).toEqual({ connection: 'center', priority: 0 }); + }); + + it('preserves source provenance through an assembled histogram plan', async () => { + const sourceRecords = [1.7, 1.9, 2.1, 2.4, 3.1, 3.4].map((duration) => ({ + 'Duration (min)': duration, + })); + const spec = assembleVegaLite({ + data: { values: sourceRecords }, + semantic_types: { 'Duration (min)': 'Quantity' }, + chart_spec: { chartType: 'Histogram', encodings: { x: 'Duration (min)' } }, + } as never) as any; + const { plan, compiled } = instrument(spec, [inspect({ mode: 'x' })]); + if (!plan?.resolve) throw new Error('Expected an instrumented histogram plan'); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bar = sceneItems(view).find((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + const hit = renderHit(bar); + if (!hit) throw new Error('Expected an interactive histogram bar'); + expect(plan.sourceRecords).toHaveLength(sourceRecords.length); + expect(hit.datum).toMatchObject({ + __bin_start: expect.any(Number), + __bin_end: expect.any(Number), + }); + const target = plan.resolve( + { gesture: 'hover', role: 'mark', hits: [hit] }, + { allHits: [hit], keyField: INTERACTION_KEY }, + ); + expect(target?.elements[0].records?.[0]).toMatchObject({ + __bin_start: bar.datum.__bin_start, + __bin_end: bar.datum.__bin_end, + }); + const enriched = enrichTargetWithSourceProvenance(target, plan); + const start = bar.datum.__bin_start as number; + const end = bar.datum.__bin_end as number; + const expectedRecords = sourceRecords + .filter((record) => record['Duration (min)'] >= start && record['Duration (min)'] < end) + .map((record) => ({ 'Duration (min)': record['Duration (min)'] })); + + expect(enriched?.elements[0].value).toEqual({ + field: 'Duration (min)', range: { start, end }, count: expectedRecords.length, + }); + expect(enriched?.elements[0].records).toEqual(expectedRecords); + view.finalize(); + }); + + it('keeps interactive Power BI histogram bins non-zero in width', async () => { + const spec = assembleVegaLite({ + data: { + values: [1.7, 1.9, 2.1, 2.4, 3.1, 3.4, 3.8, 4.2, 4.6].map((duration) => ({ + 'Duration (min)': duration, + })), + }, + semantic_types: { 'Duration (min)': 'Quantity' }, + chart_spec: { chartType: 'Histogram', encodings: { x: 'Duration (min)' } }, + theme_spec: 'powerbi', + } as never) as any; + const { compiled } = instrument(spec, [clickMark()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bars = sceneItems(view) + .filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(bars.length).toBeGreaterThan(1); + for (const bar of bars) { + expect(bar.bounds.x2).toBeGreaterThan(bar.bounds.x1); + } + view.finalize(); + }); + + it('inspect-x chooses one stacked category and returns all of its segments', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Region: 'West', Segment: 'Consumer', Value: 10 }, + { Region: 'West', Segment: 'Corporate', Value: 12 }, + { Region: 'East', Segment: 'Consumer', Value: 8 }, + { Region: 'East', Segment: 'Corporate', Value: 9 }, + ] }, + semantic_types: { Region: 'Category', Segment: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'Region', y: 'Value', color: 'Segment' }, + }, + } as never) as any; + const { compiled } = instrument(spec, [inspect({ mode: 'x' })]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + const west = bars.filter((item) => item.datum.Region === 'West'); + const east = bars.filter((item) => item.datum.Region === 'East'); + const westEdge = Math.max(...west.map((item) => item.bounds.x2)); + const eastEdge = Math.min(...east.map((item) => item.bounds.x1)); + const gapPoint = { x: westEdge + (eastEdge - westEdge) / 3, y: 0 }; + const hits = tolerantInspectHits( + bars, gapPoint, 'x', { x: '=' }, { x: Math.abs(eastEdge - westEdge), y: 0 }, + ); + + expect(new Set(hits.map((hit) => hit.datum.Region))).toEqual(new Set(['West'])); + expect(new Set(hits.map((hit) => hit.datum.Segment))).toEqual(new Set(['Consumer', 'Corporate'])); + view.finalize(); + }); + + it('index inspection can keep all or one named series', () => { + const item = (key: string, series: string, y: number) => ({ + bounds: { x1: 48, x2: 52, y1: y - 2, y2: y + 2 }, + datum: { [INTERACTION_KEY]: key, Series: series }, + mark: { marktype: 'symbol', role: 'mark' }, + }); + const items = [item('alpha', 'Alpha', 20), item('beta', 'Beta', 80)]; + const point = { x: 50, y: 76 }; + expect(indexInspectHits(items, point, 'x', { show: 'all', seriesBy: 'Series' })) + .toHaveLength(2); + expect(indexInspectHits(items, point, 'x', { + show: { series: 'Alpha' }, seriesBy: 'Series', + })[0].datum.Series).toBe('Alpha'); + }); + + it('interpolates a smooth continuous value-axis rule between line observations', () => { + const segment = { + bounds: { x1: 10, x2: 90, y1: 20, y2: 80 }, + datum: { [INTERACTION_KEY]: 'alpha', Index: 0, Series: 'Alpha' }, + endDatum: { [INTERACTION_KEY]: 'alpha', Index: 10, Series: 'Alpha' }, + interactionGeometry: { + kind: 'segment', + points: [{ x: 10, y: 80 }, { x: 90, y: 20 }], + }, + mark: { marktype: 'line', role: 'mark', items: [] }, + }; + const acquisition = indexInspectAcquisition( + [segment], { x: 50, y: 40 }, 'x', { show: 'all', seriesBy: 'Series' }, true, + ); + + expect(acquisition.coordinate).toBe(50); + expect(acquisition.valueCoordinates).toEqual([50]); + expect(indexInspectAcquisition( + [segment], { x: 90, y: 20 }, 'x', { show: 'all', seriesBy: 'Series' }, true, + )).toMatchObject({ coordinate: 90, valueCoordinates: [20], hits: [expect.any(Object)] }); + }); + + it('snaps a discrete index to supplied band-scale centers', () => { + const segment = { + bounds: { x1: 10, x2: 90, y1: 20, y2: 80 }, + datum: { [INTERACTION_KEY]: 'alpha', Series: 'Alpha' }, + endDatum: { [INTERACTION_KEY]: 'alpha', Series: 'Alpha' }, + interactionGeometry: { + kind: 'segment', + points: [{ x: 10, y: 80 }, { x: 90, y: 20 }], + }, + mark: { marktype: 'line', role: 'mark', items: [] }, + }; + const acquisition = indexInspectAcquisition( + [segment], { x: 38, y: 50 }, 'x', { show: 'all' }, false, [20, 60], + ); + + expect(acquisition.coordinate).toBe(20); + }); + + it('assists nearby continuous point indices without acquiring distant points', () => { + const pointMark = { + bounds: { x1: 48, x2: 52, y1: 28, y2: 32 }, + datum: { [INTERACTION_KEY]: 'alpha', Index: 50, Value: 30 }, + mark: { marktype: 'symbol', role: 'mark' }, + }; + const nearby = indexInspectAcquisition( + [pointMark], { x: 57, y: 80 }, 'x', { show: 'all' }, true, undefined, 5, + ); + const distant = indexInspectAcquisition( + [pointMark], { x: 58, y: 80 }, 'x', { show: 'all' }, true, undefined, 5, + ); + + expect(nearby).toMatchObject({ coordinate: 50, valueCoordinates: [30], hits: [expect.any(Object)] }); + expect(distant).toEqual({ coordinate: 58, valueCoordinates: [], hits: [] }); + }); + it('keeps path fallback connections anchored to the selected segment midpoint', () => { + const item = { + bounds: { x1: 56, y1: 52, x2: 196, y2: 220 }, + interactionGeometry: { + kind: 'segment', + points: [{ x: 56, y: 220 }, { x: 196, y: 52 }], + annotationPoints: [{ x: 56, y: 220 }, { x: 196, y: 52 }], + }, + }; + const plotCenter = { x: 126, y: 136 }; + + const midpoint = annotationConnectionPoint(item, 'segment-midpoint', [item], plotCenter); + const rightFallback = annotationConnectionPoint(item, 'right', [item], plotCenter); + + expect(midpoint.point).toEqual({ x: 126, y: 136 }); + expect(rightFallback.point).toEqual(midpoint.point); + expect(rightFallback.preferredAngle).toBe(0); + expect(annotationPrimaryAnchor( + item, + { left: 56, top: 52, width: 140, height: 168 }, + { left: 220, top: 120, width: 30, height: 20 }, + 'right', + rightFallback.point, + )).toEqual(midpoint.point); + }); + + it('uses a borderless spotlight for area hover', () => { + const hoverStyle = (resolvedEncodings: Record) => + areaChartDef.semanticInteractions!({ resolvedEncodings }).renderHoverStyles?.area; + + expect(hoverStyle({})).toEqual({ opacity: 'spotlight' }); + expect(hoverStyle({ opacity: { field: 'confidence', type: 'quantitative' } })) + .toEqual({ opacity: 'spotlight' }); + }); + + it('compiles navigation capabilities into resettable Vega domain signals', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: { field: 'x' }, y: { field: 'y' } }, + }, + semantic_types: { x: 'Number', y: 'Number' }, + data: { values: [{ x: 1, y: 2 }, { x: 3, y: 4 }] }, + }) as any; + const plan = addVegaLiteInteractions(spec, [navigate()]); + expect(plan?.navigationChannels).toEqual(['x', 'y']); + const compiled = compile(spec).spec as any; + const axes = injectVegaNavigationSignals(compiled, plan?.navigationChannels); + expect(axes).toMatchObject({ + x: { scale: 'x', signal: '__flint_navigation_x_domain', type: 'linear' }, + y: { scale: 'y', signal: '__flint_navigation_y_domain', type: 'linear' }, + }); + expect(compiled.scales.find((scale: any) => scale.name === 'x').domainRaw) + .toEqual({ signal: '__flint_navigation_x_domain' }); + expect(compiled.signals).toEqual(expect.arrayContaining([ + { name: '__flint_navigation_x_domain', value: null }, + { name: '__flint_navigation_y_domain', value: null }, + ])); + expect(compiled.marks.filter((mark: any) => mark.type === 'symbol')) + .toEqual(expect.arrayContaining([expect.objectContaining({ clip: true })])); + }); + + it('leaves reorder unwired when no reorder interaction is configured', () => { + const spec = assembleVegaLite({ + chart_spec: { chartType: 'Bar Chart', encodings: { x: { field: 'category' }, y: { field: 'value' } } }, + semantic_types: { category: 'Category', value: 'Number' }, + data: { values: [{ category: 'A', value: 1 }, { category: 'B', value: 2 }] }, + }) as any; + + const plan = addVegaLiteInteractions(spec, [clickMark()])!; + + expect(plan.reorderAxis).toBeUndefined(); + expect(plan.reorderAxes).toEqual([]); + }); + + it('resolves an axis scale renamed by a composed spec', () => { + const composed = { + scales: [{ name: 'concat_0_x', type: 'band' }, { name: 'concat_0_y', type: 'linear' }], + } as any; + + expect(injectVegaReorderSignal(composed, { axis: 'x', field: 'category' })).toEqual({ + axis: 'x', field: 'category', scale: 'concat_0_x', signal: '__flint_reorder_x_domain', + }); + expect(composed.scales[0].domainRaw).toEqual({ signal: '__flint_reorder_x_domain' }); + expect(injectVegaNavigationSignals(composed, ['y']).y) + .toEqual({ scale: 'concat_0_y', signal: '__flint_navigation_y_domain', type: 'linear' }); + + // An ambiguous multi-panel concat must not silently pick a panel. + expect(() => injectVegaReorderSignal( + { scales: [{ name: 'concat_0_x', type: 'band' }, { name: 'concat_1_x', type: 'band' }] } as any, + { axis: 'x', field: 'category' }, + )).toThrow(/discrete "x" scale/); + }); + + it.each([ + ['vertical', { x: { field: 'category' }, y: { field: 'value' } }, 'x'], + ['horizontal', { x: { field: 'value' }, y: { field: 'category' } }, 'y'], + ] as const)('compiles %s bar category reorder into a discrete domain signal', (_name, encodings, axis) => { + const spec = assembleVegaLite({ + chart_spec: { chartType: 'Bar Chart', encodings }, + semantic_types: { category: 'Category', value: 'Number' }, + data: { values: [{ category: 'A', value: 1 }, { category: 'B', value: 2 }] }, + }) as any; + const plan = addVegaLiteInteractions(spec, [dragReorder()])!; + expect(plan.reorderAxis).toMatchObject({ axis, field: 'category' }); + + const compiled = compile(spec).spec as any; + const reorderAxis = injectVegaReorderSignal(compiled, plan.reorderAxis); + expect(reorderAxis).toEqual({ + axis, field: 'category', scale: axis, signal: `__flint_reorder_${axis}_domain`, + }); + expect(compiled.scales.find((scale: any) => scale.name === axis).domainRaw) + .toEqual({ signal: `__flint_reorder_${axis}_domain` }); + }); + + it('leaves drag reorder inert without a template-declared category scale', () => { + expect(() => addVegaLiteInteractions({ mark: 'bar' }, [dragReorder()])) + .toThrow('requires chart interaction semantics'); + const scatter = assembleVegaLite({ + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: { field: 'x' }, y: { field: 'y' } }, + }, + semantic_types: { x: 'Number', y: 'Number' }, + data: { values: [{ x: 1, y: 2 }] }, + }) as any; + expect(addVegaLiteInteractions(scatter, [dragReorder()])?.reorderAxis).toBeUndefined(); + + const facetedSemantics = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + column: { field: 'region', type: 'nominal' }, + }, + }); + expect(facetedSemantics.reorderAxis).toBeUndefined(); + }); + + it('admits generic freeform drag without requiring a reorderable category scale', () => { + const scatter = assembleVegaLite({ + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: { field: 'x' }, y: { field: 'y' } }, + }, + semantic_types: { x: 'Number', y: 'Number' }, + data: { values: [{ x: 1, y: 2 }] }, + }) as any; + const interaction: CanvasInteractionDef = { + id: 'freeform-drag', + eventSource: dragTrigger(), + handle: () => null, + }; + + const plan = addVegaLiteInteractions(scatter, [interaction]); + expect(plan).toBeTruthy(); + expect(plan?.reorderAxis).toBeUndefined(); + }); + + it.each(['quantitative', 'temporal'] as const)('rejects %s Bar category reorder semantics', (type) => { + const semantics = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'category', type }, + y: { field: 'value', type: 'quantitative' }, + }, + }); + expect(semantics.reorderAxis).toBeUndefined(); + expect(semantics.reorderAxes).toBeUndefined(); + }); + + it('declares and injects independent categorical Heatmap row and column reorder axes', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Heatmap', + encodings: { x: { field: 'column' }, y: { field: 'row' }, color: { field: 'value' } }, + }, + semantic_types: { column: 'Category', row: 'Category', value: 'Number' }, + data: { values: [{ column: 'A', row: 'R1', value: 1 }] }, + }) as any; + const plan = addVegaLiteInteractions(spec, [dragReorder()])!; + expect(plan.reorderAxes).toEqual([ + { axis: 'x', field: 'column', scale: '', signal: '' }, + { axis: 'y', field: 'row', scale: '', signal: '' }, + ]); + + const compiled = compile(spec).spec as any; + const axes = plan.reorderAxes!.map((axis) => injectVegaReorderSignal(compiled, axis)); + expect(axes).toEqual([ + { axis: 'x', field: 'column', scale: 'x', signal: '__flint_reorder_x_domain' }, + { axis: 'y', field: 'row', scale: 'y', signal: '__flint_reorder_y_domain' }, + ]); + expect(compiled.scales.find((scale: any) => scale.name === 'x').domainRaw) + .toEqual({ signal: '__flint_reorder_x_domain' }); + expect(compiled.scales.find((scale: any) => scale.name === 'y').domainRaw) + .toEqual({ signal: '__flint_reorder_y_domain' }); + }); + + it.each(['Boxplot', 'Line Chart'])('declares authored nominal axes as reorderable for %s', (chartType) => { + const spec = assembleVegaLite({ + chart_spec: { + chartType, + encodings: { x: { field: 'category' }, y: { field: 'value' } }, + }, + semantic_types: { category: 'Category', value: 'Number' }, + data: { values: [ + { category: 'A', value: 1 }, + { category: 'B', value: 2 }, + ] }, + }) as any; + expect(spec._interactionSemantics.reorderAxes).toEqual([{ axis: 'x', field: 'category' }]); + const plan = addVegaLiteInteractions(spec, [dragReorder()])!; + expect(plan.reorderAxes).toEqual([{ axis: 'x', field: 'category', scale: '', signal: '' }]); + }); + + it('does not declare reorder for a path-only Range Area category axis', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Range Area Chart', + encodings: { + x: { field: 'month' }, + y: { field: 'low' }, + y2: { field: 'high' }, + }, + }, + semantic_types: { month: 'Category', low: 'Number', high: 'Number' }, + data: { values: [{ month: 'Jan', low: 1, high: 3 }, { month: 'Feb', low: 2, high: 4 }] }, + }) as any; + expect(spec._interactionSemantics.reorderAxes).toEqual([]); + expect(addVegaLiteInteractions(spec, [dragReorder()])?.reorderAxis).toBeUndefined(); + }); + + it('distinguishes dumbbell connectors from stationary Slope stems during reorder preview', () => { + const input = (chartType: string) => assembleVegaLite({ + chart_spec: { + chartType, + encodings: { + x: { field: 'period' }, + y: { field: 'value' }, + color: { field: 'series' }, + }, + }, + semantic_types: { period: 'Category', value: 'Number', series: 'Category' }, + data: { values: [ + { period: 'A', value: 1, series: 'one' }, + { period: 'B', value: 2, series: 'one' }, + ] }, + }) as any; + + expect(input('Ranged Dot Plot')._interactionSemantics.reorderAxes) + .toEqual([{ axis: 'x', field: 'period', includeConnectiveMarks: true }]); + expect(input('Slope Chart')._interactionSemantics.reorderAxes) + .toEqual([{ axis: 'x', field: 'period' }]); + }); + + it('moves only Waterfall bars during reorder preview', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { x: { field: 'step' }, y: { field: 'amount' } }, + }, + semantic_types: { step: 'Category', amount: 'Number' }, + data: { values: [ + { step: 'Revenue', amount: 100 }, + { step: 'Costs', amount: -40 }, + ] }, + }) as any; + + expect(spec._interactionSemantics.reorderAxes).toEqual([ + { axis: 'x', field: 'step', markTypes: ['rect'] }, + ]); + }); + + it('rejects built-in interactions when a chart has no semantic contract', () => { + expect(() => addVegaLiteInteractions({ mark: 'line' }, [clickMark()])) + .toThrow('requires chart interaction semantics'); + expect(() => addVegaLiteInteractions({ + mark: 'line', + _interactionSemantics: { fields: [], selectableMarks: [], navigationAxes: ['x'] }, + }, [clickMark()])).toThrow('requires chart element semantics'); + }); + + it('instruments semantic targets for external interactions without adding canvas gestures', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'category' }, y: { field: 'value' } }, + }, + semantic_types: { category: 'Category', value: 'Number' }, + data: { values: [{ category: 'A', value: 2 }] }, + }) as any; + const plan = addVegaLiteInteractions(spec, [externalInteraction<{ category: string }>({ + id: 'category-picker', + handle: ({ category }) => ({ + id: 'category-picker', + ops: [{ + op: 'set-style', + targets: [{ select: { key: { category } } }], + value: { state: 'emphasized' }, + }], + }), + })]); + + expect(plan).not.toBeNull(); + expect(spec.transform).toEqual(expect.arrayContaining([ + expect.objectContaining({ as: INTERACTION_KEY }), + ])); + }); + + it('treats a Bump legend series as one target owning its line and points', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Games: 2012, Country: 'China', Rank: 2 }, + { Games: 2016, Country: 'China', Rank: 3 }, + { Games: 2020, Country: 'China', Rank: 2 }, + { Games: 2024, Country: 'China', Rank: 2 }, + { Games: 2012, Country: 'Japan', Rank: 6 }, + { Games: 2024, Country: 'Japan', Rank: 3 }, + ] }, + semantic_types: { Games: 'Year', Country: 'Country', Rank: 'Rank' }, + chart_spec: { + chartType: 'Bump Chart', + encodings: { x: 'Games', y: 'Rank', color: 'Country' }, + }, + theme_spec: 'nyt', + } as never) as any; + const { plan, compiled } = instrument(spec, [clickMark()]); + if (!plan?.resolve) throw new Error('Expected an instrumented Bump plan'); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const hits = sceneItems(view).map(renderHit).filter((hit): hit is RenderHit => hit !== null); + const legend = { + channel: 'color', field: 'Country', + domain: { kind: 'value' as const, value: 'China' }, + }; + const resolved = enrichTargetWithSourceProvenance(plan.resolve({ + gesture: 'click', role: 'legend-item', hits: [], legend, + }, { allHits: hits, keyField: INTERACTION_KEY, seriesField: 'Country' }), plan); + const target = resolvedLegendInteractionTarget(legend, resolved); + const keys = semanticElementRenderKeys(target.elements[0]); + + expect(target.elements).toHaveLength(1); + expect(target.elements[0].records).toHaveLength(4); + const renderedLineKeys = hits + .filter((hit) => hit.markType === 'line' && hit.datum.Country === 'China') + .map((hit) => hit.datum[INTERACTION_KEY]); + expect(keys.filter((key) => key.endsWith(PATH_KEY_SUFFIX))).toEqual(renderedLineKeys); + expect(keys.filter((key) => !key.endsWith(PATH_KEY_SUFFIX))).toHaveLength(4); + view.finalize(); + }); + + it('instruments semantic updates without passing external definitions to the renderer', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'category' }, y: { field: 'value' } }, + }, + semantic_types: { category: 'Category', value: 'Number' }, + data: { values: [{ category: 'A', value: 2 }] }, + }) as any; + + const plan = addVegaLiteInteractions(spec, [], true); + expect(plan).not.toBeNull(); + expect(plan?.resolve).toBeTypeOf('function'); + expect(plan?.semanticStores).toBe(true); + expect(spec.transform).toEqual(expect.arrayContaining([ + expect.objectContaining({ as: INTERACTION_KEY }), + ])); + }); + + it('clips every generated layer when navigation is combined with semantic interaction', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Line Chart', + encodings: { x: { field: 'x' }, y: { field: 'y' } }, + chartProperties: { showPoints: true }, + }, + semantic_types: { x: 'Date', y: 'Number' }, + data: { values: [{ x: '2025-01-01', y: 2 }, { x: '2025-02-01', y: 4 }] }, + }) as any; + addVegaLiteInteractions(spec, [navigate({ pan: false }), clickMark()]); + + const marks = (compile(spec).spec as any).marks; + const dataMarks = marks.filter((mark: any) => ['line', 'symbol'].includes(mark.type)); + expect(dataMarks) + .toEqual(expect.arrayContaining([expect.objectContaining({ clip: true })])); + expect(dataMarks.map((mark: any) => mark.type)).toEqual(expect.arrayContaining(['line', 'symbol'])); + expect(dataMarks).toSatisfy((compiledMarks: any[]) => ( + compiledMarks.every((mark) => mark.clip === true) + )); + }); + + it('zooms and resets an actual Vega scale through its domain signal', async () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: { field: 'x' }, y: { field: 'y' } }, + }, + semantic_types: { x: 'Number', y: 'Number' }, + data: { values: [{ x: 0, y: 0 }, { x: 100, y: 100 }] }, + }) as any; + const plan = addVegaLiteInteractions(spec, [navigate()])!; + const compiled = compile(spec).spec as any; + const axes = injectVegaNavigationSignals(compiled, plan.navigationChannels); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const initial = view.scale('x').domain().map(Number); + const controller = createVegaNavigationController(view, axes); + + const guard = { minVisibleFraction: 0.02, maxVisibleFraction: 1, overscrollFraction: 0 }; + const zoom = controller.resolve({ + type: 'navigation', phase: 'commit', operation: 'zoom', axes: 'x', + factor: 2, anchor: { x: 0.5, y: 0.5 }, + }, guard); + expect(zoom).not.toBeNull(); + controller.apply(zoom!); + await view.runAsync(); + const zoomed = view.scale('x').domain().map(Number); + expect(zoomed[1] - zoomed[0]).toBeCloseTo((initial[1] - initial[0]) / 2); + + const reset = controller.resolve({ + type: 'navigation', phase: 'commit', operation: 'reset', axes: 'x', + }, guard); + controller.apply(reset!); + await view.runAsync(); + expect(view.scale('x').domain().map(Number)).toEqual(initial); + view.finalize(); + }); + + it('declares proportional line focus and continuous-color region boundaries', () => { + expect(lineChartDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'Year', type: 'ordinal' }, y: { field: 'Value', type: 'quantitative' } }, + }).renderSelectionStyles).toEqual({ line: { strokeWidthMultiplier: 1.2 } }); + + expect(heatmapDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'Year', type: 'ordinal' }, y: { field: 'Country', type: 'nominal' }, color: { field: 'Value', type: 'quantitative' } }, + }).renderSelectionStyles).toEqual({ rect: { boundary: 'contiguous-region' } }); + expect(heatmapDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'X', type: 'ordinal' }, y: { field: 'Y', type: 'nominal' }, color: { field: 'Group', type: 'nominal' } }, + }).renderSelectionStyles).toBeUndefined(); + }); + + it('resolves continuous-color selection boundaries from the active theme', () => { + const makeSpec = (theme_spec: any) => assembleVegaLite({ + data: { values: [ + { Year: '2020', Country: 'A', Value: 10 }, + { Year: '2021', Country: 'A', Value: 14 }, + ] }, + semantic_types: { Year: 'Category', Country: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Heatmap', + encodings: { x: 'Year', y: 'Country', color: 'Value' }, + }, + theme_spec, + } as any) as any; + + expect(makeSpec('economist')._interactionSemantics.selectionBoundary).toEqual({ + color: '#e3120b', + width: 1.25, + opacity: 0.68, + haloColor: '#ffffff', + haloWidth: 2.5, + haloOpacity: 0.35, + }); + expect(makeSpec({ + extends: 'economist', + interaction: { + selectionBoundary: { + color: '#b54a20', + width: 2, + opacity: 0.9, + haloColor: '#fffaf2', + haloWidth: 0, + haloOpacity: 0.7, + }, + }, + })._interactionSemantics.selectionBoundary).toEqual({ + color: '#b54a20', + width: 2, + opacity: 0.9, + haloColor: '#fffaf2', + haloWidth: 0, + haloOpacity: 0.7, + }); + }); + + it('merges selected heatmap cells by contiguous region without bridging gaps', () => { + expect(mergeContiguousSelectionBounds([ + { x1: 0, y1: 0, x2: 10, y2: 10 }, + { x1: 11, y1: 0, x2: 21, y2: 10 }, + { x1: 0, y1: 11, x2: 10, y2: 21 }, + { x1: 0, y1: 30, x2: 10, y2: 40 }, + ])).toEqual([ + { x1: 0, y1: 0, x2: 21, y2: 21 }, + { x1: 0, y1: 30, x2: 10, y2: 40 }, + ]); + }); + + it('traces an irregular heatmap selection without boxing in unselected cells', () => { + const segments = selectionBoundarySegments([ + { x1: 0, y1: 0, x2: 10, y2: 10 }, + { x1: 10, y1: 0, x2: 20, y2: 10 }, + { x1: 0, y1: 10, x2: 10, y2: 20 }, + ]); + + expect(segments).toHaveLength(8); + expect(segments).not.toContainEqual({ x1: 20, y1: 10, x2: 20, y2: 20 }); + expect(segments).toContainEqual({ x1: 10, y1: 10, x2: 10, y2: 20 }); + expect(segments).toContainEqual({ x1: 10, y1: 10, x2: 20, y2: 10 }); + }); + + it('keeps themed line vertices filled when expanding them for interaction', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { Year: '2020', Country: 'A', Value: 10 }, + { Year: '2021', Country: 'A', Value: 14 }, + { Year: '2020', Country: 'B', Value: 13 }, + { Year: '2021', Country: 'B', Value: 11 }, + ], + }, + semantic_types: { Year: 'Category', Country: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'Year', y: 'Value', color: 'Country' }, + chartProperties: { showPoints: true }, + }, + theme_spec: THEME_PRESETS.economist.spec, + } as any) as any; + + addVegaLiteInteractions(spec, [clickMark()]); + const findPointMark = (node: any): any => { + if (node.mark?.type === 'point') return node.mark; + for (const property of ['layer', 'hconcat', 'vconcat', 'concat']) { + for (const child of node[property] ?? []) { + const found = findPointMark(child); + if (found) return found; + } + } + return undefined; + }; + expect(findPointMark(spec)).toMatchObject({ + type: 'point', + filled: true, + stroke: '#ffffff', + }); + }); + + it('keeps concatenated-chart hover paint geometry invariant', () => { + const renderHoverStyles = (definition: typeof pyramidChartDef) => + definition.semanticInteractions?.({ resolvedEncodings: {} }).renderHoverStyles; + expect(renderHoverStyles(pyramidChartDef)).toEqual({ rect: { opacity: 'contrast' } }); + expect(renderHoverStyles(barTableDef)).toEqual({ rect: { opacity: 'contrast' } }); + }); + + it('updates arc opacity in a composed Rose chart', async () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Direction'], + categoryField: 'Direction', + selectableMarks: ['arc'], + }, + data: { + values: [ + { Direction: 'N', Speed: 12 }, + { Direction: 'E', Speed: 20 }, + { Direction: 'S', Speed: 8 }, + { Direction: 'W', Speed: 16 }, + ], + }, + encoding: { + theta: { field: 'Direction', type: 'nominal', stack: true }, + }, + layer: [ + { + mark: { type: 'arc', stroke: 'white' }, + encoding: { + radius: { field: 'Speed', type: 'quantitative', scale: { type: 'sqrt' } }, + color: { field: 'Direction', type: 'nominal' }, + }, + }, + { + mark: { type: 'text', radiusOffset: 15 }, + encoding: { text: { field: 'Direction', type: 'nominal' } }, + }, + ], + }; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const before = sceneItems(view).filter((item) => item.mark.marktype === 'arc'); + const selectedKey = before[0]?.datum[INTERACTION_KEY]; + expect(selectedKey).toBeTypeOf('string'); + + view.change(INTERACTION_STORE, changeset().insert([{ key: selectedKey }])); + await view.runAsync(); + const opacities = sceneItems(view) + .filter((item) => item.mark.marktype === 'arc') + .map((item) => ({ key: item.datum[INTERACTION_KEY], opacity: item.opacity })); + + expect(opacities.filter((item) => item.opacity === 1).map((item) => item.key)).toEqual([selectedKey]); + expect(opacities.filter((item) => item.key !== selectedKey).every((item) => item.opacity === 0.25)).toBe(true); + }); + + it('maps a synthesized Rose color legend back to its category field', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { Direction: 'N', Speed: 12 }, + { Direction: 'E', Speed: 20 }, + ], + }, + semantic_types: { Direction: 'Category', Speed: 'Quantity' }, + chart_spec: { + chartType: 'Rose Chart', + encodings: { x: { field: 'Direction' }, y: { field: 'Speed' } }, + }, + } as any) as any; + + expect(spec._interactionSemantics).toMatchObject({ + fields: ['Direction'], + categoryField: 'Direction', + legendFields: { color: 'Direction' }, + }); + }); + + it('resolves a Rose category label to its label and all related arc segments', () => { + const resolve = roseChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Month', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + }, + }).resolve!; + const label = { + datum: { [INTERACTION_KEY]: 'Jan', Month: 'Jan', [INTERACTION_ROLE]: 'text-label' }, + source: 'mark' as const, + markType: 'text', + layerRole: 'text-label', + }; + const janA = { datum: { [INTERACTION_KEY]: 'Jan|A', Month: 'Jan', Segment: 'A' }, source: 'mark' as const }; + const janB = { datum: { [INTERACTION_KEY]: 'Jan|B', Month: 'Jan', Segment: 'B' }, source: 'mark' as const }; + const febA = { datum: { [INTERACTION_KEY]: 'Feb|A', Month: 'Feb', Segment: 'A' }, source: 'mark' as const }; + + const target = resolve( + { gesture: 'click', role: 'text-label', hits: [label] }, + { + allHits: [janA, janB, febA, label], + keyField: INTERACTION_KEY, + categoryField: 'Month', + seriesField: 'Segment', + }, + ); + + expect(target?.visual).toEqual({ kind: 'mark', role: 'text-label' }); + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual([ + 'Jan|A', + 'Jan|B', + 'Jan', + ]); + }); + + it('resolves a stacked-area series-end label to the whole band', async () => { + const rows = [1950, 1970, 1990, 2010, 2020].flatMap((Year, yearIndex) => + Object.entries({ Asia: 4641, Africa: 1361, Europe: 748, Americas: 1023, Oceania: 45 }) + .map(([Region, finalValue]) => ({ + Year, + Region, + Population: Math.round(finalValue * (0.6 + yearIndex * 0.1)), + }))); + const spec = assembleVegaLite({ + data: { values: rows }, + semantic_types: { Year: 'Year', Region: 'Category', Population: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'Year', y: 'Population', color: 'Region' }, + chartProperties: { stackMode: 'stack' }, + }, + theme_spec: { + id: 'series-end-test', + label: 'Series end test', + ink: { + surface: { canvas: '#fff', plot: '#fff' }, + text: { primary: '#111' }, + series: { single: '#111', categorical: ['#011827', '#2251ff', '#00a9f4', '#00c7b1', '#9ca8b3'] }, + }, + legend: { show: 'always', placement: ['seriesEnd', 'right'] }, + }, + } as any) as any; + const resolve = spec._interactionSemantics.resolve; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const scene = allSceneItems(view); + const labelItem = scene.find((item) => + item.mark?.marktype === 'text' && item.datum?.Region === 'Africa' + && item.datum?.[INTERACTION_ROLE] === 'text-label'); + const labelHit = renderHit(labelItem)!; + const allHits = sceneItems(view).map(renderHit).filter(Boolean); + const target = resolve({ + gesture: 'click', role: 'text-label', hits: [labelHit], + }, { + allHits, + keyField: INTERACTION_KEY, + categoryField: 'Year', + seriesField: 'Region', + }); + + expect(labelHit.datum[INTERACTION_KEY]).toBe('Africa'); + expect(target?.visual).toEqual({ kind: 'path', role: 'text-label' }); + expect(target?.elements).toHaveLength(4); + expect(target?.elements.every((element: SemanticElement) => element.value.Region === 'Africa')).toBe(true); + }); + + it('formats a Rose sector from its encoded category and value fields', () => { + const semantics = roseChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Month', type: 'nominal' }, + y: { field: 'Rainfall (mm)', type: 'quantitative' }, + }, + }); + const element = { + value: { [INTERACTION_KEY]: 'Jan' }, + records: [{ + Month: 'Jan', + 'Rainfall (mm)': 140, + 'Rainfall (mm)_start': 0, + 'Rainfall (mm)_end': 140, + }], + }; + const update = semantics.presentUpdate!( + annotationUpdate(element, { kind: 'mark', role: 'polar-bar' }), + { chartType: 'Rose Chart', selected: [], categoryField: 'Month' }, + ); + + expect(update.ops[0]).toMatchObject({ value: { text: '140' } }); + expect((update.ops[0] as any).value.candidates).toEqual([ + { connection: 'outer-radial', priority: 0 }, + ]); + }); + + it('formats Bar Table and Bullet annotations from their authored measures', async () => { + const barTable = barTableDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'GDP ($T)', type: 'quantitative' }, + y: { field: 'Country', type: 'nominal' }, + }, + }); + const bullet = bulletChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Share', type: 'quantitative' }, + y: { field: 'Country', type: 'nominal' }, + goal: { field: 'Target', type: 'quantitative' }, + }, + }); + const barTableElement = { + value: { [INTERACTION_KEY]: 'China' }, + records: [{ Country: 'China', 'GDP ($T)': 17.8, period_end: 0 }], + }; + const bulletElement = { + value: { [INTERACTION_KEY]: 'Germany' }, + records: [{ Country: 'Germany', Share: 51.6, Target: 80 }], + }; + + expect(barTable.presentUpdate!( + annotationUpdate(barTableElement), + { chartType: 'Bar Table', selected: [], categoryField: 'Country' }, + ).ops[0]).toMatchObject({ value: { text: '17.8' } }); + const bulletUpdate = bullet.presentUpdate!( + annotationUpdate(bulletElement), + { chartType: 'Bullet Chart', selected: [], categoryField: 'Country' }, + ); + expect(bulletUpdate.ops[0]).toMatchObject({ + value: { + text: 'Actual: 51.6\nExpected: 80', + candidates: expect.arrayContaining([expect.objectContaining({ + connectorAnchors: [ + { role: 'bullet-actual', connection: 'value-end', valueAxis: 'x' }, + { role: 'bullet-expected', connection: 'center' }, + ], + })]), + }, + }); + + const bulletSpec = assembleVegaLite({ + data: { values: [{ Country: 'Germany', Share: 51.6, Target: 80 }] }, + semantic_types: { Country: 'Country', Share: 'Quantity', Target: 'Quantity' }, + chart_spec: { + chartType: 'Bullet Chart', + encodings: { y: 'Country', x: 'Share', goal: 'Target' }, + }, + } as never) as any; + const { compiled } = instrument(bulletSpec, [clickAnnotate()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const roles = sceneItems(view) + .filter((item) => item.datum.Country === 'Germany' && item.datum[INTERACTION_ROLE]) + .map((item) => item.datum[INTERACTION_ROLE]); + + expect(new Set(roles)).toEqual(new Set(['bullet-actual', 'bullet-expected'])); + }); + + it('resolves Bullet Chart goal-attainment legend keys to their bars', () => { + const semantics = bulletChartDef.semanticInteractions!({ + resolvedEncodings: { + y: { field: 'Country', type: 'nominal' }, + x: { field: 'Share', type: 'quantitative' }, + goal: { field: 'Target', type: 'quantitative' }, + }, + } as any); + const below = { + datum: { Country: 'Norway', Share: 98, Target: 100, __status: 'Below target' }, + markType: 'bar', + source: 'mark' as const, + }; + const met = { + datum: { Country: 'Brazil', Share: 90, Target: 80, __status: 'Meets target' }, + markType: 'bar', + source: 'mark' as const, + }; + + expect(semantics.legendFields).toEqual({ color: '__status' }); + expect(semantics.resolve({ + gesture: 'click', + role: 'legend-item', + hits: [{ datum: { value: 'Below target' }, source: 'legend-item' }], + legend: { field: '__status', domain: { kind: 'value', value: 'Below target' } }, + }, { + keyField: 'Country', + allHits: [below, met], + } as any)?.elements).toHaveLength(1); + + const unmatched = semantics.resolve({ + gesture: 'click', + role: 'legend-item', + hits: [{ datum: { value: 'Meets target' }, source: 'legend-item' }], + legend: { field: '__status', domain: { kind: 'value', value: 'Meets target' } }, + }, { + keyField: 'Country', + allHits: [below], + } as any); + expect(unmatched).toBeNull(); + expect(legendSemanticTarget({ + channel: 'color', field: '__status', value: 'Meets target', + domain: { kind: 'value', value: 'Meets target' }, + })).toEqual({ + visual: { kind: 'legend', role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', + field: '__status', + domain: { kind: 'value', value: 'Meets target' }, + }, + }], + }); + expect(legendSemanticTarget({ + channel: 'color', field: '__status', value: 'Below target', + domain: { kind: 'value', value: 'Below target' }, + })).toEqual({ + visual: { kind: 'legend', role: 'legend-item' }, + elements: [{ + value: { + channel: 'color', field: '__status', + domain: { kind: 'value', value: 'Below target' }, + }, + }], + }); + }); + + it('presents Choropleth regions and Density segments with semantic values', () => { + const choropleth = choroplethDef.semanticInteractions!({ + resolvedEncodings: { + id: { field: 'State', type: 'nominal' }, + color: { field: 'Value', type: 'quantitative' }, + }, + }); + const density = densityPlotDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'Score', type: 'quantitative' } }, + }); + + const regionUpdate = choropleth.presentUpdate!( + annotationUpdate( + { value: { [INTERACTION_KEY]: '35' }, records: [{ State: 'New Mexico', Value: 35 }] }, + { kind: 'region', role: 'geographic-region' }, + ), + { chartType: 'Choropleth', selected: [], categoryField: 'State' }, + ); + const densityUpdate = density.presentUpdate!( + annotationUpdate({ + value: { [INTERACTION_KEY]: 'segment' }, + records: [{ value: 72.5, density: 0.33 }, { value: 75, density: 0.32 }], + }, { kind: 'path', role: 'area' }), + { chartType: 'Density Plot', selected: [] }, + ); + + expect(regionUpdate.ops[0]).toMatchObject({ + value: { text: 'New Mexico: 35', candidates: [{ connection: 'center' }] }, + }); + expect(densityUpdate.ops[0]).toMatchObject({ + value: { text: '72.5: 0.33', candidates: [{ connection: 'segment-midpoint' }] }, + }); + }); + + it('presents transformed ECDF, Calendar, and Violin values', () => { + const ecdf = ecdfPlotDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'Score', type: 'quantitative' } }, + }); + const calendar = vlCalendarHeatmapDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Day', type: 'temporal' }, + color: { field: 'Commits', type: 'quantitative' }, + }, + }); + const violin = violinPlotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Species', type: 'nominal' }, + y: { field: 'Length', type: 'quantitative' }, + }, + }); + + const ecdfUpdate = ecdf.presentUpdate!( + annotationUpdate({ value: { [INTERACTION_KEY]: 'step' }, records: [{ Score: 42 }, { Score: 44 }] }), + { chartType: 'ECDF Plot', selected: [] }, + ); + const calendarUpdate = calendar.presentUpdate!( + annotationUpdate({ + value: { + [INTERACTION_KEY]: 'day', + __flintCalendarDate: Date.UTC(2026, 7, 27), + sum_Commits: 12, + }, + }), + { chartType: 'Calendar Heatmap', selected: [] }, + ); + const violinUpdate = violin.presentUpdate!( + annotationUpdate({ + value: { [INTERACTION_KEY]: 'curve' }, + records: [{ Species: 'Setosa', Length: 5.1, density: 0.4 }], + }), + { chartType: 'Violin Plot', selected: [] }, + ); + + expect(ecdfUpdate.ops[0]).toMatchObject({ value: { text: '42' } }); + const calendarDate = new Intl.DateTimeFormat(undefined, { timeZone: 'UTC' }) + .format(new Date(Date.UTC(2026, 7, 27))); + expect(calendarUpdate.ops[0]).toMatchObject({ value: { text: `${calendarDate}: 12` } }); + expect(violinUpdate.ops[0]).toMatchObject({ value: { text: 'Setosa: 5.1' } }); + }); + + it('uses one local hover rule across color semantics', () => { + const makeSpec = (colorSemanticType: 'Category' | 'Quantity') => assembleVegaLite({ + data: { values: [ + { Region: 'North', Sales: 10, Color: colorSemanticType === 'Category' ? 'Retail' : 0.2 }, + { Region: 'South', Sales: 14, Color: colorSemanticType === 'Category' ? 'Enterprise' : 0.8 }, + ] }, + semantic_types: { Region: 'Category', Sales: 'Quantity', Color: colorSemanticType }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: { field: 'Region' }, y: { field: 'Sales' }, color: { field: 'Color' } }, + }, + } as any) as any; + + expect(makeSpec('Category')._interactionSemantics.renderHoverStyles).toEqual({ + rect: { opacity: 'contrast' }, + }); + expect(makeSpec('Quantity')._interactionSemantics.renderHoverStyles).toEqual({ + rect: { opacity: 'contrast' }, + }); + }); + + it('uses an outline when a bar opacity channel is data-encoded', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Region: 'North', Sales: 10, Confidence: 0.4 }, + { Region: 'South', Sales: 14, Confidence: 0.8 }, + ] }, + semantic_types: { Region: 'Category', Sales: 'Quantity', Confidence: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Region', y: 'Sales', opacity: 'Confidence' }, + }, + } as any) as any; + + expect(spec._interactionSemantics.renderHoverStyles).toEqual({ + rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 1.5 }, + }); + }); + + it('makes generated legend marks physical click targets', () => { + const spec = { + data: { values: [{ X: 1, Y: 2, Color: 'Blue' }] }, + mark: 'point', + encoding: { + x: { field: 'X', type: 'quantitative' }, + y: { field: 'Y', type: 'quantitative' }, + color: { field: 'Color', type: 'nominal' }, + }, + _interactionSemantics: { + fields: ['X', 'Y', 'Color'], + seriesField: 'Color', + legendFields: { color: 'Color' }, + selectableMarks: ['point'], + }, + }; + const { compiled } = instrument(spec); + expect(compiled.legends.length).toBeGreaterThan(0); + for (const legend of compiled.legends) { + expect(legend.encode.gradient.interactive).toBe(true); + expect(legend.encode.gradient.update.cursor).toBeUndefined(); + expect(legend.encode.symbols.interactive).toBe(true); + expect(legend.encode.symbols.update.cursor).toBeUndefined(); + expect(legend.encode.labels.interactive).toBe(true); + expect(legend.encode.labels.update.cursor).toBeUndefined(); + } + }); + + it('leaves mark cursors to runtime affordance resolution', () => { + const spec = { + data: { values: [{ X: 1, Y: 2 }] }, + mark: 'point', + encoding: { + x: { field: 'X', type: 'quantitative' }, + y: { field: 'Y', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['X', 'Y'], + selectableMarks: ['point'], + }, + }; + const clickable = instrument(structuredClone(spec)).compiled; + const selectable = instrument(structuredClone(spec), [select()]).compiled; + const symbolMark = (compiled: Record) => compiled.marks + .flatMap((mark: Record) => mark.marks ?? [mark]) + .find((mark: Record) => mark.type === 'symbol'); + + expect(symbolMark(clickable).encode.update.cursor).toBeUndefined(); + expect(symbolMark(selectable).encode.update.cursor).toBeUndefined(); + }); + + it('compiles template hover paint into native mark encodings', async () => { + for (const [mark, renderMark, width] of [['rect', 'rect', 1.5], ['circle', 'symbol', 2]] as const) { + const spec: Record = { + data: { values: [{ X: 'A', Y: 2 }] }, + mark, + encoding: { + x: { field: 'X', type: 'nominal' }, + y: { field: 'Y', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['X', 'Y'], + selectableMarks: [mark], + renderHoverStyles: { [renderMark]: { stroke: '#59636d', strokeWidth: width } }, + }, + }; + + const plan = addVegaLiteInteractions(spec, [clickMark()]); + const compiled = compile(spec as any).spec as Record; + injectVegaInteractionStore(compiled, plan ?? undefined); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const item = sceneItems(view).find((candidate) => candidate.datum[INTERACTION_KEY]); + const key = item?.datum[INTERACTION_KEY]; + view.change(HOVER_STORE, changeset().insert([{ key }])); + await view.runAsync(); + const hovered = sceneItems(view).find((candidate) => candidate.datum[INTERACTION_KEY] === key); + + expect(hovered?.stroke).toBe('#59636d'); + expect(hovered?.strokeWidth).toBe(width); + } + }); + + it('slightly dims the owning area path behind the hovered slice', async () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Area Chart', + encodings: { x: { field: 'Year' }, y: { field: 'Value' } }, + }, + semantic_types: { Year: 'Date', Value: 'Number' }, + data: { values: [ + { Year: '2024-01-01', Value: 2 }, + { Year: '2024-02-01', Value: 4 }, + ] }, + }) as any; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const area = sceneItems(view).find((candidate) => candidate.mark?.marktype === 'area'); + const key = area?.datum[INTERACTION_KEY]; + + view.change(HOVER_STORE, changeset().insert([{ key: `${key}${PATH_KEY_SUFFIX}` }])); + await view.runAsync(); + const hovered = sceneItems(view).find((candidate) => candidate.mark?.marktype === 'area'); + + expect(hovered?.opacity).toBe(0.9); + expect(hovered?.stroke).toBeUndefined(); + + view.change(INTERACTION_STORE, changeset().insert([{ key }])); + await view.runAsync(); + const hoveredWhileSelected = sceneItems(view).find((candidate) => candidate.mark?.marktype === 'area'); + + expect(hoveredWhileSelected?.opacity).toBe(0.25); + }); + + it('uses one area segment for its highlight, endpoint text, and annotation boundary', async () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Area Chart', + encodings: { x: { field: 'Year' }, y: { field: 'Users' } }, + }, + semantic_types: { Year: 'Date', Users: 'Number' }, + data: { values: [ + { Year: '2018-01-01', Users: 51 }, + { Year: '2020-01-01', Users: 60 }, + { Year: '2022-01-01', Users: 67 }, + ] }, + }) as any; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const areaSegments = sceneItems(view).filter((candidate) => candidate.mark?.marktype === 'area'); + const finalSegment = areaSegments.find((candidate) => + candidate.interactionGeometry.endDatum?.Users === 67); + const hit = renderHit(finalSegment)!; + const semantics = areaChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Year', type: 'temporal' }, + y: { field: 'Users', type: 'quantitative' }, + }, + }); + const target = semantics.resolve( + { gesture: 'click', role: 'mark', hits: [hit] }, + { allHits: [hit], keyField: INTERACTION_KEY }, + )!; + + expect(areaSegments).toHaveLength(2); + expect(finalSegment.interactionGeometry.annotationPoints).toEqual( + finalSegment.interactionGeometry.points.slice(0, 2), + ); + expect(annotationBounds(finalSegment).y2).toBeLessThan(finalSegment.bounds.y2); + expect(target.elements[0].records?.map((record) => record.Users)).toEqual([60, 67]); + expect(semantics.presentUpdate!( + annotationUpdate(target.elements[0]), + { chartType: 'Area Chart', selected: [] }, + ).ops[0]).toMatchObject({ + value: { + text: '60 → 67', + candidates: [{ connection: 'segment-midpoint', priority: 0 }], + }, + }); + }); + + it('adds a light hover fill only to shape-only scatter points', () => { + const hoverStyle = (resolvedEncodings: Record) => + scatterPlotDef.semanticInteractions!({ resolvedEncodings }).renderHoverStyles?.symbol; + + expect(hoverStyle({ shape: { field: 'Shape', type: 'nominal' } })).toMatchObject({ + fill: MUTED_HOVER_FILL, + }); + expect(hoverStyle({ + shape: { field: 'Shape', type: 'nominal' }, + color: { field: 'Color', type: 'nominal' }, + })).not.toHaveProperty('fill'); + }); + + it('declares scatter detail encodings as semantic selector fields', () => { + const semantics = scatterPlotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'GDP', type: 'quantitative' }, + y: { field: 'Life', type: 'quantitative' }, + detail: { field: 'Country', type: 'nominal' }, + }, + }); + + expect(semantics.fields).toContain('Country'); + }); + + it('applies one lollipop hover key to both stem and point layers', async () => { + const spec: Record = { + data: { values: [{ Category: 'A', Value: 2 }] }, + layer: [ + { mark: { type: 'rule', strokeWidth: 1.5 }, encoding: {} }, + { mark: { type: 'circle', size: 80 }, encoding: {} }, + ], + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['Category', 'Value'], + selectableMarks: ['rule', 'circle'], + renderHoverStyles: { + rule: { stroke: '#59636d' }, + symbol: { stroke: '#59636d', strokeWidth: 2 }, + }, + }, + }; + + const plan = addVegaLiteInteractions(spec, [clickMark()]); + + expect(spec.layer[0].encoding.detail.field).toBe(INTERACTION_KEY); + expect(spec.layer[1].encoding.detail.field).toBe(INTERACTION_KEY); + + const compiled = compile(spec as any).spec as Record; + injectVegaInteractionStore(compiled, plan ?? undefined); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const key = sceneItems(view).find((item) => item.mark.marktype === 'symbol')?.datum[INTERACTION_KEY]; + view.change(HOVER_STORE, changeset().insert([{ key }])); + await view.runAsync(); + const unit = sceneItems(view).filter((item) => item.datum[INTERACTION_KEY] === key); + + expect(unit.find((item) => item.mark.marktype === 'rule')?.stroke).toBe('#59636d'); + expect(unit.find((item) => item.mark.marktype === 'symbol')?.stroke).toBe('#59636d'); + }); + + it('preserves base strokes for line and composite charts before hover', async () => { + const cases: Record[] = [ + { + data: { values: [{ X: 1, Y: 2, Group: 'A' }] }, + mark: 'point', + encoding: { + x: { field: 'X', type: 'quantitative' }, + y: { field: 'Y', type: 'quantitative' }, + color: { field: 'Group', type: 'nominal' }, + }, + _interactionSemantics: { + fields: ['X', 'Y', 'Group'], selectableMarks: ['point'], + renderHoverStyles: { symbol: { stroke: '#59636d', strokeWidth: 2 } }, + }, + }, + { + data: { values: [{ X: 0, Y: 1 }, { X: 1, Y: 2 }] }, + mark: { type: 'line', strokeWidth: 2 }, + encoding: { x: { field: 'X', type: 'quantitative' }, y: { field: 'Y', type: 'quantitative' } }, + _interactionSemantics: { + fields: ['X', 'Y'], selectableMarks: ['line'], + renderHoverStyles: { line: { strokeWidth: 3 } }, + }, + }, + { + data: { values: [{ X: 'A', Low: 1, High: 4, Open: 2, Close: 3 }] }, + encoding: { x: { field: 'X', type: 'nominal' } }, + layer: [ + { mark: 'rule', encoding: { y: { field: 'Low', type: 'quantitative' }, y2: { field: 'High' } } }, + { mark: 'bar', encoding: { y: { field: 'Open', type: 'quantitative' }, y2: { field: 'Close' } } }, + ], + _interactionSemantics: { + fields: ['X'], selectableMarks: ['rule', 'bar'], + renderHoverStyles: { rule: { strokeWidth: 2.5 }, rect: { stroke: '#59636d', strokeWidth: 1.5 } }, + }, + }, + { + data: { values: [ + { Group: 'A', Value: 1 }, { Group: 'A', Value: 2 }, { Group: 'A', Value: 3 }, + ] }, + mark: 'boxplot', + encoding: { + x: { field: 'Group', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['Group'], selectableMarks: ['boxplot'], + renderHoverStyles: { + rect: { opacity: 'contrast', stroke: '#59636d', strokeWidth: 2 }, + rule: { opacity: 'contrast', stroke: '#59636d', strokeWidth: 2 }, + symbol: { opacity: 'contrast', stroke: '#59636d', strokeWidth: 2 }, + }, + }, + }, + ]; + + for (const spec of cases) { + const plan = addVegaLiteInteractions(spec, [clickMark()]); + const compiled = compile(spec as any).spec as Record; + injectVegaInteractionStore(compiled, plan ?? undefined); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const strokes = sceneItems(view).filter((item) => + item.datum[INTERACTION_KEY] + && (item.mark.marktype === 'line' || item.mark.marktype === 'rule' || item.mark.marktype === 'symbol')); + + expect(strokes.length).toBeGreaterThan(0); + expect(strokes.every((item) => item.stroke !== 'transparent' && item.strokeWidth > 0)).toBe(true); + } + }); + + it('compiles Boxplot color contrast and outlines for every composite submark', () => { + const spec: Record = { + data: { values: [ + { Group: 'A', Value: 1 }, { Group: 'A', Value: 2 }, + { Group: 'A', Value: 3 }, { Group: 'A', Value: 20 }, + ] }, + mark: 'boxplot', + encoding: { + x: { field: 'Group', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['Group'], selectableMarks: ['boxplot'], + renderHoverStyles: { + rect: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + rule: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + symbol: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, + }, + }, + }; + const plan = addVegaLiteInteractions(spec, [clickMark()]); + const compiled = compile(spec as any).spec as Record; + injectVegaInteractionStore(compiled, plan ?? undefined); + const marks: Record[] = []; + const collect = (items: Record[] = []) => { + for (const item of items) { + marks.push(item); + collect(item.marks); + } + }; + collect(compiled.marks); + + for (const markType of ['rect', 'rule', 'symbol']) { + const styled = marks.filter((mark) => mark.type === markType + && JSON.stringify(mark.encode).includes(INTERACTION_KEY)); + expect(styled.length).toBeGreaterThan(0); + for (const mark of styled) { + expect(JSON.stringify(mark.encode.update.opacity)).toContain(HOVER_STORE); + expect(JSON.stringify(mark.encode.update.stroke)).toContain(MUTED_HOVER_STROKE); + expect(JSON.stringify(mark.encode.update.strokeWidth)).toContain(HOVER_STORE); + } + } + }); + + it('leaves native line paint unchanged for segment-local hover', () => { + const spec = assembleVegaLite({ + chart_spec: { + chartType: 'Line Chart', + encodings: { x: { field: 'Year' }, y: { field: 'Value' } }, + }, + semantic_types: { Year: 'Date', Value: 'Number' }, + data: { values: [ + { Year: '2024-01-01', Value: 2 }, + { Year: '2024-02-01', Value: 4 }, + ] }, + }) as any; + const { compiled } = instrument(spec); + const line = compiled.marks + .flatMap((mark: Record) => mark.marks ?? [mark]) + .find((mark: Record) => mark.type === 'line'); + + expect(JSON.stringify(line.encode.update.strokeWidth ?? {})).not.toContain(HOVER_STORE); + }); + + it('tests rectangle selection against an arc sector rather than its broad bounds', () => { + const quarter = { + mark: { marktype: 'arc' }, + x: 100, + y: 100, + innerRadius: 0, + outerRadius: 80, + endAngle: 0, + startAngle: Math.PI / 2, + }; + const donutQuarter = { ...quarter, innerRadius: 40 }; + const clockwiseQuarter = { ...quarter, startAngle: 0, endAngle: Math.PI / 2 }; + const roseWedge = { ...quarter, startAngle: 0, endAngle: Math.PI / 6 }; + + expect(arcIntersectsRect(quarter, { x1: 130, y1: 40, x2: 160, y2: 70 })).toBe(true); + expect(arcIntersectsRect(clockwiseQuarter, { x1: 130, y1: 40, x2: 160, y2: 70 })).toBe(true); + expect(arcIntersectsRect(clockwiseQuarter, { x1: 40, y1: 130, x2: 70, y2: 160 })).toBe(false); + expect(arcIntersectsRect(roseWedge, { x1: 110, y1: 20, x2: 140, y2: 50 })).toBe(true); + expect(arcIntersectsRect(roseWedge, { x1: 40, y1: 130, x2: 70, y2: 160 })).toBe(false); + expect(arcIntersectsRect(quarter, { x1: 40, y1: 130, x2: 70, y2: 160 })).toBe(false); + expect(arcIntersectsRect(donutQuarter, { x1: 95, y1: 95, x2: 105, y2: 105 })).toBe(false); + expect(arcIntersectsRect(quarter, { x1: 15, y1: 15, x2: 185, y2: 185 }, true)).toBe(true); + expect(arcIntersectsRect(quarter, { x1: 90, y1: 90, x2: 180, y2: 180 }, true)).toBe(false); + }); + + it('assists to the nearest donut slice geometry, not the largest arc bounds', () => { + const center = { x: 100, y: 100 }; + const large = { + mark: { marktype: 'arc' }, ...center, + innerRadius: 35, outerRadius: 80, + startAngle: 0, endAngle: 3 * Math.PI / 2, + bounds: { x1: 20, y1: 20, x2: 180, y2: 180 }, + }; + const small = { + mark: { marktype: 'arc' }, ...center, + innerRadius: 35, outerRadius: 80, + startAngle: 3 * Math.PI / 2, endAngle: 17 * Math.PI / 10, + bounds: { x1: 20, y1: 75, x2: 36, y2: 125 }, + }; + const angle = 8 * Math.PI / 5; + const point = { + x: center.x + 60 * Math.sin(angle), + y: center.y - 60 * Math.cos(angle), + }; + + expect(nearestItemByBounds([large, small], point, 10)).toBe(small); + }); + + it('tests angular selection across the zero-angle seam and within one polar center', () => { + const arc = { + mark: { marktype: 'arc' }, x: 100, y: 100, + innerRadius: 20, outerRadius: 80, + startAngle: 11 * Math.PI / 6, endAngle: 13 * Math.PI / 6, + }; + const sector = { + center: { x: 100, y: 100 }, innerRadius: 0, outerRadius: 90, + startAngle: 7 * Math.PI / 4, endAngle: 9 * Math.PI / 4, + }; + + expect(arcIntersectsAngularSector(arc, sector)).toBe(true); + expect(arcIntersectsAngularSector(arc, sector, true)).toBe(true); + expect(arcIntersectsAngularSector(arc, { ...sector, endAngle: 2 * Math.PI }, true)).toBe(false); + expect(arcIntersectsAngularSector(arc, { ...sector, center: { x: 300, y: 100 } })).toBe(false); + expect(arcIntersectsAngularSector(arc, { ...sector, innerRadius: 85 })).toBe(false); + }); + + it('tests Radar line content against angular sectors', () => { + const sector = { + center: { x: 100, y: 100 }, innerRadius: 0, outerRadius: 90, + startAngle: -0.2, endAngle: 0.2, + }; + const contained = [{ x: 95, y: 40 }, { x: 105, y: 40 }]; + const crossing = [{ x: 70, y: 40 }, { x: 130, y: 40 }]; + const outside = [{ x: 140, y: 90 }, { x: 150, y: 110 }]; + + expect(pathIntersectsAngularSector(contained, sector)).toBe(true); + expect(pathIntersectsAngularSector(contained, sector, true)).toBe(true); + expect(pathIntersectsAngularSector(crossing, sector)).toBe(true); + expect(pathIntersectsAngularSector(crossing, sector, true)).toBe(false); + expect(pathIntersectsAngularSector(outside, sector)).toBe(false); + }); + + it('derives the Radar brush radius from grid spokes instead of legends', () => { + const spokeMark = { marktype: 'rule' }; + const legendMark = { marktype: 'rule', name: 'legend-symbol' }; + const view = { + scenegraph: () => ({ + root: { + items: [{ + mark: { marktype: 'group' }, x: 20, y: 30, + items: [ + { mark: spokeMark, datum: { __type: 'spoke' }, x: 100, y: 100, x2: 100, y2: 40 }, + { mark: spokeMark, datum: { __type: 'spoke' }, x: 100, y: 100, x2: 160, y2: 100 }, + { mark: legendMark, x: 220, y: 20, x2: 320, y2: 20 }, + ], + }], + }, + }), + }; + + expect(polarFrameFromRadarGrid(view)).toEqual({ + center: { x: 120, y: 130 }, innerRadius: 0, outerRadius: 60, + }); + }); + + it('draws annular angular-brush geometry and admits it only on polar ChartDefs', () => { + expect(angularSectorPath({ + center: { x: 100, y: 100 }, innerRadius: 30, outerRadius: 80, + startAngle: 0, endAngle: Math.PI / 2, + })).toContain('A 80 80 0 0 1'); + const fullDisk = angularSectorPath({ + center: { x: 100, y: 100 }, innerRadius: 0, outerRadius: 80, + startAngle: 0, endAngle: 2 * Math.PI, + }); + const fullDonut = angularSectorPath({ + center: { x: 100, y: 100 }, innerRadius: 30, outerRadius: 80, + startAngle: 0, endAngle: -2 * Math.PI, + }); + expect(fullDisk.match(/ A /g)).toHaveLength(2); + expect(fullDonut.match(/ A /g)).toHaveLength(4); + expect(fullDisk).not.toContain('0.000001'); + + const cartesian = { + mark: 'bar', + data: { values: [{ category: 'A', value: 1 }] }, + encoding: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, + _interactionSemantics: barChartDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, + }), + }; + expect(() => addVegaLiteInteractions(cartesian, [brushAngle()])) + .toThrow('requires a polar chart with angular-region support'); + + const polar = { + mark: 'arc', + data: { values: [{ category: 'A', value: 1 }] }, + encoding: { theta: { field: 'value', type: 'quantitative' }, color: { field: 'category', type: 'nominal' } }, + _interactionSemantics: roseChartDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, + }), + }; + const polarPlan = addVegaLiteInteractions(polar, [brushX()]); + expect(polarPlan?.angularXBrush).toBe(true); + + const radar = { + mark: 'point', + data: { values: [{ metric: 'Speed', value: 1, series: 'A' }] }, + encoding: { + x: { field: 'metric', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + color: { field: 'series', type: 'nominal' }, + }, + _interactionSemantics: radarChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'metric', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + color: { field: 'series', type: 'nominal' }, + }, + }), + }; + expect(addVegaLiteInteractions(radar, [brushAngle()])?.angularXBrush).toBe(true); + }); + + it('does not select adjacent cells that only touch the selection boundary', () => { + const selection = { x1: 10, y1: 10, x2: 30, y2: 30 }; + + expect(boundsIntersectRect({ x1: 10, y1: 10, x2: 30, y2: 30 }, selection)).toBe(true); + expect(boundsIntersectRect({ x1: 30, y1: 10, x2: 50, y2: 30 }, selection)).toBe(false); + expect(boundsIntersectRect({ x1: 10, y1: 30, x2: 30, y2: 50 }, selection)).toBe(false); + expect(boundsIntersectRect({ x1: 29.75, y1: 10, x2: 50, y2: 30 }, selection)).toBe(false); + expect(boundsIntersectRect({ x1: 29, y1: 10, x2: 50, y2: 30 }, selection)).toBe(true); + }); + + it('reports the plot origin in renderer units when the SVG is CSS-scaled', () => { + // Vega renders a 370x305 chart that CSS shrinks to 326px wide. + const cssScale = 326 / 370; + const matrix = { a: cssScale, e: 39 * cssScale, f: 10 * cssScale }; + + expect(rendererPlotOrigin(matrix, { x: 0, y: 0 })).toEqual({ x: 39, y: 10 }); + expect(rendererPlotOrigin({ a: 1, e: 39, f: 10 }, { x: 0, y: 0 })) + .toEqual({ x: 39, y: 10 }); + expect(rendererPlotOrigin(undefined, { x: 5, y: 6 })).toEqual({ x: 5, y: 6 }); + + const space = { + rect: { left: 0, top: 0, width: 370 * cssScale, height: 305 * cssScale } as DOMRect, + logicalWidth: 370, + logicalHeight: 305, + ...(({ x, y }) => ({ originX: x, originY: y }))(rendererPlotOrigin(matrix, { x: 0, y: 0 })), + plotWidth: 320, + plotHeight: 260, + }; + + // A plot-space point must land where the scaled mark actually renders. + expect(plotToClientPoint({ x: 0, y: 0 }, space).x).toBeCloseTo(39 * cssScale, 6); + const roundTrip = clientToPlotPoint({ x: 39 * cssScale, y: 10 * cssScale }, space); + expect(roundTrip.x).toBeCloseTo(0, 6); + expect(roundTrip.y).toBeCloseTo(0, 6); + }); + + it('reports the correct plot origin under nonuniform SVG scaling', () => { + const matrix = { a: 0.8, d: 0.5, e: 32, f: 15 }; + + expect(rendererPlotOrigin(matrix, { x: 0, y: 0 })).toEqual({ x: 40, y: 30 }); + + const space = { + rect: { left: 10, top: 20, width: 320, height: 150 } as DOMRect, + logicalWidth: 400, + logicalHeight: 300, + originX: 40, + originY: 30, + plotWidth: 320, + plotHeight: 240, + }; + const plotOrigin = plotToClientPoint({ x: 0, y: 0 }, space); + expect(plotOrigin).toEqual({ x: 42, y: 35 }); + expect(clientToPlotPoint(plotOrigin, space)).toEqual({ x: 0, y: 0 }); + }); + + it('round-trips coordinates through SVG scaling and Vega plot padding', () => { + const space = { + rect: { left: 100, top: 50, width: 250, height: 150 } as DOMRect, + logicalWidth: 500, + logicalHeight: 300, + originX: 60, + originY: 30, + plotWidth: 400, + plotHeight: 240, + }; + + const plot = clientToPlotPoint({ x: 180, y: 100 }, space); + expect(plot).toEqual({ x: 100, y: 70 }); + expect(plotToClientPoint(plot, space)).toEqual({ x: 180, y: 100 }); + expect(clientToPlotPoint({ x: 0, y: 0 }, space)).toEqual({ x: 0, y: 0 }); + expect(clientToPlotPoint({ x: 350, y: 100 }, space)).toEqual({ x: 400, y: 70 }); + expect(clientToRendererPoint({ x: 350, y: 100 }, space)).toEqual({ x: 500, y: 100 }); + expect(clientToLayoutPoint( + { x: 180, y: 100 }, + { left: 20, top: 20, width: 320, height: 160 }, + { width: 400, height: 200 }, + )).toEqual({ x: 200, y: 100 }); + expect(clientRectToLayoutRect( + { left: 60, top: 40, right: 260, bottom: 140 }, + { left: 20, top: 20, width: 320, height: 160 }, + { width: 400, height: 200 }, + )).toEqual({ left: 50, top: 25, width: 250, height: 125 }); + }); + + it('translates concat marks by ancestor group offsets', () => { + const view = { + scenegraph: () => ({ + root: { + items: [{ + mark: { marktype: 'group' }, + x: 240, + y: 12, + items: [{ + mark: { marktype: 'bar' }, + datum: { [INTERACTION_KEY]: '20-29|F' }, + x: 10, + y: 20, + bounds: { x1: 10, x2: 80, y1: 20, y2: 50 }, + }], + }], + }, + }), + }; + + expect(sceneItems(view)[0]).toMatchObject({ + x: 250, + y: 32, + bounds: { x1: 250, x2: 320, y1: 32, y2: 62 }, + }); + }); + + it('keys a basic bar by its category and emits a valid retained store', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Region'], categoryField: 'Region', selectableMarks: ['bar'], + }, + data: { values: [{ Region: 'West', Sales: 10 }] }, + mark: 'bar', + encoding: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Sales', type: 'quantitative' }, + }, + }; + + const { plan, compiled } = instrument(spec); + + expect(plan).toMatchObject({ fields: ['Region'], categoryField: 'Region' }); + expect(spec.transform).toContainEqual(expect.objectContaining({ as: INTERACTION_KEY })); + expect(spec.encoding.opacity.condition.test).toContain(INTERACTION_STORE); + expect(compiled.data).toContainEqual({ name: INTERACTION_STORE, values: [] }); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('uses category plus series for grouped-bar element identity', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Region', 'Segment'], + categoryField: 'Region', + seriesField: 'Segment', + selectableMarks: ['bar'], + }, + mark: 'bar', + encoding: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Sales', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + xOffset: { field: 'Segment', type: 'nominal' }, + }, + }; + + const { plan } = instrument(spec, [clickMark(), select()]); + + expect(plan).toMatchObject({ + fields: ['Region', 'Segment'], + categoryField: 'Region', + seriesField: 'Segment', + }); + }); + + it('uses both discrete axes for a heatmap cell', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Month', 'Product'], categoryField: 'Month', selectableMarks: ['rect'], + }, + mark: 'rect', + encoding: { + x: { field: 'Month', type: 'ordinal' }, + y: { field: 'Product', type: 'nominal' }, + color: { field: 'Revenue', type: 'quantitative' }, + }, + }; + + expect(instrument(spec).plan?.fields).toEqual(['Month', 'Product']); + }); + + it('instruments concatenated pyramid bars with constant opacity', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Age', 'Gender'], + categoryField: 'Age', + seriesField: 'Gender', + selectableMarks: ['bar'], + }, + data: { values: [{ Age: '20-29', Population: 10, Gender: 'F' }] }, + hconcat: [ + { + mark: 'bar', + transform: [{ filter: { field: 'Gender', equal: 'F' } }], + encoding: { + x: { field: 'Population', type: 'quantitative' }, + y: { field: 'Age', type: 'ordinal' }, + opacity: { value: 0.9 }, + }, + }, + { + mark: 'bar', + transform: [{ filter: { field: 'Gender', equal: 'M' } }], + encoding: { + x: { field: 'Population', type: 'quantitative' }, + y: { field: 'Age', type: 'ordinal' }, + opacity: { value: 0.9 }, + }, + }, + ], + }; + + const { plan, compiled } = instrument(spec, [select()]); + + expect(plan).toMatchObject({ + fields: ['Age', 'Gender'], + categoryField: 'Age', + seriesField: 'Gender', + }); + expect(spec.hconcat[0].encoding.opacity.condition.value).toBe(0.9); + expect(spec.hconcat[1].encoding.opacity.condition.value).toBe(0.9); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('formats bar-family annotations from the primary metric', () => { + const element = { + value: { [INTERACTION_KEY]: 'India' }, + records: [{ Country: 'India', Population: 1428.6, Population_end: 1428.6 }], + }; + const makeUpdate = (definition: typeof barChartDef, resolvedEncodings: Record) => { + const semantics = definition.semanticInteractions!({ resolvedEncodings }); + return semantics.presentUpdate!( + annotationUpdate(element, { kind: 'mark', role: 'bar' }), + { chartType: definition.chart, selected: [] }, + ); + }; + + const horizontalEncodings = { + x: { field: 'Population', type: 'quantitative' }, + y: { field: 'Country', type: 'nominal' }, + }; + for (const definition of [barChartDef, groupedBarChartDef, stackedBarChartDef, pyramidChartDef]) { + const update = makeUpdate(definition, horizontalEncodings); + expect(update.ops[0]).toMatchObject({ value: { text: '1,428.6' } }); + expect((update.ops[0] as any).value.candidates[0]).toMatchObject({ valueAxis: 'x' }); + } + + const flipped = makeUpdate(pyramidChartDef, { + x: { field: 'Country', type: 'nominal' }, + y: { field: 'Population', type: 'quantitative' }, + }); + + expect(flipped.ops[0]).toMatchObject({ value: { text: '1,428.6' } }); + expect((flipped.ops[0] as any).value.candidates[0]).toMatchObject({ valueAxis: 'y' }); + }); + + it('hovers Pyramid bars without changing their geometry or center gap', async () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Age', 'Gender'], categoryField: 'Age', seriesField: 'Gender', + selectableMarks: ['bar'], + renderHoverStyles: { rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 1.5 } }, + }, + data: { values: [ + { Age: '20-29', Population: 10, Gender: 'F' }, + { Age: '20-29', Population: 12, Gender: 'M' }, + ] }, + spacing: 0, + hconcat: [ + { + mark: 'bar', transform: [{ filter: { field: 'Gender', equal: 'F' } }], + encoding: { + x: { field: 'Population', type: 'quantitative', scale: { reverse: true } }, + y: { field: 'Age', type: 'ordinal' }, opacity: { value: 0.9 }, + }, + }, + { + mark: 'bar', transform: [{ filter: { field: 'Gender', equal: 'M' } }], + encoding: { + x: { field: 'Population', type: 'quantitative' }, + y: { field: 'Age', type: 'ordinal', axis: null }, opacity: { value: 0.9 }, + }, + }, + ], + }; + const { compiled } = instrument(spec, [clickMark()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const target = sceneItems(view).find((item) => item.mark.marktype === 'rect'); + const geometry = { + x: target.x, x2: target.x2, y: target.y, y2: target.y2, + width: target.width, height: target.height, + }; + const opacity = target.opacity; + + view.change(HOVER_STORE, changeset().insert([{ key: target.datum[INTERACTION_KEY] }])); + await view.runAsync(); + const hovered = sceneItems(view).find((item) => item.datum[INTERACTION_KEY] === target.datum[INTERACTION_KEY]); + + expect(hovered?.opacity).toBe(opacity); + expect({ + x: hovered?.x, x2: hovered?.x2, y: hovered?.y, y2: hovered?.y2, + width: hovered?.width, height: hovered?.height, + }).toEqual(geometry); + expect(hovered?.stroke).toBe(MUTED_HOVER_STROKE); + expect(hovered?.strokeWidth).toBe(1.5); + }); + + it.each([ + { authoredOpacity: 1, hoveredOpacity: 0.9 }, + { authoredOpacity: 0.6, hoveredOpacity: 1 }, + ])('contrasts target opacity from $authoredOpacity without changing peers', async ({ authoredOpacity, hoveredOpacity }) => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category'], categoryField: 'Category', selectableMarks: ['bar'], + renderHoverStyles: { rect: { opacity: 'contrast' } }, + }, + data: { values: [ + { Category: 'Alpha', Value: 10 }, + { Category: 'Beta', Value: 12 }, + ] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + opacity: { value: authoredOpacity }, + }, + }; + const { compiled } = instrument(spec, [clickMark()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + const [target, peer] = bars; + const targetKey = target.datum[INTERACTION_KEY]; + const peerKey = peer.datum[INTERACTION_KEY]; + + view.change(HOVER_STORE, changeset().insert([{ key: targetKey }])); + await view.runAsync(); + let renderedBars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + const hoveredTarget = renderedBars.find((item) => item.datum[INTERACTION_KEY] === targetKey); + const hoverPeer = renderedBars.find((item) => item.datum[INTERACTION_KEY] === peerKey); + expect(hoveredTarget?.opacity).toBe(hoveredOpacity); + expect(hoverPeer?.opacity).toBe(authoredOpacity); + + view.change(INTERACTION_STORE, changeset().insert([{ key: targetKey }])); + await view.runAsync(); + renderedBars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === targetKey)?.opacity).toBe(hoveredOpacity); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === peerKey)?.opacity).toBe(0.25); + }); + + it('preserves a data-encoded opacity channel and uses an outline on hover', async () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category'], categoryField: 'Category', selectableMarks: ['bar'], + renderHoverStyles: { rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 1.5 } }, + }, + data: { values: [ + { Category: 'Alpha', Value: 10, Confidence: 0.4 }, + { Category: 'Beta', Value: 12, Confidence: 0.8 }, + ] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + opacity: { field: 'Confidence', type: 'quantitative', scale: null }, + }, + }; + const { compiled } = instrument(spec, [clickMark()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + const [target, peer] = bars; + + view.change(HOVER_STORE, changeset().insert([{ key: target.datum[INTERACTION_KEY] }])); + await view.runAsync(); + let renderedBars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === target.datum[INTERACTION_KEY])?.opacity).toBe(0.4); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === target.datum[INTERACTION_KEY])?.stroke).toBe(MUTED_HOVER_STROKE); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === peer.datum[INTERACTION_KEY])?.opacity).toBe(0.8); + + view.change(INTERACTION_STORE, changeset().insert([{ key: target.datum[INTERACTION_KEY] }])); + await view.runAsync(); + renderedBars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(renderedBars.find((item) => item.datum[INTERACTION_KEY] === peer.datum[INTERACTION_KEY])?.opacity).toBe(0.25); + }); + + it('resolves a ranged-dot click to its connector and both endpoints', () => { + const semantics = rangedDotPlotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Life expectancy', type: 'quantitative' }, + y: { field: 'Country', type: 'nominal' }, + color: { field: 'Sex', type: 'nominal' }, + }, + }); + const male = { + datum: { [INTERACTION_KEY]: 'Japan|81.5|Male', Country: 'Japan', Sex: 'Male' }, + source: 'mark' as const, + markType: 'symbol', + }; + const female = { + datum: { [INTERACTION_KEY]: 'Japan|87.6|Female', Country: 'Japan', Sex: 'Female' }, + source: 'mark' as const, + markType: 'symbol', + }; + const connector = { + datum: { + [INTERACTION_KEY]: `Japan|connector${PATH_KEY_SUFFIX}`, + Country: 'Japan', Sex: 'Male', 'Life expectancy': 81.5, + }, + endDatum: { Country: 'Japan', Sex: 'Female', 'Life expectancy': 87.6 }, + source: 'mark' as const, + markType: 'line', + }; + const other = { + datum: { [INTERACTION_KEY]: 'Brazil|76|Female', Country: 'Brazil', Sex: 'Female' }, + source: 'mark' as const, + }; + + const target = semantics.resolve( + { gesture: 'click', role: 'mark', hits: [male] }, + { + allHits: [male, female, connector, other], + keyField: INTERACTION_KEY, + categoryField: 'Country', + seriesField: 'Sex', + }, + ); + + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual([ + `Japan|connector${PATH_KEY_SUFFIX}`, + 'Japan|81.5|Male', + 'Japan|87.6|Female', + ]); + expect(target?.visual).toEqual({ kind: 'path', role: 'line' }); + expect(target?.elements[0].records).toEqual([ + { Country: 'Japan', Sex: 'Male', 'Life expectancy': 81.5 }, + connector.endDatum, + ]); + expect(semantics.presentUpdate!( + annotationUpdate(target!.elements[0], target!.visual), + { chartType: 'Ranged Dot Plot', selected: [], categoryField: 'Country', seriesField: 'Sex' }, + ).ops[0]).toMatchObject({ + value: { + text: 'Male: 81.5, Female: 87.6', + candidates: [{ connection: 'segment-midpoint', priority: 0 }], + }, + }); + }); + + it('formats a pie slice from its encoded category and value fields', () => { + const semantics = pieChartDef.semanticInteractions!({ + resolvedEncodings: { + color: { field: 'Browser', type: 'nominal' }, + size: { field: 'Share', type: 'quantitative' }, + }, + }); + const element = { + value: { [INTERACTION_KEY]: 'Chrome' }, + records: [{ Browser: 'Chrome', Share: 65, Share_start: 0, Share_end: 65 }], + }; + + const update = semantics.presentUpdate!( + annotationUpdate(element, { kind: 'mark', role: 'slice' }), + { chartType: 'Pie Chart', selected: [], seriesField: 'Browser' }, + ); + + expect(update.ops[0]).toMatchObject({ value: { text: 'Chrome: 65' } }); + expect((update.ops[0] as any).value.candidates).toEqual([ + { connection: 'radial-midpoint', priority: 0 }, + { connection: 'outer-radial', priority: 1 }, + ]); + }); + + it('keeps ranged-dot hover on the physical endpoint', () => { + const resolve = rangedDotPlotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Life expectancy', type: 'quantitative' }, + y: { field: 'Country', type: 'nominal' }, + color: { field: 'Sex', type: 'nominal' }, + }, + }).resolve; + const male = { + datum: { [INTERACTION_KEY]: 'Japan|81.5|Male', Country: 'Japan', Sex: 'Male' }, + source: 'mark' as const, + markType: 'symbol', + }; + + const target = resolve( + { gesture: 'hover', role: 'mark', hits: [male] }, + { allHits: [male], keyField: INTERACTION_KEY, categoryField: 'Country', seriesField: 'Sex' }, + ); + + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['Japan|81.5|Male']); + expect(target?.visual).toEqual({ kind: 'mark', role: 'point' }); + }); + + it('highlights only the hovered legend item until it is clicked', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Region: 'West', Segment: 'Consumer', Value: 10 }, + { Region: 'West', Segment: 'Corporate', Value: 12 }, + { Region: 'East', Segment: 'Consumer', Value: 8 }, + { Region: 'East', Segment: 'Corporate', Value: 9 }, + ] }, + semantic_types: { Region: 'Category', Segment: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'Region', y: 'Value', color: 'Segment' }, + }, + } as never) as any; + const { compiled } = instrument(spec, [clickMark()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + view.change(LEGEND_HOVER_STORE, changeset().insert([{ channel: 'color', value: 'Consumer' }])); + await view.runAsync(); + + const bars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(bars.every((item) => item.opacity === 1)).toBe(true); + let legendItems = allSceneItems(view).filter((item) => + item.mark.role === 'legend-label' || item.mark.role === 'legend-symbol'); + expect(legendItems.filter((item) => item.datum.value === 'Corporate').every((item) => item.opacity === 1)).toBe(true); + const legendLabels = legendItems.filter((item) => item.mark.role === 'legend-label'); + const consumerLabel = legendLabels.find((item) => item.datum.value === 'Consumer'); + const corporateLabel = legendLabels.find((item) => item.datum.value === 'Corporate'); + expect(consumerLabel?.opacity).toBe(1); + expect(consumerLabel?.fontWeight).toBe(600); + expect(corporateLabel?.fontWeight).not.toBe(600); + const consumerSymbol = legendItems.find((item) => + item.mark.role === 'legend-symbol' && item.datum.value === 'Consumer'); + const corporateSymbol = legendItems.find((item) => + item.mark.role === 'legend-symbol' && item.datum.value === 'Corporate'); + expect(consumerSymbol?.opacity).toBe(0.72); + expect(corporateSymbol?.opacity).toBe(1); + + view.change(LEGEND_HOVER_STORE, changeset().remove(() => true)); + view.change(LEGEND_SELECTION_STORE, changeset().insert([{ channel: 'color', value: 'Consumer' }])); + const consumerKeys = bars + .filter((item) => item.datum.Segment === 'Consumer') + .map((item) => ({ key: item.datum[INTERACTION_KEY] })); + view.change(INTERACTION_STORE, changeset().insert(consumerKeys)); + await view.runAsync(); + + const selectedBars = sceneItems(view).filter((item) => item.mark.marktype === 'rect' && item.datum[INTERACTION_KEY]); + expect(selectedBars.filter((item) => item.datum.Segment === 'Consumer').every((item) => item.opacity === 1)).toBe(true); + expect(selectedBars.filter((item) => item.datum.Segment === 'Corporate').every((item) => item.opacity === 0.25)).toBe(true); + legendItems = allSceneItems(view).filter((item) => + item.mark.role === 'legend-label' || item.mark.role === 'legend-symbol'); + expect(legendItems.filter((item) => item.datum.value === 'Consumer').every((item) => item.opacity === 1)).toBe(true); + expect(legendItems.filter((item) => item.datum.value === 'Corporate').every((item) => item.opacity === 0.25)).toBe(true); + + view.change(LEGEND_SELECTION_STORE, changeset().remove(() => true)); + view.change(LEGEND_HIDDEN_STORE, changeset().insert([{ identity: 'color:Consumer', opacity: 0.25 }])); + await view.runAsync(); + + legendItems = allSceneItems(view).filter((item) => + item.mark.role === 'legend-label' || item.mark.role === 'legend-symbol'); + expect(legendItems.filter((item) => item.datum.value === 'Consumer').every((item) => item.opacity === 0.25)).toBe(true); + expect(legendItems.filter((item) => item.datum.value === 'Corporate').every((item) => item.opacity === 1)).toBe(true); + }); + + it('acquires the whitespace between a legend symbol and its label as one entry', () => { + const owner: any = { + datum: { scales: { fill: 'color' } }, + mark: { marktype: 'group' }, + x: 100, + y: 20, + items: [], + }; + const symbol = { + datum: { value: 'Android' }, + mark: { marktype: 'symbol', role: 'legend-symbol', group: owner }, + bounds: { x1: 0, y1: 0, x2: 10, y2: 10 }, + }; + const label = { + datum: { value: 'Android' }, + mark: { marktype: 'text', role: 'legend-label', group: owner }, + bounds: { x1: 20, y1: 0, x2: 70, y2: 10 }, + }; + owner.items = [symbol, label]; + const chartPoint = { + datum: { [INTERACTION_KEY]: 'chart-point' }, + mark: { marktype: 'symbol' }, + bounds: { x1: 90, y1: 30, x2: 100, y2: 40 }, + }; + const view = { scenegraph: () => ({ root: { items: [owner] } }) }; + const viewWithChartPoint = { + scenegraph: () => ({ root: { items: [owner, chartPoint] } }), + }; + + expect(legendEntryItemAtPoint(view, { x: 115, y: 25 })).toBe(symbol); + expect(legendEntryItemAtPoint(view, { x: 90, y: 25 })).toBeNull(); + expect(nearestInteractiveSceneItem(view, { x: 90, y: 25 }, 12)).toBe(symbol); + expect(nearestInteractiveSceneItem( + view, { x: 0, y: 0 }, 12, { x: 90, y: 25 }, + )).toBe(symbol); + expect(nearestInteractiveSceneItem( + viewWithChartPoint, { x: 95, y: 35 }, 12, { x: 115, y: 35 }, false, + )).toBe(symbol); + expect(nearestInteractiveSceneItem(view, { x: 80, y: 25 }, 12)).toBeUndefined(); + }); + + it('calculates keys inside a Bar Table panel with its own named data', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category'], categoryField: 'Category', selectableMarks: ['bar'], + }, + datasets: { rows: [{ Category: 'Alpha', Value: 10 }] }, + hconcat: [ + { + data: { name: 'rows' }, + mark: 'bar', + transform: [{ aggregate: [{ op: 'sum', field: 'Value', as: 'Value' }], groupby: ['Category'] }], + encoding: { + x: { field: 'Value', type: 'quantitative' }, + y: { field: 'Category', type: 'nominal' }, + color: { value: '#41a25f' }, + }, + }, + { + data: { name: 'rows' }, + mark: 'text', + encoding: { + y: { field: 'Category', type: 'nominal' }, + text: { field: 'Value', type: 'quantitative' }, + }, + }, + ], + }; + + const { plan, compiled } = instrument(spec, [select()]); + + expect(plan).toMatchObject({ fields: ['Category'], categoryField: 'Category' }); + expect(spec.hconcat[0].transform).toContainEqual(expect.objectContaining({ as: INTERACTION_KEY })); + expect(spec.hconcat[1].transform).toBeUndefined(); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('uses template-owned quantitative fields for point identity', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Horsepower', 'Efficiency'], + selectableMarks: ['circle'], + markClick: 'element', + }, + data: { values: [{ Horsepower: 120, Efficiency: 32 }] }, + mark: { type: 'circle', opacity: 0.7 }, + encoding: { + x: { field: 'Horsepower', type: 'quantitative' }, + y: { field: 'Efficiency', type: 'quantitative' }, + }, + }; + + const { plan, compiled } = instrument(spec, [clickMark(), select()]); + + expect(plan).toMatchObject({ fields: ['Horsepower', 'Efficiency'] }); + expect(spec).not.toHaveProperty('_interactionSemantics'); + expect(spec.encoding.opacity.condition.value).toBe(0.7); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('coalesces lollipop rule and circle layers under one semantic key', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category', 'Value'], + categoryField: 'Category', + selectableMarks: ['rule', 'circle'], + markClick: 'element', + }, + data: { values: [{ Category: 'A', Value: 12 }] }, + layer: [ + { + mark: 'rule', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + y2: { datum: 0 }, + }, + }, + { + mark: 'circle', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }, + ], + }; + + const { plan, compiled } = instrument(spec, [select()]); + + expect(plan).toMatchObject({ fields: ['Category', 'Value'], categoryField: 'Category' }); + expect(spec.layer[0].encoding.opacity.condition.test).toContain(INTERACTION_STORE); + expect(spec.layer[1].encoding.opacity.condition.test).toContain(INTERACTION_STORE); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('dims independent text labels without instrumenting unrelated annotations', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category', 'Value'], + categoryField: 'Category', + selectableMarks: ['bar'], + }, + data: { + values: [ + { Category: 'A', Value: 12 }, + { Category: 'B', Value: 8 }, + ], + }, + layer: [ + { + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }, + { + [INTERACTION_PROVENANCE]: { + role: 'text-label', + identity: 'inherit', + presentation: 'independent', + }, + mark: { type: 'text', dy: -6 }, + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + text: { field: 'Value', type: 'quantitative' }, + }, + }, + { + mark: { type: 'text', dy: 12 }, + encoding: { + x: { datum: 'A', type: 'nominal' }, + y: { datum: 0, type: 'quantitative' }, + text: { value: 'Reference' }, + }, + }, + ], + }; + + const { plan, compiled } = instrument(spec, [clickMark()]); + + expect(plan).not.toBeNull(); + expect(spec.layer[0].encoding.opacity.condition.test).toContain(INTERACTION_STORE); + expect(spec.layer[1].encoding.opacity.condition.test).toContain(INTERACTION_STORE); + expect(spec.layer[0].mark.cursor).toBeUndefined(); + expect(spec.layer[1].mark.cursor).toBeUndefined(); + expect(spec.layer[2].encoding.opacity).toBeUndefined(); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('carries generated on-mark label provenance without double opacity', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { Category: 'A', Value: 12 }, + { Category: 'B', Value: 8 }, + ], + }, + semantic_types: { Category: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Category', y: 'Value' }, + chartProperties: { showValueLabels: true }, + }, + theme_spec: 'economist', + } as never) as Record; + + const generatedLabel = spec.layer.find((layer: Record) => layer.mark?.type === 'text'); + expect(generatedLabel?.[INTERACTION_PROVENANCE]).toEqual({ + role: 'text-label', + identity: 'inherit', + presentation: 'on-mark', + }); + + const { compiled } = instrument(spec, [clickMark()]); + + expect(generatedLabel.encoding.opacity).toBeUndefined(); + expect(generatedLabel.transform).toContainEqual({ + calculate: "'text-label'", + as: INTERACTION_ROLE, + }); + expect(JSON.stringify(spec)).not.toContain(INTERACTION_PROVENANCE); + expect(() => parse(compiled, undefined, { ast: true } as any)).not.toThrow(); + }); + + it('resolves tagged label hits while leaving untagged text inert', () => { + const datum = { [INTERACTION_KEY]: 'A|12' }; + const mark = { marktype: 'text', name: 'value-label' }; + + expect(renderHit({ mark, datum })).toBeNull(); + expect(renderHit({ + mark, + datum: { ...datum, [INTERACTION_ROLE]: 'text-label' }, + })).toMatchObject({ + datum: { [INTERACTION_KEY]: 'A|12', [INTERACTION_ROLE]: 'text-label' }, + source: 'mark', + markType: 'text', + layerRole: 'text-label', + }); + }); + + it('acquires a generated series-end label as an exact legend domain', () => { + const item = { + mark: { marktype: 'text', name: 'series-end-label' }, + datum: { + [INTERACTION_KEY]: '2024|China|2', + [INTERACTION_ROLE]: 'legend-label', + [INTERACTION_LEGEND_CHANNEL]: 'color', + [INTERACTION_LEGEND_FIELD]: 'Country', + Country: 'China', + }, + }; + const normalized = normalizeVegaElementEvent( + {}, item, { x: 10, y: 10 }, 'commit', + { shift: false, ctrl: false, meta: false }, { color: 'Country' }, + ); + + expect(normalized.role).toBe('legend-item'); + expect(normalized.legend).toEqual({ + channel: 'color', field: 'Country', value: 'China', + domain: { kind: 'value', value: 'China' }, + }); + expect(legendSemanticTarget(normalized.legend)?.elements[0]).toEqual({ + value: { + channel: 'color', field: 'Country', + domain: { kind: 'value', value: 'China' }, + }, + }); + }); + + it('compiles Bump series-end labels as semantic legend labels', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Games: 2012, Country: 'United States', Rank: 1 }, + { Games: 2024, Country: 'United States', Rank: 1 }, + { Games: 2012, Country: 'China', Rank: 2 }, + { Games: 2024, Country: 'China', Rank: 2 }, + ] }, + semantic_types: { Games: 'Year', Country: 'Country', Rank: 'Rank' }, + chart_spec: { + chartType: 'Bump Chart', + encodings: { x: 'Games', y: 'Rank', color: 'Country' }, + }, + theme_spec: 'nyt', + } as never) as Record; + const label = spec.layer.find((layer: Record) => + layer[INTERACTION_PROVENANCE]?.role === 'legend-label'); + + expect(label?.[INTERACTION_PROVENANCE]).toMatchObject({ + role: 'legend-label', + legend: { channel: 'color', field: 'Country' }, + }); + instrument(spec, [clickMark()]); + expect(label.transform).toEqual(expect.arrayContaining([ + { calculate: "'legend-label'", as: INTERACTION_ROLE }, + { calculate: '"color"', as: INTERACTION_LEGEND_CHANNEL }, + { calculate: '"Country"', as: INTERACTION_LEGEND_FIELD }, + ])); + expect(label.mark.cursor).toBeUndefined(); + }); + + it('preserves the second datum of a clicked line segment', () => { + const mark = { marktype: 'line', name: 'trend' }; + const start = { [INTERACTION_KEY]: 'A', Month: 'Jan', Sales: 10 }; + const end = { [INTERACTION_KEY]: 'B', Month: 'Feb', Sales: 14 }; + const hit = renderHit({ + mark, + datum: start, + interactionGeometry: { endDatum: end }, + })!; + const resolve = lineChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Month', type: 'nominal' }, + y: { field: 'Sales', type: 'quantitative' }, + }, + }).resolve; + + const target = resolve( + { gesture: 'click', role: 'mark', hits: [hit] }, + { allHits: [hit], keyField: INTERACTION_KEY, categoryField: 'Month' }, + ); + + expect(target?.visual).toEqual({ kind: 'path', role: 'line' }); + expect(target?.elements[0].records).toEqual([ + { Month: 'Jan', Sales: 10 }, + { Month: 'Feb', Sales: 14 }, + ]); + }); + + it('keeps a mark click local without a declared series', () => { + const resolve = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }).resolve; + const westConsumer = { datum: { [INTERACTION_KEY]: 'West|Consumer', Region: 'West' }, source: 'mark' as const }; + const westCorporate = { datum: { [INTERACTION_KEY]: 'West|Corporate', Region: 'West' }, source: 'mark' as const }; + const eastConsumer = { datum: { [INTERACTION_KEY]: 'East|Consumer', Region: 'East' }, source: 'mark' as const }; + + const target = resolve( + { gesture: 'click', role: 'mark', hits: [westConsumer] }, + { + allHits: [westConsumer, westCorporate, eastConsumer], + keyField: INTERACTION_KEY, + categoryField: 'Region', + }, + ); + + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['West|Consumer']); + }); + + it('keeps a mark click local when a series field is declared', () => { + const resolve = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + }, + }).resolve; + const westConsumer = { datum: { [INTERACTION_KEY]: 'West|Consumer', Segment: 'Consumer' }, source: 'mark' as const }; + const westCorporate = { datum: { [INTERACTION_KEY]: 'West|Corporate', Segment: 'Corporate' }, source: 'mark' as const }; + const eastConsumer = { datum: { [INTERACTION_KEY]: 'East|Consumer', Segment: 'Consumer' }, source: 'mark' as const }; + + const target = resolve( + { gesture: 'click', role: 'mark', hits: [westConsumer] }, + { + allHits: [westConsumer, westCorporate, eastConsumer], + keyField: INTERACTION_KEY, + seriesField: 'Segment', + }, + ); + + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['West|Consumer']); + }); + + it.each(['click', 'hover'] as const)('lets the template resolver expand a legend %s to its series cohort', (gesture) => { + const resolve = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + }, + }).resolve; + const westConsumer = { datum: { [INTERACTION_KEY]: 'West|Consumer', Segment: 'Consumer' }, source: 'mark' as const }; + const westCorporate = { datum: { [INTERACTION_KEY]: 'West|Corporate', Segment: 'Corporate' }, source: 'mark' as const }; + const eastConsumer = { datum: { [INTERACTION_KEY]: 'East|Consumer', Segment: 'Consumer' }, source: 'mark' as const }; + + const target = resolve( + { gesture, role: 'legend-item', hits: [], legend: { domain: { kind: 'value', value: 'Consumer' } } }, + { + allHits: [westConsumer, westCorporate, eastConsumer], + keyField: INTERACTION_KEY, + seriesField: 'Segment', + }, + ); + + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual([ + 'West|Consumer', + 'East|Consumer', + ]); + }); + + it('retains domain-only legend identity for toggle processing', () => { + const interaction = createLegendToggleInteraction(); + const target = legendSemanticTarget({ + channel: 'color', field: 'Segment', value: 'Consumer', + domain: { kind: 'value', value: 'Consumer' }, + }); + const update = interaction.handle!({ + action: 'click-primary', phase: 'commit', target, + } as any, { available: [], selected: [] } as any); + + expect(update?.ops[0]).toMatchObject({ + op: 'set-style', + targets: [{ + visual: { kind: 'legend', role: 'legend-item' }, + elements: target?.elements, + }], + value: { visible: false }, + }); + + const restored = interaction.handle!({ + action: 'click-primary', phase: 'commit', + target: { + ...target!, + elements: target!.elements.map((element) => ({ + ...element, + records: [{ Segment: 'Consumer' }], + })), + }, + } as any, { available: [], selected: [] } as any); + expect(restored?.ops[0]).toMatchObject({ + op: 'set-style', + targets: [], + value: { visible: false }, + }); + }); + + it('resolves an unmatched legend domain to an explicit empty presentation', () => { + const legend = { + channel: 'color', field: '__status', + domain: { kind: 'value' as const, value: 'Meets target' }, + }; + const target = resolveLegendPresentationTarget( + legend, + () => null, + { allHits: [], keyField: INTERACTION_KEY }, + ); + + expect(target).toMatchObject({ + visual: { kind: 'legend', role: 'legend-item' }, + elements: [{ value: legend }], + }); + expect(target.elements[0].records).toBeUndefined(); + expect(semanticElementRenderKeys(target.elements[0])).toHaveLength(1); + }); + + it('retains concrete keys for legend domains already hidden from the scene', () => { + const resolve = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + }, + }).resolve; + const consumer = { + datum: { [INTERACTION_KEY]: 'West|Consumer', Segment: 'Consumer' }, + source: 'mark' as const, + }; + const corporate = { + datum: { [INTERACTION_KEY]: 'West|Corporate', Segment: 'Corporate' }, + source: 'mark' as const, + }; + const legend = { + channel: 'color', field: 'Segment', + domain: { kind: 'value' as const, value: 'Consumer' }, + }; + const retained = new Map(); + + resolveRetainedLegendPresentationTarget( + legend, resolve, + { allHits: [consumer, corporate], keyField: INTERACTION_KEY, seriesField: 'Segment' }, + retained, + ); + const afterConsumerIsHidden = resolveRetainedLegendPresentationTarget( + legend, resolve, + { allHits: [corporate], keyField: INTERACTION_KEY, seriesField: 'Segment' }, + retained, + ); + + expect(afterConsumerIsHidden.elements.flatMap(semanticElementRenderKeys)).toEqual(['West|Consumer']); + }); + + it('resolves every domain in a combined hidden legend target', () => { + const resolve = barChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Region', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Segment', type: 'nominal' }, + }, + }).resolve; + const hits = ['Consumer', 'Corporate'].map((Segment) => ({ + datum: { [INTERACTION_KEY]: `West|${Segment}`, Segment }, + source: 'mark' as const, + })); + const legends = ['Consumer', 'Corporate'].map((value) => ({ + channel: 'color', field: 'Segment', + domain: { kind: 'value' as const, value }, + })); + + const target = resolveRetainedLegendPresentationTargets( + legends, resolve, + { allHits: hits, keyField: INTERACTION_KEY, seriesField: 'Segment' }, + new Map(), + ); + + expect(target.elements.flatMap(semanticElementRenderKeys)).toEqual([ + 'West|Consumer', + 'West|Corporate', + ]); + }); + + it('removes a Streamgraph ribbon with the keys resolved from its legend domain', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Year: 2000, Region: 'Asia', Population: 10 }, + { Year: 2010, Region: 'Asia', Population: 12 }, + { Year: 2020, Region: 'Asia', Population: 14 }, + { Year: 2000, Region: 'Africa', Population: 4 }, + { Year: 2010, Region: 'Africa', Population: 6 }, + { Year: 2020, Region: 'Africa', Population: 8 }, + ] }, + semantic_types: { Year: 'Year', Region: 'Category', Population: 'Quantity' }, + chart_spec: { + chartType: 'Streamgraph', + encodings: { x: 'Year', y: 'Population', color: 'Region' }, + }, + } as never) as any; + const { plan, compiled } = instrument(spec, [legendToggle()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const hits = sceneItems(view).map(renderHit).filter((hit): hit is RenderHit => hit !== null); + const target = plan!.resolve!({ + gesture: 'click', role: 'legend-item', hits: [], + legend: { channel: 'color', field: 'Region', domain: { kind: 'value', value: 'Asia' } }, + }, { + allHits: hits, + keyField: INTERACTION_KEY, + seriesField: 'Region', + }); + const keys = target!.elements.flatMap(semanticElementRenderKeys); + const renderedAreaKeys = hits + .filter((hit) => hit.markType === 'area' && hit.datum.Region === 'Asia') + .map((hit) => hit.datum[INTERACTION_KEY]); + + view.change(HIDDEN_STORE, changeset().insert(keys.map((key) => ({ key })))); + await view.runAsync(); + + const remainingRegions = sceneItems(view) + .filter((item) => item.mark.marktype === 'area') + .map((item) => item.datum.Region); + expect(keys.filter((key) => key.endsWith(PATH_KEY_SUFFIX))).toEqual(renderedAreaKeys); + expect(keys.filter((key) => !key.endsWith(PATH_KEY_SUFFIX))).toHaveLength(3); + expect(remainingRegions).not.toContain('Asia'); + expect(remainingRegions).toContain('Africa'); + }); + + it('resolves color and shape legends by their own fields', () => { + const resolve = scatterPlotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'X', type: 'quantitative' }, + y: { field: 'Y', type: 'quantitative' }, + color: { field: 'Color', type: 'nominal' }, + shape: { field: 'Shape', type: 'nominal' }, + }, + }).resolve; + const hits = [ + { datum: { [INTERACTION_KEY]: 'a', Color: 'Blue', Shape: 'Circle' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'b', Color: 'Orange', Shape: 'Circle' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'c', Color: 'Blue', Shape: 'Square' }, source: 'mark' as const }, + ]; + const context = { allHits: hits, keyField: INTERACTION_KEY, seriesField: 'Color' }; + + const color = resolve( + { gesture: 'click', role: 'legend-item', hits: [], legend: { field: 'Color', domain: { kind: 'value', value: 'Blue' } } }, + context, + ); + const shape = resolve( + { gesture: 'click', role: 'legend-item', hits: [], legend: { field: 'Shape', domain: { kind: 'value', value: 'Circle' } } }, + context, + ); + + expect(color?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['a', 'c']); + expect(shape?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['a', 'b']); + }); + + it('resolves a size legend independently from color', () => { + const resolve = scatterPlotDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'X', type: 'quantitative' }, + y: { field: 'Y', type: 'quantitative' }, + color: { field: 'Color', type: 'nominal' }, + size: { field: 'Size', type: 'nominal' }, + }, + }).resolve; + const hits = [ + { datum: { [INTERACTION_KEY]: 'a', Color: 'Blue', Size: 'Large' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'b', Color: 'Orange', Size: 'Large' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'c', Color: 'Blue', Size: 'Small' }, source: 'mark' as const }, + ]; + const target = resolve( + { gesture: 'click', role: 'legend-item', hits: [], legend: { field: 'Size', domain: { kind: 'value', value: 'Large' } } }, + { allHits: hits, keyField: INTERACTION_KEY, seriesField: 'Color' }, + ); + + expect(target?.elements.map((element) => semanticElementRenderKeys(element)[0])).toEqual(['a', 'b']); + }); + + it('keeps color exact while treating size as a range when both legends exist', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { gdp: 1000, life: 66, continent: 'Africa', population: 40 }, + { gdp: 30000, life: 84, continent: 'Asia', population: 1200 }, + ], + }, + semantic_types: { + gdp: 'Quantity', life: 'Quantity', continent: 'Category', population: 'Quantity', + }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: 'gdp', y: 'life', color: 'continent', size: 'population' }, + }, + } as any) as any; + + expect(spec._interactionSemantics.legendFields).toEqual({ + color: 'continent', + size: 'population', + }); + expect(spec._interactionSemantics.rangeLegendChannels).toEqual(['size']); + }); + + it('resolves a sampled quantitative legend anchor to its midpoint range', () => { + const hits = [1, 3, 5, 7, 9].map((Size) => ({ + datum: { [INTERACTION_KEY]: String(Size), Size }, + source: 'mark' as const, + })); + const matched = legendMatchedHits( + { + gesture: 'click', + role: 'legend-item', + hits: [], + legend: { field: 'Size', domain: { kind: 'interval', start: 2.5, end: 7.5 } }, + }, + { allHits: hits, keyField: INTERACTION_KEY }, + 'Size', + ); + + expect(matched.map((hit) => hit.datum.Size)).toEqual([3, 5, 7]); + }); + + it('resolves a smooth legend ramp position to a sampled interval', () => { + const legendEntry: any = { + datum: { scales: { fill: 'color' }, type: 'gradient', vgrad: false }, + mark: { marktype: 'group' }, + x: 100, + y: 20, + items: [], + }; + const gradient = { + datum: legendEntry.datum, + mark: { marktype: 'rect', role: 'legend-gradient', group: legendEntry }, + bounds: { x1: 0, y1: 0, x2: 100, y2: 12 }, + }; + legendEntry.items = [ + gradient, + ...[0, 5, 10].map((value, index) => ({ + datum: { value, index, perc: index / 2 }, + mark: { marktype: 'text', role: 'legend-label', group: legendEntry }, + })), + ]; + const root = { mark: { marktype: 'group' }, items: [legendEntry] }; + const scale = Object.assign((value: number) => value, { + type: 'sequential-linear', + domain: () => [0, 10], + }); + const view = { scenegraph: () => ({ root }), scale: () => scale }; + + const target = legendTarget(gradient, { color: 'Temperature' }, ['color'], view, { x: 190, y: 26 }); + expect(target).toMatchObject({ + channel: 'color', field: 'Temperature', + domain: { kind: 'interval' }, + visualBounds: { x2: 200, y1: 20, y2: 32 }, + }); + expect(target?.value).toBeCloseTo(25 / 3); + expect(target?.domain.kind === 'interval' ? target.domain.start : undefined).toBeCloseTo(20 / 3); + expect(target?.visualBounds?.x1).toBeCloseTo(500 / 3); + }); + + it('adapts smooth legend segments to physical length and value cardinality', () => { + expect(continuousLegendSegmentCount(80)).toBe(3); + expect(continuousLegendSegmentCount(176)).toBe(4); + expect(continuousLegendSegmentCount(220)).toBe(5); + expect(continuousLegendSegmentCount(400)).toBe(7); + expect(continuousLegendSegmentCount(220, 2)).toBe(2); + }); + + it('resolves each discrete legend band to its exact interval', () => { + const legendEntry: any = { + datum: { scales: { fill: 'color' }, type: 'discrete', vgrad: false }, + mark: { marktype: 'group' }, + items: [], + }; + const bands = [-Infinity, 3, 6, 9].map((value, index) => ({ + datum: { value, index, perc: index / 4, perc2: (index + 1) / 4 }, + mark: { marktype: 'rect', role: 'legend-band', group: legendEntry }, + })); + legendEntry.items = bands; + + expect(legendTarget(bands[2], { color: 'Temperature' }, ['color'])) + .toEqual({ + channel: 'color', field: 'Temperature', value: 6, + domain: { kind: 'interval', start: 6, end: 9 }, + }); + expect(legendTarget(bands[0], { color: 'Temperature' }, ['color'])) + .toEqual({ + channel: 'color', field: 'Temperature', value: -Infinity, + domain: { kind: 'interval', end: 3 }, + }); + }); + + it.each([ + ['default', undefined, 'legend-gradient'], + ['quantized theme', THEME_PRESETS.datawrapper.spec, 'legend-band'], + ] as const)('targets the painted Heatmap legend under the %s', async (_name, theme, role) => { + const spec = assembleVegaLite({ + data: { values: [ + { Month: 'Jan', City: 'A', Temperature: -10 }, + { Month: 'Feb', City: 'A', Temperature: 0 }, + { Month: 'Mar', City: 'A', Temperature: 10 }, + ] }, + semantic_types: { Month: 'Category', City: 'Category', Temperature: 'Quantity' }, + chart_spec: { + chartType: 'Heatmap', + encodings: { x: 'Month', y: 'City', color: 'Temperature' }, + }, + ...(theme ? { theme_spec: theme } : {}), + } as any) as any; + const { compiled, plan } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const item = allSceneItems(view).find((candidate) => candidate.mark?.role === role); + const bounds = rootSceneBounds(view, item); + const point = bounds ? { + x: bounds.x1 + (bounds.x2 - bounds.x1) * 0.75, + y: bounds.y1 + (bounds.y2 - bounds.y1) * 0.5, + } : undefined; + const target = legendTarget(item, { color: 'Temperature' }, ['color'], view, point); + + expect(item).toBeDefined(); + expect(item.mark.interactive).toBe(true); + expect(target).toMatchObject({ channel: 'color', field: 'Temperature' }); + expect(target?.domain.kind).toBe('interval'); + if (role === 'legend-band') { + view.change(LEGEND_SELECTION_STORE, changeset().insert([{ + channel: 'color', + value: item.datum.value, + }])); + await view.runAsync(); + const bands = allSceneItems(view).filter((candidate) => candidate.mark?.role === role); + const selectedBand = bands.find((candidate) => candidate.datum.value === item.datum.value); + expect(selectedBand?.stroke).toBe(plan?.selectionBoundary?.color); + expect(selectedBand?.strokeWidth).toBe(plan?.selectionBoundary?.width); + expect(selectedBand?.strokeOpacity).toBe(plan?.selectionBoundary?.opacity); + expect(bands.filter((candidate) => candidate.datum.value !== item.datum.value) + .every((candidate) => !candidate.strokeWidth)).toBe(true); + } + }); + + it('matches temporal values against numeric legend intervals', () => { + const hits = ['2024-01-01', '2024-02-01', '2024-03-01'].map((date) => ({ + datum: { [INTERACTION_KEY]: date, Date: new Date(`${date}T00:00:00Z`) }, + source: 'mark' as const, + })); + const matched = legendMatchedHits({ + gesture: 'click', role: 'legend-item', hits: [], + legend: { + field: 'Date', + domain: { kind: 'interval', start: Date.UTC(2024, 0, 15), end: Date.UTC(2024, 2, 1) }, + }, + }, { allHits: hits, keyField: INTERACTION_KEY }, 'Date'); + + expect(matched.map((hit) => hit.datum.Date)).toEqual([new Date('2024-02-01T00:00:00Z')]); + }); + + it.each([ + ['Heatmap', heatmapDef, { + x: { field: 'Month', type: 'nominal' }, + y: { field: 'City', type: 'nominal' }, + color: { field: 'Temperature', type: 'quantitative' }, + }], + ['Calendar Heatmap', vlCalendarHeatmapDef, { + x: { field: 'Date', type: 'temporal' }, + color: { field: 'Temperature', type: 'quantitative' }, + }], + ['Choropleth', choroplethDef, { + id: { field: 'State', type: 'nominal' }, + color: { field: 'Temperature', type: 'quantitative' }, + }], + ] as const)('resolves a continuous %s legend interval to matching marks', (_name, chartDef, encodings) => { + const semantics = chartDef.semanticInteractions!({ resolvedEncodings: encodings }); + const hits = [-17, -6, 6, 17].map((Temperature, index) => ({ + datum: { + [INTERACTION_KEY]: String(index), Temperature, + sum_Temperature: Temperature, + Month: `M${index}`, City: 'A', State: `S${index}`, + }, + source: 'mark' as const, + })); + const target = semantics.resolve({ + gesture: 'click', role: 'legend-item', hits: [], + legend: { field: 'Temperature', domain: { kind: 'interval', start: 0, end: 12 } }, + }, { allHits: hits, keyField: INTERACTION_KEY }); + + expect(semantics.legendFields).toEqual({ color: 'Temperature' }); + expect(target?.elements.map((element) => element.value.Temperature)).toEqual([6]); + }); + + it('applies a Calendar legend range to the matching rendered cells', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Date: '2024-01-01', Temperature: 10 }, + { Date: '2024-01-02', Temperature: 50 }, + { Date: '2024-01-03', Temperature: 90 }, + ] }, + semantic_types: { Date: 'Date', Temperature: 'Quantity' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: 'Date', color: 'Temperature' }, + }, + } as any) as any; + expect(spec._interactionSemantics.neutralizeContinuousColor).toBe(false); + expect(spec._interactionSemantics.continuousColorFocus.boundaryWidth).toBeLessThan( + spec._interactionSemantics.selectionBoundary.width, + ); + const { compiled, plan } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const hits = sceneItems(view).map(renderHit).filter((hit): hit is NonNullable => !!hit); + const semantics = vlCalendarHeatmapDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Date', type: 'temporal' }, + color: { field: 'Temperature', type: 'quantitative' }, + }, + }); + const target = semantics.resolve({ + gesture: 'hover', role: 'legend-item', hits: [], + legend: { field: 'Temperature', domain: { kind: 'interval', start: 70, end: 100 } }, + }, { allHits: hits, keyField: INTERACTION_KEY }); + const keys = target?.elements.flatMap(semanticElementRenderKeys) ?? []; + const legendPayload = legendSemanticTarget({ + channel: 'color', field: 'Temperature', value: 90, + domain: { kind: 'interval', start: 70, end: 100 }, + }); + + expect(keys).toHaveLength(1); + expect(legendPayload).toEqual({ + visual: { kind: 'legend', role: 'legend-item' }, + elements: [{ value: { + channel: 'color', field: 'Temperature', + domain: { kind: 'interval', start: 70, end: 100 }, + } }], + }); + expect(semanticElementRenderKeys(legendPayload!.elements[0])).toEqual([]); + view.change(INTERACTION_STORE, changeset().insert(keys.map((key) => ({ key })))); + await view.runAsync(); + const cells = allSceneItems(view).filter((item) => item.mark?.marktype === 'rect' + && item.datum?.sum_Temperature !== undefined); + expect(cells.find((item) => item.datum.sum_Temperature === 90)?.opacity).toBe(1); + expect(cells.filter((item) => item.datum.sum_Temperature !== 90) + .every((item) => item.opacity === plan?.dimOpacity)).toBe(true); + }); + + it('pins a themed Calendar continuous legend to its full extent', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Date: '2024-01-01', Activity: 26 }, + { Date: '2024-01-02', Activity: 27 }, + { Date: '2024-01-03', Activity: 28 }, + { Date: '2024-01-04', Activity: 100 }, + ] }, + semantic_types: { Date: 'Date', Activity: 'Quantity' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: 'Date', color: 'Activity' }, + }, + theme_spec: 'pop', + } as any) as any; + const { compiled } = instrument(spec, [legendToggle()]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + + const colorScale = compiled.scales.find((scale: any) => + scale.type === 'quantize' && Array.isArray(scale.domain)); + expect(colorScale).toBeDefined(); + expect(colorScale.domain).toEqual([26, 100]); + expect(view.scale(colorScale.name).domain()).toEqual([26, 100]); + }); + + it('neutralizes muted continuous color only for geographic maps', () => { + const spec = assembleVegaLite({ + data: { values: [ + { lon: -74, lat: 40.7, temperature: 20 }, + { lon: -118, lat: 34, temperature: 35 }, + ] }, + semantic_types: { + lon: 'Longitude', lat: 'Latitude', temperature: 'Quantity', + }, + chart_spec: { + chartType: 'Map', + encodings: { longitude: 'lon', latitude: 'lat', color: 'temperature' }, + }, + } as any) as any; + const style = spec._interactionSemantics.continuousColorFocus; + + expect(spec._interactionSemantics.neutralizeContinuousColor).toBe(true); + instrument(spec); + const points = spec.layer.find((layer: Record) => + layer.mark?.type === 'circle' || layer.mark === 'circle'); + expect(points.encoding.color).toMatchObject({ + condition: { field: 'temperature', type: 'quantitative' }, + value: style.mutedFill, + }); + expect(points.encoding.opacity).toEqual({ value: 1 }); + }); + + it('treats a Map quantitative size key as sampled ranges', () => { + const assembled = assembleVegaLite({ + data: { values: [{ lon: -74, lat: 40.7, pop: 5 }] }, + semantic_types: { lon: 'Longitude', lat: 'Latitude', pop: 'Quantity' }, + chart_spec: { + chartType: 'Map', + encodings: { longitude: 'lon', latitude: 'lat', size: 'pop' }, + }, + } as any) as any; + const semantics = mapDef.semanticInteractions!({ + resolvedEncodings: { + longitude: { field: 'lon', type: 'quantitative' }, + latitude: { field: 'lat', type: 'quantitative' }, + size: { field: 'pop', type: 'quantitative' }, + }, + }); + const legendEntry = { + datum: { scales: { size: 'size' } }, + items: [0, 5, 10, 15].map((value) => ({ datum: { value } })), + }; + const item = { + datum: { value: 5 }, + mark: { + role: 'legend-label', + group: { mark: { group: legendEntry } }, + }, + }; + const rangeLegendChannels = assembled._interactionSemantics.rangeLegendChannels; + const legend = legendTarget(item, semantics.legendFields, rangeLegendChannels); + + expect(semantics.legendFields).toEqual({ size: 'pop' }); + expect(rangeLegendChannels).toEqual(['size']); + expect(legend).toEqual({ + channel: 'size', + field: 'pop', + value: 5, + domain: { kind: 'interval', start: 2.5, end: 7.5 }, + }); + const hits = [2.9, 4.9, 6.3, 7.6, 12.4].map((pop) => ({ + datum: { [INTERACTION_KEY]: String(pop), pop }, + source: 'mark' as const, + })); + const target = semantics.resolve({ + gesture: 'click', + role: 'legend-item', + hits: [], + legend: legend ?? undefined, + }, { + allHits: hits, + keyField: INTERACTION_KEY, + }); + const semanticLegend = legendSemanticTarget(legend); + + expect(semanticLegend?.elements[0].value).toEqual({ + channel: 'size', + field: 'pop', + domain: { kind: 'interval', start: 2.5, end: 7.5 }, + }); + expect(target?.elements.flatMap((element) => element.records ?? []).map((record) => record.pop)) + .toEqual([2.9, 4.9, 6.3]); + }); + + it('resolves Waterfall synthesized legend entries to rendered bar cohorts', () => { + const semantics = waterfallChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Step', type: 'ordinal' }, + y: { field: 'Population', type: 'quantitative' }, + }, + }); + const hits = [ + { datum: { [INTERACTION_KEY]: '1950', Step: '1950', __wf_color: 'total' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'Asia', Step: 'Asia', __wf_color: 'increase' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'Africa', Step: 'Africa', __wf_color: 'increase' }, source: 'mark' as const }, + { datum: { [INTERACTION_KEY]: 'Oceania', Step: 'Oceania', __wf_color: 'total' }, source: 'mark' as const }, + ]; + + expect(semantics.legendFields).toEqual({ color: '__wf_color' }); + const target = semantics.resolve({ + gesture: 'click', + role: 'legend-item', + hits: [], + legend: { field: '__wf_color', domain: { kind: 'value', value: 'increase' } }, + }, { + allHits: hits, + keyField: INTERACTION_KEY, + }); + expect(target?.elements.flatMap(semanticElementRenderKeys)).toEqual(['Asia', 'Africa']); + }); + + it('presents derived Waterfall ranges after source provenance enrichment', () => { + const semantics = waterfallChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Step', type: 'ordinal' }, + y: { field: 'Population', type: 'quantitative' }, + }, + }); + const element = { + value: { Step: 'Asia', __wf_prev_sum: 2_537, __wf_sum: 5_779 }, + records: [{ Step: 'Asia', Population: 3_242 }], + }; + + expect(semantics.presentUpdate!( + annotationUpdate(element, { kind: 'mark', role: 'waterfall-step' }), + { chartType: 'Waterfall Chart', selected: [] }, + ).ops[0]).toMatchObject({ value: { text: '2,537 → 5,779' } }); + }); + + it('presents a Sparkline segment transition', () => { + const semantics = sparklineDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'Month', type: 'temporal' }, + y: { field: 'Value', type: 'quantitative' }, + row: { field: 'Metric', type: 'nominal' }, + }, + }); + const element = { + value: { Month: 'Mar', Metric: 'Active users', Value: 42 }, + records: [ + { Month: 'Mar', Metric: 'Active users', Value: 42 }, + { Month: 'Apr', Metric: 'Active users', Value: 54 }, + ], + }; + + const update = semantics.presentUpdate!( + annotationUpdate(element, { kind: 'path', role: 'line' }), + { chartType: 'Sparkline', selected: [] }, + ); + expect(update.ops[0]).toMatchObject({ + value: { + text: '42 → 54', + }, + }); + expect((update.ops[0] as any).value.candidates[0]).toEqual({ + connection: 'segment-midpoint', priority: 0, + }); + }); + + it('compiles grouped-bar semantic fields from its template', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { Class: '1st', Sex: 'Female', Survival: 97 }, + { Class: '1st', Sex: 'Male', Survival: 34 }, + ], + }, + semantic_types: { Class: 'Category', Sex: 'Category', Survival: 'Quantity' }, + chart_spec: { + chartType: 'Grouped Bar Chart', + encodings: { + x: { field: 'Class' }, + y: { field: 'Survival' }, + group: { field: 'Sex' }, + }, + }, + } as any) as any; + + expect(spec._interactionSemantics).toMatchObject({ + fields: ['Class', 'Survival', 'Sex'], + categoryField: 'Class', + seriesField: 'Sex', + selectableMarks: ['bar'], + }); + }); + + it('instruments path marks without splitting them by interaction detail', () => { + const spec: Record = { + mark: 'line', + encoding: { + x: { field: 'Date', type: 'temporal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + _interactionSemantics: { + fields: ['Date', 'Value'], + categoryField: 'Date', + selectableMarks: ['line'], + }, + }; + + expect(addVegaLiteInteractions(spec, [clickMark()])).toMatchObject({ + fields: ['Date', 'Value'], + }); + expect(spec.encoding).not.toHaveProperty('detail'); + expect(spec.encoding.opacity.condition.test).toContain("!length(data('__flint_interaction_store'))"); + }); + + it.each([ + ['Bump Chart', bumpChartDef, ['line', 'point'], 'X'], + ['Slope Chart', slopeChartDef, ['line', 'point'], 'X'], + ['Regression', regressionDef, ['circle'], 'X'], + ['Streamgraph', streamgraphDef, ['area'], 'X'], + ['Map', mapDef, ['circle'], 'Longitude'], + ['Density Plot', densityPlotDef, ['area'], 'value'], + ['ECDF Plot', ecdfPlotDef, ['line', 'point'], 'X'], + ['Calendar Heatmap', vlCalendarHeatmapDef, ['rect'], '__flintCalendarWeek'], + ['Sparkline', sparklineDef, ['line'], 'X'], + ['Violin Plot', violinPlotDef, ['area'], 'X'], + ['Bullet Chart', bulletChartDef, ['bar', 'tick'], 'Value'], + ['KPI Card', kpiCardDef, ['rect'], 'Metric'], + ['Radar Chart', radarChartDef, ['line', 'point'], 'X'], + ['Choropleth', choroplethDef, ['geoshape'], 'Region'], + ] as const)('declares semantic marks and identity for %s', (_name, definition, marks, identityField) => { + const semantics = definition.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'X', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Series', type: 'nominal' }, + detail: { field: 'Detail', type: 'nominal' }, + row: { field: 'Row', type: 'nominal' }, + column: { field: 'Column', type: 'nominal' }, + longitude: { field: 'Longitude', type: 'quantitative' }, + latitude: { field: 'Latitude', type: 'quantitative' }, + size: { field: 'Size', type: 'quantitative' }, + goal: { field: 'Goal', type: 'quantitative' }, + metric: { field: 'Metric', type: 'nominal' }, + value: { field: 'Value', type: 'quantitative' }, + id: { field: 'Region', type: 'nominal' }, + }, + }); + + expect(semantics.selectableMarks).toEqual(marks); + expect(semantics.fields).toContain(identityField); + expect(semantics.resolve).toBeTypeOf('function'); + }); + + it('keeps Radar legend semantics and tuples in authored field names', () => { + const rows = [ + { Food: 'Oats', Nutrient: 'Protein', Amount: 17 }, + { Food: 'Oats', Nutrient: 'Fiber', Amount: 11 }, + { Food: 'Almonds', Nutrient: 'Protein', Amount: 21 }, + { Food: 'Almonds', Nutrient: 'Fiber', Amount: 12 }, + ]; + const spec = assembleVegaLite({ + data: { values: rows }, + semantic_types: { Food: 'Category', Nutrient: 'Category', Amount: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + encodings: { x: 'Nutrient', y: 'Amount', color: 'Food' }, + }, + } as any) as any; + const lineValues = spec.layer.find((layer: any) => layer.mark?.type === 'line')?.data?.values; + + expect(spec._interactionSemantics).toMatchObject({ + fields: ['Nutrient', 'Amount', 'Food'], + categoryField: 'Nutrient', + seriesField: 'Food', + legendFields: { color: 'Food' }, + }); + expect(lineValues[0]).toMatchObject({ Food: 'Oats', Nutrient: 'Protein', Amount: 17 }); + const oats = spec._interactionSemantics.resolve({ + gesture: 'click', role: 'legend-item', hits: [], + legend: { field: 'Food', domain: { kind: 'value', value: 'Oats' } }, + }, { + allHits: lineValues.map((datum: Record, index: number) => ({ + datum: { ...datum, [INTERACTION_KEY]: String(index) }, + source: 'mark' as const, + })), + keyField: INTERACTION_KEY, + }); + expect(oats?.elements.every((element: SemanticElement) => element.value.Food === 'Oats')).toBe(true); + expect(sourceRecordsForRenderedRecords( + lineValues.filter((datum: Record) => datum.Food === 'Oats'), + rows, + ['Nutrient', 'Amount', 'Food'], + )).toEqual(rows.slice(0, 2)); + }); + + it('includes the closing edge in Radar path interaction geometry', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Food: 'Oats', Nutrient: 'Protein', Amount: 17 }, + { Food: 'Oats', Nutrient: 'Fat', Amount: 7 }, + { Food: 'Oats', Nutrient: 'Carbs', Amount: 66 }, + { Food: 'Oats', Nutrient: 'Fiber', Amount: 11 }, + { Food: 'Oats', Nutrient: 'Sugar', Amount: 1 }, + ] }, + semantic_types: { Food: 'Category', Nutrient: 'Category', Amount: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + encodings: { x: 'Nutrient', y: 'Amount', color: 'Food' }, + }, + } as any) as any; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const segments = sceneItems(view).filter((item) => + item.mark?.marktype === 'line' && item.datum.Food === 'Oats'); + + expect(segments).toHaveLength(5); + expect(segments.every((segment) => segment.interactionGeometry.closed)).toBe(true); + expect(segments.at(-1)?.datum.Nutrient).toBe('Sugar'); + expect(segments.at(-1)?.interactionGeometry.endDatum.Nutrient).toBe('Protein'); + }); + + it('derives the angular brush frame from a rendered Radar grid with a legend', async () => { + const values = [ + ['Oats', 17, 7, 66, 11, 1], + ['Almonds', 21, 49, 22, 12, 4], + ].flatMap(([Food, ...amounts]) => ['Protein', 'Fat', 'Carbs', 'Fiber', 'Sugar'] + .map((Nutrient, index) => ({ Food, Nutrient, Amount: amounts[index] }))); + const spec = assembleVegaLite({ + data: { values }, + semantic_types: { Food: 'Category', Nutrient: 'Category', Amount: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + encodings: { x: 'Nutrient', y: 'Amount', color: 'Food' }, + }, + } as any) as any; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + + const frame = polarFrameFromRadarGrid(view); + const spokes = allSceneItems(view).filter((item) => + item.mark?.marktype === 'rule' && item.datum?.__type === 'spoke'); + + expect(frame).toBeDefined(); + expect(spokes).toHaveLength(5); + expect(frame!.outerRadius).toBeCloseTo(Math.hypot( + spokes[0].x2 - spokes[0].x, + spokes[0].y2 - spokes[0].y, + )); + expect(frame!.outerRadius).toBeLessThan(Math.min(view.width(), view.height()) / 2); + }); + + it('acquires the nearest Radar edge across overlapping filled series', async () => { + const values = [ + ['Oats', 17, 7, 66, 11, 1], + ['Almonds', 21, 49, 22, 12, 4], + ].flatMap(([Food, ...amounts]) => ['Protein', 'Fat', 'Carbs', 'Fiber', 'Sugar'] + .map((Nutrient, index) => ({ Food, Nutrient, Amount: amounts[index] }))); + const spec = assembleVegaLite({ + data: { values }, + semantic_types: { Food: 'Category', Nutrient: 'Category', Amount: 'Quantity' }, + chart_spec: { + chartType: 'Radar Chart', + encodings: { x: 'Nutrient', y: 'Amount', color: 'Food' }, + }, + } as any) as any; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const segments = sceneItems(view).filter((item) => item.mark?.marktype === 'line'); + const oats = segments.find((item) => item.datum.Food === 'Oats'); + const almond = segments.find((item) => item.datum.Food === 'Almonds'); + const [start, end] = almond.interactionGeometry.points; + const point = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; + + const acquired = physicalItemAt(view, oats, point); + + expect(acquired.mark).toBe(almond.mark); + expect(acquired.datum.Food).toBe('Almonds'); + }); + + it('instruments marks inside a nested facet unit spec', () => { + const spec: Record = { + data: { values: [{ Group: 'A', X: 1, Value: 2 }] }, + facet: { row: { field: 'Group', type: 'nominal' } }, + spec: { + mark: 'line', + encoding: { + x: { field: 'X', type: 'quantitative' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }, + _interactionSemantics: { + fields: ['Group', 'X', 'Value'], + categoryField: 'Group', + selectableMarks: ['line'], + }, + }; + + const { plan, compiled } = instrument(spec); + expect(plan?.fields).toEqual(['Group', 'X', 'Value']); + expect(spec.spec.encoding.opacity.condition.test).toContain(INTERACTION_STORE); + expect(() => parse(compiled)).not.toThrow(); + }); + + it('resolves distinct plot frames for rendered row facets', async () => { + const spec = assembleVegaLite({ + data: { values: [ + { Class: '1st', Survival: 90, Sex: 'Female' }, + { Class: '2nd', Survival: 80, Sex: 'Female' }, + { Class: '1st', Survival: 30, Sex: 'Male' }, + { Class: '2nd', Survival: 20, Sex: 'Male' }, + ] }, + semantic_types: { Class: 'Category', Survival: 'Number', Sex: 'Category' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Class', y: 'Survival', row: 'Sex' }, + baseSize: { width: 350, height: 240 }, + }, + } as any) as any; + const { compiled } = instrument(spec); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const bars = sceneItems(view).filter((item) => item.mark?.marktype === 'rect'); + const female = bars.find((item) => item.datum.Sex === 'Female'); + const male = bars.find((item) => item.datum.Sex === 'Male'); + const fallback = { x: -1, y: -1, width: 1, height: 1 }; + const frameFor = (item: any) => facetPlotFrameAt(view, { + x: (item.bounds.x1 + item.bounds.x2) / 2, + y: (item.bounds.y1 + item.bounds.y2) / 2, + }, fallback); + + const femaleFrame = frameFor(female); + const maleFrame = frameFor(male); + expect(femaleFrame).not.toEqual(fallback); + expect(maleFrame).not.toEqual(fallback); + expect(femaleFrame.y).not.toBe(maleFrame.y); + expect(femaleFrame.height).toBe(maleFrame.height); + }); + + it('does not instrument marks declared as decorative', () => { + const spec: Record = { + layer: [ + { + mark: 'rect', + data: { values: [{ Category: 'frame' }] }, + [INTERACTION_PROVENANCE]: { + role: 'decorative', + identity: 'inherit', + presentation: 'independent', + }, + }, + { + mark: 'rect', + data: { values: [{ Category: 'value' }] }, + }, + ], + _interactionSemantics: { + fields: ['Category'], + selectableMarks: ['rect'], + }, + }; + + instrument(spec); + expect(spec.layer[0].encoding).toBeUndefined(); + expect(spec.layer[1].encoding.opacity.condition.test).toContain(INTERACTION_STORE); + }); + + it('compiles geoshapes into renderable semantic shape hits', async () => { + const spec: Record = { + data: { + values: [{ + type: 'Feature', + id: 'A', + Region: 'Alpha', + properties: {}, + geometry: { + type: 'Polygon', + coordinates: [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]], + }, + }], + }, + mark: 'geoshape', + projection: { type: 'mercator' }, + _interactionSemantics: { + fields: ['Region'], + categoryField: 'Region', + selectableMarks: ['geoshape'], + }, + }; + + const { plan, compiled } = instrument(spec); + expect(plan?.fields).toEqual(['Region']); + expect(compiled.marks).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'shape' }), + ])); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const shape = sceneItems(view).find((item) => item.mark.marktype === 'shape'); + expect(renderHit(shape)).toMatchObject({ + datum: { [INTERACTION_KEY]: expect.any(String) }, + markType: 'shape', + }); + view.finalize(); + }); +}); +describe('set-style visibility', () => { + it('injects absolute runtime style channels keyed by semantic identity', () => { + const spec: Record = { + _interactionSemantics: { fields: ['Category'], selectableMarks: ['bar'] }, + data: { values: [{ Category: 'A', Value: 1 }] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { value: '#4472c4' }, + }, + }; + const { compiled } = instrument(spec); + expect(compiled.signals).toContainEqual({ name: STYLE_SIGNAL, value: {} }); + expect(JSON.stringify(compiled.marks)).toContain(`${STYLE_SIGNAL}[datum.${INTERACTION_KEY}]`); + }); + + it('maps compiled axis scales to authored discrete fields and resolves native ticks', async () => { + const compiled = compile({ + data: { values: [{ Category: 'A', Value: 1 }] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }).spec as Record; + const targets = collectVegaAxisTargets(compiled, { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, [{ axis: 'x', field: 'Category' }]); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const labels = allSceneItems(view).filter((item) => item.mark?.role === 'axis-label'); + const categoryLabel = labels.find((item) => item.datum?.value === 'A'); + const quantityLabel = labels.find((item) => item.datum?.value === 1); + expect(axisTargetIdentity(categoryLabel, targets)).toMatchObject({ + axis: 'x', field: 'Category', value: 'A', role: 'axis-label', + }); + expect(axisTargetIdentity(quantityLabel, targets)).toBeNull(); + expect(JSON.stringify(compiled.axes)).toContain('"interactive":true'); + expect(compiled.axes.find((axis: any) => axis.orient === 'bottom') + ?.encode?.labels?.update?.cursor).toBeUndefined(); + expect(compiled.axes.find((axis: any) => axis.orient === 'left') + ?.encode?.labels?.update?.cursor).toBeUndefined(); + view.finalize(); + }); + + it('highlights hovered discrete x and y axis labels', async () => { + const compiled = compile({ + data: { values: [{ Column: 'A', Row: 'R', Value: 1 }] }, + mark: 'rect', + encoding: { + x: { field: 'Column', type: 'nominal' }, + y: { field: 'Row', type: 'nominal' }, + color: { field: 'Value', type: 'quantitative' }, + }, + }).spec as Record; + const targets = collectVegaAxisTargets(compiled, { + x: { field: 'Column', type: 'nominal' }, + y: { field: 'Row', type: 'nominal' }, + }, [], '#123456'); + injectVegaInteractionStore(compiled); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + const labels = () => allSceneItems(view).filter((item) => item.mark?.role === 'axis-label'); + const xLabel = () => labels().find((item) => item.datum?.value === 'A'); + const yLabel = () => labels().find((item) => item.datum?.value === 'R'); + const xIdentity = axisTargetIdentity(xLabel(), targets)!; + const yIdentity = axisTargetIdentity(yLabel(), targets)!; + + view.change(AXIS_HOVER_STORE, changeset().remove(() => true).insert([{ + scale: xIdentity.scale, value: xIdentity.value, + }])); + await view.runAsync(); + expect(xLabel()).toMatchObject({ fill: '#123456', fontWeight: 600 }); + expect(yLabel()?.fontWeight).not.toBe(600); + + view.change(AXIS_HOVER_STORE, changeset().remove(() => true).insert([{ + scale: yIdentity.scale, value: yIdentity.value, + }])); + await view.runAsync(); + expect(yLabel()).toMatchObject({ fill: '#123456', fontWeight: 600 }); + expect(xLabel()?.fontWeight).not.toBe(600); + view.finalize(); + }); + + it('turns an axis target into a style update without accepting mark targets', () => { + const interaction = axisHighlight({ axis: 'x', dimOpacity: 0.2 }); + const context = { chartType: 'Bar Chart', selected: [] }; + const axisTarget = { + visual: { kind: 'axis' as const, role: 'axis-label' }, + elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], + }; + const update = interaction.handle!({ + action: 'click-axis', phase: 'commit', geometry: {}, target: axisTarget, + }, context); + expect(update?.ops[0]).toMatchObject({ + op: 'set-style', targets: [axisTarget], value: { state: 'emphasized', mutedOpacity: 0.2 }, + }); + expect(interaction.handle!({ + action: 'click-element', phase: 'commit', geometry: {}, + target: { visual: { kind: 'mark', role: 'bar' }, elements: [] }, + }, context)).toBeNull(); + }); + + it('pins the legend domain so a hidden series keeps a key to click', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category', 'Series'], + categoryField: 'Category', + legendFields: { color: 'Series' }, + selectableMarks: ['bar'], + }, + data: { + values: [ + { Category: 'A', Series: 'Female', Value: 12 }, + { Category: 'A', Series: 'Male', Value: 8 }, + ], + }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Series', type: 'nominal', scale: { scheme: 'tableau10' } }, + }, + }; + + addVegaLiteInteractions(spec, [legendToggle()]); + + expect(spec.encoding.color.scale.domain).toEqual(['Female', 'Male']); + }); + + it('pins an aggregate-sorted donut legend so hidden slices keep their keys', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['OS', 'Users'], + legendFields: { color: 'OS' }, + selectableMarks: ['arc'], + }, + data: { + values: [ + { OS: 'Android', Users: 70 }, + { OS: 'iOS', Users: 28 }, + { OS: 'Other', Users: 2 }, + ], + }, + mark: { type: 'arc', innerRadius: 50 }, + encoding: { + theta: { field: 'Users', type: 'quantitative', aggregate: 'sum' }, + color: { + field: 'OS', type: 'nominal', + sort: { field: 'Users', op: 'sum', order: 'descending' }, + }, + }, + }; + + addVegaLiteInteractions(spec, [legendToggle()]); + + expect(spec.encoding.color.scale.domain).toEqual(['Android', 'iOS', 'Other']); + }); + + it('clips marks when a region interaction drives the viewport', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Year', 'Value'], + selectableMarks: ['line'], + navigationAxes: ['x', 'y'], + }, + data: { values: [{ Year: 2020, Value: 1 }, { Year: 2021, Value: 2 }] }, + mark: { type: 'line', point: true }, + encoding: { + x: { field: 'Year', type: 'quantitative' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }; + + const plan = addVegaLiteInteractions(spec, [brushZoom()]); + + expect(spec.mark).toMatchObject({ type: 'line', clip: true }); + expect(spec.mark.point).toBe(true); + expect(spec.layer).toBeUndefined(); + expect(plan?.semanticStores).toBe(false); + expect(plan?.navigationChannels).toEqual(['x', 'y']); + }); + + it('leaves the legend domain alone when nothing can hide a series', () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category', 'Series'], + categoryField: 'Category', + legendFields: { color: 'Series' }, + selectableMarks: ['bar'], + }, + data: { values: [{ Category: 'A', Series: 'Female', Value: 12 }] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + color: { field: 'Series', type: 'nominal' }, + }, + }; + + addVegaLiteInteractions(spec, [clickMark()]); + + expect(spec.encoding.color.scale?.domain).toBeUndefined(); + }); + + it('filters a hidden key out of the data and rescales the remaining rows', async () => { + const spec: Record = { + _interactionSemantics: { + fields: ['Category'], + categoryField: 'Category', + selectableMarks: ['bar'], + }, + data: { values: [{ Category: 'A', Value: 12 }, { Category: 'B', Value: 8 }] }, + mark: 'bar', + encoding: { + x: { field: 'Category', type: 'nominal' }, + y: { field: 'Value', type: 'quantitative' }, + }, + }; + + const { compiled } = instrument(spec, [clickMark()]); + expect(compiled.data).toContainEqual({ name: HIDDEN_STORE, values: [] }); + const view = new View(parse(compiled), { renderer: 'none' }); + await view.runAsync(); + + // The transparent click-to-clear rect carries no key and is not a data mark. + const bars = () => allSceneItems(view) + .filter((item) => item.mark?.marktype === 'rect' && item.datum?.[INTERACTION_KEY]) + .map((item) => item.datum[INTERACTION_KEY]); + const yDomain = () => view.scale('y').domain(); + + expect(bars()).toHaveLength(2); + const tallestKey = bars()[0]; + const fullMax = yDomain()[1]; + + view.change(HIDDEN_STORE, changeset().insert([{ key: tallestKey }])); + await view.runAsync(); + + expect(bars()).toHaveLength(1); + expect(bars()).not.toContain(tallestKey); + expect(yDomain()[1]).toBeLessThan(fullMax); + + view.change(HIDDEN_STORE, changeset().remove(() => true)); + await view.runAsync(); + expect(bars()).toHaveLength(2); + expect(yDomain()[1]).toBe(fullMax); + view.finalize(); + }); +}); 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/stacked-bar-tooltip.test.ts b/packages/flint-js/tests/stacked-bar-tooltip.test.ts new file mode 100644 index 00000000..5aaf0a72 --- /dev/null +++ b/packages/flint-js/tests/stacked-bar-tooltip.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { compile } from 'vega-lite'; +import { assembleVegaLite } from '../src'; + +function compiledTooltips(node: any): any[] { + if (!node || typeof node !== 'object') return []; + const current = node.encode?.update?.tooltip ? [node.encode.update.tooltip] : []; + return [ + ...current, + ...Object.values(node).flatMap((value) => compiledTooltips(value)), + ]; +} + +describe('Stacked Bar Chart tooltips', () => { + it('shows authored fields and display names without generated stack-order fields', () => { + const spec = assembleVegaLite({ + data: { + values: [ + { country: 'France', share: 65, source: 'Nuclear' }, + { country: 'France', share: 27, source: 'Renewables' }, + { country: 'China', share: 62, source: 'Fossil' }, + ], + }, + semantic_types: { country: 'Country', share: 'Quantity', source: 'Category' }, + chart_spec: { + chartType: 'Stacked Bar Chart', + encodings: { x: 'country', y: 'share', color: 'source' }, + }, + field_display_names: { + country: 'Country', + share: 'Energy share', + source: 'Source', + }, + } as never) as any; + + expect(spec.encoding.tooltip).toEqual([ + { field: 'country', type: 'nominal', title: 'Country' }, + { field: 'share', type: 'quantitative', title: 'Energy share' }, + { field: 'source', type: 'nominal', title: 'Source' }, + ]); + expect(JSON.stringify(spec.encoding.tooltip)).not.toContain('sort_index'); + + const tooltips = compiledTooltips(compile(spec).spec); + expect(tooltips.length).toBeGreaterThan(0); + expect(JSON.stringify(tooltips)).toContain('Energy share'); + expect(JSON.stringify(tooltips)).not.toContain('sort_index'); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/static-series.test.ts b/packages/flint-js/tests/static-series.test.ts index c00057ab..90a2a737 100644 --- a/packages/flint-js/tests/static-series.test.ts +++ b/packages/flint-js/tests/static-series.test.ts @@ -3,14 +3,18 @@ import { describe, it, expect } from 'vitest'; import { + normalizeChartEncodingAliases, normalizeStaticSeries, STATIC_SERIES_KEY_COLUMN, STATIC_SERIES_VALUE_COLUMN, } from '../src/core/static-series'; import { - assembleVegaLite, - assembleECharts, assembleChartjs, + assembleECharts, + assembleExcel, + assembleImageCharts, + assemblePlotly, + assembleVegaLite, } from '../src'; import type { ChartAssemblyInput } from '../src'; @@ -24,6 +28,42 @@ const WIDE_DATA = [ { Month: '2018-09-01', 'Sales Amount': 3287605.93, 'Sales Due Date': 3707611.99 }, ]; +describe('grouped bar encoding aliases', () => { + const rows = [ + { Region: 'East', Segment: 'Consumer', Sales: 10 }, + { Region: 'East', Segment: 'Business', Sales: 20 }, + { Region: 'West', Segment: 'Consumer', Sales: 30 }, + { Region: 'West', Segment: 'Business', Sales: 40 }, + ]; + const input = (seriesChannel: 'color' | 'group'): ChartAssemblyInput => ({ + data: { values: rows }, + semantic_types: { Region: 'Category', Segment: 'Category', Sales: 'Quantity' }, + chart_spec: { + chartType: 'Grouped Bar Chart', + encodings: { x: 'Region', y: 'Sales', [seriesChannel]: 'Segment' }, + }, + }); + + it('canonicalizes a lone color binding without changing other chart types', () => { + expect(normalizeChartEncodingAliases('Grouped Bar Chart', { color: 'Segment' })) + .toEqual({ group: 'Segment' }); + expect(normalizeChartEncodingAliases('Stacked Bar Chart', { color: 'Segment' })) + .toEqual({ color: 'Segment' }); + }); + + it.each([ + ['Vega-Lite', assembleVegaLite], + ['ECharts', assembleECharts], + ['Chart.js', assembleChartjs], + ['Plotly', assemblePlotly], + ['Excel', assembleExcel], + ['Image-Charts', assembleImageCharts], + ] as const)('%s compiles color and group bindings identically', (_name, assemble) => { + expect(JSON.stringify(assemble(input('color') as never))) + .toBe(JSON.stringify(assemble(input('group') as never))); + }); +}); + // --------------------------------------------------------------------------- // normalizeStaticSeries — validation tests // --------------------------------------------------------------------------- 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-presets.test.ts b/packages/flint-js/tests/theme-presets.test.ts index 29394baa..e1752d69 100644 --- a/packages/flint-js/tests/theme-presets.test.ts +++ b/packages/flint-js/tests/theme-presets.test.ts @@ -317,6 +317,23 @@ describe('cartoon mark character', () => { expect(JSON.stringify(thin._theme?.report ?? [])).toContain('round the bar away'); }); + it('keeps ranged histogram bars square under Power BI', () => { + const spec = assembleVegaLite({ + data: { + values: [1.7, 1.9, 2.1, 2.4, 3.1, 3.4, 3.8, 4.2, 4.6].map((duration) => ({ + 'Duration (min)': duration, + })), + }, + semantic_types: { 'Duration (min)': 'Quantity' }, + chart_spec: { chartType: 'Histogram', encodings: { x: 'Duration (min)' } }, + theme_spec: THEME_PRESETS.powerbi.spec, + } as any) as any; + + const barMark = (spec.layer ?? [spec]).find((l: any) => markTypeOf(l.mark) === 'bar')?.mark; + expect(spec.config.bar.cornerRadiusEnd).toBe(3); + expect(barMark?.cornerRadiusEnd).toBe(0); + }); + it('keeps a crowded trajectory in the lab dot-to-line proportion', () => { const diameter = (size: number) => 2 * Math.sqrt(size / Math.PI); const ratioOf = (spec: any) => { 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/waterfall-titles.test.ts b/packages/flint-js/tests/waterfall-titles.test.ts index 6849d84f..548070db 100644 --- a/packages/flint-js/tests/waterfall-titles.test.ts +++ b/packages/flint-js/tests/waterfall-titles.test.ts @@ -84,6 +84,39 @@ function allEncodings(spec: any): Array<[string, any]> { } describe('Waterfall Chart axis titles', () => { + it('uses display names in tooltips without exposing internal transform fields', () => { + const spec = build({ wsu_change: 'WSU weekly change', week: 'Week (Mon, JST)' }); + const bar = spec.layer.find((layer: any) => (layer.mark?.type ?? layer.mark) === 'bar'); + const connector = spec.layer.find((layer: any) => (layer.mark?.type ?? layer.mark) === 'rule'); + + expect(bar.encoding.tooltip).toEqual([ + { field: 'week', type: 'ordinal', title: 'Week (Mon, JST)' }, + { field: 'wsu_change', type: 'quantitative', title: 'WSU weekly change' }, + { field: '__wf_color', type: 'nominal', title: 'Type' }, + ]); + expect(bar.encoding.tooltip.every((entry: any) => !entry.title.startsWith('__wf_'))).toBe(true); + expect(connector.encoding.tooltip).toBeNull(); + }); + + it('uses the authored type field and its display name in tooltips', () => { + const spec = assembleVegaLite({ + data: { values: DATA.map((row, index) => ({ ...row, kind: index === 0 ? 'start' : 'delta' })) }, + semantic_types: { week: 'Date', wsu_change: 'Quantity', kind: 'Category' }, + chart_spec: { + chartType: 'Waterfall Chart', + encodings: { x: { field: 'week' }, y: { field: 'wsu_change' }, color: { field: 'kind' } }, + }, + field_display_names: { week: 'Week', wsu_change: 'Weekly change', kind: 'Change type' }, + } as never) as any; + const bar = spec.layer.find((layer: any) => (layer.mark?.type ?? layer.mark) === 'bar'); + + expect(bar.encoding.tooltip).toEqual([ + { field: 'week', type: 'ordinal', title: 'Week' }, + { field: 'wsu_change', type: 'quantitative', title: 'Weekly change' }, + { field: 'kind', type: 'nominal', title: 'Change type' }, + ]); + }); + it('applies field_display_names to the x and y axes', () => { const spec = build({ wsu_change: 'WSU weekly change', week: 'Week (Mon, JST)' }); 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/README.md b/packages/flint-mcp/README.md index ae31bcde..4e1ef0f8 100644 --- a/packages/flint-mcp/README.md +++ b/packages/flint-mcp/README.md @@ -176,6 +176,21 @@ deployment, reject local file references and accept only inline rows: npx -y flint-chart-mcp --disable-file-reference ``` +### Local file compile (`flint-chart`) + +Compile a saved `ChartAssemblyInput` JSON to SVG or PNG without an agent: + +```bash +flint-chart compile chart.json --format svg +flint-chart compile chart.json --backend echarts --format png --output chart.png +cat chart.json | flint-chart compile - --format svg > chart.svg +flint-chart chart.json --format svg --output chart.svg # shorthand, compile is optional +``` + +Options: `--backend ` (default `vegalite`), `--format ` (default `svg` except `chartjs` → `png`), `--output ` / `-o ` (`-` for stdout; default `.` next to input, stdout when input is `-`), `--scale <0.5–4>`, `--background `, `-h/--help`, `-v/--version`. + +Relative `data.url` paths in the input resolve against the input file's directory, or the current working directory when reading from stdin (`-`). + ## Example `render_chart` call ```jsonc 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-mcp/assets/flint-theme-author.SKILL.md b/packages/flint-mcp/assets/flint-theme-author.SKILL.md index 4df9c637..925d9697 100644 --- a/packages/flint-mcp/assets/flint-theme-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-theme-author.SKILL.md @@ -129,7 +129,7 @@ the authored blocks and their jobs: | `layout` | Density, target width, title block, and band step | | `chartDefaults` | Optional defaults keyed by registered chart type or `*`; caller values still win | | `compileDefaults` | Preferred base size, canvas size, and supported assemble options | -| `interaction` | Tooltip format | +| `interaction` | Tooltip format and semantic selection-boundary paint | | `variants` | Conditional policy adaptations; variants may not change `ink` or `type` | ### High-value nested shapes @@ -154,6 +154,8 @@ the authored blocks and their jobs: } ``` +`interaction.selectionBoundary` accepts `color`, `width`, `opacity`, `haloColor`, `haloWidth`, and `haloOpacity`. Omitted paint is grounded from the theme: foreground from `ink.accent` then primary text, and halo from the plot or canvas surface. Use explicit values only when the house has a distinct interaction treatment. + This is a shape example, not a palette recommendation. Derive actual values from the user's references. diff --git a/packages/flint-mcp/package.json b/packages/flint-mcp/package.json index efd74d77..06556d16 100644 --- a/packages/flint-mcp/package.json +++ b/packages/flint-mcp/package.json @@ -28,7 +28,8 @@ }, "type": "module", "bin": { - "flint-chart-mcp": "dist/cli.js" + "flint-chart-mcp": "dist/cli.js", + "flint-chart": "dist/flint-chart.js" }, "main": "./dist/server.js", "types": "./dist/server.d.ts", diff --git a/packages/flint-mcp/src/cli.ts b/packages/flint-mcp/src/cli.ts index 3b2af32b..b773ba64 100644 --- a/packages/flint-mcp/src/cli.ts +++ b/packages/flint-mcp/src/cli.ts @@ -2,7 +2,8 @@ // Licensed under the MIT License. import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { createServer, resolveBackends, VERSION } from './server.js'; +import { createServer, resolveBackends } from './server.js'; +import { VERSION } from './version.js'; import { startHttpServer, DEFAULT_MCP_PATH } from './http.js'; import { SUPPORTED_BACKENDS, type SupportedBackend } from './tools/schemas.js'; @@ -12,7 +13,7 @@ MCP server that compiles and renders Flint chart specs to Vega-Lite, ECharts, or Chart.js artifacts (PNG/SVG), entirely in-process. Usage: - flint-chart-mcp [options] + flint-chart-mcp [options] Start the MCP server (stdio by default) Options: --transport Transport to use. Default: stdio. @@ -25,7 +26,7 @@ Options: --allowed-hosts Comma-separated Host header allowlist enabling DNS-rebinding protection (http transport only). --allowed-origins Comma-separated Origin header allowlist enabling - DNS-rebinding protection (http transport only). + DNS-rebinding protection (http transport only). --backends Comma-separated backends to expose (subset of: ${SUPPORTED_BACKENDS.join(', ')}). Overridden by the FLINT_MCP_BACKENDS env var if set. @@ -54,18 +55,21 @@ Prompts: Example MCP client config: { "command": "npx", "args": ["-y", "flint-chart-mcp"] } + +Local file compile (no agent needed): + flint-chart compile chart.json --format svg (via the separate "flint-chart" binary) + See "flint-chart --help" for compile options. `; +// keep runCompile re-export for existing tests importing from cli.js +export { runCompile, type CompileIo } from './compile.js'; + interface ParsedArgs { transport: string; backends?: SupportedBackend[]; - /** When true, reject local data.url file references (inline rows only). */ disableFileReference: boolean; - /** True when --disable-file-reference was explicitly passed on the CLI. */ disableFileReferenceSet: boolean; - /** True when a deprecated --data-root(s) flag was passed (ignored, warned). */ usedDeprecatedDataRoots: boolean; - /** HTTP transport options. */ port?: number; host?: string; path?: string; @@ -75,24 +79,16 @@ interface ParsedArgs { function parseBackends(raw: string | undefined): SupportedBackend[] | undefined { if (!raw) return undefined; - const list = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) as SupportedBackend[]; + const list = raw.split(',').map((s) => s.trim()).filter(Boolean) as SupportedBackend[]; return list.length ? list : undefined; } -/** Split a comma-separated allowlist into trimmed entries. */ function parseList(raw: string | undefined): string[] | undefined { if (!raw) return undefined; - const list = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean); + const list = raw.split(',').map((s) => s.trim()).filter(Boolean); return list.length ? list : undefined; } -/** Parse a boolean env var; undefined when unset so the flag can win. */ function parseBoolEnv(raw: string | undefined): boolean | undefined { if (raw == null) return undefined; const value = raw.trim().toLowerCase(); @@ -147,28 +143,19 @@ function parseArgs(argv: string[]): ParsedArgs { break; case '--data-roots': case '--data-root': - // Deprecated: consume and ignore the value; warned about in main(). i++; out.usedDeprecatedDataRoots = true; break; default: - if (arg.startsWith('--transport=')) { - out.transport = arg.slice('--transport='.length); - } else if (arg.startsWith('--port=')) { - out.port = Number(arg.slice('--port='.length)); - } else if (arg.startsWith('--host=')) { - out.host = arg.slice('--host='.length); - } else if (arg.startsWith('--path=')) { - out.path = arg.slice('--path='.length); - } else if (arg.startsWith('--allowed-hosts=')) { - out.allowedHosts = parseList(arg.slice('--allowed-hosts='.length)); - } else if (arg.startsWith('--allowed-origins=')) { - out.allowedOrigins = parseList(arg.slice('--allowed-origins='.length)); - } else if (arg.startsWith('--backends=')) { - out.backends = parseBackends(arg.slice('--backends='.length)); - } else if (arg.startsWith('--data-roots=') || arg.startsWith('--data-root=')) { - out.usedDeprecatedDataRoots = true; - } else { + if (arg.startsWith('--transport=')) out.transport = arg.slice('--transport='.length); + else if (arg.startsWith('--port=')) out.port = Number(arg.slice('--port='.length)); + else if (arg.startsWith('--host=')) out.host = arg.slice('--host='.length); + else if (arg.startsWith('--path=')) out.path = arg.slice('--path='.length); + else if (arg.startsWith('--allowed-hosts=')) out.allowedHosts = parseList(arg.slice('--allowed-hosts='.length)); + else if (arg.startsWith('--allowed-origins=')) out.allowedOrigins = parseList(arg.slice('--allowed-origins='.length)); + else if (arg.startsWith('--backends=')) out.backends = parseBackends(arg.slice('--backends='.length)); + else if (arg.startsWith('--data-roots=') || arg.startsWith('--data-root=')) out.usedDeprecatedDataRoots = true; + else { process.stderr.write(`Unknown argument: ${arg}\n`); process.exit(2); } @@ -182,46 +169,24 @@ async function main(): Promise { const transport = (process.env.FLINT_MCP_TRANSPORT?.trim() || args.transport).toLowerCase(); if (transport !== 'stdio' && transport !== 'http') { - process.stderr.write( - `Unsupported transport "${transport}". Use "stdio" or "http".\n`, - ); + process.stderr.write(`Unsupported transport "${transport}". Use "stdio" or "http".\n`); process.exit(2); } - // Env var takes precedence over the flag for deployment-time gating. - const enabledBackends = - parseBackends(process.env.FLINT_MCP_BACKENDS) ?? args.backends; + const enabledBackends = parseBackends(process.env.FLINT_MCP_BACKENDS) ?? args.backends; const envDisable = parseBoolEnv(process.env.FLINT_MCP_DISABLE_FILE_REFERENCE); - // The http transport is remote: local files belong to the server, not the - // user, so default to blocking file references unless explicitly overridden. - const disableFileReference = - envDisable ?? (args.disableFileReferenceSet ? args.disableFileReference : transport === 'http'); + const disableFileReference = envDisable ?? (args.disableFileReferenceSet ? args.disableFileReference : transport === 'http'); - // The legacy --data-roots/--data-root flags and FLINT_MCP_DATA_ROOTS env var - // are deprecated and no longer take effect. They USED to allow/whitelist local - // file reads, so we must NOT steer migrators toward --disable-file-reference - // (the opposite intent) — that would accidentally turn off all file charting. if (args.usedDeprecatedDataRoots || process.env.FLINT_MCP_DATA_ROOTS?.trim()) { process.stderr.write( - 'flint-chart-mcp: --data-roots / --data-root (and FLINT_MCP_DATA_ROOTS) are ' + - 'deprecated and have NO effect. Local data.url files are now readable by ' + - 'default, so you can safely REMOVE these flags and local-file charts keep ' + - 'working. (Only add --disable-file-reference if you instead want to BLOCK ' + - 'local file reads.)\n', + 'flint-chart-mcp: --data-roots / --data-root (and FLINT_MCP_DATA_ROOTS) are deprecated and have NO effect. Local data.url files are now readable by default, so you can safely REMOVE these flags and local-file charts keep working. (Only add --disable-file-reference if you instead want to BLOCK local file reads.)\n', ); } - // Validate eagerly so a bad config fails fast with a clear message. const resolved = resolveBackends({ enabledBackends }); - - const dataMode = disableFileReference - ? 'local file references disabled' - : 'local files readable on request'; + const dataMode = disableFileReference ? 'local file references disabled' : 'local files readable on request'; if (transport === 'http') { - // Some hosts (e.g. Azure App Service custom containers) inject an empty - // PORT env var that would override the intended port; treat blank env - // values as unset so the flag/default still applies. const portEnv = process.env.PORT?.trim() || process.env.FLINT_MCP_PORT?.trim(); const port = Number(portEnv || args.port || 8080); if (!Number.isFinite(port) || port <= 0) { @@ -238,10 +203,7 @@ async function main(): Promise { allowedHosts: args.allowedHosts, allowedOrigins: args.allowedOrigins, }); - process.stderr.write( - `flint-chart-mcp ${VERSION} listening on ${running.url} ` + - `(backends: ${resolved.join(', ')}; ${dataMode})\n`, - ); + process.stderr.write(`flint-chart-mcp ${VERSION} listening on ${running.url} (backends: ${resolved.join(', ')}; ${dataMode})\n`); const shutdown = () => { void running.close().finally(() => process.exit(0)); }; @@ -253,16 +215,10 @@ async function main(): Promise { const server = createServer({ enabledBackends, disableFileReference }); const stdio = new StdioServerTransport(); await server.connect(stdio); - - // stdout is the protocol channel; log to stderr only. - process.stderr.write( - `flint-chart-mcp ${VERSION} ready on stdio (backends: ${resolved.join(', ')}; ` + - `${dataMode})\n`, - ); + process.stderr.write(`flint-chart-mcp ${VERSION} ready on stdio (backends: ${resolved.join(', ')}; ${dataMode})\n`); } main().catch((err) => { process.stderr.write(`flint-chart-mcp failed to start: ${err?.stack ?? err}\n`); process.exit(1); }); - diff --git a/packages/flint-mcp/src/compile.ts b/packages/flint-mcp/src/compile.ts new file mode 100644 index 00000000..f1a965ee --- /dev/null +++ b/packages/flint-mcp/src/compile.ts @@ -0,0 +1,287 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync, writeFileSync } from 'node:fs'; +import { basename, dirname, extname, resolve as resolvePath } from 'node:path'; +import { VERSION } from './version.js'; +import { SUPPORTED_BACKENDS, type SupportedBackend } from './tools/schemas.js'; +import { renderChart } from './render/index.js'; +import type { RenderBackend, RenderFormat } from './render/types.js'; + +const COMPILE_HELP = `flint-chart ${VERSION} + +Compile a saved Flint ChartAssemblyInput JSON to SVG or PNG, entirely in-process. + +Usage: + flint-chart compile [options] + flint-chart [options] (shorthand, same as compile) + +Arguments: + Path to JSON file containing ChartAssemblyInput, or "-" for stdin. + +Options: + --backend Rendering backend: ${SUPPORTED_BACKENDS.join(', ')}. Default: vegalite. + --format Output format. Default: svg (vegalite/echarts) or png (chartjs). + --output , -o + Output file. Default: . next to input (chart.json → chart.svg). + Use "-" for stdout. Defaults to stdout when input is stdin and no output given. + --scale Device scale for PNG (0.5–4). Default: 1. + --background Background color. Default: #ffffff. + -h, --help Print this help and exit. + -v, --version Print version and exit. + +Note: + Relative data.url paths in the input resolve against the input file's + directory, or against the current working directory when reading from stdin. + +Examples: + flint-chart compile chart.json --format svg + flint-chart compile chart.json --backend echarts --format png --output chart.png + cat chart.json | flint-chart compile - --format svg > chart.svg + flint-chart chart.json --format svg --output chart.svg +`; + +interface CompileOptions { + input: string; + backend: RenderBackend; + format: RenderFormat; + output?: string; + scale?: number; + background?: string; +} + +type CompileParseResult = + | { kind: 'run'; options: CompileOptions } + | { kind: 'help' } + | { kind: 'version' }; + +class CompileError extends Error { + constructor(message: string, readonly exitCode: number = 2) { + super(message); + } +} + +function parseCompileArgs(argv: string[]): CompileParseResult { + let input: string | undefined; + let backend: string | undefined; + let format: string | undefined; + let output: string | undefined; + let scale: number | undefined; + let background: string | undefined; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '-h' || arg === '--help') { + return { kind: 'help' }; + } else if (arg === '-v' || arg === '--version') { + return { kind: 'version' }; + } else if (arg === '--backend') { + backend = argv[++i]; + if (!backend) throw new CompileError('Missing value for --backend'); + } else if (arg.startsWith('--backend=')) { + backend = arg.slice('--backend='.length); + } else if (arg === '--format') { + format = argv[++i]; + if (!format) throw new CompileError('Missing value for --format'); + } else if (arg.startsWith('--format=')) { + format = arg.slice('--format='.length); + } else if (arg === '--output' || arg === '-o') { + output = argv[++i]; + if (!output) throw new CompileError('Missing value for --output'); + } else if (arg.startsWith('--output=')) { + output = arg.slice('--output='.length); + } else if (arg.startsWith('-o') && arg.length > 2 && !arg.startsWith('-output')) { + output = arg.slice(2); + } else if (arg === '--scale') { + const raw = argv[++i]; + scale = Number(raw); + if (!Number.isFinite(scale)) throw new CompileError(`Invalid --scale value: ${raw}`); + } else if (arg.startsWith('--scale=')) { + scale = Number(arg.slice('--scale='.length)); + if (!Number.isFinite(scale)) throw new CompileError(`Invalid --scale value: ${arg.slice('--scale='.length)}`); + } else if (arg === '--background') { + background = argv[++i]; + if (!background) throw new CompileError('Missing value for --background'); + } else if (arg.startsWith('--background=')) { + background = arg.slice('--background='.length); + } else if (arg === '-') { + if (input) throw new CompileError(`Unexpected argument: ${arg} (input already set to "${input}")`); + input = arg; + } else if (arg.startsWith('-')) { + throw new CompileError(`Unknown compile option: ${arg}\nRun "flint-chart --help" for usage.`); + } else { + if (input) throw new CompileError(`Unexpected argument: ${arg} (input already set to "${input}")`); + input = arg; + } + } + + if (!input) throw new CompileError('Missing argument.\nRun "flint-chart --help" for usage.'); + + const resolvedBackend = (backend ?? 'vegalite') as RenderBackend; + if (!SUPPORTED_BACKENDS.includes(resolvedBackend as SupportedBackend)) { + throw new CompileError(`Unsupported backend "${resolvedBackend}". Choose one of: ${SUPPORTED_BACKENDS.join(', ')}`); + } + + let resolvedFormat: RenderFormat; + if (format) { + const f = format.toLowerCase() as RenderFormat; + if (f !== 'png' && f !== 'svg') throw new CompileError(`Unsupported format "${format}". Use "png" or "svg".`); + resolvedFormat = f; + } else { + resolvedFormat = resolvedBackend === 'chartjs' ? 'png' : 'svg'; + } + + if (resolvedBackend === 'chartjs' && resolvedFormat === 'svg') { + throw new CompileError('the chartjs backend supports png output only (no SVG engine); request format "png"'); + } + + if (scale !== undefined && (!Number.isFinite(scale) || scale < 0.5 || scale > 4)) { + throw new CompileError(`Invalid --scale ${scale}: must be between 0.5 and 4`); + } + + return { + kind: 'run', + options: { input, backend: resolvedBackend, format: resolvedFormat, output, scale, background }, + }; +} + +export interface CompileIo { + readStdin(): string; + stdout(data: string | Buffer): void; + stderr(line: string): void; +} + +const defaultCompileIo: CompileIo = { + readStdin: () => readFileSync(0, 'utf8'), + stdout: (data) => process.stdout.write(data), + stderr: (line) => process.stderr.write(line), +}; + +function readInputJson(inputPath: string, io: CompileIo): { json: unknown; cwd: string | undefined } { + let raw: string; + let cwd: string | undefined; + if (inputPath === '-') { + try { + raw = io.readStdin(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new CompileError(`Failed to read stdin: ${msg}`, 1); + } + } else { + const abs = resolvePath(inputPath); + try { + raw = readFileSync(abs, 'utf8'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new CompileError(`Failed to read input file "${inputPath}": ${msg}`, 1); + } + cwd = dirname(abs); + } + try { + return { json: JSON.parse(raw), cwd }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new CompileError(`Invalid JSON in "${inputPath}": ${msg}`, 1); + } +} + +function resolveOutputPath(input: string, explicitOutput: string | undefined, format: RenderFormat): string | undefined { + if (explicitOutput) { + if (explicitOutput === '-') return undefined; + return resolvePath(explicitOutput); + } + if (input === '-') return undefined; + const absInput = resolvePath(input); + const dir = dirname(absInput); + const base = basename(absInput, extname(absInput)); + const outBase = base || 'chart'; + return resolvePath(dir, `${outBase}.${format}`); +} + +export async function runCompile(argv: string[], io: CompileIo = defaultCompileIo): Promise { + let parsed: CompileParseResult; + try { + parsed = parseCompileArgs(argv); + } catch (err) { + if (err instanceof CompileError) { + io.stderr(`${err.message}\n`); + return err.exitCode; + } + throw err; + } + + if (parsed.kind === 'help') { + io.stdout(COMPILE_HELP); + return 0; + } + if (parsed.kind === 'version') { + io.stdout(`${VERSION}\n`); + return 0; + } + + const opts = parsed.options; + let json: unknown; + let cwd: string | undefined; + try { + ({ json, cwd } = readInputJson(opts.input, io)); + } catch (err) { + if (err instanceof CompileError) { + io.stderr(`${err.message}\n`); + return err.exitCode; + } + throw err; + } + + const input = json as Record; + if (input == null || typeof input !== 'object' || !('chart_spec' in input) || !('data' in input)) { + io.stderr( + 'Input JSON must be a ChartAssemblyInput with at least { data, chart_spec }.\n' + + 'Example: { "data": { "values": [...] }, "chart_spec": { "chartType": "Bar Chart", "encodings": { "x": { "field": "a" }, "y": { "field": "b" } } } }\n', + ); + return 2; + } + + let result: Awaited>; + try { + result = await renderChart(input as any, opts.backend, { + format: opts.format, + scale: opts.scale, + background: opts.background, + cwd, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + io.stderr(`Compile failed: ${msg}\n`); + return 1; + } + + for (const w of result.warnings) io.stderr(`warning [${w.code}]: ${w.message}\n`); + + const outPath = resolveOutputPath(opts.input, opts.output, opts.format); + try { + if (result.format === 'svg') { + const svg = result.svg ?? ''; + if (outPath) { + writeFileSync(outPath, svg, 'utf8'); + io.stderr(`Wrote ${result.backend} · ${result.format} · ${result.width}×${result.height}px → ${outPath}\n`); + } else { + io.stdout(svg); + } + } else { + const buffer = result.buffer!; + if (outPath) { + writeFileSync(outPath, buffer); + io.stderr(`Wrote ${result.backend} · ${result.format} · ${result.width}×${result.height}px → ${outPath}\n`); + } else { + io.stdout(buffer); + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + io.stderr(`Failed to write output: ${msg}\n`); + return 1; + } + return 0; +} + + diff --git a/packages/flint-mcp/src/flint-chart.ts b/packages/flint-mcp/src/flint-chart.ts new file mode 100644 index 00000000..d4eada09 --- /dev/null +++ b/packages/flint-mcp/src/flint-chart.ts @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runCompile } from './compile.js'; + +async function main(): Promise { + const raw = process.argv.slice(2); + if (raw.length === 0 || raw[0] === '-h' || raw[0] === '--help' || raw[0] === '-v' || raw[0] === '--version') { + const code = await runCompile(raw); + process.exit(code); + } + const argv = raw[0] === 'compile' ? raw.slice(1) : raw; + const code = await runCompile(argv); + process.exit(code); +} + +const isEntry = process.argv[1] !== undefined && (() => { + try { + return resolvePath(process.argv[1]) === resolvePath(fileURLToPath(import.meta.url)); + } catch { + return false; + } +})(); + +if (isEntry) { + main().catch((err) => { + process.stderr.write(`flint-chart failed: ${err?.stack ?? err}\n`); + process.exit(1); + }); +} diff --git a/packages/flint-mcp/src/render/data-source.ts b/packages/flint-mcp/src/render/data-source.ts index 911eca5f..f680c339 100644 --- a/packages/flint-mcp/src/render/data-source.ts +++ b/packages/flint-mcp/src/render/data-source.ts @@ -20,6 +20,12 @@ export interface DataSourceOptions { maxDataFileBytes?: number; /** Row-count guard after loading inline or referenced data. */ maxDataRows?: number; + /** + * Base directory for resolving relative `data.url` paths. Defaults to the + * current working directory. The CLI passes the input file's directory so a + * hand-edited `chart.json` can reference `./data.csv` next to it. + */ + cwd?: string; } /** @@ -68,7 +74,7 @@ export function resolveDataSource( ); } - const filePath = resolveTrustedDataPath(data.url); + const filePath = resolveTrustedDataPath(data.url, options.cwd); const rows = readLocalRows(filePath, options); return { ...input, data: { values: rows } } as ChartAssemblyInput; } @@ -80,11 +86,11 @@ function isRemoteReference(rawUrl: string): boolean { /** * Resolve a local data.url. Any local file the agent can name is read — the host - * governs the agent's file access. Relative references resolve against the - * working directory. + * governs the agent's file access. Relative references resolve against `cwd` (or + * the working directory when not specified). */ -function resolveTrustedDataPath(rawUrl: string): string { - const candidatePaths = trustedReferenceToPaths(rawUrl.trim()); +function resolveTrustedDataPath(rawUrl: string, cwd?: string): string { + const candidatePaths = trustedReferenceToPaths(rawUrl.trim(), cwd); let lastError: unknown; for (const candidatePath of candidatePaths) { try { @@ -105,7 +111,7 @@ function resolveTrustedDataPath(rawUrl: string): string { ); } -function trustedReferenceToPaths(rawReference: string): string[] { +function trustedReferenceToPaths(rawReference: string, cwd?: string): string[] { if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(rawReference)) { const parsedUrl = new URL(rawReference); if (parsedUrl.protocol !== 'file:') { @@ -116,7 +122,9 @@ function trustedReferenceToPaths(rawReference: string): string[] { } return [fileURLToPath(parsedUrl)]; } - // Absolute paths are used as given; relative paths resolve against cwd. + // Absolute paths are used as given; relative paths resolve against cwd when + // given, or the process working directory otherwise. + if (cwd) return [resolvePath(cwd, rawReference)]; return [resolvePath(rawReference)]; } diff --git a/packages/flint-mcp/src/render/index.ts b/packages/flint-mcp/src/render/index.ts index f5ab60f4..439d4e5b 100644 --- a/packages/flint-mcp/src/render/index.ts +++ b/packages/flint-mcp/src/render/index.ts @@ -71,6 +71,7 @@ export async function renderChart( const { spec, warnings, width, height } = assembleForBackend(backend, input, { disableFileReference: options.disableFileReference, + cwd: options.cwd, }); // Extract sizing before stripping Flint's private annotation keys. Vega-Lite diff --git a/packages/flint-mcp/src/render/types.ts b/packages/flint-mcp/src/render/types.ts index 4955a230..35cfcbad 100644 --- a/packages/flint-mcp/src/render/types.ts +++ b/packages/flint-mcp/src/render/types.ts @@ -21,6 +21,8 @@ export interface RenderOptions { background?: string; /** When true, reject local `data.url` file references (inline rows only). */ disableFileReference?: boolean; + /** Base directory for resolving relative `data.url` paths. Defaults to cwd. */ + cwd?: string; } /** A rendered artifact plus the assembly warnings that produced it. */ diff --git a/packages/flint-mcp/src/server.ts b/packages/flint-mcp/src/server.ts index 8ea3f516..9d073b41 100644 --- a/packages/flint-mcp/src/server.ts +++ b/packages/flint-mcp/src/server.ts @@ -23,10 +23,8 @@ import { type AssemblyInputArgs, } from './tools/schemas.js'; -/** Package version, kept in lockstep with the npm release. */ -export const VERSION = JSON.parse( - readFileSync(new URL('../package.json', import.meta.url), 'utf8'), -).version as string; +import { VERSION } from './version.js'; +export { VERSION }; export const AGENT_SKILL_RESOURCE_URI = 'flint://agent-skill'; export const THEME_SKILL_RESOURCE_URI = 'flint://theme-skill'; diff --git a/packages/flint-mcp/src/version.ts b/packages/flint-mcp/src/version.ts new file mode 100644 index 00000000..c58c8acf --- /dev/null +++ b/packages/flint-mcp/src/version.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync } from 'node:fs'; + +export const VERSION: string = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version as string; diff --git a/packages/flint-mcp/tests/compile.test.ts b/packages/flint-mcp/tests/compile.test.ts new file mode 100644 index 00000000..d351f2c7 --- /dev/null +++ b/packages/flint-mcp/tests/compile.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + mkdtempSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runCompile, type CompileIo } from '../src/cli.js'; + +function chartInput(data: unknown): string { + return JSON.stringify({ + data, + semantic_types: { region: 'Category', revenue: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + title: 'Revenue by region', + encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + }, + }); +} + +const CSV = 'region,revenue\nNorth,120\nSouth,90\nEast,150\n'; + +interface IoHarness { + io: CompileIo; + stdout(): Buffer; + stdoutText(): string; + stderrText(): string; + setStdin(text: string): void; +} + +function makeIo(): IoHarness { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let stdinText = ''; + return { + io: { + readStdin: () => stdinText, + stdout: (data) => stdoutChunks.push(Buffer.isBuffer(data) ? data : Buffer.from(data)), + stderr: (line) => stderrChunks.push(Buffer.from(line)), + }, + stdout: () => Buffer.concat(stdoutChunks), + stdoutText: () => Buffer.concat(stdoutChunks).toString('utf8'), + stderrText: () => Buffer.concat(stderrChunks).toString('utf8'), + setStdin: (text) => { + stdinText = text; + }, + }; +} + +let root: string; + +beforeEach(() => { + // realpathSync so macOS /var → /private/var symlink doesn't surprise path + // resolution in stderr assertions. + root = realpathSync(mkdtempSync(join(tmpdir(), 'flint-cli-'))); +}); + +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +describe('compile: argument parsing', () => { + it('prints help and exits 0 for --help / -h', async () => { + for (const flag of ['--help', '-h']) { + const harness = makeIo(); + expect(await runCompile([flag], harness.io)).toBe(0); + expect(harness.stdoutText()).toContain('flint-chart compile'); + } + }); + + it('help documents data.url resolution', async () => { + const harness = makeIo(); + await runCompile(['--help'], harness.io); + expect(harness.stdoutText()).toMatch(/data\.url/i); + expect(harness.stdoutText()).toMatch(/working directory when reading from stdin/i); + }); + + it('prints version and exits 0 for --version / -v', async () => { + for (const flag of ['--version', '-v']) { + const harness = makeIo(); + expect(await runCompile([flag], harness.io)).toBe(0); + expect(harness.stdoutText().trim()).toMatch(/^\d+\.\d+\.\d+/); + } + }); + + it('errors with exit 2 when input is missing', async () => { + const harness = makeIo(); + expect(await runCompile([], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('Missing argument.'); + }); + + it('errors with exit 2 on unknown options', async () => { + const harness = makeIo(); + expect(await runCompile(['--bogus', 'x.json'], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('Unknown compile option: --bogus'); + }); + + it('rejects a single-dash "-output" typo as an unknown option (exit 2)', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [] })); + const harness = makeIo(); + expect(await runCompile(['-output', 'x.svg', chartPath], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('Unknown compile option: -output'); + }); + + it('still accepts the joined -o form', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + const outPath = join(root, 'joined.svg'); + const harness = makeIo(); + expect(await runCompile([`-o${outPath}`, chartPath], harness.io)).toBe(0); + expect(readFileSync(outPath, 'utf8')).toContain(' { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [] })); + const harness = makeIo(); + expect(await runCompile([chartPath, '--backend', 'nope'], harness.io)).toBe(2); + expect(await runCompile([chartPath, '--format', 'gif'], harness.io)).toBe(2); + expect(await runCompile([chartPath, '--scale', '9'], harness.io)).toBe(2); + expect(await runCompile([chartPath, '--scale', 'abc'], harness.io)).toBe(2); + }); + + it('rejects chartjs with svg output (exit 2)', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [] })); + const harness = makeIo(); + expect(await runCompile([chartPath, '--backend', 'chartjs', '--format', 'svg'], harness.io)).toBe(2); + expect(harness.stderrText()).toMatch(/chartjs backend supports png output only/); + }); +}); + +describe('compile: input reading and exit codes', () => { + it('errors with exit 1 for a missing input file', async () => { + const harness = makeIo(); + expect(await runCompile([join(root, 'missing.json')], harness.io)).toBe(1); + expect(harness.stderrText()).toContain('Failed to read input file'); + }); + + it('errors with exit 1 for invalid JSON', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, '{not json'); + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(1); + expect(harness.stderrText()).toContain('Invalid JSON'); + }); + + it('errors with exit 2 when the JSON is not a ChartAssemblyInput', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, JSON.stringify({ hello: 'world' })); + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(2); + expect(harness.stderrText()).toContain('ChartAssemblyInput'); + }); + + it('reports render failures with exit 1 (remote data.url)', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ url: 'https://example.com/sales.csv' })); + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(1); + expect(harness.stderrText()).toContain('Compile failed'); + }); +}); + +describe('compile: rendering and output', () => { + it('resolves relative data.url against the input file directory and writes .svg', async () => { + writeFileSync(join(root, 'sales.csv'), CSV); + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ url: 'sales.csv' })); + + const harness = makeIo(); + expect(await runCompile([chartPath], harness.io)).toBe(0); + expect(harness.stdoutText()).toBe(''); // written to file, not stdout + + const svgPath = join(root, 'chart.svg'); + const svg = readFileSync(svgPath, 'utf8'); + expect(svg).toContain(' { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + + const harness = makeIo(); + expect(await runCompile([chartPath, '--format', 'png'], harness.io)).toBe(0); + const pngPath = join(root, 'chart.png'); + const bytes = readFileSync(pngPath); + expect(bytes.subarray(0, 8)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + expect(harness.stderrText()).toContain('Wrote vegalite · png'); + }); + + it('writes to stdout with -o - or --output -', async () => { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + + for (const flag of ['-o', '--output']) { + const harness = makeIo(); + expect(await runCompile([chartPath, flag, '-'], harness.io)).toBe(0); + expect(harness.stdoutText()).toContain(' { + writeFileSync(join(root, 'sales.csv'), CSV); + const harness = makeIo(); + harness.setStdin(chartInput({ url: 'sales.csv' })); + + const previousCwd = process.cwd(); + try { + process.chdir(root); + expect(await runCompile(['-'], harness.io)).toBe(0); + } finally { + process.chdir(previousCwd); + } + expect(harness.stdoutText()).toContain(' { + const chartPath = join(root, 'chart.json'); + writeFileSync(chartPath, chartInput({ values: [{ region: 'North', revenue: 1 }] })); + const outPath = join(root, 'custom', 'result.svg'); + mkdirSync(join(root, 'custom'), { recursive: true }); + + const harness = makeIo(); + expect(await runCompile([chartPath, '--output', outPath], harness.io)).toBe(0); + expect(readFileSync(outPath, 'utf8')).toContain(' 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/package.json b/site/package.json index a00be58b..db256eec 100644 --- a/site/package.json +++ b/site/package.json @@ -15,6 +15,7 @@ "@fontsource-variable/inter": "^5.2.8", "@uiw/react-codemirror": "^4.23.0", "chart.js": "^4.5.1", + "d3": "^7.9.0", "echarts": "^6.0.0", "flint-chart": "*", "i18next": "^26.3.6", @@ -35,6 +36,7 @@ "vega-lite": "^6.4.1" }, "devDependencies": { + "@types/d3": "^7.4.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@types/react-syntax-highlighter": "^15.5.13", 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/components/GalleryOptionsBar.tsx b/site/src/components/GalleryOptionsBar.tsx index 62b7bef6..46c722dc 100644 --- a/site/src/components/GalleryOptionsBar.tsx +++ b/site/src/components/GalleryOptionsBar.tsx @@ -16,6 +16,7 @@ import type { ChartOption } from 'flint-chart'; import { THEME_PRESETS, DEFAULT_THEME_ICON } from 'flint-chart'; import { siteTheme } from '../shared/theme'; import { chartIconFor } from '../shared/chart-categories'; +import { SiteRange } from './SiteRange'; import { valueKey } from '../shared/chart-options'; import type { ControlSpec, PanelModel, ResolvedAction } from '../shared/chart-options'; import './gallery-options-bar.css'; @@ -340,7 +341,6 @@ function ControlRow(props: { if (spec.type === 'continuous') { const step = spec.step ?? ((spec.max - spec.min) / 100 || 1); const num = typeof value === 'number' ? value : spec.min; - const pct = spec.max > spec.min ? ((num - spec.min) / (spec.max - spec.min)) * 100 : 0; // Reserve enough width for the widest value the slider can show so the // readout never clips (e.g. "50") or reflows as digits change. const readoutCh = Math.max( @@ -350,14 +350,11 @@ function ControlRow(props: { ); control = ( - onChange(Number(e.target.value))} /> diff --git a/site/src/components/SiteRange.tsx b/site/src/components/SiteRange.tsx new file mode 100644 index 00000000..2591c313 --- /dev/null +++ b/site/src/components/SiteRange.tsx @@ -0,0 +1,35 @@ +import type { CSSProperties, InputHTMLAttributes } from 'react'; + +type SiteRangeProps = Omit< + InputHTMLAttributes, + 'type' | 'min' | 'max' | 'value' +> & { + min: number; + max: number; + value: number; +}; + +/** + * Shared range input with a value-driven filled track. + * + * WebKit does not expose a native range-progress pseudo-element, so the site + * track uses `--pct`. Keeping the calculation here prevents controls without + * that custom property from displaying the old, misleading 50% fallback. + */ +export function SiteRange({ min, max, value, className, style, ...props }: SiteRangeProps) { + const percent = max > min + ? Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100)) + : 0; + + return ( + + ); +} diff --git a/site/src/components/SizingPlayground.tsx b/site/src/components/SizingPlayground.tsx index 22b5924f..72548790 100644 --- a/site/src/components/SizingPlayground.tsx +++ b/site/src/components/SizingPlayground.tsx @@ -1,7 +1,8 @@ -import { useMemo, useState, type CSSProperties } from 'react'; +import { useMemo, useState } from 'react'; import { assembleVegaLite, assembleECharts, type ChartAssemblyInput } from 'flint-chart'; import { VegaLiteView } from './VegaLiteView'; import { EChartsView } from './EChartsView'; +import { SiteRange } from './SiteRange'; import { siteTheme } from '../shared/theme'; /** @@ -169,8 +170,6 @@ function Slider({ label, value, min, max, step, onChange, suffix }: { onChange: (v: number) => void; suffix?: string; }) { - const percent = max > min ? ((value - min) / (max - min)) * 100 : 0; - return ( - onChange(Number(e.target.value))} - className="site-range" - style={{ '--pct': `${percent}%` } as CSSProperties} /> ); diff --git a/site/src/components/VegaLiteView.tsx b/site/src/components/VegaLiteView.tsx index e79b964c..ecb9bf66 100644 --- a/site/src/components/VegaLiteView.tsx +++ b/site/src/components/VegaLiteView.tsx @@ -1,15 +1,24 @@ import { useEffect, useRef } from 'react'; import embed from 'vega-embed'; import { readCanvasFurniture } from 'flint-chart'; +import type { View } from 'vega'; const SVG_NS = 'http://www.w3.org/2000/svg'; -export function VegaLiteView({ spec, renderer = 'canvas' }: { spec: any; renderer?: 'canvas' | 'svg' }) { +interface VegaLiteViewProps { + spec: any; + renderer?: 'canvas' | 'svg'; + onReady?: (svg: SVGSVGElement, view: View) => void | (() => void); +} + +export function VegaLiteView({ spec, renderer = 'canvas', onReady }: VegaLiteViewProps) { const ref = useRef(null); useEffect(() => { if (!ref.current) return; const host = ref.current; let cancelled = false; + let cleanupReady: void | (() => void); + let embeddedView: View | undefined; // Canvas-anchored furniture (the Economist red tab) is drawn onto the SVG // after render — Vega-Lite cannot express it. That requires SVG output, so // a spec carrying furniture is forced to render as SVG regardless of the @@ -17,26 +26,35 @@ export function VegaLiteView({ spec, renderer = 'canvas' }: { spec: any; rendere const furniture = readCanvasFurniture(spec); const useRenderer = furniture.length ? 'svg' : renderer; embed(host, spec, { actions: false, renderer: useRenderer }) - .then(() => { - if (cancelled || !furniture.length) return; + .then((result) => { + embeddedView = result.view; + if (cancelled) { + result.view.finalize(); + return; + } const svgEl = host.querySelector('svg'); if (!svgEl) return; - for (const it of furniture) { - const rect = document.createElementNS(SVG_NS, 'rect'); - rect.setAttribute('x', String(it.x)); - rect.setAttribute('y', String(it.y)); - rect.setAttribute('width', String(it.width)); - rect.setAttribute('height', String(it.height)); - rect.setAttribute('fill', it.color); - svgEl.appendChild(rect); + if (furniture.length) { + for (const it of furniture) { + const rect = document.createElementNS(SVG_NS, 'rect'); + rect.setAttribute('x', String(it.x)); + rect.setAttribute('y', String(it.y)); + rect.setAttribute('width', String(it.width)); + rect.setAttribute('height', String(it.height)); + rect.setAttribute('fill', it.color); + svgEl.appendChild(rect); + } } + cleanupReady = onReady?.(svgEl, result.view); }) .catch((err) => { if (!cancelled) console.error('vega-embed failed', err); }); return () => { cancelled = true; + cleanupReady?.(); + embeddedView?.finalize(); }; - }, [spec, renderer]); + }, [spec, renderer, onReady]); return
; } diff --git a/site/src/data/index-chart-stocks.ts b/site/src/data/index-chart-stocks.ts new file mode 100644 index 00000000..17be5fa6 --- /dev/null +++ b/site/src/data/index-chart-stocks.ts @@ -0,0 +1,63 @@ +export interface IndexChartStockRow { + Symbol: 'AAPL' | 'AMZN' | 'GOOG' | 'IBM' | 'MSFT'; + Date: string; + Close: number; +} + +// Sampled from the D3/Vega index chart reference dataset. +// A few rows are intentionally omitted so the prototype exercises +// nearest-date fallback when a symbol lacks the active reference date. +export const INDEX_CHART_STOCKS: IndexChartStockRow[] = [ + { Symbol: 'AAPL', Date: '2013-05-13', Close: 64.9629 }, + { Symbol: 'AAPL', Date: '2013-11-08', Close: 74.3657 }, + { Symbol: 'AAPL', Date: '2014-05-13', Close: 84.8229 }, + { Symbol: 'AAPL', Date: '2014-11-10', Close: 108.83 }, + { Symbol: 'AAPL', Date: '2015-05-13', Close: 126.01 }, + { Symbol: 'AAPL', Date: '2015-11-10', Close: 116.77 }, + { Symbol: 'AAPL', Date: '2016-05-12', Close: 90.34 }, + { Symbol: 'AAPL', Date: '2016-11-09', Close: 110.88 }, + { Symbol: 'AAPL', Date: '2017-05-12', Close: 156.1 }, + { Symbol: 'AAPL', Date: '2017-11-09', Close: 175.88 }, + + { Symbol: 'AMZN', Date: '2013-05-13', Close: 264.51 }, + { Symbol: 'AMZN', Date: '2013-11-08', Close: 350.31 }, + { Symbol: 'AMZN', Date: '2014-05-13', Close: 304.64 }, + { Symbol: 'AMZN', Date: '2014-11-10', Close: 305.11 }, + { Symbol: 'AMZN', Date: '2015-05-13', Close: 426.87 }, + { Symbol: 'AMZN', Date: '2015-11-10', Close: 659.68 }, + { Symbol: 'AMZN', Date: '2016-05-12', Close: 717.93 }, + { Symbol: 'AMZN', Date: '2016-11-09', Close: 771.88 }, + { Symbol: 'AMZN', Date: '2017-05-12', Close: 961.35 }, + { Symbol: 'AMZN', Date: '2017-11-09', Close: 1129.13 }, + + { Symbol: 'GOOG', Date: '2013-05-13', Close: 435.9297 }, + { Symbol: 'GOOG', Date: '2013-11-08', Close: 504.7322 }, + { Symbol: 'GOOG', Date: '2014-05-13', Close: 530.1748 }, + { Symbol: 'GOOG', Date: '2014-11-10', Close: 544.4961 }, + { Symbol: 'GOOG', Date: '2015-11-10', Close: 728.32 }, + { Symbol: 'GOOG', Date: '2016-05-12', Close: 713.31 }, + { Symbol: 'GOOG', Date: '2016-11-09', Close: 785.31 }, + { Symbol: 'GOOG', Date: '2017-05-12', Close: 932.22 }, + { Symbol: 'GOOG', Date: '2017-11-09', Close: 1031.26 }, + + { Symbol: 'IBM', Date: '2013-05-13', Close: 202.47 }, + { Symbol: 'IBM', Date: '2013-11-08', Close: 179.99 }, + { Symbol: 'IBM', Date: '2014-05-13', Close: 192.19 }, + { Symbol: 'IBM', Date: '2014-11-10', Close: 163.49 }, + { Symbol: 'IBM', Date: '2015-05-13', Close: 172.28 }, + { Symbol: 'IBM', Date: '2015-11-10', Close: 135.47 }, + { Symbol: 'IBM', Date: '2016-05-12', Close: 148.84 }, + { Symbol: 'IBM', Date: '2017-05-12', Close: 150.37 }, + { Symbol: 'IBM', Date: '2017-11-09', Close: 150.3 }, + + { Symbol: 'MSFT', Date: '2013-05-13', Close: 33.03 }, + { Symbol: 'MSFT', Date: '2013-11-08', Close: 37.78 }, + { Symbol: 'MSFT', Date: '2014-05-13', Close: 40.42 }, + { Symbol: 'MSFT', Date: '2014-11-10', Close: 48.89 }, + { Symbol: 'MSFT', Date: '2015-05-13', Close: 47.63 }, + { Symbol: 'MSFT', Date: '2015-11-10', Close: 53.51 }, + { Symbol: 'MSFT', Date: '2016-05-12', Close: 51.51 }, + { Symbol: 'MSFT', Date: '2016-11-09', Close: 60.17 }, + { Symbol: 'MSFT', Date: '2017-05-12', Close: 68.38 }, + { Symbol: 'MSFT', Date: '2017-11-09', Close: 84.09 }, +]; diff --git a/site/src/global.css b/site/src/global.css index 4c7b5b64..c0a01e53 100644 --- a/site/src/global.css +++ b/site/src/global.css @@ -46,8 +46,8 @@ input[type='range'].site-range::-webkit-slider-runnable-track { border-radius: 999px; background: linear-gradient( to right, - #0078d4 0 var(--pct, 50%), - rgba(0, 0, 0, 0.16) var(--pct, 50%) 100% + var(--site-range-color, #0078d4) 0 var(--pct, 0%), + rgba(0, 0, 0, 0.16) var(--pct, 0%) 100% ); } @@ -59,7 +59,7 @@ input[type='range'].site-range::-webkit-slider-thumb { margin-top: -3.5px; border: 0; border-radius: 50%; - background: #0078d4; + background: var(--site-range-color, #0078d4); } input[type='range'].site-range::-moz-range-track { @@ -71,7 +71,7 @@ input[type='range'].site-range::-moz-range-track { input[type='range'].site-range::-moz-range-progress { height: 3px; border-radius: 999px; - background: #0078d4; + background: var(--site-range-color, #0078d4); } input[type='range'].site-range::-moz-range-thumb { @@ -79,11 +79,11 @@ input[type='range'].site-range::-moz-range-thumb { height: 10px; border: 0; border-radius: 50%; - background: #0078d4; + background: var(--site-range-color, #0078d4); } input[type='range'].site-range:focus-visible { - outline: 2px solid #0078d4; + outline: 2px solid var(--site-range-color, #0078d4); outline-offset: 3px; } diff --git a/site/src/main.tsx b/site/src/main.tsx index fb8c034f..e6b611c5 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -15,6 +15,7 @@ import { AutoLayoutPlayground } from './routes/AutoLayoutPlayground'; import { DocSectionPage } from './routes/DocSectionPage'; import { PlaygroundShell } from './playground/PlaygroundShell'; import { Illustrations } from './playground/Illustrations'; +import { ArchitectureIllustrations } from './playground/ArchitectureIllustrations'; import { McpUi } from './playground/McpUi'; import { Labs } from './playground/Labs'; import { DemoWall } from './playground/DemoWall'; @@ -22,8 +23,18 @@ 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 { ClickFocusLab } from './playground/ClickFocusLab'; +import { AnnotationLab } from './playground/AnnotationLab'; +import { InteractionDashboardLab } from './playground/InteractionDashboardLab'; +import { InteractionCandidates } from './playground/InteractionCandidates'; +import { ExternalToChartLab } from './playground/ExternalToChartLab'; +import { ChartToExternalLab } from './playground/ChartToExternalLab'; +import { BespokeInteractionLab } from './playground/BespokeInteractionLab'; 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'; @@ -61,6 +72,7 @@ function AppRoutes({ locale }: { locale: Locale }) { }> } /> } /> + } /> } /> } /> } /> @@ -70,7 +82,20 @@ 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/AnnotationLab.tsx b/site/src/playground/AnnotationLab.tsx new file mode 100644 index 00000000..d0b9643e --- /dev/null +++ b/site/src/playground/AnnotationLab.tsx @@ -0,0 +1,294 @@ +import { useEffect, useRef, useState } from 'react'; +import { RotateCcw } from 'lucide-react'; +import { + assembleVegaLite, +} from 'flint-chart'; +import { + buildInteractiveChart, + externalInteraction, + type ChartUpdate, + type ChartUpdateResult, +} from 'flint-chart/interactive'; +import { expressionInterpreter } from 'vega-interpreter'; +import { annotationCases, type InteractionCase } from './ClickFocusLab'; +import { ThemePicker } from './ThemePicker'; +import './click-focus-lab.css'; +import './annotation-lab.css'; + +type StaticStatus = 'loading' | 'applied' | 'unsupported' | 'error'; +type AnnotationFixture = { + label: string; + key: string; + visual: { kind: 'mark' | 'path' | 'region'; role: string }; + text: string; +}; + +function fixtureSelector( + input: InteractionCase['input'], + fixture: AnnotationFixture, +): Record { + const spec = assembleVegaLite(input) as any; + const fields = spec._interactionSemantics?.fields as string[] | undefined; + const parts = fixture.key.replace(/\|__flint_path$/, '').split('|'); + const sourceRows = input.data.values as readonly Record[]; + return Object.fromEntries((fields ?? []).slice(0, parts.length).map((field, index) => { + const part = parts[index]; + const sourceValue = sourceRows.map((record) => record[field]).find((value) => + String(value) === part + || value instanceof Date && String(value.getTime()) === part + || typeof value === 'string' && String(Date.parse(value)) === part); + const numeric = Number(part); + return [field, sourceValue ?? (Number.isFinite(numeric) ? numeric : part)]; + })); +} + +const ANNOTATION_FIXTURES: Record = { + 'Area Chart': [ + { label: '1995–2000', key: '788918400000|1|__flint_path', visual: { kind: 'path', role: 'area' }, text: '1995–2000: 1% → 7%' }, + { label: '2010–2015', key: '1262304000000|29|__flint_path', visual: { kind: 'path', role: 'area' }, text: '2010–2015: 29% → 43%' }, + { label: '2020–2023', key: '1577836800000|60|__flint_path', visual: { kind: 'path', role: 'area' }, text: '2020–2023: 60% → 67%' }, + ], + 'Bar Chart': [ + { label: '1880s', key: '1880s|-0.17', visual: { kind: 'mark', role: 'mark' }, text: '1880s: -0.17 °C' }, + { label: '1960s', key: '1960s|-0.03', visual: { kind: 'mark', role: 'mark' }, text: '1960s: -0.03 °C' }, + { label: '2020s', key: '2020s|1.02', visual: { kind: 'mark', role: 'mark' }, text: '2020s: +1.02 °C' }, + ], + 'Bar Table': [ + { label: 'Brazil', key: 'Brazil', visual: { kind: 'mark', role: 'bar-table-row' }, text: 'Brazil: $2.2T GDP' }, + { label: 'India', key: 'India', visual: { kind: 'mark', role: 'bar-table-row' }, text: 'India: $3.9T GDP' }, + { label: 'United States', key: 'United States', visual: { kind: 'mark', role: 'bar-table-row' }, text: 'United States: $27.4T GDP' }, + ], + 'Bullet Chart': [ + { label: 'United States', key: 'United States|22.7|50', visual: { kind: 'mark', role: 'bullet-actual' }, text: 'United States: 22.7% vs 50% target' }, + { label: 'Germany', key: 'Germany|51.6|80', visual: { kind: 'mark', role: 'bullet-actual' }, text: 'Germany: 51.6% vs 80% target' }, + { label: 'Norway', key: 'Norway|98.6|100', visual: { kind: 'mark', role: 'bullet-actual' }, text: 'Norway: 98.6% vs 100% target' }, + ], + 'Calendar Heatmap': [ + { label: 'Jan 1', key: '1704067200000|Mon|1704067200000', visual: { kind: 'mark', role: 'calendar-day' }, text: 'Jan 1: 60 activities' }, + { label: 'Mar 1', key: '1708905600000|Fri|1709251200000', visual: { kind: 'mark', role: 'calendar-day' }, text: 'Mar 1: 68 activities' }, + { label: 'Apr 30', key: '1714348800000|Tue|1714435200000', visual: { kind: 'mark', role: 'calendar-day' }, text: 'Apr 30: 88 activities' }, + ], + 'Candlestick Chart': [ + { label: 'Jan 2', key: '1704153600000|187|188|183|185', visual: { kind: 'mark', role: 'candlestick' }, text: 'Jan 2: O 187, H 188, L 183, C 185' }, + { label: 'Jan 8', key: '1704672000000|182|186|182|185', visual: { kind: 'mark', role: 'candlestick' }, text: 'Jan 8: O 182, H 186, L 182, C 185' }, + { label: 'Jan 12', key: '1705017600000|186|188|185|185', visual: { kind: 'mark', role: 'candlestick' }, text: 'Jan 12: O 186, H 188, L 185, C 185' }, + ], + Choropleth: [ + { label: 'Alaska', key: 'Alaska|0.73', visual: { kind: 'region', role: 'geographic-region' }, text: 'Alaska: 0.73' }, + { label: 'Illinois', key: 'Illinois|12.81', visual: { kind: 'region', role: 'geographic-region' }, text: 'Illinois: 12.81' }, + { label: 'Maine', key: 'Maine|1.36', visual: { kind: 'region', role: 'geographic-region' }, text: 'Maine: 1.36' }, + ], + 'Connected Scatter Plot': [ + { label: '1956 point', key: '3675|2.38|1956', visual: { kind: 'mark', role: 'symbol' }, text: '1956: 3,675 miles/person; gas $2.38' }, + { label: '1982–1983 path', key: '6835|2.92|1982|__flint_path', visual: { kind: 'path', role: 'line' }, text: '1982 → 1983: gas $2.92 → $2.66' }, + { label: '2005 point', key: '10067|2.53|2005', visual: { kind: 'mark', role: 'symbol' }, text: '2005: 10,067 miles/person; gas $2.53' }, + ], + 'Density Plot': [ + { label: 'Low duration', key: '1.9040000000000001|0.23092429172368195|__flint_path', visual: { kind: 'path', role: 'area' }, text: '1.904 min: density 0.231' }, + { label: 'Middle duration', key: '3.184|0.21760927780507272|__flint_path', visual: { kind: 'path', role: 'area' }, text: '3.184 min: density 0.218' }, + { label: 'High duration', key: '4.464|0.2959686144815777|__flint_path', visual: { kind: 'path', role: 'area' }, text: '4.464 min: density 0.296' }, + ], + Heatmap: [ + { label: 'Singapore · Jan', key: 'Jan|Singapore', visual: { kind: 'mark', role: 'mark' }, text: 'Singapore · Jan: 26 °C' }, + { label: 'Seattle · Jun', key: 'Jun|Seattle', visual: { kind: 'mark', role: 'mark' }, text: 'Seattle · Jun: 16 °C' }, + { label: 'Seattle · Dec', key: 'Dec|Seattle', visual: { kind: 'mark', role: 'mark' }, text: 'Seattle · Dec: 4 °C' }, + ], + 'Pie Chart': [ + { label: 'Edge', key: 'Edge', visual: { kind: 'mark', role: 'slice' }, text: 'Edge: 12%' }, + { label: 'Other', key: 'Other', visual: { kind: 'mark', role: 'slice' }, text: 'Other: 5%' }, + { label: 'Chrome', key: 'Chrome', visual: { kind: 'mark', role: 'slice' }, text: 'Chrome: 65%' }, + ], + 'Ranged Dot Plot': [ + { label: 'Nigeria range', key: '51|Nigeria|Male|__flint_path', visual: { kind: 'path', role: 'line' }, text: 'Nigeria: Male 51, Female 54' }, + { label: 'Brazil range', key: '69|Brazil|Male|__flint_path', visual: { kind: 'path', role: 'line' }, text: 'Brazil: Male 69, Female 76' }, + { label: 'Japan range', key: '81.5|Japan|Male|__flint_path', visual: { kind: 'path', role: 'line' }, text: 'Japan: Male 81.5, Female 87.6' }, + ], + 'Scatter Plot': [ + { label: 'Ethiopia', key: '2000|66.2|Africa|109', visual: { kind: 'mark', role: 'point' }, text: 'Ethiopia: GDP/person 2,000; 66.2 years' }, + { label: 'China', key: '16800|76.7|Asia|1393', visual: { kind: 'mark', role: 'point' }, text: 'China: GDP/person 16,800; 76.7 years' }, + { label: 'Qatar', key: '116900|80.1|Asia|2.8', visual: { kind: 'mark', role: 'point' }, text: 'Qatar: GDP/person 116,900; 80.1 years' }, + ], + 'Slope Chart': [ + { label: 'Tablet 2019 point', key: '2019|69|Tablet', visual: { kind: 'mark', role: 'symbol' }, text: 'Tablet · 2019: 69 revenue' }, + { label: 'Phone path', key: '2019|56|Phone|__flint_path', visual: { kind: 'path', role: 'line' }, text: 'Phone: 56 → 42 revenue' }, + { label: 'Tablet 2024 point', key: '2024|35|Tablet', visual: { kind: 'mark', role: 'symbol' }, text: 'Tablet · 2024: 35 revenue' }, + ], + 'Violin Plot': [ + { label: 'Class A', key: 'Class A|__flint_path', visual: { kind: 'path', role: 'area' }, text: 'Class A density' }, + { label: 'Class B', key: 'Class B|__flint_path', visual: { kind: 'path', role: 'area' }, text: 'Class B density' }, + { label: 'Class D', key: 'Class D|__flint_path', visual: { kind: 'path', role: 'area' }, text: 'Class D density' }, + ], + 'Waterfall Chart': [ + { label: '1950 baseline', key: '1950', visual: { kind: 'mark', role: 'waterfall-step' }, text: '1950 baseline: 2,536M' }, + { label: 'Africa addition', key: 'Africa', visual: { kind: 'mark', role: 'waterfall-step' }, text: 'Africa: +1,134M' }, + { label: 'Oceania addition', key: 'Oceania', visual: { kind: 'mark', role: 'waterfall-step' }, text: 'Oceania: +32M' }, + ], +}; + +const coverage = [ + 'mark', 'path', 'area', 'distribution', 'composite', 'polar', 'region', +]; + +function nextLayoutTurn(): Promise { + return new Promise((resolve) => setTimeout(resolve, 50)); +} + +async function waitForStableChartLayout(container: HTMLElement): Promise { + let previous = container.getBoundingClientRect(); + let stableFrames = 0; + for (let attempt = 0; attempt < 8 && stableFrames < 2; attempt += 1) { + await nextLayoutTurn(); + const current = container.getBoundingClientRect(); + const stable = Math.abs(current.width - previous.width) < 0.5 + && Math.abs(current.height - previous.height) < 0.5; + stableFrames = stable ? stableFrames + 1 : 0; + previous = current; + } +} + + function StaticAnnotationChart({ + item, + fixture, + themeId, + resetVersion, + onStatus, + }: { + item: InteractionCase; + fixture: AnnotationFixture; + themeId: string | undefined; + resetVersion: number; + onStatus: (status: StaticStatus, result?: ChartUpdateResult | Error) => void; + }) { + const containerRef = useRef(null); + const statusRef = useRef(onStatus); + statusRef.current = onStatus; + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + statusRef.current('loading'); + const themedInput = themeId ? { ...item.input, theme_spec: themeId } : item.input; + const surface = buildInteractiveChart(container, themedInput, { + backend: 'vegalite', + renderer: 'svg', + interactions: [externalInteraction({ + id: 'static-annotation-policy', + handle: (update) => update, + })], + expressionInterpreter, + ariaLabel: item.input.chart_spec.title, + }); + let active = true; + void surface.ready.then(async () => { + await waitForStableChartLayout(container); + if (!active) return; + const target = { + select: { + key: fixtureSelector(themedInput, fixture), + visual: fixture.visual, + }, + }; + const result = await surface.dispatch('static-annotation-policy', { + id: `annotation-lab-${item.id}-${fixture.label}`, + ops: [ + { op: 'set-annotation', target, value: { text: fixture.text } }, + { + op: 'set-style', + targets: [target], + value: { state: 'emphasized', mutedOpacity: 0.25 }, + }, + ], + }); + if (!active) return; + statusRef.current(result?.status === 'applied' ? 'applied' : 'unsupported', result ?? undefined); + }).catch((error) => { + if (!active) return; + statusRef.current('error', error instanceof Error ? error : new Error(String(error))); + }); + return () => { + active = false; + surface.destroy(); + }; + }, [fixture, item, resetVersion, themeId]); + + return
; + } + + function StaticAnnotationCard({ + item, + fixture, + themeId, + resetVersion, + }: { + item: InteractionCase; + fixture: AnnotationFixture; + themeId: string | undefined; + resetVersion: number; + }) { + const [status, setStatus] = useState('loading'); + const [detail, setDetail] = useState('Applying annotation update'); + return ( +
+
+
+

{item.chartType}

+

{fixture.label} {fixture.visual.role}

+
+ + {status === 'applied' ? 'Applied' : status} + +
+
+ { + setStatus(nextStatus); + setDetail(result instanceof Error + ? result.message + : result ? `${result.status}: ${result.resolvedTargets} target` : 'Applying annotation update'); + }} + /> +
+
+ ); + } + + export function AnnotationLab() { + const [themeId, setThemeId] = useState(); + const [resetVersion, setResetVersion] = useState(0); + const staticCases = annotationCases.flatMap((item) => + (ANNOTATION_FIXTURES[item.chartType] ?? []).map((fixture) => ({ item, fixture }))); + + return ( +
+
+
+
+

Annotation lab

+

Static charts with annotation update specs applied directly after render.

+
+ +
+
+
+ {coverage.map((item) => {item})} +
+ +
+
{staticCases.length} exact-target cases · {annotationCases.length} chart types
+
+
+ {staticCases.map(({ item, fixture }) => ( + + ))} +
+
+ ); + } \ No newline at end of file diff --git a/site/src/playground/ArchitectureIllustrations.tsx b/site/src/playground/ArchitectureIllustrations.tsx new file mode 100644 index 00000000..18e257e8 --- /dev/null +++ b/site/src/playground/ArchitectureIllustrations.tsx @@ -0,0 +1,20 @@ +import { CompilationProcessIllustration } from './CompilationProcessIllustration'; +import { IllustrationPageHeader } from './IllustrationPageHeader'; +import { InteractionArchitectureIllustration } from './InteractionArchitectureIllustration'; +import './architecture-illustrations.css'; + +export function ArchitectureIllustrations() { + return ( +
+ + +
+ +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/site/src/playground/BandExpansionFigure.tsx b/site/src/playground/BandExpansionFigure.tsx index 46c700d6..0fc21940 100644 --- a/site/src/playground/BandExpansionFigure.tsx +++ b/site/src/playground/BandExpansionFigure.tsx @@ -5,6 +5,7 @@ import { VegaLiteView } from '../components/VegaLiteView'; import { EChartsView } from '../components/EChartsView'; import { ChartjsView } from '../components/ChartjsView'; import { PlotlyView } from '../components/PlotlyView'; +import { SiteRange } from '../components/SiteRange'; import { siteTheme } from '../shared/theme'; /** @@ -72,7 +73,6 @@ export function BandExpansionFigure() { } }, [backend, count]); - const pct = ((count - 2) / (40 - 2)) * 100; return (
Categories - setCount(Number(e.target.value))} - className="site-range" - style={{ '--pct': `${pct}%`, flex: 1 } as CSSProperties} + style={{ flex: 1 }} /> {count} diff --git a/site/src/playground/BandStretchingLab.tsx b/site/src/playground/BandStretchingLab.tsx index 1dd91a37..01a002a0 100644 --- a/site/src/playground/BandStretchingLab.tsx +++ b/site/src/playground/BandStretchingLab.tsx @@ -1,11 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useMemo, useState, type CSSProperties } from 'react'; +import { useMemo, useState } from 'react'; import { Check, Copy, RotateCcw } from 'lucide-react'; import { THEME_PRESETS, assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; import { VegaLiteView } from '../components/VegaLiteView'; import { ScaleToFit } from '../components/ScaleToFit'; +import { SiteRange } from '../components/SiteRange'; import { siteTheme } from '../shared/theme'; import './band-stretching-lab.css'; @@ -149,19 +150,16 @@ function Slider({ suffix?: string; onChange: (value: number) => void; }) { - const pct = ((value - min) / (max - min)) * 100; return (