flint-interaction-author
Skill4.5k starsChanged 11 days agoMITView on GitHub
Use when: a Flint chart should respond to the reader (highlight, zoom, brush, legend toggle, reorder, annotate, explore, link charts); an application or an agent changes a mounted chart from outside or reads what it shows; or a gesture no preset gives must be built. Produce one valid interaction_spec, one ChartUpdate, or code against flint-chart/interactive, with no invented names.
- Installs packages
We split this skill into the rules it gives an agent
15 rules. About 0 are common, 5 are distinctive, and 10 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
10
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 29
DO use only the names in this skill: preset types, options, ops, states, triggers, cursors, event actions.
- Distinctive: line 31
When the request asks for a behaviour no preset gives, say which preset comes closest and what it leaves out, then offer a bespoke …
- Very specific: line 34
DO add behaviour only when the request or the session asks for it.
- Very specific: line 39
Presets through the MCP tools: put the object beside chart_spec as interaction_spec in the chart input.
- Distinctive: line 85
Before any entry, a mounted chart already shows a tooltip with the mark's values on hover and nothing else.
- Very specific: line 525
Every change from outside is one update, { id, ops }.
- Very specific: line 644
After set-data the marks are new.
- Very specific: line 680
Check the input and updates with validate_chart first.
- Distinctive: line 1074
Every entry answers a job from Step 1: a gesture the user named, a question their goal raises, or something the chart invites.
- Very specific: line 1076
Every type is in the chart type's list from list_chart_types, and every option is under options with id on the entry.
- Very specific: line 1085
Every op and state is one from the op table.
- Distinctive: line 1086
Every selector key names encoded fields with values that exist in the rows.
- Very specific: line 1095
Every trigger, cursor, and field name is one from this skill or the installed flint-chart/interactive types.
- Very specific: line 1097
Every definition declares affordances for the hits its trigger yields.
- Very specific: line 1098
No two definitions, and no definition and spec entry, share a trigger or an id.
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
flint-chart: interactions and updates on a chart
A Flint chart is shared state. A reader's gesture becomes an event, a handler
turns the event into an update { id, ops }, updates compose as layers, and
the layers render into a state the host and the agent read back. An
application and an agent write the same kind of update directly. The full
model is in the documentation:
https://microsoft.github.io/flint-chart/#/documentation/interaction-introduction
The chart itself (chart_spec, semantic_types) is the flint-chart-author
skill. Everything here runs on the Vega-Lite backend; a static render and the
other backends leave interaction_spec and updates untouched.
What you produce (and what you do NOT)
| The request says | You produce | Section |
| A gesture or a behaviour on the chart: "highlight on click", "zoom", "brush a period", "make it interactive", "add behaviour to this chart". | One interaction_spec: { "interactions": [ ... ] }, presets only. | Add interactions to the chart |
| A change to a chart that is already on screen, from outside: "point out Japan", "frame 2010 to 2020", "note this value", "reorder by value", an agent answering a user with the chart. | One ChartUpdate: { "id", "ops": [ ... ] }. | Update the chart |
| Code around a chart: "in my app", "on click open the record", "a panel that follows the hover". | TypeScript against flint-chart/interactive: mount, presets as factories, onChange, dispatch. | Use Flint in an application |
| Code across charts or over time: "link these two charts", "a table that follows the selection", "play the years". | linkCharts or useLinkedCharts; dispatch on a timer. On an MCP board, link_spec instead (board skill, "Step 5 — connect them"). | Link charts |
| A gesture no preset gives: "drag along the path", "draw a guess", "a rule for the box", "a lens the app draws". | A CanvasInteractionDef: a trigger, affordances, a handler. | When no preset fits |
- DO use only the names in this skill: preset types, options, ops, states, triggers, cursors, event actions. Every name here is one the library ships. When the request asks for a behaviour no preset gives, say which preset comes closest and what it leaves out, then offer a bespoke definition if the host runs code.
- DO add behaviour only when the request or the session asks for it. A
static image or a bare spec gets no
interaction_spec.
Which workflow the user is asking for
- Presets through the MCP tools: put the object beside
chart_specasinteraction_specin the chart input;validate_chartreports its warnings without drawing, andshowdraws it with the same report.list_interaction_presetswithchartTypelists the presets that chart type supports, what each does, and its options. - An update through the MCP tools: there is no handle on the chart. Pass
the layers as the
updatesargument beside the input, tovalidate_chartandshow; see "Step 4 — send the layer and read the result". - Code, only when the user asks for code: mount with
mountChart, or<FlintChart>in React; presets as factory calls, updates withapplyUpdate, state withgetStateandonChange. The programming API is documented at https://microsoft.github.io/flint-chart/#/documentation/interaction-api - A bespoke interaction needs a host that runs code. In the MCP view, the answer is the closest preset and what it leaves out.
Add interactions to the chart
Step 1 — read the request and the chart
Three things in the request decide the entries, read in this order:
- What the user named. A gesture or a preset in their words: "brush a period", "zoom", "toggle the legend", "highlight on click". Each is one job.
- The goal they stated. "Explore", "compare the regions", "find outliers", "present this", "a control in my app". A goal is the set of questions the reader will ask of this chart; each question is one job.
- Nothing named, only "interactive", a live view, a dashboard. The chart and the data say what the reader can do with it.
Then read the chart spec: the chart type, the field on each channel, and the shape of the data. What a chart invites a reader to do:
| The chart | Invites |
| A time series, one or many series | Comparing the series at one x; focusing a period; hiding a series when a colour field is bound; moving along the series when it is long. |
| A scatter or a dense point chart | Focusing a cluster; moving closer; hiding a series when a colour field is bound. |
| A bar, lollipop, or other category chart | Singling out a category; ranking by hand; hiding a series when a colour field is bound. |
| A pie, donut, rose, or radar | Singling out a sector; focusing adjacent sectors. |
| A heatmap or a calendar | Singling out a cell; reading a whole row or column. |
| A map | Singling out a region; moving closer. |
| A chart that is a control in an app | A click the app reads as a selection; a stronger gesture the app reads as an activation. |
| A chart read without a pointer | Walking the chart from the keyboard. |
Step 2 — pick presets
Before any entry, a mounted chart already shows a tooltip with the mark's values on hover and nothing else. An entry adds one behaviour.
Take one row of the map per job. A named gesture takes the row whose "Gesture" is that gesture. A question takes the row whose "Answers" is that question, on a chart whose "Needs" it offers; a broad goal takes two or three rows, the ones the chart invites most. When nothing is named, the rows the chart invites, two or three. A row that answers no job on the request stays out.
| Preset | Gesture | Answers | Needs | Default reset |
| Single one thing out | ||||
click-highlight | Clicks a mark, a legend item, or an axis label; it stays emphasised and the rest mute. Shift-click adds. | Which mark, series, or category is this one? | elements | click-none, escape |
click-group-focus | Clicks a mark; every mark in its group stays emphasised. groupBy names the group field. | Which marks belong with this one? | elements | click-none, escape |
hover-group-focus | Hovers a mark; its group stands out until the pointer leaves. groupBy names the group field; emphasis is "auto" (a line widens and the rest stay as drawn; points, bars, and slices dim the rest), "highlight", or "spotlight". | Which marks belong with this one, at a glance? | elements | none |
axis-highlight | Clicks (or with event: "hover", hovers) a discrete axis label; that category's marks emphasise. | Which marks share this row or column? | discrete axis | click-none, escape |
| Read values | ||||
inspect | Moves over the plot; a guide follows the pointer and the nearest mark on the chosen axes reads out. mode picks the axes and a comparison ("x>" reads every mark past the pointer). | What is at this x, this y, or beyond it? | elements | none |
inspect-index | Moves along the index axis; every series reads out at that position on one guide, each value labelled in its series colour and the position named at the axis. show: "single" reads one series and switches it through the legend; seriesBy names the series field when the chart draws one series at a time; displayValue: false keeps the guides and drops the labels. | What does each series hold at this point? | index axis | escape |
| Focus a region | ||||
select | Drags a rectangle; the marks inside emphasise. mode: "stateful" keeps the rectangle on screen to move and resize. | Which marks lie in this area? | elements, cartesian region | click-none, escape |
lasso-select | Draws a freehand region; the marks inside emphasise. | Which marks lie in this irregular cluster? | elements, cartesian region | click-none, escape |
brush-x, brush-y | Drags an interval along one axis; the marks in it emphasise. mode: "stateful" keeps the interval on screen to move and resize. On a polar chart the x brush is a sector. | Which marks fall in this period or this range? | elements, cartesian region | click-none, escape |
brush-angle | Drags an angular sector on a pie, donut, rose, or radar; the slices in it emphasise. | Which adjacent slices make up this share? | elements, angular region | click-none, escape |
linked-brush | Drags a region; the groups inside emphasise in every chart that mounts the same preset. groupBy names the shared field; mode: 'stateful' keeps the rectangle on screen to move or resize. | Where are these groups in the other charts? | elements, cartesian region | click-none, escape |
| Move the view | ||||
navigate | Drags to pan; wheels or pinches to zoom a continuous axis. axes limits it; pan: false leaves the plot drag free. On a map it moves the projection; on a globe (a world Choropleth with projection: "orthographic") a drag turns the sphere and the zoom stays centred. On a map, geometry.domain.focus names the region at the centre. | What is in this part of the range, closer? | navigation | double-click |
brush-zoom | Drags a rectangle; the chart flies into it. | What is in this box, closer? | navigation | double-click, escape |
| Reduce or rearrange | ||||
legend-toggle | Clicks a legend item; its series hides or returns. Hidden series are a setting and survive every reset. | What does the chart look like without this series? | discrete legend | none |
drag-reorder | Drags a discrete axis label to a new place; the categories reorder. | How do these rank in the order I choose? | reorderable axis | none |
filter-controls | Picks values in controls beside the chart; the rows that fail are removed, or muted with mode: "highlight". One control per listed field. | What does the chart show for this subset of a field it does not draw? | inline data | none |
| Mark for the reader | ||||
click-annotate | Clicks a mark; a note with its value pins to it. | What is this value, left on the chart? | elements | click-none, escape |
| Hand a gesture to the app | ||||
double-activate | Double-clicks a mark; the mark activates and the host reads it. | Which record should the app open? | elements | click-none, escape |
long-press | Holds a mark (500 ms); the mark activates. The touch spelling of the same activation. | Which record should the app open, on touch? | elements | click-none, escape |
context-activate | Right-clicks or long-presses a mark; the host receives a context target and draws its own menu. | What can the app do with this mark? | elements | none |
context-menu | The reader picks marks and a menu of the app's actions opens beside them; picking one emits menu-select with the item's id and the marks. gesture picks how: rectangle (default), lasso, brush-x, brush-y, click, right-click, long-press. items is required: [{ "id", "label" }]. One entry; it brings its own selection. | What can the app do with these marks? | elements, cartesian region | click-none, escape |
| Read without a pointer | ||||
accessible-navigation | Tabs into the chart and walks titles, axes, legends, facet headers, series, and marks with the arrow keys; Enter goes a level in, and inside a level Tab steps through it before moving on. Each step says what the element is and emphasises its data. Space on a focused element runs the click presets. | What is on this chart, from the keyboard or a screen reader? | elements | none |
Within a family the rows answer neighbouring questions, so one chart takes
one of them: inspect reads the marks near the pointer, inspect-index reads
every series at one index; select takes an area, brush-x a period, lasso
a shape; navigate moves continuously, brush-zoom jumps into a box;
double-activate, long-press, and context-activate are three spellings of
"hand this mark to the app" for a mouse, a finger, and a menu.
"Needs" is what the chart type offers; list_chart_types has already applied
it to the list it returns. The data confirms a few at mount, in Step 3. A
viewport a gesture commits, and a reset, fly over 400 ms;
"transition": { "duration": 0 } or "resetTransition": { "duration": 0 }
jumps. Panning and wheel zooming follow the pointer.
filter-controls. It draws real controls beside the chart and changes the
rows the chart shows, so it answers a request that names a field to filter by
("filter by region", "one year at a time", "let me pick the group"), or a data
column the chart does not encode whose values are versions of the same chart:
a year in a snapshot scatter, a population group, a scenario. A field the
chart already draws has its own preset: the colour field legend-toggle, a
continuous axis brush-x or navigate, a category on an axis
click-highlight; and the measure the chart plots is the chart. List fields
explicitly, one or two, three at most: unlisted, the preset picks up to four
categorical and temporal fields itself. One entry per chart. A fixed subset
belongs in the data, and slices side by side are a facet in the
flint-chart-author skill.
Step 3 — resolve conflicts
Each trigger has one owner. At mount, admission gives a trigger to the first
entry that asks for it; a later entry that asks for the same trigger yields
it with an info warning when it can keep the rest, or is dropped with a
warning when it cannot. The chart still renders. Four triggers are asked for
by more than one preset:
| Trigger | Asked for by | Free it with |
| The plot drag | navigate with pan on, every region preset, brush-zoom, drag-reorder | "pan": false on navigate beside one region preset |
| The double-click | double-activate, and any entry whose reset holds double-click (navigate and brush-zoom by default) | "reset": ["escape"] on navigate or brush-zoom |
| The legend click | legend-toggle, click-highlight, inspect-index with show: "single" | "targets": ["mark"] on click-highlight |
| The retained mark focus | click-highlight, click-group-focus | one of them |
click-highlight yields a legend or an axis click on its own, with an info
warning; targets keeps the spec silent. accessible-navigation takes no
pointer trigger, so it sits beside any entry.
The data confirms what the chart type cannot promise. An entry that fails one of these at mount is dropped with a warning:
legend-toggleneeds a colour field bound to a discrete legend.navigateandbrush-zoomneed a continuous, unfaceted axis.drag-reorderneeds a discrete axis in the bound encodings.inspect-indexneeds an index axis, which it finds itself: the temporal axis, else the discrete axis beside a measure, else x. A horizontal bar chart reads along y with no option;axisoverrides the choice.seriesBynames the series field when the chart draws one series at a time.click-group-focus,hover-group-focus, andlinked-brushneedgroupBy, a bound field.
Step 4 — write and validate
The object is { "interactions": [ ... ] }, beside chart_spec as
interaction_spec. Every entry is
{ "type": <preset>, "id"?: <string>, "options"?: { ... } }:
typeis a preset from the map, supported by the chart type perlist_chart_types.optionsholds the preset's own options;list_interaction_presetslists every one with its type (see "Preset options").resetis one of them: omit it for the default list in the map, set it to free a trigger. A preset that keeps nothing (hover-group-focus,inspect,context-activate) accepts noreset.idsits on the entry and is needed only when one preset appears twice.- In code, the same entry is a factory call with the same options:
clickHighlight({ targets: ['mark'] }),brushX(),navigate({ axes: 'x' }).
{
"interactions": [
{ "type": "click-highlight", "options": { "targets": ["mark"] } },
{ "type": "navigate", "options": { "axes": "x" } },
{ "type": "legend-toggle" }
]
}validate_chart checks the object without drawing, and show runs the same
check when it draws. A malformed entry
is an error: an unknown type, an option beside type, an id inside
options, a bare string entry, a missing required option, an unsupported
reset gesture, a duplicate id. A dropped or yielded entry is a warning
that names the entry, what it needed, and the chart type:
Interaction "legend-toggle" requires a discrete legend; Bar Chart has none. The interaction was dropped.
Interaction "click-highlight" yields legend clicks to "legend-toggle".Fix or drop what it warns about. Return the bare object, strict JSON, unless the user asks for commentary.
When no preset fits
A host that runs code mounts a CanvasInteractionDef in options.interactions
beside presets: a trigger, what it affords, and a handler that returns the
layer for its id.
import { rectangleTrigger, type CanvasInteractionDef } from 'flint-chart/interactive';
const timebox: CanvasInteractionDef = {
id: 'timebox',
eventSource: rectangleTrigger('contain'),
affordances: { plot: { cursor: 'region' } },
reset: ['click-none', 'escape'],
handle(event) {
if (event.action !== 'select-region' || event.phase !== 'commit') return null;
const x = event.geometry.domain?.x;
const y = event.geometry.domain?.y;
if (x?.kind !== 'interval' || y?.kind !== 'interval') return null;
const keep = seriesInsideBox(x.start, x.end, Number(y.start), Number(y.end)); // host logic over host rows
return {
id: 'timebox',
ops: [{ op: 'set-style',
targets: keep.map((series) => ({ select: { key: { Series: series } } })),
value: { state: 'emphasized' } }],
};
},
};| Field | Role |
id | Names the interaction in events and the layer it retains. |
eventSource | The trigger: what to capture and how to read it. |
affordances | The kinds of hit the handler receives, each with its cursor and hover; a hit of a kind not listed never reaches it. Keys: mark, legend-item, axis-label, plot. Cursors: activate, drag, region, navigate, inspect, draw. Hovers: target (the mark), cohort (its group). |
handle(event, context) | Returns the whole layer for this id, or null for no change. |
reset | Gestures that clear the layer: click-none, double-click, escape. A reset reports as a commit with no interactionId. It suits a layer of styles the state shows; an overlay or a data layer is cleared by the host. |
Triggers.
| Trigger | The reader does | action | geometry.plot | geometry.domain | Affordance key |
clickTrigger | Clicks a mark, legend item, or axis label. | click-element, click-legend, click-axis | point | — | mark, legend-item, axis-label |
hoverTrigger | Moves over a mark. | hover-element, hover-legend, hover-axis | point | — | same |
dragTrigger(tolerance?) | Presses a mark, or a projectable overlay path, within tolerance px (12) and drags. | drag | drag { start, current, delta } | the pointer's values; geometry.projection while over a projectable overlay | mark |
rectangleTrigger(match?, guide?) | Drags a rectangle. | select-region | rect | x, y intervals | plot |
xBrushTrigger(match?, mode?, guide?), yBrushTrigger(…) | Drags an interval along one axis. | brush-x, brush-y | rect | that axis's interval | plot |
lassoTrigger(match?, guide?) | Draws a freehand region; previews begin at the third point. | select-lasso | polygon | points: the stroke so far, in data values | plot |
angularBrushTrigger(match?, mode?, guide?) | Drags a sector on a polar chart. | brush-angle | angular-sector | — | plot |
inspectTrigger(mode?) | Moves over the plot. | inspect-x, inspect-y, inspect-xy | point | x, y values | plot |
inspectIndexTrigger(axis?, show?, seriesBy?) | Moves along the index axis. | inspect-x, inspect-y | point | the index value | plot |
navigationTrigger({ axes?, pan?, zoom?, reset? }) | Drags, wheels, pinches. | pan-viewport, zoom-viewport, reset-viewport | viewport { delta, factor, anchor } | the viewport after the move | plot |
contextTrigger, longPressTrigger(ms?), doubleActivateTrigger | Right-clicks, holds (500 ms), double-clicks a mark. | context-element, long-press-element, double-activate-element | point | — | mark |
match is 'intersect' (default) or 'contain' and decides which marks the
region's target lists. guide styles the drawn shape; false hides it. A
region is ephemeral: its guide and its preview end at the commit. A brush or
select with mode: 'stateful' keeps its region on screen and editable, and
event.operation reads create, move, resize-leading, resize-trailing,
or clear. Spread a trigger to tune it:
{ ...hoverTrigger, defaultAssistDistance: 28 } acquires the nearest mark
within 28 px. A chart admits a trigger when it offers what the trigger needs,
per the map's "Needs"; a code definition the chart cannot honour throws at
mount with a message that names what it needed.
The event.
interface CanvasInteractionEvent {
action: string; // from the table
phase: 'start' | 'preview' | 'commit' | 'cancel'; // clicks and hovers skip start
operation?: string; // a stateful brush's edit, or pan | zoom | reset
geometry: { plot?, domain?, projection? };
target: { visual: { kind, role }, elements: [{ value, records? }] } | null;
modifiers?: { shift, ctrl, meta };
description?: { kind, type, content, text }; // on focus-element from accessible-navigation
}target.elements[i].valueholds the mark's encoded values, derived fields included;recordsholds the source rows when the runtime can prove them. A region target lists every mark inside. A legend hit'svalueis aLegendTargetValue:{ channel, field, domain: { kind: 'value', value } }, or{ kind: 'interval', start, end }on a continuous legend. An axis-label hit'svalueis anAxisTargetValue:{ axis, field, value }.geometry.domainis inverted through the plot's scales: a brush on a time axis givesdomain.x = { kind: 'interval', start: Date, end: Date }, a linear axis gives numbers, a discrete axis gives the category.descriptionarrives onfocus-elementwhileaccessible-navigationwalks the chart: what the focused element is (type, such as "Bar") and what it represents (content), with the spokentext. The walk's emphasis reports throughonChangeas apreview.geometry.projection.kind === 'path'during a drag over an overlay withprojectable: true:segment.start.valueandsegment.end.valueare the overlay rows on each side of the pointer andsegment.tthe fraction between them.
The types. flint-chart/interactive exports these, and flint-chart/react
re-exports them. onChange receives a ChartChange: the gesture's own
target and geometry, and the chart's state after it. Generated from the
source by npm run gen:reference:
<!-- event-types:start -->interface ChartChange {
// `preview` while a gesture runs, `commit` for a committed change, `cancel` when a gesture ends with none.
phase: ChartChangePhase;
// `host` for `updates`, `applyUpdate`, `clearUpdate` and `dispatch`; always `commit`.
source: ChartChangeSource;
changed: ChartStateFacet[];
// The interaction behind the change: a reader's gesture, or an external interaction run by `dispatch`.
interactionId?: string;
action?: CanvasInteractionAction;
// The gesture's own hit: the hovered point, or the marks at an inspected index.
target?: SemanticTarget | null;
// The gesture's geometry: a brushed range, an index value, or the viewport after a move.
geometry?: CanvasInteractionEvent['geometry'];
// The reset gesture a reader made: a click on nothing, Escape, or a double-click. Reported even when the chart held nothing to reset, so a host that shows a selection of its own can let it go.
reset?: InteractionResetGesture;
state: ChartState;
previous: ChartState;
}
interface ChartState {
chartType: string;
// The marks the chart emphasizes or focuses now, previews included.
selected: SemanticElement[];
// The emphasized marks of each update, by update id; `selected` is their union.
entries?: ReadonlyMap<string, ChartStateEntry>;
// The legend values a toggle hides now.
hidden?: ChartHiddenValue[];
// The domain the plot shows now, when the chart navigates.
viewport?: DomainGeometry;
// The category window of each rail.
windows?: Partial<Record<'x' | 'y', ChartCategoryWindow>>;
categoryOrder?: unknown[];
// The notes the chart holds now, previews included; `inView` tells which ones it draws.
annotations?: ChartAnnotation[];
// The active filter of each field, set by filter controls; absent fields show every value.
filters?: Readonly<Record<string, FilterValue>>;
}
interface SemanticTarget {
visual: {
kind: 'mark' | 'path' | 'region' | 'widget' | 'handle' | 'legend' | 'axis' | 'chart' | 'title' | 'header';
role: string;
};
elements: SemanticElement[];
}
interface SemanticElement {
// Values represented by the mark's channels, or by a semantic control such as a legend item.
value: Record<string, unknown>;
// Contributing input records when provenance is available; zero or many may support one value.
records?: Record<string, unknown>[];
}
interface LegendTargetValue {
channel?: string;
field?: string;
// The entry's value on a reader's hit; read `domain`, which every legend target carries.
value?: unknown;
domain: LegendDomain;
}
type LegendDomain =
| { kind: 'value'; value: unknown }
| { kind: 'interval'; start?: number; end?: number };
interface AxisTargetValue {
axis: 'x' | 'y';
field: string;
value: unknown;
}
interface DomainGeometry {
x?: DomainCoordinate;
y?: DomainCoordinate;
points?: DomainPoint[];
// The detail level a multi-level projected chart draws for this viewport.
level?: string;
// On a multi-level projected chart, the coarsest-level region under the plot centre: its feature id and properties plus the joined row fields.
focus?: Record<string, unknown>;
}
type DomainCoordinate =
| { kind: 'value'; value: DomainValue }
| { kind: 'interval'; start: DomainValue; end: DomainValue };
type DomainValue = number | Date | string;
interface DomainPoint {
x?: DomainValue;
y?: DomainValue;
}
interface ChartHiddenValue {
channel: string;
value: unknown;
}
type FilterValue =
| { in: unknown[] }
| { range: [unknown, unknown] };<!-- event-types:end -->contextis the chart state plusavailable, every drawn mark, for building refs, andselected, the current emphasis, for a toggle rule.
The handler.
- It returns the complete layer for the id on every
previewandcommit; the runtime drops the preview oncancel. Anullon apreviewshows nothing until the commit, which is how a rule applies to the committed box only. A region gesture keeps a preview layer under the interaction's id while the drag runs, so a commit after previews restates the layer; anullthere lets the last preview stand. - It maps geometry to the host's model and the model to ops. Host side
effects (state, panels, overlays the host draws) come from
flint-interactionoronChange, which carry the same event; the handler itself returns ops and leaves the DOM to the runtime. - Triggers have one owner for code definitions as for presets; two code
definitions on one trigger throw at mount.
navigate({ pan: false })goes beside a region trigger. - An element drag owns the press on the marks it affords. A press there is a
dragwith phasestart;targetis the pressed mark through every phase. The layer returned atstartis kept as the gesture's preview, and a release without movement commits that preview with no further call. No click definition fires on those marks, so the definition that drags a mark also answers its press. - A click definition that affords
hoveron marks receivespreviewevents for the hover cue. Returnnullor an emphasis for them, and the layer oncommit.
Patterns.
| Pattern | Flow | Pieces |
| Gesture rule | Flint in, Flint out | A region trigger reads geometry.domain; the host rule picks rows; set-style by key (the timebox above). |
| Host-drawn detail | Flint in, custom out | hoverTrigger or inspectTrigger with a null handle; the host listens to flint-interaction for the resolved element and draws a lens, a cursor, or a badge over the mount. |
| Application drives the chart | external in, Flint out | externalInteraction plus dispatch per frame: a playback year, a reveal progress. |
| Drag along a path | press, then drag | One dragTrigger() definition and one layer. At start on a mark it returns the mark's path as a set-overlay line with interactive: true, projectable: true and order, plus set-data for the current position. On preview and commit with geometry.projection it returns the same two ops for the pointer's position. intrinsicDomain on the x and y fields holds the axes still across the swaps. |
| Freehand stroke | draw | The rows to guess form their own series, and a mount update sets opacity: 0 on that series, which keeps the axis. lassoTrigger('contain', false) with plot: { cursor: 'draw' } is the pen: geometry.domain.points become the rows of a set-overlay line. An externalInteraction reveals the truth as an overlay, then the host clears the hide. |
| Chart-level zoom | wheel | When the layout itself must change (bar step, ticks, domain), filter the rows and mountChart again in a hidden layer; swap on ready. No preset re-lays out. |
| Semantic zoom on a map | navigate | A Choropleth with level: 'auto' (US states, then counties) or with your own levels: [{ value, url, feature?, enter?, exit? }] (one GeoJSON or TopoJSON object per level, rows joined on the feature id or featureKey, baseMapUrl drawn beneath as land without data), or a Map with levelField and levels; navigate(), and set-viewport { region: { key } } with a transition to fly into a region. Navigation events carry geometry.domain.level and focus (the coarsest region under the centre). A hover reaches the host through a hover affordance (hover: 'target'); a handler that returns null leaves the map as drawn. |
Host-drawn detail, the shape of it:
const probe: CanvasInteractionDef = {
id: 'probe',
eventSource: { ...hoverTrigger, defaultAssistDistance: 28 },
affordances: { mark: { hover: 'target' } },
handle() { return null; },
};
container.addEventListener('flint-interaction', (e) => {
const { interactionId, event } = (e as CustomEvent<FlintInteractionEventDetail>).detail;
if (interactionId !== 'probe' || event.phase !== 'preview') return;
const row = event.target?.elements[0]?.records?.[0];
if (row) lens.show(row); // the host draws; the chart is unchanged
});Update the chart
Step 1 — read the state
A host hands the model the chart input and the state, as text or as data. The
MCP chart view does this after every build (first mount, new updates, a
panel edit) and every committed gesture, and each send replaces the last:
Chart "Life expectancy": Line Chart, theme economist.
Edited in panel: theme default → economist.
Encodings: x = year, y = life, color = continent.
Interactions: legend-toggle, navigate.
Agent updates (yours):
- asia:
✓ emphasizes 3 marks where continent = Asia
- oceania:
✗ emphasizes marks where continent = Oceania: no row has continent = Oceania.
User interactions:
- Hidden: Africa.
The y axis shows life from 70 to 86.The state is in field terms: what the chart is after the reader's panel edits,
each op of your updates as applied (✓) or not and why (✗), what the reader
selected, hid or filtered, and the viewport on view when the chart navigates.
validate_chart and show return the same report before the
reader touches the chart, with User interactions: none. From it, decide what the
reader should see next: which rows stand out, what one note says, which range
frames them. The rows you were given are the source of every value; the chart
hands over no copy of the data, and nothing is computed that the chart cannot
verify.
Step 2 — choose ops
Every change from outside is one update, { id, ops }. Each op does one kind
of thing:
| Op | It is for | Effect | Shape | Needs |
set-style | The marks the chart already draws: emphasize some, fade some, hide some, recolour some. A highlight is this op with state: 'emphasized'. | Emphasize the targets and mute the rest; recolour; fade; hide. | { op, targets: UpdateTarget[], value: StyleSpec } | marks |
set-annotation | One mark that should carry a note. | Pin a note on one mark; null clears it. | { op, target: UpdateTarget, value: { text, anchor?: 'segment' | 'point' } | null } | one resolved element |
set-viewport | The range or region the chart should frame. | Frame a continuous domain. | { op, axes: 'x' | 'y' | 'xy', value: { x?: [lo, hi], y?: [lo, hi] } }; on a multi-level map value: { region: { key } } | navigate or brush-zoom mounted on that axis |
set-order | The order of the categories on a discrete axis. | Reorder the axis; list every category in the order wanted. | { op, scope: 'category', field, values: unknown[] } | drag-reorder mounted on that axis |
set-overlay | Marks the chart does not draw: a reference line, a threshold band, a forecast, a sketch of rows. | Draw new rows through the plot's own scales. | { op, name, value: { mark, data: { values }, encodings: { x?, y?, x2?, y2?, order?, color?, text? }, role, interactive?, projectable?, style? } | null } | x and y scales |
set-freeform-overlay | A custom shape with no data behind it. | Draw SVG, or a clone of marks, over the plot. | { op, name, value: { coordinateSpace: 'plot' | 'renderer', body: [{ type: 'svg', content } | { type: 'clone', targets, transform?, opacity? }] } | null } | — |
set-data | The rows the chart draws, for playback or a swap. | Replace every row; the rows carry every encoded field. | { op, source: 'main', value: { rows } } | inline data |
StyleSpec is { state?, opacity?, visible?, fill?, stroke?, strokeWidth?,
mutedOpacity? }. Two states matter from outside: emphasized keeps the
targets at full ink and mutes every other mark; normal with targets: []
clears the layer. opacity on the targets fades a subset and leaves the rest
alone; opacity: 0 hides marks and keeps the axes, where visible: false
removes the rows and shrinks the scales. The mute level is one per chart,
fixed at mount from the lowest mutedOpacity the mounted interactions carry
(0.25 when none).
Overlay marks are line, point, rule, rect, text. encodings name
fields of the overlay's own rows, projected through the chart's x, y, and
colour scales. style takes stroke, strokeWidth, strokeDash, fill,
fillOpacity, opacity, pointRadius, fontSize, fontWeight, textAlign
(start, middle, end), dx, dy. A line takes its stroke and a text
its fill from the color encoding; a point takes style.fill. A text sits on
the data, so it draws a halo in the chart's background; stroke sets another
colour, strokeWidth its width, and stroke: 'none' drops it, as a faint
watermark wants.
Two ops need a preset the chart mounts: set-viewport needs navigate or
brush-zoom on that axis, set-order needs drag-reorder. The host says
which are mounted, or the state shows viewport and categoryOrder. On a
chart that mounts neither, say so and offer an emphasis; set-data replaces
what the chart shows and is not a frame. Legend values belong to
legend-toggle: no update hides or restores one, and a series fades with
opacity instead.
Step 3 — name targets and values
A target is one of three forms:
{ select: { key: { Country: 'Japan' } } } // marks with these values
{ select: { where: { Region: { in: ['Asia', 'Europe'] }, Month: { range: ['2025-03', '2025-06'] } } } } // a set of marks
{ visual: { kind: 'mark', role: 'mark' }, elements } // elements from an event or the state- A
wheredescribes a set, in the words filters use: each fieldina list or within arange(ends included;nullleaves an end open), and every field must hold. Use it for a period, a band of values, or several categories at once, where a key would need one target per row. Onlyset-styletakes awhere; a note or an order names one mark, so it takes a key. - A selector key names fields the chart encodes (
x,y,color,detail,size, …) and matches rows by equality, so'2007'and2007differ. One key may cover many marks ({ Continent: 'Africa' }takes every African mark); several fields narrow it; an annotation's key covers one. On a line or area chart, a key on the series field covers the whole line and a key on the x field covers the segment that starts at that row, so a run of segments is one target per row. The last row of a line starts no segment; its key takes the segment that ends there. A note on an x key marks the segment, for a change from one row to the next ("2019 → 2020: +4%");anchor: "point"marks the row's own point, for its value ("Dec 2022: $0.87B"). A note on the last row is always at its point. - A field the chart does not encode resolves to nothing. Encode the identity
field (
detailon a scatter), or pass elements fromevent.target,state.selected, orcontext.availablethrough as{ visual, elements }, as they are: an element'svaluecarries derived fields on a bar, pie, heatmap, or waterfall mark (a stack start, a sort index), so the element resolves where a key copied from it does not. - A target that matches nothing is listed in the result as unresolved, and nothing nearby is taken in its place.
A key value, an overlay row, and a viewport bound hold what the parsed row
holds: a CSV 120 is the number 120. A temporal field (Year,
YearMonth, Date, …) takes a date written the way the data writes it
("2014-01", 2014 on a Year field) or UTC epoch milliseconds. On a
discrete axis the value is the category. Overlay rows need the fields their
mark reads: line, point, text read x and y. A rule with only x
is a vertical line across the plot, and one with only y a horizontal
threshold; a rect with only x and x2 is a full-height band, and one with
only y and y2 a full-width band. With all four channels, rule and rect
run from (x, y) to (x2, y2). An overlay whose rows do not project is
reported as unsupported.
{ "id": "agent", "ops": [
{ "op": "set-style", "value": { "state": "emphasized" },
"targets": [{ "select": { "key": { "month": "2014-01" } } }, { "select": { "key": { "month": "2014-02" } } }] },
{ "op": "set-overlay", "name": "threshold", "value": { "mark": "rule", "role": "reference",
"data": { "values": [{ "y": 1e9 }] }, "encodings": { "y": { "field": "y" } } } },
{ "op": "set-overlay", "name": "period", "value": { "mark": "rect", "role": "band",
"data": { "values": [{ "start": "2022-11", "end": "2026-08" }] },
"encodings": { "x": { "field": "start" }, "x2": { "field": "end" } }, "style": { "fillOpacity": 0.12 } } }
] }Step 4 — send the layer and read the result
Ids are layers. One id holds one concern: the agent's update, a story
step, a linked selection. Use one stable id, such as agent, and send the
whole layer each time:
- An update with an id already retained replaces that layer, and carries only what it lists: an omitted viewport flies home, an omitted order returns to the input order, an omitted annotation disappears. The ops that should hold are repeated; the ops the request replaces are dropped.
- Layers compose in insertion order, and a replaced layer keeps its place: styles accumulate across layers; for an annotation, an order, a data swap, or an overlay name, the layer inserted last wins. One kind of op lives in one layer: a drag and a playback that both swap the rows write the same id.
- A canvas interaction retains its layer under its own
id; a host may write or clear that layer by the id, as a Reset button does. - A gesture reset (
click-none,escape,double-click) clears preset layers only. A host layer stays until the host clears it, or until a layer withset-style,targets: [],state: 'normal'replaces it. - After
set-datathe marks are new; a style layer is sent again with the data.
The result. applyUpdate, setUpdates, and dispatch return a
ChartUpdateResult: { status: 'applied' | 'partially-applied' |
'unsupported', resolvedTargets, unresolvedTargets, unsupportedOps }. An
unresolved target has a wrong field or value; an unsupported op needs a
preset the chart does not mount. What the chart shows is what the result says
it shows: fix an unresolved key or drop an unsupported op before describing
the chart.
From the MCP view. There is no applyUpdate to call. Pass the same layers
as the updates argument beside the chart input, not inside it, to
validate_chart and then to show as part of chart:
{
"data": { "values": [ ... ] },
"chart_spec": { "chartType": "Bar Chart", "encodings": { "x": "region", "y": "revenue" } },
"updates": [
{ "id": "agent", "ops": [
{ "op": "set-style", "targets": [{ "select": { "key": { "region": "East" } } }], "value": { "state": "emphasized" } },
{ "op": "set-annotation", "target": { "select": { "key": { "region": "West" } } }, "value": { "text": "Lowest in Q3" } }
] }
]
}show opens the chart with the layers applied, with the same look as a
user action. To show the chart in a new state, call show again with the same
input and the layers wanted now. Each show opens a new card with
exactly those layers; earlier cards keep what they showed. The argument is state, and interaction_spec is behaviour, so
each stays where it is. The reader's own brush or selection does not survive
the new view; the context message reports it in field terms, and it is
carried as a layer when it still serves the reader.
Check the input and updates with validate_chart first. A malformed layer
or an invalid spec is an error, and show draws nothing for it. Otherwise the
result is the report above, one line per op, led by a count when an op did not
apply. The chart draws without a ✗ op: fix it in the layer (a key no row has,
an overlay encoding written as "x": "start" instead of { "field": "start" },
a viewport with no navigation preset) before you call show, or tell the
reader what is missing. The
reader's later brushes and clicks reach you with their next message, not in
the same turn.
Use Flint in an application
Mount
npm install flint-chart vega vega-lite vega-tooltipimport { mountChart, navigate, legendToggle } from 'flint-chart/interactive';
const surface = mountChart(container, input, {
backend: 'vegalite',
renderer: 'svg',
interactions: [navigate({ axes: 'x' }), legendToggle()],
chartId: 'sales',
});
await surface.ready;
const warnings = await surface.warnings; // interactions the chart could not honour
// …
surface.destroy();| Member | What it does |
ready, warnings | Promises: the first render; the dropped or yielded interactions. Every call below waits for ready itself. |
applyUpdate(update, options?) | Retains one update by id; replaces the layer with the same id. options.transition.duration animates a viewport. |
setUpdates(updates) | Replaces every retained update. |
clearUpdate(id) | Removes one layer. |
dispatch(id, payload) | Runs an externalInteraction handler and applies what it returns. |
getState() | What the chart shows now; undefined before ready. |
onChange(callback) | Every change to that state, after the render; returns the unsubscribe. |
layout | Once ready: plot, the plot box { width, height } on a chart that draws one plot; views, each drawn view as { role, box: { x, y, width, height } } (role plot, text, tile or rule; facet panels row by row); block, the box the views take with their axes and headers, without title and keys. Boxes are in pixels from the drawn chart's top left, read from what it drew, so they follow filters and resizes. undefined on charts with a category rail and on non-Vega-Lite backends. |
refresh() | Re-projects overlays after the host rescales the chart. |
element, chartId | The mount's root and the stable chart id. |
destroy() | Removes listeners, the renderer, and the DOM. |
inputis aChartAssemblyInput. Presets go ininput.interaction_specas JSON or inoptions.interactionsas factory calls. Both may appear; spec entries mount first, and a code definition with a spec entry'sidreplaces it with aninteraction_overriddeninfo warning.- A malformed
interaction_spec, a repeated codeid, or a code definition the chart cannot honour fails the mount with an error that names it. A spec entry the chart cannot honour is dropped with a warning instead. options.availableSize: { width?, height? }is the room the host gives the chart. It becomes thecanvasSizeceiling on the sides given, so the chart lays out for that room rather than being scaled into it.options.updatesholds retained updates present at the first render, for a chart that starts with a part hidden or framed.renderer: 'svg'for charts whose marks a host reads from the DOM, or under a few thousand marks;'canvas'for maps and dense charts.- A
YearorDatefield makes a temporal axis whose rows holdDates or ISO strings; a bare number such as2013reads as milliseconds after 1970. Asemantic_typesentry may be an object,{ semanticType, intrinsicDomain: [lo, hi] }; the intrinsic domain holds an axis still while the rows change. options: { addTooltips: false }on the input is the switch for the tooltip, for a chart whose hidden values it would give away.- Build with a bundler. The interactive entry reaches every backend through
lazy imports with no file extension; when
echarts,chart.js, andplotly.js-dist-minare not installed, mark them external (esbuild--splitting --external:echarts --external:chart.js --external:plotly.js-dist-min; Vitebuild.rollupOptions.external). - In React, use
<FlintChart>fromflint-chart/reactrather than mounting in an effect; see "In React" below.
In React
import { FlintChart, type FlintChartHandle } from 'flint-chart/react';
import { clickHighlight } from 'flint-chart/interactive';
const chartRef = useRef<FlintChartHandle>(null);
<FlintChart
ref={chartRef}
spec={input}
interactions={[clickHighlight({ targets: ['mark'] })]}
width="100%"
fit="relayout"
onChange={(change) => setSelected(change.state.selected)}
onError={(error) => report(error)}
/>;| Prop | What it does |
spec | The ChartAssemblyInput, interaction_spec included. A change of content remounts; rows compare by reference. |
interactions | Factory calls added to the spec's entries, or (fromSpec) => definitions for the full list. A factory rebuilt with the same id and options keeps the chart; a changed option remounts it. |
updates | Host updates, diffed by id. Passing even [] mounts the runtime on a chart with no interactions. |
width, height, fit | The box. fit is scale-down (default; never scales up), crop (natural size, clipped), or relayout (the box becomes the layout room; a resize remounts once it settles, so a selection does not survive it). |
onChange, onInteraction | The state after a change, and the raw gesture record. |
onRender, onWarnings, onError | Each mount's render and warnings; a failed mount, shown in the box as a muted error. |
fallback | Shown until the chart mounts. |
ariaLabel, chartId | ariaLabel applies in place; a new chartId remounts. |
The ref's FlintChartHandle holds surface (the mounted surface or null),
applyUpdate, clearUpdate, dispatch, getState, and refresh. A remount
keeps the old chart on screen until the new one is ready.
Read the chart
getState() field | Holds |
selected | The marks the chart emphasizes now, previews included. |
entries | The same marks per update id, with layer: 'retained' | 'preview'. |
hidden | Legend values a legend-toggle hides, as { channel, value }. |
viewport | The domain on view after a pan or zoom; absent on a chart that does not navigate. |
categoryOrder | The order of the reorderable axis now. |
windows | Category rail windows, { start, count, total } per channel. |
Each element's value is the mark in field terms: a bar's category and
measure, a legend item's { channel, field, domain }, a histogram bar's
{ field, range }. The host owns the rows and queries them from the value and
the geometry.
onChange fires after every render with a ChartChange: phase (preview
while a gesture runs, commit for a committed change or a host call, cancel
when a gesture ends with nothing), interactionId and action (absent for a
host call and for a reset), the gesture's own target and geometry, and the
state after. An overlay or a data swap leaves no trace in the state, so the
host keeps that model itself.
const valuesOf = (elements: readonly SemanticElement[]) => elements.map((element) => element.value);
// A panel follows the gesture live, else shows what the chart shows.
surface.onChange(({ phase, target, state }) => {
const hit = valuesOf(target?.elements ?? []);
panel.show(phase === 'preview' && hit.length > 0 ? hit : valuesOf(state.selected));
});
// An agent context reads committed state only: values and ranges, never rows.
surface.onChange(({ phase, state, geometry }) => {
if (phase !== 'commit') return;
sendContext({ selected: valuesOf(state.selected), range: geometry?.domain, hidden: state.hidden, viewport: state.viewport });
});The container also emits a bubbling DOM event, flint-interaction, for every
canvas interaction, before the chart reacts: detail.chartId,
detail.interactionId, and detail.event, the same CanvasInteractionEvent
a handler gets. It fires for a definition whose handle returns null, so it
is the path for a host that draws its own response to a gesture.
Things the surface holds fixed: no preset changes the layout, so a zoom never
recomputes the bar step, the ticks, or the domain (filter the rows and mount
again for that); the mute level is set at mount; Escape reaches a chart after
a press on it, and only that chart; a preset's toggle (select,
click-highlight) needs Shift, Ctrl, or Meta, and a plain second click
replaces; a press on an overlay path resolves to no mark, so a click-none
reset on the definition that drew the overlay clears it at that press.
Link charts
linkCharts links mounted charts on the fields they share. A selection on one
chart (a click, a brush, a group focus, a hover) becomes one condition, a part
per field; every chart emphasizes the marks that meet it, the source included,
through one linked-selection layer; a reset on any chart clears it everywhere.
import { linkCharts, mountChart, clickHighlight, brushX } from 'flint-chart/interactive';
const link = linkCharts({
bars: { input: barsInput, surface: mountChart(barsEl, barsInput, { backend: 'vegalite', interactions: [clickHighlight()] }) },
trend: { input: trendInput, surface: mountChart(trendEl, trendInput, { backend: 'vegalite', interactions: [brushX()] }) },
}, { fields: ['region', 'month'] });
for (const [id, chart] of Object.entries(await link.links)) console.log(id, chart.notes); // what each chart left out
link.onCondition(({ condition }) => table.filter(condition)); // { region: { in: ['Asia'] }, month: { range: [start, end] } }
// link.clear(); link.destroy();A table, a filter or any control beside linked charts joins the same
condition: link.select(parts) sets parts as if a chart had selected them, and
onCondition gives the table what to show. One condition, so a row click, a
region picker and a brush combine (AND) and a reset on any chart clears them all.
row.addEventListener('click', () => link.select({ country: { in: [row.dataset.country] } }));
regionPicker.addEventListener('change', (e) => link.select({ region: e.target.value ? { in: [e.target.value] } : null })); // null lets go
link.onCondition(({ condition }) => renderTable(rows.filter((r) => meets(r, condition))));selectonly takes fields infields; a chart shows a part when its marks carry the field (encode it, or put it ondetail).- The link emphasizes the matching marks; to drop rows instead (a filter), give the charts filtered data as below.
import { FlintChart, useLinkedCharts } from 'flint-chart/react';
const link = useLinkedCharts({ bars: barsInput, trend: trendInput }, { fields: ['region', 'month'] });
<FlintChart spec={barsInput} {...link.chart('bars')} />;
// link.select({ region: { in: ["Asia"] } }) from a table or control; link.clear()
<FlintChart spec={trendInput} {...link.chart('trend', { onChange: setPageState, updates: myUpdates })} />;
// the chart's own onChange, onRender and updates go through chart(id, own); a prop after the spread replaces the link's
// link.condition.condition is the same condition; filtered rows in `spec` keep it- A chart receives a field when its marks carry it (encode it, on
detailif nothing else shows it), and sends through each selecting interaction whose subject includes a connected field.{ charts: { trend: { receive: false } } }keeps a chart as drawn;send: falsekeeps its selections to itself. - The link emphasizes; it never filters or re-aggregates. To filter, read the
condition (
onCondition,link.condition) and give each chart its rows: besidelinkCharts, aset-dataupdate (a new spec remounts the chart, and the link holds the old surface); besideuseLinkedCharts, aspecwith the filtered rows. setUpdatesreplaces every layer, the link's included, until the condition next changes; useapplyUpdateandclearUpdatebeside a link.linkChartschecks the charts with the validator, loaded on first use; pass{ links }fromvalidateLinks(flint-chart/validate) to skip that load.
For a link linkCharts does not express (a payload each chart reads its own
way), wire onChange to an externalInteraction on the other charts:
const linked = externalInteraction<{ countries: string[] }>({
id: 'linked',
handle: ({ countries }) => ({
id: 'linked',
ops: [{ op: 'set-style', targets: [{ select: { where: { Country: { in: countries } } } }], value: { state: 'emphasized' } }],
}),
});
source.onChange(({ phase, interactionId, state }) => {
if (phase !== 'commit' || !interactionId) return; // host writes carry no id: no loop
void other.dispatch('linked', { countries: state.selected.map((element) => String(element.value.Country)) });
});Linked charts share one theme_spec so a value has one colour everywhere.
The same dispatch drives a chart over time: a playback loop dispatches one
frame per animation frame, and the handler returns the whole layer for that
frame.
Worked examples
In each example data is a placeholder; the host binds real rows.
Presets: a named gesture
User: "An interactive chart that I can brush." A line chart of sales by
month, one series.
{ "interactions": [ { "type": "brush-x" } ] }The user named one job, a brush along the period axis, and the row for it is
brush-x. Hover already reads the values.
Presets: a goal on a category chart
User: "Which regions stand out? Let me look." A bar chart of revenue by
region, no colour field.
The reader singles out a region and compares orders. click-highlight singles
one out; drag-reorder lets the reader rank by hand. No colour field is
bound, so legend-toggle has nothing to work with. Both presets ask for a
different trigger, a click on a bar and a drag on an axis label.
{
"interactions": [
{ "type": "click-highlight" },
{ "type": "drag-reorder" }
]
}Presets: a multi-series line chart in an explore session
User: "Help me understand how these three regions moved." The chart:
{
"data": { "values": [] },
"semantic_types": { "month": "YearMonth", "sales": "Amount", "region": "Region" },
"chart_spec": { "chartType": "Line Chart", "encodings": { "x": { "field": "month" }, "y": { "field": "sales" }, "color": { "field": "region" } } }
}The reader compares the series at one month, hides a region, and points at a
period. inspect-index reads every series at one month; legend-toggle has a
colour field to work with; brush-x focuses a period. inspect-index with
show: "all" asks for no legend click, so it sits beside legend-toggle.
{
"interactions": [
{ "type": "inspect-index" },
{ "type": "legend-toggle" },
{ "type": "brush-x" }
]
}Presets: a chart that drives the app
User: "In my dashboard, a click selects the country and a double-click opens
its page. Zooming the y axis would help too." A scatter of gdp by life
with color: continent and detail: country.
navigate holds the double-click by default for its reset, and
double-activate needs it; reset: ["escape"] frees it. click-highlight
takes targets: ["mark"] so the app's selection comes from marks only.
{
"interactions": [
{ "type": "click-highlight", "options": { "targets": ["mark"] } },
{ "type": "double-activate" },
{ "type": "navigate", "options": { "axes": "y", "reset": ["escape"] } }
]
}The app reads the selection from onChange on a commit whose action is
click-element, and the activation from one whose action is
double-activate-element.
ChartUpdate: an agent points out rows
User: "Which Asian countries are past 80 years? Point them out." The chart is
the scatter above, with navigate on y, and the state says nothing is
emphasized. The agent finds Japan and Singapore in its rows and answers:
{
"id": "agent",
"ops": [
{ "op": "set-style",
"targets": [
{ "select": { "key": { "country": "Japan" } } },
{ "select": { "key": { "country": "Singapore" } } }
],
"value": { "state": "emphasized" } },
{ "op": "set-annotation",
"target": { "select": { "key": { "country": "Japan" } } },
"value": { "text": "Japan, 82.6 years" } },
{ "op": "set-viewport", "axes": "y", "value": { "y": [70, 86] } }
]
}country resolves because it is encoded on detail. The result returns
applied with two resolved targets; a key with "Country" would come back
unresolved, and the agent fixes the spelling before it describes the chart.
Code: two linked charts
Two bar charts share a Country field: GDP per country, and population per
country. A click on one emphasizes the same country on both.
import { mountChart, clickHighlight, linkCharts } from 'flint-chart/interactive';
const mount = (el: HTMLElement, input: ChartAssemblyInput) =>
({ input, surface: mountChart(el, input, { backend: 'vegalite', interactions: [clickHighlight({ targets: ['mark'] })] }) });
const link = linkCharts({ gdp: mount(gdpEl, gdpInput), population: mount(popEl, popInput) }, { fields: ['Country'] });The clicked chart drops its own emphasis once the link shows the condition, so
both charts show one selection. A click-none, Escape, or double-click on
either chart clears it on both; the link's own writes are host changes, so no
chart echoes them back.
Bespoke: a rule over the brushed box
A line chart of Series by Month. The reader drags a box; the series whose
every point in that month range lies inside the y range stay emphasized. No
preset applies a rule over series, so the definition is the timebox above:
rectangleTrigger('contain'), a plot affordance with the region cursor,
a handler that acts on select-region at commit, and reset:
['click-none', 'escape'] so a click on empty plot clears it. It mounts beside
navigate({ pan: false }) when the chart also zooms, since both ask for the
plot drag otherwise.
Validation checklist
For an interaction_spec:
- It parses as strict JSON and is a bare object with
interactions. - Every entry answers a job from Step 1: a gesture the user named, a question their goal raises, or something the chart invites.
- Every
typeis in the chart type's list fromlist_chart_types, and every option is underoptionswithidon the entry. groupByandseriesByare present where required, and name bound fields.- At most one entry owns the plot drag, the double-click, the legend click, and the retained mark focus.
validate_chartreturns no warning.
For a ChartUpdate:
- Every
opandstateis one from the op table. - Every selector key names encoded fields with values that exist in the rows.
- The update is the whole layer for its id; clearing uses
targets: []withstate: 'normal'. - Ops that need a mounted preset have it, or
unsupportedOpshas been read and acted on. - The result came back
appliedbefore you said what the chart shows.
For code:
- Every trigger, cursor, and field name is one from this skill or the
installed
flint-chart/interactivetypes. - Every definition declares
affordancesfor the hits its trigger yields. - No two definitions, and no definition and spec entry, share a trigger or
an
id. - The surface is destroyed on unmount, and every
applyUpdateordispatchresult is read.
Preset options
list_interaction_presets returns every preset with what it needs from the
chart, its reset gestures, and every option it accepts under options, with
each option's type and note; type narrows it to one preset and chartType
to the presets a chart type supports. In code, the same options are the
factory's parameter type in flint-chart/interactive. id is never an
option: it sits on the entry. A ResetGesture is click-none,
double-click, or escape. dimOpacity is the opacity of the muted marks,
0.25 by default. A guide is { visible?, style? }, or false to draw
nothing while the gesture runs.
Trending
Less talk lately.about the whole repository
Who's talking about microsoft/flint-chart
- @github-trending-js.bsky.social“🚀 Skyrocketing! 🚀 (200+ new stars) 📦 microsoft / flint-chart ⭐ 3,009 (+291) 🗒 TypeScript 🪄 Flint is a …”Bluesky · Aug 2, 2026 · 2 likes
- @yug1224.com“AIエージェントが綺麗なグラフを簡単に作れるようにする可視化言語ツールっぽい エージェントによるデータ分析の効率が上がりそうで見逃せない yug1224 starred microsoft/flint-chart…”Bluesky · Aug 2, 2026 · 2 likes
- @matsuu.bsky.social“AI時代のビジュアライゼーション用言語。バックエンドとしてVega-Lite, ECharts, Chart.js, Plotly, Excelなどを利用可能。MCPも搭載。提供元はMicrosoft”Bluesky · Aug 8, 2026 · 1 likes
- @technews4869.bsky.social“github.com/microsoft/flint... GitHub - microsoft/flint-chart: 🪄 Flint is a visualization language that …”Bluesky · Aug 2, 2026 · 1 likes
- @epicvibecoder.bsky.social“agents that finally make readable charts. Flint is a visualization language for agents. one compact spec, …”Bluesky · Jul 30, 2026 · 1 likes
The biggest 5 of 17 mentions linking to the repository that our crawl found. Each line is their own headline.
See all 17
Talked about on GitHub, Bluesky and Hacker News (17 times).
On GitHub · 10
- Oct 24, 2026chore(web)(deps): bump flint-chart from 0.5.1 to 1.0.0 in /web — cortexapps/engrams0 reactions · 2 comments
- Oct 21, 2026agent-skills.github.microsoft.flint-chart: 0.5.1 → 1.0 — sudosubin/agents.nix0 reactions
- Oct 8, 2026[Candidate] microsoft/flint-chart/.github/extensions/flint-chart/skills/flint-chart-author - flint-chart-author — ProSkillsMD/proskills0 reactions · 1 comment
- Sep 30, 2026agent-skills.github.microsoft.flint-chart: init at 0.5.1 — sudosubin/agents.nix0 reactions
- Sep 7, 2026Bump the frontend-minor-patch group across 1 directory with 21 updates — microsoft/data-formulator0 reactions · 1 comment
5 more on GitHub
- Sep 6, 2026Bump the frontend-minor-patch group across 1 directory with 22 updates — microsoft/data-formulator0 reactions · 1 comment
- Aug 20, 2026chore(web)(deps): bump the web-minor-patch group in /web with 12 updates — cortexapps/engrams0 reactions · 1 comment
- Aug 2, 2026Add flint MCP server — docker/mcp-registry0 reactions
- Aug 2, 2026Data and charting tools — bears-ai/bear-den0 reactions
- Jun 30, 2026jul 2026 — kimtth/azure-openai-llm-notes0 reactions
On Bluesky · 6
- Aug 2, 2026🚀 Skyrocketing! 🚀 (200+ new stars) 📦 microsoft / flint-chart ⭐ 3,009 (+291) 🗒 TypeScript 🪄 Flint is a visualization language that lets… — @github-trending-js.bsky.social2 likes and reposts · 1 reply
- Aug 2, 2026AIエージェントが綺麗なグラフを簡単に作れるようにする可視化言語ツールっぽい エージェントによるデータ分析の効率が上がりそうで見逃せない yug1224 starred microsoft/flint-chart… — @yug1224.com2 likes and reposts
- Aug 8, 2026AI時代のビジュアライゼーション用言語。バックエンドとしてVega-Lite, ECharts, Chart.js, Plotly, Excelなどを利用可能。MCPも搭載。提供元はMicrosoft — @matsuu.bsky.social1 likes and repost
- Aug 2, 2026github.com/microsoft/flint... GitHub - microsoft/flint-chart: 🪄 Flint is a visualization language that lets AI agents reliably... — @technews4869.bsky.social1 likes and repost
- Jul 30, 2026agents that finally make readable charts. Flint is a visualization language for agents. one compact spec, and the compiler derives the… — @epicvibecoder.bsky.social1 likes and repost · 1 reply
1 more on Bluesky
- Jul 28, 2026🚀 Skyrocketing! 🚀 (200+ new stars) 📦 microsoft / flint-chart ⭐ 2,370 (+218) 🗒 TypeScript 🪄 Flint is a visualization language that lets… — @github-trending-js.bsky.social1 likes and repost · 1 reply
On Hacker News · 1
- Jul 2, 2026Microsoft/Flint-Chart1 point
In this repository4 other files it gives its agents.
- flint-board-authorSkill
- flint-chart-authorSkill
- flint-theme-authorSkill
- flint-chart-authorSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

