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: mapid: incomeconfig: center: [-75.19, 39.936] zoom: 12 mapStyle: "https://tiles.openfreemap.org/styles/positron"legend: position: bottom-leftsources: tracts: type: geojson url: /data/classify/sample-tracts.geojsonlayers: - 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.85The 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.
Three ways to say what the classes are
Section titled “Three ways to say what the classes are”Your own breaks
Section titled “Your own breaks”classify: field: heat_index breaks: [0.2, 0.4, 0.6, 0.8] # 4 breaks → 5 classes palette: YlOrRdbreaks: 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.
Breaks computed from the data
Section titled “Breaks computed from the data”classify: field: median_income method: quantile # quantile | equal | jenks classes: 5 # default 5method: | Classes | Good for |
|---|---|---|
quantile | the same number of features in each class | ranking; skewed data |
equal | the same width of value range in each class | evenly spread data; values with a natural unit |
jenks | natural 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.
Categories
Section titled “Categories”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-itoCategories 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:
Colours
Section titled “Colours”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”.
| Kind | Palettes |
|---|---|
| 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 divergingBrBGPiYGPRGnPuOrRdBu(andRdYlBuat small class counts) stay distinguishable for the common colour-vision deficiencies.RdYlGn,Spectraland the ColorBrewer qualitative sets do not; preferokabe-itofor categories. - Defaults. Numeric classes use
YlGnBu(viridispast 9 classes); categories useokabe-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);
viridisandcividisgo to 12,okabe-itoto 8. Validation says when a palette has too few colours. reverse: trueflips 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.
Graduated symbols
Section titled “Graduated symbols”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 classLeave out palette:/colors: and only the radius is classified (the layer’s
own circle-color stays); features with no value are then hidden.
Rates, not counts
Section titled “Rates, not counts”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- Breaks — your own
breaks:or a computedmethod:— are in the units of the rate, and the legend shows the rate. Without alabel:, 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 forradius:; it needs numeric classes (notcategories:), andmultiply:needsnormalize:.
The compiled input is ["/", ["to-number", value], ["to-number", divisor]]
(times multiply), inside the same let/case guard as below.
No data
Section titled “No data”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 hidenoData: 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.
The legend
Section titled “The legend”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,000for 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,000format: 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.
Which paint property
Section titled “Which paint property”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 type | property: |
|---|---|
| fill | fill-color, fill-outline-color |
| line | line-color |
| circle | circle-color, circle-stroke-color |
| fill-extrusion | fill-extrusion-color |
| symbol | text-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.
What it compiles to
Section titled “What it compiles to”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 emitbakes computed breaks into the style. With aurl:source it loads the data at export: anhttps://URL is fetched, a path is read from disk (relative to the YAML file; a/…path from the nearestpublic/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,matchorinterpolateinpaint:keeps working on any layer, beside classified ones.
Format v2 and stories
Section titled “Format v2 and stories”In format v2, classify: sits on the layer under
style.layers[] (it is style, not runtime), with label: under the layer’s
runtime::
version: 2type: mapid: income-v2style: 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.
Checks
Section titled “Checks”Validation catches the common slips, at the line they happen on:
- breaks that do not go up; a
colors:list (orradius:) whose length does not match the classes (n breaks → n + 1); - an unknown
method:orpalette:(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:oncategories:, a field divided by itself, ormultiply:withoutnormalize:(andnormalise:gets a “did you mean”).