Svelte x D3

A collection of data visualisations built with Svelte and D3

Github repo

Difference in male and female population in the UK

1950 - 2021

More men More women
020406080100Age in years 0.00100200300400500Population, '000s 1950
Source: United Nations

Inspired by this super article from Nathan Yau.

Code

<script lang="ts">
	import Chart from '@visualisations/primatives/Chart.svelte';
	import { scaleLinear, scaleOrdinal } from 'd3-scale';
	import { extent, min, max, bisector } from 'd3-array';
	import { curveMonotoneX, line, area } from 'd3-shape';
	import AxisBottom from '@visualisations/helpers/AxisBottom.svelte';
	import AxisLeft from '@visualisations/helpers/AxisLeft.svelte';
	import ClipPath from '@visualisations/helpers/ClipPath.svelte';
	import Panel from '@visualisations/helpers/Panel.svelte';
	import Tooltip from '@visualisations/helpers/Tooltip.svelte';
	import { onMount } from 'svelte';
	import { format } from 'd3-format';
	import { createSlider, melt } from '@melt-ui/svelte';

	export let data: {
		age: number;
		country: string;
		year: number;
		female: number;
		male: number;
	}[] = [];

	export let margins = { top: 30, right: 10, bottom: 60, left: 60 };

	export let x = 'age';
	export let y0 = 'male';
	export let y1 = 'female';

	export let id = Math.random();
	export let yFormat = (d: any) => format('.3s')(d);
	export let colors = ['#F4D06F', '#C1EEFF'];

	let width = 0;
	let height = 0;

	$: dimensions = {
		width: width,
		height: height,
		margins: margins,
		innerWidth: width - margins.left - margins.right,
		innerHeight: height - margins.top - margins.bottom
	};

	// Accessors
	$: getX = (d: any) => d[x];
	$: getY0 = (d: any) => d[y0];
	$: getY1 = (d: any) => d[y1];

	$: xScale = scaleLinear(extent(data, getX), [0, dimensions.innerWidth]).clamp(true);

	$: yMin = Math.min(min(data, getY0), min(data, getY1));
	$: yMax = Math.max(max(data, getY0), max(data, getY1));

	$: yScale = scaleLinear([yMin, yMax], [dimensions.innerHeight, 0]).nice();
	$: colorScale = scaleOrdinal([y0, y1], colors);

	// Clip paths
	$: belowPath = area()
		.x((d) => xScale(getX(d)))
		.y0(() => yScale(yScale.domain()[0] || 0))
		.y1((d) => yScale(getY1(d)))
		.curve(curveMonotoneX);

	$: abovePath = area()
		.x((d) => xScale(getX(d)))
		.y0(() => yScale(yScale.domain()[1]))
		.y1((d) => yScale(getY1(d)))
		.curve(curveMonotoneX);

	$: areaGenerator = area()
		.x((d) => xScale(getX(d)))
		.y0((d) => yScale(getY0(d)))
		.y1((d) => yScale(getY1(d)))
		.curve(curveMonotoneX);

	$: pathGeneratorY0 = line()
		.x((d) => xScale(getX(d)))
		.y((d) => yScale(getY0(d)))
		.curve(curveMonotoneX);

	$: pathGeneratorY1 = line()
		.x((d) => xScale(getX(d)))
		.y((d) => yScale(getY1(d)))
		.curve(curveMonotoneX);

	// Interaction
	$: tooltip = {
		left: 0,
		top: 0,
		data: null
	};

	$: bisect = bisector(getX).left;

	$: handleMouseOver = (event: MouseEvent) => {
		const xInverted = xScale.invert(event.offsetX - margins.left);
		const index = bisect(filtered, xInverted);

		const datum = filtered[index];

		const highlighted = filtered.find((d) => getX(datum) - getX(d) === 0);
		const yMax = Math.max(getY0(highlighted), getY1(highlighted));

		tooltip.data = highlighted;
		tooltip.left = xScale(getX(datum));
		tooltip.top = yScale(yMax);
	};

	$: handleMouseLeave = () => (tooltip.data = null);

	//_ Managing transitions
	const {
		elements: { root, range, thumbs },
		states: { value }
	} = createSlider({
		defaultValue: [1950],
		min: 1950,
		max: 2021,
		step: 1
	});

	$: filtered = data.filter((pop) => pop.year === $value[0]);
	let paused = false;

	onMount(() => {
		const interval = setInterval(() => {
			if (paused) return;
			value.update((val) => [val[0] + 1]);

			if ($value[0] === 2021) {
				value.set([1950]);
			}
		}, 100);

		return () => clearInterval(interval);
	});
</script>

<div>
	<div class="w-full flex gap-2">
		<span style="color: {colorScale(y0)}">More men</span>
		<span style="color: {colorScale(y1)}">More women</span>
	</div>
	<div class="flex gap-2 items-center">
		<button
			class="bg-midnight-75 px-2 py-1 rounded shadow border border-midnight-25 text-sm shadow"
			on:click={() => (paused = !paused)}
			aria-label={paused ? 'Play' : 'Pause'}
		>
			{#if paused}
				<svg
					xmlns="http://www.w3.org/2000/svg"
					width="16"
					height="16"
					fill="currentColor"
					class="bi bi-play"
					viewBox="0 0 16 16"
				>
					<path
						d="M10.804 8 5 4.633v6.734zm.792-.696a.802.802 0 0 1 0 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696z"
					/>
				</svg>
			{:else}
				<svg
					xmlns="http://www.w3.org/2000/svg"
					width="16"
					height="16"
					fill="currentColor"
					class="bi bi-pause"
					viewBox="0 0 16 16"
				>
					<path
						d="M6 3.5a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V4a.5.5 0 0 1 .5-.5m4 0a.5.5 0 0 1 .5.5v8a.5.5 0 0 1-1 0V4a.5.5 0 0 1 .5-.5"
					/>
				</svg>
			{/if}
		</button>
		<span use:melt={$root} class="relative flex h-[20px] w-full items-center">
			<span class="h-[3px] w-full bg-midnight-25">
				<span use:melt={$range} class="h-[3px] bg-white" />
			</span>
			<span
				use:melt={$thumbs[0]}
				class="h-5 w-5 rounded-full bg-white border border-midnight focus:ring-4 focus:!ring-black/40"
			/>
		</span>
	</div>
	<div class="w-full h-graph flex flex-col relative">
		<div class="w-full h-full" bind:clientHeight={height} bind:clientWidth={width}>
			<Chart {dimensions}>
				<g>
					<ClipPath id="threshold-clip-below-{id}">
						<path d={belowPath(filtered)} />
					</ClipPath>
					<ClipPath id="threshold-clip-above-{id}">
						<path d={abovePath(filtered)} />
					</ClipPath>
				</g>
				<Panel {xScale} {yScale} height={dimensions.innerHeight} width={dimensions.innerWidth} />
				<path
					d={areaGenerator(filtered)}
					clip-path={`url(#threshold-clip-below-${id})`}
					fill={colorScale(y1)}
					stroke-width={0}
					opacity={0.5}
				/>
				<path
					d={areaGenerator(filtered)}
					clip-path={`url(#threshold-clip-above-${id})`}
					fill={colorScale(y0)}
					stroke-width={0}
					opacity={0.5}
				/>
				<path d={pathGeneratorY1(filtered)} stroke={colorScale(y1)} fill="none" stroke-width={2} />
				<path d={pathGeneratorY0(filtered)} stroke={colorScale(y0)} fill="none" stroke-width={2} />
				<AxisBottom scale={xScale} label="Age in years" />
				<AxisLeft
					scale={yScale}
					hideAxisLine
					hideTicks
					formatTick={yFormat}
					label="Population, '000s"
				/>
				<text
					text-anchor="end"
					alignment-baseline="middle"
					font-size="28px"
					fill="var(--colors-text)"
					stroke="var(--colors-text)"
					paint-order="stroke"
					x={dimensions.innerWidth}
					y={margins.top}>{$value[0]}</text
				>
				{#if tooltip.data}
					<g>
						<line
							x1={tooltip.left}
							x2={tooltip.left}
							y1={yScale(getY0(tooltip.data))}
							y2={yScale(getY1(tooltip.data))}
							stroke="var(--colors-grid)"
						/>
						<circle
							cx={xScale(getX(tooltip.data))}
							cy={yScale(getY0(tooltip.data))}
							r="6"
							stroke="var(--colors-grid)"
							fill={colorScale(y0)}
						/>
						<circle
							cx={xScale(getX(tooltip.data))}
							cy={yScale(getY1(tooltip.data))}
							r="6"
							stroke="var(--colors-grid)"
							fill={colorScale(y1)}
						/>
					</g>
				{/if}
				<!-- svelte-ignore a11y-no-static-element-interactions -->
				<!-- svelte-ignore a11y-mouse-events-have-key-events -->
				<rect
					width={dimensions.innerWidth}
					height={dimensions.innerHeight}
					on:mousemove={handleMouseOver}
					on:mouseleave={handleMouseLeave}
					fill="transparent"
				/>
			</Chart>
			{#if tooltip.data}
				<Tooltip left={tooltip.left} top={tooltip.top}>
					<span class="font-bold">{getX(tooltip.data)}</span>
					<div class="divide-y px-1">
						<div class="flex gap-1 w-full items-center">
							<div
								class="w-[12px] h-[12px] rounded-full"
								style={`background-color: ${colorScale(y1)};`}
							/>
							<span>{yFormat(getY1(tooltip.data))}</span>
						</div>
						<div class="flex gap-1 w-full items-center">
							<div
								class="w-[12px] h-[12px] rounded-full"
								style={`background-color: ${colorScale(y0)};`}
							/>
							<span>{yFormat(getY0(tooltip.data))}</span>
						</div>
					</div>
				</Tooltip>
			{/if}
		</div>
	</div>
</div>