Export poster: titled by the view, real legend rows, RTL text, the reader's place names, honest attribution; MapLibre style wait names its cause

This commit is contained in:
doxa-maps-88655c70 2026-09-25 15:26:54 +00:00
parent 69d898b7e1
commit 560e73abb9
4 changed files with 94 additions and 27 deletions

View file

@ -297,7 +297,8 @@ export function useMapPoster(getSourceMap) {
async function _collectSlotSvgs(spec, layout) {
const out = [];
if (spec.slots?.title) out.push({ box: layout.title, svg: _stubTitleSvg(spec, layout.title) });
if (spec.slots?.legend) out.push({ box: layout.legend, svg: _stubLegendSvg(spec, layout.legend) });
// A legend with no rows is an empty white panel over the map: leave it out.
if (spec.slots?.legend?.rows?.length) out.push({ box: layout.legend, svg: _stubLegendSvg(spec, layout.legend) });
if (spec.slots?.scaleBar) out.push({ box: layout.scaleBar, svg: _stubScaleSvg(spec, layout.scaleBar) });
if (spec.slots?.north) out.push({ box: layout.north, svg: _stubNorthSvg(spec, layout.north) });
// Footer is ALWAYS present (attribution is mandatory).
@ -388,21 +389,34 @@ async function _spawnHiddenMap(sourceMap, w, h, opts = {}) {
fadeDuration: 0,
});
// Style ready: 'style.load', or 'load', or already loaded by the time we look (an engine may finish
// a style OBJECT before a listener is attached). A style that cannot load says why ('error'), so a
// failed poster names its cause instead of a bare timeout.
let lastError = '';
await new Promise((resolve, reject) => {
let done = false;
const t = setTimeout(() => {
const finish = (err) => {
if (done) return;
done = true;
reject(new Error('useMapPoster: hidden map style.load timeout'));
}, IDLE_TIMEOUT_MS);
hiddenMap.once('style.load', () => {
if (done) return;
done = true;
clearTimeout(t);
resolve();
});
clearTimeout(t); clearInterval(poll);
err ? reject(err) : resolve();
};
const t = setTimeout(() => finish(new Error('useMapPoster: hidden map style.load timeout' + (lastError ? ' (' + lastError + ')' : ''))), IDLE_TIMEOUT_MS);
const poll = setInterval(() => { try { if (hiddenMap.isStyleLoaded()) finish(); } catch (_) { /* not yet */ } }, 100);
hiddenMap.once('style.load', () => finish());
hiddenMap.once('load', () => finish());
hiddenMap.on('error', (e) => { lastError = String(e?.error?.message || e?.message || e).slice(0, 160); });
});
// Print the place names in the language the reader sees. Mapbox localizes INSIDE the engine
// (setLanguage), so a new instance starts in the style default (English) — measured on an Arabic
// poster; carry the source map's language across. MapLibre's localized labels are expressions in
// the style itself, which the hidden map already copied.
try {
const srcLang = typeof sourceMap.getLanguage === 'function' ? sourceMap.getLanguage() : null;
if (srcLang && typeof hiddenMap.setLanguage === 'function') hiddenMap.setLanguage(srcLang);
} catch (_) { /* a label left in the default language is a nicety lost, never a failed export */ }
if (opts.preserveBounds && opts.bounds) {
hiddenMap.fitBounds(opts.bounds, { padding: 0, animate: false, duration: 0 });
}
@ -516,31 +530,49 @@ function mergeSignals(...signals) {
// These produce minimal SVG so the headless render still has *something* in
// each slot. Live preview uses the real Vue slot components instead.
// spec.dir === 'rtl' (an Arabic poster): text starts at the RIGHT edge and runs right-to-left.
// ⚠ Under direction="rtl" SVG's text-anchor is relative to the text's direction, so the right edge
// is "start", not "end" — "end" pushed every Arabic title off the poster (measured 2026-09-25).
function _rtl(spec) { return spec?.dir === 'rtl'; }
function _textAt(spec, box, inset) {
return _rtl(spec)
? `x="${box.w - inset}" text-anchor="start" direction="rtl"`
: `x="${inset}"`;
}
function _stubTitleSvg(spec, box) {
const t = (spec.slots?.title?.text || '').replace(/[<>&]/g, _esc);
const s = (spec.slots?.title?.subtitle || '').replace(/[<>&]/g, _esc);
return `
<svg xmlns="http://www.w3.org/2000/svg" width="${box.w}" height="${box.h}" viewBox="0 0 ${box.w} ${box.h}">
<rect width="100%" height="100%" fill="rgba(255,255,255,0.92)"/>
<text x="20" y="${box.h * 0.55}" font-family="Inter, system-ui, sans-serif"
<text ${_textAt(spec, box, 20)} y="${box.h * 0.55}" font-family="Inter, system-ui, sans-serif"
font-size="${Math.min(box.h * 0.5, 64)}" font-weight="700" fill="#0a0a0a">${t}</text>
${s ? `<text x="20" y="${box.h * 0.85}" font-family="Inter, system-ui, sans-serif"
${s ? `<text ${_textAt(spec, box, 20)} y="${box.h * 0.85}" font-family="Inter, system-ui, sans-serif"
font-size="${Math.min(box.h * 0.22, 28)}" fill="#444">${s}</text>` : ''}
</svg>`;
}
function _stubLegendSvg(spec, box) {
const rows = spec.slots?.legend?.rows || [];
const items = rows.slice(0, 24).map((r, i) => {
const y = 30 + i * 26;
const rows = (spec.slots?.legend?.rows || []).slice(0, 24);
const title = (spec.slots?.legend?.title || 'Legend').replace(/[<>&]/g, _esc);
const rtl = _rtl(spec);
// Type scales with the panel (it is ~600px wide at 200 DPI); the panel is only as TALL as its rows,
// so the rest of the column stays map.
const fs = Math.max(14, Math.round(box.w / 26)), line = Math.round(fs * 1.9), sw = Math.round(fs * 1.2), pad = Math.round(fs * 1.1);
const panelH = Math.min(box.h, pad * 2 + line * (rows.length + 1));
const swX = rtl ? box.w - pad - sw : pad;
const textX = rtl ? `x="${box.w - pad - sw - Math.round(fs * 0.7)}" text-anchor="start" direction="rtl"` : `x="${pad + sw + Math.round(fs * 0.7)}"`;
const items = rows.map((r, i) => {
const y = pad + line * (i + 1.5);
const c = (r.color || '#999').replace(/[<>&]/g, _esc);
const l = (r.label || '').replace(/[<>&]/g, _esc);
return `<g><rect x="14" y="${y - 14}" width="18" height="18" fill="${c}" stroke="#222" stroke-width="0.5"/><text x="42" y="${y}" font-family="Inter, sans-serif" font-size="14" fill="#111">${l}</text></g>`;
return `<g><rect x="${swX}" y="${y - sw + Math.round(fs * 0.15)}" width="${sw}" height="${sw}" rx="2" fill="${c}" stroke="#222" stroke-width="0.5"/><text ${textX} y="${y}" font-family="Inter, sans-serif" font-size="${fs}" fill="#111">${l}</text></g>`;
}).join('');
return `
<svg xmlns="http://www.w3.org/2000/svg" width="${box.w}" height="${box.h}" viewBox="0 0 ${box.w} ${box.h}">
<rect width="100%" height="100%" fill="rgba(255,255,255,0.94)" stroke="#ccc"/>
<text x="14" y="20" font-family="Inter, sans-serif" font-size="14" font-weight="700" fill="#111">Legend</text>
<rect width="${box.w}" height="${panelH}" fill="rgba(255,255,255,0.94)" stroke="#ccc"/>
<text ${_textAt(spec, box, pad)} y="${pad + line * 0.6}" font-family="Inter, sans-serif" font-size="${Math.round(fs * 1.1)}" font-weight="700" fill="#111">${title}</text>
${items}
</svg>`;
}

View file

@ -53,6 +53,7 @@
"check:english-leaks": "node internal/check-gospel-english-leaks.mjs",
"check:experimental-shelf": "node internal/check-experimental-shelf.mjs",
"check:export-one-click": "node plugin-designs/doxa-research-map/scripts/check-export-one-click.mjs",
"check:export-one-click:maplibre": "node plugin-designs/doxa-research-map/scripts/check-export-one-click.mjs --provider maplibre",
"check:facet-cascade": "node internal/check-facet-cascade.mjs",
"check:facet-cascade:offline": "node internal/check-facet-cascade.mjs --offline",
"check:facet-cascade:self-test": "node internal/check-facet-cascade.mjs --self-test",
@ -238,6 +239,7 @@
"check:english-leaks": "A translated key must not render in English. rega's method: a string is a LEAK when the catalogue has a DIFFERENT value for the locale and the page still shows the English one - a render/wiring fault, not a missing translation. Third-party attributions, data names, placeholder-only strings and the prose this bundle deliberately keeps in English are all excluded, the last by simply not being in the catalogue. Scoped to the LEAF on purpose: six keys were reported leaking in the gallery and the leaf is clean in ar/zh/ru/hi - the gallery builds its iframe src with no lang parameter, so the embed never learns the locale. Embedder defect, not a bundle one.",
"check:experimental-shelf": "Every Experimental-folder bundle, leaf + Gallery tab, mapbox + maplibre: mounts with content, 0 errors / failed requests, maps load and DRAW their own data. Self-tests on a deliberately broken bundle first. Run through internal/browser-slot.sh.",
"check:export-one-click": "the Simple Research 'Export poster' button gives a FILE on ONE click (no menu, no dialog, no second step): leaf AND Research Gallery, en AND ar; the file is a real print PNG (US Letter @200 DPI); 0 page errors. Run inside the shared browser slot. Exit 2 = SKIP, 75 = NOT MEASURED.",
"check:export-one-click:maplibre": "the same four one-click exports on the keyless MapLibre engine (OpenFreeMap tiles): the hidden print map must be built with the engine that drew the screen, and the footer must credit the tiles actually printed.",
"check:facet-cascade": "a child facet selection must not outlive its parent: clearing a parent facet must cascade-clear its descendants on EVERY clear path. Asserts on RESULT COUNTS, not DOM — an orphaned child silently ANDs the map to empty. run check:facet-cascade:self-test for the NEGATIVE test (reverts the cascade and requires this gate to FAIL).",
"check:facet-changes-map": "a facet selection must CHANGE THE DOTS — and the dots must SAY what they mean. Asserts (1) the rd-bubbles feature set changes by count AND identity, (2) every surviving dot satisfies the selected level, (3) while a prayer filter is active circle-color is a DATA-DRIVEN EXPRESSION reading peoplePraying, not a flat literal. Assertion 3 is the one that matters: the prayer filter ALREADY narrowed 2107->689 correctly while the Driver reported it broken, because every dot painted the same orange. Browser gate (needs `bun run dev` on 5173, or BASE_URL/PROBE_PATH at a served tree), so NOT in build:unlocked. run the `check:facet-changes-map:self-test` script for the NEGATIVE test.",
"check:facet-changes-map:built": "the SAME gate against the BUILT tree instead of the dev server. Measured 2026-09-24: the dev default is 1 PASS / 3 FAIL in four runs on a busy box (88 unbundled module requests vs the built leaf's 3), while the built tree passes deterministically. Use this when you need a trustworthy answer; use the dev default when checking uncommitted source.",

View file

@ -1316,12 +1316,29 @@ async function openPoster() {
])
const exporter = useMapPoster(() => map.value)
const spec = defaultPosterSpec({ widthIn: 8.5, heightIn: 11, dpi: 200, orientation: 'portrait' })
const title = legendTitle.value || 'Doxa Map'
// The poster is titled by the VIEW on screen ("Prayer", "Religion"…), in the reader's language.
// The legend's title bar is the fallback only: on a flat tab it is the generic word "Legend".
const title = tabLabel(activeTab.value) || legendTitle.value || 'Doxa Map'
// Direction follows the MAP's language, not the host page's: an English map on an Arabic page stays LTR.
spec.dir = ['ar', 'fa', 'he', 'ur'].includes(String(uiLocale.value || '').split('-')[0]) ? 'rtl' : 'ltr'
spec.slots.title = { ...spec.slots.title, text: title }
spec.slots.legend = { ...spec.slots.legend, rows: legend?.rows?.value || [] }
spec.slots.footer = { ...spec.slots.footer, left: 'Source: pray.doxa.life', right: new Date().toISOString().slice(0, 10) }
// The legend the reader sees: the flat tab's rows (and their sub-rows), else the aggregated ones.
const flatRows = (flatLegend.items.value || []).flatMap(it => [it, ...(it.children || [])])
const rows = (flatRows.length ? flatRows : (legend?.rows?.value || [])).map(r => ({ label: r.label, color: r.color }))
spec.slots.legend = { ...spec.slots.legend, rows, title: legendTitle.value }
// No scale bar or north arrow: the headless renderer's are placeholders (a fixed bar reading
// "scale"), which on a print would be a false statement — and a globe has no single scale.
spec.slots.scaleBar = false
spec.slots.north = false
// Credit the tiles actually drawn: OpenFreeMap on MapLibre, Mapbox otherwise.
const onMaplibre = !!(window.maplibregl && map.value instanceof window.maplibregl.Map)
spec.slots.footer = { ...spec.slots.footer, left: 'pray.doxa.life', right: new Date().toISOString().slice(0, 10),
attribution: onMaplibre ? '© OpenFreeMap © OpenMapTiles © OpenStreetMap' : '© Mapbox © OpenStreetMap' }
const blob = await exporter.render(spec, 'png')
_downloadBlob(blob, (title.replace(/\W+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'doxa-map') + '-poster.png')
// File name from the view's id, not its title: an Arabic or Chinese title has no ASCII letters
// to keep, and every reader's file would otherwise be the same "doxa-map-poster.png".
const slug = String(activeTab.value?.id || '').replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase()
_downloadBlob(blob, 'doxa-' + (slug || 'map') + '-poster.png')
} catch (err) {
console.error('[research-map] poster export failed:', err)
} finally {

View file

@ -20,11 +20,13 @@
*
* Runs through internal/browser-slot.sh like every browser gate. Starts its own servers on
* OS-assigned ports and proves the server is THIS checkout's tree before measuring.
* --provider maplibre runs the same four on the keyless engine.
* EXPORT_SAVE_DIR=<dir> keeps each downloaded poster (<host>-<lang>-<file>) so a person can look at it.
* Exit: 0 pass · 1 fail · 2 SKIP (no built tree / no playwright) · 75 NOT MEASURED (server identity)
*/
import { createServer } from 'node:http'
import { spawn } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -36,11 +38,12 @@ let chromium
try { ({ chromium } = await import('playwright')) } catch { console.log('SKIP check-export-one-click: playwright not installed'); process.exit(2) }
const freePort = () => new Promise(ok => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => ok(p)) }) })
const PROVIDER = process.argv.includes('--provider') ? process.argv[process.argv.indexOf('--provider') + 1] : '' // maplibre = the keyless engine
const TP = await freePort(), HP = await freePort()
const TREE = `http://127.0.0.1:${TP}/doxa-maps`
let lang = 'en'
const srv = spawn(process.execPath, ['internal/serve-strict.mjs', '--port', String(TP)], { cwd: ROOT, stdio: 'ignore' })
const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(`<!doctype html><html lang="${lang}"><body style="margin:0"><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa tab="research-map-plus-grid" style="height:900px"></doxa-maps-spa></body></html>`) }).listen(HP, 'localhost')
const host = createServer((_, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(`<!doctype html><html lang="${lang}"><body style="margin:0"><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa tab="research-map-plus-grid"${PROVIDER ? ` provider="${PROVIDER}"` : ''} style="height:900px"></doxa-maps-spa></body></html>`) }).listen(HP, 'localhost')
const cleanup = () => { try { srv.kill() } catch {} try { host.close() } catch {} }
await new Promise(r => setTimeout(r, 800))
@ -59,10 +62,13 @@ const b = await chromium.launch({ args: ['--use-angle=swiftshader', '--enable-un
const fails = []
try {
for (lang of ['en', 'ar']) for (const where of ['leaf', 'gallery']) {
const url = where === 'leaf' ? `${TREE}/research-map-plus-grid/research-map-plus-grid/index.html?lang=${lang}` : `http://localhost:${HP}/`
const url = where === 'leaf' ? `${TREE}/research-map-plus-grid/research-map-plus-grid/index.html?lang=${lang}${PROVIDER ? `&provider=${PROVIDER}` : ''}` : `http://localhost:${HP}/`
const ctx = await b.newContext({ acceptDownloads: true, viewport: { width: 1400, height: 900 } })
const p = await ctx.newPage(); const errs = []
p.on('pageerror', e => errs.push(String(e).slice(0, 160)))
// A failed export is CAUGHT and logged (console.error), never thrown: read the console, or a dead
// button reports "no file" with no reason.
const consoleErrs = []; p.on('console', m => { if (m.type() === 'error') consoleErrs.push(m.text().slice(0, 200)) })
const downloads = []; p.on('download', d => downloads.push(d))
await p.goto(url); await p.waitForTimeout(12000)
const btn = p.locator('button.export-poster-btn').first()
@ -70,20 +76,30 @@ try {
if (!onScreen) errs.push('the export button is not on screen without a prior click')
const label = ((await btn.getAttribute('title', { timeout: 20000 }).catch(() => '')) || '').trim()
let clicks = 0
try { await btn.click({ timeout: 20000 }); clicks = 1 } catch (e) { errs.push('could not click: ' + String(e.message || e).split('\n')[0]) }
// ONE PHYSICAL CLICK at the button's centre, as a reader makes it. Not locator.click(): that
// re-checks its hit target and RETRIES, and by the retry the button is already disabled (the
// export is running), so it reported "timeout" on exports whose file then arrived — measured
// 2026-09-25 in gallery-ar and leaf-en. What the click achieved is judged by the file below.
try {
const box = await btn.boundingBox({ timeout: 20000 })
if (!box) throw new Error('the export button has no box')
await p.mouse.click(box.x + box.width / 2, box.y + box.height / 2); clicks = 1
} catch (e) { errs.push('could not click: ' + String(e.message || e).split('\n')[0]) }
for (let i = 0; i < 120 && !downloads.length; i++) await p.waitForTimeout(1000)
let file = null, info = ''
if (downloads.length) {
file = downloads[0].suggestedFilename()
const path = await downloads[0].path()
const buf = readFileSync(path)
if (process.env.EXPORT_SAVE_DIR) writeFileSync(resolve(process.env.EXPORT_SAVE_DIR, `${where}-${lang}${PROVIDER ? '-' + PROVIDER : ''}-${file}`), buf) // to LOOK at the poster
const png = buf.length > 24 && buf.slice(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
const w = png ? buf.readUInt32BE(16) : 0, h = png ? buf.readUInt32BE(20) : 0
info = `${png ? 'PNG' : 'NOT-PNG'} ${w}x${h} ${Math.round(buf.length / 1024)}KB`
if (!png || w < 1600 || h < 2100) errs.push(`file is not the expected print PNG (${info})`)
}
if (!file) for (const c of consoleErrs.filter(c => /poster|useMapPoster/i.test(c)).slice(-2)) errs.push(c)
const ok = clicks === 1 && !!file && !errs.length
console.log(`${ok ? 'PASS' : 'FAIL'} [${where} ${lang}] "${label}" · 1 click → ${file ? `${file} (${info})` : 'NO FILE within 120s'}${errs.length ? ' · ' + [...new Set(errs)].slice(0, 3).join(' | ') : ' · 0 errors'}`)
console.log(`${ok ? 'PASS' : 'FAIL'} [${where} ${lang}${PROVIDER ? ' ' + PROVIDER : ''}] "${label}" · 1 click → ${file ? `${file} (${info})` : 'NO FILE within 120s'}${errs.length ? ' · ' + [...new Set(errs)].slice(0, 3).join(' | ') : ' · 0 errors'}`)
if (!ok) fails.push(`${where} ${lang}`)
await ctx.close()
}