Wall of People Groups: the poster print counts its photos while they load, and says how many could not load

The HD poster prints on its frame's 'load', which waits for every photo: up to 2,107 photos, 132 MB, which can take minutes on a slow connection, and a photo that fails prints as a grey box without a word. The print button now reads "Preparing the poster: n of 2,107 photos" while the frame loads, and if any photo could not load, a toast says how many print as grey boxes. The counting is src/printWatch.js (it never decides when to print; the frame's own 'load' does). It finishes at 'load', at the frame's 10-minute ceiling, or at once if the frame is gone (closed), so the busy button never outlives its frame. The busy state's fallback moves from 120 s to that ceiling, because the frame is now kept until it has loaded (printInPlace, exp-shepherd/print-floor).

Headless, fake timers and a fake frame (_qa-cycle/exp-shepherd/check-wall-print-watch.mjs): progress climbs as photos load; 3 photos with no pixels are counted as failed; a load that never ends finishes once at the ceiling and not before; a frame removed at 60 s ends the watch within a tick; 4 planted defects (failures not counted, no ceiling, no progress, a removed frame holding the watch) each caught.
This commit is contained in:
Doxa Maps 2026-09-29 07:47:32 +00:00
parent 3a004068b7
commit 7e309d1777
2 changed files with 64 additions and 9 deletions

View file

@ -8,9 +8,9 @@
<span class="btn-label">{{ isLandscape ? $t('wall.toPortrait') : $t('wall.toLandscape') }}</span>
<span class="current-mode">{{ isLandscape ? $t('wall.currentLandscape') : $t('wall.currentPortrait') }}</span>
</button>
<!-- Loading every card's HD photo can take a while: the button shows it is busy and cannot
start a second load while the first is still running. -->
<button @click="onPrint" class="control-btn" :class="{ busy: printing }" :disabled="printing" :aria-busy="printing ? 'true' : 'false'">{{ $t('wall.printPoster') }}</button>
<!-- Loading every card's HD photo can take a while: the button shows it is busy, counts the
photos as they arrive, and cannot start a second load while the first is still running. -->
<button @click="onPrint" class="control-btn" :class="{ busy: printing }" :disabled="printing" :aria-busy="printing ? 'true' : 'false'">{{ printLabel }}</button>
<button @click="resetZoom" class="control-btn">{{ $t('wall.resetZoom') }}</button>
<!-- BLESS (default): name, photo and a blessing verse under every group, made to print.
SOCIAL: the interactive card (pray, comments, share). -->
@ -54,6 +54,7 @@
import { WallCanvas, groupLink } from '../wallCanvas.js'
import { buildBlessPosterHtml, screenPoster, PRESETS, DEFAULT_PRESET } from '../blessPoster.js'
import { openPrintFrame } from '@map/composables/printInPlace.js'
import { watchPrintFrame } from '../printWatch.js'
import { useHostedHelp } from '@map/composables/useHostedHelp.js'
// Wall of People Groups: a zoomable canvas wall (wallCanvas.js), Bless or Social cards, and the poster print.
export default {
@ -72,8 +73,9 @@ export default {
// Each label is isolated left to right (U+2066 … U+2069): "5 × 8 ft · 3×" would otherwise be reordered
// by an RTL page into "×ft · 3 8 × 5" (an <option> cannot hold a <bdi>).
posterPresets: Object.entries(PRESETS).map(([id, p]) => ({ id, label: '\u2066' + p.label + (p.sheets > 1 ? ' · ' + p.sheets + '×' : '') + '\u2069' })),
printing: false,
toast: '', // a short status line over the wall (e.g. "Copied!"), cleared by flash() // the HD poster print is loading photos (drives the button's busy state)
printing: false, // the HD poster print is loading photos (drives the button's busy state)
printProgress: null, // { n, total, failed }: the print frame's photos, counted while they load
toast: '', // a short status line over the wall (e.g. "Copied!"), cleared by flash()
cardWidth: 50,
cardHeight: 70,
cardSpacing: 8, // Increased horizontal spacing (was 2)
@ -99,6 +101,11 @@ export default {
loadError() {
return this.$store?.error || null;
},
printLabel() {
const p = this.printing && this.printProgress
return p ? this.$t('wall.printPreparing', { n: p.n.toLocaleString('en-US'), total: p.total.toLocaleString('en-US') }) : this.$t('wall.printPoster');
}
},
mounted() {
@ -161,10 +168,19 @@ export default {
this.printing = true;
const texts = this.posterTexts(groups.length);
const win = openPrintFrame(buildBlessPosterHtml(groups, this.posterPreset, texts), { autoprint: true });
// Busy until the photos have loaded and the print dialog is up (the frame prints on its own 'load').
const done = () => { this.printing = false; };
if (win) { try { win.addEventListener('load', () => setTimeout(done, 300), { once: true }); } catch (_) { done(); } setTimeout(done, 120000); }
else done();
// Busy until the photos have loaded and the print dialog is up (the frame prints on its own 'load',
// which waits for every photo). Never silently incomplete: the button counts the photos as they
// arrive (printWatch.js), and a photo that could not load is counted out loud, since it prints as
// a grey box.
const done = () => { this.printing = false; this.printProgress = null; };
if (!win) { done(); return; }
watchPrintFrame(win, {
onProgress: (c) => { this.printProgress = c; },
onDone: (c) => {
if (c && c.failed) this.flash(this.$t('wall.printMissing', { n: c.failed.toLocaleString('en-US') }), 10000);
setTimeout(done, 300);
},
});
},
// The wall in Bless mode IS the poster (WYSIWYG): the same sheets, cards, title, chart and footer that
// print, from the same blessPoster layout. Re-planned when the groups, the size or the language change.

View file

@ -0,0 +1,39 @@
/**
* printWatch.js — counts a print frame's photos while they load, and how many could not load.
*
* The frame prints on its own 'load' (library/composables/printInPlace.js), and 'load' waits for every
* photo, so this never decides WHEN to print: it only reports. The HD poster loads up to 2,107 photos
* (132 MB), which can take minutes on a slow connection, so the reader sees a count instead of a busy
* button. A photo that could not load is complete but has no pixels, and prints as a grey box, so the
* caller says how many rather than printing silently incomplete.
*/
/** { n, total, failed } for the frame's images right now, or null when the frame cannot be read. */
export function countImages(win) {
let imgs = null
try { imgs = win.document.images } catch (_) { return null }
if (!imgs) return null
let n = 0, failed = 0
for (const im of imgs) if (im.complete) { n++; if (!im.naturalWidth) failed++ }
return { n, total: imgs.length, failed }
}
/**
* Calls onProgress(count) now and every `every` ms while the frame loads, then onDone(count) exactly once:
* at the frame's 'load', at `ceilingMs` for a load that never ends (the frame's own ceiling in
* printInPlace.js), or with null as soon as the frame is gone (closed). Returns stop(), which ends the
* watch without a count.
*/
export function watchPrintFrame(win, { onProgress = () => {}, onDone = () => {}, every = 500, ceilingMs = 600000 } = {}) {
let iv = null, cap = null, over = false
const finish = (c) => { if (over) return; over = true; clearInterval(iv); clearTimeout(cap); onDone(c) }
// A frame removed before its 'load' (an older printInPlace dropped it 60 s after creation) never fires
// 'load': end the watch then, rather than holding the busy button until the ceiling.
const tick = () => { if (win.closed) return finish(null); const c = countImages(win); if (c && c.total) onProgress(c) }
tick()
if (over) return () => {}
iv = setInterval(tick, every)
cap = setTimeout(() => finish(countImages(win)), ceilingMs)
try { win.addEventListener('load', () => finish(countImages(win)), { once: true }) } catch (_) { finish(null) }
return () => finish(null)
}