Symbology & Data-Driven Styling

Part of the Core Mapping Architecture & Rendering guide.

Symbology is where a dataset becomes an argument. The same table of values can be classified into a map that shows a clear north-south divide, or one that shows almost nothing, purely through the choice of break values and hues. For an automated dashboard this matters twice over: the styling decisions are made once in Python and then re-applied on every rebuild, so a poor choice is not a one-off mistake but a permanent property of the product. This guide covers the full path from an attribute column to rendered colour — classification, colour scheme selection, ramp generation in Python, renderer expressions, and legends that cannot drift out of step with the map.

Prerequisites

Step 1 — Classify the Attribute

Classification converts a continuous column into a small number of bins. Four schemes cover nearly every dashboard, and they answer different questions.

Four classification schemes against the same skewed distribution Equal interval splits the value range into equal-width bins and leaves most features in the lowest class when data is skewed. Quantile puts an equal count in every class and always fills the legend, but the classes mean different things between refreshes. Natural breaks minimise within-class variance and follow the shape of the data. Manual breaks encode a domain threshold such as a regulatory limit and are the only scheme that stays comparable over time. Same column, four schemes — each answers a different question equal interval — bins of equal width answers "how large is this value?" · on skewed data 90 % of features land in class one quantile — equal count per bin answers "how does this rank?" · always fills the legend · breaks move on every refresh natural breaks — minimise within-class variance answers "where are the real groupings?" · follows the data's shape · also moves on refresh manual — domain thresholds · the only scheme that stays comparable between refreshes

The decision that matters most is not which algorithm is cleverest but whether the map has to be comparable with yesterday’s version of itself. An operational dashboard almost always does. Compute natural breaks once during design, look at them, round them to numbers a human would choose, and then freeze them as manual breaks in configuration. From that point the map answers the same question every day, and a shift in colour genuinely means a shift in the world rather than a shift in the classifier.

from __future__ import annotations

import geopandas as gpd
import numpy as np


def quantile_breaks(values: np.ndarray, k: int = 5) -> list[float]:
    """Return k+1 edges so that each class holds roughly the same count."""
    clean = values[~np.isnan(values)]
    qs = np.linspace(0, 100, k + 1)
    return [float(v) for v in np.percentile(clean, qs)]


def equal_interval_breaks(values: np.ndarray, k: int = 5) -> list[float]:
    """Return k+1 evenly spaced edges across the observed range."""
    clean = values[~np.isnan(values)]
    lo, hi = float(clean.min()), float(clean.max())
    return [lo + (hi - lo) * i / k for i in range(k + 1)]


gdf = gpd.read_file("regions.geojson")
values = gdf["incidents_per_1k"].to_numpy(dtype="float64")
print("quantile:", [round(b, 1) for b in quantile_breaks(values)])
print("equal:   ", [round(b, 1) for b in equal_interval_breaks(values)])

Step 2 — Choose a Colour Scheme

Colour carries the meaning, and the scheme type has to match the data type. Sequential ramps run light to dark for quantities with a natural low and high. Diverging ramps run dark through a neutral midpoint to dark again, and are only honest when there is a meaningful centre such as zero change or a target value. Categorical schemes use distinct hues of similar lightness for classes with no ordering — land use, operator, status.

Two constraints apply on top of that choice. About one in twelve men has a red-green colour vision deficiency, which rules out the classic red-to-green ramp for anything operational. And every colour has to survive the basemap underneath it: a mid-tone amber that reads clearly on a grey basemap disappears entirely over a beige landuse polygon. Measure your worst case rather than the average one.

Matching a colour scheme to the data type Sequential ramps suit ordered quantities such as counts and densities and fail when used for unordered categories. Diverging ramps suit values measured against a meaningful midpoint such as change from a target and fail when the midpoint is arbitrary. Categorical hues suit unordered classes and fail beyond about seven classes when readers can no longer match swatch to polygon. The scheme must match the data type — everything else is taste sequential — light → dark counts, densities, rates — anything with a natural low and high fails when used for unordered categories: readers infer a ranking that does not exist diverging — dark ← neutral → dark change from a target, surplus and deficit, above and below average fails when the midpoint is arbitrary — it invents a boundary the data does not have categorical — distinct hues, equal lightness land use, operator, status — no ordering implied fails past about seven classes: swatch-to-polygon matching breaks down

Step 3 — Build the Ramp in Python

The break values and the colours belong together in one object, computed once and serialised into the page. Keeping them together is what lets the legend and the map share a single source — the same discipline described in Layer Management & Toggling, applied to styling instead of visibility.

from __future__ import annotations

from dataclasses import dataclass, asdict

import geopandas as gpd


@dataclass(frozen=True)
class Ramp:
    """A frozen classification: breaks, colours and an explicit no-data colour."""

    field: str
    breaks: list[float]          # k+1 edges, ascending
    colors: list[str]            # k colours, light → dark
    nodata_color: str = "#E6E6E6"
    label_suffix: str = ""

    def class_of(self, value: float | None) -> int | None:
        """Return the class index for a value, or None when there is no data."""
        if value is None:
            return None
        for i in range(len(self.breaks) - 1):
            upper = self.breaks[i + 1]
            if value <= upper or i == len(self.breaks) - 2:
                return i
        return None

    def color_of(self, value: float | None) -> str:
        idx = self.class_of(value)
        return self.nodata_color if idx is None else self.colors[idx]

    def legend_rows(self) -> list[dict[str, str]]:
        """Legend entries derived from the SAME breaks the map uses."""
        rows = []
        for i, color in enumerate(self.colors):
            lo, hi = self.breaks[i], self.breaks[i + 1]
            rows.append({
                "color": color,
                "label": f"{lo:,.0f}{hi:,.0f}{self.label_suffix}",
            })
        rows.append({"color": self.nodata_color, "label": "no data"})
        return rows


RAMP = Ramp(
    field="incidents_per_1k",
    breaks=[0.0, 2.0, 5.0, 10.0, 20.0, 50.0],   # frozen domain thresholds
    colors=["#F1EEF6", "#BDC9E1", "#74A9CF", "#2B8CBE", "#045A8D"],
    label_suffix=" / 1k",
)

gdf = gpd.read_file("regions.geojson")
gdf["fill"] = gdf[RAMP.field].map(RAMP.color_of)
gdf.to_file("regions_styled.geojson", driver="GeoJSON")
print(RAMP.legend_rows())

Freezing the ramp as a dataclass has a second benefit: it serialises cleanly with asdict(), so the same object can be embedded in the page for the client to use, written to a build manifest for auditing, and diffed between releases to show exactly when a styling decision changed.

Step 4 — Express the Ramp in the Renderer

Each renderer wants the same information in a different shape. The translation is mechanical once the ramp exists as data.

One ramp, three renderer expressions In Folium the ramp becomes a Python style function evaluated once per feature at build time and baked into the exported HTML. In MapLibre it becomes a step expression inside the layer paint object, evaluated on the GPU for every frame, so changing a break needs no rebuild. In PyDeck it becomes a get-fill-colour accessor evaluated once per row when the layer is constructed. Same ramp object, three target shapes Folium — style_function(feature) → dict evaluated once per feature at BUILD time; the resulting colours are baked into the HTML changing a break means re-running the export MapLibre — ["step", ["get", field], c0, b1, c1, …] evaluated on the GPU every frame; the ramp is data in the style document changing a break is a style edit — no rebuild, no re-tiling PyDeck — get_fill_color accessor evaluated once per row at layer construction; colours upload as a typed array to the GPU
def maplibre_step_expression(ramp: Ramp) -> list:
    """Translate a Ramp into a MapLibre GL step expression.

    Produces: ["step", ["get", field], color0, break1, color1, break2, ...]
    """
    expr: list = ["step", ["get", ramp.field], ramp.colors[0]]
    for i in range(1, len(ramp.colors)):
        expr.append(ramp.breaks[i])
        expr.append(ramp.colors[i])
    return expr


def folium_style_function(ramp: Ramp):
    """Return a callable Folium can use as style_function."""

    def _style(feature: dict) -> dict:
        value = feature["properties"].get(ramp.field)
        return {
            "fillColor": ramp.color_of(value),
            "color": "#FFFFFF",
            "weight": 0.5,
            "fillOpacity": 0.85,
        }

    return _style

The MapLibre form deserves particular attention because it moves the classification out of the build entirely. The breaks travel in the style document, the renderer evaluates them per frame, and a change of threshold becomes a one-line edit that takes effect on reload — no re-tiling, no cache purge, no rebuild. That is the same restyle-without-rebuild property that makes vector tiles attractive in Tile vs Vector Rendering Strategies, applied at the symbology layer.

Step 5 — Generate the Legend from the Same Breaks

A legend that is written by hand will eventually disagree with the map, usually after someone adjusts a threshold and forgets the HTML. Deriving it from the ramp object makes that failure impossible: there is only one list of breaks in the system, and both the colours and the labels come from it.

def legend_html(ramp: Ramp, title: str) -> str:
    """Render a legend from the ramp — never from hand-written values."""
    rows = "".join(
        f'<li><span class="swatch" style="background:{r["color"]}"></span>'
        f'{r["label"]}</li>'
        for r in ramp.legend_rows()
    )
    return (
        f'<div class="map-legend"><h3>{title}</h3>'
        f'<ul class="legend-rows">{rows}</ul></div>'
    )

Include the no-data entry every time. A map with unexplained grey polygons invites readers to guess, and they usually guess “zero” — which is the one interpretation that turns absent information into a confident false claim.

Step 6 — Symbolise Points by Size Without Lying

Colour is not the only channel. Proportional symbols — circles whose size encodes a quantity — are often the better choice for point data, because a point’s colour has to fight the basemap while its size does not. The trap is that human perception reads area, not radius. Scaling radius linearly with the value makes a region with twice the count look four times as important, and the error compounds at the top of the range where it matters most.

import math


def radius_for(value: float, max_value: float, max_radius_px: float = 28.0) -> float:
    """Scale by AREA so perceived size is proportional to the value."""
    if value <= 0 or max_value <= 0:
        return 0.0
    return max_radius_px * math.sqrt(value / max_value)


def size_legend_values(max_value: float) -> list[float]:
    """Three round values spanning the range, for a nested-circle legend."""
    return [max_value * f for f in (0.1, 0.4, 1.0)]

Two further rules keep proportional symbols honest. First, cap the maximum radius at something that still lets a reader see the basemap underneath — around 28 pixels at desktop scale, less on a phone — and accept that the largest values will clip rather than allowing one outlier to dominate the map. Second, give the size legend the same treatment as the colour legend: three nested circles labelled with real values, generated from the same scaling function the map uses, so the reader can calibrate their eye against a known quantity.

Size and colour can be combined, and often should be: size for the magnitude, colour for the rate or category. What they must not do is encode the same variable twice, which doubles the apparent signal and makes small differences look decisive.

Step 7 — Handle Zoom-Dependent Symbology

A symbology that works at country scale rarely works at street scale. At low zoom a dense point layer becomes a solid mass, and at high zoom a choropleth’s polygons run off the screen entirely, leaving a single flat colour with no context. Both renderers that evaluate style at runtime let you interpolate style properties against zoom, which is the cleanest fix.

The practical pattern is to define two or three zoom stops and interpolate between them: small, high-opacity circles at low zoom where density is the message; larger, more transparent circles at high zoom where individual features are the message. For choropleths, fade the fill opacity down as zoom increases and let the basemap’s streets show through, so a reader who has zoomed in for detail is not staring at an opaque block.

This also interacts with the zoom envelope described in Zoom & Pan Constraints & Boundaries. If the dashboard’s minZoom and maxZoom are already derived from the dataset, the same numbers should drive the symbology stops — one source of truth for how far the reader can travel and how the data looks when they get there.

Verification & Smoke-Test

  • Class occupancy — print the count of features per class. A class holding zero features means the break is outside the data range; a class holding 90 % means the scheme does not fit the distribution.
  • Null handling — inject a feature with a missing attribute and confirm it renders in the no-data colour, not the lowest class.
  • Contrast — sample the rendered colours against the busiest basemap tile in the extent and confirm at least 3:1 for adjacent classes.
  • Legend agreement — assert in the build that the legend row count equals the class count plus one, and that the first and last labels match the outer breaks.
  • Refresh stability — rebuild with tomorrow’s data and diff the break list. With frozen breaks it must be identical; if it is not, the ramp is being recomputed somewhere.

Troubleshooting

Everything is the same colour

The break values do not span the data. This usually means the ramp was frozen against a sample extract and the production dataset has a different range, or the field name in the ramp does not match the property name after an export renamed it. Print gdf[field].describe() alongside the breaks and compare.

The map looks right but the legend does not match

Two sources of truth exist somewhere. Search the template for hard-coded colour values and delete them; the legend must be rendered from legend_rows() and nothing else.

Colours look correct on desktop and wrong on a projector

Adjacent classes in the ramp are too close in lightness. Projectors and cheap panels compress the mid-tones badly. Widen the lightness spacing rather than the hue spacing — lightness survives bad hardware, hue does not.

A diverging ramp makes an ordinary dataset look dramatic

The midpoint is arbitrary. If the data has no meaningful centre, a diverging scheme manufactures one and readers will interpret the neutral band as “normal”. Switch to sequential.

Gotchas & Edge Cases

  • Breaks are edges, not classes: k classes need k+1 edges, and off-by-one errors here silently drop the top class.
  • Values exactly on a break must have a documented side. Choose “upper inclusive” and apply it in both the map expression and the legend labels.
  • Zero and null are different. A count of zero belongs in the lowest class; a missing measurement belongs in no-data.
  • Opacity is not a free variable: stacking a semi-transparent fill over a busy basemap changes the effective colour of every class differently.
  • When symbolising by size rather than colour, scale by area and not by radius, or a value twice as large looks four times as large.