From c73d713b435b25780a18739fda4e3d598ea86c40 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 06:23:48 +0000 Subject: [PATCH] Delete useMapPacket.js: nothing imports it since both packets print through the shared HTML renderer research-map and countries-map moved to the shared printer, leaving the composable with no importer on main. jsPDF stays: useMapPoster.toPdf, posterSizes and my-upg-100-list still reach it. The file map row goes and the library index is regenerated. --- documentation/architecture/file-map.md | 1 - documentation/reference-library-index.md | 2 - library/composables/useMapPacket.js | 300 ----------------------- 3 files changed, 303 deletions(-) delete mode 100644 library/composables/useMapPacket.js diff --git a/documentation/architecture/file-map.md b/documentation/architecture/file-map.md index ca34c9caa..b310a33c6 100644 --- a/documentation/architecture/file-map.md +++ b/documentation/architecture/file-map.md @@ -96,7 +96,6 @@ Generated by a per-file scan. The `@map/…` import path mirrors each `library/` | `library/composables/useMapFly.js` | Camera navigation: fly/fitBounds helpers, panel-offset centering, antimeridian-safe legend-row zoom. | zoomToLegendRow reads getSource()._data and re-implements Mapbox filter evaluation in JS — extend _matchesFilter/_resolveExpr for new expression ops; wide spans ease to a world overview instead of fitBounds. | | `library/composables/useMapInstance.js` | One map's lifecycle: shadow-DOM CSS injection, aspect-aware dynamic minZoom floor, edge scroll strips, teardown. Returns map, isMapReady, initializeMap, destroy. | initializeMap() in onMounted with a real DOM element ref (string IDs break in shadow DOM); minZoom is a lower bound raised to a desktop-only no-duplicate-world floor; expects sibling `.map-scroll-edge` strips and global mapboxgl; always call destroy(). | | `library/composables/useMapLayers.js` | Creates/manages all Mapbox layers (pin GeoJSON with family derivation + co-location fan-out, regions fill/border, connection lines, prayer-glow rAF ripple) and theme-aware pin ring widths. | Do NOT add a style.load listener here — the host profile owns post-setStyle reconciliation (flash race). addPrayerGlowLayers is idempotent; keep zoom 'case' inside interpolate OUTPUTS; dark-ring exceptions are config-only, keyed by strategy name. | -| `library/composables/useMapPacket.js` | Multi-page "prayer packet" PDF: page 1 = poster map raster, then one jsPDF vector card per people group. Returns renderPacket, progress, isRendering. | Page 1 reuses useMapPoster.render — keep that the single source of truth; jspdf lazy-imported; footer attribution on every page is a ToS requirement; field reads deliberately defensive across 3 object shapes. | | `library/composables/useMapSelection.js` | Applies region/family selection to layer paint props (highlight/fade opacity, connection lines). | Region matching is on iso_3166_1_alpha_3 (tileset has no `region` prop); labels fuzzy-normalized to slugs; selection state lives in the Pinia mapStore. | | `library/composables/useMapTheme.js` | Seamless light/dark toggle by flipping Mapbox Standard's lightPreset/theme config props. Returns bootStyle, applyTheme, swapTheme, isDarkTheme. | Never setStyle to switch themes (reloads map, wipes custom layers); applyTheme must run after style.load and re-asserts once on 'idle' (fast-toggle grey-film race); profiles should watch(uiStore.theme) → applyTheme. | | `library/composables/usePplrInstance.js` | Per-map mediator "instance store" (provide/inject refs) so map, geocoder, legend in one cluster communicate. Exports createPplrInstance, provideInstance, useInstance, selectNode, clearAll. | Not Pinia — plain refs under key 'pplrInstance'; useInstance() returns null when standalone, so consumers must null-guard; profiles bridge instance.selection to their own store. | diff --git a/documentation/reference-library-index.md b/documentation/reference-library-index.md index 11f4d6108..bcbbfcf7c 100644 --- a/documentation/reference-library-index.md +++ b/documentation/reference-library-index.md @@ -163,8 +163,6 @@ and a change here can break other maps. `import … from '@map/composables/useMapInstance.js'` - **useMapLayers** — useMapLayers.js - Map Layers Composable `import … from '@map/composables/useMapLayers.js'` -- **useMapPacket** — useMapPacket — render a multi-page "prayer packet" PDF for a map selection. - `import … from '@map/composables/useMapPacket.js'` - **useMapProvider** — useMapProvider.js — resolve the active map engine ("provider"). `import … from '@map/composables/useMapProvider.js'` - **useMapSelection** — useMapSelection.js - Map Selection Composable diff --git a/library/composables/useMapPacket.js b/library/composables/useMapPacket.js deleted file mode 100644 index 878ddab02..000000000 --- a/library/composables/useMapPacket.js +++ /dev/null @@ -1,300 +0,0 @@ -/** - * 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, meta?: Object) => Promise, - * progress: import('vue').Ref<{ phase: string, completed: number, total: number, message: string }>, - * isRendering: import('vue').Ref - * }} - */ -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} peopleGroups normalized people-group objects - * @param {Object} [meta] { source?, date?, attribution? } - * @returns {Promise} - */ - 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); - }); -}