Introduction
You might have a lot of data in your SolidJs app that needs a good chart to make it easier to read. If so, you're in the right place! In this article we will go from zero to a fully type-safe and customizable pie chart powered by d3, so open your terminal and let's start!
Project setup
First, we need a new Solid project with typescript, tailwind and d3. Following the Solid docs, we'll just grab the typescript + tailwindcss template and then install d3 and the d3 types.
In your terminal:
$ npx degit solidjs/templates/ts-tailwindcss awesome-charts
$ cd awesome-charts
$ yarn add d3
$ yarn add -D @types/d3
Now that we have a blank project with all the dependencies we need, start the development server and open your favorite text editor.
$ yarn dev
You should see a Hello tailwind!
on your browser, now let's go to the src/App.tsx
file and add a little bit of markup.
// App.tsx
import type { Component } from 'solid-js'
const App: Component = () => {
return (
<main class="bg-slate-800 min-h-screen text-slate-50">
<div class="max-w-xl m-auto pt-32">
<h1 class="text-3xl mb-6 font-bold">Awesome charts</h1>
<div class="w-full bg-slate-900/50 aspect-square rounded-2xl">
{/* chart goes here */}
</div>
</div>
</main>
)
}
export default App
Now we have a pretty good setup for our awesome chart to go, we just need to make it.
Coding the chart
Let's start by thinking about the props that the Chart will need. It definitely needs a width
, height
and margin
to size the content properly, which should all be numbers.
We also need to receive the data to be rendered, but since we don't know exactly what shape that data has (as this is meant to be a reusable component), so we need a generic.
On that note, we also need to know what field of the data represents the value that needs to be displayed. For example, we might have an User
type, that has name, age and a list of friends, in this scenario, we want the Chart to represent the users by the number of friends that they have, so the Chart should allow for this type of customization. In other words, we need to pass a prop (let's call it value
) that will allow us to pick a value from our object and use it, as long as it is a number.
In the end, the props should look something like this:
type ChartProps<T extends Record<string, any>> = {
width: number
height: number
margin: number
data: T[]
value: (d: T) => number
}
We need a generic T
type that is an object, width
, height
and margin
are all numbers, data
is an Array of T's and finally value
is a function that receives a parameter called d of type T and return a number.
Now we can create the Chart component. First we need a file, and what better place to put a component than the components
folder? So let's do it.
// components/Chart.tsx
type ChartProps<T extends Record<string, any>> = {
width: number
height: number
margin: number
data: T[]
value: (d: T) => number
}
function Chart(p: ChartProps) {
return (
<svg viewBox={`0 0 ${p.width} ${p.height}`}>
<g transform={`translate(${p.width / 2},${p.height / 2}`}>
</g>
</svg>
)
}
export default Chart
Now that we have a component, we can import it on the App and replace the comment.
But what is d3js?
If you never used d3, it is an open-source javascript library for manipulating the DOM based on data. It is completely based on web standards like HTML and CSS syntax, so it's really intuitive to get started.
Normally, d3 is used to create and edit DOM nodes, in a syntax similar to JQuery, but we are not going in that route, as we're using Solid and JSX, which provide a more declarative way of writing HTML, so instead of using it to create the elements of the chart, we will use it to generate the d
attribute for the <path>
element. The d
attribute is what defines the shape of a SVG <path>
, and can be pretty cumbersome to write by hand, so d3 will do the heavy lifting for us.
Enough talking, let's go back to the code.
Generating SVG paths based on data
Now things start to get fun. First, we need a d3 pie
object, which basically calculates the start and end angle for each item on our dataset. It will look more or less like this:
import * as d3 from 'd3'
const pieGenerator = d3
.pie<T>()
.value(p.value)
Here we are creating and customizing the pieGenerator
, we can pass the generic T
that we receive from the props to make everything type-safe and then we specify the field of the object that we want to use to calculate the size of each section, in this case, we use the function that is passed via props to make it customizable.
Now we can pass the data to the generator and store the result:
const parsedData = pieGenerator(p.data)
But this is not enough, because the parsedData
only contains the start and end angle of each section and not a string that we can pass to the path
. In order to do that we need a arc
generator. The process is very similar:
const radius = Math.min(p.width, p.height) / 2 - p.margin
const arcGenerator = d3
.arc<d3.PieArcDatum<T>>()
.innerRadius(0)
.outerRadius(radius)
Again, we create an arcGenerator
from d3, passing an generic of type d3.PieArcDatum<T>
, which just represents the data that the pieGenerator
returns to us (you can hover over the parsedData
to see the type), then we set the inner and outer radius of the chart, for that we have the radius
variable, which is calculated based on the width and height of the component.
Now just as a bonus, let's make a color generator, so that the sections are different from one another. We can use d3 functions for that too.
const colorGenerator = d3
.scaleSequential(d3.interpolateWarm)
.domain([0, p.data.length])
Now all we need is to pass the parsedData
to the arcGenerator
using a map, as the generator only accepts one item at a time and we need to add some extra properties.
const arcs = parsedData.map((d, i) => ({
path: arcGenerator(d),
data: d.data,
color: colorGenerator(i),
}))
Done! This should be all we need for now, let's update our Chart component.
// components/Chart.tsx
import * as d3 from 'd3'
type ChartProps<T extends Record<string, any>> = {
width: number
height: number
margin: number
data: T[]
value: (d: T) => number
}
function Chart<T>(p: ChartProps<T>) {
const pieGenerator = d3.pie<T>().value(p.value)
const parsedData = pieGenerator(p.data)
const radius = Math.min(p.width, p.height) / 2 - p.margin
const colorGenerator = d3
.scaleSequential(d3.interpolateWarm)
.domain([0, p.data.length])
const arcGenerator = d3
.arc<d3.PieArcDatum<T>>()
.innerRadius(0)
.outerRadius(radius)
const arcs = parsedData.map((d, i) => ({
path: arcGenerator(d),
data: d.data,
color: colorGenerator(i),
}))
<svg viewBox={`0 0 ${p.width} ${p.height}`}>
<g transform={`translate(${p.width / 2},${p.height / 2}`}>
</g>
</svg>
}
export default Chart
That wasn't that bad, was it? We just need to display the data.
Displaying the chart
For that we don't need much, just add the paths to our svg
tag. We can do that with Solid's For
component.
<For each={arcs}>
{(d) => <path d={d.path} fill={d.color} class="transition hover:scale-105" />}
</For>
Here we're creating a <path>
for each data point we receive, using the path generated before as the "d" parameter, the interpolated color as the fill and also using some tailwind classes to add a hover animation.
This works fine, we now have a colorful pie chart. But there are some problems still: first, what if the data is dynamic? With the current code, all the arcs are generated only once, as Solid components don't re-run; also, how the user is supposed to know what each section means? For that we can create a label that updates when the user hovers a section. Let's solve those problems right now.
Making it reactive
For the first problem, it's pretty simple to solve. We need to update the arcs
array every time the props change, for that we can use createSignal
and createEffect
to make our component reactive. While we're at it, let's create another signal to store the current hovered section, so that we can display the information to the user. Also, we might need another prop to display the item information correctly, let's call it label
and its type should be keyof T
, as T is the type of the object we're rendering. After all these changes, we have a pretty big file, but much more functional.
type ChartProps<T extends Record<string, any>> = {
width: number
height: number
margin: number
data: T[]
label: keyof T
value: (d: T) => number
}
type Arc<T> = {
path: string
data: T
color: string
}
function Chart<T extends Record<string, any>>(p: ChartProps<T>) {
const [arcs, setArcs] = createSignal<Arc<T>[]>([])
const [hovered, setHovered] = createSignal<Arc<T> | null>(null)
const handleMouseOver = (d: Arc<T>) => {
setHovered(d)
}
const handleMouseOut = () => {
setHovered(null)
}
createEffect(() => {
const radius = Math.min(p.width, p.height) / 2 - p.margin
const colorGenerator = d3
.scaleSequential(d3.interpolateWarm)
.domain([0, p.data.length])
const pieGenerator = d3.pie<T>().value(p.value)
const parsedData = pieGenerator(p.data)
const arcGenerator = d3
.arc<d3.PieArcDatum<T>>()
.innerRadius(0)
.outerRadius(radius)
setArcs(
parsedData.map((d, i) => ({
path: arcGenerator(d),
data: d.data,
color: colorGenerator(i),
}))
)
})
return (
<div>
<svg viewBox={`0 0 ${p.width} ${p.height}`}>
<g transform={`translate(${p.width / 2},${p.height / 2})`}>
<For each={arcs()}>
{(d) => (
<path
d={d.path}
onMouseOver={() => handleMouseOver(d)}
onMouseOut={() => handleMouseOut()}
fill={d.color}
class="hover:scale-105 transition"
/>
)}
</For>
</g>
</svg>
<Show when={hovered()}>
{(item) => (
<div class="w-fit m-auto flex items-center gap-2 p-3">
<div
class="rounded-md w-5 aspect-square"
style={{
'background-color': item().color,
}}
/>
<span>
{item().data[p.label]} Amount: {p.value(item().data)}
</span>
</div>
)}
</Show>
</div>
)
}
Breaking all the changes down, we updated the props as said before, created another type to represent a single arc
, with the path, color and data, on the body of the component we now have two signals, one to hold all the arcs of the graph and the other to store the currently hovered arc. Then we have two functions that update the hovered
signal, and finally a effect, which ensures that our code re-runs whenever the props change. Now for the JSX, we are wrapping everything with a <div>
, the paths now have the mouseOver
and mouseOut
event handlers and at the end we have a <Show>
component, which only renders when something is hovered, inside we have a bit of markup to render the label of the item and its total amount.
Conclusion
OK that was a lot, and I think it's time to wrap things up.
So we've talked about how to make charts with d3 in SolidJs, how to make them reactive and interactive, all while keeping the code type-safe and extensible.
If you have any suggestion or doubt, feel free to write it in the comments.
Thanks for reading!
Top comments (0)