Wall of People Groups: level of detail like a map, and an HD poster print path

The wall gave all ~2,100 card photos their full href at once, so every photo was fetched and
decoded even with the whole 4000x3000 wall on screen and each photo a few pixels wide. Now:
  - photo URLs are parked in data-hd; zoomed out (a card under 48 px on screen) the wall shows
    only the light image slots, with no photos loaded;
  - zoomed in, updateLod() (rAF-throttled on every zoom/pan) gives HD photos only to the cards in
    view plus one card of margin, and REMOVES them from cards that leave the view; a 300-image
    cap keeps any view light;
  - "Print HD poster" loads every photo, waits for them, prints, then returns to level of detail;
    it replaces the old "coming later" alert (new key wall.printPoster, all 11 locales).
The geometry is a pure module (src/wallLod.js), so it is tested without a browser.
MEASURED, headless: the real 2,107-card layout at 1280x800. Sampled photos are ~200x250 (23 real
image URLs from the live API): ~0.22 MB each decoded, ~45 KB compressed. Loading all of them, as
before, is ~466 MB decoded + ~94 MB downloaded. With LOD: zoom 1-4x = 0 photos; 6x = 150 (~33 MB);
8x = 80 (~18 MB); 12x = 63 (~14 MB). lodPlan matches a brute-force visibility oracle at every zoom.
Photos are the part of the 1.4 GB that can be measured without a browser; the rest (DOM,
rasterisation) needs a browser heap snapshot: needs eyes.
needs eyes: Wall of People Groups: DevTools memory before/after; zoomed out = no photos, zoom in =
photos appear only where you look; "Print HD poster" prints every photo.
This commit is contained in:
Doxa Maps 2026-09-26 01:19:24 +00:00
parent e43310e479
commit ef9646f8f5
13 changed files with 143 additions and 17 deletions

View file

@ -37,5 +37,6 @@
"commentPlaceholder": "شارك صلاتك أو أفكارك…",
"postComment": "نشر التعليق",
"noPeopleId": "ليس لهذا الشعب معرّف PeopleID3",
"statusUnknown": "الحالة غير معروفة"
"statusUnknown": "الحالة غير معروفة",
"printPoster": "🖨️ طباعة ملصق عالي الدقة"
}

View file

@ -37,5 +37,6 @@
"commentPlaceholder": "Teilen Sie Ihr Gebet oder Ihre Gedanken…",
"postComment": "Kommentar posten",
"noPeopleId": "Diese Volksgruppe hat keine PeopleID3",
"statusUnknown": "Status unbekannt"
"statusUnknown": "Status unbekannt",
"printPoster": "🖨️ HD-Poster drucken"
}

View file

@ -37,5 +37,6 @@
"commentPlaceholder": "Share your prayer or thoughts…",
"postComment": "Post Comment",
"noPeopleId": "This people group doesn't have a PeopleID3",
"statusUnknown": "Status Unknown"
"statusUnknown": "Status Unknown",
"printPoster": "🖨️ Print HD poster"
}

View file

@ -37,5 +37,6 @@
"commentPlaceholder": "Comparte tu oración o tus pensamientos…",
"postComment": "Publicar comentario",
"noPeopleId": "Este pueblo no tiene PeopleID3",
"statusUnknown": "Estado desconocido"
"statusUnknown": "Estado desconocido",
"printPoster": "🖨️ Imprimir póster HD"
}

View file

@ -37,5 +37,6 @@
"commentPlaceholder": "Partagez votre prière ou vos pensées…",
"postComment": "Publier le commentaire",
"noPeopleId": "Ce peuple n’a pas de PeopleID3",
"statusUnknown": "Statut inconnu"
"statusUnknown": "Statut inconnu",
"printPoster": "🖨️ Imprimer l’affiche HD"
}

View file

@ -37,5 +37,6 @@
"commentPlaceholder": "अपनी प्रार्थना या विचार साझा करें…",
"postComment": "टिप्पणी पोस्ट करें",
"noPeopleId": "इस जनसमूह के पास PeopleID3 नहीं है",
"statusUnknown": "स्थिति अज्ञात"
"statusUnknown": "स्थिति अज्ञात",
"printPoster": "🖨️ HD पोस्टर छापें"
}

View file

@ -37,5 +37,6 @@
"commentPlaceholder": "Condividi la tua preghiera o i tuoi pensieri…",
"postComment": "Pubblica commento",
"noPeopleId": "Questo popolo non ha un PeopleID3",
"statusUnknown": "Stato sconosciuto"
"statusUnknown": "Stato sconosciuto",
"printPoster": "🖨️ Stampa poster HD"
}

View file

@ -37,5 +37,6 @@
"commentPlaceholder": "Partilhe a sua oração ou pensamentos…",
"postComment": "Publicar comentário",
"noPeopleId": "Este povo não tem PeopleID3",
"statusUnknown": "Estado desconhecido"
"statusUnknown": "Estado desconhecido",
"printPoster": "🖨️ Imprimir cartaz HD"
}

View file

@ -37,5 +37,6 @@
"commentPlaceholder": "Împărtășiți rugăciunea sau gândurile…",
"postComment": "Publică comentariul",
"noPeopleId": "Acest popor nu are PeopleID3",
"statusUnknown": "Stare necunoscută"
"statusUnknown": "Stare necunoscută",
"printPoster": "🖨️ Tipărește afișul HD"
}

View file

@ -37,5 +37,6 @@
"commentPlaceholder": "Поделитесь молитвой или мыслями…",
"postComment": "Опубликовать",
"noPeopleId": "У этого народа нет PeopleID3",
"statusUnknown": "Статус неизвестен"
"statusUnknown": "Статус неизвестен",
"printPoster": "🖨️ Печать плаката HD"
}

View file

@ -37,5 +37,6 @@
"commentPlaceholder": "分享你的祷告或想法…",
"postComment": "发表评论",
"noPeopleId": "该族群没有 PeopleID3",
"statusUnknown": "状态未知"
"statusUnknown": "状态未知",
"printPoster": "🖨️ 打印高清海报"
}

View file

@ -7,6 +7,7 @@
<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>
<button @click="printWall" class="control-btn">{{ $t('wall.printPoster') }}</button>
<button @click="resetZoom" class="control-btn">{{ $t('wall.resetZoom') }}</button>
<span class="group-count">{{ $t('wall.groupsLoaded', { n: totalGroups }) }}</span>
</div>
@ -32,6 +33,7 @@
</template>
<script>
import { lodPlan } from '../wallLod.js'
// Wall of People Groups - D3 Zoomable Whiteboard
export default {
name: 'PeopleGroupsWall',
@ -45,7 +47,8 @@ export default {
cardVerticalSpacing: 3, // Separate vertical spacing
canvasWidth: 0,
canvasHeight: 0,
isLandscape: true
isLandscape: true,
lodState: { mode: 'placeholder', loaded: 0, total: 0, cardPx: 0 }
};
},
computed: {
@ -66,6 +69,8 @@ export default {
}
},
mounted() {
// verification handle: how many HD photos the wall holds right now (for eyes and gates)
try { const host = this.$el?.getRootNode?.()?.host; if (host) host.__wallLod = () => ({ ...this.lodState }); } catch (_) { /* no host */ }
this.initializeCanvas();
this.fetchData();
},
@ -122,6 +127,8 @@ export default {
.on('zoom', (event) => {
this.svg.select('.cards-container')
.attr('transform', event.transform);
// level of detail follows the view, like a map (wallLod.js)
this.scheduleLod(event.transform);
});
this.svg.call(this.zoom);
@ -186,6 +193,10 @@ export default {
const totalWidth = this.cardWidth + this.cardSpacing;
const totalHeight = this.cardHeight + this.cardVerticalSpacing;
// The grid the level-of-detail pass reasons about: the SAME numbers this layout uses.
this._lodGrid = { n: totalGroups, cols, cw: this.cardWidth, ch: this.cardHeight,
spacing: this.cardSpacing, vSpacing: this.cardVerticalSpacing };
// Create card groups with better spacing
const cardGroups = cardsContainer
.selectAll('.card-group')
@ -312,8 +323,10 @@ export default {
.attr('stroke-width', 0.5)
.attr('rx', 0); // NO rounded corners
// Add images or placeholders
cardGroups.each(function(d) {
// Add images or placeholders. The HD photo URL is PARKED in data-hd; updateLod() moves it into
// href only for cards in view when zoomed in, and takes it out again when they leave.
const imgNodes = [];
cardGroups.each(function(d, i) {
const group = d3.select(this);
if (d.imageUrl) {
@ -324,12 +337,13 @@ export default {
.attr('y', imageY)
.attr('width', imageWidth)
.attr('height', imageHeight)
.attr('href', d.imageUrl)
.attr('data-hd', d.imageUrl)
.attr('preserveAspectRatio', 'xMidYMid slice')
.style('pointer-events', 'none')
.on('error', function() {
console.log('Image failed to load:', d.imageUrl);
});
imgNodes[i] = group.select('image').node();
} else {
// Add placeholder icon/text if no image URL
group
@ -344,6 +358,10 @@ export default {
}
});
this._imgNodes = imgNodes;
this._hd = new Set();
this.$nextTick(() => this.updateLod());
// Add action buttons row with FontAwesome icons - Instagram style
const buttonY = this.cardHeight * 0.72;
const buttonSize = iconSize * 0.7; // Make buttons smaller (was iconSize)
@ -514,8 +532,48 @@ export default {
console.log(`📐 Switched to ${this.isLandscape ? 'LANDSCAPE (4ft×3ft)' : 'PORTRAIT (3ft×4ft)'}`);
},
printWall() {
alert('Print functionality coming later!');
scheduleLod(t) {
this._lodT = t;
if (this._lodRaf) return;
this._lodRaf = requestAnimationFrame(() => { this._lodRaf = 0; this.updateLod(); });
},
// Reconcile which cards hold their HD photo with what the view needs now (wallLod.js).
updateLod() {
if (!this._lodGrid || !this.svg || !this._imgNodes || this._printing) return;
const el = this.svg.node();
const t = this._lodT || d3.zoomTransform(el);
const r = el.getBoundingClientRect();
// viewBox + 'meet': one wall unit is this many CSS pixels at zoom 1
const displayScale = Math.min(r.width / this.canvasWidth, r.height / this.canvasHeight) || 0;
const plan = lodPlan(t, { width: this.canvasWidth, height: this.canvasHeight }, displayScale, this._lodGrid);
const want = new Set(plan.visible.filter((i) => this._imgNodes[i]));
for (const i of [...this._hd]) {
if (!want.has(i)) { this._imgNodes[i].removeAttribute('href'); this._hd.delete(i); }
}
for (const i of want) {
if (!this._hd.has(i)) { const n = this._imgNodes[i]; n.setAttribute('href', n.getAttribute('data-hd')); this._hd.add(i); }
}
this.lodState = { mode: plan.mode, loaded: this._hd.size, total: this._imgNodes.filter(Boolean).length, cardPx: Math.round(plan.cardPx) };
},
// THE HD POSTER PATH: load every photo, wait for them, print, then go back to level of detail.
async printWall() {
const nodes = (this._imgNodes || []).filter(Boolean);
this._printing = true;
await Promise.all(nodes.map((n) => new Promise((res) => {
if (n.getAttribute('href')) return res();
n.addEventListener('load', res, { once: true });
n.addEventListener('error', res, { once: true });
setTimeout(res, 30000);
n.setAttribute('href', n.getAttribute('data-hd'));
})));
try { window.print(); } finally {
this._printing = false;
for (const n of nodes) n.removeAttribute('href');
this._hd = new Set();
this.updateLod();
}
}
}
};

View file

@ -0,0 +1,57 @@
/**
* wallLod.js — level of detail for the Wall of People Groups, the way a map does it.
*
* ★ WHY. The wall gave every one of its ~2,100 card images its full-size href at once, so the
* browser fetched and DECODED every photo even when the whole 4000×3000 wall was on screen and each
* photo was a few pixels wide. Decoded bitmaps are uncompressed, so that was the bulk of the ~1.4 GB
* the Driver measured. A map never does this: it draws light tiles when zoomed out and loads detail
* only for what is in view. Same rule here:
* - zoomed out (a card smaller than `minPx` on screen): NO photos, just the light image slot;
* - zoomed in: HD photos only for the cards in view (plus a margin), and they are UNLOADED again
* when they leave the view;
* - a hard `cap` so no zoom level can load more than that many at once.
* Pure geometry, no DOM: the component asks which card indices should hold an HD image and
* reconciles the difference. That also makes it testable in node.
*/
/**
* @param {{k:number,x:number,y:number}} t the d3 zoom transform (viewBox units)
* @param {{width:number,height:number}} view the viewBox size (the wall canvas)
* @param {number} displayScale CSS pixels per viewBox unit (svg meet scale)
* @param {{n:number,cols:number,cw:number,ch:number,spacing:number,vSpacing:number}} grid
* @param {{minPx?:number,marginCards?:number,cap?:number}} [opts]
* @returns {{mode:'placeholder'|'hd', cardPx:number, visible:number[]}}
*/
export function lodPlan(t, view, displayScale, grid, { minPx = 48, marginCards = 1, cap = 300 } = {}) {
const cardPx = grid.cw * t.k * displayScale
if (!(cardPx >= minPx)) return { mode: 'placeholder', cardPx, visible: [] }
// the part of the wall canvas the viewport currently shows, in wall coordinates
const x0 = (0 - t.x) / t.k, x1 = (view.width - t.x) / t.k
const y0 = (0 - t.y) / t.k, y1 = (view.height - t.y) / t.k
const stepX = grid.cw + grid.spacing, stepY = grid.ch + grid.vSpacing
const rows = Math.ceil(grid.n / grid.cols)
const c0 = Math.max(0, Math.floor((x0 - grid.spacing) / stepX) - marginCards)
const c1 = Math.min(grid.cols - 1, Math.floor((x1 - grid.spacing) / stepX) + marginCards)
const r0 = Math.max(0, Math.floor((y0 - grid.vSpacing) / stepY) - marginCards)
const r1 = Math.min(rows - 1, Math.floor((y1 - grid.vSpacing) / stepY) + marginCards)
const visible = []
for (let r = r0; r <= r1; r++) {
for (let c = c0; c <= c1; c++) {
const i = r * grid.cols + c
if (i < grid.n) visible.push(i)
}
}
// the cap is a safety net: if the view somehow holds more cards than that, stay light
if (visible.length > cap) return { mode: 'placeholder', cardPx, visible: [] }
return { mode: 'hd', cardPx, visible }
}
/** The grid the wall lays out (renderCards), so lodPlan and the renderer can never disagree. */
export function wallGrid(n, canvasWidth, canvasHeight, spacing, vSpacing) {
const cols = Math.ceil(Math.sqrt(n * (canvasWidth / canvasHeight)))
const rows = Math.ceil(n / cols)
const availableWidth = canvasWidth - spacing * (cols + 1)
const availableHeight = canvasHeight - spacing * (rows + 1)
const cw = Math.min(availableWidth / cols, (availableHeight / rows) * 0.7)
return { n, cols, rows, cw, ch: cw / 0.7, spacing, vSpacing }
}