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.

Named grid areas for a multi-panel map figure A grid container split into a wide main map area on the left and a narrow right column stacked with an inset map, a locator map, and a legend. Labels show the grid-area name assigned to each panel. main map grid-area: map inset grid-area: inset locator grid-area: locator legend grid-area: legend

Prerequisites

  • Python 3.10+ with weasyprint>=60.0 and jinja2>=3.1 installed (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 @page size 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.

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

CSS
.panel-map     { grid-area: map; }
.panel-inset   { grid-area: inset; }
.panel-locator { grid-area: locator; }
.panel-legend  { grid-area: legend; }
HTML
<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.

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

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

CSS
@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: insett does not error; the element is auto-placed into the first empty track, often overlapping another panel. Lint the generated CSS by asserting every grid-area value appears in the template string.

  • Rounding the aspect ratio. Writing aspect-ratio: 3 / 2 for a map exported at 2000 × 1320 introduces a slight letterbox that offsets the scale bar. Pass the exact export dimensions into aspect-ratio so the reserved box matches the pixels.

  • Using subgrid for aligned baselines. WeasyPrint does not implement subgrid. 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.

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