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:
parent
e43310e479
commit
ef9646f8f5
13 changed files with 143 additions and 17 deletions
|
|
@ -37,5 +37,6 @@
|
|||
"commentPlaceholder": "شارك صلاتك أو أفكارك…",
|
||||
"postComment": "نشر التعليق",
|
||||
"noPeopleId": "ليس لهذا الشعب معرّف PeopleID3",
|
||||
"statusUnknown": "الحالة غير معروفة"
|
||||
"statusUnknown": "الحالة غير معروفة",
|
||||
"printPoster": "🖨️ طباعة ملصق عالي الدقة"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,5 +37,6 @@
|
|||
"commentPlaceholder": "अपनी प्रार्थना या विचार साझा करें…",
|
||||
"postComment": "टिप्पणी पोस्ट करें",
|
||||
"noPeopleId": "इस जनसमूह के पास PeopleID3 नहीं है",
|
||||
"statusUnknown": "स्थिति अज्ञात"
|
||||
"statusUnknown": "स्थिति अज्ञात",
|
||||
"printPoster": "🖨️ HD पोस्टर छापें"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,5 +37,6 @@
|
|||
"commentPlaceholder": "Поделитесь молитвой или мыслями…",
|
||||
"postComment": "Опубликовать",
|
||||
"noPeopleId": "У этого народа нет PeopleID3",
|
||||
"statusUnknown": "Статус неизвестен"
|
||||
"statusUnknown": "Статус неизвестен",
|
||||
"printPoster": "🖨️ Печать плаката HD"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,5 +37,6 @@
|
|||
"commentPlaceholder": "分享你的祷告或想法…",
|
||||
"postComment": "发表评论",
|
||||
"noPeopleId": "该族群没有 PeopleID3",
|
||||
"statusUnknown": "状态未知"
|
||||
"statusUnknown": "状态未知",
|
||||
"printPoster": "🖨️ 打印高清海报"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
57
plugin-designs/wall-of-people-groups/src/wallLod.js
Normal file
57
plugin-designs/wall-of-people-groups/src/wallLod.js
Normal 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 }
|
||||
}
|
||||
Loading…
Reference in a new issue