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
}