DEV Community

Maxim Titov
Maxim Titov

Posted on

Chart library design

Data visualization is a common task in modern software. Users can better understand large data sets visually represented and get meaningful insights. There are a bunch of libraries that help to solve it. They render different types of charts based on data that is passed into them. Usually, chart libraries use D3.js under the hood. It’s a library in charge of drawing particular chart primitives. D3.js can render data using SVG and Canvas, and later I will discuss the difference between these two approaches.

In this article, I am describing a solution for the problem of rendering different types of charts, and I will also rely on low-level rendering provided by D3.js. It might be useful not only if you need to build it but also as an exercise for preparing for a front-end engineer interview.

Requirements

First, let’s define what exactly we want to build. We want to create such a component that will render a chart of a certain type based on any data set. We also want to customize how it looks, and we want to interact with the data. It means the component should do the following:

  1. Take data and render charts of different types (e.g. bar, line, pie, dot plots).
  2. Provide styling for different groups of data, e.g. different bars could be of different colors.
  3. Change the data scale by zooming in and out. The default min/max and step values should be calculated based on the data set or provided by a chart user.
  4. Show a popup with the details about the section of the chart on which it is clicked (x and y values).
  5. Render title and legend.

We also can mention some non-functional requirements:

  1. Scalable, i.e. adding new types of charts and interactions should extend provided API without implementation modification. It responds to the open-closed principle (OCP).
  2. Be performant, i.e. renders and interactions are fast.
  3. Be responsive and rendered fine on various devices.
  4. Be accessible.

There could be other features and customizations, however, we will focus on providing these since they look the most crucial and the final design is considered to be extendable.

Models

Now we know what we want to build, let’s move to the tech details. We will map some of the mentioned entities into models that we will use in our code. I use Typescript syntax to describe it but you can think about it as pseudocode.

There are some basic models below.

type ChartModel = {
  title?: string;
  xScaleDomain?: ScaleDomain;
  yScaleDomain?: ScaleDomain;
  showX?: boolean;
  showY?: boolean;
  showPopup?: boolean;
}

type ChartType = 'bar' | 'line' | 'dot';

type ChartSeriesModel<TData> = {
  data: TData;
  type: ChartType;
  xAxis: string | (data: TData) => Array<number | string>;
  yAxis: string | (data: TData) => Array<number | string>;
  style?: StyleFn;
}

type LegendModel = {
  [key: string]: string;
}

type ScaleDomain = {
  min?: number;
  max?: number;
  step?: number;
}
Enter fullscreen mode Exit fullscreen mode

We have two basic models. One is for the entire chart and another is for the series that we want to draw there. All fields in ChartModel are optional, their purpose is to configure the features of the chart and customize the UI.

The required fields type, data, xAxis, and yAxis in ChartSeriesModel are necessary to understand what the user wants to render. The most interesting is what can be passed as data. There could be two approaches:

  1. Force the user to prepare data of a certain format that our component will be working on. E.g., we can receive an array of objects, and if we know properties for the x and y axes we can group data by them and render these points (values).
  2. Receive any data and accessor functions that will return sets of values for the x and y axes.

Depending on a specific task it makes sense to choose one of the approaches to follow simpler implementation but I want to make this design as universal as possible so the model includes generic TData which could be anything and use xAxis and yAxis methods to process the data.

We will need a couple more models for styling charts based on the data point, so we consider x and y coordinates and return a style object that contains only color for now but can be extended with more (e.g. thickness, label color and position, etc.).

type Point = {
  x: number;
  y: number;
}

type StyleObj = {
  color: string;
}

type StyleFn = (value: Point) => StyleObj;
Enter fullscreen mode Exit fullscreen mode

Design

Now we have our data models ready, let’s look at the component tree we are going to create for our chart library.

Chart library component tree

The root component Chart is a wrapper for all other necessary parts: two axes components, legend, and set of series. Based on input it will choose what to render. The required part is ChartSeries. This component will choose the D3.js function based on type, pass the data and style function into it and render returned element.

Basically, we have two layers here. The first one is a component layer whose purpose is to provide interactivity and re-render DOM elements on change of the data or settings. Also, it will listen to DOM events to render popups on click and update zooming on the scroll.

The second layer is the drawing layer using D3.js. Functions written with D3.js will return elements for any particular ChartSeriesModel.type as well as for axes. D3.js allows drawing an axis by setting min/max values for data, and min/max values in pixels. It matches passed values, calculates equal ticks, and draws them with the axis line. In this design, ChartSeries is responsible for ingesting data and providing D3.js functions with scaling values. These settings can be manually defined if the chart component user sets their own min, max, and step with ChartModel.scaleDomain object.

This design allows extending library functionality as much as we need. For instance, we want to add a crosshair that shows lines parallel to axes and coordinates under the cursor. It requires adding a new component within the chart, receiving its configuration, and rendering. New D3.js functions should be provided for adding a new type of chart.

Note: the interaction layer can be implemented with vanilla JS or any framework. Depending on the specific implementation the UI could be composable according to this framework's best practices. For example, React JSX showing bar chart with x axis and legend could look like this:

<Chart model={chartModel}>
 <ChartSeries model={chartSeriesModel} />
 <ChartAxis position="bottom" />
 <Legend model={legendModel} />
</Chart>
Enter fullscreen mode Exit fullscreen mode

API

The API of components can be described after we defined them and the models earlier. The Chart component should take the ChartModel as input. From these configuration data, it gets the state with zoom and scale settings.

function Chart(input: {
  model: ChartModel;
}): HTMLElement

type ChartState = {
  zoom: number;  // default value is 1
  onZoomChange: () => void;
  xScale: d3.Scale;
  yScale: d3.Scale;
}
Enter fullscreen mode Exit fullscreen mode

The zoom setting will be used to tune the scale by user interaction with a chart, e.g. handling a scroll event. xScale and yScale could be either built from scale domain values defined by the user in ChartModel or calculated automatically based on the data provided for ChartSeries components. They are D3.js scales that can be of different types for different charts. Their main purpose is to map domain values to pixels on the screen.

ChartSeries should take the following:

function ChartSeries<TData>({
  model: ChartSeriesModel<TData>;
  xScale: d3.Scale;
  yScale: d3.Scale;
}): HTMLElement

type ChartSeriesState = {
  datum: Array<Point>;
}
Enter fullscreen mode Exit fullscreen mode

The model describes the raw data and functions to access values for the x and y. ChartSeries should calculate an array of points and pass it into D3.js function that will draw a particular chart of a passed model.type. The datum term is used to describe input for chart function. We also should pass xScale and yScale to map values into their positions on the chart.

ChartAxis is similar for both axes which will be differentiated by the position value.

function ChartAxis({
  position: 'top' | 'right' | 'bottom' | 'left';
  scale: d3.Scale;
}): SVGElement | HTMLCanvasElement
Enter fullscreen mode Exit fullscreen mode

It also has scale to properly draw an axis and place ticks with domain values on it. It also should react to scale changes. The position just sets where we want to see the axis relative to the chart.

function Legend({
  config: LegendModel;
  render?: () => HTMLElement
}): HTMLElement
Enter fullscreen mode Exit fullscreen mode

Legend could render either a default view to explain different charts with their coloring (default or based on the style function provided for the ChartSeries) or a custom view provided with a render property.

Evaluation

Well, let’s check that all functional requirements are done at this moment. We provided an opportunity to render different types of charts with a custom look by providing style callback and to react to user interactions such as zooming in/out and clicking to show popups. It is also able to render a title and a legend.

The component tree structure allows combining different blocks and extending the functionality by adding new ones. New types of charts can be added by implementing new D3.js functions and including them in ChartFnFactory.

A good performance can be achieved by smart strategies rendering the data. Basically, usage of one of the frameworks will be beneficial since they care about it from the box and tries to minimize updates on the page. To help with that we could memoize calculated datum, trigger axes updates only when scaling is changed, and avoid re-rendering of static parts while data is the same (e.g. legend).

Optimizations

There are a few more optimizations we have to discuss to achieve better performance, responsiveness, and accessibility.

Since users could render popups over Chart these elements should have absolute positioning. Using translate CSS function to move them horizontally and vertically will be better from a performance perspective.

We would need height and width values in a few places to properly render axes and charts. E.g. bar height could be set up with D3.js in the following way .attr("height", function(d) { return height - yScale(d.value); }); where height is related to the chart. So the height and weight should be obtained from the parent element where it is placed and should listen to its change to be responsive.

The next range of optimizations is about accessibility. We actually already have a title and legend that describes our chart. It would be probably a nice idea to make a title to be required and additionally have showTitle: boolean = true. So even If a user switches it off we could add aria-label for screen readers. Likewise, we want to add aria-label for the legend element to make it clear.

We should verify that the colors that we use to draw charts by default contrast with the background.

Additionally, we could take care of describing axes and values better. Axes should have labels and values for ticks on them. Also, the data on the chart itself could be labeled to describe it for screen readers and better visual perception.

SVG vs Canvas

As was mentioned in the beginning D3.js library gives us a choice of how to render the data. Let’s consider the difference between SVG and Canvas, and their pros and cons.

SVG (scalable vector graphics) is an XML-based format that is similar to HTML and will be a part of a DOM tree with many shapes as children. The browser can access these shapes, bind event listeners, and manipulate and update them as with any other DOM node.

Canvas gives API to imperatively create paintings which results in a single element being added to the HTML document.

Due to this core difference, the interaction with SVG is much simpler, e.g. click event can be bonded to a node and get it immediately while canvas provides us only with coordinates (x, y) on its surface. Mapping coordinates to drawn elements require additional code. One of the strategies is to put an invisible canvas behind the real where all elements are colored differently so that they can be mapped to the datum array.

The downside of having many nodes in the DOM is that it requires more effort to render and update them in the browser. Here the usage of Canvas can be beneficial because it could quickly redraw the entire scene.

SVG will provide better responsiveness, its nodes could change their dimensions as well as other DOM nodes depending on provided styling. Canvas again requires complete redrawing.

Data binding is specific to D3.js library. Using SVG users map their data to shapes, so they are bound and can be automatically updated afterward. The data and their rendering are tightly coupled. It is different for Canvas which gives an opportunity to draw either by a set of imperative commands or create a set of dummy elements and bind them to data. While data binding makes it possible to select, enter and exit dummy elements like it is done with SVG API rendering is detached from this cycle. So the library user has to additionally care about how and when to redraw elements of a canvas scene.

To sum up, SVG is simpler to achieve interactivity and responsiveness requirements and has the benefits of data binding in D3.js. Canvas can be more performant in case of having a lot of nodes in one chart. For this design I would choose SVG, and consider Canvas for only specific cases that require rendering many nodes, e.g. let’s stick with 100 nodes threshold for it.

Conclusion

In this article, we discussed how the extendable chart library could be built. Also, we discussed the advantages and disadvantages of visualization with SVG and Canvas. I hope you will find it useful for you and I would be glad to hear your comments and ideas on how this solution could be improved.

Top comments (0)