Visual Hierarchy Explained: What a Map Reader Sees First

Problem statement

A map that is correct can still be unreadable. Everything is present โ€” the data, the basemap, the labels, the legend, the north arrow, the scale bar, the credits โ€” and the reader's eye has nowhere to land.

The usual symptom is a map where every element competes: full-saturation categorical colours over a full-colour basemap, black 1 pt outlines on every polygon, forty labels at the same size, and a legend as visually loud as the data it explains.

Visual hierarchy is the set of decisions that says which element wins. It is not decoration; it is the difference between a figure a reader understands in two seconds and one they give up on. And it is measurable in places people rarely measure: a 7 pt label occupies about 78 ร— 10 pixels, and the plotting area of a standard 8 ร— 6 inch figure is 620 ร— 462 pixels โ€” so 354 labels of that size would tile the entire map with no gaps. Everything you add takes space from something else.

Quick answer

Rank your elements before you style them, then style them in that order:

# Tier 1 โ€” the thing the map is about.        Highest contrast, most saturation.
# Tier 2 โ€” what makes tier 1 readable.        Muted, present, never competing.
# Tier 3 โ€” orientation and reference.         Quiet; the reader looks for these.
# Tier 4 โ€” the apparatus.                     Legible and last.

HIERARCHY = {
    "subject":    dict(zorder=5, alpha=1.00, linewidth=0.8, edgecolor="white"),
    "context":    dict(zorder=3, alpha=0.55, linewidth=0.4, edgecolor="#cbd5e1",
                       facecolor="#f1f5f9"),
    "reference":  dict(zorder=2, alpha=0.35, linewidth=0.3, color="#94a3b8"),
    "apparatus":  dict(zorder=9, fontsize=7, color="#475569"),
}

context.plot(ax=ax, **HIERARCHY["context"])      # draw the quiet things first
subject.plot(ax=ax, column="rate", cmap="YlGnBu", **HIERARCHY["subject"])

If two elements are in the same tier, one of them is in the wrong tier.

Four stacked tiers: subject, context, reference and apparatus, with their treatments.
The basemap arriving at full colour is the usual way tier 2 ends up in tier 1.

Step-by-step solution

1. Write down what the map is about, in one sentence

If the sentence has an "and" in it, you have two maps. That is a legitimate finding โ€” small multiples exist for exactly this reason โ€” but it must be decided before styling, because a hierarchy can only have one top.

The sentence also settles arguments later. "Unemployment rate by district" means the rate is tier 1 and the districts are tier 2, so district boundaries are thin and grey, not black.

2. Assign every element to a tier

Four tiers are enough for almost any map:

Tier What belongs Treatment
1 โ€” subject the variable being mapped full saturation, highest contrast
2 โ€” context boundaries, coastline, basemap desaturated, lightened, thin
3 โ€” reference graticule, neighbouring areas, sea barely there
4 โ€” apparatus legend, scale bar, north arrow, credits small, quiet, legible

The common mistake is putting the basemap in tier 1 by accident, because it arrived at full colour and nobody turned it down.

3. Use the four visual variables you actually have

Hierarchy is produced by contrast, and there are only a few kinds available on a static map:

  • Lightness contrast โ€” the strongest and the most reliable. It survives greyscale printing and colour-blind vision.
  • Saturation โ€” a saturated shape reads as nearer and more important than a desaturated one.
  • Size and weight โ€” line width, marker size, type size.
  • Position โ€” the centre and the top-left are read first in left-to-right reading cultures.

Hue is not on that list. Hue distinguishes categories; it does not rank them, and using hue to signal importance is exactly the mistake that produces a rainbow map where nothing dominates.

4. Turn everything down that is not tier 1

The fastest route to a clear map is subtraction. Concretely:

ax.set_axis_off()                       # frame and ticks: rarely tier 1
basemap.set_alpha(0.35)                 # context, not subject
for spine in ax.spines.values():
    spine.set_visible(False)
gridlines.set_linewidth(0.3)
gridlines.set_color("#cbd5e1")

Measured against a white background, #94a3b8 has a contrast ratio of 2.56:1 and #64748b has 4.76:1. That is the working range for tier 3 and tier 4: visible when looked for, invisible when not. #1a3a6b at 11.28:1 belongs to tier 1 or to type, not to a graticule.

5. Give tier 1 the whole lightness range

If the subject is a choropleth, its colour ramp should use the full available lightness span, and nothing else on the map should. Measured on the standard sequential ramps, viridis runs from L* 14.9 to 90.9 and YlGnBu from 13.4 to 99.1 โ€” a range wide enough that the data alone carries the eye.

Then keep everything else inside a narrow band near the top of that range. A context layer that dips into the middle of the ramp's lightness range starts competing with the data.

6. Check the hierarchy by squinting, in greyscale

Convert the figure to greyscale and look at it small. Whatever is still obvious is tier 1. If the coastline or the legend survives and the data does not, the hierarchy is inverted โ€” and this is the one test that catches it in five seconds.

Bar chart of contrast ratios on white for six greys, from 14.63:1 down to 1.23:1.
A graticule at 2.56:1 is texture, not information โ€” which is usually what it should be.

Code examples

Example 1 โ€” a hierarchy applied as a style dictionary

import matplotlib.pyplot as plt

TIERS = {
    1: dict(alpha=1.00, linewidth=0.8, edgecolor="white", zorder=5),
    2: dict(alpha=0.55, linewidth=0.4, edgecolor="#cbd5e1", facecolor="#eef2f7", zorder=3),
    3: dict(alpha=0.30, linewidth=0.3, edgecolor="#cbd5e1", facecolor="#f8fafc", zorder=2),
}
APPARATUS = dict(fontsize=7, color="#475569")


def plot_with_hierarchy(subject, context=None, reference=None, *, column,
                        cmap="YlGnBu", figsize=(8, 6)):
    fig, ax = plt.subplots(figsize=figsize)
    if reference is not None:
        reference.plot(ax=ax, **TIERS[3])
    if context is not None:
        context.plot(ax=ax, **TIERS[2])
    subject.plot(ax=ax, column=column, cmap=cmap, legend=True,
                 legend_kwds={"shrink": 0.45, "label": column}, **TIERS[1])
    ax.set_axis_off()
    for text in ax.get_figure().findobj(plt.Text):
        if text.get_text():
            text.set(**APPARATUS)
    return fig, ax

Keeping the tiers in a dictionary rather than scattered through the call sites is what makes the hierarchy reviewable โ€” and what lets a second map in the same report inherit it exactly.

Example 2 โ€” the greyscale squint test, automated

import io
import numpy as np
from PIL import Image


def squint_test(fig, downscale=12):
    """Render small and grey: whatever is still visible is your tier 1."""
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=100, bbox_inches="tight")
    buf.seek(0)
    img = Image.open(buf).convert("L")
    small = img.resize((img.width // downscale, img.height // downscale),
                       Image.LANCZOS)
    arr = np.asarray(small, dtype=float)

    contrast = arr.std()
    darkest = np.unravel_index(arr.argmin(), arr.shape)
    print(f"downscaled to {small.size[0]}ร—{small.size[1]}")
    print(f"lightness spread (std): {contrast:5.1f}   "
          f"{'good separation' if contrast > 25 else 'everything is the same weight'}")
    print(f"darkest region at row {darkest[0]}, col {darkest[1]} "
          f"โ€” is that where your subject is?")
    return small

Example 3 โ€” auditing contrast before you commit to a palette

def relative_luminance(rgb):
    import numpy as np
    c = np.asarray(rgb, dtype=float)
    lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
    return float(0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2])


def contrast_ratio(fg, bg=(1, 1, 1)):
    l1, l2 = relative_luminance(fg), relative_luminance(bg)
    hi, lo = max(l1, l2), min(l1, l2)
    return (hi + 0.05) / (lo + 0.05)


def audit_layers(layers: dict, background=(1, 1, 1)):
    """layers: {'coastline': '#94a3b8', 'labels': '#1e293b', ...}"""
    import matplotlib.colors as mc
    print(f"{'layer':16} {'colour':9} {'ratio':>6}  reads as")
    for name, colour in layers.items():
        ratio = contrast_ratio(mc.to_rgb(colour), background)
        tier = ("tier 1 / type" if ratio >= 7 else
                "tier 2" if ratio >= 4.5 else
                "tier 3 โ€” quiet" if ratio >= 2 else
                "invisible")
        print(f"{name:16} {colour:9} {ratio:6.2f}  {tier}")
layer            colour      ratio  reads as
subject-dark     #1a3a6b     11.28  tier 1 / type
labels           #475569      7.55  tier 1 / type
coastline        #64748b      4.76  tier 2
graticule        #94a3b8      2.56  tier 3 โ€” quiet
hairline         #e2e8f0      1.23  invisible

Explanation

Why lightness does the work and hue does not

The human visual system separates lightness from colour early and processes it with more spatial acuity. That has three consequences for maps: lightness contrast survives at small sizes, it survives greyscale reproduction, and it survives every form of colour vision deficiency.

Hue does none of those reliably. Two colours that differ only in hue can be indistinguishable to a reader with deuteranopia โ€” measured on the standard tab10 palette, the minimum perceptual distance between its ten colours falls from ฮ”E 27.7 in normal vision to 7.2 under deuteranopia and 4.6 under protanopia, with four of the forty-five pairs closer than ฮ”E 10.

So a hierarchy built on hue is a hierarchy that some readers do not see at all.

Why the basemap is the usual culprit

A basemap tile arrives designed to be looked at. It has its own labels, its own colour scheme, and its own visual hierarchy โ€” one built for interactive browsing, not for sitting behind your data.

Dropped in at full opacity it is tier 1, competing with the layer it is supposed to support. The fix is mechanical: reduce opacity to around a third, or choose a deliberately quiet basemap style, and accept that the basemap's job is to answer "where is this?" and nothing else.

Why "add a north arrow" is often the wrong instinct

Map elements are frequently added because a checklist says so rather than because a reader needs them. Each one consumes space and attention that the subject could have used.

A north arrow earns its place when the map is rotated or the orientation is genuinely ambiguous; on a north-up map of a familiar country it is noise. The same test applies to graticules, neighbouring-country labels and the frame. The question is never "is this a normal map element?" but "does removing it cost the reader anything?"

Why the space argument is a real constraint

The tiling arithmetic at the top is not rhetorical. A 7 pt label measured 78.3 ร— 10.3 pixels in a figure whose plotting area was 620 ร— 462 pixels, so 354 such labels fill the map completely.

Real maps place far fewer than that and still collide: labelling the 200 largest places in a Europe-sized window produced 204 overlapping label pairs. Hierarchy is partly a way of deciding what does not get drawn, and the space budget is what makes that decision unavoidable rather than optional.

Table of label size, plotting area, tiling capacity and a realistic label ceiling.
Every element added takes from a budget that is smaller than it feels.

Edge cases or notes

  • Print and screen differ. A hierarchy tuned on a bright screen can collapse on paper; check a greyscale print of anything important.
  • Small multiples need a shared hierarchy, or the reader re-learns the map on every panel.
  • Interactive maps can defer tiers to zoom levels; static maps cannot.
  • Do not use transparency to create hierarchy on overlapping polygons โ€” the overlaps become a fifth colour nobody chose.
  • Legends inherit the tier of what they explain, but at apparatus size.
  • Dark backgrounds invert the lightness logic but not the principle; check contrast against the actual background.
  • Ranking by hue fails for 1 in 12 men. Use lightness or size for anything ordered.
  • If everything is emphasised, nothing is. Removing an element is a styling decision too.

FAQ

What is visual hierarchy on a map?

The deliberate ranking of elements so the reader's eye reaches the subject first. It is produced by contrast in lightness, saturation, size and position โ€” not by hue.

How many tiers should a map have?

Four is enough: subject, context, reference and apparatus. If two elements are competing, one of them is in the wrong tier.

Why is my map hard to read even though everything is correct?

Usually the basemap or the boundaries are at full strength, so they compete with the data. Turn everything that is not the subject down until the subject is obviously first.

How do I test the hierarchy quickly?

Render it small and in greyscale. Whatever is still obvious is your tier 1; if the coastline survives and the data does not, the hierarchy is inverted.

Can I use colour alone to show importance?

No. Under deuteranopia the minimum distance between the ten tab10 colours drops from ฮ”E 27.7 to 7.2. Rank with lightness or size, and use hue only for categories.

How much can I fit on one map?

Less than you think. A 7 pt label is about 78 ร— 10 pixels and a standard figure's plotting area is 620 ร— 462 โ€” 354 labels would tile it completely, and 200 real labels already produced 204 overlapping pairs.