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:

  1. Media box — trim plus bleed on every side.
  2. Trim box — the finished size.
  3. Safe box — trim inset by the safety margin; nothing readable may sit outside it.
  4. Marginalia bands — allocated from the safe box, each sized from its own content.
  5. 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.

The page boxes, and the order in which they are computed A sheet diagram with nested rectangles. The media box includes 3 millimetres of bleed. Inside it the trim box marks the finished size. Inside that the safe box is inset 8 millimetres. Within the safe box a title band occupies the top, a legend column occupies the right, a credits strip occupies the bottom, and the map frame takes the remaining area. Numbers show each band sized from its own content. title band — from text height legend — from entries scale bar · credits map frame the remainder computed in this order 1 media = trim + 3 mm bleed 2 trim = finished size 3 safe = trim − 8 mm 4 bands, each from content 5 frame = what is left Reversing 4 and 5 gives the legend whatever remains instead of what it needs.
Only the map frame can absorb an arbitrary remainder, which is why it is sized last. Every other element has a floor below which it stops being readable.

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.

Scoring inset positions by the information they would cover A map frame divided into a coarse grid shaded by information density. Dense urban areas score high, open water scores near zero. Three candidate inset rectangles are drawn: the fixed bottom-right corner covers a dense area and scores 812, a mid-left candidate scores 340, and the chosen top-left candidate over open water scores 24. 24 340 812 candidate scores solid = chosen, lowest ink dashed = rejected The bottom-right corner is the usual hard-coded position, and the worst choice on this sheet. A summed-area table makes every candidate window four lookups regardless of its size.
The density raster does not need to be accurate — a coarse render at a twentieth of the output resolution ranks candidates identically to a full-resolution one, at a thousandth of the cost.

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

Choosing a graticule interval from the frame extent The same 4.4 degree extent shown with three candidate intervals. A 0.5 degree interval gives nine lines, which crowds the frame. A 1 degree interval gives four lines, which reads cleanly. A 2 degree interval gives two lines, too few to serve as a reference. The one degree candidate is marked as the coarsest interval still yielding at least three lines. 0.5° — 9 lines competes with the map 1° — 4 lines chosen — coarsest with ≥ 3 2° — 2 lines no usable reference frame Same 1-2-5 ladder as the scale bar: take the coarsest candidate that still yields three lines. A fixed interval works on one sheet and fails at both ends of a series that spans scales.
The interval is a function of the extent, so it must be recomputed per sheet. Fixing it in the template gives a crowded graticule on the zoomed-in sheets and an empty one on the overview.

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.


Back to Automated Cartographic Design Fundamentals