Topic 5.5
Joins in SVG
In one line
Bars are a join of an array with <rect> elements: selectAll("rect").data(rows).join("rect"), then attributes from d. Run the same function again with new data and the chart updates, grows, shrinks or empties. Nested joins build groups, and a wrong field name leaves NaN in the attributes.
Think of it like this
A magnetic whiteboard with one magnet per station. When a new month starts, you do not throw all the magnets away and make new ones. You move the magnets that are still needed, add a magnet for each new station and take away those for closed ones. A join on SVG shapes is exactly that, and the "move" is changing x, y and height.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- draw function
- A function
draw(data)that makes the page match the data. Safe to call again and again. - nested join
- A join inside each element of another join, to make groups of marks.
- datum()
- Sets or reads the single value bound to an element. It does not join.
- empty state
- What the chart shows when there is no data: a sentence, not a blank box.
- NaN
- "Not a number": the result of maths on something that is not a number, such as
undefined / 600.
Step by step
01Step 1: bars from data
One join creates 12 <rect>s. The height is the month's rides divided by 600, the top is 220 minus that, so the bars stand on the baseline y = 220. The Console prints the count, the first bar's datum and the tallest height, 196.3 px. The 117,803 rides of July are the maximum.
02Step 2: the same code, called again with new data
The drawing moves into draw(data). It is called with the first 6 months, then with all 12. After the second call the first six elements are still the very same elements (checked with ===), and six new ones were added.
03Step 3: fewer rows, no rows, and the empty state
draw(months.slice(9)) leaves 3 bars (Oct, Nov, Dec): nine elements exited. draw([]) leaves none, and a second join, on a .empty text with a one-item or zero-item array, shows or hides "No data for this period". The note is itself a join: one item makes it, zero items remove it.
04Then group marks with a nested join
The first live example below joins four stations to four <g>s, then joins each station's 12 rows to 12 bars inside its group. Read the Console: 4 groups, 48 bars, and a bar's datum is a row of the file.
Try it yourself
- 1
Fix the field name
In the NaN example change both
d.totaltod.rides. The errors disappear, the bars get heights, andy of the first barprints77.6...(150 minus 43,439 ÷ 600). Check the first bar'sattr("y")withconsole.logany time a bar goes missing. - 2
Draw only the stations that had rides
In the nested example change the inner data to
(d) => d[1].filter((r) => r.rides > 0). S22's group now has 8 rects instead of 12 (44 bars in all), and the bars for Jan to Apr do not exist. The two S22 lines in the Console now read the wrong months: the line labelled "Jan-Apr" prints8.485,13.94,14.63,14.06(May to August) and "May height" prints12.485(September), because the code counts positions, not months. Dropping rows changes what every index means. Zero rides in January 2025 for a station that was not open yet is better shown as absence than as a zero bar: choose on purpose (Topic 4.4).
Code & diagrams
S22 has rows for January to April with 0 rides: the bars are there with height 0, so nothing is missing, and the 12 rects per station are 12 data rows.
Preview
Console
Nothing logged yet.Expected console output
groups: 4 - bars: 48
a group's datum: S01 with 12 rows
a bar's datum: { station: 'S01', month: 'Jan', rides: 2994 }
S22 Jan-Apr heights: 0,0,0,0 (zero, not missing)
S22 May height: 8.485Without `output` this example would fail the course's NaN check: here the failure is the point. The browser prints one error per bad attribute, and the bars are in the page but have no size.
Preview
Console
Nothing logged yet.Expected console output
Error: <rect> attribute y: Expected length, "NaN".
Error: <rect> attribute y: Expected length, "NaN".
Error: <rect> attribute y: Expected length, "NaN".
Error: <rect> attribute height: Expected length, "NaN".
Error: <rect> attribute height: Expected length, "NaN".
Error: <rect> attribute height: Expected length, "NaN".
bars: 3
y of the first bar: NaN
datum.total: undefinedBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
A field name that does not exist
In the NaN example the code reads d.total, but the data has rides.
Myth vs fact
Myth
selectAll("rect").data(rows) draws the bars.
Fact
It only pairs rows with existing rectangles. join("rect") (or enter().append) creates the missing ones, and the attr calls give them shapes.
Myth
Calling the draw function twice draws twice.
Fact
With a join, a second call finds the first call's rectangles and updates them. It draws twice only if you append without a join.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
selection.jointakes your enter function and returns the merged enter and update selections, followed by the exit being removed (unless your exit function keeps them). So code afterjoinruns for new and kept elements. Put per-element creation (children, classes that never change) in the enter function and per-update values after the join. The creation then runs once per element, not once per update. - ▸
The
din anattrcallback is read when the attribute is set. If the data array is mutated later, nothing updates untildrawis called again. D3 has no reactive binding: you calldraw. - ▸
Two more data cases to know. A
nullrow inside the array still gets an element, andd.rideson it throwsTypeError: Cannot read properties of nullpartway through the join, leaving the chart half drawn: remove such rows before the join. A very long array (thousands of marks) works but creates thousands of DOM nodes; Phase 12 measures where SVG becomes slow.
Remember this
- 1
The problem this solves: the bar chart of Phase 3 was a loop that created elements. Calling it twice made twice as many. A chart as a function of the data (
draw(data)) that you can call any number of times, with the page always matching the latest data, is what every dashboard needs. The join is what makesdrawsafe to repeat. - 2
The shape of every D3 chart.
svg.selectAll("rect").data(rows).join("rect"), then.attr("x", ...),.attr("y", ...),.attr("width", ...),.attr("height", ...)as functions ofdandi. First time: the selection is empty, so every row enters and a rectangle is created per row. Second time, with new data: existing rectangles update in place, extra rows enter, missing rows exit and are removed. The code is the same on every call. - 3
Build it one change at a time. The stage on the right of the steps builds this: step 1 draws 12 bars from data; step 2 moves the code into
draw(data)and calls it with 6 months and then 12, which reuses the first six elements; step 3 shows 3 months, no months, and back, with an empty-state note drawn through another join. - 4
Nested joins make groups. A first join makes one
<g>per station; each<g>carries the station's rows. A second joing.selectAll("rect").data((d) => d.values)makes the bars inside every group. The data function is called once pergwith that group's datum, so each group joins its own array.selectAllafter a join starts a new join per parent. The first live example draws four stations by twelve months this way. - 5
**
datumversusdata.**selection.data(array)joins an array to many elements (enter, update and exit).selection.datum(value)only sets the bound value of each selected element, with no join and no groups.selection.datum()with no argument reads the first element's value. Andselect("rect")(singular) copies the parent's datum to the child, which is why a group's bar and label can both used, as the build topic does. - 6
Failures. If an attribute function returns
NaN(a field with a different name, text that is not a number, a division by zero), the browser printsError: <rect> attribute height: Expected length, "NaN".for each bad attribute and draws nothing for that bar. Nothing else warns you. The second example shows it withd.totalwhere the data saysrides. Zero is fine: a zero-height bar is drawn as nothing, and zero is a value, not a missing value.
Explain it without notes
Why can you call draw(data) many times and get one correct chart?
What does groups.select("rect") do for the data that groups.selectAll("rect") does not?
What is the difference between data() and datum()?
Practice
Write draw(rows) for rows like { id, name, rides } that draws one <rect class="bar"> per row, keyed by id, with the width rides / 100. Say what the second call with a shorter array does.
Add a guard so that rows whose rides is not a finite number are left out, and log how many were left out.
Trade-offs
- ↔
Drawing from scratch (
svg.selectAll("*").remove()then append) is simpler to write and loses element identity, focus and animation state. Use it only for charts that never animate or take focus.
Done when you can
I can draw bars from an array with
selectAll,data,joinandattrcallbacks.I can call the same
drawagain and explain what happens to old and new elements.I can build groups with a nested join and say what
selectpasses down.I can say when to use
datum()instead ofdata().