From 682bdc7fb111b29cd801f5caa519683ee297c996 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 10:31:54 -0700 Subject: [PATCH] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=20F=20(pe?= =?UTF-8?q?rf):=20de-lag=20hover,=20dense=20zoom,=20fix=20tooltip=20photo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the perf + tooltip feedback: - Hover constellation no longer clips per node every frame (images are already pre-masked circles) — that per-node ctx.clip() was the hover-lag culprit once the ambient loop forced continuous redraws. Now a plain drawImage + arc tint. - Ambient buoyancy loop runs at ~30fps when idle (full 60 only during reveal/ripple), halving redraw cost on dense zoomed-in maps while keeping the bob smooth. - Gloss highlight gated to bubbles >=12px on screen (skips the dense swarm) — halves per-frame drawImage cost when zoomed in. - Tooltip photo now paints from the already-decoded bitmap into a canvas instead of a fresh reload — fixes the blank photo when sweeping across dense zoomed-in bubbles (the was churn-reloading faster than it could decode). 64 JS integrity tests pass. --- webui/static/discover.js | 50 +++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index b4b1e4d8..c3b0300a 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5533,8 +5533,9 @@ function _artMapDrawNodeToBuffer(octx, n, scale) { octx.fill(); } - // Glassy specular highlight (orb look) — skip the tiniest where it'd be noise. - if (rScaled >= 5) { + // Glassy specular highlight (orb look) — only on bubbles big enough to read + // it; skipping the dense swarm halves per-frame drawImage cost when zoomed in. + if (rScaled >= 12) { octx.drawImage(_artMapGlossSprite(), n.x - r, n.y - r, r * 2, r * 2); } @@ -5694,10 +5695,14 @@ function _artMapStartLoop() { if (!a.running) return; _artMap._now = t; const more = _artMapStepAnimations(t); - _artMapDraw(); // sets _artMap._liveCount - // Keep looping while something is animating, OR while ambient buoyancy is - // on AND there are live bubbles on screen to bob (so a zoomed-out static - // overview costs nothing — the loop parks until the next interaction). + // Reveal/ripple → full 60fps. Idle buoyancy → ~30fps (the gentle bob + // doesn't need 60, and halving the redraws keeps dense zoomed-in maps + // smooth + cool). + const ambientOnly = !more && _artMap._ambient; + if (!ambientOnly || (t - (a._lastDraw || 0)) >= 32) { + _artMapDraw(); // sets _artMap._liveCount + a._lastDraw = t; + } const keep = more || (_artMap._ambient && _artMap._liveCount > 0 && !document.hidden); if (keep) { a.raf = requestAnimationFrame(tick); @@ -5996,23 +6001,21 @@ function _artMapDraw() { ctx.stroke(); } - // Circle + image - ctx.save(); - ctx.beginPath(); - ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); - ctx.closePath(); - ctx.clip(); - + // Circle + image — pre-masked, so no per-frame clip (that was + // the hover-lag culprit once the ambient loop forced redraws). const img = _artMap.images[hn.id]; if (img) { ctx.drawImage(img, hn.x - r, hn.y - r, r * 2, r * 2); - ctx.fillStyle = 'rgba(0,0,0,0.35)'; - ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); + ctx.beginPath(); + ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(0,0,0,0.35)'; // keep the name legible over art + ctx.fill(); } else { + ctx.beginPath(); + ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); ctx.fillStyle = isW ? '#1a0a30' : '#141420'; - ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); + ctx.fill(); } - ctx.restore(); // Border ctx.beginPath(); @@ -6155,7 +6158,13 @@ function _artMapShowTooltip(e, node) { // the hovered artist actually changes — a plain mousemove just repositions. if (_artMap._tipNodeId !== node.id) { _artMap._tipNodeId = node.id; - const img = node.image_url ? `` : '
'; + // Prefer the already-decoded (pre-masked) bitmap — instant, and it can't + // churn-blank while sweeping across dense zoomed-in bubbles the way a + // fresh reload does. Fall back to the URL, then a glyph. + const bmp = _artMap.images[node.id]; + const img = bmp + ? '' + : (node.image_url ? `` : '
'); const genres = (node.genres || []).slice(0, 3); const genreHTML = genres.length ? `
${genres.map(g => `${escapeHtml(g)}`).join('')}
` : ''; const typeLabel = node.type === 'watchlist' ? '★ Watchlist' : ''; @@ -6176,6 +6185,11 @@ function _artMapShowTooltip(e, node) { `; tip.style.display = 'block'; + // Paint the cached bitmap into the tooltip canvas (instant, no reload). + if (bmp) { + const c = tip.querySelector('canvas.artmap-tip-img'); + if (c) { try { c.getContext('2d').drawImage(bmp, 0, 0, 88, 88); } catch (e) { /* ignore */ } } + } } // Position — keep on screen (cheap; runs every move)