fix(posters): one bad zone no longer costs the other 28
Some checks are pending
build-gate / build (push) Waiting to run

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/<id>.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.
This commit is contained in:
doxa-maps-88655c70 2026-09-24 03:20:27 +00:00
parent bc06a03ec6
commit 52b7a110d8
2 changed files with 124 additions and 1 deletions

View file

@ -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);
}

View file

@ -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/<id>.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/<id>.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.');
}