doxa-maps/public/dx-maps/assets/lib/poster-render.js

3084 lines
199 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* poster-render.js — THE MAP SHEET GENERATOR, as a pure factory that runs in Node AND in the browser.
*
* ★ WHY IT MOVED HERE. The zone and country map sheets (the posters, and the map-only sheet on a
* packet's page 1) were drawn only by library/scripts/posters/generate.mjs, a Node script: the browser
* could print a sheet only if one had been generated ahead of time and STORED. The print
* standard's rule is the opposite — every export is generated at the moment it is asked for, and
* only true previews are stored. This file is that generator with its Node half taken off: the drawing
* code is moved here byte-for-byte, and everything it used to read from the machine is now PASSED IN.
*
* What the caller supplies (generate.mjs from files and env; a browser from fetches):
* env the knobs generate.mjs read from process.env: FORMAT, POSTER_DATE, SPARSE, CITIES,
* BASEMAP, SERIES_NAME, PIN_R. A plain object — the code below reads `process.env.X`
* through a LOCAL `process`, which is what lets it move without a single edit.
* SERIES_DEF the series' definition from series.json (upg100 / country): its poster text, networks,
* and whether it is the country series.
* ISO3_NAME the ISO3 -> English country name table the labels are drawn from.
* adm0 world country boundaries (the neighbouring countries drawn around the zone).
* adm1Ind, adm1Chn province boundaries (optional; zones with provinces name them from these).
* nePlaces Natural Earth capitals and cities (optional; absent = no city dots, never a crash).
* fetchGrid, bestZoom the OSM street basemap (optional; only city posters use it).
* series { totalGroups, totalLists, index } — the SERIES facts; the caller may mutate it.
*
* renderPoster(zone, pins, zoneFC) -> { html, title, octiOK, crossings, diagFrac }, exactly as before.
* PROVEN BYTE-IDENTICAL AT THE MOVE: every sheet the Node generator writes — the 28 zones in all four
* formats and the countries at both sheet sizes, offline with a pinned date — hashed the same before and
* after (see the commit that created this file). Cities are the one mode not in that proof: they fetch
* live OSM tiles, which no offline run can reproduce.
*/
export function createPosterGenerator({
env = {}, SERIES_DEF = {}, ISO3_NAME = {}, adm0 = null, adm1Ind = null, adm1Chn = null, nePlaces = [],
fetchGrid = null, bestZoom = null, series = { totalGroups: 0, totalLists: 0, index: 1 },
onStep = null,
} = {}) {
// The moved code reads process.env.X; this LOCAL process makes that read the caller's env object.
const process = { env };
const SERIES = series;
// ★ PROGRESS THAT NEVER FREEZES (the print HUD): with onStep, renderPoster reports each stage by name and YIELDS a
// frame between stages, so the page repaints (the step, the elapsed time) instead of one long frozen task. Without
// onStep (Node, the proofs) a step is a no-op: `await undefined` changes no state, so every sheet stays byte-identical.
const __step = onStep
? (name) => {
try { onStep(name); } catch (_) { /* the caller's own display */ }
return new Promise((r) => (typeof requestAnimationFrame === 'function' ? requestAnimationFrame(() => setTimeout(r, 0)) : setTimeout(r, 0)));
}
: () => undefined;
// ── PIN BBOX: antimeridian-aware, outlier-trimmed ───────────────────────────
// Two independent failure modes made three posters render the WHOLE GLOBE:
//
// 1. ANTIMERIDIAN WRAP. oceania-indonesia runs 83.9E -> -136.0 (i.e. across the
// date line). A plain min/max gives W=-136, E=+83.9 and calls that 220 wide,
// so the projection zooms out to fit a span that is really ~140 the OTHER
// way round. It is a zoom issue: part of the region overlaps the edge of the
// globe, so the frame zooms in and takes the clip from the other edge.
// Fix: sort the longitudes, find the LARGEST GAP between adjacent pins, and
// cut the circle there — the remaining arc is the true span. Longitudes east
// of the cut are unrolled past 180 so the box is contiguous.
//
// 2. FAR OUTLIERS. europe ran -42 -> 168.6E: a handful of pins in Siberia and
// the Americas dragged the box across Eurasia, so "Europe" rendered Brazil,
// Japan and Iceland. Fix: drop the extreme 4% at each end when that shrinks
// the span materially. Every pin still RENDERS — the leader lines reach them
// — but a few strays no longer set the zoom for the other 96%.
function pinBbox(pins) {
const lats = pins.map((p) => p.lat).sort((a, b) => a - b);
const lngs = pins.map((p) => p.lng).sort((a, b) => a - b);
const n = lngs.length;
// (1) cut the circle at the largest gap
let gap = -1, cut = 0;
for (let i = 0; i < n; i++) {
const a = lngs[i], b = lngs[(i + 1) % n];
const g = i === n - 1 ? (b + 360) - a : b - a;
if (g > gap) { gap = g; cut = (i + 1) % n; }
}
const unrolled = lngs.map((_, k) => {
const v = lngs[(cut + k) % n];
return v < lngs[cut] ? v + 360 : v;
});
// (2) trim outliers, but only when it actually helps
const trim = (arr) => {
const k = Math.floor(arr.length * 0.04);
if (k < 1) return arr;
const inner = arr.slice(k, arr.length - k);
const full = arr[arr.length - 1] - arr[0];
const cutSpan = inner[inner.length - 1] - inner[0];
return cutSpan < full * 0.72 ? inner : arr;
};
const lo = trim(unrolled), la = trim(lats);
let W = lo[0], E = lo[lo.length - 1];
if (W > 180) { W -= 360; E -= 360; }
return [W, la[0], E, la[la.length - 1]];
}
// ── Web Mercator, fit a [W,S,E,N] bbox into a cw×ch box preserving aspect ──────
const mercX = (lng) => (lng + 180) / 360;
const mercY = (lat) => {
const r = (Math.max(-85.05, Math.min(85.05, lat)) * Math.PI) / 180;
return (1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2;
};
function makeProjection(bbox, cw, ch, padFrac = 0.06) {
let [w, s, e, n] = bbox;
// pad
const dx = (e - w) * padFrac || 0.5, dy = (n - s) * padFrac || 0.5;
w -= dx; e += dx; s -= dy; n += dy;
const x0 = mercX(w), x1 = mercX(e), y0 = mercY(n), y1 = mercY(s); // y0<y1
const sx = cw / (x1 - x0), sy = ch / (y1 - y0);
const scale = Math.min(sx, sy);
const offX = (cw - (x1 - x0) * scale) / 2;
const offY = (ch - (y1 - y0) * scale) / 2;
return (lng, lat) => [offX + (mercX(lng) - x0) * scale, offY + (mercY(lat) - y0) * scale];
}
// ── geojson → SVG path d (Polygon + MultiPolygon) ─────────────────────────────
function geoToPath(geom, project) {
const rings = geom.type === 'Polygon' ? geom.coordinates
: geom.type === 'MultiPolygon' ? geom.coordinates.flat() : [];
let d = '';
for (const ring of rings) {
for (let i = 0; i < ring.length; i++) {
const [px, py] = project(ring[i][0], ring[i][1]);
d += (i === 0 ? 'M' : 'L') + px.toFixed(1) + ' ' + py.toFixed(1);
}
d += 'Z';
}
return d;
}
// A ring's own bbox [minX, minY, maxX, maxY] (one pass), for the point-in-ring skips.
function ringBox(ring) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [x, y] of ring) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
return [x0, y0, x1, y1];
}
function bboxOf(geom) {
const rings = geom.type === 'Polygon' ? geom.coordinates
: geom.type === 'MultiPolygon' ? geom.coordinates.flat() : [];
let W = 180, S = 90, E = -180, N = -90;
for (const ring of rings) for (const [x, y] of ring) {
if (x < W) W = x; if (x > E) E = x; if (y < S) S = y; if (y > N) N = y;
}
return [W, S, E, N];
}
const bboxIntersect = (a, b) => !(a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3]);
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// ── OCTILINEAR leader routing (design decision: 45°/90° "techno" lane traces) ──
// Each leader is a Manhattan/octilinear "circuit-board" trace — ONLY horizontal,
// vertical, or exact 45° segments, sharp corners, no curves. It is channeled
// through a per-leader vertical LANE in the side margin so parallel runs separate
// and each stays followable; the corners are 45° chamfers (the "techno" bevel).
//
// Structure (left side; right mirrors) — ORTHOGONAL-DOMINANT so crossings stay
// near the proven 0-crossing boundary-labeling baseline (a big straight 45°
// diagonal per leader is what produced the earlier spaghetti of 500–650 crossings;
// a horizontal feeler at the pin's own latitude + a vertical run in a dedicated
// lane is the low-crossing shape, and the 45° chamfer gives the techno look):
// (1) short horizontal stub off the pin
// (2) horizontal feeler at the pin's TRUE latitude, stopping one chamfer short
// of the lane ── the run that keeps leaders in disjoint latitude bands
// (3) 45° chamfer corner into the lane (sharp bevel, no curve)
// (4) vertical run DOWN/UP the dedicated lane to the label's row
// The label sits at the lane, so the vertical ends exactly at the port (no jog).
// The vertical gap pin→label is covered by a 45° diagonal for a fraction `dfrac`
// of its length and by a straight in-lane vertical for the rest. dfrac is swept
// per poster (pickDiagFrac) to MINIMISE crossings — every value is octilinear, so
// the winner is the minimal-crossing octilinear layout for that poster. dfrac=0
// is a pure horizontal-feeler + vertical-lane trace (cleanest 90° grid); dfrac=1
// leans the leader into a 45° diagonal where the horizontal budget allows.
function octiRoute(it, laneX, dirOut, dfrac) {
const STUB = 7;
const P = [it.px, it.py];
const xStub = it.px + dirOut * STUB; // stub end (toward the margin)
const dy = it.labelY - it.py;
const vdir = dy === 0 ? 0 : Math.sign(dy); // vertical sense toward the label
const gap = Math.abs(xStub - laneX); // horizontal budget stub→lane (≥0)
const diag = Math.max(0, Math.min(dfrac * Math.abs(dy), gap)); // 45° leg (both axes)
const feeler = gap - diag; // remaining horizontal at pin latitude
const pts = [P, [xStub, it.py]]; // (1) stub — horizontal
pts.push([xStub + dirOut * feeler, it.py]); // (2) feeler — horizontal at pin latitude
pts.push([laneX, it.py + vdir * diag]); // (3) 45° diagonal into the lane
pts.push([laneX, it.labelY]); // (4) vertical run in the lane to the port
return pts;
}
// every emitted segment must be horizontal (dy=0), vertical (dx=0) or 45° (|dx|=|dy|)
function isOctilinear(pts, eps = 0.6) {
for (let i = 0; i < pts.length - 1; i++) {
const dx = Math.abs(pts[i + 1][0] - pts[i][0]), dy = Math.abs(pts[i + 1][1] - pts[i][1]);
if (dx < eps || dy < eps) continue; // horizontal or vertical
if (Math.abs(dx - dy) < eps) continue; // 45°
return false;
}
return true;
}
function segCross(p, q) {
const o = (ax, ay, bx, by, cx, cy) => Math.sign((bx - ax) * (cy - ay) - (by - ay) * (cx - ax));
const [a, b] = p, [c, d] = q;
const same = (u, v) => Math.abs(u[0] - v[0]) < 0.5 && Math.abs(u[1] - v[1]) < 0.5;
if (same(a, c) || same(a, d) || same(b, c) || same(b, d)) return false;
const o1 = o(...a, ...b, ...c), o2 = o(...a, ...b, ...d), o3 = o(...c, ...d, ...a), o4 = o(...c, ...d, ...b);
return o1 !== o2 && o3 !== o4 && o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0;
}
function countPolyCrossings(polys) {
const segs = [];
// Each segment carries its bbox: a proper crossing needs the two bboxes to overlap, so a disjoint pair is never
// counted by segCross either; skipping it gives the same count without the orientation tests (print speed).
polys.forEach((pts, li) => { for (let i = 0; i < pts.length - 1; i++) { const a = pts[i], b = pts[i + 1];
segs.push({ s: [a, b], li, x0: Math.min(a[0], b[0]), x1: Math.max(a[0], b[0]), y0: Math.min(a[1], b[1]), y1: Math.max(a[1], b[1]) }); } });
let n = 0;
for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) {
if (segs[i].li === segs[j].li) continue;
if (segs[i].x1 < segs[j].x0 || segs[j].x1 < segs[i].x0 || segs[i].y1 < segs[j].y0 || segs[j].y1 < segs[i].y0) continue;
if (segCross(segs[i].s, segs[j].s)) n++;
}
return n;
}
// ── poster geometry constants (CSS px; 3:4) ──────────────────────────────────
// ── SHEET FORMAT ────────────────────────────────────────────────────────────
// Parameterised rather than hardcoded, so a new aspect is one env var and not an
// edit: FORMAT=3x4 (default, portrait) | 3x3 (square).
// Everything downstream derives from PW/PH — the map frame fits the region inside
// whatever is left after the header, footer and gutters, so a format change does
// not require re-tuning the layout by hand.
const FORMATS = {
// 3x4 REBUILT 2026-08-01 from the production v5 3x3 — NOT the old 1350x1800 sheet.
// It is cheap because the whole template already exists: the sheet is one foot
// longer, and every field and parameter built for the 100 list carries over.
// WIDTH STAYS 1500. That is the whole reason this is cheap: the header budget,
// the 330 gutter and every width-derived measurement were calibrated at 1500 and
// carry over intact. The OLD 3x4 was 1350 wide, and that narrower sheet is exactly
// what overflowed the header guard by 4px (content y=355 vs HEADER=351) — the rot
// was a WIDTH problem, not a height one. Only height-derived values change here,
// and those already derive from MAP_BREATH/HEADER rather than hardcoded offsets.
// Serves China (154 groups) and is the sheet the shelved granular 4-column table
// (F93) was waiting on — build it once, both are served.
'3x4': { w: 1500, h: 2000, gutter: 330, page: '3in 4in', pageWidthIn: 3 },
// Square: a 3ft x 3ft sheet. The map loses height relative to 3x4, so the
// gutters widen slightly — with fewer label rows per column the names can take
// more width, and the map stays close to square rather than becoming a letterbox.
// GUTTER IS THE NAME COLUMN — widening it narrows the map, and that is the
// intended trade: a slightly narrower map gives the names room to get longer
// or larger.
// 240 -> 285: at 240 the name column was only ~145px after the index column, so
// ordinary names like "Kummara, Kummari" and "Parvatarajakulam" wrapped when
// they had no need to. Wrapping is only necessary when a name would overrun
// the gutter space. 285 fits every name on this sheet but the single longest.
// 285 -> 330. The white sheet edge took 26px off each label column and nothing
// gave it back, so names needed 185-215px but had only 177 and SEVEN wrapped
// that had no need to (rows 1, 6, 34, 44 and 49 among them). At 330 the name
// column is 222px and only "Vellaiyan Kuppam Padaiyaci" (265px) still wraps,
// which is a name that genuinely overruns any sane column.
'3x3': { w: 1500, h: 1500, gutter: 330, page: '3in 3in', pageWidthIn: 3 },
// LETTER PORTRAIT — the same sheet, sized for paper a normal printer can take.
// The layout and the text stay exactly those of the poster; only the format
// changes, to a vertical sheet of paper.
//
// WIDTH STAYS 1500 for the reason the 3x4 note gives above: every width-derived
// measurement (the 330 gutter, the name columns, the header budget) was calibrated
// at 1500 and carries over UNCHANGED. Height is the only variable: 8.5x11 at
// width 1500 -> 1500 * (11/8.5) = 1941. That lands between 3x3 (1500) and 3x4
// (2000), so the map keeps roughly the 3x3 proportions with ~29% more height for
// the label columns — the sheet gets taller, nothing gets re-tuned.
//
// `page` is what the generated HTML puts in @page{size:...}. Naming the real
// paper size (rather than an inch pair) lets the browser's own Letter preset
// match it, which is what makes an ordinary printer produce a full-bleed-free,
// correctly-scaled sheet instead of "fit to page" guesswork.
letter: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5 },
// ★ PACKET PAGE 1 — THE MAP ALONE, ON A VERTICAL PAGE. Not the poster: page 1 of a prayer packet is
// a lightweight one-pager showing the geography and every pin with its numbered name, and nothing of
// the poster's furniture (the facts table, the two vision columns, the three networks, the closing
// verse). Those are English prose baked into the artwork, which is also why the poster could never be
// page 1 of a translated packet: the packet draws its OWN title into the reserved band, from the
// locale's strings, and this sheet supplies only what needs no translation — the map, the pins and
// the people-group names.
//
// SAME 1500x1941 AS `letter` AND AS THE PACKET'S OWN SHEET (packet-render.js W/H), so it drops onto
// page 1 at 1:1 with no rescale. `header` is the band left EMPTY for the packet's title block and
// `footer` the band for its fine print; everything between goes to the map and its label columns —
// about 1,600px of height against the letter sheet's ~1,200, which is what lets the names grow.
// Output: <zone>.packet.html beside the posters (see FILE_SUFFIX), never over them.
packet: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5, mapOnly: true, header: 250, footer: 90 },
// ★ LARGE SHEETS WITH SIDE LANES (`sideLanes`): a whole country's hundreds of groups, every one named horizontally
// in two staggered columns per side, ONE leader per cluster of co-located groups, crossing-free by construction
// (see sideLanesLayout). Same 41.67 px/in as the 3x3, so the header, pins and footer keep their physical size.
// `nameMaxPt` caps the names' print size; the layout solves the largest size that fits the rows and columns.
'60x96': { w: 2500, h: 4000, gutter: 572, page: '60in 96in', pageWidthIn: 60, sideLanes: true, nameMaxPt: 20.7, photos: true },
'66x66': { w: 2750, h: 2750, gutter: 470, page: '66in 66in', pageWidthIn: 66, sideLanes: true, nameMaxPt: 18, photos: true },
'36x60': { w: 1500, h: 2500, gutter: 400, page: '36in 60in', pageWidthIn: 36, sideLanes: true, nameMaxPt: 14, photos: false },
};
const FMT_KEY = FORMATS[process.env.FORMAT] ? process.env.FORMAT : '3x4';
// The side-lane marks' styles: emitted ONLY on a sideLanes sheet, so every other format's page is byte-identical.
const SIDE_LANES_CSS = `
.slead{stroke:#5a5246;stroke-width:0.9;fill:none;opacity:.5}
.sbr{stroke:#5a5246;stroke-width:1.1;fill:none}
.srow{stroke:#b9ac8c;stroke-width:.3;fill:none;opacity:.7}
.spin{fill:#1c1c17;stroke:#f7f4ec;stroke-width:.8}
.snm{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-weight:700;fill:#1c1c17}`;
// The three-column header's styles, and the footer prose drawn at the size it is MEASURED at: the footer's
// three blocks are wrapped and spaced for 13.5px (FS below) but were drawn at .hsrc's 12px, so the text can
// grow to 13.5 with no change to the lines or the band's height. Emitted only for a series that opts in.
const HEAD_COLUMNS_CSS = `
.hcolk{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:12.5px;fill:#8a8470;font-weight:700;letter-spacing:.14em;text-transform:uppercase}
.hcolt{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:14px;fill:#5f5849;letter-spacing:.01em}
.hsrc{font-size:13.5px}`;
// ★ READABLE ON PAPER (2026-10-02): the full sheet is the packet's page 1, printed on letter at 92% (its margins),
// where 14px column text came out near 5pt in light grey. The header band and the footer's three blocks are set
// at 22 / 21px with darker ink: about 8pt on the printed page. Only the full layout (HEAD_COLUMNS) gets this; the
// series-wide rules above stay as they were, so the series' SPARSE posters are byte-identical.
// Print size = sheet px x 0.92 (the packet's margins) x 0.408 (1500px = 8.5in): 22px ≈ 8.3pt, 19px ≈ 7.1pt.
// ★ THE NAMES COME FIRST (2026-10-03): the header columns step down to 18px (≈6.8pt; keys 15.5, headings 19) so
// the people-group names beside the map can be the larger type. The shorter band gives the map ~80px more height,
// and the names' cap on this layout rises to 19px (see MAXF). These sizes must equal HC's (fs, keyFs, headFs).
const HEAD_COLUMNS_FULL_CSS = `
.hcolk{font-size:15.5px;fill:#2f2a22;font-weight:800;letter-spacing:.1em}
.hcolt{font-size:18px;fill:#3a362c}
.hcolb{font-weight:700;fill:#2f2a22}
.hcolh{font-family:Georgia,'Times New Roman',serif;font-size:19px;font-weight:700;fill:#2f2a22;letter-spacing:.04em;font-variant:small-caps}
.hvref{font-size:19px}
.hverse{font-size:19.5px}
.hsrc{font-size:22px;fill:#4a4636}
.hsrc-em{fill:#2f2a22}`;
const FMT = FORMATS[FMT_KEY];
// Map-only sheets (packet page 1) skip the poster's header and footer furniture — see FORMATS.packet.
const MAP_ONLY = FMT.mapOnly === true;
// ★ A FORMAT THAT IS NOT A POSTER MUST NOT OVERWRITE THE POSTER. Every format used to write
// out/posters/<zone>.html, so `FORMAT=letter` silently replaced the 3x4 poster the wall and the site
// serve — which is part of why no letter or packet sheet was ever generated alongside them. The paper
// formats get their own names. 3x4 and 3x3 keep the bare name: they ARE the poster, whichever was
// built last, and renaming them would break every consumer that fetches posters/<zone>.html.
const FILE_SUFFIX = { letter: '.letter', packet: '.packet' }[FMT_KEY] || '';
// Stamped at generation time. Overridable so a rebuild can reproduce an existing
// sheet byte-for-byte — print determinism matters more than always-now.
const POSTER_DATE = process.env.POSTER_DATE
|| new Date().toISOString().slice(0, 10);
const PW = FMT.w, PH = FMT.h;
// HEADER carries the title, the Countries/Provinces list, the vision block AND
// the "Ways to use this map" examples; FOOTER carries only the scripture, so it
// stays slim. HEADER must exceed HEAD_BOTTOM (headY 104 + 298 = 402) or the map
// frame is drawn over the last lines of the examples list.
// HEADER carries the title, the facts table and the vision columns.
// FOOTER carries the BELOW-MAP sandwich (the goal question + the three networks)
// AND the scripture, so it is now the taller of the two. Both are checked at
// render time by fail-loud guards — see HEAD_BOTTOM and FOOT_LIMIT.
// ── SPARSE (v3, "thumbnail-worthy") ─────────────────────────────────────────
// The full sheet carried too much information. This variant carries less: larger
// people-group names and a larger map; the how-to question stays at the bottom,
// the scripture at the top, and the table that explains the countries stays too;
// the two explainers (the Jesus Strategy for 5000, and What is a Prayer List of
// 100) are dropped.
//
// The point is a poster that survives being a BLOG THUMBNAIL: at thumbnail scale
// the only things that must still read are the region name, the map and the
// people-group names. The two vision columns are what cost the most height, and
// height is what was squeezing the names down to ~10.6px.
//
// SPARSE=1 drops those two columns and gives the reclaimed height to the map,
// which raises the label type from ~10.6px to ~14.8px (+40%).
const SPARSE = process.env.SPARSE === '1';
// ★ THE HEADER IN THREE COLUMNS (series opt-in, `poster.headColumns`). The full sheet's facts stacked as
// centred rows ABOVE the scripture and only two columns sat under it, the first starting at the band's
// edge with no inset. With the flag the scripture banner sits directly under the title and ONE
// three-column band follows it, built like the footer's: the facts (countries, administrative areas,
// people groups, series) | Jesus' strategy | what the series is, each block centred in an equal column
// between short rules, the text a size larger. Opt-in per series so every other series' sheet stays
// byte-identical; SPARSE already sets its facts as columns and is unchanged.
// MODE FLAGS live HERE, beside SPARSE, because renderPoster() reads them at line
// ~735 — far above where the mode blocks sit. Declaring them next to their mode
// block left a const declared at 2372 being read at 735; it happened to work
// because renderPoster is only CALLED after that line executes, but it is a
// temporal-dead-zone accident, not a design. One misplaced early call would turn
// it into a crash that looks unrelated to the mode.
const CITIES = process.env.CITIES === '1';
// OSM street tiles on city sheets. Opt-in (BASEMAP=1) so a run can be reproduced
// without network access, and so the tile cache is only exercised deliberately.
const BASEMAP = process.env.BASEMAP === '1';
// ★ DERIVED FROM THE SERIES, so SERIES=country selects the country ZONE SOURCE too and not just
// the wording. COUNTRIES=1 still works: it resolves to SERIES_ID 'country' above, whose
// countryMode is true, so this is the same boolean by a different route.
const COUNTRIES = SERIES_DEF.countryMode === true;
// SERIES NAME — the operator is still settling this ("it might make more sense to
// call it 'UPG100 List'"). One constant, so the rename is a one-line change
// and never has to be hunted across the sheet.
// The name inside the quotes on the sheet: Series "UPG100 List".
// PARAMETERIZED PER APP — the country app is a different series entirely, so it
// must not print the 100-list's name. Country mode defaults it below (COUNTRIES=1
// → "Country Maps"); SERIES_NAME still overrides either default.
// The country series prints "Country Maps" inside the quotes, so the series name is a
// parameterized field.
// ★ NOW FROM series.json (SERIES_DEF.poster.seriesName), so the Series line, the facts-table row
// and the side box all read one source. CITIES keeps its own name: there is no cities entry in
// series.json yet, and inventing one here would be a second place to look. SERIES_NAME still
// overrides everything, which is the behaviour the original comment promised.
const POSTER_TEXT = SERIES_DEF.poster || {};
const HEAD_COLUMNS = POSTER_TEXT.headColumns === true && !SPARSE && !MAP_ONLY;
// The series-wide part (the footer prose at its measured size) applies to its SPARSE sheets too.
const HEAD_COLUMNS_ON_SERIES = POSTER_TEXT.headColumns === true && !MAP_ONLY;
// The full sheet's footer band at the larger type (21px blocks): measured on the 28 zones, see HEAD_COLUMNS_FULL_CSS.
const FULL_FOOTER = 400;
const SERIES_NAME = process.env.SERIES_NAME
|| (process.env.CITIES === '1' ? 'Key City Maps'
: POSTER_TEXT.seriesName
|| 'UPG100 List');
// ── PIN BADGE RADIUS (template parameter) ───────────────────────────────────
// The numbered disc on the map. It is the single knob that decides how far the
// de-collision pass has to shove pins away from their true coordinates: MIN_SEP
// is derived from it, so a smaller badge means truer pin placement.
// 8.0 measured: drops mainland occupancy on Tamil Nadu from 51% -> 29%.
// Overridable per run; `zone.pinScale` multiplies it per zone.
const PIN_R_BASE = Number(process.env.PIN_R || 8.0);
// The province enumeration for a zone. `label` is now an authored REGION NAME, so
// it must NOT be split for provinces — clusters.json keeps the old enumeration in
// `provinceLabel`. Defined once because the header-MEASURING pass and the RENDER
// pass both need it and had drifted to different rules.
const provincesOf = (zone) =>
Array.isArray(zone.provinces) && zone.provinces.length ? zone.provinces
: zone.provinceLabel ? String(zone.provinceLabel).split(' + ').map((x) => x.trim()).filter(Boolean)
: String(zone.label || zone.id).split(' + ').map((x) => x.trim()).filter(Boolean);
// Sheet margin for the FULL-WIDTH bands (scripture + facts above the map, the
// goal question + three networks below it, and the closing verse). Deliberately
// independent of GUTTER: the gutters exist to give the label columns room and get
// re-tuned per format, while these bands always want the sheet's own edge.
// The horizontal rules go full width, all the way to the white border: they run
// to SHEET_EDGE exactly and terminate against it —
// the white edge becomes the frame they resolve into. The TEXT inside these bands
// still gets its own inset (BAND_TEXT_PAD) so words never touch the border.
const BAND_MARGIN_G = 26; // == SHEET_EDGE; rules meet the white border
// Breathing space between the map block and the ruled bands above and below it.
// The map and the people groups sat closer to the bottom table than to the top
// one, so the spacing needed evening out. The two gaps were set by INDEPENDENT
// constants and had drifted to 82px above / 38px below — the map block visibly
// sank toward the footer. One constant is used on both sides, so they cannot
// diverge again as the header grows or shrinks per zone.
const MAP_BREATH = 58;
const BAND_TEXT_PAD = 30; // text inset within those rules
// White edge framing the whole sheet — a print margin: the border around the
// poster itself is a white edge. Drawn LAST, over everything, so any
// element that runs to the sheet edge is trimmed by it rather than colliding.
const SHEET_EDGE = 26;
// Label-column geometry. Hoisted to module scope because the label LAYOUT pass
// (which now solves for a uniform type size + wrapping) needs the same column
// width the RENDER pass uses; they were duplicated locals and drifted apart.
// Label columns must clear the white sheet edge, not sit ON it — at 26 they ran
// exactly to the edge and every long name was trimmed by it. Derived from
// SHEET_EDGE so the two can never drift apart again.
const SHEET_MARGIN_L = SHEET_EDGE + 8;
const LANE_EDGE_GAP_L = 6;
const LABEL_RES_M = () => GUTTER - SHEET_MARGIN_L - LANE_EDGE_GAP_L - 24;
const HEADER_BASE = SPARSE ? 355 : 460;
// 280 not 255 in the dense case. MEASURED, not guessed: on a 3x3 city sheet the
// below-map block put the verse rule at y=1461 against a hard limit of PH-56=1444
// — 17px over, and CONSTANT for every city zone (HEADER/MAP_H/fy are all
// zone-invariant when SPARSE is off), so the guard failed on the FIRST zone and
// no city poster rendered at all. 280 clears it with ~8px of slack for the
// per-zone wrapping the block's height depends on. This costs 25px of map height,
// which is the documented trade this constant exists to make.
const FOOTER = MAP_ONLY ? FMT.footer : SPARSE ? 316 : HEAD_COLUMNS ? FULL_FOOTER : 280;
// GUTTER was 300px A SIDE — 600 of 1350 (44% of the sheet) given to text, leaving
// the map only 750px wide. The names are short and do not need that; the MAP does.
// At 210 a side the map grows 750 -> 930 (+24% width, +24% linear scale), which is
// what gives the pins room to stop overlapping. The poster's own aspect ratio is
// untouched — PW/PH are unchanged; only the split between map and margin moves.
// Label column (LABEL_RES) drops to match, still comfortably wider than the
// longest people-group name at this size.
const GUTTER = FMT.gutter, GPAD = 16;
const MAP_X = GUTTER;
const MAP_W = PW - 2 * GUTTER;
// Set per-zone by setMapBox() before each poster renders.
let HEADER = MAP_ONLY ? FMT.header : SPARSE ? 355 : 460;
let MAP_Y = HEADER;
let MAP_H = PH - HEADER - FOOTER;
const setMapBox = (headerPx) => {
HEADER = headerPx;
MAP_Y = HEADER;
MAP_H = PH - HEADER - FOOTER;
};
// HEADER IS PER-ZONE, NOT A SHARED CONSTANT. The facts table's height depends on
// how many countries and provinces a zone has, so one fixed HEADER cannot serve
// all 28 sheets: tuned to North India (2 countries) it overflowed on west-africa
// (many more), and tuned to west-africa it would steal ~60px of map from every
// other sheet. measureHeader() predicts the height from the zone's OWN content,
// so each poster gets exactly the header it needs and the map takes the rest.
// The fail-loud guard still runs afterwards as the backstop.
function measureHeader(zone, pins, ctryNames, provinces) {
if (HEAD_COLUMNS) {
// The same layout function the renderer draws with, so this is the height, not an estimate.
const { bottom } = headColumnsBand(HC_TOP, headFacts(zone, pins), Math.round(SERIES.totalLists / 10) * 10);
return bottom + 4 + 14;
}
if (!SPARSE) {
// ── FULL VERSION: measured, not a constant ────────────────────────────────
// This used to `return HEADER_BASE` — a flat 460 — so the per-zone measurement
// everything above describes ONLY ever ran in SPARSE mode. Four zones' content
// is taller than 460 and they threw the fail-loud guard, which meant they had
// NO POSTER AT ALL: oceania-indonesia and andean-caribbean at 504, deaf at 619,
// asia-china-tibet at 481.
//
// Raising the constant is the wrong fix: HEADER is subtracted from the map
// height, so 460 -> 620 would steal 160px of map from the 25 healthy sheets to
// rescue 4. That is exactly the tradeoff the per-zone measurement exists to
// avoid (see the note above about tuning to North India breaking west-africa).
//
// MEASURED the full header on six zones and the shape is exact, not estimated:
// HEAD_BOTTOM = (headY + 34) + 118 on laos, west-africa, deaf,
// oceania-indonesia, andean-caribbean
// and asia-china-tibet — all six.
// The 118 is the two vision columns (a constant 104 — they are fixed copy, so
// they wrap the same on every sheet) plus the 14px trailing gap. ALL per-zone
// variation is the facts table, which advances headY by 23px PER WRAPPED LINE.
//
// So the prediction is arithmetic on the same inputs the renderer wraps, and it
// needs no slack — unlike the SPARSE model below, whose long +24/+52/+66/-8/+6
// calibration history is what an estimate costs.
const HEAD_MAXCH = 78; // must match the renderer's wrap width
const wrapCount = (items) => {
let lines = 0;
let line = '';
for (const it of items || []) {
const next = line ? `${line} · ${it}` : it;
if (next.length > HEAD_MAXCH) { lines += 1; line = it; } else { line = next; }
}
return lines + (line ? 1 : 0);
};
// headY = 191 + 23 * lines, DERIVED FROM THE RENDER on four zones rather than
// from reading the code — an earlier attempt at this reasoned the constant to
// 138 by tracing headY's assignments and was 53px short on every zone, because
// the scripture block between the table and the vision columns advances the
// cursor too. Measured: laos 283 (4 lines), west-africa 306 (5),
// oceania-indonesia 352 (7), deaf 467 (12) — headY - 23*lines = 191 on all four.
// The 4 rows are countries, administrative areas, people groups, series.
const tableLines = wrapCount(ctryNames) + wrapCount(provinces) + 1 + 1;
const headY = 191 + tableLines * 23;
// +8 of slack: the guard is the real check, and being short fails the build
// while being long costs a few px of map. Measured exact on six zones, so the
// slack covers a content shape none of them had rather than a known gap.
return headY + 34 + 118 + 8;
}
// PREDICT WITH THE SAME COLUMN WIDTHS THE RENDERER USES. This used to assume
// four EQUAL quarter-width columns, but the table now sizes columns to their
// content — so a zone with one long list (deaf, europe) was predicted to wrap
// far more than it really does. The surplus landed as dead space ABOVE the map:
// deaf rendered 340px above vs 74px below, a 266px drift.
const FT_BAND = PW - 2 * BAND_MARGIN_G;
const vals = [
[`${pins.length} UPG's`, 34],
[ctryNames.join(' \u00b7 ') || '\u2014', 17],
[provinces.join(' \u00b7 ') || '\u2014', 17],
['~0.0M', 34],
];
const wts = vals.map(([v]) => Math.sqrt(Math.max(10, String(v).length)));
const tot = wts.reduce((a, b) => a + b, 0);
const minW = FT_BAND * 0.17;
let ws = wts.map((w) => Math.max(minW, (w / tot) * FT_BAND));
const sum = ws.reduce((a, b) => a + b, 0);
ws = ws.map((w) => (w / sum) * FT_BAND);
const rows = Math.max(...vals.map(([v, fs], i) =>
wrapPx(v, fs, ws[i] - 24 - BAND_TEXT_PAD / 2).length));
// 136 title flow + series(28) + scripture block(53) + table head(22)
// + first value row(34) + each extra wrapped row(20) + 14 trailing.
// +24 measured: the guard reported 351 against a predicted 327 on the
// reference zone, so the fixed part of the block is 24px taller than the
// sum of its nominal steps (baseline offsets inside each sub-block).
// +52 not +38: the estimate is a PREDICTION of a block whose exact height
// depends on per-zone wrapping, so it carries slack rather than sitting on the
// boundary. 14px of slack costs 14px of map on the tightest sheets and prevents
// a per-zone constant hunt (sudan tripped a 38 that North India cleared).
// The fail-loud guard remains the real check.
// +66, was +52. The header now also carries the two-tone title, which sits on
// its own line above the series text; zones whose facts wrap to fewer rows
// (gujarat: one country, one province) tripped the guard at 52.
// The trailing term is the gap between the last header rule and the map's top
// edge. It uses MAP_BREATH so it MATCHES the gap below the map, plus 8px for
// the rule's own weight and the drop shadow that sits above the frame.
// -8 not +8: measured, the block lands 16px lower than the nominal sum of its
// steps (baseline offsets inside each sub-block), so the trailing term is
// corrected against the RENDER rather than trusted from arithmetic. This makes
// the gap above the map equal the gap below it.
// +6 of slack over the corrected value: the prediction is a MODEL of a block
// whose height depends on per-zone wrapping, and gujarat (one country, one
// province -> fewest wrapped rows) landed 12px past the corrected estimate.
// Slack here costs a few px of map; being short trips the guard and fails the
// build, so it errs generous. The guard remains the real check.
// 28 -> 12: the series block is ONE line now, not two, so the model must stop
// reserving the second line or the freed space just becomes a bigger top gap.
// +16, was +6. Predicting with the renderer's real column widths made this
// estimate much tighter, which is what let the map grow — but oceania-indonesia
// then landed 8px past it and tripped the guard. A small fixed margin covers
// the residual per-zone variation; the map still takes everything else.
return 136 + 12 + 53 + 22 + 34 + Math.max(0, rows - 1) * 20 + MAP_BREATH + 16;
}
// distribute pins into left/right gutters, balanced, return label rows w/ leader anchors
// Label rows are confined to the MAP BAND, not the whole sheet. When the map
// frame fits a landscape region it occupies only part of the sheet height, and
// labels spread over the full height then sat beside blank paper with their
// connectors pointing at nothing.
let LABEL_TOP = null, LABEL_BOT = null;
function layoutLabels(pins, project) {
// project + clamp into the map content rect for the leader-line origin
const items = pins.map((p) => {
// _fixed pins carry positions already settled by the de-overlap pass; do not
// re-project them or the separation work is thrown away.
const [lx, ly] = p._fixed ? [p._px, p._py] : project(p.lng, p.lat);
const x = Math.max(MAP_X + 2, Math.min(MAP_X + MAP_W - 2, lx));
const y = Math.max(MAP_Y + 2, Math.min(MAP_Y + MAP_H - 2, ly));
return { ...p, px: x, py: y };
});
// GUARANTEED NON-CROSSING side assignment (anti-overlap math).
// Sort pins by px and split at the median index: the left half of pins (by
// longitude) take the LEFT gutter, the right half the RIGHT. Because every
// left pin sits physically left of every right pin, the two leader fans
// occupy DISJOINT horizontal bands (meeting only at the median x) and cannot
// cross each other. Within a gutter, `place()` orders the ports by pin
// latitude (py), so the leaders form a monotonic fan to a common vertical
// port line — the classical boundary-labeling result that straight leaders
// are non-crossing iff the point→port assignment preserves y-order. The two
// properties together → no leader line crosses another. (Counts are balanced
// to ±1, so neither gutter is overcrowded and labels stay legible.)
// Y-ORDER BASELINE (median x-split, then y within each side).
const byX = [...items].sort((a, b) => a.px - b.px);
const halfB = Math.ceil(byX.length / 2);
byX.forEach((it, i) => { it.side = i < halfB ? 'L' : 'R'; });
// INTRA-COLUMN ordering by GREEDY CROSSING REPAIR.
// The median x-split already yields ZERO inter-column crossings (measured), so
// the hard rule is satisfied by construction. Everything left is within
// one column's fan.
//
// Sorting by y is the classic answer but is NOT optimal here, because the slots
// are evenly pitched while the pins are not: a pin's slot y and its own y differ,
// and two leaders cross exactly when that difference inverts between them.
// Rather than guess an ordering heuristic, repair directly: start from y-order,
// then repeatedly swap any ADJACENT pair whose swap reduces the crossing count.
// Adjacent swaps are enough because a crossing between two leaders in the same
// fan is precisely an inversion, and bubble-style repair removes inversions.
const segCross = (a, b) => {
const d = (p1, p2, p3) => (p3.y - p1.y) * (p2.x - p1.x) - (p2.y - p1.y) * (p3.x - p1.x);
const A1 = { x: a.sx, y: a.sy }, A2 = { x: a.px, y: a.py };
const B1 = { x: b.sx, y: b.sy }, B2 = { x: b.px, y: b.py };
return (d(A1, A2, B1) > 0) !== (d(A1, A2, B2) > 0)
&& (d(B1, B2, A1) > 0) !== (d(B1, B2, A2) > 0);
};
for (const sd of ['L', 'R']) {
const col = items.filter((i) => i.side === sd).sort((a, b) => a.py - b.py);
const n2 = col.length || 1;
const bandTop0 = LABEL_TOP ?? MAP_Y, bandBot0 = LABEL_BOT ?? (MAP_Y + MAP_H);
const rowH0 = (bandBot0 - bandTop0) / n2;
// FRAME_W is computed per-poster in the caller and is NOT in scope here; the
// module-level MAP_W is the same width for the label geometry's purposes.
const sxCol = sd === 'L' ? MAP_X - 6 : MAP_X + MAP_W + 6;
const setSlots = () => col.forEach((it, i) => { it.sx = sxCol; it.sy = bandTop0 + rowH0 * (i + 0.5); });
setSlots();
// Bubble-repair: swap adjacent rows whenever it strictly reduces crossings.
for (let pass = 0; pass < 60; pass++) {
let improved = false;
for (let i = 0; i < col.length - 1; i++) {
const a = col[i], b = col[i + 1];
const before = segCross(a, b) ? 1 : 0;
const ay = a.sy, by = b.sy;
a.sy = by; b.sy = ay;
const after = segCross(a, b) ? 1 : 0;
if (after < before) { col[i] = b; col[i + 1] = a; improved = true; }
else { a.sy = ay; b.sy = by; }
}
if (!improved) break;
}
col.forEach((it, i) => { it.ringIdx = i; });
}
const place = (side) => {
// Order within a column is: the TOP-band quadrant (sorted by pin y), then the
// BOTTOM-band quadrant (sorted by pin y). Sorting by y INSIDE each band is the
// monotonicity property — within a quadrant, label order matches pin order, so
// leaders in the same quadrant cannot cross each other.
// Rows follow the RING ORDER, not y — that ordering is the whole mechanism.
const col = items.filter((i) => i.side === side).sort((a, b) => a.ringIdx - b.ringIdx);
const n = col.length || 1;
// Rows fill the MAP BAND, not the whole sheet. When the frame fits a landscape
// region it occupies only part of the sheet height; labels spread over the full
// height then sat beside blank paper with their connectors pointing at nothing.
// Rows span the PINS' OWN vertical extent, not the whole frame. The map frame
// is full sheet height but the pins occupy only a band within it, so labels
// spread over the full height put "bottom quadrant" names far below any
// bottom-quadrant pin — every one of those leaders then ran steeply upward,
// which is exactly the long diagonal the quadrant scheme exists to remove.
const pinYs = col.map((it) => it.py);
const pinTop = Math.min(...pinYs), pinBot = Math.max(...pinYs);
// FULL FRAME HEIGHT, evenly pitched (design, designer call). The label band
// used to be blended toward the pins' own extent to shorten leaders — it cut
// crossings 77 -> 24 but left the names bunched mid-sheet with empty margin
// above and below. Longer leaders are the accepted cost: a poster is looked
// at, not audited, and even spacing edge-to-edge reads better AND buys the
// type more room.
const bandTop = LABEL_TOP ?? MAP_Y;
const bandBot = LABEL_BOT ?? (MAP_Y + MAP_H);
const bandH = bandBot - bandTop;
const rowH = bandH / n;
// Seen from a distance the people-group names did not read; they need to be
// larger and bold.
// Two separate limits were holding them down: the 0.62 row-height ratio, and a
// hard 15px CAP that bit as soon as the map grew. At 46 rows in a ~1030px band
// the pitch is ~22px, so 0.76 gives ~17px type with ~5px of leading left — the
// names stay separated while getting materially bigger. The cap rises with it;
// rowH still governs, so a denser zone cannot collide.
// ── ONE SIZE FOR EVERY NAME, WITH WRAPPING ────────────────────────────
// Previously the size came from row pitch alone and long names were then
// SHRUNK to fit the column width, so no two rows matched. Now the size is
// uniform and a long name wraps instead — but a wrapped name occupies two
// line-slots, so the pitch has to account for the extra lines BEFORE the size
// is fixed. Solve it by trying the largest size down: for each candidate,
// count how many lines the column really needs; keep the largest size whose
// total lines still fit the band.
// The number column is reserved on every line, so the wrap solver needs the
// sheet's largest index. `pins.length` is the count for the whole sheet.
const MAXNUM_L = pins.length;
col.forEach((it) => { it.maxNum = MAXNUM_L; });
// A map-only sheet frees the header and footer bands the same way SPARSE frees the vision
// columns, so it gets SPARSE's ceiling; the solver still only uses what the column can hold.
// The three-column sheet (HEAD_COLUMNS) gets it too: there the names are the main text, and the cap of 15
// held 21 of the 28 zones' names at 15px (≈5.6pt printed) with room to spare in the band.
const MAXF = (SPARSE || MAP_ONLY || HEAD_COLUMNS) ? 19 : 15;
// AVAIL IS THE COLUMN, AND THE NAME DOES NOT GET ALL OF IT.
// The renderer draws the name starting one number-column IN from the column's
// map-facing edge (`nameEdge = tx ± numColW`) but does not shrink its budget by
// the same amount — so the name is handed `LABEL_RES` of room while physically
// having `LABEL_RES - numColW`. Below, wrapName subtracts numW from `avail`,
// which is the right intent but leaves the two ends disagreeing about where the
// column starts. On a 109-row sheet that gap let "Eastern Xiangxi Miao" set as
// one line and run to x=1479.7 — past the 1474 sheet edge. (Measured, not
// estimated: the label is only 224.7px wide against a 262px budget, so it was
// never a too-wide name. It STARTED 55px too far right.)
// Trimming the budget here narrows the name column by SHEET_EDGE, which is
// exactly the inset the renderer already applies. MEASURED EFFECT on the 28
// 3x3 sheets: 18 identical, 10 shifted slightly as a row re-wrapped (e.g.
// tamil-nadu 267->271, central-africa 256->239, deaf 115->119). No sheet gained
// an overflow and none tripped a guard — the shifts are re-wraps, not damage.
const avail = LABEL_RES_M() - 4 - SHEET_EDGE;
const BOLD_W = 1.06;
// Wrap `name` to `avail`, reserving room for the "12. " prefix on line 1.
const wrapName = (it, f) => {
// The name column is inset by the NUMBER column on every line (the number is
// its own element now), so the reserve applies to all lines, not just line 1.
const numW = textW(`${it.maxNum}.`, f) * BOLD_W + f * 0.42;
const words = String(it.name).split(/\s+/).filter(Boolean);
const out = []; let line = ''; let first = true;
const room = avail - numW;
for (const w of words) {
const cand = line ? `${line} ${w}` : w;
if (textW(cand, f) * BOLD_W > room && line) { out.push(line); line = w; }
else line = cand;
}
if (line) out.push(line);
return out.length ? out : [it.name];
};
// The air between rows, as a share of the size. The three-column sheet sets it tighter (0.22): its densest
// zones (95-118 names) are held by the band's height, and the tighter rows turn that height into type
// (west-africa 12.5 -> 13.8px, laos 15.8 -> 17.3px). A row's underline sits 0.30 below its baseline and the
// next row's capitals start 0.56 below it, so they stay clear. Every other sheet keeps 0.34.
const GAPF = HEAD_COLUMNS ? 0.22 : 0.34;
let font = MAXF, wraps = null;
for (let f = MAXF; f >= 8; f -= 0.25) {
const ws = col.map((it) => wrapName(it, f));
const totalLines = ws.reduce((a, w) => a + w.length, 0);
// Each line needs f*1.06 of pitch, plus a little air between rows.
if (totalLines * (f * 1.06) + col.length * (f * GAPF) <= bandH) { font = f; wraps = ws; break; }
}
if (!wraps) wraps = col.map((it) => wrapName(it, font));
// Re-pitch rows by LINE COUNT so a two-line name gets the room it needs and
// single-line rows are not stretched to match it.
const lineH = font * 1.06;
const gap = font * GAPF;
const usedH = wraps.reduce((a, w) => a + w.length * lineH, 0) + col.length * gap;
const slack = Math.max(0, bandH - usedH) / Math.max(1, col.length);
let cursor = bandTop + slack / 2;
col.forEach((it, i) => {
it.wrap = wraps[i];
const blockH = wraps[i].length * lineH;
it.labelY = cursor + lineH * 0.5 - font * 0.34;
cursor += blockH + gap + slack;
it.font = font;
it.rowH = rowH;
// gutter inner edge (where the leader meets the text block)
it.dotX = side === 'L' ? MAP_X - 6 : MAP_X + MAP_W + 6;
it.textX = side === 'L' ? MAP_X - GPAD : MAP_X + MAP_W + GPAD;
it.anchor = side === 'L' ? 'end' : 'start';
});
return col;
};
return [...place('L'), ...place('R')];
}
// ── footer Scripture ─────────────────────────────────────────────────────────
// The feeding of the 5000 — Jesus had the crowd sit "in groups of hundreds and
// of fifties" (Mark 6:40 / Luke 9:14). That 50/100 grouping is the motif behind
// the my-upg-100-list: the harvest seated in countable companies, none forgotten.
// Override per region via VERSE_BY_ZONE (keyed by zoneKey or zone.id).
const DEFAULT_VERSE = {
text: 'And they sat down in groups, by hundreds and by fifties.',
ref: 'Mark 6:40',
};
const VERSE_BY_ZONE = {
// 'asia-india-andhra-pradesh': { text: '…', ref: '…' },
};
const verseFor = (zone) => VERSE_BY_ZONE[zone.zoneKey] || VERSE_BY_ZONE[zone.id] || DEFAULT_VERSE;
// ── TEXT MEASUREMENT + COLUMN WRAPPING (for the header grid) ─────────────────
// SVG <text> does not word-wrap, so a multi-column header has to be broken into
// lines HERE. Widths come from the same per-character advance table the label
// underlines use — it is approximate but consistent, and consistency is what a
// column needs: every line in a column is measured by the same ruler.
const CHADV = (ch) => {
if ('iIjl|.,:;\'! '.includes(ch)) return 0.28;
if ('ftr()[]-'.includes(ch)) return 0.34;
if ('mwMW'.includes(ch)) return 0.86;
if ('ABCDEFGHKLNOPQRSTUVXYZ0123456789'.includes(ch)) return 0.63;
return 0.53;
};
// Width of a plain string at a given font size, in px.
// CALIBRATED AGAINST THE RENDERER, not guessed. The raw advance table averages
// ~0.45/char over ordinary prose, but Lato at 12.5px measured 0.498/char across
// 8 rendered lines (getBBox in Firefox) — an 11% under-estimate, which let every
// column overrun its own rule. Measure, then correct; do not re-tune by eye.
const WFUDGE = 1.11;
const textW = (str, fs) => [...str].reduce((a, ch) => a + CHADV(ch), 0) * fs * WFUDGE;
// Greedy word-wrap to a pixel width. Returns an array of lines.
function wrapPx(str, fs, maxW) {
const words = String(str).split(/\s+/).filter(Boolean);
const out = [];
let line = '';
for (const w of words) {
const next = line ? line + ' ' + w : w;
if (textW(next, fs) > maxW && line) { out.push(line); line = w; }
else line = next;
}
if (line) out.push(line);
return out;
}
// Emit one wrapped paragraph into a column. `lead` markup (e.g. a bold run-in)
// is applied to the opening words of the first line only.
// Returns the y AFTER the last line.
function paraInto(push, text, { x, y, w, fs, lh, cls, lead }) {
const full = lead ? `${lead} ${text}` : text;
const lines = wrapPx(full, fs, w);
let cy = y;
for (let i = 0; i < lines.length; i++) {
let body = esc(lines[i]);
// Re-apply the run-in emphasis to however much of it landed on line 0.
if (i === 0 && lead) {
const le = esc(lead);
if (body.startsWith(le)) body = `<tspan class="hsrc-em">${le}</tspan>` + body.slice(le.length);
}
push(`<text x="${x}" y="${cy}" class="${cls}">${body}</text>`);
cy += lh;
}
return cy;
}
// ── THE THREE-COLUMN HEADER BAND (HEAD_COLUMNS) ──────────────────────────────
// ONE function lays the band out for both callers: measureHeader() reads only its bottom, renderPoster() emits
// its text. A second copy of the arithmetic is how the full header's prediction once ran 53px short.
// Built like the footer's three networks: equal columns, a text measure narrower than the column, each block
// CENTRED on its column with its lines sharing one left edge, and short rules between the columns.
// 1 Jesus' strategy to feed the 5000
// 2 the facts: a bold key above each value (countries, administrative areas, people groups, series)
// 3 what the series is
// The sizes equal HEAD_COLUMNS_FULL_CSS (.hcolt = fs, .hcolk = keyFs, .hcolh = headFs); leading and gaps scale with them.
const HC = { gut: 32, fs: 18, lh: 22, keyFs: 15.5, keyLh: 19.5, headLh: 24.5, gap: 7, headFs: 19 };
// textW is calibrated for Lato. Without Lato the browser falls back to a system sans that sets
// these columns 2-6% wider (measured 1.05-1.06x in DejaVu Sans). Each block is centred on its
// width at 1.03x, halfway, so any overrun splits across both sides instead of all running right.
const HC_FALLBACK = 1.03;
// The two explainer columns' text, ONE copy for both header layouts (the head-columns band and the classic
// header), so the two can no longer drift apart. A series may set its own Jesus column in series.json
// (poster.strategyBody: one paragraph, or a list of them), as UPG100 does; the series that do not, the
// Country and India-state posters, print this default.
// COPY IS THE OWN (05-poster-vision/source-material/01). It KEEPS THE MIRACLE: earlier drafts reduced the
// account to a seating arrangement, when the point is that Jesus supernaturally MULTIPLIED the offering
// and the grouping is how it was distributed.
const STRATEGY_DEFAULT = [
'Jesus’ disciples faced a similar challenge: “How can we feed so many with only five loaves and two fish?” Jesus fed the 5,000 by supernaturally multiplying that small offering — and His answer holds the key to our people-groups problem.',
'Through radical faith and miracle-expectant prayer, He focused on the few in front of Him — dividing the crowd and entrusting faithful men and women to serve the most strategic groups of fifties and hundreds (Mark 6:40).',
];
const strategyParas = () => (POSTER_TEXT.strategyBody ? [].concat(POSTER_TEXT.strategyBody) : STRATEGY_DEFAULT);
// The explainer's further paragraphs (its first is series.json's sideBoxBody). A series may set its own in
// series.json (poster.sideBoxMore: one paragraph, or a list; [] prints none), as UPG100 does to keep its band even.
const whatIsMore = () => (POSTER_TEXT.sideBoxMore !== undefined ? [].concat(POSTER_TEXT.sideBoxMore) : [WHAT_IS_MORE]);
const WHAT_IS_MORE = 'Each map comes with a prayer list of its fifty to a hundred people groups — their names, a representative photo, and more detail — to mobilise informed intercession.';
// A paragraph's lines as SVG text content, its **...** runs set bold (.hcolb); the markers are not printed.
// The wrap measures the plain text: a short bold run is a few px wider, inside the measure's room.
function boldLines(text, width) {
let plain = '', bold = [], on = false;
for (const part of String(text).replace(/\s+/g, ' ').trim().split('**')) {
for (let i = 0; i < part.length; i++) { plain += part[i]; bold.push(on); } // per UTF-16 unit, as plain[p] reads
on = !on;
}
// A scripture reference never breaks between its book and its chapter:verse ("Mark 6:40"): that space becomes a
// placeholder for the wrap and prints as a space again. One character for one, so the bold flags stay aligned.
plain = plain.replace(/([A-Za-z]+) (\d+:\d+)/g, '$1\u0001$2');
let p = 0;
return wrapPx(plain, HC.fs, width).map((ln) => {
while (plain[p] === ' ') p++;
let out = '', run = '', runBold = null;
const flush = () => { if (run) out += runBold ? `<tspan class="hcolb">${esc(run)}</tspan>` : esc(run); run = ''; };
for (let k = 0; k < ln.length; k++, p++) {
if (bold[p] !== runBold) { flush(); runBold = bold[p]; }
run += plain[p] === '\u0001' ? ' ' : plain[p];
}
flush();
return out;
});
}
function headColumnsBand(top, facts, approxLists) {
const GRID_L = BAND_MARGIN_G, GRID_W = PW - 2 * BAND_MARGIN_G;
const AVAIL = GRID_W - 2 * HC.gut;
// The measure leaves room for a fallback font (see HC_FALLBACK): a block set to it stays inside its column.
const measure = (cw) => (cw - 10 - BAND_TEXT_PAD / 2) * 0.92;
// ★ EVEN COLUMNS (2026-10-03): when the facts column runs longer than the two text columns it is WIDENED (and
// shorter, it is narrowed) so the three end at about the same height. The two side columns stay equal, so they
// still mirror each other round the centre. The centre's share is searched from 26% to 46% of the three-column
// width; the split whose TALLEST column is shortest wins, and a tie goes to the split nearest equal widths, so a
// zone that is already balanced keeps equal columns. measureHeader calls this same function, so the map below
// gets exactly the height that is left.
// Uppercase, letterspaced keys: measured as capitals plus the .14em tracking.
const keyW = (k) => textW(k.toUpperCase(), HC.keyFs) + k.length * HC.keyFs * 0.14;
// Each column's height at a given measure (the same arithmetic the drawing below advances y by).
const proseH = (col, tw) => HC.headLh + col[0].paras.reduce((h, t, k) => h + (k ? HC.gap : 0) + boldLines(t, tw).length * HC.lh, 0);
const factsH = (tw) => facts.reduce((h, [, value], k) => h + (k ? HC.gap : 0) + HC.keyLh + wrapPx(value, HC.fs, tw).length * HC.lh, 0);
// Column order (2026-10-02): Jesus' strategy | the facts | what the series is.
const cols = [
[{ head: 'Jesus’ Strategy to Feed the 5000', paras: strategyParas() }],
null, // the facts column, wrapped below at the centre's measure
[{ head: POSTER_TEXT.sideBoxHeading || 'What is a UPG100 List?', paras: [
(POSTER_TEXT.sideBoxBody
|| 'This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.')
.replace('{approxLists}', String(approxLists)).replace('{seriesName}', SERIES_NAME),
...whatIsMore(),
] }],
];
// The split: equal thirds first, then centre shares on a 1% step ordered by their distance from a third, so
// among splits whose tallest column is equally short, the one nearest equal widths is found first and kept.
const shares = Array.from({ length: 21 }, (_, k) => 0.26 + k * 0.01).sort((a, b) => Math.abs(a - 1 / 3) - Math.abs(b - 1 / 3));
let best = null;
for (const cwC of [1 / 3, ...shares].map((f) => AVAIL * f)) {
const cwS = (AVAIL - cwC) / 2;
const tall = Math.max(proseH(cols[0], measure(cwS)), factsH(measure(cwC)), proseH(cols[2], measure(cwS)));
if (!best || tall < best.tall - 0.5) best = { cwC, cwS, tall };
}
const widths = [best.cwS, best.cwC, best.cwS];
const lefts = [GRID_L, GRID_L + best.cwS + HC.gut, GRID_L + best.cwS + HC.gut + best.cwC + HC.gut];
cols[1] = facts.map(([key, value]) => ({ key, lines: wrapPx(value, HC.fs, measure(best.cwC)) }));
let frags = '', bottom = top;
cols.forEach((col, i) => {
const CW = widths[i], TW = measure(CW);
const centre = lefts[i] + CW / 2;
if (col[0] && col[0].key !== undefined) { // the facts column: a bold key above each value
const blockW = Math.min(TW, Math.max(...col.map((r) => Math.max(keyW(r.key), ...r.lines.map((ln) => textW(ln, HC.fs))))));
const x = (centre - blockW * HC_FALLBACK / 2).toFixed(1);
let y = top;
col.forEach((r, k) => {
if (k) y += HC.gap;
frags += `<text x="${x}" y="${y}" class="hcolk">${esc(r.key)}</text>`;
y += HC.keyLh;
for (const ln of r.lines) { frags += `<text x="${x}" y="${y}" class="hcolt">${esc(ln)}</text>`; y += HC.lh; }
});
bottom = Math.max(bottom, y);
} else {
const { head, paras } = col[0];
// ★ MIRRORED (2026-10-03): both prose columns take the full measure, so each sits the same distance from
// its rule and the two outer columns mirror each other round the centred facts column. (Each used to be
// centred on its own widest line, so the two could sit at different distances from their rules.)
const blockW = TW;
const x = (centre - blockW * HC_FALLBACK / 2).toFixed(1);
frags += `<text x="${x}" y="${top}" class="hcolh">${esc(head)}</text>`;
let y = top + HC.headLh;
paras.forEach((text, k) => {
if (k) y += HC.gap;
for (const ln of boldLines(text, TW)) { frags += `<text x="${x}" y="${y}" class="hcolt">${ln}</text>`; y += HC.lh; }
});
bottom = Math.max(bottom, y);
}
});
for (let i = 1; i < 3; i++) {
const rx = (lefts[i] - HC.gut / 2).toFixed(1);
frags += `<line x1="${rx}" y1="${top - 14}" x2="${rx}" y2="${bottom - 12}" class="hcolrule"/>`;
}
return { frags, bottom };
}
// The facts the band's first column states, in its order. Shared by the measure and the render.
function headFacts(zone, pins) {
const ctry = [...new Set([
...pins.map((p) => ISO3_NAME[p.country_code] || p.country_code),
...(Array.isArray(zone.adm0) ? zone.adm0.map((c) => ISO3_NAME[c] || c) : []),
].filter(Boolean))].sort((a, b) => a.localeCompare(b, 'en-US'));
return [
// A list of countries longer than a column can carry (the Deaf bloc names 66) prints its first ten and the
// count of the rest, so one sheet's facts do not take its map's height.
['Countries', (ctry.length > 12 ? `${ctry.slice(0, 10).join(' \u00b7 ')} \u00b7 +${ctry.length - 10}` : ctry.join(' \u00b7 ')) || '\u2014'],
['Administrative Areas', provincesOf(zone).join(' \u00b7 ') || '\u2014'],
['People groups', `${pins.length} UPG`],
['Series', `${SERIES_NAME} \u00b7 map ${SERIES.index} of ${SERIES.totalLists}`],
];
}
// The band's first baseline: the title flow (136), the scripture block (16 + 25 + 12), and the 34 the
// renderer leaves before its columns. The bottom adds the renderer's 4 + 14.
// The verse's second line (HEAD_COLUMNS sets it on two) and the split, by words, nearest the middle of the text.
const HC_VERSE_LH = 26;
function VERSE_LINES(v) {
const w = v.split(' '), half = v.length / 2;
let best = 1, d = Infinity, run = 0;
for (let i = 0; i < w.length - 1; i++) { run += w[i].length + 1; if (Math.abs(run - half) < d) { d = Math.abs(run - half); best = i + 1 } }
return [w.slice(0, best).join(' '), w.slice(best).join(' ')];
}
const HC_TOP = 136 + 16 + 25 + HC_VERSE_LH + 12 + 34;
async function renderPoster(zone, pins, zoneFC) {
// Per-zone override for the badge, so a zone with unusually crowded or sparse
// pins can tune without a code branch. Defaults to the template value.
const PIN_R = PIN_R_BASE * (Number(zone.pinScale) > 0 ? Number(zone.pinScale) : 1);
// SIZE THE HEADER FIRST — the map box is derived from it, and every geometry
// consumer below reads MAP_Y/MAP_H. Both inputs are available up here: the
// country list comes from the pins, and the province list from the zone (the
// title-derived fallback only applies to zones with no `provinces` field, which
// are exactly the zones whose label IS the province list).
{
const ctry = [...new Set(pins.map((p) => ISO3_NAME[p.country_code] || p.country_code).filter(Boolean))]
.sort((a, b) => a.localeCompare(b, 'en-US')); // pinned: the sheet must not sort by the reader's browser
const prov = provincesOf(zone);
// TWO-PASS SIZING. measureHeader() must err GENEROUS or the fail-loud guard
// trips mid-build — but every over-reserved pixel used to sit as dead white
// space above the map (deaf: 340px above vs 74px below).
// A map with white space around it should grow into any vertical room, with
// the people-group columns spaced out to match.
// So: predict generously, then hand the map the FULL remaining band. MAP_H is
// derived from the prediction, so a smaller header automatically yields a
// taller map — and the label columns re-pitch to the same band, which is what
// spaces the names out to match.
// A map-only sheet has no header CONTENT to measure — the band is reserved, at a fixed height,
// for the title the packet draws itself.
setMapBox(MAP_ONLY ? FMT.header : measureHeader(zone, pins, ctry, prov));
}
// bbox: prefer resolved polygons; fallback to pins
let bbox;
// A CITY frames its OWN PINS. Its geometry selector is the whole country (see
// the CITY MODE note), so falling through to the polygon bbox would zoom out to
// all of India for an 8-pin cluster — measured on Ernakulam, where the sheet
// showed three states and the cluster was a dot. pinBbox() also carries the
// antimeridian + outlier handling the country path needs.
if (CITIES && pins.length) {
bbox = pinBbox(pins);
} else if (zoneFC.features.length) {
let W = 180, S = 90, E = -180, N = -90;
for (const f of zoneFC.features) { const b = bboxOf(f.geometry); if (b[0] < W) W = b[0]; if (b[1] < S) S = b[1]; if (b[2] > E) E = b[2]; if (b[3] > N) N = b[3]; }
bbox = [W, S, E, N];
// A polygon set can span the globe for the same two reasons the pins can —
// an antimeridian wrap or a far outlier — so fall back to the pin-derived
// box when the polygon box is implausibly wide.
if (E - W > 170 && pins.length) bbox = pinBbox(pins);
} else {
bbox = pinBbox(pins);
}
const FRAME_X = MAP_X;
const proj = (lng, lat) => { const [x, y] = project(lng, lat); return [FRAME_X + x, FRAME_Y + y]; };
// 25% context pad on EVERY side, on top of the projection's own 6%, left the
// subject floating small inside a large empty frame — the poster's whole middle
// was neighbouring land and sea. 10% still shows the surrounding countries (the
// the brief calls for them named and visible) while letting the zone fill the sheet.
// Enough surrounding land to PLACE the region on the globe. 10% showed barely
// any neighbours; 22% brings in the countries that answer "where is this?"
// CONTEXT PAD — a plain 22% of the subject's own span.
// The `+1` is a DEGREE FLOOR so a narrow zone still shows some surround. For a
// 100-list zone or a country that is right; for a CITY it dominates entirely.
// MEASURED on Ernakulam: pin bbox 0.08deg wide, pad +1.018deg per side -> a
// 2.1deg view. A ~9km cluster rendered as a three-state map with the pins as a
// dot. That is a ~25x over-zoom produced by a constant, not by the math.
// So the floor scales to the subject: a city gets a floor proportional to its
// own span (min 0.05deg ~ 5km of surround), everything else keeps the 1deg floor
// that the 28 existing sheets are tuned to.
// The floor must scale with the CLUSTER, not sit at a constant. Measured after
// the first fix: Thrissur's 5 pins still rendered 44x42px inside an 840x853
// frame — 5% of the sheet — because a 0.05deg floor is huge next to a ~0.01deg
// cluster. Five sheets shipped that way and I would not have caught them by eye.
// Now the floor is a MULTIPLE OF THE SPAN with a very small absolute minimum, so
// a tight cluster stays tight and a wide one still gets context.
// Use the LARGER span for the floor, but pad each AXIS from its OWN span, so a
// degenerate axis does not inherit the other's padding.
// Measured: Kasaragod's 4 pins are PERFECTLY COLLINEAR — y-span exactly 0 — so a
// single shared floor derived from max(w,h) over-padded the vertical into a
// 0.0%-spread sheet. Nagercoil had the same shape less severely.
// A zero-height bbox is not an error, it is a real arrangement of real pins, and
// the frame has to cope with it rather than the data being blamed.
const _w = bbox[2] - bbox[0], _h = bbox[3] - bbox[1];
const _floor = (span) => Math.max(0.012, span * 0.6);
const floorDeg = CITIES ? _floor(Math.max(_w, _h)) : 1;
// PER-AXIS floor. A collinear cluster (zero span on one axis) must not be padded
// as if it were as wide as the other axis — that is what produced a 0.0% spread.
// A side-lane sheet is the COUNTRY at its largest: a thin margin, since the names, not neighbours, fill the sides.
const pad = FMT.sideLanes ? [_w * 0.04, _h * 0.04]
: CITIES
? [_w * 0.22 + _floor(Math.max(_w, _h * 0.35)), _h * 0.22 + _floor(Math.max(_h, _w * 0.35))]
: [_w * 0.22 + floorDeg, _h * 0.22 + floorDeg];
const viewBbox = [bbox[0] - pad[0], bbox[1] - pad[1], bbox[2] + pad[0], bbox[3] + pad[1]];
await __step('map-frame');
// ── FIT THE MAP FRAME TO THE REGION'S SHAPE ────────────────────────────────
// The map frame was a fixed portrait rect (930x1580, aspect 0.59) but this
// region is LANDSCAPE (aspect 1.26). makeProjection fits with Math.min(sx,sy),
// so width bound and the region floated in a tall letterboxed void — roughly
// half the sheet was empty sea and sky.
// Now the frame takes the region's own aspect, capped so it never gets so short
// that the label columns have nowhere to sit. The POSTER aspect is untouched;
// only the map's share of it moves, and the freed height goes to the margins.
const SHEET_MARGIN_X = 30;
const mercYr = (la) => Math.log(Math.tan(Math.PI / 4 + (la * Math.PI / 180) / 2));
const zoneAspect = (viewBbox[2] - viewBbox[0]) / (((mercYr(viewBbox[3]) - mercYr(viewBbox[1])) * 180) / Math.PI);
// A landscape region should be allowed to use the FULL sheet width. Capping it
// at MAP_W (sheet minus two 210px gutters) meant this region could only reach
// 887px of the 1580px band — 693px of dead paper. The gutters do not need to be
// full-height: the labels now track the map band, so the map may extend past
// them vertically and the names simply sit alongside where it is.
const FRAME_W = MAP_W;
// The frame keeps the SHEET's height and simply shows more surrounding land
// above and below the region. Fitting the frame tightly to the region's own
// aspect looked correct in isolation but left 693px of dead paper on the sheet,
// and a poster cannot have a void that size. Extra context is not waste: the
// the brief asked for neighbouring countries to be visible and named, and a taller
// frame is exactly what shows them.
// FULL SHEET HEIGHT — the map runs from just under the header to just above the
// footer rule, so there is no dead band anywhere on the page.
// A side-lane sheet fits the frame to the country's own shape and centres it: its names run the sheet's full height
// beside it, so the frame need not, and a full-height frame would only shrink the country to show its neighbours.
const FRAME_H = FMT.sideLanes ? Math.min(MAP_H, FRAME_W / zoneAspect) : MAP_H;
// OPTICAL CENTRING, not geometric. A block centred by arithmetic looks like it
// is falling off the bottom of the sheet — the optical centre sits ABOVE the
// geometric one, which is why framers cut a bottom mat 1.25-1.4x the top. Here
// the remainder is split 42/58 so the bottom margin is the larger one.
const FRAME_Y = FMT.sideLanes ? MAP_Y + (MAP_H - FRAME_H) / 2 : MAP_Y;
// PROJECT FROM viewBbox, NOT bbox.
// These two had drifted apart: `viewBbox` (bbox + 22% context pad) decides WHICH
// countries are drawn, while the projection scaled from the bare `bbox` — so the
// generator selected neighbours it then had no room to show. They were emitted
// into the SVG and clipped away by #mapclip.
// Measured on country-zaf: 8 context polygons in the file, only 2 visible. Three
// were at NEGATIVE y (-368, -98, -196) — above the frame — and two ran past its
// right edge. Botswana/Zimbabwe/Mozambique were "missing" because they were drawn
// off-sheet, not because they were absent.
// The result on the sheet was random white spots where the countries around the
// highlighted country should have been.
// One box now governs both selection and scale, so anything chosen for context
// is guaranteed to fit. padFrac drops to 0.02 because viewBbox already carries
// the 22% context margin — keeping 0.08 would pad the padding and shrink the
// subject on every sheet.
const project = makeProjection(viewBbox, FRAME_W, FRAME_H, 0.02);
// Hand the label layout the map band so names track the map, not the sheet.
// IN SPARSE MODE the names get the SHEET's height instead. The names live in the
// side margins, not inside the frame, so nothing stops them running past the map
// band — and row pitch is what sets their type size. Letting them use the full
// column between the header rule and the footer rule buys ~2px of type at no
// cost to the map, and larger names are the point.
if (SPARSE) {
LABEL_TOP = HEADER - 40;
LABEL_BOT = PH - FOOTER + 40;
} else {
LABEL_TOP = FRAME_Y; LABEL_BOT = FRAME_Y + FRAME_H;
}
const color = zone.color || '#2bb';
// context countries (faint) — any adm0 whose bbox intersects the view
let ctx = '';
// Context countries + their NAMES. Previously these drew as an almost-invisible
// wash with no labels at all, so a reader could not tell what land surrounded
// the highlighted zone. Imhof's figure-ground: the surround must be legible but
// clearly subordinate — separation by VALUE, with a hairline edge so
// near-identical fills still read (Tufte separates 3% and 7% tints this way).
const ctxLabels = [];
for (const f of adm0.features) {
const fb = bboxOf(f.geometry);
if (!bboxIntersect(fb, viewBbox)) continue;
ctx += `<path d="${geoToPath(f.geometry, proj)}" class="ctry"/>`;
// Label the LARGEST VISIBLE RING, not the feature's bbox centre.
// A bbox centre is wrong for two independent reasons here:
// 1. ANTIMERIDIAN — the USA spans Alaska (-178) to the Aleutians (+179), so
// its bbox centre is 0.8E: the middle of the Sahara. It really did print
// "UNITED STATES" over Algeria.
// 2. Multi-part countries (islands, exclaves) put the centre in open water.
// Taking the biggest ring that actually intersects the view fixes both, and is
// also what makes the label land ON the landmass a reader associates it with.
const rings = f.geometry.type === 'Polygon' ? [f.geometry.coordinates[0]]
: f.geometry.type === 'MultiPolygon' ? f.geometry.coordinates.map((poly) => poly[0])
: [];
let best = null, bestArea = 0;
for (const ring of rings) {
let x0 = 180, y0 = 90, x1 = -180, y1 = -90;
for (const [x, y] of ring) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
if (!bboxIntersect([x0, y0, x1, y1], viewBbox)) continue;
const vx0 = Math.max(x0, viewBbox[0]), vx1 = Math.min(x1, viewBbox[2]);
const vy0 = Math.max(y0, viewBbox[1]), vy1 = Math.min(y1, viewBbox[3]);
const area = (vx1 - vx0) * (vy1 - vy0);
if (area > bestArea) { bestArea = area; best = [vx0, vy0, vx1, vy1]; }
}
if (!best) continue;
const wFrac = (best[2] - best[0]) / (viewBbox[2] - viewBbox[0]);
const hFrac = (best[3] - best[1]) / (viewBbox[3] - viewBbox[1]);
// Area test, not an AND of both axes: requiring both rejected every country
// whose visible slice is wide-but-short or tall-but-narrow.
// Threshold lowered hard: the point of the surrounding countries is to say
// WHERE ON THE GLOBE this is, and that only works if enough of them are named.
// Previously most neighbours were rejected and the region floated unplaced.
if (wFrac * hFrac < 0.00025 || wFrac < 0.012 || hFrac < 0.008) continue;
const nm = ISO3_NAME[f.properties?.iso3] || f.properties?.iso3;
if (!nm) continue;
// proj takes (lng, lat) as TWO ARGS — passing an array yields NaN silently,
// which put every label at x="NaN" and rendered nothing at all.
// A BBOX CENTRE IS NOT A POINT INSIDE THE SHAPE. China's visible slice is a
// wide east-west band across the top of the crop; the centre of that box sits
// SOUTH of the border — inside Nepal — so the poster printed "CHINA" in Nepal.
// Sample candidate points inside the clipped box and keep the one that is
// actually inside the country's own polygon and furthest from its edges. This
// is a coarse pole-of-inaccessibility: it guarantees the name lands on its own
// land rather than on a neighbour's.
const ringsOf = (geom) => geom.type === 'Polygon' ? [geom.coordinates[0]]
: geom.type === 'MultiPolygon' ? geom.coordinates.map((poly) => poly[0]) : [];
const inRing = (px, py, ring) => {
let hit = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i], [xj, yj] = ring[j];
if ((yi > py) !== (yj > py) && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) hit = !hit;
}
return hit;
};
const cand = [];
// Each ring with its bbox: a point outside a ring's bbox is never inside it (the parity test gives false), so
// skipping those rings gives exactly the same answers and spares the vertex walk (the mobile print speed).
const R = ringsOf(f.geometry).map((ring) => [ring, ringBox(ring)]);
for (let gx = 1; gx <= 7; gx++) {
for (let gy = 1; gy <= 7; gy++) {
const cxp = best[0] + ((best[2] - best[0]) * gx) / 8;
const cyp = best[1] + ((best[3] - best[1]) * gy) / 8;
if (!R.some(([ring, b]) => cxp >= b[0] && cxp <= b[2] && cyp >= b[1] && cyp <= b[3] && inRing(cxp, cyp, ring))) continue;
// score = distance to the nearest edge of the clipped box (keeps it central)
const d = Math.min(cxp - best[0], best[2] - cxp, cyp - best[1], best[3] - cyp);
cand.push({ cxp, cyp, d });
}
}
if (!cand.length) continue; // no interior point in view — skip it
cand.sort((a, b) => b.d - a.d);
const [lx, ly] = proj(cand[0].cxp, cand[0].cyp);
// IS THIS COUNTRY PART OF THE ZONE, or merely a neighbour in view?
// A zone whose members are mixed geometry (ADM1 provinces + a whole ADM0, as in
// North India + Nepal) was labelling its OWN member country in the neighbour
// style — so Nepal read as context, exactly like China, even though its land is
// filled in the zone colour and 34 of the sheet's people groups are inside it.
const isMember = Array.isArray(zone.adm0)
&& zone.adm0.includes(String(f.properties?.iso3 || '').toUpperCase());
ctxLabels.push({ x: lx, y: ly, nm: String(nm), member: isMember, area: bestArea });
}
// ALL-CAPS country names, tracked +0.1em (Bringhurst 5-10% of type size; shipped
// map styles converge on exactly 0.1em). Haloed to the PAPER colour, not white.
let ctryNames = '';
// Biggest visible landmass first, so when two names compete for the same space
// the more prominent country keeps its label.
ctxLabels.sort((a, b) => (b.area || 0) - (a.area || 0));
const placedLabels = [];
for (const L of ctxLabels) {
// Bounds must use FRAME_X, not MAP_X — the map draws from FRAME_X, so testing
// against MAP_X silently rejected labels that were genuinely inside the frame.
// A CLAMP IS WRONG HERE AND PRINTED "CHINA" INSIDE UTTAR PRADESH.
// Clamping drags a name to the frame edge nearest its true centre, so a country
// that is mostly off-screen lands its label on whatever land happens to sit
// there. China's visible slice is the Tibetan plateau to the north while its
// ring centre is far east — the clamp parked it in the middle of India.
// A wrong country name on a map is worse than no name at all.
// Instead: keep only labels whose position is genuinely INSIDE the frame, and
// place them at the centroid of the CLIPPED visible ring (computed above), so
// the name sits on the country's own visible landmass or not at all.
if (L.x < FRAME_X + 26 || L.x > FRAME_X + FRAME_W - 26) continue;
if (L.y < FRAME_Y + 26 || L.y > FRAME_Y + FRAME_H - 26) continue;
// GREEDY DE-OVERLAP. The area test admits a label if the country is big
// enough to deserve one, but says nothing about whether there is ROOM. On a
// world-scale zone (uncovered-scatter) that let ~60 names pile into Eurasia
// as an unreadable block. Reserve each accepted label's box and drop any
// later one that would land on top of it — a name that cannot be read is
// worse than no name, and the big countries win because they are sorted
// by visible area first.
const halfW = textW(L.nm, 15) * 0.5 + 6, halfH = 9;
if (placedLabels.some((q) => Math.abs(q.x - L.x) < halfW + q.halfW
&& Math.abs(q.y - L.y) < halfH + q.halfH)) continue;
placedLabels.push({ x: L.x, y: L.y, halfW, halfH });
const t = esc(L.nm.toUpperCase());
// Member countries take the same ink as the zone's own province names, so a
// reader can tell at a glance which land the sheet is ABOUT and which is only
// there for orientation.
const cls = L.member ? 'ctry-name-own' : 'ctry-name';
ctryNames += `<text x="${L.x.toFixed(1)}" y="${L.y.toFixed(1)}" text-anchor="middle" class="ctry-name-halo">${t}</text>`
+ `<text x="${L.x.toFixed(1)}" y="${L.y.toFixed(1)}" text-anchor="middle" class="${cls}">${t}</text>`;
}
await __step('neighbours');
// ── NEIGHBOURING PROVINCES (context ADM1) ─────────────────────────────────
// Previously only the ZONE's provinces were drawn, so everything around the
// highlighted cluster was one undifferentiated wash — a reader could not tell
// which province was which, or that there were provinces at all outside the
// zone. Draw EVERY adm1 feature intersecting the view as faint context, and
// label it. Zero new data: gb-IND-ADM1 and gb-CHN-ADM1 are already local and
// already carry shapeName.
let ctxAdm1 = '';
const adm1Labels = [];
for (const pool of [adm1Ind, adm1Chn].filter(Boolean)) {
for (const f of pool.features || []) {
const fb = bboxOf(f.geometry);
if (!bboxIntersect(fb, viewBbox)) continue;
// EVERY province in view is painted as land FIRST, including the zone's own.
// The zone's coloured fill draws over this, so the underlay only shows where
// nothing else covers it. Without it, un-zoned Indian land fell through to
// the sea fill and read as empty white space.
ctxAdm1 += `<path d="${geoToPath(f.geometry, proj)}" class="prov"/>`;
const inZone = f.properties && f.properties.listId === zone.id;
if (inZone) continue; // its label is drawn in zone colour
const nm = f.properties?.shapeName;
if (!nm) continue;
const vx0 = Math.max(fb[0], viewBbox[0]), vx1 = Math.min(fb[2], viewBbox[2]);
const vy0 = Math.max(fb[1], viewBbox[1]), vy1 = Math.min(fb[3], viewBbox[3]);
const wF = (vx1 - vx0) / (viewBbox[2] - viewBbox[0]);
const hF = (vy1 - vy0) / (viewBbox[3] - viewBbox[1]);
if (wF * hF < 0.0012) continue; // too small to carry a name
const [lx, ly] = proj((vx0 + vx1) / 2, (vy0 + vy1) / 2);
adm1Labels.push({ x: lx, y: ly, nm: String(nm) });
}
}
// Name the zone's OWN provinces too — the operator cannot tell Bihar from Jharkhand.
for (const pool of [adm1Ind, adm1Chn].filter(Boolean)) {
for (const f of pool.features || []) {
if (!f.properties || f.properties.listId !== zone.id) continue;
const fb = bboxOf(f.geometry);
if (!bboxIntersect(fb, viewBbox)) continue;
const nm = f.properties.shapeName;
if (!nm) continue;
const [lx, ly] = proj((fb[0] + fb[2]) / 2, (fb[1] + fb[3]) / 2);
adm1Labels.push({ x: lx, y: ly, nm: String(nm), own: true });
}
}
await __step('seas');
// ── SEA LABELS ─────────────────────────────────────────────────────────────
// Water rendered as an unlabelled pale field, so a reader could not tell ocean
// from a province that failed to draw — the operator read the Arabian Sea as a
// missing polygon.
//
// A fixed lng/lat gazetteer does NOT work here: the frame is full sheet height
// and shows only the northern fringe of each sea, so every gazetteer point
// (chosen at the sea's own centre) projected far below the frame. Measured:
// Arabian Sea at y=1829 against a frame ending at 1730.
// Instead, place each sea's name at the point where its water is ACTUALLY
// VISIBLE — clamp the gazetteer point into the frame along its own axis, and
// only keep it if that clamped point is genuinely on water (no land polygon
// covers it), tested against the same adm0/adm1 rings already loaded.
const SEAS = [
// Two anchors for the Arabian Sea: its true centre, and a NORTH-EAST point on
// the Indian west coast. The centre alone is west of most India crops, so
// coastal water on the page was attributed to a sea whose group never met the
// minimum-patch floor and went unlabelled.
{ nm: 'ARABIAN SEA', lng: 65, lat: 15 },
{ nm: 'ARABIAN SEA', lng: 71, lat: 19 },
{ nm: 'BAY OF BENGAL', lng: 89, lat: 15 },
{ nm: 'INDIAN OCEAN', lng: 78, lat: 0 },
{ nm: 'SOUTH CHINA SEA', lng: 114, lat: 14 },
{ nm: 'ATLANTIC OCEAN', lng: -20, lat: 5 },
{ nm: 'GULF OF GUINEA', lng: 2, lat: 1 },
{ nm: 'MEDITERRANEAN SEA', lng: 18, lat: 34 },
{ nm: 'RED SEA', lng: 38, lat: 20 },
{ nm: 'PACIFIC OCEAN', lng: 175, lat: -10 },
{ nm: 'CORAL SEA', lng: 155, lat: -17 },
];
const landRings = [];
for (const f of adm0.features || []) {
if (!bboxIntersect(bboxOf(f.geometry), viewBbox)) continue;
const g = f.geometry;
if (g.type === 'Polygon') landRings.push(g.coordinates[0]);
else if (g.type === 'MultiPolygon') for (const poly of g.coordinates) landRings.push(poly[0]);
}
for (const pool of [adm1Ind, adm1Chn].filter(Boolean)) {
for (const f of pool.features || []) {
if (!bboxIntersect(bboxOf(f.geometry), viewBbox)) continue;
const g = f.geometry;
if (g.type === 'Polygon') landRings.push(g.coordinates[0]);
else if (g.type === 'MultiPolygon') for (const poly of g.coordinates) landRings.push(poly[0]);
}
}
// ★ MOBILE PRINT SPEED: every ring carries its bbox, and a point outside it skips the vertex walk. Exact: outside the
// bbox the parity test is always false (no edge spans the latitude, or every crossing lies on one side of a closed
// ring). On a phone this scan was most of a country poster's time (China ~45 s at CPU x4).
const landBoxes = landRings.map(ringBox);
const onLand = (lng, lat) => landRings.some((ring, k) => {
const b = landBoxes[k];
if (lng < b[0] || lng > b[2] || lat < b[1] || lat > b[3]) return false;
let hit = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i], [xj, yj] = ring[j];
if ((yi > lat) !== (yj > lat) && lng < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) hit = !hit;
}
return hit;
});
// Find where each sea's water is ACTUALLY VISIBLE by scanning the frame, rather
// than trusting a gazetteer point. A fixed lng/lat fails twice over here: the
// Arabian Sea's centre (lng 65) is WEST of this crop entirely, and every sea's
// centre latitude projects below the frame — so both the point and a northward
// walk from it miss. Scanning finds the water that is on the page.
let seaNames = '';
{
const stepX = (viewBbox[2] - viewBbox[0]) / 26;
const stepY = (viewBbox[3] - viewBbox[1]) / 26;
// Nearest named sea wins the patch, so a label describes the water beside it.
const nearest = (lng, lat) => {
let best = null, bd = Infinity;
for (const S of SEAS) {
const d = Math.hypot(S.lng - lng, S.lat - lat);
if (d < bd) { bd = d; best = S; }
}
return best;
};
const used = new Set();
const patches = [];
for (let lng = viewBbox[0]; lng <= viewBbox[2]; lng += stepX) {
for (let lat = viewBbox[1]; lat <= viewBbox[3]; lat += stepY) {
const [px2, py2] = proj(lng, lat);
if (px2 < FRAME_X + 60 || px2 > FRAME_X + FRAME_W - 60) continue;
if (py2 < FRAME_Y + 40 || py2 > FRAME_Y + FRAME_H - 40) continue;
if (onLand(lng, lat)) continue; // after the frame test: a point outside the frame never needed it
patches.push({ lng, lat, px: px2, py: py2 });
}
}
// Group patches by their nearest sea and label each group once, at the patch
// furthest from land (approximated by the centre of that group's extent).
const groups = new Map();
for (const pt of patches) {
const S = nearest(pt.lng, pt.lat);
if (!S) continue;
if (!groups.has(S.nm)) groups.set(S.nm, []);
groups.get(S.nm).push(pt);
}
for (const [nm, pts] of groups) {
if (pts.length < 4) continue; // too little water to name
if (used.has(nm)) continue;
used.add(nm);
const mx = pts.reduce((a, q) => a + q.px, 0) / pts.length;
const my = pts.reduce((a, q) => a + q.py, 0) / pts.length;
seaNames += `<text x="${mx.toFixed(1)}" y="${my.toFixed(1)}" text-anchor="middle" class="sea-name">${esc(nm)}</text>`;
}
}
let provNames = '';
// Provinces de-overlap against the SAME reservation list the country names
// used, not a fresh one — otherwise a province name lands on top of a country
// name that was already placed. Country names are laid out first and win, which
// is the right precedence: a province is the finer detail of the two.
for (const L of adm1Labels) {
if (L.x < FRAME_X + 20 || L.x > FRAME_X + FRAME_W - 20) continue;
if (L.y < FRAME_Y + 20 || L.y > FRAME_Y + FRAME_H - 20) continue;
// 1.21 is MEASURED, not assumed: "Dādra and Nagar Haveli and Damān and Diu"
// renders 295px where textW predicts 244 — the advance table under-reports
// this run by ~21%, so an uncorrected guard let the overrun through.
const PROV_W_CORR = 1.21;
const pHalfW = textW(L.nm, 11.5) * PROV_W_CORR * 0.5 + 5, pHalfH = 7;
// THE WHOLE LABEL MUST FIT INSIDE THE FRAME, not just its anchor point.
// Only the anchor was bounds-checked, so a long name on a small subdivision
// near the edge ran out of the map and was sliced mid-word by the frame
// border, its tail crossing the leader lines. Measured on Central India:
// "Dādra and Nagar Haveli and Damān and Diu" is far wider than the union
// territory it names. A half-word ending at a hard edge is worse than no
// label — same principle as the country-name de-overlap.
if (L.x - pHalfW < FRAME_X + 4 || L.x + pHalfW > FRAME_X + FRAME_W - 4) continue;
if (placedLabels.some((q) => Math.abs(q.x - L.x) < pHalfW + q.halfW
&& Math.abs(q.y - L.y) < pHalfH + q.halfH)) continue;
placedLabels.push({ x: L.x, y: L.y, halfW: pHalfW, halfH: pHalfH });
const t = esc(L.nm);
provNames += `<text x="${L.x.toFixed(1)}" y="${L.y.toFixed(1)}" text-anchor="middle" class="prov-name-halo">${t}</text>`
+ `<text x="${L.x.toFixed(1)}" y="${L.y.toFixed(1)}" text-anchor="middle" class="prov-name${L.own ? ' own' : ''}">${t}</text>`;
}
// zone polygons (the region(s) this poster is about)
let zoneP = '';
for (const f of zoneFC.features) zoneP += `<path d="${geoToPath(f.geometry, proj)}" class="zone"/>`;
// labels (de-overlap, full-height even pitch, balanced L/R) + OCTILINEAR
// lane-routed leaders. Each side gets a band of vertical lanes between the map
// edge and the label column; each leader owns a distinct lane, so vertical
// runs never coincide. Geometry is symmetric and mirrored L/R.
// De-overlap the PINS FIRST, then lay out labels from the settled positions.
// Order matters and this is why: layoutLabels sorts each gutter by pin y, and
// that y-ordering (monotonicity) is the property that keeps leaders from
// crossing. Moving pins AFTER the sort silently invalidated it — measured 618
// crossings at f=0 where the ordering-preserving version measures 0.
const prePins = pins.map((p) => { const [lx, ly] = proj(p.lng, p.lat); return { ...p, _px: lx, _py: ly }; });
await __step('pins');
// ── PIN DE-OVERLAP (relaxation, pixel space, AFTER projection) ────────────
// The earlier version only caught EXACT pixel collisions, which missed the real
// problem: pins that land 2-3px apart still overlap visually and still read as
// one mark. The dense band through Chad/Cameroon piled into a solid mass.
//
// This is a repulsion relaxation instead: any two pins closer than MIN_SEP are
// pushed apart along the line between them, repeatedly, until the field settles.
// Displacement is capped so a pin never wanders far from its true location — the
// poster must stay honest about WHERE a people group is; it is only resolving
// marks that would otherwise be indistinguishable.
//
// This is what makes leaders viable again: leaders can only be routed without
// crossing if their endpoints are distinct, and the endpoints are these pins.
// A side-lane sheet keeps every pin at its TRUE position: co-located groups share one leader instead.
if (!FMT.sideLanes) {
// MIN_SEP MUST TRACK THE BADGE SIZE. It was a hardcoded 25 tuned against the
// old r=10.8 disc; leaving it fixed while the badge shrinks would keep pushing
// pins just as far apart and defeat the whole point of the smaller badge.
// 1.16x the diameter keeps a hairline of paper between neighbouring discs.
const MIN_SEP = PIN_R * 2 * 1.16;
// Drift cap raised: at 34px a tight knot of ~10 pins could not physically
// spread far enough to separate, so the relaxation stalled with them stacked.
// 58px still keeps a pin near its true location at this map scale.
const MAX_DRIFT = 58; // hard cap on how far a pin may move from truth
const ITERS = 260;
for (const it of prePins) { it._ox = it._px; it._oy = it._py; }
for (let pass = 0; pass < ITERS; pass++) {
let moved = false;
for (let i = 0; i < prePins.length; i++) {
for (let j = i + 1; j < prePins.length; j++) {
const a = prePins[i], b = prePins[j];
let dx = b._px - a._px, dy = b._py - a._py;
let dist = Math.hypot(dx, dy);
if (dist >= MIN_SEP) continue;
if (dist < 0.0001) { // exactly coincident — split on a
const ang = (i * 2.399963); // golden-angle spread, deterministic
dx = Math.cos(ang); dy = Math.sin(ang); dist = 1;
}
const push = (MIN_SEP - dist) / 2 + 0.15;
const ux = dx / dist, uy = dy / dist;
a._px -= ux * push; a._py -= uy * push;
b._px += ux * push; b._py += uy * push;
moved = true;
}
}
// Re-anchor: pull each pin back toward its true position if it has drifted
// past the cap, and keep everything inside the map rect.
for (const it of prePins) {
const dx = it._px - it._ox, dy = it._py - it._oy;
const d = Math.hypot(dx, dy);
if (d > MAX_DRIFT) { it._px = it._ox + (dx / d) * MAX_DRIFT; it._py = it._oy + (dy / d) * MAX_DRIFT; }
it._px = Math.max(MAP_X + 4, Math.min(MAP_X + MAP_W - 4, it._px));
it._py = Math.max(FRAME_Y + 4, Math.min(FRAME_Y + FRAME_H - 4, it._py));
}
if (!moved) break;
}
}
// Feed the settled pixel positions back as lng/lat-free overrides.
const settled = prePins.map((p) => ({ ...p, _fixed: true }));
await __step('side-lanes');
// ── SIDE LANES: a whole country's groups on one large sheet (FMT.sideLanes) ───────────────────────────
// Every name HORIZONTAL, on the left and right of the map, in two STAGGERED columns per side: the rows alternate
// inner/outer at half the line pitch, so each column keeps a full pitch and each row holds exactly ONE name.
// Co-located groups (within CL_R px) form a CLUSTER with ONE leader, a bracket and a block of names.
// CROSSING-FREE BY CONSTRUCTION, which the numbered design below could not reach at this density:
// · one vertical split (the weighted median x) sends each cluster to the nearer side, so runs never meet;
// · each leader is a short vertical stub to its own LANE, a horizontal run to the map edge, then a straight fan
// to its block; lanes are placed FARTHEST-FROM-THE-EDGE first, each clamped between its neighbouring lanes,
// so a stub never jumps a lane, and the fan keeps the lanes' top-to-bottom order (ordered points on two
// parallel lines, joined in order, cannot cross). The count is measured and returned, never assumed.
// A real photo (never the generic placeholder) sits on the OUTSIDE of its name when the format allows photos.
function sideLanesLayout(pinsIn) {
const X0 = MAP_X, X1 = MAP_X + MAP_W;
const BY0 = LABEL_TOP ?? FRAME_Y, BY1 = LABEL_BOT ?? (FRAME_Y + FRAME_H);
const PXI = PW / FMT.pageWidthIn;
const pins = pinsIn.map((p) => ({ ...p, px: p._px, py: p._py }));
// A photo only from the known people-group photo hosts, over https, and never the generic placeholder.
const PHOTO_HOSTS = /^https:\/\/(media\.joshuaproject\.net|joshuaproject\.net|images\.peoplegroups\.org|peoplegroupspublic\.blob\.core\.windows\.net)\//;
const photoOf = (p) => (FMT.photos && typeof p.image_url === 'string' && PHOTO_HOSTS.test(p.image_url) && !/no-photo-images/.test(p.image_url) ? p.image_url : null);
const CL_R = 14, clusters = [];
for (const p of pins) {
const c = clusters.find((q) => Math.hypot(q.x - p.px, q.y - p.py) <= CL_R);
if (c) { c.items.push(p); c.x += (p.px - c.x) / c.items.length; c.y += (p.py - c.y) / c.items.length; }
else clusters.push({ x: p.px, y: p.py, items: [p] });
}
for (const c of clusters) c.items.sort((a, b) => String(a.name).localeCompare(String(b.name), 'en'));
const byX = [...clusters].sort((a, b) => a.x - b.x);
let acc = 0, split = byX.length ? byX[0].x : 0;
for (const c of byX) { acc += c.items.length; if (acc >= pins.length / 2) { split = c.x; break; } }
for (const c of clusters) c.side = c.x <= split ? 'L' : 'R';
// THE TYPE: the largest size (capped by nameMaxPt) whose rows fit the band and whose two columns fit the gutter.
const FAN = 70, GAP = 10, PHOTO_GAP = 4;
const room = GUTTER - (SHEET_EDGE + 8) - FAN - GAP;
const photoPx = (f) => Math.floor(f * 1.18) - 1;
// ★ A PHOTO SITS IN A FIXED SLOT ON THE LEADER SIDE of its name, and its name moves out by the slot. A side-lane
// name is anchored at its leader-side end (left: text-anchor end; right: start), so its glyphs lie wholly on the
// far side of that anchor and the photo wholly on the near side: they cannot overlap whatever width the font
// draws. (Placing the photo from an ESTIMATED width put it on the first letters of half the 60x96 names,
// measured.) Only a name WITH a photo takes the slot, so the columns, and the type, keep their size.
const slotOf = (p, f) => (photoOf(p) ? photoPx(f) + PHOTO_GAP : 0);
const longest = (f) => Math.max(...pins.map((p) => textW(String(p.name), f) * 1.06 + slotOf(p, f)));
const lineCount = (sd) => clusters.filter((c) => c.side === sd).reduce((a, c) => a + c.items.length, 0);
const maxLines = Math.max(1, lineCount('L'), lineCount('R'));
let font = Math.min((FMT.nameMaxPt || 14) / 72 * PXI, (BY1 - BY0) / (maxLines * 0.59 * 1.02));
while (font > 5 && 2 * (longest(font) + 8) > room) font -= 0.1;
const ROW = font * 1.18 / 2, PHOTO = photoPx(font), COLW = longest(font) + 8;
// LANES, farthest from the edge first (see above). PIN_CLEAR: within its safe gap a lane prefers a height
// that passes no other pin, at most 7.5 px from its own pin.
const LANE_GAP = 1.5, PIN_CLEAR = 4.5;
let laneJumps = 0;
for (const sd of ['L', 'R']) {
const L = sd === 'L';
const mine = clusters.filter((c) => c.side === sd).sort((a, b) => (L ? b.x - a.x : a.x - b.x));
const lanes = [];
for (const c of mine) {
let lo = -Infinity, hi = Infinity;
for (const ly of lanes) { if (ly < c.y && ly > lo) lo = ly; if (ly >= c.y && ly < hi) hi = ly; }
let y = Math.min(Math.max(c.y, lo + LANE_GAP), hi - LANE_GAP);
if (lo + LANE_GAP > hi - LANE_GAP) { y = (lo + hi) / 2; laneJumps++; }
else {
const a0 = L ? X0 : c.x, a1 = L ? c.x : X1;
const blockers = clusters.filter((o) => o !== c && o.x > a0 + 1 && o.x < a1 - 1);
let best = y, bestHits = Infinity;
for (let t = Math.max(lo + LANE_GAP, c.y - 7.5); t <= Math.min(hi - LANE_GAP, c.y + 7.5); t += 0.75) {
const hits = blockers.filter((o) => Math.abs(o.y - t) < PIN_CLEAR).length;
if (hits < bestHits || (hits === bestHits && Math.abs(t - c.y) < Math.abs(best - c.y))) { best = t; bestHits = hits; }
}
if (bestHits !== Infinity) y = best;
}
c.lane = y; lanes.push(y);
}
}
// BLOCKS, stacked top to bottom in lane order, each as near its lane as the rows allow.
const rowsAvail = Math.floor((BY1 - BY0) / ROW);
let leadersS = '', labelsS = '', dotsS = '', overflow = 0;
const placed = [];
for (const sd of ['L', 'R']) {
const L = sd === 'L';
const blocks = clusters.filter((c) => c.side === sd).sort((a, b) => a.lane - b.lane)
.map((c) => ({ c, n: c.items.length, want: (c.lane - BY0) / ROW }));
const pos = []; let cur = 0;
for (const b of blocks) { const q = Math.max(cur, Math.round(b.want - b.n / 2)); pos.push(q); cur = q + b.n; }
let over = cur - rowsAvail;
for (let k = pos.length - 1; k >= 0 && over > 0; k--) {
const lim = k ? pos[k - 1] + blocks[k - 1].n : 0, can = Math.min(over, pos[k] - lim);
for (let j = k; j < pos.length; j++) pos[j] -= can;
over -= can;
}
overflow += Math.max(0, over);
const edgeX = L ? X0 : X1, portX = L ? X0 - FAN : X1 + FAN;
const innerX = L ? portX - 6 : portX + 6, outerX = L ? innerX - COLW : innerX + COLW;
blocks.forEach((b, k) => {
const c = b.c, r0 = pos[k], midY = BY0 + (r0 + b.n / 2) * ROW;
leadersS += `<path class="slead" d="M${c.x.toFixed(1)} ${c.y.toFixed(1)}L${c.x.toFixed(1)} ${c.lane.toFixed(2)}L${edgeX.toFixed(1)} ${c.lane.toFixed(2)}L${portX.toFixed(1)} ${midY.toFixed(1)}"/>`;
if (b.n > 1) leadersS += `<path class="sbr" d="M${portX.toFixed(1)} ${(BY0 + r0 * ROW + 1).toFixed(1)}V${(BY0 + (r0 + b.n) * ROW - 1).toFixed(1)}"/>`;
dotsS += `<circle class="spin" cx="${c.x.toFixed(1)}" cy="${c.y.toFixed(1)}" r="${b.n > 1 ? 4.4 : 3.2}"/>`;
c.items.forEach((g, j) => {
const r = r0 + j, rowY = BY0 + (r + 0.5) * ROW, x = r % 2 === 0 ? innerX : outerX;
if (r % 2 === 1) leadersS += `<path class="srow" d="M${innerX.toFixed(1)} ${rowY.toFixed(1)}H${(L ? outerX + 3 : outerX - 3).toFixed(1)}"/>`;
const slot = slotOf(g, font), tx = L ? x - slot : x + slot;
labelsS += `<text class="snm" x="${tx.toFixed(1)}" y="${(rowY + font * 0.34).toFixed(1)}" font-size="${font.toFixed(2)}"${L ? ' text-anchor="end"' : ''}>${esc(String(g.name))}</text>`;
const ph = photoOf(g);
if (ph) {
const px = L ? x - PHOTO : x;
labelsS += `<image href="${esc(ph)}" x="${px.toFixed(1)}" y="${(rowY - PHOTO / 2).toFixed(1)}" width="${PHOTO}" height="${PHOTO}" preserveAspectRatio="xMidYMid slice"/>`;
}
placed.push({ g, side: sd, r, photo: !!ph });
});
});
}
// MEASURED, not assumed: leader crossings (every segment pair of different leaders).
const polys = [...leadersS.matchAll(/class="slead" d="M([^"]+)"/g)].map((m) => m[1].split('L').map((q) => q.trim().split(' ').map(Number)));
const segsOf = polys.map((pl) => pl.slice(1).map((q, k) => [pl[k], q]));
const sx = (p, q, r) => (r[1] - p[1]) * (q[0] - p[0]) - (q[1] - p[1]) * (r[0] - p[0]);
const segX = (a, b) => (sx(a[0], a[1], b[0]) > 0) !== (sx(a[0], a[1], b[1]) > 0) && (sx(b[0], b[1], a[0]) > 0) !== (sx(b[0], b[1], a[1]) > 0);
let crossingsS = 0;
for (let i = 0; i < segsOf.length; i++) for (let j = i + 1; j < segsOf.length; j++) if (segsOf[i].some((a) => segsOf[j].some((b) => segX(a, b)))) crossingsS++;
const stats = { names: placed.length, pins: pins.length, clusters: clusters.length, crossings: crossingsS, overflowRows: overflow,
laneJumps, photos: placed.filter((p) => p.photo).length, namePt: +(font / PXI * 72).toFixed(1), photoMm: FMT.photos ? +(PHOTO / PXI * 25.4).toFixed(1) : 0 };
return { leaders: leadersS, labels: labelsS, pinDots: dotsS, crossings: crossingsS, pins, stats };
}
const laid = FMT.sideLanes ? [] : layoutLabels(settled, proj);
const sides = FMT.sideLanes ? sideLanesLayout(settled) : null;
// What the city layer must keep clear of: the drawn pins (laid out, or true positions on a side-lane sheet).
const avoidPins = sides ? sides.pins : laid;
await __step('cities');
// ── 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 avoidPins) {
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
// still leaving room to route a short leader per label.
// LABEL_RES is the width available to the NAME. It must fit inside the gutter
// minus the lane band minus the sheet margin, or names run off the paper —
// "Jarkhandi Julaha" and "Khangar (Bundeli)" were both clipped at the edges.
const SHEET_MARGIN = SHEET_EDGE + 8;
const LANE_EDGE_GAP = 6, LABEL_GAP = 6, LANE_PITCH_MAX = 3;
const LABEL_RES = GUTTER - SHEET_MARGIN - LANE_EDGE_GAP - 24;
const sideItems = { L: laid.filter((i) => i.side === 'L'), R: laid.filter((i) => i.side === 'R') };
// assign each label its own vertical lane (distinct margin channel), symmetric L/R
for (const side of ['L', 'R']) {
const col = sideItems[side]; // already sorted by py (place())
const m = col.length || 1;
const dirOut = side === 'L' ? -1 : 1;
const edge = side === 'L' ? MAP_X : MAP_X + MAP_W;
const band = MAP_X - LABEL_RES - LANE_EDGE_GAP - LABEL_GAP; // lane band width (margins are symmetric)
const pitch = Math.min(LANE_PITCH_MAX, band / Math.max(1, m - 1));
col.forEach((it, k) => {
it.laneX = edge + dirOut * (LANE_EDGE_GAP + k * pitch);
it.dirOut = dirOut;
it.tanchor = side === 'L' ? 'end' : 'start';
});
}
// route every leader at a given diagonal-fraction (all octilinear by construction)
const routesFor = (d) => laid.map((it) => octiRoute(it, it.laneX, it.dirOut, d));
// sweep dfrac; keep the minimal-crossing octilinear layout for THIS poster.
// ties → prefer the smaller dfrac (cleaner 90° grid, less diagonal drift).
let bestD = 0, bestX = Infinity;
// po-LEADERS (L-shaped) ONLY. Barth/Gemsa/Niedermann/Nollenburg (GD 2015,
// Information Visualization 2019) ran the first controlled readability study on
// BOUNDARY LABELING — labels in margins joined to points by leaders, which is
// exactly this problem. Four leader types were tested; "po" (a segment parallel
// to the labeled side, then one orthogonal segment) won on BOTH performance and
// preference. Straight diagonals ("s") rated fast but weaker; S-shaped ("opo")
// was explicitly not recommended.
// The sweep is therefore capped at pure-orthogonal, with one small 0.15 fallback
// kept because planarity is a HARD constraint — a single crossing damages the
// layout more than a slight bend does.
// Sweep prefers the LOWEST d (most L-shaped) that does not cost crossings.
// Planarity outranks leader-shape: the study ranks po highest for readability,
// but a crossed layout is unreadable regardless of segment geometry, and Barth
// et al. treat non-crossing as a precondition, not a preference.
for (const d of [0]) {
const x = countPolyCrossings(routesFor(d));
if (x < bestX) { bestX = x; bestD = d; }
}
const routes = routesFor(bestD);
let leaders = '', dots = '', labels = '', pinDots = '';
let octiOK = true;
await __step('names');
// ── NUMBERED-LEGEND MODE (dense posters) ──────────────────────────────────
// A previous builder PROVED the leader design is geometrically contradictory,
// not badly tuned: "labels evenly filling the margin" + "non-horizontal leaders"
// + "zero crossings" + "pins at true coords" cannot all hold at once. Measured
// on these exact posters, crossings rise monotonically the moment any slant is
// added — 0 at f=0, then 166 / 280 / 348 / 458 / 573 on west-africa. No sweet
// spot; Bezier curves were tried and cross harder still.
//
// So above a density threshold we stop drawing leaders entirely: pins carry a
// NUMBER at their true coordinate, and the margins carry "N. Name". Zero
// crossings by construction, no horizontal banding, and the margins still fill
// eloquently. This is the standard cartographic answer to dense labelling.
// DENSE_AT — above this pin count, drop leaders and number the pins instead.
// Measured on THIS code: leaders cross 334-622 times on west-africa at every
// slant value including f=0. The prior builder's "0 crossings at f=0" describes
// a code state that is NOT what is on disk — that fix was reverted or never
// landed here. So numbering is the only variant that is actually planar today.
// ONE TEMPLATE, ONE PATH. This was `laid.length >= 40`, so any zone with fewer
// than 40 pins fell through to a legacy branch with NO numbered badges, NO
// straight leaders and none of the current label treatment: Southern Africa
// and the Scattered/Diaspora map drew without straight lines and broke the
// template.
// Exactly three zones were affected — uncovered-scatter (6), deaf (28),
// southern-africa-islands (39) — and all three sat just under the threshold.
// A sparse zone is not a different KIND of poster; it is the same poster with
// fewer pins, so it takes the same path.
const numbered = true;
if (numbered) {
// Number in READING ORDER down each gutter, so a reader scanning the list
// moves top-to-bottom and the numbers on the map ascend with latitude.
laid.forEach((it, i) => { it.num = i + 1; });
// Every row reserves the same number-column width, sized to the largest index
// on the sheet, so the name columns line up and no width is wasted.
const MAXNUM = laid.length;
laid.forEach((it) => { it.maxNum = MAXNUM; });
laid.forEach((it) => {
// THE BADGE MUST FIT ITS NUMBER. Radius and type were both fixed, so on a
// zone with 100+ groups a three-digit index overflowed its circle — the
// digits spilled out and read as stray marks on the map. Both now scale
// with the digit count: the badge grows a little, the type shrinks a little.
// BADGE SIZE IS A TEMPLATE PARAMETER, not a constant.
// Large badges pushed pins far from their true location, outside the
// province and apparently into the ocean; a smaller badge lets each pin sit
// closer to its true position.
//
// Measured on Tamil Nadu: 64 mainland pins in a 219x312px area need 51%
// occupancy at r=10.8 / MIN_SEP 25 — high enough that the de-collision pass,
// not geography, sets the layout. At r=8 that falls to 29%, so each pin can
// sit much nearer its true coordinate.
//
// The sheet is BOTH a 3ft print and a blog thumbnail, so this is the floor
// of what stays legible rather than the smallest that would fit.
// `zone.pinScale` tunes it per zone; default serves every map.
// SIZE THE NUMERAL FROM WHAT MUST FIT, not from a flat multiple of the
// radius. `rad * 1.2` produced ~9.6px digits inside a 16px disc — they
// overflowed and read as smudges. The digits must fit INSIDE the circle:
// the usable chord across a disc of radius r, allowing a little padding,
// is about 1.5r, so solve for the size whose measured width fits that.
const nd = String(it.num).length;
const rad = PIN_R * (nd >= 3 ? 1.17 : 1);
// SIZE THE NUMERAL FROM MEASURED DIGIT WIDTH, NOT THE ADVANCE TABLE.
// CHADV gives digits 0.63em, but Lato BOLD tabular digits actually render
// at ~0.98em — a 55% under-prediction, which is exactly why the numeral
// overflowed its circle. Measured on the render: "118" at font 9.2 came out
// 27.1px wide inside an 18.72px disc (8.4px of spill), and even a single
// digit's line box (17px) exceeded a 16px diameter.
//
// A circle's usable width at the glyphs' own height is NOT the full
// diameter — text is centred on its baseline, not the disc's centre line —
// so reserve DIGIT_PAD and size from what genuinely fits.
const DIGIT_EM = 0.98; // measured, bold tabular
const DIGIT_PAD = 3.0; // keeps glyphs off the rim
const dia = rad * 2;
const nfs = Math.min(
dia * 0.62, // vertical ceiling: cap-height must fit
(dia - DIGIT_PAD) / (String(it.num).length * DIGIT_EM)
);
// Stamped with the TRUE source coordinate for the same reason as the plain
// pin below: the de-overlap relaxation moves marks apart, so drawn cx/cy
// cannot distinguish "several places" from "one place drawn several times".
// The NUMBERED badge is the path the city posters actually take.
pinDots += `<circle cx="${it.px.toFixed(1)}" cy="${it.py.toFixed(1)}" r="${rad}" class="pin-num-bg"`
+ ` data-lng="${Number(it.lng).toFixed(4)}" data-lat="${Number(it.lat).toFixed(4)}"/>`
+ `<text x="${it.px.toFixed(1)}" y="${(it.py + nfs * 0.355).toFixed(1)}" text-anchor="middle" font-size="${nfs}" class="pin-num">${it.num}</text>`;
// SHORT CONNECTOR from the label out to its lane. The brief calls for the line
// back — but a full pin-to-label leader is what crossed 600+ times. A stub
// gives the eye the same "this name belongs to that row" cue with zero
// crossings, because every stub is horizontal and lives in its own lane.
// UNDERLINE-INTO-CURVE (design, designer call). One continuous stroke:
// a straight rule beneath the name — which reads as an underline — then a
// smooth curve peeling out of it toward the numbered pin.
//
// Why this beats a bare leader: a straight line arriving at a name has to
// point at some arbitrary spot on the text. An underline gives the stroke a
// legitimate ORIGIN — it belongs to the word — and visually binds the name
// and its line into one object.
//
// The curve is a cubic whose FIRST control point continues the underline's
// direction. That makes the tangent at the join horizontal, so the underline
// flows into the curve with no visible kink (C1 continuity, not just C0).
const textEdge = it.dirOut < 0 ? SHEET_MARGIN + LABEL_RES : PW - SHEET_MARGIN - LABEL_RES;
// The text is drawn at labelY + font*0.34, so the BASELINE is there and the
// underline must clear the descenders below it. 0.44 put the rule through the
// lower third of the glyphs — it read as a strikethrough, not an underline.
// THE UNDERLINE FOLLOWS THE WRAP: it goes under the wrapped name and never
// cuts it in half. With a two-line name the rule has to
// drop by one line height or it strikes through the second line.
const nLines = (it.wrap || [it.name]).length;
const ulY = it.labelY + it.font * 0.34 + it.font * 0.30 + (nLines - 1) * (it.font * 1.06);
// THE UNDERLINE MUST SIT UNDER THE TEXT, and the text runs AWAY from the map:
// the left column is text-anchor="end" so it extends LEFT from tx, the right
// column is "start" so it extends RIGHT. Both therefore run in the -dirOut
// direction. The previous version extended the underline by +dirOut, i.e.
// TOWARD the map — so on both columns the rule was drawn on the empty side of
// the label and underlined nothing at all.
// The text anchor point. Declared HERE because the underline geometry below
// derives from it — leaving it further down put it in the temporal dead zone.
const tx = it.dirOut < 0 ? SHEET_MARGIN + LABEL_RES : PW - SHEET_MARGIN - LABEL_RES;
// MEASURED, not assumed. Text for a left-column label is anchored "end" at
// tx, so it occupies [tx - width, tx]; a right-column label is "start" and
// occupies [tx, tx + width]. The underline must span EXACTLY that, then the
// leader leaves from the MAP-FACING end of it.
// Previous attempts derived the span from dirOut and put the rule 56px clear
// of the text on the wrong side — measurement showed text at 110-181 while
// the underline ran 237->176.
// Width estimate covers the "12. " index prefix AND the name. Measured at
// 0.52em the rule fell 16px short of the text start (text 88-181, rule
// 104-177) — the prefix was unaccounted for. 0.56em plus a wider constant
// spans the whole label.
// PER-CHARACTER width, not a flat character count. A count-based estimate
// over- or under-shoots by up to 11px depending on which letters a name
// happens to contain — "3. Raji" (narrow i/j) got a rule 6px wider than its
// text while "5. Bhand" got one 7px narrower. Measured overhang ranged -2 to
// +11px, which is exactly the disproportionate look reported.
// Lato advance widths as a fraction of em, bucketed. Good to ~2% — far
// tighter than a flat 0.56 for every glyph.
const ADV = (ch) => {
if ('iIjl|.,:;\'!'.includes(ch)) return 0.26;
if ('ftr()[]-'.includes(ch)) return 0.34;
if (' '.includes(ch)) return 0.26;
if ('mwMW'.includes(ch)) return 0.86;
if ('ABCDEFGHKLNOPQRSTUVXYZ0123456789'.includes(ch)) return 0.63;
return 0.53; // ordinary lowercase
};
const labelText = `${it.num}. ${it.name}`;
// Bucket values measured slightly low across the board (median -12px). A
// uniform 1.08 correction plus a 3px pad lands the rule on the glyph edges.
// Span the LAST rendered line — that is the one the rule sits beneath.
// On a wrapped name the last line has NO "12. " prefix and is indented, so
// measuring the whole label would draw a rule wider than the text above it.
// The NAME now lives in its own element, inset by the number column, so the
// rule spans just the last line of the name — the number is never underlined.
const lastLine = (it.wrap || [it.name])[nLines - 1];
const measured = [...lastLine].reduce((a, ch) => a + ADV(ch), 0) * it.font * 1.08 + 3;
const nameW = Math.min(LABEL_RES - 18, measured);
// A wrapped last line starts at the HANGING INDENT, not at the number
// column, so the rule under it must start there too.
// Everything is measured from the NAME COLUMN's edge (one number-column in
// from the map-facing edge), not from tx, since the name starts there now.
const ulNumColW = textW(`${it.maxNum}.`, it.font) * 1.06 + it.font * 0.42;
const ulNameEdge = it.dirOut < 0 ? tx - ulNumColW : tx + ulNumColW;
const ulTextStart = it.dirOut < 0 ? ulNameEdge - nameW : ulNameEdge + nameW;
// RUN THE RULE OUT TO THE WHITE BORDER: each people-group rule spans
// outward and connects to the white border.
// It used to stop at the start of the name, so every rule ended at a
// different x (names are ragged) and the outer edge of both columns looked
// unresolved. Extending to SHEET_EDGE gives all 73 a shared outer terminus
// against the border — the same trick that fixed the number column, applied
// to the rules: one hard edge instead of a ragged one.
const ulOuter = it.dirOut < 0 ? SHEET_EDGE : PW - SHEET_EDGE;
const ulInner = tx + it.dirOut * 3; // under the map-facing end
// Stop short of the disc so the stroke meets the pin without crossing the numeral.
const dx = it.px - ulInner, dy = it.py - ulY;
const len = Math.hypot(dx, dy) || 1;
const ex = it.px - (dx / len) * 12.5, ey = it.py - (dy / len) * 12.5;
// UNDERLINE + STRAIGHT RUN (design call, 2026-07-31). The curve was tested
// and rejected: straight lines are easier to follow, and 92 curves reading
// against each other made the sheet busier rather than calmer. The underline
// stays — it gives the stroke a legitimate origin on the word — and the run
// to the pin is now a single straight segment out of its inner end.
const dpath = `M${ulOuter.toFixed(1)} ${ulY.toFixed(1)} L${ulInner.toFixed(1)} ${ulY.toFixed(1)} `
+ `L${ex.toFixed(1)} ${ey.toFixed(1)}`;
leaders += `<path d="${dpath}" class="lead-halo"/><path d="${dpath}" class="lead"/>`;
// tx is hoisted above the underline block — it is used there.
// MEASURE WHAT IS ACTUALLY DRAWN. Two independent bugs lived here:
// 1. the estimate used an uncalibrated 0.5em advance (real: ~0.498 for
// REGULAR Lato, and BOLD is wider again), so it under-reported; and
// 2. it measured `it.name` while the element renders `"36. " + name`.
// A flat `- 26` reserve was standing in for that prefix, but the prefix
// scales with the type: at 16.8px bold it costs ~36px, not 26.
// Both together let "36. Khangar (Bundeli)" render 209.5px into a 184px
// column — off the sheet edge. Measure the WHOLE string, calibrated.
const anchor = it.tanchor;
// WRAP, DO NOT SHRINK. Long names used to be scaled down to fit the column,
// which made every row a different size — measured 10.6px to 19.0px on this
// sheet, a 79% spread, with "53. Vellaiyan Kuppam Padaiyaci" the smallest.
// The goal: every people-group name and number at exactly the same font
// size, with word wrapping supported.
//
// So the type size is now UNIFORM across the whole sheet and a name that
// does not fit takes a second line instead. it.wrap carries the lines.
// HANGING INDENT (it reads better): the number sits in its own column and
// continuation lines align under the NAME, not under the number.
const lines = it.wrap || [it.name];
// ── THE NUMBER IS ITS OWN ELEMENT, PINNED TO THE MAP-FACING EDGE ────────
// The numbers were not aligned. Putting the number on the map-facing side of
// the name lines every number up.
//
// Why the numbers were ragged: the label is ONE text element anchored at the
// column's outer edge ("end" on the left column). Its first line is
// "12. Name", so the number's x depends on how wide THAT LINE happens to be
// — every row a different width, so every number a different position. It
// only looked aligned before wrapping because the outer edge was the edge.
//
// Splitting the number out fixes it by construction: it is anchored at the
// column's MAP-FACING edge, which is the same x for every row, so the whole
// index column reads as a straight rule. The name is then a separate element
// that grows away from the map and wraps freely without ever moving a number.
const numGap = it.font * 0.42;
const numX = tx; // map-facing edge, shared
const nameX = tx - it.dirOut * 0 + (it.dirOut < 0 ? 0 : 0);
// The name block starts one number-column in from the map-facing edge.
// Reserve for the LARGEST index actually on this sheet, not a fixed three
// digits. A 73-pin zone was giving up 40px to a hypothetical "000." when
// "73." needs 29 — 11px of name width thrown away on every row.
const numColW = textW(`${it.maxNum}.`, it.font) * 1.06 + numGap;
const nameEdge = it.dirOut < 0 ? tx - numColW : tx + numColW;
labels += `<text x="${numX.toFixed(1)}" y="${(it.labelY + it.font * 0.34).toFixed(1)}" `
+ `text-anchor="${it.dirOut < 0 ? 'end' : 'start'}" font-size="${it.font.toFixed(1)}" class="lnum-col">${it.num}.</text>`;
labels += `<text x="${nameEdge.toFixed(1)}" y="${(it.labelY + it.font * 0.34).toFixed(1)}" text-anchor="${anchor}" font-size="${it.font.toFixed(1)}" class="lbl">`
+ lines.map((ln, k) => k === 0 ? esc(ln)
: `<tspan x="${nameEdge.toFixed(1)}" dy="${(it.font * 1.06).toFixed(1)}">${esc(ln)}</tspan>`).join('')
+ `</text>`;
});
octiOK = true;
} else laid.forEach((it, i) => {
const pts = routes[i];
if (!isOctilinear(pts)) octiOK = false;
leaders += `<path d="M${pts.map((p) => `${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' L')}" class="lead"/>`;
// Stamp the TRUE source coordinate alongside the drawn position. The pin
// de-overlap relaxation deliberately moves pins apart, so cx/cy cannot tell a
// verifier whether two marks are two places or one place drawn twice. Several
// city sheets pin multiple people groups at one gazetteer centroid; without
// this, a coincident-pin sheet is indistinguishable from an over-zoomed one.
pinDots += `<circle cx="${it.px.toFixed(1)}" cy="${it.py.toFixed(1)}" r="3.6" class="pin"`
+ ` data-lng="${Number(it.lng).toFixed(4)}" data-lat="${Number(it.lat).toFixed(4)}"/>`;
dots += `<circle cx="${it.laneX.toFixed(1)}" cy="${it.labelY.toFixed(1)}" r="1.6" class="ldot"/>`;
const tx = it.laneX + it.dirOut * 5;
labels += `<text x="${tx.toFixed(1)}" y="${(it.labelY + it.font * 0.34).toFixed(1)}" text-anchor="${it.tanchor}" font-size="${it.font.toFixed(1)}" class="lbl">${esc(it.name)}</text>`;
});
if (sides) { leaders += sides.leaders; pinDots += sides.pinDots; labels += sides.labels; }
if (!octiOK) console.warn(` ⚠ non-octilinear segment in ${zone.zoneKey}`);
// routes = routesFor(bestD), whose crossings the sweep just counted (bestX): reuse it rather than count twice.
const crossings = sides ? sides.crossings : (Number.isFinite(bestX) ? bestX : countPolyCrossings(routes));
const diagFrac = bestD;
await __step('header');
// ── TITLE vs SUBTITLE ──────────────────────────────────────────────────────
// A TITLE IS NEVER A LIST. The India/China lists carry labels like "Rajasthan +
// Haryana + Himachal Pradesh + Jammu and Kashmir + Punjab + Chandigarh + Ladakh"
// (89 chars) — as a single SVG <text> that ran off BOTH edges of the sheet,
// because SVG text does not wrap and the element is centre-anchored.
// So: a SHORT functional title, with the full enumeration moved to a wrapped
// subtitle beneath it where a reader can still find the specifics.
const fullLabel = (zone.label || zone.id).replace(/&/g, '&amp;');
const parts = fullLabel.split(' + ').map((x) => x.trim()).filter(Boolean);
const isList = parts.length > 1;
// Name the parent region from the zone id, so "asia-india-rajasthan" titles as
// "India" rather than inventing a name from the province list.
const parentOf = (id) =>
id.startsWith('asia-india-') ? 'India'
: id.startsWith('asia-china-') ? 'China'
: null;
const parent = parentOf(zone.zoneKey || zone.id || '');
// Named clusters, not "India · 6 Provinces". A poster on a wall wants a NAME a
// person can hold, and "6 Provinces" is a count, not a name. Keyed by zone id so
// each cluster gets the name its region is actually known by.
// Nepal is a SOVEREIGN COUNTRY, not a part of North India — naming the cluster
// "North India" while colouring Nepali land inside it would misrepresent a
// nation. The title names both.
// (The hand-coded CLUSTER_NAME map is gone. Region names now live in
// clusters.json `label`, where they belong as data — two zones had been
// special-cased in code while the other eight fell back to a generic
// "India · N Provinces". See provinceLabel for the enumeration they replaced.)
const CLUSTER_NAME = {};
// THE LABEL IS THE TITLE. Once `label` became an authored REGION NAME, the
// "collapse a long list into 'India · N Provinces'" fallback became actively
// wrong: "Southeast India + Andaman Islands" contains a "+", so it was treated
// as an enumeration and collapsed back into the generic form the rename was
// meant to remove. The collapse now only applies to a label that is STILL a raw
// province enumeration — i.e. one with no provinceLabel recorded beside it.
const labelIsRegionName = Boolean(zone.provinceLabel) || parts.length <= 2;
const title = CLUSTER_NAME[zone.zoneKey || zone.id]
|| (labelIsRegionName || !isList ? fullLabel
: parent ? `${parent} \u00b7 ${parts.length} Provinces`
: `${parts[0]} + ${parts.length - 1} more`);
// The subtitle carries the enumeration, wrapped across lines that fit the sheet.
// ── HEADER BLOCK (design spec) ─────────────────────────────────────────────
// Countries: India · Nepal
// Provinces: Uttarakhand · Uttar Pradesh · ...
// 92 people groups · pray for the harvest · Luke 10:2
//
// Left-justified as a LABELLED LIST rather than centred prose: the labels stack
// on a common left edge so "Countries" and "Provinces" align and the eye reads
// down one column. The label is bold; the values are not, so the structure is
// visible without the values shouting.
// COUNTRIES = the countries the sheet COVERS, which is not only the countries
// its pins happen to sit in. A zone can declare a whole country as a member
// (zone.adm0) whose land is drawn and highlighted even when it carries no
// people groups of its own — Mongolia on the Mainland-China sheet. Deriving
// this column from the pins alone silently dropped those, so the title said
// "+ Mongolia" while the Countries column said only "China".
const ctryNamesSet = [...new Set([
...pins.map((p) => ISO3_NAME[p.country_code] || p.country_code),
...(Array.isArray(zone.adm0) ? zone.adm0.map((c) => ISO3_NAME[c] || c) : []),
].filter(Boolean))].sort((a, b) => a.localeCompare(b, 'en-US')); // pinned, as above
// Provinces come from their OWN field. Deriving them by splitting `label` broke
// the moment the label was renamed to a cluster name — the enumeration is data,
// not a formatting artefact of the title, and needed somewhere to live.
// The label is now a REGION NAME ("Southeast India + Andaman Islands"), so it no
// longer enumerates provinces. clusters.json preserves the old enumeration in
// `provinceLabel` — use it so the facts table keeps naming the actual provinces.
const provinceList = provincesOf(zone);
const wrapTo = (items, maxch) => {
const out = [];
let line = '';
for (const it of items) {
const next = line ? `${line} \u00b7 ${it}` : it;
if (next.length > maxch) { out.push(line); line = it; } else { line = next; }
}
if (line) out.push(line);
return out;
};
const HEAD_MAXCH = 78;
const ctryLines = wrapTo(ctryNamesSet, HEAD_MAXCH);
const provLines = wrapTo(provinceList, HEAD_MAXCH);
// Built as absolute rows so the label column and the value column share one
// left edge no matter how many lines each list wraps to.
const HEAD_X = GUTTER;
// Wider label column: "Provinces:" needs the room, and a consistent gutter
// between key and value is what makes the two rows read as a table rather than
// two sentences that happen to start near each other.
// ── THE HEADER IS CENTRED ON THE SHEET'S AXIS ──────────────────────────────
// Everything above the map was flush-left while the map below it was centred —
// two alignments fighting on one sheet, which is why the top read as "hanging
// off the left". Title, table and prose now share the sheet's axis (CX).
const CX = PW / 2;
// ── THE FACTS TABLE ────────────────────────────────────────────────────────
// A centred table needs fixed internal geometry or the rows wander: keys are
// RIGHT-aligned to a seam and values LEFT-aligned to it, so the colon gutter
// forms one clean vertical seam. That seam — not a box or heavy borders — is
// what makes it read as a table while staying quiet enough to be beautiful.
// The h1 baseline is y=104 at 56px, so its descenders reach ~118 — starting the
// header flow at 118 printed the series line straight through the title. 136
// clears it.
let headY = SPARSE || HEAD_COLUMNS ? 136 : 150;
let headBlock = '';
// THE SERIES LINE, in the header FLOW so it can never collide with the rule
// below it. The site reference was wrapped into the scripture header, where it
// does not belong.
if (SPARSE) {
// ONE LINE, not two, to save the space under the title, which was too
// generous. The site reference folds in after a separator rather
// than taking a line of its own.
headBlock += `<text x="${CX}" y="${headY}" text-anchor="middle" class="hseries">`
+ `Series <tspan class="hseries-em">\u201c${esc(SERIES_NAME)}\u201d</tspan>`
// ★ SMALL-TOTAL GUARD. The total is rounded to ~tens because a series of 28 or 173 is
// read as a size, not counted. Below ten that rounding breaks: a one-member tier --
// China, the only 3x4 country sheet -- rounds 1 to ~0 and would print "1 of ~0 maps".
// So under ten the line states the exact count, "map 1 of 1", the same text the
// dense layout already prints. Every real series is unaffected: 28 -> ~30, 173 -> ~170.
+ (SERIES.totalLists < 10
? ` \u2014 map ${SERIES.index} of ${SERIES.totalLists}`
: ` \u2014 ${SERIES.index} of ~${Math.round(SERIES.totalLists / 10) * 10} maps covering unengaged people groups in prayer`)
+ ` \u00b7 <tspan class="hseries-em">https://Doxa.Life</tspan>`
+ `</text>`;
headY += 12;
}
// In SPARSE the facts move BELOW the scripture as a real 4-column table —
// see THE FACTS TABLE further down. Nothing is emitted here. With HEAD_COLUMNS they are the first column of
// the band under the scripture (headColumnsBand).
if (!SPARSE && !HEAD_COLUMNS) {
// ── THE FACTS TABLE (full version) ───────────────────────────────────────
// A centred table needs fixed internal geometry or the rows wander: keys are
// RIGHT-aligned to a seam and values LEFT-aligned to it, so the colon gutter
// forms one clean vertical seam. That seam — not a box or heavy borders — is
// what makes it read as a table while staying quiet enough to be beautiful.
const SEAM = CX - 30;
const KEY_X = SEAM - 14;
const VAL_X = SEAM + 14;
const TABLE_TOP = headY - 18;
const headRow = (label, lines) => {
if (!lines.length) return;
headBlock += `<text x="${KEY_X}" y="${headY}" text-anchor="end" class="hkey">${label}</text>`;
for (const ln of lines) {
headBlock += `<text x="${VAL_X}" y="${headY}" text-anchor="start" class="hval">${esc(ln)}</text>`;
headY += 23;
}
};
headRow('Countries', ctryLines);
headRow('Administrative Areas', provLines);
headRow('People groups', [`${pins.length} UPG`]);
headRow('Series', [`${SERIES_NAME} · map ${SERIES.index} of ${SERIES.totalLists}`]);
// Hairline rules top and bottom close the table without boxing it in.
const TABLE_BOT = headY - 12;
const TABLE_HALF = 320;
headBlock += `<line x1="${SEAM - TABLE_HALF}" y1="${TABLE_TOP}" x2="${SEAM + TABLE_HALF}" y2="${TABLE_TOP}" class="hrule"/>`;
headBlock += `<line x1="${SEAM - TABLE_HALF}" y1="${TABLE_BOT}" x2="${SEAM + TABLE_HALF}" y2="${TABLE_BOT}" class="hrule"/>`;
headY = TABLE_BOT;
}
// THE COMPASSION VERSE — given the SAME treatment as the goal question below
// the map: one full-map-width line, ruled above and below, so the scripture up
// top spans the full width of the map too. The two rules-and-a-line blocks now
// bracket the sheet symmetrically — scripture at the top, the question at the
// bottom, the map between them.
// THE SCRIPTURE + FACTS BAND SPANS THE FULL SHEET, not the map's width.
// The table spans the full width of the poster.
// It was pinned to GUTTER (the MAP's edge), so as the gutters widened to give
// the names room, this band narrowed with them — it is header information about
// the whole sheet, not a caption belonging to the map, so it should use the
// sheet's own margin instead.
const VRULE_L = BAND_MARGIN_G, VRULE_R = PW - BAND_MARGIN_G;
headY += 16;
headBlock += `<line x1="${VRULE_L}" y1="${headY}" x2="${VRULE_R}" y2="${headY}" class="hrule"/>`;
headY += 25;
const VERSE = `\u201cBut when he saw the multitudes, he was moved with compassion for them because they were harassed and scattered, like sheep without a shepherd.\u201d`;
if (HEAD_COLUMNS) {
// At the full sheet's print size the verse is set on TWO balanced lines (one line at a readable size would
// run past the band); the reference ends the second line.
const [l1, l2] = VERSE_LINES(VERSE);
headBlock += `<text x="${CX}" y="${headY}" text-anchor="middle" class="hverse">${esc(l1)}</text>`;
headY += HC_VERSE_LH;
headBlock += `<text x="${CX}" y="${headY}" text-anchor="middle" class="hverse">${esc(l2)} <tspan class="hvref">MATTHEW 9:36</tspan></text>`;
} else {
headBlock += `<text x="${CX}" y="${headY}" text-anchor="middle" class="hverse">`
+ VERSE
+ ` <tspan class="hvref">MATTHEW 9:36</tspan>`
+ `</text>`;
}
headY += 12;
headBlock += `<line x1="${VRULE_L}" y1="${headY}" x2="${VRULE_R}" y2="${headY}" class="hrule"/>`;
// ── THE FACTS TABLE (SPARSE) — four columns, under the scripture ─────────
// People Groups, Countries, Provinces and Population sit under the scripture,
// above the map, as a table: four columns, each with a header and its value.
//
// A four-column table beats the single run-on line it replaces because each
// fact gets a HEADER naming what the number is. "92" on its own means nothing;
// "People Groups / 92" is self-describing — which is what a stranger reading a
// blog thumbnail needs. Columns are evenly pitched across the map width so the
// block reads as one band rather than four stranded items.
if (SPARSE) {
const totalPop = pins.reduce((a, p) => a + (Number(p.population) || 0), 0);
// Populations are estimates that move; print them ROUNDED and prefixed so the
// sheet never claims a precision the data does not have.
const popText = totalPop >= 1e6 ? `~${(totalPop / 1e6).toFixed(1)}M`
: totalPop >= 1e3 ? `~${Math.round(totalPop / 1e3)}K`
: String(totalPop || '—');
// THE TWO NUMERIC COLUMNS ARE THE HEADLINE STATS. The count is much larger
// than the people-group text, and it reads {n} UPG's CALCULATED FROM THE
// NUMBER, because this is the template every sheet follows.
// So the count is DERIVED from pins.length on every sheet, never written down;
// the apostrophe form matches the series name the operator is using.
// `big: true` marks the columns that get display type; the two list columns
// stay at reading size because a list cannot be set at display size and fit.
const FACTS = [
['People Groups', `${pins.length} UPG's`, true],
['Countries', ctryNamesSet.join(' · ') || '—', false],
// "PROVINCES" WAS INACCURATE: not every country uses the term, and some of
// these areas are not provinces at all. Andaman and Nicobar,
// Puducherry, Lakshadweep and Chandīgarh are UNION TERRITORIES, and one zone
// carries a sovereign country (Nepal). Across the 28 sheets this column
// holds states, union territories, provinces, prefectures and countries.
// "Administrative Areas" is the widest term that is true of all of them.
['Administrative Areas', provinceList.join(' · ') || '—', false],
['Population', popText, true],
];
// COLUMN WIDTHS FOLLOW CONTENT: a zone with many countries or provinces needs
// wider columns for them in the four-column table.
// Four equal columns forced west-bengal's nine administrative areas onto five
// lines while POPULATION sat beside it holding "~5.6M". Weight each column by
// the length of what it must hold, then clamp so no column starves.
// PAD THE OUTER COLUMNS: the first and fourth columns' content keeps clear of
// the white border and aligns with the people-group columns under them.
// The RULES still run to the border (that is deliberate), but the table's
// CONTENT is inset to SHEET_MARGIN_L — the same x the label columns beneath
// start at — so the first value sits directly above the first name and the
// last above the last, instead of crowding the white edge.
const FT_INSET = SHEET_MARGIN_L - VRULE_L;
const FT_BAND = (VRULE_R - VRULE_L) - FT_INSET * 2;
// sqrt-damped weights: a 90-char list should get MORE room than a 5-char one,
// but not 18x more — raw length starved the short columns so badly that
// "Bangladesh · Bhutan · India" broke across two lines with "· India" orphaned.
const ftWeights = FACTS.map(([, val]) => Math.sqrt(Math.max(10, String(val).length)));
const ftTotal = ftWeights.reduce((a, b) => a + b, 0);
const FT_MIN = FT_BAND * 0.17; // no column below 17% of the band
let ftW = ftWeights.map((w) => Math.max(FT_MIN, (w / ftTotal) * FT_BAND));
const ftSum = ftW.reduce((a, b) => a + b, 0);
ftW = ftW.map((w) => (w / ftSum) * FT_BAND); // renormalise after clamping
const ftX = [];
ftW.reduce((acc, w) => { ftX.push(acc); return acc + w; }, VRULE_L + FT_INSET);
const FT_W = FT_BAND / 4; // kept for the header-height model
headY += 22;
let fbot = headY;
FACTS.forEach(([head, val, big], i) => {
const cx = ftX[i] + ftW[i] / 2;
headBlock += `<text x="${cx}" y="${headY}" text-anchor="middle" class="hfhead">${esc(head)}</text>`;
// Values wrap inside their own column so a long province list cannot run
// into its neighbour.
// Wrap at the size the value is ACTUALLY set at — wrapping at one size and
// rendering at another overruns the column by exactly the ratio between them.
const vfs = big ? 34 : 17;
const lines = wrapPx(val, vfs, ftW[i] - 24 - BAND_TEXT_PAD / 2);
lines.forEach((ln, k) => {
headBlock += `<text x="${cx}" y="${headY + (big ? 34 : 24) + k * (big ? 36 : 20)}" text-anchor="middle" class="${big ? 'hfbig' : 'hfval'}">${esc(ln)}</text>`;
});
const bot = headY + (big ? 34 : 24) + (lines.length - 1) * (big ? 36 : 20);
if (bot > fbot) fbot = bot;
});
// Thin dividers between the four columns.
for (let i = 1; i < 4; i++) {
const rx = ftX[i];
headBlock += `<line x1="${rx}" y1="${headY - 16}" x2="${rx}" y2="${fbot + 6}" class="hcolrule"/>`;
}
// CLOSE THE TABLE. The band was ruled above (under the scripture) but open at
// the bottom, so it bled into the map. A rule here closes it as a block.
headBlock += `<line x1="${VRULE_L}" y1="${fbot + 14}" x2="${VRULE_R}" y2="${fbot + 14}" class="hrule"/>`;
headY = fbot + 14;
}
// SOURCE + CALL TO ACTION, in the header rather than the footer. At the bottom it
// read as an afterthought stranded under the map; here it sits with the other
// metadata (Countries, Provinces, count) as one coherent block a reader takes in
// before looking at the map. /adopt is deliberately absent — that page is retired.
// (the old full-bleed rule here is gone — the table's own hairlines already
// close the metadata block, and a second full-width rule below them just
// re-drew the same boundary twice.)
// Numbers are DERIVED from the API + clusters.json at generation time, then
// SPOKEN rounded — a poster outlives its data, so the vision reads in
// approximations while the sheet's own count stays exact.
const approxGroups = Math.round(SERIES.totalGroups / 100) * 100;
const approxLists = Math.round(SERIES.totalLists / 10) * 10;
// ── THE VISION BLOCK ───────────────────────────────────────────────────────
// COPY IS THE OWN (05-poster-vision/source-material/01, "THE
// OWN COPY"). It supersedes my drafts, and it is better for reasons worth
// recording: it KEEPS THE MIRACLE. My versions reduced the account to a seating
// arrangement — a logistics lesson — when the point is that Jesus supernaturally
// multiplied the offering and the grouping is how it was DISTRIBUTED.
// "A similar challenge" is also the honest connective: it states a likeness
// without claiming the disciples were reading a map.
// ── THE HEADER GRID ────────────────────────────────────────────────────────
// The data at the top looked busy and unorganised; a table uses the space and
// distributes it evenly.
//
// The problem was that sixteen full-width CENTRED lines stack into a ragged
// column: every line starts and ends somewhere different, so the eye has no
// edge to follow and the block reads as noise rather than as information.
//
// The fix is a GRID. Two bands, both built on a shared column measure:
// BAND 1 — the vision, in TWO columns (Feed the 5000 | What is a Prayer List)
// BAND 2 — the three networks, in THREE equal columns, one per network
// Set left-aligned inside each column, so every line shares a hard left edge.
// That edge is what makes a text block read as ORGANISED rather than busy.
// FULL-SHEET WIDTH, like the scripture/facts band above the map: the footer
// section goes full width too.
// These blocks are about the whole sheet (the goal question, the three
// networks), not captions on the map, so they use the sheet margin — not the
// map's gutter, which moves whenever the label columns are re-tuned.
const GRID_L = BAND_MARGIN_G;
const GRID_W = PW - 2 * BAND_MARGIN_G;
// The type is slightly larger now that there is more room to work with.
// 12.5 -> 13.5 with leading kept proportional (~1.30).
const FS = HEAD_COLUMNS ? 22 : 13.5, LH = HEAD_COLUMNS ? 28 : 17.5;
// The two vision columns sit BELOW the ruled scripture, so they read as its
// supporting detail, not as headline copy. Slimmer type + tighter leading than
// the sheet's body size: less height for the same words, and the contrast in
// scale is what tells the eye the scripture outranks them.
const B1_FS = 12, B1_LH = 15;
const push = (frag) => { headBlock += frag; };
const colRule = (x, y0, y1) =>
`<line x1="${x}" y1="${y0}" x2="${x}" y2="${y1}" class="hcolrule"/>`;
let py = headY + 34;
// ── BAND 1: the two vision columns — FULL VERSION ONLY ───────────────────
// In SPARSE mode these are dropped entirely (the Jesus Strategy for 5000 and
// the What is a Prayer List of 100 explainers), and the
// height they occupied goes to the map, which is what makes the people-group
// names bigger. The scripture above and the question below both stay.
if (HEAD_COLUMNS) {
const band = headColumnsBand(py, headFacts(zone, pins), approxLists);
headBlock += band.frags;
py = band.bottom + 4;
} else if (!SPARSE) {
const B1_GUT = 54;
// TEXT_W is narrower than the column pitch so prose never touches the rule.
const B1_CW = (GRID_W - B1_GUT) / 2;
const B1_TW = B1_CW - 10;
const b1x = [GRID_L, GRID_L + B1_CW + B1_GUT];
const b1Top = py;
headBlock += `<text x="${b1x[0]}" y="${py}" class="hvh">Jesus’ Strategy to Feed the 5000</text>`;
headBlock += `<text x="${b1x[1]}" y="${py}" class="hvh">${esc(POSTER_TEXT.sideBoxHeading || 'What is a UPG100 List?')}</text>`;
const bodyTop = py + 20;
// The text is STRATEGY_DEFAULT unless the series sets its own (see strategyParas).
let cy0 = bodyTop;
strategyParas().forEach((text, k) => {
if (k) cy0 += 5;
cy0 = paraInto(push, String(text).replace(/\*\*/g, ''), { x: b1x[0], y: cy0, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' });
});
let cy1 = bodyTop;
// {approxLists} and {seriesName} are filled here rather than in series.json, so the sentence
// still states the MEASURED list count (approxLists is derived from the zones actually built)
// and can never carry a typed number that drifts from the sheet it is printed on.
const whatIs = (POSTER_TEXT.sideBoxBody
|| 'This map is one of a {approxLists}-map set called the {seriesName} — a large, overwhelming target in the thousands, organised into clear groupings and displayed on maps and prayer lists.')
.replace('{approxLists}', String(approxLists))
.replace('{seriesName}', SERIES_NAME);
// A **...** bold marker is the band's (boldLines); this classic header prints plain, so the markers are dropped.
const plain = (t) => String(t).replace(/\*\*/g, '');
[whatIs, ...whatIsMore()].forEach((text, k) => {
if (k) cy1 += 5;
cy1 = paraInto(push, plain(text), { x: b1x[1], y: cy1, w: B1_TW, fs: B1_FS, lh: B1_LH, cls: 'hsrc2' });
});
const b1Bot = Math.max(cy0, cy1);
headBlock += colRule(GRID_L + B1_CW + B1_GUT / 2, b1Top - 12, b1Bot - 12);
py = b1Bot + 4;
}
const HEAD_BOTTOM = py + 14;
// FAIL LOUDLY, don't ship a silent overlap. HEADER is a module constant (MAP_Y
// and MAP_H are derived from it before the header is laid out), so it cannot be
// computed from the content — which means the content CAN outgrow it. It already
// did once: the last "Ways to use this map" line landed on the map frame and the
// markup checks all passed, because an overlap is valid SVG. Only the render
// showed it. This turns that class of regression into a build error.
// A map-only sheet computes the header content but never EMITS it (see the assembly below), so
// there is nothing to overflow — the guard would only be measuring a block that is thrown away.
if (!MAP_ONLY && HEAD_BOTTOM > HEADER) {
throw new Error(
`[generate] header overflows the map frame: content ends at y=${HEAD_BOTTOM} `
+ `but HEADER=${HEADER} (zone ${zone.zoneKey}). Raise HEADER or tighten the header copy.`
);
}
// ── BELOW THE MAP: THE GOAL + THE THREE NETWORKS ─────────────────────────
// This section sits under the map: the question and its three answers below,
// the facts above, with the map sandwiched between them.
//
// So the sheet now reads as a sandwich:
// ABOVE — what this is (the facts table + the vision, in columns)
// MIDDLE — the map itself
// BELOW — the question, and the three answers to it
// The question and its three answers belong together directly under the map,
// because they are what a reader does AFTER looking at it.
let footBlock = '';
// FOOT_TOP is measured UP from the verse rule, so this block always sits
// directly beneath the map no matter how the map's height changes.
let fy = MAP_Y + MAP_H + (SPARSE ? MAP_BREATH : 30);
// THE GOAL — the question the three networks answer, kept in view on every
// sheet. INDIGENOUS is load-bearing: the aim is not
// merely disciples among these peoples, but disciples, churches and leaders
// FROM them, in their own language.
// RULE ABOVE THE GOAL. Restored now that the band runs the FULL SHEET width —
// at the map's width it lined up with the map edge and read as a stray second
// border; spanning the sheet it clearly belongs to this block instead.
fy += 16;
footBlock += `<line x1="${GRID_L}" y1="${fy}" x2="${GRID_L + GRID_W}" y2="${fy}" class="hrule"/>`;
fy += 24;
footBlock += `<text x="${CX}" y="${fy}" text-anchor="middle" class="hgoal">`
+ `How can we multiply indigenous disciples, churches and leaders among all these people groups — and get the gospel into their own language?`
+ `</text>`;
fy += 12;
footBlock += `<line x1="${GRID_L}" y1="${fy}" x2="${GRID_L + GRID_W}" y2="${fy}" class="hrule"/>`;
// THE THREE NETWORKS, three equal columns — the three answers.
// The MAP AND THE PRAYER PACKET TRAVEL TOGETHER in all three: the packet is
// what makes the intercession INFORMED rather than general.
fy += SPARSE ? 22 : 28;
// Its OWN class, not the shared .hvh — this is the call to action the three
// networks answer, so it carries more weight than an ordinary column heading,
// and sizing it here does not move every other heading on the sheet.
footBlock += `<text x="${CX}" y="${fy}" text-anchor="middle" class="hcta">Start with Prayer!</text>`;
fy += SPARSE ? 25 : HEAD_COLUMNS ? 38 : 22; // clears the larger .hcta line (and, full, the larger blocks below)
const B2_GUT = 40;
const B2_CW = (GRID_W - 2 * B2_GUT) / 3;
// The text measure is deliberately NARROWER than the column. At full width each
// block filled ~90% of its column, so there was no slack to centre INTO — what
// read as "not centred" was the ragged right edge of a block already spanning
// the column. Wrapping to 82% leaves real margin on both sides, which is what
// makes the block read as set INSIDE its column.
const B2_TW = (B2_CW - 10 - BAND_TEXT_PAD / 2) * 0.82;
// The three columns under the map need to align.
// They ran 4 / 3 / 4 lines, so their bottoms were ragged. The copy is trimmed
// to run the SAME number of lines in each column — that is what makes three
// columns read as one band instead of three unequal blocks, and it costs a line
// of height, which goes back to the map.
// ★ FROM series.json, NOT FROM HERE. These three blocks were the second reported problem: they
// tell the reader to divide the set among THIRTY disciples, which is coherent for ~30 UPG-100
// zones and incoherent for ~170 country posters. The country wording is not a find-and-replace of
// "thirty" with "a hundred and seventy" — the SHAPE changed, from "split the set" to "ask
// which one, then give them that poster". Both sets live in series.json.
// ENGLISH ONLY, on purpose: this file does no localization at all (no PACKET_LANG, no
// packet-strings, and its non-ASCII characters are entirely typographic — no translated string
// has ever been in it). The packet's 11 locales live in packet-strings.json.
const NETWORKS = SERIES_DEF.networks;
const fpush = (frag) => { footBlock += frag; };
let fBot = fy;
NETWORKS.forEach(([lead, body], i) => {
// CENTRE THE BLOCK, LEFT-ALIGN THE LINES: the text stays left-aligned within
// its block, and the block is centred in the column.
// Ragged-right text that starts at the column's left edge leaves all its
// slack on the right, so the block reads as shoved left inside its column.
// Measuring the widest wrapped line and offsetting by half the leftover
// centres the BLOCK while every line keeps a shared left edge to read down.
// CENTRE EACH BLOCK ON ITS COLUMN.
// The block's width is its widest wrapped line. Line 0 carries the BOLD
// run-in ("3 \u00b7 Thirty indigenous leaders.") and the rest is regular, so a
// flat correction mis-measures whichever block happens to be widest on its
// first line — that is why col3 drifted 50px while col1 sat at 3px.
// Measure line 0 with the bold weight and the others without.
const BOLD_RUN = 1.06; // bold costs ~6% width at this size
// textW carries WFUDGE; regular runs ~10% narrower. That 0.90 is also 12/13.5: the prose is wrapped at FS
// but .hsrc drew it at 12px. Where the series draws it at FS (HEAD_COLUMNS_CSS), its width IS the estimate,
// and centring on 0.90 of it pushed every block right.
const REG_FILL = HEAD_COLUMNS_ON_SERIES ? 1 : 0.90;
const colCentre = GRID_L + i * (B2_CW + B2_GUT) + B2_CW / 2;
const lines = wrapPx(`${lead} ${body}`, FS, B2_TW);
const lineW = (ln, k) => {
if (k > 0) return textW(ln, FS) * REG_FILL;
// Line 0 = bold lead + regular remainder.
const rest = ln.startsWith(lead) ? ln.slice(lead.length) : ln;
const leadPart = ln.length - rest.length > 0 ? lead : '';
return (textW(leadPart, FS) * BOLD_RUN + textW(rest, FS)) * REG_FILL;
};
const blockW = Math.min(B2_TW, lines.reduce((m, ln, k) => Math.max(m, lineW(ln, k)), 0));
const x = colCentre - blockW / 2;
const end = paraInto(fpush, body, { x, y: fy, w: B2_TW, fs: FS, lh: LH, cls: 'hsrc', lead });
if (end > fBot) fBot = end;
});
for (let i = 1; i < 3; i++) {
footBlock += colRule(GRID_L + i * (B2_CW + B2_GUT) - B2_GUT / 2, fy - 14, fBot - 12);
}
// THE VERSE RULE IS MEASURED FROM THIS BLOCK, NOT FROM A FIXED OFFSET.
// Anchoring it at PH - FOOTER could never work: FOOTER also shrinks MAP_H, so
// raising it moved the block and the rule up by the SAME amount and the gap
// between them stayed constant (measured: stuck at 164px for every value of
// FOOTER). The block is INSIDE the footer band, so the verse has to follow the
// block's real bottom.
const VERSE_RULE_Y = fBot + 14;
// The sheet still has to physically fit: the verse and its reference sit below
// the rule, and the attribution line below that.
// The reference baseline sits at VERSE_RULE_Y + 44; the attribution line is at
// PH - 4. Keeping the ref's descender clear of it needs +44 <= PH - 12, so the
// real limit is +56, not the +46/PH-8 pair that was guarding an imagined 8px.
// Same reasoning as the header guard: map-only sheets never emit the below-map block.
if (!MAP_ONLY && VERSE_RULE_Y + 56 > PH) {
throw new Error(
`[generate] below-map block runs off the sheet: verse rule would sit at `
+ `y=${VERSE_RULE_Y.toFixed(0)} on a ${PH}px sheet (zone ${zone.zoneKey}). `
+ `Raise FOOTER (which shrinks the map) or tighten this copy.`
);
}
// ── MAP CREDITS (inside the map, UNDER the leader lines) ─────────────────
// The Mapbox copyright painted above the leader lines, which is wrong, and the
// data tag moves to the BOTTOM LEFT. Emitted inside the clipped map
// group so every leader and label paints over them — they are basemap
// furniture, not content, and must never occlude the sheet's own data.
const credit = (x, y, anchor, txt) =>
`<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" class="attrib-halo">${txt}</text>`
+ `<text x="${x.toFixed(1)}" y="${y.toFixed(1)}" text-anchor="${anchor}" class="attrib">${txt}</text>`;
// ATTRIBUTION CORRECTED 2026-09-23. This said "(c) Mapbox (c) OpenStreetMap",
// which was inherited boilerplate and simply not true of this sheet: the header
// of this file states NO Mapbox, NO token, NO external fetch at render time, and
// the basemap is drawn from GeoJSON bundled in the repo. Crediting a vendor that
// contributes nothing implies a dependency that does not exist \u2014 the kind of line
// that makes a later reader add a token "to comply".
// The real source is geoBoundaries (the adm0/adm1 files carry its shapeGroup /
// shapeName fields), CC BY 4.0, which REQUIRES attribution \u2014 so the credit stays,
// it just names who actually earned it. OpenStreetMap is named only when OSM
// street tiles are actually fetched (BASEMAP=1, city sheets).
const basemapCredit = BASEMAP
? '\u00a9 geoBoundaries \u00b7 \u00a9 OpenStreetMap'
: '\u00a9 geoBoundaries (CC BY 4.0)';
// \u2605 PRIVACY LINE. The people-group coordinates arrive already generalised from
// the data publisher \u2014 they are deliberately NOT precise locations. A printed
// sheet travels far beyond the person who generated it, so it has to carry that
// fact itself rather than rely on the reader knowing it.
// Worded as provenance ("generalised at the source"), not as a claim about
// processing we perform here, because we do no rounding of our own.
const privacyNote = 'Marker positions are approximate \u00b7 people-group locations are generalised at the source for privacy';
// The privacy line gets its OWN row, 13px above the credits. Sharing the credit
// row put it at 516-984px against a stamp ending at 543 \u2014 a measured collision.
// It is also a different KIND of text: the other two are ~200px provenance
// stamps, this is a sentence a reader must actually READ, so it needs the room.
const mapCredits =
credit(FRAME_X + FRAME_W / 2, FRAME_Y + FRAME_H - 20, 'middle', privacyNote)
+ credit(FRAME_X + 8, FRAME_Y + FRAME_H - 7, 'start', `Generated ${POSTER_DATE} \u00b7 data from doxa.life`)
+ credit(FRAME_X + FRAME_W - 8, FRAME_Y + FRAME_H - 7, 'end', basemapCredit);
// FIT THE TITLE TO THE SHEET. Region names vary a lot in length ("West India"
// vs "Northeast India + Bangladesh + Bhutan"), and the qualifier adds a fixed
// ~13 characters on top, so a single hardcoded size cannot serve all 28 sheets —
// at 56px this one ran off BOTH edges. Solve for the largest size that fits
// between the sheet margins.
const H1_MAX = 56, H1_MIN = 26;
const h1Text = `${POSTER_TEXT.titleQualifier || 'Unengaged of'} ${title}`;
const h1Room = PW - 2 * (SHEET_EDGE + 24);
let H1_SIZE = H1_MAX;
// GEORGIA BOLD MEASURES ~1.35x THE LATO-TUNED ESTIMATE. textW's advance table
// is calibrated for Lato at body sizes; at display size in a serif bold it
// under-predicts badly. Measured on the render: 54px produced 1474px of text
// where the estimate said ~1090 — the title ran off BOTH sheet edges twice
// before this factor was taken from the actual getBBox width.
const H1_SERIF_BOLD = 1.35;
while (H1_SIZE > H1_MIN && textW(h1Text, H1_SIZE) * H1_SERIF_BOLD > h1Room) H1_SIZE -= 0.5;
const verse = verseFor(zone);
// ── OSM STREET BASEMAP (city sheets only) ────────────────────────────────
// A city sheet at ~9km needs STREETS; the ADM0/ADM1 polygon fill this template
// draws has none, which is why the first 176 city posters shipped as blank
// shapes. Tiles are stitched UNDER everything else inside #mapclip, so pins,
// leaders and labels all still draw on top unchanged.
//
// NO TOKEN, NO ACCOUNT — see osm-basemap.mjs for why that matters here rather
// than being a convenience. Country/100-list sheets are untouched: at that scale
// street tiles are noise, and the polygon fill is the right artefact.
let baseLayer = '';
if (CITIES && BASEMAP) {
try {
const z = bestZoom(viewBbox);
const grid = await fetchGrid(viewBbox, z);
// Place each tile by projecting its own top-left corner through the SHEET's
// projection, so the basemap lands in the same geo-space as the pins rather
// than merely near them.
const parts = [];
for (const t of grid.tiles) {
const lng = (t.x / Math.pow(2, z)) * 360 - 180;
const nAt = Math.PI - (2 * Math.PI * t.y) / Math.pow(2, z);
const lat = (180 / Math.PI) * Math.atan(0.5 * (Math.exp(nAt) - Math.exp(-nAt)));
const lng2 = ((t.x + 1) / Math.pow(2, z)) * 360 - 180;
const nAt2 = Math.PI - (2 * Math.PI * (t.y + 1)) / Math.pow(2, z);
const lat2 = (180 / Math.PI) * Math.atan(0.5 * (Math.exp(nAt2) - Math.exp(-nAt2)));
const [x0p, y0p] = proj(lng, lat);
const [x1p, y1p] = proj(lng2, lat2);
parts.push(`<image x="${x0p.toFixed(1)}" y="${y0p.toFixed(1)}" `
+ `width="${(x1p - x0p).toFixed(1)}" height="${(y1p - y0p).toFixed(1)}" `
+ `href="data:image/png;base64,${t.buf.toString('base64')}" `
+ `preserveAspectRatio="none"/>`);
}
// FULL OPACITY. The first attempt used opacity:0.72 + saturate(0.55), which
// on OSM's already-pale palette made the streets effectively invisible —
// measured: 36 tiles correctly placed across the clip, and the sheet still
// read as blank. Figure-ground here is achieved by the PINS being saturated
// cyan on a muted map, not by fading the map until it disappears.
baseLayer = `<g style="filter:saturate(0.72) contrast(1.04)">${parts.join('')}</g>`;
console.log(` basemap: ${grid.cols}x${grid.rows} OSM tiles @ z${z}`);
} catch (e) {
// A basemap failure must NOT lose the poster — the sheet still carries the
// names, pins and scripture, which is the artefact that matters.
console.warn(` basemap skipped: ${e.message}`);
}
}
// data-sheet="map-only" tells the packet this sheet reserves the top band for the packet's own
// title (packet-render.js liftMap), as opposed to a poster that carries its own.
// ★ EVERYTHING FROM THE TITLE TO THE CLOSING VERSE, AND THE WHITE EDGE, IS POSTER FURNITURE, and a
// map-only sheet (packet page 1) omits all of it: the title, the facts table and vision columns
// (headBlock), the goal question and three networks (footBlock), and the verse. The packet supplies
// its own title in the reserved band, in the reader's language.
// ⚠ These notes are JS comments, NOT <!-- --> inside the literal: an HTML comment there is EMITTED
// into every poster, which made the default 3x4 output differ from before this mode existed.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${PW} ${PH}" width="${PW}" height="${PH}"${MAP_ONLY ? ' data-sheet="map-only"' : ''}>
<rect width="${PW}" height="${PH}" fill="var(--paper)"/>
<defs>
<!-- Two-part shadow: a tight dark edge for definition, plus a wide soft one
for lift. One blur alone reads either muddy or hard. -->
<!-- THE FILTER REGION MUST CONTAIN THE WHOLE BLUR, or the blur is clipped and
its bounding box shows in the paper as a faint rectangle offset from the
map — visible above and left of the top-left corner. A Gaussian needs
~3x stdDeviation to fade to nothing: the widest shadow here is
stdDeviation 18 with a 10px y-offset, so it needs ~64px of margin. The
region is set in objectBoundingBox units, so the percentage has to cover
that on the SHORTEST side. userSpaceOnUse with explicit padding is exact
and cannot drift when the frame's aspect changes between zones. -->
<filter id="mapfloat" filterUnits="userSpaceOnUse"
x="${FRAME_X - 60}" y="${FRAME_Y - 60}"
width="${FRAME_W + 120}" height="${FRAME_H + 120}">
<!-- ONE tight shadow, not a wide second pass. The wide pass (stdDeviation 18,
dy 10) needed ~64px+ of filter margin to fade out; anything less clipped
it and the clip edge showed in the paper as a faint rectangle offset up
and left of the map. A single tighter shadow gives the same "floating"
read, fades out inside 30px, and cannot produce a visible box. -->
<feDropShadow dx="0" dy="3" stdDeviation="5" flood-color="#2A2721" flood-opacity="0.34"/>
</filter>
</defs>
<!-- THE MAP FLOATS: a border line around the map itself, with a shadow outside
that line, makes the map look like it is floating.
A soft drop shadow under a crisp 1.5px border lifts the map off the paper,
so it reads as an object ON the sheet rather than a window cut INTO it —
which is also what makes the surrounding text feel like a mount around it. -->
<!-- THE MAP FLOATS: a border line around the map, with a shadow outside it.
The white mat was tried and rejected — the shadow-only
border reads better, and the mat also cut every leader line with a band of
white where the lines crossed it. -->
<rect x="${FRAME_X}" y="${FRAME_Y}" width="${FRAME_W}" height="${FRAME_H}" fill="var(--sea)"
stroke="var(--frameline)" stroke-width="1.5" filter="url(#mapfloat)"/>
<!-- LAND UNDERLAY. The frame is filled with SEA, so any land not covered by a
polygon shows as water — and India was absent from world-adm0 entirely
(161 countries, no IND), so Indian territory outside the drawn ADM1 features
fell through to sea. That is the "white space" gap. Painting every ADM1
feature in view as a land underlay closes it: the sea then shows only where
there genuinely is sea. India is now in world-adm0 too (2026-08-02), but the
ADM1 underlay stays — it is what makes the gap impossible rather than merely
filled for one country. -->
<!-- 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}${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
on one line at h1 size would either overflow the sheet or force the type
down to where the region name stops being the loudest thing on the poster.
Splitting it lets the CATEGORY sit quietly above and the REGION stay big:
the region is what a passer-by uses to find their sheet on a wall of 30. -->
<!-- TITLE, two-tone. The title reads "Unengaged of <Region>", with
"Unengaged of" in a DIFFERENT COLOUR from the region name — so a passer-by
reads at a glance that it is these PEOPLE who are unengaged, while the
region stays the loudest thing on the sheet. The overline above it is now
the plain category, "Unengaged People Groups". -->
<!-- CENTRED between the white edge and the title, not flush against the edge.
At y=46 its cap-height started ~32 — only 6px clear of the 26px border, so
it read as pinned to the edge. The band runs 26..(title cap-top ~78), so
its optical middle is ~57. -->
${MAP_ONLY ? '' : `<text x="${PW / 2}" y="57" text-anchor="middle" class="h1over">${esc(POSTER_TEXT.kicker || 'Unengaged People Groups')}</text>
<text x="${PW / 2}" y="104" text-anchor="middle" class="h1" font-size="${H1_SIZE}"><tspan class="h1qual">${esc(POSTER_TEXT.titleQualifier || 'Unengaged of')}</tspan> ${esc(title)}</text>
<!-- (The series line is emitted INSIDE headBlock, not here. Absolutely placing
it at a fixed y while the scripture flowed from headY is what made the
"(see Doxa.Life…)" line collide with the scripture rule.) -->
<!-- (The top-left Doxa.Life lockup was removed 2026-07-31 at the the brief's
request: the series line under the title already carries the site
reference, so the lockup was saying it twice.) -->
${headBlock}
${footBlock}
<!-- (The HEAD_BOTTOM rule is gone. It marked the boundary between the header and
the map back when the map was a plain rect. The white mat and its drop
shadow now define that edge far more clearly, so the rule just floated as a
stray horizontal line above the map.) -->
<line x1="${BAND_MARGIN_G}" y1="${VERSE_RULE_Y}" x2="${PW - BAND_MARGIN_G}" y2="${VERSE_RULE_Y}" class="rule"/>
<!-- FOOTER SCRIPTURE — TIER 1: larger, bold, on ONE line, with the scripture
reference on the right. It is one of the
three things that must read when a reader steps closer, alongside the title
and the people-group names, so it carries display weight. The reference is
right-aligned to the map's own edge, which is what keeps it on ONE line. -->
<!-- CENTRED on the sheet axis, with the reference set inline after it so the
whole thing reads as ONE centred line. It was left-aligned with the ref
pushed to the right margin, which split it into two objects at opposite
ends of the sheet rather than one closing statement. -->
<text x="${PW / 2}" y="${VERSE_RULE_Y + 30}" text-anchor="middle" class="verse">&#8220;${esc(verse.text)}&#8221;<tspan class="vref"> ${esc(verse.ref)}</tspan></text>`}
<!-- GENERATION DATE, bottom-left. Deliberately NO hard counts of the global
unengaged total on this poster: that number changes as research updates, and
a printed figure would silently go stale on a wall. What CAN be stated
honestly is WHEN this sheet was made — so a reader knows how current it is
and can go to the site for the live picture. -->
<!-- THE WHITE SHEET EDGE. Drawn last so it trims anything reaching the paper's
edge. A stroked rect inset by half its own width paints a band exactly
SHEET_EDGE wide around the whole poster. -->
${MAP_ONLY ? '' : `<rect x="${SHEET_EDGE / 2}" y="${SHEET_EDGE / 2}" width="${PW - SHEET_EDGE}" height="${PH - SHEET_EDGE}"
fill="none" stroke="#FFFFFF" stroke-width="${SHEET_EDGE}"/>`}
</svg>`;
const html = `<!doctype html><html><head><meta charset="utf-8">
<title>${esc(title)} — UPG poster</title>
<style>
:root{--paper:#f7f4ec;--sea:#eef3f4;--frame:#cdbf9e;--frameline:#B9AC8C;--ink:#1c1c17;--zc:${color};}
*{margin:0;box-sizing:border-box}
html,body{background:#777}
.stage{width:${PW}px;height:${PH}px;margin:0 auto;background:var(--paper)}
svg{display:block}
/* CONTEXT COUNTRIES. Imhof rule 3 — large fills sit muted and low-saturation
(OS ships land at 13% saturation, sea at 8%); chroma is reserved for the small
marks. The HAIRLINE EDGE is what lets these near-identical values read as
separate countries at all (Tufte separates 3% and 7% tints this way). Edge is
darkened from the old #cfc6ad so borders are actually visible. */
/* Borders must be STRONGER than the fill contrast, or a small neighbour with the
same tone as its surroundings simply disappears — Nepal was drawn correctly
the whole time and still read as part of India, because nothing delineated it.
Tufte: a hairline contour is what lets near-identical fills separate; here the
contour had to be darkened and thickened to actually do that job. */
.ctry{fill:#EAE5D8;stroke:#9C8F73;stroke-width:1.5;stroke-linejoin:round}
/* Country NAMES: all-caps tracked +0.1em, haloed to the PAPER colour rather
than white (Brewer — halos should be subtle and match the background), and
held one clear value-step lighter than the people-group labels so the surround
never competes with the subject. */
/* Size and value both raised: at 11px/#8C8370 these rendered but were
effectively invisible against a fill of similar value. The density budget
allows generosity — this poster carries ~11 names/dm2 against Imhof's 500/dm2
world-atlas ceiling — so 15px with a darker warm grey is still subordinate to
the people-group labels while actually being readable. */
/* CONTEXT PROVINCES — visible structure, clearly subordinate to the zone.
Imhof: separate by VALUE, keep the surround muted; the hairline edge is what
lets near-identical fills read as separate territories at all. */
.prov{fill:#E4DECF;stroke:#B3A488;stroke-width:1;stroke-linejoin:round}
/* Province names sit BELOW country names in the hierarchy: smaller, lighter,
and not all-caps, so the eye reads country > province > people group. */
/* Water names: italic, spaced, in the water's own hue — the cartographic
convention for hydrography, and it keeps them clearly distinct from land. */
.sea-name{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:12px;
font-style:italic;fill:#93AEBC;letter-spacing:.18em;font-weight:500}
.prov-name{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:11.5px;
fill:#7E7562;font-weight:500;letter-spacing:.02em}
.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;
letter-spacing:.1em;font-weight:700;fill:none;stroke:#F2EFE6;stroke-width:4;stroke-linejoin:round}
/* A country that is a MEMBER of this zone (mixed-geometry zones join whole
countries to provinces — North India + Nepal). It takes the zone's own ink,
matching .prov-name.own, so members and neighbours are never confusable. */
.ctry-name-own{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:15px;
letter-spacing:.1em;fill:#6B4A78;font-weight:700}
/* Internal province borders inside the zone, or the 5 highlighted provinces read
as one undifferentiated blob and the reader cannot tell Bihar from Jharkhand. */
.zone{fill:var(--zc);fill-opacity:.30;stroke:#7B5A88;stroke-width:1.1;stroke-opacity:.75;stroke-linejoin:round}
.pin{fill:var(--zc);stroke:#2b2616;stroke-width:1.1;paint-order:stroke}
/* SELF-HALOED LEADER (Frye/Esri): white 1.2pt under, grey 0.6pt over. At this
poster's CSS scale 1pt ~= 1.33px, so 1.2pt=1.6px and 0.6pt=0.8px. The grey is
a MID value (~45%), never black — a black leader competes with the label it
serves. 0.6pt is deliberately just above the 0.5pt print floor. */
/* Paper-coloured halo so a hairline stays readable where it crosses land. */
.lead-halo{fill:none;stroke:#F5F1E7;stroke-width:1.7;stroke-opacity:.7;stroke-linejoin:round;stroke-linecap:round}
/* Leaders must be SUBORDINATE — they are wiring, not content. At .9 opacity and
118 of them the bundle read as the loudest thing on the sheet, competing with
both the map and the names it serves. Dropped to a light warm grey at .55, one
clear value-step below the labels. Esri: consistent colour and weight, never
black, never varying per label. */
/* A true HAIRLINE in near-black. The warm grey at .55 opacity vanished against
the map fills — a leader you cannot see does not connect anything. 0.4px is
just above the 0.25pt offset-litho dropout floor, so it stays a hair rather
than a rule, and near-black (not pure #000) keeps it from competing with the
label text it serves. */
/* 0.4px rendered as a pale grey rather than a hairline — below about 0.55px a
stroke antialiases into a tint instead of a line, so "thin and black" needs
WEIGHT, not a darker hex. 0.6px at full opacity is a true hair that still sits
just above the 0.25pt offset-litho dropout floor. */
.lead{fill:none;stroke:#14110B;stroke-width:.6;stroke-opacity:1;stroke-linejoin:round;stroke-linecap:round}
.ldot{fill:#857A6A}
/* NUMBERED PINS. A filled disc in the zone colour with a knocked-out numeral —
the number must read at arm's length on a printed sheet, so the disc carries
it rather than sitting beside it. Paper-coloured ring separates touching pins
without adding a second hue (Imhof: separate by value, keep hues few). */
/* Numerals sized to be READ, not merely present. At 8.6px they were illegible
even magnified; on a printed sheet at arm's length they would fail outright.
The density budget allows this — ~11 names/dm2 against Imhof's 500/dm2
world-atlas ceiling — so the sheet has room the earlier size did not use. */
/* ★ INK, NOT THE ZONE HUE, AND MEASURED. The number sits in white (.pin-num below) on this disc.
While this was filled with var(--zc) the white failed WCAG 4.5:1 on most zone hues -- six of the
eight country-region hues, and on the 100-list's own sheets too (europe's hsl(103,72%,56%) gives
1.70:1). The demo country, Sudan, was 1.62:1. These numbers render at 5.3-9.9px on the sheet, so
the large-text 3:1 allowance does not apply.
A per-hue choice of white-or-ink CANNOT fix it: on red, hsl(0,72%,56%), white is 4.26 and ink
4.01, so the best of the two is still under 4.5. White on #1c1c17 is 17.10:1 on every hue,
unconditionally, and #1c1c17 is the sheet's own --ink rather than a new colour.
The badge therefore stops carrying the zone hue; the zone fill and the pins still do, so the
sheet keeps its colour identity. */
.pin-num-bg{fill:#1c1c17;stroke:#F7F4EC;stroke-width:1.8;fill-opacity:.97}
/* NO font-size HERE. It used to declare 13px, and a CSS rule BEATS the SVG
font-size presentation attribute — so every per-badge size the generator
computed was silently discarded and every numeral rendered at 13px. That is
why "118" spilled 8.4px out of an 18.7px disc: the fix was being overridden
by the stylesheet, not miscalculated. Size is set per element, on purpose,
because it depends on the digit count and the disc radius. */
.pin-num{font-family:Lato,'Segoe UI',system-ui,sans-serif;
font-weight:700;fill:#FFFFFF;letter-spacing:0}
/* The index is subordinate to the NAME — lighter and smaller, so the margin
column reads as a list of peoples, not a list of numbers. */
.lnum{fill:#A79C86;font-weight:400}
/* Sans for CULTURAL features (people groups). Cartographer survey favours
Frutiger; Lato/Open Sans are the available substitutes. Small labels take
POSITIVE tracking (optical sizing). */
/* The people-group names ARE the payload of this sheet — at thumbnail scale they
are the one thing that must still read. Bold, and darker than body ink. */
.lbl{font-family:Lato,'Segoe UI',system-ui,-apple-system,sans-serif;fill:#2A2721;
font-weight:700;letter-spacing:.012em;dominant-baseline:auto}
/* The index column — its own element so every number shares one x. Lighter than
the name so the column reads as names first, indices second. */
.lnum-col{font-family:Lato,'Segoe UI',system-ui,-apple-system,sans-serif;fill:#8a8470;
font-weight:600;letter-spacing:.012em;dominant-baseline:auto}
/* TITLE: display type takes NEGATIVE tracking. Set at 0 it is one of the
loudest amateur tells. Sized up per the density budget — at ~5-11 names/dm2
this poster is very sparse against Imhof's 500/dm2 world-atlas ceiling, so
there is room to be generous. */
.h1{font-family:Georgia,'Times New Roman',serif;font-weight:700;fill:var(--ink);letter-spacing:-.02em}
/* "Unengaged of" — same size and weight as the title so it reads as one line,
but in a lighter tone so the REGION NAME still carries the emphasis. */
.h1qual{fill:#A79E8A}
/* The category line above the region name — smaller, tracked, quiet. */
.h1over{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:19px;font-weight:600;
fill:#8a8470;letter-spacing:.16em;text-transform:uppercase}
/* Doxa lockup, top-left. */
.brand{font-family:Georgia,'Times New Roman',serif;font-size:21px;font-weight:700;
fill:#6B4A78;letter-spacing:.01em}
.brandsub{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:10.5px;font-weight:600;
fill:#8a8470;letter-spacing:.10em;text-transform:uppercase}
/* SUBTITLE: the province enumeration lives here, wrapped, never in the title. */
.h2{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:19px;fill:#6a6450;letter-spacing:.04em}
.h3{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:15px;fill:#7d7663;letter-spacing:.01em}
/* Labelled list under the title. The KEY is bold so the structure reads at a
glance; the VALUE is regular so the names do not shout over the title. Both
sit on their own left edge, so Countries and Provinces align as a column. */
.hkey{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:15px;font-weight:700;
fill:#4a4636;letter-spacing:.02em}
.hval{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:15px;font-weight:400;
fill:#6a6450;letter-spacing:.01em}
.hsum{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:14px;font-weight:600;
fill:#8a8470;letter-spacing:.03em}
.rule{stroke:var(--frame);stroke-width:1.5}
/* CALL TO ACTION — quiet but findable. It sits BELOW the map and ABOVE the
scripture, so the eye lands on the map first, the invitation second, and the
verse last. Type is small: a poster's job is the map, and a shout here would
compete with it. */
/* Source + invitation, set as ONE running line in the header block. Quiet
weight so it reads as provenance rather than advertising; only the URLs and
the term carry emphasis. */
.hrule{stroke:#C9BFA4;stroke-width:1.2}
.hsrc{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:12px;fill:#8a8470;letter-spacing:.01em}
.hsrc-em{font-weight:700;fill:#5f5849}
/* The two vision columns under the ruled scripture — a step down in scale from
body prose, so the scripture above them stays the louder voice. */
.hsrc2{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:12px;fill:#8a8470;letter-spacing:.01em}
.hsrc-url{font-weight:700;fill:#6B4A78}
.hsrc-it{font-style:italic}
/* The goal question — the hinge between the vision and the three networks.
Slightly larger and darker than body prose because it is the one line the
whole header is arranged around. */
/* Sized to FILL its band. At 14.5px it measured ~1018px in a 1388px band and
read as a small line floating in a wide space; the band went full-sheet-width
and the type did not follow. 18px MEASURED (getBBox) at 1344px in the 1388px
band — the estimator said 19px would fit at 1334 but it actually rendered
1419 and overflowed both edges, so this size is taken from the RENDER. */
.hgoal{font-family:Georgia,'Times New Roman',serif;font-size:18px;font-style:italic;
fill:#4a4636;letter-spacing:.01em}
/* Hairline between grid columns. A rule, not a box: it separates the columns
without drawing a container around them. */
.hcolrule{stroke:#D6CCB4;stroke-width:.8}
/* SPARSE header: the series line under the title, and the one-line facts. */
.hseries{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:14px;fill:#7E7562;letter-spacing:.01em}
.hseries-em{font-weight:700;fill:#6B4A78}
.hseries-dim{fill:#A79E8A}
/* The site reference on its OWN line, so the series block is less wide. A
single long line was forcing the series block wider than the map. */
.hseries-dim2{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:12px;
fill:#A79E8A;letter-spacing:.02em}
/* SPARSE four-column facts table under the scripture. The HEADER names what the
number is — "92" alone is meaningless, "People Groups / 92" is self-describing. */
.hfhead{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:12.5px;fill:#8a8470;
font-weight:700;letter-spacing:.14em;text-transform:uppercase}
.hfval{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:17px;fill:#3A3730;
font-weight:700;letter-spacing:.01em}
/* The two HEADLINE stats — the count and the population. Display size, in the
zone ink, so they read before anything else in this band. */
.hfbig{font-family:Georgia,'Times New Roman',serif;font-size:34px;fill:#6B4A78;
font-weight:700;letter-spacing:-.01em}
/* The compassion verse under the facts table — scripture, so it takes the
serif italic of the footer verse rather than the sans of the metadata. */
/* Sized to FILL the full-sheet band on ONE line. It was 12.5px when the band was
the map's width (~1100px); the band is now 1388px, so the type follows.
15px measures ~1263px — fills the band while keeping headroom over the width
estimator's error rather than sitting one glyph from overflow. */
/* 13.5px bold. At full width it was too large: the line needs gaps at both
ends. At 15px bold it measured 1396px of the 1448px band,
i.e. touching both rules; 13.5 measures ~1256px and leaves ~96px of paper at
each end, so the line reads as SET INSIDE the band rather than filling it.
Bold + dark ink still carry the weight. */
.hverse{font-family:Georgia,'Times New Roman',serif;font-size:13.5px;font-style:italic;
font-weight:700;fill:#3a362c;letter-spacing:.01em}
/* The scripture REFERENCE — bold and in the zone ink, matching how the series
name and the site URL are emphasised, so the citation reads as a deliberate
part of the line rather than a grey afterthought. */
.hvref{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-size:11px;
fill:#6B4A78;letter-spacing:.14em;font-weight:700;font-style:normal}
/* The hook is a QUESTION and reads one step up from the body — it is the line
that stops a passer-by, and everything after it is the answer. */
/* The vision block gets a small titled heading of its own — it tells a passer-by
what they are about to read before they commit to reading it. Small caps and
letterspacing mark it as a heading without needing size. */
.hvh{font-family:Georgia,'Times New Roman',serif;font-size:15px;font-weight:700;
fill:#3f3a2e;letter-spacing:.06em;font-variant:small-caps}
/* "Start with Prayer!" — the call to action above the three networks. */
.hcta{font-family:Georgia,'Times New Roman',serif;font-size:21px;font-weight:700;
fill:#3f3a2e;letter-spacing:.06em;font-variant:small-caps}
.verse{font-family:Georgia,'Times New Roman',serif;font-style:italic;font-size:23px;font-weight:700;fill:#2A2721}
.vref{font-family:'Segoe UI',system-ui,sans-serif;font-size:13px;font-weight:700;fill:#6B4A78;letter-spacing:.14em;text-transform:uppercase}
.attrib{font-family:'Segoe UI',system-ui,sans-serif;font-size:9px;fill:#8d857a;letter-spacing:.02em}
/* Haloed so the credits stay legible wherever they land on the map. */
.attrib-halo{font-family:'Segoe UI',system-ui,sans-serif;font-size:9px;fill:none;
stroke:#EEF3F4;stroke-width:2.6;stroke-linejoin:round;letter-spacing:.02em}
/* PRINT. The stage is sized in CSS PIXELS (1500 wide) while @page declares a
PHYSICAL size, so a browser maps 1500px -> 96dpi -> 15.6in and TILES that
across the sheet: the 3x3 sheet prints as FOUR pages, not one. That went
unnoticed because these posters were built to be SCREENSHOT by shoot.mjs at
deviceScaleFactor 4, never printed from the browser.
Scaling the stage to the page's own width makes one sheet = one page on any
format, which is what a home printer needs. transform-origin top-left so the
scale collapses toward the page corner rather than the element centre. */
@media print{
@page{size:${FMT.page};margin:0}
html,body{background:#fff}
/* A FIXED ratio, computed at generate time — not a vw calc. a vw/px ratio does not
resolve against the PAGE box in print, so the calc silently evaluated to a
no-op and the sheet still tiled. The page width in CSS px is the paper width
in inches x 96; the scale is that over the stage width. */
.stage{margin:0;transform:scale(${(FMT.pageWidthIn * 96 / FMT.w).toFixed(6)});transform-origin:top left}
/* The scaled stage still reserves its ORIGINAL box, which pushes a blank second
page. Collapse the flow height to the scaled height. */
html,body{width:${(FMT.pageWidthIn * 96).toFixed(0)}px;height:${(FMT.pageWidthIn * 96 * FMT.h / FMT.w).toFixed(0)}px;overflow:hidden}
}
${FMT.sideLanes ? SIDE_LANES_CSS + '\n' : ''}${HEAD_COLUMNS_ON_SERIES ? HEAD_COLUMNS_CSS + '\n' : ''}${HEAD_COLUMNS ? HEAD_COLUMNS_FULL_CSS + '\n' : ''}</style></head><body><div class="stage">${svg}</div></body></html>`;
return sides ? { html, title, octiOK, crossings, diagFrac, sideLanes: sides.stats } : { html, title, octiOK, crossings, diagFrac };
}
return { renderPoster, CITIES, COUNTRIES, FILE_SUFFIX, FMT_KEY, FMT, MAP_ONLY, FORMATS };
}