Generating a Map Legend That Matches Your Python Colour Ramp

Part of the Symbology & Data-Driven Styling guide.

Operative rule: the legend must be a pure function of the ramp object — if any colour or number in the legend is typed by hand, the legend is a second source of truth and will eventually contradict the map.

Why Legends Drift

Legend drift has one cause and it is always the same: someone adjusts a threshold in the styling code and the legend markup is somewhere else. It survives review because both halves look correct in isolation, and it survives testing because nothing compares them. It is discovered by a reader, usually in a meeting.

Deriving the legend removes the possibility. One object holds the breaks and colours; the map asks it for a colour, the legend asks it for rows, and neither can express a value the other does not know about.

Hand-written legend versus derived legend In the drifting architecture the styling code holds one copy of the breaks and the legend template holds another, so an edit to one leaves the other stale. In the derived architecture a single ramp object supplies both the style function and the legend rows, so no disagreement is representable. Two copies of the truth, or one drifts — two independent copies style code: breaks + colours legend template: breaks + colours edit one, the other goes stale cannot drift — one object, two views Ramp breaks + colours color_of(value) legend_rows() a hex literal outside the Ramp is always a bug

Production-Ready Implementation

from __future__ import annotations

import html
from dataclasses import dataclass


@dataclass(frozen=True)
class Ramp:
    field: str
    label: str
    breaks: list[float]
    colors: list[str]
    unit: str = ""
    nodata_color: str = "#E6E6E6"
    nodata_label: str = "no data"

    def rows(self) -> list[tuple[str, str]]:
        """(colour, label) pairs — the ONLY place legend text is produced."""
        out: list[tuple[str, str]] = []
        last = len(self.colors) - 1
        for i, color in enumerate(self.colors):
            lo, hi = self.breaks[i], self.breaks[i + 1]
            if i == last:
                text = f"{lo:,.0f}{self.unit} and above"
            else:
                text = f"{lo:,.0f}{hi:,.0f}{self.unit}"
            out.append((color, text))
        out.append((self.nodata_color, self.nodata_label))
        return out


def legend_html(ramp: Ramp) -> str:
    """Accessible legend markup: a definition list, not a table of colours."""
    items = "".join(
        '<div class="legend-row">'
        f'<span class="legend-swatch" style="background:{color}" aria-hidden="true"></span>'
        f'<span class="legend-label">{html.escape(text)}</span>'
        "</div>"
        for color, text in ramp.rows()
    )
    return (
        f'<figure class="map-legend" role="group" '
        f'aria-label="Legend: {html.escape(ramp.label)}">'
        f"<figcaption>{html.escape(ramp.label)}</figcaption>{items}</figure>"
    )


RAMP = Ramp(
    field="incidents_per_1k",
    label="Incidents per 1 000 residents",
    breaks=[0.0, 2.0, 5.0, 10.0, 20.0, 50.0],
    colors=["#F1EEF6", "#BDC9E1", "#74A9CF", "#2B8CBE", "#045A8D"],
)

if __name__ == "__main__":
    print(legend_html(RAMP))

Note that the top class is labelled “and above” rather than with the upper edge. In a frozen-break scheme the top edge is a clamp, not a maximum, so writing 20 – 50 claims an upper bound the data does not respect. Small honesty like this is what keeps a legend trustworthy when the underlying values eventually move.

Labelling Intervals Without Ambiguity

Three ways to label class intervals Labels reading 2 to 5 and 5 to 10 overlap at the boundary and leave a reader unsure which class a value of exactly five is in. Labels reading 2 to 4 and 5 to 9 imply a gap that does not exist for continuous data. Labels that match the code's comparison and state the convention once are unambiguous. A value that sits exactly on a break has to belong somewhere "2 – 5" then "5 – 10" with no stated rule ambiguous — the reader cannot tell which row a value of 5 belongs to "2 – 4" then "5 – 9" implies a gap — fine for integers, wrong for any continuous measure "2 – 5" with "upper value included" stated once, matching the code unambiguous, compact, and testable — assert the convention in the build

Making It Work in Both Themes

A legend that is generated once and displayed in a themed page has one constraint the map does not: its swatches are fixed colours sitting on a surface that changes. A pale first swatch that reads clearly on white becomes nearly invisible on a dark panel.

The fix is structural rather than chromatic. Give every swatch a one-pixel border drawn in the current text colour, so its extent is always visible regardless of fill. Set the legend’s own background from the page’s surface token rather than a literal, so it moves with the theme. And keep the label text in the page’s text colour rather than in the swatch colour — coloured text on a themed background is the most common contrast failure in generated legends, and it is entirely avoidable.

The anatomy below is what a generated legend should contain. Each part answers a question a reader would otherwise have to guess at, and each is produced from the ramp rather than typed.

Anatomy of a generated legend The caption names the variable and its units. The class rows run in data order from low to high with bordered swatches. The no-data row appears whenever any feature lacks a value. A source note carries the data vintage, which is what lets a reader judge whether the map is current. Four parts, each generated, each answering a reader's question caption + units "Incidents per 1 000 residents" answers "what am I looking at, and per what?" class rows, low → high bordered swatch + interval label answers "which colour means more?" — order carries the meaning no-data row present only when nulls exist answers "why is that one grey?" — otherwise read as zero source + data vintage answers "is this current?" — take it from the pipeline's run record

When the Legend Needs More Than Colours

Some maps need a legend that explains more than a ramp. A layer drawn with a hatch pattern for provisional data, a boundary style that distinguishes confirmed from estimated, an outline that means “under review” — each of these is a visual convention the reader cannot infer, and each belongs in the legend.

The pattern that scales is to treat the legend as a list of entries rather than as a ramp renderer. A ramp contributes its class rows; each additional convention contributes one row; and the legend component simply renders whatever list it is given. This keeps the generation logic uniform while allowing any number of conventions to be added without touching the renderer.

It also solves the multi-layer case cleanly. A dashboard showing two thematic layers at once needs two legends, or one legend with two labelled groups, and building it from a list of entry-groups makes either arrangement a formatting decision rather than a code change. What must not happen is a second legend implementation for the second layer — that is how the drift this whole page is about creeps back in through a side door.

One further entry earns its place on almost every operational map: the date the data was produced. A legend that carries its own vintage answers the question readers ask most often about a dashboard they did not build, and it costs one string threaded through from the pipeline’s run record. Without it, every stale-data incident starts with someone asking whether the map is current, and nobody being able to answer from the page itself.

Verification Steps

  • Assert in the build that len(rows()) == len(colors) + 1, so the no-data row can never be dropped silently.
  • Compare the first and last labels against the outer breaks programmatically rather than by eye.
  • Render the legend in both themes and check swatch borders remain visible in each.
  • Read the legend with a screen reader and confirm the group label and caption are announced before the rows.
  • Change one break, rebuild, and confirm the legend text changed without anyone editing markup.

Common Errors & Fixes

The legend shows one row fewer than the map has classes

The row builder is iterating over breaks rather than colors. Iterate over colours and index into the break edges — there is always one more edge than class.

Numbers in the legend have inconsistent formatting

Formatting is applied at two call sites. Centralise it in the ramp object so the thousands separator, decimal places and unit suffix come from one place.

The no-data row appears even when nothing is missing

Make the row conditional on the data, not on the ramp: count nulls during the build and pass the result in. A no-data entry with no corresponding features is clutter that invites readers to look for something that is not there.

The legend overlaps the map on small screens

It is positioned absolutely inside the map container. Generate the markup once and let CSS place it — overlaid on wide viewports, stacked beneath the map on narrow ones, as covered in Responsive Layouts for Automated Geo-Dashboards.

Treat the legend as part of the map’s contract with its reader rather than as decoration attached afterwards. Everything above follows from that: it is generated, it is tested, it carries its own units and vintage, and it changes only when the thing it describes changes.

A generated legend also documents the map for whoever inherits it. Six months later the break values, the units and the no-data convention are all visible on the page itself rather than only in a Python module nobody has opened since.