Upgrade guide

One section per released 0.x transition, newest first. Each heading is a stable anchor that changesets and release notes link to. Pre-1.0, breaking changes ride minor releases; every deprecation or removal ships with a migration note here.

Five-minute path

  • Check that linked @ggsvelte/svelte, core, and spec packages resolve to one compatible release.
  • Read only the adjacent transition sections needed for the installed version.
  • Apply the before/after source change backed by the migration fixtures.
  • Run strict type, build, render, and visual checks before deploying.
  • Follow a stable diagnostic anchor if blocked; roll package versions back together if needed.

The accepted lifecycle and deprecation policy remains in Lifecycle and editions; this page applies it rather than creating a second policy.

0.11 to 0.12

Manual color domain/range diagnostic code

Validation used to emit the code scale-manual-domain-range when a manual color/fill scale had mismatched domain and range lengths. It now emits color-manual-domain-range — the same string the pipeline already used — so agents and the error-reference page have one name for that fault.

If your tooling matches SpecError.code or docs anchors by string, update:

  • code: scale-manual-domain-rangecolor-manual-domain-range
  • docs anchor: #scale-manual-domain-range#color-manual-domain-range (pipeline entry is now #color-manual-domain-range-pipeline when both sources appear on the page)

Pipeline-only and validation-only catalogs remain separate objects, but dual codes share one prose source in @ggsvelte/spec. PIPELINE_ERROR_CATALOG is also exported from @ggsvelte/spec (and still re-exported from @ggsvelte/core).

0.12 to 0.13

Grammar props removed from <GGPlot>

The seven grammar props deprecated in 0.11.0 — theme, scales, coord, facet, labs, guides, and legend — are removed from <GGPlot> in 0.13.0. Compose them only as declaration-only children. The ggsvelte-codemod still rewrites old source that uses the prop form.

LayerDescriptor is removed; use MarkLayerDescriptor.

normalize() returns the post-normalize geom union

normalize() rewrites five convenience geoms to a canonical name — histogram to bar, freqpoly to line, jitter to point, hline and vline to rule. Its declared return type used to name all 49 geoms anyway, so nothing could tell which 44 actually reach the pipeline.

It now returns NormalizedSpec, whose layers are NormalizedLayerSpec — the same shape, minus the five names normalize has already removed. Alongside it, @ggsvelte/spec exports ALIAS_GEOMS, GEOM_ALIASES, AliasGeomName and NormalizedGeomName.

Authored specs are unaffected: PortableSpec and the published JSON Schema still accept every one of the 49 names, and geom: "histogram" works exactly as before.

One kind of caller changes. Code that reads geoms back off a normalized spec and expects all 49 now sees 44:

// Before — the "histogram" arm was reachable in the type, never at runtime.
const spec: PortableSpec = normalize(input);
for (const layer of spec.layers) {
  if (layer.geom === "histogram") { /* dead branch */ }
}

// After — narrow before normalize, or drop the branch.
const spec = normalize(input); // NormalizedSpec
for (const layer of spec.layers) {
  if (layer.geom === "bar") { /* what histogram became */ }
}

Annotating the result as PortableSpec still compiles, so passing a normalized spec on to anything that takes one needs no change.

0.10 to 0.11

Compose the theme as a child layer

The theme prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose the theme as a declaration-only child — named shells for every built-in theme, or the generic <Theme> escape hatch for dynamic names and role overrides.

Before:

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

  // Historical pre-0.13 GGPlot grammar prop (removed). Cast for typecheck.
  /* oxlint-disable-next-line typescript/no-explicit-any -- intentional pre-removal fixture */
  const Plot = GGPlot as any;

  const rows = [
    { x: 1, y: 2 },
    { x: 2, y: 4 },
  ];
</script>

<!-- Before 0.11: theme was a top-level GGPlot prop. -->
<Plot data={rows} aes={{ x: "x", y: "y" }} theme="dark">
  <GeomPoint />
</Plot>

After:

<script lang="ts">
  import { GeomPoint, GGPlot, ThemeDark } from "@ggsvelte/svelte";

  const rows = [
    { x: 1, y: 2 },
    { x: 2, y: 4 },
  ];
</script>

<!-- After 0.11: compose the theme as a declaration-only child layer. -->
<GGPlot data={rows} aes={{ x: "x", y: "y" }}>
  <ThemeDark />
  <GeomPoint />
</GGPlot>

LayerDescriptor was renamed to MarkLayerDescriptor in 0.11.0 and the alias was removed in 0.13.0.

Compose scales as child layers

The scales prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose scales as declaration-only children — named shells for every color/fill helper (<ScaleColorDiscrete/>, <ScaleFillManual/>, British Colour aliases, …), or the generic <Scale value={…}> escape hatch for raw fragments and computed scales. Two children on one channel emit a DUPLICATE_SCALE_CHANNEL advisory (last child still wins).

Named shells route through the matching helpers, so migrating a raw fragment like scales={{color:{scheme:"colorblind"}}} to <ScaleColorDiscrete scheme="colorblind"/> adds type:"ordinal" to the PortableSpec (rendering is unchanged). Use <Scale value={…}> when you need byte-identical PortableSpec.

Before:

<script lang="ts">
  import {
    GeomPoint,
    GGPlot,
    scaleColorDiscrete,
  } from "@ggsvelte/svelte";

  // Historical pre-0.13 GGPlot grammar prop (removed). Cast for typecheck.
  /* oxlint-disable-next-line typescript/no-explicit-any -- intentional pre-removal fixture */
  const Plot = GGPlot as any;

  const rows = [
    { x: 1, y: 2, c: "a" },
    { x: 2, y: 4, c: "b" },
  ];
</script>

<!-- Before 0.11: scales was a top-level GGPlot prop. -->
<Plot
  data={rows}
  aes={{ x: "x", y: "y", color: "c" }}
  scales={scaleColorDiscrete({ scheme: "colorblind" })}
>
  <GeomPoint />
</Plot>

After:

<script lang="ts">
  import {
    GeomPoint,
    GGPlot,
    ScaleColorDiscrete,
  } from "@ggsvelte/svelte";

  const rows = [
    { x: 1, y: 2, c: "a" },
    { x: 2, y: 4, c: "b" },
  ];
</script>

<!-- After 0.11: compose scales as declaration-only child layers. -->
<GGPlot data={rows} aes={{ x: "x", y: "y", color: "c" }}>
  <ScaleColorDiscrete scheme="colorblind" />
  <GeomPoint />
</GGPlot>

PlotDiagnostic also widens to include CompositionDiagnostic (DUPLICATE_SCALE_CHANNEL, DUPLICATE_PLOT_LAYER). Exhaustive switch on .code needs new arms; handlers annotated PlotDiagnostic keep compiling.

Compose coord as a child layer

The coord prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose the coordinate system as a declaration-only child — <CoordFlip/>, <CoordFixed/> / <CoordEqual/>, <CoordTransform/>, <CoordCartesian/>, or the generic <Coord value={…}> escape hatch. Two coord children emit a DUPLICATE_PLOT_LAYER advisory (last child still wins).

Compose facet as a child layer

The facet prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose facets as declaration-only children — <FacetWrap field="g"/>, <FacetGrid rows="a" cols="b"/>, or the complete <Facet wrap={…} /> surface. Keep strip nested (strip={{position,show}}). Two facet children emit a DUPLICATE_PLOT_LAYER advisory (last child still wins). Bare <Facet/> with no wrap/rows/cols fails validation (facet-form-missing).

Compose labs as a child layer

The labs prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose labels as a declaration-only child — <Labs title="Sales" subtitle="FY25" x="Quarter" color="Region"/>. There is no <Labs value={…}> escape hatch because Labs is a flat bag of strings: <Labs {...computed} /> already covers the computed case.

labs is a MERGE family: two <Labs/> children setting different keys both survive. Two children setting the SAME key emit a DUPLICATE_MERGE_KEY advisory and the later one wins.

Compose guides as child layers

The guides prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Guides are keyed by aesthetic, so the child form is one shell per guide TYPE taking a channel prop — the aesthetic is a key, never part of the component name: <GuideAxis channel="x" showTicks={false}/>, <GuideLegend channel="color" position="bottom"/>, <GuideColorbar channel="fill"/>, <GuideColorsteps channel="color"/>, <GuideNone channel="size"/>, plus <Guides value={…}> for raw or computed guide bags.

guides is a MERGE family keyed by channel, but the value AT a channel is replaced whole. Two guide children on one channel emit a DUPLICATE_MERGE_KEY advisory (last child still wins). A top-level guide child still wins over a scale-local guide on the same channel.

The shells carry no scale knowledge and do not guess: <GuideColorbar/> over a discrete color scale fails loudly rather than silently degrading to a legend.

Compose legend as a child layer

The legend prop on <GGPlot> was deprecated in 0.11.0 and removed in 0.13.0. Compose it as <Legend order="sorted"/>.

<Legend order> is the plot-wide entry-SORT enum ("stable-domain" | "present-first-seen" | "sorted"); ordering never changes color assignments. It is NOT <GuideLegend order={2}/>, which is a per-aesthetic INTEGER placement rank. Same word, unrelated concepts — the two compose independently on one plot.

Migrate the grammar props with the codemod

All seven grammar props above move mechanically, so @ggsvelte/svelte ships a codemod. It is opt-in and prints a diff by default — it writes nothing until you pass --write:

# see what would change
npx ggsvelte-codemod src

# apply it
npx ggsvelte-codemod --write src

It rewrites facet, coord, scales, guides, legend, theme and labs into their child layers and adds the components to the @ggsvelte/svelte import that already provided GGPlot. Migrated children are inserted BEFORE any child the file already had, because props apply before children — so a hand-written <ScaleColorDiscrete/> keeps winning over a migrated scales prop exactly as it did before.

The rewrite is meaning-preserving, never a style rewrite. It targets the generic escape hatches (<Coord value={…}/>, <Scale value={…}/>, <Guides value={…}/>) rather than the named shells this guide recommends by hand: for scales the named form is not byte-identical (normalize() does not infer a scale type), so choosing it is a judgment call the tool does not make for you. Flat prop bags become named props — labs={{ title: "Sales" }}<Labs title="Sales"/> — falling back to <Labs {...expr}/> for anything it cannot expand losslessly.

One shape is deliberately left alone: theme={expr} where expr is not a string literal. theme accepts ThemeName | ThemeSpec and <Theme> has no value hatch, so routing a dynamic value needs a human. Those sites are printed as manual change required with a link back to this guide — the tool reports them rather than half-migrating them.

Two more things worth knowing: the codemod only touches files that import GGPlot from @ggsvelte/svelte (a GGPlot of your own is never rewritten), and it edits only the ranges it changes, so run your formatter afterwards if you keep multi-line open tags.

Diagnostic handlers receive PlotDiagnostic

ondiagnostic now receives the PlotDiagnostic union (InteractionDiagnostic | DeprecationDiagnostic). Explicitly annotated handlers that named InteractionDiagnostic alone need a one-line type widening; inline arrow props continue to infer.

Before:

<script lang="ts">
  import { GeomPoint, GGPlot } from "@ggsvelte/svelte";
  import type {
    InteractionDiagnostic,
    PlotDiagnostic,
  } from "@ggsvelte/svelte";

  const rows = [
    { x: 1, y: 2 },
    { x: 2, y: 4 },
  ];

  function legacy(diagnostic: InteractionDiagnostic): void {
    console.warn(diagnostic.code, diagnostic.message);
  }

  // @ts-expect-error Pre-0.11 InteractionDiagnostic-only handlers are not assignable to PlotDiagnostic.
  const ondiagnostic: (diagnostic: PlotDiagnostic) => void = legacy;
</script>

<!-- Before 0.11: ondiagnostic was typed as InteractionDiagnostic only. -->
<GGPlot data={rows} aes={{ x: "x", y: "y" }} {ondiagnostic}>
  <GeomPoint />
</GGPlot>

After:

<script lang="ts">
  import { GeomPoint, GGPlot } from "@ggsvelte/svelte";
  import type { PlotDiagnostic } from "@ggsvelte/svelte";

  const rows = [
    { x: 1, y: 2 },
    { x: 2, y: 4 },
  ];

  function ondiagnostic(diagnostic: PlotDiagnostic): void {
    console.warn(diagnostic.code, diagnostic.message);
  }
</script>

<!-- After 0.11: ondiagnostic receives PlotDiagnostic (interaction ∪ deprecation). -->
<GGPlot data={rows} aes={{ x: "x", y: "y" }} {ondiagnostic}>
  <GeomPoint />
</GGPlot>

0.7 to 0.8

Map style semantics instead of precomputing outputs

Mapped size, linewidth, and alpha now train and render complete scales. shape and linetype now use closed finite symbol sets. Remove application-side radius, opacity, stroke-width, and dash lookup columns when they only existed to compensate for ignored style mappings. Map the semantic source field and select a scale family instead.

Before 0.8, applications commonly precomputed a point radius and passed it through identity:

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

  // Historical pre-0.13 GGPlot grammar prop (removed). Cast for typecheck.
  /* oxlint-disable-next-line typescript/no-explicit-any -- intentional pre-removal fixture */
  const Plot = GGPlot as any;

  // Before 0.8, applications precomputed symbol radii.
  const rows = [
    { x: 1, y: 2, radius: 2 },
    { x: 2, y: 3, radius: 5 },
    { x: 3, y: 4, radius: 9 },
  ];
</script>

<Plot
  data={rows}
  aes={{ x: "x", y: "y", size: "radius" }}
  scales={{ size: { type: "identity" } }}
>
  <GeomPoint />
</Plot>

In 0.8, keep the source measure and let the scale interpolate in symbol area:

<script lang="ts">
  import {
    GeomPoint,
    GGPlot,
    ScaleSizeContinuous,
  } from "@ggsvelte/svelte";

  // In 0.8, map the semantic measure and let size interpolate in symbol area.
  const rows = [
    { x: 1, y: 2, magnitude: 4 },
    { x: 2, y: 3, magnitude: 25 },
    { x: 3, y: 4, magnitude: 81 },
  ];
</script>

<GGPlot data={rows} aes={{ x: "x", y: "y", size: "magnitude" }}>
  <ScaleSizeContinuous range={[2, 9]} />
  <GeomPoint />
</GGPlot>

Review implicit grouping on line, area, smooth, errorbar, and boxplot layers. Discrete and binned style mappings now split groups, as color/fill mappings do; continuous numeric styles do not. If a discrete style is descriptive rather than structural, author an explicit group mapping. Shape/linetype do not silently interpolate quantitative values: use type: "binned" or move the measure to a numeric style channel.

A mapped alpha is now the complete authored opacity aesthetic; it is not multiplied by a competing scalar geom alpha parameter. Remove that scalar parameter and set the mapped scale's range when you need a lower opacity ceiling.

Move guide layout into the guide API

Automatic non-position guides now move below the chart when the viewport is at most 480px or a right guide would leave less than 320px of readable panel. Bottom colorbars/colorsteps are horizontal and discrete keys wrap without shrinking text. If an application positioned or hid the old fixed right legend with surrounding CSS, remove that workaround and author portable guide intent:

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

  // Before 0.8, automatic legends always occupied the fixed right column.
  const rows = [
    { x: 1, y: 2, region: "North" },
    { x: 2, y: 3, region: "South" },
  ];
</script>

<GGPlot data={rows} aes={{ x: "x", y: "y", color: "region" }}>
  <GeomPoint />
</GGPlot>

In 0.8, declare the alternate presentation directly:

<script lang="ts">
  import { GGPlot, GeomPoint, GuideLegend } from "@ggsvelte/svelte";

  // Since 0.8, guide presentation is portable and responsive without changing scale math.
  const rows = [
    { x: 1, y: 2, region: "North" },
    { x: 2, y: 3, region: "South" },
  ];
</script>

<GGPlot data={rows} aes={{ x: "x", y: "y", color: "region" }}>
  <GuideLegend channel="color" position="bottom" direction="horizontal" />
  <GeomPoint />
</GGPlot>

Top-level guides override scale-local guide settings. Use guideNone() for suppression and force: true only when an identity or single-value manual guide is intentional. Guide appearance does not alter scale domains or assignments. Exact discrete entries remain focus/filter targets; numeric guide ticks and bins remain representative and non-interactive.

0.8 to 0.9

Constrain the data rectangle instead of the outer box

Before 0.9, CSS aspect-ratio on a chart wrapper constrained the complete SVG. Axes, titles, and guides still changed the inner panel ratio, so equal data units could render at unequal physical lengths:

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

  // Before 0.9, an outer CSS aspect ratio could not preserve data-unit lengths
  // after axes, titles, and guides consumed chart space.
  const circle = [
    { x: 1, y: 0 },
    { x: 0, y: 1 },
    { x: -1, y: 0 },
    { x: 0, y: -1 },
    { x: 1, y: 0 },
  ];
</script>

<div style="aspect-ratio: 1">
  <GGPlot data={circle} aes={{ x: "x", y: "y" }}>
    <GeomLine />
  </GGPlot>
</div>

Since 0.9, remove that wrapper workaround and author the coordinate directly:

<script lang="ts">
  import { CoordFixed, GGPlot, GeomLine } from "@ggsvelte/svelte";

  // Since 0.9, constrain the measured data rectangle instead of the outer box.
  const circle = [
    { x: 1, y: 0 },
    { x: 0, y: 1 },
    { x: -1, y: 0 },
    { x: 0, y: -1 },
    { x: 1, y: 0 },
  ];
</script>

<GGPlot data={circle} aes={{ x: "x", y: "y" }}>
  <CoordFixed />
  <GeomLine />
</GGPlot>

coordFixed({ ratio: 1 }) measures the trained data rectangle after chart chrome is allocated and letterboxes it without distortion. Fixed aspect now rejects facet.scales values "free", "free_x", and "free_y"; switch those facets to "fixed" or remove the fixed coordinate rather than presenting a false shared physical scale.

0.6 to 0.7

Choose explicit color/fill families

Color/fill now exposes complete continuous, discrete, binned, transformed, temporal, manual, and identity helpers. Existing ordinal and sequential JSON remains canonical. Review charts that relied on implicit continuous clamping: with an explicit domain, the default oob: "censor" now uses unknownValue; opt into oob: "squish" to clamp deliberately.

Use type: "binned" plus semantic breaks for colorsteps. Manual scales require one range color per explicit domain value and never recycle extras. Identity scales accept validated hex source values and suppress their guide by default. Replace ad-hoc preprocessing with scaleColorDate/ scaleColorDatetime and an explicit parser when date order is ambiguous.

Before 0.7, an explicit continuous color domain clamped implicitly:

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

  // Historical pre-0.13 GGPlot grammar prop (removed). Cast for typecheck.
  /* oxlint-disable-next-line typescript/no-explicit-any -- intentional pre-removal fixture */
  const Plot = GGPlot as any;

  const rows = [
    { x: 1, y: 2, score: -10 },
    { x: 2, y: 3, score: 50 },
    { x: 3, y: 4, score: 110 },
  ];
</script>

<Plot
  data={rows}
  aes={{ x: "x", y: "y", color: "score" }}
  scales={{ color: { type: "sequential", domain: [0, 100] } }}
>
  <GeomPoint />
</Plot>

In 0.7, opt into clamping when it is the intended encoding:

<script lang="ts">
  import {
    GeomPoint,
    GGPlot,
    ScaleColorContinuous,
  } from "@ggsvelte/svelte";

  const rows = [
    { x: 1, y: 2, score: -10 },
    { x: 2, y: 3, score: 50 },
    { x: 3, y: 4, score: 110 },
  ];
</script>

<GGPlot data={rows} aes={{ x: "x", y: "y", color: "score" }}>
  <ScaleColorContinuous domain={[0, 100]} oob="squish" />
  <GeomPoint />
</GGPlot>

RenderModel.guidePlans now includes serializable discrete, colorbar, and colorsteps plans beside axes. Code that assumed every plan was an axis must narrow on plan.type === "axis" before reading axis-only fields.

0.5 to 0.6

Move position transforms before statistics

Position transforms now follow ggplot2 staging: parsing, source-limit OOB, and the scale transform happen before statistics and positions. The old late projection produced incorrect smooths, bins, densities, summaries, and boxplots. This is the pre-1.0 semantic-correctness exception in decision 0015; there is no legacy staging switch.

Authored type: "log" still validates, but canonical specs now store type: "linear", transform: "log10". Prefer the explicit transform or scaleXLog10/scaleYLog10 helpers. A codemod would only rewrite spelling and cannot decide whether changed statistics are intended, so migration remains a manual chart review.

Before 0.6, this fit used the old late log projection:

<script lang="ts">
  import { GeomPoint, GeomSmooth, GGPlot } from "@ggsvelte/svelte";

  // Historical pre-0.13 GGPlot grammar prop (removed). Cast for typecheck.
  /* oxlint-disable-next-line typescript/no-explicit-any -- intentional pre-removal fixture */
  const Plot = GGPlot as any;

  const rows = [
    { latency: 1, throughput: 8 },
    { latency: 10, throughput: 18 },
    { latency: 100, throughput: 31 },
    { latency: 1000, throughput: 47 },
  ];
</script>

<Plot
  data={rows}
  aes={{ x: "latency", y: "throughput" }}
  scales={{ x: { type: "log", domain: [1, 1000] } }}
>
  <GeomPoint />
  <GeomSmooth method="lm" />
</Plot>

In 0.6, make the pre-stat transform and limit policy explicit, then compare the fit with the intended analysis. The zero expansion below restores flush bounds; the new default for non-temporal continuous and binned scales is 5% multiplicative display expansion, including pinned domains.

<script lang="ts">
  import {
    GeomPoint,
    GeomSmooth,
    GGPlot,
    ScaleXLog10,
  } from "@ggsvelte/svelte";

  const rows = [
    { latency: 1, throughput: 8 },
    { latency: 10, throughput: 18 },
    { latency: 100, throughput: 31 },
    { latency: 1000, throughput: 47 },
  ];
</script>

<GGPlot data={rows} aes={{ x: "latency", y: "throughput" }}>
  <ScaleXLog10
    domain={[1, 1000]}
    oob="censor"
    expand={{ mult: 0, add: 0 }}
    nice={false}
  />
  <GeomPoint />
  <GeomSmooth method="lm" />
</GGPlot>

Review limits, zoom, and transformed units

A pinned domain is now an unexpanded source limit. The default oob: "censor" removes out-of-limit values before stats; oob: "squish" clamps them before transform/stats. Brush zoom writes a semantic domain with nice: false and zero expansion, so it intentionally re-runs stats on the zoomed subset rather than acting like a post-stat coordinate crop. Use a wider scale domain or squish only when that is the intended analysis; a future coordinate-transform API owns visual-only zoom.

Position offsets and stack totals are transformed-space units. Under log10 or sqrt, numeric stat_bin binwidth, boundary, and center are also transformed-space units: for example log10 boundary: 0 means semantic 1, not log10(0).

Update scale and interaction inspection

Continuous log10/sqrt scales no longer report trained type: "log". RenderModel.scales, AxisGuidePlan, interval selections, and precise bounds use family-plus-transform contracts:

scale type / guide scaleType / interval kind: "linear"
transform: "identity" | "log10" | "sqrt"

Reject or migrate transient snapshots containing kind: "log"; pre-1.0 interaction snapshots do not have a compatibility branch. Keep semantic source-space domains and apply the named transform exactly once.

0.2 to 0.3

Replace custom hit indexes with CandidateStore

The experimental buildHitIndex export and its SceneHitIndex types have been removed. Every render model already owns a lazy CandidateStore with the same exact geometry hit behavior, so custom browser hosts no longer build and retain a second spatial index.

Before 0.3:

import { buildHitIndex } from "@ggsvelte/core/dom";

const hitIndex = buildHitIndex(model.scene);
const hit = hitIndex.hitTest(plotX, plotY);

In 0.3, use the model-owned candidate identity directly. hitTest() follows paint order, honors panel clipping, and returns CandidateFacts. Rectangle queries remain available as model.candidates.queryRect(...) candidate ids.

<script lang="ts">
  import { GeomPoint, GGPlot, type RenderModel } from "@ggsvelte/svelte";

  const rows = [
    { id: "a", x: 1, y: 3 },
    { id: "b", x: 2, y: 4 },
  ];
  let model = $state<RenderModel | null>(null);
  let hitRow = $state<number | null>(null);

  function inspectPlotPixel(x: number, y: number): void {
    hitRow = model?.candidates.hitTest(x, y)?.rowIndex ?? null;
  }
</script>

<GGPlot
  data={rows}
  aes={{ x: "x", y: "y" }}
  key="id"
  inspect
  onrender={(next) => (model = next)}
>
  <GeomPoint />
</GGPlot>

<button type="button" onclick={() => inspectPlotPixel(100, 100)}>
  Resolve plot pixel
</button>
<p>{hitRow === null ? "No hit" : `Row ${hitRow}`}</p>

For a separately constructed scene, call buildCandidateStore(scene, { hitTolerance }) from @ggsvelte/core. The old tolerance default remains 3 plot pixels.

0.1 to 0.2

No source changes are required: every 0.1 prop, callback, and export keeps working in 0.2. One environment requirement changed: the svelte peer dependency floor rose from ^5.29.0 to ^5.33.1, so upgrade Svelte first. The additions below are optional to adopt.

Optional: shared interaction state with a controller

0.2 adds createPlotInteraction for linked views: selection, emphasis, and zoom state shared across plots, controls, and tables. Chart-local props and callbacks remain fully supported — reach for a controller only when more than one surface consumes the same interaction state.

Chart-local (unchanged from 0.1):

<script lang="ts">
  import {
    GeomPoint,
    GGPlot,
    type PlotSelection,
  } from "@ggsvelte/svelte";

  const rows = [
    { id: "a", flipper: 181, mass: 3750, species: "Adelie" },
    { id: "b", flipper: 195, mass: 3800, species: "Chinstrap" },
    { id: "c", flipper: 217, mass: 4500, species: "Gentoo" },
  ];
  let selection = $state<PlotSelection<string> | null>(null);
</script>

<GGPlot
  data={rows}
  aes={{ x: "flipper", y: "mass", color: "species" }}
  key="id"
  select={{ type: "point", multiple: true }}
  onselect={(event) => (selection = event)}
>
  <GeomPoint />
</GGPlot>

<p>{selection === null ? 0 : selection.keys.length} selected</p>

Shared controller (new in 0.2, optional):

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

  const rows = [
    { id: "a", flipper: 181, mass: 3750, species: "Adelie" },
    { id: "b", flipper: 195, mass: 3800, species: "Chinstrap" },
    { id: "c", flipper: 217, mass: 4500, species: "Gentoo" },
  ];
  const scope = { keys: "row-id", x: "flipper-mm", y: "mass-g" } as const;
  const interaction = createPlotInteraction<string>();
  const selected = $derived(interaction.selected(scope));
</script>

<GGPlot
  data={rows}
  aes={{ x: "flipper", y: "mass", color: "species" }}
  key="id"
  select={{ type: "point", multiple: true }}
  {interaction}
  interactionScope={scope}
>
  <GeomPoint />
</GGPlot>

<p>{selected.length} selected</p>

See the linked views example and Interactions for the full controller contract.

Deprecated type aliases

Unchanged in 0.2: these pre-0.1 names have been deprecated since 0.1.0 and still compile. Replace them when convenient:

  • BrushSelectionIntervalSelection
  • TooltipContextPlotInspectionChange
  • ZoomDomainsReadonlyZoomDomains

See Interactions for current options, event shapes, and identity requirements.