From 484c6028f5eaf6eac25e478980c7e28962bc836c Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Fri, 11 Sep 2026 15:19:53 -0700 Subject: [PATCH 1/9] feat(interactions): interaction_spec contract on ChartAssemblyInput Adds the JSON shape for declarative interactions beside chart_spec and theme_spec: a list of preset entries { type, id?, options }, retained updates applied at mount, and the surface policies. The four policy interfaces move to core so the contract needs no runtime import; flint-chart/interactive re-exports them under their old names. Includes the design document that records the decisions. --- docs/design-interaction-spec.md | 471 ++++++++++++++++++ packages/flint-js/src/core/index.ts | 12 + .../flint-js/src/core/interaction-spec.ts | 92 ++++ packages/flint-js/src/core/types.ts | 16 + packages/flint-js/src/interactive/index.ts | 6 + packages/flint-js/src/interactive/types.ts | 29 +- 6 files changed, 605 insertions(+), 21 deletions(-) create mode 100644 docs/design-interaction-spec.md create mode 100644 packages/flint-js/src/core/interaction-spec.ts diff --git a/docs/design-interaction-spec.md b/docs/design-interaction-spec.md new file mode 100644 index 00000000..6152940a --- /dev/null +++ b/docs/design-interaction-spec.md @@ -0,0 +1,471 @@ +# Interaction spec: presets as a declarative API + +Status: decisions confirmed 2026-09-11 (§7). Branch `feat/interactive-api`. Written 2026-09-10. + +## 1. Problem + +Today an interaction is only reachable from JavaScript: + +```ts +import { buildInteractiveChart, clickHighlight, navigate } from 'flint-chart/interactive'; + +buildInteractiveChart(container, input, { + backend: 'vegalite', + interactions: [clickHighlight({ dimOpacity: 0.2 }), navigate({ axes: 'x' })], +}); +``` + +The Flint spec (`ChartAssemblyInput`) says *what to draw* (`chart_spec`) and *how it +looks* (`theme_spec`). It has no field that says *how the chart behaves*. An agent that +authors a spec through the MCP server, the site editor, or a JSON file cannot ask for a +brush, a pan, or a legend toggle. The 20 presets in `interactive/presets/` are already +"behaviour as data" in everything but their entry point. + +This document maps how interactions work now, then proposes a serializable +`interaction_spec` field, a registry that turns it into `InteractionDef[]`, spec-time +validation, and the host changes that make it useful. + +## 2. How interactions work today + +### 2.1 The layers + +| Layer | Files | Owns | +| --- | --- | --- | +| Update language | `core/interaction-contracts.ts` | `ChartUpdate { id, ops }` and the seven ops: `set-style`, `set-annotation`, `set-viewport`, `set-order`, `set-overlay`, `set-freeform-overlay`, `set-data`. Pure JSON. | +| Semantic contracts | `core/interaction-contracts.ts`, `core/interaction-semantics.ts` | `SemanticElement`, `SemanticTarget`, `InteractionContext`, the ChartDef resolver and presenter signatures. | +| Triggers | `interactive/triggers.ts` | `InteractionEventSource` descriptors: `clickTrigger`, `hoverTrigger`, `axisBrushTrigger`, `angularBrushTrigger`, `navigationTrigger`, `inspectTrigger`, ... They say what input to capture. | +| Presets | `interactive/presets/*.ts`, wrapped by `interactive/interactions.ts` | Factories such as `clickHighlight(options)`. Each returns a `CanvasInteractionDef`: a trigger plus a `handle(event, context)` that maps a `CanvasInteractionEvent` to a `ChartUpdate`. | +| Surface | `interactive/index.ts`, `interactive/surface.ts` | `buildInteractiveChart()`; mounts a backend adapter, hosts external `dispatch()`, `applyUpdate()`, viewport rails. | +| Vega-Lite runtime | `vegalite/interactive.ts`, `vegalite/interactions/compile.ts`, `runtime.ts`, `hit-adapter.ts`, `presentation/*` | Instruments the compiled spec, recognises gestures, resolves hits, applies updates to Vega signals and stores. | +| Chart capabilities | `ChartTemplateDef.navigation`, `.reorder`, `.semanticInteractions()` in `core/types.ts` and each `vegalite/templates/*.ts` | What each chart type can do: navigable axes, reorderable axes, angular regions, selectable marks, semantic fields. | + +Only the Vega-Lite backend implements the runtime. `buildInteractiveChart` throws at +mount for other backends when `interactions` is non-empty. + +### 2.2 One interaction definition + +```ts +interface CanvasInteractionDef { + id: string; + eventSource: InteractionEventSource; // what to capture, e.g. { type: 'region', gesture: 'drag', axis: 'x' } + affordances?: InteractionAffordance[]; // cursor and hover hints + retainedStateGroup?: string; // same-group retained updates replace each other + claimsLegendActivation?: boolean; + claimsAxisActivation?: boolean; + navigationDomainGuard?: NavigationDomainGuard; + handle?(event: CanvasInteractionEvent, context: InteractionContext): ChartUpdate | null; +} +``` + +A preset is a factory that fills this in. `brushX({ mode: 'stateful' })` pairs +`axisBrushTrigger('x', 'intersect', 'stateful')` with a handler that calls +`emphasisUpdate()` and returns one `set-style` op. `navigate()` pairs +`navigationTrigger()` with a handler that asks `context.resolveNavigation()` for an +absolute `set-viewport` op. `inspectIndex()` has a trigger and no handler at all; the +runtime draws the guide from the event alone. + +The second definition kind, `ExternalInteractionDef`, has no trigger. It binds an +application payload to a handler and is invoked through `surface.dispatch(id, payload)`. + +### 2.3 The pipeline + +```mermaid +flowchart LR + S[eventSource] --> M[Vega mount] + P[Pointer / wheel / key] --> M + M --> G[Gesture recogniser] + G --> H[Hit adapter: RenderHit list] + H --> R[ChartDef resolve: SemanticTarget] + R --> C[Coordinator] + C --> E[flint-interaction DOM event] + C -->|handle| U[ChartUpdate] + U --> T[Resolve targets] + T --> PR[ChartDef presentUpdate] + PR --> V[Vega signals and stores] +``` + +Every resolved gesture is emitted as a bubbling `flint-interaction` event whether or not +a handler runs. The handler is optional policy; emission is unconditional. + +### 2.4 Where a preset is admitted or rejected + +Admission happens once, at mount, inside `addVegaLiteInteractions()` in +`vegalite/interactions/compile.ts` (about lines 400 to 460). It throws for: + +- a semantic gesture on a chart with no element semantics; +- `navigate` on a chart with no navigable continuous axis, or with an axis it does not declare; +- `brush-angle` on a chart whose ChartDef does not list `supportedRegionGestures: ['angular']` (pie, donut, rose, radar do); +- `navigate` with pan on together with any drag gesture (`select`, `lasso-select`, `brush-*`, `brush-zoom`, `drag-reorder`); +- a duplicate interaction id (`normalizeInteractions()` in `interactive/interactions.ts`). + +The facts these checks need already leave the assembler: `assembleVegaLite()` attaches +`_interactionSemantics` to the spec with `navigationAxes`, `geoNavigation`, +`reorderAxes`, `supportedRegionGestures`, `fields`, and `selectableMarks` +(`vegalite/assemble.ts`, around line 949). `validateChart()` already assembles. So the +same checks can run at spec time with no browser. + +### 2.5 What is already JSON, and what is not + +Already serialisable: + +- every preset option except one (`id`, `dimOpacity`, `targets`, `axes`, `mode`, `match`, `guide.style`, `tolerance`, `groupBy`, `domainGuard`, `reset`, `show`, `seriesBy`, `selector`, ...); +- `ChartUpdate` and all seven ops, including `SemanticTargetSelector` (`{ select: { key: { Country: 'Japan' } } }`); +- the surface policies on `BuildInteractiveChartOptions`: `dismiss`, `assistedTargeting`, `keyboardTargeting`, and the initial `updates` list. + +Not serialisable: + +- `clickAnnotate({ format })`: a function from element to text; +- `externalInteraction({ handle })`: application code by definition; +- a hand-written `{ eventSource, handle }` definition. + +### 2.6 The preset inventory + +| Factory | Trigger | Update | Needs from the chart | +| --- | --- | --- | --- | +| `clickHighlight` | click, assisted 8 px | `set-style` emphasis, toggle with modifiers | element semantics; `targets` may add legend and discrete axis | +| `axisHighlight` | hover or click on axis labels | `set-style` | a discrete position axis | +| `clickGroupFocus` | click | `set-style` on the whole group | element semantics, `groupBy` | +| `hoverGroupFocus` | hover, tolerance 8 px | `set-style` preview and cancel | element semantics, `groupBy` required | +| `clickAnnotate` | click | `set-annotation` plus emphasis | element semantics | +| `select` | rectangle drag | `set-style` | Cartesian region | +| `lassoSelect` | freehand drag | `set-style` | Cartesian region | +| `brushX`, `brushY` | axis-constrained drag, ephemeral or stateful | `set-style` | Cartesian region | +| `brushAngle` | annular sector drag | `set-style` | angular region (polar ChartDefs) | +| `brushZoom` | rectangle drag with `viewport: true` | `set-viewport` | navigation axes | +| `linkedBrush` | rectangle or lasso | `set-style` expanded by group | element semantics, `groupBy` required | +| `legendToggle` | legend click | `set-style { visible: false }` | a legend | +| `contextActivate` | right click | emits only | element semantics | +| `longPress` | hold, default 500 ms | `set-style` | element semantics | +| `doubleActivate` | double click | `set-style` | element semantics | +| `inspect` | pointer with x, y, or xy predicates | guide only | element semantics | +| `inspectIndex` | pointer on one axis | guide only | line or point marks; `seriesBy` when `show` is not `'all'` | +| `navigate` | drag pan, wheel or pinch zoom, reset gesture | `set-viewport` | navigation axes (or `navigation.geo`) | +| `dragReorder` | element drag | `set-order` | reorder axes | + +## 3. Goals and non-goals + +Goals: + +1. An agent or a person can request behaviour in the same JSON document that requests the chart. +2. Every shipped preset is reachable from the spec with the same options it has in code. +3. Bad requests fail at spec time with a `ChartWarning`, not at mount with a thrown error. +4. Agents can discover which presets exist and which ones a chart type accepts. +5. Code users lose nothing. `buildInteractiveChart(..., { interactions })` keeps working and composes with the spec. + +Non-goals for the first release: + +- Serialising arbitrary handlers. Custom behaviour stays in JavaScript, as the presets README already says. +- Interaction support in backends other than Vega-Lite. +- A new gesture or a new preset. This is a wrapping exercise. + +## 4. Proposal + +### 4.1 Placement: a top-level `interaction_spec` + +```ts +interface ChartAssemblyInput { + data: ...; + semantic_types?: ...; + chart_spec: ...; // what to draw + theme_spec?: ...; // how it looks + interaction_spec?: InteractionSpec; // how it behaves <- new + options?: ...; + field_display_names?: ...; +} +``` + +Why top level and not inside `chart_spec`: + +- It follows the existing triad. `theme_spec` sits beside `chart_spec` "because the same theme applies to every chart" (`core/types.ts`). Behaviour is the same kind of orthogonal concern: `navigate` applies to any chart with a continuous axis, and a static renderer ignores it entirely. +- Static backends (ECharts, Chart.js, Plotly, Excel, Image-Charts, flint-py) can ignore one top-level key with an `info` warning, exactly as they ignore `theme_spec` today. +- The object has room for surface policies (`dismiss`, `assistedTargeting`, `keyboardTargeting`) and initial `updates`, which do not belong in `chart_spec`. + +The name follows the `snake_case` convention of the other top-level keys. + +### 4.2 Shape + +```ts +export type InteractionPresetType = + | 'click-highlight' | 'axis-highlight' | 'click-group-focus' | 'hover-group-focus' + | 'click-annotate' | 'select' | 'lasso-select' | 'brush-x' | 'brush-y' | 'brush-angle' + | 'brush-zoom' | 'linked-brush' | 'legend-toggle' | 'context-activate' | 'long-press' + | 'double-activate' | 'inspect' | 'inspect-index' | 'navigate' | 'drag-reorder'; + +/** Per-type options are the factory option types. `click-annotate` loses `format`, a function. */ +export interface InteractionPresetOptions { + 'click-highlight': ClickHighlightOptions; + 'axis-highlight': AxisHighlightOptions; + 'click-group-focus': ClickGroupFocusOptions; + 'hover-group-focus': HoverGroupFocusOptions; + 'click-annotate': Omit; + 'select': SelectOptions; + 'lasso-select': LassoSelectOptions; + 'brush-x': BrushOptions; + 'brush-y': BrushOptions; + 'brush-angle': AngularBrushOptions; + 'brush-zoom': BrushZoomOptions; + 'linked-brush': LinkedBrushOptions; + 'legend-toggle': LegendToggleOptions; + 'context-activate': ContextActivateOptions; + 'long-press': LongPressOptions; + 'double-activate': DoubleActivateOptions; + 'inspect': InspectOptions; + 'inspect-index': InspectIndexOptions; + 'navigate': NavigateOptions; + 'drag-reorder': DragReorderOptions; +} + +/** One entry: the preset name, an optional id, and that preset's options under `options`. */ +export type InteractionPresetSpec = { + [T in InteractionPresetType]: { type: T; id?: string; options?: Omit }; +}[InteractionPresetType]; + +/** The loose JSON shape in core, so core never imports the runtime. */ +export interface InteractionEntry { + type: InteractionPresetType; + id?: string; + options?: Record; +} + +export interface InteractionSpec { + /** One object per preset. The type name selects the factory. No string shorthand. */ + interactions: readonly InteractionPresetSpec[]; + /** Retained state applied at mount: emphasis, annotations, a viewport, an order. */ + updates?: readonly ChartUpdate[]; + assistedTargeting?: boolean | AssistedTargetingOptions; + keyboardTargeting?: boolean; + dismiss?: InteractionDismissPolicy | false; +} +``` + +The option interfaces are the ones that already exist in `interactive/interactions.ts`. +The entry wraps them under `options` and adds `type` and `id` beside them; `format` +is removed. Nothing else changes, so the JSON shape and the TypeScript shape stay in lock +step by construction. Identity and options never share a namespace: a preset can add an +option later without colliding with `type` or `id`. + +Example: + +```json +{ + "chart_spec": { + "chartType": "Line Chart", + "encodings": { "x": "Year", "y": "Score", "color": "Country" } + }, + "theme_spec": "economist", + "interaction_spec": { + "interactions": [ + { "type": "legend-toggle" }, + { "type": "click-highlight", "options": { "dimOpacity": 0.2, "targets": ["mark", "legend"] } }, + { "type": "inspect-index", "options": { "axis": "x", "seriesBy": "Country", "show": "all" } }, + { "type": "navigate", "options": { "axes": "x", "pan": false, "reset": ["double-click"] } } + ], + "updates": [ + { "id": "seed", "ops": [ + { "op": "set-annotation", + "target": { "select": { "key": { "Country": "Japan", "Year": 2018 } } }, + "value": { "text": "Reform year" } } + ] } + ], + "dismiss": { "escape": true, "click": "plot-background" } + } +} +``` + +### 4.3 Naming rules + +- `type` is the discriminator. It is the kebab-case name that is already the preset's default id (`click-highlight`, `brush-x`, `navigate`, `legend-toggle`, `inspect-index`, `drag-reorder`, ...). The rule "default `id` equals `type`" becomes normative. +- Brushes stay three types (`brush-x`, `brush-y`, `brush-angle`) so the mapping to the three factories is one to one. A `brush` type with an `axis` field is the alternative; it reads well but hides that `brush-angle` has a different admission rule. +- Options are nested under `options`; `id` sits on the entry, never inside `options`. The resolver rejects a flat option key with a hint, so an entry written by habit as `{ "type": "navigate", "axes": "x" }` fails loudly instead of losing the option. (Decided 2026-09-11.) +- No string shorthand. One shape keeps the resolver, the validator, and the MCP schema to a single case. (Decided 2026-09-11; the same preference removed the string-or-list union from `navigate({ reset })`.) + +### 4.4 Function-typed options + +| Today | In the spec | +| --- | --- | +| `clickAnnotate({ format })` | Omit `format` in v1. When `format` is absent the runtime already uses the ChartDef's default annotation text. Phase 3 can add a declarative `text` template, for example `"{Country}: {Score:.1f}"`. | +| `externalInteraction({ handle })` | Not in the spec. Phase 3 can add one declarative external binding, `external-select`, whose payload is `{ keys: Record[] }` and whose update is a `set-style` over `SemanticTargetSelector`s. That covers the linked-dashboard case without code. | +| `{ eventSource, handle }` | Never in the spec. | + +### 4.5 Resolution: the registry + +New folder `packages/flint-js/src/interactive/spec/`: + +```ts +// registry.ts +export interface InteractionEntry { + type: InteractionPresetType; + label: string; + description: string; + /** Capability the chart must expose. Checked at spec time and at mount. */ + requires: 'element-semantics' | 'cartesian-region' | 'angular-region' | 'navigation' | 'reorder' | 'legend' | 'discrete-axis'; + /** Gesture family used for the pan-versus-drag conflict rule. */ + gesture: 'click' | 'hover' | 'drag' | 'navigate' | 'inspect' | 'context' | 'long-press' | 'double'; + create(options: Record): CanvasInteractionDef; +} + +export const INTERACTION_PRESETS: Record; +export function listInteractionPresets(): Pick[]; +``` + +```ts +// resolve.ts +export function resolveInteractionSpec(spec: InteractionSpec | undefined): { + interactions: InteractionDef[]; + updates: ChartUpdate[]; + surface: Pick; +}; +``` + +`resolveInteractionSpec` normalises string shorthand, looks up `type`, calls `create` +with the remaining fields, and lets the factory throw on bad options (they already do: +`navigate` checks `domainGuard`, `inspectIndex` checks `seriesBy`). Unknown `type` +throws with the index and the list of valid names. + +This is the same pattern as `THEME_PRESETS`, `listThemePresets()`, and +`resolveThemeSpec()` in `core/theme/presets.ts`. + +Placement note: the JSON types live in `core/interaction-spec.ts` so `ChartAssemblyInput` +can reference them without importing the runtime. The registry lives under +`interactive/` because it imports the preset factories. The factories touch no DOM, so +`validate/` may import the registry safely; the cost is a small increase in the root +bundle. If that matters, split the metadata (types, `requires`, `gesture`) into core and +keep only `create` under `interactive/`. + +### 4.6 Runtime integration + +`buildInteractiveChart(container, input, options)`: + +1. `resolveInteractionSpec(input.interaction_spec)` gives spec-side interactions, updates, and surface policies. It does not know the chart yet, so it drops nothing. +2. The Vega mount owns `_interactionSemantics`, so it runs `admitInteractions()` there. A spec-origin preset the chart cannot honour is dropped and reported as a `ChartWarning`. A code-origin preset still throws, as today: a developer sees the exception, an agent reads the warning. +3. Interactions: spec list first, then `options.interactions`. `normalizeInteractions()` rejects duplicate ids as it does today. Code cannot silently replace a spec entry; give it a different id. +4. Updates: spec `updates` first, then `options.updates`. +5. Surface policies: `options` win over the spec when both are set. + +The resolver tags each definition it creates with `origin: 'spec'` so the mount can tell the two sources apart. Warnings collected at mount are exposed on the surface as `surface.warnings` after `ready`, and logged once with `console.warn`. + +The playground demos keep passing `interactions` in code. New demos and the editor can +move to `interaction_spec`. + +### 4.7 Validation at spec time + +Extend `validateChart(input, backend)` in `validate/index.ts`: + +1. If `interaction_spec` is present and `backend !== 'vegalite'`, push `info` warning `interactions_ignored`. +2. Otherwise, after assembly, run `validateInteractionSpec(input.interaction_spec, spec._interactionSemantics)` and merge its `ChartWarning[]`. + +Warning codes and severities. A malformed spec is an error; a well-formed preset the chart cannot honour is a warning, and the preset is dropped so the chart still renders. `valid` stays true when only warnings remain. + +| Code | Severity | When | +| --- | --- | --- | +| `interactions_ignored` | info | backend does not run interactions | +| `unknown_interaction_preset` | error | `type` is not in the registry | +| `duplicate_interaction_id` | error | two entries resolve to one id | +| `invalid_interaction_option` | error | the factory threw (`seriesBy` missing, `domainGuard` inverted, ...) | +| `unsupported_interaction` | warning, entry dropped | `requires` is not met: `navigate` with no continuous axis, `brush-angle` on a Cartesian chart, `drag-reorder` with no reorder axis, an explicit navigation axis the chart does not have | +| `conflicting_interactions` | warning, later entry dropped | `navigate` with pan together with any drag-gesture preset; more than one `navigate`. The entry that comes later in `interactions` is the one dropped, so order is the tie-break. | + +Warning and drop was chosen over an error (2026-09-11) so a chart always renders and the agent learns from `validate_chart` what it lost. Each warning names the interaction by id, for example `Interaction "brush-angle" requires a polar chart with angular-region support. The interaction was dropped.`; ids are unique within a spec, so the id identifies the entry. Code-origin definitions keep today's exceptions word for word, and a second code `navigate` stays what it is today: not an error, the first one wins. + +To keep one source of truth, factor the checks now inline in `addVegaLiteInteractions()` +into a pure `admitInteractions(plan, interactions)` in `interactive/spec/admission.ts` +that returns `{ admitted, warnings }`. The compile path calls it: spec-origin rejects +are dropped with their warnings, code-origin rejects still throw. The validate path +calls it and reports. Because the mount needs the drop, this extraction moves into +Phase 1. + +### 4.8 Discoverability + +| Surface | Change | +| --- | --- | +| `flint-chart/interactive` | export `INTERACTION_PRESETS`, `listInteractionPresets()`, `resolveInteractionSpec()`, `validateInteractionSpec()` | +| MCP `tools/schemas.ts` | add `interaction_spec` to `buildAssemblyInputShape()` and `toAssemblyInput()`; describe it in one sentence with a pointer to the skill | +| MCP `tools/list.ts` | `list_chart_types` gains `interactions: InteractionPresetType[]` per chart type, derived from the template: `navigation` gives `navigate` and `brush-zoom`, `reorder !== false` gives `drag-reorder`, `supportedRegionGestures` decides `brush-angle` versus `select`, `lasso-select`, `brush-x`, `brush-y`; add `list_interactions` (or fold into `list_chart_types`) | +| MCP `validate_chart`, `compile_chart` | inherit the new warnings through `validateChart` | +| `agent-skills/flint-chart-author/SKILL.md` | new section "Interactions (`interaction_spec`)": the shape, the string shorthand, the pan-versus-drag rule, two worked examples | +| `docs/interaction-spec.md` | user guide in the style of `docs/theme-spec.md` | +| `docs/api-reference.md` §3 | add the field to `ChartAssemblyInput` | +| `scripts/gen-chart-reference.ts` | one "Interactions" line per template from the same derivation as `list_chart_types` | +| `packages/flint-js/src/interactive/README.md` | short "Declarative spec" section that points here | + +### 4.9 Hosts + +- **MCP chart view** (`packages/flint-mcp/ui/src/render.ts`) renders a static SVG through `assembleVegaLite()`. When `interaction_spec.interactions` is non-empty, mount `buildInteractiveChart()` instead. MCP App hosts often forbid `eval`; pass `expressionInterpreter` from `vega-interpreter` as the site already does. +- **Site editor and gallery** (`site/src/components/VegaLiteView.tsx`, `routes/Editor.tsx`) get one spec-aware component that switches to `buildInteractiveChart()` when the input carries `interaction_spec`. The editor becomes a place to try presets by editing JSON. +- **flint-py** assembles static Vega-Lite and ignores the field. Add one test so a spec with `interaction_spec` still assembles. + +## 5. Alternatives considered + +| Option | Why not | +| --- | --- | +| `chart_spec.interactions: [...]` | No home for `dismiss`, `assistedTargeting`, `updates`; couples behaviour to the "what to draw" object that static backends must read. | +| `chart_spec.chartProperties.interactions` | `chartProperties` is per-template and validated against `ChartTemplateDef.properties`; presets are cross-template. | +| Vega-Lite `params` style (`{ name, select: { type: 'interval' } }`) | Flint's presets are higher level (they carry policy, not just selection). Exposing Vega selections would leak the backend. | +| Serialise handlers as expression strings | A new language to specify, secure, and document. Presets already cover the shared cases; code covers the rest. | +| Error on an unsupported preset | Considered. A chart that fails to mount over one optional behaviour is worse for a reader than a chart that lost a gesture; the agent still sees the warning. | + +## 6. Phased plan + +### Phase 1: types, registry, runtime (no behaviour change for existing callers) + +- `core/interaction-spec.ts`: `InteractionPresetType`, `InteractionPresetSpec`, `InteractionSpec`. Export from `core/index.ts`. +- `core/types.ts`: `interaction_spec?: InteractionSpec` on `ChartAssemblyInput`, with a doc comment that mirrors the `theme_spec` one. +- `interactive/spec/registry.ts`, `interactive/spec/resolve.ts`. Export from `interactive/index.ts`. +- `interactive/spec/admission.ts`: `admitInteractions()` extracted from `compile.ts`; `compile.ts` calls it and drops spec-origin rejects (§4.6, §4.7). +- `interactive/index.ts`: `buildInteractiveChart` merges spec and options per §4.6 and exposes `surface.warnings`. +- Tests in `packages/flint-js/tests/interaction-spec.test.ts`: + - every `type` resolves to a def whose `id`, `eventSource`, `affordances`, and flags equal the factory's output; + - string shorthand; + - unknown type, duplicate id, factory errors; + - merge order and duplicate detection between spec and code; + - a spec-origin `brush-angle` on a bar chart is dropped with a warning at mount, while the same preset from code still throws. + +### Phase 2: validation and discoverability + +- `validate/index.ts`: `validateInteractionSpec()` and the warning codes in §4.7, on top of the Phase 1 `admitInteractions()`. +- MCP: `schemas.ts`, `list.ts`, tool descriptions in `server.ts`; tests in `packages/flint-mcp/tests`. +- Docs: `docs/interaction-spec.md`, `docs/api-reference.md`, `SKILL.md`, `gen-chart-reference.ts`, interactive README. +- Tests: `validateChart` cases for Pie + `brush-x`, Bar + `brush-angle`, Bar + `navigate` on a nominal axis, Scatter + `navigate`, `navigate` + `select`, `navigate` with `pan: false` + `select`, ECharts + any preset. + +### Phase 3: hosts and declarative extensions + +- MCP chart view mounts `buildInteractiveChart()` when the spec lists interactions. +- Site spec-aware chart component; editor examples with `interaction_spec`. +- `click-annotate.text` template. +- `external-select` declarative binding. +- Guide colours grounded from `theme_spec.interaction` instead of hard-coded defaults, so an agent never writes a colour into `interaction_spec`. + +## 7. Decisions (confirmed 2026-09-11) + +| Decision | Choice | +| --- | --- | +| Placement | top-level `interaction_spec` object | +| Entry shape | one object per preset, `{ type, ...options }`; no string shorthand | +| Discriminator and names | `type`, kebab-case, equal to the preset's default id | +| Brush naming | three types: `brush-x`, `brush-y`, `brush-angle` | +| Unsupported preset for the chart type | `warning`, entry dropped; the chart still renders | +| Same id in spec and code | error, as `normalizeInteractions()` does today | +| Scope of v1 | `interactions`, `updates`, `dismiss`, `assistedTargeting`, `keyboardTargeting` | + +## 8. Risks + +- **Pan versus drag.** `navigate` with pan on conflicts with every drag preset. Agents will hit this often. Under warning-and-drop the later entry silently disappears from the chart, so the skill must teach `{ "type": "navigate", "pan": false }` and the warning message must name both entries. +- **Capability depends on encodings, not only on the chart type.** `navigate` on a bar chart is valid when x is temporal and invalid when x is nominal. `list_chart_types` can only report potential; `validate_chart` gives the real answer. Say so in both places. +- **Bundle boundary.** `core/` must stay DOM-free and small. Keep the types in core and the factories under `interactive/`. +- **Two sources of admission truth** if the checks are copied rather than extracted. Extract them (§4.7). + +## 9. Follow-up: admission per chart type + +Admission today infers what a chart can honour from a few compiled facts: the navigable +axes, the region gestures, and whether the chart has element semantics. That is indirect, +and it cannot express a chart type's intent. A KPI card supports nothing; a heatmap +supports `click-highlight` but not `inspect-index`; a map supports `navigate` but not +`brush-x`. None of that is stated anywhere an agent can read. + +The next iteration (noted 2026-09-11) gives each chart type an explicit declaration of +the interaction presets it supports and does not support, next to `navigation` and +`reorder` on `ChartTemplateDef`. `admitInteractions()` consults that declaration first +and falls back to the inferred capabilities. The same declaration feeds +`list_chart_types.interactions` in the MCP server and the generated chart reference, so +the list an agent reads and the list the mount enforces are one list. The +warning-and-drop rule for spec entries stays. diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index f7585510..b12ad70c 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -198,6 +198,18 @@ export { } from './field-semantics'; export { isRegistered, getRegisteredTypes } from './type-registry'; +// Declarative interactions: the JSON contract read by flint-chart/interactive +export { + INTERACTION_PRESET_TYPES, + type InteractionPresetType, + type InteractionEntry, + type InteractionSpec, + type AssistedTargetingOptions, + type TargetDetailsOptions, + type TargetFeedbackOptions, + type InteractionDismissPolicy, +} from './interaction-spec'; + // ThemeSpec: public visual-system vocabulary and chart-specific grounding export { type ThemeSpec, diff --git a/packages/flint-js/src/core/interaction-spec.ts b/packages/flint-js/src/core/interaction-spec.ts new file mode 100644 index 00000000..f5dbb472 --- /dev/null +++ b/packages/flint-js/src/core/interaction-spec.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * The declarative interaction contract: what an agent or a person writes in + * `ChartAssemblyInput.interaction_spec`. Pure data, no DOM, no runtime import. + * + * `flint-chart/interactive` turns it into interaction definitions + * (`resolveInteractionSpec`) and exposes the precise per-type option shapes + * (`InteractionPresetSpec`). This module only names the presets and the + * envelope around them, so the core stays free of the interactive runtime. + */ + +import type { ChartUpdate } from './interaction-contracts'; + +/** The shipped interaction presets. Each name is also that preset's default id. */ +export const INTERACTION_PRESET_TYPES = [ + 'click-highlight', + 'axis-highlight', + 'click-group-focus', + 'hover-group-focus', + 'click-annotate', + 'select', + 'lasso-select', + 'brush-x', + 'brush-y', + 'brush-angle', + 'brush-zoom', + 'linked-brush', + 'legend-toggle', + 'context-activate', + 'long-press', + 'double-activate', + 'inspect', + 'inspect-index', + 'navigate', + 'drag-reorder', +] as const; + +export type InteractionPresetType = (typeof INTERACTION_PRESET_TYPES)[number]; + +/** + * One preset as JSON: the type name, an optional id, and that preset's options + * under `options`, for example + * `{ "type": "navigate", "options": { "axes": "x", "pan": false } }`. + * The options are the ones the matching factory in `flint-chart/interactive` + * accepts; the precise per-type shape is `InteractionPresetSpec` there. + * `id` defaults to `type` and lives on the entry, never inside `options`. + */ +export interface InteractionEntry { + type: InteractionPresetType; + id?: string; + options?: Record; +} + +/** 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; +} + +/** How committed presentation and annotation state is cleared. */ +export interface InteractionDismissPolicy { + click?: 'any' | 'non-element' | 'plot-background' | false; + escape?: boolean; +} + +/** + * How a chart behaves. Sits beside `chart_spec` and `theme_spec` in + * `ChartAssemblyInput`. Only the Vega-Lite interactive surface reads it; the + * assemblers and the static backends leave it untouched. + */ +export interface InteractionSpec { + /** One entry per interaction. Its type names the preset that makes it; no string shorthand. */ + interactions: readonly InteractionEntry[]; + /** Retained state applied at mount: emphasis, annotations, a viewport, an order. */ + updates?: readonly ChartUpdate[]; + /** Presets assist by default; false requires direct hits, maxDistance overrides eligible presets. */ + assistedTargeting?: boolean | AssistedTargetingOptions; + keyboardTargeting?: boolean; + dismiss?: InteractionDismissPolicy | false; +} diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index 3fb613e2..aec2ebf4 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -6,6 +6,7 @@ import type { LabelSizingDecision } from './decisions'; import type { SemanticAnnotation, FormatSpec, DomainConstraint, TickConstraint } from './field-semantics'; import type { ColorDecisionResult } from './color-decisions'; import type { GeometryKind, ThemeGeometry, ThemeSpec } from './theme/types'; +import type { InteractionSpec } from './interaction-spec'; /** * Core types for the chart engine library. @@ -1225,6 +1226,21 @@ export interface ChartAssemblyInput { */ theme_spec?: ThemeSpec | string; + /** + * Interactions — describes *how it behaves*. + * + * Presets named by type with their options, retained updates applied at + * mount, and the surface policies (dismiss, assisted and keyboard + * targeting). Sits beside `chart_spec` for the same reason `theme_spec` + * does: one behaviour applies to many charts, and a static backend ignores + * it without harm. + * + * Read by the interactive surface in `flint-chart/interactive` (Vega-Lite + * only). The assemblers leave it untouched. A preset the chart cannot + * honour is dropped with a warning rather than failing the chart. + */ + interaction_spec?: InteractionSpec; + /** * Options for the assembler — layout tuning, tooltips, etc. * All fields are optional and have sensible defaults. diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index c2e113fa..daeb404d 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -131,6 +131,12 @@ export { yBrushTrigger, } from './triggers'; export { clampViewportStart, mountInteractiveChartSurface } from './surface'; +export { INTERACTION_PRESET_TYPES } from '../core/interaction-spec'; +export type { + InteractionEntry, + InteractionPresetType, + InteractionSpec, +} from '../core/interaction-spec'; export function buildInteractiveChart( container: HTMLElement, diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts index 707d58ca..f6f80d8a 100644 --- a/packages/flint-js/src/interactive/types.ts +++ b/packages/flint-js/src/interactive/types.ts @@ -1,6 +1,14 @@ import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; import type { InteractionContext, InteractionDef } from './interactions'; import type { ChartUpdate, ChartUpdateResult } from './language/updates'; +import type { AssistedTargetingOptions, InteractionDismissPolicy } from '../core/interaction-spec'; + +export type { + AssistedTargetingOptions, + InteractionDismissPolicy, + TargetDetailsOptions, + TargetFeedbackOptions, +} from '../core/interaction-spec'; export type ViewportChannel = 'x' | 'y'; export type ViewportState = Partial>; @@ -12,22 +20,6 @@ export interface ViewportGeometry { 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; -} - /** Animates a viewport change over `duration` milliseconds; projected charts honour it. */ export interface ChartUpdateTransition { duration: number; @@ -38,11 +30,6 @@ export interface ChartUpdateApplyOptions { transition?: ChartUpdateTransition; } -export interface InteractionDismissPolicy { - click?: 'any' | 'non-element' | 'plot-background' | false; - escape?: boolean; -} - export interface InteractiveRenderer { viewports: CategoryViewport[]; setViewports(starts: ViewportState): void | Promise; From dc548ad51339cb43a70467e0f7b55c96bdb1d3e3 Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Fri, 11 Sep 2026 15:20:29 -0700 Subject: [PATCH 2/9] feat(interactions): preset registry and spec resolver INTERACTION_PRESETS maps each preset name to the factory code calls today, with the capability it needs and the gesture it uses. resolveInteractionSpec turns interaction_spec entries into the same CanvasInteractionDef values, tagged origin 'spec', with the type name as the default id. It rejects an unknown type, a flat option outside "options", a missing required option, and a duplicate id, and names the entry in every message. --- packages/flint-js/src/interactive/index.ts | 15 ++ .../flint-js/src/interactive/interactions.ts | 2 + .../flint-js/src/interactive/spec/registry.ts | 251 ++++++++++++++++++ .../flint-js/src/interactive/spec/resolve.ts | 117 ++++++++ .../flint-js/src/interactive/spec/types.ts | 63 +++++ .../flint-js/tests/interaction-spec.test.ts | 157 +++++++++++ 6 files changed, 605 insertions(+) create mode 100644 packages/flint-js/src/interactive/spec/registry.ts create mode 100644 packages/flint-js/src/interactive/spec/resolve.ts create mode 100644 packages/flint-js/src/interactive/spec/types.ts create mode 100644 packages/flint-js/tests/interaction-spec.test.ts diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index daeb404d..54bdf221 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -52,6 +52,11 @@ export type { ClickHighlightOptions, ClickHighlightTarget, ClickGroupFocusOptions, + ContextActivateOptions, + DoubleActivateOptions, + DragReorderOptions, + LegendToggleOptions, + LongPressOptions, LinkedBrushOptions, HoverGroupFocusOptions, GroupBy, @@ -137,6 +142,16 @@ export type { InteractionPresetType, InteractionSpec, } from '../core/interaction-spec'; +export type { InteractionPresetOptions, InteractionPresetSpec } from './spec/types'; +export { INTERACTION_PRESETS, listInteractionPresets } from './spec/registry'; +export type { + InteractionCapability, + InteractionGestureFamily, + InteractionPresetDefinition, + InteractionPresetSummary, +} from './spec/registry'; +export { resolveInteractionSpec } from './spec/resolve'; +export type { ResolvedInteractionSpec } from './spec/resolve'; export function buildInteractiveChart( container: HTMLElement, diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index 844f23af..838578e0 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -100,6 +100,8 @@ export type { export interface CanvasInteractionDef { readonly id: string; + /** Set by the spec resolver. A definition made in code has no origin. */ + readonly origin?: 'spec'; readonly eventSource: InteractionEventSource; readonly affordances?: readonly InteractionAffordance[]; /** Retained updates from interactions in the same group replace one another. */ diff --git a/packages/flint-js/src/interactive/spec/registry.ts b/packages/flint-js/src/interactive/spec/registry.ts new file mode 100644 index 00000000..c7ff0d58 --- /dev/null +++ b/packages/flint-js/src/interactive/spec/registry.ts @@ -0,0 +1,251 @@ +import { INTERACTION_PRESET_TYPES, type InteractionPresetType } from '../../core/interaction-spec'; +import { + axisHighlight, + brushAngle, + brushX, + brushY, + brushZoom, + clickAnnotate, + clickGroupFocus, + clickHighlight, + contextActivate, + doubleActivate, + dragReorder, + hoverGroupFocus, + inspect, + inspectIndex, + lassoSelect, + legendToggle, + linkedBrush, + longPress, + navigate, + select, + type CanvasInteractionDef, +} from '../interactions'; +import type { InteractionPresetOptions } from './types'; + +/** What a chart must expose for a preset to work; admission checks it against the compiled chart. */ +export type InteractionCapability = + | 'element-semantics' + | 'cartesian-region' + | 'angular-region' + | 'navigation' + | 'reorder' + | 'legend' + | 'discrete-axis'; + +/** The gesture a preset captures; `drag` presets conflict with `navigate` when pan is on. */ +export type InteractionGestureFamily = + | 'click' + | 'hover' + | 'drag' + | 'navigate' + | 'inspect' + | 'context' + | 'long-press' + | 'double'; + +export interface InteractionPresetDefinition { + readonly type: T; + readonly label: string; + readonly description: string; + readonly requires: InteractionCapability; + readonly gesture: InteractionGestureFamily; + /** Options the factory needs but cannot default; the resolver reports a missing one by name. */ + readonly requiredOptions?: readonly (keyof InteractionPresetOptions[T] & string)[]; + create(options: InteractionPresetOptions[T]): CanvasInteractionDef; +} + +/** + * The registry behind `interaction_spec.presets`: one entry per preset type, + * mapping the JSON name to the factory that code calls today. The mapped type + * makes a missing or extra name a compile error. + */ +export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: InteractionPresetDefinition } = { + 'click-highlight': { + type: 'click-highlight', + label: 'Click highlight', + description: 'Click a mark, legend item, or discrete axis label to emphasise it and mute the rest.', + requires: 'element-semantics', + gesture: 'click', + create: clickHighlight, + }, + 'axis-highlight': { + type: 'axis-highlight', + label: 'Axis highlight', + description: 'Hover or click a discrete axis label to emphasise its category.', + requires: 'discrete-axis', + gesture: 'click', + create: axisHighlight, + }, + 'click-group-focus': { + type: 'click-group-focus', + label: 'Click group focus', + description: 'Click a mark to emphasise every mark that shares its group.', + requires: 'element-semantics', + gesture: 'click', + create: clickGroupFocus, + }, + 'hover-group-focus': { + type: 'hover-group-focus', + label: 'Hover group focus', + description: 'Hover a mark to preview its group; leaving the mark restores the chart.', + requires: 'element-semantics', + gesture: 'hover', + requiredOptions: ['groupBy'], + create: hoverGroupFocus, + }, + 'click-annotate': { + type: 'click-annotate', + label: 'Click annotate', + description: 'Click a mark to pin an annotation on it.', + requires: 'element-semantics', + gesture: 'click', + create: clickAnnotate, + }, + 'select': { + type: 'select', + label: 'Rectangle select', + description: 'Drag a rectangle to emphasise the marks inside it.', + requires: 'cartesian-region', + gesture: 'drag', + create: select, + }, + 'lasso-select': { + type: 'lasso-select', + label: 'Lasso select', + description: 'Draw a freehand region to emphasise the marks inside it.', + requires: 'cartesian-region', + gesture: 'drag', + create: lassoSelect, + }, + 'brush-x': { + type: 'brush-x', + label: 'Brush x', + description: 'Drag an interval along x; a stateful brush stays editable after the drag.', + requires: 'cartesian-region', + gesture: 'drag', + create: brushX, + }, + 'brush-y': { + type: 'brush-y', + label: 'Brush y', + description: 'Drag an interval along y; a stateful brush stays editable after the drag.', + requires: 'cartesian-region', + gesture: 'drag', + create: brushY, + }, + 'brush-angle': { + type: 'brush-angle', + label: 'Brush angle', + description: 'Drag an angular sector on a polar chart such as a pie, donut, rose, or radar.', + requires: 'angular-region', + gesture: 'drag', + create: brushAngle, + }, + 'brush-zoom': { + type: 'brush-zoom', + label: 'Brush zoom', + description: 'Drag a rectangle to zoom the viewport to it.', + requires: 'navigation', + gesture: 'drag', + create: brushZoom, + }, + 'linked-brush': { + type: 'linked-brush', + label: 'Linked brush', + description: 'Brush marks and emphasise every mark that shares their group, across views.', + requires: 'element-semantics', + gesture: 'drag', + requiredOptions: ['groupBy'], + create: linkedBrush, + }, + 'legend-toggle': { + type: 'legend-toggle', + label: 'Legend toggle', + description: 'Click a legend item to hide or restore its series.', + requires: 'legend', + gesture: 'click', + create: legendToggle, + }, + 'context-activate': { + type: 'context-activate', + label: 'Context activate', + description: 'Right-click a mark; emits the event for the host and applies no built-in update.', + requires: 'element-semantics', + gesture: 'context', + create: contextActivate, + }, + 'long-press': { + type: 'long-press', + label: 'Long press', + description: 'Hold on a mark to emphasise it; the touch equivalent of a context request.', + requires: 'element-semantics', + gesture: 'long-press', + create: longPress, + }, + 'double-activate': { + type: 'double-activate', + label: 'Double activate', + description: 'Double-click a mark to emphasise it.', + requires: 'element-semantics', + gesture: 'double', + create: doubleActivate, + }, + 'inspect': { + type: 'inspect', + label: 'Inspect', + description: 'Move the pointer to read values with x, y, or xy guides.', + requires: 'element-semantics', + gesture: 'inspect', + create: inspect, + }, + 'inspect-index': { + type: 'inspect-index', + label: 'Inspect index', + description: 'Move along one axis to read every series at that position.', + requires: 'element-semantics', + gesture: 'inspect', + create: inspectIndex, + }, + 'navigate': { + type: 'navigate', + label: 'Navigate', + description: 'Drag to pan, wheel or pinch to zoom, and a reset gesture to return to the full frame.', + requires: 'navigation', + gesture: 'navigate', + create: navigate, + }, + 'drag-reorder': { + type: 'drag-reorder', + label: 'Drag reorder', + description: 'Drag a mark or an axis label to reorder the categories.', + requires: 'reorder', + gesture: 'drag', + create: dragReorder, + }, +}; + +/** A registry entry without its factory: what a catalogue or an agent needs to choose. */ +export interface InteractionPresetSummary { + readonly type: InteractionPresetType; + readonly label: string; + readonly description: string; + readonly requires: InteractionCapability; + readonly gesture: InteractionGestureFamily; + readonly requiredOptions?: readonly string[]; +} + +export function listInteractionPresets(): readonly InteractionPresetSummary[] { + return INTERACTION_PRESET_TYPES.map((type) => { + const definition = INTERACTION_PRESETS[type] as InteractionPresetDefinition; + return { + type, + label: definition.label, + description: definition.description, + requires: definition.requires, + gesture: definition.gesture, + ...(definition.requiredOptions ? { requiredOptions: definition.requiredOptions } : {}), + }; + }); +} diff --git a/packages/flint-js/src/interactive/spec/resolve.ts b/packages/flint-js/src/interactive/spec/resolve.ts new file mode 100644 index 00000000..3e025aad --- /dev/null +++ b/packages/flint-js/src/interactive/spec/resolve.ts @@ -0,0 +1,117 @@ +import type { ChartUpdate } from '../../core/interaction-contracts'; +import { + INTERACTION_PRESET_TYPES, + type InteractionEntry, + type InteractionPresetType, + type InteractionSpec, +} from '../../core/interaction-spec'; +import type { CanvasInteractionDef } from '../interactions'; +import { INTERACTION_PRESETS, type InteractionPresetDefinition } from './registry'; + +export interface ResolvedInteractionSpec { + /** Canvas definitions in spec order, each tagged `origin: 'spec'`. */ + readonly interactions: readonly CanvasInteractionDef[]; + readonly updates: readonly ChartUpdate[]; + readonly surface: Pick; +} + +const EMPTY: ResolvedInteractionSpec = Object.freeze({ + interactions: Object.freeze([]) as readonly CanvasInteractionDef[], + updates: Object.freeze([]) as readonly ChartUpdate[], + surface: Object.freeze({}), +}); + +function isPresetType(value: unknown): value is InteractionPresetType { + return typeof value === 'string' && (INTERACTION_PRESET_TYPES as readonly string[]).includes(value); +} + +function entryLabel(index: number, type?: unknown): string { + return `interaction_spec.interactions[${index}]${typeof type === 'string' ? ` (${type})` : ''}`; +} + +/** + * Turn `interaction_spec` into the definitions `buildInteractiveChart()` takes. + * + * Every entry is looked up by `type` and created through the same factory that + * code calls, so a spec entry and a factory call are two spellings of one + * definition. The resolver knows nothing about the chart: a preset the chart + * cannot honour is admitted or dropped later, at mount, where the compiled + * capabilities are known. Malformed input throws here, naming the entry. + */ +export function resolveInteractionSpec(spec: InteractionSpec | undefined): ResolvedInteractionSpec { + if (!spec) return EMPTY; + if (!Array.isArray(spec.interactions)) { + throw new Error('interaction_spec.interactions must be an array of preset entries.'); + } + const interactions: CanvasInteractionDef[] = []; + const idOwners = new Map(); + spec.interactions.forEach((entry, index) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + throw new Error(`${entryLabel(index)}: expected an object with a "type".`); + } + const { type, id, options, ...unexpected } = entry as InteractionEntry; + if (!isPresetType(type)) { + throw new Error( + `${entryLabel(index, type)}: unknown preset type. Known types: ${INTERACTION_PRESET_TYPES.join(', ')}.`, + ); + } + // A flat option written by habit must not vanish silently. + const stray = Object.keys(unexpected); + if (stray.length > 0) { + throw new Error( + `${entryLabel(index, type)}: unexpected key${stray.length > 1 ? 's' : ''} ${stray.map((key) => `"${key}"`).join(', ')}. Put preset options under "options".`, + ); + } + if (id !== undefined && typeof id !== 'string') { + throw new Error(`${entryLabel(index, type)}: "id" must be a string.`); + } + if (options !== undefined && (options === null || typeof options !== 'object' || Array.isArray(options))) { + throw new Error(`${entryLabel(index, type)}: "options" must be an object.`); + } + const presetOptions: Record = options ?? {}; + if ('id' in presetOptions) { + throw new Error(`${entryLabel(index, type)}: put "id" on the entry, not inside "options".`); + } + const definition = INTERACTION_PRESETS[type] as InteractionPresetDefinition; + for (const option of definition.requiredOptions ?? []) { + if (presetOptions[option] === undefined) { + throw new Error(`${entryLabel(index, type)}: option "${option}" is required.`); + } + } + let created: CanvasInteractionDef; + try { + // The type name is the default id, so two entries of one type need explicit ids. + const create = definition.create as (options: Record) => CanvasInteractionDef; + created = create({ ...presetOptions, id: id ?? type }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${entryLabel(index, type)}: ${message}`); + } + const owner = idOwners.get(created.id); + if (owner !== undefined) { + throw new Error( + `${entryLabel(index, type)}: duplicate id "${created.id}" (also used by interactions[${owner}]). Give one of them an id.`, + ); + } + idOwners.set(created.id, index); + interactions.push({ ...created, origin: 'spec' }); + }); + const updates = spec.updates ?? []; + if (!Array.isArray(updates)) { + throw new Error('interaction_spec.updates must be an array of ChartUpdate values.'); + } + updates.forEach((update, index) => { + if (!update || typeof update !== 'object' || typeof update.id !== 'string' || !Array.isArray(update.ops)) { + throw new Error(`interaction_spec.updates[${index}] must be a ChartUpdate with a string id and an ops array.`); + } + }); + return { + interactions, + updates, + surface: { + ...(spec.assistedTargeting !== undefined ? { assistedTargeting: spec.assistedTargeting } : {}), + ...(spec.keyboardTargeting !== undefined ? { keyboardTargeting: spec.keyboardTargeting } : {}), + ...(spec.dismiss !== undefined ? { dismiss: spec.dismiss } : {}), + }, + }; +} diff --git a/packages/flint-js/src/interactive/spec/types.ts b/packages/flint-js/src/interactive/spec/types.ts new file mode 100644 index 00000000..0805b8b9 --- /dev/null +++ b/packages/flint-js/src/interactive/spec/types.ts @@ -0,0 +1,63 @@ +import type { InteractionPresetType } from '../../core/interaction-spec'; +import type { + AngularBrushOptions, + AxisHighlightOptions, + BrushOptions, + BrushZoomOptions, + ClickAnnotateOptions, + ClickGroupFocusOptions, + ClickHighlightOptions, + ContextActivateOptions, + DoubleActivateOptions, + DragReorderOptions, + HoverGroupFocusOptions, + InspectIndexOptions, + InspectOptions, + LassoSelectOptions, + LegendToggleOptions, + LinkedBrushOptions, + LongPressOptions, + NavigateOptions, + SelectOptions, +} from '../interactions'; + +/** + * The options each preset type accepts in a spec. They are the factory option + * types, so the JSON shape and the code shape cannot drift apart. The one + * difference: `click-annotate` loses `format`, which is a function. + */ +export interface InteractionPresetOptions { + 'click-highlight': ClickHighlightOptions; + 'axis-highlight': AxisHighlightOptions; + 'click-group-focus': ClickGroupFocusOptions; + 'hover-group-focus': HoverGroupFocusOptions; + 'click-annotate': Omit; + 'select': SelectOptions; + 'lasso-select': LassoSelectOptions; + 'brush-x': BrushOptions; + 'brush-y': BrushOptions; + 'brush-angle': AngularBrushOptions; + 'brush-zoom': BrushZoomOptions; + 'linked-brush': LinkedBrushOptions; + 'legend-toggle': LegendToggleOptions; + 'context-activate': ContextActivateOptions; + 'long-press': LongPressOptions; + 'double-activate': DoubleActivateOptions; + 'inspect': InspectOptions; + 'inspect-index': InspectIndexOptions; + 'navigate': NavigateOptions; + 'drag-reorder': DragReorderOptions; +} + +/** + * A preset entry with its precise options, for TypeScript authors of a spec: + * the name, an optional id, and the factory options under `options`. The id + * lives on the entry, so it is removed from the nested options. + */ +export type InteractionPresetSpec = { + [T in InteractionPresetType]: { + type: T; + id?: string; + options?: Omit; + }; +}[InteractionPresetType]; diff --git a/packages/flint-js/tests/interaction-spec.test.ts b/packages/flint-js/tests/interaction-spec.test.ts new file mode 100644 index 00000000..566a6746 --- /dev/null +++ b/packages/flint-js/tests/interaction-spec.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; +import { INTERACTION_PRESET_TYPES, type InteractionSpec } from '../src/core/interaction-spec'; +import { INTERACTION_PRESETS, listInteractionPresets } from '../src/interactive/spec/registry'; +import type { InteractionPresetSpec } from '../src/interactive/spec/types'; +import { resolveInteractionSpec } from '../src/interactive/spec/resolve'; +import { brushX, navigate } from '../src/interactive/interactions'; + +/** The comparable half of a definition: everything but the handler and the origin tag. */ +const dataOnly = (definition: object): Record => Object.fromEntries( + Object.entries(definition).filter(([key, value]) => typeof value !== 'function' && key !== 'origin'), +); + +/** Options a factory cannot default; the registry names them as `requiredOptions`. */ +const REQUIRED: Partial>> = { + 'hover-group-focus': { groupBy: 'Country' }, + 'linked-brush': { groupBy: 'Country' }, +}; + +describe('interaction preset registry', () => { + it('has one entry per preset type, keyed by that type', () => { + expect(Object.keys(INTERACTION_PRESETS).sort()).toEqual([...INTERACTION_PRESET_TYPES].sort()); + for (const type of INTERACTION_PRESET_TYPES) expect(INTERACTION_PRESETS[type].type).toBe(type); + }); + + it('names the options a factory cannot default', () => { + expect(INTERACTION_PRESETS['hover-group-focus'].requiredOptions).toEqual(['groupBy']); + expect(INTERACTION_PRESETS['linked-brush'].requiredOptions).toEqual(['groupBy']); + expect(INTERACTION_PRESETS.navigate.requiredOptions).toBeUndefined(); + }); + + it('lists every preset without its factory', () => { + const summaries = listInteractionPresets(); + expect(summaries.map((summary) => summary.type)).toEqual([...INTERACTION_PRESET_TYPES]); + for (const summary of summaries) { + expect(summary).not.toHaveProperty('create'); + expect(summary.label.length).toBeGreaterThan(0); + expect(summary.description.length).toBeGreaterThan(0); + } + }); +}); + +describe('resolveInteractionSpec', () => { + it('returns nothing for an absent spec', () => { + const resolved = resolveInteractionSpec(undefined); + expect(resolved.interactions).toEqual([]); + expect(resolved.updates).toEqual([]); + expect(resolved.surface).toEqual({}); + }); + + it('creates the same definition the factory returns, tagged with its origin', () => { + const { interactions } = resolveInteractionSpec({ + interactions: [{ type: 'brush-x', options: { mode: 'stateful', dimOpacity: 0.3 } }], + }); + expect(interactions).toHaveLength(1); + expect(interactions[0].origin).toBe('spec'); + expect(typeof interactions[0].handle).toBe('function'); + expect(dataOnly(interactions[0])).toEqual(dataOnly(brushX({ mode: 'stateful', dimOpacity: 0.3 }))); + }); + + it('resolves every preset type with default options and the type as its id', () => { + for (const type of INTERACTION_PRESET_TYPES) { + const required = REQUIRED[type]; + const { interactions } = resolveInteractionSpec({ + interactions: [required ? { type, options: required } : { type }], + }); + expect(interactions).toHaveLength(1); + expect(interactions[0].id).toBe(type); + expect(interactions[0].eventSource.type).toBeTruthy(); + } + }); + + it('passes options through, including the navigate reset list', () => { + const entry: InteractionPresetSpec = { + type: 'navigate', + options: { axes: 'x', pan: false, reset: ['click-background'] }, + }; + const { interactions } = resolveInteractionSpec({ interactions: [entry] }); + expect(interactions[0].eventSource).toMatchObject({ + type: 'navigation', axes: 'x', pan: false, reset: ['click-background'], + }); + expect(dataOnly(interactions[0])).toEqual(dataOnly(navigate({ axes: 'x', pan: false, reset: ['click-background'] }))); + }); + + it('takes the id from the entry, so one type can appear twice', () => { + const { interactions } = resolveInteractionSpec({ + interactions: [ + { type: 'brush-x', id: 'years' }, + { type: 'brush-x', id: 'months', options: { mode: 'stateful' } }, + ], + }); + expect(interactions.map((interaction) => interaction.id)).toEqual(['years', 'months']); + }); + + it('rejects an unknown type and names the entry and the known types', () => { + expect(() => resolveInteractionSpec({ interactions: [{ type: 'zoom' as never }] })) + .toThrow(/interaction_spec\.interactions\[0\] \(zoom\): unknown preset type\. Known types: click-highlight, .*navigate/); + }); + + it('rejects an entry that is not an object', () => { + expect(() => resolveInteractionSpec({ interactions: ['navigate' as never] })) + .toThrow(/interaction_spec\.interactions\[0\]: expected an object with a "type"/); + }); + + it('rejects a flat option and points at "options"', () => { + expect(() => resolveInteractionSpec({ interactions: [{ type: 'navigate', axes: 'x' } as never] })) + .toThrow(/interaction_spec\.interactions\[0\] \(navigate\): unexpected key "axes"\. Put preset options under "options"/); + expect(() => resolveInteractionSpec({ interactions: [{ type: 'navigate', axes: 'x', pan: false } as never] })) + .toThrow(/unexpected keys "axes", "pan"/); + }); + + it('rejects an id inside options', () => { + expect(() => resolveInteractionSpec({ interactions: [{ type: 'brush-x', options: { id: 'years' } as never }] })) + .toThrow(/interaction_spec\.interactions\[0\] \(brush-x\): put "id" on the entry, not inside "options"/); + }); + + it('rejects options that are not an object', () => { + expect(() => resolveInteractionSpec({ interactions: [{ type: 'brush-x', options: ['stateful'] as never }] })) + .toThrow(/interaction_spec\.interactions\[0\] \(brush-x\): "options" must be an object/); + }); + + it('rejects a missing required option by name', () => { + expect(() => resolveInteractionSpec({ interactions: [{ type: 'hover-group-focus' }] })) + .toThrow(/interaction_spec\.interactions\[0\] \(hover-group-focus\): option "groupBy" is required/); + }); + + it('prefixes a factory error with the entry', () => { + expect(() => resolveInteractionSpec({ interactions: [{ type: 'inspect-index', options: { show: 'single' } }] })) + .toThrow(/interaction_spec\.interactions\[0\] \(inspect-index\): inspectIndex/); + expect(() => resolveInteractionSpec({ + interactions: [{ type: 'navigate', options: { domainGuard: { minVisibleFraction: 0.5, maxVisibleFraction: 0.1 } } }], + })).toThrow(/interaction_spec\.interactions\[0\] \(navigate\): navigate\(\)/); + }); + + it('rejects two entries that resolve to one id', () => { + expect(() => resolveInteractionSpec({ interactions: [{ type: 'brush-x' }, { type: 'brush-x' }] })) + .toThrow(/interaction_spec\.interactions\[1\] \(brush-x\): duplicate id "brush-x" \(also used by interactions\[0\]\)/); + expect(() => resolveInteractionSpec({ interactions: [{ type: 'brush-x', id: 'same' }, { type: 'brush-y', id: 'same' }] })) + .toThrow(/interactions\[1\] \(brush-y\): duplicate id "same"/); + }); + + it('passes updates and surface policies through unchanged', () => { + const spec: InteractionSpec = { + interactions: [], + updates: [{ id: 'seed', ops: [{ op: 'set-style', targets: [], value: { state: 'normal' } }] }], + dismiss: { escape: true }, + keyboardTargeting: true, + }; + const resolved = resolveInteractionSpec(spec); + expect(resolved.updates).toEqual(spec.updates); + expect(resolved.surface).toEqual({ dismiss: { escape: true }, keyboardTargeting: true }); + }); + + it('rejects a malformed update', () => { + expect(() => resolveInteractionSpec({ interactions: [], updates: [{ id: 'seed' } as never] })) + .toThrow(/interaction_spec\.updates\[0\] must be a ChartUpdate/); + }); +}); From f0773c1d4eeccedabf4f5616325fff823811e1fb Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Fri, 11 Sep 2026 15:20:38 -0700 Subject: [PATCH 3/9] feat(interactions): admission at mount warns and drops spec entries The checks addVegaLiteInteractions made inline move to admitInteractions. A code definition still throws with the same message. A spec entry the chart cannot honour is dropped and reported as a ChartWarning; in a pan-versus-drag conflict the later entry yields. The plan carries the admitted list and the warnings, the Vega mount runs the admitted list, and the renderer exposes the warnings. --- packages/flint-js/src/interactive/index.ts | 2 + .../src/interactive/spec/admission.ts | 128 ++++++++++++++++ packages/flint-js/src/interactive/types.ts | 4 +- .../src/vegalite/interactions/compile.ts | 57 ++----- .../src/vegalite/interactions/contracts.ts | 7 +- packages/flint-js/src/vegalite/interactive.ts | 9 +- .../tests/interaction-admission.test.ts | 143 ++++++++++++++++++ 7 files changed, 303 insertions(+), 47 deletions(-) create mode 100644 packages/flint-js/src/interactive/spec/admission.ts create mode 100644 packages/flint-js/tests/interaction-admission.test.ts diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 54bdf221..1fa14c3c 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -152,6 +152,8 @@ export type { } from './spec/registry'; export { resolveInteractionSpec } from './spec/resolve'; export type { ResolvedInteractionSpec } from './spec/resolve'; +export { admitInteractions } from './spec/admission'; +export type { InteractionAdmission, InteractionAdmissionPlan } from './spec/admission'; export function buildInteractiveChart( container: HTMLElement, diff --git a/packages/flint-js/src/interactive/spec/admission.ts b/packages/flint-js/src/interactive/spec/admission.ts new file mode 100644 index 00000000..084c05fc --- /dev/null +++ b/packages/flint-js/src/interactive/spec/admission.ts @@ -0,0 +1,128 @@ +import type { ChartWarning } from '../../core/types'; +import type { CanvasInteractionDef } from '../interactions'; +import type { NavigationAxes } from '../language/events'; + +/** What admission reads from the compiled chart: the fields the assembler writes to `_interactionSemantics`. */ +export interface InteractionAdmissionPlan { + readonly fields: readonly string[]; + readonly selectableMarks: readonly string[]; + readonly resolve?: unknown; + readonly navigationAxes?: readonly ('x' | 'y')[]; + readonly supportedRegionGestures?: readonly ('cartesian' | 'angular')[]; +} + +export interface InteractionAdmission { + /** The interactions the chart can honour, in their original order. */ + readonly admitted: readonly CanvasInteractionDef[]; + /** One warning per dropped spec interaction. */ + readonly warnings: readonly ChartWarning[]; +} + +type Axis = 'x' | 'y'; + +/** The axes a navigation source asks for, given what the chart offers. */ +export function navigationAxesFor( + axes: NavigationAxes | 'available' | undefined, + available: readonly Axis[], +): readonly Axis[] { + if (axes === undefined || axes === 'available') return available; + return axes === 'xy' ? ['x', 'y'] : [axes]; +} + +const PAN_DRAG_CONFLICT = 'Pan navigation cannot share an unmodified drag gesture with a region interaction.'; +const DROPPED = 'The interaction was dropped.'; + +/** + * Decide which interactions a compiled chart can honour. + * + * The checks are the ones the Vega-Lite compile step used to make inline. They + * now answer differently by origin: a definition made in code throws, as it + * always did, because a developer sees the exception; an entry that came from + * `interaction_spec` is dropped and reported as a `ChartWarning`, because an + * agent reads warnings and the chart should still render. When two entries + * conflict, the one later in the list yields. + */ +export function admitInteractions( + plan: InteractionAdmissionPlan, + interactions: readonly CanvasInteractionDef[], +): InteractionAdmission { + const warnings: ChartWarning[] = []; + const reject = ( + interaction: CanvasInteractionDef, + code: 'unsupported_interaction' | 'conflicting_interactions', + message: string, + ): false => { + if (interaction.origin !== 'spec') throw new Error(message); + warnings.push({ severity: 'warning', code, message: `${message} ${DROPPED}` }); + return false; + }; + const hasElementSemantics = !!plan.resolve || plan.fields.length > 0 || plan.selectableMarks.length > 0; + const available = plan.navigationAxes ?? []; + const angular = plan.supportedRegionGestures?.includes('angular') ?? false; + + // Capability checks, one interaction at a time. + let admitted = interactions.filter((interaction) => { + const source = interaction.eventSource; + if ((source.type === 'element' || source.type === 'region') && !hasElementSemantics) { + return reject(interaction, 'unsupported_interaction', + `Interaction "${interaction.id}" requires chart element semantics.`); + } + if (source.type === 'navigation') { + const requested = navigationAxesFor(source.axes, available); + if (requested.length === 0) { + return reject(interaction, 'unsupported_interaction', + `Interaction "${interaction.id}" requires a chart with a navigable continuous axis.`); + } + const unsupported = requested.filter((axis) => !available.includes(axis)); + if (unsupported.length > 0) { + return reject(interaction, 'unsupported_interaction', + `Interaction "${interaction.id}" requested unsupported navigation axis: ${unsupported.join(', ')}.`); + } + } + if (source.regionGeometry === 'angular' && !angular) { + return reject(interaction, 'unsupported_interaction', + `Interaction "${interaction.id}" requires a polar chart with angular-region support.`); + } + return true; + }); + + // A chart navigates through one interaction. A later spec entry yields; code keeps today's + // behaviour, where the first definition wins. + let navigation: CanvasInteractionDef | undefined; + admitted = admitted.filter((interaction) => { + if (interaction.eventSource.type !== 'navigation') return true; + if (!navigation) { + navigation = interaction; + return true; + } + if (interaction.origin !== 'spec') return true; + warnings.push({ + severity: 'warning', + code: 'conflicting_interactions', + message: `Interaction "${interaction.id}" is a second navigation interaction; the chart keeps "${navigation.id}". ${DROPPED}`, + }); + return false; + }); + + // Pan and an unmodified drag gesture cannot share the plot. + for (;;) { + const pan = admitted.find((interaction) => + interaction.eventSource.type === 'navigation' && interaction.eventSource.pan); + const drag = admitted.find((interaction) => + interaction.eventSource.type !== 'navigation' && interaction.eventSource.gesture === 'drag'); + if (!pan || !drag) break; + if (pan.origin !== 'spec' && drag.origin !== 'spec') throw new Error(PAN_DRAG_CONFLICT); + const later = admitted.indexOf(pan) > admitted.indexOf(drag) ? pan : drag; + const earlier = later === pan ? drag : pan; + const victim = later.origin === 'spec' ? later : earlier; + const kept = victim === pan ? drag : pan; + warnings.push({ + severity: 'warning', + code: 'conflicting_interactions', + message: `Interaction "${victim.id}" conflicts with "${kept.id}": ${PAN_DRAG_CONFLICT.charAt(0).toLowerCase()}${PAN_DRAG_CONFLICT.slice(1)} ${DROPPED}`, + }); + admitted = admitted.filter((interaction) => interaction !== victim); + } + + return { admitted, warnings }; +} diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts index f6f80d8a..578f2074 100644 --- a/packages/flint-js/src/interactive/types.ts +++ b/packages/flint-js/src/interactive/types.ts @@ -1,4 +1,4 @@ -import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { CategoryViewport, ChartAssemblyInput, ChartWarning } from '../core/types'; import type { InteractionContext, InteractionDef } from './interactions'; import type { ChartUpdate, ChartUpdateResult } from './language/updates'; import type { AssistedTargetingOptions, InteractionDismissPolicy } from '../core/interaction-spec'; @@ -32,6 +32,8 @@ export interface ChartUpdateApplyOptions { export interface InteractiveRenderer { viewports: CategoryViewport[]; + /** Admission warnings from the mount: spec interactions the chart could not honour. */ + readonly warnings?: readonly ChartWarning[]; setViewports(starts: ViewportState): void | Promise; getViewportGeometry?(channel: ViewportChannel): ViewportGeometry | undefined; getInteractionContext?(): InteractionContext; diff --git a/packages/flint-js/src/vegalite/interactions/compile.ts b/packages/flint-js/src/vegalite/interactions/compile.ts index d4778117..89ebddb0 100644 --- a/packages/flint-js/src/vegalite/interactions/compile.ts +++ b/packages/flint-js/src/vegalite/interactions/compile.ts @@ -6,6 +6,7 @@ import { type InteractionDef, } from '../../interactive/interactions'; import { toCanvasInteractionEvent } from '../../interactive/canvas-interaction'; +import { admitInteractions, navigationAxesFor } from '../../interactive/spec/admission'; import { DEFAULT_DIM_OPACITY } from '../../interactive/presets/utils'; import { INTERACTION_PROVENANCE, type InteractionProvenance } from '../interaction-provenance'; import type { @@ -394,26 +395,21 @@ export function addVegaLiteInteractions( } return null; } - const navigationInteraction = canvasInteractions.find( + // Admission decides what this chart can honour. A code definition still throws; a spec + // entry is dropped with a warning. Everything below reads the admitted list, and the plan + // carries it so the runtime mounts the same list. + const admission = admitInteractions(templateSemantics, canvasInteractions); + const admitted = admission.admitted; + const navigationInteraction = admitted.find( (interaction) => interaction.eventSource.type === 'navigation', ); const declaredReorderAxes = templateSemantics.reorderAxes ?? (templateSemantics.reorderAxis ? [templateSemantics.reorderAxis] : []); - const hasElementDrag = canvasInteractions.some( + const hasElementDrag = admitted.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( + const semanticInteractions = admitted.filter( (interaction) => interaction.eventSource.type !== 'navigation', ); const presentationInteractions = semanticInteractions.filter( @@ -422,41 +418,14 @@ export function addVegaLiteInteractions( 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 viewportRegion = admitted.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'] + ? navigationAxesFor(navigationInteraction.eventSource.axes, availableNavigationAxes) : 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); @@ -490,7 +459,7 @@ export function addVegaLiteInteractions( : false; if (needsSemanticPresentation && !instrumented) return null; if (instrumented) addLocalKeyTransforms(spec, fields, selectableMarks); - if (instrumented && canvasInteractions.some((interaction) => interaction.claimsLegendActivation)) { + if (instrumented && admitted.some((interaction) => interaction.claimsLegendActivation)) { pinLegendDomains(spec, templateSemantics.legendFields); } stripInteractionProvenance(spec); @@ -532,6 +501,8 @@ export function addVegaLiteInteractions( : [], resolve: templateSemantics.resolve, presentUpdate: templateSemantics.presentUpdate, + interactions: admitted, + warnings: admission.warnings, }; } diff --git a/packages/flint-js/src/vegalite/interactions/contracts.ts b/packages/flint-js/src/vegalite/interactions/contracts.ts index 1d27b8c7..a8f617b2 100644 --- a/packages/flint-js/src/vegalite/interactions/contracts.ts +++ b/packages/flint-js/src/vegalite/interactions/contracts.ts @@ -1,5 +1,6 @@ import type { ChartInteractionResolver } from '../../core/interaction-semantics'; -import type { ChartUpdatePresenter, InteractionContext } from '../../interactive/interactions'; +import type { ChartWarning } from '../../core/types'; +import type { CanvasInteractionDef, ChartUpdatePresenter, InteractionContext } from '../../interactive/interactions'; import type { GeoLevelConfig, GeoPreProjection } from './navigation-geo'; export interface HoverStyle { @@ -94,4 +95,8 @@ export interface VegaInteractionPlan { reorderAxes?: readonly VegaReorderAxis[]; resolve?: ChartInteractionResolver; presentUpdate?: ChartUpdatePresenter; + /** The canvas interactions the chart admitted; the runtime mounts these, not the requested list. */ + interactions?: readonly CanvasInteractionDef[]; + /** One warning per spec interaction the chart could not honour and dropped. */ + warnings?: readonly ChartWarning[]; } \ No newline at end of file diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts index 06cb6bdb..8e63c4bd 100644 --- a/packages/flint-js/src/vegalite/interactive.ts +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -86,7 +86,7 @@ export function createVegaInteractiveRenderer( vegaSpec, interactionPlan.axisFields, interactionPlan.reorderAxes, - canvasInteractions.some((interaction) => interaction.affordances?.some((affordance) => + (interactionPlan.interactions ?? canvasInteractions).some((interaction) => interaction.affordances?.some((affordance) => affordance.target === 'axis-label' && affordance.hover)) ? interactionPlan.selectionBoundary?.color ?? '#20262c' : undefined, @@ -139,13 +139,17 @@ export function createVegaInteractiveRenderer( tooltip.call(handler, event, item, withoutSemanticInteractionField(value)); }); await view.runAsync(); + // The runtime mounts what admission kept; external definitions pass through untouched. + const admittedCanvas = new Set(interactionPlan?.interactions ?? canvasInteractions); + const mountedInteractions = interactions.filter((interaction) => + !isCanvasInteraction(interaction) || admittedCanvas.has(interaction)); const interactionController = interactionPlan ? mountVegaInteractions( view, container, input.chart_spec.chartType, interactionPlan, - interactions, + mountedInteractions, interactionPlan.resolve, interactionPlan.presentUpdate ?? ((update) => update), options.assistDistance, @@ -185,6 +189,7 @@ export function createVegaInteractiveRenderer( return { viewports, + warnings: interactionPlan?.warnings ?? [], getInteractionContext() { return interactionController?.getInteractionContext() ?? { chartType: input.chart_spec.chartType, diff --git a/packages/flint-js/tests/interaction-admission.test.ts b/packages/flint-js/tests/interaction-admission.test.ts new file mode 100644 index 00000000..b24d08d2 --- /dev/null +++ b/packages/flint-js/tests/interaction-admission.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import { admitInteractions } from '../src/interactive/spec/admission'; +import { resolveInteractionSpec } from '../src/interactive/spec/resolve'; +import { brushAngle, brushX, clickHighlight, navigate, select } from '../src/interactive/interactions'; +import type { CanvasInteractionDef } from '../src/interactive/interactions'; +import type { InteractionEntry } from '../src/core/interaction-spec'; +import { addVegaLiteInteractions } from '../src/vegalite/interactions/compile'; +import { assembleVegaLite } from '../src/vegalite/assemble'; + +/** A Cartesian chart with element semantics and one navigable axis. */ +const CARTESIAN = { + fields: ['category'], + selectableMarks: ['bar'], + resolve: () => null, + navigationAxes: ['x'] as const, + supportedRegionGestures: ['cartesian'] as const, +}; +const NO_SEMANTICS = { fields: [], selectableMarks: [] }; + +const fromSpec = (entries: readonly InteractionEntry[]): readonly CanvasInteractionDef[] => + resolveInteractionSpec({ interactions: entries }).interactions; +const ids = (interactions: readonly CanvasInteractionDef[]): string[] => interactions.map((interaction) => interaction.id); + +describe('admitInteractions', () => { + it('admits everything the chart can honour, in order, with no warnings', () => { + const result = admitInteractions(CARTESIAN, [clickHighlight(), brushX(), ...fromSpec([{ type: 'legend-toggle' }])]); + expect(ids(result.admitted)).toEqual(['click-highlight', 'brush-x', 'legend-toggle']); + expect(result.warnings).toEqual([]); + }); + + it('throws for a code definition the chart cannot honour, with the message the compile step used', () => { + expect(() => admitInteractions(CARTESIAN, [brushAngle()])) + .toThrow('Interaction "brush-angle" requires a polar chart with angular-region support.'); + expect(() => admitInteractions(NO_SEMANTICS, [clickHighlight()])) + .toThrow('Interaction "click-highlight" requires chart element semantics.'); + expect(() => admitInteractions({ ...CARTESIAN, navigationAxes: [] }, [navigate()])) + .toThrow('Interaction "navigate" requires a chart with a navigable continuous axis.'); + expect(() => admitInteractions(CARTESIAN, [navigate({ axes: 'y' })])) + .toThrow('Interaction "navigate" requested unsupported navigation axis: y.'); + }); + + it('drops a spec entry the chart cannot honour and says so in a warning', () => { + const result = admitInteractions(CARTESIAN, fromSpec([{ type: 'brush-angle' }, { type: 'click-highlight' }])); + expect(ids(result.admitted)).toEqual(['click-highlight']); + expect(result.warnings).toEqual([{ + severity: 'warning', + code: 'unsupported_interaction', + message: 'Interaction "brush-angle" requires a polar chart with angular-region support. The interaction was dropped.', + }]); + }); + + it('drops a spec navigate the chart cannot navigate', () => { + const none = admitInteractions({ ...CARTESIAN, navigationAxes: [] }, fromSpec([{ type: 'navigate' }])); + expect(none.admitted).toEqual([]); + expect(none.warnings[0].message).toContain('requires a chart with a navigable continuous axis'); + const wrongAxis = admitInteractions(CARTESIAN, fromSpec([{ type: 'navigate', options: { axes: 'y' } }])); + expect(wrongAxis.admitted).toEqual([]); + expect(wrongAxis.warnings[0].message).toContain('requested unsupported navigation axis: y'); + const available = admitInteractions(CARTESIAN, fromSpec([{ type: 'navigate' }])); + expect(ids(available.admitted)).toEqual(['navigate']); + }); + + it('drops a spec entry on a chart with no element semantics', () => { + const result = admitInteractions(NO_SEMANTICS, fromSpec([{ type: 'click-highlight' }])); + expect(result.admitted).toEqual([]); + expect(result.warnings[0]).toMatchObject({ code: 'unsupported_interaction' }); + }); + + it('keeps one navigation interaction: a second spec navigate yields, code keeps today\'s behaviour', () => { + const spec = admitInteractions(CARTESIAN, fromSpec([{ type: 'navigate' }, { type: 'navigate', id: 'again' }])); + expect(ids(spec.admitted)).toEqual(['navigate']); + expect(spec.warnings[0]).toMatchObject({ code: 'conflicting_interactions' }); + expect(spec.warnings[0].message).toContain('"again" is a second navigation interaction; the chart keeps "navigate"'); + const code = admitInteractions(CARTESIAN, [navigate(), navigate({ id: 'again' })]); + expect(ids(code.admitted)).toEqual(['navigate', 'again']); + expect(code.warnings).toEqual([]); + }); + + describe('pan versus drag', () => { + it('throws when both definitions come from code, as today', () => { + expect(() => admitInteractions(CARTESIAN, [navigate(), select()])) + .toThrow('Pan navigation cannot share an unmodified drag gesture with a region interaction.'); + }); + + it('drops the later spec entry', () => { + const dragLater = admitInteractions(CARTESIAN, fromSpec([{ type: 'navigate' }, { type: 'select' }])); + expect(ids(dragLater.admitted)).toEqual(['navigate']); + expect(dragLater.warnings[0]).toMatchObject({ code: 'conflicting_interactions' }); + expect(dragLater.warnings[0].message).toContain('"select" conflicts with "navigate"'); + const navigateLater = admitInteractions(CARTESIAN, fromSpec([{ type: 'select' }, { type: 'navigate' }])); + expect(ids(navigateLater.admitted)).toEqual(['select']); + expect(navigateLater.warnings[0].message).toContain('"navigate" conflicts with "select"'); + }); + + it('drops the spec entry when the other side is code, whatever the order', () => { + const codeNavigate = admitInteractions(CARTESIAN, [navigate(), ...fromSpec([{ type: 'select' }])]); + expect(ids(codeNavigate.admitted)).toEqual(['navigate']); + const codeSelectLater = admitInteractions(CARTESIAN, [...fromSpec([{ type: 'navigate' }]), select()]); + expect(ids(codeSelectLater.admitted)).toEqual(['select']); + }); + + it('admits both when pan is off', () => { + const result = admitInteractions(CARTESIAN, fromSpec([{ type: 'navigate', options: { pan: false } }, { type: 'select' }])); + expect(ids(result.admitted)).toEqual(['navigate', 'select']); + expect(result.warnings).toEqual([]); + }); + }); +}); + +describe('addVegaLiteInteractions with spec interactions', () => { + const assembled = (): any => assembleVegaLite({ + data: { values: [{ category: 'A', value: 1 }, { category: 'B', value: 2 }] }, + semantic_types: { value: 'Quantity' }, + chart_spec: { chartType: 'Bar Chart', encodings: { x: 'category', y: 'value' } }, + }); + + it('drops an unsupported spec entry, reports it, and carries the admitted list on the plan', () => { + const plan = addVegaLiteInteractions(assembled(), fromSpec([{ type: 'brush-angle' }, { type: 'click-highlight' }])); + expect(plan).not.toBeNull(); + expect(ids(plan!.interactions ?? [])).toEqual(['click-highlight']); + expect(plan!.warnings).toHaveLength(1); + expect(plan!.warnings![0]).toMatchObject({ severity: 'warning', code: 'unsupported_interaction' }); + expect(plan!.warnings![0].message).toContain('"brush-angle"'); + }); + + it('drops a spec navigate on an axis a bar chart cannot navigate, and keeps one it can', () => { + const x = addVegaLiteInteractions(assembled(), fromSpec([{ type: 'navigate', options: { axes: 'x' } }])); + expect(x!.interactions).toEqual([]); + expect(x!.warnings![0].message).toContain('unsupported navigation axis: x'); + expect(x!.navigationChannels).toEqual([]); + const y = addVegaLiteInteractions(assembled(), fromSpec([{ type: 'navigate', options: { axes: 'y' } }])); + expect(ids(y!.interactions ?? [])).toEqual(['navigate']); + expect(y!.navigationChannels).toEqual(['y']); + expect(y!.warnings).toEqual([]); + }); + + it('still throws for the same request made in code', () => { + expect(() => addVegaLiteInteractions(assembled(), [brushAngle()])) + .toThrow('requires a polar chart with angular-region support'); + expect(() => addVegaLiteInteractions(assembled(), [navigate(), select()])) + .toThrow('Pan navigation cannot share'); + }); +}); From d0ebb3ad4cda371ca418c175004265c15bbedd6b Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Fri, 11 Sep 2026 15:20:47 -0700 Subject: [PATCH 4/9] feat(interactions): buildInteractiveChart reads interaction_spec composeInteractiveOptions merges the spec with the code options: the spec comes first, an id shared by both sources is an error, the code wins on the surface policies, and a backend that runs no interactions ignores the spec with one info warning. The surface exposes surface.warnings, resolved after ready, and logs the list once. --- packages/flint-js/src/interactive/index.ts | 26 +++--- .../flint-js/src/interactive/spec/compose.ts | 67 ++++++++++++++ packages/flint-js/src/interactive/surface.ts | 12 ++- packages/flint-js/src/interactive/types.ts | 4 + .../tests/interaction-compose.test.ts | 87 +++++++++++++++++++ 5 files changed, 183 insertions(+), 13 deletions(-) create mode 100644 packages/flint-js/src/interactive/spec/compose.ts create mode 100644 packages/flint-js/tests/interaction-compose.test.ts diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 1fa14c3c..657d418a 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -1,5 +1,6 @@ import type { ChartAssemblyInput } from '../core/types'; -import { isCanvasInteraction, normalizeInteractions } from './interactions'; +import { isCanvasInteraction } from './interactions'; +import { composeInteractiveOptions } from './spec/compose'; import { mountInteractiveChartSurface } from './surface'; import type { BuildInteractiveChartOptions, InteractiveChartSurface } from './types'; @@ -151,20 +152,21 @@ export type { InteractionPresetSummary, } from './spec/registry'; export { resolveInteractionSpec } from './spec/resolve'; -export type { ResolvedInteractionSpec } from './spec/resolve'; export { admitInteractions } from './spec/admission'; +export { composeInteractiveOptions } from './spec/compose'; +export type { ComposedInteractiveOptions } from './spec/compose'; export type { InteractionAdmission, InteractionAdmissionPlan } from './spec/admission'; +export type { ResolvedInteractionSpec } from './spec/resolve'; 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 { backend, renderer, expressionInterpreter, background, className, ariaLabel, chartId } = options; + // The spec and the code are two sources of one configuration; the spec comes first. + const { interactions, updates, assistedTargeting, keyboardTargeting, dismiss, warnings } = + composeInteractiveOptions(input, options); const canvasInteractions = interactions.filter(isCanvasInteraction); const hoverTolerance = Math.max(0, ...canvasInteractions .filter((interaction) => interaction.eventSource.gesture === 'hover') @@ -178,7 +180,7 @@ export function buildInteractiveChart( throw new Error(`Semantic interactions are not supported by backend "${backend}".`); }, }, - { className, ariaLabel, chartId, updates }, + { className, ariaLabel, chartId, updates, warnings }, ); } switch (backend) { @@ -211,7 +213,7 @@ export function buildInteractiveChart( }).mount(chartContainer, chartInput); }, }, - { className, ariaLabel, chartId, updates, interactions }, + { className, ariaLabel, chartId, updates, interactions, warnings }, ); case 'echarts': return mountInteractiveChartSurface( @@ -223,7 +225,7 @@ export function buildInteractiveChart( return createEChartsInteractiveRenderer({ renderer }).mount(chartContainer, chartInput); }, }, - { className, ariaLabel, chartId, updates, interactions }, + { className, ariaLabel, chartId, updates, interactions, warnings }, ); case 'chartjs': return mountInteractiveChartSurface( @@ -235,7 +237,7 @@ export function buildInteractiveChart( return createChartjsInteractiveRenderer().mount(chartContainer, chartInput); }, }, - { className, ariaLabel, chartId, updates, interactions }, + { className, ariaLabel, chartId, updates, interactions, warnings }, ); case 'plotly': return mountInteractiveChartSurface( @@ -247,7 +249,7 @@ export function buildInteractiveChart( return createPlotlyInteractiveRenderer().mount(chartContainer, chartInput); }, }, - { className, ariaLabel, chartId, updates, interactions }, + { className, ariaLabel, chartId, updates, interactions, warnings }, ); } } \ No newline at end of file diff --git a/packages/flint-js/src/interactive/spec/compose.ts b/packages/flint-js/src/interactive/spec/compose.ts new file mode 100644 index 00000000..19b33041 --- /dev/null +++ b/packages/flint-js/src/interactive/spec/compose.ts @@ -0,0 +1,67 @@ +import type { ChartUpdate } from '../../core/interaction-contracts'; +import type { ChartAssemblyInput, ChartWarning } from '../../core/types'; +import { normalizeInteractions, type InteractionDef } from '../interactions'; +import type { BuildInteractiveChartOptions } from '../types'; +import { resolveInteractionSpec } from './resolve'; + +export interface ComposedInteractiveOptions { + /** Spec interactions first, then the code's, with no id shared between the two. */ + readonly interactions: readonly InteractionDef[]; + /** Spec updates first, then the code's. */ + readonly updates: readonly ChartUpdate[]; + readonly assistedTargeting: BuildInteractiveChartOptions['assistedTargeting']; + readonly keyboardTargeting: boolean | undefined; + readonly dismiss: BuildInteractiveChartOptions['dismiss']; + /** Warnings known before the mount, such as a spec a static backend ignores. */ + readonly warnings: readonly ChartWarning[]; +} + +type ComposeInput = Pick; +type ComposeOptions = Pick< + BuildInteractiveChartOptions, + 'backend' | 'interactions' | 'updates' | 'assistedTargeting' | 'keyboardTargeting' | 'dismiss' +>; + +/** + * Merge `input.interaction_spec` with what the code passed to `buildInteractiveChart()`. + * + * The spec comes first in every list. A code definition cannot replace a spec + * entry by reusing its id; the collision is an error that names both sources. + * The three surface policies come from the code when it sets them, `false` + * included, and from the spec otherwise. A backend that runs no interactions + * ignores the spec with one `info` warning, unless the code also asked for + * interactions, in which case the mount still fails as it does today. + */ +export function composeInteractiveOptions(input: ComposeInput, options: ComposeOptions): ComposedInteractiveOptions { + const resolved = resolveInteractionSpec(input.interaction_spec); + const code = options.interactions ?? []; + const warnings: ChartWarning[] = []; + let specInteractions = resolved.interactions; + let specUpdates = resolved.updates; + if (options.backend !== 'vegalite' + && code.length === 0 + && (specInteractions.length > 0 || specUpdates.length > 0)) { + warnings.push({ + severity: 'info', + code: 'interactions_ignored', + message: `interaction_spec is ignored: backend "${options.backend}" does not run interactions.`, + }); + specInteractions = []; + specUpdates = []; + } + const codeIds = new Set(code.map((interaction) => interaction.id)); + const shared = specInteractions.find((interaction) => codeIds.has(interaction.id)); + if (shared) { + throw new Error( + `Interaction "${shared.id}" is defined in interaction_spec and in options.interactions. Give one of them another id.`, + ); + } + return { + interactions: normalizeInteractions([...specInteractions, ...code]), + updates: [...specUpdates, ...(options.updates ?? [])], + assistedTargeting: options.assistedTargeting ?? resolved.surface.assistedTargeting, + keyboardTargeting: options.keyboardTargeting ?? resolved.surface.keyboardTargeting, + dismiss: options.dismiss ?? resolved.surface.dismiss, + warnings, + }; +} diff --git a/packages/flint-js/src/interactive/surface.ts b/packages/flint-js/src/interactive/surface.ts index 647f0006..37089f28 100644 --- a/packages/flint-js/src/interactive/surface.ts +++ b/packages/flint-js/src/interactive/surface.ts @@ -1,4 +1,4 @@ -import type { CategoryViewport, ChartAssemblyInput } from '../core/types'; +import type { CategoryViewport, ChartAssemblyInput, ChartWarning } from '../core/types'; import type { InteractiveChartSurface, InteractiveChartSurfaceOptions, @@ -188,6 +188,7 @@ export function mountInteractiveChartSurface( let renderer: InteractiveRenderer | undefined; let updateTimer: number | undefined; let destroyed = false; + const warnings: ChartWarning[] = [...(options.warnings ?? [])]; root.className = options.className ?? 'flint-interactive-surface'; root.setAttribute('role', 'figure'); @@ -230,6 +231,14 @@ export function mountInteractiveChartSurface( return; } renderer = mounted; + warnings.push(...(mounted.warnings ?? [])); + if (warnings.length > 0) { + // A host that never reads `surface.warnings` still learns what the chart dropped. + console.warn([ + `[flint-chart] ${chartId}: ${warnings.length} interaction warning${warnings.length === 1 ? '' : 's'}`, + ...warnings.map((warning) => ` - ${warning.code}: ${warning.message}`), + ].join('\n')); + } 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 ?? []); @@ -266,6 +275,7 @@ export function mountInteractiveChartSurface( element: root, chartId, ready, + warnings: ready.then(() => warnings as readonly ChartWarning[], () => warnings as readonly ChartWarning[]), getViewportState: () => ({ ...state }), setViewport, dispatch: async (interactionId, payload) => { diff --git a/packages/flint-js/src/interactive/types.ts b/packages/flint-js/src/interactive/types.ts index 578f2074..353cee34 100644 --- a/packages/flint-js/src/interactive/types.ts +++ b/packages/flint-js/src/interactive/types.ts @@ -61,6 +61,8 @@ export interface InteractiveChartSurfaceOptions { keyboardTargeting?: boolean; /** How committed presentation and annotation state is cleared. */ dismiss?: InteractionDismissPolicy | false; + /** Warnings known before the mount; the surface reports them with the mount's own. */ + warnings?: readonly ChartWarning[]; } export type InteractiveBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly'; @@ -76,6 +78,8 @@ export interface InteractiveChartSurface { readonly element: HTMLElement; readonly chartId: string; readonly ready: Promise; + /** Every warning about this chart's interactions, once the mount has settled. Never rejects. */ + readonly warnings: Promise; getViewportState(): ViewportState; setViewport(channel: ViewportChannel, start: number): void; dispatch(interactionId: string, payload: unknown): Promise; diff --git a/packages/flint-js/tests/interaction-compose.test.ts b/packages/flint-js/tests/interaction-compose.test.ts new file mode 100644 index 00000000..d5ae081b --- /dev/null +++ b/packages/flint-js/tests/interaction-compose.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { composeInteractiveOptions } from '../src/interactive/spec/compose'; +import { clickHighlight, navigate, select, type CanvasInteractionDef } from '../src/interactive/interactions'; +import type { InteractionSpec } from '../src/core/interaction-spec'; + +const SPEC: InteractionSpec = { + interactions: [{ type: 'legend-toggle' }, { type: 'navigate', options: { axes: 'x' } }], + updates: [{ id: 'seed', ops: [{ op: 'set-style', targets: [], value: { state: 'normal' } }] }], + dismiss: { escape: false }, + keyboardTargeting: true, +}; +const ids = (composed: { interactions: readonly { id: string }[] }): string[] => + composed.interactions.map((interaction) => interaction.id); + +describe('composeInteractiveOptions', () => { + it('leaves a spec-less chart exactly as the code configured it', () => { + const composed = composeInteractiveOptions({}, { + backend: 'vegalite', interactions: [clickHighlight()], updates: [], dismiss: false, + }); + expect(ids(composed)).toEqual(['click-highlight']); + expect(composed.updates).toEqual([]); + expect(composed.dismiss).toBe(false); + expect(composed.keyboardTargeting).toBeUndefined(); + expect(composed.warnings).toEqual([]); + }); + + it('puts the spec before the code, for interactions and for updates', () => { + const codeUpdate = { id: 'host', ops: [] }; + const composed = composeInteractiveOptions({ interaction_spec: SPEC }, { + backend: 'vegalite', interactions: [select()], updates: [codeUpdate], + }); + expect(ids(composed)).toEqual(['legend-toggle', 'navigate', 'select']); + expect((composed.interactions[0] as CanvasInteractionDef).origin).toBe('spec'); + expect((composed.interactions[2] as CanvasInteractionDef).origin).toBeUndefined(); + expect(composed.updates.map((update) => update.id)).toEqual(['seed', 'host']); + }); + + it('rejects an id shared by the spec and the code, naming both sources', () => { + expect(() => composeInteractiveOptions({ interaction_spec: SPEC }, { + backend: 'vegalite', interactions: [navigate({ axes: 'y' })], + })).toThrow('Interaction "navigate" is defined in interaction_spec and in options.interactions. Give one of them another id.'); + }); + + it('still rejects a duplicate inside the code list', () => { + expect(() => composeInteractiveOptions({}, { + backend: 'vegalite', interactions: [select(), select()], + })).toThrow(/Duplicate interaction id: "select"/); + }); + + it('lets the code win on the surface policies, false included', () => { + const fromSpec = composeInteractiveOptions({ interaction_spec: SPEC }, { backend: 'vegalite' }); + expect(fromSpec.dismiss).toEqual({ escape: false }); + expect(fromSpec.keyboardTargeting).toBe(true); + expect(fromSpec.assistedTargeting).toBeUndefined(); + const fromCode = composeInteractiveOptions({ interaction_spec: SPEC }, { + backend: 'vegalite', dismiss: false, keyboardTargeting: false, assistedTargeting: { maxDistance: 4 }, + }); + expect(fromCode.dismiss).toBe(false); + expect(fromCode.keyboardTargeting).toBe(false); + expect(fromCode.assistedTargeting).toEqual({ maxDistance: 4 }); + }); + + it('ignores the spec on a backend that runs no interactions, with one info warning', () => { + const composed = composeInteractiveOptions({ interaction_spec: SPEC }, { backend: 'echarts' }); + expect(composed.interactions).toEqual([]); + expect(composed.updates).toEqual([]); + expect(composed.warnings).toEqual([{ + severity: 'info', + code: 'interactions_ignored', + message: 'interaction_spec is ignored: backend "echarts" does not run interactions.', + }]); + }); + + it('keeps the code interactions on such a backend, so the mount fails as it does today', () => { + const composed = composeInteractiveOptions({ interaction_spec: SPEC }, { + backend: 'echarts', interactions: [clickHighlight()], + }); + expect(ids(composed)).toEqual(['legend-toggle', 'navigate', 'click-highlight']); + expect(composed.warnings).toEqual([]); + }); + + it('does not warn for a backend that runs no interactions when the spec asks for none', () => { + const composed = composeInteractiveOptions({ interaction_spec: { interactions: [] } }, { backend: 'chartjs' }); + expect(composed.interactions).toEqual([]); + expect(composed.warnings).toEqual([]); + }); +}); From 5067e49c63b0a7c09ad349b2b6a31abef6b6c288 Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Fri, 11 Sep 2026 16:12:18 -0700 Subject: [PATCH 5/9] feat(site): spec test cases tab drives the interaction gallery from interaction_spec The Test cases lab gains a `source` and a second route, spec-test-cases, that mounts every case from `input.interaction_spec` instead of factory calls. `modeSpec()` mirrors `modeInteractions()` entry for entry, with the surface policies in the spec. Each card shows the JSON it mounted from in a foldable, token-coloured panel with a copy button, reports dropped entries in a callout, and the page tallies ready and dropped cards. A headless comparison of both tabs found no status difference across 23 modes and 837 cards. --- site/src/main.tsx | 3 +- site/src/playground/ClickFocusLab.tsx | 287 +++++++++++++++++++++--- site/src/playground/PlaygroundShell.tsx | 1 + site/src/playground/click-focus-lab.css | 138 +++++++++++- 4 files changed, 398 insertions(+), 31 deletions(-) diff --git a/site/src/main.tsx b/site/src/main.tsx index fb627db3..e61e48f7 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -25,7 +25,7 @@ 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 { ClickFocusLab, SpecTestCasesLab } from './playground/ClickFocusLab'; import { AnnotationLab } from './playground/AnnotationLab'; import { InteractionDashboardLab } from './playground/InteractionDashboardLab'; import { InteractionCandidates } from './playground/InteractionCandidates'; @@ -86,6 +86,7 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/site/src/playground/ClickFocusLab.tsx b/site/src/playground/ClickFocusLab.tsx index 2976c2db..f2ca5a3d 100644 --- a/site/src/playground/ClickFocusLab.tsx +++ b/site/src/playground/ClickFocusLab.tsx @@ -1,7 +1,14 @@ -import { Fragment, useEffect, useRef, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; -import { EyeOff, GripVertical, Keyboard, Lasso, Layers3, Link2, Menu, MessageSquareText, MousePointerClick, Move, MoveHorizontal, MoveVertical, RotateCcw, Ruler, Scan, Target, Timer, ZoomIn } from 'lucide-react'; -import { assembleVegaLite, type ChartAssemblyInput } from 'flint-chart'; +import { AlertTriangle, Braces, Check, ChevronDown, ChevronRight, Copy, EyeOff, GripVertical, Keyboard, Lasso, Layers3, Link2, Menu, MessageSquareText, MousePointerClick, Move, MoveHorizontal, MoveVertical, RotateCcw, Ruler, Scan, Target, Timer, ZoomIn } from 'lucide-react'; +import { + assembleVegaLite, + type ChartAssemblyInput, + type ChartWarning, + type InteractionEntry, + type InteractionPresetType, + type InteractionSpec, +} from 'flint-chart'; import { genBarTests, genGroupedBarTests, @@ -52,6 +59,8 @@ export type InteractionMode = 'click-highlight' | 'click-group-focus' | 'annotat | 'long-press' | 'double-activate' | 'legend-toggle' | 'brush-zoom' | 'keyboard-focus' | 'select-context'; type ProbeStatus = 'loading' | 'ready' | 'unsupported' | 'error'; +/** Where a card's interactions come from: factory calls in code, or `interaction_spec` JSON. */ +export type InteractionSource = 'code' | 'spec'; export interface NavigationGuard { minVisibleFraction: number; @@ -127,6 +136,79 @@ function modeInteractions( } } +/** Colours a JSON document by token: keys, preset type names, other strings, numbers. */ +function highlightJson(value: unknown): ReactNode[] { + const text = JSON.stringify(value, null, 2); + const tokens = /("(?:[^"\\]|\\.)*")(\s*:)?|(-?\d+(?:\.\d+)?)|\b(true|false|null)\b/g; + const nodes: ReactNode[] = []; + let last = 0; + let previousKey = ''; + let match: RegExpExecArray | null; + while ((match = tokens.exec(text)) !== null) { + if (match.index > last) nodes.push(text.slice(last, match.index)); + const [whole, string, colon, number, literal] = match; + if (string && colon) { + previousKey = string.slice(1, -1); + nodes.push({string}, colon); + } else if (string) { + nodes.push({string}); + previousKey = ''; + } else { + nodes.push({number ?? literal}); + previousKey = ''; + } + last = match.index + whole.length; + } + if (last < text.length) nodes.push(text.slice(last)); + return nodes; +} + +/** + * The same table as `modeInteractions`, spelled as `interaction_spec`. Each mode + * maps to the same preset with the same options, so the two tabs test one + * design from two entry points. Surface policies ride in the spec too. + */ +function modeSpec( + mode: InteractionMode, + navigationAxes: 'x' | 'y' | 'xy' | undefined, + navigationGuard: NavigationGuard | undefined, + groupBy: string | readonly string[] | undefined, + indexInspection: InteractionCase['indexInspection'], +): InteractionSpec { + const entry = (type: InteractionPresetType, options?: Record): InteractionEntry => + options ? { type, options } : { type }; + switch (mode) { + case 'click-highlight': return { interactions: [entry('click-highlight', { targets: ['mark', 'legend', 'discreteAxis'] })] }; + case 'click-group-focus': return { interactions: [entry('click-group-focus', groupBy ? { groupBy } : undefined)] }; + case 'hover-group-focus': return { interactions: groupBy ? [entry('hover-group-focus', { groupBy })] : [] }; + case 'annotate': return { interactions: [entry('click-annotate')] }; + case 'select': return { interactions: [entry('select')] }; + case 'linked-brush': return { interactions: groupBy ? [entry('linked-brush', { groupBy })] : [] }; + case 'brush-x': return { interactions: [entry('brush-x')] }; + case 'brush-y': return { interactions: [entry('brush-y')] }; + case 'brush-angle': return { interactions: [entry('brush-angle')] }; + case 'brush-x-stateful': return { interactions: [entry('brush-x', { mode: 'stateful' })] }; + case 'brush-y-stateful': return { interactions: [entry('brush-y', { mode: 'stateful' })] }; + case 'brush-angle-stateful': return { interactions: [entry('brush-angle', { mode: 'stateful' })] }; + case 'drag-reorder': return { interactions: [entry('drag-reorder')] }; + case 'lasso': return { interactions: [entry('lasso-select')] }; + case 'inspect': return { interactions: [entry('inspect', { mode: 'y' })] }; + case 'inspect-index': return { interactions: indexInspection ? [entry('inspect-index', { ...indexInspection })] : [] }; + case 'keyboard-focus': return { interactions: [entry('click-highlight', { targets: ['mark'] })], keyboardTargeting: true }; + case 'select-context': return { interactions: [entry('select'), entry('context-activate')] }; + case 'legend-toggle': return { interactions: [entry('legend-toggle')] }; + case 'long-press': return { interactions: [entry('long-press')], dismiss: { click: 'any', escape: true } }; + case 'double-activate': return { interactions: [entry('double-activate')], dismiss: { click: 'any', escape: true } }; + case 'brush-zoom': return { interactions: [entry('brush-zoom')] }; + default: return { + interactions: [entry('navigate', { + axes: navigationAxes ?? 'available', + ...(navigationGuard ? { domainGuard: navigationGuard } : {}), + })], + }; + } +} + export interface InteractionCase { id: string; title?: string; @@ -720,6 +802,7 @@ function InteractiveChart({ navigationAxes, groupBy, indexInspection, + spec, resetVersion, onStatus, onSemanticEvent, @@ -731,8 +814,10 @@ function InteractiveChart({ navigationAxes?: 'x' | 'y' | 'xy'; groupBy?: string | readonly string[]; indexInspection?: InteractionCase['indexInspection']; + /** When set, the chart mounts from this spec and the code-side options stay empty. */ + spec?: InteractionSpec; resetVersion: number; - onStatus: (status: ProbeStatus, message?: string) => void; + onStatus: (status: ProbeStatus, message?: string, warnings?: readonly ChartWarning[]) => void; onSemanticEvent: (detail: FlintInteractionEventDetail) => void; }) { const containerRef = useRef(null); @@ -772,34 +857,59 @@ function InteractiveChart({ }; container.addEventListener('contextmenu', captureContextPoint, true); container.addEventListener('flint-interaction', handleInteraction); - const interactions = modeInteractions(mode, navigationAxes, navigationGuard, groupBy, indexInspection); const themedInput = themeId ? { ...input, theme_spec: themeId } : input; - const surface = buildInteractiveChart(container, themedInput, { - backend: 'vegalite', - renderer: 'svg', - interactions, - expressionInterpreter, - ariaLabel: input.chart_spec.title, - keyboardTargeting: mode === 'keyboard-focus', - dismiss: mode === 'long-press' || mode === 'double-activate' - ? { click: 'any', escape: true } - : undefined, - }); - surfaceRef.current = surface; - void surface.ready.then(() => statusRef.current('ready')).catch((error) => { - const message = error instanceof Error ? error.message : String(error); - statusRef.current(message.includes('requires') || message.includes('support') ? 'unsupported' : 'error', message); - }); - return () => { + const detach = () => { container.removeEventListener('contextmenu', captureContextPoint, true); container.removeEventListener('flint-interaction', handleInteraction); surfaceRef.current = null; selectionRef.current = null; setContextMenu(null); setComment(null); + }; + let surface: ReturnType; + try { + surface = spec + // The spec tab: behaviour comes from the JSON, nothing from the options. + ? buildInteractiveChart(container, { ...themedInput, interaction_spec: spec }, { + backend: 'vegalite', + renderer: 'svg', + expressionInterpreter, + ariaLabel: input.chart_spec.title, + }) + : buildInteractiveChart(container, themedInput, { + backend: 'vegalite', + renderer: 'svg', + interactions: modeInteractions(mode, navigationAxes, navigationGuard, groupBy, indexInspection), + expressionInterpreter, + ariaLabel: input.chart_spec.title, + keyboardTargeting: mode === 'keyboard-focus', + dismiss: mode === 'long-press' || mode === 'double-activate' + ? { click: 'any', escape: true } + : undefined, + }); + } catch (error) { + // The resolver rejects a malformed spec before anything mounts. + statusRef.current('error', error instanceof Error ? error.message : String(error)); + return detach; + } + surfaceRef.current = surface; + void surface.ready.then(async () => { + // A spec entry the chart cannot honour is dropped and reported, not thrown. + const warnings = spec ? await surface.warnings : []; + if (warnings.length > 0) { + statusRef.current('unsupported', warnings.map((warning) => warning.message).join('\n'), warnings); + return; + } + statusRef.current('ready'); + }).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + statusRef.current(message.includes('requires') || message.includes('support') ? 'unsupported' : 'error', message); + }); + return () => { + detach(); surface.destroy(); }; - }, [groupBy, input, mode, navigationAxes, navigationGuard, resetVersion, themeId]); + }, [groupBy, input, mode, navigationAxes, navigationGuard, resetVersion, spec, themeId]); const menuTarget = contextMenu?.detail.event.target ?? null; const menuElement = menuTarget?.elements[0]; @@ -883,15 +993,43 @@ export function CaseCard({ themeId, navigationGuard, resetVersion, + source = 'code', + showSpec = true, + onProbe, }: { item: InteractionCase; mode: InteractionMode; themeId: string | undefined; navigationGuard: NavigationGuard; resetVersion: number; + source?: InteractionSource; + /** Show the JSON panel under the chart on the spec tab. */ + showSpec?: boolean; + /** Reports the card's status so the page can tally ready and dropped cards. */ + onProbe?: (id: string, status: ProbeStatus) => void; }) { const [status, setStatus] = useState('loading'); const [statusMessage, setStatusMessage] = useState('Compiling'); + const [warnings, setWarnings] = useState([]); + const [copied, setCopied] = useState(false); + // Each card folds its own JSON; the page switch sets the default for all of them. + const [specOpen, setSpecOpen] = useState(showSpec); + useEffect(() => { setSpecOpen(showSpec); }, [showSpec]); + useEffect(() => { onProbe?.(item.id, status); }, [item.id, onProbe, status]); + // Memoised, so a re-render does not remount the chart through a fresh spec object. + const spec = useMemo( + () => source === 'spec' + ? modeSpec(mode, item.navigationAxes, navigationGuard, item.groupBy, item.indexInspection) + : undefined, + [source, mode, item, navigationGuard], + ); + const copySpec = () => { + if (!spec) return; + void navigator.clipboard?.writeText(JSON.stringify(spec, null, 2)).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1200); + }); + }; const [lastInteraction, setLastInteraction] = useState(null); const title = item.title || item.input.chart_spec.title || item.input.chart_spec.chartType; const availableNavigationAxes = navigationAxesByCase.get(item.id); @@ -922,6 +1060,25 @@ export function CaseCard({ : status === 'ready' ? 'Ready' : status} + {spec && status === 'unsupported' && warnings.length > 0 && ( +
+
+ )} + {spec && status === 'error' && ( +
+
+ )}
{ + onStatus={(nextStatus, message, nextWarnings) => { setStatus(nextStatus); setStatusMessage(message ?? (nextStatus === 'ready' ? 'Interactive surface ready' : 'Compiling')); + setWarnings(nextWarnings ?? []); }} onSemanticEvent={setLastInteraction} />
+ {spec && ( +
+
+ + +
+ {specOpen &&
{highlightJson(spec)}
} +
+ )}