Skip to content

Thematic maps & legends

A thematic map colours (or sizes) each feature by one of its values: income by tract, population by city, land use by parcel. In MapLibre you write that as a step or match expression, and then write the legend by hand to match it. A classify: block does both from one description of the classes:

type: map
id: income
config:
center: [-75.19, 39.936]
zoom: 12
mapStyle: "https://tiles.openfreemap.org/styles/positron"
legend:
position: bottom-left
sources:
tracts:
type: geojson
url: /data/classify/sample-tracts.geojson
layers:
- id: income
type: fill
source: tracts
label: Median household income
classify:
field: median_income # the feature property to classify
method: quantile # breaks computed from the data
classes: 5
palette: Blues # a ColorBrewer palette
paint:
fill-opacity: 0.85
Five quantile classes computed when the data loads; tracts without a value are light grey

The examples on this page use two sample files: sample-tracts.geojson and world-cities.geojson. Download them into a data/classify/ folder of your site (for an Astro site, public/data/classify/) so the url: paths above work, or use the full addresses as the url:.

classify: sits on the layer beside paint:. It is sugar over MapLibre, not a new language: it compiles to the step (numeric classes) or match (categories) expression you would otherwise write, in paint.fill-color (or circle-color, line-color, …). What you learn here — thresholds, palettes, no-data — is what MapLibre, QGIS and d3 call the same things.

classify:
field: heat_index
breaks: [0.2, 0.4, 0.6, 0.8] # 4 breaks → 5 classes
palette: YlOrRd

breaks: are MapLibre step thresholds: n breaks make n + 1 classes, and a value equal to a break goes in the class above it (0.4 is in the third class, 0.4–0.6). The first class is everything below the first break, the last everything from the last break up.

Explicit breaks work on every source — GeoJSON, vector tiles, PMTiles — because nothing has to be computed in the browser. Breaks you computed elsewhere (QGIS, a notebook, the census bureau’s published ranges) go here.

classify:
field: median_income
method: quantile # quantile | equal | jenks
classes: 5 # default 5
method:ClassesGood for
quantilethe same number of features in each classranking; skewed data
equalthe same width of value range in each classevenly spread data; values with a natural unit
jenksnatural breaks: groups that are as alike inside, and as different between, as possible (Fisher–Jenks, computed exactly)data with clusters

The breaks are computed when the data arrives — at load for inline data:, when the fetch lands for a url: (or a FlatGeobuf file), and again on every refresh of live data — so the classes always describe the features on the map. When many values tie (40% of tracts at 0), a method can produce fewer classes than you asked for; the map and legend then show the classes that exist.

classify:
field: land_use
categories:
residential: "#f6e8c3"
commercial: "#d8b365"
industrial: "#8c510a"
other: "#bbbbbb" # values you did not list (default grey; or hide)

Or list the values and let a palette colour them (in order):

classify:
field: CONTINENT
categories: [Africa, Asia, Europe, North America, South America, Oceania]
palette: okabe-ito

Categories compile to a match. Numbers work too (categories: [1, 2, 3] matches a zoning code stored as 1 or "1"). The legend lists categories in the order you write them, numeric codes included. Categories need no data scan, so they work on vector tiles exactly as on GeoJSON:

The demo tiles' countries by continent, with a colour-blind-safe palette

palette: names a palette; colors: lists your own (one per class, or per category). Names are matched case-insensitively (blues is Blues), and a near miss gets a “did you mean”.

KindPalettes
Sequential (light → dark)Blues Greens Greys Oranges Purples Reds BuGn BuPu GnBu OrRd PuBu PuBuGn PuRd RdPu YlGn YlGnBu YlOrBr YlOrRd viridis cividis
Diverging (low ↔ high around a middle)BrBG PiYG PRGn PuOr RdBu RdGy RdYlBu RdYlGn Spectral
Qualitative (categories)okabe-ito Set1 Set2 Set3 Pastel1 Pastel2 Dark2 Paired Accent
  • Colour-blind safety. Every sequential palette, viridis, cividis, okabe-ito, and the diverging BrBG PiYG PRGn PuOr RdBu (and RdYlBu at small class counts) stay distinguishable for the common colour-vision deficiencies. RdYlGn, Spectral and the ColorBrewer qualitative sets do not; prefer okabe-ito for categories.
  • Defaults. Numeric classes use YlGnBu (viridis past 9 classes); categories use okabe-ito. Both are colour-blind safe.
  • Class counts. ColorBrewer palettes start at 3 classes and go up to 9 (sequential), 11 (diverging) or 8–12 (qualitative); viridis and cividis go to 12, okabe-ito to 8. Validation says when a palette has too few colours.
  • reverse: true flips a palette (dark → light).

Palette names resolve to plain hex colours when the document is read: the compiled expression and an exported style carry literal colours, never a palette name.

On a circle layer, radius: sizes each class — the same breaks drive circle-color and circle-radius:

# `cities` is a GeoJSON source declared under the document's sources:
- id: cities
type: circle
source: cities
label: City population
classify:
field: pop
method: jenks
classes: 5
palette: YlOrRd
radius: [2, 4, 7, 10, 14] # one circle-radius per class
Natural-breaks classes; the legend draws each class's circle at its size

Leave out palette:/colors: and only the radius is classified (the layer’s own circle-color stays); features with no value are then hidden.

A map of raw counts mostly shows where people live: the biggest tracts have the most of everything. Map a rate instead — normalize: divides the field by another property, and multiply: scales the result:

classify:
field: cases # the count
normalize: population # ÷ the people it happened to
multiply: 100000 # × 100,000 → cases per 100,000 residents
method: jenks # breaks are computed on the rate
palette: OrRd
Cases per 100,000 residents: the legend's edges are rates
  • Breaks — your own breaks: or a computed method: — are in the units of the rate, and the legend shows the rate. Without a label:, the legend heading names it (cases per 100,000 population).
  • A tract whose population is missing, 0 or not a number is no data, as is one whose count is missing: the compiled expression checks before it divides, so no feature is ever coloured by Infinity or NaN.
  • normalize: works for colour and for radius:; it needs numeric classes (not categories:), and multiply: needs normalize:.

The compiled input is ["/", ["to-number", value], ["to-number", divisor]] (times multiply), inside the same let/case guard as below.

A feature has no data when its value is null or missing, an empty string, or text that is not a number ("n/a"). Numeric strings — "15", which CSV-derived GeoJSON is full of — are read as numbers.

classify:
field: median_income
breaks: [25000, 50000, 75000]
noData: "#eeeeee" # a colour (default: light grey #cccccc), or hide

noData: hide leaves those features off the map instead (it adds a condition to the layer’s filter:).

When every feature is no data, the field name is usually the problem: property names are case-sensitive (Median_Income is not median_income), and numeric classes need a numeric property. Once the data has loaded, the console (and, while you develop, a note on the map) names the field and the data’s actual property names, with a did-you-mean. The same goes for normalize:. The legend then shows only the “No data” line.

A classified layer adds its classes to the map’s legend: a heading (the layer’s label:, else the field name), one line per class with its real edges, and a “No data” line. A label: also gives the layer a visibility checkbox in the map’s layer panel (top-right, in maps and stories alike); add toggleable: false to the layer to keep the heading without the checkbox. The lines read:

  • < 25,000, 25,000–50,000, ≥ 75,000 for explicit breaks;
  • 3–10 … 31–55, using the data’s lowest and highest value, when the data is GeoJSON (so the edges are the dataset’s own);
  • “Other” and “No data” only when they can occur (with GeoJSON data, only when some feature actually has no data).

A map with a classified layer gets its legend automatically, even with no legend: block, in the default place (top-left, open, headed “Legend”); a story pins one by its usual rules, with the same classes (breaks computed after load included). legend: false at the top level means no legend, and legend: false on a classified layer leaves its classes out. A hand-written layer legend: entry wins over the generated lines, and a legend: block with its own items: lists them first and the generated classes after them (when those items describe the colours from before classify:, remove them; validation and the page’s author notes point this out). Placement, title and the other legend rules are in Map configuration › Legend.

Number formatting follows the reader’s locale; set it with MapLibre’s own number-format option names:

classify:
field: median_income
method: quantile
format: { currency: USD, max-fraction-digits: 0 } # $25,000–$50,000

format: takes locale, currency, unit (e.g. percent, kilometer), min-fraction-digits and max-fraction-digits; without a locale the edges follow the page’s language. The “No data” and “Other” lines are UI strings you can translate in locale: like the rest (Accessibility & languages): maplibre-yaml.Classify.NoData and maplibre-yaml.Classify.Other.

By default classify drives the layer type’s main colour: fill-color, line-color, circle-color, fill-extrusion-color, or text-color on a symbol layer. property: picks another colour property:

classify:
field: risk
breaks: [1, 2]
palette: OrRd
property: circle-stroke-color
Layer typeproperty:
fillfill-color, fill-outline-color
lineline-color
circlecircle-color, circle-stroke-color
fill-extrusionfill-extrusion-color
symboltext-color, icon-color, text-halo-color, icon-halo-color

One owner per property: writing both classify: and paint.fill-color on the same layer is an error (keep one). Other paint properties — fill-opacity, circle-stroke-width — sit beside classify as usual.

classify is an exports construct: an exported style carries the compiled expression and nothing else is needed to draw it. The income layer above (with explicit breaks) becomes:

"fill-color": [
"let", "value", ["get", "median_income"],
["case",
["any",
["all", ["==", ["typeof", ["var", "value"]], "number"], ["==", ["var", "value"], ["var", "value"]]],
["all", ["==", ["typeof", ["var", "value"]], "string"], ["!=", ["var", "value"], ""],
["==", ["to-number", ["var", "value"], 0], ["to-number", ["var", "value"], 1]]]],
["step", ["to-number", ["var", "value"]], "#eff3ff", 25000, "#bdd7e7", 50000, "#6baed6", 75000, "#2171b5"],
"#cccccc"]
]

The case is the no-data guard (a number that is not NaN, or a non-empty numeric string); inside it is the plain step. Categories compile to ["match", ["to-string", ["get", field]], …, other] behind the same kind of guard.

  • Provenance. The layer’s metadata["maplibre-yaml:classify"] records what the expression means — field, method, the breaks actually drawn, colours, palette, no-data treatment — so a third-party legend (or a future importer) can read it.
  • mlym emit bakes computed breaks into the style. With a url: source it loads the data at export: an https:// URL is fetched, a path is read from disk (relative to the YAML file; a /… path from the nearest public/ folder). If the data cannot be loaded, the export fails and names the layer and the URL — a classed map whose classes are unknown has no honest fallback.
  • Raw MapLibre still works. classify is one way to write the expression, never the only one: a hand-written step, match or interpolate in paint: keeps working on any layer, beside classified ones.

In format v2, classify: sits on the layer under style.layers[] (it is style, not runtime), with label: under the layer’s runtime::

version: 2
type: map
id: income-v2
style:
basemap: "https://tiles.openfreemap.org/styles/positron"
center: [-75.19, 39.936]
zoom: 12
sources:
tracts: { type: geojson, url: /data/classify/sample-tracts.geojson }
layers:
- id: income
type: fill
source: tracts
classify: { field: median_income, method: quantile, palette: Blues }
runtime: { label: Median household income }
runtime:
legend: { position: bottom-left }

A scrollytelling story’s layers: take classify: exactly as a map’s do.

Validation catches the common slips, at the line they happen on:

  • breaks that do not go up; a colors: list (or radius:) whose length does not match the classes (n breaks → n + 1);
  • an unknown method: or palette: (with a “did you mean”);
  • a palette with too few colours for the classes;
  • method: on a vector tile source;
  • classify: and the same paint property both written;
  • radius: on a layer that is not a circle layer;
  • normalize: on categories:, a field divided by itself, or multiply: without normalize: (and normalise: gets a “did you mean”).