Map Layout and Composition Automation
A map sheet is not just a rendered extent. It is a composition: a frame at a stated scale, a legend that fits, a title that does not collide with the neatline, a scale bar whose length was chosen rather than inherited, and enough margin that a guillotine can miss by a millimetre without cutting into the data. When one sheet is produced, all of that can be arranged by eye. When four hundred are produced from the same template, every one of those relationships has to be computed.
This page covers the geometry of that computation: how the page boxes are derived, why marginalia must be allocated before the map frame is sized, how insets are placed from measured free space instead of a fixed corner, and how reference elements take their coordinates from the frame rather than from constants.
Prerequisites and Environment Configuration
python==3.11
matplotlib==3.8.4
geopandas==0.14.4
shapely==2.0.4
numpy==1.26.4
reportlab==4.1.0
Two inputs are required before any geometry can be computed, and both belong in the sheet specification rather than in the code:
- The finished size and the bleed allowance. Finished size is what the reader holds; bleed is what the printer needs beyond it. Both come from the print specification, and a change to either must propagate to every derived box automatically. The relationship between these boxes is covered in more depth under Print-Ready Export and Batch Generation Workflows.
- The required scale, or the required extent — not both. A sheet can honour a fixed scale or a fixed extent, but only rarely both, because the frame that remains after marginalia is whatever size it is. Deciding which of the two is authoritative is a specification decision, and a layout engine that silently adjusts the one the caller thought was fixed produces a series whose sheets are not comparable.
All lengths in this material are in millimetres of finished output. Pixels appear only at the final rasterisation step, for the reasons set out in Scale Mapping for Web and Print.
Conceptual Foundation: The Page as a Constraint System
Sheet composition is a small constraint problem with one degree of freedom. The page boxes are fixed by the specification. The marginalia have minimum sizes set by their content and by legibility floors. Everything left over belongs to the map frame. Expressed as an ordering, that gives:
- Media box — trim plus bleed on every side.
- Trim box — the finished size.
- Safe box — trim inset by the safety margin; nothing readable may sit outside it.
- Marginalia bands — allocated from the safe box, each sized from its own content.
- Map frame — the remainder.
The ordering is the substance of the design. Reversing steps four and five, which is what a template with a hard-coded frame rectangle effectively does, means the legend is given whatever is left instead of what it needs, and the failure surfaces as unreadable type on the one sheet with the most classes.
Step-by-Step Implementation
Step 1: Derive the page boxes
from dataclasses import dataclass
@dataclass(frozen=True)
class Box:
"""A rectangle in millimetres of finished output, origin bottom-left."""
x: float
y: float
w: float
h: float
def inset(self, margin: float) -> "Box":
if self.w - 2 * margin <= 0 or self.h - 2 * margin <= 0:
raise ValueError(f"inset of {margin} mm collapses a {self.w}×{self.h} box")
return Box(self.x + margin, self.y + margin,
self.w - 2 * margin, self.h - 2 * margin)
def outset(self, margin: float) -> "Box":
return Box(self.x - margin, self.y - margin,
self.w + 2 * margin, self.h + 2 * margin)
def page_boxes(finished_w: float, finished_h: float,
bleed: float = 3.0, safety: float = 8.0) -> dict:
"""Media, trim and safe boxes derived from one finished size."""
trim = Box(0.0, 0.0, finished_w, finished_h)
return {"media": trim.outset(bleed), "trim": trim, "safe": trim.inset(safety)}
The inset method raising rather than returning a degenerate box matters more than it looks. A safety margin larger than half the sheet is always a specification error, and catching it here names the sheet size rather than producing a negative-width frame that fails somewhere in the renderer.
Step 2: Allocate the marginalia from content
Each marginal element reports the size it needs, and the allocator subtracts those from the safe box:
def allocate(safe: Box, title_h: float, legend_w: float,
credits_h: float, gutter: float = 4.0) -> dict:
"""Reserve marginalia bands and return the frame that remains."""
frame_h = safe.h - title_h - credits_h - 2 * gutter
frame_w = safe.w - legend_w - gutter
if frame_h <= 0 or frame_w <= 0:
raise ValueError("marginalia do not fit; reduce content or enlarge the sheet")
return {
"title": Box(safe.x, safe.y + safe.h - title_h, safe.w, title_h),
"legend": Box(safe.x + frame_w + gutter, safe.y + credits_h + gutter,
legend_w, frame_h),
"credits": Box(safe.x, safe.y, safe.w, credits_h),
"frame": Box(safe.x, safe.y + credits_h + gutter, frame_w, frame_h),
}
Legend width and height come from the legend generator, not from a constant — the reflow logic described in Dynamic Legend Generation returns the size its chosen layout requires, and this allocator consumes it. When the allocator raises, the correct response is to ask the legend for a narrower layout and retry, not to shrink the type.
Step 3: Reconcile scale against the frame
Once the frame is known, its size and the required scale together determine the ground extent it covers:
def extent_for_frame(frame: Box, scale_denominator: float,
centre_x: float, centre_y: float) -> tuple:
"""Ground extent (in CRS units) that a frame covers at a given scale."""
ground_w = (frame.w / 1000.0) * scale_denominator # mm → m → ground units
ground_h = (frame.h / 1000.0) * scale_denominator
return (centre_x - ground_w / 2, centre_y - ground_h / 2,
centre_x + ground_w / 2, centre_y + ground_h / 2)
If the resulting extent does not contain the subject, something has to give, and the specification must say which. Holding the scale and widening the extent keeps the series comparable and may crop the subject. Holding the extent and relaxing the scale keeps the subject whole and makes the sheet incomparable with its neighbours. Both are defensible; silently doing one while the caller assumed the other is not.
Step 4: Place insets in measured free space
import numpy as np
def best_inset_position(ink: np.ndarray, frame: Box,
inset_w: float, inset_h: float) -> Box:
"""Pick the inset position that obscures the least map information.
`ink` is a coarse raster of the composed frame, higher values meaning
more information. Its grid maps linearly onto the frame.
"""
rows, cols = ink.shape
cell_w, cell_h = frame.w / cols, frame.h / rows
span_c = max(1, int(round(inset_w / cell_w)))
span_r = max(1, int(round(inset_h / cell_h)))
if span_c > cols or span_r > rows:
raise ValueError("inset is larger than the frame")
# Summed-area table so every candidate window costs four lookups.
integral = ink.cumsum(axis=0).cumsum(axis=1)
padded = np.pad(integral, ((1, 0), (1, 0)))
best, best_rc = None, (0, 0)
for r in range(rows - span_r + 1):
for c in range(cols - span_c + 1):
total = (padded[r + span_r, c + span_c] - padded[r, c + span_c]
- padded[r + span_r, c] + padded[r, c])
if best is None or total < best:
best, best_rc = total, (r, c)
r, c = best_rc
return Box(frame.x + c * cell_w, frame.y + r * cell_h, inset_w, inset_h)
The summed-area table matters at atlas scale: a naive scan over a 200×200 ink raster with a 40×40 window costs about 41 million additions per sheet, and the integral version costs about four per candidate. On four hundred sheets that is the difference between seconds and an afternoon.
Step 5: Derive the graticule interval
A graticule is useful when it carries roughly three to seven labelled lines per axis. Below three it provides no reference frame; above seven it competes with the map. The interval is therefore chosen the same way a scale bar length is chosen — from a one-two-five ladder, taking the coarsest value that still yields enough lines:
def graticule_interval(span_degrees: float, min_lines: int = 3) -> float:
"""Coarsest 1-2-5 interval giving at least `min_lines` lines across a span."""
candidates = [n * 10 ** e for e in range(-3, 3) for n in (1, 2, 5)]
usable = [c for c in sorted(candidates) if span_degrees / c >= min_lines]
if not usable:
raise ValueError(f"span of {span_degrees}° is too small for a graticule")
return usable[-1]
The same routine drives the scale bar described in How to Automate Scale Bar Generation in Python, which is not a coincidence: both are answering “what round number of these units fits comfortably in that space”.
Performance Optimization Patterns
Compute the layout once per sheet size, not once per sheet. In an atlas where every sheet shares a page size and a legend, the entire box computation is identical across the run. Cache it keyed on the sheet specification and the legend content hash; only the inset placement genuinely varies per sheet.
Score insets on a coarse raster. The ink-density raster does not need to resemble the final map. Rendering the frame at a twentieth of the output resolution, with labels omitted, ranks candidate positions identically and costs almost nothing.
Keep the layout in millimetres until the final transform. Every intermediate value in this material is a physical length. Converting to pixels once, at rasterisation, means a change of output DPI touches one line rather than every box computation — the reasoning set out in DPI and Resolution Management.
Fail the sheet, not the run. A sheet whose marginalia do not fit should raise and be recorded, while the remaining sheets continue. A layout error on sheet 217 of 400 that aborts the batch wastes the 216 sheets already rendered.
Common Pitfalls and Debugging
The legend overflows on one sheet in the series. Almost always a sheet with more classes present than the template was sized against. Size the legend from the union of classes across the whole series, not per sheet, so every sheet reserves the same space and the legend is comparable page to page.
Title text collides with the neatline. The title band was sized from a nominal string rather than from the actual rendered text extents. Measure the title with the real font at the real size, as described under Typography Rules for Maps, and size the band from the measurement.
Insets land on the subject. A hard-coded corner. Score positions instead; the summed-area implementation above costs a few milliseconds per sheet.
Sheets in a series have visibly different scales. The specification made the extent authoritative without saying so, and the frame that remained differed slightly between sheets because a longer title consumed more of one page. Fix the marginalia allocation across the series so every frame is identical, then hold the scale.
Bleed appears as a white sliver on some printed copies. The basemap was clipped to the trim box rather than to the media box. Map content must extend into the bleed; only readable content is confined to the safe box.
Conclusion
Everything in a map sheet outside the data itself is derivable. The boxes come from the finished size and the bleed specification, the marginalia from their own content, the frame from what remains, the inset from measured free space, and the graticule interval from the extent. Automating composition means writing those derivations down once and letting a change to the page size or the class count propagate through all of them, rather than maintaining a template whose constants encode a page size nobody remembers choosing. The batch machinery that renders the resulting sheets is covered in Batch Queue Orchestration.
Related
- Automating Inset Map Placement and Extent Indicators — scoring free space and drawing the locator rectangle.
- Generating North Arrows and Graticules Programmatically — interval selection, label placement and when an arrow is redundant.
- Computing Neatline and Margin Geometry for Variable Page Sizes — one derivation that serves A4, A3 and a custom trim.
- Scale Mapping for Web and Print — reconciling a required scale against the frame the layout leaves.