Making a Chart? Try Using Mobx State Tree to Power the Data

Publikováno: 5.11.2019

Who loves charts? Everyone, right? There are lots of ways to create them, including a number of libraries. There’s D3.js, Chart.js, amCharts, Highcharts, and Chartist, to name only a few of many, many options.

But we don’t necessary need a chart library to create charts. Take Mobx-state-tree (MST), an intuitive alternative to Redux for managing state in React. We can build an interactive custom chart with simple SVG elements, using MST to manage … Read article

The post Making a Chart? Try Using Mobx State Tree to Power the Data appeared first on CSS-Tricks.

Celý článek

Who loves charts? Everyone, right? There are lots of ways to create them, including a number of libraries. There’s D3.js, Chart.js, amCharts, Highcharts, and Chartist, to name only a few of many, many options.

But we don’t necessary need a chart library to create charts. Take Mobx-state-tree (MST), an intuitive alternative to Redux for managing state in React. We can build an interactive custom chart with simple SVG elements, using MST to manage and manipulate data for the chart. If you've attempted to build charts using something like D3.js in the past, I think you’ll find this approach more intuitive. Even if you're an experienced D3.js developer, I still think you'll be interested to see how powerful MST can be as a data architecture for visualizations.

Here’s an example of MST being used to power a chart:

This example uses D3's scale functions but the chart itself is rendered simply using SVG elements within JSX. I don’t know of any chart library that has an option for flashing hamster points so this is a great example of why it’s great to build your own charts — and it’s not as hard as you might think!

I’ve been building charts with D3 for over 10 years and, while I love how powerful it is, I’ve always found that my code can end up being unwieldy and hard to maintain, especially when working with complex visualizations. MST has changed all that completely by providing an elegant way to separate the data handling from the rendering. My hope for this article is that it will encourage you to give it a spin.

Getting familiar with MST model

First of all, let’s cover a quick overview of what a MST model looks like. This isn’t an in-depth tutorial on all things MST. I only want to show the basics because, really, that’s all you need about 90% of the time.

Below is a Sandbox with the code for a simple to-do list built in MST. Take a quick look and then I’ve explain what each section does.

First of all, the shape of the object is defined with typed definitions of the attribute of the model. In plain English, this means an instance of the to-do model must have a title, which must be a string and will default to having a “done” attribute of false.

.model("Todo", {
  title: types.string,
  done: false //this is equivalent to types.boolean that defaults to false
})

Next, we have the view and action functions. View functions are ways to access calculated values based on data within the model without making any changes to the data held by the model. You can think of them as read-only functions.

.views(self => ({
  outstandingTodoCount() {
    return self.todos.length - self.todos.filter(t => t.done).length;
  }
}))

Action functions, on the other hand, allow us to safely update the data. This is always done in the background in a non-mutable way.

.actions(self => ({
  addTodo(title) {
    self.todos.push({
      id: Math.random(),
      title
    });
  }
}));

Finally, we create a new instance of the store:

const todoStore = TodoStore.create({
  todos: [
    {
      title: "foo",
      done: false
    }
  ]
});

To show the store in action, I’ve added a couple of console logs to show the output of outStandingTodoCount() before and after triggering the toggle function of the first instance of a Todo.

console.log(todoStore.outstandingTodoCount()); // outputs: 1
todoStore.todos[0].toggle();
console.log(todoStore.outstandingTodoCount()); // outputs: 0

As you can see, MST gives us a data structure that allows us to easily access and manipulate data. More importantly, it’s structure is very intuitive and the code is easy to read at a glance — not a reducer in sight!

Let’s make a React chart component

OK, so now that we have a bit of background on what MST looks like, let’s use it to create a store that manages data for a chart. We’ll will start with the chart JSX, though, because it’s much easier to build the store once you know what data is needed.

Let’s look at the JSX which renders the chart.

The first thing to note is that we are using styled-components to organize our CSS. If that’s new to you, Cliff Hall has a great post that shows it in use with a React app.

First of all, we are rendering the dropdown that will change the chart axes. This is a fairly simple HTML dropdown wrapped in a styled component. The thing to note is that this is a controlled input, with the state set using the selectedAxes value from our model (we’ll look at this later).

<select
  onChange={e =>
    model.setSelectedAxes(parseInt(e.target.value, 10))
  }
  defaultValue={model.selectedAxes}
>

Next, we have the chart itself. I’ve split up the axes and points in to their own components, which live in a separate file. This really helps keep the code maintainable by keeping each file nice and small. Additionally, it means we can reuse the axes if we want to, say, have a line chart instead of points. This really pays off when working on large projects with multiple types of chart. It also makes it easy to test the components in isolation, both programmatically and manually within a living style guide.

{model.ready ? (
  <div>
    <Axes
      yTicks={model.getYAxis()}
      xTicks={model.getXAxis()}
      xLabel={xAxisLabels[model.selectedAxes]}
      yLabel={yAxisLabels[model.selectedAxes]}
    ></Axes>
    <Points points={model.getPoints()}></Points>
  </div>
) : (
  <Loading></Loading>
)}

Try commenting out the axes and points components in the Sandbox above to see how they work independently of each other.

Lastly, we’ll wrap the component with an observer function. This means that any changes in the model will trigger a re-render.

export default observer(HeartrateChart);

Let’s take a look at the Axes component:

As you can see, we have an XAxis and a YAxis. Each has a label and a set of tick marks. We go into how the marks are created later, but here you should note that each axis is made up of a set of ticks, generated by mapping over an array of objects with a label and either an x or y value, depending on which axis we are rendering.

Try changing some of the attribute values for the elements and see what happens… or breaks! For example, change the line element in the YAxis to the following:

<line x1={30} x2="95%" y1={0} y2={y} />

The best way to learn how to build visuals with SVG is simply to experiment and break things. 🙂

OK, that’s half of the chart. Now we’ll look at the Points component.

Each point on the chart is composed of two things: an SVG image and a circle element. The image is the animal icon and the circle provides the pulse animation that is visible when mousing over the icon.

Try commenting out the image element and then the circle element to see what happens.

This time the model has to provide an array of point objects which gives us four properties: x and y values used to position the point on the graph, a label for the point (the name of the animal) and pulse, which is the duration of the pulse animation for each animal icon. Hopefully this all seems intuitive and logical.

Again, try fiddling with attribute values to see what changes and breaks. You can try setting the y attribute of the image to 0. Trust me, this is a much less intimidating way to learn than reading the W3C specification for an SVG image element!

Hopefully this gives you an understanding and feel for how we are rendering the chart in React. Now, it’s just a case of creating a model with the appropriate actions to generate the points and ticks data we need to loop over in JSX.

Creating our store

Here is the complete code for the store:

I’ll break down the code into the three parts mentioned earlier:

  1. Defining the attributes of the model
  2. Defining the actions
  3. Defining the views

Defining the attributes of the model

Everything we define here is accessible externally as a property of the instance of the model and — if using an observable wrapped component — any changes to these properties will trigger a re-render.

.model('ChartModel', {
  animals: types.array(AnimalModel),
  paddingAndMargins: types.frozen({
    paddingX: 30,
    paddingRight: 0,
    marginX: 30,
    marginY: 30,
    marginTop: 30,
    chartHeight: 500
  }),
  ready: false, // means a types.boolean that defaults to false
  selectedAxes: 0 // means a types.number that defaults to 0
})

Each animal has four data points: name (Creature), longevity (Longevity__Years_), weight (Mass__grams_), and resting heart rate (Resting_Heart_Rate__BPM_).

const AnimalModel = types.model('AnimalModel', {
  Creature: types.string,
  Longevity__Years_: types.number,
  Mass__grams_: types.number,
  Resting_Heart_Rate__BPM_: types.number
});

Defining the actions

We only have two actions. The first (setSelectedAxes ) is called when changing the dropdown menu, which updates the selectedAxes attribute which, in turn, dictates what data gets used to render the axes.

setSelectedAxes(val) {
  self.selectedAxes = val;
},

The setUpScales action requires a bit more explanation. This function is called just after the chart component mounts, within a useEffect hook function, or after the window is resized. It accepts an object with the width of the DOM that contains the element. This allows us to set up the scale functions for each axis to fill the full available width. I will explain the scale functions shortly.

In order to set up scale functions, we need to calculate the maximum value for each data type, so the first thing we do is loop over the animals to calculate these maximum and minimum values. We can use zero as the minimum value for any scale we want to start at zero.

// ...
self.animals.forEach(
  ({
    Creature,
    Longevity__Years_,
    Mass__grams_,
    Resting_Heart_Rate__BPM_,
    ...rest
  }) => {
    maxHeartrate = Math.max(
      maxHeartrate,
      parseInt(Resting_Heart_Rate__BPM_, 10)
    );
    maxLongevity = Math.max(
      maxLongevity,
      parseInt(Longevity__Years_, 10)
    );
    maxWeight = Math.max(maxWeight, parseInt(Mass__grams_, 10));
    minWeight =
      minWeight === 0
        ? parseInt(Mass__grams_, 10)
        : Math.min(minWeight, parseInt(Mass__grams_, 10));
  }
);
// ...

Now to set up the scale functions! Here, we’ll be using the scaleLinear and scaleLog functions from D3.js. When setting these up, we specify the domain, which is the minimum and maximum input the functions can expect, and the range, which is the maximum and minimum output.

For example, when I call self.heartScaleY with the maxHeartrate value, the output will be equal to marginTop. That makes sense because this will be at the very top of the chart. For the longevity attribute, we need to have two scale functions since this data will appear on either the x- or the y-axis, depending on which dropdown option is chosen.

self.heartScaleY = scaleLinear()
  .domain([maxHeartrate, minHeartrate])
  .range([marginTop, chartHeight - marginY - marginTop]);
self.longevityScaleX = scaleLinear()
  .domain([minLongevity, maxLongevity])
  .range([paddingX + marginY, width - marginX - paddingX - paddingRight]);
self.longevityScaleY = scaleLinear()
  .domain([maxLongevity, minLongevity])
  .range([marginTop, chartHeight - marginY - marginTop]);
self.weightScaleX = scaleLog()
  .base(2)
  .domain([minWeight, maxWeight])
  .range([paddingX + marginY, width - marginX - paddingX - paddingRight]);

Finally, we set self.ready to be true since the chart is ready to render.

Defining the views

We have two sets of functions for the views. The first set outputs the data needed to render the axis ticks (I said we’d get there!) and the second set outputs the data needed to render the points. We’ll take a look at the tick functions first.

There are only two tick functions that are called from the React app: getXAxis and getYAxis. These simply return the output of other view functions depending on the value of self.selectedAxes.

getXAxis() {
  switch (self.selectedAxes) {
    case 0:
      return self.longevityXAxis;
      break;
    case 1:
    case 2:
      return self.weightXAxis;
      break;
  }
},
getYAxis() {
  switch (self.selectedAxes) {
    case 0:
    case 1:
      return self.heartYAxis;
      break;
    case 2:
      return self.longevityYAxis;
      break;
  }
},

If we take a look at the Axis functions themselves we can see they use a ticks method of the scale function. This returns an array of numbers suitable for an axis. We then map over the values to return the data we need for our axis component.

heartYAxis() {
  return self.heartScaleY.ticks(10).map(val => ({
    label: val,
    y: self.heartScaleY(val)
  }));
}
// ...

Try changing the value of the parameter for the ticks function to 5 and see how it affects the chart: self.heartScaleY.ticks(5).

Now we have the view functions to return the data needed for the Points component.

If we take a look at longevityHeartratePoints (which returns the point data for the “Longevity vs. Heart” rate chart), we can see that we are looping over the array of animals and using the appropriate scale functions to get the x and y positions for the point. For the pulse attribute, we use some maths to convert the beats per minute value of the heart rate into a value representing the duration of a single heartbeat in milliseconds.

longevityHeartratePoints() {
  return self.animals.map(
    ({ Creature, Longevity__Years_, Resting_Heart_Rate__BPM_ }) => ({
      y: self.heartScaleY(Resting_Heart_Rate__BPM_),
      x: self.longevityScaleX(Longevity__Years_),
      pulse: Math.round(1000 / (Resting_Heart_Rate__BPM_ / 60)),
      label: Creature
    })
  );
},

At the end of the store.js file, we need to create a Store model and then instantiate it with the raw data for the animal objects. It is a common pattern to attach all models to a parent Store model which can then be accessed through a provider at top level if needed.

const Store = types.model('Store', {
  chartModel: ChartModel
});
const store = Store.create({
  chartModel: { animals: data }
});
export default store;

And that is it! Here’s our demo once again:


This is by no means the only way to organize data to build charts in JSX, but I have found it to be incredibly effective. I’ve have used this structure and stack in the wild to build a library of custom charts for a big corporate client and was blown away with how nicely MST worked for this purpose. I hope you have the same experience!

The post Making a Chart? Try Using Mobx State Tree to Power the Data appeared first on CSS-Tricks.

Nahoru
Tento web používá k poskytování služeb a analýze návštěvnosti soubory cookie. Používáním tohoto webu s tímto souhlasíte. Další informace