WebGL Scatter (svg axes, quadtree hover)
1980
1985
1990
1995
2000
2005
2010
2015
4
5
6
7
A scatter plot drawn with WebGL, with HTML axes and a QuadTree layer that marks the nearest point on hover.
This is the server-side rendered version. ssr and percentRange on <LayerCake> put the scales in percentages, so the chart renders before the browser measures it. The axes are HTML components, and the WebGL layer draws once the page is in the browser.
- +page.svelte
- ./_components/Scatter.webgl.svelte
- ./_components/AxisX.percent-range.html.svelte
- ./_components/AxisY.percent-range.html.svelte
- ./_components/QuadTree.percent-range.html.svelte
- ./_data/points.csv
<script>
import { LayerCake, WebGL, Html } from 'layercake';
import ScatterWebgl from './_components/Scatter.webgl.svelte';
import AxisX from './_components/AxisX.percent-range.html.svelte';
import AxisY from './_components/AxisY.percent-range.html.svelte';
import QuadTree from './_components/QuadTree.percent-range.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;
// In percent units, so smaller than the client-side version's 6 pixels
const xyPadding = 2;
</script>
<div class="chart-container">
<LayerCake
ssr
percentRange
padding={{ top: 5, right: 5, bottom: 20, left: 25 }}
x={xKey}
y={yKey}
xPadding={[xyPadding, xyPadding]}
yPadding={[xyPadding, xyPadding]}
{data}
>
<Html>
<AxisX />
<AxisY tickMarks={false} ticks={5} />
</Html>
<WebGL>
<ScatterWebgl {r} />
</WebGL>
<Html>
<QuadTree>
{#snippet children({ x, y, visible })}
<div
class="circle"
style="top:{y}%;left:{x}%;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 HTML x-axis along the bottom of the chart, for server-side rendered charts. If the x scale is a band scale, each tick sits in the middle of its band.
Positions are percentages when `percentRange={true}` and pixels otherwise, so this also works in a client-side chart with no setup. Set the `units` prop to `'%'` or `'px'` to override that.
-->
<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=0] - Vertical offset of the label in pixels.
* @property {'px'|'%'} [units] - Position with pixels or percentages. Defaults to `'%'` when `percentRange={true}`, otherwise `'px'`.
*/
/** @type {Props} */
let {
tickMarks = false,
gridlines = true,
tickMarkLength = 6,
showBaseline = false,
snapLabels = false,
format = d => d,
ticks = undefined,
tickGutter = 0,
dx = 0,
dy = 0,
units = k.percentRange === true ? '%' : 'px'
} = $props();
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>
<div class="axis x-axis" class:snapLabels>
{#if showBaseline === true}
<div class="baseline" style="top:100%; width:100%;"></div>
{/if}
{#each tickVals as tick, i (tick)}
{@const tickValUnits = k.xScale(tick)}
{#if gridlines === true}
<div
class="gridline"
style:left="{tickValUnits + halfBand}{units}"
style="top:0; bottom:0;"
></div>
{/if}
{#if tickMarks === true}
<div
class="tick-mark"
style:left="{tickValUnits + halfBand}{units}"
style:height="{tickLen}px"
style:bottom="{-tickLen - tickGutter}px"
></div>
{/if}
<div
class="tick tick-{i}"
style:left="{tickValUnits + halfBand}{units}"
style="top:calc(100% + {tickGutter}px);"
>
<div
class="text"
style:top="{tickLen}px"
style:transform="translate(calc(-50% + {dx}px), {dy}px)"
>
{format(tick)}
</div>
</div>
{/each}
</div>
<style>
.axis,
.tick,
.tick-mark,
.gridline,
.baseline {
position: absolute;
}
.axis {
width: 100%;
height: 100%;
}
.tick {
font-size: 11px;
}
.gridline {
border-left: 1px dashed #aaa;
}
.tick-mark {
border-left: 1px solid #aaa;
}
.baseline {
border-top: 1px solid #aaa;
}
.tick .text {
color: #666;
position: relative;
white-space: nowrap;
transform: translateX(-50%);
}
/* Snapped end labels sit 40% inside their edge instead of centered on it */
.axis.snapLabels .tick:last-child {
transform: translateX(-40%);
}
.axis.snapLabels .tick.tick-0 {
transform: translateX(40%);
}
</style><!--
@component
Generates an HTML y-axis along the left edge of the chart, for server-side rendered charts. If the y scale is a band scale, each tick sits in the middle of its band.
Positions are percentages when `percentRange={true}` and pixels otherwise, so this also works in a client-side chart with no setup. Set the `units` prop to `'%'` or `'px'` to override that.
-->
<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=-3] - 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).
* @property {'px'|'%'} [units] - Position with pixels or percentages. Defaults to `'%'` when `percentRange={true}`, otherwise `'px'`.
*/
/** @type {Props} */
let {
tickMarks = false,
labelPosition = 'even',
snapBaselineLabel = false,
gridlines = true,
tickMarkLength = undefined,
format = d => d,
ticks = 4,
tickGutter = 0,
dx = 0,
dy = -3,
charPixelWidth = 7.25,
units = k.percentRange === true ? '%' : 'px'
} = $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 maxTickValUnits = $derived(Math.max(...tickVals.map(k.yScale)));
</script>
<div class="axis y-axis">
{#each tickVals as tick, i (tick)}
{@const tickValUnits = k.yScale(tick)}
<div
class="tick tick-{i}"
style="left:{k.xRange ? k.xRange[0] : 0}{units};top:{tickValUnits + halfBand}{units};"
>
{#if gridlines === true}
<div class="gridline" style="top:0;" style:left="{x1}px" style:right="0px"></div>
{/if}
{#if tickMarks === true}
<div class="tick-mark" style:top="0" style:left="{x1}px" style:width="{tickLen}px"></div>
{/if}
<div
class="text"
style:top="0"
style:text-align={labelPosition === 'even' ? 'right' : 'left'}
style:width="{widestTickLen}px"
style:left="{-widestTickLen - tickGutter - (labelPosition === 'even' ? tickLen : 0)}px"
style:transform="translate({dx + (labelPosition === 'even' ? -3 : 0)}px, calc(-50% + {dy +
(labelPosition === 'above' ||
(snapBaselineLabel === true && tickValUnits === maxTickValUnits)
? -3
: 4)}px))"
>
{format(tick)}
</div>
</div>
{/each}
</div>
<style>
.axis,
.tick,
.tick-mark,
.gridline,
.text {
position: absolute;
}
.axis {
width: 100%;
height: 100%;
}
.tick {
font-size: 11px;
width: 100%;
}
.gridline {
border-top: 1px dashed #aaa;
}
.tick-mark {
border-top: 1px solid #aaa;
}
.tick .text {
color: #666;
}
</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 position as percentages, for a `percentRange={true}` chart, `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.percent-range.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 as percentages, 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 as percentages of the chart, swapped when a prop points at the other dimension
const [px, py] = k.pointer(evt);
const xVal = (x === 'x' ? px / k.width : py / k.height) * 100;
const yVal = (y === 'y' ? py / k.height : px / k.width) * 100;
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