diff --git a/CHANGELOG.md b/CHANGELOG.md index a11cf91b..50fce179 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `interaction_spec`, a third document beside `chart_spec` and `theme_spec`: a + list of interaction presets by `type`, each with its `options`. + `buildInteractiveChart()`, the MCP chart view, and the site editor mount from + it. Guide: `docs/interaction-spec.md`. +- Admission per chart type. Each Vega-Lite template declares its capabilities + in `ChartTemplateDef.interactionSupport`; each preset declares its needs in + `INTERACTION_PRESET_REQUIREMENTS`. A spec entry the chart cannot honour is + dropped with an `unsupported_interaction` warning; a code definition throws. + `validateChart()` and the MCP `validate_chart` report the same warnings; + `list_chart_types` and the Vega-Lite reference list the supported presets. + One trigger, one owner: `triggersOf(definition)` lists the triggers a + definition takes (the navigation, region drag, and element drag slots, the + plot drag, the double-click, and the legend, axis, and retained-focus mark + clicks); when two admitted definitions share one, the one that can give it + up and keep the rest does so with an `info` warning (`click-highlight` + through `withoutAffordances`), otherwise the later entry drops with a + `conflicting_interactions` warning, a spec entry always yields to code, and + two code definitions throw. Before, only three pairs were checked, and two + code definitions on one slot were kept with the runtime using the first. - Chart validation is now part of the core package. `validateChart(input, backend)` returns `{ valid, warnings, errors, computedSize }` without throwing, alongside `validateChartInput`, `validateSemanticTypes`, @@ -24,8 +43,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `isRegistered` / `getRegisteredTypes` are exported from `flint-chart/core` ([#104](https://github.com/microsoft/flint-chart/issues/104)). +### Changed + +- `CanvasInteractionDef.affordances` is now a required map from the kind of + hit (`mark`, `legend-item`, `axis-label`, `plot`) to its cursor and hover, + and it is the only dispatch gate: the runtime sends a hit to an interaction + only when the interaction affords its kind, on every path including + keyboard, context, long press, and double-click. The flags + `claimsLegendActivation` and `claimsAxisActivation` are gone; a key says the + same thing. `clickHighlight()` gains `withoutAffordances(drop)`, a copy that + affords fewer targets. A definition built by hand must declare + `affordances`; `affordsTarget(interaction, target)` reads the gate. A long + press, a right-click, or a double-click on a legend item no longer reaches a + preset that affords marks only. + ### Fixed +- A legend click with both `click-highlight` and `legend-toggle` mounted hid the + series and dimmed every other bar, because both presets answered the click. + `click-highlight` now yields the legend click at admission. +- A click on a discrete axis label through `click-highlight` or + `axis-highlight` changed nothing on the chart. The renderer routed an axis + target to the label painter only and skipped the render keys of its marks. + The category's marks now emphasise and the rest mute, like a mark click. - Keyboard targeting now navigates and emits `focus-element` through the `keyboard-targeting` interaction ID without requiring a click preset. Enter and Space still invoke configured click presets when present. diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index f7326fb1..dc8b0761 100644 --- a/agent-skills/flint-chart-author/SKILL.md +++ b/agent-skills/flint-chart-author/SKILL.md @@ -16,6 +16,9 @@ or `assembleChartjs` to get a backend spec. - **DO** emit `chart_spec` (chart type, channel→field mapping, properties) and `semantic_types` (field → semantic type). +- **DO** add `interaction_spec` when the user asks for behaviour (highlight, + legend toggle, pan and zoom, brush). List presets by name; see + "Interactions". - **Reference columns by name.** How `data` itself gets bound depends on the situation — a URL, a host-side variable, or embedded rows (see "How data gets bound"). Embedding is fine for small tables; just don't @@ -242,6 +245,51 @@ chart input. ThemeSpec currently affects Vega-Lite only. Full reference: https://microsoft.github.io/flint-chart/#/documentation/theme-spec +## Interactions (`interaction_spec`) + +Add `interaction_spec` beside `chart_spec` only when the user asks for +behaviour: highlight on click, a legend that hides series, pan and zoom, a +brush, an annotation on click. A static image never needs it. + +```json +{ + "chart_spec": { "chartType": "Bar Chart", "encodings": { "x": "country", "y": "gdp", "color": "region" } }, + "interaction_spec": { + "interactions": [ + { "type": "click-highlight" }, + { "type": "legend-toggle" }, + { "type": "navigate", "options": { "axes": "y", "pan": false, "reset": ["double-click", "escape"] } } + ] + } +} +``` + +Rules: + +- **Presets only.** Every entry is `{ "type": , "options": { ... } }`. + Take the preset names for the chosen chart type from `list_chart_types` + (`chartTypes[].interactions`); a KPI card supports no brush, a pie chart no + `navigate`. Never invent a type. +- **Options nest under `options`.** An option beside `type` is rejected. + `id` is optional and sits on the entry, never inside `options`. +- **`reset`** is a list of `"click-none"`, `"double-click"`, `"escape"` on any + preset that keeps state. Leave it out to accept the preset's default. +- **The data decides too.** `legend-toggle` needs a colour field with a + discrete legend; `navigate` needs a continuous axis; `drag-reorder` needs a + discrete axis. An entry the chart cannot honour is dropped with a warning + and the chart still renders. Run `validate_chart` to read those warnings + before you show the chart. +- **Vega-Lite only.** Other backends ignore the spec. + +Common presets: `click-highlight` (focus a mark), `click-group-focus` +(focus its group, `groupBy`), `legend-toggle`, `navigate` (`axes`, `pan`), +`brush-x` / `brush-y` / `select` (drag to focus an interval or area), +`click-annotate`, `inspect` and `inspect-index` (read values on hover), +`drag-reorder` (reorder categories). + +Full guide: +https://microsoft.github.io/flint-chart/#/documentation/interaction-spec + ## Step 1 — pick `chartType` Use one of the registered names **exactly**. Vega-Lite is the default and diff --git a/docs/README.md b/docs/README.md index a4fd1d77..6860cc1b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +9,8 @@ > For the semantic type system, see > [design-semantics.md](design-semantics.md). For the axis layout > compression models, see -> [design-stretch-model.md](design-stretch-model.md). +> [design-stretch-model.md](design-stretch-model.md). For the interaction +> model, see [design-interactions.md](design-interactions.md). --- diff --git a/docs/adding-a-chart-template.md b/docs/adding-a-chart-template.md index fc5dda79..fbb6b50b 100644 --- a/docs/adding-a-chart-template.md +++ b/docs/adding-a-chart-template.md @@ -44,6 +44,12 @@ export const dotPlotDef: ChartTemplateDef = { chart: 'Dot Plot', template: { mark: 'circle', encoding: {} }, channels: ['x', 'y', 'color', 'size', 'column', 'row'], + interactionSupport: { + elements: true, // marks resolve to data rows + region: ['cartesian'], // rectangle and lasso drags resolve marks + navigation: {}, // continuous x and y pan and zoom + legend: true, // a discrete colour legend can be toggled + }, markCognitiveChannel: 'position', declareLayoutMode: (channelSemantics, table, chartProperties) => { @@ -69,6 +75,7 @@ export const dotPlotDef: ChartTemplateDef = { 2. **`markCognitiveChannel`** — tells the compiler how readers decode value (affects zero baseline and [Auto Layout Algorithm](/documentation/layout-model) compression). 3. **`instantiate`** — receives a **deep clone** of `template` plus `InstantiateContext` (resolved encodings, `ChannelSemantics`, `LayoutResult`, data table, canvas size). 4. **No semantic branching** — read `ctx.channelSemantics[channel].format`, `.type`, `.zero`, etc.; do not switch on raw field names or storage types. +5. **`interactionSupport`** — what the chart type offers to interaction presets (`ChartInteractionSupport` in `core/interaction-spec.ts`). Declare only what the chart can honour: `elements`, `region`, `navigation`, `reorder`, `legend`, `discreteAxis`, `index`. An absent key means "never"; the assembler confirms the data-dependent ones against the bound encodings. Admission drops or rejects a preset whose `requires` list names a capability the chart lacks, and `list_chart_types` reports the supported presets from the same block. Optional hooks: `postProcess` (after layout), `encodingActions` (shelf quick actions). diff --git a/docs/api-reference.md b/docs/api-reference.md index 52e7d9ad..ef29541f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -123,6 +123,8 @@ interface ChartAssemblyInput { canvasSize?: { width: number; height: number }; // optional hard ceiling on stretch chartProperties?: Record; }; + theme_spec?: ThemeSpec | string; // presentation, Vega-Lite only + interaction_spec?: InteractionSpec; // behaviour, Vega-Lite interactive surface only options?: AssembleOptions; field_display_names?: Record; } @@ -156,6 +158,24 @@ legend headers. Keep encodings bound to the original field names: } ``` +### `interaction_spec` + +How the chart behaves. Lists interaction presets by `type`, each with its own +`options`, plus the surface policies `assistedTargeting` and `keyboardTargeting`: + +```ts +interface InteractionSpec { + interactions: { type: InteractionPresetType; id?: string; options?: Record }[]; + assistedTargeting?: boolean | AssistedTargetingOptions; + keyboardTargeting?: boolean; +} +``` + +`buildInteractiveChart()` reads it; the assemblers ignore it. An entry the chart type +cannot honour is dropped with an `unsupported_interaction` warning, and `validateChart` +reports the same warnings before anything renders. `supportedInteractionPresets(def.interactionSupport)` +lists the presets a template supports by declaration. See [Using interactions](/documentation/interaction-spec). + ### `chart_spec` | Field | Description | diff --git a/docs/design-interaction-spec.md b/docs/design-interaction-spec.md deleted file mode 100644 index b5d22dbe..00000000 --- a/docs/design-interaction-spec.md +++ /dev/null @@ -1,542 +0,0 @@ -# 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 targeting policies on `BuildInteractiveChartOptions`: `assistedTargeting`, `keyboardTargeting`. - -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 the targeting policies (`assistedTargeting`, `keyboardTargeting`), 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[]; - assistedTargeting?: boolean | AssistedTargetingOptions; - keyboardTargeting?: boolean; -} -``` - -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"] } } - ] - } -} -``` - -### 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[]; - 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 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. Surface policies: `options` win over the spec when both are set. Retained state (`options.updates`, `applyUpdate`, `setUpdates`, `dispatch`) stays a host signal and never comes from the spec. - -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 `assistedTargeting`, `keyboardTargeting`; 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`, `assistedTargeting`, `keyboardTargeting` | -| `dismiss` | removed from the spec and from the code options (2026-09-12); each interaction owns a `reset` list (§10) | -| Retained state (`updates`) | not in `interaction_spec` (removed 2026-09-11): state arrives from outside the chart, through `applyUpdate`, `setUpdates`, `dispatch`, or `options.updates`. A JSON home for seeded state, if needed, is a separate top-level field. | - -## 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. - -## 10. Follow-up: `dismiss` becomes a per-interaction `reset` - -Decided 2026-09-12. `dismiss` is one global policy: a click on nothing, or Escape, clears -every retained `set-style` and `set-annotation` from every interaction. It cannot tell a -selection from a setting (a background click un-hides what `legend-toggle` hid while the -preset's closure still believes the series are hidden), and an agent reading one entry -cannot see how that interaction ends. `navigate` already has the shape we want: `reset`, -a list of gestures that return that one interaction to its neutral state. - -### Vocabulary - -| Gesture | Meaning | -| --- | --- | -| `click-none` | one click whose hit resolves to nothing: no mark, no path segment, no legend item, no axis label. Empty plot and margin both count. A click inside the assist radius of a mark is on the mark. | -| `double-click` | two quick clicks anywhere on the chart. No hit rule: dense charts (maps, heatmaps, areas) have no empty pixel, and a double-click is already a deliberate act. | -| `escape` | the Escape key while the chart is on the page. | - -The hit rule for `click-none` depends only on geometry, never on which other entries are -mounted, so the meaning of an entry's `reset` does not change when a neighbour is added. -Escape during a drag cancels the gesture in progress; that is a separate path and stays. - -### Shape - -- Every preset accepts `reset?: readonly InteractionResetGesture[]`. Factories normalise it - onto the definition as `reset`; `navigate` keeps its option and drops `eventSource.reset`. -- The registry gives each preset `defaultReset` and `supportedReset`. The resolver rejects - a spec `reset` outside the supported set as a malformed entry. -- Definitions may carry `onReset()` so a preset with closure state (`legend-toggle`) can - drop it. - -| Preset | Default `reset` | -| --- | --- | -| `click-highlight`, `axis-highlight`, `click-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-angle`, `linked-brush`, `long-press`, `double-activate` | `['click-none', 'escape']`. The emphasis they commit is retained state, whether the drag overlay is ephemeral or stateful. | -| `inspect-index` | `['escape']` (releases a locked series) | -| `navigate` | `['double-click']` | -| `brush-zoom` | `['double-click', 'escape']` (Escape returned a brushed zoom to the full frame before, and still does) | -| `hover-group-focus`, `inspect`, `context-activate` | none: nothing is retained, so the preset has no `reset` option and the resolver rejects one | -| `legend-toggle`, `drag-reorder` | `[]` (a setting is not a selection); an author may opt in | - -Step 1 landed 2026-09-12: the vocabulary, the option and its normalised copy on the definition, -the registry's `supportedReset` and `defaultReset`, the resolver checks, and `navigate` -renamed from `click-background` to `click-none` (a margin click now resets too). The runtime -honours only `navigate`'s list until step 2; `navigate` accepts `escape` from step 2. - -### Runtime - -One dispatcher replaces `dismissPolicy`. A click is classified once as `click-none` or not; -a double-click and Escape are their own events. For each admitted interaction whose list -holds the gesture, the runtime clears the retained and preview entries stored under that -interaction's id and calls its `onReset()`. `navigate` and the stateful brush route their -existing reset paths through the same dispatcher, which retires `backgroundResetInteraction` -and the region gesture's `escapeClears` flag. The delay that protects `double-activate` from -a first click stays. Host state (`options.updates`, `applyUpdate`) is never touched by a -gesture reset: it arrived from outside and the host clears it with `clearUpdate`. - -### Admission - -A chart that mounts `double-activate` together with an entry whose `reset` lists -`double-click` has a gesture conflict, like pan against drag. `admitInteractions()` reports -`conflicting_interactions`; for spec entries the later one is dropped. - -### Spec and code surface - -`dismiss` leaves `InteractionSpec`, `composeInteractiveOptions()`, and every code option -(`buildInteractiveChart`, the surface, the Vega renderer), together with the -`InteractionDismissPolicy` type. The resolver rejects a spec that still carries the key and -points at `reset`. The two callers in the repo needed nothing in its place: the Test cases lab -used the values the presets default to, and the you-draw-it demo mounts a hand-built definition -with no `reset` list and host updates, which no gesture resets. - -Step 2 landed 2026-09-12: one reset dispatcher in the Vega runtime replaces `dismissPolicy`. -A click that hits nothing, a double-click, or Escape resets only the interactions whose list -holds that gesture, each by its own id; `legend-toggle` drops its closure state through -`onReset()`; `navigate` flies home through its navigation path and the stateful brush clears -through the gesture's `reset()`. A chart with an `escape` reset becomes focusable and takes -focus on a pointer press, so Escape reaches the chart the reader touched last and no other. -`double-activate` next to a `double-click` reset is an admission conflict. `options.dismiss` -survives as a deprecated code option that maps onto every interaction that resets by default. - -Step 3 landed 2026-09-12: `dismiss` is gone from the spec and from the code options; the deprecated -mapping was removed rather than kept, because no caller needed it. diff --git a/docs/design-interactions.md b/docs/design-interactions.md new file mode 100644 index 00000000..b0e89b85 --- /dev/null +++ b/docs/design-interactions.md @@ -0,0 +1,430 @@ +# Interaction Design + +How a Flint chart behaves when a reader clicks, hovers, drags, or presses a key, and how a +chart type decides which of those behaviours it can honour. + +> This document explains the model. For the authoring guide, see +> [Using interactions](interaction-spec.md). For the API surface, see the +> [API reference](api-reference.md). + +## Table of Contents + +- [§1 Overview](#1-overview) +- [§2 The interaction model](#2-the-interaction-model) +- [§3 The specification](#3-the-specification) +- [§4 Chart semantics](#4-chart-semantics) +- [§5 Capabilities and admission](#5-capabilities-and-admission) +- [§6 Reset](#6-reset) +- [§7 The pipeline](#7-the-pipeline) +- [§8 Hosts and discovery](#8-hosts-and-discovery) +- [§9 Extending the model](#9-extending-the-model) +- [Appendix: the declaration table](#appendix-the-declaration-table) + +--- + +# §1 Overview + +A Flint chart is three documents in one `ChartAssemblyInput`: + +| Document | Says | Read by | +|---|---|---| +| `chart_spec` | what the chart means: type, encodings, properties | every assembler | +| `theme_spec` | how it looks | the Vega-Lite assembler | +| `interaction_spec` | how it behaves | the Vega-Lite interactive surface | + +The assemblers never read `interaction_spec`. A static render is untouched by it. Only +`buildInteractiveChart()` does, after the chart is assembled. + +Behaviour comes from **presets**: named interactions Flint ships, such as `click-highlight` +or `navigate`. Code reaches a preset through a factory, `clickHighlight({ dimOpacity: 0.2 })`. +A spec reaches the same preset through a name, +`{ "type": "click-highlight", "options": { "dimOpacity": 0.2 } }`. The registry maps the name +to the factory, so the two are two spellings of one definition. Everything below applies to +both. + +Two facts about a chart decide what it can honour, and they are answered in two places: + +- The **chart type** declares the properties it offers, in its template. This is static, known + before any data. +- The **data** confirms the properties that depend on it, at assemble time: a legend needs a + discrete legend channel bound, navigation needs a continuous axis. + +Admission compares what a preset needs with what this chart, type and data together, provides. +A spec entry the chart cannot honour is dropped with a warning, and the chart renders. A code +definition the chart cannot honour throws, because a developer sees the exception. + +# §2 The interaction model + +## §2.1 Definitions + +A preset factory returns a `CanvasInteractionDef`: + +| Field | Role | +|---|---| +| `id` | names the interaction in updates and in the `flint-interaction` event; defaults to the preset name | +| `preset` | the preset that made it; admission reads its requirements from the core table | +| `eventSource` | the trigger: an element click or hover, a drag region, a navigation gesture | +| `reset` | the gestures that return it to neutral (§6) | +| `affordances` | the kinds of hit it affords (`mark`, `legend-item`, `axis-label`, `plot`), each with its cursor and hover; the runtime dispatches a hit only to the interactions that afford its kind | +| `handle(event, context)` | turns a resolved semantic event into a `ChartUpdate`, or `null` | +| `origin` | `'spec'` when the resolver made it; absent for code | + +An `ExternalInteractionDef` has no gesture. A host drives it through `surface.dispatch(id, +payload)`, and its `handle` turns the payload into an update. Linked dashboards and stories use +it. + +## §2.2 Event sources + +Three families, and admission reasons about them: + +- **element**: a click, hover, long press, double-click, or inspect on marks, legend items, or + axis labels. Needs marks that resolve to data. +- **region**: a drag that draws a rectangle, an interval along one axis, a lasso, or an angular + sector, and resolves the marks inside. Needs a plot with a drag region of the right kind. +- **navigation**: a drag that pans and a wheel or pinch that zooms continuous axes, or a + projection's extent on a map. Needs a navigable axis. + +## §2.3 The update language + +A `handle` never touches the DOM. It returns a `ChartUpdate`, `{ id, ops }`, and the runtime +applies it: + +| Op | Effect | +|---|---| +| `set-style` | emphasise or mute targets, hide a series | +| `set-annotation` | pin or clear an annotation on a target | +| `set-viewport` | move a continuous domain or a projection | +| `set-order` | reorder a discrete domain | +| `set-overlay`, `set-freeform-overlay` | draw a guide or a free path | +| `set-data` | replace the rows a layer shows | + +Targets are either references to resolved elements, `{ visual, elements }`, or selectors by row +key, `{ select: { key } }`. Hosts apply the same language from outside through +`surface.applyUpdate()` and `surface.setUpdates()`, so a story step and a click produce the same +kind of change. + +## §2.4 The surface + +`buildInteractiveChart(container, input, options)` returns an `InteractiveChartSurface`: +`ready`, `warnings`, `applyUpdate`, `setUpdates`, `clearUpdate`, `dispatch`, `refresh`, +`destroy`. The container emits a `flint-interaction` DOM event for every semantic event, with +the interaction id and the resolved target, so a host can listen without knowing the preset. + +# §3 The specification + +```json +{ + "interaction_spec": { + "interactions": [ + { "type": "click-highlight" }, + { "type": "legend-toggle" }, + { "type": "navigate", "id": "pan", "options": { "axes": "y", "pan": false, "reset": ["double-click", "escape"] } } + ], + "assistedTargeting": true, + "keyboardTargeting": false + } +} +``` + +One plain shape, on purpose: + +- Every entry is an object with a `type`. There is no string shorthand. +- Options nest under `options`. A flat option beside `type` is rejected with a hint, so a habit + from the factory call cannot vanish silently. +- `id` sits on the entry, never inside `options`. It defaults to `type`, so two entries of one + type need explicit ids. +- The two surface policies, `assistedTargeting` and `keyboardTargeting`, sit beside the list. + They describe the surface, not one interaction. +- Retained state is not part of the spec. It arrives from the host through the surface. + +The resolver, `resolveInteractionSpec()`, turns the spec into definitions and tags each +`origin: 'spec'`. It knows nothing about the chart. It rejects what is malformed: an unknown +`type`, a stray key, a non-object `options`, an `id` inside `options`, a missing required +option such as `groupBy`, an unknown or unsupported `reset` gesture, a duplicate id, and a +top-level key that is not one of the three, with a hint for the two keys the spec once had, +`dismiss` and `updates`. Every message names the entry by index and type. + +`composeInteractiveOptions()` merges the spec with what code passed to +`buildInteractiveChart()`: spec entries first, then code; an id shared by both is an error; the +surface policies come from code when it sets them and from the spec otherwise; a backend that +runs no interactions ignores the spec with one `info` warning. + +# §4 Chart semantics + +Presets speak in data: "the bars for Asia". The renderer speaks in pixels. Each chart type owns +the translation, in `semanticInteractions` on its `ChartTemplateDef`. The assembler calls it +once per chart with the resolved encodings, and the runtime reads the result. + +It returns a dictionary in three parts: + +- **Roles.** `fields`, `categoryField`, `seriesField`, `legendFields`, `selectableMarks`: which + fields play which part, which legend belongs to which field, which mark names a click can hit. + Presets such as `click-group-focus` read these. +- **Two functions.** `resolve(event, context)` takes the physical hits under the pointer and + returns a `SemanticTarget`: the data elements plus a visual kind and role such as `bar` or + `legend-item`. `presentUpdate(update, context)` takes a generic update and returns the + chart-specific version, for example where an annotation may sit on a bar. +- **Presentation.** `renderHoverStyles`, `renderSelectionStyles`, `annotationMarkType`, + and where a template chooses its own reorder axis, `reorderAxes`. + +This dictionary says **how to read** a chart. It does not say **what the chart type supports**. +Every one of the 36 Vega-Lite templates has one, so its presence carries no information about +support. That distinction is the reason the next section exists. + +# §5 Capabilities and admission + +## §5.1 Eight capabilities + +A capability is a fact about a chart that at least one preset reads at runtime. There are eight, +`INTERACTION_CAPABILITIES` in core: + +| Capability | The chart type says | The data confirms | +|---|---|---| +| `elements` | `elements: true`: marks resolve to data rows | nothing | +| `cartesian-region` | `region: ['cartesian']`: a rectangle, interval, or lasso drag resolves marks | nothing | +| `angular-region` | `region: ['angular']`: a sector drag resolves marks | nothing | +| `navigation` | `navigation: { axes?, geo? }` | a continuous field on x or y, on an unfaceted chart; or a projection | +| `reorder` | `reorder: { axes?, includeConnectiveMarks?, markTypes? }` | a discrete field on x or y, on an unfaceted chart | +| `legend` | `legend: true` | a discrete field on a legend channel | +| `discrete-axis` | `discreteAxis: true` | a discrete field on x or y | +| `index` | `index: true`: one x position reads every series | a field on x | + +The three region and element capabilities are geometry, so the template is the only source. +The other five are confirmed against the encodings. `navigation` and `index` are also curated: +not every chart with a continuous axis should pan, and `index` names a reading model that only +some chart shapes have. + +A polar chart declares both regions. A rectangle or a lasso resolves its arcs by pixel bounds, +and an interval brush on it is honoured as a sector. + +## §5.2 What the type declares + +Each template carries an `interactionSupport` block, `ChartInteractionSupport`: + +```ts +interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, +}, +``` + +An absent key means never. A present key means the type can have it, and the data decides the +rest. The block is the gate: if a template leaves a key out, the data cannot switch it on. The +appendix lists all 36 blocks. + +## §5.3 What the data confirms + +At assemble time the Vega-Lite assembler intersects the block with the resolved encodings and +writes the result into the compiled spec, beside the dictionary: + +```ts +_interactionSemantics: { + ...templateSemantics, + chartType: 'Bar Chart', + capabilities: ['elements', 'cartesian-region', 'navigation', 'reorder', 'discrete-axis'], + navigationAxes: ['y'], + reorderAxes: [{ axis: 'x', field: 'country' }], + ... +} +``` + +The list above is a bar chart with no colour field: `legend` is declared but not confirmed, so +it is absent. `capabilities` is the single authority admission reads; no second reading of the +plan exists. The assembler writes `_interactionSemantics` for every Vega-Lite chart, so the +field is never absent on an assembled spec. + +## §5.4 What a preset needs + +One table in core, `INTERACTION_PRESET_REQUIREMENTS`, gives each preset the smallest set of +capabilities without which it does nothing: + +| Presets | Require | +|---|---| +| `click-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `context-activate`, `long-press`, `double-activate`, `inspect` | `elements` | +| `select`, `lasso-select`, `brush-x`, `brush-y`, `linked-brush` | `elements`, `cartesian-region` | +| `brush-angle` | `elements`, `angular-region` | +| `navigate`, `brush-zoom` | `navigation` | +| `legend-toggle` | `legend` | +| `axis-highlight` | `discrete-axis` | +| `drag-reorder` | `reorder` | +| `inspect-index` | `index` | + +A definition reaches its row through the `preset` name its factory stamps on it. A definition +made by hand, with no preset, needs nothing; its author is responsible for it. + +## §5.5 The match + +`admitInteractions(plan, interactions)` runs at mount, in the compile step, with the plan the +assembler wrote: + +1. For every interaction, every required capability must be in `plan.capabilities`. The first + one missing decides the message: `Interaction "legend-toggle" requires a discrete legend; + Bar Chart has none.` +2. A `navigate` that asks for an axis the chart does not navigate is refused by axis, because + the capability alone cannot judge `axes: 'x'` against a chart that navigates y. +3. One trigger, one owner. `triggersOf(definition)` reads the triggers a definition takes for + itself from its event source, its affordance keys, its state group, and its reset list: the + navigation, region drag, and element drag slots, the plot drag, the double-click, and the + legend, axis, and retained-focus mark clicks. For each trigger two definitions share, the one + that can give it up and keep the rest does so through `withoutAffordances` with an `info` + warning; otherwise the later entry yields whole, a spec entry always yields to a code + definition, and two code definitions throw. + +The origin decides the consequence. A spec entry is dropped with a `ChartWarning`, +`unsupported_interaction` or `conflicting_interactions`, and the message ends with "The +interaction was dropped." A code definition throws with the same sentence. Warnings reach +`surface.warnings`, the console once, `validateChart()`, and the MCP `validate_chart`. + +## §5.6 Why two layers and not one + +Inference from the assembled chart alone was the model before this one, and it admitted every +preset on every chart type: every template has a dictionary, so "has element semantics" was +always true. The data cannot say no where the type must: a heatmap binds a colour legend that +is continuous; a rose binds a nominal x whose labels are sectors; a KPI card has a rect mark and +rows but no plot to drag on. And three readers need the answer before any data exists: an agent +calling `list_chart_types`, the generated reference, the coverage view. + +# §6 Reset + +Every preset that keeps state carries `reset`, a list of gestures that return it to neutral: + +| Gesture | Meaning | +|---|---| +| `click-none` | a click whose hit resolves to no chart element: empty plot, margin, background | +| `double-click` | a double-click anywhere on the chart | +| `escape` | the Escape key while the chart has focus | + +Each preset has a default list in the registry and a supported list; the resolver rejects an +unsupported gesture and any `reset` on a preset that keeps nothing. One dispatcher per chart +runs the gestures. A gesture resets only the interactions whose list holds it, each by its own +id: retained updates are cleared, a stateful brush clears through its controller, `navigate` +flies home through its own path, and a preset with closure state drops it through `onReset()`. + +A chart with an `escape` reset becomes focusable and takes focus on a pointer press, so Escape +reaches the chart the reader touched last and no other. Host updates applied through the surface +are never reset by a gesture; the host clears them with `clearUpdate()`. + +# §7 The pipeline + +``` +chart_spec + data ──► assembleVegaLite ──► spec + _interactionSemantics + │ dictionary, chartType, capabilities, + │ navigationAxes, reorderAxes, ... +interaction_spec ──► resolveInteractionSpec ─┤ +options.interactions ──► compose ────────────┤ + ▼ + addVegaLiteInteractions + admit ─► plan (admitted, warnings) + instrument marks, scales, signals + ▼ + mountVegaInteractions + gestures, dispatcher, overlays, events +``` + +Which fact is decided where: + +| Fact | Decided in | +|---|---| +| what the type offers | the template block | +| what this chart confirms | the assembler | +| what a preset needs | the core table | +| what is admitted, and the warnings | the compile step, through admission | +| how a hit becomes rows | the dictionary, at runtime | +| which gesture resets what | the dispatcher, from each definition's list | + +# §8 Hosts and discovery + +- **`buildInteractiveChart()`** reads `interaction_spec` from the input and merges it with code + definitions. The MCP `create_chart_view`, the site editor, and the site gallery mount through + it whenever the input carries interaction entries; a static render otherwise. +- **`validateChart()`** runs the resolver and admission against the assembled semantics and + returns the same warnings the mount would, before anything renders. A malformed spec is an + `invalid_interaction_spec` error. The MCP `validate_chart` returns the same list. +- **`supportedInteractionPresets(def.interactionSupport)`** lists the presets a chart type + supports by declaration. `list_chart_types` returns it per chart type, and the Vega-Lite + reference prints it. The data can still remove one at mount; the guide says so. +- **The coverage tab** in the Interactions lab assembles one representative case per chart type + and shows every chart type against every preset: active for that data, supported by the type + but not confirmed by that data, or never offered. + +# §9 Extending the model + +**A new preset.** Add its name to `INTERACTION_PRESET_TYPES` and its needs to +`INTERACTION_PRESET_REQUIREMENTS`, both in core. Add its options type to +`InteractionPresetOptions`, its registry entry with label, gesture family, supported and default +reset lists, and its factory wrapper, which stamps `preset` and attaches the reset list. The +mapped types make a missing entry a compile error. Regenerate the reference. + +**A new capability.** Add its name to `INTERACTION_CAPABILITIES` and its phrase to +`INTERACTION_CAPABILITY_DESCRIPTIONS`. Add a key to `ChartInteractionSupport` and a line to +`declaredInteractionCapabilities`. If the data must confirm it, add the fact to the assembler's +`confirmed` map. Declare it on the templates that offer it. + +**A new template.** Write its `interactionSupport` block: `elements` when it has a resolver, the +region kinds its geometry allows, `navigation` when a continuous axis should pan, `reorder`, +`legend`, and `discreteAxis` wherever the geometry permits, `index` only when one x position +reads across series. A test asserts every Vega-Lite template carries a block. + +# Appendix: the declaration table + +Generated from the templates by `npm run gen:reference`. Do not edit the table by hand. + + +| Chart type | elements | region | navigation | reorder | legend | discrete axis | index | +|---|---|---|---|---|---|---|---| +| Area Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Bar Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Bar Table | ✓ | cartesian | | ✓ | ✓ | ✓ | | +| Boxplot | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Bullet Chart | ✓ | cartesian | | ✓ | ✓ | ✓ | | +| Bump Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Calendar Heatmap | ✓ | cartesian | | ✓ | ✓ | ✓ | | +| Candlestick Chart | ✓ | cartesian | x | ✓ | | ✓ | ✓ | +| Choropleth | ✓ | cartesian | geo | | ✓ | | | +| Connected Scatter Plot | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Density Plot | ✓ | cartesian | x | ✓ | ✓ | ✓ | ✓ | +| Donut Chart | ✓ | cartesian, angular | | | ✓ | | | +| ECDF Plot | ✓ | cartesian | x | ✓ | ✓ | ✓ | ✓ | +| Gantt Chart | ✓ | cartesian | x | ✓ | ✓ | ✓ | | +| Grouped Bar Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Heatmap | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Histogram | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| KPI Card | ✓ | | | | | | | +| Line Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Lollipop Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Map | ✓ | cartesian | geo | | ✓ | | | +| Pie Chart | ✓ | cartesian, angular | | | ✓ | | | +| Pyramid Chart | ✓ | cartesian | | ✓ | ✓ | ✓ | | +| Radar Chart | ✓ | cartesian, angular | | | ✓ | | | +| Range Area Chart | ✓ | cartesian | x, y | | ✓ | ✓ | ✓ | +| Ranged Dot Plot | ✓ | cartesian | x, y | connective marks | ✓ | ✓ | | +| Regression | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Rose Chart | ✓ | cartesian, angular | | | ✓ | | | +| Scatter Plot | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Slope Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Sparkline | ✓ | cartesian | x | ✓ | | ✓ | ✓ | +| Stacked Bar Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Streamgraph | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Strip Plot | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Violin Plot | ✓ | cartesian | | | ✓ | ✓ | | +| Waterfall Chart | ✓ | cartesian | x, y | rect marks | ✓ | ✓ | | + + +Judgment calls behind the rows: + +- **`elements` on KPI Card.** The template ships a resolver and an annotation presenter, so + `click-highlight` and `click-annotate` work on the tile. +- **`reorder` absent on Violin and Range Area.** Both refused reorder before the block existed; + Range Area's category axis is a path, and a test guards it. +- **`region: cartesian` on Bar Table, Bullet, Sparkline, Calendar Heatmap, Map, Choropleth.** + The region controller resolves marks by pixel bounds, so a rectangle over rows, cells, or + bubbles selects them. Only KPI Card has no drag region. +- **`discreteAxis` absent on the polar charts and the projections.** Their labels are sectors + or place names, not categories to click. +- **`index`** on the charts whose x is a shared index across series: the line family, the area + family, bump, slope, density, ECDF, candlestick, sparkline, and the scatter family, where the + lab's curated index-inspection cases rely on it. diff --git a/docs/interaction-spec.md b/docs/interaction-spec.md new file mode 100644 index 00000000..9beb4c96 --- /dev/null +++ b/docs/interaction-spec.md @@ -0,0 +1,144 @@ +# Using interactions + +`interaction_spec` sits beside `chart_spec` and `theme_spec` in a `ChartAssemblyInput`. The chart spec says **what the chart means**. The theme spec says **how it looks**. The interaction spec says **how it behaves** when a reader clicks, hovers, drags, or presses a key. + +Every behaviour comes from a **preset**: a named interaction Flint ships, such as `click-highlight` or `navigate`. You list the presets you want, each with its own options. Flint mounts the ones the chart can honour and tells you about the ones it cannot. + +> `interaction_spec` affects the Vega-Lite interactive surface only. The assemblers and the static backends leave it untouched, and `validateChart` reports it as ignored for those backends. + +## Shape + +```json +{ + "chart_spec": { "chartType": "Bar Chart", "encodings": { "x": "country", "y": "gdp", "color": "region" } }, + "interaction_spec": { + "interactions": [ + { "type": "click-highlight" }, + { "type": "legend-toggle" }, + { "type": "navigate", "options": { "axes": "y", "pan": false, "reset": ["double-click", "escape"] } } + ] + } +} +``` + +| Key | Meaning | +|---|---| +| `interactions` | One entry per interaction, in the order they are mounted. | +| `interactions[].type` | The preset that makes the interaction. It is also the interaction's default `id`. | +| `interactions[].id` | Optional. Names the interaction when a chart uses the same preset twice, and names it in the `flint-interaction` event. | +| `interactions[].options` | The preset's own options, always nested under `options`. Never put an option beside `type`. | +| `assistedTargeting` | Optional. Pointer acquisition that snaps to a nearby mark. `false` requires direct hits; an object sets `maxDistance`, `indicator`, `details`. | +| `keyboardTargeting` | Optional. Lets a reader move between marks with the keyboard. | + +An entry has no string shorthand: `"click-highlight"` alone is rejected, `{ "type": "click-highlight" }` is the smallest form. + +## The presets + +| Type | What the reader does | Needs from the chart | Default reset | +|---|---|---|---| +| `click-highlight` | Clicks a mark, legend item, or axis label to emphasise it and mute the rest. | elements | click-none, escape | +| `click-group-focus` | Clicks a mark to emphasise every mark in its group (`groupBy`). | elements | click-none, escape | +| `hover-group-focus` | Hovers a mark to preview its group (`groupBy` required). | elements | none | +| `click-annotate` | Clicks a mark to pin an annotation with its value. | elements | click-none, escape | +| `context-activate` | Right-clicks or long-presses to hand the host a context target. | elements | none | +| `long-press` | Holds a mark to activate it. | elements | click-none, escape | +| `double-activate` | Double-clicks a mark to activate it. | elements | click-none, escape | +| `inspect` | Moves over the plot to read the nearest mark's values. | elements | none | +| `inspect-index` | Moves over the plot to read every series at one x position (`seriesBy` for a single series). | index axis | escape | +| `select` | Drags a rectangle to emphasise the marks inside. | elements, cartesian region | click-none, escape | +| `lasso-select` | Draws a freehand region to emphasise the marks inside. | elements, cartesian region | click-none, escape | +| `brush-x`, `brush-y` | Drags an interval along one axis; on a polar chart the x brush is an angular sector. | elements, cartesian region | click-none, escape | +| `brush-angle` | Drags an angular sector on a pie, donut, rose, or radar chart. | elements, angular region | click-none, escape | +| `linked-brush` | Brushes marks to highlight the same groups elsewhere (`groupBy` required). | elements, cartesian region | click-none, escape | +| `brush-zoom` | Drags a rectangle to zoom into it. | navigation | double-click, escape | +| `navigate` | Drags to pan and scrolls or pinches to zoom continuous axes (`axes`, `pan`, `domainGuard`). | navigation | double-click | +| `legend-toggle` | Clicks a legend item to hide or restore its series. | discrete legend | none | +| `axis-highlight` | Clicks a discrete axis label to emphasise its category. | discrete axis | click-none, escape | +| `drag-reorder` | Drags a discrete axis label to change the category order. | reorderable axis | none | + +The option names are the ones the matching factory in `flint-chart/interactive` accepts. `InteractionPresetSpec` in that entry gives the precise shape per type for TypeScript callers. + +## Reset gestures + +Every preset that keeps state accepts `reset`, a list of the gestures that return it to neutral: + +| Gesture | Meaning | +|---|---| +| `click-none` | A click whose hit resolves to no chart element: empty plot, margin, background. | +| `double-click` | A double-click anywhere on the chart. | +| `escape` | The Escape key, while the chart has focus. A chart with an `escape` reset takes focus when the reader presses on it, so Escape reaches the last chart touched and no other. | + +A gesture resets only the interactions whose list holds it, each by its own id. Host updates applied through the surface are never reset by a gesture. A preset that keeps nothing (`hover-group-focus`, `inspect`, `context-activate`) has no `reset`, and the resolver rejects one. + +## What a chart type supports + +Each chart type declares the properties it offers: marks that resolve to data, a drag region, navigable axes, a reorderable axis, a discrete legend, discrete axis labels, an index axis. Each preset declares the properties it needs. A preset is supported when the chart type offers everything it needs. + +Three places show the answer: + +- The [Vega-Lite chart reference](/documentation/reference-vegalite) prints an **Interactions** line per chart type. +- The MCP tool `list_chart_types` returns `interactions` per chart type. +- The Interactions lab's **Coverage** tab shows every chart type against every preset. + +The data can still remove a preset at mount. A bar chart supports `legend-toggle`, but a bar chart with no colour field has no legend to toggle. `navigate` needs a continuous, unfaceted axis. `drag-reorder` needs a discrete axis in the bound encodings. + +## Warnings + +An entry the chart cannot honour is **dropped with a warning**, and the chart still renders. The message names the interaction, what it needed, and the chart type: + +``` +Interaction "legend-toggle" requires a discrete legend; Bar Chart has none. The interaction was dropped. +``` + +### When two interactions share a trigger + +A trigger is one gesture on one kind of hit: a click on a legend item, a drag on the plot, a double-click. Two entries can ask for the same trigger: + +| Trigger | Who asks for it | +|---|---| +| the navigation slot | every `navigate` | +| the region drag slot | `select`, `lasso-select`, the brushes, `linked-brush`, `brush-zoom` | +| the element drag slot | `drag-reorder` | +| the plot drag | every region drag, `drag-reorder`, and `navigate` with pan on | +| the double-click | `double-activate`, and any entry whose `reset` holds `double-click` | +| legend clicks | `legend-toggle`, `click-highlight`, `inspect-index` with a series switch | +| axis label clicks | `axis-highlight`, `click-highlight` | +| mark clicks with retained focus | `click-highlight`, `click-group-focus` | + +One trigger has one owner. When exactly one of the two can give the trigger up and keep the rest, it does, and an `info` warning says so. Today only `click-highlight` can, one target at a time: + +``` +Interaction "click-highlight" yields legend clicks to "legend-toggle". +``` + +Otherwise the later entry yields whole and is dropped with a warning, and a spec entry always yields to a code definition. Two code definitions that share a trigger throw. + +Two options avoid a conflict before it happens. `click-highlight` takes `targets`, so `{ "type": "click-highlight", "options": { "targets": ["mark"] } }` never asks for the legend or the axis. `navigate` takes `reset`, so `reset: ["escape"]` lets `double-activate` keep the double-click. + +Where to read the warnings: + +- `validateChart(input, 'vegalite')` returns them before anything renders, with a malformed spec reported as an `invalid_interaction_spec` error. +- `buildInteractiveChart(container, input)` exposes them on `surface.warnings` and logs them once to the console. +- The MCP tool `validate_chart` returns the same list. + +A malformed entry is an error, not a drop: an unknown `type`, an option outside `options`, an `id` inside `options`, a missing required option such as `groupBy`, an unknown or unsupported `reset` gesture, or a duplicate `id`. + +## Code and spec, one definition + +A spec entry and a factory call are two spellings of one interaction: + +```ts +import { buildInteractiveChart, clickHighlight } from 'flint-chart/interactive'; + +// From the spec +buildInteractiveChart(container, { ...input, interaction_spec: { interactions: [{ type: 'click-highlight', options: { dimOpacity: 0.2 } }] } }); + +// From code +buildInteractiveChart(container, input, { interactions: [clickHighlight({ dimOpacity: 0.2 })] }); +``` + +Both may appear on one chart; the spec entries mount first. An `id` used by both is an error. A code definition the chart cannot honour throws, because a developer sees the exception; a spec entry is dropped, because an agent reads warnings. + +## Where it runs + +`buildInteractiveChart()` reads `interaction_spec` from the input. The MCP tool `create_chart_view` mounts the same surface, so an agent can ask for behaviour in the same JSON that asks for the chart. The site's editor and gallery mount from the spec too. diff --git a/docs/reference-vegalite.md b/docs/reference-vegalite.md index 2ca9e2b8..7b192a0f 100644 --- a/docs/reference-vegalite.md +++ b/docs/reference-vegalite.md @@ -10,6 +10,7 @@ This reference lists the 36 chart types currently supported by the Vega-Lite bac - **Encoding channels** — the visual roles accepted in `chart_spec.encodings`, such as `x`, `y`, `color`, `size`, `column`, or `row`. - **Options** — template-specific `chart_spec.chartProperties` keys, including control type, domain, default, availability, and description. +- **Interactions** — the presets the chart type supports in `interaction_spec`. The data can still remove one at mount: a legend needs a bound discrete legend channel, navigation needs a continuous axis. See the [interaction guide](/documentation/interaction-spec). Use the chart type name exactly as shown in `chart_spec.chartType`. @@ -33,6 +34,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `size`, `shape`, `detail`, `opacity`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `opacity` | number | 0.1 – 1 (step 0.1) | `1` | always | Mark opacity. | @@ -46,6 +49,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `size`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `regressionMethod` | choice | `linear` (Linear), `log` (Logarithmic), `exp` (Exponential), `pow` (Power), `quad` (Quadratic), `poly` (Polynomial) | `linear` | always | Regression fit method. | @@ -60,6 +65,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `order`, `color`, `detail`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | @@ -72,6 +79,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `logScale_x` | toggle | on / off | `false` | conditional | Use a log/symlog scale on the x-axis. | @@ -83,6 +92,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `size`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `stepWidth` | number | 10 – 100 (step 5) | `20` | always | Jitter spread width. | @@ -100,6 +111,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `opacity`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `cornerRadius` | number | 0 – 15 (step 1) | `0` | always | Corner radius for supported marks. | @@ -112,6 +125,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `group`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `dodge` | choice | `auto` (Auto), `local` (Local (compact)), `global` (Global (aligned)) | `auto` | conditional | Dodge | @@ -122,6 +137,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center) | — | conditional | Stacking strategy for overlapping series. | @@ -132,6 +149,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `dotSize` | number | 20 – 300 (step 10) | `80` | always | Size of the dot mark. | @@ -144,6 +163,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `cornerRadius` | number | 0 – 8 (step 1) | `0` | always | Corner radius for supported marks. | @@ -155,6 +176,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `y`, `x`, `x2`, `color`, `detail`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `taskHeight` | number | 40 – 90 (step 5) | `70` | always | Task bar height as a percentage of each row. | @@ -170,6 +193,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `y`, `x`, `goal`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | @@ -180,6 +205,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `binCount` | number | 5 – 50 (step 1) | `Auto` | always | Maximum bin cap; Auto lets the backend choose. | @@ -189,6 +216,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `bandwidth` | number | 0.05 – 2 (step 0.05) | `0` | always | Kernel-density bandwidth (0 = auto). | @@ -198,6 +227,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `color`, `detail`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `showPoints` | toggle | on / off | `false` | always | Overlay point markers on the line. | @@ -211,6 +242,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `bandwidth` | number | 0.05 – 2 (step 0.05) | `0` | always | Kernel-density bandwidth (0 = auto). | @@ -224,6 +257,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `opacity`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `whiskerMethod` | choice | `iqr` (Tukey (1.5 × IQR)), `minmax` (Min–Max) | `iqr` | always | Whiskers | @@ -240,6 +275,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `showValueLabels` | toggle | on / off | `false` | conditional | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | @@ -248,6 +285,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `open`, `high`, `low`, `close`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `independentYAxis` | toggle | on / off | `false` | conditional | Use independent y-scales for facets. | @@ -262,6 +301,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `strokeDash`, `detail`, `opacity`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | @@ -278,6 +319,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `detail`, `row`, `column` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | @@ -292,6 +335,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `detail`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | @@ -305,6 +350,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `detail`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `showText` | toggle | on / off | `false` | always | Values | @@ -319,6 +366,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `opacity`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | @@ -332,6 +381,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | @@ -341,6 +392,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `y2`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `inspect-index`, `navigate` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)) | — | always | Line or area interpolation method. | @@ -353,6 +406,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `size`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-angle`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `innerRadius` | number | 0 – 100 (step 5) | `0` | always | Inner radius as a percentage of the outer radius. | @@ -364,6 +419,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `size`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-angle`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `innerRadius` | number | 0 – 100 (step 5) | `50` | always | Inner radius as a percentage of the outer radius. | @@ -375,6 +432,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-angle`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `padAngle` | number | 0 – 0.1 (step 0.005) | `0` | always | Angular gap between radial segments. | @@ -387,6 +446,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-angle`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `filled` | toggle | on / off | `true` | always | Fill the enclosed radar area. | @@ -404,6 +465,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `y`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `showValueLabels` | toggle | on / off | `false` | always | Print the numbers on the marks. Seeded from the theme’s own habit at this density; withheld when the marks are too dense to read. On a stacked bar each segment prints its own value, centred in the segment — or its share, where the stack is normalized. Printed values are rounded to about three significant figures, with a k/M suffix once the numbers get long — but never so far that two different marks print the same number, or a value that is not zero prints as zero, so the mark carries a number rather than a transcription. | @@ -415,6 +478,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `color` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `cornerRadius` | number | 0 – 8 (step 1) | `2` | always | Corner radius for supported marks. | @@ -423,6 +488,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `y`, `x`, `color`, `column`, `row` +**Interactions:** `click-highlight`, `axis-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `drag-reorder` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `maxRows` | number | 5 – 100 (step 1) | `20` | always | Maximum number of table rows to display. | @@ -433,6 +500,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `metric`, `value`, `goal` +**Interactions:** `click-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `context-activate`, `long-press`, `double-activate`, `inspect` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `layout` | choice | `horizontal` (Horizontal), `vertical` (Vertical), `grid` (Grid) | `grid` | always | Layout | @@ -447,6 +516,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `longitude`, `latitude`, `color`, `size`, `opacity` +**Interactions:** `click-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `region` | choice | `auto` (Auto-detect), `us` (United States), `world` (World) | `auto` | always | Region | @@ -461,6 +532,8 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `id`, `color`, `detail` +**Interactions:** `click-highlight`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `brush-zoom`, `linked-brush`, `legend-toggle`, `context-activate`, `long-press`, `double-activate`, `inspect`, `navigate` + | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| | `region` | choice | `auto` (Auto-detect), `us` (United States), `world` (World) | `auto` | always | Region | diff --git a/docs/tutorials/setup-flint-mcp.md b/docs/tutorials/setup-flint-mcp.md index 5585d70b..a1beaa8f 100644 --- a/docs/tutorials/setup-flint-mcp.md +++ b/docs/tutorials/setup-flint-mcp.md @@ -21,7 +21,7 @@ opens that chart locally. | `validate_chart` | Check whether a Flint input is valid and inspect warnings, errors, and computed size. | | `render_chart` | Render a static PNG or SVG locally when you need an artifact or the host has no MCP App UI. | | `compile_chart` | Return backend-native Vega-Lite, ECharts, or Chart.js JSON. | -| `list_chart_types` | Inspect supported chart types and encoding channels. | +| `list_chart_types` | Inspect supported chart types, encoding channels, and the interaction presets each chart type supports. | | `list_themes` | Inspect built-in visual themes and retrieve guidance for a selected preset. | | Resource or prompt | Use it for | diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md index 3a967257..a53944bf 100644 --- a/docs/zh-CN/api-reference.md +++ b/docs/zh-CN/api-reference.md @@ -116,6 +116,8 @@ interface ChartAssemblyInput { chartProperties?: Record; }; options?: AssembleOptions; + theme_spec?: ThemeSpec | string; // 呈现,仅 Vega-Lite + interaction_spec?: InteractionSpec; // 行为,仅 Vega-Lite 交互层 field_display_names?: Record; } ``` @@ -131,6 +133,20 @@ interface ChartAssemblyInput { 将列名映射到语义类型。这驱动编码类型、格式化、聚合默认值、颜色类与布局。见[语义类型](/documentation/semantic-types)。 +### `interaction_spec` + +图表的行为。按 `type` 列出交互预设,每项带自己的 `options`,另有 `assistedTargeting` 与 `keyboardTargeting` 两个交互层策略: + +```ts +interface InteractionSpec { + interactions: { type: InteractionPresetType; id?: string; options?: Record }[]; + assistedTargeting?: boolean | AssistedTargetingOptions; + keyboardTargeting?: boolean; +} +``` + +`buildInteractiveChart()` 读取它,装配器忽略它。图表类型无法支持的条目会以 `unsupported_interaction` 警告被丢弃;`validateChart` 在渲染前报告同样的警告。`supportedInteractionPresets(def.interactionSupport)` 列出模板按声明支持的预设。参见[使用交互](/documentation/interaction-spec)。 + ### `chart_spec` | 字段 | 说明 | diff --git a/docs/zh-CN/design-interactions.md b/docs/zh-CN/design-interactions.md new file mode 100644 index 00000000..1f40c466 --- /dev/null +++ b/docs/zh-CN/design-interactions.md @@ -0,0 +1,322 @@ +# 交互设计 + +读者点击、悬停、拖动或按键时,Flint 图表如何响应;以及一种图表类型如何决定自己能够支持其中的哪些行为。 + +> 本文解释模型本身。写法指南见[使用交互](interaction-spec.md),API 见 [API 参考](api-reference.md)。 + +## 目录 + +- [§1 概览](#1-概览) +- [§2 交互模型](#2-交互模型) +- [§3 规范](#3-规范) +- [§4 图表语义](#4-图表语义) +- [§5 能力与准入](#5-能力与准入) +- [§6 重置](#6-重置) +- [§7 流水线](#7-流水线) +- [§8 宿主与发现](#8-宿主与发现) +- [§9 扩展模型](#9-扩展模型) +- [附录:声明表](#附录声明表) + +--- + +# §1 概览 + +一张 Flint 图表由一个 `ChartAssemblyInput` 中的三份文档组成: + +| 文档 | 说明 | 读取方 | +|---|---|---| +| `chart_spec` | 图表表达什么:类型、编码、属性 | 所有装配器 | +| `theme_spec` | 图表长什么样 | Vega-Lite 装配器 | +| `interaction_spec` | 图表如何响应 | Vega-Lite 交互层 | + +装配器从不读取 `interaction_spec`,静态渲染不受它影响。只有 `buildInteractiveChart()` 在图表装配完成后读取它。 + +行为来自**预设(preset)**:Flint 内置的、有名字的交互,如 `click-highlight` 或 `navigate`。代码通过工厂函数使用预设,`clickHighlight({ dimOpacity: 0.2 })`;spec 通过名字使用同一预设,`{ "type": "click-highlight", "options": { "dimOpacity": 0.2 } }`。注册表把名字映射到工厂函数,因此两者是同一定义的两种写法。下文的一切同时适用于两者。 + +图表能支持什么,由两个事实决定,分别在两个地方回答: + +- **图表类型**在模板中声明它提供的属性。这是静态的,在任何数据到来之前就已知。 +- **数据**在装配时确认依赖数据的属性:图例需要绑定离散的图例通道,导航需要连续坐标轴。 + +准入(admission)把预设所需与这张图表(类型加数据)所提供的进行比较。图表无法支持的 spec 条目会带警告被丢弃,图表仍然渲染;无法支持的代码定义会抛出异常,因为开发者能看到异常。 + +# §2 交互模型 + +## §2.1 定义 + +预设工厂返回一个 `CanvasInteractionDef`: + +| 字段 | 作用 | +|---|---| +| `id` | 在更新和 `flint-interaction` 事件中标识该交互;默认为预设名 | +| `preset` | 生成它的预设;准入据此从核心表读取需求 | +| `eventSource` | 触发器:元素点击或悬停、拖动区域、导航手势 | +| `reset` | 使其回到中性状态的手势(§6) | +| `affordances` | 它响应的命中类型(`mark`、`legend-item`、`axis-label`、`plot`),每种带光标与悬停反馈;运行时只把命中派发给声明了该类型的交互 | +| `handle(event, context)` | 把已解析的语义事件变成 `ChartUpdate`,或返回 `null` | +| `origin` | 由解析器生成时为 `'spec'`;代码定义没有该字段 | + +`ExternalInteractionDef` 没有手势。宿主通过 `surface.dispatch(id, payload)` 驱动它,其 `handle` 把载荷变成更新。联动仪表盘和叙事页面使用它。 + +## §2.2 事件源 + +三个家族,准入按其推理: + +- **element**:对标记、图例项或坐标轴标签的点击、悬停、长按、双击或检视。需要能解析为数据的标记。 +- **region**:拖出矩形、沿一条轴的区间、套索或角度扇区,并解析其中的标记。需要具备相应区域类型的绘图区。 +- **navigation**:拖动平移、滚轮或双指缩放连续坐标轴,或地图上投影的范围。需要可导航的坐标轴。 + +## §2.3 更新语言 + +`handle` 从不触碰 DOM。它返回 `ChartUpdate`,即 `{ id, ops }`,由运行时施加: + +| 操作 | 效果 | +|---|---| +| `set-style` | 强调或淡化目标,隐藏系列 | +| `set-annotation` | 在目标上固定或清除注释 | +| `set-viewport` | 移动连续域或投影 | +| `set-order` | 重排离散域 | +| `set-overlay`、`set-freeform-overlay` | 绘制引导线或自由路径 | +| `set-data` | 替换某一层显示的行 | + +目标要么是对已解析元素的引用 `{ visual, elements }`,要么是按行键的选择器 `{ select: { key } }`。宿主通过 `surface.applyUpdate()` 和 `surface.setUpdates()` 从外部施加同一语言,因此叙事步骤与点击产生同类变化。 + +## §2.4 交互层 + +`buildInteractiveChart(container, input, options)` 返回 `InteractiveChartSurface`:`ready`、`warnings`、`applyUpdate`、`setUpdates`、`clearUpdate`、`dispatch`、`refresh`、`destroy`。容器为每个语义事件派发 `flint-interaction` DOM 事件,携带交互 id 和已解析目标,宿主无需了解预设即可监听。 + +# §3 规范 + +```json +{ + "interaction_spec": { + "interactions": [ + { "type": "click-highlight" }, + { "type": "legend-toggle" }, + { "type": "navigate", "id": "pan", "options": { "axes": "y", "pan": false, "reset": ["double-click", "escape"] } } + ], + "assistedTargeting": true, + "keyboardTargeting": false + } +} +``` + +有意保持一种简单形状: + +- 每个条目是带 `type` 的对象,没有字符串简写。 +- 选项嵌套在 `options` 下。与 `type` 并列的选项会被拒绝并给出提示,工厂调用的习惯不会悄悄消失。 +- `id` 位于条目上,绝不在 `options` 内。默认为 `type`,因此同一类型的两个条目需要显式 id。 +- 两个交互层策略 `assistedTargeting` 与 `keyboardTargeting` 与列表并列。它们描述交互层,而非某个交互。 +- 保留状态不属于 spec,由宿主通过交互层施加。 + +解析器 `resolveInteractionSpec()` 把 spec 变成定义并为每个打上 `origin: 'spec'`。它对图表一无所知。它拒绝格式错误:未知的 `type`、多余的键、非对象的 `options`、位于 `options` 内的 `id`、缺少 `groupBy` 等必需选项、未知或不支持的 `reset` 手势、重复 id、不属于这三个的顶层键(对曾经存在的 `dismiss` 与 `updates` 给出提示)。每条消息按索引和类型指出条目。 + +`composeInteractiveOptions()` 把 spec 与代码传给 `buildInteractiveChart()` 的内容合并:spec 条目在前,代码在后;两边共用一个 id 是错误;交互层策略由代码设置时取代码,否则取 spec;不运行交互的后端以一条 `info` 警告忽略 spec。 + +# §4 图表语义 + +预设用数据说话:“亚洲的那些柱子”。渲染器用像素说话。每种图表类型在其 `ChartTemplateDef` 的 `semanticInteractions` 中拥有这份翻译。装配器对每张图表用解析后的编码调用它一次,运行时读取结果。 + +它返回一份三部分的字典: + +- **角色。**`fields`、`categoryField`、`seriesField`、`legendFields`、`selectableMarks`:哪些字段扮演哪种角色,哪个图例属于哪个字段,点击能命中哪些标记名。`click-group-focus` 等预设读取它们。 +- **两个函数。**`resolve(event, context)` 接收指针下的物理命中,返回 `SemanticTarget`:数据元素加上视觉类型与角色,如 `bar` 或 `legend-item`。`presentUpdate(update, context)` 接收通用更新,返回图表专用版本,例如注释可以落在柱子的哪个位置。 +- **呈现。**`renderHoverStyles`、`renderSelectionStyles`、`annotationMarkType`,以及模板自选重排轴时的 `reorderAxes`。 + +这份字典说明**如何读**一张图表,并不说明**图表类型支持什么**。36 个 Vega-Lite 模板每个都有它,因此它的存在不携带任何关于支持的信息。这一区分正是下一节存在的原因。 + +# §5 能力与准入 + +## §5.1 八种能力 + +能力是至少有一个预设在运行时读取的图表事实。共有八种,即核心中的 `INTERACTION_CAPABILITIES`: + +| 能力 | 图表类型声明 | 数据确认 | +|---|---|---| +| `elements` | `elements: true`:标记可解析为数据行 | 无 | +| `cartesian-region` | `region: ['cartesian']`:矩形、区间或套索拖动可解析标记 | 无 | +| `angular-region` | `region: ['angular']`:扇区拖动可解析标记 | 无 | +| `navigation` | `navigation: { axes?, geo? }` | x 或 y 上有连续字段且未分面;或为投影 | +| `reorder` | `reorder: { axes?, includeConnectiveMarks?, markTypes? }` | x 或 y 上有离散字段且未分面 | +| `legend` | `legend: true` | 图例通道上有离散字段 | +| `discrete-axis` | `discreteAxis: true` | x 或 y 上有离散字段 | +| `index` | `index: true`:一个 x 位置读取所有系列 | x 上有字段 | + +三种区域与元素能力是几何事实,模板是唯一来源。其余五种针对编码确认。`navigation` 与 `index` 同时也是有意筛选的:不是每张有连续轴的图表都应当平移,而 `index` 描述的是只有部分图形具备的阅读模型。 + +极坐标图表声明两种区域:矩形或套索按像素范围解析其弧段,其上的区间刷选则被当作扇区。 + +## §5.2 类型声明什么 + +每个模板携带一个 `interactionSupport` 块,即 `ChartInteractionSupport`: + +```ts +interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, +}, +``` + +缺少的键意味着永不。存在的键意味着类型可以拥有它,其余由数据决定。这个块是闸门:模板漏掉某个键,数据无法把它打开。附录列出全部 36 个块。 + +## §5.3 数据确认什么 + +装配时,Vega-Lite 装配器把块与解析后的编码求交,并把结果写入编译后的 spec,与字典并列: + +```ts +_interactionSemantics: { + ...templateSemantics, + chartType: 'Bar Chart', + capabilities: ['elements', 'cartesian-region', 'navigation', 'reorder', 'discrete-axis'], + navigationAxes: ['y'], + reorderAxes: [{ axis: 'x', field: 'country' }], + ... +} +``` + +上面是一张没有颜色字段的柱状图:`legend` 已声明但未确认,所以不在列表中。`capabilities` 是准入读取的唯一权威,不存在对计划的第二种读法。装配器为每张 Vega-Lite 图表写入 `_interactionSemantics`,因此装配过的 spec 上该字段从不缺席。 + +## §5.4 预设需要什么 + +核心中的一张表 `INTERACTION_PRESET_REQUIREMENTS` 给出每个预设的最小能力集合,缺少任何一项它都无法工作: + +| 预设 | 需要 | +|---|---| +| `click-highlight`、`click-group-focus`、`hover-group-focus`、`click-annotate`、`context-activate`、`long-press`、`double-activate`、`inspect` | `elements` | +| `select`、`lasso-select`、`brush-x`、`brush-y`、`linked-brush` | `elements`、`cartesian-region` | +| `brush-angle` | `elements`、`angular-region` | +| `navigate`、`brush-zoom` | `navigation` | +| `legend-toggle` | `legend` | +| `axis-highlight` | `discrete-axis` | +| `drag-reorder` | `reorder` | +| `inspect-index` | `index` | + +定义通过工厂打在其上的 `preset` 名字找到自己的行。手写的定义没有预设,不需要任何能力;其作者自行负责。 + +## §5.5 匹配 + +`admitInteractions(plan, interactions)` 在挂载时、在编译步骤中运行,使用装配器写下的计划: + +1. 对每个交互,其所需的每项能力必须在 `plan.capabilities` 中。第一个缺失的能力决定消息:`Interaction "legend-toggle" requires a discrete legend; Bar Chart has none.` +2. 请求图表不能导航的轴的 `navigate` 按轴拒绝,因为仅凭能力无法判断 `axes: 'x'` 对一张只能导航 y 的图表。 +3. 一个触发器,一个所有者。`triggersOf(definition)` 从事件源、affordance 键、状态组和重置列表读出一个定义占用的触发器:导航、区域拖动和元素拖动槽位,绘图区拖动,双击,以及图例、坐标轴和带保留焦点的标记点击。对每个被两个定义共用的触发器,能放弃它并保留其余部分的一方通过 `withoutAffordances` 放弃并附带 `info` 警告;否则后面的条目整体让步,spec 条目总是让步给代码定义,两个代码定义则抛出异常。 + +来源决定后果。spec 条目以 `ChartWarning` 丢弃,代码为 `unsupported_interaction` 或 `conflicting_interactions`,消息以 “The interaction was dropped.” 结尾。代码定义以同一句子抛出异常。警告到达 `surface.warnings`、控制台(一次)、`validateChart()` 以及 MCP 的 `validate_chart`。 + +## §5.6 为什么是两层而不是一层 + +仅从装配后的图表推断,是这一模型之前的做法,它让每种图表类型接纳所有预设:每个模板都有字典,所以“具备元素语义”永远为真。数据无法在类型必须说不的地方说不:热力图绑定的颜色图例是连续的;玫瑰图绑定的名义 x 的标签是扇区;KPI 卡片有矩形标记和行,却没有可拖动的绘图区。而且有三类读者在数据存在之前就需要答案:调用 `list_chart_types` 的智能体、生成的参考文档、覆盖视图。 + +# §6 重置 + +每个保留状态的预设都带有 `reset`,即使其回到中性状态的手势列表: + +| 手势 | 含义 | +|---|---| +| `click-none` | 没有命中任何图表元素的点击:空白绘图区、边距、背景 | +| `double-click` | 图表任意位置的双击 | +| `escape` | 图表拥有焦点时按下 Escape | + +每个预设在注册表中有默认列表和支持列表;解析器拒绝不支持的手势,以及为不保留状态的预设设置的任何 `reset`。每张图表一个分派器运行这些手势。一个手势只重置列表中包含它的交互,各按自己的 id:清除保留的更新,有状态刷选通过其控制器清除,`navigate` 沿自己的路径飞回,带闭包状态的预设通过 `onReset()` 丢弃状态。 + +带 `escape` 重置的图表可获得焦点,并在指针按下时获取焦点,因此 Escape 只到达读者最后触碰的图表。通过交互层施加的宿主更新从不被手势重置,宿主用 `clearUpdate()` 清除它们。 + +# §7 流水线 + +``` +chart_spec + data ──► assembleVegaLite ──► spec + _interactionSemantics + │ 字典、chartType、capabilities、 + │ navigationAxes、reorderAxes…… +interaction_spec ──► resolveInteractionSpec ─┤ +options.interactions ──► compose ────────────┤ + ▼ + addVegaLiteInteractions + 准入 ─► 计划(已接纳的定义、警告) + 为标记、比例尺、信号加装 + ▼ + mountVegaInteractions + 手势、分派器、叠加层、事件 +``` + +哪个事实在哪里决定: + +| 事实 | 决定于 | +|---|---| +| 类型提供什么 | 模板块 | +| 这张图确认什么 | 装配器 | +| 预设需要什么 | 核心表 | +| 接纳什么、有哪些警告 | 编译步骤,通过准入 | +| 命中如何变成行 | 字典,运行时 | +| 哪个手势重置什么 | 分派器,来自各定义的列表 | + +# §8 宿主与发现 + +- **`buildInteractiveChart()`** 从输入读取 `interaction_spec` 并与代码定义合并。MCP 的 `create_chart_view`、站点编辑器与图库在输入含交互条目时经由它挂载,否则静态渲染。 +- **`validateChart()`** 对装配后的语义运行解析器与准入,在任何渲染之前返回与挂载相同的警告。格式错误的 spec 是 `invalid_interaction_spec` 错误。MCP 的 `validate_chart` 返回同一列表。 +- **`supportedInteractionPresets(def.interactionSupport)`** 列出图表类型按声明支持的预设。`list_chart_types` 按图表类型返回它,Vega-Lite 参考文档打印它。数据仍可能在挂载时移除某一项,指南对此有说明。 +- **交互实验室的覆盖页签**为每种图表类型装配一个代表性用例,展示每种图表类型对每个预设的情况:对该数据生效、类型支持但该数据未确认、或从不提供。 + +# §9 扩展模型 + +**新增预设。**把名字加入核心的 `INTERACTION_PRESET_TYPES`,把需求加入 `INTERACTION_PRESET_REQUIREMENTS`。把选项类型加入 `InteractionPresetOptions`,添加带标签、手势家族、支持与默认重置列表的注册表条目,以及打上 `preset` 并附加重置列表的工厂包装。映射类型使缺失的条目成为编译错误。重新生成参考文档。 + +**新增能力。**把名字加入 `INTERACTION_CAPABILITIES`,把措辞加入 `INTERACTION_CAPABILITY_DESCRIPTIONS`。为 `ChartInteractionSupport` 添加键,为 `declaredInteractionCapabilities` 添加一行。若需数据确认,把事实加入装配器的 `confirmed` 映射。在提供它的模板上声明。 + +**新增模板。**写出它的 `interactionSupport` 块:有解析器时声明 `elements`;几何允许的区域类型;连续轴应当平移时声明 `navigation`;几何允许之处声明 `reorder`、`legend`、`discreteAxis`;只有一个 x 位置能跨系列读取时才声明 `index`。一个测试断言每个 Vega-Lite 模板都携带该块。 + +# 附录:声明表 + +由 `npm run gen:reference` 从模板生成。请勿手工编辑此表。 + + +| 图表类型 | elements | region | navigation | reorder | legend | discrete axis | index | +|---|---|---|---|---|---|---|---| +| Area Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Bar Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Bar Table | ✓ | cartesian | | ✓ | ✓ | ✓ | | +| Boxplot | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Bullet Chart | ✓ | cartesian | | ✓ | ✓ | ✓ | | +| Bump Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Calendar Heatmap | ✓ | cartesian | | ✓ | ✓ | ✓ | | +| Candlestick Chart | ✓ | cartesian | x | ✓ | | ✓ | ✓ | +| Choropleth | ✓ | cartesian | geo | | ✓ | | | +| Connected Scatter Plot | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Density Plot | ✓ | cartesian | x | ✓ | ✓ | ✓ | ✓ | +| Donut Chart | ✓ | cartesian, angular | | | ✓ | | | +| ECDF Plot | ✓ | cartesian | x | ✓ | ✓ | ✓ | ✓ | +| Gantt Chart | ✓ | cartesian | x | ✓ | ✓ | ✓ | | +| Grouped Bar Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Heatmap | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Histogram | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| KPI Card | ✓ | | | | | | | +| Line Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Lollipop Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Map | ✓ | cartesian | geo | | ✓ | | | +| Pie Chart | ✓ | cartesian, angular | | | ✓ | | | +| Pyramid Chart | ✓ | cartesian | | ✓ | ✓ | ✓ | | +| Radar Chart | ✓ | cartesian, angular | | | ✓ | | | +| Range Area Chart | ✓ | cartesian | x, y | | ✓ | ✓ | ✓ | +| Ranged Dot Plot | ✓ | cartesian | x, y | 含连接标记 | ✓ | ✓ | | +| Regression | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Rose Chart | ✓ | cartesian, angular | | | ✓ | | | +| Scatter Plot | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Slope Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Sparkline | ✓ | cartesian | x | ✓ | | ✓ | ✓ | +| Stacked Bar Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Streamgraph | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | +| Strip Plot | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | +| Violin Plot | ✓ | cartesian | | | ✓ | ✓ | | +| Waterfall Chart | ✓ | cartesian | x, y | rect 标记 | ✓ | ✓ | | + + +各行背后的判断: + +- **KPI 卡片的 `elements`。**模板带有解析器和注释呈现器,因此 `click-highlight` 与 `click-annotate` 在卡片上可用。 +- **小提琴图与范围面积图没有 `reorder`。**两者在块存在之前就拒绝重排;范围面积图的类别轴是路径,有测试守护。 +- **柱状表、子弹图、迷你图、日历热力图、地图、分级统计图的 `region: cartesian`。**区域控制器按像素范围解析标记,因此覆盖行、单元格或气泡的矩形可以选中它们。只有 KPI 卡片没有拖动区域。 +- **极坐标图表与投影图没有 `discreteAxis`。**它们的标签是扇区或地名,不是可点击的类别。 +- **`index`** 出现在 x 为跨系列共享索引的图表上:折线族、面积族、碰撞图、斜率图、密度图、ECDF、K 线图、迷你图,以及散点族(实验室中精选的索引检视用例依赖它)。 diff --git a/docs/zh-CN/interaction-spec.md b/docs/zh-CN/interaction-spec.md new file mode 100644 index 00000000..83be8583 --- /dev/null +++ b/docs/zh-CN/interaction-spec.md @@ -0,0 +1,144 @@ +# 使用交互 + +`interaction_spec` 与 `chart_spec`、`theme_spec` 并列位于 `ChartAssemblyInput` 中。chart spec 说明图表**表达什么**,theme spec 说明图表**长什么样**,interaction spec 说明读者点击、悬停、拖动或按键时图表**如何响应**。 + +每种行为都来自一个**预设(preset)**:Flint 内置的、有名字的交互,例如 `click-highlight` 或 `navigate`。你列出想要的预设和各自的选项,Flint 挂载图表能够支持的那些,并告诉你哪些无法支持。 + +> `interaction_spec` 只影响 Vega-Lite 交互层。装配器和静态后端不会读取它;对这些后端,`validateChart` 会报告该字段被忽略。 + +## 结构 + +```json +{ + "chart_spec": { "chartType": "Bar Chart", "encodings": { "x": "country", "y": "gdp", "color": "region" } }, + "interaction_spec": { + "interactions": [ + { "type": "click-highlight" }, + { "type": "legend-toggle" }, + { "type": "navigate", "options": { "axes": "y", "pan": false, "reset": ["double-click", "escape"] } } + ] + } +} +``` + +| 键 | 含义 | +|---|---| +| `interactions` | 每个交互一项,按挂载顺序排列。 | +| `interactions[].type` | 生成该交互的预设,同时也是交互的默认 `id`。 | +| `interactions[].id` | 可选。同一图表两次使用同一预设时用来区分,也是 `flint-interaction` 事件中的名字。 | +| `interactions[].options` | 预设自己的选项,始终嵌套在 `options` 下,不要与 `type` 并列。 | +| `assistedTargeting` | 可选。指针吸附到附近的标记;`false` 要求精确命中,对象可设置 `maxDistance`、`indicator`、`details`。 | +| `keyboardTargeting` | 可选。允许读者用键盘在标记间移动。 | + +条目没有字符串简写:单独的 `"click-highlight"` 会被拒绝,`{ "type": "click-highlight" }` 是最小形式。 + +## 预设一览 + +| 类型 | 读者的操作 | 需要图表提供 | 默认重置 | +|---|---|---|---| +| `click-highlight` | 点击标记、图例项或坐标轴标签以强调它并淡化其余。 | 元素 | click-none, escape | +| `click-group-focus` | 点击标记以强调同组的所有标记(`groupBy`)。 | 元素 | click-none, escape | +| `hover-group-focus` | 悬停标记以预览其组(必须提供 `groupBy`)。 | 元素 | 无 | +| `click-annotate` | 点击标记以固定一个带数值的注释。 | 元素 | click-none, escape | +| `context-activate` | 右键或长按,把上下文目标交给宿主。 | 元素 | 无 | +| `long-press` | 长按标记以激活。 | 元素 | click-none, escape | +| `double-activate` | 双击标记以激活。 | 元素 | click-none, escape | +| `inspect` | 在绘图区移动,读取最近标记的值。 | 元素 | 无 | +| `inspect-index` | 在绘图区移动,读取同一 x 位置上所有系列的值(`seriesBy` 指定单个系列)。 | 索引轴 | escape | +| `select` | 拖出矩形以强调其中的标记。 | 元素、直角坐标区域 | click-none, escape | +| `lasso-select` | 自由绘制区域以强调其中的标记。 | 元素、直角坐标区域 | click-none, escape | +| `brush-x`、`brush-y` | 沿一条轴拖出区间;在极坐标图上,x 刷选是一个角度扇区。 | 元素、直角坐标区域 | click-none, escape | +| `brush-angle` | 在饼图、环图、玫瑰图或雷达图上拖出角度扇区。 | 元素、角度区域 | click-none, escape | +| `linked-brush` | 刷选标记,在其他视图中高亮相同的组(必须提供 `groupBy`)。 | 元素、直角坐标区域 | click-none, escape | +| `brush-zoom` | 拖出矩形并放大到该范围。 | 导航 | double-click, escape | +| `navigate` | 拖动平移、滚轮或双指缩放连续坐标轴(`axes`、`pan`、`domainGuard`)。 | 导航 | double-click | +| `legend-toggle` | 点击图例项以隐藏或恢复其系列。 | 离散图例 | 无 | +| `axis-highlight` | 点击离散坐标轴标签以强调该类别。 | 离散坐标轴 | click-none, escape | +| `drag-reorder` | 拖动离散坐标轴标签以改变类别顺序。 | 可重排坐标轴 | 无 | + +选项名与 `flint-chart/interactive` 中对应工厂函数接受的选项一致;TypeScript 调用方可用该入口的 `InteractionPresetSpec` 获得逐类型的精确形状。 + +## 重置手势 + +每个保留状态的预设都接受 `reset`,即让它回到中性状态的手势列表: + +| 手势 | 含义 | +|---|---| +| `click-none` | 一次没有命中任何图表元素的点击:空白绘图区、边距、背景。 | +| `double-click` | 图表任意位置的双击。 | +| `escape` | 图表获得焦点时按下 Escape。带 `escape` 重置的图表在读者按下时获取焦点,因此 Escape 只作用于最后触碰的图表。 | + +一个手势只重置列表中包含它的交互,各自按 id 处理。通过 surface 施加的宿主更新不会被手势重置。不保留状态的预设(`hover-group-focus`、`inspect`、`context-activate`)没有 `reset`,解析器会拒绝为其设置。 + +## 图表类型支持什么 + +每种图表类型声明它提供的属性:可解析为数据的标记、拖动区域、可导航的坐标轴、可重排的坐标轴、离散图例、离散坐标轴标签、索引轴。每个预设声明它需要的属性。图表类型提供了预设所需的全部属性时,该预设即受支持。 + +三个地方可以查看结果: + +- [Vega-Lite 图表参考](/documentation/reference-vegalite) 为每种图表类型打印一行 **交互**。 +- MCP 工具 `list_chart_types` 为每种图表类型返回 `interactions`。 +- 交互实验室的 **Coverage** 页签展示每种图表类型对每个预设的支持情况。 + +数据仍可能在挂载时移除某个预设。柱状图支持 `legend-toggle`,但没有颜色字段的柱状图没有可切换的图例;`navigate` 需要连续且未分面的坐标轴;`drag-reorder` 需要绑定编码中有离散坐标轴。 + +## 警告 + +图表无法支持的条目会**带警告被丢弃**,图表仍然渲染。消息中会写明交互名、所需属性和图表类型: + +``` +Interaction "legend-toggle" requires a discrete legend; Bar Chart has none. The interaction was dropped. +``` + +### 两个交互共用一个触发器时 + +触发器(trigger)是一种手势落在一种命中上:点击图例项、在绘图区拖动、双击。两个条目可能请求同一个触发器: + +| 触发器 | 谁请求它 | +|---|---| +| 导航槽位 | 每个 `navigate` | +| 区域拖动槽位 | `select`、`lasso-select`、各种 brush、`linked-brush`、`brush-zoom` | +| 元素拖动槽位 | `drag-reorder` | +| 绘图区拖动 | 每个区域拖动、`drag-reorder`,以及开启平移的 `navigate` | +| 双击 | `double-activate`,以及任何 `reset` 含 `double-click` 的条目 | +| 图例点击 | `legend-toggle`、`click-highlight`、带系列切换的 `inspect-index` | +| 轴标签点击 | `axis-highlight`、`click-highlight` | +| 带保留焦点的标记点击 | `click-highlight`、`click-group-focus` | + +一个触发器只有一个所有者。当两者中恰好一个能放弃该触发器并保留其余部分时,它就放弃,并以一条 `info` 警告说明。今天只有 `click-highlight` 能这样做,一次放弃一个目标: + +``` +Interaction "click-highlight" yields legend clicks to "legend-toggle". +``` + +否则后面的条目整体让步并被丢弃,附带警告;spec 条目总是让步给代码定义。两个共用触发器的代码定义会抛出异常。 + +两个选项可以提前避免冲突。`click-highlight` 接受 `targets`,所以 `{ "type": "click-highlight", "options": { "targets": ["mark"] } }` 永不请求图例或坐标轴。`navigate` 接受 `reset`,所以 `reset: ["escape"]` 把双击留给 `double-activate`。 + +在哪里读取警告: + +- `validateChart(input, 'vegalite')` 在渲染前返回它们,格式错误的 spec 以 `invalid_interaction_spec` 错误报告。 +- `buildInteractiveChart(container, input)` 通过 `surface.warnings` 暴露它们,并在控制台记录一次。 +- MCP 工具 `validate_chart` 返回同一列表。 + +格式错误的条目是错误而不是丢弃:未知的 `type`、放在 `options` 外的选项、放在 `options` 内的 `id`、缺少 `groupBy` 等必需选项、未知或不支持的 `reset` 手势、重复的 `id`。 + +## 代码与 spec,同一定义 + +spec 条目和工厂调用是同一交互的两种写法: + +```ts +import { buildInteractiveChart, clickHighlight } from 'flint-chart/interactive'; + +// 来自 spec +buildInteractiveChart(container, { ...input, interaction_spec: { interactions: [{ type: 'click-highlight', options: { dimOpacity: 0.2 } }] } }); + +// 来自代码 +buildInteractiveChart(container, input, { interactions: [clickHighlight({ dimOpacity: 0.2 })] }); +``` + +两者可以同时出现在一张图表上;spec 条目先挂载。两边使用同一个 `id` 是错误。图表无法支持的代码定义会抛出异常,因为开发者能看到异常;spec 条目则被丢弃,因为智能体读取的是警告。 + +## 在哪里生效 + +`buildInteractiveChart()` 从输入中读取 `interaction_spec`。MCP 工具 `create_chart_view` 挂载同一交互层,因此智能体可以在请求图表的同一份 JSON 中请求行为。站点的编辑器和图库同样从 spec 挂载。 diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index b1961590..4515fa8e 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -201,7 +201,14 @@ export { isRegistered, getRegisteredTypes } from './type-registry'; // Declarative interactions: the JSON contract read by flint-chart/interactive export { INTERACTION_PRESET_TYPES, + INTERACTION_CAPABILITIES, + INTERACTION_CAPABILITY_DESCRIPTIONS, + INTERACTION_PRESET_REQUIREMENTS, + declaredInteractionCapabilities, + supportedInteractionPresets, type InteractionPresetType, + type InteractionCapability, + type ChartInteractionSupport, type InteractionEntry, type InteractionSpec, type AssistedTargetingOptions, diff --git a/packages/flint-js/src/core/interaction-spec.ts b/packages/flint-js/src/core/interaction-spec.ts index a5c9ff03..ac1017bc 100644 --- a/packages/flint-js/src/core/interaction-spec.ts +++ b/packages/flint-js/src/core/interaction-spec.ts @@ -37,6 +37,119 @@ export const INTERACTION_PRESET_TYPES = [ export type InteractionPresetType = (typeof INTERACTION_PRESET_TYPES)[number]; +/** + * A fact about a chart that at least one interaction preset reads at runtime. + * `cartesian-region` is a rectangle, interval, or lasso drag the plot resolves marks in; + * `angular-region` is a sector drag, which only the angular brush needs. A polar chart + * offers both: its interval brush is honoured as a sector. + */ +export const INTERACTION_CAPABILITIES = [ + 'elements', + 'cartesian-region', + 'angular-region', + 'navigation', + 'reorder', + 'legend', + 'discrete-axis', + 'index', +] as const; + +export type InteractionCapability = (typeof INTERACTION_CAPABILITIES)[number]; + +/** What each capability is, in the words a warning or a tooltip uses. */ +export const INTERACTION_CAPABILITY_DESCRIPTIONS: Readonly> = { + 'elements': 'marks that resolve to data', + 'cartesian-region': 'a plot to drag a region on', + 'angular-region': 'a polar chart with an angular region', + 'navigation': 'a navigable continuous axis', + 'reorder': 'a discrete axis whose order can change', + 'legend': 'a discrete legend', + 'discrete-axis': 'a discrete axis with category labels', + 'index': 'an index axis shared by the series', +}; + +/** + * What a chart type offers to interaction presets, declared on + * `ChartTemplateDef.interactionSupport`. An absent key means the chart type never + * offers that capability. The assembler confirms the data-dependent ones + * against the encodings: a legend needs a bound discrete legend channel, + * navigation needs a continuous unfaceted axis, reorder needs a discrete axis. + */ +export interface ChartInteractionSupport { + /** Marks resolve to data elements, so click, hover, annotate, and inspect presets work. */ + elements?: boolean; + /** Drag regions the plot can resolve marks in. */ + region?: readonly ('cartesian' | 'angular')[]; + /** + * Continuous positional axes whose domains pan and zoom. `geo` marks a + * chart that places marks through a projection: pan and zoom then move the + * projection's extent, and both axes navigate together. + */ + navigation?: { axes?: readonly ('x' | 'y')[]; geo?: boolean }; + /** Discrete positional axes whose domain order a drag can change. */ + reorder?: { axes?: readonly ('x' | 'y')[]; includeConnectiveMarks?: boolean; markTypes?: readonly string[] }; + /** A discrete legend whose items stand for series or categories. */ + legend?: boolean; + /** Axis labels stand for categories a pointer can target. */ + discreteAxis?: boolean; + /** One position on the index axis reads a value from every series. */ + index?: boolean; +} + +/** The capabilities each preset needs: the smallest set without which it does nothing. */ +export const INTERACTION_PRESET_REQUIREMENTS: Readonly> = { + 'click-highlight': ['elements'], + 'axis-highlight': ['discrete-axis'], + 'click-group-focus': ['elements'], + 'hover-group-focus': ['elements'], + 'click-annotate': ['elements'], + 'select': ['elements', 'cartesian-region'], + 'lasso-select': ['elements', 'cartesian-region'], + 'brush-x': ['elements', 'cartesian-region'], + 'brush-y': ['elements', 'cartesian-region'], + 'brush-angle': ['elements', 'angular-region'], + 'brush-zoom': ['navigation'], + 'linked-brush': ['elements', 'cartesian-region'], + 'legend-toggle': ['legend'], + 'context-activate': ['elements'], + 'long-press': ['elements'], + 'double-activate': ['elements'], + 'inspect': ['elements'], + 'inspect-index': ['index'], + 'navigate': ['navigation'], + 'drag-reorder': ['reorder'], +}; + +/** The capabilities a chart type declares, before the assembler confirms the data-dependent ones. */ +export function declaredInteractionCapabilities( + support: ChartInteractionSupport | undefined, +): InteractionCapability[] { + if (!support) return []; + const list: InteractionCapability[] = []; + if (support.elements) list.push('elements'); + if (support.region?.includes('cartesian')) list.push('cartesian-region'); + if (support.region?.includes('angular')) list.push('angular-region'); + if (support.navigation) list.push('navigation'); + if (support.reorder) list.push('reorder'); + if (support.legend) list.push('legend'); + if (support.discreteAxis) list.push('discrete-axis'); + if (support.index) list.push('index'); + return list; +} + +/** + * The presets a chart type can honour by declaration. The data may still remove + * one at assemble time: a legend needs a bound discrete legend channel, and + * navigation needs a continuous unfaceted axis. + */ +export function supportedInteractionPresets( + support: ChartInteractionSupport | undefined, +): InteractionPresetType[] { + const declared = new Set(declaredInteractionCapabilities(support)); + return INTERACTION_PRESET_TYPES.filter((type) => + INTERACTION_PRESET_REQUIREMENTS[type].every((capability) => declared.has(capability))); +} + /** * One preset as JSON: the type name, an optional id, and that preset's options * under `options`, for example diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index 417fe55d..84a23187 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -6,7 +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'; +import type { ChartInteractionSupport, InteractionSpec } from './interaction-spec'; /** * Core types for the chart engine library. @@ -902,23 +902,13 @@ export interface ChartTemplateDef { /** Which encoding channels are available for this chart */ channels: string[]; - /** Cartesian positional channels whose continuous domains may be navigated at runtime. */ - navigation?: { - axes?: readonly ('x' | 'y')[]; - /** - * The chart places marks through a cartographic projection instead of - * x/y scales. Pan and zoom then move the projection's fitted extent, - * and both axes navigate together. - */ - geo?: boolean; - }; - - /** Whether authored categorical position axes support runtime domain reorder. */ - reorder?: false | { - axes?: readonly ('x' | 'y')[]; - includeConnectiveMarks?: boolean; - markTypes?: readonly string[]; - }; + /** + * What this chart type offers to interaction presets: the marks that resolve + * to data, the drag regions, the navigable and reorderable axes, the legend, + * the discrete axis labels, the index axis. Absent means the chart type + * supports no interaction. + */ + interactionSupport?: ChartInteractionSupport; /** * How the primary mark encodes its quantitative value. @@ -949,7 +939,6 @@ export interface ChartTemplateDef { selectableMarks: string[]; /** Backend marktype to anchor annotations to when one key matches several marks. */ annotationMarkType?: string; - supportedRegionGestures?: ('cartesian' | 'angular')[]; renderHoverStyles?: Record>>; + +/** A resolved affordance for one target, as the renderer reads it. */ +export interface ResolvedInteractionAffordance extends InteractionAffordance { + readonly target: InteractionAffordanceTarget; +} + +export function affordsTarget(interaction: CanvasInteractionDef, target: InteractionAffordanceTarget): boolean { + return target in interaction.affordances; +} + const CURSOR_PRIORITY: Record = { activate: 10, inspect: 20, @@ -33,16 +45,26 @@ const DRAW_CURSOR_SVG = [ ].join(''); export const DRAW_CURSOR = `url("data:image/svg+xml;utf8,${DRAW_CURSOR_SVG}") 3 3, crosshair`; +/** + * The cursor and hover to show for `target`, merged across `interactions`. An exact + * affordance wins; a `plot` affordance is the fallback for every other target, so a + * region cursor also shows over the marks inside the plot. + */ export function resolveInteractionAffordance( interactions: readonly CanvasInteractionDef[], target: InteractionAffordanceTarget, eligibleInteractionIds?: ReadonlySet, -): InteractionAffordance | undefined { - const claims = interactions +): ResolvedInteractionAffordance | undefined { + const claims: ResolvedInteractionAffordance[] = interactions .filter((interaction) => !eligibleInteractionIds || eligibleInteractionIds.has(interaction.id)) - .flatMap((interaction) => interaction.affordances ?? []) - .filter((affordance) => affordance.target === target - || (target !== 'plot' && affordance.target === 'plot')); + .flatMap((interaction) => { + const exact = interaction.affordances[target]; + const fallback = target !== 'plot' ? interaction.affordances.plot : undefined; + return [ + ...(exact ? [{ target, ...exact }] : []), + ...(fallback ? [{ target: 'plot' as const, ...fallback }] : []), + ]; + }); const exactClaims = claims.filter((claim) => claim.target === target); const eligibleClaims = exactClaims.length > 0 ? exactClaims : claims; const priority = (claim: InteractionAffordance): number => @@ -54,7 +76,7 @@ export function resolveInteractionAffordance( return cursor || hover ? { target, ...(cursor ? { cursor } : {}), ...(hover ? { hover } : {}) } : undefined; } -export function affordanceCursor(affordance: InteractionAffordance | undefined): string | undefined { +export function affordanceCursor(affordance: ResolvedInteractionAffordance | undefined): string | undefined { switch (affordance?.cursor) { case 'activate': return 'pointer'; case 'drag': return 'grab'; diff --git a/packages/flint-js/src/interactive/index.ts b/packages/flint-js/src/interactive/index.ts index 4cb3e863..ade763b0 100644 --- a/packages/flint-js/src/interactive/index.ts +++ b/packages/flint-js/src/interactive/index.ts @@ -31,11 +31,13 @@ export type { } from './guides'; export type { InteractionAffordance, + InteractionAffordances, InteractionAffordanceTarget, InteractionCursor, InteractionHoverEffect, + ResolvedInteractionAffordance, } from './affordances'; -export { DRAW_CURSOR, affordanceCursor, resolveInteractionAffordance } from './affordances'; +export { DRAW_CURSOR, affordanceCursor, affordsTarget, resolveInteractionAffordance } from './affordances'; export type { AnnotationCandidate, AnnotationConnection, diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index 23a595ce..cb98a0cd 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -8,9 +8,10 @@ import type { import type { InteractionEventSource, NavigationResetGesture } from './triggers'; export type { NavigationResetGesture } from './triggers'; import { NAVIGATION_RESET, NO_RESET, SELECTION_RESET, normalizeResetGestures, type InteractionResetGesture } from './reset'; +import type { InteractionPresetType } from '../core/interaction-spec'; import type { InspectIndexShow, InspectMode } from './triggers'; import type { InspectGuideOptions, RegionGuideOptions } from './guides'; -import type { InteractionAffordance } from './affordances'; +import type { InteractionAffordanceTarget, InteractionAffordances } from './affordances'; import type { NavigationAxes, } from './language/events'; @@ -22,6 +23,8 @@ import { createClickAnnotateInteraction, createClickGroupFocusInteraction, createClickHighlightInteraction, + CLICK_HIGHLIGHT_AFFORDANCE_TARGET, + CLICK_HIGHLIGHT_DEFAULT_TARGETS, createContextActivateInteraction, createDoubleActivateInteraction, createInspectInteraction, @@ -103,21 +106,25 @@ export interface CanvasInteractionDef { readonly id: string; /** Set by the spec resolver. A definition made in code has no origin. */ readonly origin?: 'spec'; + /** The preset that made this definition; admission reads its requirements from the registry. */ + readonly preset?: InteractionPresetType; /** Gestures that return this interaction to its neutral state, normalised by the factory. Absent on presets that retain nothing. */ readonly reset?: readonly InteractionResetGesture[]; /** Drops state the preset keeps outside the chart's retained updates, when a reset gesture fires. */ onReset?(): void; readonly eventSource: InteractionEventSource; - readonly affordances?: readonly InteractionAffordance[]; + /** + * The kinds of hit this interaction affords the reader, each with the cursor and hover + * that signal it. The runtime sends a hit only to the interactions that afford its kind. + */ + readonly affordances: InteractionAffordances; + /** A copy that affords fewer targets, or null when none remain. */ + withoutAffordances?(drop: readonly InteractionAffordanceTarget[]): CanvasInteractionDef | null; /** Retained updates from interactions in the same group replace one another. */ readonly retainedStateGroup?: string; readonly navigationDomainGuard?: NavigationDomainGuard; /** A reset gesture on this interaction tweens home over this duration. */ readonly navigationResetTransition?: NavigationTransition; - /** Claims legend activations exclusively, so a legend click never also reads as an element click. */ - readonly claimsLegendActivation?: boolean; - /** Claims native axis tick activations instead of treating them as mark activations. */ - readonly claimsAxisActivation?: boolean; handle?(event: CanvasInteractionEvent, context: InteractionContext): ChartUpdate | null; } @@ -301,6 +308,10 @@ export interface DragReorderOptions { reset?: readonly InteractionResetGesture[]; } +function asPreset(type: InteractionPresetType, definition: CanvasInteractionDef): CanvasInteractionDef { + return { ...definition, preset: type }; +} + /** Attaches the normalised reset list; presets that retain nothing never pass through here. */ function withReset( definition: CanvasInteractionDef, @@ -311,90 +322,97 @@ function withReset( } export function clickHighlight(options: ClickHighlightOptions = {}): CanvasInteractionDef { - return withReset(createClickHighlightInteraction(options), options.reset, SELECTION_RESET); + return { + ...asPreset('click-highlight', withReset(createClickHighlightInteraction(options), options.reset, SELECTION_RESET)), + withoutAffordances(drop) { + const remaining = (options.targets ?? CLICK_HIGHLIGHT_DEFAULT_TARGETS) + .filter((target) => !drop.includes(CLICK_HIGHLIGHT_AFFORDANCE_TARGET[target])); + return remaining.length > 0 ? clickHighlight({ ...options, targets: remaining }) : null; + }, + }; } export function axisHighlight(options: AxisHighlightOptions = {}): CanvasInteractionDef { - return withReset(createAxisHighlightInteraction(options), options.reset, SELECTION_RESET); + return asPreset('axis-highlight', withReset(createAxisHighlightInteraction(options), options.reset, SELECTION_RESET)); } export function clickGroupFocus(options: ClickGroupFocusOptions = {}): CanvasInteractionDef { - return withReset(createClickGroupFocusInteraction({ + return asPreset('click-group-focus', withReset(createClickGroupFocusInteraction({ id: options.id ?? 'click-group-focus', dimOpacity: options.dimOpacity, groupBy: options.groupBy, - }), options.reset, SELECTION_RESET); + }), options.reset, SELECTION_RESET)); } export function clickAnnotate(options: ClickAnnotateOptions = {}): CanvasInteractionDef { - return withReset(createClickAnnotateInteraction(options), options.reset, SELECTION_RESET); + return asPreset('click-annotate', withReset(createClickAnnotateInteraction(options), options.reset, SELECTION_RESET)); } export function linkedBrush(options: LinkedBrushOptions): CanvasInteractionDef { - return withReset(createLinkedBrushInteraction(options), options.reset, SELECTION_RESET); + return asPreset('linked-brush', withReset(createLinkedBrushInteraction(options), options.reset, SELECTION_RESET)); } export function hoverGroupFocus(options: HoverGroupFocusOptions): CanvasInteractionDef { - return createHoverGroupFocusInteraction({ ...options, id: options.id ?? 'hover-group-focus' }); + return asPreset('hover-group-focus', createHoverGroupFocusInteraction({ ...options, id: options.id ?? 'hover-group-focus' })); } export function select(options: SelectOptions = {}): CanvasInteractionDef { - return withReset(createSelectInteraction(options), options.reset, SELECTION_RESET); + return asPreset('select', withReset(createSelectInteraction(options), options.reset, SELECTION_RESET)); } export function lassoSelect(options: LassoSelectOptions = {}): CanvasInteractionDef { - return withReset(createLassoSelectInteraction(options), options.reset, SELECTION_RESET); + return asPreset('lasso-select', withReset(createLassoSelectInteraction(options), options.reset, SELECTION_RESET)); } export function legendToggle(options: LegendToggleOptions = {}): CanvasInteractionDef { - return withReset(createLegendToggleInteraction(options), options.reset, NO_RESET); + return asPreset('legend-toggle', withReset(createLegendToggleInteraction(options), options.reset, NO_RESET)); } export function contextActivate(options: ContextActivateOptions = {}): CanvasInteractionDef { - return createContextActivateInteraction(options); + return asPreset('context-activate', createContextActivateInteraction(options)); } export function inspect(options: InspectOptions = {}): CanvasInteractionDef { - return createInspectInteraction(options); + return asPreset('inspect', createInspectInteraction(options)); } export function inspectIndex(options: InspectIndexOptions = {}): CanvasInteractionDef { - return withReset(createInspectIndexInteraction(options), options.reset, ['escape']); + return asPreset('inspect-index', withReset(createInspectIndexInteraction(options), options.reset, ['escape'])); } export function brushZoom(options: BrushZoomOptions = {}): CanvasInteractionDef { - return withReset(createBrushZoomInteraction(options), options.reset, ['double-click', 'escape']); + return asPreset('brush-zoom', withReset(createBrushZoomInteraction(options), options.reset, ['double-click', 'escape'])); } export function longPress(options: LongPressOptions = {}): CanvasInteractionDef { - return withReset(createLongPressInteraction(options), options.reset, SELECTION_RESET); + return asPreset('long-press', withReset(createLongPressInteraction(options), options.reset, SELECTION_RESET)); } export function doubleActivate(options: DoubleActivateOptions = {}): CanvasInteractionDef { - return withReset(createDoubleActivateInteraction(options), options.reset, SELECTION_RESET); + return asPreset('double-activate', withReset(createDoubleActivateInteraction(options), options.reset, SELECTION_RESET)); } export function brushX(options: BrushOptions = {}): CanvasInteractionDef { - return withReset(createBrushInteraction('x', options), options.reset, SELECTION_RESET); + return asPreset('brush-x', withReset(createBrushInteraction('x', options), options.reset, SELECTION_RESET)); } export function brushY(options: BrushOptions = {}): CanvasInteractionDef { - return withReset(createBrushInteraction('y', options), options.reset, SELECTION_RESET); + return asPreset('brush-y', withReset(createBrushInteraction('y', options), options.reset, SELECTION_RESET)); } /** Select an angular interval on a polar chart. */ export function brushAngle(options: AngularBrushOptions = {}): CanvasInteractionDef { - return withReset(createAngularBrushInteraction(options), options.reset, SELECTION_RESET); + return asPreset('brush-angle', withReset(createAngularBrushInteraction(options), options.reset, SELECTION_RESET)); } export function navigate(options: NavigateOptions = {}): CanvasInteractionDef { const definition = createNavigateInteraction(options); // Mirrors the trigger's normalised list. - return { ...definition, reset: definition.eventSource.reset ?? NAVIGATION_RESET }; + return asPreset('navigate', { ...definition, reset: definition.eventSource.reset ?? NAVIGATION_RESET }); } export function dragReorder(options: DragReorderOptions = {}): CanvasInteractionDef { - return withReset(createDragReorderInteraction(options), options.reset, NO_RESET); + return asPreset('drag-reorder', withReset(createDragReorderInteraction(options), options.reset, NO_RESET)); } export function normalizeInteractions( diff --git a/packages/flint-js/src/interactive/presets/README.md b/packages/flint-js/src/interactive/presets/README.md index e82a3f4f..b81dd39d 100644 --- a/packages/flint-js/src/interactive/presets/README.md +++ b/packages/flint-js/src/interactive/presets/README.md @@ -28,6 +28,7 @@ only when they represent broadly reusable canvas policies. const legendSelection: CanvasInteractionDef = { id: 'legend-selection', eventSource: clickTrigger, + affordances: { 'legend-item': { cursor: 'activate' } }, handle: (event) => { if (event.action !== 'click-legend' || !event.target) return null; return { diff --git a/packages/flint-js/src/interactive/presets/angular-brush.ts b/packages/flint-js/src/interactive/presets/angular-brush.ts index 846cd6ab..ef57f2df 100644 --- a/packages/flint-js/src/interactive/presets/angular-brush.ts +++ b/packages/flint-js/src/interactive/presets/angular-brush.ts @@ -8,7 +8,7 @@ export function createAngularBrushInteraction(options: AngularBrushOptions = {}) return { id, eventSource: angularBrushTrigger(options.match ?? 'intersect', options.mode ?? 'ephemeral', options.guide), - affordances: [{ target: 'plot', cursor: 'region' }], + affordances: { plot: { cursor: 'region' } }, handle(event, context) { if (event.action !== 'brush-angle' || event.phase === 'start' || event.phase === 'cancel') return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); diff --git a/packages/flint-js/src/interactive/presets/axis-highlight.ts b/packages/flint-js/src/interactive/presets/axis-highlight.ts index f272122e..89b010ff 100644 --- a/packages/flint-js/src/interactive/presets/axis-highlight.ts +++ b/packages/flint-js/src/interactive/presets/axis-highlight.ts @@ -8,16 +8,16 @@ export function createAxisHighlightInteraction(options: AxisHighlightOptions = { return { id, eventSource: options.event === 'hover' ? hoverTrigger : clickTrigger, - claimsAxisActivation: true, - affordances: [{ - target: 'axis-label', - ...(options.event === 'hover' ? {} : { cursor: 'activate' as const }), - hover: 'cohort', - }], + affordances: { + 'axis-label': { + ...(options.event === 'hover' ? {} : { cursor: 'activate' as const }), + hover: 'cohort', + }, + }, handle(event, context) { if (event.action !== 'hover-axis' && event.action !== 'click-axis') return null; if (event.phase === 'start') return null; - const target = event.target?.visual.kind === 'axis' + const target = event.target && (!options.axis || event.target.elements.some((element) => element.value.axis === options.axis)) ? event.target : null; diff --git a/packages/flint-js/src/interactive/presets/brush-zoom.ts b/packages/flint-js/src/interactive/presets/brush-zoom.ts index faa20d53..95ad157e 100644 --- a/packages/flint-js/src/interactive/presets/brush-zoom.ts +++ b/packages/flint-js/src/interactive/presets/brush-zoom.ts @@ -10,7 +10,7 @@ export function createBrushZoomInteraction(options: BrushZoomOptions = {}): Canv return { id, eventSource: brushZoomTrigger(axes, options.guide), - affordances: [{ target: 'plot', cursor: 'region' }], + affordances: { plot: { cursor: 'region' } }, handle(event) { if (!REGION_ACTIONS.has(event.action) || event.phase !== 'commit' || event.operation === 'clear') return null; const domain = event.geometry.domain; diff --git a/packages/flint-js/src/interactive/presets/brush.ts b/packages/flint-js/src/interactive/presets/brush.ts index 98783439..94be40b8 100644 --- a/packages/flint-js/src/interactive/presets/brush.ts +++ b/packages/flint-js/src/interactive/presets/brush.ts @@ -10,7 +10,7 @@ export function createBrushInteraction(axis: 'x' | 'y', options: BrushOptions = id, axis, eventSource: axisBrushTrigger(axis, options.match ?? 'intersect', options.mode ?? 'ephemeral', options.guide), - affordances: [{ target: 'plot', cursor: 'region' }], + affordances: { plot: { cursor: 'region' } }, handle(event, context) { const acceptsAngular = axis === 'x' && event.action === 'brush-angle'; if ((event.action !== `brush-${axis}` && !acceptsAngular) diff --git a/packages/flint-js/src/interactive/presets/click-annotate.ts b/packages/flint-js/src/interactive/presets/click-annotate.ts index c70b791b..2076fad8 100644 --- a/packages/flint-js/src/interactive/presets/click-annotate.ts +++ b/packages/flint-js/src/interactive/presets/click-annotate.ts @@ -11,10 +11,9 @@ export function createClickAnnotateInteraction(options: ClickAnnotateOptions = { return { id, eventSource: assistedElementTrigger(clickTrigger, 8), - affordances: [{ target: 'mark', cursor: 'activate' }], + affordances: { mark: { cursor: 'activate' } }, handle(event, context) { if (!isActivationAction(event.action) || event.phase !== 'commit') return null; - if (event.target?.visual.role === 'legend-item') return null; if (!event.target) { return { id, diff --git a/packages/flint-js/src/interactive/presets/click-group-highlight.ts b/packages/flint-js/src/interactive/presets/click-group-highlight.ts index c7c2138d..f7e98a26 100644 --- a/packages/flint-js/src/interactive/presets/click-group-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-group-highlight.ts @@ -6,7 +6,6 @@ import type { SemanticTarget, GroupBy, } from '../interactions'; -import type { InteractionAffordance } from '../affordances'; import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; import { assistedElementTrigger, clickTrigger } from '../triggers'; import { expandElementsByFields } from './semantic-cohort'; @@ -53,17 +52,13 @@ function groupElements( export function createClickGroupFocusInteraction(options: GroupFocusEngineOptions = {}): CanvasInteractionDef { const id = options.id ?? 'click-group-focus'; const dimOpacity = normalizedOpacity(options.dimOpacity); - const affordances: InteractionAffordance[] = [ - { target: 'mark', cursor: 'activate', hover: 'cohort' }, - ]; return { id, eventSource: assistedElementTrigger(clickTrigger, 8), retainedStateGroup: 'focus', - affordances, + affordances: { mark: { cursor: 'activate', hover: 'cohort' } }, handle(event, context) { if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; - if (event.target?.visual.role === 'legend-item') return null; const target = event.target ? { ...event.target, elements: groupElements( event.target, context, options.groupBy, diff --git a/packages/flint-js/src/interactive/presets/click-highlight.ts b/packages/flint-js/src/interactive/presets/click-highlight.ts index d4f12f3b..9fc0a26e 100644 --- a/packages/flint-js/src/interactive/presets/click-highlight.ts +++ b/packages/flint-js/src/interactive/presets/click-highlight.ts @@ -1,34 +1,38 @@ import type { CanvasInteractionDef, ClickHighlightOptions, ClickHighlightTarget } from '../interactions'; -import type { InteractionAffordance } from '../affordances'; +import type { InteractionAffordance, InteractionAffordanceTarget } from '../affordances'; import { emphasisUpdate, isActivationAction, normalizedOpacity } from './utils'; import { assistedElementTrigger, clickTrigger } from '../triggers'; import { expandRangedDotTarget } from './ranged-dot-target'; -const DEFAULT_TARGETS: readonly ClickHighlightTarget[] = ['mark', 'legend', 'discreteAxis']; +export const CLICK_HIGHLIGHT_DEFAULT_TARGETS: readonly ClickHighlightTarget[] = ['mark', 'legend', 'discreteAxis']; + +/** The kind of hit each `targets` option names. */ +export const CLICK_HIGHLIGHT_AFFORDANCE_TARGET: Record = { + mark: 'mark', + legend: 'legend-item', + discreteAxis: 'axis-label', +}; + +const SIGNAL_OF: Record = { + mark: { cursor: 'activate', hover: 'target' }, + legend: { cursor: 'activate', hover: 'cohort' }, + discreteAxis: { cursor: 'activate', hover: 'cohort' }, +}; export function createClickHighlightInteraction(options: ClickHighlightOptions = {}): CanvasInteractionDef { const id = options.id ?? 'click-highlight'; const dimOpacity = normalizedOpacity(options.dimOpacity); - const targets = new Set(options.targets ?? DEFAULT_TARGETS); - const affordances: InteractionAffordance[] = []; - if (targets.has('mark')) affordances.push({ target: 'mark', cursor: 'activate', hover: 'target' }); - if (targets.has('legend')) affordances.push({ target: 'legend-item', cursor: 'activate', hover: 'cohort' }); - if (targets.has('discreteAxis')) affordances.push({ target: 'axis-label', cursor: 'activate', hover: 'cohort' }); + const targets = options.targets ?? CLICK_HIGHLIGHT_DEFAULT_TARGETS; return { id, eventSource: assistedElementTrigger(clickTrigger, 8), retainedStateGroup: 'focus', - claimsLegendActivation: targets.has('legend'), - claimsAxisActivation: targets.has('discreteAxis'), - affordances, + affordances: Object.fromEntries(targets.map((target) => [CLICK_HIGHLIGHT_AFFORDANCE_TARGET[target], SIGNAL_OF[target]])), handle(event, context) { if (!isActivationAction(event.action) || event.phase === 'start' || event.phase === 'cancel') return null; if (!event.target) return emphasisUpdate(id, event, null, dimOpacity, context); const isLegend = event.target.visual.role === 'legend-item'; const isAxis = event.target.visual.kind === 'axis'; - if (isLegend && !targets.has('legend')) return null; - if (isAxis && !targets.has('discreteAxis')) return null; - if (!isLegend && !isAxis && !targets.has('mark')) return null; const target = isLegend || isAxis ? event.target : expandRangedDotTarget(event.target, context); diff --git a/packages/flint-js/src/interactive/presets/context-activate.ts b/packages/flint-js/src/interactive/presets/context-activate.ts index ab7067b0..bf407faa 100644 --- a/packages/flint-js/src/interactive/presets/context-activate.ts +++ b/packages/flint-js/src/interactive/presets/context-activate.ts @@ -11,6 +11,6 @@ export function createContextActivateInteraction( return { id: options.id ?? 'context-activate', eventSource: assistedElementTrigger(contextTrigger, 8), - affordances: [{ target: 'mark', cursor: 'activate' }], + affordances: { mark: { cursor: 'activate' } }, }; } diff --git a/packages/flint-js/src/interactive/presets/drag-reorder.ts b/packages/flint-js/src/interactive/presets/drag-reorder.ts index a0e1acff..6e93c6d3 100644 --- a/packages/flint-js/src/interactive/presets/drag-reorder.ts +++ b/packages/flint-js/src/interactive/presets/drag-reorder.ts @@ -26,10 +26,10 @@ export function createDragReorderInteraction(options: DragReorderOptions = {}): return { id, eventSource: dragTrigger(), - affordances: [ - { target: 'mark', cursor: 'drag', hover: 'target' }, - { target: 'axis-label', cursor: 'drag', hover: 'target' }, - ], + affordances: { + mark: { cursor: 'drag', hover: 'target' }, + 'axis-label': { cursor: 'drag', hover: 'target' }, + }, handle(event, context) { if (event.action !== 'drag' || (event.phase !== 'preview' && event.phase !== 'commit') || !event.target || !event.dropTarget) return null; diff --git a/packages/flint-js/src/interactive/presets/hover-group-highlight.ts b/packages/flint-js/src/interactive/presets/hover-group-highlight.ts index 0dc1e8b2..429af4e3 100644 --- a/packages/flint-js/src/interactive/presets/hover-group-highlight.ts +++ b/packages/flint-js/src/interactive/presets/hover-group-highlight.ts @@ -1,5 +1,4 @@ import type { CanvasInteractionDef, HoverGroupFocusOptions } from '../interactions'; -import type { InteractionAffordance } from '../affordances'; import { assistedElementTrigger, hoverTrigger } from '../triggers'; import { expandElementsByFields } from './semantic-cohort'; import { emphasisUpdate, normalizedOpacity } from './utils'; @@ -12,17 +11,15 @@ export function createHoverGroupFocusInteraction(options: HoverGroupFocusEngineO const tolerance = options.tolerance === undefined || !Number.isFinite(options.tolerance) ? 8 : Math.max(0, options.tolerance); - const affordances: InteractionAffordance[] = [{ target: 'mark', hover: 'cohort' }]; return { id, eventSource: { ...assistedElementTrigger(hoverTrigger, 6), targetTolerance: tolerance, }, - affordances, + affordances: { mark: { hover: 'cohort' } }, handle(event, context) { if (!event.action.startsWith('hover-') || event.phase !== 'preview' || !event.target) return null; - if (event.target.visual.role === 'legend-item') return null; const target = { ...event.target, elements: expandElementsByFields(event.target.elements, context.available, options.groupBy), diff --git a/packages/flint-js/src/interactive/presets/index.ts b/packages/flint-js/src/interactive/presets/index.ts index 3834ec39..51337f6b 100644 --- a/packages/flint-js/src/interactive/presets/index.ts +++ b/packages/flint-js/src/interactive/presets/index.ts @@ -4,7 +4,7 @@ export { createAngularBrushInteraction } from './angular-brush'; export { createAxisHighlightInteraction } from './axis-highlight'; export { createClickAnnotateInteraction } from './click-annotate'; export { createClickGroupFocusInteraction } from './click-group-highlight'; -export { createClickHighlightInteraction } from './click-highlight'; +export { createClickHighlightInteraction, CLICK_HIGHLIGHT_AFFORDANCE_TARGET, CLICK_HIGHLIGHT_DEFAULT_TARGETS } from './click-highlight'; export { createContextActivateInteraction } from './context-activate'; export { createInspectInteraction } from './inspect'; export { createInspectIndexInteraction } from './inspect-index'; diff --git a/packages/flint-js/src/interactive/presets/inspect-index.ts b/packages/flint-js/src/interactive/presets/inspect-index.ts index 605c57c6..11739ab6 100644 --- a/packages/flint-js/src/interactive/presets/inspect-index.ts +++ b/packages/flint-js/src/interactive/presets/inspect-index.ts @@ -1,5 +1,5 @@ import type { CanvasInteractionDef, InspectIndexOptions } from '../interactions'; -import type { InteractionAffordance } from '../affordances'; +import type { InteractionAffordances } from '../affordances'; import { inspectIndexTrigger } from '../triggers'; /** Reads values at one independent-axis position across one or more series. */ @@ -10,9 +10,9 @@ export function createInspectIndexInteraction(options: InspectIndexOptions = {}) if (show !== 'all' && !options.seriesBy) { throw new Error('inspectIndex({ show: "single" | { series } }) requires seriesBy.'); } - const affordances: InteractionAffordance[] = show !== 'all' - ? [{ target: 'legend-item', cursor: 'activate', hover: 'cohort' }] - : [{ target: 'plot', cursor: 'inspect' }]; + const affordances: InteractionAffordances = show !== 'all' + ? { 'legend-item': { cursor: 'activate', hover: 'cohort' } } + : { plot: { cursor: 'inspect' } }; return { id, eventSource: inspectIndexTrigger( diff --git a/packages/flint-js/src/interactive/presets/inspect.ts b/packages/flint-js/src/interactive/presets/inspect.ts index 2f028f44..5af76f27 100644 --- a/packages/flint-js/src/interactive/presets/inspect.ts +++ b/packages/flint-js/src/interactive/presets/inspect.ts @@ -16,7 +16,7 @@ export function createInspectInteraction(options: InspectOptions = {}): CanvasIn eventSource: inspectTrigger( options.mode ?? 'xy', options.selector, options.tolerance, options.guide, options.cycle, ), - affordances: [{ target: 'plot', cursor: 'inspect' }], + affordances: { plot: { cursor: 'inspect' } }, handle(event, context) { if (!INSPECT_ACTIONS.has(event.action) || event.phase === 'cancel') return null; if (!event.target) return { diff --git a/packages/flint-js/src/interactive/presets/lasso-select.ts b/packages/flint-js/src/interactive/presets/lasso-select.ts index 838471d1..ae269c7d 100644 --- a/packages/flint-js/src/interactive/presets/lasso-select.ts +++ b/packages/flint-js/src/interactive/presets/lasso-select.ts @@ -8,7 +8,7 @@ export function createLassoSelectInteraction(options: LassoSelectOptions = {}): return { id, eventSource: lassoTrigger(options.match ?? 'intersect', options.guide), - affordances: [{ target: 'plot', cursor: 'region' }], + affordances: { plot: { cursor: 'region' } }, handle(event, context) { if (event.action !== 'select-lasso' || event.phase === 'start' || event.phase === 'cancel') return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); diff --git a/packages/flint-js/src/interactive/presets/legend-toggle.ts b/packages/flint-js/src/interactive/presets/legend-toggle.ts index 790c6679..adfb68be 100644 --- a/packages/flint-js/src/interactive/presets/legend-toggle.ts +++ b/packages/flint-js/src/interactive/presets/legend-toggle.ts @@ -50,11 +50,9 @@ function hidesFullLegendDomain( }))); } -/** Only legend activations toggle series, so these presets compose with mark-click presets. */ +/** The runtime sends this preset legend hits only; a toggle happens on the commit. */ function legendActivation(event: CanvasInteractionEvent): boolean { - return isActivationAction(event.action) - && event.phase === 'commit' - && event.target?.visual.role === 'legend-item'; + return isActivationAction(event.action) && event.phase === 'commit'; } /** Hides or restores the activated series, the way a legend key normally behaves. */ @@ -65,8 +63,7 @@ export function createLegendToggleInteraction(options: LegendToggleOptions = {}) return { id, eventSource: clickTrigger, - claimsLegendActivation: true, - affordances: [{ target: 'legend-item', cursor: 'activate', hover: 'cohort' }], + affordances: { 'legend-item': { cursor: 'activate', hover: 'cohort' } }, onReset() { hidden = []; }, handle(event, context) { if (!legendActivation(event)) return null; diff --git a/packages/flint-js/src/interactive/presets/linked-brush.ts b/packages/flint-js/src/interactive/presets/linked-brush.ts index 2e9bc73f..e289049b 100644 --- a/packages/flint-js/src/interactive/presets/linked-brush.ts +++ b/packages/flint-js/src/interactive/presets/linked-brush.ts @@ -12,7 +12,7 @@ export function createLinkedBrushInteraction(options: LinkedBrushOptions): Canva eventSource: lasso ? lassoTrigger(options.match ?? 'intersect', options.guide) : rectangleTrigger(options.match ?? 'intersect', options.guide), - affordances: [{ target: 'plot', cursor: 'region' }], + affordances: { plot: { cursor: 'region' } }, handle(event, context) { const expectedAction = lasso ? 'select-lasso' : 'select-region'; if (event.action !== expectedAction || event.phase === 'start' || event.phase === 'cancel') return null; diff --git a/packages/flint-js/src/interactive/presets/long-press.ts b/packages/flint-js/src/interactive/presets/long-press.ts index bf5bf91e..88f08dc8 100644 --- a/packages/flint-js/src/interactive/presets/long-press.ts +++ b/packages/flint-js/src/interactive/presets/long-press.ts @@ -13,7 +13,7 @@ export function createLongPressInteraction(options: LongPressOptions = {}): Canv return { id, eventSource: assistedElementTrigger(longPressTrigger(options.holdMs ?? 500), 12), - affordances: [{ target: 'mark', cursor: 'activate', hover: 'target' }], + affordances: { mark: { cursor: 'activate', hover: 'target' } }, handle(event, context) { if (!event.action.startsWith('long-press-') || (event.phase !== 'preview' && event.phase !== 'commit')) return null; @@ -31,7 +31,7 @@ export function createDoubleActivateInteraction( return { id, eventSource: assistedElementTrigger(doubleActivateTrigger, 8), - affordances: [{ target: 'mark', cursor: 'activate', hover: 'target' }], + affordances: { mark: { cursor: 'activate', hover: 'target' } }, handle(event, context) { if (!event.action.startsWith('double-activate-') || (event.phase !== 'preview' && event.phase !== 'commit')) return null; diff --git a/packages/flint-js/src/interactive/presets/navigate.ts b/packages/flint-js/src/interactive/presets/navigate.ts index acb12487..61e5bb88 100644 --- a/packages/flint-js/src/interactive/presets/navigate.ts +++ b/packages/flint-js/src/interactive/presets/navigate.ts @@ -52,7 +52,7 @@ export function createNavigateInteraction(options: NavigateOptions = {}): Canvas wheelSensitivity: options.wheelSensitivity ?? 0.002, reset: options.reset, }), - affordances: options.pan === false ? [] : [{ target: 'plot', cursor: 'navigate' }], + affordances: options.pan === false ? { plot: {} } : { plot: { cursor: 'navigate' } }, handle(event, context) { const viewport = event.geometry.plot; if (!context.resolveNavigation || viewport?.kind !== 'viewport' || !event.operation) return null; diff --git a/packages/flint-js/src/interactive/presets/select.ts b/packages/flint-js/src/interactive/presets/select.ts index c9a90aa3..d5578737 100644 --- a/packages/flint-js/src/interactive/presets/select.ts +++ b/packages/flint-js/src/interactive/presets/select.ts @@ -8,7 +8,7 @@ export function createSelectInteraction(options: SelectOptions = {}): CanvasInte return { id, eventSource: rectangleTrigger(options.match ?? 'intersect', options.guide), - affordances: [{ target: 'plot', cursor: 'region' }], + affordances: { plot: { cursor: 'region' } }, handle(event, context) { if (event.action !== 'select-region' || event.phase === 'start' || event.phase === 'cancel') return null; return emphasisUpdate(id, event, event.target, dimOpacity, context); diff --git a/packages/flint-js/src/interactive/spec/admission.ts b/packages/flint-js/src/interactive/spec/admission.ts index 3bd91c49..972095a0 100644 --- a/packages/flint-js/src/interactive/spec/admission.ts +++ b/packages/flint-js/src/interactive/spec/admission.ts @@ -1,14 +1,19 @@ import type { ChartWarning } from '../../core/types'; +import { + INTERACTION_CAPABILITY_DESCRIPTIONS, + INTERACTION_PRESET_REQUIREMENTS, + type InteractionCapability, +} from '../../core/interaction-spec'; import type { CanvasInteractionDef } from '../interactions'; +import type { InteractionAffordanceTarget } from '../affordances'; 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 chartType?: string; + /** The capabilities the assembler confirmed for this chart and its data. */ + readonly capabilities: readonly InteractionCapability[]; readonly navigationAxes?: readonly ('x' | 'y')[]; - readonly supportedRegionGestures?: readonly ('cartesian' | 'angular')[]; } export interface InteractionAdmission { @@ -29,16 +34,86 @@ export function navigationAxesFor( 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.'; +/** One gesture on one kind of hit: the unit two interactions can share. */ +export interface InteractionTrigger { + readonly id: string; + /** How a warning names it. */ + readonly description: string; + /** The affordance key behind a hit trigger; an interaction can give it up and keep the rest. */ + readonly key?: InteractionAffordanceTarget; +} + +const NAVIGATION: InteractionTrigger = { id: 'navigation', description: 'the navigation slot' }; +const REGION_DRAG: InteractionTrigger = { id: 'region-drag', description: 'the region drag slot' }; +const ELEMENT_DRAG: InteractionTrigger = { id: 'element-drag', description: 'the element drag slot' }; +const PLOT_DRAG: InteractionTrigger = { id: 'drag:plot', description: 'the plot drag' }; +const DOUBLE_CLICK: InteractionTrigger = { id: 'double-click', description: 'the double-click' }; +const LEGEND_CLICK: InteractionTrigger = { id: 'click:legend-item', description: 'legend clicks', key: 'legend-item' }; +const AXIS_CLICK: InteractionTrigger = { id: 'click:axis-label', description: 'axis label clicks', key: 'axis-label' }; + +/** The triggers an interaction takes for itself, read from its event source, affordances, state group, and reset list. */ +export function triggersOf(interaction: CanvasInteractionDef): readonly InteractionTrigger[] { + const { eventSource, affordances, reset, retainedStateGroup } = interaction; + const triggers: InteractionTrigger[] = []; + const drags = eventSource.gesture === 'drag'; + if (eventSource.type === 'navigation') { + triggers.push(NAVIGATION); + if (eventSource.pan) triggers.push(PLOT_DRAG); + } + if (eventSource.type === 'region' && drags) triggers.push(REGION_DRAG, PLOT_DRAG); + if (eventSource.type === 'element' && drags) triggers.push(ELEMENT_DRAG, PLOT_DRAG); + if (eventSource.gesture === 'double' || reset?.includes('double-click')) triggers.push(DOUBLE_CLICK); + if (eventSource.gesture === 'click') { + if ('legend-item' in affordances) triggers.push(LEGEND_CLICK); + if ('axis-label' in affordances) triggers.push(AXIS_CLICK); + if (retainedStateGroup && 'mark' in affordances) { + triggers.push({ + id: `${retainedStateGroup}:click:mark`, + description: `mark clicks with retained ${retainedStateGroup}`, + key: 'mark', + }); + } + } + return triggers; +} + +/** The first trigger two admitted interactions share, in list order. */ +function firstSharedTrigger( + interactions: readonly CanvasInteractionDef[], +): { trigger: InteractionTrigger; earlier: CanvasInteractionDef; later: CanvasInteractionDef } | undefined { + for (const [index, earlier] of interactions.entries()) { + for (const trigger of triggersOf(earlier)) { + const later = interactions.slice(index + 1) + .find((candidate) => triggersOf(candidate).some((other) => other.id === trigger.id)); + if (later) return { trigger, earlier, later }; + } + } + return undefined; +} + +/** A copy of `interaction` that no longer takes `trigger`, or undefined when it cannot give it up and keep the rest. */ +function without(interaction: CanvasInteractionDef, trigger: InteractionTrigger): CanvasInteractionDef | undefined { + if (!trigger.key || !interaction.withoutAffordances) return undefined; + const copy = interaction.withoutAffordances([trigger.key]); + if (!copy) return undefined; + return interaction.origin ? { ...copy, origin: interaction.origin } : copy; +} + +/** A preset needs what the core table says; a definition made by hand needs nothing. */ +export function interactionRequirements(interaction: CanvasInteractionDef): readonly InteractionCapability[] { + return interaction.preset ? INTERACTION_PRESET_REQUIREMENTS[interaction.preset] : []; +} + /** * Decide which interactions a compiled chart can honour. * * The answer depends on origin. A definition made in code throws, because a * developer sees the exception. An entry 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. + * should still render. When two entries share a trigger, the one that can give it up + * and keep the rest does so; otherwise the later entry yields whole. */ export function admitInteractions( plan: InteractionAdmissionPlan, @@ -54,91 +129,55 @@ export function admitInteractions( warnings.push({ severity: 'warning', code, message: `${message} ${DROPPED}` }); return false; }; - const hasElementSemantics = !!plan.resolve || plan.fields.length > 0 || plan.selectableMarks.length > 0; + const capabilities = new Set(plan.capabilities); + const chart = plan.chartType ?? 'this chart'; const available = plan.navigationAxes ?? []; - const angular = plan.supportedRegionGestures?.includes('angular') ?? false; - // Capability checks, one interaction at a time. + // Every capability the interaction needs must be present on this chart. let admitted = interactions.filter((interaction) => { - const source = interaction.eventSource; - if ((source.type === 'element' || source.type === 'region') && !hasElementSemantics) { + const missing = interactionRequirements(interaction).find((capability) => !capabilities.has(capability)); + if (missing) { return reject(interaction, 'unsupported_interaction', - `Interaction "${interaction.id}" requires chart element semantics.`); + `Interaction "${interaction.id}" requires ${INTERACTION_CAPABILITY_DESCRIPTIONS[missing]}; ${chart} has none.`); } + const source = interaction.eventSource; 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; in code the first 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. + // One trigger, one owner. When exactly one of the two can give the trigger up and keep + // the rest, it does; otherwise the later entry yields whole, and a spec entry always + // yields to a code definition. 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); - } - - // A double-click cannot both activate a mark and reset another interaction. Code definitions - // both fire; a spec entry yields, the later one first. - for (;;) { - const activate = admitted.find((interaction) => interaction.eventSource.gesture === 'double'); - const reset = admitted.find((interaction) => - interaction.eventSource.gesture !== 'double' && interaction.reset?.includes('double-click')); - if (!activate || !reset) break; - if (activate.origin !== 'spec' && reset.origin !== 'spec') break; - const later = admitted.indexOf(activate) > admitted.indexOf(reset) ? activate : reset; - const earlier = later === activate ? reset : activate; - const victim = later.origin === 'spec' ? later : earlier; - const kept = victim === activate ? reset : activate; - warnings.push({ - severity: 'warning', - code: 'conflicting_interactions', - message: `Interaction "${victim.id}" conflicts with "${kept.id}": a double-click cannot both activate a mark and reset another interaction. ${DROPPED}`, - }); - admitted = admitted.filter((interaction) => interaction !== victim); + const shared = firstSharedTrigger(admitted); + if (!shared) break; + const { trigger, earlier, later } = shared; + const narrowed = [earlier, later] + .map((interaction) => ({ interaction, copy: without(interaction, trigger) })) + .filter(({ copy }) => copy); + if (narrowed.length === 1) { + const { interaction, copy } = narrowed[0]; + const owner = interaction === earlier ? later : earlier; + warnings.push({ + severity: 'info', + code: 'conflicting_interactions', + message: `Interaction "${interaction.id}" yields ${trigger.description} to "${owner.id}".`, + }); + admitted = admitted.map((candidate) => (candidate === interaction ? copy! : candidate)); + continue; + } + const victim = later.origin === 'spec' || earlier.origin !== 'spec' ? later : earlier; + const kept = victim === later ? earlier : later; + reject(victim, 'conflicting_interactions', + `Interaction "${victim.id}" shares ${trigger.description} with "${kept.id}".`); + admitted = admitted.filter((candidate) => candidate !== victim); } return { admitted, warnings }; diff --git a/packages/flint-js/src/interactive/spec/registry.ts b/packages/flint-js/src/interactive/spec/registry.ts index eb70c4b7..331d1697 100644 --- a/packages/flint-js/src/interactive/spec/registry.ts +++ b/packages/flint-js/src/interactive/spec/registry.ts @@ -1,4 +1,4 @@ -import { INTERACTION_PRESET_TYPES, type InteractionPresetType } from '../../core/interaction-spec'; +import { INTERACTION_PRESET_REQUIREMENTS, INTERACTION_PRESET_TYPES, type InteractionCapability, type InteractionPresetType } from '../../core/interaction-spec'; import { axisHighlight, brushAngle, @@ -29,15 +29,7 @@ const ANY_RESET: readonly InteractionResetGesture[] = INTERACTION_RESET_GESTURES /** Presets that retain nothing have no reset to speak of. */ const NEVER: readonly InteractionResetGesture[] = []; -/** 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'; +export type { InteractionCapability } from '../../core/interaction-spec'; /** The gesture a preset captures; `drag` presets conflict with `navigate` when pan is on. */ export type InteractionGestureFamily = @@ -54,7 +46,6 @@ export interface InteractionPresetDefinition; + /** Behaviour the case asks for; the site mounts the case interactively when present. */ + interactionSpec?: InteractionSpec; } /** Date format definition for date stress tests */ diff --git a/packages/flint-js/src/validate/index.ts b/packages/flint-js/src/validate/index.ts index f5c14910..def98860 100644 --- a/packages/flint-js/src/validate/index.ts +++ b/packages/flint-js/src/validate/index.ts @@ -26,6 +26,8 @@ import type { import { isRegistered } from '../core/type-registry'; import { toTypeString } from '../core/field-semantics'; import { assembleVegaLite } from '../vegalite/assemble'; +import { resolveInteractionSpec } from '../interactive/spec/resolve'; +import { admitInteractions } from '../interactive/spec/admission'; import { vlGetTemplateDef } from '../vegalite/templates'; import { assembleECharts } from '../echarts/assemble'; import { ecGetTemplateDef } from '../echarts/templates'; @@ -301,12 +303,42 @@ export function assembleForBackend( return { spec, warnings, width, height }; } +/** + * The warnings `interaction_spec` would produce at mount: a malformed spec is an + * error, an entry the assembled chart cannot honour is a warning, and a backend + * that runs no interactions reports the spec as ignored. + */ +export function validateInteractionSpec( + input: ChartAssemblyInput, + backend: ValidationBackend, + assembled: unknown, +): ChartWarning[] { + if (!input.interaction_spec) return []; + if (backend !== 'vegalite') { + return [{ + severity: 'info', + code: 'interactions_ignored', + message: `interaction_spec is ignored: backend "${backend}" does not run interactions.`, + }]; + } + try { + const resolved = resolveInteractionSpec(input.interaction_spec); + const { _interactionSemantics: plan } = assembled as { _interactionSemantics: Parameters[0] }; + return [...admitInteractions(plan, resolved.interactions).warnings]; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return [{ severity: 'error', code: 'invalid_interaction_spec', message }]; + } +} + /** * Validate a {@link ChartAssemblyInput} for a backend: report warnings/errors, * applicability, and the computed layout size. Never throws — validation and * assembly failures are surfaced as an error entry. Unregistered * `semantic_types` labels are included as warnings (see - * {@link validateSemanticTypes}) and do not affect `valid`. + * {@link validateSemanticTypes}) and do not affect `valid`. An + * `interaction_spec` is checked the way the mount checks it (see + * {@link validateInteractionSpec}). */ export function validateChart( input: ChartAssemblyInput, @@ -318,8 +350,8 @@ export function validateChart( : '(unknown)'; const semanticTypeWarnings = validateSemanticTypes(input?.semantic_types); try { - const { warnings, width, height } = assembleForBackend(backend, input, options); - const all = [...warnings, ...semanticTypeWarnings]; + const { spec, warnings, width, height } = assembleForBackend(backend, input, options); + const all = [...warnings, ...semanticTypeWarnings, ...validateInteractionSpec(input, backend, spec)]; const errors = all.filter((w) => w.severity === 'error'); return { backend, diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index 9b378d26..61e337ae 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -53,6 +53,7 @@ import { InstantiateContext, } from '../core/types'; import type { ChartWarning, ChartOption, OptionEvalContext } from '../core/types'; +import { declaredInteractionCapabilities, type InteractionCapability } from '../core/interaction-spec'; import { applyEncodingOverrides } from '../core/encoding-overrides'; import { applyAggregation } from '../core/aggregate'; import { planBandDodge, resolveDodge } from '../core/band-dodge'; @@ -884,91 +885,104 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { const unfaceted = !resolvedEncodings.column?.field && !resolvedEncodings.row?.field; // A projected chart navigates its projection extent, so both axes move // together and no continuous x/y encoding is required. - const geoNavigation = !!chartTemplate.navigation?.geo && unfaceted; + const support = chartTemplate.interactionSupport; + const geoNavigation = !!support?.navigation?.geo && unfaceted; const navigationAxes: ('x' | 'y')[] = geoNavigation ? ['x', 'y'] - : chartTemplate.navigation && unfaceted - ? (chartTemplate.navigation.axes ?? ['x', 'y']).filter((axis) => { + : support?.navigation && unfaceted + ? (support.navigation.axes ?? ['x', 'y']).filter((axis) => { const encoding = resolvedEncodings[axis]; return !!encoding?.field && (encoding.type === 'quantitative' || encoding.type === 'temporal'); }) : []; - if (chartTemplate.semanticInteractions || navigationAxes.length > 0) { - const templateSemantics = chartTemplate.semanticInteractions?.({ resolvedEncodings }) ?? { - fields: [], - provenanceFields: undefined, - temporalProvenanceFields: undefined, - rangeProvenance: undefined, - selectableMarks: [], - reorderAxis: undefined, - reorderAxes: undefined, - }; - const semanticEncodings = Object.values(resolvedEncodings) - .filter((encoding: any) => typeof encoding?.field === 'string') as any[]; - const hasAggregate = semanticEncodings.some((encoding) => encoding.aggregate); - const provenanceFields = [...new Set(semanticEncodings - .filter((encoding) => !hasAggregate || !encoding.aggregate) - .map((encoding) => encoding.field as string))]; - const temporalProvenanceFields = [...new Set(semanticEncodings - .filter((encoding) => encoding.type === 'temporal') - .map((encoding) => encoding.field as string))]; - const allowedReorderAxes: readonly ('x' | 'y')[] = chartTemplate.reorder === false - ? [] - : chartTemplate.reorder?.axes ?? ['x', 'y']; - const defaultReorderAxes = allowedReorderAxes.length > 0 - && !resolvedEncodings.column?.field && !resolvedEncodings.row?.field - ? (['x', 'y'] as const).flatMap((axis) => { - const encoding = resolvedEncodings[axis]; - return allowedReorderAxes.includes(axis) - && encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal') - ? [{ - axis, - field: encoding.field, - ...(chartTemplate.reorder && chartTemplate.reorder.includeConnectiveMarks - ? { includeConnectiveMarks: true } - : {}), - ...(chartTemplate.reorder && chartTemplate.reorder.markTypes - ? { markTypes: chartTemplate.reorder.markTypes } - : {}), - }] - : []; - }) - : []; - const explicitReorderAxes = templateSemantics.reorderAxes - ?? (templateSemantics.reorderAxis ? [templateSemantics.reorderAxis] : []); - const legendFields = 'legendFields' in templateSemantics ? templateSemantics.legendFields : undefined; - const rangeLegendChannels = Object.keys(legendFields ?? {}) - .filter((channel) => { - const type = resolvedEncodings[channel]?.type; - return type === 'quantitative' || type === 'temporal'; - }); - const reorderAxes = [...explicitReorderAxes, ...defaultReorderAxes] - .filter((candidate, index, candidates) => candidates.findIndex( - (axis) => axis.axis === candidate.axis && axis.field === candidate.field, - ) === index); - result._interactionSemantics = { - ...templateSemantics, - axisFields: Object.fromEntries((['x', 'y'] as const).flatMap((axis) => { - const encoding = resolvedEncodings[axis]; - return encoding?.field - ? [[axis, { field: encoding.field, type: encoding.type ?? 'nominal' }]] - : []; - })), - sourceRecords: values.map((record) => ({ ...record })), - provenanceFields: templateSemantics.provenanceFields ?? provenanceFields, - temporalProvenanceFields: templateSemantics.temporalProvenanceFields ?? temporalProvenanceFields, - rangeLegendChannels, - navigationAxes, - geoNavigation, - ...(vgObj._geoLevels ? { geoLevels: vgObj._geoLevels } : {}), - ...(vgObj._geoPreProjection ? { geoPreProjection: vgObj._geoPreProjection } : {}), - reorderAxis: reorderAxes[0], - reorderAxes, - selectionBoundary: design.interaction.selectionBoundary, - continuousColorFocus: design.interaction.continuousColorFocus, - neutralizeContinuousColor: chartTemplate.chart === 'Map' || chartTemplate.chart === 'Choropleth', - }; - } + const templateSemantics = chartTemplate.semanticInteractions?.({ resolvedEncodings }) ?? { + fields: [], + provenanceFields: undefined, + temporalProvenanceFields: undefined, + rangeProvenance: undefined, + selectableMarks: [], + reorderAxis: undefined, + reorderAxes: undefined, + }; + const semanticEncodings = Object.values(resolvedEncodings) + .filter((encoding: any) => typeof encoding?.field === 'string') as any[]; + const hasAggregate = semanticEncodings.some((encoding) => encoding.aggregate); + const provenanceFields = [...new Set(semanticEncodings + .filter((encoding) => !hasAggregate || !encoding.aggregate) + .map((encoding) => encoding.field as string))]; + const temporalProvenanceFields = [...new Set(semanticEncodings + .filter((encoding) => encoding.type === 'temporal') + .map((encoding) => encoding.field as string))]; + const reorderSupport = support?.reorder; + const allowedReorderAxes: readonly ('x' | 'y')[] = reorderSupport + ? reorderSupport.axes ?? ['x', 'y'] + : []; + const defaultReorderAxes = allowedReorderAxes.length > 0 + && !resolvedEncodings.column?.field && !resolvedEncodings.row?.field + ? (['x', 'y'] as const).flatMap((axis) => { + const encoding = resolvedEncodings[axis]; + return allowedReorderAxes.includes(axis) + && encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal') + ? [{ + axis, + field: encoding.field, + ...(reorderSupport?.includeConnectiveMarks ? { includeConnectiveMarks: true } : {}), + ...(reorderSupport?.markTypes ? { markTypes: reorderSupport.markTypes } : {}), + }] + : []; + }) + : []; + const explicitReorderAxes = templateSemantics.reorderAxes + ?? (templateSemantics.reorderAxis ? [templateSemantics.reorderAxis] : []); + const legendFields = 'legendFields' in templateSemantics ? templateSemantics.legendFields : undefined; + const rangeLegendChannels = Object.keys(legendFields ?? {}) + .filter((channel) => { + const type = resolvedEncodings[channel]?.type; + return type === 'quantitative' || type === 'temporal'; + }); + const reorderAxes = [...explicitReorderAxes, ...defaultReorderAxes] + .filter((candidate, index, candidates) => candidates.findIndex( + (axis) => axis.axis === candidate.axis && axis.field === candidate.field, + ) === index); + const discreteLegend = Object.keys(legendFields ?? {}) + .some((channel) => !rangeLegendChannels.includes(channel)); + const discreteAxis = (['x', 'y'] as const).some((axis) => { + const encoding = resolvedEncodings[axis]; + return !!encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal'); + }); + const confirmed: Partial> = { + navigation: navigationAxes.length > 0, + reorder: reorderAxes.length > 0, + legend: discreteLegend, + 'discrete-axis': discreteAxis, + index: !!resolvedEncodings.x?.field, + }; + const capabilities = declaredInteractionCapabilities(support) + .filter((capability) => confirmed[capability] ?? true); + result._interactionSemantics = { + ...templateSemantics, + chartType: chartTemplate.chart, + capabilities, + axisFields: Object.fromEntries((['x', 'y'] as const).flatMap((axis) => { + const encoding = resolvedEncodings[axis]; + return encoding?.field + ? [[axis, { field: encoding.field, type: encoding.type ?? 'nominal' }]] + : []; + })), + sourceRecords: values.map((record) => ({ ...record })), + provenanceFields: templateSemantics.provenanceFields ?? provenanceFields, + temporalProvenanceFields: templateSemantics.temporalProvenanceFields ?? temporalProvenanceFields, + rangeLegendChannels, + navigationAxes, + geoNavigation, + ...(vgObj._geoLevels ? { geoLevels: vgObj._geoLevels } : {}), + ...(vgObj._geoPreProjection ? { geoPreProjection: vgObj._geoPreProjection } : {}), + reorderAxis: reorderAxes[0], + reorderAxes, + selectionBoundary: design.interaction.selectionBoundary, + continuousColorFocus: design.interaction.continuousColorFocus, + neutralizeContinuousColor: chartTemplate.chart === 'Map' || chartTemplate.chart === 'Choropleth', + }; result._width = layoutResult.subplotWidth; result._height = layoutResult.subplotHeight; // Annotated option catalog: every configurable property this template diff --git a/packages/flint-js/src/vegalite/interactions/compile.ts b/packages/flint-js/src/vegalite/interactions/compile.ts index 6540c3f2..d3dff015 100644 --- a/packages/flint-js/src/vegalite/interactions/compile.ts +++ b/packages/flint-js/src/vegalite/interactions/compile.ts @@ -1,4 +1,5 @@ import type { ChartInteractionResolver } from '../../core/interaction-semantics'; +import type { InteractionCapability } from '../../core/interaction-spec'; import { isCanvasInteraction, type ChartUpdatePresenter, @@ -7,6 +8,7 @@ import { } from '../../interactive/interactions'; import { toCanvasInteractionEvent } from '../../interactive/canvas-interaction'; import { admitInteractions, navigationAxesFor } from '../../interactive/spec/admission'; +import { affordsTarget } from '../../interactive/affordances'; import { DEFAULT_DIM_OPACITY } from '../../interactive/presets/utils'; import { INTERACTION_PROVENANCE, type InteractionProvenance } from '../interaction-provenance'; import type { @@ -47,6 +49,8 @@ const LEGEND_ENTRY_MARK = '__flint_legend_entry'; const SUPPORTED_SPEC_MARKS = new Set(['arc', 'area', 'bar', 'boxplot', 'circle', 'geoshape', 'line', 'point', 'rect', 'rule', 'tick']); interface TemplateInteractionSemantics { + chartType?: string; + capabilities: readonly InteractionCapability[]; fields: string[]; sourceRecords?: readonly Record[]; provenanceFields?: readonly string[]; @@ -60,7 +64,6 @@ interface TemplateInteractionSemantics { rangeLegendChannels?: readonly string[]; selectableMarks: string[]; annotationMarkType?: string; - supportedRegionGestures?: ('cartesian' | 'angular')[]; navigationAxes?: ('x' | 'y')[]; geoNavigation?: boolean; geoLevels?: GeoLevelConfig; @@ -459,7 +462,7 @@ export function addVegaLiteInteractions( : false; if (needsSemanticPresentation && !instrumented) return null; if (instrumented) addLocalKeyTransforms(spec, fields, selectableMarks); - if (instrumented && admitted.some((interaction) => interaction.claimsLegendActivation)) { + if (instrumented && admitted.some((interaction) => affordsTarget(interaction, 'legend-item'))) { pinLegendDomains(spec, templateSemantics.legendFields); } stripInteractionProvenance(spec); @@ -492,7 +495,7 @@ export function addVegaLiteInteractions( geoNavigation: templateSemantics.geoNavigation ?? false, geoLevels: templateSemantics.geoLevels, geoPreProjection: templateSemantics.geoPreProjection, - angularXBrush: templateSemantics.supportedRegionGestures?.includes('angular') ?? false, + angularXBrush: templateSemantics.capabilities?.includes('angular-region') ?? false, reorderAxis: hasElementDrag && declaredReorderAxes[0] ? { ...declaredReorderAxes[0], scale: '', signal: '' } : undefined, diff --git a/packages/flint-js/src/vegalite/interactions/runtime.ts b/packages/flint-js/src/vegalite/interactions/runtime.ts index 93c3cde7..2079fe28 100644 --- a/packages/flint-js/src/vegalite/interactions/runtime.ts +++ b/packages/flint-js/src/vegalite/interactions/runtime.ts @@ -25,6 +25,7 @@ import type { import { isCanvasInteraction } from '../../interactive/interactions'; import { affordanceCursor, + affordsTarget, resolveInteractionAffordance, type InteractionAffordanceTarget, } from '../../interactive/affordances'; @@ -354,7 +355,7 @@ export function interactionsForHoverPresentation( ...clickInteractions, ...elementDragInteractions, ...inspectInteractions, - ].filter((interaction, index, candidates) => interaction.affordances?.some((affordance) => affordance.hover) + ].filter((interaction, index, candidates) => Object.values(interaction.affordances).some((affordance) => affordance.hover) && candidates.findIndex((candidate) => candidate.id === interaction.id) === index); } @@ -558,15 +559,15 @@ export function mountVegaInteractions( const hoverInteractions = resolve ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'hover') : []; - const axisClickInteractions = clickInteractions.filter((interaction) => interaction.claimsAxisActivation); - const markClickInteractions = clickInteractions.filter((interaction) => - resolveInteractionAffordance([interaction], 'mark') - || resolveInteractionAffordance([interaction], 'legend-item')); - const axisHoverInteractions = hoverInteractions.filter((interaction) => interaction.claimsAxisActivation); - const markHoverInteractions = hoverInteractions.filter((interaction) => !interaction.claimsAxisActivation); + // One list per gesture and kind of hit, each defined by the affordance it needs. + const axisClickInteractions = clickInteractions.filter((interaction) => affordsTarget(interaction, 'axis-label')); + const markClickInteractions = clickInteractions.filter((interaction) => affordsTarget(interaction, 'mark')); + const legendClickInteractions = clickInteractions.filter((interaction) => affordsTarget(interaction, 'legend-item')); + const axisHoverInteractions = hoverInteractions.filter((interaction) => affordsTarget(interaction, 'axis-label')); + const markHoverInteractions = hoverInteractions.filter((interaction) => affordsTarget(interaction, 'mark')); + const legendHoverInteractions = hoverInteractions.filter((interaction) => affordsTarget(interaction, 'legend-item')); const axisHoverPresentationInteractions = [...axisClickInteractions, ...axisHoverInteractions] - .filter((interaction) => interaction.affordances?.some((affordance) => - affordance.target === 'axis-label' && affordance.hover)); + .filter((interaction) => interaction.affordances['axis-label']?.hover); const contextInteractions = resolve ? canvasInteractions.filter((interaction) => interaction.eventSource.gesture === 'context') : []; @@ -584,7 +585,7 @@ export function mountVegaInteractions( && interaction.eventSource.gesture === 'drag') : []; const hoverPresentationInteractions = interactionsForHoverPresentation( - [...markClickInteractions, ...longPressInteractions, ...doubleInteractions], + [...markClickInteractions, ...legendClickInteractions, ...longPressInteractions, ...doubleInteractions], markHoverInteractions, elementDragInteractions, inspectInteractions, @@ -1041,6 +1042,8 @@ export function mountVegaInteractions( } for (const target of op.targets) { if ('select' in target) continue; + // An axis element styles its label, and its marks below, through the + // render keys the axis resolver attached to it. if (target.visual.kind === 'axis') { for (const element of target.elements) { axisStyles.push({ @@ -1048,7 +1051,6 @@ export function mountVegaInteractions( style: op.value, }); } - continue; } for (const element of target.elements) { for (const key of semanticElementRenderKeys(element)) { @@ -1266,7 +1268,7 @@ export function mountVegaInteractions( for (const sibling of evictRetainedStateSiblings( interaction, canvasInteractions, retainedUpdates, previewUpdates, )) { - if (sibling.claimsLegendActivation) selectedLegend = null; + if (affordsTarget(sibling, 'legend-item')) selectedLegend = null; } } await storeUpdate(update, preview ? previewUpdates : retainedUpdates, legendSelection, options, false); @@ -1606,12 +1608,10 @@ export function mountVegaInteractions( ); const legend = normalized.legend; if (legend) { - const legendHoverInteractions = hoverPresentationForTarget('legend-item'); - if (legendHoverInteractions.length === 0) return clearHover(); + if (hoverPresentationForTarget('legend-item').length === 0) return clearHover(); const resolved = legendSemanticTarget(legend); hoverActive = true; - for (const interaction of markHoverInteractions.filter((candidate) => - legendHoverInteractions.includes(candidate))) { + for (const interaction of legendHoverInteractions) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'preview', target: resolved, point, modifiers: normalized.event.modifiers, @@ -1697,9 +1697,7 @@ export function mountVegaInteractions( resolveTarget('click', 'legend-item', [], legend), ) : resolveTarget('click', normalized.role, normalized.event.hits); - for (const interaction of markClickInteractions) { - const affordanceTarget = legend ? 'legend-item' : 'mark'; - if (!resolveInteractionAffordance([interaction], affordanceTarget)) continue; + for (const interaction of legend ? legendClickInteractions : markClickInteractions) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'commit', target, point, modifiers: normalized.event.modifiers, @@ -1732,7 +1730,7 @@ export function mountVegaInteractions( const { legend } = normalized; const target = legend ? legendSemanticTarget(legend) : resolveTarget('click', normalized.role, normalized.event.hits); - for (const interaction of contextInteractions) { + for (const interaction of contextInteractions.filter((candidate) => affordsTarget(candidate, legend ? 'legend-item' : 'mark'))) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'commit', target, point, modifiers: normalized.event.modifiers, @@ -2021,7 +2019,7 @@ export function mountVegaInteractions( consumeDismissClick = true; suppressClick = true; window.setTimeout(() => { suppressClick = false; }, 0); - for (const interaction of longPressInteractions) { + for (const interaction of longPressInteractions.filter((candidate) => affordsTarget(candidate, acquired.legend ? 'legend-item' : 'mark'))) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'commit', target: acquired.target, point: acquired.point, modifiers: acquired.modifiers, @@ -2041,7 +2039,7 @@ export function mountVegaInteractions( event.preventDefault(); cancelPendingDismiss(); const acquired = pointerTarget(event, doubleInteractions); - for (const interaction of doubleInteractions) { + for (const interaction of doubleInteractions.filter((candidate) => affordsTarget(candidate, acquired.legend ? 'legend-item' : 'mark'))) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'commit', target: acquired.target, point: acquired.point, modifiers: acquired.modifiers, @@ -2073,7 +2071,7 @@ export function mountVegaInteractions( const previousTouchAction = container.style.touchAction; if (longPressInteractions.length > 0) container.style.touchAction = 'none'; const suppressTextSelection = doubleInteractions.length > 0 - || canvasInteractions.some((interaction) => interaction.claimsLegendActivation); + || canvasInteractions.some((interaction) => affordsTarget(interaction, 'legend-item')); if (suppressTextSelection) container.style.userSelect = 'none'; const localPoint = (event: PointerEvent): { x: number; y: number } => { return clientToPlotPoint({ x: event.clientX, y: event.clientY }, coordinateSpace()); @@ -2087,7 +2085,7 @@ export function mountVegaInteractions( }; }; const cursorInteractions = canvasInteractions.filter((interaction) => - interaction.affordances?.some((affordance) => affordance.cursor)); + Object.values(interaction.affordances).some((affordance) => affordance.cursor)); const setAffordanceCursor = ( target: InteractionAffordanceTarget, reorderEligible: boolean, @@ -2442,6 +2440,7 @@ export function mountVegaInteractions( const keyboardInteraction: CanvasInteractionDef = { id: 'keyboard-targeting', eventSource: keyboardTrigger, + affordances: { mark: {} }, }; const moveKeyboardTarget = (direction: SpatialDirection): void => { const items = keyboardTargets(); @@ -2477,7 +2476,7 @@ export function mountVegaInteractions( .find((candidate) => renderHit(candidate)?.datum[INTERACTION_KEY] === activeKeyboardKey); const active = item ? keyboardFocus(item) : undefined; if (!active) return; - for (const interaction of clickInteractions) { + for (const interaction of markClickInteractions) { void dispatch(interaction, { type: 'semantic', source: 'element', phase: 'commit', target: active.target, point: active.point, diff --git a/packages/flint-js/src/vegalite/interactive.ts b/packages/flint-js/src/vegalite/interactive.ts index 52f24be0..bc0ced5e 100644 --- a/packages/flint-js/src/vegalite/interactive.ts +++ b/packages/flint-js/src/vegalite/interactive.ts @@ -15,6 +15,23 @@ import { withoutSemanticInteractionField, } from './interactions/compile'; import { mountVegaInteractions } from './interactions/runtime'; + +/** + * The runtime mounts what admission kept, in the author's order. Admission may replace a + * definition with a copy that affords less, so a canvas definition is matched by id, not + * by identity; external definitions pass through untouched. + */ +export function mountedInteractionList( + interactions: readonly InteractionDef[], + admitted: readonly InteractionDef[], +): InteractionDef[] { + const byId = new Map(admitted.map((interaction) => [interaction.id, interaction])); + return interactions.flatMap((interaction) => { + if (!isCanvasInteraction(interaction)) return [interaction]; + const kept = byId.get(interaction.id); + return kept ? [kept] : []; + }); +} import { INTERACTION_STORES } from './interactions/stores'; import { compile } from 'vega-lite'; import { Error as VegaError, parse, View } from 'vega'; @@ -85,8 +102,8 @@ export function createVegaInteractiveRenderer( vegaSpec, interactionPlan.axisFields, interactionPlan.reorderAxes, - (interactionPlan.interactions ?? canvasInteractions).some((interaction) => interaction.affordances?.some((affordance) => - affordance.target === 'axis-label' && affordance.hover)) + (interactionPlan.interactions ?? canvasInteractions).some((interaction) => + interaction.affordances['axis-label']?.hover) ? interactionPlan.selectionBoundary?.color ?? '#20262c' : undefined, ); @@ -138,10 +155,7 @@ 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 mountedInteractions = mountedInteractionList(interactions, interactionPlan?.interactions ?? canvasInteractions); const interactionController = interactionPlan ? mountVegaInteractions( view, diff --git a/packages/flint-js/src/vegalite/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts index 50718017..bf6bd0d4 100644 --- a/packages/flint-js/src/vegalite/templates/area.ts +++ b/packages/flint-js/src/vegalite/templates/area.ts @@ -133,7 +133,15 @@ export const areaChartDef: ChartTemplateDef = { chart: "Area Chart", template: { mark: "area", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'area', geometryKinds: ['area', 'line', 'point'], semanticInteractions: ({ resolvedEncodings }) => { @@ -216,7 +224,15 @@ export const streamgraphDef: ChartTemplateDef = { chart: "Streamgraph", template: { mark: "area", encoding: {} }, channels: ["x", "y", "color", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'area', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts index c797c7b7..d064bc9b 100644 --- a/packages/flint-js/src/vegalite/templates/bar-table.ts +++ b/packages/flint-js/src/vegalite/templates/bar-table.ts @@ -46,6 +46,13 @@ export const barTableDef: ChartTemplateDef = { config: { view: { stroke: null }, axis: { grid: false, domain: false, ticks: false } }, }, channels: ["y", "x", "color", "column", "row"], + interactionSupport: { + elements: true, + region: ['cartesian'], + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['y']); diff --git a/packages/flint-js/src/vegalite/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts index 3c91ce63..c18188f1 100644 --- a/packages/flint-js/src/vegalite/templates/bar.ts +++ b/packages/flint-js/src/vegalite/templates/bar.ts @@ -171,7 +171,14 @@ export const barChartDef: ChartTemplateDef = { chart: "Bar Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const fields = ['x', 'y', 'color'] @@ -259,6 +266,13 @@ export const pyramidChartDef: ChartTemplateDef = { config: { view: { stroke: null }, axis: { grid: false } }, }, channels: ["x", "y", "color"], + interactionSupport: { + elements: true, + region: ['cartesian'], + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const fields = ['x', 'y', 'color'] @@ -398,7 +412,14 @@ export const groupedBarChartDef: ChartTemplateDef = { chart: "Grouped Bar Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "group", "color", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const fields = ['x', 'y', 'color'] @@ -523,7 +544,14 @@ export const stackedBarChartDef: ChartTemplateDef = { chart: "Stacked Bar Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const fields = ['x', 'y', 'color'] @@ -609,7 +637,14 @@ export const histogramDef: ChartTemplateDef = { }, }, channels: ["x", "color", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const sourceField = resolvedEncodings.x?.field; @@ -684,7 +719,14 @@ export const heatmapDef: ChartTemplateDef = { chart: "Heatmap", template: { mark: "rect", encoding: {} }, channels: ["x", "y", "color", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'color', semanticInteractions: ({ resolvedEncodings }) => { const fields = ['x', 'y', 'color'] diff --git a/packages/flint-js/src/vegalite/templates/bullet.ts b/packages/flint-js/src/vegalite/templates/bullet.ts index b17089ab..5e9c0c39 100644 --- a/packages/flint-js/src/vegalite/templates/bullet.ts +++ b/packages/flint-js/src/vegalite/templates/bullet.ts @@ -51,6 +51,13 @@ export const bulletChartDef: ChartTemplateDef = { layer: [], }, channels: ["y", "x", "goal", "color", "column", "row"], + interactionSupport: { + elements: true, + region: ['cartesian'], + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = resolvedEncodings.y?.field; diff --git a/packages/flint-js/src/vegalite/templates/bump.ts b/packages/flint-js/src/vegalite/templates/bump.ts index 0f975ee0..f7cdbc70 100644 --- a/packages/flint-js/src/vegalite/templates/bump.ts +++ b/packages/flint-js/src/vegalite/templates/bump.ts @@ -25,7 +25,15 @@ export const bumpChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "detail", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color', 'detail']); diff --git a/packages/flint-js/src/vegalite/templates/calendar.ts b/packages/flint-js/src/vegalite/templates/calendar.ts index 5fb851f8..5ef4b12a 100644 --- a/packages/flint-js/src/vegalite/templates/calendar.ts +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -105,6 +105,13 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { chart: 'Calendar Heatmap', template: { mark: { type: 'rect', cornerRadius: 2 }, encoding: {} }, channels: ['x', 'color'], + interactionSupport: { + elements: true, + region: ['cartesian'], + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'color', semanticInteractions: ({ resolvedEncodings }) => { const valueField = resolvedEncodings.color?.field ?? COUNT_FIELD; diff --git a/packages/flint-js/src/vegalite/templates/candlestick.ts b/packages/flint-js/src/vegalite/templates/candlestick.ts index f865259a..1323cbf1 100644 --- a/packages/flint-js/src/vegalite/templates/candlestick.ts +++ b/packages/flint-js/src/vegalite/templates/candlestick.ts @@ -16,7 +16,14 @@ export const candlestickChartDef: ChartTemplateDef = { ], }, channels: ["x", "open", "high", "low", "close", "column", "row"], - navigation: { axes: ['x'] }, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: { axes: ['x'] }, + reorder: {}, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = resolvedEncodings.x?.field; diff --git a/packages/flint-js/src/vegalite/templates/connected-scatter.ts b/packages/flint-js/src/vegalite/templates/connected-scatter.ts index f9068998..2d6bf908 100644 --- a/packages/flint-js/src/vegalite/templates/connected-scatter.ts +++ b/packages/flint-js/src/vegalite/templates/connected-scatter.ts @@ -71,7 +71,14 @@ export const connectedScatterDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "order", "color", "detail", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color', 'detail']); diff --git a/packages/flint-js/src/vegalite/templates/density.ts b/packages/flint-js/src/vegalite/templates/density.ts index 59b61e04..0d452e6b 100644 --- a/packages/flint-js/src/vegalite/templates/density.ts +++ b/packages/flint-js/src/vegalite/templates/density.ts @@ -72,7 +72,15 @@ export const densityPlotDef: ChartTemplateDef = { }, }, channels: ["x", "color", "column", "row"], - navigation: { axes: ['x'] }, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: { axes: ['x'] }, + reorder: {}, + legend: true, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'area', semanticInteractions: ({ resolvedEncodings }) => { const groupFields = ['color', 'column', 'row'] diff --git a/packages/flint-js/src/vegalite/templates/ecdf.ts b/packages/flint-js/src/vegalite/templates/ecdf.ts index d724acc8..b3ed6a3c 100644 --- a/packages/flint-js/src/vegalite/templates/ecdf.ts +++ b/packages/flint-js/src/vegalite/templates/ecdf.ts @@ -66,7 +66,15 @@ export const ecdfPlotDef: ChartTemplateDef = { encoding: {}, }, channels: ['x', 'color', 'detail', 'column', 'row'], - navigation: { axes: ['x'] }, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: { axes: ['x'] }, + reorder: {}, + legend: true, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const valueField = resolvedEncodings.x?.field; diff --git a/packages/flint-js/src/vegalite/templates/gantt.ts b/packages/flint-js/src/vegalite/templates/gantt.ts index 01cdbcd8..d0055fb4 100644 --- a/packages/flint-js/src/vegalite/templates/gantt.ts +++ b/packages/flint-js/src/vegalite/templates/gantt.ts @@ -39,7 +39,14 @@ export const ganttChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["y", "x", "x2", "color", "detail", "column", "row"], - navigation: { axes: ['x'] }, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: { axes: ['x'] }, + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['y']); diff --git a/packages/flint-js/src/vegalite/templates/jitter.ts b/packages/flint-js/src/vegalite/templates/jitter.ts index e5bea01b..3f709097 100644 --- a/packages/flint-js/src/vegalite/templates/jitter.ts +++ b/packages/flint-js/src/vegalite/templates/jitter.ts @@ -20,7 +20,14 @@ export const stripPlotDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "size", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); diff --git a/packages/flint-js/src/vegalite/templates/kpi-card.ts b/packages/flint-js/src/vegalite/templates/kpi-card.ts index 61accc7e..2cc2c91d 100644 --- a/packages/flint-js/src/vegalite/templates/kpi-card.ts +++ b/packages/flint-js/src/vegalite/templates/kpi-card.ts @@ -62,6 +62,9 @@ export const kpiCardDef: ChartTemplateDef = { chart: "KPI Card", template: { layer: [] }, channels: ["metric", "value", "goal"], + interactionSupport: { + elements: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => ({ fields: fieldsFromEncodingChannels(resolvedEncodings, ['metric', 'value', 'goal']), diff --git a/packages/flint-js/src/vegalite/templates/line.ts b/packages/flint-js/src/vegalite/templates/line.ts index 15afa38a..0447a2c7 100644 --- a/packages/flint-js/src/vegalite/templates/line.ts +++ b/packages/flint-js/src/vegalite/templates/line.ts @@ -132,7 +132,15 @@ export const lineChartDef: ChartTemplateDef = { chart: "Line Chart", template: { mark: "line", encoding: {} }, channels: ["x", "y", "color", "strokeDash", "detail", "opacity", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'position', geometryKinds: ['line', 'point'], semanticInteractions: ({ resolvedEncodings }) => { diff --git a/packages/flint-js/src/vegalite/templates/lollipop.ts b/packages/flint-js/src/vegalite/templates/lollipop.ts index 96930b06..d3081437 100644 --- a/packages/flint-js/src/vegalite/templates/lollipop.ts +++ b/packages/flint-js/src/vegalite/templates/lollipop.ts @@ -25,7 +25,14 @@ export const lollipopChartDef: ChartTemplateDef = { ], }, channels: ["x", "y", "color", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); diff --git a/packages/flint-js/src/vegalite/templates/map.ts b/packages/flint-js/src/vegalite/templates/map.ts index 59ae0d43..8d6d1b98 100644 --- a/packages/flint-js/src/vegalite/templates/map.ts +++ b/packages/flint-js/src/vegalite/templates/map.ts @@ -299,7 +299,12 @@ export const mapDef: ChartTemplateDef = { ], }, channels: ["longitude", "latitude", "color", "size", "opacity"], - navigation: { geo: true }, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: { geo: true }, + legend: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); @@ -468,7 +473,12 @@ export const choroplethDef: ChartTemplateDef = { encoding: {}, }, channels: ["id", "color", "detail"], - navigation: { geo: true }, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: { geo: true }, + legend: true, + }, markCognitiveChannel: 'color', semanticInteractions: ({ resolvedEncodings }) => { const idField = resolvedEncodings.id?.field; diff --git a/packages/flint-js/src/vegalite/templates/pie.ts b/packages/flint-js/src/vegalite/templates/pie.ts index bb9f65e9..eaf4bf34 100644 --- a/packages/flint-js/src/vegalite/templates/pie.ts +++ b/packages/flint-js/src/vegalite/templates/pie.ts @@ -20,6 +20,11 @@ export const pieChartDef: ChartTemplateDef = { chart: "Pie Chart", template: { mark: "arc", encoding: {} }, channels: ["size", "color", "column", "row"], + interactionSupport: { + elements: true, + region: ['cartesian', 'angular'], + legend: true, + }, markCognitiveChannel: 'area', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); @@ -31,7 +36,6 @@ export const pieChartDef: ChartTemplateDef = { legendFields: colorField ? { color: colorField } : undefined, selectableMarks: ['arc'], annotationMarkType: 'arc', - supportedRegionGestures: ['angular'], renderHoverStyles: { arc: { opacity: 'contrast' } }, resolve: (event, context) => { const legendField = event.legend?.field ?? seriesField; diff --git a/packages/flint-js/src/vegalite/templates/radar.ts b/packages/flint-js/src/vegalite/templates/radar.ts index 84e96870..4a8db6f4 100644 --- a/packages/flint-js/src/vegalite/templates/radar.ts +++ b/packages/flint-js/src/vegalite/templates/radar.ts @@ -279,13 +279,17 @@ function buildRadarLayers( // --------------------------------------------------------------------------- export const radarChartDef: ChartTemplateDef = { chart: "Radar Chart", - reorder: false, template: { description: "Radar / Spider chart", mark: "point", encoding: {}, }, channels: ["x", "y", "color", "column", "row"], + interactionSupport: { + elements: true, + region: ['cartesian', 'angular'], + legend: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const axisField = resolvedEncodings.x?.field; @@ -297,7 +301,6 @@ export const radarChartDef: ChartTemplateDef = { seriesField: groupField, legendFields: groupField ? { color: groupField } : undefined, selectableMarks: ['line', 'point'], - supportedRegionGestures: ['angular'], renderHoverStyles: { line: { strokeWidth: 3 }, symbol: { strokeWidth: 2 }, diff --git a/packages/flint-js/src/vegalite/templates/range-area.ts b/packages/flint-js/src/vegalite/templates/range-area.ts index 397d94bf..5c01de50 100644 --- a/packages/flint-js/src/vegalite/templates/range-area.ts +++ b/packages/flint-js/src/vegalite/templates/range-area.ts @@ -48,10 +48,16 @@ const interpolateConfigProperty: ChartPropertyDef = { export const rangeAreaChartDef: ChartTemplateDef = { chart: 'Range Area Chart', - reorder: false, template: { mark: { type: 'area', opacity: 0.5, line: { strokeWidth: 1 } }, encoding: {} }, channels: ['x', 'y', 'y2', 'color', 'column', 'row'], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + legend: true, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'area', geometryKinds: ['area'], semanticInteractions: ({ resolvedEncodings }) => { diff --git a/packages/flint-js/src/vegalite/templates/rose.ts b/packages/flint-js/src/vegalite/templates/rose.ts index 99bac966..6fcc50a7 100644 --- a/packages/flint-js/src/vegalite/templates/rose.ts +++ b/packages/flint-js/src/vegalite/templates/rose.ts @@ -33,7 +33,6 @@ import { setMarkProp } from './utils'; export const roseChartDef: ChartTemplateDef = { chart: "Rose Chart", - reorder: false, template: { mark: { type: "arc", @@ -43,6 +42,11 @@ export const roseChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "column", "row"], + interactionSupport: { + elements: true, + region: ['cartesian', 'angular'], + legend: true, + }, markCognitiveChannel: 'area', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); @@ -56,7 +60,6 @@ export const roseChartDef: ChartTemplateDef = { legendFields: colorLegendField ? { color: colorLegendField } : undefined, selectableMarks: ['arc'], annotationMarkType: 'arc', - supportedRegionGestures: ['angular'], renderHoverStyles: { arc: { opacity: 'contrast' } }, resolve: (event, context) => { const legendField = event.legend?.field ?? seriesField ?? categoryField; diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index 6674251a..abcb4108 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -50,7 +50,15 @@ export const scatterPlotDef: ChartTemplateDef = { chart: "Scatter Plot", template: { mark: "circle", encoding: {} }, channels: ["x", "y", "color", "size", "shape", "detail", "opacity", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); @@ -130,7 +138,15 @@ export const regressionDef: ChartTemplateDef = { ], }, channels: ["x", "y", "size", "color", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); @@ -231,7 +247,6 @@ export const regressionDef: ChartTemplateDef = { export const rangedDotPlotDef: ChartTemplateDef = { chart: "Ranged Dot Plot", - reorder: { includeConnectiveMarks: true }, template: { encoding: {}, layer: [ @@ -240,7 +255,14 @@ export const rangedDotPlotDef: ChartTemplateDef = { ], }, channels: ["x", "y", "color"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: { includeConnectiveMarks: true }, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); @@ -310,7 +332,14 @@ export const boxplotDef: ChartTemplateDef = { chart: "Boxplot", template: { mark: "boxplot", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); diff --git a/packages/flint-js/src/vegalite/templates/slope.ts b/packages/flint-js/src/vegalite/templates/slope.ts index 5c9ac1a4..c7460032 100644 --- a/packages/flint-js/src/vegalite/templates/slope.ts +++ b/packages/flint-js/src/vegalite/templates/slope.ts @@ -74,7 +74,15 @@ export const slopeChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "detail", "column", "row"], - navigation: {}, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color', 'detail']); diff --git a/packages/flint-js/src/vegalite/templates/sparkline.ts b/packages/flint-js/src/vegalite/templates/sparkline.ts index 2ac4f760..c34d518a 100644 --- a/packages/flint-js/src/vegalite/templates/sparkline.ts +++ b/packages/flint-js/src/vegalite/templates/sparkline.ts @@ -116,7 +116,14 @@ export const sparklineDef: ChartTemplateDef = { chart: 'Sparkline', template: { mark: 'line', encoding: {} }, channels: ['x', 'y', 'color', 'detail', 'row', 'column'], - navigation: { axes: ['x'] }, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: { axes: ['x'] }, + reorder: {}, + discreteAxis: true, + index: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['row', 'color', 'detail']); diff --git a/packages/flint-js/src/vegalite/templates/violin.ts b/packages/flint-js/src/vegalite/templates/violin.ts index 9fa6409c..455b7de5 100644 --- a/packages/flint-js/src/vegalite/templates/violin.ts +++ b/packages/flint-js/src/vegalite/templates/violin.ts @@ -130,7 +130,6 @@ function maxGroupBandwidth(table: any[], measure: string, groupby: string[]): nu } export const violinPlotDef: ChartTemplateDef = { - reorder: false, chart: 'Violin Plot', template: { mark: { type: 'area', orient: 'horizontal' }, @@ -149,6 +148,12 @@ export const violinPlotDef: ChartTemplateDef = { // `column` is consumed internally for the per-category panels; only `row` // is exposed as an additional outer facet. channels: ['x', 'y', 'color', 'row'], + interactionSupport: { + elements: true, + region: ['cartesian'], + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'area', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = resolvedEncodings.x?.field; diff --git a/packages/flint-js/src/vegalite/templates/waterfall.ts b/packages/flint-js/src/vegalite/templates/waterfall.ts index 0d66e259..6166165b 100644 --- a/packages/flint-js/src/vegalite/templates/waterfall.ts +++ b/packages/flint-js/src/vegalite/templates/waterfall.ts @@ -37,8 +37,14 @@ export const waterfallChartDef: ChartTemplateDef = { chart: "Waterfall Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], - navigation: {}, - reorder: { markTypes: ['rect'] }, + interactionSupport: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: { markTypes: ['rect'] }, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x']); diff --git a/packages/flint-js/tests/interaction-admission.test.ts b/packages/flint-js/tests/interaction-admission.test.ts index b24d08d2..520c6f07 100644 --- a/packages/flint-js/tests/interaction-admission.test.ts +++ b/packages/flint-js/tests/interaction-admission.test.ts @@ -1,21 +1,23 @@ 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 { brushAngle, brushX, brushY, clickHighlight, dragReorder, 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. */ +/** A Cartesian chart with elements, a region, a legend, and one navigable axis. */ const CARTESIAN = { - fields: ['category'], - selectableMarks: ['bar'], - resolve: () => null, + capabilities: ['elements', 'cartesian-region', 'navigation', 'legend'] as const, navigationAxes: ['x'] as const, - supportedRegionGestures: ['cartesian'] as const, }; -const NO_SEMANTICS = { fields: [], selectableMarks: [] }; +/** The same chart with nothing to navigate. */ +const NO_NAVIGATION = { + capabilities: ['elements', 'cartesian-region', 'legend'] as const, + navigationAxes: [] as const, +}; +const NO_SEMANTICS = { capabilities: [] as const }; const fromSpec = (entries: readonly InteractionEntry[]): readonly CanvasInteractionDef[] => resolveInteractionSpec({ interactions: entries }).interactions; @@ -23,18 +25,18 @@ const ids = (interactions: readonly CanvasInteractionDef[]): string[] => interac describe('admitInteractions', () => { it('admits everything the chart can honour, in order, with no warnings', () => { - const result = admitInteractions(CARTESIAN, [clickHighlight(), brushX(), ...fromSpec([{ type: 'legend-toggle' }])]); + const result = admitInteractions(CARTESIAN, [clickHighlight({ targets: ['mark'] }), 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.'); + .toThrow('Interaction "brush-angle" requires a polar chart with an angular region; this chart has none.'); 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.'); + .toThrow('Interaction "click-highlight" requires marks that resolve to data; this chart has none.'); + expect(() => admitInteractions(NO_NAVIGATION, [navigate()])) + .toThrow('Interaction "navigate" requires a navigable continuous axis; this chart has none.'); expect(() => admitInteractions(CARTESIAN, [navigate({ axes: 'y' })])) .toThrow('Interaction "navigate" requested unsupported navigation axis: y.'); }); @@ -45,14 +47,14 @@ describe('admitInteractions', () => { 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.', + message: 'Interaction "brush-angle" requires a polar chart with an angular region; this chart has none. The interaction was dropped.', }]); }); it('drops a spec navigate the chart cannot navigate', () => { - const none = admitInteractions({ ...CARTESIAN, navigationAxes: [] }, fromSpec([{ type: 'navigate' }])); + const none = admitInteractions(NO_NAVIGATION, fromSpec([{ type: 'navigate' }])); expect(none.admitted).toEqual([]); - expect(none.warnings[0].message).toContain('requires a chart with a navigable continuous axis'); + expect(none.warnings[0].message).toContain('requires 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'); @@ -66,30 +68,116 @@ describe('admitInteractions', () => { expect(result.warnings[0]).toMatchObject({ code: 'unsupported_interaction' }); }); - it('keeps one navigation interaction: a second spec navigate yields, code keeps today\'s behaviour', () => { + it('keeps one navigation interaction: a second spec navigate yields, a second code navigate throws', () => { 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([]); + expect(spec.warnings[0]).toMatchObject({ severity: 'warning', code: 'conflicting_interactions' }); + expect(spec.warnings[0].message).toBe('Interaction "again" shares the navigation slot with "navigate". The interaction was dropped.'); + expect(() => admitInteractions(CARTESIAN, [navigate(), navigate({ id: 'again' })])) + .toThrow('Interaction "again" shares the navigation slot with "navigate".'); + }); + + it('keeps one region drag: a second spec brush yields, a second code brush throws', () => { + const spec = admitInteractions(CARTESIAN, fromSpec([{ type: 'brush-x' }, { type: 'brush-y' }, { type: 'click-highlight' }])); + expect(ids(spec.admitted)).toEqual(['brush-x', 'click-highlight']); + expect(spec.warnings).toHaveLength(1); + expect(spec.warnings[0].message).toBe('Interaction "brush-y" shares the region drag slot with "brush-x". The interaction was dropped.'); + expect(() => admitInteractions(CARTESIAN, [brushX(), brushY()])) + .toThrow('Interaction "brush-y" shares the region drag slot with "brush-x".'); + }); + + it('a spec region drag yields to a code region drag, whatever the order', () => { + const specFirst = admitInteractions(CARTESIAN, [...fromSpec([{ type: 'select' }]), brushX()]); + expect(ids(specFirst.admitted)).toEqual(['brush-x']); + expect(specFirst.warnings[0].message).toContain('"select" shares the region drag slot with "brush-x"'); + const specLater = admitInteractions(CARTESIAN, [brushX(), ...fromSpec([{ type: 'select' }])]); + expect(ids(specLater.admitted)).toEqual(['brush-x']); + }); + + it('keeps one element drag: a second spec drag-reorder yields', () => { + const plan = { capabilities: ['elements', 'reorder'] as const }; + const spec = admitInteractions(plan, fromSpec([{ type: 'drag-reorder' }, { type: 'drag-reorder', id: 'again' }])); + expect(ids(spec.admitted)).toEqual(['drag-reorder']); + expect(spec.warnings[0].message).toContain('"again" shares the element drag slot with "drag-reorder"'); + expect(() => admitInteractions(plan, [dragReorder(), dragReorder({ id: 'again' })])) + .toThrow('shares the element drag slot'); + }); + + describe('shared hit triggers', () => { + const keys = (interaction: CanvasInteractionDef) => Object.keys(interaction.affordances).sort(); + + it('click-highlight yields the legend click to legend-toggle and keeps the rest', () => { + const result = admitInteractions(CARTESIAN, fromSpec([{ type: 'click-highlight' }, { type: 'legend-toggle' }])); + expect(ids(result.admitted)).toEqual(['click-highlight', 'legend-toggle']); + expect(keys(result.admitted[0])).toEqual(['axis-label', 'mark']); + expect(result.admitted[0]).toMatchObject({ origin: 'spec', preset: 'click-highlight', retainedStateGroup: 'focus' }); + expect(result.warnings).toEqual([{ + severity: 'info', + code: 'conflicting_interactions', + message: 'Interaction "click-highlight" yields legend clicks to "legend-toggle".', + }]); + }); + + it('click-highlight yields the axis click to axis-highlight', () => { + const plan = { capabilities: ['elements', 'discrete-axis', 'legend'] as const }; + const result = admitInteractions(plan, fromSpec([{ type: 'axis-highlight' }, { type: 'click-highlight' }])); + expect(ids(result.admitted)).toEqual(['axis-highlight', 'click-highlight']); + expect(keys(result.admitted[1])).toEqual(['legend-item', 'mark']); + expect(result.warnings[0].message).toBe('Interaction "click-highlight" yields axis label clicks to "axis-highlight".'); + }); + + it('click-highlight yields the mark click to click-group-focus, which shares its focus group', () => { + const result = admitInteractions(CARTESIAN, fromSpec([{ type: 'click-highlight' }, { type: 'click-group-focus' }])); + expect(ids(result.admitted)).toEqual(['click-highlight', 'click-group-focus']); + expect(keys(result.admitted[0])).toEqual(['axis-label', 'legend-item']); + expect(result.warnings[0].message).toBe('Interaction "click-highlight" yields mark clicks with retained focus to "click-group-focus".'); + }); + + it('a click-highlight left with one target yields no further; the later entry drops', () => { + const result = admitInteractions(CARTESIAN, fromSpec([ + { type: 'click-highlight', options: { targets: ['mark'] } }, { type: 'click-group-focus' }, + ])); + expect(ids(result.admitted)).toEqual(['click-highlight']); + expect(result.warnings[0]).toMatchObject({ severity: 'warning' }); + expect(result.warnings[0].message).toContain('"click-group-focus" shares mark clicks with retained focus with "click-highlight"'); + }); + + it('click-annotate and click-highlight share a mark click and compose with no warning', () => { + const result = admitInteractions(CARTESIAN, fromSpec([{ type: 'click-highlight' }, { type: 'click-annotate' }])); + expect(ids(result.admitted)).toEqual(['click-highlight', 'click-annotate']); + expect(result.warnings).toEqual([]); + }); + + it('a code click-highlight yields the same way, and the copy has no origin', () => { + const result = admitInteractions(CARTESIAN, [clickHighlight(), ...fromSpec([{ type: 'legend-toggle' }])]); + expect(keys(result.admitted[0])).toEqual(['axis-label', 'mark']); + expect(result.admitted[0].origin).toBeUndefined(); + expect(result.warnings[0]).toMatchObject({ severity: 'info' }); + }); + + it('keeps the legend on click-highlight when the chart drops legend-toggle', () => { + const noLegend = { capabilities: ['elements', 'cartesian-region'] as const }; + const result = admitInteractions(noLegend, fromSpec([{ type: 'click-highlight' }, { type: 'legend-toggle' }])); + expect(ids(result.admitted)).toEqual(['click-highlight']); + expect(keys(result.admitted[0])).toEqual(['axis-label', 'legend-item', 'mark']); + expect(result.warnings.map((warning) => warning.code)).toEqual(['unsupported_interaction']); + }); }); 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.'); + .toThrow('Interaction "select" shares the plot drag with "navigate".'); }); 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"'); + expect(dragLater.warnings[0].message).toContain('"select" shares the plot drag 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"'); + expect(navigateLater.warnings[0].message).toContain('"navigate" shares the plot drag with "select"'); }); it('drops the spec entry when the other side is code, whatever the order', () => { @@ -107,6 +195,21 @@ describe('admitInteractions', () => { }); }); +describe('mountedInteractionList', () => { + it('mounts the admitted copy in the author\'s place, drops what admission dropped, and passes externals through', async () => { + const { mountedInteractionList } = await import('../src/vegalite/interactive'); + const { externalInteraction } = await import('../src/interactive/interactions'); + const highlight = clickHighlight(); + const narrowed = highlight.withoutAffordances!(['legend-item'])!; + const external = externalInteraction({ id: 'host', handle: () => null }); + const authored = [highlight, external, brushX(), ...fromSpec([{ type: 'legend-toggle' }])]; + const admitted = [narrowed, authored[3]]; + const mounted = mountedInteractionList(authored, admitted); + expect(mounted.map((interaction) => interaction.id)).toEqual(['click-highlight', 'host', 'legend-toggle']); + expect(mounted[0]).toBe(narrowed); + }); +}); + describe('addVegaLiteInteractions with spec interactions', () => { const assembled = (): any => assembleVegaLite({ data: { values: [{ category: 'A', value: 1 }, { category: 'B', value: 2 }] }, @@ -136,8 +239,91 @@ describe('addVegaLiteInteractions with spec interactions', () => { it('still throws for the same request made in code', () => { expect(() => addVegaLiteInteractions(assembled(), [brushAngle()])) - .toThrow('requires a polar chart with angular-region support'); + .toThrow('requires a polar chart with an angular region; Bar Chart has none'); expect(() => addVegaLiteInteractions(assembled(), [navigate(), select()])) - .toThrow('Pan navigation cannot share'); + .toThrow('shares the plot drag'); + }); +}); + +describe('admission against the chart type declaration', () => { + const rows = [ + { region: 'North', category: 'A', value: 1, when: '2024-01-01' }, + { region: 'South', category: 'B', value: 2, when: '2024-02-01' }, + ]; + const semanticsOf = (chartType: string, encodings: Record): any => + (assembleVegaLite({ + data: { values: rows }, + semantic_types: { value: 'Quantity', when: 'Date' }, + chart_spec: { chartType, encodings }, + }) as any)._interactionSemantics; + const every = fromSpec([ + { type: 'click-highlight' }, { type: 'select' }, { type: 'brush-x' }, { type: 'brush-angle' }, + { type: 'legend-toggle' }, { type: 'drag-reorder' }, { type: 'axis-highlight' }, + { type: 'inspect-index' }, { type: 'navigate', options: { pan: false } }, + ]); + + it('writes the confirmed capabilities and the chart type into the compiled semantics', () => { + const bar = semanticsOf('Bar Chart', { x: 'category', y: 'value', color: 'region' }); + expect(bar.chartType).toBe('Bar Chart'); + expect(bar.capabilities).toEqual(['elements', 'cartesian-region', 'navigation', 'reorder', 'legend', 'discrete-axis']); + const pie = semanticsOf('Pie Chart', { theta: 'value', color: 'category' }); + expect(pie.capabilities).toEqual(['elements', 'cartesian-region', 'angular-region', 'legend']); + const kpi = semanticsOf('KPI Card', { metric: 'category', value: 'value' }); + expect(kpi.capabilities).toEqual(['elements']); + }); + + it('a KPI card keeps the element presets and drops the rest, naming the chart type', () => { + const result = admitInteractions(semanticsOf('KPI Card', { metric: 'category', value: 'value' }), every); + expect(ids(result.admitted)).toEqual(['click-highlight']); + expect(result.warnings.map((warning) => warning.message)).toEqual([ + 'Interaction "select" requires a plot to drag a region on; KPI Card has none. The interaction was dropped.', + 'Interaction "brush-x" requires a plot to drag a region on; KPI Card has none. The interaction was dropped.', + 'Interaction "brush-angle" requires a polar chart with an angular region; KPI Card has none. The interaction was dropped.', + 'Interaction "legend-toggle" requires a discrete legend; KPI Card has none. The interaction was dropped.', + 'Interaction "drag-reorder" requires a discrete axis whose order can change; KPI Card has none. The interaction was dropped.', + 'Interaction "axis-highlight" requires a discrete axis with category labels; KPI Card has none. The interaction was dropped.', + 'Interaction "inspect-index" requires an index axis shared by the series; KPI Card has none. The interaction was dropped.', + 'Interaction "navigate" requires a navigable continuous axis; KPI Card has none. The interaction was dropped.', + ]); + }); + + it('a pie keeps the first region drag and the legend, and drops the axis presets', () => { + const result = admitInteractions(semanticsOf('Pie Chart', { theta: 'value', color: 'category' }), every); + expect(ids(result.admitted)).toEqual(['click-highlight', 'select', 'legend-toggle']); + expect(result.warnings.map((warning) => warning.code)).toEqual([ + ...Array(4).fill('unsupported_interaction'), + ...Array(3).fill('conflicting_interactions'), + ]); + expect(result.warnings[4]).toMatchObject({ severity: 'info' }); + expect(result.warnings[4].message).toBe('Interaction "click-highlight" yields legend clicks to "legend-toggle".'); + expect(result.warnings[5].message).toContain('"brush-x" shares the region drag slot with "select"'); + expect(result.warnings[6].message).toContain('"brush-angle" shares the region drag slot with "select"'); + }); + + it('a legend is confirmed by the data: a bar chart without a colour field drops legend-toggle', () => { + const plain = admitInteractions(semanticsOf('Bar Chart', { x: 'category', y: 'value' }), fromSpec([{ type: 'legend-toggle' }])); + expect(plain.admitted).toEqual([]); + expect(plain.warnings[0].message).toBe('Interaction "legend-toggle" requires a discrete legend; Bar Chart has none. The interaction was dropped.'); + const coloured = admitInteractions(semanticsOf('Bar Chart', { x: 'category', y: 'value', color: 'region' }), fromSpec([{ type: 'legend-toggle' }])); + expect(ids(coloured.admitted)).toEqual(['legend-toggle']); + }); + + it('a continuous colour legend is not a discrete legend', () => { + const heatmap = semanticsOf('Heatmap', { x: 'category', y: 'region', color: 'value' }); + expect(heatmap.capabilities).not.toContain('legend'); + const result = admitInteractions(heatmap, fromSpec([{ type: 'legend-toggle' }, { type: 'drag-reorder' }])); + expect(ids(result.admitted)).toEqual(['drag-reorder']); + }); + + it('a code definition made by a preset throws the same way', () => { + const kpi = semanticsOf('KPI Card', { metric: 'category', value: 'value' }); + expect(() => admitInteractions(kpi, [brushX()])) + .toThrow('Interaction "brush-x" requires a plot to drag a region on; KPI Card has none.'); + }); + + it('a definition made by hand needs nothing', () => { + const kpi = semanticsOf('KPI Card', { metric: 'category', value: 'value' }); + const bare: CanvasInteractionDef = { ...clickHighlight({ id: 'bare' }), preset: undefined }; + expect(ids(admitInteractions(kpi, [bare]).admitted)).toEqual(['bare']); }); }); diff --git a/packages/flint-js/tests/interaction-reset.test.ts b/packages/flint-js/tests/interaction-reset.test.ts index ca9e0c81..0afd6c2f 100644 --- a/packages/flint-js/tests/interaction-reset.test.ts +++ b/packages/flint-js/tests/interaction-reset.test.ts @@ -92,7 +92,7 @@ describe('the dispatcher picks interactions by their own list', () => { }); describe('admission: a double-click cannot both activate and reset', () => { - const PLAN = { fields: ['c'], selectableMarks: ['bar'], resolve: () => null, navigationAxes: ['x'] as const }; + const PLAN = { capabilities: ['elements', 'cartesian-region', 'navigation'] as const, navigationAxes: ['x'] as const }; const fromSpec = (entries: readonly InteractionEntry[]) => resolveInteractionSpec({ interactions: entries }).interactions; it('drops the later spec entry with a warning', async () => { @@ -100,17 +100,16 @@ describe('admission: a double-click cannot both activate and reset', () => { const later = admitInteractions(PLAN, fromSpec([{ type: 'navigate' }, { type: 'double-activate' }])); expect(later.admitted.map((i) => i.id)).toEqual(['navigate']); expect(later.warnings[0]).toMatchObject({ code: 'conflicting_interactions' }); - expect(later.warnings[0].message).toContain('"double-activate" conflicts with "navigate"'); + expect(later.warnings[0].message).toContain('"double-activate" shares the double-click with "navigate"'); const reversed = admitInteractions(PLAN, fromSpec([{ type: 'double-activate' }, { type: 'navigate' }])); expect(reversed.admitted.map((i) => i.id)).toEqual(['double-activate']); }); - it('keeps both when both come from code, and admits a navigate that resets on escape only', async () => { + it('throws when both come from code, and admits a navigate that resets on escape only', async () => { const { admitInteractions } = await import('../src/interactive/spec/admission'); const { doubleActivate } = await import('../src/interactive/interactions'); - const code = admitInteractions(PLAN, [navigate(), doubleActivate()]); - expect(code.admitted.map((i) => i.id)).toEqual(['navigate', 'double-activate']); - expect(code.warnings).toEqual([]); + expect(() => admitInteractions(PLAN, [navigate(), doubleActivate()])) + .toThrow('Interaction "double-activate" shares the double-click with "navigate".'); const noClash = admitInteractions(PLAN, fromSpec([{ type: 'navigate', options: { reset: ['escape'] } }, { type: 'double-activate' }])); expect(noClash.admitted.map((i) => i.id)).toEqual(['navigate', 'double-activate']); }); diff --git a/packages/flint-js/tests/interaction-support.test.ts b/packages/flint-js/tests/interaction-support.test.ts new file mode 100644 index 00000000..e82c3de0 --- /dev/null +++ b/packages/flint-js/tests/interaction-support.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + INTERACTION_PRESET_TYPES, + declaredInteractionCapabilities, + supportedInteractionPresets, +} from '../src/core/interaction-spec'; +import { vlAllTemplateDefs } from '../src/vegalite/templates'; + +const def = (chart: string) => vlAllTemplateDefs.find((candidate) => candidate.chart === chart)!; + +describe('declaredInteractionCapabilities', () => { + it('reads the template block key by key and names nothing for an absent block', () => { + expect(declaredInteractionCapabilities(undefined)).toEqual([]); + expect(declaredInteractionCapabilities(def('KPI Card').interactionSupport)).toEqual(['elements']); + expect(declaredInteractionCapabilities(def('Pie Chart').interactionSupport)) + .toEqual(['elements', 'cartesian-region', 'angular-region', 'legend']); + expect(declaredInteractionCapabilities(def('Bar Chart').interactionSupport)) + .toEqual(['elements', 'cartesian-region', 'navigation', 'reorder', 'legend', 'discrete-axis']); + }); +}); + +describe('supportedInteractionPresets', () => { + it('lists the presets whose requirements sit inside the declaration', () => { + expect(supportedInteractionPresets(def('KPI Card').interactionSupport)).toEqual([ + 'click-highlight', 'click-group-focus', 'hover-group-focus', 'click-annotate', + 'context-activate', 'long-press', 'double-activate', 'inspect', + ]); + const pie = supportedInteractionPresets(def('Pie Chart').interactionSupport); + expect(pie).toContain('brush-angle'); + expect(pie).toContain('brush-x'); + expect(pie).toContain('legend-toggle'); + expect(pie).not.toContain('navigate'); + expect(pie).not.toContain('axis-highlight'); + expect(pie).not.toContain('drag-reorder'); + const bar = supportedInteractionPresets(def('Bar Chart').interactionSupport); + expect(bar).not.toContain('brush-angle'); + expect(bar).not.toContain('inspect-index'); + expect(bar).toContain('drag-reorder'); + expect(supportedInteractionPresets(undefined)).toEqual([]); + }); + + it('every preset is supported by at least one chart type, and every chart type supports at least one preset', () => { + const union = new Set(vlAllTemplateDefs.flatMap((template) => supportedInteractionPresets(template.interactionSupport))); + expect([...INTERACTION_PRESET_TYPES].filter((type) => !union.has(type))).toEqual([]); + const empty = vlAllTemplateDefs.filter((template) => supportedInteractionPresets(template.interactionSupport).length === 0); + expect(empty.map((template) => template.chart)).toEqual([]); + }); +}); diff --git a/packages/flint-js/tests/interactions.test.ts b/packages/flint-js/tests/interactions.test.ts index 5569c763..d8064f40 100644 --- a/packages/flint-js/tests/interactions.test.ts +++ b/packages/flint-js/tests/interactions.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { axisHighlight, brushAngle, brushX, brushY, brushZoom, clickAnnotate, clickGroupFocus, clickHighlight, doubleActivate, dragReorder, externalInteraction, hoverGroupFocus, inspect, inspectIndex, lassoSelect, legendToggle, linkedBrush, longPress, navigate, normalizeInteractions, select } from '../src/interactive/interactions'; import type { ClickHighlightOptions } from '../src/interactive/interactions'; -import { affordanceCursor, resolveInteractionAffordance } from '../src/interactive/affordances'; +import { affordanceCursor, affordsTarget, resolveInteractionAffordance } from '../src/interactive/affordances'; import { reorderValues } from '../src/interactive/presets/drag-reorder'; import { annotationCandidates, countAnnotationText, presentAnnotationUpdate } from '../src/interactive/presentation/annotation'; import { toCanvasInteractionEvent } from '../src/interactive/canvas-interaction'; @@ -404,8 +404,8 @@ describe('hover presentation policy', () => { it('includes only interactions that register hover presentation', () => { const preset = clickMark(); - const observer: InteractionDef = { id: 'click-observer', eventSource: clickTrigger }; - const hover: InteractionDef = { id: 'hover-observer', eventSource: hoverTrigger }; + const observer: InteractionDef = { id: 'click-observer', eventSource: clickTrigger, affordances: { mark: {} } }; + const hover: InteractionDef = { id: 'hover-observer', eventSource: hoverTrigger, affordances: { mark: {} } }; const reorder = dragReorder(); const indexReader = inspectIndex({ show: 'single', seriesBy: 'Series' }); const sustained = longPress(); @@ -494,6 +494,7 @@ describe('viewport navigation', () => { const interaction: InteractionDef = { id: 'click-observer', eventSource: clickTrigger, + affordances: { mark: {} }, }; expect(interaction.handle).toBeUndefined(); @@ -1145,7 +1146,7 @@ describe('interaction definitions', () => { .toMatchObject({ hover: 'cohort' }); const freehand = { id: 'freehand', eventSource: lassoTrigger('contain', false), - affordances: [{ target: 'plot' as const, cursor: 'draw' as const }], + affordances: { plot: { cursor: 'draw' as const } }, }; const drawCursor = affordanceCursor(resolveInteractionAffordance([freehand, select()], 'plot')); expect(drawCursor).toMatch(/^url\("data:image\/svg\+xml/); @@ -1176,16 +1177,46 @@ describe('interaction definitions', () => { expect(resolveInteractionAffordance([interaction], 'mark')).toBeDefined(); expect(resolveInteractionAffordance([interaction], 'legend-item')).toBeDefined(); expect(resolveInteractionAffordance([interaction], 'axis-label')).toBeDefined(); - expect(interaction).toMatchObject({ - retainedStateGroup: 'focus', - claimsLegendActivation: true, - claimsAxisActivation: true, - }); + expect(interaction).toMatchObject({ retainedStateGroup: 'focus' }); + expect(Object.keys(interaction.affordances).sort()).toEqual(['axis-label', 'legend-item', 'mark']); + expect(Object.keys(markAndAxis.affordances).sort()).toEqual(['axis-label', 'mark']); expect(resolveInteractionAffordance([markAndAxis], 'mark')).toBeDefined(); expect(resolveInteractionAffordance([markAndAxis], 'axis-label')).toBeDefined(); expect(resolveInteractionAffordance([markAndAxis], 'legend-item')).toBeUndefined(); }); + it('each preset affords the kinds of hit it answers', () => { + expect(Object.keys(legendToggle().affordances)).toEqual(['legend-item']); + expect(Object.keys(axisHighlight().affordances)).toEqual(['axis-label']); + for (const preset of [clickGroupFocus(), clickAnnotate(), longPress(), doubleActivate(), hoverGroupFocus({ groupBy: 'Series' })]) { + expect(Object.keys(preset.affordances)).toEqual(['mark']); + } + expect(Object.keys(dragReorder().affordances).sort()).toEqual(['axis-label', 'mark']); + for (const preset of [select(), brushX(), lassoSelect(), inspect(), navigate()]) { + expect(Object.keys(preset.affordances)).toEqual(['plot']); + } + }); + + it('navigate without pan affords the plot and signals nothing', () => { + expect(navigate({ pan: false }).affordances).toEqual({ plot: {} }); + expect(affordsTarget(navigate({ pan: false }), 'plot')).toBe(true); + expect(resolveInteractionAffordance([navigate({ pan: false })], 'plot')).toBeUndefined(); + }); + + it('a plot cursor is a fallback for looks only, never a claim on marks', () => { + expect(resolveInteractionAffordance([select()], 'mark')).toMatchObject({ cursor: 'region' }); + expect(affordsTarget(select(), 'mark')).toBe(false); + }); + + it('click highlight yields targets through a rebuilt copy that keeps its preset and reset', () => { + const full = clickHighlight({ dimOpacity: 0.2, reset: ['escape'] }); + const narrowed = full.withoutAffordances!(['legend-item', 'axis-label']); + expect(Object.keys(narrowed!.affordances)).toEqual(['mark']); + expect(narrowed).toMatchObject({ id: 'click-highlight', preset: 'click-highlight', retainedStateGroup: 'focus', reset: ['escape'] }); + expect(Object.keys(full.affordances)).toHaveLength(3); + expect(full.withoutAffordances!(['mark', 'legend-item', 'axis-label'])).toBeNull(); + }); + it('provides reusable trigger descriptors', () => { expect(clickTrigger).toEqual({ type: 'element', gesture: 'click' }); expect(hoverTrigger).toEqual({ type: 'element', gesture: 'hover' }); @@ -1251,8 +1282,11 @@ describe('interaction definitions', () => { expect(clickHighlight()).toMatchObject({ id: 'click-highlight', eventSource: { ...clickTrigger, defaultAssistDistance: 8 }, - claimsLegendActivation: true, - claimsAxisActivation: true, + affordances: { + mark: { cursor: 'activate', hover: 'target' }, + 'legend-item': { cursor: 'activate', hover: 'cohort' }, + 'axis-label': { cursor: 'activate', hover: 'cohort' }, + }, }); expect(clickMark()).toMatchObject({ id: 'click-mark', eventSource: { ...clickTrigger, defaultAssistDistance: 8 }, @@ -1264,7 +1298,8 @@ describe('interaction definitions', () => { id: 'hover-group-focus', eventSource: { ...hoverTrigger, defaultAssistDistance: 6 }, }); expect(axisHighlight()).toMatchObject({ - id: 'axis-highlight', eventSource: clickTrigger, claimsAxisActivation: true, + id: 'axis-highlight', eventSource: clickTrigger, + affordances: { 'axis-label': { cursor: 'activate', hover: 'cohort' } }, }); expect(axisHighlight({ event: 'hover' }).eventSource).toBe(hoverTrigger); expect(clickAnnotate()).toMatchObject({ @@ -2960,11 +2995,11 @@ describe('legend, inspect, zoom, and touch presets', () => { }); }); - it('ignores mark activations so it composes with element click presets', () => { + it('affords legend items only, so it composes with element click presets', () => { const interaction = legendToggle(); - const markTarget = { visual: { kind: 'mark' as const, role: 'mark' }, elements: [{ value: { key: 'A' } }] }; - expect(activate(interaction, markTarget)).toBeNull(); + expect(affordsTarget(interaction, 'legend-item')).toBe(true); + expect(affordsTarget(interaction, 'mark')).toBe(false); }); it('focuses configured discrete-axis and legend targets through one preset', () => { @@ -2974,13 +3009,11 @@ describe('legend, inspect, zoom, and touch presets', () => { visual: { kind: 'axis' as const, role: 'axis-label' }, elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], }; - const markTarget = { - visual: { kind: 'mark' as const, role: 'mark' }, - elements: [{ value: { Category: 'A' } }], - }; - expect(axisInteraction).toMatchObject({ claimsAxisActivation: true }); - expect(legendInteraction).toMatchObject({ claimsLegendActivation: true }); + expect(affordsTarget(axisInteraction, 'axis-label')).toBe(true); + expect(affordsTarget(axisInteraction, 'legend-item')).toBe(false); + expect(affordsTarget(legendInteraction, 'legend-item')).toBe(true); + expect(affordsTarget(legendInteraction, 'mark')).toBe(false); expect(activate(axisInteraction, axisTarget)?.ops[0]).toMatchObject({ op: 'set-style', targets: [{ elements: axisTarget.elements }], value: { state: 'emphasized', mutedOpacity: 0.2 }, @@ -2989,8 +3022,6 @@ describe('legend, inspect, zoom, and touch presets', () => { op: 'set-style', targets: [{ elements: seriesTarget('A').elements }], value: { state: 'emphasized', mutedOpacity: 0.2 }, }); - expect(activate(axisInteraction, markTarget)).toBeNull(); - expect(activate(legendInteraction, markTarget)).toBeNull(); }); it('handles mark, legend, and axis activations through one click highlight instance', () => { @@ -3029,26 +3060,20 @@ describe('legend, inspect, zoom, and touch presets', () => { }, }], }; - const axisTarget = { - visual: { kind: 'axis' as const, role: 'axis-label' }, - elements: [{ value: { axis: 'x', field: 'Category', value: 'A' } }], - }; expect(activate(interaction, intervalTarget)?.ops[0]).toMatchObject({ op: 'set-style', targets: [{ elements: intervalTarget.elements }], }); - expect(activate(interaction, axisTarget)).toBeNull(); + expect(affordsTarget(interaction, 'axis-label')).toBe(false); }); it('assigns observable legend events only to legend interactions', () => { - expect(activate(clickMark(), seriesTarget('A'))).toBeNull(); - expect(activate(clickGroupFocus(), seriesTarget('A'))).toBeNull(); + expect(affordsTarget(clickMark(), 'legend-item')).toBe(false); + expect(affordsTarget(clickGroupFocus(), 'legend-item')).toBe(false); + expect(affordsTarget(clickAnnotate(), 'legend-item')).toBe(false); + expect(affordsTarget(hoverGroupFocus({ groupBy: 'Series' }), 'legend-item')).toBe(false); + expect(affordsTarget(clickHighlight({ targets: ['legend'] }), 'legend-item')).toBe(true); expect(activate(clickHighlight({ targets: ['legend'] }), seriesTarget('A'))).not.toBeNull(); - expect(activate(clickAnnotate(), seriesTarget('A'))).toBeNull(); - const hover = (interaction: CanvasInteractionDef) => interaction.handle!(toCanvasInteractionEvent({ - type: 'semantic', source: 'element', phase: 'preview', target: seriesTarget('A'), - }, interaction.eventSource), context); - expect(hover(hoverGroupFocus({ groupBy: 'Series' }))).toBeNull(); }); it('reports the resolved role for context, long-press, and double activation', () => { @@ -3075,8 +3100,8 @@ describe('legend, inspect, zoom, and touch presets', () => { }, clickTrigger); expect(event).toMatchObject({ action: 'click-legend', target }); - expect(activate(clickMark(), target)).toBeNull(); - expect(activate(clickGroupFocus(), target)).toBeNull(); + expect(affordsTarget(clickMark(), 'legend-item')).toBe(false); + expect(affordsTarget(clickGroupFocus(), 'legend-item')).toBe(false); expect(activate(clickHighlight({ targets: ['legend'] }), target)?.ops[0]).toMatchObject({ op: 'set-style', targets: [{ visual: target.visual, elements: target.elements }], @@ -3130,9 +3155,7 @@ describe('legend, inspect, zoom, and touch presets', () => { const single = inspectIndex({ axis: 'y', show: 'single', seriesBy: 'Series', tolerance: 0.03 }); expect(single.eventSource.inspectIndex).toEqual({ axis: 'y', show: 'single', seriesBy: 'Series' }); expect(single.eventSource.inspectTolerance).toBe(0.03); - expect(single.affordances).toEqual([ - { target: 'legend-item', cursor: 'activate', hover: 'cohort' }, - ]); + expect(single.affordances).toEqual({ 'legend-item': { cursor: 'activate', hover: 'cohort' } }); expect(inspectIndex({ show: { series: 'Forecast' }, seriesBy: 'Series' }).eventSource.inspectIndex) .toEqual({ axis: 'x', show: { series: 'Forecast' }, seriesBy: 'Series' }); expect(() => inspectIndex({ show: 'single' })).toThrow('requires seriesBy'); @@ -3389,12 +3412,8 @@ describe('legend, inspect, zoom, and touch presets', () => { elements: [{ value: { category: 'A' } }], }; expect(longPress({ holdMs: 250 }).eventSource).toMatchObject({ gesture: 'long-press', holdMs: 250 }); - expect(longPress().affordances).toEqual([ - { target: 'mark', cursor: 'activate', hover: 'target' }, - ]); - expect(doubleActivate().affordances).toEqual([ - { target: 'mark', cursor: 'activate', hover: 'target' }, - ]); + expect(longPress().affordances).toEqual({ mark: { cursor: 'activate', hover: 'target' } }); + expect(doubleActivate().affordances).toEqual({ mark: { cursor: 'activate', hover: 'target' } }); expect(longPress().handle!(toCanvasInteractionEvent({ type: 'semantic', source: 'element', phase: 'commit', target, }, longPressTrigger()), context)?.ops[0]).toMatchObject({ diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index 680c0d68..ab7a2546 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -90,6 +90,7 @@ import { import { createVegaNavigationController } from '../src/vegalite/interactions/navigation-scale'; import { INTERACTION_PROVENANCE } from '../src/vegalite/interaction-provenance'; import { THEME_PRESETS } from '../src/core/theme/presets'; +import { INTERACTION_CAPABILITIES } from '../src/core/interaction-spec'; import { lineChartDef } from '../src/vegalite/templates/line'; import { bumpChartDef } from '../src/vegalite/templates/bump'; import { slopeChartDef } from '../src/vegalite/templates/slope'; @@ -113,6 +114,9 @@ import { resolvedLegendInteractionTarget, } from '../src/vegalite/interactions/runtime'; +/** Hand-built semantics in this file offer every capability unless a test says otherwise. */ +const ALL_CAPABILITIES = [...INTERACTION_CAPABILITIES]; + const clickMark = (options: Omit = {}) => clickHighlight({ ...options, targets: ['mark'] }); @@ -569,7 +573,7 @@ describe('Vega-Lite semantic interactions', () => { .toEqual({ signal: `__flint_reorder_${axis}_domain` }); }); - it('leaves drag reorder inert without a template-declared category scale', () => { + it('refuses drag reorder without a template-declared category scale', () => { expect(() => addVegaLiteInteractions({ mark: 'bar' }, [dragReorder()])) .toThrow('requires chart interaction semantics'); const scatter = assembleVegaLite({ @@ -580,7 +584,8 @@ describe('Vega-Lite semantic interactions', () => { semantic_types: { x: 'Number', y: 'Number' }, data: { values: [{ x: 1, y: 2 }] }, }) as any; - expect(addVegaLiteInteractions(scatter, [dragReorder()])?.reorderAxis).toBeUndefined(); + expect(() => addVegaLiteInteractions(scatter, [dragReorder()])) + .toThrow('Interaction "drag-reorder" requires a discrete axis whose order can change; Scatter Plot has none.'); const facetedSemantics = barChartDef.semanticInteractions!({ resolvedEncodings: { @@ -604,6 +609,7 @@ describe('Vega-Lite semantic interactions', () => { const interaction: CanvasInteractionDef = { id: 'freeform-drag', eventSource: dragTrigger(), + affordances: { mark: { cursor: 'drag' } }, handle: () => null, }; @@ -681,7 +687,8 @@ describe('Vega-Lite semantic interactions', () => { data: { values: [{ month: 'Jan', low: 1, high: 3 }, { month: 'Feb', low: 2, high: 4 }] }, }) as any; expect(spec._interactionSemantics.reorderAxes).toEqual([]); - expect(addVegaLiteInteractions(spec, [dragReorder()])?.reorderAxis).toBeUndefined(); + expect(() => addVegaLiteInteractions(spec, [dragReorder()])) + .toThrow('Interaction "drag-reorder" requires a discrete axis whose order can change; Range Area Chart has none.'); }); it('distinguishes dumbbell connectors from stationary Slope stems during reorder preview', () => { @@ -730,8 +737,8 @@ describe('Vega-Lite semantic interactions', () => { .toThrow('requires chart interaction semantics'); expect(() => addVegaLiteInteractions({ mark: 'line', - _interactionSemantics: { fields: [], selectableMarks: [], navigationAxes: ['x'] }, - }, [clickMark()])).toThrow('requires chart element semantics'); + _interactionSemantics: { fields: [], selectableMarks: [], navigationAxes: ['x'], capabilities: ['navigation'] }, + }, [clickMark()])).toThrow('requires marks that resolve to data'); }); it('instruments semantic targets for external interactions without adding canvas gestures', () => { @@ -1010,6 +1017,7 @@ describe('Vega-Lite semantic interactions', () => { it('updates arc opacity in a composed Rose chart', async () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Direction'], categoryField: 'Direction', selectableMarks: ['arc'], @@ -1450,6 +1458,7 @@ describe('Vega-Lite semantic interactions', () => { color: { field: 'Color', type: 'nominal' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['X', 'Y', 'Color'], seriesField: 'Color', legendFields: { color: 'Color' }, @@ -1477,6 +1486,7 @@ describe('Vega-Lite semantic interactions', () => { y: { field: 'Y', type: 'quantitative' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['X', 'Y'], selectableMarks: ['point'], }, @@ -1501,6 +1511,7 @@ describe('Vega-Lite semantic interactions', () => { y: { field: 'Y', type: 'quantitative' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['X', 'Y'], selectableMarks: [mark], renderHoverStyles: { [renderMark]: { stroke: '#59636d', strokeWidth: width } }, @@ -1640,6 +1651,7 @@ describe('Vega-Lite semantic interactions', () => { y: { field: 'Value', type: 'quantitative' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category', 'Value'], selectableMarks: ['rule', 'circle'], renderHoverStyles: { @@ -1678,6 +1690,7 @@ describe('Vega-Lite semantic interactions', () => { color: { field: 'Group', type: 'nominal' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['X', 'Y', 'Group'], selectableMarks: ['point'], renderHoverStyles: { symbol: { stroke: '#59636d', strokeWidth: 2 } }, }, @@ -1687,6 +1700,7 @@ describe('Vega-Lite semantic interactions', () => { mark: { type: 'line', strokeWidth: 2 }, encoding: { x: { field: 'X', type: 'quantitative' }, y: { field: 'Y', type: 'quantitative' } }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['X', 'Y'], selectableMarks: ['line'], renderHoverStyles: { line: { strokeWidth: 3 } }, }, @@ -1699,6 +1713,7 @@ describe('Vega-Lite semantic interactions', () => { { mark: 'bar', encoding: { y: { field: 'Open', type: 'quantitative' }, y2: { field: 'Close' } } }, ], _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['X'], selectableMarks: ['rule', 'bar'], renderHoverStyles: { rule: { strokeWidth: 2.5 }, rect: { stroke: '#59636d', strokeWidth: 1.5 } }, }, @@ -1713,6 +1728,7 @@ describe('Vega-Lite semantic interactions', () => { y: { field: 'Value', type: 'quantitative' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Group'], selectableMarks: ['boxplot'], renderHoverStyles: { rect: { opacity: 'contrast', stroke: '#59636d', strokeWidth: 2 }, @@ -1750,6 +1766,7 @@ describe('Vega-Lite semantic interactions', () => { y: { field: 'Value', type: 'quantitative' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Group'], selectableMarks: ['boxplot'], renderHoverStyles: { rect: { opacity: 'contrast', stroke: MUTED_HOVER_STROKE, strokeWidth: 2 }, @@ -1928,20 +1945,26 @@ describe('Vega-Lite semantic interactions', () => { mark: 'bar', data: { values: [{ category: 'A', value: 1 }] }, encoding: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, - _interactionSemantics: barChartDef.semanticInteractions!({ - resolvedEncodings: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, - }), + _interactionSemantics: { + ...barChartDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, + }), + capabilities: ['elements', 'cartesian-region'], + }, }; expect(() => addVegaLiteInteractions(cartesian, [brushAngle()])) - .toThrow('requires a polar chart with angular-region support'); + .toThrow('requires a polar chart with an angular region'); const polar = { mark: 'arc', data: { values: [{ category: 'A', value: 1 }] }, encoding: { theta: { field: 'value', type: 'quantitative' }, color: { field: 'category', type: 'nominal' } }, - _interactionSemantics: roseChartDef.semanticInteractions!({ - resolvedEncodings: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, - }), + _interactionSemantics: { + ...roseChartDef.semanticInteractions!({ + resolvedEncodings: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, + }), + capabilities: ['elements', 'cartesian-region', 'angular-region'], + }, }; const polarPlan = addVegaLiteInteractions(polar, [brushX()]); expect(polarPlan?.angularXBrush).toBe(true); @@ -1954,13 +1977,16 @@ describe('Vega-Lite semantic interactions', () => { y: { field: 'value', type: 'quantitative' }, color: { field: 'series', type: 'nominal' }, }, - _interactionSemantics: radarChartDef.semanticInteractions!({ - resolvedEncodings: { - x: { field: 'metric', type: 'nominal' }, - y: { field: 'value', type: 'quantitative' }, - color: { field: 'series', type: 'nominal' }, - }, - }), + _interactionSemantics: { + ...radarChartDef.semanticInteractions!({ + resolvedEncodings: { + x: { field: 'metric', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + color: { field: 'series', type: 'nominal' }, + }, + }), + capabilities: ['elements', 'cartesian-region', 'angular-region'], + }, }; expect(addVegaLiteInteractions(radar, [brushAngle()])?.angularXBrush).toBe(true); }); @@ -2079,6 +2105,7 @@ describe('Vega-Lite semantic interactions', () => { it('keys a basic bar by its category and emits a valid retained store', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Region'], categoryField: 'Region', selectableMarks: ['bar'], }, data: { values: [{ Region: 'West', Sales: 10 }] }, @@ -2101,6 +2128,7 @@ describe('Vega-Lite semantic interactions', () => { it('uses category plus series for grouped-bar element identity', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Region', 'Segment'], categoryField: 'Region', seriesField: 'Segment', @@ -2127,6 +2155,7 @@ describe('Vega-Lite semantic interactions', () => { it('uses both discrete axes for a heatmap cell', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Month', 'Product'], categoryField: 'Month', selectableMarks: ['rect'], }, mark: 'rect', @@ -2143,6 +2172,7 @@ describe('Vega-Lite semantic interactions', () => { it('instruments concatenated pyramid bars with constant opacity', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Age', 'Gender'], categoryField: 'Age', seriesField: 'Gender', @@ -2218,6 +2248,7 @@ describe('Vega-Lite semantic interactions', () => { it('hovers Pyramid bars without changing their geometry or center gap', async () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Age', 'Gender'], categoryField: 'Age', seriesField: 'Gender', selectableMarks: ['bar'], renderHoverStyles: { rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 1.5 } }, @@ -2273,6 +2304,7 @@ describe('Vega-Lite semantic interactions', () => { ])('contrasts target opacity from $authoredOpacity without changing peers', async ({ authoredOpacity, hoveredOpacity }) => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category'], categoryField: 'Category', selectableMarks: ['bar'], renderHoverStyles: { rect: { opacity: 'contrast' } }, }, @@ -2313,6 +2345,7 @@ describe('Vega-Lite semantic interactions', () => { it('preserves a data-encoded opacity channel and uses an outline on hover', async () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category'], categoryField: 'Category', selectableMarks: ['bar'], renderHoverStyles: { rect: { stroke: MUTED_HOVER_STROKE, strokeWidth: 1.5 } }, }, @@ -2564,6 +2597,7 @@ describe('Vega-Lite semantic interactions', () => { it('calculates keys inside a Bar Table panel with its own named data', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category'], categoryField: 'Category', selectableMarks: ['bar'], }, datasets: { rows: [{ Category: 'Alpha', Value: 10 }] }, @@ -2600,6 +2634,7 @@ describe('Vega-Lite semantic interactions', () => { it('uses template-owned quantitative fields for point identity', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Horsepower', 'Efficiency'], selectableMarks: ['circle'], markClick: 'element', @@ -2623,6 +2658,7 @@ describe('Vega-Lite semantic interactions', () => { it('coalesces lollipop rule and circle layers under one semantic key', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category', 'Value'], categoryField: 'Category', selectableMarks: ['rule', 'circle'], @@ -2659,6 +2695,7 @@ describe('Vega-Lite semantic interactions', () => { it('dims independent text labels without instrumenting unrelated annotations', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category', 'Value'], categoryField: 'Category', selectableMarks: ['bar'], @@ -3407,7 +3444,7 @@ describe('Vega-Lite semantic interactions', () => { }); it('pins a themed Calendar continuous legend to its full extent', async () => { - const spec = assembleVegaLite({ + const assembleCalendar = (): any => assembleVegaLite({ data: { values: [ { Date: '2024-01-01', Activity: 26 }, { Date: '2024-01-02', Activity: 27 }, @@ -3420,8 +3457,11 @@ describe('Vega-Lite semantic interactions', () => { encodings: { x: 'Date', color: 'Activity' }, }, theme_spec: 'pop', - } as any) as any; - const { compiled } = instrument(spec, [legendToggle()]); + } as any); + expect(() => instrument(assembleCalendar(), [legendToggle()])) + .toThrow('Interaction "legend-toggle" requires a discrete legend; Calendar Heatmap has none.'); + const legendClaimer = { ...legendToggle(), preset: undefined }; + const { compiled } = instrument(assembleCalendar(), [legendClaimer]); const view = new View(parse(compiled), { renderer: 'none' }); await view.runAsync(); @@ -3631,6 +3671,7 @@ describe('Vega-Lite semantic interactions', () => { y: { field: 'Value', type: 'quantitative' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Date', 'Value'], categoryField: 'Date', selectableMarks: ['line'], @@ -3824,6 +3865,7 @@ describe('Vega-Lite semantic interactions', () => { }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Group', 'X', 'Value'], categoryField: 'Group', selectableMarks: ['line'], @@ -3889,6 +3931,7 @@ describe('Vega-Lite semantic interactions', () => { }, ], _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category'], selectableMarks: ['rect'], }, @@ -3916,6 +3959,7 @@ describe('Vega-Lite semantic interactions', () => { mark: 'geoshape', projection: { type: 'mercator' }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Region'], categoryField: 'Region', selectableMarks: ['geoshape'], @@ -3940,7 +3984,7 @@ describe('Vega-Lite semantic interactions', () => { describe('set-style visibility', () => { it('injects absolute runtime style channels keyed by semantic identity', () => { const spec: Record = { - _interactionSemantics: { fields: ['Category'], selectableMarks: ['bar'] }, + _interactionSemantics: { capabilities: ALL_CAPABILITIES, fields: ['Category'], selectableMarks: ['bar'] }, data: { values: [{ Category: 'A', Value: 1 }] }, mark: 'bar', encoding: { @@ -4045,6 +4089,7 @@ describe('set-style visibility', () => { it('pins the legend domain so a hidden series keeps a key to click', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category', 'Series'], categoryField: 'Category', legendFields: { color: 'Series' }, @@ -4072,6 +4117,7 @@ describe('set-style visibility', () => { it('pins an aggregate-sorted donut legend so hidden slices keep their keys', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['OS', 'Users'], legendFields: { color: 'OS' }, selectableMarks: ['arc'], @@ -4101,6 +4147,7 @@ describe('set-style visibility', () => { it('clips marks when a region interaction drives the viewport', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Year', 'Value'], selectableMarks: ['line'], navigationAxes: ['x', 'y'], @@ -4125,6 +4172,7 @@ describe('set-style visibility', () => { it('leaves the legend domain alone when nothing can hide a series', () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category', 'Series'], categoryField: 'Category', legendFields: { color: 'Series' }, @@ -4147,6 +4195,7 @@ describe('set-style visibility', () => { it('filters a hidden key out of the data and rescales the remaining rows', async () => { const spec: Record = { _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category'], categoryField: 'Category', selectableMarks: ['bar'], diff --git a/packages/flint-js/tests/template-interactions.test.ts b/packages/flint-js/tests/template-interactions.test.ts new file mode 100644 index 00000000..a00b3dde --- /dev/null +++ b/packages/flint-js/tests/template-interactions.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { vlAllTemplateDefs } from '../src/vegalite/templates'; + +const POLAR = ['Pie Chart', 'Donut Chart', 'Rose Chart', 'Radar Chart']; + +describe('Vega-Lite templates declare their interaction support', () => { + it('every template carries an interactions block', () => { + const missing = vlAllTemplateDefs.filter((def) => !def.interactionSupport).map((def) => def.chart); + expect(missing).toEqual([]); + }); + + it('every template resolves marks to data elements', () => { + const without = vlAllTemplateDefs.filter((def) => !def.interactionSupport?.elements).map((def) => def.chart); + expect(without).toEqual([]); + }); + + it('polar templates offer the angular region beside the cartesian one, and no axis', () => { + for (const def of vlAllTemplateDefs) { + const region = def.interactionSupport?.region ?? []; + if (POLAR.includes(def.chart)) { + expect(region, def.chart).toEqual(['cartesian', 'angular']); + expect(def.interactionSupport?.navigation, def.chart).toBeUndefined(); + expect(def.interactionSupport?.reorder, def.chart).toBeUndefined(); + } else { + expect(region, def.chart).not.toContain('angular'); + } + } + }); + + it('projected charts navigate through geo and never through a reorder axis', () => { + for (const chart of ['Map', 'Choropleth']) { + const def = vlAllTemplateDefs.find((candidate) => candidate.chart === chart)!; + expect(def.interactionSupport?.navigation).toEqual({ geo: true }); + expect(def.interactionSupport?.reorder).toBeUndefined(); + } + }); + + it('the donut inherits the pie declaration', () => { + const pie = vlAllTemplateDefs.find((def) => def.chart === 'Pie Chart')!; + const donut = vlAllTemplateDefs.find((def) => def.chart === 'Donut Chart')!; + expect(donut.interactionSupport).toBe(pie.interactionSupport); + }); +}); diff --git a/packages/flint-js/tests/validate.test.ts b/packages/flint-js/tests/validate.test.ts index eec3dcbd..f1a01bb8 100644 --- a/packages/flint-js/tests/validate.test.ts +++ b/packages/flint-js/tests/validate.test.ts @@ -204,3 +204,58 @@ describe('stripPrivateKeys', () => { expect(spec).toEqual({ width: 1, nested: { _keep: true } }); }); }); + +describe('validateChart with interaction_spec', () => { + const withInteractions = (interactions: unknown, backend: 'vegalite' | 'echarts' = 'vegalite') => + validateChart({ ...barChart, interaction_spec: { interactions } } as ChartAssemblyInput, backend); + + it('reports an entry the chart would drop as a warning and keeps the chart valid', () => { + const result = withInteractions([{ type: 'legend-toggle' }, { type: 'click-highlight' }]); + expect(result.valid).toBe(true); + const dropped = result.warnings.filter((warning) => warning.code === 'unsupported_interaction'); + expect(dropped).toHaveLength(1); + expect(dropped[0].message).toBe( + 'Interaction "legend-toggle" requires a discrete legend; Bar Chart has none. The interaction was dropped.', + ); + }); + + it('reports the triggers one entry yields to another as info, and keeps the chart valid', () => { + const coloured = { + ...barChart, + chart_spec: { ...barChart.chart_spec, encodings: { ...barChart.chart_spec.encodings, color: { field: 'region' } } }, + interaction_spec: { interactions: [ + { type: 'click-highlight' }, + { type: 'hover-group-focus', options: { groupBy: 'region' } }, + { type: 'axis-highlight' }, + { type: 'legend-toggle' }, + { type: 'drag-reorder' }, + ] }, + } as ChartAssemblyInput; + const result = validateChart(coloured, 'vegalite'); + expect(result.valid).toBe(true); + expect(result.warnings.filter((warning) => warning.severity !== 'info')).toEqual([]); + expect(result.warnings.map((warning) => warning.message)).toEqual([ + 'Interaction "click-highlight" yields legend clicks to "legend-toggle".', + 'Interaction "click-highlight" yields axis label clicks to "axis-highlight".', + ]); + }); + + it('reports a malformed spec as an error', () => { + const result = withInteractions([{ type: 'no-such-preset' }]); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatchObject({ code: 'invalid_interaction_spec' }); + expect(result.errors[0].message).toContain('no-such-preset'); + }); + + it('says a static backend ignores the spec', () => { + const result = withInteractions([{ type: 'click-highlight' }], 'echarts'); + expect(result.valid).toBe(true); + expect(result.warnings).toContainEqual(expect.objectContaining({ severity: 'info', code: 'interactions_ignored' })); + }); + + it('adds nothing without a spec', () => { + const result = validateChart(barChart, 'vegalite'); + expect(result.warnings.some((warning) => warning.code.includes('interaction'))).toBe(false); + }); +}); diff --git a/packages/flint-mcp/README.md b/packages/flint-mcp/README.md index 4e1ef0f8..1478cab4 100644 --- a/packages/flint-mcp/README.md +++ b/packages/flint-mcp/README.md @@ -26,7 +26,7 @@ renders **locally**. | `render_chart` | spec + `backend` + `format` (`png`/`svg`) + `scale?` | inline PNG image or SVG text | | `compile_chart` | spec + `backend` | backend-native spec JSON + warnings | | `validate_chart` | spec + `backend` | validity, warnings/errors, computed size | -| `list_chart_types` | `backend?` | chart types + encoding channels per backend | +| `list_chart_types` | `backend?` | chart types, encoding channels, and supported interaction presets per backend | | `list_themes` | optional preset `id` | shipped visual themes, plus guidance for a selected theme | | `create_chart_view` | spec | interactive chart **UI** (MCP App): live SVG preview + customization panel | diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index f7326fb1..dc8b0761 100644 --- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md @@ -16,6 +16,9 @@ or `assembleChartjs` to get a backend spec. - **DO** emit `chart_spec` (chart type, channel→field mapping, properties) and `semantic_types` (field → semantic type). +- **DO** add `interaction_spec` when the user asks for behaviour (highlight, + legend toggle, pan and zoom, brush). List presets by name; see + "Interactions". - **Reference columns by name.** How `data` itself gets bound depends on the situation — a URL, a host-side variable, or embedded rows (see "How data gets bound"). Embedding is fine for small tables; just don't @@ -242,6 +245,51 @@ chart input. ThemeSpec currently affects Vega-Lite only. Full reference: https://microsoft.github.io/flint-chart/#/documentation/theme-spec +## Interactions (`interaction_spec`) + +Add `interaction_spec` beside `chart_spec` only when the user asks for +behaviour: highlight on click, a legend that hides series, pan and zoom, a +brush, an annotation on click. A static image never needs it. + +```json +{ + "chart_spec": { "chartType": "Bar Chart", "encodings": { "x": "country", "y": "gdp", "color": "region" } }, + "interaction_spec": { + "interactions": [ + { "type": "click-highlight" }, + { "type": "legend-toggle" }, + { "type": "navigate", "options": { "axes": "y", "pan": false, "reset": ["double-click", "escape"] } } + ] + } +} +``` + +Rules: + +- **Presets only.** Every entry is `{ "type": , "options": { ... } }`. + Take the preset names for the chosen chart type from `list_chart_types` + (`chartTypes[].interactions`); a KPI card supports no brush, a pie chart no + `navigate`. Never invent a type. +- **Options nest under `options`.** An option beside `type` is rejected. + `id` is optional and sits on the entry, never inside `options`. +- **`reset`** is a list of `"click-none"`, `"double-click"`, `"escape"` on any + preset that keeps state. Leave it out to accept the preset's default. +- **The data decides too.** `legend-toggle` needs a colour field with a + discrete legend; `navigate` needs a continuous axis; `drag-reorder` needs a + discrete axis. An entry the chart cannot honour is dropped with a warning + and the chart still renders. Run `validate_chart` to read those warnings + before you show the chart. +- **Vega-Lite only.** Other backends ignore the spec. + +Common presets: `click-highlight` (focus a mark), `click-group-focus` +(focus its group, `groupBy`), `legend-toggle`, `navigate` (`axes`, `pan`), +`brush-x` / `brush-y` / `select` (drag to focus an interval or area), +`click-annotate`, `inspect` and `inspect-index` (read values on hover), +`drag-reorder` (reorder categories). + +Full guide: +https://microsoft.github.io/flint-chart/#/documentation/interaction-spec + ## Step 1 — pick `chartType` Use one of the registered names **exactly**. Vega-Lite is the default and diff --git a/packages/flint-mcp/src/server.ts b/packages/flint-mcp/src/server.ts index 9d073b41..4360a730 100644 --- a/packages/flint-mcp/src/server.ts +++ b/packages/flint-mcp/src/server.ts @@ -145,7 +145,8 @@ export function createServer(options: CreateServerOptions = {}): McpServer { 'when the host has no App UI support or the user explicitly wants a ' + 'static image. Use compile_chart for the backend spec JSON, ' + 'validate_chart to check a spec, and list_chart_types to discover chart ' + - 'types and their channels. Use list_themes to discover visual themes; ' + + 'types, their channels, and the interaction presets each supports for ' + + 'interaction_spec. Use list_themes to discover visual themes; ' + 'prefer a preset id, and use an `extends` override only when the user ' + 'asks to customize it. Before authoring chart specs, read the ' + 'flint://agent-skill resource or use the author_flint_chart prompt. ' + @@ -245,7 +246,8 @@ export function createServer(options: CreateServerOptions = {}): McpServer { title: 'Validate chart spec', description: 'Validate a Flint chart spec for a backend without rendering. Reports ' + - 'whether it is valid, all warnings/errors, and the computed layout size.', + 'whether it is valid, all warnings/errors, and the computed layout size. ' + + 'With an interaction_spec it also reports the entries the chart would drop.', inputSchema: { ...assemblyInputShape, backend: backendEnum }, }, async (args: any) => { @@ -264,8 +266,9 @@ export function createServer(options: CreateServerOptions = {}): McpServer { { title: 'List chart types', description: - 'List the available chart types and their encoding channels for a ' + - 'backend, or for all backends when none is given.', + 'List the available chart types, their encoding channels, and the ' + + 'interaction presets each supports in interaction_spec (Vega-Lite only), ' + + 'for a backend, or for all backends when none is given.', inputSchema: { backend: backendEnum.optional(), }, diff --git a/packages/flint-mcp/src/tools/list.ts b/packages/flint-mcp/src/tools/list.ts index 7490e8ae..47b92fa0 100644 --- a/packages/flint-mcp/src/tools/list.ts +++ b/packages/flint-mcp/src/tools/list.ts @@ -6,8 +6,10 @@ import { ecAllTemplateDefs, cjsAllTemplateDefs, listThemePresets, + supportedInteractionPresets, THEME_PRESETS, type ChartTemplateDef, + type InteractionPresetType, } from 'flint-chart'; import type { RenderBackend } from '../render/types.js'; @@ -21,6 +23,13 @@ export interface ChartTypeInfo { chartType: string; /** Encoding channels this chart type accepts (e.g. x, y, color, size). */ channels: string[]; + /** + * Interaction presets the chart type supports in `interaction_spec`, by + * declaration. The data can still remove one at mount: a legend needs a + * bound discrete legend channel, navigation needs a continuous axis. Empty + * for backends that run no interactions. + */ + interactions: InteractionPresetType[]; } export interface BackendCatalog { @@ -40,7 +49,11 @@ export function listChartTypes(backend?: RenderBackend): BackendCatalog[] { return backends.map((b) => { const defs = REGISTRY[b] ?? []; const chartTypes = defs - .map((d) => ({ chartType: d.chart, channels: d.channels ?? [] })) + .map((d) => ({ + chartType: d.chart, + channels: d.channels ?? [], + interactions: supportedInteractionPresets(d.interactionSupport), + })) .sort((a, b2) => a.chartType.localeCompare(b2.chartType)); return { backend: b, count: chartTypes.length, chartTypes }; }); diff --git a/packages/flint-mcp/src/tools/schemas.ts b/packages/flint-mcp/src/tools/schemas.ts index 294851ca..ca205ae2 100644 --- a/packages/flint-mcp/src/tools/schemas.ts +++ b/packages/flint-mcp/src/tools/schemas.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { z } from 'zod'; -import type { ChartAssemblyInput } from 'flint-chart'; +import { INTERACTION_PRESET_TYPES, type ChartAssemblyInput, type InteractionPresetType } from 'flint-chart'; /** The three backends this server can compile, validate, and render. */ export const SUPPORTED_BACKENDS = ['vegalite', 'echarts', 'chartjs'] as const; @@ -108,6 +108,29 @@ export function buildAssemblyInputShape(disableFileReference = false) { .describe( 'Visual theme for Vega-Lite. Prefer a preset id from list_themes (e.g. "economist"). To customize it, pass an object with `extends` plus a small set of overrides. Full guide: https://microsoft.github.io/flint-chart/#/documentation/theme-spec', ), + interaction_spec: z + .object({ + interactions: z + .array( + z.object({ + type: z + .enum(INTERACTION_PRESET_TYPES as unknown as [InteractionPresetType, ...InteractionPresetType[]]) + .describe('The preset that makes this interaction; also its default id.'), + id: z.string().optional().describe('Names the interaction when one chart uses the same preset twice.'), + options: z + .record(z.string(), z.any()) + .optional() + .describe('The preset\'s own options, including its `reset` gesture list ("click-none", "double-click", "escape").'), + }), + ) + .describe('One entry per interaction. Take the preset names from list_chart_types → chartTypes[].interactions for the chosen chart type.'), + assistedTargeting: z.union([z.boolean(), z.record(z.string(), z.any())]).optional(), + keyboardTargeting: z.boolean().optional(), + }) + .optional() + .describe( + 'How the chart behaves, for create_chart_view (Vega-Lite only). Lists interaction presets by type with their options nested under `options`. An entry the chart type cannot honour is dropped with a warning and the chart still renders; validate_chart reports the same warnings. Guide: https://microsoft.github.io/flint-chart/#/documentation/interaction-spec', + ), options: z .record(z.string(), z.any()) .optional() @@ -136,6 +159,7 @@ export type AssemblyInputArgs = { }; options?: Record; theme_spec?: string | Record; + interaction_spec?: Record; field_display_names?: Record; }; @@ -146,6 +170,7 @@ export function toAssemblyInput(args: AssemblyInputArgs): ChartAssemblyInput { semantic_types: args.semantic_types, chart_spec: args.chart_spec, theme_spec: args.theme_spec, + interaction_spec: args.interaction_spec, options: args.options, field_display_names: args.field_display_names, } as ChartAssemblyInput; diff --git a/packages/flint-mcp/tests/server.test.ts b/packages/flint-mcp/tests/server.test.ts index 6d575adb..14f514b0 100644 --- a/packages/flint-mcp/tests/server.test.ts +++ b/packages/flint-mcp/tests/server.test.ts @@ -152,6 +152,31 @@ describe('MCP server', () => { expect(payload[0].count).toBeGreaterThan(10); expect(payload[0].chartTypes[0]).toHaveProperty('chartType'); expect(payload[0].chartTypes[0]).toHaveProperty('channels'); + expect(payload[0].chartTypes[0]).toHaveProperty('interactions'); + const bar = payload[0].chartTypes.find((entry: any) => entry.chartType === 'Bar Chart'); + expect(bar.interactions).toContain('click-highlight'); + expect(bar.interactions).toContain('drag-reorder'); + expect(bar.interactions).not.toContain('brush-angle'); + const kpi = payload[0].chartTypes.find((entry: any) => entry.chartType === 'KPI Card'); + expect(kpi.interactions).not.toContain('legend-toggle'); + }); + + it('validate_chart reports the interaction_spec entries the chart would drop', async () => { + const res: any = await client.callTool({ + name: 'validate_chart', + arguments: { + backend: 'vegalite', + data: { values: [{ region: 'East', revenue: 120 }, { region: 'West', revenue: 90 }] }, + semantic_types: { revenue: 'Quantity' }, + chart_spec: { chartType: 'Bar Chart', encodings: { x: 'region', y: 'revenue' } }, + interaction_spec: { interactions: [{ type: 'click-highlight' }, { type: 'legend-toggle' }] }, + }, + }); + const payload = JSON.parse(res.content[0].text); + expect(payload.valid).toBe(true); + const dropped = payload.warnings.filter((warning: any) => warning.code === 'unsupported_interaction'); + expect(dropped).toHaveLength(1); + expect(dropped[0].message).toContain('"legend-toggle" requires a discrete legend; Bar Chart has none'); }); it('render_chart surfaces assembly errors as isError', async () => { diff --git a/packages/flint-mcp/ui/src/FlintApp.tsx b/packages/flint-mcp/ui/src/FlintApp.tsx index 57a9ee8e..5361776c 100644 --- a/packages/flint-mcp/ui/src/FlintApp.tsx +++ b/packages/flint-mcp/ui/src/FlintApp.tsx @@ -14,10 +14,12 @@ import type { App, McpUiHostContext } from '@modelcontextprotocol/ext-apps'; import { useApp } from '@modelcontextprotocol/ext-apps/react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import type { ChartAssemblyInput, ChartOption } from 'flint-chart'; +import type { ChartAssemblyInput, ChartOption, ChartWarning } from 'flint-chart'; import { THEME_PRESETS, DEFAULT_THEME_ICON } from 'flint-chart'; +import { buildInteractiveChart } from 'flint-chart/interactive'; +import { expressionInterpreter } from 'vega-interpreter'; -import { renderFlintSvg, type FlintRenderResult } from './render'; +import { renderFlintSvg, withAppPreviewDefaults, type FlintRenderResult } from './render'; import { chartIconFor } from './chart-icons'; import { buildPanelModel, @@ -735,6 +737,8 @@ export function FlintAppInner(props: { const [current, setCurrent] = useState(input); const [render, setRender] = useState(null); const [error, setError] = useState(null); + const [surfaceWarnings, setSurfaceWarnings] = useState([]); + const [surfaceError, setSurfaceError] = useState(null); const [copyStatus, setCopyStatus] = useState<'idle' | 'copying' | 'copied' | 'downloaded' | 'error'>('idle'); const [copyError, setCopyError] = useState(null); const renderSeq = useRef(0); @@ -875,7 +879,14 @@ export function FlintAppInner(props: { } }, [app, render]); - const warnings = render?.warnings ?? []; + const interactive = (current.interaction_spec?.interactions?.length ?? 0) > 0; + const previewInput = useMemo( + () => withAppPreviewDefaults(current, chartWidth ? { width: chartWidth } : undefined), + [current, chartWidth], + ); + const renderWarnings = render?.warnings ?? []; + const warnings = interactive ? [...renderWarnings, ...surfaceWarnings] : renderWarnings; + const shownError = error ?? (interactive ? surfaceError : null); return (
- {error ? ( + {shownError ? (
Could not render chart -
{error}
+
{shownError}
) : ( // The frame is always mounted, so its size is known before the first @@ -902,7 +913,15 @@ export function FlintAppInner(props: { style={surface ? { background: surface } : undefined} > {render - ?
+ ? interactive + ? ( + + ) + :
: Rendering…}
)} @@ -933,6 +952,51 @@ export function FlintAppInner(props: { ); } +/** The live chart when the input carries interaction_spec: the preview input, mounted through the interactive surface. */ +function InteractiveChart({ + input, + onWarnings, + onError, +}: { + input: ChartAssemblyInput; + onWarnings: (warnings: readonly ChartWarning[]) => void; + onError: (message: string | null) => void; +}) { + const mountRef = useRef(null); + useEffect(() => { + const mount = mountRef.current; + if (!mount) return; + let live = true; + let surface: ReturnType; + try { + surface = buildInteractiveChart(mount, input, { + backend: 'vegalite', + renderer: 'svg', + expressionInterpreter, + chartId: 'flint-chart-view', + }); + } catch (err) { + onError(err instanceof Error ? err.message : String(err)); + return; + } + void surface.warnings.then((list) => { + if (live) onWarnings(list); + }); + void surface.ready + .then(() => { + if (live) onError(null); + }) + .catch((err) => { + if (live) onError(err instanceof Error ? err.message : String(err)); + }); + return () => { + live = false; + surface.destroy(); + }; + }, [input, onWarnings, onError]); + return
; +} + export function FlintApp() { const [input, setInput] = useState(null); const [hostContext, setHostContext] = useState(); diff --git a/packages/flint-mcp/ui/src/render.ts b/packages/flint-mcp/ui/src/render.ts index a27387e2..38cd6c8c 100644 --- a/packages/flint-mcp/ui/src/render.ts +++ b/packages/flint-mcp/ui/src/render.ts @@ -99,7 +99,7 @@ function previewCanvasSize(viewport?: { width: number; height?: number }): { wid return { width, height }; } -function withAppPreviewDefaults( +export function withAppPreviewDefaults( input: ChartAssemblyInput, viewport?: { width: number; height?: number }, ): ChartAssemblyInput { diff --git a/packages/flint-mcp/ui/src/styles.css b/packages/flint-mcp/ui/src/styles.css index 7816369c..557144b3 100644 --- a/packages/flint-mcp/ui/src/styles.css +++ b/packages/flint-mcp/ui/src/styles.css @@ -89,6 +89,11 @@ body { display: contents; } +.chart-interactive { + display: block; + width: 100%; +} + .chart-pending { color: var(--muted); } diff --git a/scripts/gen-chart-reference.ts b/scripts/gen-chart-reference.ts index e61f5fa3..132727d7 100644 --- a/scripts/gen-chart-reference.ts +++ b/scripts/gen-chart-reference.ts @@ -13,11 +13,12 @@ * Output: docs/reference-.md */ -import { writeFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import type { ChartTemplateDef, ChartPropertyDef } from '../packages/flint-js/src/core/types'; +import { supportedInteractionPresets } from '../packages/flint-js/src/core/interaction-spec'; import type { ExcelTemplateDef } from '../packages/flint-js/src/excel/templates/types'; import { vlTemplateDefs } from '../packages/flint-js/src/vegalite/templates/index'; import { ecTemplateDefs } from '../packages/flint-js/src/echarts/templates/index'; @@ -309,6 +310,11 @@ function renderChart(def: ChartTemplateDef): string { const channels = (def.channels ?? []).map((c) => `\`${c}\``).join(', ') || '_none_'; lines.push(`**Encoding channels:** ${channels}`); lines.push(''); + if (def.interactionSupport) { + const presets = supportedInteractionPresets(def.interactionSupport).map((type) => `\`${type}\``).join(', ') || '_none_'; + lines.push(`**Interactions:** ${presets}`); + lines.push(''); + } const props = def.properties ?? []; if (props.length === 0) { @@ -357,6 +363,11 @@ function renderBackend(spec: BackendSpec): string { out.push( '- **Options** — template-specific `chart_spec.chartProperties` keys, including control type, domain, default, availability, and description.', ); + if (spec.name.toLowerCase().includes('vega')) { + out.push( + '- **Interactions** — the presets the chart type supports in `interaction_spec`. The data can still remove one at mount: a legend needs a bound discrete legend channel, navigation needs a continuous axis. See the [interaction guide](/documentation/interaction-spec).', + ); + } out.push(''); out.push('Use the chart type name exactly as shown in `chart_spec.chartType`.'); out.push(''); @@ -399,6 +410,10 @@ function renderChartZh(def: ChartTemplateDef): string { lines.push(`### ${icon ? `![](${icon}) ` : ''}${def.chart}`, ''); const channels = (def.channels ?? []).map((channel) => `\`${channel}\``).join(', ') || '_无_'; lines.push(`**编码通道:** ${channels}`, ''); + if (def.interactionSupport) { + const presets = supportedInteractionPresets(def.interactionSupport).map((type) => `\`${type}\``).join(', ') || '_无_'; + lines.push(`**交互:** ${presets}`, ''); + } const props = def.properties ?? []; if (props.length === 0) return [...lines, '_无模板专用参数。_', ''].join('\n'); lines.push('| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 说明 |', '|---|---|---|---|---|---|'); @@ -519,6 +534,45 @@ function renderExcelReference(locale: 'en' | 'zh-CN'): string { return out.join('\n') + '\n'; } +/** The declaration table of the interaction design doc: one row per Vega-Lite chart type, one column per capability. */ +function renderInteractionSupportTable(locale: 'en' | 'zh-CN'): string { + const zh = locale === 'zh-CN'; + const yes = '✓'; + const rows = Object.values(vlTemplateDefs).flat() + .sort((left, right) => left.chart.localeCompare(right.chart)) + .map((def) => { + const support = def.interactionSupport ?? {}; + const navigation = support.navigation + ? support.navigation.geo ? 'geo' : (support.navigation.axes ?? ['x', 'y']).join(', ') + : ''; + const reorder = support.reorder + ? support.reorder.includeConnectiveMarks + ? (zh ? '含连接标记' : 'connective marks') + : support.reorder.markTypes + ? `${support.reorder.markTypes.join(', ')} ${zh ? '标记' : 'marks'}` + : yes + : ''; + return `| ${def.chart} | ${support.elements ? yes : ''} | ${(support.region ?? []).join(', ')} | ${navigation} | ${reorder} | ${support.legend ? yes : ''} | ${support.discreteAxis ? yes : ''} | ${support.index ? yes : ''} |`; + }); + const header = zh + ? '| 图表类型 | elements | region | navigation | reorder | legend | discrete axis | index |' + : '| Chart type | elements | region | navigation | reorder | legend | discrete axis | index |'; + return [header, '|---|---|---|---|---|---|---|---|', ...rows].join('\n'); +} + +for (const [locale, directory] of [['en', DOCS_DIR], ['zh-CN', ZH_DOCS_DIR]] as const) { + const path = resolve(directory, 'design-interactions.md'); + const doc = readFileSync(path, 'utf8'); + const start = ''; + const end = ''; + const from = doc.indexOf(start); + const to = doc.indexOf(end); + if (from < 0 || to < 0) throw new Error(`${path}: declaration table markers not found`); + writeFileSync(path, `${doc.slice(0, from + start.length)}\n${renderInteractionSupportTable(locale)}\n${doc.slice(to)}`, 'utf8'); + // eslint-disable-next-line no-console + console.log(`Wrote the declaration table into ${locale === 'en' ? '' : 'zh-CN/'}design-interactions.md`); +} + for (const spec of BACKENDS) { const md = renderBackend(spec); const path = resolve(DOCS_DIR, spec.file); diff --git a/site/src/components/InteractiveVegaLiteView.tsx b/site/src/components/InteractiveVegaLiteView.tsx new file mode 100644 index 00000000..bed240b6 --- /dev/null +++ b/site/src/components/InteractiveVegaLiteView.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ChartAssemblyInput, ChartWarning } from 'flint-chart'; +import { buildInteractiveChart } from 'flint-chart/interactive'; +import { siteTheme } from '../shared/theme'; + +interface InteractiveVegaLiteViewProps { + input: ChartAssemblyInput; + chartId?: string; + ariaLabel?: string; +} + +/** True when the input asks for behaviour, so the chart must mount through the interactive surface. */ +export function hasInteractionEntries(input: unknown): boolean { + const spec = (input as { interaction_spec?: { interactions?: unknown[] } } | null)?.interaction_spec; + return Array.isArray(spec?.interactions) && spec.interactions.length > 0; +} + +/** Mounts a Vega-Lite chart from its input through `buildInteractiveChart`, so `interaction_spec` takes effect. */ +export function InteractiveVegaLiteView({ input, chartId, ariaLabel }: InteractiveVegaLiteViewProps) { + const ref = useRef(null); + const [warnings, setWarnings] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + const host = ref.current; + if (!host) return; + let live = true; + setWarnings([]); + setError(null); + let surface: ReturnType; + try { + surface = buildInteractiveChart(host, input, { backend: 'vegalite', renderer: 'svg', chartId, ariaLabel }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + return; + } + void surface.warnings.then((list) => { + if (live) setWarnings(list); + }); + void surface.ready.catch((err) => { + if (live) setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + live = false; + surface.destroy(); + }; + }, [input, chartId, ariaLabel]); + + return ( +
+
+ {error && ( +
{error}
+ )} + {warnings.length > 0 && ( +
    + {warnings.map((warning, index) => ( +
  • + {warning.severity}{' '} + {warning.message} +
  • + ))} +
+ )} +
+ ); +} diff --git a/site/src/components/TripleChart.tsx b/site/src/components/TripleChart.tsx index 8cd17a5e..85bfb5db 100644 --- a/site/src/components/TripleChart.tsx +++ b/site/src/components/TripleChart.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { TestCase } from 'flint-chart/test-data'; import { VegaLiteView } from './VegaLiteView'; +import { InteractiveVegaLiteView, hasInteractionEntries } from './InteractiveVegaLiteView'; import { EChartsView } from './EChartsView'; import { ChartjsView } from './ChartjsView'; import { PlotlyView } from './PlotlyView'; @@ -128,7 +129,9 @@ export function TripleChart({ > {compiled.ok ? ( <> - {backend === 'vegalite' && } + {backend === 'vegalite' && (hasInteractionEntries(input) + ? + : )} {backend === 'echarts' && } {backend === 'chartjs' && } {backend === 'plotly' && } diff --git a/site/src/main.tsx b/site/src/main.tsx index e61e48f7..837ccc9f 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -26,6 +26,8 @@ import { BandStretchingLab } from './playground/BandStretchingLab'; import { LabelExperimentLab } from './playground/LabelExperimentLab'; import { OverflowViewportLab } from './playground/OverflowViewportLab'; import { ClickFocusLab, SpecTestCasesLab } from './playground/ClickFocusLab'; +import { InteractionCoverageLab } from './playground/InteractionCoverageLab'; +import { InteractionConflictsLab } from './playground/InteractionConflictsLab'; import { AnnotationLab } from './playground/AnnotationLab'; import { InteractionDashboardLab } from './playground/InteractionDashboardLab'; import { InteractionCandidates } from './playground/InteractionCandidates'; @@ -88,7 +90,8 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/site/src/playground/ChartToExternalLab.tsx b/site/src/playground/ChartToExternalLab.tsx index c8bf1c43..871a3c23 100644 --- a/site/src/playground/ChartToExternalLab.tsx +++ b/site/src/playground/ChartToExternalLab.tsx @@ -4,7 +4,6 @@ import type { InteractionDef, SemanticTarget, } from 'flint-chart/interactive'; -import { clickHighlight, select as rectangleSelect } from 'flint-chart/interactive'; import { InteractionDemoChart } from './InteractionDemoChart'; import { countriesFixture, @@ -193,15 +192,21 @@ const demos: OutboundDemo[] = [ }, ]; +const NO_CODE_INTERACTIONS: readonly InteractionDef[] = []; + function OutboundDemoRow({ demo }: { demo: OutboundDemo }) { const [detail, setDetail] = useState(null); - const interaction: InteractionDef = useMemo( - () => demo.gesture === 'select' - ? rectangleSelect({ id: `${demo.id}-selection` }) - : clickHighlight({ id: `${demo.id}-element`, targets: ['mark'] }), - [demo.gesture, demo.id], - ); - const interactions = useMemo(() => [interaction], [interaction]); + const fixture = useMemo(() => ({ + ...demo.fixture, + input: { + ...demo.fixture.input, + interaction_spec: { + interactions: [demo.gesture === 'select' + ? { type: 'select' as const, id: `${demo.id}-selection` } + : { type: 'click-highlight' as const, id: `${demo.id}-element`, options: { targets: ['mark'] } }], + }, + }, + }), [demo]); const handleSemanticEvent = useCallback((event: FlintInteractionEventDetail) => setDetail(event), []); const records = targetRecords(detail?.event.target ?? null); const rendered = demo.render(records, demo.fixture); @@ -217,8 +222,8 @@ function OutboundDemoRow({ demo }: { demo: OutboundDemo }) {
diff --git a/site/src/playground/ClickFocusLab.tsx b/site/src/playground/ClickFocusLab.tsx index a45e56cc..fac7774b 100644 --- a/site/src/playground/ClickFocusLab.tsx +++ b/site/src/playground/ClickFocusLab.tsx @@ -45,7 +45,7 @@ import { ScaleToFit } from '../components/ScaleToFit'; import { SiteRange } from '../components/SiteRange'; import foodPrices from '../data/cpi-food-prices.json'; import { BACKENDS } from '../shared/supported-backends'; -import { testCaseToAssemblyInput } from '../shared/test-case-utils'; +import { representativeCasesByChartType, testCaseToAssemblyInput } from '../shared/test-case-utils'; import { ThemePicker } from './ThemePicker'; import { navigationDemoCases } from './navigation-demo-data'; import { gapminderRows } from './gapminder-dashboard-data'; @@ -255,24 +255,9 @@ function interactionCase(testCase: TestCase, suffix = ''): InteractionCase { } function representativeCases(): InteractionCase[] { - const byChartType = new Map(); - for (const generator of Object.values(TEST_GENERATORS)) { - let cases: TestCase[]; - try { - cases = generator(); - } catch { - continue; - } - for (const testCase of cases) { - if (!BACKENDS.vegalite.getTemplateDef(testCase.chartType)) continue; - const current = byChartType.get(testCase.chartType); - const preferred = testCase.tags?.includes('real') - && !testCase.encodingMap.column?.fieldID - && !testCase.encodingMap.row?.fieldID; - if (!current || preferred) byChartType.set(testCase.chartType, testCase); - } - } - const cases = [...byChartType.values()].map((testCase) => interactionCase(testCase)); + const cases = [...representativeCasesByChartType().values()] + .filter((testCase) => BACKENDS.vegalite.getTemplateDef(testCase.chartType)) + .map((testCase) => interactionCase(testCase)); const horizontalBar = genBarTests().find((testCase) => testCase.description.includes('Horizontal')); if (horizontalBar) cases.push(interactionCase(horizontalBar, '-horizontal')); return cases.sort((left, right) => left.chartType.localeCompare(right.chartType) || left.id.localeCompare(right.id)); diff --git a/site/src/playground/ClimatePhaseStage.tsx b/site/src/playground/ClimatePhaseStage.tsx index 4c83008e..7020e02b 100644 --- a/site/src/playground/ClimatePhaseStage.tsx +++ b/site/src/playground/ClimatePhaseStage.tsx @@ -22,7 +22,7 @@ const PLAYBACK_ID = 'climate-phase-playback'; const MARK_CLICK: CanvasInteractionDef = { id: MARK_CLICK_ID, eventSource: { ...clickTrigger, defaultAssistDistance: 12 }, - affordances: [{ target: 'mark', cursor: 'activate', hover: 'target' }], + affordances: { mark: { cursor: 'activate', hover: 'target' } }, handle() { return null; }, @@ -31,8 +31,7 @@ const MARK_CLICK: CanvasInteractionDef = { const LEGEND_CLICK: CanvasInteractionDef = { id: LEGEND_CLICK_ID, eventSource: clickTrigger, - claimsLegendActivation: true, - affordances: [{ target: 'legend-item', cursor: 'activate', hover: 'cohort' }], + affordances: { 'legend-item': { cursor: 'activate', hover: 'cohort' } }, handle() { return null; }, @@ -215,7 +214,7 @@ export function ClimatePhaseStage() { const dragInteraction = useMemo(() => ({ id: TRAJECTORY_ID, eventSource: dragTrigger(), - affordances: [{ target: 'mark', cursor: 'drag', hover: 'target' }], + affordances: { mark: { cursor: 'drag', hover: 'target' } }, handle(event) { if (event.action !== 'drag') return null; if (event.phase === 'start') setIsPlaying(false); diff --git a/site/src/playground/ExplodedDetailStage.tsx b/site/src/playground/ExplodedDetailStage.tsx index d9f3cd2f..9499acb3 100644 --- a/site/src/playground/ExplodedDetailStage.tsx +++ b/site/src/playground/ExplodedDetailStage.tsx @@ -244,7 +244,7 @@ export function FreeformExplodedDetailStage() { zoom: true, wheelSensitivity: 0.004, }, - affordances: [{ target: 'plot', cursor: 'inspect' }], + affordances: { plot: { cursor: 'inspect' } }, handle(event) { if (event.phase === 'cancel') return null; if (event.action === 'zoom-viewport') { diff --git a/site/src/playground/FisheyeZoomStage.tsx b/site/src/playground/FisheyeZoomStage.tsx index 44eeff94..ea88f9f6 100644 --- a/site/src/playground/FisheyeZoomStage.tsx +++ b/site/src/playground/FisheyeZoomStage.tsx @@ -83,7 +83,7 @@ const CHART_INPUT: ChartAssemblyInput = { const HOVER_INTERACTION: CanvasInteractionDef = { id: HOVER_ID, eventSource: { ...hoverTrigger, defaultAssistDistance: 28, targetTolerance: 28 }, - affordances: [{ target: 'mark', hover: 'target' }], + affordances: { mark: { hover: 'target' } }, handle() { // Acquisition only: the host renders the result without a Flint ChartUpdate. return null; diff --git a/site/src/playground/FlintDimpVisStage.tsx b/site/src/playground/FlintDimpVisStage.tsx index 4cbbe0b6..dfd20b2a 100644 --- a/site/src/playground/FlintDimpVisStage.tsx +++ b/site/src/playground/FlintDimpVisStage.tsx @@ -162,9 +162,7 @@ const PLAYBACK_INTERACTION_ID = 'flint-dimpvis-playback'; const MAIN_MARK_CLICK_INTERACTION: CanvasInteractionDef = { id: MAIN_MARK_INTERACTION_ID, eventSource: { ...clickTrigger, defaultAssistDistance: 12 }, - affordances: [ - { target: 'mark', cursor: 'activate', hover: 'target' }, - ], + affordances: { mark: { cursor: 'activate', hover: 'target' } }, handle() { return null; }, @@ -173,10 +171,7 @@ const MAIN_MARK_CLICK_INTERACTION: CanvasInteractionDef = { const MAIN_LEGEND_CLICK_INTERACTION: CanvasInteractionDef = { id: MAIN_LEGEND_INTERACTION_ID, eventSource: clickTrigger, - claimsLegendActivation: true, - affordances: [ - { target: 'legend-item', cursor: 'activate', hover: 'cohort' }, - ], + affordances: { 'legend-item': { cursor: 'activate', hover: 'cohort' } }, handle() { return null; }, @@ -335,7 +330,7 @@ export function FlintDimpVisStage({ large = false }: { large?: boolean } = {}) { const dragInteraction = useMemo(() => ({ id: TRAJECTORY_UPDATE_ID, eventSource: dragTrigger(), - affordances: [{ target: 'mark', cursor: 'drag', hover: 'target' }], + affordances: { mark: { cursor: 'drag', hover: 'target' } }, handle(event) { if (event.action !== 'drag') return null; if (event.phase === 'start') setIsPlaying(false); diff --git a/site/src/playground/IndexChartStage.tsx b/site/src/playground/IndexChartStage.tsx index 5cd444a5..c92399a7 100644 --- a/site/src/playground/IndexChartStage.tsx +++ b/site/src/playground/IndexChartStage.tsx @@ -3,7 +3,6 @@ import { scaleLinear, scaleUtc } from 'd3'; import type { ChartAssemblyInput } from 'flint-chart'; import { buildInteractiveChart, - inspectIndex, type FlintInteractionEventDetail, type InteractiveChartSurface, } from 'flint-chart/interactive'; @@ -54,6 +53,9 @@ function chartInput(rows: ReturnType['indexedRows' }, }, options: { addTooltips: false }, + interaction_spec: { + interactions: [{ type: 'inspect-index', id: INSPECT_INTERACTION_ID, options: { axis: 'x', show: 'all' } }], + }, chart_spec: { chartType: 'Line Chart', title: 'Index chart (Flint + D3 reference)', @@ -115,11 +117,6 @@ export function IndexChartStage() { const [cursorX, setCursorX] = useState(() => xScaleForBounds(FALLBACK_PLOT_BOUNDS)(initialState.activeDate)); const mountRef = useRef(null); const surfaceRef = useRef(null); - const inspectInteraction = useMemo(() => inspectIndex({ - id: INSPECT_INTERACTION_ID, - axis: 'x', - show: 'all', - }), []); const plotWidth = Math.max(1, plotBounds.right - plotBounds.left); const plotXScale = useMemo(() => ( scaleUtc() @@ -176,7 +173,6 @@ export function IndexChartStage() { const surface = buildInteractiveChart(mount, chartInput(initialState.indexedRows), { backend: 'vegalite', renderer: 'svg', - interactions: [inspectInteraction], ariaLabel: 'Index chart with a movable reference date', chartId: 'index-chart-stage', }); @@ -191,7 +187,7 @@ export function IndexChartStage() { surfaceRef.current = null; surface.destroy(); }; - }, [initialState.indexedRows, inspectInteraction]); + }, [initialState.indexedRows]); useEffect(() => { const surface = surfaceRef.current; diff --git a/site/src/playground/InteractionConflictsLab.tsx b/site/src/playground/InteractionConflictsLab.tsx new file mode 100644 index 00000000..cada2ccd --- /dev/null +++ b/site/src/playground/InteractionConflictsLab.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { Braces, ChevronDown, ChevronRight } from 'lucide-react'; +import type { ChartAssemblyInput, InteractionEntry } from 'flint-chart'; +import { InteractiveVegaLiteView } from '../components/InteractiveVegaLiteView'; +import { ScaleToFit } from '../components/ScaleToFit'; +import './click-focus-lab.css'; +import './interaction-conflicts.css'; + +const BARS = [ + { country: 'Viet Nam', region: 'East Asia and the Pacific', reading: 83.2 }, + { country: 'Belarus', region: 'Europe and Central Asia', reading: 82.4 }, + { country: 'Tunisia', region: 'Middle East and North Africa', reading: 66.0 }, + { country: 'Mongolia', region: 'East Asia and the Pacific', reading: 63.2 }, + { country: 'Kyrgyzstan', region: 'Europe and Central Asia', reading: 57.8 }, + { country: 'Bangladesh', region: 'South Asia', reading: 48.8 }, + { country: 'Zimbabwe', region: 'Sub-Saharan Africa', reading: 44.4 }, + { country: 'Nepal', region: 'South Asia', reading: 39.2 }, + { country: 'Ghana', region: 'Sub-Saharan Africa', reading: 21.4 }, + { country: 'Chad', region: 'Sub-Saharan Africa', reading: 4.4 }, +]; + +const POINTS = Array.from({ length: 24 }, (_, index) => ({ + x: Math.round(((index * 37) % 100) * 10) / 10, + y: Math.round(((index * 53 + 17) % 100) * 10) / 10, + group: ['North', 'South', 'East'][index % 3], +})); + +function bar(interactions: readonly InteractionEntry[], colour = true): ChartAssemblyInput { + return { + data: { values: BARS }, + semantic_types: { + country: 'Country', + region: 'Region', + reading: { semanticType: 'Percentage', intrinsicDomain: [0, 100] }, + }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { + y: { field: 'country', sortBy: 'x', sortOrder: 'descending' }, + x: { field: 'reading' }, + ...(colour ? { color: { field: 'region' } } : {}), + }, + baseSize: { width: 380, height: 260 }, + }, + interaction_spec: { interactions }, + } as ChartAssemblyInput; +} + +function scatter(interactions: readonly InteractionEntry[]): ChartAssemblyInput { + return { + data: { values: POINTS }, + semantic_types: { x: 'Number', y: 'Number', group: 'Category' }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: { field: 'x' }, y: { field: 'y' }, color: { field: 'group' } }, + baseSize: { width: 380, height: 260 }, + }, + interaction_spec: { interactions }, + } as ChartAssemblyInput; +} + +interface ConflictCase { + id: string; + title: string; + input: ChartAssemblyInput; +} + +const CASES: readonly ConflictCase[] = [ + { id: 'legend-click', title: 'click-highlight and legend-toggle', input: bar([{ type: 'click-highlight' }, { type: 'legend-toggle' }]) }, + { id: 'axis-click', title: 'click-highlight and axis-highlight', input: bar([{ type: 'click-highlight' }, { type: 'axis-highlight' }]) }, + { + id: 'focus-mark', + title: 'click-highlight and click-group-focus', + input: bar([{ type: 'click-highlight' }, { type: 'click-group-focus', options: { groupBy: 'region' } }]), + }, + { id: 'composes', title: 'click-highlight and click-annotate', input: bar([{ type: 'click-highlight' }, { type: 'click-annotate' }]) }, + { + id: 'targets-option', + title: 'click-highlight on marks only, and legend-toggle', + input: bar([{ type: 'click-highlight', options: { targets: ['mark'] } }, { type: 'legend-toggle' }]), + }, + { id: 'owner-dropped', title: 'legend-toggle on a chart with no legend', input: bar([{ type: 'click-highlight' }, { type: 'legend-toggle' }], false) }, + { id: 'region-slot', title: 'brush-x and brush-y', input: scatter([{ type: 'brush-x' }, { type: 'brush-y' }]) }, + { id: 'plot-drag', title: 'navigate and select', input: scatter([{ type: 'navigate' }, { type: 'select' }]) }, + { id: 'double-click', title: 'navigate and double-activate', input: scatter([{ type: 'navigate' }, { type: 'double-activate' }]) }, + { + id: 'reset-option', + title: 'navigate that resets on Escape, and double-activate', + input: scatter([{ type: 'navigate', options: { reset: ['escape'] } }, { type: 'double-activate' }]), + }, +]; + +function CaseCard({ item }: { item: ConflictCase }) { + const [specOpen, setSpecOpen] = useState(false); + const entries = item.input.interaction_spec?.interactions ?? []; + return ( +
+
+

{item.title}

+
+
+ + + +
+
+
+ +
+ {specOpen &&
{JSON.stringify(item.input.interaction_spec, null, 2)}
} +
+
+ ); +} + +export function InteractionConflictsLab() { + return ( +
+
+

Conflict cases

+

+ Two presets that share a trigger. The line under each chart is the warning the surface reports: + info when one entry gave up the shared trigger and kept the rest, + warning when the later entry was dropped. +

+
+
+ {CASES.map((item) => )} +
+
+ ); +} diff --git a/site/src/playground/InteractionCoverageLab.tsx b/site/src/playground/InteractionCoverageLab.tsx new file mode 100644 index 00000000..d6865a84 --- /dev/null +++ b/site/src/playground/InteractionCoverageLab.tsx @@ -0,0 +1,163 @@ +import { useMemo, useState } from 'react'; +import { + assembleVegaLite, + declaredInteractionCapabilities, + INTERACTION_CAPABILITY_DESCRIPTIONS, + INTERACTION_PRESET_REQUIREMENTS, + INTERACTION_PRESET_TYPES, + vlAllTemplateDefs, + type ChartTemplateDef, + type InteractionCapability, + type InteractionPresetType, +} from 'flint-chart'; +import type { TestCase } from 'flint-chart/test-data'; +import { INTERACTION_PRESETS } from 'flint-chart/interactive'; +import { representativeCasesByChartType, testCaseToAssemblyInput } from '../shared/test-case-utils'; +import './interaction-coverage.css'; + +type CellStatus = 'active' | 'declared' | 'unsupported'; + +type Cell = + | { status: 'active' } + | { status: 'declared' | 'unsupported'; missing: InteractionCapability }; + +interface Row { + chartType: string; + caseTitle?: string; + declared: readonly InteractionCapability[]; + active: readonly InteractionCapability[]; + assembleError?: string; + cells: Record; +} + + +function rowFor(def: ChartTemplateDef, testCase: TestCase | undefined): Row { + const declared = declaredInteractionCapabilities(def.interactionSupport); + let active: readonly InteractionCapability[] = []; + let assembleError: string | undefined; + if (testCase) { + try { + const spec = assembleVegaLite(testCaseToAssemblyInput(testCase)) as any; + active = spec._interactionSemantics?.capabilities ?? []; + } catch (error) { + assembleError = error instanceof Error ? error.message : String(error); + } + } + const declaredSet = new Set(declared); + const activeSet = new Set(active); + const cells = Object.fromEntries(INTERACTION_PRESET_TYPES.map((type) => { + const requires = INTERACTION_PRESET_REQUIREMENTS[type]; + const missingDeclared = requires.find((capability) => !declaredSet.has(capability)); + if (missingDeclared) return [type, { status: 'unsupported', missing: missingDeclared }]; + const missingActive = requires.find((capability) => !activeSet.has(capability)); + if (missingActive) return [type, { status: 'declared', missing: missingActive }]; + return [type, { status: 'active' }]; + })) as Record; + return { chartType: def.chart, caseTitle: testCase?.title, declared, active, assembleError, cells }; +} + +const GLYPH: Record = { active: '●', declared: '○', unsupported: '×' }; + +function cellTitle(row: Row, type: InteractionPresetType, cell: Cell): string { + const label = INTERACTION_PRESETS[type].label; + if (cell.status === 'active') return `${label} on ${row.chartType}: supported, and active for "${row.caseTitle ?? 'this case'}".`; + if (cell.status === 'declared') { + return `${label} on ${row.chartType}: supported by the chart type, but "${row.caseTitle ?? 'this case'}" lacks ${INTERACTION_CAPABILITY_DESCRIPTIONS[cell.missing]}. A spec entry is dropped for this data.`; + } + return `${label} on ${row.chartType}: not supported. The chart type never offers ${INTERACTION_CAPABILITY_DESCRIPTIONS[cell.missing]}.`; +} + +export function InteractionCoverageLab() { + const [filter, setFilter] = useState(''); + const rows = useMemo(() => { + const cases = representativeCasesByChartType(); + return [...vlAllTemplateDefs] + .sort((left, right) => left.chart.localeCompare(right.chart)) + .map((def) => rowFor(def, cases.get(def.chart))); + }, []); + const visible = filter.trim() + ? rows.filter((row) => row.chartType.toLowerCase().includes(filter.trim().toLowerCase())) + : rows; + const tally = visible.reduce((counts, row) => { + for (const cell of Object.values(row.cells)) counts[cell.status] += 1; + return counts; + }, { active: 0, declared: 0, unsupported: 0 } as Record); + + return ( +
+
+

Interaction coverage

+

+ Every Vega-Lite chart type against every interaction preset. A chart type declares the properties it + offers in its template; a preset declares the properties it needs in the registry. A filled dot means the + preset is supported and active for the chart type's representative test case. A hollow dot means the chart + type supports it, but this case's data lacks a property, so a spec entry would be dropped for this data. + A cross means the chart type never offers what the preset needs. +

+
+ {visible.length} chart types + {INTERACTION_PRESET_TYPES.length} presets + {tally.active} active + {tally.declared} supported, inactive for this data + {tally.unsupported} unsupported + {tally.active + tally.declared} supported by declaration +
+ +
+
+ + + + + + {INTERACTION_PRESET_TYPES.map((type) => ( + + ))} + + + + {visible.map((row) => ( + + + + {INTERACTION_PRESET_TYPES.map((type) => { + const cell = row.cells[type]; + return ( + + ); + })} + + ))} + +
Chart typeDeclared properties + {type} +
+ {row.chartType} + {row.assembleError && !} + + {row.declared.map((capability) => ( + + {capability} + + ))} + + {GLYPH[cell.status]} +
+
+
+ ); +} diff --git a/site/src/playground/MapSemanticZoomStage.tsx b/site/src/playground/MapSemanticZoomStage.tsx index ec426b4f..1ae90b06 100644 --- a/site/src/playground/MapSemanticZoomStage.tsx +++ b/site/src/playground/MapSemanticZoomStage.tsx @@ -40,7 +40,7 @@ const FLY_MS = 700; const CLICK_REGION: CanvasInteractionDef = { id: CLICK_ID, eventSource: clickTrigger, - affordances: [{ target: 'mark', cursor: 'activate', hover: 'target' }], + affordances: { mark: { cursor: 'activate', hover: 'target' } }, handle() { return null; }, diff --git a/site/src/playground/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index 27aceb74..bf4e2f6c 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -29,6 +29,8 @@ const pages: NavEntry[] = [ children: [ { to: 'click-focus', label: 'Test cases' }, { to: 'spec-test-cases', label: 'Spec test cases' }, + { to: 'interaction-coverage', label: 'Coverage' }, + { to: 'interaction-conflicts', label: 'Conflict cases' }, { to: 'bespoke-interaction', label: 'Advanced prototypes' }, { to: 'annotation-lab', label: 'Annotation lab' }, { to: 'interaction-candidates', label: 'References' }, diff --git a/site/src/playground/TimeboxStage.tsx b/site/src/playground/TimeboxStage.tsx index c9241df6..bf3acb91 100644 --- a/site/src/playground/TimeboxStage.tsx +++ b/site/src/playground/TimeboxStage.tsx @@ -137,7 +137,7 @@ export function TimeboxStage() { const timeboxInteraction = useMemo(() => ({ id: TIMEBOX_INTERACTION_ID, eventSource: rectangleTrigger('contain'), - affordances: [{ target: 'plot', cursor: 'region' }], + affordances: { plot: { cursor: 'region' } }, handle(event) { if (event.action !== 'select-region' || event.phase !== 'commit') return null; const nextSelection = selectionFromEvent(event); diff --git a/site/src/playground/YouDrawItStage.tsx b/site/src/playground/YouDrawItStage.tsx index 4da29146..df747776 100644 --- a/site/src/playground/YouDrawItStage.tsx +++ b/site/src/playground/YouDrawItStage.tsx @@ -128,7 +128,7 @@ export function YouDrawItStage() { const drawInteraction = useMemo(() => ({ id: DRAW_INTERACTION_ID, eventSource: lassoTrigger('contain', false), - affordances: [{ target: 'plot', cursor: 'draw' }], + affordances: { plot: { cursor: 'draw' } }, handle(event): ChartUpdate | null { if (event.action !== 'select-lasso') return null; if (event.phase === 'start') return null; diff --git a/site/src/playground/interaction-conflicts.css b/site/src/playground/interaction-conflicts.css new file mode 100644 index 00000000..47be6c4b --- /dev/null +++ b/site/src/playground/interaction-conflicts.css @@ -0,0 +1,4 @@ +/* The Conflict cases page reuses the Test cases card; this rule only trims its header. */ +.icf-header { + min-height: 0; +} diff --git a/site/src/playground/interaction-coverage.css b/site/src/playground/interaction-coverage.css new file mode 100644 index 00000000..0be73b9e --- /dev/null +++ b/site/src/playground/interaction-coverage.css @@ -0,0 +1,174 @@ +.ic-page { + gap: 20px; +} + +.ic-page .dev-page-heading p { + max-width: 760px; + margin: 7px 0 0; + color: #66707a; + font-size: 13px; + line-height: 1.5; +} + +.ic-summary { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-top: 10px; + color: #737d86; + font-size: 11px; +} + +.ic-summary strong { + margin-right: 3px; + color: #1f2328; +} + +.ic-summary-ok strong { color: #3f6b57; } +.ic-summary-warn strong { color: #806327; } + +.ic-filter { + display: grid; + gap: 5px; + width: 220px; + margin-top: 14px; + color: #66707a; + font-size: 10px; + font-weight: 600; +} + +.ic-filter input { + padding: 5px 8px; + border: 1px solid #cfd5da; + border-radius: 6px; + font: inherit; + font-size: 12px; + font-weight: 400; + color: #1f2328; + background: #fff; +} + +.ic-table-wrap { + max-height: calc(100vh - 220px); + overflow: auto; + border: 1px solid #d8dde2; + border-radius: 8px; + background: #fff; +} + +.ic-table { + border-collapse: separate; + border-spacing: 0; + font-size: 12px; + color: #1f2328; +} + +.ic-table th, +.ic-table td { + border-bottom: 1px solid #eef1f4; + padding: 6px 8px; + text-align: left; + white-space: nowrap; +} + +.ic-table thead { + position: sticky; + top: 0; + z-index: 4; + background: #f6f8fa; + box-shadow: 0 1px 0 #d8dde2; +} + +.ic-table thead th { + background: transparent; + color: #66707a; + font-size: 11px; + font-weight: 600; + vertical-align: bottom; +} + +.ic-table th.ic-preset { + position: relative; + height: 112px; + width: 44px; + min-width: 44px; + padding: 0; + overflow: visible; +} + +.ic-table thead th.ic-preset:last-child { + padding-right: 96px; +} + +.ic-table th.ic-preset span { + position: absolute; + left: 14px; + bottom: 8px; + transform: rotate(-45deg); + transform-origin: 0 100%; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + font-weight: 500; +} + +.ic-table th.ic-chart { + position: sticky; + left: 0; + z-index: 2; + min-width: 170px; + background: #fff; + font-weight: 600; +} + +.ic-table thead th.ic-chart { + z-index: 3; + background: #f6f8fa; +} + +.ic-table thead th.ic-caps { + background: #f6f8fa; +} + +.ic-table td.ic-caps { + max-width: 260px; + white-space: normal; +} + +.ic-cap { + display: inline-block; + margin: 1px 3px 1px 0; + padding: 1px 6px; + border-radius: 999px; + border: 1px solid #d8dde2; + color: #806327; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10px; +} + +.ic-cap-active { + color: #3f6b57; + border-color: #b9d3c6; + background: #eef6f1; +} + +.ic-cell { + width: 44px; + min-width: 44px; + text-align: center !important; + font-size: 14px; + cursor: help; +} + +.ic-cell-unsupported { + font-size: 12px; +} + +.ic-cell-active { color: #3f6b57; } +.ic-cell-declared { color: #806327; } +.ic-cell-unsupported { color: #c4cbd2; } + +.ic-error { + color: #cf222e; + font-weight: 700; +} diff --git a/site/src/routes/Editor.tsx b/site/src/routes/Editor.tsx index f9306a22..64ea62b7 100644 --- a/site/src/routes/Editor.tsx +++ b/site/src/routes/Editor.tsx @@ -5,6 +5,7 @@ import { SiteShell } from '../components/SiteShell'; import { JsonCodeMirror } from '../components/JsonCodeMirror'; import { ResizeSplit } from '../components/ResizeSplit'; import { VegaLiteView } from '../components/VegaLiteView'; +import { InteractiveVegaLiteView, hasInteractionEntries } from '../components/InteractiveVegaLiteView'; import { EChartsView } from '../components/EChartsView'; import { ChartjsView } from '../components/ChartjsView'; import { EXAMPLES } from './editor-examples'; @@ -300,7 +301,9 @@ function PreviewPane({ ) : compiled?.ok ? ( <> - {backend === 'vegalite' && } + {backend === 'vegalite' && (hasInteractionEntries(parsed.value) + ? + : )} {backend === 'echarts' && } {backend === 'chartjs' && } diff --git a/site/src/routes/editor-examples.ts b/site/src/routes/editor-examples.ts index 45ea8893..4827ee8a 100644 --- a/site/src/routes/editor-examples.ts +++ b/site/src/routes/editor-examples.ts @@ -27,3 +27,20 @@ export const EXAMPLES: Example[] = GALLERY_PICKS.flatMap(({ name, generator }) = if (!testCase) return []; return [{ name, input: testCaseToAssemblyInput(testCase) }]; }); + +const interactiveCase = TEST_GENERATORS['Gallery: Stacked Bar']?.()[0]; +if (interactiveCase) { + EXAMPLES.push({ + name: 'Interactive bar', + input: testCaseToAssemblyInput({ + ...interactiveCase, + interactionSpec: { + interactions: [ + { type: 'click-highlight' }, + { type: 'legend-toggle' }, + { type: 'navigate', options: { axes: 'y', pan: false, reset: ['double-click', 'escape'] } }, + ], + }, + }), + }); +} diff --git a/site/src/shared/docs-catalog.ts b/site/src/shared/docs-catalog.ts index 5069074f..ac999049 100644 --- a/site/src/shared/docs-catalog.ts +++ b/site/src/shared/docs-catalog.ts @@ -42,6 +42,12 @@ export const DOCUMENTATION_GROUPS: DocGroup[] = [ description: 'Use a preset, define a design system, or inherit and override a shipped theme.', file: '../../../docs/theme-spec.md', }, + { + slug: 'interaction-spec', + title: 'Using interactions', + description: 'List interaction presets in interaction_spec, set their reset gestures, and read what each chart type supports.', + file: '../../../docs/interaction-spec.md', + }, { slug: 'setup-flint-mcp', title: 'Set up Flint MCP', @@ -94,6 +100,12 @@ export const DOCUMENTATION_GROUPS: DocGroup[] = [ description: 'Spring, gas-pressure, radial, and area sizing models.', file: '../../../docs/design-stretch-model.md', }, + { + slug: 'interaction-design', + title: 'Interaction Design', + description: 'Presets, the interaction spec, chart semantics, capabilities and admission, reset, and the mount pipeline.', + file: '../../../docs/design-interactions.md', + }, { slug: 'api-reference', title: 'API reference', diff --git a/site/src/shared/test-case-utils.ts b/site/src/shared/test-case-utils.ts index d069f105..40234c07 100644 --- a/site/src/shared/test-case-utils.ts +++ b/site/src/shared/test-case-utils.ts @@ -1,4 +1,4 @@ -import type { TestCase } from 'flint-chart/test-data'; +import { TEST_GENERATORS, type TestCase } from 'flint-chart/test-data'; import type { ThemeSpec } from 'flint-chart'; import { themeOwnsContinuousColor } from './theme-color'; @@ -102,6 +102,7 @@ export function testCaseToAssemblyInput(t: TestCase, canvasSize: CanvasSize = DE }, options: t.assembleOptions, semantic_annotations: t.semanticAnnotations, + ...(t.interactionSpec ? { interaction_spec: t.interactionSpec } : {}), // Data goes last so the compact, frequently-edited spec stays at the top of // the editor and the bulky values array sits at the bottom. data: { values: t.data }, @@ -203,3 +204,29 @@ export function withHouse }>( ...(theme ? { theme_spec: theme } : {}), } as T; } + +/** + * The case each chart type shows first: a real, unfaceted one when there is one, else the first. + * One pass over every generator; the labs index the result by chart type. + */ +export function representativeCasesByChartType(): Map { + const byChartType = new Map(); + const settled = new Set(); + for (const generator of Object.values(TEST_GENERATORS)) { + let cases: TestCase[]; + try { + cases = generator(); + } catch { + continue; + } + for (const testCase of cases) { + if (settled.has(testCase.chartType)) continue; + const preferred = testCase.tags?.includes('real') + && !testCase.encodingMap.column?.fieldID + && !testCase.encodingMap.row?.fieldID; + if (preferred || !byChartType.has(testCase.chartType)) byChartType.set(testCase.chartType, testCase); + if (preferred) settled.add(testCase.chartType); + } + } + return byChartType; +}