/** * 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 [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, '&').replace(//g, '>').replace(/"/g, '"'); // ── 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: .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/.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/.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 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 = `${le}` + body.slice(le.length); } push(`${body}`); 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 ? `${esc(run)}` : 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 += `${esc(r.key)}`; y += HC.keyLh; for (const ln of r.lines) { frags += `${esc(ln)}`; 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 += `${esc(head)}`; let y = top + HC.headLh; paras.forEach((text, k) => { if (k) y += HC.gap; for (const ln of boldLines(text, TW)) { frags += `${ln}`; 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 += ``; } 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 += ``; // 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 += `${t}` + `${t}`; } 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 += ``; 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 += `${esc(nm)}`; } } 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 += `${t}` + `${t}`; } // zone polygons (the region(s) this poster is about) let zoneP = ''; for (const f of zoneFC.features) zoneP += ``; // 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 += ``; if (b.n > 1) leadersS += ``; dotsS += ``; 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 += ``; const slot = slotOf(g, font), tx = L ? x - slot : x + slot; labelsS += `${esc(String(g.name))}`; const ph = photoOf(g); if (ph) { const px = L ? x - PHOTO : x; labelsS += ``; } 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 ? `` + `` : ``; cityDots += `${esc(c.n)}`; } } // 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 += `` + `${it.num}`; // 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 += ``; // 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 += `${it.num}.`; labels += `` + lines.map((ln, k) => k === 0 ? esc(ln) : `${esc(ln)}`).join('') + ``; }); octiOK = true; } else laid.forEach((it, i) => { const pts = routes[i]; if (!isOctilinear(pts)) octiOK = false; leaders += ``; // 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 += ``; dots += ``; const tx = it.laneX + it.dirOut * 5; labels += `${esc(it.name)}`; }); 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 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, '&'); 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 += `` + `Series \u201c${esc(SERIES_NAME)}\u201d` // ★ 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 https://Doxa.Life` + ``; 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 += `${label}`; for (const ln of lines) { headBlock += `${esc(ln)}`; 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 += ``; headBlock += ``; 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 += ``; 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 += `${esc(l1)}`; headY += HC_VERSE_LH; headBlock += `${esc(l2)} MATTHEW 9:36`; } else { headBlock += `` + VERSE + ` MATTHEW 9:36` + ``; } headY += 12; headBlock += ``; // ── 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 += `${esc(head)}`; // 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 += `${esc(ln)}`; }); 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 += ``; } // 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 += ``; 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) => ``; 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 += `Jesus’ Strategy to Feed the 5000`; headBlock += `${esc(POSTER_TEXT.sideBoxHeading || 'What is a UPG100 List?')}`; 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 += ``; fy += 24; footBlock += `` + `How can we multiply indigenous disciples, churches and leaders among all these people groups — and get the gospel into their own language?` + ``; fy += 12; footBlock += ``; // 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 += `Start with Prayer!`; 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) => `${txt}` + `${txt}`; // 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(``); } // 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 = `${parts.join('')}`; 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 = ` ${baseLayer}${(CITIES && BASEMAP) ? '' : ctx}${(CITIES && BASEMAP) ? '' : ctxAdm1}${(CITIES && BASEMAP) ? '' : zoneP}${seaNames}${provNames}${ctryNames}${cityDots}${mapCredits} ${leaders}${dots}${pinDots}${labels} ${MAP_ONLY ? '' : `${esc(POSTER_TEXT.kicker || 'Unengaged People Groups')} ${esc(POSTER_TEXT.titleQualifier || 'Unengaged of')} ${esc(title)} ${headBlock} ${footBlock} “${esc(verse.text)}” ${esc(verse.ref)}`} ${MAP_ONLY ? '' : ``} `; const html = ` ${esc(title)} — UPG poster
${svg}
`; 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 }; }