From 70c716057a5cf5bc8d79b6b54c7de64c10cc60ac Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 09:36:48 +0000 Subject: [PATCH] One map-print template, piloted on Simple Research: landscape, the map's name, the legend beside it, a prayer line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map's "Export poster…" now prints through a shared template: always landscape Letter; a header with the map's name (from the host's profile-config mapTitle), the view on screen as its subtitle; the legend as a side panel; and a footer line from the prayer packet's final page (prayer point 6 with Luke 10:2 and John 10:11-15, in the reader's language). While it renders, a loading screen like the poster wall's shows the renderer's own progress. Without a mapTitle the view on screen stays the title, as before. --- .../components/poster/MapPrintProgress.vue | 45 +++++ .../composables/poster/mapPrintTemplate.js | 61 ++++++ .../doxa-research-map/research-map.vue | 72 ++++---- public/dx-maps/doxa-maps-spa/manifest.json | 6 +- .../doxa-research-map/doxa-research-map.js | 174 +++++++++--------- .../doxa-research-map/research-map/index.html | 2 +- .../research-map/maplibre/index.html | 2 +- public/dx-maps/manifest.json | 6 +- 8 files changed, 235 insertions(+), 133 deletions(-) create mode 100644 library/components/poster/MapPrintProgress.vue create mode 100644 library/composables/poster/mapPrintTemplate.js diff --git a/library/components/poster/MapPrintProgress.vue b/library/components/poster/MapPrintProgress.vue new file mode 100644 index 000000000..0eb44eee7 --- /dev/null +++ b/library/components/poster/MapPrintProgress.vue @@ -0,0 +1,45 @@ + + + + diff --git a/library/composables/poster/mapPrintTemplate.js b/library/composables/poster/mapPrintTemplate.js new file mode 100644 index 000000000..92e69aa9b --- /dev/null +++ b/library/composables/poster/mapPrintTemplate.js @@ -0,0 +1,61 @@ +/** + * mapPrintTemplate.js — SHARED (@map/composables/poster/mapPrintTemplate.js). + * + * ★ ONE TEMPLATE FOR A MAP'S PRINT (2026-09-30: "the map download/print should look finished"). Every map's + * "Export poster…" builds its sheet here, so they all look the same: + * - LANDSCAPE Letter, always: a map is wider than a page is tall, and a standard sheet prints anywhere; + * - a HEADER with the MAP's own name, the view on screen ("Prayer", "Religion"…) as its subtitle; + * - the LEGEND as a side panel (computeLayout's right-hand column); + * - a FOOTER line from the prayer packet's final page, the 12 prayer points: point 6, the prayer for laborers + * and shepherds with its scripture references, in the reader's language. packet-strings.json already + * carries it, with the book names, in all 11 locales, so no scripture text is newly translated. + * The loading screen that shows the renderer's progress is MapPrintProgress.vue. + * Piloted on Simple Research's map; the other maps adopt it after it has been seen. + */ +import { defaultPosterSpec } from '../../constants/posterDefaults.js' + +/** The prayer point (1-12) whose references the footer quotes. */ +export const MAP_PRINT_POINT = 6 + +/** + * The poster spec for a map print. Rendered by useMapPoster, then printed by printImageBlob. + * @param {{ title: string, subtitle?: string, legendRows?: Array, legendTitle?: string, dir?: 'ltr'|'rtl', + * footerLeft?: string, footerRight?: string, attribution?: string }} o + */ +export function mapPrintSpec(o = {}) { + const spec = defaultPosterSpec({ widthIn: 11, heightIn: 8.5, dpi: 200, orientation: 'landscape' }) + spec.dir = o.dir === 'rtl' ? 'rtl' : 'ltr' + // The composed sheet: title band, map, legend column, footer (see useMapPoster, layout 'poster'). + spec.layout = 'poster' + spec.slots.title = { ...spec.slots.title, text: o.title || '', ...(o.subtitle ? { subtitle: o.subtitle } : {}) } + spec.slots.legend = { ...spec.slots.legend, rows: o.legendRows || [], title: o.legendTitle || '' } + // No scale bar or north arrow: the headless renderer's are placeholders, and a globe has no single scale. + spec.slots.scaleBar = false + spec.slots.north = false + spec.slots.footer = { + ...spec.slots.footer, + left: o.footerLeft || '', + right: o.footerRight || '', + ...(o.attribution ? { attribution: o.attribution } : {}), + } + return spec +} + +/** + * The footer line in `lang`: the prayer point's heading and its references, with the book names from the same + * table ("laborers & shepherds — Luke 10:2 · John 10:11–15"). '' when the table has no such point. + * @param {Object} strings loadPacketStrings()'s table ({ en, es, …, zh }) + */ +export function packetScriptureLine(strings, lang = 'en') { + const loc = (strings && (strings[lang] || strings.en)) || {} + const en = (strings && strings.en) || {} + const points = (Array.isArray(loc.points) && loc.points.length ? loc.points : en.points) || [] + const p = points.find((x) => x && x.n === MAP_PRINT_POINT) || points[MAP_PRINT_POINT - 1] + if (!p) return '' + const books = loc.books || {} + const refs = (p.refs || []).map((r) => { + const m = String(r).match(/^((?:\d\s)?[A-Za-z]+)\s+(.+)$/) + return m && books[m[1]] ? `${books[m[1]]} ${m[2]}` : String(r) + }) + return [p.sub, refs.join(' · ')].filter(Boolean).join(' — ') +} diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 0e11b9a05..695755e8b 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -37,6 +37,8 @@ */ import { getCurrentInstance, inject, provide, ref, computed, onMounted, onBeforeUnmount, watch, markRaw, toRaw, nextTick, toRef } from 'vue' +import MapPrintProgress from '@map/components/poster/MapPrintProgress.vue' +import { resolveAssetsBase } from '@map/utils/embedBase.js' import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js' // ─── Composables (framework) ───────────────────────────────────────────────── @@ -1412,54 +1414,44 @@ function posterLegendRows(preferFlat) { } const posterBusy = ref(false) +// The loading screen (MapPrintProgress): the title it names and the renderer's progress while it works. +const printTitle = ref('') +const printProgress = ref(null) async function openPoster() { if (posterBusy.value) return posterBusy.value = true try { - const [{ useMapPoster }, { defaultPosterSpec }, { printImageBlob }] = await Promise.all([ + const [{ useMapPoster }, { mapPrintSpec, packetScriptureLine }, { printImageBlob }, { loadPacketStrings }] = await Promise.all([ import('@map/composables/poster/useMapPoster.js'), - import('@map/constants/posterDefaults.js'), + import('@map/composables/poster/mapPrintTemplate.js'), import('@map/composables/printInPlace.js'), + import('@map/packet/packetStrings.js'), ]) const exporter = useMapPoster(() => map.value) - // ★ THE PAPER FOLLOWS THE MAP, because a map is usually wider than it is tall and a portrait sheet - // letterboxes it (reported 2026-09-28: the print came out vertical and it should "probably [be] a - // horizontal map"). Same 8.5x11 stock either way — only the ORIENTATION turns, so the sheet is - // still a standard page and not a custom size no printer has. - // Measured from the map's own container rather than from the window: the map may be one pane of a - // composite, and it is the MAP's shape being exported, not the browser's. - const mapEl = map.value?.getContainer?.() || null - const landscape = !!mapEl && mapEl.clientWidth > mapEl.clientHeight - const spec = defaultPosterSpec({ - widthIn: landscape ? 11 : 8.5, - heightIn: landscape ? 8.5 : 11, - dpi: 200, - orientation: landscape ? 'landscape' : 'portrait', - }) - // The poster is titled by the VIEW on screen ("Prayer", "Religion"…), in the reader's language. - // The legend's title bar is the fallback only: on a flat tab it is the generic word "Legend". - const title = tabLabel(activeTab.value) || legendTitle.value || 'Doxa Map' + // The loading screen follows the renderer's own progress for as long as this print runs. + stopPrintWatch = watch(() => exporter.progress.value, (p) => { printProgress.value = p ? { ...p } : null }, { immediate: true, deep: true }) + // ★ THE SHARED MAP-PRINT TEMPLATE (mapPrintTemplate.js): landscape Letter, a header with the MAP's name and + // the view as its subtitle, the legend as a side panel, and a footer line from the prayer packet's final page. + // The map's name comes from the host (profile-config mapTitle, e.g. the gallery's tab label); without one the + // view on screen ("Prayer", "Religion"…) is the title, as before. + const view = tabLabel(activeTab.value) || legendTitle.value || 'Doxa Map' + const mapTitle = String(profileConfig?.value?.mapTitle || '').trim() + const title = mapTitle || view + printTitle.value = title // Direction follows the MAP's language, not the host page's: an English map on an Arabic page stays LTR. - spec.dir = ['ar', 'fa', 'he', 'ur'].includes(String(uiLocale.value || '').split('-')[0]) ? 'rtl' : 'ltr' - // ★ ASK FOR THE COMPOSED SHEET. Without this the renderer keeps its historical behaviour — the map - // across the whole page with the chrome painted on top, which is what put the legend over the map. - // It is opt-in because country-posters embeds this renderer's output INSIDE its own packet page, - // under the packet's own title, so default-on bands would print a second header there. - spec.layout = 'poster' - spec.slots.title = { ...spec.slots.title, text: title } - // The legend the reader sees: the flat tab's rows (and their sub-rows), else the aggregated ones. - const rows = posterLegendRows(true) - spec.slots.legend = { ...spec.slots.legend, rows, title: legendTitle.value } - // No scale bar or north arrow: the headless renderer's are placeholders (a fixed bar reading - // "scale"), which on a print would be a false statement — and a globe has no single scale. - spec.slots.scaleBar = false - spec.slots.north = false - // Credit the tiles actually drawn: OpenFreeMap on MapLibre, Mapbox otherwise. - // C3: ask the library, not the globals. The explicit === 'maplibre' keeps the original FALSE for - // a map that is neither, so the Mapbox attribution stays the fall-through it always was. + const lang = String(uiLocale.value || 'en').split('-')[0] + const dir = ['ar', 'fa', 'he', 'ur'].includes(lang) ? 'rtl' : 'ltr' + // loadPacketStrings never rejects: a copy it cannot use gives the English table. + const scripture = packetScriptureLine(await loadPacketStrings(resolveAssetsBase(profileConfig?.value)), lang) + // Credit the tiles actually drawn: OpenFreeMap on MapLibre, Mapbox otherwise (C3: ask the library). const onMaplibre = providerFor(map.value) === 'maplibre' - spec.slots.footer = { ...spec.slots.footer, left: 'pray.doxa.life', right: new Date().toISOString().slice(0, 10), - attribution: onMaplibre ? '© OpenFreeMap © OpenMapTiles © OpenStreetMap' : '© Mapbox © OpenStreetMap' } + const date = new Date().toISOString().slice(0, 10) + const spec = mapPrintSpec({ + title, subtitle: mapTitle ? view : '', dir, + legendRows: posterLegendRows(true), legendTitle: legendTitle.value, + footerLeft: scripture || 'pray.doxa.life', footerRight: scripture ? `pray.doxa.life · ${date}` : date, + attribution: onMaplibre ? '© OpenFreeMap © OpenMapTiles © OpenStreetMap' : '© Mapbox © OpenStreetMap', + }) const blob = await exporter.render(spec, 'png') // ★ THE DIALOG, NOT A DOWNLOAD (ruled 2026-09-28: an export-poster button must open the // print dialog with a poster preview, never hand over a file). This used to call _downloadBlob, @@ -1481,9 +1473,12 @@ async function openPoster() { } catch (err) { console.error('[research-map] poster export failed:', err) } finally { + if (stopPrintWatch) { stopPrintWatch(); stopPrintWatch = null } + printProgress.value = null posterBusy.value = false } } +let stopPrintWatch = null // The toolbar's export label, WITH its ellipsis. The ellipsis was stripped while this button handed // over a file directly; it now opens the print dialog, which is exactly what an ellipsis promises. const exportPosterLabel = computed(() => String(t('controls.export_poster') || 'Export poster…')) @@ -3177,6 +3172,7 @@ onBeforeUnmount(() => {