Implementing Visual Hierarchy with Matplotlib
Set zorder, alpha, marker s, and fontweight explicitly on every Matplotlib artist — base geography at zorder=1, thematic layers at zorder=2–3, annotations at zorder=5+ — and automated map exports will have deterministic, cartographically correct visual hierarchy regardless of data volume.
Core Algorithm and Workflow
Matplotlib renders artists in ascending zorder order, drawing lower values first. By treating this as a rank system rather than an incidental property, you gain full control over what the viewer’s eye reaches first. The approach maps directly onto the three programmable levers that Visual Hierarchy in Code codifies for automated pipelines: layer stacking order, perceptual weight via alpha and size, and typographic emphasis.
The workflow proceeds in six deterministic steps:
- Initialise a fixed canvas. Set
figsize,dpi, axis limits, andfacecolorbefore touching any data. Callingax.set_autoscale_on(False)locks the coordinate space sozordercomparisons remain stable across every item added later — without this,autoscalecan trigger a full artist re-sort mid-loop. - Render base geography at
zorder=1. Low-saturation polygon fills and thinedgecolorstrokes push land/sea context into the background perceptually and technically. - Add transport networks and boundaries at
zorder=2. Mediumlinewidthwithalpha=0.8lets base geography show through junctions without dominating the thematic signal. - Plot thematic data at
zorder=3. Scatter point area encodes the primary quantitative variable via a square-root transform; a perceptually uniform colormap (as covered in Color Theory for GIS) encodes the secondary variable. - Place annotation halos at
zorder=4. A whitebboxpatch behind eachax.textcall prevents label occlusion without covering data atzorder=3. - Draw typographic UI elements at
zorder=5. Title, axis labels, scale indicator, and north arrow sit above all data layers and are never obscured.
Production-Ready Python Implementation
The following script enforces the complete six-level stacking strategy using synthetic geospatial data. Drop in real GeoDataFrame geometry by replacing the polygon and coordinate arrays with gdf.plot(ax=ax, ...) calls — the zorder and alpha logic transfers directly.
import matplotlib
matplotlib.use("Agg") # Headless backend for CI/CD and batch pipelines
import matplotlib.pyplot as plt
import numpy as np
# ── Canvas initialisation ──────────────────────────────────────────────────
fig, ax = plt.subplots(figsize=(8.5, 11), dpi=300)
ax.set_facecolor("#F8F9FA") # Neutral off-white reduces visual fatigue
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.set_aspect("equal")
# Fix limits BEFORE adding any artists to prevent auto-scale from
# reordering the draw list in batch loops.
ax.set_autoscale_on(False)
# ── zorder 1: Base geography ───────────────────────────────────────────────
land_poly = plt.Polygon(
[(0.05, 0.05), (0.95, 0.05), (0.88, 0.88), (0.12, 0.92)],
facecolor="#E2E8F0", # Muted blue-grey recedes perceptually
edgecolor="#94A3B8",
linewidth=1.0,
zorder=1,
)
ax.add_patch(land_poly)
# Internal sub-region boundary — same zorder, lighter stroke
sub_region = plt.Polygon(
[(0.3, 0.1), (0.7, 0.15), (0.65, 0.55), (0.28, 0.5)],
facecolor="none",
edgecolor="#CBD5E1",
linewidth=0.6,
linestyle="--",
zorder=1,
)
ax.add_patch(sub_region)
# ── zorder 2: Transport network ────────────────────────────────────────────
# alpha=0.8 lets the base polygon edge show through at intersections
ax.plot(
[0.15, 0.50, 0.85], [0.18, 0.55, 0.22],
color="#3B82F6", linewidth=2.2, alpha=0.8,
solid_capstyle="round", zorder=2, label="Primary corridor",
)
ax.plot(
[0.25, 0.50, 0.60], [0.75, 0.55, 0.30],
color="#64748B", linewidth=1.4, alpha=0.7,
linestyle=(0, (6, 3)), zorder=2, label="Secondary corridor",
)
# ── zorder 3: Primary thematic scatter layer ───────────────────────────────
rng = np.random.default_rng(42)
n_obs = 28
x_obs = rng.uniform(0.12, 0.88, n_obs)
y_obs = rng.uniform(0.12, 0.88, n_obs)
pop_density = rng.uniform(50, 9800, n_obs) # realistic range (persons/km²)
# Square-root transform prevents extreme outliers from dominating marker size
s_min, s_max = 30, 320
sqrt_vals = np.sqrt(pop_density)
marker_area = np.interp(sqrt_vals, (sqrt_vals.min(), sqrt_vals.max()), (s_min, s_max))
scatter = ax.scatter(
x_obs, y_obs,
c=pop_density,
cmap="YlOrRd", # Sequential, perceptually uniform for density
vmin=0, vmax=10000, # Fixed across all batch exports for comparability
s=marker_area,
edgecolors="white",
linewidths=0.8,
alpha=0.92,
zorder=3,
label="Settlement density (per km²)",
)
# ── zorder 4: Annotation halos ─────────────────────────────────────────────
# Identify the two highest-density observations for labelling
top2_idx = np.argsort(pop_density)[-2:]
for idx in top2_idx:
ax.annotate(
f"{pop_density[idx]:,.0f}",
xy=(x_obs[idx], y_obs[idx]),
xytext=(x_obs[idx] + 0.05, y_obs[idx] + 0.04),
fontsize=8,
color="#1E293B",
arrowprops=dict(arrowstyle="-", color="#94A3B8", lw=0.8),
bbox=dict(facecolor="white", edgecolor="none", alpha=0.85, pad=2),
zorder=4,
)
# ── zorder 5: Typographic UI ───────────────────────────────────────────────
ax.set_title(
"Regional Settlement Density",
fontsize=15, fontweight="bold", pad=14, loc="left", zorder=5,
)
ax.set_xlabel("Easting (projected)", fontsize=9, color="#475569", zorder=5)
ax.set_ylabel("Northing (projected)", fontsize=9, color="#475569", zorder=5)
# Colorbar — create before tight_layout to allow space reservation
cbar = fig.colorbar(scatter, ax=ax, fraction=0.03, pad=0.02)
cbar.set_label("Population density (per km²)", fontsize=8, color="#475569")
cbar.ax.tick_params(labelsize=7)
# ── Legend and export ──────────────────────────────────────────────────────
ax.legend(
loc="lower right",
framealpha=0.92,
fontsize=8,
edgecolor="#CBD5E1",
)
fig.tight_layout(rect=[0, 0, 0.92, 1]) # Reserve right margin for colorbar
fig.savefig("settlement_density_export.png", dpi=300, bbox_inches="tight")
plt.close(fig)
Performance Tuning and Cartographic Best Practices
-
Precompute marker areas and color mappings before the plot loop. In a batch pipeline generating hundreds of regional maps, calling
np.interpandplt.Normalizeinside each iteration multiplies CPU time needlessly. Build a sharednorm = matplotlib.colors.Normalize(vmin=0, vmax=10000)and a precomputedsarray, then pass them into everyax.scattercall. This decouples design computation from rendering and cuts batch time by 20–40% on datasets with thousands of features. -
Fix
vmin/vmaxacross all maps in a series. Dynamic color scaling — letting Matplotlib auto-range per map — makes panels visually incomparable. Always pass explicitvminandvmaxthat span the global data range of your entire batch. The DPI and Resolution Management workflow extends this principle to print-ready outputs where consistent scale across panels is a production requirement. -
Store hierarchy configurations externally. Externalise
zorderoffsets, color palettes,linewidthvalues, andfontsizesettings in a YAML or JSON file, then inject them into your plotting function at runtime. This separates design rules from data pipelines: a cartographer can update visual weights without touching rendering code. -
Use
plt.close(fig)in every batch loop. Matplotlib accumulatesFigureobjects in memory if you do not close them. In a loop producing 500 exports, this exhausts RAM silently. TheAggheadless backend does not display figures, soplt.close(fig)has no user-visible effect but prevents memory leaks. -
Validate typographic hierarchy programmatically. After export, assert that title
fontsizeexceeds labelfontsizeby at least 4 pt and that labelfontsizeexceeds tick label size by at least 2 pt. The sizing constraints in Typography Rules for Maps provide the authoritative thresholds — encode them asassertstatements in your CI pipeline so configuration drift is caught before maps reach publication.
Integration and Next Steps
This script slots directly into a CI/CD rendering pipeline: the Agg backend and plt.close(fig) pattern make it safe to call from GitHub Actions, Jenkins, or Airflow tasks without a display server. Wrap the plotting block in a function that accepts a gdf (GeoDataFrame), a hierarchy_config dict, and an output_path string, then call it from a batch loop that iterates over region identifiers.
For vector output, swap fig.savefig("...", dpi=300) for fig.savefig("output.svg") or fig.savefig("output.pdf"). The same zorder and alpha settings apply — SVG serialises them as paint-order and opacity attributes, while PDF encodes them as transparency groups. The High-Resolution Vector Export workflow covers font embedding flags and PDF/X-1a compliance settings required for print production.
When your thematic data density increases to the point where individual scatter markers merge visually, replace ax.scatter with a kernel density raster layer rendered via ax.imshow at zorder=2, and use the scatter layer only for labelled sample points at zorder=3. This keeps visual hierarchy intact while handling datasets with tens of thousands of point features.
For label-heavy outputs, combine this approach with the techniques in Label Collision Avoidance Algorithms to prevent annotation crowding in dense urban regions. The zorder=4 halo pattern above is the entry point; full collision detection requires spatial indexing with an STRtree over annotation bounding boxes. Separately, validate that your alpha-blended layers still meet AA contrast requirements as described in WCAG Contrast Checking for Map Layers.
Frequently Asked Questions
Why does my zorder setting get ignored when I call ax.add_patch after ax.scatter?
Matplotlib resolves zorder per artist at draw time, not at add time. If you call ax.set_xlim() or ax.autoscale() after adding artists, Matplotlib may redraw and re-sort the artist list. Fix: set bounds before any add_patch or scatter call, and never call autoscale() in batch loops.
How do I prevent colorbar labels from overlapping the map in tight_layout exports?
Create the colorbar with fig.colorbar(scatter, ax=ax, fraction=0.03, pad=0.02) before calling fig.tight_layout(). If labels still clip, pass rect=[0, 0, 0.88, 1] to tight_layout to reserve space on the right side of the figure.
Why do my marker edge halos disappear at dpi=72 but show correctly at dpi=300?
At low DPI, sub-pixel linewidths on edgecolors are anti-aliased away. Use linewidths >= 1.0 in screen exports and set the Agg renderer floor with matplotlib.rcParams['figure.dpi'] = 150 in your pipeline configuration.
Related
- Visual Hierarchy in Code — the parent workflow: ranking every map element by perceptual priority in automated pipelines.
- Color Palette Generation for Thematic Maps — constructing perceptually uniform palettes for choropleth and proportional-symbol maps.
- Typography Scaling Rules for Multi-Page Atlases —
fontsizeandfontweightconstraints that keep typographic hierarchy consistent across batches of atlas pages.
Back to Visual Hierarchy in Code.