How to Style Data-Driven Colours in a Web Map

Problem statement

Data-driven styling is what makes vector tiles worth using: the same tiles serve every style, and the colour is computed in the browser from the feature's attributes.

Two things make it harder than it looks.

The attributes have to be in the tiles. Styling by a field that was dropped during generalisation produces the fallback colour everywhere, with no error. Attributes cost real bytes β€” two string fields added 38% to a measured tile.

The classification decides the map. Equal-interval, quantile and natural-breaks classifications of the same data produce different maps and support different conclusions, and the style sheet is where that choice gets made without anyone noticing.

Quick answer

paint: {
  // continuous: interpolate between stops
  "fill-color": [
    "interpolate", ["linear"], ["get", "density"],
    0,    "#f7fbff",
    50,   "#c6dbef",
    200,  "#6baed6",
    1000, "#08306b",
  ],
  // categorical: match exact values, with a fallback
  "fill-outline-color": [
    "match", ["get", "class"],
    "residential", "#1a3a6b",
    "commercial",  "#0ea5e9",
    /* fallback */ "#94a3b8",
  ],
  // zoom-dependent
  "fill-opacity": ["interpolate", ["linear"], ["zoom"], 12, 0.2, 16, 0.7],
}

match requires a fallback as its last argument. Without one, features whose value is not listed get no paint and are invisible.

Three expression types: interpolate for continuous values, step for classed values, and match for categories, each with a fallback.
Interpolate for continuous, step for classes, match for categories. Each needs an explicit fallback.

Step-by-step solution

1. Make sure the attribute is in the tiles

from pmtiles.reader import Reader, MmapSource
with open("tiles.pmtiles", "rb") as f:
    for layer in Reader(MmapSource(f)).metadata()["vector_layers"]:
        print(layer["id"], sorted(layer.get("fields", {})))

A style referring to a missing field is not an error β€” ["get", "density"] returns null, the expression falls through to its default, and the map renders in one flat colour.

2. Watch the type of the value

MVT properties are typed, and tile generators frequently write everything as strings.

["interpolate", ["linear"], ["to-number", ["get", "density"]], ...]

interpolate on a string produces no output and no error. ["to-number", ...] is the defensive form, and returns null for values it cannot parse β€” which the fallback then catches.

3. Choose the classification deliberately

The same values classified three ways give three maps:

  • Equal interval β€” even value ranges. Honest about magnitude, and useless when the distribution is skewed, which spatial data usually is.
  • Quantile β€” even feature counts per class. Always uses the full palette, and can put nearly identical values in different classes.
  • Natural breaks β€” minimises within-class variance. Usually the most readable and the hardest to explain.

Compute the breaks in Python, where you can inspect them, and paste them into the style rather than letting the style imply them.

4. Use step for classes, interpolate for continua

["step", ["get", "value"], "#f7fbff", 50, "#c6dbef", 200, "#6baed6"]

step produces discrete classes with sharp boundaries β€” the choropleth convention, and easier to read from a legend. interpolate produces a continuous ramp, which suits density surfaces and looks vague on polygons.

5. Handle nulls explicitly

["case",
  ["==", ["get", "value"], null], "#e2e8f0",
  ["interpolate", ["linear"], ["get", "value"], 0, "#f7fbff", 100, "#08306b"]]

"No data" and "zero" are different, and a colour ramp that renders them identically is a factual error on the map.

The same values classified by equal interval, quantile and natural breaks, producing three visibly different maps.
Three classifications of one dataset. Each is defensible and they support different readings.

Code examples

Example 1 β€” generating the style expression from the data

import json
import numpy as np
import mapclassify


def colour_expression(values, field, scheme="quantiles", k=5,
                      palette=("#f7fbff", "#c6dbef", "#6baed6",
                               "#2171b5", "#08306b"), null_colour="#e2e8f0"):
    """A MapLibre step expression with breaks computed and reported."""
    clean = np.asarray(values, dtype=float)
    clean = clean[np.isfinite(clean)]

    classifier = {
        "quantiles": mapclassify.Quantiles,
        "equalinterval": mapclassify.EqualInterval,
        "naturalbreaks": mapclassify.NaturalBreaks,
    }[scheme](clean, k=k)
    breaks = [float(b) for b in classifier.bins[:-1]]

    print(f"  {scheme}, k={k}: breaks {[round(b, 2) for b in breaks]}")
    for i, count in enumerate(classifier.counts):
        print(f"    class {i}: {count:,} features "
              f"({count / len(clean):.1%})")

    expression = ["case",
                  ["==", ["get", field], None], null_colour,
                  ["step", ["to-number", ["get", field]], palette[0]]]
    for break_value, colour in zip(breaks, palette[1:]):
        expression[-1].extend([break_value, colour])

    return expression
  quantiles, k=5: breaks [41.0, 55.0, 71.0, 118.0]
    class 0: 11,878 features (20.0%)
    class 1: 11,878 features (20.0%)
    class 2: 11,879 features (20.0%)
    class 3: 11,878 features (20.0%)
    class 4: 11,878 features (20.0%)

Printing the class counts is what turns the classification into a decision. Equal interval on the same data would put most features in one class, which the counts make obvious immediately.

Example 2 β€” checking a style against the tiles

import gzip
import re
import mapbox_vector_tile as mvt
from pmtiles.reader import Reader, MmapSource
from pmtiles.tile import zxy_to_tileid


def validate_style(style, archive_path, sample_tile):
    """Do the fields the style uses exist in the tiles?"""
    text = json.dumps(style)
    used = set(re.findall(r'\["get",\s*"([^"]+)"\]', text))
    source_layers = set(re.findall(r'"source-layer":\s*"([^"]+)"', text))

    with open(archive_path, "rb") as handle:
        reader = Reader(MmapSource(handle))
        data = reader.get(zxy_to_tileid(*sample_tile))
        decoded = mvt.decode(gzip.decompress(data))

    available_layers = set(decoded)
    print(f"  style uses layers {sorted(source_layers)}")
    print(f"  tile contains     {sorted(available_layers)}")
    for missing in source_layers - available_layers:
        print(f"  ! source-layer '{missing}' is not in the tiles")

    fields = set()
    for layer in decoded.values():
        for feature in layer["features"][:50]:
            fields.update(feature.get("properties", {}))
    print(f"  style reads fields {sorted(used)}")
    for missing in used - fields:
        print(f"  ! field '{missing}' is not in the tiles β€” "
              "the expression will fall through to its default")
    return used - fields

Both failures β€” a missing layer and a missing field β€” are silent in the browser. Checking them against a real tile before deploying is a minute's work.

Example 3 β€” a legend that matches the style

def legend_from_expression(expression, field, unit=""):
    """Extract the breaks and colours so the legend cannot drift."""
    step = expression
    while isinstance(step, list) and step[0] != "step":
        step = next((part for part in step
                     if isinstance(part, list) and part and part[0] == "step"),
                    None)
        if step is None:
            raise ValueError("no step expression found")

    default_colour = step[2]
    pairs = step[3:]
    entries = [{"label": f"< {pairs[0]}{unit}", "colour": default_colour}]
    for i in range(0, len(pairs), 2):
        value, colour = pairs[i], pairs[i + 1]
        upper = pairs[i + 2] if i + 2 < len(pairs) else None
        entries.append({
            "label": f"{value}{unit} – {upper}{unit}" if upper
                     else f"β‰₯ {value}{unit}",
            "colour": colour,
        })

    for entry in entries:
        print(f"  {entry['colour']}  {entry['label']}")
    return entries

Deriving the legend from the style expression rather than writing it separately is the only way to stop the two drifting apart. A legend that does not match the map is worse than no legend.

Explanation

Why a missing field is silent

MVT features carry only the properties the encoder wrote. ["get", "density"] on a feature without that property returns null.

interpolate and step given null fall through to their default, which is usually the first colour. So the map renders β€” in one flat colour, uniformly, with no console message.

The same thing happens when generalisation drops attributes at low zoom to save bytes: the map is correctly styled at high zoom and flat at low zoom, which looks like a data problem and is a pipeline one.

Why types matter more than in Python

MVT properties are typed as string, number or boolean, and many generators write numbers as strings β€” often because the source column was object dtype.

MapLibre's interpolate requires a number. Given a string it produces no output, and again there is no error.

["to-number", ["get", "field"]] coerces and returns null on failure, which a case can catch. Alternatively, fix the types when writing the tiles, which is better and requires controlling the generator.

Why the classification is the map

A choropleth's message comes from its classification. Equal interval on a skewed distribution puts almost everything in the first class and shows a nearly uniform map. Quantiles guarantee an even spread of colour, which can exaggerate differences between nearly identical values.

Neither is wrong; they answer different questions. The problem is that the choice usually happens inside a style sheet, invisibly, and the reader has no way to know which was used.

Computing the breaks in Python and printing the class counts makes it a decision. Recording the scheme in the legend makes it honest.

Why null must be distinguished from zero

A colour ramp maps zero to its lowest colour. If missing data is also rendered as the lowest colour β€” or as the fallback, which is often the same β€” the map asserts that unmeasured places have low values.

For a density map that reads as "nobody lives here" where the truth is "we did not measure". The convention is a distinct neutral grey with its own legend entry, and it costs one case branch.

A colour ramp rendering null and zero identically against one giving null a distinct neutral colour and legend entry.
One `case` branch separates "we measured zero" from "we did not measure".

Edge cases or notes

  • A missing field falls through to the default with no error.
  • Numbers can arrive as strings. Wrap in ["to-number", ...].
  • match needs a fallback as its final argument.
  • step for classes, interpolate for continua.
  • Null and zero are different. Give null its own colour and legend entry.
  • Compute breaks in Python and paste them in; print the class counts.
  • Attributes cost tile bytes β€” two string fields added 38% to a measured tile.
  • Derive the legend from the expression so they cannot drift apart.

FAQ

Why is my whole layer one colour?

The styled field is not in the tiles, or arrives as a string. The expression falls through to its default, silently.

How do I check which fields are in my tiles?

Read vector_layers from the archive metadata, or decode one tile and print the properties of a few features.

Why does interpolate not work on my numeric field?

It is probably a string. Wrap it in ["to-number", ["get", "field"]], or fix the type when generating the tiles.

Should I use step or interpolate?

step for classed choropleths with a discrete legend, interpolate for continuous surfaces such as density.

Where should I compute the class breaks?

In Python, where you can print the class counts and compare schemes. Paste the resulting breaks into the style.

How do I show missing data?

A case branch testing for null, rendering a neutral grey, with its own legend entry. Never let null share a colour with zero.

Does adding attributes make tiles much bigger?

Measurably. Two string attributes added 38% to a tile of 182 buildings. Include only what the style and interaction need.