You could create this kind of plot by reshaping your data into rows of containing name, value and group and plot it like a scatter plot, but here's another way that doesn't require all that data manipulation and takes advantage of Layer Cake's accessor shorthand. In main.js, we simply specify the names of the keys in each row that we want to plot and in the layer component, we call $xGet(row) to map each object into an array of pixel values.
+page.svelte
./_components/ClevelandDotPlot.svelte
./_components/AxisX.svelte
./_components/AxisY.svelte
./_data/fruitOrdinal.csv
<script>import { LayerCake, Svg } from'layercake';
import { scaleBand, scaleOrdinal } from'd3-scale';
importClevelandDotPlotfrom'./_components/ClevelandDotPlot.svelte';
importAxisXfrom'./_components/AxisX.svelte';
importAxisYfrom'./_components/AxisY.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/fruitOrdinal.csv';
const yKey = 'year';
const xKey = Object.keys(data[0]).filter(d => d !== yKey);
const seriesColors = ['#f0c', '#00bbff', '#00e047', '#ff7a33'];
</script><divclass="chart-container"><LayerCakepadding={{ right: 10, bottom: 20, left: 30 }}x={xKey}y={yKey}yScale={scaleBand().paddingInner(0.05).round(true)}yDomainSort={true}xDomain={[0, null]}xPadding={[10, 0]}zScale={scaleOrdinal()}zDomain={xKey}zRange={seriesColors}{data}
><Svg><AxisX /><AxisYgridlines={false} /><ClevelandDotPlot /></Svg></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 Cleveland dot plot, also known as a lollipop-chart.
--><script>import { getContext } from'svelte';
const { data, xGet, yGet, yScale, zScale, config } = getContext('LayerCake');
/**
* @typedef {Object} Props
* @property {number} [r=5] - The circle radius.
*//** @type {Props} */let { r = 5 } = $props();
let midHeight = $derived($yScale.bandwidth() / 2);
</script><gclass="dot-plot">{#each $data as row}{@const yVal = $yGet(row)}{@const xVals = $xGet(row)}<gclass="dot-row"><linex1={Math.min(...xVals)}y1={yVal + midHeight}x2={Math.max(...xVals)}y2={yVal + midHeight}
></line>{#each xVals as circleX, i}<circlecx={circleX}cy={yVal + midHeight}{r}fill={$zScale($config.x[i])}></circle>{/each}</g>{/each}</g><style>line {
stroke-width: 1px;
stroke: #000;
}
circle {
stroke: #000;
stroke-width: 1px;
}
</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>