diff --git a/plugin-designs/country-posters/countries-map.vue b/plugin-designs/country-posters/countries-map.vue index 7ce8f9b59..2eb5d7a00 100644 --- a/plugin-designs/country-posters/countries-map.vue +++ b/plugin-designs/country-posters/countries-map.vue @@ -81,11 +81,6 @@ import { useGeocoderSearch } from '@map/composables/useGeocoderSearch.js' // the import until that happens — see `openPoster()` below. let _posterPromise = null -// 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. -// EXPERIMENTAL: dormant behind the shared kill-switch — only fires from the -// SemanticTreeLegend Export → Packet menu item. Lazy-loaded once via openPacket(). -let _packetPromise = null // ── Geocoder search logic moved to @map/composables/useGeocoderSearch.js ────── // The geocoder-clear guard (_geoBeingCleared + clearGeocoderProgrammatic) and the @@ -1240,52 +1235,95 @@ function onExport(kind) { if (kind === 'packet') return openPacket() } -// ─── 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 -// throw uncaught into the UI; guard against double-run. +// ─── Packet (printed through the shared renderer, like Simple Research's) ───── +// Defensive throughout: never throw uncaught into the UI; guard against double-run. let _packetBusy = false -function _downloadBlob(blob, filename) { - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - document.body.appendChild(a) - a.click() - a.remove() - setTimeout(() => URL.revokeObjectURL(url), 1000) -} async function openPacket() { if (_packetBusy) return _packetBusy = true try { - if (!_packetPromise) { - _packetPromise = import('@map/composables/useMapPacket.js') - .then(m => m.useMapPacket(() => map.value)) - } - const packet = await _packetPromise + // ★ THE PACKET PRINTS, AND IT PRINTS THROUGH THE SHARED RENDERER (Driver-lens audit card 2, and + // the Driver's own rule: an export button opens the print dialog, never hands over a file). This + // built a jsPDF document and downloaded packet.pdf while its poster sibling, two functions up, + // already printed. The fix is NOT to wrap that PDF in a dialog — a PDF blob cannot go in the + // the in-place print frame uses, and every workaround (embed, object, the browser's own + // viewer) prints on some engines and silently does nothing on others, which is the dead-button + // class this file has already paid for twice. + // + // library/composables/usePosterPrints.js ALREADY has printPacket: the same pages, authored as HTML + // with @page rules, printed in place. This map was running a private second implementation of a + // shared product feature. Routing to it deletes the duplicate, drops jspdf from this bundle, and + // the packet gains the pages the shared renderer has that the local one never did (the life + // calendar, the funnel, the prayer wheel — see packet-render.buildPacket's page order). + const [{ usePosterPrints }, { defaultPosterSpec }, { useMapPoster }] = await Promise.all([ + import('@map/composables/usePosterPrints.js'), + import('@map/constants/posterDefaults.js'), + import('@map/composables/poster/useMapPoster.js'), + ]) - // Gather the people groups for the current selection. No reliable - // descendant filter exists across all tabs, so use the currently - // normalized groups. CAP at 50 for overnight safety. + // Same 50-group cap as before: an overnight safety limit, not a design choice. let groups = mapData.normalizedPeopleGroups.value || [] if (groups.length > 50) { - console.warn(`[research-map] packet export truncated ${groups.length} → 50 people groups`) + console.warn(`[country-posters] packet export truncated ${groups.length} → 50 people groups`) groups = groups.slice(0, 50) } - const { defaultPosterSpec } = await import('@map/constants/posterDefaults.js') + // ★ AN EXPLICIT ADAPTER, because the two halves name the same fields differently and a mismatch + // here is SILENT — the packet would print with blank names and empty photos rather than failing. + // The renderer reads slug / name / population / religion / country_code, and usePosterPrints reads + // image_url; this map's normalized rows carry country and imageUrl in camelCase. Mapped once, here, + // where the difference is visible, rather than renaming either side's vocabulary. + const records = groups.map((g) => ({ + slug: String(g.slug || g.uniqueId || g.id || ''), + name: g.name, + population: g.population, + // ★ THE DISPLAY NAME, NOT THE CODE, and the order is the whole point. The card prints these two + // straight into the page — flat(g.religion) and flat(g.country_code) — so they are read by a + // person, not matched by a machine. DataSourceManager normalizes `religion` to the CODE and puts + // the readable form in `religionName` (it does the same for country: countryIso is the code, + // countryName the label). Reading religion first would have printed MSN under a people group's + // name, and the field name country_code invites exactly that mistake on the country line. + // Checked against the normalizer rather than guessed: library/api/DataSourceManager.js:357-358. + religion: g.religionName || g.religionLabel || g.religion || '', + country_code: g.countryName || g.countryIsoLabel || g.country || g.countryIso || '', + image_url: g.imageUrl || g.image_url || '', + })) + + // Page 1 keeps the LIVE map rather than a fetched poster: this half is the one that knows what is + // currently on screen. The shared renderer interpolates `mapSvg` as raw markup, so the PNG this map + // already knows how to produce rides in as an — a data URL, because the print frame is a + // separate document and an object URL created here would be revoked out from under it. const spec = defaultPosterSpec({ widthIn: 8.27, heightIn: 11.69, dpi: 150, orientation: 'portrait' }) spec.slots.title = { ...spec.slots.title, text: legendTitle.value || 'Doxa Map' } spec.slots.legend = { ...spec.slots.legend, rows: legend?.rows?.value || [] } + let mapSvg = '' + try { + const mapBlob = await useMapPoster(() => map.value).render(spec, 'png') + const dataUrl = await new Promise((resolve, reject) => { + const r = new FileReader() + r.onload = () => resolve(String(r.result)) + r.onerror = reject + r.readAsDataURL(mapBlob) + }) + mapSvg = `` + } catch (e) { + // A cover without the map beats no packet — the same call the shared composable documents. + console.warn('[country-posters] packet map page skipped:', e?.message || e) + } - const blob = await packet.renderPacket(spec, groups, { - source: 'pray.doxa.life', - date: new Date().toISOString().slice(0, 10), + const zone = { + id: String(activeTab.value?.id || 'research-map'), + label: legendTitle.value || tabLabel(activeTab.value) || 'Doxa Map', + zoneKey: String(activeTab.value?.id || 'research-map'), + } + const prints = usePosterPrints({ + records: () => records, + lang: () => String(uiLocale.value || 'en').split('-')[0], + label: () => zone.label, }) - _downloadBlob(blob, 'packet.pdf') + await prints.printPacket(zone, { mapSvg }) } catch (err) { - console.error('[research-map] packet export failed:', err) + console.error('[country-posters] packet export failed:', err) } finally { _packetBusy = false }