diff --git a/webui/static/discover.js b/webui/static/discover.js index a4b28736..e3b0960e 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5504,6 +5504,16 @@ function _artMapRebuildBuffer() { } function _artMapRender() { + // v2 perf: coalesce every render request into a single rAF, so a burst of + // mousemove/pan/animation calls never draws more than once per frame. + if (_artMap._rafPending) return; + _artMap._rafPending = requestAnimationFrame(() => { + _artMap._rafPending = null; + _artMapDraw(); + }); +} + +function _artMapDraw() { /**Blit offscreen buffer to screen canvas with pan/zoom. Near-zero cost.**/ const ctx = _artMap.ctx; const w = _artMap.width; @@ -5581,20 +5591,18 @@ function _artMapRender() { ctx.globalAlpha = 1; ctx.restore(); - // Draw glowing connection lines + // 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; + ctx.beginPath(); for (const cn of highlightNodes) { if (cn === n) continue; - ctx.beginPath(); ctx.moveTo(n.x, n.y); ctx.lineTo(cn.x, cn.y); - // Gradient line - const lineGrad = ctx.createLinearGradient(n.x, n.y, cn.x, cn.y); - lineGrad.addColorStop(0, `rgba(138,43,226,${0.5 * cFade})`); - lineGrad.addColorStop(1, `rgba(138,43,226,${0.15 * cFade})`); - ctx.strokeStyle = lineGrad; - ctx.lineWidth = 2; - ctx.stroke(); } + ctx.stroke(); // Redraw highlighted nodes on top ctx.globalAlpha = cFade; @@ -5736,26 +5744,30 @@ function artMapZoomToNode(nodeId) { function _artMapShowTooltip(e, node) { const tip = document.getElementById('artist-map-tooltip'); if (!tip) return; - if (!node) { tip.style.display = 'none'; return; } + if (!node) { tip.style.display = 'none'; _artMap._tipNodeId = null; return; } - const img = 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' : ''; - - tip.innerHTML = ` -
- ${img} -
-
${escapeHtml(node.name)}
- ${typeLabel} - ${genreHTML} + // v2 perf: only rebuild the tooltip's innerHTML (and reload its image) when + // the hovered artist actually changes — a plain mousemove just repositions. + if (_artMap._tipNodeId !== node.id) { + _artMap._tipNodeId = node.id; + const img = 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' : ''; + tip.innerHTML = ` +
+ ${img} +
+
${escapeHtml(node.name)}
+ ${typeLabel} + ${genreHTML} +
-
- `; - tip.style.display = 'block'; + `; + tip.style.display = 'block'; + } - // Position — keep on screen + // Position — keep on screen (cheap; runs every move) const x = Math.min(e.clientX + 16, window.innerWidth - tip.offsetWidth - 10); const y = Math.min(e.clientY - 10, window.innerHeight - tip.offsetHeight - 10); tip.style.left = x + 'px'; @@ -6731,17 +6743,48 @@ function _artMapScreenToWorld(e, canvas) { }; } -function _artMapHitTest(wx, wy) { - // Check watchlist first (drawn on top), then similar - const sorted = [..._artMap.placed].sort((a, b) => - (b.type === 'watchlist' ? 1 : 0) - (a.type === 'watchlist' ? 1 : 0)); - for (const n of sorted) { - if ((n.opacity || 0) < 0.3) continue; - const dx = wx - n.x; - const dy = wy - n.y; - if (dx * dx + dy * dy <= n.radius * n.radius) return n; +function _artMapBuildHitGrid() { + // v2 perf: bucket nodes into a coarse world-space grid so hit-testing checks + // only the cell under the cursor instead of sorting+scanning every node each + // mousemove. A node is inserted into every cell its bounding box overlaps, + // so a single-cell lookup at the cursor is exhaustive. + const placed = _artMap.placed || []; + const cell = 400; + const grid = new Map(); + for (const n of placed) { + const mincx = Math.floor((n.x - n.radius) / cell); + const maxcx = Math.floor((n.x + n.radius) / cell); + const mincy = Math.floor((n.y - n.radius) / cell); + const maxcy = Math.floor((n.y + n.radius) / cell); + for (let cx = mincx; cx <= maxcx; cx++) { + for (let cy = mincy; cy <= maxcy; cy++) { + const k = cx + ',' + cy; + let arr = grid.get(k); + if (!arr) { arr = []; grid.set(k, arr); } + arr.push(n); + } + } } - return null; + _artMap._grid = { cell, grid }; + _artMap._gridFor = placed; // identity guard — loaders assign a fresh array +} + +function _artMapHitTest(wx, wy) { + if (!_artMap._grid || _artMap._gridFor !== _artMap.placed) _artMapBuildHitGrid(); + const { cell, grid } = _artMap._grid; + const candidates = grid.get(Math.floor(wx / cell) + ',' + Math.floor(wy / cell)); + if (!candidates) return null; + // Watchlist nodes draw on top, so they win ties. + let similarHit = null; + for (const n of candidates) { + if ((n.opacity || 0) < 0.3) continue; + const dx = wx - n.x, dy = wy - n.y; + if (dx * dx + dy * dy <= n.radius * n.radius) { + if (n.type === 'watchlist') return n; + if (!similarHit) similarHit = n; + } + } + return similarHit; } async function openYourArtistInfoModal_direct(node) {