Artist Map v2 — Phase 3 polish: crisp adaptive images, snappier glowing hover, premium backdrop
- Images: decode adaptively (focal/watchlist nodes ~256-384px, small nodes ~112-150px) instead of a flat 128px — crisp where it matters, memory still bounded (~150-250MB, not 6GB). Fixes the low-res look. - Hover constellation: drop the activation delay 800ms → 220ms (it felt 'gone' because nothing happened for nearly a second), and draw the connection lines as a wide-faint halo + crisp core (a real glow) with no per-frame gradients or shadowBlur — stays cheap. - Backdrop: subtle cached radial glow + vignette behind the map for depth instead of a flat fill (one cheap fillRect/frame). JS clean; 64 integrity tests pass.
This commit is contained in:
parent
20b77a774a
commit
c97108ff6b
1 changed files with 48 additions and 16 deletions
|
|
@ -5231,7 +5231,7 @@ async function openArtistMap() {
|
|||
const n = imgNodes[idx++];
|
||||
if (_artMap.images[n.id]) { loaded++; continue; }
|
||||
batch.push(
|
||||
_artMapLoadImage(n.image_url)
|
||||
_artMapLoadImage(n.image_url, _artMapNodeImgPx(n))
|
||||
.then(bmp => { if (bmp) _artMap.images[n.id] = bmp; })
|
||||
.finally(() => {
|
||||
loaded++;
|
||||
|
|
@ -5530,6 +5530,19 @@ function _artMapDraw() {
|
|||
ctx.fillStyle = '#0a0a14';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Premium backdrop: a soft central glow fading to a dark vignette. The
|
||||
// gradient is cached and only rebuilt on resize, so it's one cheap fillRect.
|
||||
if (!_artMap._bgGrad || _artMap._bgW !== w || _artMap._bgH !== h) {
|
||||
const g = ctx.createRadialGradient(w / 2, h * 0.42, Math.min(w, h) * 0.12,
|
||||
w / 2, h / 2, Math.max(w, h) * 0.78);
|
||||
g.addColorStop(0, 'rgba(46,34,78,0.40)');
|
||||
g.addColorStop(0.5, 'rgba(16,12,28,0.0)');
|
||||
g.addColorStop(1, 'rgba(0,0,0,0.55)');
|
||||
_artMap._bgGrad = g; _artMap._bgW = w; _artMap._bgH = h;
|
||||
}
|
||||
ctx.fillStyle = _artMap._bgGrad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
if (_artMap.dirty || !_artMap.offscreen) {
|
||||
const _rt = _artMap._perf ? performance.now() : 0;
|
||||
_artMapRebuildBuffer();
|
||||
|
|
@ -5603,17 +5616,21 @@ function _artMapDraw() {
|
|||
ctx.globalAlpha = 1;
|
||||
ctx.restore();
|
||||
|
||||
// Draw connection lines — ONE path, ONE solid stroke. The old
|
||||
// code built a fresh linear-gradient object per line every frame,
|
||||
// which is the main cause of the hover-lag on dense maps.
|
||||
ctx.strokeStyle = `rgba(138,43,226,${0.42 * cFade})`;
|
||||
ctx.lineWidth = 2;
|
||||
// Connection lines — build the path ONCE, then two cheap strokes
|
||||
// (wide faint halo + crisp core) for a glow look without per-frame
|
||||
// gradients or shadowBlur (those were the hover-lag culprits).
|
||||
ctx.lineCap = 'round';
|
||||
ctx.beginPath();
|
||||
for (const cn of highlightNodes) {
|
||||
if (cn === n) continue;
|
||||
ctx.moveTo(n.x, n.y);
|
||||
ctx.lineTo(cn.x, cn.y);
|
||||
}
|
||||
ctx.strokeStyle = `rgba(168,85,247,${0.18 * cFade})`;
|
||||
ctx.lineWidth = 6;
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = `rgba(201,150,255,${0.6 * cFade})`;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
|
||||
// Redraw highlighted nodes on top
|
||||
|
|
@ -6242,7 +6259,7 @@ async function _openGenreMapWithSelection(selectedGenre) {
|
|||
const batch = [];
|
||||
while (idx < imgNodes.length && batch.length < CONCURRENT) {
|
||||
const n = imgNodes[idx++];
|
||||
batch.push(_artMapLoadImage(n.image_url)
|
||||
batch.push(_artMapLoadImage(n.image_url, _artMapNodeImgPx(n))
|
||||
.then(bmp => { if (bmp) _artMap.images[n.id] = bmp; })
|
||||
.finally(() => { loaded++; }));
|
||||
}
|
||||
|
|
@ -6469,7 +6486,7 @@ async function _openArtistMapExplorerWithName(name) {
|
|||
const batch = [];
|
||||
while (idx < imgNodes.length && batch.length < CONCURRENT) {
|
||||
const n = imgNodes[idx++];
|
||||
batch.push(_artMapLoadImage(n.image_url)
|
||||
batch.push(_artMapLoadImage(n.image_url, _artMapNodeImgPx(n))
|
||||
.then(bmp => { if (bmp) _artMap.images[n.id] = bmp; })
|
||||
.finally(() => { loaded++; }));
|
||||
}
|
||||
|
|
@ -6611,32 +6628,46 @@ function artMapToggleSimilar() {
|
|||
// of decoded image memory → GC/GPU thrash that locks the browser even though the
|
||||
// per-frame draw is cheap. Decode straight to a small avatar (~128px) so the
|
||||
// whole map's images fit in ~100 MB instead of gigabytes.
|
||||
const _ARTMAP_IMG_PX = 128;
|
||||
function _artMapDecodeSmall(blob) {
|
||||
// Decode to a sensible avatar size for how big the node actually draws — crisp
|
||||
// where it matters (focal/watchlist nodes), light for the swarm of small ones —
|
||||
// so total image memory stays ~150-250 MB instead of multiple GB.
|
||||
function _artMapImgPx(px) {
|
||||
return Math.min(384, Math.max(112, Math.round(px || 144)));
|
||||
}
|
||||
function _artMapDecodeSmall(blob, px) {
|
||||
if (!blob) return Promise.resolve(null);
|
||||
const d = _artMapImgPx(px);
|
||||
try {
|
||||
return createImageBitmap(blob, {
|
||||
resizeWidth: _ARTMAP_IMG_PX, resizeHeight: _ARTMAP_IMG_PX, resizeQuality: 'medium',
|
||||
resizeWidth: d, resizeHeight: d, resizeQuality: 'high',
|
||||
}).catch(() => createImageBitmap(blob).catch(() => null)); // older engines ignore opts
|
||||
} catch (e) {
|
||||
return createImageBitmap(blob).catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
function _artMapLoadImage(url) {
|
||||
function _artMapLoadImage(url, px) {
|
||||
// Try direct CORS fetch first (zero server load, works for Spotify/iTunes/Discogs)
|
||||
return fetch(url, { mode: 'cors' })
|
||||
.then(r => r.ok ? r.blob() : Promise.reject('not ok'))
|
||||
.then(b => _artMapDecodeSmall(b))
|
||||
.then(b => _artMapDecodeSmall(b, px))
|
||||
.catch(() => {
|
||||
// Fallback: server proxy for CDNs without CORS headers
|
||||
return fetch('/api/image-proxy?url=' + encodeURIComponent(url))
|
||||
.then(r => r.ok ? r.blob() : null)
|
||||
.then(b => _artMapDecodeSmall(b))
|
||||
.then(b => _artMapDecodeSmall(b, px))
|
||||
.catch(() => null);
|
||||
});
|
||||
}
|
||||
|
||||
// Target avatar px for a node, based on its world radius (≈ its on-screen size
|
||||
// at full zoom). Focal/watchlist nodes get a big crisp avatar; small ones stay
|
||||
// light. Used by every map's image loader.
|
||||
function _artMapNodeImgPx(n) {
|
||||
const isFocal = n.type === 'watchlist' || n.type === 'center' || n.ring === 1;
|
||||
return _artMapImgPx(isFocal ? Math.max(256, (n.radius || 0) * 1.4) : (n.radius || 0) * 1.6);
|
||||
}
|
||||
|
||||
function _artMapHideContextMenu() {
|
||||
const m = document.getElementById('artist-map-context');
|
||||
if (m) m.style.display = 'none';
|
||||
|
|
@ -6762,7 +6793,8 @@ function _artMapSetupInteraction(canvas) {
|
|||
_artMapAnimateConstellation(); // fade out
|
||||
}
|
||||
if (_artMap.hoveredNode) {
|
||||
// Delay constellation effect by 800ms of sustained hover
|
||||
// Snappy sustained-hover delay before the constellation lights up
|
||||
// (was 800ms, which felt like nothing happened).
|
||||
_artMap._constellationTimer = setTimeout(() => {
|
||||
if (_artMap.hoveredNode) {
|
||||
_artMap._constellationActive = true;
|
||||
|
|
@ -6770,7 +6802,7 @@ function _artMapSetupInteraction(canvas) {
|
|||
_artMap._constellationCache = null;
|
||||
_artMapAnimateConstellation();
|
||||
}
|
||||
}, 800);
|
||||
}, 220);
|
||||
}
|
||||
_artMapRender();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue