WebGL Scatter (svg axes, quadtree hover)Edit
1980
1985
1990
1995
2000
2005
2010
2015
4
5
6
7
8
- +page.svelte
- ./_components/Scatter.webgl.svelte
- ./_components/AxisX.percent-range.html.svelte
- ./_components/AxisY.percent-range.html.svelte
- ./_components/QuadTree.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.html.svelte';
// This example loads csv data as json using @rollup/plugin-dsv
import data from './_data/points.csv';
const xKey = 'myX';
const yKey = 'myY';
const r = 3;
const xyPadding = 6;
const padding = { top: 5, right: 5, bottom: 20, left: 25 };
data.forEach(d => {
d[yKey] = +d[yKey];
});
</script>
<div class="chart-container">
<LayerCake
position="absolute"
ssr
percentRange
{padding}
x={xKey}
y={yKey}
xPadding={[xyPadding, xyPadding]}
yPadding={[xyPadding, xyPadding]}
{data}
>
<Html>
<AxisX />
<AxisY tickMarks={false} />
</Html>
</LayerCake>
<LayerCake
position="absolute"
{padding}
x={xKey}
y={yKey}
xPadding={[xyPadding, xyPadding]}
yPadding={[xyPadding, xyPadding]}
{data}
>
<WebGL>
<ScatterWebGL {r} />
</WebGL>
<Html>
<QuadTree let:x let:y let:visible>
<div
class="circle"
style="top:{y}px;left:{x}px;display: {visible ? 'block' : 'none'};"
></div>
</QuadTree>
</Html>
</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 {
position: relative;
width: 100%;
height: 400px;
}
.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 a WebGl scatter plot.
-->
<script>
import reglWrapper from 'regl';
import { getContext } from 'svelte';
const { data, xGet, yGet, width, height } = getContext('LayerCake');
/** @type {Number} [r=5] - The circle's radius. */
export let r = 5;
/** @type {String} [fill='#0cf'] - The circle's fill color. */
export let fill = '#0cf';
export let stroke = '#000'; // Not yet implemented
// export let strokeWidth = 0;
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 { gl } = getContext('gl');
function resize() {
if ($gl) {
const canvas = $gl.canvas;
// Lookup the size the browser is displaying the canvas.
const displayWidth = canvas.clientWidth;
const displayHeight = canvas.clientHeight;
// Check if the canvas is not the same size.
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
// Make the canvas the same size
canvas.width = displayWidth;
canvas.height = displayHeight;
}
$gl.viewport(0, 0, canvas.width, canvas.height);
}
}
let regl;
function render() {
if ($gl) {
regl = reglWrapper({
gl: $gl,
extensions: ['oes_standard_derivatives']
});
regl.clear({
color: [0, 0, 0, 0],
depth: 1
});
const draw = 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(fill_color,1.0) * alpha;
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: {
// There will be a position value for each point
// we pass in
position: (context, props) => {
return props.points.map(point => {
return [$xGet(point), $yGet(point)];
});
},
r: (context, props) => {
// const m = window.devicePixelRatio > 1 ? 4.0 : 2.0
// If using an r-scale, set width here
return props.points.map(p => props.pointWidth);
},
stroke_size: (context, props) => {
// If using an r-scale, set that here
return props.points.map(p => 0);
}
},
uniforms: {
fill_color: hexToRgbPercent(fill),
// stroke_color: [0.6705882352941176, 0, 0.8392156862745098],
stroke_color: hexToRgbPercent(stroke),
// FYI: there is a helper method for grabbing
// values out of the context as well.
// These uniforms are used in our fragment shader to
// convert our x / y values to WebGL coordinate space.
stage_width: regl.context('drawingBufferWidth'),
stage_height: regl.context('drawingBufferHeight')
},
count: (context, props) => {
// set the count based on the number of points we have
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 }
});
draw({
pointWidth: r * 2,
points: $data
});
}
}
$: $width, $height, $gl, resize(), render();
</script>
<!--
@component
Generates an HTML x-axis, useful for server-side rendered charts. 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.
Although this is marked as a percent-range component, you can also use it with a normal scale with no configuration needed. By default, if you have `percentRange={true}` it will use percentages, otherwise it will use pixels. This makes this component compatible with server-side and client-side rendered charts. Set the `units` prop to either `'%'` or `'px'` to override the default behavior.
-->
<script>
import { getContext } from 'svelte';
const { xScale, percentRange } = getContext('LayerCake');
/** @type {boolean} [tickMarks=false] - Show a vertical mark for each tick. */
export let tickMarks = false;
/** @type {boolean} [gridlines=true] - Show gridlines extending into the chart area. */
export let gridlines = true;
/** @type {Number} [tickMarkLength=6] - The length of the tick mark. */
export let tickMarkLength = 6;
/** @type {boolean} [baseline=false] – Show a solid line at the bottom. */
export let baseline = false;
/** @type {boolean} [snapLabels=false] - Instead of centering the text labels on the first and the last items, align them to the edges of the chart. */
export let snapLabels = false;
/** @type {(d: any) => string} [format=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let format = d => d;
/** @type {Number|Array<any>|Function|undefined} [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. */
export let ticks = undefined;
/** @type {Number} [tickGutter=0] - The amount of whitespace between the start of the tick and the chart drawing area (the yRange min). */
export let tickGutter = 0;
/** @type {Number} [dx=0] - Any optional value passed to the `dx` attribute on the text label. */
export let dx = 0;
/** @type {Number} [dy=0] - Any optional value passed to the `dy` attribute on the text label. */
export let dy = 0;
/** @type {String} units - Whether this component should use percentage or pixel values. If `percentRange={true}` it defaults to `'%'`. Options: `'%'` or `'px'`. */
export let units = $percentRange === true ? '%' : 'px';
$: tickLen = tickMarks === true ? tickMarkLength ?? 6 : 0;
$: isBandwidth = typeof $xScale.bandwidth === 'function';
/** @type {Array<any>} */
$: tickVals = Array.isArray(ticks)
? ticks
: isBandwidth
? $xScale.domain()
: typeof ticks === 'function'
? ticks($xScale.ticks())
: $xScale.ticks(ticks);
$: halfBand = isBandwidth ? $xScale.bandwidth() / 2 : 0;
</script>
<div class="axis x-axis" class:snapLabels>
{#each tickVals as tick, i (tick)}
{@const tickValUnits = $xScale(tick)}
{#if baseline === true}
<div class="baseline" style="top:100%; width:100%;"></div>
{/if}
{#if gridlines === true}
<div class="gridline" style:left="{tickValUnits}{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%);
}
/* This looks a little better at 40 percent than 50 */
.axis.snapLabels .tick:last-child {
transform: translateX(-40%);
}
.axis.snapLabels .tick.tick-0 {
transform: translateX(40%);
}
</style>
<!--
@component
Generates an HTML 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.
Although this is marked as a percent-range component, you can also use it with a normal scale with no configuration needed. By default, if you have `percentRange={true}` it will use percentages, otherwise it will use pixels. This makes this component compatible with server-side and client-side rendered charts. Set the `units` prop to either `'%'` or `'px'` to override the default behavior.
-->
<script>
import { getContext } from 'svelte';
const { xRange, yScale, percentRange } = getContext('LayerCake');
/** @type {boolean} [tickMarks=false] - Show marks next to the tick label. */
export let tickMarks = false;
/** @type {String} [labelPosition='even'] - Whether the label sits even with its value ('even') or sits on top ('above') the tick mark. Default is 'even'. */
export let labelPosition = 'even';
/** @type {boolean} [snapBaselineLabel=false] - When labelPosition='even', adjust the lowest label so that it sits above the tick mark. */
export let snapBaselineLabel = false;
/** @type {boolean} [gridlines=true] - Show gridlines extending into the chart area. */
export let gridlines = true;
/** @type {Number|undefined} [tickMarkLength=undefined] - The length of the tick mark. If not set, becomes the length of the widest tick. */
export let tickMarkLength = undefined;
/** @type {(d: any) => string} [format=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let format = d => d;
/** @type {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. */
export let ticks = 4;
/** @type {Number} [tickGutter=0] - The amount of whitespace between the start of the tick and the chart drawing area (the xRange min). */
export let tickGutter = 0;
/** @type {Number} [dx=0] - Any optional value passed to the `dx` attribute on the text label. */
export let dx = 0;
/** @type {Number} [dy=-3] - Any optional value passed to the `dy` attribute on the text label. */
export let dy = -3;
/** @type {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). */
export let charPixelWidth = 7.25;
/** @type {String} units - Whether this component should use percentage or pixel values. If `percentRange={true}` it defaults to `'%'`. Options: `'%'` or `'px'`. */
export let units = $percentRange === true ? '%' : 'px';
$: isBandwidth = typeof $yScale.bandwidth === 'function';
/** @type {Array<any>} */
$: tickVals = Array.isArray(ticks)
? ticks
: isBandwidth
? $yScale.domain()
: typeof ticks === 'function'
? ticks($yScale.ticks())
: $yScale.ticks(ticks);
/** @param {Number} sum
* @param {String} val */
function calcStringLength(sum, val) {
if (val === ',' || val === '.') return sum + charPixelWidth * 0.5;
return sum + charPixelWidth;
}
$: tickLen =
tickMarks === true
? labelPosition === 'above'
? tickMarkLength ?? widestTickLen
: tickMarkLength ?? 6
: 0;
$: widestTickLen = Math.max(
10,
Math.max(...tickVals.map(d => format(d).toString().split('').reduce(calcStringLength, 0)))
);
$: x1 = -tickGutter - (labelPosition === 'above' ? widestTickLen : tickLen);
$: halfBand = isBandwidth ? $yScale.bandwidth() / 2 : 0;
$: maxTickValUnits = Math.max(...tickVals.map($yScale));
</script>
<div class="axis y-axis">
{#each tickVals as tick, i (tick)}
{@const tickValUnits = $yScale(tick)}
<div
class="tick tick-{i}"
style="left:{$xRange[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,
.baseline,
.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;
}
.baseline.gridline {
border-top-style: solid;
}
.tick .text {
color: #666;
}
</style>
<!--
@component
Creates an interaction layer (in HTML) using [d3-quadtree](https://github.com/d3/d3-quadtree) to find the nearest datapoint to the mouse. This component creates a slot that exposes variables `x`, `y`, `found` (the found datapoint), `visible` (a Boolean whether any data was found) and `e` (the event object).
The quadtree searches across both the x and y dimensions at the same time. But if you want to only search across one, set the `x` and `y` props to the same value. For example, the [shared tooltip component](https://layercake.graphics/components/SharedTooltip.html.svelte) sets `y='x'` since it's nicer behavior to only pick up on the nearest x-value.
-->
<script>
import { getContext } from 'svelte';
import { quadtree } from 'd3-quadtree';
const { data, xGet, yGet, width, height } = getContext('LayerCake');
let visible = false;
let found = {};
let e = {};
/** @type {String} [x='x'] – The dimension to search across when moving the mouse left and right. */
export let x = 'x';
/** @type {String} [y='y'] – The dimension to search across when moving the mouse up and down. */
export let y = 'y';
/** @type {Number|undefined} [searchRadius] – The number of pixels to search around the mouse's location. This is the third argument passed to [`quadtree.find`](https://github.com/d3/d3-quadtree#quadtree_find) and by default a value of `undefined` means an unlimited range. */
export let searchRadius = undefined;
/** @type {Array<Object>|undefined} [dataset] – The dataset to work off of—defaults to $data if left unset. You can pass override the default here in here in case you don't want to use the main data or it's in a strange format. */
export let dataset = undefined;
$: xGetter = x === 'x' ? $xGet : $yGet;
$: yGetter = y === 'y' ? $yGet : $xGet;
function findItem(evt) {
e = evt;
const xLayerKey = `layer${x.toUpperCase()}`;
const yLayerKey = `layer${y.toUpperCase()}`;
found = finder.find(evt[xLayerKey], evt[yLayerKey], searchRadius) || {};
visible = Object.keys(found).length > 0;
}
$: finder = quadtree()
.extent([
[-1, -1],
[$width + 1, $height + 1]
])
.x(xGetter)
.y(yGetter)
.addAll(dataset || $data);
</script>
<div
class="bg"
on:mousemove={findItem}
on:mouseout={() => (visible = false)}
on:blur={() => (visible = false)}
role="tooltip"
></div>
<slot x={xGetter(found) || 0} y={yGetter(found) || 0} {found} {visible} {e}></slot>
<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