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.html.svelte
- ./_components/AxisY.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.html.svelte';
import AxisY from './_components/AxisY.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 padding = 6;
data.forEach(d => {
d[yKey] = +d[yKey];
});
</script>
<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>
<div class="chart-container">
<LayerCake
position={'absolute'}
ssr={true}
percentRange={true}
padding={{ top: 5, right: 5, bottom: 20, left: 25 }}
x={xKey}
y={yKey}
xPadding={[padding, padding]}
yPadding={[padding, padding]}
data={data}
>
<Html>
<AxisX/>
<AxisY/>
</Html>
</LayerCake>
<LayerCake
position={'absolute'}
padding={{ top: 0, right: 5, bottom: 20, left: 25 }}
x={xKey}
y={yKey}
xPadding={[padding, padding]}
yPadding={[padding, padding]}
data={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>
<!--
@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(point => props.pointWidth);
},
stroke_size: (context, props) => {
// If using an r-scale, set width here
return props.points.map(point => 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.
-->
<script>
import { getContext } from 'svelte';
const { xScale } = getContext('LayerCake');
/** @type {Boolean} [gridlines=true] - Extend lines from the ticks into the chart space. */
export let gridlines = true;
/** @type {Boolean} [tickMarks=false] - Show a vertical mark for each tick. */
export let tickMarks = false;
/** @type {Boolean} [baseline=false] – Show a solid line at the bottom. */
export let baseline = false;
/** @type {Boolean} [snapTicks=false] - Instead of centering the text on the first and the last items, align them to the edges of the chart. */
export let snapTicks = false;
/** @type {Function} [formatTick=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let formatTick = d => d;
/** @type {Number|Array|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. */
export let ticks = undefined;
/** @type {Number} [yTick=7] - The distance from the baseline to place each tick value, in pixels. */
export let yTick = 7;
$: isBandwidth = typeof $xScale.bandwidth === 'function';
$: tickVals = Array.isArray(ticks) ? ticks :
isBandwidth ?
$xScale.domain() :
typeof ticks === 'function' ?
ticks($xScale.ticks()) :
$xScale.ticks(ticks);
</script>
<div class='axis x-axis' class:snapTicks>
{#each tickVals as tick, i (tick)}
{#if gridlines !== false}
<div class="gridline" style='left:{$xScale(tick)}%;top: 0px;bottom: 0;'></div>
{/if}
{#if tickMarks === true}
<div class="tick-mark" style='left:{$xScale(tick) + (isBandwidth ? $xScale.bandwidth() / 2 : 0)}%;height:6px;bottom: -6px;'></div>
{/if}
<div
class='tick tick-{ i }'
style='left:{$xScale(tick) + (isBandwidth ? $xScale.bandwidth() / 2 : 0)}%;top:100%;'>
<div
class="text"
style='top:{(yTick)}px;'>{formatTick(tick)}</div>
</div>
{/each}
{#if baseline === true}
<div class="baseline" style='top: 100%;width: 100%;'></div>
{/if}
</div>
<style>
.axis,
.tick,
.tick-mark,
.gridline,
.baseline {
position: absolute;
}
.axis {
width: 100%;
height: 100%;
}
.tick {
font-size: .725em;
font-weight: 200;
}
.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.snapTicks .tick:last-child {
transform: translateX(-40%);
}
.axis.snapTicks .tick.tick-0 {
transform: translateX(40%);
}
</style>
<!--
@component
Generates an HTML y-axis.
-->
<script>
import { getContext } from 'svelte';
const { padding, xRange, yScale } = getContext('LayerCake');
/** @type {Boolean} [gridlines=true] - Extend lines from the ticks into the chart space */
export let gridlines = true;
/** @type {Boolean} [tickMarks=false] - Show a vertical mark for each tick. */
export let tickMarks = false;
/** @type {Boolean} [baseline=false] – Show a solid line at the bottom. */
export let baseline = false;
/** @type {Function} [formatTick=d => d] - A function that passes the current tick value and expects a nicely formatted value in return. */
export let formatTick = d => d;
/** @type {Number|Array|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} [xTick=-4] - How far over to position the text marker. */
export let xTick = -4;
/** @type {Number} [yTick=-1] - How far up and down to position the text marker. */
export let yTick = -1;
$: isBandwidth = typeof $yScale.bandwidth === 'function';
$: tickVals = Array.isArray(ticks) ? ticks :
isBandwidth ?
$yScale.domain() :
typeof ticks === 'function' ?
ticks($yScale.ticks()) :
$yScale.ticks(ticks);
</script>
<div class='axis y-axis' style='transform:translate(-{$padding.left}px, 0)'>
{#each tickVals as tick, i (tick)}
<div class='tick tick-{i}' style='top:{$yScale(tick) + (isBandwidth ? $yScale.bandwidth () / 2 : 0)}%;left:{$xRange[0]}%;'>
{#if gridlines !== false}
<div class="gridline" style='top:0;left:{isBandwidth ? $padding.left : 0}px;right:-{$padding.left + $padding.right}px;'></div>
{/if}
{#if baseline !== false && i === 0}
<div class="gridline baseline" style='top:0;left:{isBandwidth ? $padding.left : 0};right:-{$padding.left + $padding.right}px;'></div>
{/if}
{#if tickMarks === true}
<div class="tick-mark" style='top:0;left:{isBandwidth ? $padding.left - 6 : 0}px;width:6px;'></div>
{/if}
<div
class="text"
style='
top:{yTick}px;
left:{isBandwidth ? ($padding.left + xTick - 4) : 0}px;
transform: translate({isBandwidth ? '-100%' : 0}, {isBandwidth ? -50 - Math.floor($yScale.bandwidth() / -2) : '-100'}%);
'
>{formatTick(tick)}</div>
</div>
{/each}
</div>
<style>
.axis,
.tick,
.tick-mark,
.gridline,
.baseline,
.text {
position: absolute;
}
.axis {
width: 100%;
height: 100%;
}
.tick {
font-size: 12px;
width: 100%;
font-weight: 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 {String} [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} [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>
<style>
.bg {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
</style>
<div
class="bg"
on:mousemove="{findItem}"
on:mouseout="{() => visible = false}"
on:blur="{() => visible = false}"
></div>
<slot
x={xGetter(found) || 0}
y={yGetter(found) || 0}
{found}
{visible}
{e}
></slot>
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