The map template draws a map-only vertical page for the prayer packet

Page 1 of a prayer packet is a lightweight vertical one-pager showing
the zone's geography and every pin with its numbered name - not the
poster. Until now the packet lifted the 3x4 poster (1500x2000, its own
title, facts table, vision columns, networks and closing verse), shrank
it to 62% and pasted it under the packet's title: a small poster on a
page, with the English poster prose baked into the artwork.

FORMAT=packet is a new sheet format: 1500x1941, the packet's own page,
with the top band left EMPTY for the title the packet draws in the
reader's language, a slim foot, and everything between given to the map
and its label columns - about 1,600px of height against the letter
sheet's ~1,200, so the names get the larger type the sparse posters use.
It omits all the poster furniture, and its root <svg> says
data-sheet="map-only" so the packet can tell it from a poster.

Paper formats no longer overwrite the poster. Every format used to write
out/posters/<zone>.html, so FORMAT=letter silently replaced the 3x4 the
wall and the site serve. packet and letter sheets are now
<zone>.packet.html and <zone>.letter.html, with their own manifest;
3x4 and 3x3 keep the bare name, which every consumer fetches.
publish-html.mjs read everything before ".html" as the zone id and so
skipped every paper sheet as an unknown zone; it now strips the suffix.

The two fail-loud guards (header overflow, verse off the sheet) stay
armed for posters and stand down only for a sheet that does not draw
the furniture they measure.

Proof that posters are untouched: the default 3x4 output for sudan,
asia-india-kerala, deaf and west-africa is byte-identical before and
after, generated with POSTER_DATE pinned. The first attempt was NOT
identical - an HTML comment added inside the SVG template literal is
emitted into every poster - so the notes live in JS comments instead.

check-zone-census counted paper sheets as posters (it would read 56
for 28 zones); it now counts posters and reports paper sheets apart.
This commit is contained in:
Doxa Maps 2026-09-29 00:44:51 +00:00
parent 502d7107e3
commit c121c63834
3 changed files with 66 additions and 18 deletions

View file

@ -42,7 +42,12 @@ if (!existsSync(POSTERS)) {
console.log(`ZONE-CENSUS: SKIP — no posters at ${POSTERS} (run generate.mjs first).`) console.log(`ZONE-CENSUS: SKIP — no posters at ${POSTERS} (run generate.mjs first).`)
process.exit(0) process.exit(0)
} }
const built = new Set(readdirSync(POSTERS).filter((f) => f.endsWith('.html')).map((f) => f.slice(0, -5))) // ⚠ POSTERS ONLY. generate.mjs also writes PAPER sheets beside them (<zone>.packet.html, packet page 1;
// <zone>.letter.html), and counting those as posters would print "posters built: 56" for 28 zones.
const PAPER = /\.(packet|letter)\.html$/
const htmls = readdirSync(POSTERS).filter((f) => f.endsWith('.html'))
const built = new Set(htmls.filter((f) => !PAPER.test(f)).map((f) => f.slice(0, -5)))
const paperSheets = htmls.filter((f) => PAPER.test(f)).length
const missing = leaves.map((z) => z.id).filter((id) => !built.has(id)) const missing = leaves.map((z) => z.id).filter((id) => !built.has(id))
// Definitions that exist in the data but are NOT reachable — reported as context, // Definitions that exist in the data but are NOT reachable — reported as context,
@ -55,7 +60,7 @@ for (const k of ['lists', 'deaf_lists', 'asia_lists', 'uncovered_lists']) {
const unreachable = [...declared].filter((id) => !leaves.some((z) => z.id === id)) const unreachable = [...declared].filter((id) => !leaves.some((z) => z.id === id))
console.log(` selectable zones : ${leaves.length}`) console.log(` selectable zones : ${leaves.length}`)
console.log(` posters built : ${built.size}`) console.log(` posters built : ${built.size}${paperSheets ? ` (+ ${paperSheets} paper sheet(s), not counted)` : ''}`)
console.log(` unreachable defs : ${unreachable.length}${unreachable.length ? ` (${unreachable.join(', ')}) — superseded, not a gap` : ''}`) console.log(` unreachable defs : ${unreachable.length}${unreachable.length ? ` (${unreachable.join(', ')}) — superseded, not a gap` : ''}`)
if (missing.length) { if (missing.length) {

View file

@ -343,8 +343,31 @@ const FORMATS = {
// match it, which is what makes an ordinary printer produce a full-bleed-free, // match it, which is what makes an ordinary printer produce a full-bleed-free,
// correctly-scaled sheet instead of "fit to page" guesswork. // correctly-scaled sheet instead of "fit to page" guesswork.
letter: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5 }, letter: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5 },
// ★ PACKET PAGE 1 — THE MAP ALONE, ON A VERTICAL PAGE. Not the poster: page 1 of a prayer packet is
// a lightweight one-pager showing the geography and every pin with its numbered name, and nothing of
// the poster's furniture (the facts table, the two vision columns, the three networks, the closing
// verse). Those are English prose baked into the artwork, which is also why the poster could never be
// page 1 of a translated packet: the packet draws its OWN title into the reserved band, from the
// locale's strings, and this sheet supplies only what needs no translation — the map, the pins and
// the people-group names.
//
// SAME 1500x1941 AS `letter` AND AS THE PACKET'S OWN SHEET (packet-render.js W/H), so it drops onto
// page 1 at 1:1 with no rescale. `header` is the band left EMPTY for the packet's title block and
// `footer` the band for its fine print; everything between goes to the map and its label columns —
// about 1,600px of height against the letter sheet's ~1,200, which is what lets the names grow.
// Output: <zone>.packet.html beside the posters (see FILE_SUFFIX), never over them.
packet: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5, mapOnly: true, header: 250, footer: 90 },
}; };
const FMT = FORMATS[process.env.FORMAT || '3x4'] || FORMATS['3x4']; const FMT_KEY = FORMATS[process.env.FORMAT] ? process.env.FORMAT : '3x4';
const FMT = FORMATS[FMT_KEY];
// Map-only sheets (packet page 1) skip the poster's header and footer furniture — see FORMATS.packet.
const MAP_ONLY = FMT.mapOnly === true;
// ★ A FORMAT THAT IS NOT A POSTER MUST NOT OVERWRITE THE POSTER. Every format used to write
// out/posters/<zone>.html, so `FORMAT=letter` silently replaced the 3x4 poster the wall and the site
// serve — which is part of why no letter or packet sheet was ever generated alongside them. The paper
// formats get their own names. 3x4 and 3x3 keep the bare name: they ARE the poster, whichever was
// built last, and renaming them would break every consumer that fetches posters/<zone>.html.
const FILE_SUFFIX = { letter: '.letter', packet: '.packet' }[FMT_KEY] || '';
// Stamped at generation time. Overridable so a rebuild can reproduce an existing // Stamped at generation time. Overridable so a rebuild can reproduce an existing
// sheet byte-for-byte — print determinism matters more than always-now. // sheet byte-for-byte — print determinism matters more than always-now.
const POSTER_DATE = process.env.POSTER_DATE const POSTER_DATE = process.env.POSTER_DATE
@ -458,7 +481,7 @@ const HEADER_BASE = SPARSE ? 355 : 460;
// no city poster rendered at all. 280 clears it with ~8px of slack for the // 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, // 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. // which is the documented trade this constant exists to make.
const FOOTER = SPARSE ? 316 : 280; const FOOTER = MAP_ONLY ? FMT.footer : SPARSE ? 316 : 280;
// GUTTER was 300px A SIDE — 600 of 1350 (44% of the sheet) given to text, leaving // 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. // 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 // At 210 a side the map grows 750 -> 930 (+24% width, +24% linear scale), which is
@ -470,7 +493,7 @@ const GUTTER = FMT.gutter, GPAD = 16;
const MAP_X = GUTTER; const MAP_X = GUTTER;
const MAP_W = PW - 2 * GUTTER; const MAP_W = PW - 2 * GUTTER;
// Set per-zone by setMapBox() before each poster renders. // Set per-zone by setMapBox() before each poster renders.
let HEADER = SPARSE ? 355 : 460; let HEADER = MAP_ONLY ? FMT.header : SPARSE ? 355 : 460;
let MAP_Y = HEADER; let MAP_Y = HEADER;
let MAP_H = PH - HEADER - FOOTER; let MAP_H = PH - HEADER - FOOTER;
const setMapBox = (headerPx) => { const setMapBox = (headerPx) => {
@ -712,7 +735,9 @@ function layoutLabels(pins, project) {
// sheet's largest index. `pins.length` is the count for the whole sheet. // sheet's largest index. `pins.length` is the count for the whole sheet.
const MAXNUM_L = pins.length; const MAXNUM_L = pins.length;
col.forEach((it) => { it.maxNum = MAXNUM_L; }); col.forEach((it) => { it.maxNum = MAXNUM_L; });
const MAXF = SPARSE ? 19 : 15; // 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.
const MAXF = (SPARSE || MAP_ONLY) ? 19 : 15;
// AVAIL IS THE COLUMN, AND THE NAME DOES NOT GET ALL OF IT. // 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 // 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 // map-facing edge (`nameEdge = tx ± numColW`) but does not shrink its budget by
@ -867,7 +892,9 @@ async function renderPoster(zone, pins, zoneFC) {
// derived from the prediction, so a smaller header automatically yields a // 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 // taller map — and the label columns re-pitch to the same band, which is what
// spaces the names out to match. // spaces the names out to match.
setMapBox(measureHeader(zone, pins, ctry, prov)); // 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 // bbox: prefer resolved polygons; fallback to pins
let bbox; let bbox;
@ -2151,7 +2178,9 @@ async function renderPoster(zone, pins, zoneFC) {
// did once: the last "Ways to use this map" line landed on the map frame and the // 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 // 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. // showed it. This turns that class of regression into a build error.
if (HEAD_BOTTOM > HEADER) { // 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( throw new Error(
`[generate] header overflows the map frame: content ends at y=${HEAD_BOTTOM} ` `[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.` + `but HEADER=${HEADER} (zone ${zone.zoneKey}). Raise HEADER or tighten the header copy.`
@ -2268,7 +2297,8 @@ async function renderPoster(zone, pins, zoneFC) {
// The reference baseline sits at VERSE_RULE_Y + 44; the attribution line is at // 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 // 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. // real limit is +56, not the +46/PH-8 pair that was guarding an imagined 8px.
if (VERSE_RULE_Y + 56 > PH) { // 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( throw new Error(
`[generate] below-map block runs off the sheet: verse rule would sit at ` `[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}). ` + `y=${VERSE_RULE_Y.toFixed(0)} on a ${PH}px sheet (zone ${zone.zoneKey}). `
@ -2377,7 +2407,15 @@ async function renderPoster(zone, pins, zoneFC) {
} }
} }
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${PW} ${PH}" width="${PW}" height="${PH}"> // data-sheet="map-only" tells the packet this sheet reserves the top band for the packet's own
// title (packet-render.js liftMap), as opposed to a poster that carries its own.
// ★ EVERYTHING FROM THE TITLE TO THE CLOSING VERSE, AND THE WHITE EDGE, IS POSTER FURNITURE, and a
// map-only sheet (packet page 1) omits all of it: the title, the facts table and vision columns
// (headBlock), the goal question and three networks (footBlock), and the verse. The packet supplies
// its own title in the reserved band, in the reader's language.
// ⚠ These notes are JS comments, NOT <!-- --> inside the literal: an HTML comment there is EMITTED
// into every poster, which made the default 3x4 output differ from before this mode existed.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${PW} ${PH}" width="${PW}" height="${PH}"${MAP_ONLY ? ' data-sheet="map-only"' : ''}>
<rect width="${PW}" height="${PH}" fill="var(--paper)"/> <rect width="${PW}" height="${PH}" fill="var(--paper)"/>
<defs> <defs>
<!-- Two-part shadow: a tight dark edge for definition, plus a wide soft one <!-- Two-part shadow: a tight dark edge for definition, plus a wide soft one
@ -2442,7 +2480,7 @@ async function renderPoster(zone, pins, zoneFC) {
At y=46 its cap-height started ~32 — only 6px clear of the 26px border, so At y=46 its cap-height started ~32 — only 6px clear of the 26px border, so
it read as pinned to the edge. The band runs 26..(title cap-top ~78), so it read as pinned to the edge. The band runs 26..(title cap-top ~78), so
its optical middle is ~57. --> its optical middle is ~57. -->
<text x="${PW / 2}" y="57" text-anchor="middle" class="h1over">${esc(POSTER_TEXT.kicker || 'Unengaged People Groups')}</text> ${MAP_ONLY ? '' : `<text x="${PW / 2}" y="57" text-anchor="middle" class="h1over">${esc(POSTER_TEXT.kicker || 'Unengaged People Groups')}</text>
<text x="${PW / 2}" y="104" text-anchor="middle" class="h1" font-size="${H1_SIZE}"><tspan class="h1qual">${esc(POSTER_TEXT.titleQualifier || 'Unengaged of')}</tspan> ${esc(title)}</text> <text x="${PW / 2}" y="104" text-anchor="middle" class="h1" font-size="${H1_SIZE}"><tspan class="h1qual">${esc(POSTER_TEXT.titleQualifier || 'Unengaged of')}</tspan> ${esc(title)}</text>
<!-- (The series line is emitted INSIDE headBlock, not here. Absolutely placing <!-- (The series line is emitted INSIDE headBlock, not here. Absolutely placing
it at a fixed y while the scripture flowed from headY is what made the it at a fixed y while the scripture flowed from headY is what made the
@ -2466,7 +2504,7 @@ async function renderPoster(zone, pins, zoneFC) {
whole thing reads as ONE centred line. It was left-aligned with the ref whole thing reads as ONE centred line. It was left-aligned with the ref
pushed to the right margin, which split it into two objects at opposite pushed to the right margin, which split it into two objects at opposite
ends of the sheet rather than one closing statement. --> ends of the sheet rather than one closing statement. -->
<text x="${PW / 2}" y="${VERSE_RULE_Y + 30}" text-anchor="middle" class="verse">&#8220;${esc(verse.text)}&#8221;<tspan class="vref"> ${esc(verse.ref)}</tspan></text> <text x="${PW / 2}" y="${VERSE_RULE_Y + 30}" text-anchor="middle" class="verse">&#8220;${esc(verse.text)}&#8221;<tspan class="vref"> ${esc(verse.ref)}</tspan></text>`}
<!-- GENERATION DATE, bottom-left. Deliberately NO hard counts of the global <!-- GENERATION DATE, bottom-left. Deliberately NO hard counts of the global
unengaged total on this poster: that number changes as research updates, and unengaged total on this poster: that number changes as research updates, and
a printed figure would silently go stale on a wall. What CAN be stated a printed figure would silently go stale on a wall. What CAN be stated
@ -2475,8 +2513,8 @@ async function renderPoster(zone, pins, zoneFC) {
<!-- THE WHITE SHEET EDGE. Drawn last so it trims anything reaching the paper's <!-- THE WHITE SHEET EDGE. Drawn last so it trims anything reaching the paper's
edge. A stroked rect inset by half its own width paints a band exactly edge. A stroked rect inset by half its own width paints a band exactly
SHEET_EDGE wide around the whole poster. --> SHEET_EDGE wide around the whole poster. -->
<rect x="${SHEET_EDGE / 2}" y="${SHEET_EDGE / 2}" width="${PW - SHEET_EDGE}" height="${PH - SHEET_EDGE}" ${MAP_ONLY ? '' : `<rect x="${SHEET_EDGE / 2}" y="${SHEET_EDGE / 2}" width="${PW - SHEET_EDGE}" height="${PH - SHEET_EDGE}"
fill="none" stroke="#FFFFFF" stroke-width="${SHEET_EDGE}"/> fill="none" stroke="#FFFFFF" stroke-width="${SHEET_EDGE}"/>`}
</svg>`; </svg>`;
const html = `<!doctype html><html><head><meta charset="utf-8"> const html = `<!doctype html><html><head><meta charset="utf-8">
@ -2933,13 +2971,14 @@ for (const z of zones) {
continue; continue;
} }
const { html, title, octiOK, crossings, diagFrac } = rendered; const { html, title, octiOK, crossings, diagFrac } = rendered;
const file = `${z.zoneKey}.html`; const file = `${z.zoneKey}${FILE_SUFFIX}.html`;
writeFileSync(join(POSTERS, file), html); writeFileSync(join(POSTERS, file), html);
manifest.push({ zoneKey: z.zoneKey, title, count: pins.length, octilinear: octiOK, crossings, diagFrac, html: `posters/${file}`, png: `posters/${z.zoneKey}.png` }); manifest.push({ zoneKey: z.zoneKey, title, count: pins.length, octilinear: octiOK, crossings, diagFrac, html: `posters/${file}`, png: `posters/${z.zoneKey}${FILE_SUFFIX}.png` });
made++; made++;
console.log(` ✓ ${z.zoneKey.padEnd(34)} ${String(pins.length).padStart(3)} pins octi=${octiOK?'Y':'N'} d=${diagFrac} cross=${crossings}`); console.log(` ✓ ${z.zoneKey.padEnd(34)} ${String(pins.length).padStart(3)} pins octi=${octiOK?'Y':'N'} d=${diagFrac} cross=${crossings}`);
} }
if (!ONLY) writeFileSync(join(OUT, 'manifest.json'), JSON.stringify({ generated: 'live', count: manifest.length, posters: manifest }, null, 2)); // The poster manifest is what the wall reads; a paper-format run writes its own beside it.
if (!ONLY) writeFileSync(join(OUT, `manifest${FILE_SUFFIX}.json`), JSON.stringify({ generated: 'live', count: manifest.length, posters: manifest }, null, 2));
console.log(`\n${made} poster(s) → ${POSTERS}${ONLY ? ' (ONLY=' + ONLY + ')' : ', manifest.json written'}`); console.log(`\n${made} poster(s) → ${POSTERS}${ONLY ? ' (ONLY=' + ONLY + ')' : ', manifest.json written'}`);
// FAIL THE RUN, but only after every good poster is on disk. A non-zero exit keeps // FAIL THE RUN, but only after every good poster is on disk. A non-zero exit keeps
// this a build error rather than a warning somebody scrolls past, and the census // this a build error rather than a warning somebody scrolls past, and the census

View file

@ -85,7 +85,11 @@ let bytes = 0;
const skipped = []; const skipped = [];
for (const file of readdirSync(SRC)) { for (const file of readdirSync(SRC)) {
if (!file.endsWith('.html')) continue; if (!file.endsWith('.html')) continue;
const id = file.slice(0, -5); // ★ PAPER FORMATS CARRY A SUFFIX (generate.mjs FILE_SUFFIX): <zone>.packet.html is page 1 of the
// prayer packet, <zone>.letter.html the poster on letter paper. The zone id is what precedes it —
// taking everything before ".html" as the id reported every packet sheet as an unknown zone and
// silently skipped it.
const id = file.slice(0, -5).replace(/\.(packet|letter)$/, '');
if (ONLY && id !== ONLY) continue; if (ONLY && id !== ONLY) continue;
if (!zoneIds.has(id)) { skipped.push(id); continue; } if (!zoneIds.has(id)) { skipped.push(id); continue; }
const html = readFileSync(join(SRC, file), 'utf8'); const html = readFileSync(join(SRC, file), 'utf8');