Mapping CSS Grid Areas to Multi-Panel Map Layouts
A publication-grade spatial figure is rarely a single map. It is a composition: a main map panel, a zoomed inset, a small-scale locator, and a legend, all aligned to a shared baseline so the reader parses them as one unit. CSS grid-template-areas is the cleanest way to express that composition declaratively, because it lets you name each panel and paint the layout as ASCII art that mirrors the printed page. This guide is a focused technique within the CSS Grid Systems for Report Layouts workflow, covering how to name panel areas, lock per-panel aspect ratios, drive the markup from Python, and work around the grid features WeasyPrint does not implement.
Prerequisites
- Python 3.10+ with
weasyprint>=60.0andjinja2>=3.1installed (pip install weasyprint jinja2) - Map panels exported as PNG or SVG at known pixel dimensions (main, inset, locator) plus a legend fragment
- Working knowledge of the
@pagesize and margin rules from CSS Grid Systems for Report Layouts, since the grid sizes against the usable page box - A confirmed target paper size — the fractional tracks below assume an A4 portrait content area
Step 1: Name the Panels with grid-template-areas
Declare the container once. The grid-template-areas string is a visual map: each quoted row is a grid row, and each token is a named cell. Repeating a token spans that area across cells.
.map-figure {
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-rows: auto auto auto;
grid-template-areas:
"map inset"
"map locator"
"map legend";
gap: 4mm;
break-inside: avoid; /* keep the whole figure on one page */
}
Because the map token occupies the entire first column across all three rows, the main map becomes one tall panel while the right column stacks the inset, locator, and legend. Changing the composition later is a one-line edit to the template string — no repositioning of individual elements.
Step 2: Assign Each Panel to Its Named Area
Every child element declares which named area it fills with grid-area. The names must match the tokens in the template exactly; an unmatched name silently drops the element into auto-placement.
.panel-map { grid-area: map; }
.panel-inset { grid-area: inset; }
.panel-locator { grid-area: locator; }
.panel-legend { grid-area: legend; }
<figure class="map-figure">
<img class="panel-map" src="main_2000x2600.png" alt="Study area — flood risk zones">
<img class="panel-inset" src="inset_900x520.png" alt="Detail: harbour district">
<img class="panel-locator" src="locator_900x440.png" alt="Locator: national context">
<div class="panel-legend">
<!-- legend swatches injected here -->
</div>
</figure>
Keeping the legend as a <div> rather than an image lets you inject live swatches. This is where the composition connects to Dynamic Legend Injection for Variable Datasets: the legend cell is a fixed slot, but its contents scale with the number of classes in the map.
Step 3: Lock Per-Panel Aspect Ratios
A grid cell will happily stretch a raster to fill its box, distorting the map projection. Reserve the correct box with aspect-ratio and constrain the pixels with object-fit: contain so the exported map keeps its cartographic proportions.
.panel-map { aspect-ratio: 2000 / 2600; width: 100%; object-fit: contain; }
.panel-inset { aspect-ratio: 900 / 520; width: 100%; object-fit: contain; }
.panel-locator{ aspect-ratio: 900 / 440; width: 100%; object-fit: contain; }
.panel-legend {
align-self: stretch;
border: 0.4mm solid currentColor;
padding: 2mm;
}
Set each aspect-ratio to the export’s own pixel dimensions, not to a rounded ratio. When the ratio matches the source exactly, object-fit: contain produces no letterboxing and no upscaling, so the printed scale bar stays accurate. This mirrors the pre-scaling discipline used in Handling Multi-Page Landscape vs Portrait Switches, where assets are exported at exact usable-area dimensions rather than resized at render time.
Step 4: Generate the Figure HTML from Python
In an automated pipeline the panel set is data-driven — some maps have no inset, some layers need no locator. Build the grid from a context object so absent panels are simply omitted, and rebuild the template string to match.
from __future__ import annotations
from dataclasses import dataclass
from jinja2 import Environment, BaseLoader
@dataclass(frozen=True)
class Panel:
area: str # "map" | "inset" | "locator" | "legend"
src: str
alt: str
ratio: str # e.g. "2000 / 2600"
FIGURE_TEMPLATE = """
<style>
.map-figure {
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-areas: {{ areas }};
gap: 4mm;
break-inside: avoid;
}
{%- for p in panels %}
.panel-{{ p.area }} { grid-area: {{ p.area }}; aspect-ratio: {{ p.ratio }}; width: 100%; object-fit: contain; }
{%- endfor %}
</style>
<figure class="map-figure">
{%- for p in panels %}
<img class="panel-{{ p.area }}" src="{{ p.src }}" alt="{{ p.alt }}">
{%- endfor %}
</figure>
"""
def build_areas(panels: list[Panel]) -> str:
"""Compose the grid-template-areas string from the panels that exist."""
right = [p.area for p in panels if p.area != "map"]
rows = [f'"map {slot}"' for slot in right] or ['"map map"']
return " ".join(rows)
def render_figure(panels: list[Panel]) -> str:
env = Environment(loader=BaseLoader())
template = env.from_string(FIGURE_TEMPLATE)
return template.render(panels=panels, areas=build_areas(panels))
The build_areas helper regenerates the template string so the right column always has exactly one row per secondary panel. If an inset is missing, the locator moves up and no empty track is left behind.
Step 5: Add a Responsive Fallback for Screen Review
Before the PDF stage, analysts review the same HTML in a browser. A two-column grid is cramped on a narrow screen, so collapse it to a single column below a breakpoint. Print media is unaffected because the media query only targets screens.
@media screen and (max-width: 640px) {
.map-figure {
grid-template-columns: 1fr;
grid-template-areas:
"map"
"inset"
"locator"
"legend";
}
}
Keep the print template untouched; the paged-media grid should always be the two-column composition. The screen fallback is purely for the review loop and never reaches WeasyPrint, which renders against the print stylesheet.
Key Parameters / Configuration Reference
| Property | Value | Effect |
|---|---|---|
display |
grid |
Activates the grid formatting context on the figure container |
grid-template-columns |
2fr 1fr |
Main map takes two-thirds width; secondary column takes one-third |
grid-template-areas |
named string | Visual map of panel placement; must match every grid-area name |
grid-area |
map / inset / locator / legend |
Assigns a child to a named cell; unmatched names fall to auto-placement |
aspect-ratio |
W / H (source pixels) |
Reserves the correct box so panels never distort or shift |
object-fit |
contain |
Fits the raster without cropping or stretching inside its cell |
gap |
4mm |
Gutter between panels; use mm for predictable print spacing |
break-inside |
avoid |
Keeps the whole multi-panel figure on a single page |
Common Pitfalls
-
Relying on grid auto-placement across a page break. WeasyPrint resolves a grid within a single fragment. If the figure is taller than the remaining page space and lacks
break-inside: avoid, panels can be split or reflowed unpredictably. Always wrap the figure so it moves to the next page as a unit. -
Mismatched area names. A typo like
grid-area: insettdoes not error; the element is auto-placed into the first empty track, often overlapping another panel. Lint the generated CSS by asserting everygrid-areavalue appears in the template string. -
Rounding the aspect ratio. Writing
aspect-ratio: 3 / 2for a map exported at2000 × 1320introduces a slight letterbox that offsets the scale bar. Pass the exact export dimensions intoaspect-ratioso the reserved box matches the pixels. -
Using
subgridfor aligned baselines. WeasyPrint does not implementsubgrid. If you need the legend text baseline to align with the locator caption, set explicit row heights on the parent grid rather than nesting a subgrid.
Verification
Render the figure and assert the grid produced the expected number of image boxes at the expected positions using PyMuPDF, which reports the bounding box of each embedded image.
import fitz # pymupdf
def assert_panel_count(pdf_path: str, expected: int) -> None:
"""Confirm the multi-panel figure placed the expected number of images."""
doc = fitz.open(pdf_path)
images = doc[0].get_images(full=True)
assert len(images) == expected, f"expected {expected} panels, got {len(images)}"
assert_panel_count("figure.pdf", expected=3) # map + inset + locator
For a visual gate, render the figure to PNG at review DPI and diff it against a committed reference image; a shifted panel changes pixels at the panel boundary and fails the comparison.
Related
- CSS Grid Systems for Report Layouts — parent guide covering the full grid track and template system this figure composition extends
- Handling Multi-Page Landscape vs Portrait Switches — companion technique for switching orientation when a multi-panel figure needs a landscape page
- Dynamic Legend Injection for Variable Datasets — filling the legend cell of the grid with swatches that scale to layer cardinality
- Parent section: CSS Grid Systems for Report Layouts