doxa-maps/library/composables/useMapPacket.js
doxa-maps d5ba21fa76 doxa-maps — portable bundler for embeddable JAMstack maps
Standalone repo that builds embeddable JAMstack map bundles (web components),
composes them into a lazy-loaded SPA via layouts/, and optionally wraps a
Capacitor Android/iOS app — one build, choose your output/CDN. Diátaxis docs.

History reset to a single clean commit; no PII, IPs, or internal terms.
2026-09-10 01:53:07 +00:00

300 lines
11 KiB
JavaScript

/**
* useMapPacket — render a multi-page "prayer packet" PDF for a map selection.
*
* A packet is one cohesive PDF whose:
* - Page 1 = the full-bleed map image (reuses useMapPoster.render(spec,'png')
* so the map raster, hidden-map spin-up, and Mapbox attribution path are
* identical to the poster export — single source of truth).
* - Pages 2..N = one clean "detail card" per people group, drawn with jsPDF
* vector primitives (setFontSize / text / rect / line) so the cards stay
* crisp at any zoom and add almost nothing to the file size.
*
* People-group field reads are DEFENSIVE: the same object may arrive as a
* normalized record from useMapData (top-level `name`, `country`, `population`,
* `peoplePraying`, …), as a backward-compat `peopleGroups[]` entry, or as a
* GeoJSON-feature `.properties` bag. We probe the known keys in priority order
* and fall back to an em-dash so a card never renders blank labels.
*
* Mapbox / OpenStreetMap attribution is rendered into every page footer —
* non-negotiable (Mapbox ToS), mirroring useMapPoster's footer slot.
*
* @example
* const packet = useMapPacket(getMap);
* const blob = await packet.renderPacket(spec, peopleGroups, { source: 'Doxa Life' });
* download(blob, 'prayer-packet.pdf');
*
* // Deps (already in package.json): "jspdf": "^4.2.1"
*/
import { ref, readonly } from 'vue';
import { useMapPoster } from './poster/useMapPoster.js';
/**
* @param {() => any} getMap function returning the live Mapbox map
* @returns {{
* renderPacket: (spec: import('./poster/useMapPoster.js').PosterSpec, peopleGroups: Array<Object>, meta?: Object) => Promise<Blob>,
* progress: import('vue').Ref<{ phase: string, completed: number, total: number, message: string }>,
* isRendering: import('vue').Ref<boolean>
* }}
*/
export function useMapPacket(getMap) {
const progress = ref({ phase: 'idle', completed: 0, total: 0, message: '' });
const isRendering = ref(false);
function _setProgress(phase, completed, total, message) {
progress.value = { phase, completed, total, message };
}
/**
* Render the multi-page packet PDF.
* @param {import('./poster/useMapPoster.js').PosterSpec} spec same shape useMapPoster uses
* @param {Array<Object>} peopleGroups normalized people-group objects
* @param {Object} [meta] { source?, date?, attribution? }
* @returns {Promise<Blob>}
*/
async function renderPacket(spec, peopleGroups, meta = {}) {
if (isRendering.value) throw new Error('useMapPacket: already rendering');
isRendering.value = true;
const groups = Array.isArray(peopleGroups) ? peopleGroups : [];
const total = 1 + groups.length; // page 1 = map, then one page per group
try {
_setProgress('map', 0, total, 'rendering map page');
// Page 1 raster — reuse the poster renderer so the map image, attribution,
// and hidden-map machinery are identical to the poster export path.
const mapBlob = await useMapPoster(getMap).render(spec, 'png');
const mapDataUrl = await _blobToDataUrl(mapBlob);
_setProgress('pdf-init', 1, total, 'building PDF');
// Lazy ESM import — keep jspdf out of the main bundle until a packet is
// actually requested. Mirrors useMapPoster._toPdfBlob exactly.
let jsPDFCtor;
try {
const mod = await import('jspdf');
jsPDFCtor = mod.jsPDF || mod.default;
} catch (_) {
throw new Error('useMapPacket: jspdf not installed (npm i jspdf)');
}
const doc = new jsPDFCtor({
orientation: spec.widthIn > spec.heightIn ? 'l' : 'p',
unit: 'in',
format: [spec.widthIn, spec.heightIn],
});
const pageW = spec.widthIn;
const pageH = spec.heightIn;
const footer = _footerText(meta);
// ─── Page 1: full-page map image ──────────────────────────────────────
doc.addImage(mapDataUrl, 'PNG', 0, 0, pageW, pageH);
_drawFooter(doc, pageW, pageH, footer);
// ─── Pages 2..N: one detail card per people group ────────────────────
for (let i = 0; i < groups.length; i++) {
const pg = groups[i] || {};
doc.addPage();
_drawDetailCard(doc, pageW, pageH, pg, footer);
_setProgress('cards', 2 + i, total, `card ${i + 1}/${groups.length}`);
}
_setProgress('done', total, total, 'packet complete');
return doc.output('blob');
} finally {
isRendering.value = false;
}
}
return {
renderPacket,
progress: readonly(progress),
isRendering: readonly(isRendering),
};
}
// ──────────────────────────────────────────────────────────────────────────
// Detail-card drawing (module-private)
// ──────────────────────────────────────────────────────────────────────────
/**
* Draw a single people-group detail card onto the current PDF page.
* Uses only jsPDF vector primitives so output is crisp and tiny.
*/
function _drawDetailCard(doc, pageW, pageH, pg, footer) {
const margin = Math.min(pageW, pageH) * 0.08;
const x = margin;
let y = margin;
// Heading: people-group name
const name = _pgName(pg);
doc.setFont('helvetica', 'bold');
doc.setFontSize(28);
doc.setTextColor(20, 20, 20);
doc.text(name, x, y + 0.32);
y += 0.7;
// Underline rule beneath the heading
doc.setDrawColor(146, 177, 149); // #92b195 — the Doxa accent green
doc.setLineWidth(0.02);
doc.line(x, y, pageW - margin, y);
y += 0.45;
// Field rows
const rows = [
['Country', _pgCountry(pg)],
['Population', _pgPopulation(pg)],
['% Evangelical', _pgPercent(pg, 'evangelical')],
['% Christian', _pgPercent(pg, 'christian')],
['Prayer count', _pgPrayerCount(pg)],
];
const labelX = x;
const valueX = x + 2.4;
const rowH = 0.5;
doc.setFontSize(14);
for (const [label, value] of rows) {
doc.setFont('helvetica', 'normal');
doc.setTextColor(110, 110, 110);
doc.text(String(label), labelX, y);
doc.setFont('helvetica', 'bold');
doc.setTextColor(40, 40, 40);
doc.text(String(value), valueX, y);
// hairline separator under each row
doc.setDrawColor(225, 225, 225);
doc.setLineWidth(0.008);
doc.line(labelX, y + 0.14, pageW - margin, y + 0.14);
y += rowH;
}
_drawFooter(doc, pageW, pageH, footer);
}
/**
* Footer line drawn on every page: source • date • Mapbox/OSM attribution.
*/
function _drawFooter(doc, pageW, pageH, footer) {
const margin = Math.min(pageW, pageH) * 0.04;
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(120, 120, 120);
doc.text(footer, margin, pageH - margin, { baseline: 'bottom' });
}
function _footerText(meta = {}) {
const date = meta.date || new Date().toISOString().slice(0, 10);
const attribution = meta.attribution || '© Mapbox © OpenStreetMap';
const parts = [];
if (meta.source) parts.push(String(meta.source));
parts.push(String(date));
parts.push(attribution);
return parts.join(' • ');
}
// ──────────────────────────────────────────────────────────────────────────
// Defensive people-group field reads
// ──────────────────────────────────────────────────────────────────────────
// People-group objects reach us in three shapes (see useMapData.js +
// PeopleGroupDetail.vue): the normalized record (top-level keys), the
// backward-compat peopleGroups[] entry, and a GeoJSON feature `.properties`
// bag. `_props` flattens those so a single probe order works for all three.
function _props(pg) {
// Prefer an explicit GeoJSON `.properties`, fall back to the object itself.
return (pg && pg.properties) ? pg.properties : (pg || {});
}
function _firstDefined(...vals) {
for (const v of vals) {
if (v !== undefined && v !== null && v !== '') return v;
}
return undefined;
}
function _pgName(pg) {
const p = _props(pg);
return _firstDefined(
p.name,
p.peopleName,
p.PeopNameInCountry,
p._raw?.PeopNameInCountry,
p._raw?.peo_name,
pg.name,
) ?? '—';
}
function _pgCountry(pg) {
const p = _props(pg);
return _firstDefined(
p.countryName,
p.countryIsoLabel,
p.country,
p.countryIso,
p._raw?.Country,
pg.country,
) ?? '—';
}
function _pgPopulation(pg) {
const p = _props(pg);
const raw = _firstDefined(
p.population,
p._raw?.Population,
pg.population,
);
const n = Number(String(raw ?? '').replace(/,/g, ''));
if (raw === undefined || Number.isNaN(n)) return '—';
return n.toLocaleString();
}
/**
* Percent fields. The pray-tools / UUPG records expose evangelical &
* Christian percentages under several historical keys; probe them all and
* format as a percentage with one decimal.
*/
function _pgPercent(pg, kind) {
const p = _props(pg);
const candidates = kind === 'evangelical'
? [p.percentEvangelical, p.pctEvangelical, p.evangelical, p._raw?.PercentEvangelical, p._raw?.percent_evangelical]
: [p.percentChristian, p.pctChristian, p.percentAdherents, p.christian, p._raw?.PercentChristianPC, p._raw?.percent_christian, p._raw?.PercentAdherents];
const raw = _firstDefined(...candidates);
if (raw === undefined) return '—';
const n = Number(raw);
if (Number.isNaN(n)) return '—';
// Some sources store 0..1 fractions, others 0..100; normalize to percent.
const pct = n > 0 && n <= 1 ? n * 100 : n;
return `${pct.toFixed(1)}%`;
}
function _pgPrayerCount(pg) {
const p = _props(pg);
const raw = _firstDefined(
p.peoplePraying,
p._raw?.people_praying,
pg.peoplePraying,
);
if (raw === undefined) return '—';
const n = Number(raw);
if (Number.isNaN(n)) return '—';
return n.toLocaleString();
}
// ──────────────────────────────────────────────────────────────────────────
// Blob → dataURL
// ──────────────────────────────────────────────────────────────────────────
function _blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error('useMapPacket: failed to read map blob'));
reader.readAsDataURL(blob);
});
}