Building an interactive line chart using Svelte and D3

stefano agresti
14 min readJan 30, 2022

--

Merging Svelte and D3 to create captivating and easy to implement visualizations

I recently came across a few projects realized using Svelte, a new web framework that’s been gaining a lot of traction among developers lately. Once I started using it, I could see why. Not only is it more performant than React, but it maintains a similar syntax, making it easy to perform the switch, and a actually simplifying the work a lot of times. So, having a bit of free time this week, I decided to play a bit with it, building a cool line chart, enriched with animations, tooltips and customizable graphics. And today I will be sharing the results with you!

Here’s what we’re going to build

If you want to check the code by yourself, here’s the GitHub repository with all the files, while here you can find a live preview of the chart.

Setting up a Svelte application

First things first, we need to set up a Svelte application locally. I won’t go too much into detail on this part, but you can check out @getting-started-with-svelte if you want more information.

Go into the folder where you want to create your project and run on your terminal these commands (you can replace the name “line-chart” with the name you prefer):

npx degit sveltejs/template line-chart
cd line-chart
npm install

Since we will be working with data, we will need D3, one of the most popular libraries in the world of data viz. To install it, just run:

npm install d3

Once that’s done, you can start your application using:

npm run dev

And you’re ready to go! If you go to localhost:5000 on your browser you’ll see something like this:

The initial image of a Svelte project

Setting up our work environment

Now that our Svelte project is up and running, we can start the actual development. Let’s go to the project folder and let’s take a look at the files that were created. If you go into the folder src, you’ll see that there’s a file named App.svelte. This is the entry point of your project and it will probably look something like this:

<script>
export let name;
</script>
<main>
<h1>Hello {name}!</h1>
<p>Visit the <a href="https://svelte.dev/tutorial">Svelte tutorial</a> to learn how to build Svelte apps.</p>
</main>
<style>
...
</style>

Now, before changing it, create in src a subfolder called components and, inside of this new folder, create two files named ChartContainer.svelte and LineChart.svelte. The latter file will be the one containing the code for the actual line chart, while the former is a container that we will use to place the chart inside of the page. It’s good practice to separate these two files, so that you’re more flexible to reuse them in different projects and to avoid repeating the same CSS and logic in case you want to build different charts.

Once you create the files, import ChartContainer into the App.svelte file, by replacing its content with this:

<script>
import { ChartContainer} from "./components/ChartContainer.svelte"
</script>
<main>
<ChartContainer />
</main>

If you go back to the browser, you’ll see now a blank page, since the ChartContainer file is indeed still empty. So let’s move to this file and write the following:

<script>
import LineChart from "./LineChart.svelte";
export let type;
export let chartProps;
const CHART_TYPES = {
lineChart: LineChart,
};
</script>
<section class="container">
<svelte:component this={CHART_TYPES[type]} {...chartProps} /></section>
<style>
.container {
max-width: 640px;
border: solid 1px black;
margin: auto;
}
</style>

On the browser you’ll see something like this:

For the moment, you only see a line

Before moving on, let’s break down what we did here.

We imported the LineChart component (which is still empty right now). If we had different types of charts, we would’ve imported those as well. We then created two props, one (type) to select which chart to show and another for the props to be passed to the actual chart component. We then created an object CHART_TYPES to associate a chart name to the correct component.

In the line <svelte:component this={CHART_TYPES[type]} {...chartProps} /> we are telling Svelte to call the component associated to variable type, passing to it the variable chartProps. Finally, in the CSS part, we place the container in the center of the page and surround it with a border.

The utility of having this extra step before building the actual chart is that this same code can be reused no matter how many types of charts we will be creating, just by adding two simple lines:

<script>
...
import NewChart from "./NewChart.svelte"
...
const CHART_TYPES = {
...
newChart: NewChart,
...
}

This is particularly useful if we need to make the charts responsive, if we have a particular repeated style, or in any circumstance where we would be unnecessarily repeating code across many components.

The last thing to do before moving to the next paragraph is to update the App.svelte file by changing the call to the ChartContainer component in this way:

<ChartContainer type={“lineChart”} chartProps={{}} />

Retrieving the data

For this tutorial, I will be using a dataset provided by my city, Milan, about the number and types of weddings celebrated in the city from 1946 until 2020. The original dataset is available on dati.comune.milano.it, but I modified it a little to make it easier to work with. This version is available on my GitHub at this link.

Once you downloaded this file, go in the src folder, create a new subfolder named data and place the file inside. In the App.svelte, add the following line:

import { weddings } from “./data/weddings”;

Now, with everything set up, we are finally ready to build our line chart!

Building the Line Chart

1. Creating the skeleton

The first thing to do is build the skeleton of the chart, which is its SVG container and its two axes. We do this by writing the following in the LineChart.svelte file:

<script>
export let chartWidth;
export let chartHeight;
const paddings = {
top: 50,
left: 50,
right: 50,
bottom: 50,
};
</script>
<svg width={chartWidth} height={chartHeight}>
<g>
<line
x1={paddings.left}
x2={chartWidth - paddings.right}
y1={chartHeight - paddings.bottom}
y2={chartHeight - paddings.bottom}
stroke="black"
stroke-width="2"
/>
<line
x1={paddings.left}
x2={paddings.left}
y1={paddings.top}
y2={chartHeight - paddings.bottom}
stroke="black"
stroke-width="2"
/>
</g>
</svg>

What we’re doing here is defining a SVG container, whose width and height will be defined by two props, and adding two black lines inside of it. The paddings object will be used for some fine-tuning in order to give some space to the borders of the chart. In the App.svelte, we add the new props by changing the component call to:

<ChartContainer type={“lineChart”} chartProps={{ chartWidth: 640, chartHeight: 500 }} />

Here’s what your browser should look like:

The skeleton of the charts with the two axes

2. Drawing the lines

Now we start dealing with serious stuff. To draw the lines of the chart we’ll need to pass the data to the component, specify which values we want to use as x and y axis, use D3 to remap the values on the chart and finally draw the actual lines using the information above.

Let’s start first with the script part:

<script>
import { scaleLinear } from "d3-scale";
...
export let data;
export let xVar;
export let yVars;
const xScale = scaleLinear()
.domain([Math.min(...data.map((d) => d[xVar])), Math.max(...data.map((d) => d[xVar])),])
.range([paddings.left, chartWidth - paddings.right]);
const yScale = scaleLinear()
.domain([Math.min(...data.map((d) => Math.min(...yVars.map((yVar) => d[yVar])))), Math.max(...data.map((d) => Math.max(...yVars.map((yVar) => d[yVar])))),])
.range([chartHeight - paddings.bottom, paddings.top])
.nice(10);
</script>

In the second line we’re importing scaleLinear, a D3 function to map linearly a domain to a range. We then add the three new props for handling the data and we create two functions, xScale and yScale, to map values on the x and y axis. To be noted that we’re planning to have a line for each variable on the y axis, so yVars is actually an array of strings. This implies that, when we’re computing the domain for the y, we need to look for the minimum and maximum values across all of the y variables — that’s why the formulas for x and y domain look different.

The nice(10) will be useful later to draw the ticks on the y axis (it basically rounds up the maximum value of the domain to split it into 10 slices in a nice way).

We then continue by writing in the HTML:

<g>
{#each data as datum, i}
{#each yVars as yVar}
{#if i != data.length - 1}
<line
x1={xScale(data[i][xVar])}
x2={xScale(data[i + 1][xVar])}
y1={yScale(data[i][yVar])}
y2={yScale(data[i + 1][yVar])}
stroke="black"
stroke-width="2"
/>
{/if}
{/each}
{/each}
</g>

Here, we’re basically telling Svelte to loop on data and, for each entry, build a line that goes from the coordinates associated to that entry to the coordinates associated to the next entry. We also loop through the different y variables in order draw a line for each of them.

Finally, we update the usual call on App.svelte:

<ChartContainer type={“lineChart”} chartProps={{ chartWidth: 640, chartHeight: 500, data: weddings, xVar: “Year”, yVars: [“Civil”, “Religious”]}} />

If everything went right, your chart should look now like the image below.

Still very raw, but at least looks like a chart

3. Coloring the chart

Now that we have the lines, we need to color them in order to distinguish among the different data. There are different ways to approach this problem, but I’ll keep it simple. We will define an array of colors and remap the yVars prop on this array using another D3 function, while creating another prop to be used in case in the future we would like to customize our coloring function. Here’s the code:

<script>
import { scaleLinear, scaleOrdinal } from "d3-scale"
...
export let colorFunction;const colorScale = colorFunction === undefined
? scaleOrdinal().domain(yVars)
.range(["#e41a1c","#377eb8","#4daf4a","#984ea3",
"#ff7f00","#ffff33","#a65628","#f781bf","#999999",])
: colorFunction;
</script>
...
{#each data as datum,i}
{#each yVars as yVar}
{#if i !== data.length - 1}
<line
...
stroke={colorScale(yVar)}
/>
{/if}
{/each}
{/each}
...

And here’s the result:

Now it’s starting to look better

Perfect! Now the chart is colorful and, if we change our minds in the future, we can easily customize its colors by using the colorFunction prop.

4. Complete the axes

This chart is still pretty hard to read for the user, though. We can make it better by adding some ticks and labels on the axes.

To do this, we create a new component in the components subfolder called Tick.svelte and place the following code inside:

<script>
export let x;
export let y;
export let value;
export let direction;
export let format = true;
export let formatFunction;
const xTranslation = direction === "horizontal" ? x - 10 : x;function nFormatter(num, digits) {
const lookup = [
{ value: 1, symbol: "" },
{ value: 1e3, symbol: "k" },
{ value: 1e6, symbol: "M" },
{ value: 1e9, symbol: "G" },
{ value: 1e12, symbol: "T" },
{ value: 1e15, symbol: "P" },
{ value: 1e18, symbol: "E" },
];
const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;

var item = lookup
.slice()
.reverse()
.find(function (item) {
return num >= item.value;
});

return item
? (num / item.value)
.toFixed(digits).replace(rx, "$1") + item.symbol
: "0";
}
const valueLabel = formatFunction !== undefined
? formatFunction(value)
: format
? nFormatter(value, 1)
: value;
</script><g transform={"translate(" + xTranslation + ", " + y + ")"}>
<text
y={direction === "horizontal" ? 0 : 20}
font-size="13px"
text-anchor={direction === "horizontal" ? "end" : "middle"}
alignment-baseline="middle">
{valueLabel}
</text>
{#if direction === "horizontal"}
<line
x1={2}
x2={8}
y1={0}
y2={0}
stroke="black"
stroke-width="1" />
{:else}
<line
x1={0}
x2={0}
y1={2}
y2={8}
stroke="black"
stroke-width="1" />
{/if}
</g>

I won’t enter too much into detail here, but basically what we’re doing is creating a tick, composed of a label and a small line, that’s placed in the SVG depending on the props x and y. The label can be formatted using the default nFormatter function, which shortens numeric values using labels such as k,M,G… (ex. 100.000 -> 100k).

In LineChart.svelte:

<script>
import Tick from "./Tick.svelte"
...
const yGrid = yScale.ticks(10)
const xGrid = xScale.ticks(10)
</script>
<svg>
...
<g>
{#each yGrid.slice(1) as gridLine}
<Tick
x={paddings.left}
y={yScale(gridLine)}
value={gridLine}
direction={"horizontal"}
/>
{/each}
</g>
<g>
{#each xGrid as gridLine}
<Tick
x={xScale(gridLine)}
y={chartHeight - paddings.bottom}
value={gridLine}
direction={"vertical"}
format={false}
/>
{/each}
</g>

We imported the new component, computed the grid values using xScale.ticks(10) and yScale.ticks(10) and then drawn them in the SVG.

Looking at the browser, we have this:

Much better!

Arrived at this point, we already have a pretty good component that can be used in basic dashboards or in some websites. However, there are still a lot of ways in which we can improve it.

5. Let’s make it responsive!

Try to use the developer mode on your browser and decrease the window to 300px, the average smartphone size and see what happens.

Not good, eh?

What’s happening is that the SVG is always 640px, even if the screen is smaller than that, causing the chart to overflow. To solve the issue, let’s use the ChartContainer component we created in the beginning and modify it as follows:

<script>
import LineChart from "./LineChart.svelte";
import { onMount } from "svelte";
export let type;
export let chartProps;
const CHART_TYPES = {
lineChart: LineChart,
};
let viewport = "mobile";function setViewportMq(_) {
viewport =
window.innerWidth >= 768
? "desktop"
: window.innerWidth >= 480
? "tablet"
: "mobile";
}
onMount(() => {
if (typeof window !== "undefined") {
window.addEventListener("resize", setViewportMq);
setViewportMq();
}
});
</script>
<section class="container">
{#if viewport === "desktop"}
<svelte:component
this={CHART_TYPES[type]}
{...chartProps}
chartWidth={640}
chartHeight={500}
/>
{:else if viewport === "tablet"}
<svelte:component
this={CHART_TYPES[type]}
{...chartProps}
chartWidth={480}
chartHeight={375}
/>
{:else}
<svelte:component
this={CHART_TYPES[type]}
{...chartProps}
chartWidth={window.innerWidth * 0.9}
chartHeight={window.innerWidth * 0.9 * 0.78}
/>{/if}
</section>
<style>
.container {
max-width: 640px;
border: solid 1px black;
margin: auto;
}
</style>

Now, if you try again to decrease the window size, the chart will adapt its size to the screen.

Ops, those ticks are ugly!

Yet, there’s still an issue with the ticks. If the chart is too small they end up overlapping. Luckily this is not hard to solve. In LineChart.svelte we add the following line:

const tickNumber = chartWidth > 480 ? 10 : 5;

And replace the 10 we used earlier in the grid definition with the new constant.

Much better!

Great! Now it’s truly a responsive chart!

6. Adding a tooltip

A great way to explore a chart is by using a tooltip. Luckily, using Svelte, this is not too hard.

Let’s add these lines of code to LineChart.svelte:

const idContainer = 'svg-container-' + Math.random() * 1000000let mousePosition = { x: null, y: null }function followMouse(event) {
const svg = document.getElementById(idContainer)
if (svg === null) return
const dim = svg.getBoundingClientRect()
const positionInSVG =
{ x: event.clientX - dim.left, y: event.clientY - dim.top }
mousePosition =
positionInSVG.x > paddings.left &&
positionInSVG.x < chartWidth - paddings.right &&
positionInSVG.y > paddings.top &&
positionInSVG.y < chartHeight - paddings.bottom
? { x: positionInSVG.x, y: positionInSVG.y }
: { x: null, y: null }
}
function removePointer() {
mousePosition = { x: null, y: null }
}
function computeSelectedXValue(value) {
return data.filter((d) => xScale(d[xVar]) >= value)[0][xVar]
}
</script>
<svg
width={chartWidth}
height={chartHeight}
on:mousemove={followMouse}
on:mouseleave={removePointer}
id={idContainer}>
...

In these lines we created a series of functions to follow the mouse and generated a random id for the SVG to check whether the mouse is pointing inside or outside of it. The function computeSelectedXValue will be used to show the correct values in the tooltip.

Now, to give some feedback to the user, we will show a line in the position where the mouse is pointing and we will highlight the selected points with a circle. The code to that is the following:

<svg>
...
{#if mousePosition.x !== null}
<g
transform=
"translate({xScale(computeSelectedXValue(mousePosition.x))} 0)"
>
<line
x1="0"
x2="0"
y1={paddings.top}
y2={chartHeight - paddings.bottom - 2}
stroke="black"
stroke-width="1"
/>
{#each yVars as yVar}
<circle
cx={0}
cy={yScale(
data.find(
(d) => d[xVar] === computeSelectedXValue(mousePosition.x)
)[yVar]
)}
r="3"
fill={colorScale(yVar)}
/>
{/each}
</g>
{/if}

Et voilà! If you try to go with the mouse on the chart it will show a line and highlight the points where it intersects the data lines we drew previously.

For the tooltip we create yet another component called Tooltip.svelte and place the following code:

<script>
import { onMount } from "svelte";
export let x;
export let y;
export let labels;
export let values;
export let colorScale;
export let width = 150;
export let backgroundColor = "white";
export let textColor = "black";
export let opacity = 1;
export let title;
export let adaptTexts = true;
const step = 25;
const paddingLeft = 15;
const paddingRight = 15;
const lineLength = 10;
const spaceBetweenLineText = 3;
const idContainer = "svg-legend-" + Math.random() * 10000;
const maxTextLength = width - paddingLeft - lineLength - spaceBetweenLineText - paddingRight;
let computedWidth = width;
onMount(async () => {
const texts = document
.getElementById(idContainer)
.getElementsByClassName("legend-labels");
const textWidths = [...Array(texts.length).keys()].map((d) => ({id: d,
width: texts[d].getBoundingClientRect().width,}));
const longTexts = textWidths.filter((d) => d.width > maxTextLength); if (longTexts.length === 0) return;
if (adaptTexts) {
longTexts.map((d) => {
const textContent = texts[d.id].textContent;
const numCharsAvailable =
Math.floor((maxTextLength * textContent.length)/d.width)-3;
texts[d.id].textContent =
textContent.slice(0, numCharsAvailable).trim() + "...";
});
} else {
const maxLength = Math.max(...longTexts.map((d) => d.width));
computedWidth =
paddingLeft +
lineLength +
spaceBetweenLineText +
maxLength +
paddingRight;
}
});
</script><svg x={x - 10} {y} width={computedWidth + 2} height="200" id={idContainer}>
<rect
x="1"
y="1"
width={computedWidth}
height={(labels.length + 1 + (title !== undefined ? 1 : 0)) * step}
stroke="black"
stroke-width="1"
fill={backgroundColor}
{opacity}
/>
{#if title !== undefined}
<text
x={paddingLeft + 3}
y={step}
alignment-baseline="middle"
font-size="14"
fill={textColor}>{title}</text>
{/if}
{#each labels as label, i}
<g>
<line
x1={paddingLeft}
x2={paddingLeft + lineLength}
y1={(i + 1 + (title !== undefined ? 1 : 0)) * step - 1}
y2={(i + 1 + (title !== undefined ? 1 : 0)) * step - 1}
stroke={colorScale(label)}
stroke-width="3"/>
<text
x={paddingLeft + lineLength + spaceBetweenLineText}
y={(i + 1 + (title !== undefined ? 1 : 0)) * step}
alignment-baseline="middle"
font-size="14"
fill={textColor}
class="legend-labels">{label}{values !== undefined
? ": " + values[label].toLocaleString(): ""}</text>
</g>
{/each}
</svg>

And we created this:

Looks simple, right?

The reason why the code is more complex than it seems is that I considered also the possibility of having labels too long for the rectangle, so in this case they would be cut in half and completed with three dots.

Now that the tooltip is done, we just need to place it in the chart following the mouse position. Here’s the code to do it:

<svg>
...
{#if mousePosition.x !== null}
<Tooltip
labels={yVars}
values={data.find(
(d) => d[xVar] === computeSelectedXValue(mousePosition.x))}
{colorScale}
x={mousePosition.x + 180 > chartWidth
? mousePosition.x - 195
: mousePosition.x + 15}
y={Math.max(0, mousePosition.y - (yVars.length + 2) * 25)}
backgroundColor={"black"}
opacity="0.5"
textColor={"white"}
title={"Year: " + computeSelectedXValue(mousePosition.x)}
width="180"
adaptTexts={false}
/>
{/if}
</svg>

And here we are! If you move the mouse on the chart, you’ll see that the tooltip pops up!

And here’s the final chart!

In the code above, we handle edge cases, such as when the mouse is too close to the end of the chart, by placing the tooltip alternatively on the left or right of the cursor.

Conclusion

It’s quite amazing how powerful these tools are and it’s funny to play around with them. In a few hours I was able to build a chart that looks like those you can find in proprietary software like PowerBI, with the benefit that this chart is easily customizable and that it can be implemented in a website or an app with little effort.

Indeed, to create a bundle to show this work on a website, just run on your terminal:

npm run build

And place in your server the files that were generated in the folder public. You can see the results on my website by clicking here.

That’s all for this tutorial, I hope you enjoyed! This was actually my first Medium article, so please, leave a comment if you have any doubts or suggestions, I’d like to hear from you!

You can find all of the files in my GitHub repository here.

--

--

stefano agresti

Freelance developer and Co-Founder of Oblique.ai. Formerly Analyst in Deloitte and Junior Data Scientist in Accurat.