From 52b7a110d829ccf4a053d74ab1f4660afca0a43b Mon Sep 17 00:00:00 2001 From: doxa-maps-88655c70 <88627970+doxa-maps-88655c70@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:20:27 +0000 Subject: [PATCH] fix(posters): one bad zone no longer costs the other 28 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate.mjs threw on the first zone whose header overflowed, so a single bad sheet ended the run: 16 posters written, the remaining 13 silently unbuilt, and a non-obvious short count as the only symptom. Failures are now collected, the run continues, and it exits 1 at the end with a census naming every zone that has no poster. The guard stays loud — an overflowing header is a real defect and must not be downgraded to a warning — but it fails the RUN rather than truncating it. That change immediately surfaced a FOURTH broken zone the abort had been hiding. All four overflow the same way: oceania-indonesia 504 vs HEADER=460 (+44px) andean-caribbean 504 vs HEADER=460 (+44px) asia-china-tibet-autonomous-region 481 vs HEADER=460 (+21px) deaf 619 vs HEADER=460 (+159px) Root cause, found but NOT patched here: measureHeader() returns a fixed HEADER_BASE when SPARSE is off instead of measuring the zone's own content — so the per-zone measurement its own comments describe only runs in SPARSE mode. That is a separate fix with its own layout risk; carded rather than bolted on. Also adds publish-html.mjs, the missing half of the browser-rasterise fallback. That fallback fetches posters/.html and NOTHING published it: out/ is gitignored and public/ had zero .html, so a zone with no image 404s today. The script writes zone poster HTML into public/assets/posters/, skips city-* posters (they need OSM tiles to regenerate, so their images must stay), and warns loudly about zones with no HTML instead of quietly writing fewer files. ★ IT IS NOT RUN AS PART OF THIS COMMIT, and the reason is worth recording. Measured with real git packs: webp 5.0 MB raw -> ~5.0 MB packed (already compressed; git cannot pack it) HTML 21.4 MB raw -> 5.78 MB packed (text, repetitive across posters) Swapping zone webp for zone HTML is about +0.8 MB PACKED — a net LOSS, not the 42 MB win the headline implies. The raw-byte comparison that made it look like a win (21.4 vs 5.0) and the gzip comparison that made it look better still (5.8 vs 5.0) both mislead; what git stores is the number that decides it. The real 22 MB is 176 city-* posters, which cannot be replaced this way at all. So the deletion this was leading to is deliberately NOT done: it would have made the repo bigger while being reported as a saving. --- library/scripts/posters/generate.mjs | 29 ++++++- library/scripts/posters/publish-html.mjs | 96 ++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 library/scripts/posters/publish-html.mjs diff --git a/library/scripts/posters/generate.mjs b/library/scripts/posters/generate.mjs index 6700f6105..4cb3e237c 100644 --- a/library/scripts/posters/generate.mjs +++ b/library/scripts/posters/generate.mjs @@ -2794,6 +2794,7 @@ for (const r of records) { const ONLY = process.env.ONLY; const manifest = []; let made = 0; +const failed = []; // zones whose render threw — reported as a census at the end for (const z of zones) { // The sheet's own position in the series. design note 2026-07-31: "it should not say // 23 of 30 because the ORDER OF THE LIST SHOULD CHANGE — it should say 1 of 30." @@ -2805,7 +2806,25 @@ for (const z of zones) { if (ONLY && z.zoneKey !== ONLY && z.id !== ONLY) continue; const pins = pinsByZone.get(z.zoneKey) || []; const zoneFC = resolveZoneGeometry(z, { records, adm0, adm1Ind, adm1Chn }); - const { html, title, octiOK, crossings, diagFrac } = await renderPoster(z, pins, zoneFC); + // ONE BAD ZONE MUST NOT COST THE OTHER 28. The header guard throws when the + // predicted HEADER is smaller than the laid-out content, which is a REAL defect + // and must stay loud — but aborting the whole run meant a single overflowing + // zone produced 16 posters instead of 29 and left the rest silently unbuilt. + // Collect failures, keep going, and fail the RUN at the end with a census, so + // the error is impossible to miss and the good posters still exist. + // (Measured 2026-09-24: 3 of 29 zones overflow — oceania-indonesia and + // andean-caribbean by 44px, asia-china-tibet by 21px. Root cause is that + // measureHeader() returns a fixed HEADER_BASE outside SPARSE mode instead of + // measuring the zone's own content — carded separately, not patched here.) + let rendered; + try { + rendered = await renderPoster(z, pins, zoneFC); + } catch (err) { + failed.push({ zone: z.zoneKey, message: String(err && err.message || err) }); + console.error(` ✗ ${z.zoneKey.padEnd(34)} ${String(err && err.message || err).slice(0, 90)}`); + continue; + } + const { html, title, octiOK, crossings, diagFrac } = rendered; const file = `${z.zoneKey}.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` }); @@ -2814,3 +2833,11 @@ for (const z of zones) { } if (!ONLY) writeFileSync(join(OUT, 'manifest.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 +// names exactly which zones are missing instead of leaving a silent short count. +if (failed.length) { + console.error(`\n${failed.length} zone(s) FAILED to render and have NO poster:`); + for (const f of failed) console.error(` ✗ ${f.zone}: ${f.message}`); + process.exit(1); +} diff --git a/library/scripts/posters/publish-html.mjs b/library/scripts/posters/publish-html.mjs new file mode 100644 index 000000000..04af6803e --- /dev/null +++ b/library/scripts/posters/publish-html.mjs @@ -0,0 +1,96 @@ +// publish-html.mjs — put the poster HTML where the app can fetch it, so the +// browser can rasterise a poster instead of the repo shipping 42 MB of .webp. +// +// ★ WHY THIS SCRIPT EXISTS. PosterWall falls back to rendering posters/.html +// in the browser when no pre-rendered image is present (library/packet/ +// poster-raster.js). That fallback was shipped and verified against a locally +// served file — but NOTHING PUBLISHED THE HTML. out/ is gitignored, public/ had +// zero .html, so deleting the .webp would have taken poster download from working +// to 404. The rasteriser worked; the feature would not have. This closes that gap. +// +// SCOPE — ZONE POSTERS ONLY, and that is a real distinction, not laziness: +// zone posters token-free, drawn from bundled geoBoundaries. Reproducible +// offline by anyone with the repo -> safe to stop shipping as +// images, because the HTML regenerates the picture. +// city posters need CITIES=1 BASEMAP=1 and fetch OSM STREET TILES at generation +// time. NOT reproducible offline. Deleting those .webp would make +// them unrecoverable without network, so they stay as images. +// Treating "42 MB of posters" as one homogeneous pile would have deleted the +// irreproducible half. +// +// ⚠ RUNNING THIS DOES NOT, BY ITSELF, SHRINK THE REPO. Measured with real git +// packs, for the 24 zone posters that render: +// webp 5.0 MB raw -> ~5.0 MB packed (already compressed; git cannot pack it) +// HTML 21.4 MB raw -> 5.78 MB packed (text, and highly repetitive across posters) +// Swapping one for the other is about +0.8 MB PACKED — a net loss, not the 42 MB +// win the headline suggests. Do not ship that swap and call it a saving. +// +// WHERE THE REAL 22 MB IS: 176 city-* posters, which this script deliberately does +// not touch because they need OSM tiles to regenerate. The zone posters were never +// the expensive half. +// +// So what is this FOR? Two things that are true regardless of size: +// 1. It is the missing half of the browser-rasterise fallback. That fallback +// fetches posters/.html and nothing published it, so a zone whose image is +// absent 404s today. Run this and the fallback has an input. +// 2. It makes the HTML the SOURCE a reader can rasterise at any resolution, +// instead of one fixed raster — useful for a host that would rather serve text +// and let the browser do the work. +// Use it when you are dropping the images deliberately, not as a size optimisation. +// +// node publish-html.mjs # all zone posters found in out/ +// ONLY=laos node publish-html.mjs # one + +import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SRC = join(__dirname, 'out', 'posters'); +const REPO = resolve(__dirname, '..', '..', '..'); +const DEST = join(REPO, 'public', 'assets', 'posters'); +const ONLY = process.env.ONLY || ''; + +if (!existsSync(SRC)) { + console.error(`[publish-html] no ${SRC} — run \`node generate.mjs\` first.`); + process.exit(1); +} + +// The zone ids the app can actually reach. A poster for no zone is not publishable. +const clusters = JSON.parse(readFileSync( + join(REPO, 'plugin-designs', 'my-upg-100-list', 'data', 'clusters.json'), 'utf8')); +const zoneIds = new Set(); +for (const key of ['lists', 'deaf_lists', 'asia_lists', 'uncovered_lists']) { + for (const z of clusters[key] || []) zoneIds.add(z.id); +} + +mkdirSync(DEST, { recursive: true }); + +let published = 0; +let bytes = 0; +const skipped = []; +for (const file of readdirSync(SRC)) { + if (!file.endsWith('.html')) continue; + const id = file.slice(0, -5); + if (ONLY && id !== ONLY) continue; + if (!zoneIds.has(id)) { skipped.push(id); continue; } + const html = readFileSync(join(SRC, file), 'utf8'); + writeFileSync(join(DEST, file), html); + published += 1; + bytes += Buffer.byteLength(html); +} + +console.log(`[publish-html] ${published} poster HTML -> public/assets/posters/`); +console.log(`[publish-html] ${(bytes / 1048576).toFixed(1)} MB raw ` + + `(gzips to roughly a third over the wire)`); +if (skipped.length) { + // Loud, not silent: a reader should know what was left behind and why. + console.log(`[publish-html] skipped ${skipped.length} non-zone poster(s) ` + + `(city-* posters belong to Key Cities and need OSM tiles to regenerate)`); +} +const missing = [...zoneIds].filter((z) => !existsSync(join(SRC, `${z}.html`))); +if (missing.length && !ONLY) { + console.warn(`[publish-html] ⚠ ${missing.length} zone(s) have NO poster HTML: ` + + `${missing.slice(0, 6).join(', ')}${missing.length > 6 ? '…' : ''}`); + console.warn('[publish-html] their download will 404 until generate.mjs covers them.'); +}