Country posters: the packet prints through the shared renderer instead of downloading a PDF

The map's packet built a jsPDF file and downloaded packet.pdf while its poster already
printed. It now goes through usePosterPrints.printPacket, the same path as Simple
Research (8457b5dc): the live map rides in as an image data URL, and an explicit
adapter maps the rows, reading religionName and countryName so a card never prints a
religion code ("MSN") under a people group's name.
This commit is contained in:
Doxa Maps 2026-09-28 05:22:08 +00:00
parent d8c67ca28a
commit d8ee9e9787

View file

@ -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
// <img> 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 <img> — 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 = `<img src="${dataUrl}" alt="" style="width:100%;height:100%;object-fit:contain">`
} 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
}