Interactions

Static by default. Opt in with inspect, select, zoom, legendFocus, legendFilter. With more than one draw tool, an accessible tool rail keeps gestures from competing.

Without a controller, state is private to one chart and callbacks report changes. Pass createPlotInteraction() when plots, controls, or tables share semantic state (required, stable semantic scope via interactionScope).

Examples: inspect, interval/zoom, linked views, legend focus, legend filter, facet intervals. Contracts: interaction reference.

Inspection

inspect={true} enables the default HTML tooltip, semantic crosshair, keyboard traversal, and click-or-Enter pinning. Configure it when the chart has a natural comparison axis:

<GGPlot
  {data}
  aes={{ x: "date", y: "value", color: "series" }}
  key="id"
  inspect={{ mode: "x", pin: true, maxDistance: 24 }}
  oninspect={(event) => console.log(event)}
>
  <GeomLine />
  <GeomPoint />
</GGPlot>

The modes are auto, exact, x, y, and xy. auto resolves to a concrete mode before an event is emitted. x and y return one representative per semantic series at the focused axis value; exact and xy return the focused datum. maxDistance is measured in CSS pixels: the dominant axis for x or y, Euclidean distance for xy, and geometry containment plus tolerance for exact. Rect marks (geom_col / geom_bar) never draw a point ring; default hover is tooltip-only. Pass muteSiblings: true to mute non-focused bars via the interaction mask.

For custom HTML, pass a Svelte 5 snippet. Informational content is the default; choose contentMode: "interactive" only when the pinned tooltip contains controls that need focus.

{#snippet details(inspection)}
  <strong>{inspection.focus.row?.name}</strong>
  <span>{inspection.members.length} series at this value</span>
{/snippet}

<GGPlot inspect={{ mode: "x", content: details }} />

Point and interval selection

Point selection is durable identity, not a renderer index. Supply a unique, stable string, number, or symbol for every source row:

<GGPlot
  key="id"
  select={{ type: "point", multiple: true }}
  onselect={(event) => {
    if (event.mode === "point") selectedKeys = event.keys;
  }}
/>

Use interval selection for brushing. The callback receives both the selected domain and normalized plot-pixel rectangle, plus semantic keys and a lineage count for aggregate marks.

<GGPlot
  key="id"
  select={{ type: "interval", mode: "xy", persistent: true }}
  onselect={(event) => {
    if (event.mode !== "point" && event.phase === "end") {
      selectedDomain = event.domain;
    }
  }}
/>

Faceted intervals use stable field-and-value panel identities rather than panel indices. Choose a preset for the relationship between panels:

  • independent (default) replaces the interval in only the origin panel.
  • union keeps independently drawn panel intervals and combines their keys.
  • cross-panel projects one semantic domain through every compatible panel.

cross-panel intersects the interval with each panel's domain when facet scales are free; a disjoint panel selects nothing instead of clamping to an unrelated edge. Panel identity survives row reordering and temporary absence. See the runnable facet example.

Shared controlled state

createPlotInteraction<Key>() owns selection, emphasis, and continuous zoom domains outside any chart. Give linked consumers the same controller and a required, stable semantic scope via interactionScope. A transition is published once by its origin; passive charts render the new snapshot without emitting the callback again. Controlled plots never infer channel names: add an x and/or y scope whenever controlled zoom uses that channel.

<script lang="ts">
  import { createPlotInteraction } from "@ggsvelte/svelte";

  const interaction = createPlotInteraction<string>();
  const scope = { keys: "penguin-id", x: "flipper-mm", y: "mass-g" } as const;
  const selected = $derived(interaction.selected(scope));
</script>

<GGPlot
  {data}
  key="id"
  select={{ type: "point", multiple: true }}
  {interaction}
  interactionScope={scope}
/>
<GGPlot
  {data}
  key="id"
  select={{ type: "point", multiple: true }}
  {interaction}
  interactionScope={scope}
/>
<button onclick={() => interaction.setSelection(["gentoo-1"], { scope })}>
  Select Gentoo 1
</button>

Use setSelection, toggleSelection, and clearSelection for durable keys. setEmphasis is presentation-only: linked charts update their highlight overlay without retraining scales or rerunning the render pipeline. Matching x and y scope names share numeric zoom domains. When application data is replaced, call reconcileKeys(validKeys, { scope }) explicitly; a chart never guesses whether a temporary subset should erase another view's selection.

Durable facet intervals use their own optional interactionScope.intervals namespace (falling back to keys). Read them with intervals(scope), write one with setInterval, clear one panel with clearInterval, or clear the scope with clearIntervals. Interval state is semantic data-space state, not pixels or renderer indices.

Legend focus

legendFocus={true} adds real HTML controls over discrete color and fill legends. Hover and DOM focus preview one chart without mutating shared state. Click, touch, Enter, or Space commits the matching stable row keys; the active entry or Escape clears them. Arrow keys traverse entries in rendered legend order, with Home and End moving to the boundaries.

legendFocus={{ preview: false }} keeps committed activation but disables transient previews. Continuous ramps remain static. A stable key is required: encoded legend values are reported as values, never used as controller keys. Focused and muted marks share one semantic mask across SVG and canvas, and the mask does not retrain scales, recompute statistics, change layout, or reassign colors. Author discrete legend appearance with GuideLegend; see the full guides reference and the runnable three-view example.

Legend filtering

legendFocus is presentation emphasis only — it does not change data. legendFilter={true} adds Show-group checkboxes on discrete color/fill legends and filters rows before facets, stats, scales, layout, and render. Hidden groups stay in the legend catalog and keep the same categorical color when shown again.

Use legendFilter={{ mode: "exclude", multiple: true }} for the default independent checkboxes. mode: "include" stores the shown values instead; multiple: false makes a toggle isolate one group. onlegendfilter reports the raw typed values and field in a LegendFilterClause. Reset legend filters restores the data pipeline; Clear legend focus only removes presentation emphasis. See the stable-color example.

Brush zoom

zoom={true} enables two-dimensional brush zoom. Set zoom={{ mode: "x" }} or zoom={{ mode: "y" }} for a single axis. The tool rail separates Zoom area from Select area when both are enabled. A completed zoom emits explicit domains; Reset zoom or double-click emits a clear event. Faceted interval selection is supported, but faceted brush zoom remains disabled with INTERACTION_INTERVAL_FACET_UNSUPPORTED; use a linked detail view when each facet needs a zoomed inspection surface.

<GGPlot
  zoom={{ mode: "xy" }}
  onzoom={(event) => console.log(event.domains)}
/>

Precise bounds without dragging

After an interval selection or zoom is committed, the tool rail exposes Edit x or y bounds alongside its drag controls. The inline HTML form stages edits: typing does not rerun the chart, Apply commits once, Cancel or Escape discards the draft, and validation focuses the first invalid field. This provides a keyboard and assistive-technology path to the same semantic result as brushing.

  • Linear and reversed scales accept ascending data-space numbers. Reversal is presentation only, so do not enter screen order.
  • Log scales accept positive ascending numbers.
  • Time scales accept ISO 8601 dates or date-times with Z or an explicit offset; events store Unix milliseconds.
  • Band scales use two native selects and include both endpoint categories.

Recovery actions are deliberately separate: Clear panel selection removes one facet interval, Clear all selections removes interval state, Reset zoom restores natural domains, and Reset legend filters restores excluded rows. None of these controls silently performs another reset.

Event reference

All events carry type, phase, and source (pointer, keyboard, touch, or programmatic). Use the focused callback for one capability or oninteraction for the discriminated union of every event.

oninspect(event: PlotInspection)

  • A change is { type: "inspect", phase: "change", state, source, mode, panelId, focus, members }.
  • state is transient or pinned; members is always non-empty and focus is the member under direct inspection.
  • x and y changes also carry the original logical axisValue and its formatted axisLabel.
  • Dismissal is the small event { type: "inspect", phase: "clear", source }.

Each PlotDatum has key, source row when one exists, aggregate sourceKeys and lineageCount, layerIndex, panelId, mapped fields, and a plot-pixel anchor. Keyless or synthetic marks expose key: null; internal renderer indices never leak into callbacks.

onselect(event: PlotSelection)

  • Point selection emits { type: "select", phase: "end" | "clear", mode: "point", keys, source }.
  • Interval selection emits start, change, end, and clear phases with mode, panelId, domain, pixels, keys, lineageCount, and source.

onzoom(event: ZoomEvent)

  • Zoom completion is { type: "zoom", phase: "end", source, domains }.
  • Reset is { type: "zoom", phase: "clear", source, domains: null }.

onlegendfocus(event: LegendFocusEvent)

  • Preview and commit emit { type: "legend-focus", phase: "change", state, source, scale, value, label, keys }.
  • state is transient or committed. value is the raw encoded domain value while keys are distinct stable source-row identities.
  • Dismissal emits { type: "legend-focus", phase: "clear", source }.

onlegendfilter(event: LegendFilterEvent)

  • A change emits { type: "legend-filter", phase: "change", source, clause }.
  • clause names the color or fill scale, source field, typed values, and include or exclude mode. Reset emits phase: "clear" and clause: null.
  • Filtering is data-changing and intentionally separate from the presentation-only onlegendfocus event.

oninteraction(event: PlotInteractionEvent) receives the same objects. It does not wrap or duplicate them. A linked chart that consumes shared state should not re-emit the origin chart's event.

Keyboard and accessibility defaults

Name charts with ariaLabel (subject or takeaway — not generic image alt). Focus the plot, then use arrow keys or brackets to traverse data. Enter or Space pins inspection, activates point selection, or sets the two corners of an area, depending on the active tool. Escape dismisses the current interaction. Keyboard inspection updates a polite live region with a concise axis, count, and pin summary; complete pinned content remains ordinary labelled and navigable DOM. Canvas marks keep SVG axes/legends and the accessible description path.

Identity and diagnostics

Use key="id" when the row has a field, or key={(row) => row.id} for an accessor. Keys must be non-null unique PropertyKey values and stable across updates. Invalid or duplicate keys emit structured diagnostics through ondiagnostic; they never silently fall back to array positions. Stable keys let pinned inspection and point selection follow a datum when data is updated.