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.
This commit is contained in:
Doxa Maps 2026-09-29 04:22:15 +00:00
parent daf8a335ea
commit 042803ab3a
2 changed files with 73 additions and 15 deletions

View file

@ -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.

View file

@ -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 <img> (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 = `<style>${sp.css}\n.ovw{position:absolute;left:0;top:0;transform-origin:0 0;pointer-events:none}.ovw a{pointer-events:auto}</style>`
this.ovRoot.innerHTML = `<style>${sp.css}\n.ovw{position:absolute;left:0;top:0;transform-origin:0 0;pointer-events:none}.ovw a{pointer-events:auto}.ovw .sheet{content-visibility:auto}</style>`
+ `<div class="ovw poster" style="width:${sp.worldW}px;height:${sp.worldH}px;transform:scale(0)">${sp.chromeHtml}</div>`
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()
}
}