Prayer Walk Planner: full screen from any embed, and a print proven sharp by resolution

The Driver asked for full-screen, and for "a print that stays sharp when
zoomed".

Full screen. The planner mounts the shared FullscreenButton (the library's
Fullscreen-API control, with its CSS pseudo-fullscreen fallback for iOS
Safari) as a map overlay. It full-screens the whole planner, toolbar and
map, not just the canvas. Measured from a 520px embed in the Research
Gallery on a foreign page: the planner took 1280x800 and the map grew 459
→ 739px. The label is translated (Arabic "تبديل ملء الشاشة"), because the
planner now keeps the shared vue-i18n instance on its own locale. Before,
Share and Full screen took the PAGE language even when the gallery sent
another.

Print resolution, measured, not asserted. The printed map lays out at
7.53 x 8.4 in on the letter sheet (printable width = 8.5in minus the two
12mm margins; emulateMedia alone does not apply @page margins, which
misread it once as 8.48in). Its image is 2400x2688 px, so 319 x 320 DPI.
A 1:1 crop of the zoomed print was read: small street names (Sancaktar
Yokuşu Sokağı, Baki Dede Sokağı), individual building outlines and
footpaths are crisp, because the map is rendered from vector tiles at
pixelRatio 2, not upscaled from the screen. One PDF page.

check:prayer-walk-planner gains both: full screen from a 520px embed must
take the viewport, and the printed map must measure >= 300 DPI. 10/10
PASS.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 10:51:21 +00:00
parent 6a2cc89207
commit 30e6e5699b
7 changed files with 70 additions and 23 deletions

View file

@ -13,6 +13,8 @@
* each way) that is not blank: many distinct colours, mostly non-white. The image is saved so a * each way) that is not blank: many distinct colours, mostly non-white. The image is saved so a
* human can READ it: streets, building footprints, the zone outline on top. * human can READ it: streets, building footprints, the zone outline on top.
* 3. LOCALE — ?lang=es gives a Spanish toolbar. 4. 0 page errors on the print page. * 3. LOCALE — ?lang=es gives a Spanish toolbar. 4. 0 page errors on the print page.
* 5. FULL SCREEN — from a 520px embed, the whole planner takes the viewport.
* 6. PRINT RESOLUTION — the printed map measures >= 300 DPI at the sheet's printable width.
* *
* Starts its own servers on OS-assigned ports; needs only a built tree. Run it inside the shared * Starts its own servers on OS-assigned ports; needs only a built tree. Run it inside the shared
* browser locks, like every browser gate. OUT=<dir> chooses where the print PNG is written. * browser locks, like every browser gate. OUT=<dir> chooses where the print PNG is written.
@ -69,6 +71,17 @@ try {
console.log(`${ok ? 'PASS' : 'FAIL'} size ${where} ${W}x${H}: ${JSON.stringify(m)} ${errs.length ? 'ERR ' + errs.join(' | ') : '0 errors'}`) console.log(`${ok ? 'PASS' : 'FAIL'} size ${where} ${W}x${H}: ${JSON.stringify(m)} ${errs.length ? 'ERR ' + errs.join(' | ') : '0 errors'}`)
await p.close() await p.close()
} }
// 1b. FULL SCREEN from inside a small embed: the whole planner (toolbar + map) takes the viewport.
{
const p = await b.newPage({ viewport: { width: 1280, height: 800 } })
const fsHost = createServer((q, r) => { r.writeHead(200, { 'content-type': 'text/html' }); r.end(`<!doctype html><body style="margin:0"><h1 style="height:80px;margin:0">host</h1><script src="${TREE}/doxa-maps-spa/doxa-maps-spa.js"></script><doxa-maps-spa tab="prayer-walk-planner" style="height:520px"></doxa-maps-spa></body>`) })
const FP = await new Promise(ok => fsHost.listen(0, 'localhost', () => ok(fsHost.address().port)))
await p.goto(`http://localhost:${FP}/`); await p.waitForTimeout(9000)
const fs = await p.evaluate(`(async () => { const el = (${findEl.toString()})(); const btn = el.shadowRoot.querySelector('.pwp-map-controls button'); if (!btn) return null; const before = Math.round(el.shadowRoot.querySelector('canvas').getBoundingClientRect().height); btn.click(); await new Promise(r => setTimeout(r, 2500)); const r = el.getBoundingClientRect(); return { before, after: Math.round(el.shadowRoot.querySelector('canvas').getBoundingClientRect().height), host: [Math.round(r.width), Math.round(r.height)] } })()`)
const fok = !!fs && fs.host[0] >= 1270 && fs.host[1] >= 790 && fs.after > fs.before
console.log(`${fok ? 'PASS' : 'FAIL'} full screen from a 520px embed: ${JSON.stringify(fs)}`); results.push({ check: 'full screen', ok: fok })
await p.close(); fsHost.close()
}
// 2. PRINT MAP of a real zone (Balat, Istanbul: dense houses on small streets) + 4. LOCALE (es) // 2. PRINT MAP of a real zone (Balat, Istanbul: dense houses on small streets) + 4. LOCALE (es)
const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); const errs = [] const p = await b.newPage({ viewport: { width: 1280, height: 800 } }); const errs = []
p.on('pageerror', e => errs.push(String(e).slice(0, 140))) p.on('pageerror', e => errs.push(String(e).slice(0, 140)))
@ -86,6 +99,24 @@ try {
console.log(`${pok ? 'PASS' : 'FAIL'} print map: ${JSON.stringify(st)} -> ${OUT}/pwp-print-balat.png`); results.push({ check: 'print map', ok: pok, st }) console.log(`${pok ? 'PASS' : 'FAIL'} print map: ${JSON.stringify(st)} -> ${OUT}/pwp-print-balat.png`); results.push({ check: 'print map', ok: pok, st })
} else { console.log('FAIL print map: renderZoneMap returned null'); results.push({ check: 'print map', ok: false }) } } else { console.log('FAIL print map: renderZoneMap returned null'); results.push({ check: 'print map', ok: false }) }
console.log(`${errs.length ? 'FAIL' : 'PASS'} page errors on print page: ${errs.length} ${errs.join(' | ')}`); results.push({ check: 'errors', ok: !errs.length }) console.log(`${errs.length ? 'FAIL' : 'PASS'} page errors on print page: ${errs.length} ${errs.join(' | ')}`); results.push({ check: 'errors', ok: !errs.length })
// 5. PRINT RESOLUTION, MEASURED: the real printed sheet laid out as PRINT at the printable width
// (8.5in - 2 x 12mm @page margins = 725 CSS px; emulateMedia alone does NOT apply @page margins,
// which is how a first measurement read the frame as 8.48in wide). DPI = natural px / printed in.
// The Driver: "a print that stays sharp when zoomed" — at >= 300 DPI a zoomed PDF shows real
// street names and house outlines, not an upscaled screen grab.
await p.evaluate(() => { new MutationObserver(ms => { for (const m of ms) for (const n of m.addedNodes) if (n.tagName === 'IFRAME') { try { n.contentWindow.print = () => {} } catch (_) {} window.__pwpFrame = n } }).observe(document.body, { childList: true }) })
await p.evaluate(`(async () => { const el = (${findEl.toString()})(); const z = el.listPolygons().find(x => x.title === 'Balat'); el.selectPolygon(z.id, true); await el.print() })()`)
await p.waitForTimeout(3000)
const sheet = await p.evaluate(() => window.__pwpFrame ? window.__pwpFrame.contentDocument.documentElement.outerHTML : '')
if (sheet) {
const q = await b.newPage(); await q.emulateMedia({ media: 'print' }); await q.setViewportSize({ width: 725, height: 965 })
await q.setContent('<!DOCTYPE html>' + sheet.replace(/<script>[\s\S]*?<\/script>/g, ''), { waitUntil: 'load' }); await q.waitForTimeout(1200)
const dpi = await q.evaluate(() => { const i = document.querySelector('.snapshot'); if (!i) return null; const r = i.getBoundingClientRect(); return { naturalPx: [i.naturalWidth, i.naturalHeight], printedInches: [+(r.width / 96).toFixed(2), +(r.height / 96).toFixed(2)], dpi: [Math.round(i.naturalWidth / (r.width / 96)), Math.round(i.naturalHeight / (r.height / 96))] } })
const dok = !!dpi && dpi.dpi[0] >= 300 && dpi.dpi[1] >= 300
console.log(`${dok ? 'PASS' : 'FAIL'} print resolution: ${JSON.stringify(dpi)}`); results.push({ check: 'print dpi', ok: dok })
await q.close()
} else { console.log('FAIL print resolution: the print frame never appeared'); results.push({ check: 'print dpi', ok: false }) }
await p.close() await p.close()
} finally { await b.close(); host.close(); srv.kill() } } finally { await b.close(); host.close(); srv.kill() }
const bad = results.filter(r => !r.ok).length const bad = results.filter(r => !r.ok).length

View file

@ -46,11 +46,12 @@
* source of truth (the `polygons` ref), so a human clicking buttons and an agent * source of truth (the `polygons` ref), so a human clicking buttons and an agent
* calling methods can freely interleave. * calling methods can freely interleave.
*/ */
import { ref, shallowRef, reactive, computed, onMounted, onBeforeUnmount, nextTick } from 'vue' import { ref, shallowRef, reactive, computed, onMounted, onBeforeUnmount, nextTick, inject, watch } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.js' import { useMapInstance } from '@map/composables/useMapInstance.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { openPrintFrame } from '@map/composables/printInPlace.js' import { openPrintFrame } from '@map/composables/printInPlace.js'
import ShareButton from '@map/components/map-controls/ShareButton.vue' import ShareButton from '@map/components/map-controls/ShareButton.vue'
import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
import { resolveNamedStyle } from '@map/styles/registry.js' import { resolveNamedStyle } from '@map/styles/registry.js'
import { ensureRtlTextPlugin } from '@map/maps/rtl-text.js' import { ensureRtlTextPlugin } from '@map/maps/rtl-text.js'
import { localizeBasemapLabels } from '@map/maps/localize-labels.js' import { localizeBasemapLabels } from '@map/maps/localize-labels.js'
@ -102,6 +103,14 @@ const uiLocale = computed(() => {
const uiDir = computed(() => (uiLocale.value === 'ar' ? 'rtl' : 'ltr')) const uiDir = computed(() => (uiLocale.value === 'ar' ? 'rtl' : 'ltr'))
const tr = (key, params) => translate(uiLocale.value, key, params) const tr = (key, params) => translate(uiLocale.value, key, params)
// The shared controls (Share, Full screen) label themselves through the vue-i18n instance index.js
// installs, which reads only the PAGE language. Inside the Research Gallery the language arrives in
// profile-config instead, so keep that instance on the planner's own locale.
const appI18n = inject('appI18n', null)
watch(uiLocale, (l) => {
try { if (appI18n && appI18n.global) { if (typeof appI18n.global.locale === 'object') appI18n.global.locale.value = l; else appI18n.global.locale = l } } catch (_) { /* labels stay in the page language */ }
}, { immediate: true })
const INSTANCE_ID = props.instanceId || ('pwp-' + Math.random().toString(36).slice(2, 10)) const INSTANCE_ID = props.instanceId || ('pwp-' + Math.random().toString(36).slice(2, 10))
const STORAGE_KEY = 'prayer-walk-planner:v1:' + INSTANCE_ID const STORAGE_KEY = 'prayer-walk-planner:v1:' + INSTANCE_ID
@ -612,6 +621,8 @@ useShadowStyles(
'.pwp-body{position:relative;flex:1;min-height:0;display:flex}' + '.pwp-body{position:relative;flex:1;min-height:0;display:flex}' +
'.pwp-map{position:relative;flex:1;min-width:0}' + '.pwp-map{position:relative;flex:1;min-width:0}' +
'.pwp-map-el{position:absolute;inset:0}' + '.pwp-map-el{position:absolute;inset:0}' +
'.pwp-map-controls{position:absolute;top:10px;right:10px;z-index:4}' +
'.pwp[dir=rtl] .pwp-map-controls{right:auto;left:10px}' +
'.pwp-list{width:260px;flex-shrink:0;overflow-y:auto;background:#fff;border-left:1px solid #d1d4cd;padding:8px}' + '.pwp-list{width:260px;flex-shrink:0;overflow-y:auto;background:#fff;border-left:1px solid #d1d4cd;padding:8px}' +
'.pwp-list-empty{padding:16px 10px;font-size:12px;color:#6d766e}' + '.pwp-list-empty{padding:16px 10px;font-size:12px;color:#6d766e}' +
'.pwp-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;margin-bottom:4px;' + '.pwp-item{display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;margin-bottom:4px;' +
@ -685,6 +696,11 @@ useShadowStyles(
<div class="pwp-body"> <div class="pwp-body">
<div class="pwp-map"> <div class="pwp-map">
<div ref="mapContainer" class="pwp-map-el"></div> <div ref="mapContainer" class="pwp-map-el"></div>
<!-- FULL SCREEN for the WHOLE planner (toolbar + map), not just the canvas: the shared
FullscreenButton requests fullscreen on this element's custom-element host, and falls
back to a CSS pseudo-fullscreen where the API does not exist (iOS Safari). A planner
embedded in a partner page or the gallery can take the whole screen for drawing. -->
<div class="pwp-map-controls"><FullscreenButton :map-container="mapContainer" /></div>
</div> </div>
<div class="pwp-list"> <div class="pwp-list">
<div v-if="polygons.length === 0" class="pwp-list-empty"> <div v-if="polygons.length === 0" class="pwp-list-empty">

View file

@ -863,7 +863,7 @@
"bundle": "prayer-walk-planner", "bundle": "prayer-walk-planner",
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js", "scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js", "output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
"integrity": "sha384-xlbZdmRFPSAnxx7W/cetgQQjY3mAH5byO0uZWvFi4fCYkkzHZnBl78HSRCQcqgv9", "integrity": "sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV",
"elements": [ "elements": [
"prayer-walk-planner" "prayer-walk-planner"
], ],
@ -879,7 +879,7 @@
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js", "scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-xlbZdmRFPSAnxx7W/cetgQQjY3mAH5byO0uZWvFi4fCYkkzHZnBl78HSRCQcqgv9\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>" "script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
} }
} }
] ]

File diff suppressed because one or more lines are too long

View file

@ -863,7 +863,7 @@
"bundle": "prayer-walk-planner", "bundle": "prayer-walk-planner",
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js", "scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js", "output": "doxa-maps/prayer-walk-planner/prayer-walk-planner.js",
"integrity": "sha384-xlbZdmRFPSAnxx7W/cetgQQjY3mAH5byO0uZWvFi4fCYkkzHZnBl78HSRCQcqgv9", "integrity": "sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV",
"elements": [ "elements": [
"prayer-walk-planner" "prayer-walk-planner"
], ],
@ -879,7 +879,7 @@
"scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js", "scriptUrl": "./prayer-walk-planner/prayer-walk-planner.js",
"embed": { "embed": {
"iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>", "iframe": "<iframe src=\"./prayer-walk-planner/prayer-walk-planner/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-xlbZdmRFPSAnxx7W/cetgQQjY3mAH5byO0uZWvFi4fCYkkzHZnBl78HSRCQcqgv9\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>" "script": "<!-- Mapbox engine, fetched early; remove this line if you set \"provider\":\"maplibre\" -->\n<link rel=\"preload\" as=\"script\" href=\"https://api.mapbox.com/mapbox-gl-js/v3.24.0/mapbox-gl.js\">\n<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./prayer-walk-planner/prayer-walk-planner.js\" integrity=\"sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV\" crossorigin=\"anonymous\"></script>\n<prayer-walk-planner profile-config='{\"profile\":\"prayer-walk-planner\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></prayer-walk-planner>"
} }
} }
] ]

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script> <script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <prayer-walk-planner> custom element. --> <!-- Bundle IIFE — registers the <prayer-walk-planner> custom element. -->
<script src="../prayer-walk-planner.js" integrity="sha384-xlbZdmRFPSAnxx7W/cetgQQjY3mAH5byO0uZWvFi4fCYkkzHZnBl78HSRCQcqgv9" crossorigin="anonymous"></script> <script src="../prayer-walk-planner.js" integrity="sha384-rI097ecm/idym9j/Od/aOtgqhk0kB/1CwMpal9jTLm/ZyNKdlo6eYRMwV+J0rJyV" crossorigin="anonymous"></script>
<script> <script>
// Mapbox token resolution — the A3 ladder, in precedence order: // Mapbox token resolution — the A3 ladder, in precedence order: