Command Palette

Search for a command to run...

Hectal
PHASE 3Beginner ~34 min· topic 3 of 6

Topic 3.3

A Bar Chart in Plain SVG

In one line

Build a bar chart from data with a loop: one SVG rectangle per row, with bar width and position worked out from the number of bars. Then meet the cases that break it: zero, negative and missing values, long names and sorting.

Think of it like this

Seating guests at a long banquet table. Every guest gets the same width of table, with a small gap to the neighbour. The number of guests decides how wide each seat is, so the table never runs out. The bars of a bar chart are guests, and the table is the plot area.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

namespace
The family an element belongs to. SVG elements live in http://www.w3.org/2000/svg; createElementNS creates one in that family.
slot
The share of the plot width that belongs to one bar: the width divided by the number of bars.
gap
The empty part of a slot between two bars.
baseline
The line the bars stand on, at the value 0.
zero line
The baseline when some values are negative: bars above it are positive, bars below it are negative.
missing value
A row with no number at all. It is different from 0: the first means "unknown" or "did not exist", the second means "counted none".
getBBox
A method on an SVG element that returns its bounding box (x, y, width, height) in its own units. It is how you measure a text label.
sort in place
array.sort() rearranges the array it is called on, instead of returning a new one.

Step by step

01Start with one box per number

The data is Lumen's rides per month in 2025 (43,439 for January up to 117,803 in July). The first step makes one <rect> per value, 30 pixels wide, 38 apart, with height = total / 1000. It draws, but the bars hang down from the top edge: SVG's y starts at the top, and the y attribute is 0.

02Add a margin and a scale, so the bars stand up

Now the margin convention (Topic 3.2) and a scale: y(value) = innerHeight − value ÷ most × innerHeight. The bar's top is y(total) and its height is innerHeight − y(total). The 117,803-ride bar is exactly 210 pixels tall, the full height of the plot, and the bars stand on the baseline.

03Get the bar width and the gaps from the count

The bars so far use fixed numbers (30 wide, 38 apart). Step 3 divides the plot width by the number of bars (460 ÷ 12 = 38.33 per slot), keeps a fifth of it as the gap (7.67) and uses the rest as the bar width (30.67). Add or remove a month and the bars re-fit by themselves.

04Label the bars

Step 4 adds two pieces of text per bar through a small text() helper: the month name below the baseline, and the value above the bar, both centred on the slot's middle (i × slot + slot ÷ 2). The values are rounded to thousands (Math.round(total / 1000)), so 99,946 shows as 100k.

05Sort a copy, busiest first

Step 5 turns the two arrays into rows ({ month, total }) and sorts a copy: [...rows].sort((a, b) => b.total - a.total). July comes first and January last. For months, calendar order is usually better (it is a sequence); for unordered categories such as stations, sorting by value is what makes a bar chart readable.

06Handle negatives, zero and missing values

The first example below draws month-on-month change: June +7,766, July +10,091, then every month from August down by 9,000 to 19,000. The scale's domain is [min(0, …), max(0, …)] so zero is always inside it, and every bar runs between y(0) and y(value).

The second example is a station that opened on 10 May. station-month.csv has 0 for its January to April, but "zero rides" is not what happened: the station did not exist. The example draws the raw zeros (four invisible bars) and then the honest version (four n/a marks).

07Measure labels before you choose a margin

The third example draws all 25 stations, sorted by 2025 rides, as horizontal bars. It creates the name labels first, measures the widest with getBBox().width, and sets the left margin from that. A guessed margin works on your computer and cuts off a long name on someone else's font.

Try it yourself

  1. 1

    Make the bars thinner

    In the first example, change slot * 0.1 to slot * 0.25 (the x offset) and slot * 0.8 to slot * 0.5 (the width). The bars shrink to half a slot and stay centred, because the offset is half of what is left over: (1 − 0.5) ÷ 2 = 0.25.

  2. 2

    Sort the other way

    In the third example, change b.total - a.total to a.total - b.total. The log now reads first: Windmill 10486 | last: Quay 58584 and the longest bar is at the bottom. The chart is equally correct; smallest-first is a different story ("who needs help?").

  3. 3

    Mark a different gap

    In the second example, change const opened = 4; to const opened = 5; so that May also counts as "not open". The last log line becomes bars with height: 3 | zero-height bars (invisible): 0 | marked n/a: 5. The station opened on 10 May, so May is partly real: choose with care what you hide.

Code & diagrams

drawBars: negative values and a zero line New tab

The orange bars are negative. Labels of negative bars sit above the zero line, so they never run into the bars.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

domain -18996 to 10091 - the zero line is at y = 65.9
Bars: 7 - heights: 51 66 59 107 104 124 79
Zero is not missing: a station that had not opened yet New tab

In the top chart, January to April look like a quiet station. In the bottom chart, they are marked as not applicable. The data was the same; the honest chart says more.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

bars with height: 4 | zero-height bars (invisible): 4 | marked n/a: 0
bars with height: 4 | zero-height bars (invisible): 0 | marked n/a: 4
All 25 stations, sorted, with labels measured before the margin is set New tab

The code is inside an async function because this example has no import, so it is a classic script. The 25 names are real; the longest one sets the left margin.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Stations: 25 | first: Quay 58584 | last: Windmill 10486
Every label fits in the left margin: true

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Create the rectangles without the SVG namespace

In the first stage step, change document.createElementNS(NS, "rect") to document.createElement("rect") and add console.log(svg.firstChild instanceof SVGRectElement); at the end.

terminal
$ Preview and Console
── what you'll see ──
Bars: 12 - they hang from the top, because y = 0 is the top
false
The preview is blank and no error is printed. The 12 nodes are in the page as <rect> tags (the count finds them), but they are HTMLUnknownElement objects, not SVG rectangles.

Break #2

Put the value into height without flipping

In the first example, change const height = Math.abs(y(r.value) - y(0)); to const height = y(0) - y(r.value);.

terminal
$ Console
── what you'll see ──
Error: <rect> attribute height: A negative value is not valid. ("-58.769553408739284")
Error: <rect> attribute height: A negative value is not valid. ("-107.46656581978202")
Error: <rect> attribute height: A negative value is not valid. ("-103.65833533881114")
Error: <rect> attribute height: A negative value is not valid. ("-124.08429882765496")
Error: <rect> attribute height: A negative value is not valid. ("-79.12366349228178")
domain -18996 to 10091 - the zero line is at y = 65.9
Bars: 7 - heights: 51 66 -59 -107 -104 -124 -79

Myth vs fact

Myth

A bar's height is the number of pixels you want to give it, so I can use the value directly.

Fact

A value (43,439 rides) and a pixel height (210) live in different units. A scale converts one to the other, and the bar's y must also be set, because y is the top edge, not the bottom.

Myth

A missing value and 0 are the same thing in a chart.

Fact

Zero is a real measurement ("counted none"). A missing value is no measurement. The Tram Depot example shows four months of zero for a station that did not exist: the data format cannot tell them apart, the chart has to.

Myth

Sorting is only about making the chart look neat.

Fact

Order is part of the message. Sorted by value, a bar chart answers "which is biggest?" at once. Kept in calendar order, it answers "how did it change over the year?". Pick the order that answers your question.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    getBBox() returns the box in the element's own coordinate units and needs the element to be in the document and rendered (inside a display: none parent it can return zeros or throw, depending on the browser). That is why the third example appends the labels first, measures, and only then positions them. getComputedTextLength() is a lighter alternative that gives only the width of a text element. Fonts matter too: if a web font is still loading, the measured width is the fallback font's, so measure after document.fonts.ready.

  • ▸

    Array.prototype.sort with (a, b) => b.total - a.total is only safe when every total is a finite number. A NaN or undefined makes the comparator return NaN, which the sort treats as "equal", and the order becomes implementation-dependent. Clean the data first (Phase 4), or compare with a guard.

  • ▸

    Bars from a band-like layout give you hit areas for free: each <rect> is a clickable element. With 25 bars that is fine, and it is also why a bar chart is a good first thing to make interactive (Phase 5.6, Phase 8). At several thousand bars, switch to Canvas (Phase 11).

Remember this

  1. 1

    The problem this solves: in Phase 0 you placed seven bars by hand. Data changes: next week there may be 12 bars, then 25 stations. You want code that works out each bar's width and position from the number of rows, so any amount of data fits, and a new row only means a longer array.

  2. 2

    One rectangle per row, made in the SVG namespace. You loop over the data and make a <rect> for each row with document.createElementNS("http://www.w3.org/2000/svg", "rect"). The NS version is required: document.createElement("rect") makes an HTML element that the browser does not know (HTMLUnknownElement). It accepts the attributes and draws nothing, with no error. Break it shows it.

  3. 3

    Slots, gaps and bar width come from the count. Divide the plot width by the number of bars to get a slot: slot = innerWidth ÷ count. Keep a share of each slot empty as a gap (here a fifth) and let the bar take the rest. The bar's x is i × slot + gap ÷ 2, so each bar sits in the middle of its slot. This is what a band scale does (Topic 6.4).

  4. 4

    **A bar stands on the baseline, so y is its top edge.** With a scale y(value) that maps 0 to the bottom, the bar's y attribute is y(value) and its height is innerHeight − y(value), down to the baseline. Putting the scale's result straight into height hangs the bars from the top (stage step 1 shows it). When the data has negative numbers, the bar runs from the zero line y(0) to y(value): its top is Math.min(y(value), y(0)) and its height is Math.abs(y(value) − y(0)). A negative height prints an error and draws nothing.

  5. 5

    Labels are text, placed by arithmetic. A month name goes under the slot's middle (text-anchor="middle"), and the value goes right above the bar. Long names do not fit under narrow bars, and SVG text does not cut itself with an ellipsis like HTML does. The usual answer is a horizontal bar chart: names on the left, bars to the right, and a left margin measured from the real labels with getBBox() (the third example).

  6. 6

    **Sorting changes what people see, and sort changes your data.** Unordered categories (stations) read best sorted by value; ordered ones (months, days) must keep their natural order. array.sort() sorts in place, so sort a copy ([...rows].sort(...)) or the chart's input order changes under you. Zero is not missing: a station that had 0 rides is a real zero, but a station that did not exist yet has no value at all. Draw the first as a bar of height 0 and the second as a marked gap (the second example).

Explain it without notes

01

How do you work out each bar's x position and width from the number of bars?

02

Why does a bar's y attribute not hold its height, and how do you draw a bar that goes below the zero line?

03

What is the difference between a value of 0 and a missing value, and how should each look in the chart?

04

Why measure the labels before choosing the left margin of a horizontal bar chart?

Practice

01

Make the monthly bar chart show the value labels with a thousands separator (for example 117,803 instead of 118k), and say what happens to the layout on a 12-bar chart.

02

Write bandX(i, count, width, gapShare) that returns { x, barWidth } for bar i. Test it with (0, 12, 460, 0.2) and (11, 12, 460, 0.2).

03

What should the chart do for an empty array of rows? Write the check and the message.

Trade-offs

  • ↔

    Writing the loop by hand takes about 20 lines and teaches you the geometry. When the chart has to update, animate or react to the mouse, you will want D3's data join (Phase 5) instead of rebuilding every rectangle.

  • ↔

    Sorted bars are quick to rank but break the natural order of months or days. When order has meaning (time), keep it. When it has none (stations), sort by value.

Done when you can

  • I can draw a bar chart from an array with createElementNS and a loop.

  • I can compute slot, gap, bar width and each bar's x from the number of bars.

  • I can set y and height so bars stand on the baseline, including bars with negative values.

  • I can tell zero from missing and draw each one differently.

  • I can sort a copy of the data and pick an order that matches the question.

  • I can measure labels with getBBox() and size the margin from them.