Building a Map Series Index Sheet with Page Numbers

Generate the index diagram and every sheet’s neighbour stamps from a single numbered grid, because the moment those come from two places they can disagree — and a printed volume in which the index and the sheet stamps disagree is unusable in a way no per-sheet check detects.

Core Algorithm and Workflow

The index sheet and the neighbour stamps are two renderings of one data structure: a lattice of sheets, each with a row, a column and a page number. Deriving both from that structure makes disagreement impossible rather than unlikely.

Numbering comes first, and it must be a total order over values that compare identically on every platform. Sorting cells by their raw floating-point bounds is not a total order: two cells whose northern edges differ in the fifteenth decimal place can sort either way, and a re-run then renumbers part of the atlas. Rounding the sort keys to a sensible precision — millimetres of ground, say — removes the ambiguity.

Neighbours then follow from lattice position rather than from geometry. A sheet at row r, column c has neighbours at (r−1, c), (r+1, c), (r, c−1) and (r, c+1), and any of those that were dropped for lying outside the coverage simply have no stamp.

One structure, two renderings A numbered lattice at the centre feeds two outputs. On the left it is rendered as an index sheet with the coverage outline behind the numbered cells. On the right it is rendered as neighbour stamps on an individual page, showing the numbers above, below, left and right of the frame. A note records that both derive from the same structure so they cannot disagree. numbered lattice (row, col, page, extent) index sheet 1 2 3 4 5 6 7 8 coverage behind, grid over sheet 5 5 ↑ 2 ↓ 8 4 ← → 6 stamps from lattice position Neither output stores its numbers. Both compute them, so a change to the grid updates both.
Storing the neighbour numbers on each sheet is the obvious shortcut and the source of the failure: the stored copies survive a regrid that changes the numbering.

Production-Ready Python Implementation

def number_grid(cells, precision: int = 3) -> list:
    """Assign stable row, column and page numbers to a lattice of extents.

    Sorting on rounded coordinates makes the order total and platform-independent,
    so a re-run over the same coverage reproduces the numbering exactly.
    """
    rows = sorted({round(c.bounds[3], precision) for c in cells}, reverse=True)
    cols = sorted({round(c.bounds[0], precision) for c in cells})
    row_of = {v: i for i, v in enumerate(rows)}
    col_of = {v: i for i, v in enumerate(cols)}

    placed = []
    for c in cells:
        placed.append({"row": row_of[round(c.bounds[3], precision)],
                       "col": col_of[round(c.bounds[0], precision)],
                       "extent": c})

    placed.sort(key=lambda s: (s["row"], s["col"]))     # north to south, west to east
    for i, sheet in enumerate(placed):
        sheet["page"] = i + 1
    return placed


def neighbours(sheets: list) -> dict:
    """Map each page number to its north, south, west and east neighbours."""
    by_rc = {(s["row"], s["col"]): s["page"] for s in sheets}
    out = {}
    for s in sheets:
        r, c = s["row"], s["col"]
        out[s["page"]] = {
            "north": by_rc.get((r - 1, c)),
            "south": by_rc.get((r + 1, c)),
            "west": by_rc.get((r, c - 1)),
            "east": by_rc.get((r, c + 1)),
        }
    return out


def assert_symmetric(nb: dict) -> None:
    """Fail loudly if A says B is east while B does not say A is west."""
    opposite = {"north": "south", "south": "north", "west": "east", "east": "west"}
    for page, sides in nb.items():
        for side, other in sides.items():
            if other is None:
                continue
            back = nb[other][opposite[side]]
            if back != page:
                raise AssertionError(
                    f"page {page} {side}{other}, but {other} "
                    f"{opposite[side]}{back}")

The symmetry assertion is three lines and catches every class of numbering bug at once. If page 5 says its eastern neighbour is 6 while page 6 does not say its western neighbour is 5, something in the lattice construction is wrong — a duplicated cell, a coordinate that rounded to two different rows, a dropped cell that left a hole the arithmetic did not expect. All of those are hard to spot in a printed volume and trivial to spot here.

Performance Tuning and Cartographic Best Practices

  • Show the coverage on the index, not just the grid. A reader locates themselves by a coastline or a border, then reads the number. An index of bare numbered rectangles requires them to already know where they are.
  • Omit absent neighbours rather than printing a placeholder. A blank edge reads as “the series ends here”. A dash or a zero invites a search for a page that does not exist.
  • Skip diagonal neighbours. Four references is as many as a reader will scan at a page turn, and the diagonals are recoverable from the index for the rare case that needs them.
  • Number the index outside the map sequence. Roman numerals or no number at all, so the map sheets run from one without a gap and the lattice arithmetic matches the printed numbers.
  • Render the index at a scale that shows every cell distinctly. If the grid is dense enough that adjacent numbers collide, drop to labelling every other row and column and rely on the reader interpolating — the same legibility floor that governs graticule intervals in Generating North Arrows and Graticules Programmatically.
What to do when the grid is denser than the labels Two index diagrams over the same coverage. In the first, every cell of a dense grid carries its page number and adjacent numbers overlap, making them unreadable. In the second, only every other row and column is labelled, with tick marks on the remaining edges, so the reader interpolates the intermediate numbers from a legible sequence. every cell numbered 101102103 104105 106107108 109110 three-digit numbers in a 44 pt cell alternate rows and columns 101103105 111113115 legible; intermediates interpolated A sequential numbering is interpolable, which is precisely what makes thinning the labels safe.
Thinning the labels works only because the numbering is sequential along the lattice. It is one more reason to derive numbers from position rather than from render order.

Integration and Next Steps

The numbered lattice is produced by the grid generation in Generating Atlas Page Grids with Guaranteed Overlap and consumed by the sheet renderer, so it belongs in the frozen decision artefact described in Atlas and Map Series Automation alongside the class breaks and label tiers. Render the index sheet last, after every page has succeeded, so it can be trusted to describe a volume that actually exists.

Why completion order cannot be the numbering Two parallel render runs over the same eight sheets. Numbered by completion order, the two runs assign different numbers to the same extents because worker scheduling differs. Numbered by lattice position, both runs assign identical numbers regardless of the order in which the sheets finished. numbered by completion order run A 3 · 1 · 4 · 2 · 6 · 5 · 8 · 7 run B 1 · 4 · 2 · 3 · 5 · 7 · 6 · 8 same extents, different numbers numbered by lattice position run A 1 · 2 · 3 · 4 · 5 · 6 · 7 · 8 run B 1 · 2 · 3 · 4 · 5 · 6 · 7 · 8 identical, whatever the scheduler did Only the second is a property of the atlas rather than of the machine that rendered it.
The top case is not hypothetical — any parallel render produces it, and it is invisible until somebody compares two printings of the same volume.

Frequently Asked Questions

Why must page numbering be reproducible?

Because every neighbour stamp, index entry and printed cross-reference encodes it. If a re-run assigns different numbers — because a floating-point comparison tied differently, or because sheets were numbered in the order they finished rendering — then all of those references become wrong while remaining internally consistent, so nothing detects the change. A total order over rounded coordinates makes the numbering a pure function of the grid, which is the property that lets a second printing match the first.

Should the index sheet show the whole coverage or just the grid?

Both, layered. The coverage outline gives the reader something recognisable — a coastline, a border, a river — to locate themselves against, and the numbered grid over it converts that location into a page. An index of bare numbered rectangles is technically complete and close to unusable, because it requires the reader to already know their grid position in order to find their grid position.

What goes on a sheet with no neighbour on one side?

Nothing at all. An absent stamp reads immediately as the edge of the series, whereas a dash or a zero invites the reader to look for a page that does not exist. The same reasoning applies to diagonal neighbours: four references is about as many as anyone scans at a page turn, and the diagonals are recoverable from the index on the rare occasions they are wanted.

Does the index sheet need its own page number?

Conventionally it sits outside the map sequence — roman numerals, or unnumbered — so the map sheets run from one without a gap. That matters more than it appears: the neighbour arithmetic derives page numbers from lattice position, and inserting the index into the same sequence puts every printed number one off from what that arithmetic produces, which is a defect that reaches print because both halves are individually correct.


Back to Atlas and Map Series Automation