From 042803ab3a48c503e6479dded0d9a0e81949df8d Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 04:22:15 +0000 Subject: [PATCH] Wall of People Groups: photos are kept as small copies, text is measured a little at a time, and off-screen sheets skip rendering Measured on the live list: 77 photos are 1024 x 1024 (4 MB each decoded) and drawImage downscaled each of them on every frame it was visible. A loaded photo is now kept at 320 px on its long side (createImageBitmap with resize, or one drawImage into a small canvas where that is missing), and the original is released; the print frame loads the originals itself. Below text zoom no card is measured (the photo box uses an estimate), and at text zoom at most 150 new cards are measured per frame. The overlay's sheets use content-visibility:auto. The host hook reports the counts (photos loaded, thumbnails, fallbacks, cards measured) for the performance probes. --- .../src/components/PeopleGroupsWall.vue | 2 +- .../wall-of-people-groups/src/wallCanvas.js | 86 ++++++++++++++++--- 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue b/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue index 0b6cfb15f..ee18e6a53 100644 --- a/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue +++ b/plugin-designs/wall-of-people-groups/src/components/PeopleGroupsWall.vue @@ -103,7 +103,7 @@ export default { }, mounted() { // A test hook on the HOST element (not window): the level-of-detail state, as before. - try { const host = this.$el?.getRootNode?.()?.host; if (host) host.__wallLod = () => ({ ...this.lodState }); } catch (_) { /* no host */ } + try { const host = this.$el?.getRootNode?.()?.host; if (host) host.__wallLod = () => ({ ...this.lodState, stats: this._wc ? { ...this._wc.stats } : null }); } catch (_) { /* no host */ } this.initializeCanvas(); this.fetchData(); // The canvas is not reactive: redraw when a prayer is added (heart + count) or the language changes. diff --git a/plugin-designs/wall-of-people-groups/src/wallCanvas.js b/plugin-designs/wall-of-people-groups/src/wallCanvas.js index eb5b13803..a83b928f4 100644 --- a/plugin-designs/wall-of-people-groups/src/wallCanvas.js +++ b/plugin-designs/wall-of-people-groups/src/wallCanvas.js @@ -36,6 +36,13 @@ export function groupLink(g) { return id && String(id).trim() ? `https://joshuaproject.net/people_groups/${String(id).trim()}` : '' } const TEXT_PX = 40, IMG_PX = 48, RECT_PX = 12, IMG_CACHE = 400, IMG_PARALLEL = 12 +// A loaded photo is kept at THUMB_PX on its long side, never larger (G7, measured on the live list: 77 of the +// photos are 1024 × 1024, 4 MB each decoded, and drawImage used to downscale every one of them on every frame). +// The print frame loads the originals itself. MEASURE_PER_FRAME caps the cards whose text is first measured +// in one frame, so zooming into ~800 cards spreads that work instead of one long task. +const THUMB_PX = 320, MEASURE_PER_FRAME = 150 +// A photo's pixel size, for an (natural*) or a thumbnail canvas / ImageBitmap (width/height). +const pw = (im) => im.naturalWidth || im.width, ph = (im) => im.naturalHeight || im.height export class WallCanvas { /** @@ -49,7 +56,10 @@ export class WallCanvas { this.mode = 'bless' this.k0 = 1; this.ox = 0; this.oy = 0 // "meet" fit of the world into the canvas this.t = { k: 1, x: 0, y: 0 } // d3 zoom transform (in world-fit space) - this.images = new Map() // url → { img, ok, used } + this.images = new Map() // url → { img, ok, used } (img: the photo or its thumbnail) + // Counters for the performance probes (read through the Vue host hook): photos kept as thumbnails, + // how many took the canvas fallback, and cards whose text was measured. + this.stats = { loaded: 0, thumbs: 0, thumbsFallback: 0, measured: 0 } this.loading = 0 this.queue = [] this.canvas = document.createElement('canvas') @@ -89,6 +99,7 @@ export class WallCanvas { setPoster(sp) { this.sp = sp this.textCache = new Map() + this.estCache = new Map() // the unmeasured line estimates (below text zoom, or awaiting measurement) if (!this.ov) { this.ov = document.createElement('div') this.ov.style.cssText = 'position:absolute;inset:0;overflow:hidden;pointer-events:none' @@ -98,7 +109,7 @@ export class WallCanvas { } // The print CSS (its @page rule is ignored on screen), scoped by the shadow root. The overlay starts at // scale(0) and takes the zoom's transform on the next frame, so it never flashes at full print size. - this.ovRoot.innerHTML = `` + this.ovRoot.innerHTML = `` + `
${sp.chromeHtml}
` this.ovw = this.ovRoot.querySelector('.ovw') this.showPoster(); this.resize() @@ -196,6 +207,7 @@ export class WallCanvas { for (const R of sp.sheets) ctx.fillRect(R.x, R.y, R.w, R.h) const [x0, y0] = this.toWorld(0, 0), [x1, y1] = this.toWorld(this.cssW, this.cssH) const cardPx = (sp.cards[0]?.w || 0) * s, want = [] + this.measureBudget = MEASURE_PER_FRAME; this.measureMore = false for (const R of sp.cards) { if (R.x + R.w < x0 || R.x > x1 || R.y + R.h < y0 || R.y > y1) continue if (R.i >= this.groups.length) continue @@ -203,6 +215,7 @@ export class WallCanvas { this.drawPrintCard(ctx, R, cardPx, want) } if (want.length) this.load(want) + if (this.measureMore) this.request() // the cards still waiting to be measured get the next frame } /** A card exactly as the print lays it out (blessPoster's .card CSS), sized by the same cardText(). */ @@ -213,7 +226,20 @@ export class WallCanvas { const ct = T ? T.ct : cardText(sp.plan, name) const pad = ct.padIn * px, iw = R.w - 2 * pad, x = R.x + pad, cx = R.x + R.w / 2 const nameFs = ct.name.pt * pt, vFs = ct.versePt * pt, rFs = ct.refPt * pt - if (!T) { + // Below TEXT_PX no text is drawn, so nothing is measured: the photo box uses an estimate of the lines + // (a card is under 40 px there; it settles to the measured layout once text shows). At text zoom, at + // most MEASURE_PER_FRAME new cards are measured per frame; the rest wait a frame with the estimate. + if (!T && (cardPx < TEXT_PX || this.measureBudget <= 0)) { + if (cardPx >= TEXT_PX) this.measureMore = true + T = this.estCache.get(R.i) + if (!T) { + const est = (text, fs) => Math.max(1, Math.ceil((String(text).length * 0.5 * fs) / Math.max(1, iw))) + T = { ct, estimate: true, nameLines: Array(ct.name.wrap ? ct.name.lines : 1).fill(''), + verse: ct.readable ? { lines: Array(est(blessingFor(R.i).text, vFs)).fill(''), ref: '' } : null } + this.estCache.set(R.i, T) + } + } else if (!T) { + this.measureBudget--; this.stats.measured++ // Line breaks measured once per card (the fonts are the print's), then cached. ctx.font = `700 ${nameFs}px -apple-system, "Segoe UI", Arial, sans-serif` const nameLines = ct.name.wrap ? this.breakLines(ctx, name, iw, ct.name.lines) : [name] @@ -232,15 +258,15 @@ export class WallCanvas { const url = g.imageUrl, cached = url && this.images.get(url) if (cached && cached.ok) { cached.used = performance.now() - const im = cached.img, ar = im.naturalWidth / im.naturalHeight || 1, box = iw / (imgH || 1) - let sx = 0, sy = 0, sw = im.naturalWidth, sh = im.naturalHeight - if (ar > box) { sw = sh * box; sx = (im.naturalWidth - sw) / 2 } else { sh = sw / box; sy = (im.naturalHeight - sh) / 2 } + const im = cached.img, ar = pw(im) / ph(im) || 1, box = iw / (imgH || 1) + let sx = 0, sy = 0, sw = pw(im), sh = ph(im) + if (ar > box) { sw = sh * box; sx = (pw(im) - sw) / 2 } else { sh = sw / box; sy = (ph(im) - sh) / 2 } ctx.drawImage(im, sx, sy, sw, sh, x, imgY, iw, imgH) // object-fit: cover } else { ctx.fillStyle = '#eee'; ctx.fillRect(x, imgY, iw, imgH) if (url && cardPx >= IMG_PX) want.push(url) } - if (cardPx < TEXT_PX) return + if (cardPx < TEXT_PX || T.estimate) return // no text yet (below text zoom, or waiting its turn to be measured) ctx.textAlign = 'center'; ctx.textBaseline = 'top' ctx.font = `700 ${nameFs}px -apple-system, "Segoe UI", Arial, sans-serif` ctx.fillStyle = '#1c1c1c' // as printed; a linked name shows the pointer cursor instead of link blue @@ -286,9 +312,9 @@ export class WallCanvas { const cached = url && this.images.get(url) if (cached && cached.ok) { cached.used = performance.now() - const im = cached.img, ar = im.naturalWidth / im.naturalHeight || 1, box = w / imgH - let sx = 0, sy = 0, sw = im.naturalWidth, sh = im.naturalHeight - if (ar > box) { sw = sh * box; sx = (im.naturalWidth - sw) / 2 } else { sh = sw / box; sy = (im.naturalHeight - sh) / 2 } + const im = cached.img, ar = pw(im) / ph(im) || 1, box = w / imgH + let sx = 0, sy = 0, sw = pw(im), sh = ph(im) + if (ar > box) { sw = sh * box; sx = (pw(im) - sw) / 2 } else { sh = sw / box; sy = (ph(im) - sh) / 2 } ctx.drawImage(im, sx, sy, sw, sh, x, imgY, w, imgH) // object-fit: cover } else { ctx.fillStyle = '#f2f2f2'; ctx.fillRect(x, imgY, w, imgH) @@ -404,25 +430,57 @@ export class WallCanvas { const rec = { img, ok: false, used: performance.now() } this.images.set(u, rec) this.loading++ - img.onload = () => { rec.ok = true; this.loading--; this.request(); this.load([]) } + img.decoding = 'async' + img.onload = () => { + this.stats.loaded++ + this.thumbnail(img).then((small) => { + if (small) rec.img = small // the original is dropped: only the thumbnail is kept + rec.ok = true; this.loading--; this.request(); this.load([]) + }) + } img.onerror = () => { this.loading--; this.load([]) } img.src = u } if (this.images.size > IMG_CACHE) { const old = [...this.images.entries()].filter(([, r]) => r.ok).sort((a, b) => a[1].used - b[1].used) - for (const [u] of old.slice(0, this.images.size - IMG_CACHE)) this.images.delete(u) + for (const [u, r] of old.slice(0, this.images.size - IMG_CACHE)) { r.img?.close?.(); this.images.delete(u) } } } + /** + * A photo larger than THUMB_PX on its long side, as a small copy: createImageBitmap with resize (decoded + * off the main thread where the browser can), or, where the resize options are missing or ignored (older + * Safari and WebViews), ONE drawImage into a small canvas. Either way the original's pixels are released. + * Resolves to null when the photo is already small enough (it is kept as it is). + */ + async thumbnail(img) { + const w0 = img.naturalWidth, h0 = img.naturalHeight + if (!w0 || !h0 || Math.max(w0, h0) <= THUMB_PX) return null + const k = THUMB_PX / Math.max(w0, h0), w = Math.max(1, Math.round(w0 * k)), h = Math.max(1, Math.round(h0 * k)) + if (typeof createImageBitmap === 'function') { + try { + const bmp = await createImageBitmap(img, { resizeWidth: w, resizeHeight: h, resizeQuality: 'medium' }) + if (bmp.width === w && bmp.height === h) { this.stats.thumbs++; return bmp } + bmp.close?.() // the options were ignored: a full-size bitmap is no saving + } catch (_) { /* no resize support: fall back */ } + } + try { + const c = document.createElement('canvas'); c.width = w; c.height = h + c.getContext('2d').drawImage(img, 0, 0, w, h) + this.stats.thumbs++; this.stats.thumbsFallback++ + return c + } catch (_) { return null } // a tainted or odd image: keep the original + } + destroy() { if (this.raf) cancelAnimationFrame(this.raf) this.ro?.disconnect() this.sel.on('.zoom', null) this.canvas.removeEventListener('click', this._click) this.canvas.removeEventListener('mousemove', this._move) - for (const r of this.images.values()) { r.img.onload = r.img.onerror = null; r.img.src = '' } + for (const r of this.images.values()) { if (r.img) { r.img.onload = r.img.onerror = null; if ('src' in r.img) r.img.src = ''; r.img.close?.() } } this.images.clear(); this.queue = [] this.canvas.remove() - this.ov?.remove(); this.ov = this.ovRoot = this.ovw = this.sp = null; this.textCache?.clear() + this.ov?.remove(); this.ov = this.ovRoot = this.ovw = this.sp = null; this.textCache?.clear(); this.estCache?.clear() } }