WIP: Simple Research one-click export (toolbar button, shared poster path, engine-aware hidden map, gate)
This commit is contained in:
parent
a2be2396b9
commit
1cbfb3aadf
4 changed files with 156 additions and 35 deletions
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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=<zoneKey> to build one",
|
||||
"packets": "the prayer packets. READS out/posters/<zone>.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=<loc> for one language, ONLY=<zoneKey> 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=<dir> overrides the destination",
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
|||
<LocationButton :map="map" :is-dark="isDark" />
|
||||
<FullscreenButton :map-container="mapContainer" :is-dark="isDark" />
|
||||
<ThemeToggleButton :is-dark="isDark" @toggle="handleToggleTheme" />
|
||||
<!-- EXPORT, ONE CLICK, ALWAYS VISIBLE (BUILD-SIMPLE-RESEARCH-EXPORT-ZERO-CLICK). The only
|
||||
export control used to live in the side drawer's footer, which is CLOSED by default
|
||||
(translated off-screen), so a file took two clicks at best — and then nothing arrived.
|
||||
This control sits in the toolbar every reader sees and exports the current view as a
|
||||
print poster on the first click; busy while it renders, so a second click cannot
|
||||
double-fire. -->
|
||||
<MapControlButton :is-dark="isDark" :active="posterBusy" :disabled="posterBusy"
|
||||
:title="exportPosterLabel" class="export-poster-btn" :aria-busy="posterBusy ? 'true' : 'false'"
|
||||
@click="openPoster">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 3v12M7 10l5 5 5-5M5 21h14" />
|
||||
</svg>
|
||||
</MapControlButton>
|
||||
<!-- Share button hidden behind FEATURES.share (glitch — PR gate). All
|
||||
ShareButton code kept warm; flip FEATURES.share=true to restore. -->
|
||||
<ShareButton v-if="FEATURES.share" :is-dark="isDark" :embed-path="'doxa-research-map/research-map/index.html'" />
|
||||
|
|
@ -2474,14 +2502,6 @@ onBeforeUnmount(() => {
|
|||
@close="mapStore.selectFeature(null)"
|
||||
/>
|
||||
|
||||
<!-- Poster export modal -->
|
||||
<PosterDialog
|
||||
v-if="showPoster"
|
||||
:poster="poster"
|
||||
:legend-rows="legend.rows"
|
||||
:active-tab="activeTab"
|
||||
@close="showPoster = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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(`<!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 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')
|
||||
Loading…
Reference in a new issue