Skip to content

feat(interactions): declarative interaction_spec for specifying chart interactions with presets - #124

Open
xavier (xavier-shaw) wants to merge 9 commits into
devfrom
feat/interactive-api
Open

xavier (xavier-shaw) wants to merge 9 commits into
devfrom
feat/interactive-api

Conversation

@xavier-shaw

@xavier-shaw xavier (xavier-shaw) commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Charts can now ask for behaviour in the same JSON document that asks for the chart. A new top-level field, interaction_spec, lists interaction presets by name with their options, plus the surface policies. Retained state stays a host signal (applyUpdate, setUpdates, dispatch), not part of the spec. A registry maps each name to the factory code already calls, so a spec entry and a factory call are two spellings of one definition. The mount decides what the chart can honour: a spec entry it cannot honour is dropped with a warning, and the chart still renders.

"interaction_spec": {
  "interactions": [
    { "type": "legend-toggle" },
    { "type": "click-highlight", "options": { "dimOpacity": 0.2 } },
    { "type": "navigate", "options": { "axes": "x", "pan": false, "reset": ["double-click", "escape"] } }
  ]
}

Design notes and the decision record: docs/design-interaction-spec.md.

Commits

  1. speccore/interaction-spec.ts: the InteractionSpec contract, interaction_spec on ChartAssemblyInput. The four surface-policy interfaces move to core; flint-chart/interactive re-exports them under their old names.
  2. registryINTERACTION_PRESETS (name → factory, capability, gesture) and resolveInteractionSpec(). Entries are { type, id?, options }; the resolver rejects an unknown type, a flat option outside options, a missing required option, and a duplicate id, naming the entry each time.
  3. admissionadmitInteractions() extracted from the inline checks in addVegaLiteInteractions(). Code definitions still throw with the same messages. Spec entries are dropped with a ChartWarning (unsupported_interaction, conflicting_interactions); in a pan-versus-drag conflict the later entry yields. The plan carries the admitted list and the Vega mount runs it.
  4. composecomposeInteractiveOptions(): spec first, then code; an id shared by both is an error; code wins on dismiss, assistedTargeting, keyboardTargeting; a non-Vega-Lite backend ignores the spec with one info warning. The surface exposes surface.warnings and logs the list once.
  5. refactorupdates removed from the spec after review: retained state arrives from outside the chart, so it stays on options.updates and the surface methods.
  6. reset, step 1 — every preset that retains state accepts reset, a list of click-none, double-click, escape; the registry records each preset's supported and default lists; the resolver rejects an unknown or unsupported gesture by name; navigate renames click-background to click-none.
  7. reset, step 2 — one reset dispatcher in the Vega runtime replaces the global dismiss policy: a gesture resets only the interactions whose list holds it, each by its own id; legend-toggle drops closure state through onReset(); a chart with an escape reset becomes focusable and takes focus on a pointer press; double-activate next to a double-click reset is an admission conflict.
  8. reset, step 3dismiss is removed from the spec and from the code options; the resolver rejects the key with a hint.
  9. site — a Spec test cases tab in the Interactions lab (playground/spec-test-cases). It renders the same cases as Test cases, but every chart mounts from interaction_spec; modeSpec() mirrors modeInteractions() entry for entry. Each card shows the JSON it used in a foldable, token-coloured panel with a copy button, dropped entries appear in a callout, and the page tallies ready and dropped cards.

Behaviour guarantees

  • Breaking: options.dismiss is removed. Write reset on the interaction, for example clickHighlight({ reset: ['escape'] }) or { "type": "click-highlight", "options": { "reset": ["escape"] } }. Every other caller of buildInteractiveChart() and the factories is unchanged, and the code path keeps every exception it had.
  • Vega-Lite only, as for all interactions today.
  • The core bundle gains the preset names and the JSON types and none of the runtime.

Verification

  • 70 test files, 1603 tests pass; 42 new tests across interaction-spec, interaction-admission, and interaction-compose.
  • Each commit was checked out alone in a worktree and type-checked and tested on its own.
  • Parity check between the two lab tabs: a headless browser clicked every mode on Test cases and on Spec test cases and read each card's status. 23 modes, 837 cards per tab, 0 differences.
  • Browser check of the reset dispatcher on the Spec test cases tab: a click on empty space clears a highlight; Escape resets only the focused chart of two; a hidden legend series survives a click on nothing; a double-click flies a zoom home; Escape clears a brush.
  • Browser check on the site dev server: a bar chart mounted from a spec with brush-angle, navigate on x, click-highlight, and a seeded annotation rendered with the annotation in place, reported the two dropped entries on surface.warnings and once on the console, and a click emphasised a bar with the flint-interaction event carrying click-highlight.

Upcoming work (not in this PR)

  • Admission per chart type. Each template declares which presets it supports, next to navigation and reorder; admitInteractions() reads that first. Today's checks infer support from navigation axes, region gestures, and element semantics only.
  • Phase 2: validation and discovery. validateChart() reports the same warnings; the MCP schema gains interaction_spec; list_chart_types gains interactions per chart type; the chart-author skill, docs/api-reference.md, a new docs/interaction-spec.md, and the generated chart reference gain sections.
  • Spec API lab page. A coverage matrix of chart type by preset, a spec editor with a warnings panel, a two-spellings comparison, and merge-rule toggles.
  • Phase 3: hosts and extensions. The MCP chart view and the site editor mount interactive charts from a spec; a declarative text template for click-annotate; an external-select binding; guide colours grounded from theme_spec.

🤖 Generated with Claude Code

Adds the JSON shape for declarative interactions beside chart_spec and
theme_spec: a list of preset entries { type, id?, options }, retained
updates applied at mount, and the surface policies. The four policy
interfaces move to core so the contract needs no runtime import;
flint-chart/interactive re-exports them under their old names. Includes
the design document that records the decisions.
INTERACTION_PRESETS maps each preset name to the factory code calls
today, with the capability it needs and the gesture it uses.
resolveInteractionSpec turns interaction_spec entries into the same
CanvasInteractionDef values, tagged origin 'spec', with the type name as
the default id. It rejects an unknown type, a flat option outside
"options", a missing required option, and a duplicate id, and names the
entry in every message.
The checks addVegaLiteInteractions made inline move to
admitInteractions. A code definition still throws with the same
message. A spec entry the chart cannot honour is dropped and reported as
a ChartWarning; in a pan-versus-drag conflict the later entry yields.
The plan carries the admitted list and the warnings, the Vega mount runs
the admitted list, and the renderer exposes the warnings.
composeInteractiveOptions merges the spec with the code options: the
spec comes first, an id shared by both sources is an error, the code
wins on the surface policies, and a backend that runs no interactions
ignores the spec with one info warning. The surface exposes
surface.warnings, resolved after ready, and logs the list once.
…nteraction_spec

The Test cases lab gains a `source` and a second route, spec-test-cases,
that mounts every case from `input.interaction_spec` instead of factory
calls. `modeSpec()` mirrors `modeInteractions()` entry for entry, with the
surface policies in the spec. Each card shows the JSON it mounted from in
a foldable, token-coloured panel with a copy button, reports dropped
entries in a callout, and the page tallies ready and dropped cards. A
headless comparison of both tabs found no status difference across 23
modes and 837 cards.
@Chenglong-MS

Copy link
Copy Markdown
Contributor

Let's move update / dismiss out of the interaction API?

Retained state leaves the spec. An update says what the chart shows now
and arrives from outside: a host seeds a view, a story scrolls to a
sentence, an agent answers a question. The code already treats it that
way through options.updates, applyUpdate, setUpdates, and dispatch, so
the spec keeps interactions and the surface policies and nothing else.
The design document records the decision and notes the next step: a
per-interaction reset list that replaces the global dismiss policy.
… state

Step 1 of replacing the global dismiss policy with a per-interaction
reset. Three gestures, defined by what the reader does and nothing else:
click-none (a click whose hit resolves to no element), double-click
(anywhere), and escape. Every preset that retains state accepts `reset`
and normalises it onto its definition; presets that retain nothing have
no such option. The registry records each preset's supported and default
lists, the resolver rejects an unknown or unsupported gesture by name,
and navigate renames click-background to click-none (a margin click now
resets too). The runtime still honours only navigate's list; the
dispatcher that reads the others is step 2.
…r interaction

Step 2 of replacing the global dismiss policy. A click that hits nothing,
a double-click, or Escape now resets only the interactions whose reset
list holds that gesture, each by its own id. Host updates and the other
interactions keep their state; legend-toggle drops its closure through
onReset(). navigate flies home through its navigation path and the
stateful brush clears through the gesture's own reset(), so the special
double-click and Escape paths in the two gesture modules go away. 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.
…t lists own it

The global dismiss policy is gone from interaction_spec, from
buildInteractiveChart, the surface, and the Vega renderer options, and
the InteractionDismissPolicy type with it. Each interaction's reset list
already decides what a click on nothing, a double-click, or Escape
clears. The resolver rejects a spec that still carries dismiss or
updates and points at the replacement. The two callers in the repo
needed nothing in exchange: the Test cases lab passed the values the
presets default to, and the you-draw-it demo mounts a hand-built
definition with no reset list plus host updates, which no gesture
resets.
@xavier-shaw

Copy link
Copy Markdown
Collaborator Author

The update and dismiss are out of the API spec now.

updates left the spec in a8747bc. State arrives from the host through applyUpdate, setUpdates, dispatch, or options.updates. The spec describes behavior only.

dismiss left the spec and the code options in 1d5927c. Each interaction now owns a reset list (click-none, double-click, escape), so a gesture clears only the interactions that list it. The resolver rejects a spec that still carries either field.

@xavier-shaw xavier (xavier-shaw) changed the title feat(interactions): declarative interaction_spec with a preset registry and admission at mount feat(interactions): create declarative interaction_spec for specifying chart interactions with presets Sep 16, 2026
@xavier-shaw xavier (xavier-shaw) changed the title feat(interactions): create declarative interaction_spec for specifying chart interactions with presets feat(interactions): declarative interaction_spec for specifying chart interactions with presets Sep 16, 2026
@xavier-shaw
xavier (xavier-shaw) added this pull request to stack #128 September 16, 2026 00:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants