24
loading...
This website collects cookies to deliver better user experience
💡 You already want to check out the end result ? Look at it here !
# Cloning the starter project
git clone -b setup [email protected]:rhidra/d3js-leaderboard.git
cd d3js-leaderboard
# Install dependancies
npm i
npm i d3
npm i react-use-measure
App.jsx
file<div className="app">
<div className="leaderboard-container">
<Leaderboard
data={data}
/>
</div>
<div className="button">
<button onClick={() => refreshData()}>Refresh Data</button>
</div>
</div>
data
variable.Leaderboard
component, we must consider possible changes to the data
input.Leaderboard.jsx
file.import { useRef, useState, useEffect } from 'react';
import * as d3 from 'd3';
function Leaderboard({data}) {
const d3Ref = useRef(null);
useEffect(() => {
/***
Write D3.js code here !
***/
}, [d3Ref, data]);
return (
<svg
ref={d3Ref}
/>
);
}
export default Leaderboard;
useEffect()
, you will write all the D3.js code. The Leaderboard
component is basically just made of a <svg>
component. In the following sections, we are going to connect it to D3.js. Then, we will use the framework to draw shapes and text on the SVG canvas.data.js
file and is made of a unique ID, a label and a value.const data = [
...
{ "id":15, "value":33, "label":"Indonesia" },
{ "id":16, "value":14, "label":"China" },
{ "id":21, "value":7, "label":"Germany" },
{ "id":22, "value":12, "label":"China" },
{ "id":23, "value":38, "label":"Argentina" },
{ "id":24, "value":58, "label":"China" },
...
];
viewBox
parameter. Because of this, we must specify a fixed width and height for the SVG component.100%
. Unfortunately, we cannot write <svg width="100%"/>
, we must use a value in pixel. A solution is to measure the child component from the parent. In App.jsx
, you will measure the width of the Leaderboard
child. Then, you can pass it its width as a parameter.Leaderboard
.function Leaderboard({data, width}) {
// ...
// Constant (in px)
const rowHeight = 60;
// Total height of the leaderboard
const [height, setHeight] = useState(rowHeight * data.length ?? 0);
useEffect(() => {
// Update total height, to use the most up-to-date value
setHeight(rowHeight * data.length);
const height = rowHeight * data.length;
// ...
}, [d3Ref, data, width]);
return (
<svg
width={width}
height={height}
ref={d3Ref}
/>
);
}
App
, there is no easy, one-line solution to easily measure the size of a component. So instead, we will use a React library, react-use-measure. It is quite popular and very easy to use.App
look like this.import useMeasure from 'react-use-measure';
// ...
// Use React-use-measure to measure the Leaderboard component
const [ref, {width: leaderboardWidth}] = useMeasure({debounce: 100});
return (
// ...
<div className="leaderboard-container" ref={ref}>
<Leaderboard
data={data}
width={leaderboardWidth}
/>
</div>
// ...
);
max-width
and width: 100%
in the CSS, so that the leaderboard component does not extends its width indefinitely, and it looks good on smaller devices ! useEffect(() => {
// ...
// Select the root SVG tag
const svg = d3.select(d3Ref.current);
// Scales
// Get the biggest value in the set,
// to draw all other relative to the maximum value.
const maxValue = d3.max(data.map(d => +d.value)) ?? 1;
const x = d3.scaleLinear().domain([0, maxValue]).range([5, width]);
const y = d3.scaleLinear().domain([0, data.length]).range([0, height]);
// Join the data
// We use the ID of a row to distinguish identical elements.
const g = svg.selectAll('g').data(data, d => d.id);
<g>
elements, one for each data row. In SVG, a <g>
element is just a group of other elements. x
and y
, using the maximum value of the dataset.g.enter()
function isolate the rows that needs to be created.// Initialization
const gEnter = g.enter()
.append('g')
.attr('transform', `translate(0, ${y(data.length) + 500})`);
<g>
element of our row, and we give it a transformation. This transform instructions moves the group vertically to y(data.length) + 500
. In other words, it moves the row beyond the bottom of the leaderboard, to not be in sight. This will allow us to make a small enter animation for when new rows are added.// More constants !
const fontSize = '1.1rem';
const textColor = 'black';
const bgColor = '#d4d8df'; // Background bar color (grey)
const barColor = '#3d76c1'; // Main bar color (blue)
const barHeight = 10;
const marginText = 2; // Margin between the text and the bars
// Append background rect as child
gEnter
.append('rect')
.attr('class', 'bg')
.attr('fill', bgColor)
.attr('x', 0).attr('y', marginText)
.attr('rx', 5).attr('ry', 5)
.attr('height', barHeight);
// Append main rect as child
gEnter
.append('rect')
.attr('class', 'main')
.attr('fill', barColor)
.attr('x', 0).attr('y', marginText)
.attr('rx', 5).attr('ry', 5) // Rectangle border radius
.attr('height', barHeight);
// Append label text as child
gEnter
.append('text')
.attr('class', 'label')
.attr('font-size', fontSize)
.attr('fill', textColor)
.attr('x', 0)
.attr('y', -5)
.text(d => d.label);
// Append value text as child
gEnter
.append('text')
.attr('class', 'value')
.attr('text-anchor', 'end')
.attr('fill', textColor)
.attr('font-size', fontSize)
.attr('y', -5);
// Update each g row, when data changes
const gUpdate = g.merge(gEnter);
gUpdate
.transition()
.ease(d3.easePoly)
.duration(500)
.attr('transform', (d, i) => `translate(0, ${y(i) + 30})`);
<g>
, we can simply update its transform attribute to move the row to the right position. You can see that we display the rows in order, which is why we use the i
index parameter instead of the value d.value
.// Update rect bg
gUpdate
.select('rect.bg')
.attr('width', x(maxValue));
// Update rect main
gUpdate
.select('rect.main')
.transition()
.ease(d3.easePolyOut)
.duration(1000)
.attr('width', d => x(d.value));
// Update value text
gUpdate
.select('text.value')
.text(d => d.value)
.attr('x', x(maxValue));
g.exit()
function, which isolate rows which should be removed.// Exit animation
g.exit()
.attr('opacity', 1)
.transition()
.ease(d3.easeLinear)
.duration(200)
.attr('transform', (d, i) => `translate(-50, ${y(i)})`)
.attr('opacity', 0)
.remove();
.duration(200)
if you are not happy with the exit animation duration.💡 Feel free to follow me on Twitter (@remyhidra), to get more articles and tutorial about Web development. I just started writing blog posts and I am trying to build a small audience from it, so there will be plenty of content coming soon ! 😄
Additionaly, tell me in the comments, what other kind of data visualization would you like to build ?