diff --git a/library/composables/poster/useMapPoster.js b/library/composables/poster/useMapPoster.js index 7e4446755..1633f0614 100644 --- a/library/composables/poster/useMapPoster.js +++ b/library/composables/poster/useMapPoster.js @@ -362,14 +362,19 @@ async function _spawnHiddenMap(sourceMap, w, h, opts = {}) { // Some environments lock devicePixelRatio; fall back silently. } - // Lazy-grab mapboxgl off the global — same pattern as useMapInstance. + // THE SAME ENGINE AS THE SOURCE MAP. This used to grab window.mapboxgl unconditionally, so on a + // keyless page (provider=maplibre) every poster threw "mapboxgl global not found" — the source map + // is a MapLibre map and no Mapbox engine is loaded at all. Measured 2026-09-25 while making the + // Simple Research export one click. Mapbox behaviour is unchanged. // eslint-disable-next-line no-undef - const mapboxgl = (typeof window !== 'undefined' ? window.mapboxgl : null); - if (!mapboxgl) throw new Error('useMapPoster: mapboxgl global not found'); + const W = (typeof window !== 'undefined' ? window : {}); + const isMaplibre = !!W.maplibregl && sourceMap instanceof W.maplibregl.Map; + const gl = isMaplibre ? W.maplibregl : W.mapboxgl; + if (!gl) throw new Error('useMapPoster: ' + (isMaplibre ? 'maplibregl' : 'mapboxgl') + ' global not found'); const styleObj = sourceMap.getStyle(); - const hiddenMap = new mapboxgl.Map({ + const hiddenMap = new gl.Map({ container, style: styleObj, center: sourceMap.getCenter(), @@ -379,6 +384,7 @@ async function _spawnHiddenMap(sourceMap, w, h, opts = {}) { interactive: false, attributionControl: false, preserveDrawingBuffer: true, + canvasContextAttributes: { preserveDrawingBuffer: true }, // MapLibre v5 spelling of the same fadeDuration: 0, }); diff --git a/package.json b/package.json index 552d6c261..93d715c3b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "tree": "node internal/generate-tree.mjs", "gallery": "node internal/generate-gallery.mjs", "check:drop": "node internal/drop-portability-check.mjs", + "check:export-one-click": "node plugin-designs/doxa-research-map/scripts/check-export-one-click.mjs", "check:parse": "node internal/inline-parse-check.mjs", "verify": "node library/scripts/research/verify-all.mjs", "verify:full": "node library/scripts/research/verify-all.mjs --full", @@ -215,6 +216,7 @@ "posters:one": "ONLY= to build one", "packets": "the prayer packets. READS out/posters/.html for page 1's map, which `bun run posters` writes — without it the packet still builds and page 1 is simply BLANK, so the script now warns per-zone AND in its summary. PACKET_LANG= for one language, ONLY= for one zone, NOPHOTOS=1 to skip the photo fetch.", "packet-fixture": "regenerate the committed records subset (smallest zone, image_url stripped) that lets CI build a packet at all — out/ is gitignored, so a clean checkout has neither the records cache nor any packet.", + "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:packet-fixture": "the committed fixture must still match clusters.json and must never carry image_url (CI would fetch 43 photos from someone else's CDN).", "packet:ci": "build EVERY committed fixture, no network, each renamed to its own output so they coexist. Was one fixture (tibet); packet.mjs names its output for the ZONE not the fixture, so sequential builds overwrote each other and check:packets-all saw a single SHAPE while reporting success. The fixtures are chosen at the PER_PAGE=20 page boundary — exactly 20, 21, and 1 group — because laos (95) and tibet (43) are both mid-range remainder cases and the boundary itself had never rendered.", "deploy:dev": "destination + allowlist come from vite.doxa-maps-build-config.json (dropin.dest / dropin.include); DOXA_DROPIN_DEST= overrides the destination", diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 8d94c9773..4fd407588 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -63,9 +63,7 @@ import { useGeocoderSearch } from '@map/composables/useGeocoderSearch.js' // below. // PERF: useMapPoster pulls in the tile-stitch + PDF composition pipeline -// (~117 KB). It's only needed when the user opens the poster dialog. Defer -// the import until that happens — see `openPoster()` below. -let _posterPromise = null +// (~117 KB). It is imported on the first "Export poster" click — see `openPoster()` below. // PERF: useMapPacket builds a multi-page PDF (map page + one detail card per // people group). It reuses useMapPoster's pipeline, so it's at least as heavy. @@ -148,17 +146,6 @@ function _syncScrollZoomToHost(m) { m.scrollZoom.enable() } catch (_) { /* an engine without scrollZoom must never break the panel */ } } -function loadPoster() { - if (!_posterPromise) { - _posterPromise = import('@map/composables/poster/useMapPoster.js') - .then(m => m.useMapPoster({ - sourceMap: () => map.value, - mapboxToken: mapboxToken.value, - styleUrl: () => map.value?.getStyle()?.sprite?.replace(/\/sprite.*$/, '') || profileConfig?.value?.style - })) - } - return _posterPromise -} // Shared legend pipeline — lives in the library (@map/composables/legends/useLegendData.js). import { useLegendData } from '@map/composables/legends/useLegendData.js' @@ -194,6 +181,7 @@ import ThemeToggleButton from '@map/components/map-controls/ThemeToggleButton.vu import HamburgerButton from '@map/components/map-controls/HamburgerButton.vue' import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue' import ShareButton from '@map/components/map-controls/ShareButton.vue' +import MapControlButton from '@map/components/map-controls/MapControlButton.vue' // PERF: SideMenuDrawer is unused in the current research-map template (the // ResearchMapSideMenu sibling renders the actual menu). Removed entirely from @@ -226,9 +214,7 @@ import { useWagfRegionsLegendData } from '@map/composables/legends/useWagfRegi import { useLegendData as useFlatLegendData } from '@map/composables/legends/useLegendData.js' import { RESEARCH_LEGEND_OPTIONS } from '@map/composables/legends/researchLegendOptions.js' -// PERF: PosterDialog only renders when the user opens the poster export -// flow. Async-import it so the dialog's deps don't load on every map boot. -const PosterDialog = retryableAsyncComponent(() => import('@map/components/poster/PosterDialog.vue')) + // ─── Shadow-DOM style injection ────────────────────────────────────────────── useShadowStyles(` @@ -1310,11 +1296,41 @@ watch(() => mapStore.selectedRegion, (key) => { // Instantiated only when the user opens the poster dialog. The composable + // off-screen Mapbox instance + PDF pipeline are ~117 KB; keep them off the // critical boot path. -const poster = ref(null) +// ★ ONE CLICK, ONE FILE (BUILD-SIMPLE-RESEARCH-EXPORT-ZERO-CLICK). The Driver: "there's an export +// button on the simple dashboard ... get it working". Measured 2026-09-25, leaf AND gallery: clicking +// "Export poster…" produced NOTHING — no dialog, no file in 60s, no error. Two bugs stacked: the +// template passed the poster dialog a `poster` prop it does not have and omitted its v-model and +// required getMap, so it mounted invisible; and the old loader called useMapPoster({ sourceMap, … }) +// with an OBJECT where the shared composable takes a FUNCTION, so a render would have thrown anyway. +// Now the button does the export itself, through the SHARED poster path (useMapPoster → render → +// download), with a sensible print default: the current view on US Letter portrait at 200 DPI, the +// legend title and rows, and a source/date footer. No dialog, no second step. +const posterBusy = ref(false) async function openPoster() { - if (!poster.value) poster.value = await loadPoster() - showPoster.value = true + if (posterBusy.value) return + posterBusy.value = true + try { + const [{ useMapPoster }, { defaultPosterSpec }] = await Promise.all([ + import('@map/composables/poster/useMapPoster.js'), + import('@map/constants/posterDefaults.js'), + ]) + const exporter = useMapPoster(() => map.value) + const spec = defaultPosterSpec({ widthIn: 8.5, heightIn: 11, dpi: 200, orientation: 'portrait' }) + const title = legendTitle.value || 'Doxa Map' + 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) } + const blob = await exporter.render(spec, 'png') + _downloadBlob(blob, (title.replace(/\W+/g, '-').replace(/^-+|-+$/g, '').toLowerCase() || 'doxa-map') + '-poster.png') + } catch (err) { + console.error('[research-map] poster export failed:', err) + } finally { + posterBusy.value = false + } } +// The toolbar's export label: the drawer's translated 'Export poster…' WITHOUT the ellipsis — an +// ellipsis promises a dialog, and this button hands over the file directly. +const exportPosterLabel = computed(() => String(t('controls.export_poster') || 'Export poster').replace(/\s*…\s*$/, '')) // ─── Export menu router (SemanticTreeLegend @export) ───────────────────────── // EXPERIMENTAL — dormant behind the shared kill-switch (SHOW_EXPORT_BUTTON). @@ -1326,7 +1342,7 @@ function onExport(kind) { // ─── Packet export (multi-page PDF: map + one card per people group) ───────── // PERF: useMapPacket reuses useMapPoster's render pipeline — heavy. Lazy-load -// once, mirroring loadPoster()/_posterPromise. Defensive throughout: never +// once, the same lazy-import pattern openPoster() uses. Defensive throughout: never // throw uncaught into the UI; guard against double-run. let _packetBusy = false function _downloadBlob(blob, filename) { @@ -1377,7 +1393,6 @@ async function openPacket() { // ─── Side-menu / dialog state ──────────────────────────────────────────────── const showSideMenu = ref(false) -const showPoster = ref(false) const selectedFeature = computed(() => mapStore.selectedFeature) // ─── Theme toggle (clone of doxa-simple-map.vue:368-395) ───────────────────── @@ -2330,7 +2345,6 @@ onBeforeUnmount(() => { window.removeEventListener('legend:highlight', onLegendHighlight) if (_containerObserver) { _containerObserver.disconnect(); _containerObserver = null } if (_scrollZoomObserver) { _scrollZoomObserver.disconnect(); _scrollZoomObserver = null } - poster.value?.cleanup?.() mapLayers.stopPrayerGlow() clustering.cleanup?.() selectedPin.cleanup?.() @@ -2401,6 +2415,20 @@ onBeforeUnmount(() => { + + + + @@ -2474,14 +2502,6 @@ onBeforeUnmount(() => { @close="mapStore.selectFeature(null)" /> - - diff --git a/plugin-designs/doxa-research-map/scripts/check-export-one-click.mjs b/plugin-designs/doxa-research-map/scripts/check-export-one-click.mjs new file mode 100644 index 000000000..f1f2356ed --- /dev/null +++ b/plugin-designs/doxa-research-map/scripts/check-export-one-click.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * check-export-one-click.mjs — the Simple Research "Export poster" button gives you a FILE on ONE + * click, with no menu, dialog or second step, in its own leaf AND inside the Research Gallery, in + * en AND ar. + * + * WHY (card BUILD-SIMPLE-RESEARCH-EXPORT-ZERO-CLICK; the Driver: "there's an export button on the + * simple dashboard ... get it working"). Measured 2026-09-25 before the fix: one click produced + * NOTHING — no dialog, no file within 60s, no error — in both hosts. A silent dead button reads as + * "the export is slow" to a reader, never as "it is broken", so no other gate could see it. + * + * WHAT IT ASSERTS, per host × language: + * 1. exactly ONE click on the ALWAYS-VISIBLE export button in the map toolbar (button.export-poster-btn). + * The drawer's "Export poster…" does not count: the drawer is CLOSED by default (translated off + * screen), so it costs an extra click to reach — measured 2026-09-25, and in Arabic the button + * could not be clicked at all. The toolbar button must be on screen with no preparation; + * 2. a download arrives within 90s, WITHOUT any further click; + * 3. the file is a real PNG (signature) of the print size (US Letter at 200 DPI = 1700x2200); + * 4. 0 page errors. + * + * 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. + * 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 { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') +if (!existsSync(resolve(ROOT, 'public/doxa-maps/research-map-plus-grid/research-map-plus-grid.js'))) { + console.log('SKIP check-export-one-click: no built research-map-plus-grid'); process.exit(2) +} +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 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(``) }).listen(HP, 'localhost') +const cleanup = () => { try { srv.kill() } catch {} try { host.close() } catch {} } +await new Promise(r => setTimeout(r, 800)) + +// Server identity by CONTENT: the served manifest must be this checkout's. +{ + const disk = readFileSync(resolve(ROOT, 'public/doxa-maps/manifest.json'), 'utf8') + let served = '' + for (let i = 0; i < 20 && !served; i++) { + try { const r = await fetch(`${TREE}/manifest.json`); if (r.ok) served = await r.text() } catch { /* not up yet */ } + if (!served) await new Promise(r => setTimeout(r, 300)) + } + if (served !== disk) { console.log(`NOT MEASURED: :${TP} is not serving this checkout's tree. Re-run.`); cleanup(); process.exit(75) } +} + +const b = await chromium.launch({ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }) +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 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))) + 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() + const onScreen = await btn.evaluate(el => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.left >= 0 && r.top >= 0 && r.right <= innerWidth && r.bottom <= innerHeight }).catch(() => false) + 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]) } + for (let i = 0; i < 90 && !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) + 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})`) + } + const ok = clicks === 1 && !!file && !errs.length + console.log(`${ok ? 'PASS' : 'FAIL'} [${where} ${lang}] "${label}" · 1 click → ${file ? `${file} (${info})` : 'NO FILE within 90s'}${errs.length ? ' · ' + [...new Set(errs)].slice(0, 3).join(' | ') : ' · 0 errors'}`) + if (!ok) fails.push(`${where} ${lang}`) + await ctx.close() + } +} finally { await b.close(); cleanup() } + +if (fails.length) { console.log(`\ncheck-export-one-click: FAIL (${fails.join(', ')})`); process.exit(1) } +console.log('\ncheck-export-one-click: PASS — one click on Export poster gives a print PNG, leaf and gallery, en and ar')