Artist Map v2 — Phase 4: progressive image streaming (paint-first, click-click-click)

All three maps (watchlist/genre/explore) now paint instantly with placeholder
circles and stay fully interactive (pan/zoom/hover/click) while images stream
in throttled ~280ms waves and sharpen the map in place. Replaces the old
blocking 'await all N images then paint' loaders — the headline 'feels slow'
fix. Focal/large nodes fetch first; a per-open load token cancels stale streams
when you jump to another artist, so rapid click-through never piles up fetches.
This commit is contained in:
BoulderBadgeDad 2026-06-03 08:46:11 -07:00
parent c97108ff6b
commit ff2bddf1db

View file

@ -5214,48 +5214,18 @@ async function openArtistMap() {
setTimeout(async () => {
_artMap.placed.forEach(n => { n.opacity = 1; });
// Load images in parallel using createImageBitmap (non-blocking)
const loadingText = container.querySelector('.artist-map-loading-text');
const imgNodes = _artMap.placed.filter(n => n.image_url);
let loaded = 0;
if (loadingText) loadingText.textContent = `Loading ${imgNodes.length} artist images...`;
// Batch image loading — 20 concurrent fetches
const CONCURRENT = 20;
let idx = 0;
async function loadNextBatch() {
const batch = [];
while (idx < imgNodes.length && batch.length < CONCURRENT) {
const n = imgNodes[idx++];
if (_artMap.images[n.id]) { loaded++; continue; }
batch.push(
_artMapLoadImage(n.image_url, _artMapNodeImgPx(n))
.then(bmp => { if (bmp) _artMap.images[n.id] = bmp; })
.finally(() => {
loaded++;
if (loadingText && loaded % 50 === 0) {
loadingText.textContent = `Loading images... ${loaded}/${imgNodes.length}`;
}
})
);
}
if (batch.length) await Promise.all(batch);
if (idx < imgNodes.length) return loadNextBatch();
}
await loadNextBatch();
// Build buffer and render
if (loadingText) loadingText.textContent = 'Rendering map...';
await new Promise(r => setTimeout(r, 20)); // let text update render
// Paint NOW — the map is fully interactive (pan/zoom/hover/click)
// with placeholder circles before a single image is fetched. Images
// then stream in throttled waves and sharpen the map in place. This
// is the "click-click-click" win: no blocking on N image fetches
// before the first frame.
_artMap.dirty = true;
_artMapRender();
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
_artMapStreamImages(_artMap.placed);
}, 50);
} catch (err) {
@ -6250,32 +6220,15 @@ async function _openGenreMapWithSelection(selectedGenre) {
// Load images + render
if (loadingText) loadingText.textContent = `Rendering ${placedCount} artists...`;
setTimeout(async () => {
const imgNodes = _artMap.placed.filter(n => n.image_url && !n._isLabel);
let loaded = 0;
const CONCURRENT = 20;
let idx = 0;
async function loadBatch() {
const batch = [];
while (idx < imgNodes.length && batch.length < CONCURRENT) {
const n = imgNodes[idx++];
batch.push(_artMapLoadImage(n.image_url, _artMapNodeImgPx(n))
.then(bmp => { if (bmp) _artMap.images[n.id] = bmp; })
.finally(() => { loaded++; }));
}
if (batch.length) await Promise.all(batch);
if (idx < imgNodes.length) return loadBatch();
}
await loadBatch();
_artMap.dirty = true;
_artMapRender();
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
}, 50);
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
_artMap.dirty = true;
_artMapRender();
// Stream images in throttled waves — interactive immediately, sharpens in place.
_artMapStreamImages(_artMap.placed.filter(n => !n._isLabel));
} catch (err) {
console.error('Genre map error:', err);
const lt = container.querySelector('.artist-map-loading-text');
@ -6477,32 +6430,15 @@ async function _openArtistMapExplorerWithName(name) {
const loadingText = container.querySelector('.artist-map-loading-text');
if (loadingText) loadingText.textContent = `Loading ${_artMap.placed.length} artists...`;
setTimeout(async () => {
const imgNodes = _artMap.placed.filter(n => n.image_url);
let loaded = 0;
const CONCURRENT = 20;
let idx = 0;
async function loadBatch() {
const batch = [];
while (idx < imgNodes.length && batch.length < CONCURRENT) {
const n = imgNodes[idx++];
batch.push(_artMapLoadImage(n.image_url, _artMapNodeImgPx(n))
.then(bmp => { if (bmp) _artMap.images[n.id] = bmp; })
.finally(() => { loaded++; }));
}
if (batch.length) await Promise.all(batch);
if (idx < imgNodes.length) return loadBatch();
}
await loadBatch();
_artMap.dirty = true;
_artMapRender();
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
}, 50);
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
_artMap.dirty = true;
_artMapRender();
// Stream images in throttled waves — interactive immediately, sharpens in place.
_artMapStreamImages(_artMap.placed);
} catch (err) {
console.error('Artist explorer error:', err);
const lt = container.querySelector('.artist-map-loading-text');
@ -6668,6 +6604,48 @@ function _artMapNodeImgPx(n) {
return _artMapImgPx(isFocal ? Math.max(256, (n.radius || 0) * 1.4) : (n.radius || 0) * 1.6);
}
// Stream node images in the background WITHOUT blocking the first paint. The map
// is drawn immediately with placeholder circles and stays fully interactive
// (click/hover/pan) while images fill in. Redraws are throttled into ~waves so
// 1000s of arrivals don't trigger 1000s of buffer rebuilds. A load token makes
// opening another map cancel this stream (stale bitmaps are dropped). Focal
// nodes are fetched first so what you're looking at sharpens soonest.
function _artMapStreamImages(imgNodes, concurrent = 24) {
const token = (_artMap._loadToken = (_artMap._loadToken || 0) + 1);
// Focal/large nodes first — the user's eye lands there.
const queue = imgNodes.filter(n => n.image_url).slice().sort((a, b) => (b.radius || 0) - (a.radius || 0));
let idx = 0, inFlight = 0, redrawPending = false;
const scheduleRedraw = () => {
if (redrawPending || token !== _artMap._loadToken) return;
redrawPending = true;
setTimeout(() => {
redrawPending = false;
if (token !== _artMap._loadToken) return;
_artMap.dirty = true;
_artMapRender();
}, 280);
};
function pump() {
if (token !== _artMap._loadToken) return; // a newer map took over
while (inFlight < concurrent && idx < queue.length) {
const n = queue[idx++];
if (_artMap.images[n.id]) continue;
inFlight++;
_artMapLoadImage(n.image_url, _artMapNodeImgPx(n))
.then(bmp => {
if (bmp && token === _artMap._loadToken) {
_artMap.images[n.id] = bmp;
scheduleRedraw();
}
})
.finally(() => { inFlight--; pump(); });
}
}
pump();
}
function _artMapHideContextMenu() {
const m = document.getElementById('artist-map-context');
if (m) m.style.display = 'none';