Visual Hierarchy Explained: What a Map Reader Sees First

On this page

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 tiers of map element, from the subject at full contrast to the apparatus. four tiers, and only one toptier 1 โ€” subjectthe mapped variablefull saturation, widest lightness rangetier 2 โ€” contextboundaries, basemapdesaturated, thintier 3 โ€” referencegraticule, sea, neighbourscontrast 2.56:1 โ€” barely theretier 4 โ€” apparatuslegend, scale, creditssmall, quiet, legibleIf two elements are competing, one of them is in the wrong tier spatialworkflow.io
The basemap arriving at full colour is the usual way tier 2 ends up in tier 1.

One sentence, then a tier for every element

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.

Measured WCAG contrast ratios on white for the greys a map uses. what each grey is allowed to carry#1e293b ink14.63 : 1#1a3a6b indigo11.28 : 1#475569 slate7.58 : 1#64748b muted4.76 : 1#94a3b8 faint2.56 : 1#e2e8f0 rule1.23 : 1015Text needs 4.5:1, graphical elements 3:1 โ€” so the bottom two carry no meaning spatialworkflow.io
A graticule at 2.56:1 is texture, not information โ€” which is usually what it should be.

A hierarchy applied to Iowa's counties

The data used on this page

The map in Examples 1 and 2 is real: Iowa's 99 counties from the Census Bureau's 2023 cartographic boundary file as the subject, shaded by land area; the counties of the six neighbouring states as context; and the state outlines as reference. The colours audited in Example 3 are the ones this page names.

import os

import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import numpy as np

if not os.path.exists("districts.gpkg"):
    counties = gpd.read_file("https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_500k.zip")
    counties[["GEOID", "NAME", "STATE_NAME", "ALAND", "geometry"]].rename(
        columns={"STATE_NAME": "region"}).to_file("districts.gpkg")
districts = gpd.read_file("districts.gpkg").to_crs(5070)
districts["land_km2"] = districts.ALAND / 1e6
subject = districts[districts.region == "Iowa"]
context = districts[districts.region.isin(["Minnesota", "Wisconsin", "Illinois", "Missouri",
                                           "Nebraska", "South Dakota"])]
reference = districts[districts.region.isin(["Iowa", *context.region.unique()])].dissolve("region").boundary.to_frame("geometry")
print(len(subject), len(context), len(reference))
99 535 7

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


fig, ax = plot_with_hierarchy(subject, context, reference, column="land_km2")
fig.savefig("hierarchy.png", dpi=150)
print(f"{len(subject)} subject polygons over {len(context)} context and {len(reference)} reference")
print(f"Iowa county land area: {subject.land_km2.min():,.0f} to {subject.land_km2.max():,.0f} kmยฒ, "
      f"coefficient of variation {subject.land_km2.std() / subject.land_km2.mean():.2f}")
99 subject polygons over 535 context and 7 reference
Iowa county land area: 986 to 2,519 kmยฒ, coefficient of variation 0.21

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


_ = squint_test(fig)
downscaled to 45ร—40
lightness spread (std):  17.1   everything is the same weight
darkest region at row 18, col 21 โ€” is that where your subject is?

On the Iowa map the test reports a spread of 17.1, under its own threshold of 25. That is a fair verdict on this map rather than a fault in the test: Iowa's counties vary little in land area โ€” a coefficient of variation of 0.21 โ€” so the subject occupies a narrow band of the ramp, and at thumbnail size little separates it from its context. A subject with more variation, or a ramp stretched to the data's range, is what would change it.

The automated squint test: render, shrink, drop the colour and measure the lightness spread. what squint_test does1render the figure at 100 dpithe same pixels a reader gets2shrink it twelvefolddetail goes; masses stay3convert to greyscalehue goes; lightness stays4measure the spreadIowa: 17.1, threshold 25Whatever survives the shrink and the grey is the map's real tier 1 spatialworkflow.io
The test measures what a glance sees, which is why it can disagree with the style sheet.

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}")


audit_layers({"subject-dark": "#1a3a6b", "labels": "#475569", "coastline": "#64748b",
              "graticule": "#94a3b8", "hairline": "#e2e8f0"})
layer            colour     ratio  reads as
subject-dark     #1a3a6b    11.28  tier 1 / type
labels           #475569     7.58  tier 1 / type
coastline        #64748b     4.76  tier 2
graticule        #94a3b8     2.56  tier 3 โ€” quiet
hairline         #e2e8f0     1.23  invisible

Example 4 โ€” the lightness span of two standard ramps

import matplotlib
from skimage.color import rgb2lab

for name in ("viridis", "YlGnBu"):
    rgb = matplotlib.colormaps[name](np.linspace(0, 1, 256))[:, :3]
    L = rgb2lab(rgb.reshape(1, -1, 3))[0, :, 0]
    print(f"{name:8} L* {L.min():.1f} to {L.max():.1f}")
viridis  L* 14.9 to 90.9
YlGnBu   L* 13.4 to 99.1

Lightness, basemaps and the space budget

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.

How many labels fit on a map before it is completely tiled with text. the space every element competes formeasuredone 7 pt label78 ร— 10 pxplotting area620 ร— 462 pxlabels to tile it!354realistic ceilingโœ“~59 with white spaceLabelling the 200 largest places in a Europe window produced 204 overlapping pairs spatialworkflow.io
Every element added takes from a budget that is smaller than it feels.

Print, small multiples, zoom levels and legends

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