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;createElementNScreates 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
Make the bars thinner
In the first example, change
slot * 0.1toslot * 0.25(the x offset) andslot * 0.8toslot * 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
Sort the other way
In the third example, change
b.total - a.totaltoa.total - b.total. The log now readsfirst: Windmill 10486 | last: Quay 58584and the longest bar is at the bottom. The chart is equally correct; smallest-first is a different story ("who needs help?"). - 3
Mark a different gap
In the second example, change
const opened = 4;toconst opened = 5;so that May also counts as "not open". The last log line becomesbars 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
The orange bars are negative. Labels of negative bars sit above the zero line, so they never run into the bars.
Preview
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 79In 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
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: 4The 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
Console
Nothing logged yet.Expected console output
Stations: 25 | first: Quay 58584 | last: Windmill 10486
Every label fits in the left margin: trueBreak 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.
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);.
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 adisplay: noneparent 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 afterdocument.fonts.ready. - ▸
Array.prototype.sortwith(a, b) => b.total - a.totalis only safe when everytotalis a finite number. ANaNorundefinedmakes the comparator returnNaN, 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
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
One rectangle per row, made in the SVG namespace. You loop over the data and make a
<rect>for each row withdocument.createElementNS("http://www.w3.org/2000/svg", "rect"). TheNSversion 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
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'sxisi × slot + gap ÷ 2, so each bar sits in the middle of its slot. This is what a band scale does (Topic 6.4). - 4
**A bar stands on the baseline, so
yis its top edge.** With a scaley(value)that maps 0 to the bottom, the bar'syattribute isy(value)and itsheightisinnerHeight − y(value), down to the baseline. Putting the scale's result straight intoheighthangs the bars from the top (stage step 1 shows it). When the data has negative numbers, the bar runs from the zero liney(0)toy(value): its top isMath.min(y(value), y(0))and its height isMath.abs(y(value) − y(0)). A negative height prints an error and draws nothing. - 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 withgetBBox()(the third example). - 6
**Sorting changes what people see, and
sortchanges 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
How do you work out each bar's x position and width from the number of bars?
Why does a bar's y attribute not hold its height, and how do you draw a bar that goes below the zero line?
What is the difference between a value of 0 and a missing value, and how should each look in the chart?
Why measure the labels before choosing the left margin of a horizontal bar chart?
Practice
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.
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).
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
createElementNSand a loop.I can compute slot, gap, bar width and each bar's x from the number of bars.
I can set
yandheightso 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.