Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
542 changes: 542 additions & 0 deletions docs/design-interaction-spec.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions packages/flint-js/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,17 @@ export {
} from './field-semantics';
export { isRegistered, getRegisteredTypes } from './type-registry';

// Declarative interactions: the JSON contract read by flint-chart/interactive
export {
INTERACTION_PRESET_TYPES,
type InteractionPresetType,
type InteractionEntry,
type InteractionSpec,
type AssistedTargetingOptions,
type TargetDetailsOptions,
type TargetFeedbackOptions,
} from './interaction-spec';

// ThemeSpec: public visual-system vocabulary and chart-specific grounding
export {
type ThemeSpec,
Expand Down
82 changes: 82 additions & 0 deletions packages/flint-js/src/core/interaction-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/**
* The declarative interaction contract: what an agent or a person writes in
* `ChartAssemblyInput.interaction_spec`. Pure data, no DOM, no runtime import.
*
* `flint-chart/interactive` turns it into interaction definitions
* (`resolveInteractionSpec`) and exposes the precise per-type option shapes
* (`InteractionPresetSpec`). This module only names the presets and the
* envelope around them, so the core stays free of the interactive runtime.
*/

/** The shipped interaction presets. Each name is also that preset's default id. */
export const INTERACTION_PRESET_TYPES = [
'click-highlight',
'axis-highlight',
'click-group-focus',
'hover-group-focus',
'click-annotate',
'select',
'lasso-select',
'brush-x',
'brush-y',
'brush-angle',
'brush-zoom',
'linked-brush',
'legend-toggle',
'context-activate',
'long-press',
'double-activate',
'inspect',
'inspect-index',
'navigate',
'drag-reorder',
] as const;

export type InteractionPresetType = (typeof INTERACTION_PRESET_TYPES)[number];

/**
* One preset as JSON: the type name, an optional id, and that preset's options
* under `options`, for example
* `{ "type": "navigate", "options": { "axes": "x", "pan": false } }`.
* The options are the ones the matching factory in `flint-chart/interactive`
* accepts; the precise per-type shape is `InteractionPresetSpec` there.
* `id` defaults to `type` and lives on the entry, never inside `options`.
*/
export interface InteractionEntry {
type: InteractionPresetType;
id?: string;
options?: Record<string, any>;
}

/** Pointer acquisition that snaps to a nearby mark instead of requiring a direct hit. */
export interface TargetDetailsOptions {
fields?: readonly string[];
maxRows?: number;
}

export interface TargetFeedbackOptions {
indicator?: boolean;
details?: boolean | TargetDetailsOptions;
}

export interface AssistedTargetingOptions extends TargetFeedbackOptions {
/** Hard override for eligible preset distances, in renderer pixels. */
maxDistance?: number;
}

/**
* How a chart behaves. Sits beside `chart_spec` and `theme_spec` in
* `ChartAssemblyInput`. Only the Vega-Lite interactive surface reads it; the
* assemblers and the static backends leave it untouched. Retained state is not
* part of it; a host applies that through the surface.
*/
export interface InteractionSpec {
/** One entry per interaction. Its type names the preset that makes it, and its options carry its own `reset` list. */
interactions: readonly InteractionEntry[];
/** Presets assist by default; false requires direct hits, maxDistance overrides eligible presets. */
assistedTargeting?: boolean | AssistedTargetingOptions;
keyboardTargeting?: boolean;
}
17 changes: 17 additions & 0 deletions packages/flint-js/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { LabelSizingDecision } from './decisions';
import type { SemanticAnnotation, FormatSpec, DomainConstraint, TickConstraint } from './field-semantics';
import type { ColorDecisionResult } from './color-decisions';
import type { GeometryKind, ThemeGeometry, ThemeSpec } from './theme/types';
import type { InteractionSpec } from './interaction-spec';

/**
* Core types for the chart engine library.
Expand Down Expand Up @@ -1225,6 +1226,22 @@ export interface ChartAssemblyInput {
*/
theme_spec?: ThemeSpec | string;

/**
* Interactions — describes *how it behaves*.
*
* Presets named by type with their options, each with its own reset
* gestures, and the targeting policies. Retained state is not part of it;
* a host applies that through the interactive surface.
* Sits beside `chart_spec` for the same reason `theme_spec`
* does: one behaviour applies to many charts, and a static backend ignores
* it without harm.
*
* Read by the interactive surface in `flint-chart/interactive` (Vega-Lite
* only). The assemblers leave it untouched. A preset the chart cannot
* honour is dropped with a warning rather than failing the chart.
*/
interaction_spec?: InteractionSpec;

/**
* Options for the assembler — layout tuning, tooltips, etc.
* All fields are optional and have sensible defaults.
Expand Down
51 changes: 38 additions & 13 deletions packages/flint-js/src/interactive/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ChartAssemblyInput } from '../core/types';
import { isCanvasInteraction, normalizeInteractions } from './interactions';
import { isCanvasInteraction } from './interactions';
import { composeInteractiveOptions } from './spec/compose';
import { mountInteractiveChartSurface } from './surface';
import type { BuildInteractiveChartOptions, InteractiveChartSurface } from './types';

Expand All @@ -14,7 +15,6 @@ export type {
InteractiveBackend,
InteractiveChartSurface,
InteractiveChartSurfaceOptions,
InteractionDismissPolicy,
InteractiveRenderer,
InteractiveRendererAdapter,
ViewportChannel,
Expand Down Expand Up @@ -52,6 +52,11 @@ export type {
ClickHighlightOptions,
ClickHighlightTarget,
ClickGroupFocusOptions,
ContextActivateOptions,
DoubleActivateOptions,
DragReorderOptions,
LegendToggleOptions,
LongPressOptions,
LinkedBrushOptions,
HoverGroupFocusOptions,
GroupBy,
Expand Down Expand Up @@ -131,17 +136,38 @@ export {
yBrushTrigger,
} from './triggers';
export { clampViewportStart, mountInteractiveChartSurface } from './surface';
export { INTERACTION_PRESET_TYPES } from '../core/interaction-spec';
export type {
InteractionEntry,
InteractionPresetType,
InteractionSpec,
} from '../core/interaction-spec';
export type { InteractionPresetOptions, InteractionPresetSpec } from './spec/types';
export { INTERACTION_PRESETS, listInteractionPresets } from './spec/registry';
export type {
InteractionCapability,
InteractionGestureFamily,
InteractionPresetDefinition,
InteractionPresetSummary,
} from './spec/registry';
export { resolveInteractionSpec } from './spec/resolve';
export { INTERACTION_RESET_GESTURES, interactionsToReset, normalizeResetGestures } from './reset';
export type { InteractionResetGesture } from './reset';
export { admitInteractions } from './spec/admission';
export { composeInteractiveOptions } from './spec/compose';
export type { ComposedInteractiveOptions } from './spec/compose';
export type { InteractionAdmission, InteractionAdmissionPlan } from './spec/admission';
export type { ResolvedInteractionSpec } from './spec/resolve';

export function buildInteractiveChart(
container: HTMLElement,
input: ChartAssemblyInput,
options: BuildInteractiveChartOptions,
): InteractiveChartSurface {
const {
backend, renderer, expressionInterpreter, background,
className, ariaLabel, chartId, updates, assistedTargeting, keyboardTargeting, dismiss,
} = options;
const interactions = normalizeInteractions(options.interactions);
const { backend, renderer, expressionInterpreter, background, className, ariaLabel, chartId } = options;
// The spec and the code are two sources of one configuration; the spec comes first.
const { interactions, updates, assistedTargeting, keyboardTargeting, warnings } =
composeInteractiveOptions(input, options);
const canvasInteractions = interactions.filter(isCanvasInteraction);
const hoverTolerance = Math.max(0, ...canvasInteractions
.filter((interaction) => interaction.eventSource.gesture === 'hover')
Expand All @@ -155,7 +181,7 @@ export function buildInteractiveChart(
throw new Error(`Semantic interactions are not supported by backend "${backend}".`);
},
},
{ className, ariaLabel, chartId, updates },
{ className, ariaLabel, chartId, updates, warnings },
);
}
switch (backend) {
Expand Down Expand Up @@ -184,11 +210,10 @@ export function buildInteractiveChart(
keyboard: keyboardTargeting ? {} : false,
},
keyboardTargeting,
dismiss,
}).mount(chartContainer, chartInput);
},
},
{ className, ariaLabel, chartId, updates, interactions },
{ className, ariaLabel, chartId, updates, interactions, warnings },
);
case 'echarts':
return mountInteractiveChartSurface(
Expand All @@ -200,7 +225,7 @@ export function buildInteractiveChart(
return createEChartsInteractiveRenderer({ renderer }).mount(chartContainer, chartInput);
},
},
{ className, ariaLabel, chartId, updates, interactions },
{ className, ariaLabel, chartId, updates, interactions, warnings },
);
case 'chartjs':
return mountInteractiveChartSurface(
Expand All @@ -212,7 +237,7 @@ export function buildInteractiveChart(
return createChartjsInteractiveRenderer().mount(chartContainer, chartInput);
},
},
{ className, ariaLabel, chartId, updates, interactions },
{ className, ariaLabel, chartId, updates, interactions, warnings },
);
case 'plotly':
return mountInteractiveChartSurface(
Expand All @@ -224,7 +249,7 @@ export function buildInteractiveChart(
return createPlotlyInteractiveRenderer().mount(chartContainer, chartInput);
},
},
{ className, ariaLabel, chartId, updates, interactions },
{ className, ariaLabel, chartId, updates, interactions, warnings },
);
}
}
Loading