Synced brush
Four charts sharing one brush. Each SyncedBrushWrapper.svelte holds a chart and a brush and exposes min and max as bindable props. Binding all four to the same values keeps them in step, so dragging any brush zooms every chart.
- +page.svelte
- ./_components/SyncedBrushWrapper.svelte
- ./_components/Line.svelte
- ./_components/Area.svelte
- ./_components/AxisX.svelte
- ./_components/AxisY.svelte
- ./_components/Brush.html.svelte
- ./_data/points.csv
- ./_data/pointsTwo.csv
- ./_data/pointsThree.csv
- ./_data/pointsFour.csv
<script>
import SyncedBrushWrapper from './_components/SyncedBrushWrapper.svelte';
// The CSV rows are parsed, and their numbers typed, by @rollup/plugin-dsv. See vite.config.js
import pointsOne from './_data/points.csv';
import pointsTwo from './_data/pointsTwo.csv';
import pointsThree from './_data/pointsThree.csv';
import pointsFour from './_data/pointsFour.csv';
// One brush range shared by every chart
/** @type {[number|null, number|null]} */
let brushExtents = $state([null, null]);
const xKey = 'myX';
const yKey = 'myY';
const datasets = [pointsOne, pointsTwo, pointsThree, pointsFour];
const colors = ['#00e047', '#00bbff', '#ff00cc', '#ffcc00'];
</script>
<div class="chart-container">
{#each datasets as dataset, i}
<SyncedBrushWrapper
data={dataset}
{xKey}
{yKey}
bind:min={brushExtents[0]}
bind:max={brushExtents[1]}
stroke={colors[i]}
/>
{/each}
</div>
<style>
/* Give the wrapper a width and height. LayerCake fills it. */
.chart-container {
width: 100%;
height: 250px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-content: space-between;
}
</style><!--
@component
Draws a line chart with a brush under it. Drag on the brush to zoom the chart above to that slice of the rows. Bind `min` and `max` to keep several of these in step, as the [synced brush example](https://layercake.graphics/example/SyncedBrush) does.
-->
<script>
import { LayerCake, Svg, Html } from 'layercake';
import Line from './Line.svelte';
import Area from './Area.svelte';
import AxisX from './AxisX.svelte';
import AxisY from './AxisY.svelte';
import Brush from './Brush.html.svelte';
/**
* @typedef {Object} Props
* @property {number|null} [min=null] - Where the brush starts, as a share of the rows from 0 to 1. Bind to it to sync charts.
* @property {number|null} [max=null] - Where the brush ends, as a share of the rows from 0 to 1. Bind to it to sync charts.
* @property {string} [xKey='x'] - The field holding each row's x value.
* @property {string} [yKey='y'] - The field holding each row's y value.
* @property {Array<Object>} [data=[]] - The rows to draw.
* @property {string} [stroke='#00e047'] - The line's stroke color. The area under it is the same color at low opacity.
*/
/** @type {Props} */
let {
min = $bindable(null),
max = $bindable(null),
xKey = 'x',
yKey = 'y',
data = [],
stroke = '#00e047'
} = $props();
// The slice of rows inside the brush, with at least two so the line still draws when the brush is very narrow
let brushedData = $derived.by(() => {
const start = Math.max(0, Math.floor((min ?? 0) * data.length));
const end = Math.min(data.length, Math.ceil((max ?? 1) * data.length));
const brushed = data.slice(start, end);
if (brushed.length < 2 && data.length >= 2) {
return data.slice(start, start + 2);
}
return brushed;
});
</script>
<div class="chart-wrapper">
<div class="chart-container">
<LayerCake
padding={{ bottom: 20, left: 25 }}
x={xKey}
y={yKey}
yDomain={[0, null]}
data={brushedData}
>
<Svg>
<!-- Whole-number ticks only, and every other one when there are too many -->
<AxisX
ticks={ticks => {
const filtered = ticks.filter(t => t % 1 === 0);
if (filtered.length > 7) {
return filtered.filter((t, i) => i % 2 === 0);
}
return filtered;
}}
/>
<AxisY ticks={2} />
<Line {stroke} />
<Area fill={`${stroke}10`} />
</Svg>
</LayerCake>
</div>
<div class="brush-container">
<LayerCake padding={{ top: 5 }} x={xKey} y={yKey} yDomain={[0, null]} {data}>
<Svg>
<Line {stroke} />
<Area fill={`${stroke}10`} />
</Svg>
<Html>
<Brush bind:min bind:max />
</Html>
</LayerCake>
</div>
</div>
<style>
/* Give the wrapper a width and height. LayerCake fills it. */
.chart-wrapper {
width: 48%;
height: 40%;
}
.chart-container {
width: 100%;
height: 80%;
}
.brush-container {
width: 100%;
height: 20%;
}
</style><!--
@component
Generates an SVG line shape.
-->
<script>
import { getLayerCakeContext } from 'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {string} [stroke='#ab00d6'] - The line's stroke color.
*/
/** @type {Props} */
let { stroke = '#ab00d6' } = $props();
let path = $derived(
'M' +
k.data
.map(d => {
return k.xGet(d) + ',' + k.yGet(d);
})
.join('L')
);
</script>
<path class="path-line" d={path} {stroke}></path>
<style>
.path-line {
fill: none;
stroke-linejoin: round;
stroke-linecap: round;
stroke-width: 2px;
}
</style><!--
@component
Generates an SVG area shape.
-->
<script>
import { getLayerCakeContext } from 'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {string} [fill='#ab00d610'] - The shape's fill color.
*/
/** @type {Props} */
let { fill = '#ab00d610' } = $props();
let path = $derived(
'M' +
k.data
.map(d => {
return k.xGet(d) + ',' + k.yGet(d);
})
.join('L')
);
// Close the line along the bottom of the chart to make the area
/** @type {string} */
let area = $derived.by(() => {
const yRange = k.yScale.range();
return (
path +
('L' +
k.xScale(k.extents.x ? k.extents.x[1] : 0) +
',' +
yRange[0] +
'L' +
k.xScale(k.extents.x ? k.extents.x[0] : 0) +
',' +
yRange[0] +
'Z')
);
});
</script>
<path class="path-area" d={area} {fill}></path><!--
@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
Adds an HTML brush for picking a range from 0 to 1. Drag on the track to draw one, or focus a handle and use the arrow keys. Bind `min` and `max` to read the range elsewhere. See the [brush example](https://layercake.graphics/example/Brush).
-->
<script>
import { clamp } from 'yootils';
/**
* @typedef {Object} Props
* @property {number|null} [min=null] - Where the brush starts, from 0 to 1. Bind to it.
* @property {number|null} [max=null] - Where the brush ends, from 0 to 1. Bind to it.
*/
/** @type {Props} */
let { min = $bindable(null), max = $bindable(null) } = $props();
/** @type {HTMLDivElement|undefined} */
let brush = $state();
// A horizontal page position as a share of the brush's width, from 0 to 1
/** @param {number} clientX */
function fractionAcross(clientX) {
if (!brush) return 0;
const { left, right } = brush.getBoundingClientRect();
return clamp((clientX - left) / (right - left), 0, 1);
}
/** @typedef {{ min: number, max: number, p: number }} DragStart The range and pointer position when a drag began. */
// Wraps a drag rule so it runs on every mouse or touch move until the pointer lifts.
// It gets the range as it was when the drag began and the pointer's current position.
/** @param {(start: DragStart, p: number) => void} fn */
function handler(fn) {
/** @param {MouseEvent|TouchEvent} e */
return e => {
e.stopPropagation();
e.preventDefault();
// Only follow one finger at a time
/** @type {number|undefined} */
let touchId;
/** @type {{ clientX: number }} */
let point = /** @type {MouseEvent} */ (e);
if ('touches' in e) {
if (e.touches.length !== 1) return;
point = e.touches[0];
touchId = e.touches[0].identifier;
}
const start = { min: min ?? 0, max: max ?? 1, p: fractionAcross(point.clientX) };
/** @param {MouseEvent|TouchEvent} e */
const handleMove = e => {
e.preventDefault();
/** @type {{ clientX: number }} */
let moved = /** @type {MouseEvent} */ (e);
if ('changedTouches' in e) {
if (e.changedTouches.length !== 1) return;
if (e.changedTouches[0].identifier !== touchId) return;
moved = e.changedTouches[0];
}
fn(start, fractionAcross(moved.clientX));
};
/** @param {MouseEvent|TouchEvent} e */
const handleEnd = e => {
if ('changedTouches' in e) {
if (e.changedTouches.length !== 1) return;
if (e.changedTouches[0].identifier !== touchId) return;
} else if (e.target === brush) {
// A click on the empty track clears the range
clear();
}
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleEnd);
window.removeEventListener('touchmove', handleMove);
window.removeEventListener('touchend', handleEnd);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleEnd);
window.addEventListener('touchmove', handleMove);
window.addEventListener('touchend', handleEnd);
};
}
function clear() {
min = null;
max = null;
}
// Drag on the track to draw a new range
const reset = handler((start, p) => {
min = clamp(Math.min(start.p, p), 0, 1);
max = clamp(Math.max(start.p, p), 0, 1);
});
// Drag the range to slide it along, keeping its width
const move = handler((start, p) => {
const d = clamp(p - start.p, -start.min, 1 - start.max);
min = start.min + d;
max = start.max + d;
});
// Drag a handle to move one end, swapping ends if it crosses the other
const adjustMin = handler((start, p) => {
min = p > start.max ? start.max : p;
max = p > start.max ? p : start.max;
});
const adjustMax = handler((start, p) => {
min = p < start.min ? p : start.min;
max = p < start.min ? start.min : p;
});
// Arrow keys nudge by a hundredth, or a tenth with shift. Home and End go to
// the edges. Escape clears the range. With no range yet, the handles sit at
// the edges, so the first key press draws one.
/** @param {KeyboardEvent} e @param {'min'|'max'|'both'} part Which part of the range the key moves. */
function handleKeydown(e, part) {
const step = e.shiftKey ? 0.1 : 0.01;
const start = { min: min ?? 0, max: max ?? 1 };
let delta;
if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') delta = -step;
else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') delta = step;
else if (e.key === 'Home') delta = -1;
else if (e.key === 'End') delta = 1;
else if (e.key === 'Escape') {
clear();
return;
} else return;
e.preventDefault();
if (part === 'both') {
const d = clamp(delta, -start.min, 1 - start.max);
min = start.min + d;
max = start.max + d;
} else if (part === 'min') {
const p = clamp(start.min + delta, 0, 1);
min = Math.min(p, start.max);
max = Math.max(p, start.max);
} else {
const p = clamp(start.max + delta, 0, 1);
min = Math.min(p, start.min);
max = Math.max(p, start.min);
}
}
let left = $derived(100 * (min ?? 0));
let right = $derived(100 * (1 - (max ?? 1)));
/** @param {number} value */
const percent = value => `${Math.round(value * 100)}%`;
</script>
<!-- The track itself is mouse and touch only. Keyboard users work the range and its two handles, which are sliders. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={brush} class="brush-outer" onmousedown={reset} ontouchstart={reset}>
{#if min !== null && max !== null}
<div
class="brush-inner"
role="slider"
tabindex="0"
aria-label="Selected range"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round(min * 100)}
aria-valuetext="{percent(min)} to {percent(max)}"
draggable="false"
onmousedown={move}
ontouchstart={move}
onkeydown={e => handleKeydown(e, 'both')}
style="left: {left}%; right: {right}%"
></div>
{/if}
<!-- The handles stay in the DOM with no range so they can be focused, but the mouse then goes to the track -->
<div
class="brush-handle"
class:idle={min === null}
role="slider"
tabindex="0"
aria-label="Start of range"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round((min ?? 0) * 100)}
aria-valuetext={percent(min ?? 0)}
draggable="false"
onmousedown={adjustMin}
ontouchstart={adjustMin}
onkeydown={e => handleKeydown(e, 'min')}
style="left: {left}%"
></div>
<div
class="brush-handle"
class:idle={max === null}
role="slider"
tabindex="0"
aria-label="End of range"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow={Math.round((max ?? 1) * 100)}
aria-valuetext={percent(max ?? 1)}
draggable="false"
onmousedown={adjustMax}
ontouchstart={adjustMax}
onkeydown={e => handleKeydown(e, 'max')}
style="right: {right}%"
></div>
</div>
<style>
.brush-outer {
position: relative;
width: 100%;
height: calc(100% + 5px);
top: -5px;
}
.brush-inner {
position: absolute;
height: 100%;
cursor: move;
background-color: #cccccc90;
}
.brush-handle {
position: absolute;
width: 0;
height: 100%;
cursor: ew-resize;
}
.brush-handle.idle {
pointer-events: none;
}
.brush-handle::before {
position: absolute;
content: '';
width: 8px;
left: -4px;
height: 100%;
background: transparent;
}
</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
myX,myY 1979,5.03 1980,3.99 1981,10.35 1982,4.06 1983,10.11 1984,11.66 1985,5.95 1986,7.27 1987,2.77 1988,3.43 1989,6.49 1990,8.27 1991,3.85 1992,6.21 1993,0.14 1994,7.65 1995,5.57 1996,15.61 1997,12.42 1998,5.2 1999,9.81 2000,8.64 2001,10.07 2002,1.71 2003,3 2004,11.82 2005,4.74 2006,4.25 2007,0.16 2008,7.97 2009,9.75 2010,3.34 2011,3.46 2012,1.73 2013,5.97 2014,2.17 2015,0.88 2016,8.19
myX,myY 1979,5.43 1980,7.27 1981,8.82 1982,13.14 1983,14.63 1984,5.41 1985,4.76 1986,12.46 1987,10.38 1988,0.06 1989,9.35 1990,5.84 1991,6.94 1992,2.78 1993,9.84 1994,6.48 1995,9.97 1996,4.85 1997,5.35 1998,4.12 1999,1.56 2000,10.11 2001,2.04 2002,4.03 2003,3.85 2004,8.61 2005,1.44 2006,9.41 2007,3.51 2008,4.12 2009,6.76 2010,3.65 2011,6.76 2012,6.46 2013,0.75 2014,9.93 2015,8.11 2016,1.4
myX,myY 1979,10.27 1980,1.99 1981,2.25 1982,3.56 1983,0.32 1984,4.18 1985,1.75 1986,2.35 1987,2.54 1988,6.53 1989,5.02 1990,3.4 1991,4.79 1992,1.29 1993,12.65 1994,5.26 1995,3.11 1996,14.92 1997,13.21 1998,10.34 1999,5.02 2000,9.11 2001,13.24 2002,8.02 2003,1.54 2004,0.25 2005,6.02 2006,5.91 2007,6.83 2008,6.76 2009,3.7 2010,2.3 2011,3.37 2012,4.02 2013,2.15 2014,2.33 2015,5.98 2016,6.27