...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 cKey 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';
importGroupLabelsfrom'./_components/GroupLabels.html.svelte';
importSharedTooltipfrom'./_components/SharedTooltip.html.svelte';
// The CSV rows are parsed, and their numbers typed, by @rollup/plugin-dsv. See vite.config.jsimport data from'./_data/fruit.csv';
// Name the x field so it can be told apart from the series fieldsconst xKey = 'month';
const yKey = 'value';
const cKey = 'fruit';
const seriesNames = Object.keys(data[0]).filter(d => d !== xKey);
const seriesColors = ['#ffe4b8', '#ffb3c0', '#ff7ac7', '#ff00cc'];
// Turn the date strings into Date objects, on copies so the imported rows stay as they areconst parseDate = timeParse('%Y-%m-%d');
const rows = data.map(d => ({ ...d, [xKey]: parseDate(d[xKey]) }));
const formatLabelX = timeFormat('%b. %e');
const formatLabelY = format('~s');
// One tick per month in the data, oldest first so the snapped labels sit at the right endsconst xTicks = rows.map(d => d[xKey]).sort((a, b) => a - b);
// Reshape the wide rows into one group per series, each with its own list of pointsconst groupedData = groupLonger(rows, seriesNames, {
groupTo: cKey,
valueTo: yKey
});
</script><divclass="chart-container"><LayerCakepadding={{ top: 7, right: 10, bottom: 20, left: 25 }}x={xKey}y={yKey}c={cKey}yDomain={[0, null]}cScale={scaleOrdinal()}cRange={seriesColors}flatData={flatten(groupedData, 'values')}data={groupedData}
><Svg><AxisXgridlines={false}ticks={xTicks}format={formatLabelX}snapLabelstickMarks /><AxisYticks={4}format={formatLabelY} /><MultiLine /></Svg><Html><GroupLabels /><SharedTooltipformatTitle={formatLabelX}dataset={rows} /></Html></LayerCake></div><style>/* Give the wrapper a width and height. LayerCake fills 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 { line, curveLinear } from'd3-shape';
import { getLayerCakeContext } from'layercake';
const k = getLayerCakeContext();
/** @typedef {import('d3-shape').CurveFactory} CurveFactory *//**
* @typedef {Object} Props
* @property {CurveFactory} [curve=curveLinear] - A D3 curve factory such as `curveCardinal`, passed uncalled. See [d3-shape](https://github.com/d3/d3-shape#curves) for the options.
*//** @type {Props} */let { curve = curveLinear } = $props();
let path = $derived(line().x(k.xGet).y(k.yGet).curve(curve));
</script><gclass="line-group">{#each k.dataas group}<pathclass="path-line"d={path(group.values)}stroke={k.cGet?.(group) ?? '#ccc'}></path>{/each}</g><style>.path-line {
fill: none;
stroke-linejoin: round;
stroke-linecap: round;
stroke-width: 3px;
}
</style>
<!--
@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 */functiontextAnchor(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><gclass="axis x-axis"class:snapLabels>{#if showBaseline === true}<lineclass="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 --><gclass="tick tick-{i}"transform="translate({k.xScale(tick)},{k.yRange ? Math.max(...k.yRange) : k.height})"
>{#if gridlines === true}<lineclass="gridline"x1={halfBand}x2={halfBand}y1={-k.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)}
>{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;
}
/* Push the snapped end labels 3px outward so they clear the chart edge */.axis.snapLabels.tick:last-childtext {
transform: translateX(3px);
}
.axis.snapLabels.tick.tick-0text {
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 */functioncalcStringLength(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><gclass="axis y-axis">{#each tickVals astick (tick)}{@const tickValPx = k.yScale(tick)}<!-- Fall back to the left edge if the chart has no x dimension --><gclass="tick tick-{tick}"transform="translate({k.xRange ? k.xRange[0] : 0}, {tickValPx})">{#if gridlines === true}<lineclass="gridline"{x1}x2={k.width}y1={halfBand}y2={halfBand}></line>{/if}{#if tickMarks === true}<lineclass="tick-mark"{x1}x2={x1 + tickLen}y1={halfBand}y2={halfBand}></line>{/if}<textx={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;
}
.tickline {
stroke: #aaa;
}
.tick.gridline {
stroke-dasharray: 2;
}
.ticktext {
fill: #666;
}
/* A solid line at the zero tick */.tick.tick-0line {
stroke-dasharray: 0;
}
</style>
<!--
@component
Generates one HTML text label per group of a nested dataset. Each label sits at the group's largest x and largest y value, which on a multi-series line chart is just past the end of the line. The data must be an array of groups, each with a `values` array of rows. The label text comes from the `c` accessor.
--><script>import { getLayerCakeContext } from'layercake';
import { max } from'd3-array';
const k = getLayerCakeContext();
/** @param {string} val */constcapitalizeFirst = val => val.replace(/^\w/, d => d.toUpperCase());
// The group's largest x and y values, as a share of the chart/** @param {Array<any>} values */functionleftPercent(values) {
return (k.xScale(max(values, k.x)) / Math.max(...k.xRange)) * 100;
}
/** @param {Array<any>} values */functiontopPercent(values) {
return (k.yScale(max(values, k.y)) / Math.max(...k.yRange)) * 100;
}
</script>{#each k.dataas group}<divclass="label"style="
top:{topPercent(group.values)}%;
left:{leftPercent(group.values)}%;
"
>{capitalizeFirst(k.c?.(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 { format } from'd3-format';
import { getLayerCakeContext } from'layercake';
importQuadTreefrom'./QuadTree.html.svelte';
const k = getLayerCakeContext();
const commas = format(',');
/** @param {string} d */constcapitalizeFirst = d => d.replace(/^\w/, w => w.toUpperCase());
/**
* @typedef {Object} Props
* @property {(d: any) => string} [formatTitle=d => d] - Formats the tooltip title, the hovered row's x value.
* @property {(d: any) => string} [formatValue=d => (isNaN(+d) ? d : commas(d))] - Formats a series value.
* @property {(d: any) => string} [formatKey=d => capitalizeFirst(d)] - Formats a series name.
* @property {number} [offset=-20] - A y-offset from the hover point, in pixels.
* @property {Array<Object>|undefined} [dataset] - Rows to search, defaulting to `k.data`. Pass your own list when the chart data is nested or reshaped.
*//** @type {Props} */let {
formatTitle = d => d,
formatValue = d => (isNaN(+d) ? d : commas(d)),
formatKey = d =>capitalizeFirst(d),
offset = -20,
dataset
} = $props();
const tooltipWidth = 150;
const halfTooltipWidth = tooltipWidth / 2;
// Sort the series by value, highest first, leaving out the x field/** @param {Record<string, any>} result */functionsortResult(result) {
if (Object.keys(result).length === 0) return [];
const rows = Object.keys(result)
.filter(d => d !== k.config.x)
.map(key => {
return {
key,
value: result[key]
};
})
.sort((a, b) => b.value - a.value);
return rows;
}
</script><QuadTreedataset={dataset || k.data}y="x">{#snippet children({ x, visible, found })}{@const foundSorted = sortResult(found)}{#if visible === true}<divstyle="left:{x}px;"class="line"></div><divclass="tooltip"style="
width:{tooltipWidth}px;
top:{k.yScale(foundSorted[0].value) + offset}px;
left:{Math.min(Math.max(halfTooltipWidth, x), k.width - halfTooltipWidth)}px;"
><divclass="title">{formatTitle(found[k.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;
}
.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
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 pixel position, `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.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 in pixels, 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 */functionfindItem(evt) {
e = evt;
// The mouse position in chart coordinates, swapped when a prop points at the other dimensionconst [px, py] = k.pointer(evt);
const xVal = x === 'x' ? px : py;
const yVal = y === 'y' ? py : px;
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 --><divclass="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>