DEV Community

Bruno Panizzi
Bruno Panizzi

Posted on

How to build a type-safe pie chart in SolidJs with d3 and tailwind

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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),
}))
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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)