---
name: sprezzature-maps
description: >-
  Real geography, three kinds: a choropleth on a true basemap and projection, a
  layered areas-of-control "situation" plate, and an accumulation map where
  point events draw the land themselves, no coastline drawn. The trigger is
  data attached to PLACES, not the word "map": a column of countries, ISO
  codes, states or départements is a map waiting to be drawn, and so is a list
  of coordinates where something happened. Trigger
  phrases: "map this by country", "colour the regions by score", "which country
  is worst affected", "break this down by region", "where is this happening",
  "show me the spread across Europe", "world map", "choropleth", "thematic map",
  "areas of control", "front line", "contested zones", "who controls what",
  "situation map", "where did these incidents fall", « une carte par
  département », « carte de situation ». Schematic place-shaped charts — hex
  map, spike map, binned grid, dot density over an outline — are NOT here; they
  live in sprezzature-figures. Output is SVG (PNG / PDF on request).
license: BSD-3-Clause
compatibility: >-
  Runtime: Claude.ai, Claude Code, OpenCode. Ships as the standalone
  `sprezzature-maps` package (Python 3.10+). The basemap is bundled Natural
  Earth data — no network, no tile server, no API key at any point.
metadata:
  author: Warith HARCHAOUI
  version: 1.3.7
---

> The generators below ship as the standalone package [`sprezzature-maps`](https://github.com/warith-harchaoui/sprezzature-maps) (`pip install sprezzature-maps`), invoked as `make-map …`. There is no `scripts/` folder in this monorepo; the SKILL.md here is the agentic contract.

# sprezzature-maps — choropleths and situation maps on real geography

## The trigger, stated once

**Data attached to *places* is the trigger.** Not the word "map". "Which
countries are worst affected", "break this down by région", "where is this
happening", "show me the spread across Europe" — a column of country names,
ISO codes, states or départements is a map waiting to be drawn, and the only
open question is which kind.

## The one routing decision that matters

This package draws **real geography**: actual coastlines, an actual
projection, bundled Natural Earth outlines. That is the whole scope, and it
is also the line an agent gets wrong.

| The user wants… | Go to | Why |
|---|---|---|
| Territories shaded by a value, on real coastlines | **here** — `make-map choropleth` | the shape of the land carries meaning |
| Who holds which ground, front lines, contested zones | **here** — `make-map situation_map` | same |
| Where events *fell* — incidents, outages, strikes, sightings | **here** — `make-map density` | positions, not a value per territory |
| A hex map, dot density, spike map, binned grid, cartogram | **`sprezzature-figures`** | those are layout conventions, not geography |

**The line between `choropleth` and `density` is the one to get right.** A
choropleth answers "what is the rate *here*"; it needs one number per
territory, and filling it with a raw count draws population rather than your
phenomenon. `density` answers "where did this happen"; it takes the positions
themselves and needs no territories at all.

Ask the question plainly: **does the shape of the land matter?** If it does,
this skill. If the map is a seating chart that happens to look like a country,
`sprezzature-figures` has those five kinds and `list_kinds` will name them.

## Honest framing of what each generator covers

| Generator | Catches | Misses |
|---|---|---|
| `choropleth` | one fill colour per territory on a perceptual OKLCH ramp, Equal Earth projection, hillshade relief, automatic diverging scale when the values span both signs, neutral grey for territories with no data | it maps a value per *territory*, so it inherits the choropleth's own bias: a large sparsely-populated region shouts louder than a small dense one. If the story is about people rather than land, say so, and consider a cartogram or a per-capita normalisation before drawing. |
| `situation_map` | a layered plate for any region: auto-centred Lambert conformal conic projection, real national outlines, bathymetry halo near the coast, zones filled by category, flashpoint markers, a scale bar in kilometres **and** miles | it has **no view on whether a claim of control is true**. It draws exactly what the config says. |
| `density` | an accumulation map: point events binned to a luminous field. No coastline, no border, no graticule are drawn — the land appears because events fell on it and the sea stays dark because none did, so a reader recognises the shape without being shown it. Vector, binned, one path per level. | it shows *where*, never *how many per capita*: a dense city looks dense because people are there. And the form is persuasive enough to be believed, so the caption carrying the events' provenance is not decoration — the bundled demo says on the plate that its data is synthetic. |

## One contract an agent must not break

**A situation map draws exactly what you send it.** A plate that looks like a
professional intelligence product gets read as one. When you hand such a map
to a user, or a user hands it onward, say where the underlying assessment came
from. That is not a disclaimer to append at the bottom — it is part of the
map's meaning, and omitting it is how a rough sketch becomes someone's
evidence.

## Decision tree

| Trigger | Call | Run |
|---|---|---|
| "map this by country" / "colour the regions by score" / "which country is highest" / « une carte par département » | `choropleth` | `make-map choropleth --data rows.json --out world.svg [--title "…"]`. Rows are `{"id": "<ISO-3166-1 numeric>", "value": <number>}` — `"840"` for the United States, `"124"` for Canada. Send nothing at all and you get the demo map, which is the fastest way to show someone the shape of the input before they commit real data to it. |
| the values are skewed — income, density, counts, anything with a long tail | `choropleth` with `classes` | `make-map choropleth --classes 5 --method quantile`, or `{"classes": 5, "method": "jenks"}` on the route. Without it the ramp stretches min-to-max and most territories collapse into its bottom. See the section above for which method answers which question; the legend names whichever you pick. |
| "areas of control" / "front line" / "who controls what" / « carte de situation » | `situation_map` | `make-map situation_map --config region.yaml --out region.svg`. The region, the zones, the categories and the flashpoints all live in the YAML, so the CLI is usually the better surface here; the bundled Western-Europe demo config runs with no arguments. |
| "what kinds of map can you draw" | `list_kinds` | Three: `choropleth`, `density`, `situation_map`. Anything else map-shaped is in `sprezzature-figures`. |

## The situation map is configured, not flagged

Everything a situation plate shows lives in one YAML file, and **almost
every thematic layer is off by default** — they are modes, and a plate that
switches them all on says less than one that picks. The keys the generator
reads:

**`region`** is the only required one — `{bbox: [west, south, east, north]}`
in degrees, and what the projection auto-centres on. The rest:

`title`, `subtitle`, `caption`, `as_of` · `projection`, `basemap`, `frame`,
`padding`, `canvas_width`, `simplify`, `interactivity` ·
`areas_of_control`, `front`, `arrows`, `frontiers`, `internal_borders` ·
`forces`, `events`, `infrastructure`, `labels`, `annotations`, `rivers`,
`lakes`, `cities`, `inset` · `legend_position`, `legend_footer`,
`marker_legend` · `source`, `attribution`, `method`.

`make_situation_map`'s module docstring carries the full reference under
**Every configuration key**, and a test fails if a top-level key is added
without a line there. That guard covers the top level only, so a nested key
can still arrive undocumented — `areas_of_control.over_water` and
`lakes.depth_rings` both did, in 0.10.0.

The last three are the provenance block, and they are the ones to fill in
first rather than last: `source` and `as_of` are what stop a plate from being
read as current when it is not, and `method` is where the assessment behind
the zones is named. See the contract above.

### The one key that is on by default

`interactivity` is the exception to "off unless asked", and it is worth
knowing before a plate goes on a page.

| Value | What ships |
|---|---|
| `"self-contained"` *(default)* | the plate carries its own layer switches, confidence filter and clickable legend, inside the file, with no page script |
| `"external"` | the controls are left to a page-level script |
| `"static"` | none of it ships |

It only materializes when `areas_of_control` has a `palette` — a plate with
no assessment on it is unaffected. And it degrades by construction: the panel
ships hidden and the script reveals it, so an `<img>` embed, a rasteriser or
a PDF see exactly the plain map. **An `<object>` embed does not** — there the
SVG is a live document and the controls come up, which is the point, but it
means a page that enlarges plates in a lightbox is shipping an interactive
map whether or not it meant to.

The controls keep their state in the URL fragment, which sounds alarming on
a page that routes by fragment itself and is not: the script reads and writes
`svg.ownerDocument.defaultView.location`, so inside an `<object>` it addresses
its own nested document and never the host page, and it uses
`history.replaceState` rather than `pushState`, so it adds no history entries.

Two more keys arrived with it, both nested, both off by default:
`areas_of_control.over_water` (`false`) keeps a zone's own shape instead of
clipping it to land, for a claim that really is maritime; `lakes.depth_rings`
(`4`) steps shelf rings inward from a lake's shore, `0` turns them off.

Rivers taper by prominence when `rivers` asks them to; they are absent
otherwise. The same is true of every other layer — ask for what the map is
about, not for everything the generator can draw.

## What every map carries by default

Since 0.7.0 a map is of a populated planet unless you say otherwise. Three
layers come on their own, and each has an off switch rather than an on one.

| Layer | Why it is on | Turn it off with |
|---|---|---|
| **Cities** | A map with nobody on it gives a reader nowhere to stand — they see where the Andes are and not where Lima is. Chosen by cartographic prominence, **not** by "is it a capital": that list omits New York, Mumbai, São Paulo, Shanghai, Los Angeles and Karachi, six of the world's twelve largest. The selection widens as the map zooms in. | `cities=False` / `--no-cities` / `"cities": false`; on a situation plate, `cities: {show: false}` |
| **Rivers** | Drainage is the structure of a landmass, and tapering by prominence turns a tangle of blue lines into a network you can follow. Drawn over the thematic fill, where published cartography puts water. | `rivers=False` / `--no-rivers`; on a plate, `rivers: {show: false}` |
| **Relief** | Computed from real elevation — a Lambertian hillshade blended with Brown's fractional-Laplacian texture shading — not a pre-shaded picture. | `relief=False` |

**A label in the way is dropped, not moved.** A city name nudged clear of its
own dot points at the wrong place and the reader has no way to tell. If a name
you want is missing, widen the selection (`cities.limit`, `cities.max_rank`) or
place it by hand in `labels.places`, which always wins over the automatic layer.

## The choropleth's one real decision: how to class the values

A choropleth turns numbers into colours, and **the rule it uses is the most
consequential setting on the map**. The same dataset classed four ways tells
four stories, and a reader given no statement of the method cannot tell which
one they are being told.

By default this generator does not class at all: it stretches the ramp
linearly from the smallest value to the largest. That is fine for values
spread evenly, and it is the weakest possible choice for the right-skewed data
most indicators are — income, density, counts, GDP per head. On GDP per head
for twenty countries it puts nine of them in the bottom quarter of the ramp,
and leaves India, at ten times Burundi's figure, a shade the eye cannot tell
from Burundi's.

**So: whenever the values are skewed, pass `classes`.** Then pick the method
for the question being asked, not by habit:

| The map has to… | `method` | What it costs |
|---|---|---|
| support ranking — "which fifth is this region in?" | `quantile` | two regions with very different values can share a class; it flattens the skew by construction |
| show the groupings the data itself has | `jenks` | classes can be very unequal in count, and a lone outlier gets a class of its own |
| mean something outside the data — decades, percentage bands | `equal` | on skewed data most regions land in one class, which is the failure above |
| handle a heavy tail — "far more small things than large" | `headtail` | the class count comes from the data, not from you, and may be small |

**On a diverging indicator, zero is always a boundary.** Growth rates,
anomalies, swings — anything where the sign is the story. A class spanning
zero takes the colour of its own centre, so a country in recession comes out
the colour of growth; that is the one thing a diverging scale exists to
prevent. The generator now classes each side on its own values and keeps zero
as a break, so you do not have to arrange it. Worth knowing because it was
broken between 0.3.0 and 0.5.0: if you have a classed diverging map from that
window, re-render it.

**When the bands are editorial, pass them.** `breaks=[5, 10, 25]` (or
`--breaks 5,10,25`) overrides both `classes` and `method`: a regulatory
threshold, a figure the newsroom already published, a number the reader
arrives with — no algorithm will land on those by luck. The legend then says
the breaks were *given*, which is the provenance that matters.

The legend prints the method and the boundaries. Do not strip that: a classed
map without its method is a claim with the evidence removed. When you hand the
figure on, say which method it used and why — the choice is editorial, and it
belongs with the map.

## A mistyped config key is an error, not a missing feature

The situation map is driven by two dozen optional keys. Get one wrong and the
generator used to read past it in silence, so a slip on `areas_of_control`
dropped a whole layer while the plate still looked finished. Since 0.6.0 an
unknown key is refused and the error names the closest known one.

So when you see *"config has key(s) this generator never reads"*, read it as a
typo with the fix already in the message — not as a capability that is
missing. The keys are listed in the section above.

## Two modes: make and audit

| Mode | Tool | Purpose |
|---|---|---|
| **Make** — a choropleth on real geography | `make-map choropleth` | Territories shaded by a value, Equal Earth, OKLCH ramp, relief. |
| **Make** — an areas-of-control plate | `make-map situation_map` | Layered situation map for any region from one YAML config. |
| **Audit** | _(none here — see `sprezzature-figures`' `audit_figure.py`)_ | The rendered SVG is a figure like any other, so the data-viz auditor and the colour-vision accessibility levels apply to it unchanged. Run them there rather than duplicating a second auditor here. |

The gap in the audit column is real and deliberate: a second SVG auditor in
this package would drift from the one in `sprezzature-figures` within a
release. One auditor, two producers.

## The five surfaces

Every tool in this suite is reachable five ways, and maps is no exception.

| Surface | How |
|---|---|
| Library | `from sprezzature_maps import make_choropleth, make_density, make_situation_map` |
| CLI | `make-map choropleth --out world.svg`, `make-map density`, `make-map situation_map` |
| HTTP API | `POST /v1/choropleth`, `/v1/density`, `/v1/situation-map` (`pip install 'sprezzature-maps[api]'`) |
| MCP | `render_choropleth` / `render_density` / `render_situation_map` / `list_kinds`, derived from those routes (`[mcp]`) |
| Skill | this file |

Both render surfaces take every field as optional: send an empty body and you
get the demo map.

## When NOT to use this skill

- **The shape of the land does not matter.** A hex map of US states, a dot
  density, a spike map, a binned grid: `sprezzature-figures`.
- **You need an interactive slippy map** (pan, zoom, tiles): this emits one
  static SVG per call. Leaflet / MapLibre are the right tools.
- **You need routing, geocoding, or spatial joins.** This draws; it does not
  compute geography. Bring the rows already keyed by territory.
- **You need a basemap the bundle does not carry.** The Natural Earth data
  ships with the package precisely so nothing phones home; a custom basemap is
  outside that bargain.

## References

- `references/choosing-a-map.md` — which of the three map families (real
  geography, schematic, cartogram) answers which question, and the failure
  each one is prone to.

## Companion skills

| You also need… | Install |
|---|---|
| Charts, including the schematic place-shaped ones | `sprezzature-figures` |
| Contrast audit and colour-vision simulation on the rendered plate | `sprezzature-colors` |
| Alt text for the emitted SVG | `sprezzature-vision` |
| Static a11y lint on the page that embeds it | `sprezzature-accessibility` |
