Scatter (svg + canvas + voronoi)
- +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';
// This example loads csv data as json using @rollup/plugin-dsv
import data from './_data/points.csv';
const xKey = 'myX';
const yKey = 'myY';
data.forEach(d => {
d[yKey] = +d[yKey];
});
const r = 3;
const padding = 10;
const color = '#fff';
function logEvent(d) {
console.log('dispatched event', d, d.detail);
}
</script>
<div class="chart-container">
<LayerCake
padding={{ top: 10, right: 5, bottom: 20, left: 25 }}
x={xKey}
y={yKey}
xPadding={[padding, padding]}
yPadding={[padding, padding]}
{data}
>
<Svg>
<AxisX gridlines={false} />
<AxisY gridlines={false} ticks={4} />
</Svg>
<Canvas>
<ScatterCanvas r={r * 1.5} fill="#0cf" />
</Canvas>
<Svg>
<ScatterSvg {r} fill={color} />
<Voronoi stroke="#333" onmouseover={logEvent} />
</Svg>
</LayerCake>
</div>
<style>
/*
The wrapper div needs to have an explicit width and height in CSS.
It can also be a flexbox child or CSS grid element.
The point being it needs dimensions since the <LayerCake> element will
expand to fill it.
*/
.chart-container {
width: 100%;
height: 250px;
}
</style>
<!--
@component
Generates an SVG scatter plot. This component can also work if the x- or y-scale is ordinal, i.e. it has a `.bandwidth` method. See the [timeplot chart](https://layercake.graphics/example/Timeplot) for an example.
-->
<script>
import { getContext } from 'svelte';
const { data, xGet, yGet, xScale, yScale } = getContext('LayerCake');
/**
* @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.
*/
/** @type {Props} */
let { r = 5, fill = '#0cf', stroke = '#000', strokeWidth = 0 } = $props();
</script>
<g class="scatter-group">
{#each $data as d}
<circle
cx={$xGet(d) + ($xScale.bandwidth ? $xScale.bandwidth() / 2 : 0)}
cy={$yGet(d) + ($yScale.bandwidth ? $yScale.bandwidth() / 2 : 0)}
{r}
{fill}
{stroke}
stroke-width={strokeWidth}
/>
{/each}
</g>
<!--
@component
Generates a canvas scatter plot.
-->
<script>
import { getContext, onMount, untrack } from 'svelte';
import { scaleCanvas } from 'layercake';
const { data, xGet, yGet, width, height } = getContext('LayerCake');
const { ctx } = getContext('canvas');
/**
* @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=1] - The circle's stroke width.
*/
/** @type {Props} */
let { r = 5, fill = '#0cf', stroke = '#000', strokeWidth = 1 } = $props();
onMount(() => {
$effect(() => {
if ($width && $height) {
untrack(() => {
if (!$ctx) return;
/**
* If you were to have multiple canvas layers
* maybe for some artistic layering purposes
* put these reset functions in the first layer, not each one
* since they should only run once per update
*/
scaleCanvas($ctx, $width, $height);
$ctx.clearRect(0, 0, $width, $height);
/**
* Draw our scatterplot
*/
$data.forEach((/** @type {any} d */ d) => {
$ctx.beginPath();
$ctx.arc($xGet(d), $yGet(d), r, 0, 2 * Math.PI, false);
$ctx.lineWidth = strokeWidth;
$ctx.strokeStyle = stroke;
$ctx.stroke();
$ctx.fillStyle = fill;
$ctx.fill();
});
});
}
});
});
</script>
<!--
@component
Generates a voronoi layer using [d3-delauney](https://github.com/d3/d3-delauney).
-->
<script>
import { getContext } from 'svelte';
import { uniques } from 'layercake';
import { Delaunay } from 'd3-delaunay';
const { data, xGet, yGet, width, height } = getContext('LayerCake');
/**
* @typedef {Object} Props
* @property {string|undefined} [stroke] - An optional stroke color, which is likely only useful for testing to make sure the shapes drew correctly.
* @property {(event: MouseEvent, point: Array<number>) => void} [onmouseover] - A function that gets called on mouseover events. The first argument is the event, and the second is the point data.
*/
/** @type {Props} */
let { stroke, onmouseover = () => {} } = $props();
function log(e, point) {
console.log(point, point.data);
onmouseover(e, point);
}
let points = $derived(
$data.map(d => {
const point = [$xGet(d), $yGet(d)];
point.data = d;
return point;
})
);
let uniquePoints = $derived(uniques(points, d => d.join(), false));
let voronoi = $derived(Delaunay.from(uniquePoints).voronoi([0, 0, $width, $height]));
</script>
{#each uniquePoints as point, i}
<path
style="stroke: {stroke}"
class="voronoi-cell"
d={voronoi.renderCell(i)}
onmouseover={e => log(e, point)}
onfocus={e => log(e, point)}
role="tooltip"
></path>
{/each}
<style>
.voronoi-cell {
fill: none;
stroke: none;
pointer-events: all;
outline: none;
}
/* Useful to testing but you'll want to disable this for production */
.voronoi-cell:hover {
stroke: #333 !important;
stroke-width: 3px;
}
</style>
<!--
@component
Generates an SVG x-axis. This component is also configured to detect if your x-scale is an ordinal scale. If so, it will place the markers in the middle of the bandwidth.
-->
<script>
import { getContext } from 'svelte';
const { width, height, xScale, yRange } = getContext('LayerCake');
/**
* @typedef {Object} Props
* @property {boolean} [tickMarks=false] - Show a vertical mark for 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} [baseline=false] - Show a solid line at the bottom.
* @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] - A function that passes the current tick value and expects a nicely formatted value in return.
* @property {number|Array<any>|Function} [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 amount of whitespace between the start of the tick and the chart drawing area (the yRange min).
* @property {number} [dx=0] - Any optional value passed to the `dx` attribute on the text label.
* @property {number} [dy=12] - Any optional value passed to the `dy` attribute on the text label.
*/
/** @type {Props} */
let {
tickMarks = false,
gridlines = true,
tickMarkLength = 6,
baseline = false,
snapLabels = false,
format = d => d,
ticks = undefined,
tickGutter = 0,
dx = 0,
dy = 12
} = $props();
/** @param {number} i
* @param {boolean} sl */
function textAnchor(i, sl) {
if (sl === 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 $xScale.bandwidth === 'function');
/** @type {Array<any>} */
let tickVals = $derived(
Array.isArray(ticks)
? ticks
: isBandwidth
? $xScale.domain()
: typeof ticks === 'function'
? ticks($xScale.ticks())
: $xScale.ticks(ticks)
);
let halfBand = $derived(isBandwidth ? $xScale.bandwidth() / 2 : 0);
</script>
<g class="axis x-axis" class:snapLabels>
{#each tickVals as tick, i (tick)}
{#if baseline === true}
<line class="baseline" y1={$height} y2={$height} x1="0" x2={$width} />
{/if}
<g class="tick tick-{i}" transform="translate({$xScale(tick)},{Math.max(...$yRange)})">
{#if gridlines === true}
<line class="gridline" x1={halfBand} x2={halfBand} y1={-$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, snapLabels)}
>{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;
}
/* This looks slightly better */
.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. This component is also configured to detect if your y-scale is an ordinal scale. If so, it will place the tickMarks in the middle of the bandwidth.
-->
<script>
import { getContext } from 'svelte';
const { xRange, yScale, width } = getContext('LayerCake');
/**
* @typedef {Object} Props
* @property {boolean} [tickMarks=false] - Show marks next to the tick label.
* @property {string} [labelPosition='even'] - Whether the label sits even with its value ('even') or sits on top ('above') the tick mark. Default is 'even'.
* @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] - The length of the tick mark. If not set, becomes the length of the widest tick.
* @property {(d: any) => string} [format=d => d] - A function that passes the current tick value and expects a nicely formatted value in return.
* @property {number|Array<any>|Function} [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 amount of whitespace between the start of the tick and the chart drawing area (the xRange min).
* @property {number} [dx=0] - Any optional value passed to the `dx` attribute on the text label.
* @property {number} [dy=0] - Any optional value passed to the `dy` attribute on the text label.
* @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 $yScale.bandwidth === 'function');
/** @type {Array<any>} */
let tickVals = $derived(
Array.isArray(ticks)
? ticks
: isBandwidth
? $yScale.domain()
: typeof ticks === 'function'
? ticks($yScale.ticks())
: $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 y = $derived(isBandwidth ? $yScale.bandwidth() / 2 : 0);
let maxTickValPx = $derived(Math.max(...tickVals.map($yScale)));
</script>
<g class="axis y-axis">
{#each tickVals as tick (tick)}
{@const tickValPx = $yScale(tick)}
<g class="tick tick-{tick}" transform="translate({$xRange[0]}, {tickValPx})">
{#if gridlines === true}
<line class="gridline" {x1} x2={$width} y1={y} y2={y}></line>
{/if}
{#if tickMarks === true}
<line class="tick-mark" {x1} x2={x1 + tickLen} y1={y} y2={y}></line>
{/if}
<text
x={x1}
{y}
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;
}
.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