Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~20 min· topic 5 of 7

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. 1

    Fix the field name

    In the NaN example change both d.total to d.rides. The errors disappear, the bars get heights, and y of the first bar prints 77.6... (150 minus 43,439 ÷ 600). Check the first bar's attr("y") with console.log any time a bar goes missing.

  2. 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" prints 8.485,13.94,14.63,14.06 (May to August) and "May height" prints 12.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

Nested joins: four stations by twelve months New tab

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

Press Run to see the page.

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.485
A join that leaves NaN in the attributes New tab

Without `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

Press Run to see the page.

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: undefined

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

A field name that does not exist

In the NaN example the code reads d.total, but the data has rides.

terminal
$ Console
── what you'll see ──
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: undefined

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.join takes 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 after join runs 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 d in an attr callback is read when the attribute is set. If the data array is mutated later, nothing updates until draw is called again. D3 has no reactive binding: you call draw.

  • ▸

    Two more data cases to know. A null row inside the array still gets an element, and d.rides on it throws TypeError: Cannot read properties of null partway 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. 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 makes draw safe to repeat.

  2. 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 of d and i. 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. 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. 4

    Nested joins make groups. A first join makes one <g> per station; each <g> carries the station's rows. A second join g.selectAll("rect").data((d) => d.values) makes the bars inside every group. The data function is called once per g with that group's datum, so each group joins its own array. selectAll after a join starts a new join per parent. The first live example draws four stations by twelve months this way.

  5. 5

    **datum versus data.** 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. And select("rect") (singular) copies the parent's datum to the child, which is why a group's bar and label can both use d, as the build topic does.

  6. 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 prints Error: <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 with d.total where the data says rides. Zero is fine: a zero-height bar is drawn as nothing, and zero is a value, not a missing value.

Explain it without notes

01

Why can you call draw(data) many times and get one correct chart?

02

What does groups.select("rect") do for the data that groups.selectAll("rect") does not?

03

What is the difference between data() and datum()?

Practice

01

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.

02

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, join and attr callbacks.

  • I can call the same draw again and explain what happens to old and new elements.

  • I can build groups with a nested join and say what select passes down.

  • I can say when to use datum() instead of data().