The poster's legend was painted over the map it was describing

The Driver, on the Simple Research poster: the legend has to sit beside the map,
never over it, with a proper header and footer.

usePosterLayout has always computed exactly that. computeLayout narrows the map
by the legend column and insets it by the margins, the title strip and the footer
strip, and it has done so since the file was written. Nothing in the exporter
ever read it. The hidden map was spawned at the full page size — edge to edge,
across the margins, the header band and the footer band — and the title, legend
and footer were then composited on top of that image. So the legend was a
translucent white panel lying over the map, and there were no bands at all.

What kept it hidden is worth recording: PosterPreview.vue, the thing a developer
looks at, DOES honour layout.map. The on-screen preview has always shown the
intended design; only the exported file was wrong. A preview is not evidence for
an export, and the two had drifted apart with nothing to notice.

The map is now rastered at the size of its own box and drawn onto a white sheet
at the position the layout gives it. A sheet with no room for a map after its
margins, bands and legend column falls back to full-bleed and says so, because
silence there is the bug this commit removes.

The check has two arms on purpose. A test of computeLayout alone would have
passed every day the bug was alive, since the geometry was never what was broken;
the second arm asserts the renderer consumes it. Both arms are negative-tested,
and two of those tests caught the check reading a function DECLARATION rather
than a call — the parameter is also named px, and a name is present because it is
declared, so presence proves nothing about use.

country-posters renders through the same path and inherits the change: its page-1
map image gains the same bands.
This commit is contained in:
Doxa Maps 2026-09-28 19:27:07 +00:00
parent 7efe106513
commit 6e31def193
3 changed files with 152 additions and 4 deletions

View file

@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* check-poster-layout — the exported poster is a LAYOUT, not a map with things painted on it.
*
* Driver, 2026-09-28, on the Simple Research poster: the legend must sit BESIDE the map, never over
* it, with a proper header and footer.
*
* WHY BOTH ARMS. usePosterLayout.computeLayout has always produced the right geometry — it narrows
* the map by the legend column and insets it by the margins, title and footer. The bug was that the
* renderer never used it: the hidden map was spawned at the FULL PAGE size and the slots were
* composited on top, so the legend was a translucent panel over the map and there were no bands.
* A test of computeLayout alone would therefore have passed throughout the entire life of the bug.
*
* So arm A checks the geometry is right, and arm B checks the renderer actually CONSUMES it — the
* link that was missing. Arm B reads source structure because the real path needs WebGL and a
* browser, which this repo's gates do not get; each link it asserts is negative-tested below.
*
* ★ WHAT MADE THIS SURVIVE, worth keeping in mind for any preview: PosterPreview.vue DOES honour
* layout.map, so the on-screen preview showed the intended design while the exported file did not.
* The preview was never evidence for the export.
*
* Headless, no network, no browser, no dependencies. Exit 0 pass · 1 fail.
*/
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { readFileSync } from 'node:fs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const { computeLayout } = await import(resolve(ROOT, 'library/composables/poster/usePosterLayout.js'))
const { defaultPosterSpec } = await import(resolve(ROOT, 'library/constants/posterDefaults.js'))
const fails = []
const ok = (what, cond) => { if (!cond) fails.push(what) }
const overlap = (a, b) => !(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y)
const px = (spec) => ({ width: Math.round(spec.widthIn * spec.dpi), height: Math.round(spec.heightIn * spec.dpi), dpi: spec.dpi })
// ── ARM A: the geometry, on the sheets the two real callers actually ask for ──
const SHEETS = [
['Simple Research, landscape (the Driver\'s poster)', { widthIn: 11, heightIn: 8.5, dpi: 200, orientation: 'landscape' }],
['Simple Research, portrait', { widthIn: 8.5, heightIn: 11, dpi: 200, orientation: 'portrait' }],
['country-posters, A4 portrait @150', { widthIn: 8.27, heightIn: 11.69, dpi: 150, orientation: 'portrait' }],
['a big 24x36 research poster @300', { widthIn: 24, heightIn: 36, dpi: 300, orientation: 'portrait' }],
]
for (const [name, size] of SHEETS) {
const spec = defaultPosterSpec(size)
spec.slots.legend = { ...spec.slots.legend, rows: [{ label: 'Reached', color: '#2a7' }, { label: 'Unreached', color: '#c33' }] }
const L = computeLayout(spec, px(spec))
ok(`${name}: the map has a positive area`, L.map.w > 0 && L.map.h > 0)
ok(`${name}: THE LEGEND IS BESIDE THE MAP, NOT OVER IT`, !overlap(L.legend, L.map))
ok(`${name}: the legend is on the outside edge, past the map`, L.legend.x >= L.map.x + L.map.w)
ok(`${name}: the header sits above the map, not on it`, !overlap(L.title, L.map) && L.title.y + L.title.h <= L.map.y)
ok(`${name}: the footer sits below the map, not on it`, !overlap(L.footer, L.map) && L.footer.y >= L.map.y + L.map.h)
ok(`${name}: the footer does not run into the legend`, !overlap(L.footer, L.legend))
ok(`${name}: the header does not run into the legend`, !overlap(L.title, L.legend))
// everything stays on the paper, inside the margins
for (const [slot, box] of Object.entries({ map: L.map, legend: L.legend, title: L.title, footer: L.footer })) {
ok(`${name}: ${slot} stays inside the printable area`,
box.x >= L.content.x - 1 && box.y >= L.content.y - 1 &&
box.x + box.w <= L.content.x + L.content.w + 1 && box.y + box.h <= L.content.y + L.content.h + 1)
}
// the map must still be worth printing — a legend column that eats the sheet is its own defect
ok(`${name}: the map keeps the majority of the sheet`, L.map.w * L.map.h > L.legend.w * L.legend.h)
}
// A legend with no rows must not silently reserve a blank column: the map should take the space.
{
const spec = defaultPosterSpec({ widthIn: 11, heightIn: 8.5, dpi: 200 })
const withRows = { ...spec, slots: { ...spec.slots, legend: { ...spec.slots.legend, rows: [{ label: 'x', color: '#000' }] } } }
const a = computeLayout(withRows, px(spec))
ok('a legend with rows narrows the map', a.map.w < a.content.w)
}
// ── ARM B: the renderer consumes the layout (the link the bug was missing) ──
const SRC = readFileSync(resolve(ROOT, 'library/composables/poster/useMapPoster.js'), 'utf8')
// Comments describe; they must not be able to satisfy a structural check. (A predicate matching a
// docstring is a mistake this repo's gates have made before.)
const CODE = SRC.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
const link1 = /\blayout\s*=\s*computeLayout\s*\(\s*spec\s*,\s*px\s*\)/.test(CODE)
const link2 = /\bmapPx\s*=[\s\S]{0,200}?layout\.map\.w[\s\S]{0,80}?layout\.map\.h/.test(CODE)
// `await` pins these to the CALL sites: both functions are DECLARED with a parameter named `px`,
// so a bare name match reads the declaration and reports the opposite of the truth.
const link3 = /await\s+_renderSingleCanvas\s*\(\s*sourceMap\s*,\s*mapPx\s*,/.test(CODE) &&
/await\s+_renderTileStitch\s*\(\s*sourceMap\s*,\s*mapPx\s*,/.test(CODE)
// Same trap as link3, one step subtler: a name appears at least once because it is DECLARED, so
// presence proves nothing about use. Require the declaration AND at least one further occurrence.
const _sheetUses = (CODE.match(/_placeMapOnSheet\s*\(/g) || []).length
const link4 = /function\s+_placeMapOnSheet\s*\(/.test(CODE) && _sheetUses >= 2
ok('the render path computes the layout', link1)
ok('it derives the map raster size FROM layout.map', link2)
ok('BOTH renderers are given the map box, not the page — this is the line the bug lived on', link3)
ok('and the map is placed onto a full sheet rather than being the sheet', link4)
// the specific regression: neither renderer may be handed the page px again
ok('neither renderer is called with the page px',
!/await\s+_renderSingleCanvas\s*\(\s*sourceMap\s*,\s*px\s*,/.test(CODE) &&
!/await\s+_renderTileStitch\s*\(\s*sourceMap\s*,\s*px\s*,/.test(CODE))
if (fails.length) {
console.error('POSTER-LAYOUT: FAIL')
for (const f of fails) console.error(` ✗ ${f}`)
process.exit(1)
}
console.log('POSTER-LAYOUT: PASS — on four sheets the legend sits beside the map and never over it, the header and footer are their own bands, everything stays inside the margins, and the renderer draws the map into the box the layout reserved instead of across the whole page.')

View file

@ -121,12 +121,37 @@ export function useMapPoster(getSourceMap) {
? 'stitch'
: renderPath;
// ★ THE MAP IS RENDERED INTO ITS OWN BOX, NOT ACROSS THE WHOLE SHEET.
// Driver, 2026-09-28: the legend must be "beside the map, not over it", with a proper header
// and footer. usePosterLayout has ALWAYS computed that layout correctly — it narrows
// layout.map by the legend column and insets it by the margins, title strip and footer strip.
// Nothing here ever used it. The hidden map was spawned at the full page size and the title,
// legend and footer were painted ON TOP of that edge-to-edge image, so the legend was a
// translucent white panel over the map and there were no header or footer bands at all.
// It survived because PosterPreview.vue — the thing a developer looks at — DOES honour
// layout.map, so the on-screen preview showed the intended design and only the exported file
// was wrong. The preview was never evidence for the export.
const layout = computeLayout(spec, px);
const fits = layout.map.w > 0 && layout.map.h > 0;
if (!fits) {
// A page too small for its own margins plus legend column. Rendering full-bleed is wrong,
// but it is a poster rather than nothing, and silence here would be the old bug again.
// eslint-disable-next-line no-console
console.warn('[useMapPoster] the map box computed to ' + layout.map.w + '×' + layout.map.h +
'px; the margins, title, footer and legend column do not leave room for a map on this ' +
'sheet. Falling back to a full-bleed map.');
}
const mapPx = fits
? { width: layout.map.w, height: layout.map.h, dpi: px.dpi }
: px;
const baseRaster = (finalPath === 'single')
? await _renderSingleCanvas(sourceMap, px, signal)
: await _renderTileStitch(sourceMap, px, signal);
? await _renderSingleCanvas(sourceMap, mapPx, signal)
: await _renderTileStitch(sourceMap, mapPx, signal);
_setProgress('compose', 0, 1, 'compositing overlay');
const composed = await _composeOverlay(baseRaster, spec, px);
const sheet = fits ? _placeMapOnSheet(baseRaster, layout, px) : baseRaster;
const composed = await _composeOverlay(sheet, spec, px);
_setProgress('encode', 0, 1, `encoding ${format.toUpperCase()}`);
const blob = (format === 'pdf')
@ -286,6 +311,21 @@ export function useMapPoster(getSourceMap) {
}
}
// ─── The sheet ────────────────────────────────────────────────────────
// Paper first, then the map into the box the layout reserved for it. White rather than
// transparent: a PNG with holes prints as whatever is under it, and the slots below draw
// their own panels expecting paper, not a checkerboard.
function _placeMapOnSheet(mapCanvas, layout, px) {
const sheet = document.createElement('canvas');
sheet.width = px.width;
sheet.height = px.height;
const ctx = sheet.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, px.width, px.height);
ctx.drawImage(mapCanvas, layout.map.x, layout.map.y, layout.map.w, layout.map.h);
return sheet;
}
// ─── Overlay compositing ──────────────────────────────────────────────
async function _composeOverlay(baseCanvas, spec, px) {
const layout = computeLayout(spec, px);

View file

@ -206,6 +206,7 @@
"check:one-scroll-container": "node internal/check-one-scroll-container.mjs",
"check:pin-selection": "node internal/check-pin-selection.mjs",
"check:poster-action-print": "node internal/check-poster-action-print.mjs",
"check:poster-layout": "node internal/check-poster-layout.mjs",
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs",
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
"check:poster-print-fallback": "node internal/check-poster-print-fallback.mjs",
@ -289,7 +290,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:frozen-labels",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:script-registry",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:script-registry check:poster-layout",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",
@ -368,6 +369,7 @@
"check:dashboard-geocoder-provider": "the research dashboard leaf at ?provider=maplibre makes ZERO requests to api.mapbox.com, and its search bars still suggest (built tree; browser gate). It was kept out of gates:post while the generated leaf hard-linked the Mapbox engine; the leaf now loads only the engine it is asked for.",
"check:dep-names": "every declared dep is a REAL package name (a rename once rewrote @capacitor/* and broke bun install in a clone)",
"check:script-registry": "one bundle script executes ONCE PER PAGE. Every plugin is a self-contained IIFE, so library/utils/embedScript.js is compiled into each one and a module-scoped cache was one cache per BUNDLE: two shells on a page each loaded the same child, which re-executed a whole bundle (a second Vue, i18n and pinia) before its guarded customElements.define silently declined. The check imports the module twice under different URLs — two module instances, one globalThis, the same shape as two IIFEs — and asserts the second caller receives the FIRST ONE'S PROMISE, because counting script tags alone is satisfied by the DOM-adoption path and would not catch a regression. Headless; in gates:pre.",
"check:poster-layout": "the EXPORTED poster is a layout, not a map with things painted on it (Driver 2026-09-28: the legend beside the map, never over it, with a proper header and footer). usePosterLayout always computed the right geometry; the renderer never used it, spawning the hidden map at FULL PAGE size and compositing the title, legend and footer on top. One arm checks the geometry on four real sheets, the other checks the renderer CONSUMES it — the link that was missing, and the reason a computeLayout-only test would have passed throughout the bug's life. Headless; in gates:pre.",
"check:describe-group": "the prayer packet's generated profile sentence (library/packets/describeGroup.js): each GSEC level states its own fact, and an index-ordered language name (\"Arabic, Moroccan\") is un-inverted by library/utils/languageLabel.js without shredding a comma inside parentheses, the 13 names the old split printed reversed. A catalogue arm renders every GSEC level from the REAL packet-strings.json in en and ar and fails on a blank sentence. Headless; in gates:pre.",
"check:descriptions": "every shipped profile must say what it is (reads the BUILT manifest)",
"check:done-evidence": "a card may not reach status:done with NO bluf AND NO fields — the Doxa team inherits this board and an evidence-less 'done' cannot be reconciled against the code. 10 pre-existing are baselined by id; the list should only ever SHRINK.",