There is a dire need of including a chart based library to showcase metrics at my workplace ecosystem. While building features from scratch, we realised a lot feature parity that comes from Looker dashboards. It was a joint exercise to explore varius analytics dashboards that uses charts to show different metrics.
As an engineer/developer, there should be significant amount of data manipulation/transformation processes done in order to understand what metric should be rendered in what format.
Going through chart.js library and the documentation, the learning curve is short. The support for different set of data with several entities in one chart makes it more flexible. This helped align our product requirements to data in a flexible way.
For the purpose of explaining the base design of code, we will directly jump into the code part. This can be divided into two major things.
A wrapper component on top of chart.js to support base React.js
A custom function (or React-hook) to separate business logic
Custom wrapper component
import React, { useEffect, useRef, useState } from 'react';
import Chart from 'chart.js/auto';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import 'chartjs-adapter-moment';
import { PropTypes } from 'mobx-react';
/**
* @returns JSX Element
* Native Chart accepts three basic props to render a chart with chartjs library.
* type : explains the kind of chart mentioned in CHART_TYPES
* data : the values and the labels that chart should renders
* options : all the relevant options that should be providing a layout or design the chart.
*/
export default function NativeChart (props) {
const [chart, setChart] = useState(null);
const chartRef = useRef(null);
useEffect(() => {
let config = {
data: props.data,
plugins: [ChartDataLabels],
options: props.options
};
// Set and override chart types only if the prop 'type' is provided
props.type && (config['type'] = props.type);
let chart = new Chart(chartRef.current, config);
setChart(chart);
return () => {
chart.destroy();
setChart(null);
};
}, [props.data, props.options]);
return (
<div className='native-chart'>
<canvas ref={chartRef} />
</div>
);
}
NativeChart.propTypes = {
type: PropTypes.string,
data: PropTypes.object,
options: PropTypes.object
};
This is a basic component setup that chart.js recommends. This includes the key canvas element which is where chart.js draws the chart. This canvas element draws the chart with the instance of config. Config is made up of three major items, which are getting as prop passed to this component.
type : type of chart i.e., bar, line, area, doughnut, pie, etc.
data: A simple mapping object includes all the data points, x-axis ticks, y-axis ticks, axes names, entities lenged, etc.
const labels = ['Mar 21', 'April 21', 'May 21']; //Utils.months({count: 7});
const data = {
labels: labels,
datasets: [{
label: 'My First Dataset',
data: [65, 59, 80, 81, 56, 55, 40],
fill: false,
borderColor: 'rgb(75, 192, 192)',
tension: 0.1
}]
}
options: Chart options are the key to modify the look and feel of the chart based on product specs. This allows to modify text, color, aeshtetics, truncate long strings, interactions with charts like click and hover, transformation of data points.
Function for Business logic (custom react-hook)
The wrapper/native chart component can be reutilised in the container page of choice. This is where it requires the type, data and options available readily. To separate this part, we at Reporting have came up with a function that acts more like a custom react hooks. As mentioned it returns us with type, data and options. In order for it to formulate these three components, we can pass in major data and methods to mainly operate functionalities for these charts.
This separation also helps us in formualting the data before chart renders, so we as engineers can make sure our rendering cycle is not dynamic with data changes.
// container page of the Chart wrapper/native component
...
let { chartType, chartData, chartOptions } = generateChart(rep.value, currentTheme, reportGroup, populateDrills, setTileActive, executeScroll);
...
return (
<>
...
<NativeChart
key={rep.value.id} // only required when there are list of charts
type={chartType}
data={chartData}
options={chartOptions}
/>
...
<>
)
In this above snippet, generateChart is that function which takes in
all the data that comes from Backend (e.g., reporting-api)
Several other functions based on state of the app, as well as functionalities to define interactions on top of chart
The internal structure of this function/custom-hook follows the same methodolgy to traverse through chart-data.
The chart-type can come predefined from the backend mentioning the kind of chart to render.
As we iterate over the chart-data, there can be several checks based on the type of chart. All these checks can transform the chart-data in a way that our wrapper component can be hydrated. Along with this, logic should be specified to modify the default chart-options. This way chart.js has clear instructions to render with set of colors, level of details, readibility and view changes.
// define default/base chart-type
let chartType = '',
// define default chart-data
let chartData = {
labels: report.data.labels,
datasets: []
};
// define default chart-options
let chartOptions = {
animation: false,
maintainAspectRatio: false,
scales: {
x: {
title: {
display: report.data.title && report.data.title.x ? true : false,
text: report.data.title && report.data.title.x ? report.data.title.x : '',
color: currentTheme['content-color-secondary'],
font: 'Inter',
padding: {
top: 8,
bottom: 8
}
},
ticks: {
display: true,
color: currentTheme['content-color-tertiary'],
padding: 8
},
grid: {
drawOnChartArea: false,
drawTicks: false,
borderColor: currentTheme['grey-05'],
color: currentTheme['grey-05'],
z: -1
},
offset: true
},
y: {
title: {
display: report.data.title && report.data.title.y ? true : false,
text: report.data.title && report.data.title.y ? report.data.title.y : '',
color: currentTheme['content-color-secondary'],
font: 'Inter',
padding: {
bottom: 16
}
},
grid: {
drawOnChartArea: false,
drawTicks: false,
borderColor: currentTheme['grey-05'],
color: currentTheme['grey-05']
},
ticks: {
display: true,
padding: 16,
crossAlign: 'near',
color: currentTheme['content-color-tertiary'],
autoSkipPadding: 20
},
beginAtZero: true
}
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
usePointStyle: true,
color: currentTheme['content-color-primary'],
boxWidth: 8,
generateLabels: () => {},
}
},
onHover: (e) => e.chart.canvas.style.cursor = 'pointer',
onLeave: (e) => e.chart.canvas.style.cursor = 'default',
onClick: function (e, legendItem, legend){}
},
datalabels: {
display: 'auto',
anchor: 'center',
clamp: true,
font: {
weight: 600,
size: 11,
lineHeight: 1.8
}
},
While this file can also hold other wrapper functions and conditionals for the different functionalities, the basic logic is to iterate over the provieded chart-data from backend and extract information and feed to this fundamental elements to returns, i.e., chart-type, chart-data, chart-options.
Here we check the data and for each dataset we indentify several options and actual data paremeters to render. This setup provides strong ability to include several type of charts in one canavas to draw. e.g., we have chart Used slots over time chart on this report-page (https://cooper.postman-beta.co/reports/resource-usage ) includes two sets of bar charts along with a line chart.
...
chartData.datasets = _.map(report.data.dataset, (set, index) => {
if(set.type === DOUGHNUT){
...
// 1. transform chart-data to feed
...
// 2. transform chart-options according to product-specs and requirements e.g.,
// 2.1 also modify the CSS here for better look and feel
_.set(chartOptions, 'scales.x.display', false);
_.set(chartOptions, 'scales.y.display', false);
// we hide away ticks from showing up on any axis
// 3. Provide the defined colour set to this chart-options e.g.,
newSet.backgroundColor = DATASETS_COLOURS.slice(0, newSet.data.length);
newSet.borderColor = DATASETS_COLOURS.slice(0, newSet.data.length);
// 4. set or unset onClick, onHover behaviours on chart, chart-data-points, etc.
_.set(chartOptions, 'onClick', () => {})
}
if(set.type == BAR){
// same as above
}
if(set.type == AREA){
// same as above
}
if(set.type == LINE){
// same as above
}
}
...
On top of all this logic chart.js provides a specific functionality, where it understands the incremental values that can be computed automatically. This is really beneficial to automatically render x-axis/y-axis ticks. At Reporting, we have taken advantage of this and send the data from backend-api in a format that supports a bucket of range, as well as the type. We call this as timeseries charts. They can span through any range of dates/time with a start and end point. A format should be provided based on spec. Example as follows;
// continue in the previous code snippet
if (/*report data suggests that it is a timeseries chart*/) {
// setup the values based on bucket and other things
_.set(chartOptions, 'scales.x', {
...chartOptions.scales.x,
type: 'time',
min: report.data.startLabel,
max: report.data.endLabel,
time: {
unit: report.data.bucket,
displayFormats: {
[report.data.bucket]: report.data.labelFormat
}
}
});
Once all this is setup, this custom functions, makes a complete dish of chart-type, chart-data & chart-options that can be fed to wrapper/native component to render.
This document is a comprehensive walkthrough of code design. I’ve aimed at solving the need of data-transformation such that different teams can modulate their data to achieve the best possible viewing results.
References
- https://www.chartjs.org/
- https://chartjs-plugin-datalabels.netlify.app/ We have used this to modify the view and interactions of data labels
- Helper libraries like Lodash, moment.js, classnames.
- Alas, https://www.stackoverflow.com/
Top comments (0)