...a "long" format, where each type of fruit is grouped into its own array and each datapoint is a row. The column name becomes a property on the group whose name we define with the zKey variable.
We also need a flat, ungrouped array of objects so that Layer Cake can measure the full data extents. This gets passed to the flatData prop so the scales know the full domain of the data.
<script>import { LayerCake, Svg, Html, groupLonger, flatten } from'layercake';
import { scaleOrdinal } from'd3-scale';
import { timeParse, timeFormat } from'd3-time-format';
import { format } from'd3-format';
importMultiLinefrom'./_components/MultiLine.svelte';
importAxisXfrom'./_components/AxisX.svelte';
importAxisYfrom'./_components/AxisY.svelte';
importLabelsfrom'./_components/GroupLabels.html.svelte';
importSharedTooltipfrom'./_components/SharedTooltip.html.svelte';
// This example loads csv data as json and converts numeric columns to numbers using @rollup/plugin-dsv. See vite.config.js for detailsimport data from'./_data/fruit.csv';
/* --------------------------------------------
* Set what is our x key to separate it from the other series
*/const xKey = 'month';
const yKey = 'value';
const zKey = 'fruit';
const xKeyCast = timeParse('%Y-%m-%d');
const seriesNames = Object.keys(data[0]).filter(d => d !== xKey);
const seriesColors = ['#ffe4b8', '#ffb3c0', '#ff7ac7', '#ff00cc'];
/* --------------------------------------------
* Cast values
*/
data.forEach(d => {
d[xKey] = typeof d[xKey] === 'string' ? xKeyCast(d[xKey]) : d[xKey];
});
const formatLabelX = timeFormat('%b. %e');
constformatLabelY = d => format(`~s`)(d);
const groupedData = groupLonger(data, seriesNames, {
groupTo: zKey,
valueTo: yKey
});
</script><divclass="chart-container"><LayerCakepadding={{ top: 7, right: 10, bottom: 20, left: 25 }}x={xKey}y={yKey}z={zKey}yDomain={[0, null]}zScale={scaleOrdinal()}zRange={seriesColors}flatData={flatten(groupedData, 'values')}data={groupedData}
><Svg><AxisXgridlines={false}ticks={data.map(d => d[xKey]).sort((a, b) => a - b)}format={formatLabelX}snapLabelstickMarks
/><AxisYticks={4}format={formatLabelY} /><MultiLine /></Svg><Html><Labels /><SharedTooltipformatTitle={formatLabelX}dataset={data} /></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 {
width: 100%;
height: 250px;
}
</style>
<!--
@component
Generates an SVG multi-series line chart. It expects your data to be an array of objects, each with a `values` key that is an array of data objects.
--><script>import { getContext } from'svelte';
import { line, curveLinear } from'd3-shape';
const { data, xGet, yGet, zGet } = getContext('LayerCake');
/** @typedef {import('d3-shape').CurveFactory} CurveFactory *//**
* @typedef {Object} Props
* @property {CurveFactory} [curve] - An optional D3 interpolation function. See [d3-shape](https://github.com/d3/d3-shape#curves) for options. Pass this function in uncalled, i.e. without the open-close parentheses.
*//** @type {Props} */let { curve = curveLinear } = $props();
let path = $derived(line().x($xGet).y($yGet).curve(curve));
// .defined($y)</script><gclass="line-group">{#each $data as group}<pathclass="path-line"d={path(group.values)}stroke={$zGet(group)}></path>{/each}</g><style>.path-line {
fill: none;
stroke-linejoin: round;
stroke-linecap: round;
stroke-width: 3px;
}
</style>
<!--
@component
Generates an SVG x-axis. 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 { width, height, xScale, yRange } = getContext('LayerCake');
/**
* @typedef {Object} Props
* @property {boolean} [tickMarks=false] - Show a vertical mark for 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} [baseline=false] - Show a solid line at the bottom.
* @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] - A function that passes the current tick value and expects a nicely formatted value in return.
* @property {number|Array<any>|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.
* @property {number} [tickGutter=0] - The amount of whitespace between the start of the tick and the chart drawing area (the yRange min).
* @property {number} [dx=0] - Any optional value passed to the `dx` attribute on the text label.
* @property {number} [dy=12] - Any optional value passed to the `dy` attribute on the text label.
*//** @type {Props} */let {
tickMarks = false,
gridlines = true,
tickMarkLength = 6,
baseline = false,
snapLabels = false,
format = d => d,
ticks = undefined,
tickGutter = 0,
dx = 0,
dy = 12
} = $props();
/** @param {number} i
* @param {boolean} sl */functiontextAnchor(i, sl) {
if (sl === 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 $xScale.bandwidth === 'function');
/** @type {Array<any>} */let tickVals = $derived(
Array.isArray(ticks)
? ticks
: isBandwidth
? $xScale.domain()
: typeof ticks === 'function'
? ticks($xScale.ticks())
: $xScale.ticks(ticks)
);
let halfBand = $derived(isBandwidth ? $xScale.bandwidth() / 2 : 0);
</script><gclass="axis x-axis"class:snapLabels>{#each tickVals as tick, i (tick)}{#if baseline === true}<lineclass="baseline"y1={$height}y2={$height}x1="0"x2={$width} />{/if}<gclass="tick tick-{i}"transform="translate({$xScale(tick)},{Math.max(...$yRange)})">{#if gridlines === true}<lineclass="gridline"x1={halfBand}x2={halfBand}y1={-$height}y2="0" />{/if}{#if tickMarks === true}<lineclass="tick-mark"x1={halfBand}x2={halfBand}y1={tickGutter}y2={tickGutter + tickLen}
/>{/if}<textx={halfBand}y={tickGutter + tickLen}{dx}{dy}text-anchor={textAnchor(i, snapLabels)}
>{format(tick)}</text
>
</g>{/each}</g><style>.tick {
font-size: 11px;
}
line,
.tickline {
stroke: #aaa;
stroke-dasharray: 2;
}
.ticktext {
fill: #666;
}
.tick.tick-mark,
.baseline {
stroke-dasharray: 0;
}
/* This looks slightly better */.axis.snapLabels.tick:last-childtext {
transform: translateX(3px);
}
.axis.snapLabels.tick.tick-0text {
transform: translateX(-3px);
}
</style>
<!--
@component
Generates an SVG 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.
--><script>import { getContext } from'svelte';
const { xRange, yScale, width } = getContext('LayerCake');
/**
* @typedef {Object} Props
* @property {boolean} [tickMarks=false] - Show marks next to the tick label.
* @property {string} [labelPosition='even'] - Whether the label sits even with its value ('even') or sits on top ('above') the tick mark. Default is 'even'.
* @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] - The length of the tick mark. If not set, becomes the length of the widest tick.
* @property {(d: any) => string} [format=d => d] - A function that passes the current tick value and expects a nicely formatted value in return.
* @property {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.
* @property {number} [tickGutter=0] - The amount of whitespace between the start of the tick and the chart drawing area (the xRange min).
* @property {number} [dx=0] - Any optional value passed to the `dx` attribute on the text label.
* @property {number} [dy=0] - Any optional value passed to the `dy` attribute on the text label.
* @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 */functioncalcStringLength(sum, val) {
if (val === ',' || val === '.') return sum + charPixelWidth * 0.5;
return sum + charPixelWidth;
}
let isBandwidth = $derived(typeof $yScale.bandwidth === 'function');
/** @type {Array<any>} */let tickVals = $derived(
Array.isArray(ticks)
? ticks
: isBandwidth
? $yScale.domain()
: typeof ticks === 'function'
? ticks($yScale.ticks())
: $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 y = $derived(isBandwidth ? $yScale.bandwidth() / 2 : 0);
let maxTickValPx = $derived(Math.max(...tickVals.map($yScale)));
</script><gclass="axis y-axis">{#each tickVals astick (tick)}{@const tickValPx = $yScale(tick)}<gclass="tick tick-{tick}"transform="translate({$xRange[0]}, {tickValPx})">{#if gridlines === true}<lineclass="gridline"{x1}x2={$width}y1={y}y2={y}></line>{/if}{#if tickMarks === true}<lineclass="tick-mark"{x1}x2={x1 + tickLen}y1={y}y2={y}></line>{/if}<textx={x1}{y}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;
}
.tickline {
stroke: #aaa;
}
.tick.gridline {
stroke-dasharray: 2;
}
.ticktext {
fill: #666;
}
.tick.tick-0line {
stroke-dasharray: 0;
}
</style>
<!--
@component
Generates HTML text labels for a nested data structure. It places the label near the y-value of the highest x-valued data point. This is useful for labeling the final point in a multi-series line chart, for example. It expects your data to be an array of objects where each has `values` field that is an array of data objects. It uses the `z` field accessor to pull the text label.
--><script>import { getContext } from'svelte';
import { max } from'd3-array';
const { data, x, y, xScale, yScale, xRange, yRange, z } = getContext('LayerCake');
/* --------------------------------------------
* Title case the first letter
*/constcap = val => val.replace(/^\w/, d => d.toUpperCase());
/* --------------------------------------------
* Put the label on the highest value
*/let left = $derived(values => $xScale(max(values, $x)) / Math.max(...$xRange));
let top = $derived(values => $yScale(max(values, $y)) / Math.max(...$yRange));
</script>{#each $data as group}<divclass="label"style="
top:{top(group.values) * 100}%;
left:{left(group.values) * 100}%;
"
>{cap($z(group))}</div>{/each}<style>.label {
position: absolute;
transform: translate(-100%, -100%) translateY(1px);
font-size: 13px;
}
</style>
<!--
@component
Generates a tooltip that works on multiseries datasets, like multiline charts. It creates a tooltip showing the name of the series and the current value. It finds the nearest data point using the [QuadTree.html.svelte](https://layercake.graphics/components/QuadTree.html.svelte) component.
--><script>import { getContext } from'svelte';
import { format } from'd3-format';
importQuadTreefrom'./QuadTree.html.svelte';
const { data, width, yScale, config } = getContext('LayerCake');
const commas = format(',');
consttitleCase = d => d.replace(/^\w/, w => w.toUpperCase());
/**
* @typedef {Object} Props
* @property {Function} [formatTitle=d => d] - A function to format the tooltip title, which is `$config.x`.
* @property {Function} [formatValue=d => (isNaN(+d) ? d : commas(d))] - A function to format the value.
* @property {Function} [formatKey=d => titleCase(d)] - A function to format the series name.
* @property {number} [offset=-20] - A y-offset from the hover point, in pixels.
* @property {Array<Object>|undefined} [dataset] - The dataset to work off of—defaults to $data if left unset. You can pass something custom in here in case you don't want to use the main data or it's in a strange format.
*//** @type {Props} */let {
formatTitle = d => d,
formatValue = d => (isNaN(+d) ? d : commas(d)),
formatKey = d =>titleCase(d),
offset = -20,
dataset
} = $props();
const w = 150;
const w2 = w / 2;
/* --------------------------------------------
* Sort the keys by the highest value
*/functionsortResult(result) {
if (Object.keys(result).length === 0) return [];
const rows = Object.keys(result)
.filter(d => d !== $config.x)
.map(key => {
return {
key,
value: result[key]
};
})
.sort((a, b) => b.value - a.value);
return rows;
}
</script><QuadTreedataset={dataset || $data}y="x">{#snippet children({ x, y, visible, found, e })}{@const foundSorted = sortResult(found)}{#if visible === true}<divstyle="left:{x}px;"class="line"></div><divclass="tooltip"style="
width:{w}px;
display: {visible ? 'block' : 'none'};
top:{$yScale(foundSorted[0].value) + offset}px;
left:{Math.min(Math.max(w2, x), $width - w2)}px;"
><divclass="title">{formatTitle(found[$config.x])}</div>{#each foundSorted as row}<divclass="row"><spanclass="key">{formatKey(row.key)}:</span>{formatValue(row.value)}</div>{/each}</div>{/if}{/snippet}</QuadTree><style>.tooltip {
position: absolute;
font-size: 13px;
pointer-events: none;
border: 1px solid #ccc;
background: rgba(255, 255, 255, 0.85);
transform: translate(-50%, -100%);
padding: 5px;
z-index: 15;
pointer-events: none;
}
.line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
border-left: 1px dotted #666;
pointer-events: none;
}
.tooltip,
.line {
transition:
left 250ms ease-out,
top 250ms ease-out;
}
.title {
font-weight: bold;
}
.key {
color: #999;
}
</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 snippet 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 = $state(false);
let found = $state({});
let e = $state({});
/**
* @typedef {Object} Props
* @property {string} [x='x'] - The dimension to search across when moving the mouse left and right.
* @property {string} [y='y'] - The dimension to search across when moving the mouse up and down.
* @property {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.
* @property {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.
* @property {import('svelte').Snippet<[any]>} [children]
*//** @type {Props} */let { x = 'x', y = 'y', searchRadius, dataset, children } = $props();
let xGetter = $derived(x === 'x' ? $xGet : $yGet);
let yGetter = $derived(y === 'y' ? $yGet : $xGet);
/** @param {MouseEvent} evt */functionfindItem(evt) {
e = evt;
const xLayerKey = /** @type {'layerX'|'layerY'} */ (`layer${x.toUpperCase()}`);
const yLayerKey = /** @type {'layerX'|'layerY'}*/ (`layer${y.toUpperCase()}`);
found = finder.find(evt[xLayerKey], evt[yLayerKey], searchRadius) || {};
visible = Object.keys(found).length > 0;
}
let finder = $derived(
quadtree()
.extent([
[-1, -1],
[$width + 1, $height + 1]
])
.x(xGetter)
.y(yGetter)
.addAll(dataset || $data)
);
</script><divclass="bg"onmousemove={findItem}onmouseout={() => (visible = false)}onblur={() => (visible = false)}role="tooltip"
></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>