Maps, data layers and charts
Claude skill atlas-maps-data · for Maps data, Editor, Frontend
The Atlas globe (MapLibre), its layers, topic badges and status marks, the indicator data layers and their sources, choropleth and colour rules, and the "Maps & charts" visuals (embeds and custom maps). Use when adding or changing a data layer or indicator, colouring the map, touching the globe code, embedding a Datawrapper/Flourish/OWID/World Bank chart, building a custom map, or citing data.
The globe is the Atlas's front door, and every colour on it is a claim. Map only comparable (normalised) values, show where they come from and when, and give every map and chart a text equivalent.
Before you start
- Code changes:
atlas-architecture,atlas-frontend; page weight and loading:atlas-performance. - General WCAG rules (focus, contrast, motion):
atlas-accessibility. Palette tokens:atlas-visual-design. - New embed provider or anything touching CSP:
atlas-security. - Writing captions and citing sources in prose:
atlas-content. - Full limits, the indicator checklist and the source list: reference.
- Admin: Map data layers, Custom map areas, Map appearance, Regions & countries.
How the globe works
src/components/map/AtlasGlobe.tsxdraws it; props are built on the server insrc/app/[locale]/(map)/layout.tsx. Style and layer ids:src/components/map/mapStyle.ts(buildStyle,LAYERS,TOPIC_LAYERS,GLOBE_DATA).- Modes (
ModeSwitch): countries, regions, global issues. Region and issue colours are set per region / issue in the admin (fill,stroke); global look (saturation, border width) in Map appearance. - Topic badges: the number of topics per place. Computed on the server
(
src/features/topics/map-counts.ts:countTopics,badgeNumbers) fromentries.map_layers; the globe only draws them (globe/topicBadges.ts). Never count on the client. On the countries layer a country shows its region's count (countryBadges, ADR-030) — the only number under its name; per-country counts serve its panel and the Topics filter. - No red on the globe badges (owner, 2026-10-16): a place with no topic shows the same white pill with "0" as any other; the red pill and its pulsing glow were removed.
- Status marks on regions and issues come from
content_status(none / preparing / ready,src/features/geography/content-status.ts, drawn bycomponents/map/global-issues.ts). Colours:THEME_COLORS.statusReady,statusPreparinginsrc/config/palette.ts. - Borders are Natural Earth 50 m, rebuilt with
npm run data:geothennpm run data:optimiseintopublic/data/. The Atlas follows UN practice:src/data/territories.jsontransfers, merges or flags territories (Crimea → Ukraine, Golan → Syria, Northern Cyprus and Somaliland merged), each with a note and its UN basis. Flagged territories get the dashedcountry-disputedoutline. Change the policy only in that file, with the basis cited. - Basemap: MapTiler satellite when
NEXT_PUBLIC_MAPTILER_KEYis set, otherwise Esri World Imagery (satelliteSource()); each source declares itsattribution.
Rules
Data layers (indicators)
- One indicator = one row in
indicatorsplus values inindicator_values(one per country, with its year). Edit in Map data layers; schemasrc/features/indicators/schema.ts. - Map rates, shares, indices or per-capita values only. Never colour countries by a raw count (refugees, GDP, deaths) — divide by population or area first, or use point symbols in a custom map.
- Every indicator states
sourceandsource_url,unit,decimalsand the data year (latest_year). The legend caption is built from them (legendFor: "Label – year · Source: X"). - A manually entered value needs a
source_note(the database refuses it otherwise); usenote(≤ 120) for "estimate", "excl. Crimea" and similar caveats. - Missing data stays missing: never enter 0 for "no data". Countries without a value keep the
neutral land colour (
GLOBE_COLORS.land). - Set
higher_is_bettercorrectly: it orders the ranking on/view/<id>. - The 9 imported indicators live in
supabase/seed.sql, which upserts their label, unit, source, range and colours on the next seed run. Change those fields in the seed by PR, not only in the admin. Manual values (is_manual) and edited descriptions survive. Custom indicators are admin-only.
Choropleth colour and scale
- Sequential data (low → high): one hue, light → dark, 5–7 ramp stops (the schema takes 2–9). Use a ColorBrewer-style ramp; the seeded layers show the pattern.
- Diverging data (meaningful centre such as 0 or the world average): an odd number of stops with a light-grey (not white) middle, and a domain symmetric around the centre.
- Categorical data: ≤ 7 categories, hues that imply no order, and a label for each
(
indicator_categories). Use it for classifications such as political regime. - The scale is continuous:
colorForValueinterpolates between ramp stops acrossdomain_min…domain_max; values outside the domain clamp to the end colours. Pick a domain that covers the real spread and say in the description when the ends are open ("90,000 or more"). - Use the log scale for heavily skewed data (income, emissions); it must start above 0.
- Check every ramp in greyscale and with a colour-blindness simulator; vary lightness, not just hue. Blue with orange is the safe pair. Never use a rainbow ramp.
- Keep a place's colour the same across the site: region colours belong to regions; do not reuse them for data.
- The globe exaggerates high latitudes. When area matters to the message, pair the map with the
ranking (
/view/<id>) or a bar chart.
"Maps & charts" visuals
- One table
visuals(ADR-027) for four owners: region, global issue, topic, subtopic (VISUAL_OWNERS). Kinds:embed,image,map(src/features/visuals/constants.ts). Public:components/visuals/VisualsCarousel; admin:components/admin/VisualsEditor. - Every visual has a title (≤ 200) that states the takeaway, not the topic ("Half of Sudan's displaced fled within the country", not "Displacement"). The title is the iframe title, the image's alt text and the caption heading.
- Fill source label and link (≤ 120) on every visual, and a caption (≤ 600) with units, the data year and any caveat.
- Embeds come only from the allowlist in
src/lib/embeds.ts: Datawrapper, Flourish, Our World in Data grapher, World Bank data widget. Editors paste the embed code or link; only the canonical URL is stored and the site renders its own sandboxed iframe. Height 200–1600 px. Adding a provider meanslib/embeds.ts+ a migration for the DB CHECK (CSP followsEMBED_ORIGINS) — seeatlas-security. - Prefer an embed from the original data publisher (OWID, World Bank) over a screenshot; a
screenshot is an
imageand loses the data table.
Custom maps (map_spec v1)
- Built in the admin map builder (
src/features/visuals/map/); the spec type isMapSpecinmap/types.ts, validated byparseMapSpec(map/schema.ts, strict: unknown keys fail). - Limits (
MAP_LIMITS): 500 points, 50 highlighted countries, 20 legend items, 200 kB, latitude ±85°. Projectionmercatororglobe; basemaplightorsatellite. - Every point has a label (the builder calls it the title): it is the point's accessible name and the popup heading. Write it as a place or event name, not "Point 1".
- The reader's map, the builder's "Preview as a reader" and the preview link all open on the
saved
view.bounds. In the builder a free view takes the map's current view as the framing; countries and regions keep theirs until "Use this view". - Popups accept simple rich text only (paragraphs, bold, italic, links, lists), max 5,000
characters, sanitized by
sanitizePopupHtml. - Show a legend whenever colour or icons carry meaning; one item per colour or icon used.
- Pulsing points: one or two at most, for the place the story is about. They never pulse with reduced motion.
- A breaking change to the spec shape means
version: 2plus a reader for v1 (expand-only, as inatlas-backend); never reinterpret stored v1 specs.
Chart choice and accessibility
- Choose the chart by the relationship: change over time → line; ranking → sorted bars; part-to-whole → stacked bar (≤ 5 parts); distribution → histogram; location → map. Bars start at zero. No pie charts for comparisons; no 3D.
- Use ≤ 7 colours; grey for context, one saturated colour for the point of the chart. Label lines and series directly instead of relying on a legend.
- Never let colour alone carry meaning: add labels, values, patterns or a table. Marks need 3:1 contrast against neighbours; chart text ≥ 12 px.
- Give every map and chart a text equivalent: the title as its takeaway, a caption that states the
key finding, and a way to the numbers (the embed's data tab, the
/view/<id>ranking, or a table in the text). The globe is never the only path:/countries,/topicsand/view/<id>list the same places. - Note correlation versus causation in the caption when a chart invites the leap.
Data citation
- Cite the original provider and the Atlas's processing separately: "Source: UNHCR (2025), per-capita rate computed by the Atlas."
- Third-party data keeps its licence; check it before republishing values. OWID data is CC BY: credit OWID and its underlying source.
- The About page lists data sources live (
{{data-sources}}), so a new indicator'ssourceappears there automatically — write it as the organisation's full name.
Performance notes
- MapLibre is loaded at runtime from
public/maplibre/<version>/(ADR-017):loadMapLibre()insrc/components/map/maplibre.tssets the worker URL;scripts/copy-maplibre-worker.mjscopies the files onpredev/prebuild. Never importmaplibre-glas a runtime module in a component. - Metric layers load only when switched on, from
/api/globe-layer/[id](globeLayer()), as ISO3 → colour plus formatted values. Do not ship values in the map layout. - Custom maps render only near the viewport (
MapVisual, IntersectionObserver); without WebGL they fall back to a list of the points. - Keep
/data/*.geo.jsonsmall: re-rundata:optimiseafterdata:geo. Budgets:atlas-performance.
Checklist (PR or admin change)
- [ ] Mapped values are normalised; unit, decimals, year and source filled.
- [ ] Ramp: sequential light → dark or diverging with grey centre; ≤ 7 categories; greyscale-checked.
- [ ] Missing data shown as no data, never 0; manual values carry
source_note. - [ ] Imported indicator fields changed in
supabase/seed.sql, not only in the admin. - [ ] Visual has a takeaway title, caption with year and units, source label and link.
- [ ] Custom map points labelled; legend present when colour or icons carry meaning.
- [ ] Text equivalent exists (caption + data or list page).
- [ ] Border or territory change goes through
src/data/territories.jsonwith the UN basis. - [ ] No new direct
maplibre-glimport or eager layer data in the map layout.
Anti-patterns
- Colouring countries by total population or total refugees — big countries win by size, not by rate.
- Rainbow or red–green ramps — unreadable for colour-blind readers and imply false order.
- Pasting an embed from an unlisted host (refused) or a raw
<iframe>into rich text (stripped on save) — use a "Maps & charts" visual. - Editing an imported indicator's colours only in the admin — the next seed run reverts them.
- A chart titled with a topic instead of a finding — the reader cannot tell what to see.
- Drawing borders differently on a custom map to "simplify" — the territory policy is editorial.
Sources
- Datawrapper Academy: choropleth maps — https://www.datawrapper.de/academy/what-to-consider-when-creating-choropleth-maps
- Datawrapper Academy: choosing colours — https://www.datawrapper.de/academy/what-to-consider-when-choosing-colors-for-data-visualization
- Axis Maps: choropleth guide — https://www.axismaps.com/guide/choropleth
- ColorBrewer schemes — https://colorbrewer2.org/learnmore/schemes_full.html
- FT Visual Vocabulary — https://github.com/Financial-Times/chart-doctor/blob/main/visual-vocabulary/README.md
- Chartability (POUR-CAF) — https://chartability.github.io/POUR-CAF/
- W3C: complex images — https://www.w3.org/WAI/tutorials/images/complex/
- WCAG 2.2: use of colour — https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html
- WCAG 2.2: non-text contrast — https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html
- Natural Earth: disputed boundaries policy — https://www.naturalearthdata.com/about/disputed-boundaries-policy/
- MapLibre AttributionControl — https://maplibre.org/maplibre-gl-js/docs/API/classes/AttributionControl/
- Our World in Data: FAQs on reuse and citation — https://ourworldindata.org/faqs
- More: reference
Files of this skill
reference.md
atlas-maps-data — reference
Long detail for SKILL.md. Limits come from the Zod schemas and DB CHECKs; if the code and this file differ, the code wins — fix this file in the same PR.
1. Indicator fields (indicators, src/features/indicators/schema.ts)
| Field | Rule |
|---|---|
id | slug ≤ 60; it is also the public URL /view/<id> — choose it once |
label / short_label | ≤ 120 / ≤ 60; the short label is the hover-label name |
description | ≤ 1,000; what is measured, for whom, and caveats (open-ended ends, breaks in series) |
unit | ≤ 20; $, €, £ are written before the number, anything else after (formatValue) |
decimals | 0–4 |
source, source_url | full organisation name + https link to the dataset page |
type | sequential (continuous scale) or categorical |
scale | linear or log (log needs domain_min > 0) |
domain_min, domain_max | required for sequential; must differ |
ramp | 2–9 hex colours, low → high |
higher_is_better | orders the ranking on /view/<id> |
latest_year | the year of the newest values; shown in the legend and on About |
Values (indicator_values): one per country, value, year (1800–2100), note ≤ 120,
source_note ≤ 300 (required for manual values). Categories (indicator_categories): integer
value, label ≤ 80, color.
The country card shows up to 6 indicators (FEATURED_MAX), chosen per country in the admin.
2. Imported indicators (seed)
supabase/seed.sql holds the 9 imported layers and their values: Human Development Index, life
expectancy, GDP per capita (PPP, log scale), political regime (categorical), electoral democracy
index, Corruption Perceptions Index, extreme poverty, CO₂ per capita, internet users. The seed
upserts their definitions (label, unit, source, type, scale, domain, ramp, higher_is_better,
latest_year) and their non-manual values; a description edited in the admin is kept, and
manual values (is_manual = true) are never overwritten. Deploys run supabase db push --include-seed (atlas-testing-release).
3. Checklist: adding a data layer
- Is the measure a rate, share, index or per-capita value? If not, normalise it first.
- Find the primary provider and its licence; note dataset name, version or year, and URL.
- Create the indicator in Map data layers
(custom) — or add it to
supabase/seed.sqlby PR if it is a maintained import. - Choose the type and scale; set the domain to cover the real spread; pick a ramp (section 4).
- Enter values with their year; mark estimates in
note; leave missing countries empty. - Write the description: what, unit, coverage, caveats, open-ended ends.
- Check the globe legend,
/view/<id>and the About data-sources list. - Check the ramp in greyscale and in a colour-blindness simulator.
4. Ramp patterns
| Data | Stops | Pattern |
|---|---|---|
| Sequential (more is more) | 5–7 | one hue, light → dark (seeded HDI, GDP, life expectancy ramps) |
| Diverging (above/below a centre) | 5, 7 or 9 | hue A dark → light grey centre → hue B dark; domain symmetric |
| Categorical | ≤ 7 | distinct hues of similar lightness, no implied order; or an ordered ramp only if the categories are ordered (regime: closed autocracy → liberal democracy) |
Colours for map code that cannot read CSS variables live in src/config/palette.ts
(THEME_COLORS, PLAIN_MAP_COLORS, GLOBE_COLORS); each equals its --color-* token, enforced
by palette.test.ts. Do not hard-code new globe colours elsewhere.
5. Visuals (visuals, src/features/visuals/)
| Field | Limit |
|---|---|
| Title | 200 (required) |
| Caption | 600 |
| Source label | 120; source URL https |
| Embed height | 200–1600 px |
| Embed paste | 20,000 characters (code or link) |
| Custom map spec | 200 kB |
Embed providers (EMBED_PROVIDER_INFO in src/lib/embeds.ts):
| Provider | Accepted input | Stored canonical URL | Auto height |
|---|---|---|---|
| Datawrapper | iframe, script embed, datawrapper.de/_/<id>/ link | https://datawrapper.dwcdn.net/<id>/<version>/ | yes |
| Flourish | iframe, script embed (data-src), public.flourish.studio link | https://flo.uri.sh/visualisation/<id>/embed | yes |
| Our World in Data | grapher URL with its settings | https://ourworldindata.org/grapher/<chart>?… | no (600 px) |
| World Bank | share widget or indicator page link | https://data.worldbank.org/share/widget?… | no (450 px) |
6. Custom map spec v1 (src/features/visuals/map/types.ts)
| Part | Limits and options |
|---|---|
view | mode countries / region / bounds; bounds [west, south, east, north] (east may exceed 180 across the antimeridian); projection mercator / globe; basemap light / satellite; country labels on/off |
countries | ≤ 50 highlights: ISO3, fill, opacity 0–1, optional outline |
points | ≤ 500: id [a-z0-9]{1,16}, lat ±85, lon ±180, label ≤ 120 (required), showLabel, colour, size 6–48 px, icon from MAP_ICON_NAMES or none, pulse, optional popup |
popup | HTML ≤ 5,000 before sanitizing (p, br, strong, em, a, ul, ol, li, span); font sans / display / serif; size sm / md / lg; text and background colour; max width 160–420 px |
legend | visible, title ≤ 120, corner, ≤ 20 items (label ≤ 80, colour and/or icon) |
The builder keeps an undo history (builder-state.ts). The light basemap draws disputed borders in
PLAIN_MAP_COLORS.disputed, so custom maps follow the same territory policy as the globe.
7. Custom map areas
Areas drawn on the globe (Admin → Custom map areas,
src/features/map/schema.ts): one GeoJSON polygon ring of 4–2,000 points (a Feature or a
one-feature collection is accepted), name ≤ 120, label ≤ 60, note ≤ 1,000, fill and stroke.
Map appearance: saturation 0.2–2, border width 0.4–2.2.
8. More sources
- OSM attribution guidelines (when OSM-derived tiles are used) — https://osmfoundation.org/wiki/Licence/Attribution_Guidelines
- Natural Earth terms of use — https://www.naturalearthdata.com/about/terms-of-use/
- OWID grapher example with "Sources and processing" — https://ourworldindata.org/grapher/life-expectancy
- Datawrapper: responsive annotations — https://www.datawrapper.de/blog/better-more-responsive-annotations-in-datawrapper-data-visualizations
- NPS: web map accessibility — https://www.nps.gov/maps/web/accessibility