Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
docs: add canvas examples for arrow + vector mark, update api docs
  • Loading branch information
gka committed Mar 29, 2026
commit 43dec090fb69858176dedb3a10d04c63d0e5a767
40 changes: 24 additions & 16 deletions CANVAS_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ Helper component for rendering [MarkName] marks in canvas
**Style resolution:** Use `resolveScaledStyleProps()` to get computed styles, then `resolveColor()` to handle `currentColor`, CSS variables, and SVG gradients. Canvas doesn't inherit CSS, so every color must be explicitly resolved.

**Default color prop:** The last argument to `resolveScaledStyleProps` determines the default color channel:

- `'stroke'` for line-like marks (Arrow, Link, Rule, Line, Tick)
- `'fill'` for area-like marks (Dot, Rect, Area, Geo)
- Some marks use both (Vector) — resolve fill and stroke separately
Expand All @@ -150,7 +151,10 @@ Helper component for rendering [MarkName] marks in canvas
```svelte
{#snippet children({ scaledData, usedScales })}
{#if canvas}
<MyMarkCanvas data={scaledData} options={args} {usedScales} />
<MyMarkCanvas
data={scaledData}
options={args}
{usedScales} />
{:else}
<!-- existing SVG rendering -->
{/if}
Expand All @@ -176,7 +180,10 @@ For marks using d3-shape generators (line, area), set the canvas context:
```js
const fn = line().curve(curveFactory).context(context);
context.beginPath();
fn([[x1, y1], [x2, y2]]);
fn([
[x1, y1],
[x2, y2]
]);
context.stroke();
fn.context(null); // reset after loop
```
Expand All @@ -200,6 +207,7 @@ If the SVG mark and canvas helper need the same geometry logic, extract it to a
## What canvas can't do

These SVG-only features should be skipped in canvas mode:

- Per-element event handlers (`onclick`, `onmouseenter`)
- SVG `<marker>` elements (arrowheads on Link marks)
- `<textPath>` (text along a curve)
Expand All @@ -211,17 +219,17 @@ Interactive features like tooltips still work via the `<Pointer>` component, whi

## Existing canvas helpers

| Helper | Mark(s) | Technique | Default color |
|--------|---------|-----------|---------------|
| DotCanvas | Dot | d3-symbol via `context` | fill |
| LineCanvas | Line, LineX, LineY | d3-line with `.context()` | stroke |
| AreaCanvas | Area, AreaX, AreaY | d3-area with `.context()` | fill |
| RectCanvas | Rect, BarX, BarY, Cell, RectX, RectY | `fillRect` / `strokeRect` | fill |
| RuleCanvas | RuleX, RuleY | `moveTo` / `lineTo` | stroke |
| TickCanvas | TickX, TickY | `moveTo` / `lineTo` | stroke |
| TextCanvas | Text | `fillText` / `strokeText` | fill |
| TrailCanvas | Trail | width-varying path | fill |
| GeoCanvas | Geo | d3-geoPath with `.context()` | fill |
| ArrowCanvas | Arrow | `Path2D(arrowPath())` | stroke |
| LinkCanvas | Link | d3-line with `.context()` | stroke |
| VectorCanvas | Vector | `Path2D(shapePath())` + transforms | varies |
| Helper | Mark(s) | Technique | Default color |
| ------------ | ------------------------------------ | ---------------------------------- | ------------- |
| DotCanvas | Dot | d3-symbol via `context` | fill |
| LineCanvas | Line, LineX, LineY | d3-line with `.context()` | stroke |
| AreaCanvas | Area, AreaX, AreaY | d3-area with `.context()` | fill |
| RectCanvas | Rect, BarX, BarY, Cell, RectX, RectY | `fillRect` / `strokeRect` | fill |
| RuleCanvas | RuleX, RuleY | `moveTo` / `lineTo` | stroke |
| TickCanvas | TickX, TickY | `moveTo` / `lineTo` | stroke |
| TextCanvas | Text | `fillText` / `strokeText` | fill |
| TrailCanvas | Trail | width-varying path | fill |
| GeoCanvas | Geo | d3-geoPath with `.context()` | fill |
| ArrowCanvas | Arrow | `Path2D(arrowPath())` | stroke |
| LinkCanvas | Link | d3-line with `.context()` | stroke |
| VectorCanvas | Vector | `Path2D(shapePath())` + transforms | varies |
9 changes: 7 additions & 2 deletions src/routes/api/marks/+page.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Creates arrows with customizable heads, angles, and bending
| `insetStart?` | ConstantAccessor&lt;number, Datum&gt; | inset at the start of the arrow |
| `inset?` | ConstantAccessor&lt;number, Datum&gt; | shorthand for the two insets |
| `sweep?` | SweepOption | controls the sweep direction of the arrow arc; 1 or -1 |
| `canvas?` | boolean | if true, renders using Canvas instead of SVG |

Inherited props from [BaseMarkProps](/api/marks#BaseMarkProps).

Expand Down Expand Up @@ -303,8 +304,8 @@ Renders contour lines (or filled contour bands) from a scalar field using the ma
| Prop | Type | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `data?` | Datum[] \| null | Input data. For **dense grid** mode supply a flat row-major array and<br>set `width`/`height`. Omit (or set null) for **function-sampling**<br>mode. For **scatter interpolation** supply an array of records with<br>`x`/`y` channels. |
| `x?` | ChannelAccessor&lt;Datum&gt; | x position channel (scatter / dense-grid mode) |
| `y?` | ChannelAccessor&lt;Datum&gt; | y position channel (scatter / dense-grid mode) |
| `x?` | ChannelAccessor&lt;Datum&gt; | x position channel (scatter mode) |
| `y?` | ChannelAccessor&lt;Datum&gt; | y position channel (scatter mode) |
| `value?` | ChannelAccessor&lt;Datum&gt; \| ((x: number, y: number) =&gt; number) | Scalar field accessor, identity function for dense grid, or an<br>`(x, y) =&gt; number` function for function-sampling mode. |
| `thresholds?` | number \| number[] \| ((values: number[], min: number, max: number) =&gt; number[]) \| {'{'} floor(x: number): number; range(a: number, b: number): number[]; {'}'} | Contour threshold levels. Can be:<br>- a **count** (number): approximately that many nicely-spaced levels<br>- an explicit **array** of threshold values<br>- a **function** `(values, min, max) =&gt; number[]`<br>- a d3 **threshold scheme** object with `.floor()` / `.range()`<br><br>Defaults to Sturges' formula applied to the value range. |
| `interval?` | number \| {'{'} floor(x: number): number; range(a: number, b: number): number[]; {'}'} | Step interval between contour levels (alternative to `thresholds`).<br>Can be a number (constant step) or an interval object with `.floor()`<br>/ `.range()`. |
Expand All @@ -327,6 +328,8 @@ Renders contour lines (or filled contour bands) from a scalar field using the ma
| `strokeMiterlimit?` | number | |
| `clipPath?` | string | |
| `class?` | string | |
| `fx?` | ChannelAccessor&lt;Datum&gt; | the horizontal facet channel |
| `fy?` | ChannelAccessor&lt;Datum&gt; | the vertical facet channel |

Inherited props: see the [shared section](/api/marks#Inherited-props) below.

Expand Down Expand Up @@ -493,6 +496,7 @@ For showing images positioned at x/y coordinates
| `src?` | ConstantAccessor&lt;string, Datum&gt; | the image source URL |
| `title?` | ConstantAccessor&lt;string, Datum&gt; | the title attribute for the image element (shown as a browser tooltip) |
| `preserveAspectRatio?` | string | the SVG preserveAspectRatio attribute for the image (e.g. "xMidYMid meet") |
| `canvas?` | boolean | if true, renders using Canvas instead of SVG |
| `imageClass?` | ConstantAccessor&lt;string, Datum&gt; | CSS class name(s) to apply to individual image elements |

Inherited props from [BaseMarkProps](/api/marks#BaseMarkProps), [LinkableMarkProps](/api/marks#LinkableMarkProps).
Expand Down Expand Up @@ -563,6 +567,7 @@ Creates connections between pairs of points with optional curve styling and mark
| `textStroke?` | ConstantAccessor&lt;string, Datum&gt; | the stroke color for the text label rendered along the link |
| `textStartOffset?` | ConstantAccessor&lt;string, Datum&gt; | the offset position for the text label along the link path |
| `textStrokeWidth?` | ConstantAccessor&lt;number, Datum&gt; | the stroke width for the text label rendered along the link |
| `canvas?` | boolean | if true, renders using Canvas instead of SVG |

Inherited props from [BaseMarkProps](/api/marks#BaseMarkProps), [MarkerOptions](/api/marks#MarkerOptions).

Expand Down
71 changes: 71 additions & 0 deletions src/routes/examples/arrow/metro-canvas.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script module>
export const title = 'Canvas arrows';
export const description = `Rising inequality (and population) in various U.S. cities from 1980 to 2015. Each arrow represents two observations of a city: the city’s population (x) and inequality (y) in 1980, and the same in 2015. Based on an example from <a href="https://observablehq.com/@observablehq/plot-arrow-variation-chart">Observable Plot</a>.`;
export const data = { metros: '/data/metros.csv' };
export const sortKey = 20;
</script>

<script lang="ts">
import {
Plot,
Arrow,
Text,
setPlotDefaults
} from 'svelteplot';
import type { MetrosRow } from '../types';
let { metros }: { metros: MetrosRow[] } = $props();

setPlotDefaults({
arrow: {
headAngle: 45
}
});

let hl = $state<MetrosRow | null>(null);
</script>

<Plot
grid
marginRight={20}
inset={10}
height={450}
x={{ type: 'log', label: 'Population' }}
y={{ label: 'Inequality' }}
color={{
label: 'Change in inequality from 1980 to 2015',
legend: true,
tickFormat: {
minimumFractionDigits: 0,
maximumFractionDigits: 1
}
}}>
<Arrow
data={metros}
x1="POP_1980"
y1="R90_10_1980"
x2="POP_2015"
y2="R90_10_2015"
bend
canvas
style="transition: opacity 0.2s ease-in"
opacity={{
scale: null,
value: (d) =>
!hl || hl.Metro === d.Metro ? 1 : 0.1
}}
stroke={(d) => d.R90_10_2015 - d.R90_10_1980} />
<Text
data={metros}
x="POP_2015"
y="R90_10_2015"
filter={(d) =>
hl
? hl.Metro === d.Metro
: Boolean(d.highlight)}
text="nyt_display"
fill="currentColor"
stroke="var(--svelteplot-bg)"
strokeWidth={4}
lineAnchor="bottom"
dy={-6} />
</Plot>
95 changes: 95 additions & 0 deletions src/routes/examples/vector/shift-map-canvas.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<script module lang="ts">
export const title = 'Shift map (canvas)';
export const description =
'Uses projected county centroids and vector arrows to show the direction and magnitude of 2020 election shifts.';
export const data = {
us: '/data/us-counties-10m.json',
election: '/data/election.csv'
};
export const sortKey = 11;
</script>

<script lang="ts">
import {
Plot,
Geo,
Vector,
geoCentroid
} from 'svelteplot';
import * as topojson from 'topojson-client';

type ElectionDatum = {
fips: number;
margin2020?: number;
votes?: number;
};

type CountyFeature = {
id?: string | number;
properties?: Record<string, unknown>;
};

const { us, election } = $props();

const nation = $derived(
topojson.feature(us, us.objects.nation)
);
const stateMesh = $derived(
topojson.mesh(us, us.objects.states)
);

const electionByFips = $derived(
new Map(
election.map((d: ElectionDatum) => [d.fips, d])
)
);

const counties = $derived(
(
topojson.feature(us, us.objects.counties) as any
).features.map((feat: CountyFeature) => {
return {
...feat,
properties: {
...feat.properties,
// oxlint-disable-next-line unicorn/no-useless-fallback-in-spread -- TS requires ?? {} for Map.get()
...(electionByFips.get(
Number(feat.id)
) as object)
}
};
})
);

const centroids = $derived(
geoCentroid({ data: counties }) as any
);
</script>

<Plot
projection="albers-usa"
length={{ type: 'sqrt', range: [3, 40] }}>
<Geo
data={[nation]}
fill="var(--svelteplot-bg)"
stroke="currentColor" />
<Geo
data={[stateMesh]}
stroke="currentColor"
strokeWidth={0.5} />
<Vector
{...centroids}
Comment thread
gka marked this conversation as resolved.
length={(d: any) =>
Math.abs(
(d.properties?.margin2020 ?? 0) *
(d.properties?.votes ?? 0)
)}
shape="arrow-filled"
strokeLinecap="round"
fill={(d: any) =>
d.properties?.margin2020 > 0
? 'var(--svp-red)'
: 'var(--svp-blue)'}
rotate={(d: any) =>
d.properties?.margin2020 > 0 ? 60 : -60} />
</Plot>
Loading