Command Palette

Search for a command to run...

Hectal
PHASE 13Advanced ~27 min· topic 3 of 7

Topic 13.3

Choropleths and Symbol Maps

In one line

A choropleth colours each area by a value, and a symbol map puts a circle on each place. Both are easy to draw and easy to get wrong: totals follow size, classes decide the colours, a circle's area (not its radius) must show the value, and zoom must not grow the symbols.

Think of it like this

A weather map. The colour of each region says how warm it is, and a snowflake or sun on a town says what is happening there. The first is a shaded map of areas. The second is a symbol placed on a point.

Words you'll meet

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

choropleth
A map in which each area is coloured by a value.
rate
A value divided by something, such as rides per dock. It stops a big place looking busy only because it is big.
class
One of the groups a scale cuts the values into. Each class has one colour.
quantize / quantile / threshold
Three ways to make classes: equal-width steps, equal-count groups, or breaks you pick.
symbol map
A map with a mark (usually a circle) at each place. Its size or colour shows a value.
scaleSqrt
A scale that gives the square root of the value, so a circle's area follows the value.
transform
In d3.zoom, the object { k, x, y }: scale and shift. Applied to a group it zooms and pans everything inside.

Step by step

01Make one row per district

The first example adds up each district's rides (from station-month.csv) and docks (from stations.csv). Harbour has 247,823 rides and 84 docks, so 2,950 rides per dock.

02Colour with classes, twice

The same four-colour scale is used for the totals and for rides per dock. The two maps do not look the same: Old Town drops from the darkest class to the third, and University from the third to the second. The busiest district is 5.4 times the quietest by total, and only 3.4 times by rides per dock.

03Look at the edge cases in the same example

If all six values are equal, scaleQuantize puts every district in its last class. A missing value and NaN both print "no data" and would get grey. Quantile classes (equal counts) give 4 4 2 3 1 1, quantize classes 4 3 2 2 2 1: the choice of method changes the map.

04Size circles by area

The second example puts a circle on every station. scaleSqrt makes the 30-dock station 2.5 times the area of the 12-dock one, which is the real ratio. A linear radius would make it 6.2 times. Zero, missing and negative values draw no circle.

05Zoom without growing the dots

The third example zooms the map 4 times on Quay. The layer is scaled by k, and the circles' r is 5 / k, so they are 5 pixels wide on the screen at every zoom. It also asks for 20 times and gets 8, the scaleExtent limit.

Zoom without growing the dotsdiagram
Rendering diagram…

Try it yourself

  1. 1

    Use equal-count classes

    In the first example, change the quantize helper to return d3.scaleQuantile(rows.map((d) => d[key]), classes). The per-dock classes become 4 4 2 3 1 1: Riverside (1,741) is now class 3 and University (1,703) class 2, although they differ by 38 rides per dock. Equal-count classes can split near-equal values.

  2. 2

    Choose your own breaks

    Replace the perDock scale with d3.scaleThreshold([1000, 1800, 2500], classes). Westpark (876) is class 1, Hillcrest (1,535) class 2, University (1,703) class 2, Old Town (2,245) class 3 and Harbour (2,950) class 4.

  3. 3

    Make the circles bigger

    In the second example, change [0, 16] to [0, 24]. The overlap count goes from 6 to 15. Bigger symbols need transparency and a drawing order, or they hide each other.

Code & diagrams

Districts coloured by total and by rides per dock New tab

Ardenvale has no population data, so the rate is rides per dock. A map that shows a count and a map that shows a rate are two different answers.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

district    |    rides | docks | per dock | class by total | class per dock
Harbour     |   247823 |    84 |     2950 |              4 |              4
Old Town    |   237979 |   106 |     2245 |              4 |              3
University  |   149867 |    88 |     1703 |              3 |              2
Riverside   |   135790 |    78 |     1741 |              2 |              2
Hillcrest   |   116649 |    76 |     1535 |              2 |              2
Westpark    |    45543 |    52 |      876 |              1 |              1
Busiest district against the quietest: total 5.4x, per dock 3.4x
all six equal: every district is class 4 | a missing value: no data | NaN: no data
quantile classes (about equal counts): 4 4 2 3 1 1 | quantize classes: 4 3 2 2 2 1
Symbols sized by area, with zero and missing values New tab

A square-root scale gives a 2.5 times bigger area for 2.5 times more docks.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

12 docks vs 30 docks: the value is 2.5x
  sqrt scale: radii 10.1 and 16.0 -> area 2.5x (right)
  linear radius: 6.4 and 16.0 -> area 6.2x (the 30-dock station looks about 6 times bigger)
radius for 0: 0 | missing: 0 | negative: 0 | raw scale given a missing value: undefined
circles: 25 | pairs that overlap: 6 | biggest first: Clock Tower 30
Zoom the map without growing the stations New tab

The zoom is moved by code here (`svg.call(zoom.transform, ...)`). A wheel, a double click and a pinch call the same handler.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

start                      | k 1.0 | x,y 0,0 | r in the file 5.00 | r on screen 5.0
zoom 4x on Quay            | k 4.0 | x,y -713,-703 | r in the file 1.25 | r on screen 5.0
ask for 20x (limit is 8)   | k 8.0 | x,y -1636,-1576 | r in the file 0.63 | r on screen 5.0
move the centre to the corner | k 8.0 | x,y 0,0 | r in the file 0.63 | r on screen 5.0
reset                      | k 1.0 | x,y 0,0 | r in the file 5.00 | r on screen 5.0

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

Use the raw value as the radius

In the second example, change .attr("r", (d) => safe(d.docks)) to .attr("r", (d) => d.docks).

terminal
$ Console
── what you'll see ──
circles: 25 | pairs that overlap: 16 | biggest first: Clock Tower 30

Break #2

Pass a missing value to the scale

In the second example, change (d) => safe(d.docks) to (d) => radius(d.id === "S05" ? undefined : d.docks), and in the last console.log add "circles with no radius:", circles.filter((c) => !c.hasAttribute("r")).length, "|", before "circles:".

terminal
$ Console
── what you'll see ──
circles with no radius: 1 | circles: 25 | pairs that overlap: 6 | biggest first: Clock Tower 30

Myth vs fact

Myth

A choropleth of totals is fine.

Fact

Totals follow the size of the place and the number of people. A rate (rides per dock, per 1,000 residents) compares places fairly.

Myth

More colours show more detail.

Fact

Beyond about six classes the eye cannot tell them apart, and a legend with ten swatches is unreadable.

Myth

A circle's radius can show the value.

Fact

People read area. Radius proportional to the value makes the biggest values look far too big.

Pro corner

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

  • ▸

    A choropleth hides how big each area is: a small district with a high rate looks unimportant next to a large one. For small areas, add a symbol map or a cartogram. Rates from tiny populations are also noisy, so show the count somewhere near.

  • ▸

    The class breaks can mislead on purpose or by accident. Show the legend with its numbers, and say how the breaks were chosen. scaleQuantize is sensitive to one extreme value: it stretches the equal-width classes and pushes everything else into the lowest class.

  • ▸

    Colour scales for maps: use a sequential scale for a rate (light to dark) and a diverging scale only when the data has a meaningful middle. Test both on a colour-blind simulation (Topic 10.2) and show contrast against the borders.

  • ▸

    Zooming in redraws the same shapes bigger, so a coarse map looks coarse. For a street-level map, load tiles or a finer file at higher zoom. vector-effect: non-scaling-stroke keeps border widths constant under the zoom transform in SVG.

Remember this

  1. 1

    The problem this solves: a table of 25 stations and six districts says nothing about where the busy part of the island is. A map answers "where" at once, but only if the colours and sizes tell the truth.

  2. 2

    A choropleth colours areas by one value. Each district gets a colour from a scale. The value must be a rate, not a plain count, whenever bigger areas or more people would make a bigger count anyway. A map of total rides mostly shows where the stations and the people are. Ardenvale has no population in the course data, so the fair rates here are rides per dock (use of what Lumen supplies) or rides per station. A real map of a city would divide by residents (rides per 1,000 people).

  3. 3

    The classes decide what the reader sees. d3.scaleQuantize cuts the range from smallest to largest into equal-width classes. d3.scaleQuantile makes classes with about the same number of districts. d3.scaleThreshold uses breaks you choose (such as 1,000 and 2,000 rides per dock). Four to six classes are readable. With six areas, quantize and quantile give different maps, as the first example prints. Always show the legend: the reader cannot read a colour without it.

  4. 4

    Missing data is not zero. A district with no value must not get the lowest colour. Give it a separate colour (light grey) and say so in the legend. A scale given undefined returns undefined, and fill: undefined makes the SVG default to black: a visible error, but not a helpful one.

  5. 5

    A symbol map puts a mark on every point. The mark's size shows a value. Because people read the area of a circle, the radius must be the square root of the value: d3.scaleSqrt. Then 30 docks looks 2.5 times as big as 12 docks. If the radius grows in step with the value, the area grows with its square and the same pair looks 6.2 times bigger. Zero and missing values need a rule (no circle). Draw the big circles first, so small ones are not hidden under them.

  6. 6

    Zoom is a transform on a group. d3.zoom() hands you a transform { k, x, y }. Applying it to one <g> that holds the districts and the circles pans and zooms them together. The circles would grow with k, so divide their radius by k, and use vector-effect: non-scaling-stroke on the borders to keep their thickness. Set scaleExtent (how far) and translateExtent (how far away) so the reader cannot lose the map.

Explain it without notes

01

Why is a choropleth of total rides less useful than one of rides per dock?

02

How do quantize, quantile and threshold scales differ, and when does each mislead?

03

Why must a circle's radius be the square root of the value?

04

How do you stop circles growing when the map zooms?

Practice

01

Add a legend to the choropleth: four squares with the class ranges (scale.thresholds() gives the breaks of a quantize scale).

02

Write circleRadius(value, max, maxRadius) that returns 0 for missing, zero or negative values, and a square-root scaled radius otherwise.

Trade-offs

  • ↔

    Equal-width classes keep gaps honest but can leave most areas in one colour. Equal-count classes spread the colours, but hide the size of the gaps.

  • ↔

    Symbols show exact places and can show two values (size and colour). Choropleths show whole areas but only one value, and favour large areas.

Done when you can

  • I can explain why a choropleth needs a rate and say what the rate is for Ardenvale.

  • I can build classes with quantize, quantile and threshold scales, and say how each can mislead.

  • I can size circles by area with scaleSqrt and handle zero and missing values.

  • I can zoom a map and keep symbols and lines the same size on the screen.

  • I can give missing data its own colour and a place in the legend.