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:
parent
c97108ff6b
commit
ff2bddf1db
1 changed files with 59 additions and 81 deletions
|
|
@ -5214,48 +5214,18 @@ async function openArtistMap() {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
_artMap.placed.forEach(n => { n.opacity = 1; });
|
_artMap.placed.forEach(n => { n.opacity = 1; });
|
||||||
|
|
||||||
// Load images in parallel using createImageBitmap (non-blocking)
|
// Paint NOW — the map is fully interactive (pan/zoom/hover/click)
|
||||||
const loadingText = container.querySelector('.artist-map-loading-text');
|
// with placeholder circles before a single image is fetched. Images
|
||||||
const imgNodes = _artMap.placed.filter(n => n.image_url);
|
// then stream in throttled waves and sharpen the map in place. This
|
||||||
let loaded = 0;
|
// is the "click-click-click" win: no blocking on N image fetches
|
||||||
|
// before the first frame.
|
||||||
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
|
|
||||||
|
|
||||||
_artMap.dirty = true;
|
_artMap.dirty = true;
|
||||||
_artMapRender();
|
_artMapRender();
|
||||||
|
|
||||||
const le = document.getElementById('artist-map-loading');
|
const le = document.getElementById('artist-map-loading');
|
||||||
if (le) le.remove();
|
if (le) le.remove();
|
||||||
|
|
||||||
|
_artMapStreamImages(_artMap.placed);
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -6250,32 +6220,15 @@ async function _openGenreMapWithSelection(selectedGenre) {
|
||||||
// Load images + render
|
// Load images + render
|
||||||
if (loadingText) loadingText.textContent = `Rendering ${placedCount} artists...`;
|
if (loadingText) loadingText.textContent = `Rendering ${placedCount} artists...`;
|
||||||
|
|
||||||
setTimeout(async () => {
|
const le = document.getElementById('artist-map-loading');
|
||||||
const imgNodes = _artMap.placed.filter(n => n.image_url && !n._isLabel);
|
if (le) le.remove();
|
||||||
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);
|
|
||||||
|
|
||||||
_artMap.dirty = true;
|
_artMap.dirty = true;
|
||||||
_artMapRender();
|
_artMapRender();
|
||||||
|
|
||||||
|
// Stream images in throttled waves — interactive immediately, sharpens in place.
|
||||||
|
_artMapStreamImages(_artMap.placed.filter(n => !n._isLabel));
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Genre map error:', err);
|
console.error('Genre map error:', err);
|
||||||
const lt = container.querySelector('.artist-map-loading-text');
|
const lt = container.querySelector('.artist-map-loading-text');
|
||||||
|
|
@ -6477,32 +6430,15 @@ async function _openArtistMapExplorerWithName(name) {
|
||||||
const loadingText = container.querySelector('.artist-map-loading-text');
|
const loadingText = container.querySelector('.artist-map-loading-text');
|
||||||
if (loadingText) loadingText.textContent = `Loading ${_artMap.placed.length} artists...`;
|
if (loadingText) loadingText.textContent = `Loading ${_artMap.placed.length} artists...`;
|
||||||
|
|
||||||
setTimeout(async () => {
|
const le = document.getElementById('artist-map-loading');
|
||||||
const imgNodes = _artMap.placed.filter(n => n.image_url);
|
if (le) le.remove();
|
||||||
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);
|
|
||||||
|
|
||||||
_artMap.dirty = true;
|
_artMap.dirty = true;
|
||||||
_artMapRender();
|
_artMapRender();
|
||||||
|
|
||||||
|
// Stream images in throttled waves — interactive immediately, sharpens in place.
|
||||||
|
_artMapStreamImages(_artMap.placed);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Artist explorer error:', err);
|
console.error('Artist explorer error:', err);
|
||||||
const lt = container.querySelector('.artist-map-loading-text');
|
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);
|
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() {
|
function _artMapHideContextMenu() {
|
||||||
const m = document.getElementById('artist-map-context');
|
const m = document.getElementById('artist-map-context');
|
||||||
if (m) m.style.display = 'none';
|
if (m) m.style.display = 'none';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue