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).`)
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))
// 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))
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` : ''}`)
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,
// correctly-scaled sheet instead of "fit to page" guesswork.
letter: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5 },
// ★ PACKET PAGE 1 — THE MAP ALONE, ON A VERTICAL PAGE. Not the poster: page 1 of a prayer packet is
// a lightweight one-pager showing the geography and every pin with its numbered name, and nothing of
// the poster's furniture (the facts table, the two vision columns, the three networks, the closing
// verse). Those are English prose baked into the artwork, which is also why the poster could never be
// page 1 of a translated packet: the packet draws its OWN title into the reserved band, from the
// locale's strings, and this sheet supplies only what needs no translation — the map, the pins and
// the people-group names.
//
// SAME 1500x1941 AS `letter` AND AS THE PACKET'S OWN SHEET (packet-render.js W/H), so it drops onto
// page 1 at 1:1 with no rescale. `header` is the band left EMPTY for the packet's title block and
// `footer` the band for its fine print; everything between goes to the map and its label columns —
// about 1,600px of height against the letter sheet's ~1,200, which is what lets the names grow.
// Output: <zone>.packet.html beside the posters (see FILE_SUFFIX), never over them.
packet: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5, mapOnly: true, header: 250, footer: 90 },
};
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
// sheet byte-for-byte — print determinism matters more than always-now.
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
// 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 = 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
// 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
@ -470,7 +493,7 @@ 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 = SPARSE ? 355 : 460;
let HEADER = MAP_ONLY ? FMT.header : SPARSE ? 355 : 460;
let MAP_Y = HEADER;
let MAP_H = PH - HEADER - FOOTER;
const setMapBox = (headerPx) => {
@ -712,7 +735,9 @@ function layoutLabels(pins, project) {
// 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; });
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.
// 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
@ -867,7 +892,9 @@ async function renderPoster(zone, pins, zoneFC) {
// 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.
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
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
// 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.
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(
`[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.`
@ -2268,7 +2297,8 @@ async function renderPoster(zone, pins, zoneFC) {
// 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.
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(
`[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}). `
@ -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)"/>
<defs>
<!-- 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
it read as pinned to the edge. The band runs 26..(title cap-top ~78), so
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>
<!-- (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
@ -2466,7 +2504,7 @@ async function renderPoster(zone, pins, zoneFC) {
whole thing reads as ONE centred line. It was left-aligned with the ref
pushed to the right margin, which split it into two objects at opposite
ends of the sheet rather than one closing statement. -->
<text x="${PW / 2}" y="${VERSE_RULE_Y + 30}" text-anchor="middle" class="verse">&#8220;${esc(verse.text)}&#8221;<tspan class="vref"> ${esc(verse.ref)}</tspan></text>
<text x="${PW / 2}" y="${VERSE_RULE_Y + 30}" text-anchor="middle" class="verse">&#8220;${esc(verse.text)}&#8221;<tspan class="vref"> ${esc(verse.ref)}</tspan></text>`}
<!-- GENERATION DATE, bottom-left. Deliberately NO hard counts of the global
unengaged total on this poster: that number changes as research updates, and
a printed figure would silently go stale on a wall. What CAN be stated
@ -2475,8 +2513,8 @@ async function renderPoster(zone, pins, zoneFC) {
<!-- THE WHITE SHEET EDGE. Drawn last so it trims anything reaching the paper's
edge. A stroked rect inset by half its own width paints a band exactly
SHEET_EDGE wide around the whole poster. -->
<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}"/>
${MAP_ONLY ? '' : `<rect x="${SHEET_EDGE / 2}" y="${SHEET_EDGE / 2}" width="${PW - SHEET_EDGE}" height="${PH - SHEET_EDGE}"
fill="none" stroke="#FFFFFF" stroke-width="${SHEET_EDGE}"/>`}
</svg>`;
const html = `<!doctype html><html><head><meta charset="utf-8">
@ -2933,13 +2971,14 @@ for (const z of zones) {
continue;
}
const { html, title, octiOK, crossings, diagFrac } = rendered;
const file = `${z.zoneKey}.html`;
const file = `${z.zoneKey}${FILE_SUFFIX}.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++;
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'}`);
// 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

View file

@ -85,7 +85,11 @@ let bytes = 0;
const skipped = [];
for (const file of readdirSync(SRC)) {
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 (!zoneIds.has(id)) { skipped.push(id); continue; }
const html = readFileSync(join(SRC, file), 'utf8');