feat(posters): capitals and major cities on the country maps
Some checks are pending
build-gate / build (push) Waiting to run

The Driver asked for map detail: "a typical map poster needs details on the
map that is not hard to see, but not so much that it becomes too complicated
— cities and capitals makes sense."

Natural Earth 1:10m, PUBLIC DOMAIN: no attribution to carry onto a printed
packet, vendored so a build is reproducible offline with no runtime
dependency. Trimmed at vendor time from 4.9 MB / 7,342 places to 170 KB /
2,573 (national + admin-1 capitals, plus cities >= 250k) because a poster
cannot legibly carry 7,342 dots and the packet is downloaded.

The layer is deliberately SUBORDINATE. It answers "where am I?"; the
people-group pins answer "who do I pray for?", and they keep the only
saturated colour on the sheet. Capitals are ringed dots (the cartographic
convention), cities plain dots — shape and value carry the rank so no extra
hue is spent. Reading order stays country > province > city > people group.

Three things had to be right, each found by measuring rather than by looking:

1. RANK BY RELEVANCE TO THE SUBJECT, not by global importance. Sorting by
   capital-rank then population is the obvious choice and is wrong: the
   neighbours are usually bigger. On the Laos sheet that sort spent the budget
   on 12 Vietnamese and 8 Thai cities and printed exactly ONE Laotian place
   while 12 more sat available — a poster of Laos labelled with other
   countries' cities. Subject countries now win the budget.

2. DERIVE THE SUBJECT FROM THE PINS, not from the zone. zone.adm0 exists only
   on generated city/country zones; the hand-authored lists carry a selector
   in SEVEN shapes (pg_slugs, country_code, country_codes, wagf_block,
   wagf_region, union, deaf_predicate) and the most common names no country at
   all. Reading zone.adm0 produced an empty set and the whole sort silently did
   nothing. The pins always know their own country.

3. AVOID THE PINS USING WHERE THEY ARE DRAWN. Two distinct errors here. Testing
   the label's ANCHOR is wrong for a right-aligned label, whose glyphs run
   leftward from it — so the box is tested, not the point. And the de-overlap
   relaxation MOVES badges after projection, so proj(pin.lng,pin.lat) is not
   where the badge lands; this block therefore runs AFTER layoutLabels and reads
   laid[].px/py. With the naive tests, 7 of 25 labels sat within 14px of a pin
   and Saravan landed 1.4px away while the check reported it clear. Now: 291
   labels across 16 posters, ZERO overlaps. A city that cannot fit clear of a
   pin is dropped — a missing city costs nothing, an obscured people group
   costs the poster its purpose.

⚠ ROADS WERE REQUESTED AND DELIBERATELY NOT SHIPPED. Natural Earth's 10m roads
layer was fetched and measured: ZERO features for LAO, MMR, KHM, VNM, THA, NPL,
BTN, TCD, NER, MLI, SOM, AFG, YEM, IDN, IND and CHN. Its coverage is essentially
USA/CAN/MEX. It would draw a dense network on a US map and nothing on every
country these packets are for — a broken-looking map exactly where it matters.
Real road coverage needs an OSM extract: a much larger dependency with an ODbL
attribution obligation, so it is carded rather than half-shipped. See
data/README.md.

Note: zone oceania-indonesia fails generation with a header-overflow error.
Verified pre-existing on a clean tree — unrelated to this change.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 02:20:23 +00:00
parent b9f6ecf12e
commit fd2077625a
3 changed files with 190 additions and 1 deletions

View file

@ -0,0 +1,51 @@
# Vendored map data
## `ne-places.json` — capitals and major cities
**Source:** Natural Earth 1:10m populated places (`ne_10m_populated_places_simple`)
**Licence:** **Public domain.** Natural Earth explicitly places its data in the public
domain: no attribution required, no notice to carry onto a printed packet.
**URL:** https://www.naturalearthdata.com/
### Why vendored rather than fetched
A build must be reproducible offline and must not add a runtime dependency — the
posters are generated token-free and the packets are printed and carried. Vendoring
also pins the data, so a poster regenerated next year looks like the one printed
today.
### How it was trimmed (4.9 MB → 170 KB, 7,342 → 2,573 places)
Kept:
- every national capital (`Admin-0 capital`, `Admin-0 capital alt`)
- every first-order administrative capital (`Admin-1 capital`, and the
`Admin-0/1 region capital` variants)
- ordinary populated places only where `pop_max >= 250000`
A poster cannot legibly carry 7,342 dots, and the packet is downloaded, so the
filter serves legibility and weight at once. Field names are single letters for the
same reason:
n name (ascii) c ISO3 country
k 0 national capital / 1 admin-1 capital / 2 city
p pop_max y latitude x longitude
To regenerate, fetch `ne_10m_populated_places_simple.geojson` from
`nvkelso/natural-earth-vector` and apply that filter.
## ⚠ Why there is no `ne-roads.json`
Roads were requested and **deliberately not shipped**. Natural Earth's 1:10m roads
layer was fetched (50 MB) and measured: it has **ZERO features** for
LAO MMR KHM VNM THA NPL BTN TCD NER MLI SOM AFG YEM IDN IND CHN
Its attributed coverage is essentially USA (7,417 segments), Canada (900) and
Mexico (897); 47,262 of 56,600 segments carry no country at all.
Shipping it would draw a dense road network on a United States map and **nothing**
on every country these packets are actually for — which reads as a broken map
precisely where it matters most. A roads layer needs a source with real coverage in
South/Southeast Asia and Africa (an OpenStreetMap extract), which is a much larger
dependency with an attribution obligation (ODbL). That is a separate piece of work,
carded rather than half-shipped.

File diff suppressed because one or more lines are too long

View file

@ -53,6 +53,29 @@ const adm0 = readJSON(join(DATA, 'adm0', 'world-adm0.geojson'));
const adm1Ind = readJSON(join(DATA, 'gb-IND-ADM1.geojson'));
const adm1Chn = readJSON(join(DATA, 'gb-CHN-ADM1.geojson'));
// CAPITALS + MAJOR CITIES. Driver: "a typical map poster needs details on the map
// that is not hard to see, but not so much that it becomes too complicated" —
// "cities and capitals makes sense".
//
// Natural Earth 1:10m, PUBLIC DOMAIN: no attribution required, no licence notice to
// carry onto a printed packet, and vendored so a build is reproducible offline and
// adds no runtime dependency. Trimmed at vendor time from 4.9 MB / 7,342 places to
// 170 KB / 2,573 (national + admin-1 capitals, plus cities >= 250k) because a poster
// cannot legibly carry 7,342 dots and the packet is downloaded.
//
// ⚠ ROADS WERE DELIBERATELY NOT ADDED, though the Driver asked for "main roads".
// Natural Earth's 10m roads layer has ZERO features for LAO, MMR, KHM, VNM, THA,
// NPL, BTN, TCD, NER, MLI, SOM, AFG, YEM, IDN, IND and CHN — its coverage is
// essentially USA/CAN/MEX. Shipping it would draw a rich road network on a US map
// and NOTHING on the countries these packets are actually for, which reads as a
// broken map exactly where it matters. A roads layer needs a source with real
// coverage in South/Southeast Asia and Africa (OSM extracts), which is a much
// bigger dependency — carded rather than half-shipped.
const nePlaces = (() => {
try { return readJSON(join(__dirname, 'data', 'ne-places.json')).places || []; }
catch { return []; } // absent data degrades to no cities, never a crash
})();
// CONFIG-ONLY, FAIL-CLOSED — mirrors src/api-connections/apiBaseUrl.js. The data
// host comes SOLELY from build-time config; there is deliberately NO default host
// so an unconfigured run never silently phones home to someone else's data source.
@ -1258,6 +1281,104 @@ async function renderPoster(zone, pins, zoneFC) {
const laid = layoutLabels(settled, proj);
// ── CAPITALS + MAJOR CITIES ────────────────────────────────────────────────
// Orientation marks, deliberately SUBORDINATE to the people-group pins. Imhof's
// figure-ground again: this layer answers "where am I?" and must never compete
// with the layer that answers "who do I pray for?".
//
// Three devices keep it quiet:
// 1. A DENSITY BUDGET, not a fixed list. A sheet showing one small country and
// one showing half a continent need different numbers of dots; ranking by
// importance and capping the count keeps both legible.
// 2. IMPORTANCE ORDER — national capitals first, then admin-1 capitals, then
// big cities by population, so what survives the cap is what orients best.
// 3. COLLISION REJECTION in SHEET space, not lng/lat degrees. Degrees are not
// a constant distance on the page (and shrink with latitude), so a
// degree-based spacing test scatters at one zoom and overlaps at another.
let cityDots = '';
const cityPlaced = [];
if (!CITIES) { // city sheets have OSM streets already
const inView = nePlaces.filter((p) =>
p.x >= viewBbox[0] && p.x <= viewBbox[2] && p.y >= viewBbox[1] && p.y <= viewBbox[3]);
// Budget scales with frame area so a dense sheet does not turn into a dot field.
const budget = Math.max(6, Math.min(28, Math.round((FRAME_W * FRAME_H) / 42000)));
// ★ RANK BY RELEVANCE TO THE SUBJECT, NOT BY GLOBAL IMPORTANCE.
// Sorting purely by capital-rank then population looks reasonable and is wrong:
// neighbours are often bigger than the subject. Measured on the Laos sheet, that
// sort spent the budget on 12 Vietnamese and 8 Thai cities and printed exactly
// ONE Laotian place (Vientiane) while 12 more sat available — a poster of Laos
// labelled almost entirely with other countries' cities. The subject's places
// orient a reader inside the zone; neighbours only fill what is left over.
// ⚠ Derive the subject countries from THE PINS ON THIS SHEET, not from the zone
// definition. `zone.adm0` exists only on generated city/country zones — the
// hand-authored lists in clusters.json carry a `selector` in SEVEN different
// shapes (pg_slugs, country_code, country_codes, wagf_block, wagf_region, union,
// deaf_predicate), and the most common of them names no country at all. Reading
// zone.adm0 therefore produced an EMPTY set for the Laos sheet and this whole
// sort silently did nothing. The pins always know which country they are in.
const memberIso = new Set(pins.map((p) => String(p.country_code || '').toUpperCase())
.filter(Boolean));
const ranked = inView.sort((a, b) =>
(memberIso.has(b.c) - memberIso.has(a.c)) // subject countries first
|| (a.k - b.k) // then national > admin-1 > city
|| (b.p - a.p)); // then by population
for (const p of ranked) {
if (cityPlaced.length >= budget) break;
const [cx, cy] = proj(p.x, p.y);
if (cx < FRAME_X + 6 || cx > FRAME_X + FRAME_W - 6) continue;
if (cy < FRAME_Y + 6 || cy > FRAME_Y + FRAME_H - 6) continue;
// Reject anything too close to an already-placed city, IN PAGE PIXELS.
const MIN_PX = p.k === 0 ? 26 : 34; // a capital may crowd a bit closer
if (cityPlaced.some((q) => Math.hypot(q.cx - cx, q.cy - cy) < MIN_PX)) continue;
// ★ THE PEOPLE-GROUP PINS WIN, ALWAYS. This layer orients; that layer IS the
// poster. Measured before this guard: 7 of 25 city labels landed within 14px
// of a numbered pin and Vientiane sat 6px away, so the orientation layer was
// crowding the one thing a reader is meant to find. Choose the side of the
// dot that is furthest from the nearest pin, and if even the better side is
// cramped, drop the city entirely — a missing city costs nothing, an obscured
// people group costs the poster its purpose.
// ⚠ Test the BOX THE TEXT OCCUPIES, not its anchor point. Measuring distance
// from the anchor is wrong for a right-aligned label: with text-anchor="end"
// the glyphs run LEFTWARD from the anchor, so an anchor comfortably clear of a
// pin can still have its text sitting on top of one. Measured with the naive
// anchor test, Saravan landed 2px from a pin while the test reported it clear.
const fs = p.k === 0 ? 10.5 : 9.5;
const wPx = esc(p.n).length * fs * 0.55; // ~0.55em average advance
const boxClear = (x0) => { // x0 = left edge of the glyphs
const x1 = x0 + wPx, yTop = cy - fs * 0.5, yBot = cy + fs * 0.5;
let best = Infinity;
// ⚠ Use the LAID-OUT positions (laid[].px/py), not proj(pin.lng,pin.lat).
// The de-overlap relaxation MOVES badges apart after projection, so the raw
// projected point is not where the badge is drawn. Measuring against the
// source coordinate let Saravan's label land 1.4px from a badge while the
// guard believed it was clear. This block therefore runs AFTER layoutLabels.
for (const pin of laid) {
const px = pin.px, py = pin.py;
const dx = px < x0 ? x0 - px : (px > x1 ? px - x1 : 0);
const dy = py < yTop ? yTop - py : (py > yBot ? py - yBot : 0);
best = Math.min(best, Math.hypot(dx, dy));
}
return best;
};
const RIGHT = { dx: 5.5, anchor: 'start', clear: boxClear(cx + 5.5) };
const LEFT = { dx: -5.5, anchor: 'end', clear: boxClear(cx - 5.5 - wPx) };
const side = RIGHT.clear >= LEFT.clear ? RIGHT : LEFT;
if (side.clear < 7) continue; // cramped on both sides — drop it
cityPlaced.push({ cx, cy, ...p, ...side });
}
for (const c of cityPlaced) {
// A capital is a ringed dot (the cartographic convention); a city is a plain
// small dot. Shape carries the rank so colour does not have to.
cityDots += c.k === 0
? `<circle cx="${c.cx.toFixed(1)}" cy="${c.cy.toFixed(1)}" r="3.6" class="cap-o"/>`
+ `<circle cx="${c.cx.toFixed(1)}" cy="${c.cy.toFixed(1)}" r="1.5" class="cap-i"/>`
: `<circle cx="${c.cx.toFixed(1)}" cy="${c.cy.toFixed(1)}" r="${c.k === 1 ? 2.1 : 1.7}" class="cty-d"/>`;
cityDots += `<text x="${(c.cx + c.dx).toFixed(1)}" y="${(c.cy + 3.4).toFixed(1)}" `
+ `text-anchor="${c.anchor}" class="${c.k === 0 ? 'cap-t' : 'cty-t'}">${esc(c.n)}</text>`;
}
}
// LABEL_RES is the text column; LANE_* is the channel between text and map.
// Widened the text column (names were clipping: "Jarkhandi Julaha" ran off the
// sheet) AND tightened the lane band, so the names sit CLOSER to the map while
@ -2212,7 +2333,7 @@ async function renderPoster(zone, pins, zoneFC) {
<!-- On a CITY sheet the zone fill is the whole COUNTRY (its geometry selector),
so painting it over the tiles would hide every street the basemap exists to
show. City sheets therefore drop ctx/zone fills and let OSM be the map. -->
<g clip-path="url(#mapclip)">${baseLayer}${(CITIES && BASEMAP) ? '' : ctx}${(CITIES && BASEMAP) ? '' : ctxAdm1}${(CITIES && BASEMAP) ? '' : zoneP}${seaNames}${provNames}${ctryNames}${mapCredits}</g>
<g clip-path="url(#mapclip)">${baseLayer}${(CITIES && BASEMAP) ? '' : ctx}${(CITIES && BASEMAP) ? '' : ctxAdm1}${(CITIES && BASEMAP) ? '' : zoneP}${seaNames}${provNames}${ctryNames}${cityDots}${mapCredits}</g>
<clipPath id="mapclip"><rect x="${FRAME_X}" y="${FRAME_Y}" width="${FRAME_W}" height="${FRAME_H}"/></clipPath>
<g style="--zc:${color}">${leaders}${dots}${pinDots}${labels}</g>
<!-- TITLE, in two parts. "Unengaged People Groups of North India + Nepal" set
@ -2309,6 +2430,22 @@ async function renderPoster(zone, pins, zoneFC) {
.prov-name.own{fill:#6B4A78;font-weight:600}
.prov-name-halo{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:11.5px;
fill:none;stroke:#F2EFE6;stroke-width:3;stroke-linejoin:round;letter-spacing:.02em}
/* CAPITALS + CITIES sit at the BOTTOM of the label hierarchy — smaller and
lighter than province names — because they orient the reader, they are not the
subject. Reading order stays country > province > city > people group, and the
people-group pins keep the only saturated colour on the sheet.
A capital is a ringed dot (the cartographic convention) and its name is set in
the darker ink; an ordinary city is a plain dot with a lighter name. Shape and
value carry the rank, so no extra hue is spent on this layer. */
.cap-o{fill:#FFFFFF;stroke:#5C5443;stroke-width:1.4}
.cap-i{fill:#5C5443}
.cty-d{fill:#8A8271}
.cap-t{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:10.5px;
fill:#5C5443;font-weight:700;letter-spacing:.02em;paint-order:stroke;
stroke:#F2EFE6;stroke-width:3;stroke-linejoin:round}
.cty-t{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:9.5px;
fill:#8A8271;font-weight:500;letter-spacing:.02em;paint-order:stroke;
stroke:#F2EFE6;stroke-width:2.6;stroke-linejoin:round}
.ctry-name{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:15px;
letter-spacing:.1em;fill:#6E6553;font-weight:700}
.ctry-name-halo{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:15px;