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.
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.
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.
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.
Related
- Generating Atlas Page Grids with Guaranteed Overlap — the lattice this numbering is applied to.
- Debugging Inconsistent Extents Across Atlas Pages — when a sheet’s rendered extent does not match its grid cell.
Back to Atlas and Map Series Automation