From 33d7575522eb276398d11f303574bd4003ba2ecf Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Sat, 12 Sep 2026 17:18:16 -0700 Subject: [PATCH 01/18] feat(interactions): chart capabilities named once, preset requirements as a list Eight chart capabilities (elements, cartesian-region, angular-region, navigation, reorder, legend, discrete-axis, index) move to core as INTERACTION_CAPABILITIES, and ChartInteractionSupport describes what a chart type offers; ChartTemplateDef gains an optional interactions block for it. Each registry entry lists the capabilities its preset needs in requires, and every wrapper stamps preset on the definition it returns so a definition made in code can be checked against the same list as a spec entry. Nothing reads the declarations yet. --- packages/flint-js/src/core/index.ts | 3 + .../flint-js/src/core/interaction-spec.ts | 42 ++++++++++++++ packages/flint-js/src/core/types.ts | 5 +- .../flint-js/src/interactive/interactions.ts | 51 ++++++++++------- .../flint-js/src/interactive/spec/registry.ts | 57 ++++++++----------- 5 files changed, 104 insertions(+), 54 deletions(-) diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index b1961590..3ceb8ac5 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -201,7 +201,10 @@ export { isRegistered, getRegisteredTypes } from './type-registry'; // Declarative interactions: the JSON contract read by flint-chart/interactive export { INTERACTION_PRESET_TYPES, + INTERACTION_CAPABILITIES, 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..274302df 100644 --- a/packages/flint-js/src/core/interaction-spec.ts +++ b/packages/flint-js/src/core/interaction-spec.ts @@ -37,6 +37,48 @@ 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. */ +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 a chart type offers to interaction presets, declared on + * `ChartTemplateDef.interactions`. 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; +} + /** * 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..c8845960 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. @@ -920,6 +920,9 @@ export interface ChartTemplateDef { markTypes?: readonly string[]; }; + /** What this chart type offers to interaction presets; absent means none. */ + interactions?: ChartInteractionSupport; + /** * How the primary mark encodes its quantitative value. * Determines zero-baseline, scale tightness, and compression behavior. diff --git a/packages/flint-js/src/interactive/interactions.ts b/packages/flint-js/src/interactive/interactions.ts index 23a595ce..f313caec 100644 --- a/packages/flint-js/src/interactive/interactions.ts +++ b/packages/flint-js/src/interactive/interactions.ts @@ -8,6 +8,7 @@ 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 { InteractionCapability, InteractionPresetType } from '../core/interaction-spec'; import type { InspectIndexShow, InspectMode } from './triggers'; import type { InspectGuideOptions, RegionGuideOptions } from './guides'; import type { InteractionAffordance } from './affordances'; @@ -103,6 +104,10 @@ 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; + /** Chart capabilities a custom definition needs; a preset carries them through the registry instead. */ + readonly requires?: readonly InteractionCapability[]; /** 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. */ @@ -301,6 +306,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 +320,90 @@ 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)); } 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/spec/registry.ts b/packages/flint-js/src/interactive/spec/registry.ts index eb70c4b7..1e1e0bf9 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_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,8 @@ export interface InteractionPresetDefinition Date: Sat, 12 Sep 2026 17:18:16 -0700 Subject: [PATCH 02/18] feat(templates): every Vega-Lite template declares its interaction support The interactions block on ChartTemplateDef replaces the navigation and reorder fields and the supportedRegionGestures entry of semanticInteractions. All 36 chart types declare what they offer: marks that resolve to data, the drag regions, the navigable and reorderable axes, the legend, the discrete axis labels, and the index axis. The assembler reads the block for navigation axes, reorder axes, and region gestures, so the compiled facts are unchanged; a test asserts that every template carries the block. The design doc records the table with the source of each cell and the judgment calls open for review. --- docs/adding-a-chart-template.md | 7 ++ docs/design-interaction-spec.md | 84 +++++++++++++++++++ packages/flint-js/src/core/types.ts | 26 ++---- packages/flint-js/src/vegalite/assemble.ts | 23 +++-- .../flint-js/src/vegalite/templates/area.ts | 16 +++- .../src/vegalite/templates/bar-table.ts | 7 ++ .../flint-js/src/vegalite/templates/bar.ts | 49 +++++++++-- .../flint-js/src/vegalite/templates/bullet.ts | 7 ++ .../flint-js/src/vegalite/templates/bump.ts | 10 ++- .../src/vegalite/templates/calendar.ts | 4 + .../src/vegalite/templates/candlestick.ts | 7 +- .../vegalite/templates/connected-scatter.ts | 7 +- .../src/vegalite/templates/density.ts | 8 +- .../flint-js/src/vegalite/templates/ecdf.ts | 8 +- .../flint-js/src/vegalite/templates/gantt.ts | 9 +- .../flint-js/src/vegalite/templates/jitter.ts | 9 +- .../src/vegalite/templates/kpi-card.ts | 3 + .../flint-js/src/vegalite/templates/line.ts | 9 +- .../src/vegalite/templates/lollipop.ts | 9 +- .../flint-js/src/vegalite/templates/map.ts | 14 +++- .../flint-js/src/vegalite/templates/pie.ts | 6 +- .../flint-js/src/vegalite/templates/radar.ts | 7 +- .../src/vegalite/templates/range-area.ts | 9 +- .../flint-js/src/vegalite/templates/rose.ts | 7 +- .../src/vegalite/templates/scatter.ts | 33 ++++++-- .../flint-js/src/vegalite/templates/slope.ts | 10 ++- .../src/vegalite/templates/sparkline.ts | 7 +- .../flint-js/src/vegalite/templates/violin.ts | 6 +- .../src/vegalite/templates/waterfall.ts | 10 ++- .../tests/semantic-interactions.test.ts | 26 +++--- .../tests/template-interactions.test.ts | 43 ++++++++++ 31 files changed, 403 insertions(+), 77 deletions(-) create mode 100644 packages/flint-js/tests/template-interactions.test.ts diff --git a/docs/adding-a-chart-template.md b/docs/adding-a-chart-template.md index fc5dda79..5a7d55c1 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'], + interactions: { + 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. **`interactions`** — 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/design-interaction-spec.md b/docs/design-interaction-spec.md index b5d22dbe..74cd5614 100644 --- a/docs/design-interaction-spec.md +++ b/docs/design-interaction-spec.md @@ -540,3 +540,87 @@ survives as a deprecated code option that maps onto every interaction that reset 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. + +## 11. Stage A: admission per chart type + +Landed on `feat/interaction-admission` (2026-09-12). Before this stage, admission inferred +support from the presence of `semanticInteractions` (true for all 36 Vega-Lite templates), +the navigable axes, and the polar region flag. A probe over the first test case of nine chart +types admitted `click-highlight`, `brush-x`, `select`, `legend-toggle`, `drag-reorder`, +`axis-highlight`, and `inspect-index` on every one of them, KPI Card and Pie Chart included. +The registry's `requires` was written but never read. + +### Design + +Three parts, each in one place: + +1. **A vocabulary of chart capabilities** (`InteractionCapability`, core): a fact some preset + reads at runtime. `elements` (marks resolve to data), `cartesian-region`, `angular-region`, + `navigation`, `reorder`, `legend`, `discrete-axis`, `index` (one x position reads every + series). +2. **Per preset, `requires`** in the registry, now a list: the smallest set without which the + preset does nothing. Brushes need `elements` and a region; `brush-zoom` and `navigate` need + `navigation`; `legend-toggle` needs `legend`; `axis-highlight` needs `discrete-axis`; + `drag-reorder` needs `reorder`; `inspect-index` needs `index`; the click, hover, and inspect + presets need `elements`. Each wrapper stamps `preset` on its definition so a code-made + definition is checked the same way; a custom definition may state `requires` itself. +3. **Per template, `interactions`** on `ChartTemplateDef`: one block that absorbed the former + `navigation` and `reorder` fields and the `supportedRegionGestures` entry of + `semanticInteractions`. An absent key means never. The assembler confirms the + data-dependent capabilities against the bound encodings and writes the active list into + `_interactionSemantics.capabilities`; admission requires every entry of a preset's + `requires` to be active. Conflict rules are unchanged. + +### The declarations + +Source: **T** = the template's own code (resolver, `legendFields`, reorder axes, navigation, +mark geometry), **P** = the probe over the shipped test cases, **J** = judgment, listed below. + +| Chart type | elements | region | navigation | reorder | legend | discrete axis | index | Source | +|---|---|---|---|---|---|---|---|---| +| Scatter Plot, Regression, Connected Scatter Plot | ✓ | cartesian | x, y | | ✓ | | | T, P | +| Ranged Dot Plot | ✓ | cartesian | x, y | connective marks | ✓ | ✓ | | T | +| Boxplot, Strip Plot | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | T, P | +| Bar, Grouped Bar, Stacked Bar, Lollipop | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | T, P | +| Waterfall Chart | ✓ | cartesian | x, y | rect marks | ✓ | ✓ | | T | +| Pyramid Chart | ✓ | cartesian | | ✓ | ✓ | ✓ | | T, P | +| Gantt Chart | ✓ | cartesian | x | ✓ | ✓ | ✓ | | T, P | +| Bullet Chart, Bar Table | ✓ | cartesian | | ✓ | ✓ | ✓ | | T, P, J | +| Histogram | ✓ | cartesian | x, y | | ✓ | | | T | +| Heatmap | ✓ | cartesian | x, y | ✓ | | ✓ | | T, J | +| Calendar Heatmap | ✓ | cartesian | | | | | | T, J | +| Violin Plot | ✓ | cartesian | | | | ✓ | | T | +| Density Plot, ECDF Plot | ✓ | cartesian | x | | ✓ | | ✓ | T, J | +| Candlestick Chart | ✓ | cartesian | x | | | | ✓ | T, J | +| Sparkline | ✓ | cartesian | x | | | | ✓ | T, J | +| Line Chart | ✓ | cartesian | x, y | ✓ | ✓ | | ✓ | T, P, J | +| Area Chart, Streamgraph, Range Area Chart | ✓ | cartesian | x, y | | ✓ | | ✓ | T, J | +| Bump Chart, Slope Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | T, P, J | +| Pie Chart, Donut Chart, Rose Chart, Radar Chart | ✓ | angular | | | ✓ | | | T | +| KPI Card | ✓ | | | | | | | T, J | +| Map, Choropleth | ✓ | cartesian | geo | | ✓ | | | T, J | + +Judgment calls, open for review: + +- **`elements` on KPI Card.** The template ships a resolver (`kpi-tile`) and an annotation + presenter, so `click-highlight` and `click-annotate` work on the tile. Declared, although + §9 once guessed "supports nothing". +- **`legend` absent on Heatmap and Calendar Heatmap.** Their colour is continuous by + definition, so there are no legend items to toggle. Choropleth keeps `legend` because its + colour can be categorical; the assembler admits `legend-toggle` only when it is. +- **`region: cartesian` kept 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 and the polar charts lose it. +- **`reorder` kept on Line, Bump, and Slope.** A nominal x axis on these charts was + reorderable before this stage (a test asserts it for Line Chart), so the behaviour is kept. +- **`discreteAxis` absent on Rose, Range Area, Sparkline, and the continuous-x charts.** + `axis-highlight` emphasises the marks of one category label; on these charts the x labels are + periods or bins rather than categories a reader would pick. +- **`index`** on the charts whose x is a shared index across series: line, area, streamgraph, + range area, bump, slope, density, ECDF, candlestick, sparkline. + +### Discovery + +`supportedInteractions(template)` lists the presets whose `requires` sits inside the +template's declaration. `list_chart_types` and the generated chart reference report it, so +the list an agent reads and the list the mount enforces come from one block. diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index c8845960..71fb9306 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -902,25 +902,12 @@ 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; absent means none. */ + /** + * 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. + */ interactions?: ChartInteractionSupport; /** @@ -952,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 { + : support?.navigation && unfaceted + ? (support.navigation.axes ?? ['x', 'y']).filter((axis) => { const encoding = resolvedEncodings[axis]; return !!encoding?.field && (encoding.type === 'quantitative' || encoding.type === 'temporal'); }) @@ -912,9 +913,10 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { 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 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) => { @@ -924,12 +926,8 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { ? [{ axis, field: encoding.field, - ...(chartTemplate.reorder && chartTemplate.reorder.includeConnectiveMarks - ? { includeConnectiveMarks: true } - : {}), - ...(chartTemplate.reorder && chartTemplate.reorder.markTypes - ? { markTypes: chartTemplate.reorder.markTypes } - : {}), + ...(reorderSupport?.includeConnectiveMarks ? { includeConnectiveMarks: true } : {}), + ...(reorderSupport?.markTypes ? { markTypes: reorderSupport.markTypes } : {}), }] : []; }) @@ -948,6 +946,7 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { ) === index); result._interactionSemantics = { ...templateSemantics, + supportedRegionGestures: support?.region ? [...support.region] : undefined, axisFields: Object.fromEntries((['x', 'y'] as const).flatMap((axis) => { const encoding = resolvedEncodings[axis]; return encoding?.field diff --git a/packages/flint-js/src/vegalite/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts index 50718017..dafa7325 100644 --- a/packages/flint-js/src/vegalite/templates/area.ts +++ b/packages/flint-js/src/vegalite/templates/area.ts @@ -133,7 +133,13 @@ export const areaChartDef: ChartTemplateDef = { chart: "Area Chart", template: { mark: "area", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], - navigation: {}, + interactions: { + elements: true, + region: ['cartesian'], + navigation: {}, + legend: true, + index: true, + }, markCognitiveChannel: 'area', geometryKinds: ['area', 'line', 'point'], semanticInteractions: ({ resolvedEncodings }) => { @@ -216,7 +222,13 @@ export const streamgraphDef: ChartTemplateDef = { chart: "Streamgraph", template: { mark: "area", encoding: {} }, channels: ["x", "y", "color", "column", "row"], - navigation: {}, + interactions: { + elements: true, + region: ['cartesian'], + navigation: {}, + legend: 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..add9e240 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"], + interactions: { + 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..5dfca223 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: {}, + interactions: { + 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"], + interactions: { + 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: {}, + interactions: { + 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: {}, + interactions: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const fields = ['x', 'y', 'color'] @@ -609,7 +637,12 @@ export const histogramDef: ChartTemplateDef = { }, }, channels: ["x", "color", "column", "row"], - navigation: {}, + interactions: { + elements: true, + region: ['cartesian'], + navigation: {}, + legend: true, + }, markCognitiveChannel: 'length', semanticInteractions: ({ resolvedEncodings }) => { const sourceField = resolvedEncodings.x?.field; @@ -684,7 +717,13 @@ export const heatmapDef: ChartTemplateDef = { chart: "Heatmap", template: { mark: "rect", encoding: {} }, channels: ["x", "y", "color", "column", "row"], - navigation: {}, + interactions: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + 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..1249cfc5 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"], + interactions: { + 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..5bb66fb4 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: {}, + interactions: { + 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..5899949c 100644 --- a/packages/flint-js/src/vegalite/templates/calendar.ts +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -105,6 +105,10 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { chart: 'Calendar Heatmap', template: { mark: { type: 'rect', cornerRadius: 2 }, encoding: {} }, channels: ['x', 'color'], + interactions: { + elements: true, + region: ['cartesian'], + }, 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..5be31057 100644 --- a/packages/flint-js/src/vegalite/templates/candlestick.ts +++ b/packages/flint-js/src/vegalite/templates/candlestick.ts @@ -16,7 +16,12 @@ export const candlestickChartDef: ChartTemplateDef = { ], }, channels: ["x", "open", "high", "low", "close", "column", "row"], - navigation: { axes: ['x'] }, + interactions: { + elements: true, + region: ['cartesian'], + navigation: { axes: ['x'] }, + 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..7c9a9351 100644 --- a/packages/flint-js/src/vegalite/templates/connected-scatter.ts +++ b/packages/flint-js/src/vegalite/templates/connected-scatter.ts @@ -71,7 +71,12 @@ export const connectedScatterDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "order", "color", "detail", "column", "row"], - navigation: {}, + interactions: { + elements: true, + region: ['cartesian'], + navigation: {}, + legend: 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..d797161d 100644 --- a/packages/flint-js/src/vegalite/templates/density.ts +++ b/packages/flint-js/src/vegalite/templates/density.ts @@ -72,7 +72,13 @@ export const densityPlotDef: ChartTemplateDef = { }, }, channels: ["x", "color", "column", "row"], - navigation: { axes: ['x'] }, + interactions: { + elements: true, + region: ['cartesian'], + navigation: { axes: ['x'] }, + legend: 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..72c7347c 100644 --- a/packages/flint-js/src/vegalite/templates/ecdf.ts +++ b/packages/flint-js/src/vegalite/templates/ecdf.ts @@ -66,7 +66,13 @@ export const ecdfPlotDef: ChartTemplateDef = { encoding: {}, }, channels: ['x', 'color', 'detail', 'column', 'row'], - navigation: { axes: ['x'] }, + interactions: { + elements: true, + region: ['cartesian'], + navigation: { axes: ['x'] }, + legend: 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..48d5868d 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'] }, + interactions: { + 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..8e88a243 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: {}, + interactions: { + 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..a2f6498b 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"], + interactions: { + 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..f1d28c35 100644 --- a/packages/flint-js/src/vegalite/templates/line.ts +++ b/packages/flint-js/src/vegalite/templates/line.ts @@ -132,7 +132,14 @@ export const lineChartDef: ChartTemplateDef = { chart: "Line Chart", template: { mark: "line", encoding: {} }, channels: ["x", "y", "color", "strokeDash", "detail", "opacity", "column", "row"], - navigation: {}, + interactions: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: {}, + legend: 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..4a9d9bf3 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: {}, + interactions: { + 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..5f91572c 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 }, + interactions: { + 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 }, + interactions: { + 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..b184a076 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"], + interactions: { + elements: true, + region: ['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..638e35ea 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"], + interactions: { + elements: true, + region: ['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..c240c213 100644 --- a/packages/flint-js/src/vegalite/templates/range-area.ts +++ b/packages/flint-js/src/vegalite/templates/range-area.ts @@ -48,10 +48,15 @@ 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: {}, + interactions: { + elements: true, + region: ['cartesian'], + navigation: {}, + legend: 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..ad88e0de 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"], + interactions: { + elements: true, + region: ['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..e851ec46 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -50,7 +50,12 @@ export const scatterPlotDef: ChartTemplateDef = { chart: "Scatter Plot", template: { mark: "circle", encoding: {} }, channels: ["x", "y", "color", "size", "shape", "detail", "opacity", "column", "row"], - navigation: {}, + interactions: { + elements: true, + region: ['cartesian'], + navigation: {}, + legend: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); @@ -130,7 +135,12 @@ export const regressionDef: ChartTemplateDef = { ], }, channels: ["x", "y", "size", "color", "column", "row"], - navigation: {}, + interactions: { + elements: true, + region: ['cartesian'], + navigation: {}, + legend: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const seriesField = firstDiscreteEncodingField(resolvedEncodings, ['color']); @@ -231,7 +241,6 @@ export const regressionDef: ChartTemplateDef = { export const rangedDotPlotDef: ChartTemplateDef = { chart: "Ranged Dot Plot", - reorder: { includeConnectiveMarks: true }, template: { encoding: {}, layer: [ @@ -240,7 +249,14 @@ export const rangedDotPlotDef: ChartTemplateDef = { ], }, channels: ["x", "y", "color"], - navigation: {}, + interactions: { + elements: true, + region: ['cartesian'], + navigation: {}, + reorder: { includeConnectiveMarks: true }, + legend: true, + discreteAxis: true, + }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { const categoryField = firstDiscreteEncodingField(resolvedEncodings, ['x', 'y']); @@ -310,7 +326,14 @@ export const boxplotDef: ChartTemplateDef = { chart: "Boxplot", template: { mark: "boxplot", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], - navigation: {}, + interactions: { + 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..cd03010c 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: {}, + interactions: { + 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..56379ebd 100644 --- a/packages/flint-js/src/vegalite/templates/sparkline.ts +++ b/packages/flint-js/src/vegalite/templates/sparkline.ts @@ -116,7 +116,12 @@ export const sparklineDef: ChartTemplateDef = { chart: 'Sparkline', template: { mark: 'line', encoding: {} }, channels: ['x', 'y', 'color', 'detail', 'row', 'column'], - navigation: { axes: ['x'] }, + interactions: { + elements: true, + region: ['cartesian'], + navigation: { axes: ['x'] }, + 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..f6e7a88f 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,11 @@ 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'], + interactions: { + elements: true, + region: ['cartesian'], + 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..50564484 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'] }, + interactions: { + 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/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index 680c0d68..beb7aebc 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -1939,9 +1939,12 @@ describe('Vega-Lite semantic interactions', () => { 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' } }, + }), + supportedRegionGestures: [...roseChartDef.interactions!.region!], + }, }; const polarPlan = addVegaLiteInteractions(polar, [brushX()]); expect(polarPlan?.angularXBrush).toBe(true); @@ -1954,13 +1957,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' }, + }, + }), + supportedRegionGestures: [...radarChartDef.interactions!.region!], + }, }; expect(addVegaLiteInteractions(radar, [brushAngle()])?.angularXBrush).toBe(true); }); 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..28edf393 --- /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.interactions).map((def) => def.chart); + expect(missing).toEqual([]); + }); + + it('every template resolves marks to data elements', () => { + const without = vlAllTemplateDefs.filter((def) => !def.interactions?.elements).map((def) => def.chart); + expect(without).toEqual([]); + }); + + it('polar templates offer the angular region and nothing cartesian', () => { + for (const def of vlAllTemplateDefs) { + const region = def.interactions?.region ?? []; + if (POLAR.includes(def.chart)) { + expect(region, def.chart).toEqual(['angular']); + expect(def.interactions?.navigation, def.chart).toBeUndefined(); + expect(def.interactions?.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.interactions?.navigation).toEqual({ geo: true }); + expect(def.interactions?.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.interactions).toBe(pie.interactions); + }); +}); From 26291dc06ceaa90a4513f551c111b74f30a58066 Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Sat, 12 Sep 2026 17:29:05 -0700 Subject: [PATCH 03/18] feat(interactions): admission matches a preset's requirements against the chart's capabilities The assembler confirms the declared capabilities against the bound encodings and writes the active list, with the chart type, into _interactionSemantics. Admission replaces its four inferred checks with one rule: every capability in the preset's requires list must be present, or a spec entry drops with a message that names the chart type and the missing property, and a code definition throws. A plan the assembler did not annotate is read the way the compile step read it, so hand-built plans keep their behaviour. The region capability covers either kind of drag region, because brush-x on a polar chart is honoured as an angular brush; only brush-angle needs the angular one. A survey of both lab tabs showed 787 cards with no difference between code and spec; the KPI card lost the region presets, and the scatter family gained the index declaration its lab cases relied on. --- docs/design-interaction-spec.md | 22 +++-- .../flint-js/src/core/interaction-spec.ts | 8 +- .../src/interactive/spec/admission.ts | 63 ++++++++++--- .../flint-js/src/interactive/spec/registry.ts | 10 +-- packages/flint-js/src/vegalite/assemble.ts | 21 +++++ .../src/vegalite/interactions/compile.ts | 3 + .../src/vegalite/templates/scatter.ts | 2 + .../tests/interaction-admission.test.ts | 90 +++++++++++++++++-- .../tests/semantic-interactions.test.ts | 21 +++-- 9 files changed, 200 insertions(+), 40 deletions(-) diff --git a/docs/design-interaction-spec.md b/docs/design-interaction-spec.md index 74cd5614..575ce9b6 100644 --- a/docs/design-interaction-spec.md +++ b/docs/design-interaction-spec.md @@ -555,9 +555,11 @@ The registry's `requires` was written but never read. Three parts, each in one place: 1. **A vocabulary of chart capabilities** (`InteractionCapability`, core): a fact some preset - reads at runtime. `elements` (marks resolve to data), `cartesian-region`, `angular-region`, - `navigation`, `reorder`, `legend`, `discrete-axis`, `index` (one x position reads every - series). + reads at runtime. `elements` (marks resolve to data), `region` (any drag region the plot + resolves marks in), `angular-region` (the polar kind), `navigation`, `reorder`, `legend`, + `discrete-axis`, `index` (one x position reads every series). `brush-x` on a polar chart is + honoured as an angular brush, which is why the brushes need a region of either kind and only + `brush-angle` needs the angular one. 2. **Per preset, `requires`** in the registry, now a list: the smallest set without which the preset does nothing. Brushes need `elements` and a region; `brush-zoom` and `navigate` need `navigation`; `legend-toggle` needs `legend`; `axis-highlight` needs `discrete-axis`; @@ -578,7 +580,8 @@ mark geometry), **P** = the probe over the shipped test cases, **J** = judgment, | Chart type | elements | region | navigation | reorder | legend | discrete axis | index | Source | |---|---|---|---|---|---|---|---|---| -| Scatter Plot, Regression, Connected Scatter Plot | ✓ | cartesian | x, y | | ✓ | | | T, P | +| Scatter Plot, Regression | ✓ | cartesian | x, y | | ✓ | | ✓ | T, P, lab | +| Connected Scatter Plot | ✓ | cartesian | x, y | | ✓ | | | T, P | | Ranged Dot Plot | ✓ | cartesian | x, y | connective marks | ✓ | ✓ | | T | | Boxplot, Strip Plot | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | T, P | | Bar, Grouped Bar, Stacked Bar, Lollipop | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | | T, P | @@ -617,7 +620,16 @@ Judgment calls, open for review: `axis-highlight` emphasises the marks of one category label; on these charts the x labels are periods or bins rather than categories a reader would pick. - **`index`** on the charts whose x is a shared index across series: line, area, streamgraph, - range area, bump, slope, density, ECDF, candlestick, sparkline. + range area, bump, slope, density, ECDF, candlestick, sparkline, and the scatter family, where + the lab's curated index-inspection cases (income, year on x) already worked. + +### Enforcement, verified + +With the match rule in place, a headless survey clicked every mode on both lab tabs: 787 +cards, 0 differences between the code tab and the spec tab. The cards that changed status +were the KPI Card in every region mode (select, the brushes, lasso), which is the intended +truth, and two index-inspection cases on scatter plots, which led to the `index` declaration +on the scatter family above. Every other card kept its status. ### Discovery diff --git a/packages/flint-js/src/core/interaction-spec.ts b/packages/flint-js/src/core/interaction-spec.ts index 274302df..e6c9a057 100644 --- a/packages/flint-js/src/core/interaction-spec.ts +++ b/packages/flint-js/src/core/interaction-spec.ts @@ -37,10 +37,14 @@ 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. */ +/** + * A fact about a chart that at least one interaction preset reads at runtime. + * `region` is any drag region the plot resolves marks in; `angular-region` is + * the polar kind, which only the angular brush needs. + */ export const INTERACTION_CAPABILITIES = [ 'elements', - 'cartesian-region', + 'region', 'angular-region', 'navigation', 'reorder', diff --git a/packages/flint-js/src/interactive/spec/admission.ts b/packages/flint-js/src/interactive/spec/admission.ts index 3bd91c49..917b0b37 100644 --- a/packages/flint-js/src/interactive/spec/admission.ts +++ b/packages/flint-js/src/interactive/spec/admission.ts @@ -1,9 +1,14 @@ import type { ChartWarning } from '../../core/types'; +import type { InteractionCapability } from '../../core/interaction-spec'; import type { CanvasInteractionDef } from '../interactions'; import type { NavigationAxes } from '../language/events'; +import { INTERACTION_PRESETS } from './registry'; /** What admission reads from the compiled chart: the fields the assembler writes to `_interactionSemantics`. */ export interface InteractionAdmissionPlan { + readonly chartType?: string; + /** The capabilities the assembler confirmed for this chart and its data. A plan without them is read from the other fields. */ + readonly capabilities?: readonly InteractionCapability[]; readonly fields: readonly string[]; readonly selectableMarks: readonly string[]; readonly resolve?: unknown; @@ -32,6 +37,43 @@ export function navigationAxesFor( const PAN_DRAG_CONFLICT = 'Pan navigation cannot share an unmodified drag gesture with a region interaction.'; const DROPPED = 'The interaction was dropped.'; +const NEEDS: Readonly> = { + 'elements': 'marks that resolve to data', + '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', +}; + +/** + * A plan the assembler did not annotate is read the way the compile step read it: + * element semantics, the angular flag, and the navigable axes decide; the other + * capabilities are taken as present. + */ +function inferredCapabilities(plan: InteractionAdmissionPlan): readonly InteractionCapability[] { + const list: InteractionCapability[] = ['legend', 'reorder', 'discrete-axis', 'index']; + if (!!plan.resolve || plan.fields.length > 0 || plan.selectableMarks.length > 0) list.push('elements', 'region'); + if (plan.supportedRegionGestures?.includes('angular')) list.push('angular-region'); + if ((plan.navigationAxes ?? []).length > 0) list.push('navigation'); + return list; +} + +/** A custom definition states its needs; a preset carries them through the registry; anything else is read off the event source. */ +export function interactionRequirements(interaction: CanvasInteractionDef): readonly InteractionCapability[] { + if (interaction.requires) return interaction.requires; + if (interaction.preset) return INTERACTION_PRESETS[interaction.preset].requires; + const source = interaction.eventSource; + if (source.type === 'navigation') return ['navigation']; + if (source.type === 'region') { + return source.regionGeometry === 'angular' ? ['elements', 'angular-region'] : ['elements', 'region']; + } + if (source.type === 'element') return ['elements']; + return []; +} + /** * Decide which interactions a compiled chart can honour. * @@ -54,33 +96,26 @@ 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 ?? inferredCapabilities(plan)); + 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 ${NEEDS[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; }); diff --git a/packages/flint-js/src/interactive/spec/registry.ts b/packages/flint-js/src/interactive/spec/registry.ts index 1e1e0bf9..8b4ff186 100644 --- a/packages/flint-js/src/interactive/spec/registry.ts +++ b/packages/flint-js/src/interactive/spec/registry.ts @@ -119,7 +119,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'select', label: 'Rectangle select', description: 'Drag a rectangle to emphasise the marks inside it.', - requires: ['elements', 'cartesian-region'], + requires: ['elements', 'region'], gesture: 'drag', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -129,7 +129,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'lasso-select', label: 'Lasso select', description: 'Draw a freehand region to emphasise the marks inside it.', - requires: ['elements', 'cartesian-region'], + requires: ['elements', 'region'], gesture: 'drag', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -139,7 +139,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'brush-x', label: 'Brush x', description: 'Drag an interval along x; a stateful brush stays editable after the drag.', - requires: ['elements', 'cartesian-region'], + requires: ['elements', 'region'], gesture: 'drag', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -149,7 +149,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'brush-y', label: 'Brush y', description: 'Drag an interval along y; a stateful brush stays editable after the drag.', - requires: ['elements', 'cartesian-region'], + requires: ['elements', 'region'], gesture: 'drag', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -179,7 +179,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'linked-brush', label: 'Linked brush', description: 'Brush marks and emphasise every mark that shares their group, across views.', - requires: ['elements', 'cartesian-region'], + requires: ['elements', 'region'], gesture: 'drag', requiredOptions: ['groupBy'], supportedReset: ANY_RESET, diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index b82bf2b1..f28d68c3 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 type { InteractionCapability } from '../core/interaction-spec'; import { applyEncodingOverrides } from '../core/encoding-overrides'; import { applyAggregation } from '../core/aggregate'; import { planBandDodge, resolveDodge } from '../core/band-dodge'; @@ -944,8 +945,28 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { .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 hasElements = 'resolve' in templateSemantics + || templateSemantics.fields.length > 0 + || templateSemantics.selectableMarks.length > 0; + const capabilities: InteractionCapability[] = []; + if (support?.elements && hasElements) capabilities.push('elements'); + if (support?.region?.length) capabilities.push('region'); + if (support?.region?.includes('angular')) capabilities.push('angular-region'); + if (navigationAxes.length > 0) capabilities.push('navigation'); + if (reorderAxes.length > 0) capabilities.push('reorder'); + if (support?.legend && discreteLegend) capabilities.push('legend'); + if (support?.discreteAxis && discreteAxis) capabilities.push('discrete-axis'); + if (support?.index && resolvedEncodings.x?.field) capabilities.push('index'); result._interactionSemantics = { ...templateSemantics, + chartType: chartTemplate.chart, + capabilities, supportedRegionGestures: support?.region ? [...support.region] : undefined, axisFields: Object.fromEntries((['x', 'y'] as const).flatMap((axis) => { const encoding = resolvedEncodings[axis]; diff --git a/packages/flint-js/src/vegalite/interactions/compile.ts b/packages/flint-js/src/vegalite/interactions/compile.ts index 6540c3f2..44ece4d9 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, @@ -47,6 +48,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[]; diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index e851ec46..1328b0ad 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -55,6 +55,7 @@ export const scatterPlotDef: ChartTemplateDef = { region: ['cartesian'], navigation: {}, legend: true, + index: true, }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { @@ -140,6 +141,7 @@ export const regressionDef: ChartTemplateDef = { region: ['cartesian'], navigation: {}, legend: true, + index: true, }, markCognitiveChannel: 'position', semanticInteractions: ({ resolvedEncodings }) => { diff --git a/packages/flint-js/tests/interaction-admission.test.ts b/packages/flint-js/tests/interaction-admission.test.ts index b24d08d2..ffb39811 100644 --- a/packages/flint-js/tests/interaction-admission.test.ts +++ b/packages/flint-js/tests/interaction-admission.test.ts @@ -30,11 +30,11 @@ describe('admitInteractions', () => { 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.'); + .toThrow('Interaction "click-highlight" requires marks that resolve to data; this chart has none.'); expect(() => admitInteractions({ ...CARTESIAN, navigationAxes: [] }, [navigate()])) - .toThrow('Interaction "navigate" requires a chart with a navigable continuous axis.'); + .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 +45,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' }])); 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'); @@ -136,8 +136,86 @@ 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'); }); }); + +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', 'region', 'navigation', 'reorder', 'legend', 'discrete-axis']); + const pie = semanticsOf('Pie Chart', { theta: 'value', color: 'category' }); + expect(pie.capabilities).toEqual(['elements', '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 brushes as angular gestures 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', 'brush-x', 'brush-angle', 'legend-toggle']); + expect(result.warnings.map((warning) => warning.code)).toEqual(Array(4).fill('unsupported_interaction')); + }); + + 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 custom definition states its own requirements, and one without any is read from its event source', () => { + const kpi = semanticsOf('KPI Card', { metric: 'category', value: 'value' }); + const custom: CanvasInteractionDef = { ...clickHighlight({ id: 'custom' }), preset: undefined, requires: ['legend'] }; + expect(() => admitInteractions(kpi, [custom])).toThrow('Interaction "custom" requires a discrete legend; KPI Card has none.'); + const bare: CanvasInteractionDef = { ...clickHighlight({ id: 'bare' }), preset: undefined }; + expect(ids(admitInteractions(kpi, [bare]).admitted)).toEqual(['bare']); + }); +}); diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index beb7aebc..df641847 100644 --- a/packages/flint-js/tests/semantic-interactions.test.ts +++ b/packages/flint-js/tests/semantic-interactions.test.ts @@ -569,7 +569,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 +580,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: { @@ -681,7 +682,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', () => { @@ -731,7 +733,7 @@ describe('Vega-Lite semantic interactions', () => { expect(() => addVegaLiteInteractions({ mark: 'line', _interactionSemantics: { fields: [], selectableMarks: [], navigationAxes: ['x'] }, - }, [clickMark()])).toThrow('requires chart element semantics'); + }, [clickMark()])).toThrow('requires marks that resolve to data'); }); it('instruments semantic targets for external interactions without adding canvas gestures', () => { @@ -1933,7 +1935,7 @@ describe('Vega-Lite semantic interactions', () => { }), }; 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', @@ -3413,7 +3415,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 }, @@ -3426,8 +3428,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, requires: [] }; + const { compiled } = instrument(assembleCalendar(), [legendClaimer]); const view = new View(parse(compiled), { renderer: 'none' }); await view.runAsync(); From 668ac1ecfec438df0821d609a81ebe559c9a79e1 Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Sat, 12 Sep 2026 17:31:38 -0700 Subject: [PATCH 04/18] feat(interactions): one requirement table, and a coverage tab of chart types against presets INTERACTION_PRESET_REQUIREMENTS in core is the single table of what each preset needs; the registry entries read it. declaredInteractionCapabilities() and supportedInteractionPresets() turn a template's interactions block into the presets it can honour by declaration, so hosts and docs can list them without the runtime. The Interactions lab gains a Coverage tab: all 36 Vega-Lite chart types against all 20 presets, with the representative test case assembled per row to show which presets are active for that data, which the chart type supports but the data does not confirm, and which it never offers. --- docs/design-interaction-spec.md | 13 +- packages/flint-js/src/core/index.ts | 3 + .../flint-js/src/core/interaction-spec.ts | 54 +++++ .../flint-js/src/interactive/spec/registry.ts | 42 ++-- .../tests/interaction-support.test.ts | 56 +++++ site/src/main.tsx | 3 +- .../src/playground/InteractionCoverageLab.tsx | 197 ++++++++++++++++++ site/src/playground/PlaygroundShell.tsx | 1 + site/src/playground/interaction-coverage.css | 148 +++++++++++++ 9 files changed, 492 insertions(+), 25 deletions(-) create mode 100644 packages/flint-js/tests/interaction-support.test.ts create mode 100644 site/src/playground/InteractionCoverageLab.tsx create mode 100644 site/src/playground/interaction-coverage.css diff --git a/docs/design-interaction-spec.md b/docs/design-interaction-spec.md index 575ce9b6..57a9f3ef 100644 --- a/docs/design-interaction-spec.md +++ b/docs/design-interaction-spec.md @@ -633,6 +633,13 @@ on the scatter family above. Every other card kept its status. ### Discovery -`supportedInteractions(template)` lists the presets whose `requires` sits inside the -template's declaration. `list_chart_types` and the generated chart reference report it, so -the list an agent reads and the list the mount enforces come from one block. +`INTERACTION_PRESET_REQUIREMENTS` (core) is the one table of what each preset needs; the +registry reads it. `declaredInteractionCapabilities(block)` and +`supportedInteractionPresets(block)` (core) turn a template's declaration into the list of +presets it can honour, before the data confirms the data-dependent ones. The Interactions lab +gained a **Coverage** tab (`playground/interaction-coverage`): every chart type against every +preset, with a filled dot where the representative test case activates the preset, a hollow dot +where the chart type supports it but this case's data lacks a property, and a small dot where +the chart type never offers what the preset needs. `list_chart_types` and the generated chart +reference report the same list, so the list an agent reads and the list the mount enforces come +from one block. diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index 3ceb8ac5..8660d97d 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -202,6 +202,9 @@ export { isRegistered, getRegisteredTypes } from './type-registry'; export { INTERACTION_PRESET_TYPES, INTERACTION_CAPABILITIES, + INTERACTION_PRESET_REQUIREMENTS, + declaredInteractionCapabilities, + supportedInteractionPresets, type InteractionPresetType, type InteractionCapability, type ChartInteractionSupport, diff --git a/packages/flint-js/src/core/interaction-spec.ts b/packages/flint-js/src/core/interaction-spec.ts index e6c9a057..952ebf2b 100644 --- a/packages/flint-js/src/core/interaction-spec.ts +++ b/packages/flint-js/src/core/interaction-spec.ts @@ -83,6 +83,60 @@ export interface ChartInteractionSupport { 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', 'region'], + 'lasso-select': ['elements', 'region'], + 'brush-x': ['elements', 'region'], + 'brush-y': ['elements', 'region'], + 'brush-angle': ['elements', 'angular-region'], + 'brush-zoom': ['navigation'], + 'linked-brush': ['elements', '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?.length) list.push('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/interactive/spec/registry.ts b/packages/flint-js/src/interactive/spec/registry.ts index 8b4ff186..8792dae3 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 InteractionCapability, type InteractionPresetType } from '../../core/interaction-spec'; +import { INTERACTION_PRESET_REQUIREMENTS, INTERACTION_PRESET_TYPES, type InteractionCapability, type InteractionPresetType } from '../../core/interaction-spec'; import { axisHighlight, brushAngle, @@ -68,7 +68,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'click-highlight', label: 'Click highlight', description: 'Click a mark, legend item, or discrete axis label to emphasise it and mute the rest.', - requires: ['elements'], + requires: INTERACTION_PRESET_REQUIREMENTS['click-highlight'], gesture: 'click', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -78,7 +78,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'axis-highlight', label: 'Axis highlight', description: 'Hover or click a discrete axis label to emphasise its category.', - requires: ['discrete-axis'], + requires: INTERACTION_PRESET_REQUIREMENTS['axis-highlight'], gesture: 'click', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -88,7 +88,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'click-group-focus', label: 'Click group focus', description: 'Click a mark to emphasise every mark that shares its group.', - requires: ['elements'], + requires: INTERACTION_PRESET_REQUIREMENTS['click-group-focus'], gesture: 'click', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -98,7 +98,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'hover-group-focus', label: 'Hover group focus', description: 'Hover a mark to preview its group; leaving the mark restores the chart.', - requires: ['elements'], + requires: INTERACTION_PRESET_REQUIREMENTS['hover-group-focus'], gesture: 'hover', requiredOptions: ['groupBy'], supportedReset: NEVER, @@ -109,7 +109,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'click-annotate', label: 'Click annotate', description: 'Click a mark to pin an annotation on it.', - requires: ['elements'], + requires: INTERACTION_PRESET_REQUIREMENTS['click-annotate'], gesture: 'click', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -119,7 +119,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'select', label: 'Rectangle select', description: 'Drag a rectangle to emphasise the marks inside it.', - requires: ['elements', 'region'], + requires: INTERACTION_PRESET_REQUIREMENTS['select'], gesture: 'drag', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -129,7 +129,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'lasso-select', label: 'Lasso select', description: 'Draw a freehand region to emphasise the marks inside it.', - requires: ['elements', 'region'], + requires: INTERACTION_PRESET_REQUIREMENTS['lasso-select'], gesture: 'drag', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -139,7 +139,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'brush-x', label: 'Brush x', description: 'Drag an interval along x; a stateful brush stays editable after the drag.', - requires: ['elements', 'region'], + requires: INTERACTION_PRESET_REQUIREMENTS['brush-x'], gesture: 'drag', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -149,7 +149,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'brush-y', label: 'Brush y', description: 'Drag an interval along y; a stateful brush stays editable after the drag.', - requires: ['elements', 'region'], + requires: INTERACTION_PRESET_REQUIREMENTS['brush-y'], gesture: 'drag', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -159,7 +159,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'brush-angle', label: 'Brush angle', description: 'Drag an angular sector on a polar chart such as a pie, donut, rose, or radar.', - requires: ['elements', 'angular-region'], + requires: INTERACTION_PRESET_REQUIREMENTS['brush-angle'], gesture: 'drag', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -169,7 +169,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'brush-zoom', label: 'Brush zoom', description: 'Drag a rectangle to zoom the viewport to it.', - requires: ['navigation'], + requires: INTERACTION_PRESET_REQUIREMENTS['brush-zoom'], gesture: 'drag', supportedReset: ANY_RESET, defaultReset: ['double-click', 'escape'], @@ -179,7 +179,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'linked-brush', label: 'Linked brush', description: 'Brush marks and emphasise every mark that shares their group, across views.', - requires: ['elements', 'region'], + requires: INTERACTION_PRESET_REQUIREMENTS['linked-brush'], gesture: 'drag', requiredOptions: ['groupBy'], supportedReset: ANY_RESET, @@ -190,7 +190,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'legend-toggle', label: 'Legend toggle', description: 'Click a legend item to hide or restore its series.', - requires: ['legend'], + requires: INTERACTION_PRESET_REQUIREMENTS['legend-toggle'], gesture: 'click', supportedReset: ANY_RESET, defaultReset: NO_RESET, @@ -200,7 +200,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'context-activate', label: 'Context activate', description: 'Right-click a mark; emits the event for the host and applies no built-in update.', - requires: ['elements'], + requires: INTERACTION_PRESET_REQUIREMENTS['context-activate'], gesture: 'context', supportedReset: NEVER, defaultReset: NEVER, @@ -210,7 +210,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'long-press', label: 'Long press', description: 'Hold on a mark to emphasise it; the touch equivalent of a context request.', - requires: ['elements'], + requires: INTERACTION_PRESET_REQUIREMENTS['long-press'], gesture: 'long-press', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -220,7 +220,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'double-activate', label: 'Double activate', description: 'Double-click a mark to emphasise it.', - requires: ['elements'], + requires: INTERACTION_PRESET_REQUIREMENTS['double-activate'], gesture: 'double', supportedReset: ANY_RESET, defaultReset: SELECTION_RESET, @@ -230,7 +230,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'inspect', label: 'Inspect', description: 'Move the pointer to read values with x, y, or xy guides.', - requires: ['elements'], + requires: INTERACTION_PRESET_REQUIREMENTS['inspect'], gesture: 'inspect', supportedReset: NEVER, defaultReset: NEVER, @@ -240,7 +240,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'inspect-index', label: 'Inspect index', description: 'Move along one axis to read every series at that position.', - requires: ['index'], + requires: INTERACTION_PRESET_REQUIREMENTS['inspect-index'], gesture: 'inspect', supportedReset: ANY_RESET, defaultReset: ['escape'], @@ -250,7 +250,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'navigate', label: 'Navigate', description: 'Drag to pan, wheel or pinch to zoom, and a reset gesture to return to the full frame.', - requires: ['navigation'], + requires: INTERACTION_PRESET_REQUIREMENTS['navigate'], gesture: 'navigate', supportedReset: ANY_RESET, defaultReset: NAVIGATION_RESET, @@ -260,7 +260,7 @@ export const INTERACTION_PRESETS: { readonly [T in InteractionPresetType]: Inter type: 'drag-reorder', label: 'Drag reorder', description: 'Drag a mark or an axis label to reorder the categories.', - requires: ['reorder'], + requires: INTERACTION_PRESET_REQUIREMENTS['drag-reorder'], gesture: 'drag', supportedReset: ANY_RESET, defaultReset: NO_RESET, 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..19ab1c41 --- /dev/null +++ b/packages/flint-js/tests/interaction-support.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + INTERACTION_PRESET_REQUIREMENTS, + INTERACTION_PRESET_TYPES, + declaredInteractionCapabilities, + supportedInteractionPresets, +} from '../src/core/interaction-spec'; +import { INTERACTION_PRESETS } from '../src/interactive/spec/registry'; +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').interactions)).toEqual(['elements']); + expect(declaredInteractionCapabilities(def('Pie Chart').interactions)) + .toEqual(['elements', 'region', 'angular-region', 'legend']); + expect(declaredInteractionCapabilities(def('Bar Chart').interactions)) + .toEqual(['elements', 'region', 'navigation', 'reorder', 'legend', 'discrete-axis']); + }); +}); + +describe('supportedInteractionPresets', () => { + it('lists the presets whose requirements sit inside the declaration', () => { + expect(supportedInteractionPresets(def('KPI Card').interactions)).toEqual([ + 'click-highlight', 'click-group-focus', 'hover-group-focus', 'click-annotate', + 'context-activate', 'long-press', 'double-activate', 'inspect', + ]); + const pie = supportedInteractionPresets(def('Pie Chart').interactions); + 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').interactions); + expect(bar).not.toContain('brush-angle'); + expect(bar).not.toContain('inspect-index'); + expect(bar).toContain('drag-reorder'); + expect(supportedInteractionPresets(undefined)).toEqual([]); + }); + + it('keeps the registry and the core table in step', () => { + for (const type of INTERACTION_PRESET_TYPES) { + expect(INTERACTION_PRESETS[type].requires, type).toBe(INTERACTION_PRESET_REQUIREMENTS[type]); + } + }); + + 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.interactions))); + expect([...INTERACTION_PRESET_TYPES].filter((type) => !union.has(type))).toEqual([]); + const empty = vlAllTemplateDefs.filter((template) => supportedInteractionPresets(template.interactions).length === 0); + expect(empty.map((template) => template.chart)).toEqual([]); + }); +}); diff --git a/site/src/main.tsx b/site/src/main.tsx index e61e48f7..63080f76 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -26,6 +26,7 @@ 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 { AnnotationLab } from './playground/AnnotationLab'; import { InteractionDashboardLab } from './playground/InteractionDashboardLab'; import { InteractionCandidates } from './playground/InteractionCandidates'; @@ -88,7 +89,7 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/site/src/playground/InteractionCoverageLab.tsx b/site/src/playground/InteractionCoverageLab.tsx new file mode 100644 index 00000000..70d2ecc6 --- /dev/null +++ b/site/src/playground/InteractionCoverageLab.tsx @@ -0,0 +1,197 @@ +import { useMemo, useState } from 'react'; +import { + assembleVegaLite, + declaredInteractionCapabilities, + INTERACTION_PRESET_REQUIREMENTS, + INTERACTION_PRESET_TYPES, + supportedInteractionPresets, + vlAllTemplateDefs, + type ChartTemplateDef, + type InteractionCapability, + type InteractionPresetType, +} from 'flint-chart'; +import { TEST_GENERATORS, type TestCase } from 'flint-chart/test-data'; +import { INTERACTION_PRESETS } from 'flint-chart/interactive'; +import { testCaseToAssemblyInput } from '../shared/test-case-utils'; +import './interaction-coverage.css'; + +type CellStatus = 'active' | 'declared' | 'unsupported'; + +interface Cell { + status: CellStatus; + /** The first requirement the chart lacks, for the tooltip. */ + missing?: InteractionCapability; +} + +interface Row { + chartType: string; + caseTitle?: string; + declared: readonly InteractionCapability[]; + active: readonly InteractionCapability[]; + assembleError?: string; + cells: Record; +} + +const CAPABILITY_LABEL: Record = { + 'elements': 'marks that resolve to data', + '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', +}; + +/** The case the Test cases tab would show first for a chart type: a real, unfaceted one when there is one. */ +function representativeCase(chartType: string): TestCase | undefined { + let chosen: TestCase | undefined; + for (const generator of Object.values(TEST_GENERATORS)) { + let cases: TestCase[]; + try { + cases = generator(); + } catch { + continue; + } + for (const testCase of cases) { + if (testCase.chartType !== chartType) continue; + const preferred = testCase.tags?.includes('real') + && !testCase.encodingMap.column?.fieldID + && !testCase.encodingMap.row?.fieldID; + if (!chosen || preferred) chosen = testCase; + if (preferred) return chosen; + } + } + return chosen; +} + +function rowFor(def: ChartTemplateDef): Row { + const declared = declaredInteractionCapabilities(def.interactions); + const testCase = representativeCase(def.chart); + 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 ${CAPABILITY_LABEL[cell.missing!]}. A spec entry is dropped for this data.`; + } + return `${label} on ${row.chartType}: not supported. The chart type never offers ${CAPABILITY_LABEL[cell.missing!]}.`; +} + +export function InteractionCoverageLab() { + const [filter, setFilter] = useState(''); + const rows = useMemo(() => [...vlAllTemplateDefs] + .sort((left, right) => left.chart.localeCompare(right.chart)) + .map(rowFor), []); + 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); + const staticTotal = visible.reduce((sum, row) => + sum + supportedInteractionPresets(vlAllTemplateDefs.find((def) => def.chart === row.chartType)?.interactions).length, 0); + + 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 small dot 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 + {staticTotal} 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/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index 27aceb74..16847902 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -29,6 +29,7 @@ const pages: NavEntry[] = [ children: [ { to: 'click-focus', label: 'Test cases' }, { to: 'spec-test-cases', label: 'Spec test cases' }, + { to: 'interaction-coverage', label: 'Coverage' }, { to: 'bespoke-interaction', label: 'Advanced prototypes' }, { to: 'annotation-lab', label: 'Annotation lab' }, { to: 'interaction-candidates', label: 'References' }, diff --git a/site/src/playground/interaction-coverage.css b/site/src/playground/interaction-coverage.css new file mode 100644 index 00000000..7e25e685 --- /dev/null +++ b/site/src/playground/interaction-coverage.css @@ -0,0 +1,148 @@ +.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 { + overflow-x: auto; + border: 1px solid #d8dde2; + border-radius: 8px; + background: #fff; +} + +.ic-table { + border-collapse: collapse; + 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 th { + position: sticky; + top: 0; + z-index: 1; + background: #f6f8fa; + color: #66707a; + font-size: 11px; + font-weight: 600; + vertical-align: bottom; +} + +.ic-table th.ic-preset { + height: 128px; + padding: 6px 0; + text-align: center; +} + +.ic-table th.ic-preset span { + display: inline-block; + writing-mode: vertical-rl; + transform: rotate(180deg); + 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 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: 30px; + text-align: center !important; + font-size: 14px; + cursor: help; +} + +.ic-cell-active { color: #3f6b57; } +.ic-cell-declared { color: #806327; } +.ic-cell-unsupported { color: #b6bec6; } + +.ic-error { + color: #cf222e; + font-weight: 700; +} From 31faee25a858cd727afddd1ca89f2ed8e69d24ce Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Sat, 12 Sep 2026 17:41:27 -0700 Subject: [PATCH 05/18] feat(interactions): validateChart and the MCP server report and discover interaction support validateChart() checks interaction_spec the way the mount does: a malformed spec is an invalid_interaction_spec error, an entry the assembled chart cannot honour is the same unsupported_interaction warning the surface reports, and a backend that runs no interactions reports the spec as ignored. The MCP tool schema gains interaction_spec with the preset names as an enum, toAssemblyInput passes it through, validate_chart reports the drops, and list_chart_types returns the interaction presets each chart type supports from the template declaration. --- docs/tutorials/setup-flint-mcp.md | 2 +- packages/flint-js/src/validate/index.ts | 41 ++++++++++++++++++++++-- packages/flint-js/tests/validate.test.ts | 34 ++++++++++++++++++++ packages/flint-mcp/README.md | 2 +- packages/flint-mcp/src/server.ts | 11 ++++--- packages/flint-mcp/src/tools/list.ts | 15 ++++++++- packages/flint-mcp/src/tools/schemas.ts | 27 +++++++++++++++- packages/flint-mcp/tests/server.test.ts | 25 +++++++++++++++ 8 files changed, 146 insertions(+), 11 deletions(-) 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/packages/flint-js/src/validate/index.ts b/packages/flint-js/src/validate/index.ts index f5c14910..85f1ea45 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,45 @@ 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 semantics = (assembled as { _interactionSemantics?: unknown } | undefined)?._interactionSemantics; + const plan = semantics && typeof semantics === 'object' + ? semantics as Parameters[0] + : { fields: [], selectableMarks: [] }; + 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 +353,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/tests/validate.test.ts b/packages/flint-js/tests/validate.test.ts index eec3dcbd..456c3b5e 100644 --- a/packages/flint-js/tests/validate.test.ts +++ b/packages/flint-js/tests/validate.test.ts @@ -204,3 +204,37 @@ 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 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/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..f4861442 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.interactions), + })) .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 () => { From 7025d531ead254860a14a448be8b368d9063abd9 Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Sat, 12 Sep 2026 17:41:27 -0700 Subject: [PATCH 06/18] docs(interactions): a guide to interaction_spec, and the presets each chart type supports A new Using interactions page (docs/interaction-spec.md, with a zh-CN mirror, registered in the site catalog) explains the spec shape, the twenty presets with what each needs and its default reset, the reset gestures, how a chart type declares support, the warnings and where to read them, and the equivalence of a spec entry and a factory call. The API reference gains interaction_spec on ChartAssemblyInput with a section of its own. The chart-author skill gains an Interactions section with the authoring rules. The generated Vega-Lite reference prints an Interactions line per chart type from the same declaration the mount enforces. --- agent-skills/flint-chart-author/SKILL.md | 48 +++++++ docs/api-reference.md | 20 +++ docs/interaction-spec.md | 121 ++++++++++++++++++ docs/reference-vegalite.md | 73 +++++++++++ docs/zh-CN/api-reference.md | 16 +++ docs/zh-CN/interaction-spec.md | 121 ++++++++++++++++++ .../assets/flint-chart-author.SKILL.md | 48 +++++++ scripts/gen-chart-reference.ts | 15 +++ site/src/shared/docs-catalog.ts | 6 + 9 files changed, 468 insertions(+) create mode 100644 docs/interaction-spec.md create mode 100644 docs/zh-CN/interaction-spec.md 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/api-reference.md b/docs/api-reference.md index 52e7d9ad..4367ef68 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.interactions)` +lists the presets a template supports by declaration. See [Using interactions](/documentation/interaction-spec). + ### `chart_spec` | Field | Description | diff --git a/docs/interaction-spec.md b/docs/interaction-spec.md new file mode 100644 index 00000000..5591cc25 --- /dev/null +++ b/docs/interaction-spec.md @@ -0,0 +1,121 @@ +# 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, region | click-none, escape | +| `lasso-select` | Draws a freehand region to emphasise the marks inside. | elements, 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, 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, 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. +``` + +Two entries can also conflict: a second `navigate`, a pan gesture next to a drag gesture, or `double-activate` next to a `double-click` reset. The later entry yields. + +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..a4403534 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`, `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 | |---|---|---|---|---|---| | `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`, `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 | |---|---|---|---|---|---| | `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`, `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 | |---|---|---|---|---|---| | `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`, `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 | |---|---|---|---|---|---| | `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`, `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 | |---|---|---|---|---|---| | `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`, `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 | |---|---|---|---|---|---| | `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`, `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`, `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` + | 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`, `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`, `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` + | 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`, `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)), `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`, `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)), `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`, `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`, `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`, `click-group-focus`, `hover-group-focus`, `click-annotate`, `select`, `lasso-select`, `brush-x`, `brush-y`, `linked-brush`, `context-activate`, `long-press`, `double-activate`, `inspect` + | 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/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md index 3a967257..d02dc8c2 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.interactions)` 列出模板按声明支持的预设。参见[使用交互](/documentation/interaction-spec)。 + ### `chart_spec` | 字段 | 说明 | diff --git a/docs/zh-CN/interaction-spec.md b/docs/zh-CN/interaction-spec.md new file mode 100644 index 00000000..7cc643dd --- /dev/null +++ b/docs/zh-CN/interaction-spec.md @@ -0,0 +1,121 @@ +# 使用交互 + +`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. +``` + +两个条目也可能冲突:第二个 `navigate`、平移手势旁的拖动手势、或与 `double-click` 重置并存的 `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-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/scripts/gen-chart-reference.ts b/scripts/gen-chart-reference.ts index e61f5fa3..842ccd68 100644 --- a/scripts/gen-chart-reference.ts +++ b/scripts/gen-chart-reference.ts @@ -18,6 +18,7 @@ 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.interactions) { + const presets = supportedInteractionPresets(def.interactions).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.interactions) { + const presets = supportedInteractionPresets(def.interactions).map((type) => `\`${type}\``).join(', ') || '_无_'; + lines.push(`**交互:** ${presets}`, ''); + } const props = def.properties ?? []; if (props.length === 0) return [...lines, '_无模板专用参数。_', ''].join('\n'); lines.push('| 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 说明 |', '|---|---|---|---|---|---|'); diff --git a/site/src/shared/docs-catalog.ts b/site/src/shared/docs-catalog.ts index 5069074f..e5d3fc98 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', From 64829907d24405ca987c75702f70dd39a02fe479 Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Sat, 12 Sep 2026 17:50:26 -0700 Subject: [PATCH 07/18] feat(hosts): the MCP chart view, the editor and the gallery mount from interaction_spec The MCP chart view mounts buildInteractiveChart() with the CSP-safe expression interpreter when the input lists interactions, on the same preview input the static render sizes; the static render keeps running for the PNG export, and the surface warnings join the assembler's. The site gains one spec-aware Vega-Lite component, InteractiveVegaLiteView, used by the editor and by TripleChart whenever the input carries interaction entries; a TestCase may carry an interactionSpec, and the editor ships an Interactive bar example. The index chart stage and the chart-to-external lab, which used presets only, now ask for them in interaction_spec. --- packages/flint-js/src/test-data/types.ts | 3 + packages/flint-mcp/ui/src/FlintApp.tsx | 85 +++++++++++++++++-- packages/flint-mcp/ui/src/render.ts | 8 ++ packages/flint-mcp/ui/src/styles.css | 5 ++ .../components/InteractiveVegaLiteView.tsx | 67 +++++++++++++++ site/src/components/TripleChart.tsx | 5 +- site/src/playground/ChartToExternalLab.tsx | 25 +++--- site/src/playground/IndexChartStage.tsx | 12 +-- site/src/routes/Editor.tsx | 5 +- site/src/routes/editor-examples.ts | 20 +++++ site/src/shared/test-case-utils.ts | 1 + 11 files changed, 210 insertions(+), 26 deletions(-) create mode 100644 site/src/components/InteractiveVegaLiteView.tsx diff --git a/packages/flint-js/src/test-data/types.ts b/packages/flint-js/src/test-data/types.ts index 5f9824ca..0b3042b4 100644 --- a/packages/flint-js/src/test-data/types.ts +++ b/packages/flint-js/src/test-data/types.ts @@ -10,6 +10,7 @@ import { Type } from './df-types'; import { Channel, EncodingItem, FieldItem } from './df-types'; import { AssembleOptions } from '../core/types'; import type { SemanticAnnotation } from '../core/field-semantics'; +import type { InteractionSpec } from '../core/interaction-spec'; // ============================================================================ // Test Case Definition @@ -32,6 +33,8 @@ export interface TestCase { * E.g., { rating: { semanticType: 'Score', intrinsicDomain: [1, 5] } } */ semanticAnnotations?: Record; + /** 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-mcp/ui/src/FlintApp.tsx b/packages/flint-mcp/ui/src/FlintApp.tsx index 57a9ee8e..860fa3f4 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 { previewAssemblyInput, renderFlintSvg, 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,19 @@ export function FlintAppInner(props: { } }, [app, render]); - const warnings = render?.warnings ?? []; + const interactive = (current.interaction_spec?.interactions?.length ?? 0) > 0; + const previewInput = useMemo( + () => previewAssemblyInput(current, chartWidth ? { width: chartWidth } : undefined), + [current, chartWidth], + ); + const renderWarnings = render?.warnings ?? []; + const warnings = interactive + ? [ + ...renderWarnings, + ...surfaceWarnings.filter((warning) => !renderWarnings.some((known) => known.message === warning.message)), + ] + : 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 +918,15 @@ export function FlintAppInner(props: { style={surface ? { background: surface } : undefined} > {render - ?
+ ? interactive + ? ( + + ) + :
: Rendering…}
)} @@ -933,6 +957,55 @@ export function FlintAppInner(props: { ); } +/** + * The live chart when the input carries interaction_spec: the same preview input + * the static render sizes, mounted through the interactive surface. The static + * render keeps running beside it for the PNG export and the assembler warnings. + */ +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..b35fd148 100644 --- a/packages/flint-mcp/ui/src/render.ts +++ b/packages/flint-mcp/ui/src/render.ts @@ -213,6 +213,14 @@ export function assemblePreviewSpec( return spec; } +/** The input the preview assembles: the same sizing the static render uses, for an interactive mount. */ +export function previewAssemblyInput( + input: ChartAssemblyInput, + viewport?: { width: number; height?: number }, +): ChartAssemblyInput { + return withAppPreviewDefaults(input, viewport); +} + /** * Assemble a Flint {@link ChartAssemblyInput} to a Vega-Lite spec and render it * to an SVG string. Throws on assembly or compile failure so the caller can 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/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/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/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/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..efdee5a4 100644 --- a/site/src/routes/editor-examples.ts +++ b/site/src/routes/editor-examples.ts @@ -27,3 +27,23 @@ export const EXAMPLES: Example[] = GALLERY_PICKS.flatMap(({ name, generator }) = if (!testCase) return []; return [{ name, input: testCaseToAssemblyInput(testCase) }]; }); + +// One example asks for behaviour, so the editor shows interaction_spec at work. +const interactiveCase = TEST_GENERATORS['Gallery: Stacked Bar']?.()[0]; +if (interactiveCase) { + const { data, ...rest } = testCaseToAssemblyInput(interactiveCase); + EXAMPLES.push({ + name: 'Interactive bar', + input: { + ...rest, + interaction_spec: { + interactions: [ + { type: 'click-highlight' }, + { type: 'legend-toggle' }, + { type: 'navigate', options: { axes: 'y', pan: false, reset: ['double-click', 'escape'] } }, + ], + }, + data, + }, + }); +} diff --git a/site/src/shared/test-case-utils.ts b/site/src/shared/test-case-utils.ts index d069f105..5b65b60f 100644 --- a/site/src/shared/test-case-utils.ts +++ b/site/src/shared/test-case-utils.ts @@ -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 }, From 4b09ad732bd211ae1741961fb48c2e22be5322ab Mon Sep 17 00:00:00 2001 From: xavier-shaw Date: Tue, 15 Sep 2026 16:43:17 -0700 Subject: [PATCH 08/18] code review fix Review of Stages A to C with the maintainer: - the template block is interactionSupport, not interactions - the drag-region capability is cartesian-region, and the four polar templates declare both regions, because a rectangle or lasso resolves their arcs and brush-x is honoured as a sector - the assembler is the one authority on a chart's capabilities: it derives the confirmed list from declaredInteractionCapabilities and the bound encodings, writes _interactionSemantics for every Vega-Lite chart, and capabilities is a required field of the admission plan; the gates that replayed the old inferred rules are gone, a definition made by hand needs nothing - preset requirements live in INTERACTION_PRESET_REQUIREMENTS only; the registry entries and the definition type no longer carry a copy, and admission no longer imports the registry - one phrase table for the capabilities, one generator pass shared by the two lab tabs, no wrapper around the preview sizing, no dedupe of disjoint warning lists - the coverage tab pins its header, marks unsupported cells with a cross, and slants the preset names - docs: stages B and C, this review, the future steps set aside, and the changelog --- CHANGELOG.md | 10 + docs/adding-a-chart-template.md | 4 +- docs/api-reference.md | 2 +- docs/design-interaction-spec.md | 69 ++++++- docs/interaction-spec.md | 8 +- docs/zh-CN/api-reference.md | 2 +- docs/zh-CN/interaction-spec.md | 8 +- packages/flint-js/src/core/index.ts | 1 + .../flint-js/src/core/interaction-spec.ts | 33 +++- packages/flint-js/src/core/types.ts | 2 +- .../flint-js/src/interactive/interactions.ts | 4 +- .../src/interactive/spec/admission.ts | 55 ++---- .../flint-js/src/interactive/spec/registry.ts | 24 +-- packages/flint-js/src/validate/index.ts | 5 +- packages/flint-js/src/vegalite/assemble.ts | 182 +++++++++--------- .../src/vegalite/interactions/compile.ts | 5 +- .../flint-js/src/vegalite/templates/area.ts | 4 +- .../src/vegalite/templates/bar-table.ts | 2 +- .../flint-js/src/vegalite/templates/bar.ts | 12 +- .../flint-js/src/vegalite/templates/bullet.ts | 2 +- .../flint-js/src/vegalite/templates/bump.ts | 2 +- .../src/vegalite/templates/calendar.ts | 2 +- .../src/vegalite/templates/candlestick.ts | 2 +- .../vegalite/templates/connected-scatter.ts | 2 +- .../src/vegalite/templates/density.ts | 2 +- .../flint-js/src/vegalite/templates/ecdf.ts | 2 +- .../flint-js/src/vegalite/templates/gantt.ts | 2 +- .../flint-js/src/vegalite/templates/jitter.ts | 2 +- .../src/vegalite/templates/kpi-card.ts | 2 +- .../flint-js/src/vegalite/templates/line.ts | 2 +- .../src/vegalite/templates/lollipop.ts | 2 +- .../flint-js/src/vegalite/templates/map.ts | 4 +- .../flint-js/src/vegalite/templates/pie.ts | 4 +- .../flint-js/src/vegalite/templates/radar.ts | 4 +- .../src/vegalite/templates/range-area.ts | 2 +- .../flint-js/src/vegalite/templates/rose.ts | 4 +- .../src/vegalite/templates/scatter.ts | 8 +- .../flint-js/src/vegalite/templates/slope.ts | 2 +- .../src/vegalite/templates/sparkline.ts | 2 +- .../flint-js/src/vegalite/templates/violin.ts | 2 +- .../src/vegalite/templates/waterfall.ts | 2 +- .../tests/interaction-admission.test.ts | 26 +-- .../flint-js/tests/interaction-reset.test.ts | 2 +- .../tests/interaction-support.test.ts | 28 +-- .../tests/semantic-interactions.test.ts | 53 ++++- .../tests/template-interactions.test.ts | 20 +- packages/flint-mcp/src/tools/list.ts | 2 +- packages/flint-mcp/ui/src/FlintApp.tsx | 17 +- packages/flint-mcp/ui/src/render.ts | 10 +- scripts/gen-chart-reference.ts | 8 +- site/src/playground/ClickFocusLab.tsx | 23 +-- .../src/playground/InteractionCoverageLab.tsx | 72 ++----- site/src/playground/interaction-coverage.css | 50 +++-- site/src/routes/editor-examples.ts | 11 +- site/src/shared/test-case-utils.ts | 28 ++- 55 files changed, 430 insertions(+), 410 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a11cf91b..7c672bfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,16 @@ 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. - Chart validation is now part of the core package. `validateChart(input, backend)` returns `{ valid, warnings, errors, computedSize }` without throwing, alongside `validateChartInput`, `validateSemanticTypes`, diff --git a/docs/adding-a-chart-template.md b/docs/adding-a-chart-template.md index 5a7d55c1..fbb6b50b 100644 --- a/docs/adding-a-chart-template.md +++ b/docs/adding-a-chart-template.md @@ -44,7 +44,7 @@ export const dotPlotDef: ChartTemplateDef = { chart: 'Dot Plot', template: { mark: 'circle', encoding: {} }, channels: ['x', 'y', 'color', 'size', 'column', 'row'], - interactions: { + interactionSupport: { elements: true, // marks resolve to data rows region: ['cartesian'], // rectangle and lasso drags resolve marks navigation: {}, // continuous x and y pan and zoom @@ -75,7 +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. **`interactions`** — 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. +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 4367ef68..ef29541f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -173,7 +173,7 @@ interface InteractionSpec { `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.interactions)` +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` diff --git a/docs/design-interaction-spec.md b/docs/design-interaction-spec.md index 57a9f3ef..3b3d75a1 100644 --- a/docs/design-interaction-spec.md +++ b/docs/design-interaction-spec.md @@ -555,18 +555,19 @@ The registry's `requires` was written but never read. Three parts, each in one place: 1. **A vocabulary of chart capabilities** (`InteractionCapability`, core): a fact some preset - reads at runtime. `elements` (marks resolve to data), `region` (any drag region the plot - resolves marks in), `angular-region` (the polar kind), `navigation`, `reorder`, `legend`, - `discrete-axis`, `index` (one x position reads every series). `brush-x` on a polar chart is - honoured as an angular brush, which is why the brushes need a region of either kind and only - `brush-angle` needs the angular one. -2. **Per preset, `requires`** in the registry, now a list: the smallest set without which the + reads at runtime. `elements` (marks resolve to data), `cartesian-region` (a rectangle, + interval, or lasso drag the plot resolves marks in), `angular-region` (a sector drag), + `navigation`, `reorder`, `legend`, `discrete-axis`, `index` (one x position reads every + series). A polar chart declares both regions: a rectangle or lasso resolves its arcs by + pixel bounds, and `brush-x` on it is honoured as an angular brush. Only `brush-angle` needs + the angular one. +2. **Per preset, `INTERACTION_PRESET_REQUIREMENTS`** in core, one list per preset: the smallest set without which the preset does nothing. Brushes need `elements` and a region; `brush-zoom` and `navigate` need `navigation`; `legend-toggle` needs `legend`; `axis-highlight` needs `discrete-axis`; `drag-reorder` needs `reorder`; `inspect-index` needs `index`; the click, hover, and inspect presets need `elements`. Each wrapper stamps `preset` on its definition so a code-made - definition is checked the same way; a custom definition may state `requires` itself. -3. **Per template, `interactions`** on `ChartTemplateDef`: one block that absorbed the former + definition is checked the same way; a definition made by hand needs nothing. +3. **Per template, `interactionSupport`** on `ChartTemplateDef`: one block that absorbed the former `navigation` and `reorder` fields and the `supportedRegionGestures` entry of `semanticInteractions`. An absent key means never. The assembler confirms the data-dependent capabilities against the bound encodings and writes the active list into @@ -599,7 +600,7 @@ mark geometry), **P** = the probe over the shipped test cases, **J** = judgment, | Line Chart | ✓ | cartesian | x, y | ✓ | ✓ | | ✓ | T, P, J | | Area Chart, Streamgraph, Range Area Chart | ✓ | cartesian | x, y | | ✓ | | ✓ | T, J | | Bump Chart, Slope Chart | ✓ | cartesian | x, y | ✓ | ✓ | ✓ | ✓ | T, P, J | -| Pie Chart, Donut Chart, Rose Chart, Radar Chart | ✓ | angular | | | ✓ | | | T | +| Pie Chart, Donut Chart, Rose Chart, Radar Chart | ✓ | cartesian, angular | | | ✓ | | | T | | KPI Card | ✓ | | | | | | | T, J | | Map, Choropleth | ✓ | cartesian | geo | | ✓ | | | T, J | @@ -643,3 +644,53 @@ where the chart type supports it but this case's data lacks a property, and a sm the chart type never offers what the preset needs. `list_chart_types` and the generated chart reference report the same list, so the list an agent reads and the list the mount enforces come from one block. + +## 12. Stages B and C (landed 2026-09-12), and what waits + +**Stage B, validation and discovery.** `validateChart()` checks `interaction_spec` the way +the mount does (`validateInteractionSpec`): a malformed spec is an `invalid_interaction_spec` +error, a dropped entry is the same `unsupported_interaction` warning the surface reports, and +a static backend reports the spec as ignored. The MCP tool schema carries `interaction_spec` +with the preset names as an enum; `validate_chart` reports the drops; `list_chart_types` +returns `interactions` per chart type from the template declaration. Docs: `interaction-spec.md` +(en, zh-CN), the API reference, the chart-author skill, and an **Interactions** line per chart +type in the generated Vega-Lite reference. + +**Stage C, hosts.** The MCP chart view mounts `buildInteractiveChart()` with the CSP-safe +expression interpreter when the input lists interactions, on the same preview input the +static render sizes; the static render keeps running for the PNG export. The site gained one +spec-aware component, `InteractiveVegaLiteView`, used by the editor and by `TripleChart` +whenever the input carries interaction entries; a `TestCase` may carry an `interactionSpec`. +The index chart stage and the chart-to-external lab, the two demos that used presets only, +now ask for them in `interaction_spec`. Demos with custom definitions stay in code. + +**Review of Stage A (2026-09-14 to 15).** The template block is `interactionSupport`; the +drag-region capability is `cartesian-region`, and the polar templates declare both regions; +the assembler is the one authority on a chart's capabilities, and the gates that replayed the +old inferred rules are gone; `capabilities` is a required field of the admission plan, and the +assembler writes `_interactionSemantics` for every Vega-Lite chart, so the validator and the +mount read one object and never disagree; the coverage tab pins its header and marks +unsupported cells with a cross. + +### Future steps, not started + +Considered and set aside on 2026-09-15 as not needed yet. Each is small and self-contained. + +- **A `text` template for `click-annotate`**, so a spec can word the annotation without the + `format` function: `{ "options": { "text": "{Country}: {Value}" } }`, filled from the element's + value and its first record. +- **An `external-select` preset**, a definition with no gesture that a host drives through + `surface.dispatch(id, { values })`, emitting a `set-style` over `select.key` targets so a linked + view can emphasise rows without code. It would require `elements` and have no reset, and the + resolver would then return `InteractionDef[]`. +- **Gesture guide ink from the theme**: `interaction.gestureGuide.color` in `theme_spec`, + grounded to the accent then the text ink, carried on the plan, and applied where a preset's + guide style names no colour. The triggers would keep their raw guide options so the runtime + can normalise them with the ink. +- **An interaction-author skill** beside the chart-author and theme-author skills, holding + every preset's options, the reset gestures, the support table, the conflict rules, and + worked examples; the preset blocks generated from the registry. +- **Named interaction bundles**, a string form of `interaction_spec` such as `"explore"`, parallel + to `theme_spec: "economist"`. +- **`surface.setInteractionSpec(spec)`**, to change a mounted chart's behaviour without a + rebuild. diff --git a/docs/interaction-spec.md b/docs/interaction-spec.md index 5591cc25..39f6dd7d 100644 --- a/docs/interaction-spec.md +++ b/docs/interaction-spec.md @@ -45,11 +45,11 @@ An entry has no string shorthand: `"click-highlight"` alone is rejected, `{ "typ | `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, region | click-none, escape | -| `lasso-select` | Draws a freehand region to emphasise the marks inside. | elements, 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, region | click-none, 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, 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 | diff --git a/docs/zh-CN/api-reference.md b/docs/zh-CN/api-reference.md index d02dc8c2..a53944bf 100644 --- a/docs/zh-CN/api-reference.md +++ b/docs/zh-CN/api-reference.md @@ -145,7 +145,7 @@ interface InteractionSpec { } ``` -`buildInteractiveChart()` 读取它,装配器忽略它。图表类型无法支持的条目会以 `unsupported_interaction` 警告被丢弃;`validateChart` 在渲染前报告同样的警告。`supportedInteractionPresets(def.interactions)` 列出模板按声明支持的预设。参见[使用交互](/documentation/interaction-spec)。 +`buildInteractiveChart()` 读取它,装配器忽略它。图表类型无法支持的条目会以 `unsupported_interaction` 警告被丢弃;`validateChart` 在渲染前报告同样的警告。`supportedInteractionPresets(def.interactionSupport)` 列出模板按声明支持的预设。参见[使用交互](/documentation/interaction-spec)。 ### `chart_spec` diff --git a/docs/zh-CN/interaction-spec.md b/docs/zh-CN/interaction-spec.md index 7cc643dd..bb6ce530 100644 --- a/docs/zh-CN/interaction-spec.md +++ b/docs/zh-CN/interaction-spec.md @@ -45,11 +45,11 @@ | `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 | +| `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 | +| `linked-brush` | 刷选标记,在其他视图中高亮相同的组(必须提供 `groupBy`)。 | 元素、直角坐标区域 | click-none, escape | | `brush-zoom` | 拖出矩形并放大到该范围。 | 导航 | double-click, escape | | `navigate` | 拖动平移、滚轮或双指缩放连续坐标轴(`axes`、`pan`、`domainGuard`)。 | 导航 | double-click | | `legend-toggle` | 点击图例项以隐藏或恢复其系列。 | 离散图例 | 无 | diff --git a/packages/flint-js/src/core/index.ts b/packages/flint-js/src/core/index.ts index 8660d97d..4515fa8e 100644 --- a/packages/flint-js/src/core/index.ts +++ b/packages/flint-js/src/core/index.ts @@ -202,6 +202,7 @@ export { isRegistered, getRegisteredTypes } from './type-registry'; export { INTERACTION_PRESET_TYPES, INTERACTION_CAPABILITIES, + INTERACTION_CAPABILITY_DESCRIPTIONS, INTERACTION_PRESET_REQUIREMENTS, declaredInteractionCapabilities, supportedInteractionPresets, diff --git a/packages/flint-js/src/core/interaction-spec.ts b/packages/flint-js/src/core/interaction-spec.ts index 952ebf2b..ac1017bc 100644 --- a/packages/flint-js/src/core/interaction-spec.ts +++ b/packages/flint-js/src/core/interaction-spec.ts @@ -39,12 +39,13 @@ export type InteractionPresetType = (typeof INTERACTION_PRESET_TYPES)[number]; /** * A fact about a chart that at least one interaction preset reads at runtime. - * `region` is any drag region the plot resolves marks in; `angular-region` is - * the polar kind, which only the angular brush needs. + * `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', - 'region', + 'cartesian-region', 'angular-region', 'navigation', 'reorder', @@ -55,9 +56,21 @@ export const INTERACTION_CAPABILITIES = [ 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.interactions`. An absent key means the chart type never + * `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. @@ -90,13 +103,13 @@ export const INTERACTION_PRESET_REQUIREMENTS: Readonly> = { - 'elements': 'marks that resolve to data', - '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', -}; - -/** - * A plan the assembler did not annotate is read the way the compile step read it: - * element semantics, the angular flag, and the navigable axes decide; the other - * capabilities are taken as present. - */ -function inferredCapabilities(plan: InteractionAdmissionPlan): readonly InteractionCapability[] { - const list: InteractionCapability[] = ['legend', 'reorder', 'discrete-axis', 'index']; - if (!!plan.resolve || plan.fields.length > 0 || plan.selectableMarks.length > 0) list.push('elements', 'region'); - if (plan.supportedRegionGestures?.includes('angular')) list.push('angular-region'); - if ((plan.navigationAxes ?? []).length > 0) list.push('navigation'); - return list; -} - -/** A custom definition states its needs; a preset carries them through the registry; anything else is read off the event source. */ +/** A preset needs what the core table says; a definition made by hand needs nothing. */ export function interactionRequirements(interaction: CanvasInteractionDef): readonly InteractionCapability[] { - if (interaction.requires) return interaction.requires; - if (interaction.preset) return INTERACTION_PRESETS[interaction.preset].requires; - const source = interaction.eventSource; - if (source.type === 'navigation') return ['navigation']; - if (source.type === 'region') { - return source.regionGeometry === 'angular' ? ['elements', 'angular-region'] : ['elements', 'region']; - } - if (source.type === 'element') return ['elements']; - return []; + return interaction.preset ? INTERACTION_PRESET_REQUIREMENTS[interaction.preset] : []; } /** @@ -96,7 +63,7 @@ export function admitInteractions( warnings.push({ severity: 'warning', code, message: `${message} ${DROPPED}` }); return false; }; - const capabilities = new Set(plan.capabilities ?? inferredCapabilities(plan)); + const capabilities = new Set(plan.capabilities); const chart = plan.chartType ?? 'this chart'; const available = plan.navigationAxes ?? []; @@ -105,7 +72,7 @@ export function admitInteractions( const missing = interactionRequirements(interaction).find((capability) => !capabilities.has(capability)); if (missing) { return reject(interaction, 'unsupported_interaction', - `Interaction "${interaction.id}" requires ${NEEDS[missing]}; ${chart} has none.`); + `Interaction "${interaction.id}" requires ${INTERACTION_CAPABILITY_DESCRIPTIONS[missing]}; ${chart} has none.`); } const source = interaction.eventSource; if (source.type === 'navigation') { diff --git a/packages/flint-js/src/interactive/spec/registry.ts b/packages/flint-js/src/interactive/spec/registry.ts index 8792dae3..331d1697 100644 --- a/packages/flint-js/src/interactive/spec/registry.ts +++ b/packages/flint-js/src/interactive/spec/registry.ts @@ -46,8 +46,6 @@ export interface InteractionPresetDefinition[0] - : { fields: [], selectableMarks: [] }; + 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); diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index f28d68c3..61e337ae 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -53,7 +53,7 @@ import { InstantiateContext, } from '../core/types'; import type { ChartWarning, ChartOption, OptionEvalContext } from '../core/types'; -import type { InteractionCapability } from '../core/interaction-spec'; +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'; @@ -885,7 +885,7 @@ 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 support = chartTemplate.interactions; + const support = chartTemplate.interactionSupport; const geoNavigation = !!support?.navigation?.geo && unfaceted; const navigationAxes: ('x' | 'y')[] = geoNavigation ? ['x', 'y'] @@ -895,100 +895,94 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { 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 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 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 !!encoding?.field && (encoding.type === 'nominal' || encoding.type === 'ordinal'); + 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 hasElements = 'resolve' in templateSemantics - || templateSemantics.fields.length > 0 - || templateSemantics.selectableMarks.length > 0; - const capabilities: InteractionCapability[] = []; - if (support?.elements && hasElements) capabilities.push('elements'); - if (support?.region?.length) capabilities.push('region'); - if (support?.region?.includes('angular')) capabilities.push('angular-region'); - if (navigationAxes.length > 0) capabilities.push('navigation'); - if (reorderAxes.length > 0) capabilities.push('reorder'); - if (support?.legend && discreteLegend) capabilities.push('legend'); - if (support?.discreteAxis && discreteAxis) capabilities.push('discrete-axis'); - if (support?.index && resolvedEncodings.x?.field) capabilities.push('index'); - result._interactionSemantics = { - ...templateSemantics, - chartType: chartTemplate.chart, - capabilities, - supportedRegionGestures: support?.region ? [...support.region] : undefined, - 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 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 44ece4d9..82df1746 100644 --- a/packages/flint-js/src/vegalite/interactions/compile.ts +++ b/packages/flint-js/src/vegalite/interactions/compile.ts @@ -49,7 +49,7 @@ const SUPPORTED_SPEC_MARKS = new Set(['arc', 'area', 'bar', 'boxplot', 'circle', interface TemplateInteractionSemantics { chartType?: string; - capabilities?: readonly InteractionCapability[]; + capabilities: readonly InteractionCapability[]; fields: string[]; sourceRecords?: readonly Record[]; provenanceFields?: readonly string[]; @@ -63,7 +63,6 @@ interface TemplateInteractionSemantics { rangeLegendChannels?: readonly string[]; selectableMarks: string[]; annotationMarkType?: string; - supportedRegionGestures?: ('cartesian' | 'angular')[]; navigationAxes?: ('x' | 'y')[]; geoNavigation?: boolean; geoLevels?: GeoLevelConfig; @@ -495,7 +494,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/templates/area.ts b/packages/flint-js/src/vegalite/templates/area.ts index dafa7325..e3843e9c 100644 --- a/packages/flint-js/src/vegalite/templates/area.ts +++ b/packages/flint-js/src/vegalite/templates/area.ts @@ -133,7 +133,7 @@ export const areaChartDef: ChartTemplateDef = { chart: "Area Chart", template: { mark: "area", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, @@ -222,7 +222,7 @@ export const streamgraphDef: ChartTemplateDef = { chart: "Streamgraph", template: { mark: "area", encoding: {} }, channels: ["x", "y", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts index add9e240..d064bc9b 100644 --- a/packages/flint-js/src/vegalite/templates/bar-table.ts +++ b/packages/flint-js/src/vegalite/templates/bar-table.ts @@ -46,7 +46,7 @@ export const barTableDef: ChartTemplateDef = { config: { view: { stroke: null }, axis: { grid: false, domain: false, ticks: false } }, }, channels: ["y", "x", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], reorder: {}, diff --git a/packages/flint-js/src/vegalite/templates/bar.ts b/packages/flint-js/src/vegalite/templates/bar.ts index 5dfca223..0a5ec41d 100644 --- a/packages/flint-js/src/vegalite/templates/bar.ts +++ b/packages/flint-js/src/vegalite/templates/bar.ts @@ -171,7 +171,7 @@ export const barChartDef: ChartTemplateDef = { chart: "Bar Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, @@ -266,7 +266,7 @@ export const pyramidChartDef: ChartTemplateDef = { config: { view: { stroke: null }, axis: { grid: false } }, }, channels: ["x", "y", "color"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], reorder: {}, @@ -412,7 +412,7 @@ export const groupedBarChartDef: ChartTemplateDef = { chart: "Grouped Bar Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "group", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, @@ -544,7 +544,7 @@ export const stackedBarChartDef: ChartTemplateDef = { chart: "Stacked Bar Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, @@ -637,7 +637,7 @@ export const histogramDef: ChartTemplateDef = { }, }, channels: ["x", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, @@ -717,7 +717,7 @@ export const heatmapDef: ChartTemplateDef = { chart: "Heatmap", template: { mark: "rect", encoding: {} }, channels: ["x", "y", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, diff --git a/packages/flint-js/src/vegalite/templates/bullet.ts b/packages/flint-js/src/vegalite/templates/bullet.ts index 1249cfc5..5e9c0c39 100644 --- a/packages/flint-js/src/vegalite/templates/bullet.ts +++ b/packages/flint-js/src/vegalite/templates/bullet.ts @@ -51,7 +51,7 @@ export const bulletChartDef: ChartTemplateDef = { layer: [], }, channels: ["y", "x", "goal", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], reorder: {}, diff --git a/packages/flint-js/src/vegalite/templates/bump.ts b/packages/flint-js/src/vegalite/templates/bump.ts index 5bb66fb4..f7cdbc70 100644 --- a/packages/flint-js/src/vegalite/templates/bump.ts +++ b/packages/flint-js/src/vegalite/templates/bump.ts @@ -25,7 +25,7 @@ export const bumpChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "detail", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, diff --git a/packages/flint-js/src/vegalite/templates/calendar.ts b/packages/flint-js/src/vegalite/templates/calendar.ts index 5899949c..53d7427c 100644 --- a/packages/flint-js/src/vegalite/templates/calendar.ts +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -105,7 +105,7 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { chart: 'Calendar Heatmap', template: { mark: { type: 'rect', cornerRadius: 2 }, encoding: {} }, channels: ['x', 'color'], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], }, diff --git a/packages/flint-js/src/vegalite/templates/candlestick.ts b/packages/flint-js/src/vegalite/templates/candlestick.ts index 5be31057..77b13294 100644 --- a/packages/flint-js/src/vegalite/templates/candlestick.ts +++ b/packages/flint-js/src/vegalite/templates/candlestick.ts @@ -16,7 +16,7 @@ export const candlestickChartDef: ChartTemplateDef = { ], }, channels: ["x", "open", "high", "low", "close", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: { axes: ['x'] }, diff --git a/packages/flint-js/src/vegalite/templates/connected-scatter.ts b/packages/flint-js/src/vegalite/templates/connected-scatter.ts index 7c9a9351..27cbac64 100644 --- a/packages/flint-js/src/vegalite/templates/connected-scatter.ts +++ b/packages/flint-js/src/vegalite/templates/connected-scatter.ts @@ -71,7 +71,7 @@ export const connectedScatterDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "order", "color", "detail", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, diff --git a/packages/flint-js/src/vegalite/templates/density.ts b/packages/flint-js/src/vegalite/templates/density.ts index d797161d..a1206386 100644 --- a/packages/flint-js/src/vegalite/templates/density.ts +++ b/packages/flint-js/src/vegalite/templates/density.ts @@ -72,7 +72,7 @@ export const densityPlotDef: ChartTemplateDef = { }, }, channels: ["x", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: { axes: ['x'] }, diff --git a/packages/flint-js/src/vegalite/templates/ecdf.ts b/packages/flint-js/src/vegalite/templates/ecdf.ts index 72c7347c..8a9099bd 100644 --- a/packages/flint-js/src/vegalite/templates/ecdf.ts +++ b/packages/flint-js/src/vegalite/templates/ecdf.ts @@ -66,7 +66,7 @@ export const ecdfPlotDef: ChartTemplateDef = { encoding: {}, }, channels: ['x', 'color', 'detail', 'column', 'row'], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: { axes: ['x'] }, diff --git a/packages/flint-js/src/vegalite/templates/gantt.ts b/packages/flint-js/src/vegalite/templates/gantt.ts index 48d5868d..d0055fb4 100644 --- a/packages/flint-js/src/vegalite/templates/gantt.ts +++ b/packages/flint-js/src/vegalite/templates/gantt.ts @@ -39,7 +39,7 @@ export const ganttChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["y", "x", "x2", "color", "detail", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: { axes: ['x'] }, diff --git a/packages/flint-js/src/vegalite/templates/jitter.ts b/packages/flint-js/src/vegalite/templates/jitter.ts index 8e88a243..3f709097 100644 --- a/packages/flint-js/src/vegalite/templates/jitter.ts +++ b/packages/flint-js/src/vegalite/templates/jitter.ts @@ -20,7 +20,7 @@ export const stripPlotDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "size", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, diff --git a/packages/flint-js/src/vegalite/templates/kpi-card.ts b/packages/flint-js/src/vegalite/templates/kpi-card.ts index a2f6498b..2cc2c91d 100644 --- a/packages/flint-js/src/vegalite/templates/kpi-card.ts +++ b/packages/flint-js/src/vegalite/templates/kpi-card.ts @@ -62,7 +62,7 @@ export const kpiCardDef: ChartTemplateDef = { chart: "KPI Card", template: { layer: [] }, channels: ["metric", "value", "goal"], - interactions: { + interactionSupport: { elements: true, }, markCognitiveChannel: 'position', diff --git a/packages/flint-js/src/vegalite/templates/line.ts b/packages/flint-js/src/vegalite/templates/line.ts index f1d28c35..42e03cfa 100644 --- a/packages/flint-js/src/vegalite/templates/line.ts +++ b/packages/flint-js/src/vegalite/templates/line.ts @@ -132,7 +132,7 @@ export const lineChartDef: ChartTemplateDef = { chart: "Line Chart", template: { mark: "line", encoding: {} }, channels: ["x", "y", "color", "strokeDash", "detail", "opacity", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, diff --git a/packages/flint-js/src/vegalite/templates/lollipop.ts b/packages/flint-js/src/vegalite/templates/lollipop.ts index 4a9d9bf3..d3081437 100644 --- a/packages/flint-js/src/vegalite/templates/lollipop.ts +++ b/packages/flint-js/src/vegalite/templates/lollipop.ts @@ -25,7 +25,7 @@ export const lollipopChartDef: ChartTemplateDef = { ], }, channels: ["x", "y", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, diff --git a/packages/flint-js/src/vegalite/templates/map.ts b/packages/flint-js/src/vegalite/templates/map.ts index 5f91572c..8d6d1b98 100644 --- a/packages/flint-js/src/vegalite/templates/map.ts +++ b/packages/flint-js/src/vegalite/templates/map.ts @@ -299,7 +299,7 @@ export const mapDef: ChartTemplateDef = { ], }, channels: ["longitude", "latitude", "color", "size", "opacity"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: { geo: true }, @@ -473,7 +473,7 @@ export const choroplethDef: ChartTemplateDef = { encoding: {}, }, channels: ["id", "color", "detail"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: { geo: true }, diff --git a/packages/flint-js/src/vegalite/templates/pie.ts b/packages/flint-js/src/vegalite/templates/pie.ts index b184a076..eaf4bf34 100644 --- a/packages/flint-js/src/vegalite/templates/pie.ts +++ b/packages/flint-js/src/vegalite/templates/pie.ts @@ -20,9 +20,9 @@ export const pieChartDef: ChartTemplateDef = { chart: "Pie Chart", template: { mark: "arc", encoding: {} }, channels: ["size", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, - region: ['angular'], + region: ['cartesian', 'angular'], legend: true, }, markCognitiveChannel: 'area', diff --git a/packages/flint-js/src/vegalite/templates/radar.ts b/packages/flint-js/src/vegalite/templates/radar.ts index 638e35ea..4a8db6f4 100644 --- a/packages/flint-js/src/vegalite/templates/radar.ts +++ b/packages/flint-js/src/vegalite/templates/radar.ts @@ -285,9 +285,9 @@ export const radarChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, - region: ['angular'], + region: ['cartesian', 'angular'], legend: true, }, markCognitiveChannel: 'position', diff --git a/packages/flint-js/src/vegalite/templates/range-area.ts b/packages/flint-js/src/vegalite/templates/range-area.ts index c240c213..e5f911c8 100644 --- a/packages/flint-js/src/vegalite/templates/range-area.ts +++ b/packages/flint-js/src/vegalite/templates/range-area.ts @@ -50,7 +50,7 @@ export const rangeAreaChartDef: ChartTemplateDef = { chart: 'Range Area Chart', template: { mark: { type: 'area', opacity: 0.5, line: { strokeWidth: 1 } }, encoding: {} }, channels: ['x', 'y', 'y2', 'color', 'column', 'row'], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, diff --git a/packages/flint-js/src/vegalite/templates/rose.ts b/packages/flint-js/src/vegalite/templates/rose.ts index ad88e0de..6fcc50a7 100644 --- a/packages/flint-js/src/vegalite/templates/rose.ts +++ b/packages/flint-js/src/vegalite/templates/rose.ts @@ -42,9 +42,9 @@ export const roseChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, - region: ['angular'], + region: ['cartesian', 'angular'], legend: true, }, markCognitiveChannel: 'area', diff --git a/packages/flint-js/src/vegalite/templates/scatter.ts b/packages/flint-js/src/vegalite/templates/scatter.ts index 1328b0ad..12c5370a 100644 --- a/packages/flint-js/src/vegalite/templates/scatter.ts +++ b/packages/flint-js/src/vegalite/templates/scatter.ts @@ -50,7 +50,7 @@ export const scatterPlotDef: ChartTemplateDef = { chart: "Scatter Plot", template: { mark: "circle", encoding: {} }, channels: ["x", "y", "color", "size", "shape", "detail", "opacity", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, @@ -136,7 +136,7 @@ export const regressionDef: ChartTemplateDef = { ], }, channels: ["x", "y", "size", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, @@ -251,7 +251,7 @@ export const rangedDotPlotDef: ChartTemplateDef = { ], }, channels: ["x", "y", "color"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, @@ -328,7 +328,7 @@ export const boxplotDef: ChartTemplateDef = { chart: "Boxplot", template: { mark: "boxplot", encoding: {} }, channels: ["x", "y", "color", "opacity", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, diff --git a/packages/flint-js/src/vegalite/templates/slope.ts b/packages/flint-js/src/vegalite/templates/slope.ts index cd03010c..c7460032 100644 --- a/packages/flint-js/src/vegalite/templates/slope.ts +++ b/packages/flint-js/src/vegalite/templates/slope.ts @@ -74,7 +74,7 @@ export const slopeChartDef: ChartTemplateDef = { encoding: {}, }, channels: ["x", "y", "color", "detail", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, diff --git a/packages/flint-js/src/vegalite/templates/sparkline.ts b/packages/flint-js/src/vegalite/templates/sparkline.ts index 56379ebd..d0c660ae 100644 --- a/packages/flint-js/src/vegalite/templates/sparkline.ts +++ b/packages/flint-js/src/vegalite/templates/sparkline.ts @@ -116,7 +116,7 @@ export const sparklineDef: ChartTemplateDef = { chart: 'Sparkline', template: { mark: 'line', encoding: {} }, channels: ['x', 'y', 'color', 'detail', 'row', 'column'], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: { axes: ['x'] }, diff --git a/packages/flint-js/src/vegalite/templates/violin.ts b/packages/flint-js/src/vegalite/templates/violin.ts index f6e7a88f..41b543c9 100644 --- a/packages/flint-js/src/vegalite/templates/violin.ts +++ b/packages/flint-js/src/vegalite/templates/violin.ts @@ -148,7 +148,7 @@ 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'], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], discreteAxis: true, diff --git a/packages/flint-js/src/vegalite/templates/waterfall.ts b/packages/flint-js/src/vegalite/templates/waterfall.ts index 50564484..6166165b 100644 --- a/packages/flint-js/src/vegalite/templates/waterfall.ts +++ b/packages/flint-js/src/vegalite/templates/waterfall.ts @@ -37,7 +37,7 @@ export const waterfallChartDef: ChartTemplateDef = { chart: "Waterfall Chart", template: { mark: "bar", encoding: {} }, channels: ["x", "y", "color", "column", "row"], - interactions: { + interactionSupport: { elements: true, region: ['cartesian'], navigation: {}, diff --git a/packages/flint-js/tests/interaction-admission.test.ts b/packages/flint-js/tests/interaction-admission.test.ts index ffb39811..6207e429 100644 --- a/packages/flint-js/tests/interaction-admission.test.ts +++ b/packages/flint-js/tests/interaction-admission.test.ts @@ -7,15 +7,17 @@ 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; @@ -33,7 +35,7 @@ describe('admitInteractions', () => { .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 marks that resolve to data; this chart has none.'); - expect(() => admitInteractions({ ...CARTESIAN, navigationAxes: [] }, [navigate()])) + 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.'); @@ -50,7 +52,7 @@ describe('admitInteractions', () => { }); 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 navigable continuous axis'); const wrongAxis = admitInteractions(CARTESIAN, fromSpec([{ type: 'navigate', options: { axes: 'y' } }])); @@ -162,9 +164,9 @@ describe('admission against the chart type declaration', () => { 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', 'region', 'navigation', 'reorder', 'legend', 'discrete-axis']); + 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', 'region', 'angular-region', 'legend']); + expect(pie.capabilities).toEqual(['elements', 'cartesian-region', 'angular-region', 'legend']); const kpi = semanticsOf('KPI Card', { metric: 'category', value: 'value' }); expect(kpi.capabilities).toEqual(['elements']); }); @@ -211,10 +213,8 @@ describe('admission against the chart type declaration', () => { .toThrow('Interaction "brush-x" requires a plot to drag a region on; KPI Card has none.'); }); - it('a custom definition states its own requirements, and one without any is read from its event source', () => { + it('a definition made by hand needs nothing', () => { const kpi = semanticsOf('KPI Card', { metric: 'category', value: 'value' }); - const custom: CanvasInteractionDef = { ...clickHighlight({ id: 'custom' }), preset: undefined, requires: ['legend'] }; - expect(() => admitInteractions(kpi, [custom])).toThrow('Interaction "custom" requires a discrete legend; KPI Card has none.'); 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..e1873af0 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 () => { diff --git a/packages/flint-js/tests/interaction-support.test.ts b/packages/flint-js/tests/interaction-support.test.ts index 19ab1c41..e82c3de0 100644 --- a/packages/flint-js/tests/interaction-support.test.ts +++ b/packages/flint-js/tests/interaction-support.test.ts @@ -1,11 +1,9 @@ import { describe, expect, it } from 'vitest'; import { - INTERACTION_PRESET_REQUIREMENTS, INTERACTION_PRESET_TYPES, declaredInteractionCapabilities, supportedInteractionPresets, } from '../src/core/interaction-spec'; -import { INTERACTION_PRESETS } from '../src/interactive/spec/registry'; import { vlAllTemplateDefs } from '../src/vegalite/templates'; const def = (chart: string) => vlAllTemplateDefs.find((candidate) => candidate.chart === chart)!; @@ -13,44 +11,38 @@ const def = (chart: string) => vlAllTemplateDefs.find((candidate) => candidate.c 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').interactions)).toEqual(['elements']); - expect(declaredInteractionCapabilities(def('Pie Chart').interactions)) - .toEqual(['elements', 'region', 'angular-region', 'legend']); - expect(declaredInteractionCapabilities(def('Bar Chart').interactions)) - .toEqual(['elements', 'region', 'navigation', 'reorder', 'legend', 'discrete-axis']); + 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').interactions)).toEqual([ + 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').interactions); + 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').interactions); + 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('keeps the registry and the core table in step', () => { - for (const type of INTERACTION_PRESET_TYPES) { - expect(INTERACTION_PRESETS[type].requires, type).toBe(INTERACTION_PRESET_REQUIREMENTS[type]); - } - }); - 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.interactions))); + 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.interactions).length === 0); + const empty = vlAllTemplateDefs.filter((template) => supportedInteractionPresets(template.interactionSupport).length === 0); expect(empty.map((template) => template.chart)).toEqual([]); }); }); diff --git a/packages/flint-js/tests/semantic-interactions.test.ts b/packages/flint-js/tests/semantic-interactions.test.ts index df641847..a27ce0fc 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'] }); @@ -732,7 +736,7 @@ describe('Vega-Lite semantic interactions', () => { .toThrow('requires chart interaction semantics'); expect(() => addVegaLiteInteractions({ mark: 'line', - _interactionSemantics: { fields: [], selectableMarks: [], navigationAxes: ['x'] }, + _interactionSemantics: { fields: [], selectableMarks: [], navigationAxes: ['x'], capabilities: ['navigation'] }, }, [clickMark()])).toThrow('requires marks that resolve to data'); }); @@ -1012,6 +1016,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'], @@ -1452,6 +1457,7 @@ describe('Vega-Lite semantic interactions', () => { color: { field: 'Color', type: 'nominal' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['X', 'Y', 'Color'], seriesField: 'Color', legendFields: { color: 'Color' }, @@ -1479,6 +1485,7 @@ describe('Vega-Lite semantic interactions', () => { y: { field: 'Y', type: 'quantitative' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['X', 'Y'], selectableMarks: ['point'], }, @@ -1503,6 +1510,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 } }, @@ -1642,6 +1650,7 @@ describe('Vega-Lite semantic interactions', () => { y: { field: 'Value', type: 'quantitative' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category', 'Value'], selectableMarks: ['rule', 'circle'], renderHoverStyles: { @@ -1680,6 +1689,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 } }, }, @@ -1689,6 +1699,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 } }, }, @@ -1701,6 +1712,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 } }, }, @@ -1715,6 +1727,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 }, @@ -1752,6 +1765,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 }, @@ -1930,9 +1944,12 @@ 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 an angular region'); @@ -1945,7 +1962,7 @@ describe('Vega-Lite semantic interactions', () => { ...roseChartDef.semanticInteractions!({ resolvedEncodings: { x: { field: 'category', type: 'nominal' }, y: { field: 'value', type: 'quantitative' } }, }), - supportedRegionGestures: [...roseChartDef.interactions!.region!], + capabilities: ['elements', 'cartesian-region', 'angular-region'], }, }; const polarPlan = addVegaLiteInteractions(polar, [brushX()]); @@ -1967,7 +1984,7 @@ describe('Vega-Lite semantic interactions', () => { color: { field: 'series', type: 'nominal' }, }, }), - supportedRegionGestures: [...radarChartDef.interactions!.region!], + capabilities: ['elements', 'cartesian-region', 'angular-region'], }, }; expect(addVegaLiteInteractions(radar, [brushAngle()])?.angularXBrush).toBe(true); @@ -2087,6 +2104,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 }] }, @@ -2109,6 +2127,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', @@ -2135,6 +2154,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', @@ -2151,6 +2171,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', @@ -2226,6 +2247,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 } }, @@ -2281,6 +2303,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' } }, }, @@ -2321,6 +2344,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 } }, }, @@ -2572,6 +2596,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 }] }, @@ -2608,6 +2633,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', @@ -2631,6 +2657,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'], @@ -2667,6 +2694,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'], @@ -3431,7 +3459,7 @@ describe('Vega-Lite semantic interactions', () => { } as any); expect(() => instrument(assembleCalendar(), [legendToggle()])) .toThrow('Interaction "legend-toggle" requires a discrete legend; Calendar Heatmap has none.'); - const legendClaimer = { ...legendToggle(), preset: undefined, requires: [] }; + const legendClaimer = { ...legendToggle(), preset: undefined }; const { compiled } = instrument(assembleCalendar(), [legendClaimer]); const view = new View(parse(compiled), { renderer: 'none' }); await view.runAsync(); @@ -3642,6 +3670,7 @@ describe('Vega-Lite semantic interactions', () => { y: { field: 'Value', type: 'quantitative' }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Date', 'Value'], categoryField: 'Date', selectableMarks: ['line'], @@ -3835,6 +3864,7 @@ describe('Vega-Lite semantic interactions', () => { }, }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Group', 'X', 'Value'], categoryField: 'Group', selectableMarks: ['line'], @@ -3900,6 +3930,7 @@ describe('Vega-Lite semantic interactions', () => { }, ], _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Category'], selectableMarks: ['rect'], }, @@ -3927,6 +3958,7 @@ describe('Vega-Lite semantic interactions', () => { mark: 'geoshape', projection: { type: 'mercator' }, _interactionSemantics: { + capabilities: ALL_CAPABILITIES, fields: ['Region'], categoryField: 'Region', selectableMarks: ['geoshape'], @@ -3951,7 +3983,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: { @@ -4056,6 +4088,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' }, @@ -4083,6 +4116,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'], @@ -4112,6 +4146,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'], @@ -4136,6 +4171,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' }, @@ -4158,6 +4194,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 index 28edf393..a00b3dde 100644 --- a/packages/flint-js/tests/template-interactions.test.ts +++ b/packages/flint-js/tests/template-interactions.test.ts @@ -5,22 +5,22 @@ 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.interactions).map((def) => def.chart); + 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.interactions?.elements).map((def) => def.chart); + const without = vlAllTemplateDefs.filter((def) => !def.interactionSupport?.elements).map((def) => def.chart); expect(without).toEqual([]); }); - it('polar templates offer the angular region and nothing cartesian', () => { + it('polar templates offer the angular region beside the cartesian one, and no axis', () => { for (const def of vlAllTemplateDefs) { - const region = def.interactions?.region ?? []; + const region = def.interactionSupport?.region ?? []; if (POLAR.includes(def.chart)) { - expect(region, def.chart).toEqual(['angular']); - expect(def.interactions?.navigation, def.chart).toBeUndefined(); - expect(def.interactions?.reorder, def.chart).toBeUndefined(); + 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'); } @@ -30,14 +30,14 @@ describe('Vega-Lite templates declare their interaction support', () => { 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.interactions?.navigation).toEqual({ geo: true }); - expect(def.interactions?.reorder).toBeUndefined(); + 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.interactions).toBe(pie.interactions); + expect(donut.interactionSupport).toBe(pie.interactionSupport); }); }); diff --git a/packages/flint-mcp/src/tools/list.ts b/packages/flint-mcp/src/tools/list.ts index f4861442..47b92fa0 100644 --- a/packages/flint-mcp/src/tools/list.ts +++ b/packages/flint-mcp/src/tools/list.ts @@ -52,7 +52,7 @@ export function listChartTypes(backend?: RenderBackend): BackendCatalog[] { .map((d) => ({ chartType: d.chart, channels: d.channels ?? [], - interactions: supportedInteractionPresets(d.interactions), + 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/ui/src/FlintApp.tsx b/packages/flint-mcp/ui/src/FlintApp.tsx index 860fa3f4..5361776c 100644 --- a/packages/flint-mcp/ui/src/FlintApp.tsx +++ b/packages/flint-mcp/ui/src/FlintApp.tsx @@ -19,7 +19,7 @@ import { THEME_PRESETS, DEFAULT_THEME_ICON } from 'flint-chart'; import { buildInteractiveChart } from 'flint-chart/interactive'; import { expressionInterpreter } from 'vega-interpreter'; -import { previewAssemblyInput, renderFlintSvg, type FlintRenderResult } from './render'; +import { renderFlintSvg, withAppPreviewDefaults, type FlintRenderResult } from './render'; import { chartIconFor } from './chart-icons'; import { buildPanelModel, @@ -881,16 +881,11 @@ export function FlintAppInner(props: { const interactive = (current.interaction_spec?.interactions?.length ?? 0) > 0; const previewInput = useMemo( - () => previewAssemblyInput(current, chartWidth ? { width: chartWidth } : undefined), + () => withAppPreviewDefaults(current, chartWidth ? { width: chartWidth } : undefined), [current, chartWidth], ); const renderWarnings = render?.warnings ?? []; - const warnings = interactive - ? [ - ...renderWarnings, - ...surfaceWarnings.filter((warning) => !renderWarnings.some((known) => known.message === warning.message)), - ] - : renderWarnings; + const warnings = interactive ? [...renderWarnings, ...surfaceWarnings] : renderWarnings; const shownError = error ?? (interactive ? surfaceError : null); return ( @@ -957,11 +952,7 @@ export function FlintAppInner(props: { ); } -/** - * The live chart when the input carries interaction_spec: the same preview input - * the static render sizes, mounted through the interactive surface. The static - * render keeps running beside it for the PNG export and the assembler warnings. - */ +/** The live chart when the input carries interaction_spec: the preview input, mounted through the interactive surface. */ function InteractiveChart({ input, onWarnings, diff --git a/packages/flint-mcp/ui/src/render.ts b/packages/flint-mcp/ui/src/render.ts index b35fd148..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 { @@ -213,14 +213,6 @@ export function assemblePreviewSpec( return spec; } -/** The input the preview assembles: the same sizing the static render uses, for an interactive mount. */ -export function previewAssemblyInput( - input: ChartAssemblyInput, - viewport?: { width: number; height?: number }, -): ChartAssemblyInput { - return withAppPreviewDefaults(input, viewport); -} - /** * Assemble a Flint {@link ChartAssemblyInput} to a Vega-Lite spec and render it * to an SVG string. Throws on assembly or compile failure so the caller can diff --git a/scripts/gen-chart-reference.ts b/scripts/gen-chart-reference.ts index 842ccd68..ea2c28bf 100644 --- a/scripts/gen-chart-reference.ts +++ b/scripts/gen-chart-reference.ts @@ -310,8 +310,8 @@ function renderChart(def: ChartTemplateDef): string { const channels = (def.channels ?? []).map((c) => `\`${c}\``).join(', ') || '_none_'; lines.push(`**Encoding channels:** ${channels}`); lines.push(''); - if (def.interactions) { - const presets = supportedInteractionPresets(def.interactions).map((type) => `\`${type}\``).join(', ') || '_none_'; + if (def.interactionSupport) { + const presets = supportedInteractionPresets(def.interactionSupport).map((type) => `\`${type}\``).join(', ') || '_none_'; lines.push(`**Interactions:** ${presets}`); lines.push(''); } @@ -410,8 +410,8 @@ function renderChartZh(def: ChartTemplateDef): string { lines.push(`### ${icon ? `![](${icon}) ` : ''}${def.chart}`, ''); const channels = (def.channels ?? []).map((channel) => `\`${channel}\``).join(', ') || '_无_'; lines.push(`**编码通道:** ${channels}`, ''); - if (def.interactions) { - const presets = supportedInteractionPresets(def.interactions).map((type) => `\`${type}\``).join(', ') || '_无_'; + if (def.interactionSupport) { + const presets = supportedInteractionPresets(def.interactionSupport).map((type) => `\`${type}\``).join(', ') || '_无_'; lines.push(`**交互:** ${presets}`, ''); } const props = def.properties ?? []; 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/InteractionCoverageLab.tsx b/site/src/playground/InteractionCoverageLab.tsx index 70d2ecc6..d6865a84 100644 --- a/site/src/playground/InteractionCoverageLab.tsx +++ b/site/src/playground/InteractionCoverageLab.tsx @@ -2,26 +2,24 @@ import { useMemo, useState } from 'react'; import { assembleVegaLite, declaredInteractionCapabilities, + INTERACTION_CAPABILITY_DESCRIPTIONS, INTERACTION_PRESET_REQUIREMENTS, INTERACTION_PRESET_TYPES, - supportedInteractionPresets, vlAllTemplateDefs, type ChartTemplateDef, type InteractionCapability, type InteractionPresetType, } from 'flint-chart'; -import { TEST_GENERATORS, type TestCase } from 'flint-chart/test-data'; +import type { TestCase } from 'flint-chart/test-data'; import { INTERACTION_PRESETS } from 'flint-chart/interactive'; -import { testCaseToAssemblyInput } from '../shared/test-case-utils'; +import { representativeCasesByChartType, testCaseToAssemblyInput } from '../shared/test-case-utils'; import './interaction-coverage.css'; type CellStatus = 'active' | 'declared' | 'unsupported'; -interface Cell { - status: CellStatus; - /** The first requirement the chart lacks, for the tooltip. */ - missing?: InteractionCapability; -} +type Cell = + | { status: 'active' } + | { status: 'declared' | 'unsupported'; missing: InteractionCapability }; interface Row { chartType: string; @@ -32,42 +30,9 @@ interface Row { cells: Record; } -const CAPABILITY_LABEL: Record = { - 'elements': 'marks that resolve to data', - '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', -}; - -/** The case the Test cases tab would show first for a chart type: a real, unfaceted one when there is one. */ -function representativeCase(chartType: string): TestCase | undefined { - let chosen: TestCase | undefined; - for (const generator of Object.values(TEST_GENERATORS)) { - let cases: TestCase[]; - try { - cases = generator(); - } catch { - continue; - } - for (const testCase of cases) { - if (testCase.chartType !== chartType) continue; - const preferred = testCase.tags?.includes('real') - && !testCase.encodingMap.column?.fieldID - && !testCase.encodingMap.row?.fieldID; - if (!chosen || preferred) chosen = testCase; - if (preferred) return chosen; - } - } - return chosen; -} -function rowFor(def: ChartTemplateDef): Row { - const declared = declaredInteractionCapabilities(def.interactions); - const testCase = representativeCase(def.chart); +function rowFor(def: ChartTemplateDef, testCase: TestCase | undefined): Row { + const declared = declaredInteractionCapabilities(def.interactionSupport); let active: readonly InteractionCapability[] = []; let assembleError: string | undefined; if (testCase) { @@ -91,22 +56,25 @@ function rowFor(def: ChartTemplateDef): Row { return { chartType: def.chart, caseTitle: testCase?.title, declared, active, assembleError, cells }; } -const GLYPH: Record = { active: '●', declared: '○', unsupported: '·' }; +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 ${CAPABILITY_LABEL[cell.missing!]}. A spec entry is dropped for this data.`; + 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 ${CAPABILITY_LABEL[cell.missing!]}.`; + 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(() => [...vlAllTemplateDefs] - .sort((left, right) => left.chart.localeCompare(right.chart)) - .map(rowFor), []); + 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; @@ -114,8 +82,6 @@ export function InteractionCoverageLab() { for (const cell of Object.values(row.cells)) counts[cell.status] += 1; return counts; }, { active: 0, declared: 0, unsupported: 0 } as Record); - const staticTotal = visible.reduce((sum, row) => - sum + supportedInteractionPresets(vlAllTemplateDefs.find((def) => def.chart === row.chartType)?.interactions).length, 0); return (
@@ -126,7 +92,7 @@ export function InteractionCoverageLab() { 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 small dot means the chart type never offers what the preset needs. + A cross means the chart type never offers what the preset needs.

{visible.length} chart types @@ -134,7 +100,7 @@ export function InteractionCoverageLab() { {tally.active} active {tally.declared} supported, inactive for this data {tally.unsupported} unsupported - {staticTotal} supported by declaration + {tally.active + tally.declared} supported by declaration