Introduction
Recently I've started working on an expense tracking application for my personal use, and in order to visualize data better I've decided to add some bar charts to it.
I did some research and found a lot of helpful libraries e.g. recharts or react-vis, but I thought for my case it would be an overkill, also it seems like a great opportunity to learn something new, so I've decided to use D3.
What is D3?
D3 stands for Data-Driven Documents and as the docs states:
D3.js is a JavaScript library for manipulating documents based on data. D3 helps you bring data to life using HTML, SVG, and CSS.
After getting familiar with it, I got really excited by how powerful this library is and how many various cases this can help you to solve. Just check out this gallery and tell me you're not impressed 😅
Before we start
First things first, let's install D3 and its type declarations.
yarn add d3
yarn add --dev @types/d3
Also, let's initialize some dummy data to fill our chart.
interface Data {
label: string;
value: number;
}
const DATA: Data[] = [
{ label: "Apples", value: 100 },
{ label: "Bananas", value: 200 },
{ label: "Oranges", value: 50 },
{ label: "Kiwis", value: 150 }
];
Now we're ready to jump to the next section, so buckle up!
Bar chart
Of course, we want our bar chart to be reusable through the whole application. To achieve that, let's declare it as a separate component that will take data
prop and return SVG elements to visualize given data.
interface BarChartProps {
data: Data[];
}
function BarChart({ data }: BarChartProps) {
const margin = { top: 0, right: 0, bottom: 0, left: 0 };
const width = 500 - margin.left - margin.right;
const height = 300 - margin.top - margin.bottom;
return (
<svg
width={width + margin.left + margin.right}
height={height + margin.top + margin.bottom}
>
<g transform={`translate(${margin.left}, ${margin.top})`}></g>
</svg>
);
}
Great, we have our SVG with declared width
and height
attributes. So far, so good. But you might wonder what is this g
element for. Basically, you can think of it as a container for elements that will come next - x-axis, y-axis and bars that will represent our data. By manipulating its transform
attribute with margin
values, we will create some space to properly render all the above-mentioned elements.
Bottom axis
Before we render our horizontal axis, we have to remember about scales. Scales are functions that are responsible for mapping data values to visual variables. I don't want to dive too deep into this topic, but if you're interested in further reading, you can check out scales documentation. We want our x-axis to display labels from data, so for this we will use scaleBand
.
const scaleX = scaleBand()
.domain(data.map(({ label }) => label))
.range([0, width]);
Now we can create AxisBottom
component which will render g
element that will be used for drawing horizontal axis by calling axisBottom
function on it.
interface AxisBottomProps {
scale: ScaleBand<string>;
transform: string;
}
function AxisBottom({ scale, transform }: AxisBottomProps) {
const ref = useRef<SVGGElement>(null);
useEffect(() => {
if (ref.current) {
select(ref.current).call(axisBottom(scale));
}
}, [scale]);
return <g ref={ref} transform={transform} />;
}
After using AxisBottom
in our BarChart
component, the code will look like this 👇
export function BarChart({ data }: BarChartProps) {
const margin = { top: 0, right: 0, bottom: 20, left: 0 };
const width = 500 - margin.left - margin.right;
const height = 300 - margin.top - margin.bottom;
const scaleX = scaleBand()
.domain(data.map(({ label }) => label))
.range([0, width]);
return (
<svg
width={width + margin.left + margin.right}
height={height + margin.top + margin.bottom}
>
<g transform={`translate(${margin.left}, ${margin.top})`}>
<AxisBottom scale={scaleX} transform={`translate(0, ${height})`} />
</g>
</svg>
);
}
Notice how we added some bottom margin and set transform
property of AxisBottom
component to place it at the very bottom of our SVG container, since originally this would be rendered in the top-left corner.
Here's the result 👀
Left axis
The process of creating the vertical axis is very similar to what we did earlier, but this time we will use scaleLinear
for scale. On our y-axis, we want to display ticks for values from our data. Ticks are just "steps" between minimum and a maximum value in a given domain. To do that, we will pass [0, max]
for our domain and [height, 0]
for range. Notice how height
goes first - it's because we want ticks to have maximum value on top of our y-axis, not at the bottom.
const scaleY = scaleLinear()
.domain([0, Math.max(...data.map(({ value }) => value))])
.range([height, 0]);
Now we're ready to start working on AxisLeft
component. It's almost the same what we did in AxisBottom
but this time we will use axisLeft
function to draw our vertical axis.
interface AxisLeftProps {
scale: ScaleLinear<number, number, never>;
}
function AxisLeft({ scale }: AxisLeftProps) {
const ref = useRef<SVGGElement>(null);
useEffect(() => {
if (ref.current) {
select(ref.current).call(axisLeft(scale));
}
}, [scale]);
return <g ref={ref} />;
}
After using it in BarChart
the code will look like this 👇
export function BarChart({ data }: BarChartProps) {
const margin = { top: 10, right: 0, bottom: 20, left: 30 };
const width = 500 - margin.left - margin.right;
const height = 300 - margin.top - margin.bottom;
const scaleX = scaleBand()
.domain(data.map(({ label }) => label))
.range([0, width]);
const scaleY = scaleLinear()
.domain([0, Math.max(...data.map(({ value }) => value))])
.range([height, 0]);
return (
<svg
width={width + margin.left + margin.right}
height={height + margin.top + margin.bottom}
>
<g transform={`translate(${margin.left}, ${margin.top})`}>
<AxisBottom scale={scaleX} transform={`translate(0, ${height})`} />
<AxisLeft scale={scaleY} />
</g>
</svg>
);
}
This time we added some top and left margin to make it visible on our SVG, but since it's initially placed in top-left corner we didn't have to set transform
property.
Here's how it looks 👀
Bars
Time for rendering bars, it's my favourite part. In this component we will use scaleX
and scaleY
we declared earlier to compute x
, y
, width
and height
attributes for each value from our data. For rendering bar we will use SVG rect
element.
interface BarsProps {
data: BarChartProps["data"];
height: number;
scaleX: AxisBottomProps["scale"];
scaleY: AxisLeftProps["scale"];
}
function Bars({ data, height, scaleX, scaleY }: BarsProps) {
return (
<>
{data.map(({ value, label }) => (
<rect
key={`bar-${label}`}
x={scaleX(label)}
y={scaleY(value)}
width={scaleX.bandwidth()}
height={height - scaleY(value)}
fill="teal"
/>
))}
</>
);
}
After adding this to BarChart
the final version of it will look like this 👇
export function BarChart({ data }: BarChartProps) {
const margin = { top: 10, right: 0, bottom: 20, left: 30 };
const width = 500 - margin.left - margin.right;
const height = 300 - margin.top - margin.bottom;
const scaleX = scaleBand()
.domain(data.map(({ label }) => label))
.range([0, width])
.padding(0.5);
const scaleY = scaleLinear()
.domain([0, Math.max(...data.map(({ value }) => value))])
.range([height, 0]);
return (
<svg
width={width + margin.left + margin.right}
height={height + margin.top + margin.bottom}
>
<g transform={`translate(${margin.left}, ${margin.top})`}>
<AxisBottom scale={scaleX} transform={`translate(0, ${height})`} />
<AxisLeft scale={scaleY} />
<Bars data={data} height={height} scaleX={scaleX} scaleY={scaleY} />
</g>
</svg>
);
}
The things that changed is of course adding Bars
, but besides that we used padding
method on our scaleX
to create some space between rectangles and improve chart readability.
Demo
Feel free to fork this sandbox and play around with it. Maybe add separate colour for each bar, handle displaying negative values on it, add some more data, try to create horizontal bar chart etc.
Also, if you would like to learn more I encourage you to check out this tutorial by Amelia Wattenberger, it's great.
Thanks for reading! 👋
Top comments (0)