WebGL Scatter (svg axes, quadtree hover)
A scatter plot drawn with WebGL, with SVG axes and a QuadTree layer that marks the nearest point on hover. The WebGL component uses regl and draws every point in one call, so it suits large datasets.
- +page.svelte
- ./_components/Scatter.webgl.svelte
- ./_components/AxisX.svelte
- ./_components/AxisY.svelte
- ./_components/QuadTree.html.svelte
- ./_data/points.csv
<script>
import { LayerCake, Svg, WebGL, Html } from 'layercake';
import ScatterWebgl from './_components/Scatter.webgl.svelte';
import AxisX from './_components/AxisX.svelte';
import AxisY from './_components/AxisY.svelte';
import QuadTree from './_components/QuadTree.html.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 xyPadding = 6;
</script>
<div class="chart-container">
<LayerCake
padding={{ top: 5, right: 5, bottom: 20, left: 25 }}
x={xKey}
y={yKey}
xPadding={[xyPadding, xyPadding]}
yPadding={[xyPadding, xyPadding]}
{data}
>
<Svg>
<AxisX />
<AxisY tickMarks={false} ticks={5} />
</Svg>
<WebGL>
<ScatterWebgl {r} />
</WebGL>
<Html>
<QuadTree>
{#snippet children({ x, y, visible })}
<div
class="circle"
style="top:{y}px;left:{x}px;display: {visible ? 'block' : 'none'};"
></div>
{/snippet}
</QuadTree>
</Html>
</LayerCake>
</div>
<style>
/* Give the wrapper a width and height. LayerCake fills it. */
.chart-container {
width: 100%;
height: 250px;
}
.circle {
position: absolute;
border-radius: 50%;
background-color: rgba(171, 0, 214);
transform: translate(-50%, -50%);
pointer-events: none;
width: 10px;
height: 10px;
}
</style><!--
@component
Generates a WebGL scatter plot.
-->
<script>
import reglWrapper from 'regl';
import { getContext, onDestroy } from 'svelte';
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();
// The shader wants the stroke as a share of the squared radius, not in pixels
let strokeSize = $derived.by(() => {
const inner = Math.max(0, 1 - strokeWidth / r);
return 1 - inner * inner;
});
/**
* @param {string} hex
* @returns {number[]|undefined} - Returns an array of RGB values in the range [0, 1].
*/
function hexToRgbPercent(hex) {
let str = hex.replace('#', '');
if (str.length === 3) {
str = str[0] + str[0] + str[1] + str[1] + str[2] + str[2];
}
return str.match(/.{1,2}/g)?.map(d => parseInt(d, 16) / 255);
}
const glCtx = getContext('gl');
// The drawing buffer gets one pixel per device pixel, not per CSS pixel, so
// the circles come out sharp on high-density screens
let pixelRatio = $state(1);
/**
* @param {WebGLRenderingContext} context
*/
function resize(context) {
const canvas = /** @type {HTMLCanvasElement} */ (context.canvas);
pixelRatio = window.devicePixelRatio || 1;
const width = Math.round(canvas.clientWidth * pixelRatio);
const height = Math.round(canvas.clientHeight * pixelRatio);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
context.viewport(0, 0, canvas.width, canvas.height);
}
/** @type {import('regl').Regl|undefined} */
let regl;
/** @type {import('regl').DrawCommand|undefined} */
let drawPoints;
/**
* Set up regl and compile the draw command once. Anything that changes
* between frames, like the points and colors, is passed to the draw command
* as props each time it runs.
* @param {WebGLRenderingContext} context
*/
function ensureRegl(context) {
if (regl) return;
regl = reglWrapper({
gl: context,
extensions: ['oes_standard_derivatives']
});
drawPoints = regl({
// circle code comes from:
// https://www.desultoryquest.com/blog/drawing-anti-aliased-circular-points-using-opengl-slash-webgl/
frag: `
#extension GL_OES_standard_derivatives : enable
precision mediump float;
uniform vec3 fill_color;
uniform vec3 stroke_color;
varying float s_s;
void main () {
vec2 cxy = 2.0 * gl_PointCoord - 1.0;
float dist = dot(cxy, cxy);
float delta = fwidth(dist);
float alpha = 1.0 - smoothstep(1.0 - delta, 1.0 + delta, dist);
float outer_edge_center = 1.0 - s_s;
float stroke = 1.0 - smoothstep(outer_edge_center - delta, outer_edge_center + delta, dist);
gl_FragColor = vec4( mix(stroke_color, fill_color, stroke), 1.0 ) * alpha;
gl_FragColor.rgb *= gl_FragColor.a;
}`,
vert: `
precision mediump float;
attribute vec2 position;
attribute float r;
attribute float stroke_size;
varying float s_s;
uniform float stage_width;
uniform float stage_height;
// http://peterbeshai.com/beautifully-animate-points-with-webgl-and-regl.html
vec2 normalizeCoords(vec2 position) {
// read in the positions into x and y vars
float x = position[0];
float y = position[1];
return vec2(
2.0 * ((x / stage_width) - 0.5),
// invert y to treat [0,0] as bottom left in pixel space
-(2.0 * ((y / stage_height) - 0.5))
);
}
void main () {
s_s = stroke_size;
gl_PointSize = r;
gl_Position = vec4(normalizeCoords(position), 0.0, 1.0);
}`,
attributes: {
// One [x, y] position for each point, in device pixels since that is what the buffer measures in
/**
* @param {any} context
* @param {{ points: Array<any>, x: (d: any) => number, y: (d: any) => number, pointWidth: number, strokeSize: number, pixelRatio: number, fillColor?: number[], strokeColor?: number[] }} props
*/
position: (context, props) => {
return props.points.map(point => {
return [props.x(point) * props.pixelRatio, props.y(point) * props.pixelRatio];
});
},
r: (context, props) => {
// To size each circle from an r scale, use k.rGet(point) in place of pointWidth
return props.points.map(() => props.pointWidth * props.pixelRatio);
},
stroke_size: (context, props) => {
return props.points.map(() => props.strokeSize);
}
},
uniforms: {
fill_color: (context, props) => props.fillColor,
stroke_color: (context, props) => props.strokeColor,
// The canvas size, so the shaders can convert x / y pixel values to
// WebGL coordinates. `regl.context` reads them off regl's own context.
stage_width: regl.context('drawingBufferWidth'),
stage_height: regl.context('drawingBufferHeight')
},
count: (context, props) => {
// Draw one point per row
return props.points.length;
},
primitive: 'points',
blend: {
enable: true,
func: {
srcRGB: 'src alpha',
srcAlpha: 'src alpha',
dstRGB: 'one minus src alpha',
dstAlpha: 'one minus src alpha'
}
},
depth: { enable: false }
});
}
$effect(() => {
if (!k.width || !k.height || !glCtx.gl) return;
ensureRegl(glCtx.gl);
if (!regl || !drawPoints) return;
resize(glCtx.gl);
// Let regl pick up the new drawing buffer size
regl.poll();
regl.clear({
color: [0, 0, 0, 0],
depth: 1
});
drawPoints({
pointWidth: r * 2,
strokeSize,
pixelRatio,
points: k.data,
x: k.xGet,
y: k.yGet,
fillColor: hexToRgbPercent(fill),
strokeColor: hexToRgbPercent(stroke)
});
});
onDestroy(() => {
if (regl) regl.destroy();
});
</script><!--
@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><!--
@component
Finds the data point nearest the mouse with [d3-quadtree](https://github.com/d3/d3-quadtree) and renders its `children` snippet with the result: `x` and `y` are the point's pixel position, `found` is its row, `visible` is whether a point was found and `e` is the mouse event.
The search covers both dimensions. To search one only, set `x` and `y` to the same dimension. The [shared tooltip](https://layercake.graphics/components/SharedTooltip.html.svelte) sets `y='x'` so it snaps to the nearest x value.
-->
<script>
import { quadtree } from 'd3-quadtree';
import { getLayerCakeContext } from 'layercake';
const k = getLayerCakeContext();
let visible = $state(false);
/** @type {Record<string, any>} */
let found = $state({});
/** @type {MouseEvent|undefined} */
let e = $state();
/**
* What the `children` snippet receives: the nearest point's position in pixels, its row, whether a point was found and the mouse event.
* @typedef {{ x: number, y: number, found: Record<string, any>, visible: boolean, e: MouseEvent|undefined }} Nearest
*/
/**
* @typedef {Object} Props
* @property {'x'|'y'} [x='x'] - The dimension a left-right mouse move searches. Set `x` and `y` to the same dimension to search one only.
* @property {'x'|'y'} [y='y'] - The dimension an up-down mouse move searches.
* @property {number|undefined} [searchRadius] - How many pixels around the mouse to search. Unlimited by default. Passed to [quadtree.find](https://github.com/d3/d3-quadtree#quadtree_find).
* @property {Array<Object>|undefined} [dataset] - Rows to search, defaulting to `k.data`. Pass your own list when the chart data is nested or reshaped.
* @property {import('svelte').Snippet<[Nearest]>} [children] - Renders with the nearest point. See the `Nearest` typedef above for what it gets.
*/
/** @type {Props} */
let { x = 'x', y = 'y', searchRadius, dataset, children } = $props();
let xGetter = $derived(x === 'x' ? k.xGet : k.yGet);
let yGetter = $derived(y === 'y' ? k.yGet : k.xGet);
/** @param {MouseEvent} evt */
function findItem(evt) {
e = evt;
// The mouse position in chart coordinates, swapped when a prop points at the other dimension
const [px, py] = k.pointer(evt);
const xVal = x === 'x' ? px : py;
const yVal = y === 'y' ? py : px;
found = finder.find(xVal, yVal, searchRadius) || {};
visible = Object.keys(found).length > 0;
}
let finder = $derived(
quadtree()
.extent([
[-1, -1],
[k.width + 1, k.height + 1]
])
.x(xGetter)
.y(yGetter)
.addAll(dataset || k.data)
);
</script>
<!-- The hit area only tracks the mouse. It is hidden from screen readers and has no keyboard path. -->
<!-- svelte-ignore a11y_mouse_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="bg"
onmousemove={findItem}
onmouseout={() => (visible = false)}
aria-hidden="true"
></div>
{@render children?.({ x: xGetter(found) || 0, y: yGetter(found) || 0, found, visible, e })}
<style>
.bg {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 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