A radar chart. The x accessor is a list of five keys, one per axis, so each row becomes one polygon. The x scale maps a score to a radius by setting its range to run from the centre out to half the chart height, and the AxisRadial component draws the spokes and labels.
+page.svelte
./_components/Radar.svelte
./_components/AxisRadial.svelte
./_data/radarScores.csv
<script>import { LayerCake, Svg } from'layercake';
importRadarfrom'./_components/Radar.svelte';
importAxisRadialfrom'./_components/AxisRadial.svelte';
// The CSV rows are parsed, and their numbers typed, by @rollup/plugin-dsv. See vite.config.jsimport data from'./_data/radarScores.csv';
// One axis per pitch type. Each row's five scores become one polygon.const xKey = ['fastball', 'change', 'slider', 'cutter', 'curve'];
</script><divclass="chart-container"><!-- The x scale maps a score to a radius, so its range runs from the centre out to half the chart height --><LayerCakepadding={{ top: 30, bottom: 7 }}x={xKey}xDomain={[0, 10]}xRange={({ height }) => [0, height / 2]}{data}
><Svg><AxisRadial /><Radar /></Svg></LayerCake></div><style>/* Give the wrapper a width and height. LayerCake fills it. */.chart-container {
width: 100%;
height: 250px;
}
</style>
<!--
@component
Generates an SVG radar chart. Each row becomes one polygon. The x accessor must be a list of keys, one per axis, and the x scale maps each value to a radius.
--><script>import { line, curveCardinalClosed } from'd3-shape';
import { getLayerCakeContext } from'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {string} [fill='#f0c'] - The polygon's fill color.
* @property {string} [stroke='#f0c'] - The polygon's stroke color.
* @property {number} [strokeWidth=2] - The polygon's stroke width in pixels.
* @property {number} [fillOpacity=0.5] - The polygon's fill opacity.
* @property {number} [r=4.5] - Each circle's radius in pixels.
* @property {string} [circleFill='#f0c'] - Each circle's fill color.
* @property {string} [circleStroke='#fff'] - Each circle's stroke color.
* @property {number} [circleStrokeWidth=1] - Each circle's stroke width in pixels.
*//** @type {Props} */let {
fill = '#f0c',
stroke = '#f0c',
strokeWidth = 2,
fillOpacity = 0.5,
r = 4.5,
circleFill = '#f0c',
circleStroke = '#fff',
circleStrokeWidth = 1
} = $props();
let angleSlice = $derived((Math.PI * 2) / k.config.x.length);
// Each value is a radius. Axis i sits at angle i around the circle, starting from the top.let path = $derived(
line(
(/** @type {number} */ d, i) => d * Math.cos(angleSlice * i - Math.PI / 2),
(/** @type {number} */ d, i) => d * Math.sin(angleSlice * i - Math.PI / 2)
).curve(curveCardinalClosed)
);
</script><gtransform="translate({k.width / 2}, {k.height / 2})">{#each k.dataas row}{@const xVals = k.xGet(row)}<!-- Draw a line connecting all the dots --><pathclass="path-line"d={path(xVals)}{stroke}stroke-width={strokeWidth}{fill}fill-opacity={fillOpacity}
></path><!-- One dot per value -->{#each xVals as circleR, i}{@const thisAngleSlice = angleSlice * i - Math.PI / 2}<circlecx={circleR * Math.cos(thisAngleSlice)}cy={circleR * Math.sin(thisAngleSlice)}{r}fill={circleFill}stroke={circleStroke}stroke-width={circleStrokeWidth}
></circle>{/each}{/each}</g><style>.path-line {
stroke-linejoin: round;
stroke-linecap: round;
}
</style>
<!--
@component
Generates SVG axis lines and labels around a circle, for radar charts. The keys of the x accessor become the labels.
--><script>import { getLayerCakeContext } from'layercake';
const k = getLayerCakeContext();
/**
* @typedef {Object} Props
* @property {number} [lineLengthFactor=1.1] - How far to extend the lines from the circle's center. A value of `1` puts them at the circle's circumference.
* @property {number} [labelPlacementFactor=1.25] - How far to place the labels from the circle's center. A value of `1` puts them at the circle's circumference.
*//** @type {Props} */let { lineLengthFactor = 1.1, labelPlacementFactor = 1.25 } = $props();
// The largest x value, scaled, is the outer circle's radiuslet outerRadius = $derived(k.xScale(Math.max(...k.extents.x)));
let lineLength = $derived(outerRadius * lineLengthFactor);
let labelPlacement = $derived(outerRadius * labelPlacementFactor);
let angleSlice = $derived((Math.PI * 2) / k.config.x.length);
/** @param {number} total
* @param {number} i */functionanchor(total, i) {
if (i === 0 || i === total / 2) {
return'middle';
} elseif (i < total / 2) {
return'start';
}
return'end';
}
</script><gtransform="translate({k.width / 2}, {k.height / 2})"><circlecx="0"cy="0"r={outerRadius}stroke="#ccc"stroke-width="1"fill="#CDCDCD"fill-opacity="0.1"
></circle><circlecx="0"cy="0"r={outerRadius / 2}stroke="#ccc"stroke-width="1"fill="none"></circle>{#each k.config.xas label, i}{@const thisAngleSlice = angleSlice * i - Math.PI / 2}<linex1="0"y1="0"x2={lineLength * Math.cos(thisAngleSlice)}y2={lineLength * Math.sin(thisAngleSlice)}stroke="#ccc"stroke-width="1"fill="none"
></line><texttext-anchor={anchor(k.config.x.length, i)}dy="0.35em"font-size="12px"transform="translate({labelPlacement * Math.cos(thisAngleSlice)}, {labelPlacement *
Math.sin(thisAngleSlice)})">{label}</text
>
{/each}</g>