Scatter (svg + canvas + voronoi)
A scatter plot with three layers: SVG axes, canvas circles and a second SVG layer with smaller circles on top. A Voronoi layer over everything reports the nearest point on hover. Open the console and move the mouse to see it.
- +page.svelte
- ./_components/Scatter.svg.svelte
- ./_components/Scatter.canvas.svelte
- ./_components/Voronoi.svelte
- ./_components/AxisX.svelte
- ./_components/AxisY.svelte
- ./_data/points.csv
<script>
import { LayerCake, Svg, Canvas } from 'layercake';
import ScatterSvg from './_components/Scatter.svg.svelte';
import ScatterCanvas from './_components/Scatter.canvas.svelte';
import Voronoi from './_components/Voronoi.svelte';
import AxisX from './_components/AxisX.svelte';
import AxisY from './_components/AxisY.svelte';
// The CSV rows are parsed, and their numbers typed, by @rollup/plugin-dsv. See vite.config.js
import data from './_data/points.csv';
const xKey = 'myX';
const yKey = 'myY';
const r = 3;
const scalePadding = 10;
// The Voronoi layer reports the point under the mouse. Its row is on `point.data`.
/** @param {MouseEvent} e @param {any} point */
function logPoint(e, point) {
console.log(point.data);
}
</script>
<div class="chart-container">
<LayerCake
padding={{ top: 10, right: 5, bottom: 20, left: 25 }}
x={xKey}
y={yKey}
xPadding={[scalePadding, scalePadding]}
yPadding={[scalePadding, scalePadding]}
{data}
>
<Svg>
<AxisX gridlines={false} />
<AxisY gridlines={false} ticks={4} />
</Svg>
<Canvas>
<ScatterCanvas r={r * 1.5} fill="#00ccff" />
</Canvas>
<Svg>
<ScatterSvg {r} fill="#fff" />
<Voronoi stroke="#333" onmouseover={logPoint} />
</Svg>
</LayerCake>
</div>
<style>
/* Give the wrapper a width and height. LayerCake fills it. */
.chart-container {
width: 100%;
height: 250px;
}
</style><!--
@component
Generates an SVG scatter plot. If the x or y scale is a band scale, each circle sits in the middle of its band. See the [timeplot chart](https://layercake.graphics/example/Timeplot) for an example.
-->
<script>
import { getLayerCakeContext } from 'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {number} [r=5] - The circle's radius.
* @property {string} [fill='#0cf'] - The circle's fill color.
* @property {string} [stroke='#000'] - The circle's stroke color.
* @property {number} [strokeWidth=0] - The circle's stroke width in pixels.
*/
/** @type {Props} */
let { r = 5, fill = '#0cf', stroke = '#000', strokeWidth = 0 } = $props();
</script>
<g class="scatter-group">
{#each k.data as d}
<circle
cx={k.xGet(d) + (k.xScale.bandwidth ? k.xScale.bandwidth() / 2 : 0)}
cy={k.yGet(d) + (k.yScale.bandwidth ? k.yScale.bandwidth() / 2 : 0)}
{r}
{fill}
{stroke}
stroke-width={strokeWidth}
/>
{/each}
</g><!--
@component
Generates a canvas scatter plot. If the x or y scale is a band scale, each circle sits in the middle of its band.
-->
<script>
import { getLayerCakeContext, getCanvasContext } from 'layercake';
const k = getLayerCakeContext();
const canvas = getCanvasContext();
/**
* @typedef {Object} Props
* @property {number} [r=5] - The circle's radius.
* @property {string} [fill='#0cf'] - The circle's fill color.
* @property {string} [stroke='#000'] - The circle's stroke color.
* @property {number} [strokeWidth=0] - The circle's stroke width in pixels.
*/
/** @type {Props} */
let { r = 5, fill = '#0cf', stroke = '#000', strokeWidth = 0 } = $props();
// Layer Cake runs this on every repaint: resize, new data or a prop change
canvas.draw(ctx => {
k.data.forEach(d => {
const cx = k.xGet(d) + (k.xScale.bandwidth ? k.xScale.bandwidth() / 2 : 0);
const cy = k.yGet(d) + (k.yScale.bandwidth ? k.yScale.bandwidth() / 2 : 0);
ctx.beginPath();
ctx.arc(cx, cy, r, 0, 2 * Math.PI, false);
// Fill first, then stroke on top, the same as an SVG circle
ctx.fillStyle = fill;
ctx.fill();
// A lineWidth of 0 is ignored by canvas, so skip the stroke instead
if (strokeWidth > 0) {
ctx.lineWidth = strokeWidth;
ctx.strokeStyle = stroke;
ctx.stroke();
}
});
});
</script><!--
@component
Generates a Voronoi layer using [d3-delaunay](https://github.com/d3/d3-delaunay).
-->
<script>
import { getLayerCakeContext } from 'layercake';
import { Delaunay } from 'd3-delaunay';
const k = getLayerCakeContext();
/** @typedef {[number, number] & { data?: any }} Point */
/**
* @typedef {Object} Props
* @property {string|undefined} [stroke] - A stroke color for the cells, handy for seeing where they are.
* @property {(event: MouseEvent, point: Array<number>) => void} [onmouseover] - Called when the mouse enters a cell with the event and the cell's `[x, y]` point. The point's row is on `point.data`.
*/
/** @type {Props} */
let { stroke, onmouseover = () => {} } = $props();
/**
* @param {MouseEvent} e
* @param {Point} point
*/
function handleMouseover(e, point) {
onmouseover(e, point);
}
/** @type {Point[]} */
let points = $derived(
k.data.map(d => {
/** @type {Point} */
const point = [k.xGet(d), k.yGet(d)];
point.data = d;
return point;
})
);
// Two rows at the same spot would make a zero-area cell, so keep the first point per spot
let uniquePoints = $derived.by(() => {
const seen = new Set();
return points.filter(point => {
const key = point.join();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
});
// The cells need a chart with room to draw in. While a page is being taken
// down the container measures zero for a moment, and d3-delaunay rejects a
// zero-size bounding box, so there are no cells until the chart has a size.
let voronoi = $derived(
k.width > 0 && k.height > 0
? Delaunay.from(uniquePoints).voronoi([0, 0, k.width, k.height])
: null
);
</script>
<!--
The cells are invisible hit areas, not content, so they are hidden from screen
readers. Focusable cells would give keyboard users one stop per point with nothing to read.
-->
{#if voronoi}
{#each uniquePoints as point, i}
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
<path
style="stroke: {stroke}"
class="voronoi-cell"
d={voronoi.renderCell(i)}
onmouseover={e => handleMouseover(e, point)}
aria-hidden="true"
></path>
{/each}
{/if}
<style>
.voronoi-cell {
fill: none;
stroke: none;
pointer-events: all;
outline: none;
}
</style><!--
@component
Generates an SVG x-axis along the bottom of the chart. If the x scale is a band scale, each tick sits in the middle of its band.
-->
<script>
import { getLayerCakeContext } from 'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {boolean} [tickMarks=false] - Show a vertical mark at each tick.
* @property {boolean} [gridlines=true] - Show gridlines extending into the chart area.
* @property {number} [tickMarkLength=6] - The length of the tick mark.
* @property {boolean} [showBaseline=false] - Show a solid line along the bottom of the chart.
* @property {boolean} [snapLabels=false] - Instead of centering the text labels on the first and the last items, align them to the edges of the chart.
* @property {(d: any) => string} [format=d => d] - Formats a tick value for display.
* @property {number|Array<any>|((ticks: Array<any>) => Array<any>)} [ticks] - If this is a number, it passes that along to the [d3Scale.ticks](https://github.com/d3/d3-scale) function. If this is an array, hardcodes the ticks to those values. If it's a function, passes along the default tick values and expects an array of tick values in return. If nothing, it uses the default ticks supplied by the D3 function.
* @property {number} [tickGutter=0] - The gap in pixels between the bottom of the chart area and the start of the tick.
* @property {number} [dx=0] - Horizontal offset of the label in pixels.
* @property {number} [dy=12] - Vertical offset of the label in pixels.
*/
/** @type {Props} */
let {
tickMarks = false,
gridlines = true,
tickMarkLength = 6,
showBaseline = false,
snapLabels = false,
format = d => d,
ticks = undefined,
tickGutter = 0,
dx = 0,
dy = 12
} = $props();
// Snapped labels anchor the first tick to the left edge and the last to the right
/** @param {number} i */
function textAnchor(i) {
if (snapLabels === true) {
if (i === 0) {
return 'start';
}
if (i === tickVals.length - 1) {
return 'end';
}
}
return 'middle';
}
let tickLen = $derived(tickMarks === true ? (tickMarkLength ?? 6) : 0);
let isBandwidth = $derived(typeof k.xScale.bandwidth === 'function');
/** @type {Array<any>} */
let tickVals = $derived(
Array.isArray(ticks)
? ticks
: isBandwidth
? k.xScale.domain()
: typeof ticks === 'function'
? ticks(k.xScale.ticks())
: k.xScale.ticks(ticks)
);
let halfBand = $derived(isBandwidth ? k.xScale.bandwidth() / 2 : 0);
</script>
<g class="axis x-axis" class:snapLabels>
{#if showBaseline === true}
<line class="baseline" y1={k.height} y2={k.height} x1="0" x2={k.width} />
{/if}
{#each tickVals as tick, i (tick)}
<!-- Fall back to the chart height if the chart has no y dimension -->
<g
class="tick tick-{i}"
transform="translate({k.xScale(tick)},{k.yRange ? Math.max(...k.yRange) : k.height})"
>
{#if gridlines === true}
<line class="gridline" x1={halfBand} x2={halfBand} y1={-k.height} y2="0" />
{/if}
{#if tickMarks === true}
<line
class="tick-mark"
x1={halfBand}
x2={halfBand}
y1={tickGutter}
y2={tickGutter + tickLen}
/>
{/if}
<text x={halfBand} y={tickGutter + tickLen} {dx} {dy} text-anchor={textAnchor(i)}
>{format(tick)}</text
>
</g>
{/each}
</g>
<style>
.tick {
font-size: 11px;
}
line,
.tick line {
stroke: #aaa;
stroke-dasharray: 2;
}
.tick text {
fill: #666;
}
.tick .tick-mark,
.baseline {
stroke-dasharray: 0;
}
/* Push the snapped end labels 3px outward so they clear the chart edge */
.axis.snapLabels .tick:last-child text {
transform: translateX(3px);
}
.axis.snapLabels .tick.tick-0 text {
transform: translateX(-3px);
}
</style><!--
@component
Generates an SVG y-axis along the left edge of the chart. If the y scale is a band scale, each tick sits in the middle of its band.
-->
<script>
import { getLayerCakeContext } from 'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {boolean} [tickMarks=false] - Show a horizontal mark at each tick.
* @property {'even'|'above'} [labelPosition='even'] - Whether the label sits level with its tick ('even') or above it ('above').
* @property {boolean} [snapBaselineLabel=false] - When labelPosition='even', adjust the lowest label so that it sits above the tick mark.
* @property {boolean} [gridlines=true] - Show gridlines extending into the chart area.
* @property {number} [tickMarkLength] - Length of the tick mark in pixels. Defaults to the width of the widest label when `labelPosition` is 'above', otherwise 6.
* @property {(d: any) => string} [format=d => d] - Formats a tick value for display.
* @property {number|Array<any>|((ticks: Array<any>) => Array<any>)} [ticks=4] - If this is a number, it passes that along to the [d3Scale.ticks](https://github.com/d3/d3-scale) function. If this is an array, hardcodes the ticks to those values. If it's a function, passes along the default tick values and expects an array of tick values in return.
* @property {number} [tickGutter=0] - The gap in pixels between the left edge of the chart area and the tick.
* @property {number} [dx=0] - Horizontal offset of the label in pixels.
* @property {number} [dy=0] - Vertical offset of the label in pixels.
* @property {number} [charPixelWidth=7.25] - Used to calculate the widest label length to offset labels. Adjust if the automatic tick length doesn't look right because you have a bigger font (or just set `tickMarkLength` to a pixel value).
*/
/** @type {Props} */
let {
tickMarks = false,
labelPosition = 'even',
snapBaselineLabel = false,
gridlines = true,
tickMarkLength = undefined,
format = d => d,
ticks = 4,
tickGutter = 0,
dx = 0,
dy = 0,
charPixelWidth = 7.25
} = $props();
/** @param {number} sum
* @param {string} val */
function calcStringLength(sum, val) {
if (val === ',' || val === '.') return sum + charPixelWidth * 0.5;
return sum + charPixelWidth;
}
let isBandwidth = $derived(typeof k.yScale.bandwidth === 'function');
/** @type {Array<any>} */
let tickVals = $derived(
Array.isArray(ticks)
? ticks
: isBandwidth
? k.yScale.domain()
: typeof ticks === 'function'
? ticks(k.yScale.ticks())
: k.yScale.ticks(ticks)
);
let widestTickLen = $derived(
Math.max(
10,
Math.max(...tickVals.map(d => format(d).toString().split('').reduce(calcStringLength, 0)))
)
);
let tickLen = $derived(
tickMarks === true
? labelPosition === 'above'
? (tickMarkLength ?? widestTickLen)
: (tickMarkLength ?? 6)
: 0
);
let x1 = $derived(-tickGutter - (labelPosition === 'above' ? widestTickLen : tickLen));
let halfBand = $derived(isBandwidth ? k.yScale.bandwidth() / 2 : 0);
let maxTickValPx = $derived(Math.max(...tickVals.map(k.yScale)));
</script>
<g class="axis y-axis">
{#each tickVals as tick (tick)}
{@const tickValPx = k.yScale(tick)}
<!-- Fall back to the left edge if the chart has no x dimension -->
<g class="tick tick-{tick}" transform="translate({k.xRange ? k.xRange[0] : 0}, {tickValPx})">
{#if gridlines === true}
<line class="gridline" {x1} x2={k.width} y1={halfBand} y2={halfBand}></line>
{/if}
{#if tickMarks === true}
<line class="tick-mark" {x1} x2={x1 + tickLen} y1={halfBand} y2={halfBand}></line>
{/if}
<text
x={x1}
y={halfBand}
dx={dx + (labelPosition === 'even' ? -3 : 0)}
text-anchor={labelPosition === 'above' ? 'start' : 'end'}
dy={dy +
(labelPosition === 'above' || (snapBaselineLabel === true && tickValPx === maxTickValPx)
? -3
: 4)}>{format(tick)}</text
>
</g>
{/each}
</g>
<style>
.tick {
font-size: 11px;
}
.tick line {
stroke: #aaa;
}
.tick .gridline {
stroke-dasharray: 2;
}
.tick text {
fill: #666;
}
/* A solid line at the zero tick */
.tick.tick-0 line {
stroke-dasharray: 0;
}
</style>myX,myY 1979,7.19 1980,7.83 1981,7.24 1982,7.44 1983,7.51 1984,7.1 1985,6.91 1986,7.53 1987,7.47 1988,7.48 1989,7.03 1990,6.23 1991,6.54 1992,7.54 1993,6.5 1994,7.18 1995,6.12 1996,7.87 1997,6.73 1998,6.55 1999,6.23 2000,6.31 2001,6.74 2002,5.95 2003,6.13 2004,6.04 2005,5.56 2006,5.91 2007,4.29 2008,4.72 2009,5.38 2010,4.92 2011,4.61 2012,3.62 2013,5.35 2014,5.28 2015,4.63 2016,4.72