A scatter plot with an scaleBand for the y-scale to bucket them by day and a scaleTime for the x-scale. The only real fancy part of this plot is modifying the input data to be in "seconds since start of day" and generating the yDomain as every day between the min and max values, not just days for which we have values. This lets us see days in between that have no data.
This is the server-side rendered version. ssr and percentRange on <LayerCake> put the scales in percentages, so the chart renders before the browser measures it. The axes are HTML components and so are the dots.
+page.svelte
./_components/Scatter.percent-range.html.svelte
./_components/AxisX.percent-range.html.svelte
./_components/AxisY.percent-range.html.svelte
./_data/days.csv
<script>import { LayerCake, Html, calcExtents } from'layercake';
import { utcDay } from'd3-time';
import { scaleBand, scaleTime } from'd3-scale';
importScatterfrom'./_components/Scatter.percent-range.html.svelte';
importAxisXfrom'./_components/AxisX.percent-range.html.svelte';
importAxisYfrom'./_components/AxisY.percent-range.html.svelte';
// The CSV rows are parsed, and their numbers typed, by @rollup/plugin-dsv. See vite.config.jsimport data from'./_data/days.csv';
const xKey = 'seconds';
const yKey = 'day';
const r = 4;
// Split each timestring into its day and its seconds since midnightconst rows = data.map(d => {
const [day, time] = String(d.timestring).split('T');
const [hours, minutes, seconds] = time.replace('Z', '').split(':').map(Number);
return { ...d, [yKey]: day, [xKey]: hours * 60 * 60 + minutes * 60 + seconds };
});
// Every day from the first to the last, so days with no rows still get a row on the chartconst [firstDay, lastDay] = calcExtents(rows, { day: d => d[yKey] }).day;
const allDays = utcDay
.range(newDate(firstDay), utcDay.offset(newDate(lastDay), 1))
.map(d => d.toISOString().split('T')[0]);
</script><divclass="chart-container"><LayerCakessrpercentRangepadding={{ top: 0, right: 15, bottom: 20, left: 75 }}x={xKey}y={yKey}xDomain={[0, 24 * 60 * 60]}yDomain={allDays}xScale={scaleTime()}yScale={scaleBand().paddingInner(0.05)}data={rows}
><Html><AxisXticks={[0, 4, 8, 12, 16, 20, 24].map(d => d * 60 * 60)}format={d =>`${Math.floor(d / 60 / 60)}:00`}
/><AxisY /><Scatter{r}fill="rgba(255, 204, 0, 0.75)"strokeWidth={0} /></Html></LayerCake></div><style>/* Give the wrapper a width and height. LayerCake fills it. */.chart-container {
width: 100%;
height: 250px;
}
</style>
<!--
@component
Generates an HTML scatter plot for `percentRange={true}` charts, which also makes it work in server-side rendered charts. If the x or y scale is a band scale, each circle sits in the middle of its band.
--><script>import { getLayerCakeContext } from'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {number} [r=5] - The circle's radius.
* @property {string} [fill='#0cf'] - The circle's fill color.
* @property {string} [stroke='#000'] - The circle's stroke color.
* @property {number} [strokeWidth=0] - The circle's stroke width in pixels.
*//** @type {Props} */let { r = 5, fill = '#0cf', stroke = '#000', strokeWidth = 0 } = $props();
</script><divclass="scatter-group">{#each k.dataas d}<divclass="circle"style="
left: {k.xGet(d) + (k.xScale.bandwidth ? k.xScale.bandwidth() / 2 : 0)}%;
top: {k.yGet(d) + (k.yScale.bandwidth ? k.yScale.bandwidth() / 2 : 0)}%;
width: {r * 2}px;
height: {r * 2}px;
background-color: {fill};
border: {strokeWidth}px solid {stroke};
"
></div>{/each}</div><style>.circle {
position: absolute;
transform: translate(-50%, -50%);
border-radius: 50%;
}
</style>
<!--
@component
Generates an HTML x-axis along the bottom of the chart, for server-side rendered charts. If the x scale is a band scale, each tick sits in the middle of its band.
Positions are percentages when `percentRange={true}` and pixels otherwise, so this also works in a client-side chart with no setup. Set the `units` prop to `'%'` or `'px'` to override that.
--><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=0] - Vertical offset of the label in pixels.
* @property {'px'|'%'} [units] - Position with pixels or percentages. Defaults to `'%'` when `percentRange={true}`, otherwise `'px'`.
*//** @type {Props} */let {
tickMarks = false,
gridlines = true,
tickMarkLength = 6,
showBaseline = false,
snapLabels = false,
format = d => d,
ticks = undefined,
tickGutter = 0,
dx = 0,
dy = 0,
units = k.percentRange === true ? '%' : 'px'
} = $props();
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><divclass="axis x-axis"class:snapLabels>{#if showBaseline === true}<divclass="baseline"style="top:100%; width:100%;"></div>{/if}{#each tickVals as tick, i (tick)}{@const tickValUnits = k.xScale(tick)}{#if gridlines === true}<divclass="gridline"style:left="{tickValUnits + halfBand}{units}"style="top:0; bottom:0;"
></div>{/if}{#if tickMarks === true}<divclass="tick-mark"style:left="{tickValUnits + halfBand}{units}"style:height="{tickLen}px"style:bottom="{-tickLen - tickGutter}px"
></div>{/if}<divclass="tick tick-{i}"style:left="{tickValUnits + halfBand}{units}"style="top:calc(100% + {tickGutter}px);"
><divclass="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%);
}
/* Snapped end labels sit 40% inside their edge instead of centered on it */.axis.snapLabels.tick:last-child {
transform: translateX(-40%);
}
.axis.snapLabels.tick.tick-0 {
transform: translateX(40%);
}
</style>
<!--
@component
Generates an HTML y-axis along the left edge of the chart, for server-side rendered charts. If the y scale is a band scale, each tick sits in the middle of its band.
Positions are percentages when `percentRange={true}` and pixels otherwise, so this also works in a client-side chart with no setup. Set the `units` prop to `'%'` or `'px'` to override that.
--><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=-3] - 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).
* @property {'px'|'%'} [units] - Position with pixels or percentages. Defaults to `'%'` when `percentRange={true}`, otherwise `'px'`.
*//** @type {Props} */let {
tickMarks = false,
labelPosition = 'even',
snapBaselineLabel = false,
gridlines = true,
tickMarkLength = undefined,
format = d => d,
ticks = 4,
tickGutter = 0,
dx = 0,
dy = -3,
charPixelWidth = 7.25,
units = k.percentRange === true ? '%' : 'px'
} = $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 maxTickValUnits = $derived(Math.max(...tickVals.map(k.yScale)));
</script><divclass="axis y-axis">{#each tickVals as tick, i (tick)}{@const tickValUnits = k.yScale(tick)}<divclass="tick tick-{i}"style="left:{k.xRange ? k.xRange[0] : 0}{units};top:{tickValUnits + halfBand}{units};"
>{#if gridlines === true}<divclass="gridline"style="top:0;"style:left="{x1}px"style:right="0px"></div>{/if}{#if tickMarks === true}<divclass="tick-mark"style:top="0"style:left="{x1}px"style:width="{tickLen}px"></div>{/if}<divclass="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,
.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;
}
.tick.text {
color: #666;
}
</style>