From 14388d4f427a5d589469bb210947e687ae6da796 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 2 Jun 2026 23:37:27 -0700 Subject: [PATCH 01/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=201?= =?UTF-8?q?:=20performance=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kills the hover/move lag on dense maps. Root causes were in the live mouse/render path, not the layout: - Render coalescing: _artMapRender() now just requests a single rAF; the actual draw (_artMapDraw) runs at most once per frame. A burst of mousemove/pan/ animation calls no longer triggers many full-buffer blits per second. - Tooltip de-churn: only rebuild the tooltip innerHTML (and reload its image) when the hovered artist changes; a plain mousemove just repositions. Was rebuilding innerHTML + a new every pixel of movement. - Spatial-grid hit-test: bucket nodes into a coarse world grid and test only the cell under the cursor, instead of sorting + scanning every node each move. Grid rebuilds only when the node set changes. - Constellation lines: draw all connection lines as ONE solid-stroke path instead of creating a fresh linear-gradient object per line every frame — that per-frame gradient churn was the main 'connected lines' lag. No layout/data/click changes; behaviour identical, just frame-bound. Pure frontend; JS syntax clean. --- webui/static/discover.js | 115 +++++++++++++++++++++++++++------------ 1 file changed, 79 insertions(+), 36 deletions(-) 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) { From 1b21983ce7562c186c2517d0a4e57c422a82f2ad Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 2 Jun 2026 23:41:56 -0700 Subject: [PATCH 02/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=202?= =?UTF-8?q?:=20richer=20real=20data=20on=20hover=20(connection=20counts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigating 'each mode is different / not good enough' showed the engine is already shared across all three modes (watchlist/genre/explore) and already does LOD rendering, eased camera, and debounced zoom-rebuilds — so the inconsistency was perception driven mostly by the (now-fixed) lag, not separate engines. This phase surfaces more real data the map already has: the hover tooltip now shows each artist's live connection count (computed from the map edges), shown consistently across all three modes. Cheap (only recomputed when the hovered artist changes, after Phase 1's de-churn). Additive + safe. JS syntax clean. --- webui/static/discover.js | 6 ++++++ webui/static/style.css | 1 + 2 files changed, 7 insertions(+) diff --git a/webui/static/discover.js b/webui/static/discover.js index e3b0960e..1872ea5d 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5754,12 +5754,18 @@ function _artMapShowTooltip(e, node) { const genres = (node.genres || []).slice(0, 3); const genreHTML = genres.length ? `
${genres.map(g => `${escapeHtml(g)}`).join('')}
` : ''; const typeLabel = node.type === 'watchlist' ? '★ Watchlist' : ''; + // Real connection count from the map's edges (cheap; only on hover change). + let conn = 0; + const edges = _artMap.edges || []; + for (const ed of edges) { if (ed.source === node.id || ed.target === node.id) conn++; } + const connHTML = conn ? `
${conn} connection${conn === 1 ? '' : 's'}
` : ''; tip.innerHTML = `
${img}
${escapeHtml(node.name)}
${typeLabel} + ${connHTML} ${genreHTML}
diff --git a/webui/static/style.css b/webui/static/style.css index 5ffeaa8b..f5b13454 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -34299,6 +34299,7 @@ div.artist-hero-badge { .artmap-tip-info { min-width: 0; } .artmap-tip-name { font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .artmap-tip-badge { display: inline-block; font-size: 9px; font-weight: 700; color: #fbbf24; margin-top: 2px; } +.artmap-tip-conn { font-size: 10px; font-weight: 600; color: rgba(138,43,226,0.85); margin-top: 3px; } .artmap-tip-genres { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; } .artmap-tip-genres span { font-size: 9px; padding: 2px 6px; border-radius: 4px; From b85392977cb1b3a73a0330a787b7fcf5bf33e78c Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 2 Jun 2026 23:16:47 -0700 Subject: [PATCH 03/42] Artist Explorer: search-and-select instead of free text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Explorer prompt accepted any loose text and explored whatever you typed. Now it's a proper picker: type -> debounced search of the metadata source (reuses /api/discover/build-playlist/search-artists — Hydrabase if active, Spotify if configured, else the active metadata source) -> shows real artist results with images -> click one to explore that resolved artist. Enter picks the top match (never explores raw text); Escape/Cancel/backdrop close. Pure frontend: rebuilds _showArtistMapSearchPrompt() (same Promise contract, so the caller is unchanged), reusing the playlist-builder's search endpoint + picker styling. No backend change. --- webui/static/discover.js | 87 ++++++++++++++++++++++++++++++++-------- webui/static/style.css | 30 ++++++++++++++ 2 files changed, 101 insertions(+), 16 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 1872ea5d..bd378cc7 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -6431,14 +6431,22 @@ async function _openArtistMapExplorerWithName(name) { } function _showArtistMapSearchPrompt() { + // Search the metadata source and make the user PICK a real artist, rather + // than exploring whatever loose text they typed. Resolves with the chosen + // artist's resolved name (which the explorer hands to /artist-map/explore), + // or null if cancelled. return new Promise(resolve => { const existing = document.getElementById('artmap-search-prompt'); if (existing) existing.remove(); - const overlay = document.createElement('div'); + let done = false; + let overlay; + const finish = (val) => { if (done) return; done = true; if (overlay) overlay.remove(); resolve(val); }; + + overlay = document.createElement('div'); overlay.id = 'artmap-search-prompt'; overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + overlay.onclick = (e) => { if (e.target === overlay) finish(null); }; overlay.innerHTML = `
@@ -6449,32 +6457,79 @@ function _showArtistMapSearchPrompt() {

Artist Explorer

-

Enter an artist to explore their connections

+

Search and pick an artist to explore

- +
+ + +
+
- - +
`; document.body.appendChild(overlay); const input = overlay.querySelector('#artmap-explore-input'); - const goBtn = overlay.querySelector('#artmap-explore-go'); + const results = overlay.querySelector('#artmap-explore-results'); + const spinner = overlay.querySelector('#artmap-explore-spinner'); + overlay.querySelector('#artmap-explore-cancel').onclick = () => finish(null); - const submit = () => { - const val = input.value.trim(); - overlay.remove(); - resolve(val || null); + const renderResults = (artists) => { + results.innerHTML = ''; + if (!artists.length) { + results.innerHTML = '
No artists found
'; + return; + } + artists.forEach(a => { + const img = a.image_url || '/static/placeholder-album.png'; + const row = document.createElement('div'); + row.className = 'artmap-explore-result'; + row.innerHTML = ` + + ${escapeHtml(a.name)} + Explore →`; + row.onclick = () => finish(a.name); // pick the resolved artist, not raw text + results.appendChild(row); + }); }; - goBtn.onclick = submit; - input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); + let timer = null; + let token = 0; + const doSearch = () => { + const q = input.value.trim(); + if (!q) { results.innerHTML = ''; spinner.style.display = 'none'; clearTimeout(timer); return; } + clearTimeout(timer); + timer = setTimeout(async () => { + const myToken = ++token; + spinner.style.display = 'flex'; + try { + const resp = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(q)}`); + const data = await resp.json(); + if (myToken !== token) return; // a newer keystroke superseded this + renderResults((data && data.success && Array.isArray(data.artists)) ? data.artists : []); + } catch (e) { + if (myToken === token) results.innerHTML = '
Search failed — try again
'; + } finally { + if (myToken === token) spinner.style.display = 'none'; + } + }, 350); + }; + + input.addEventListener('input', doSearch); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const first = results.querySelector('.artmap-explore-result'); + if (first) first.click(); // Enter = pick top match, never raw text + } else if (e.key === 'Escape') { + finish(null); + } + }); setTimeout(() => input.focus(), 50); }); } diff --git a/webui/static/style.css b/webui/static/style.css index f5b13454..35afbdef 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -34358,6 +34358,36 @@ div.artist-hero-badge { .artmap-explore-input::placeholder { color: rgba(255,255,255,0.2); } .artmap-search-prompt-actions { display: flex; justify-content: flex-end; gap: 8px; } +/* Artist Explorer — search + select picker */ +.artmap-explore-search-wrap { position: relative; } +.artmap-explore-search-wrap .artmap-explore-input { margin-bottom: 12px; } +.artmap-explore-spinner { position: absolute; right: 12px; top: 11px; pointer-events: none; } +.artmap-explore-spinner .watch-all-loading-spinner { width: 18px; height: 18px; border-width: 2px; } +.artmap-explore-results { + max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; + margin-bottom: 18px; +} +.artmap-explore-results:empty { margin-bottom: 0; } +.artmap-explore-result { + display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 10px; + cursor: pointer; transition: background 0.15s ease; +} +.artmap-explore-result:hover { background: rgba(138,43,226,0.14); } +.artmap-explore-result img { + width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; + background: rgba(255,255,255,0.06); +} +.artmap-explore-result-name { + flex: 1 1 auto; min-width: 0; color: #fff; font-size: 14px; font-weight: 600; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.artmap-explore-result-go { + flex: 0 0 auto; font-size: 11px; font-weight: 700; color: rgba(138,43,226,0.9); + opacity: 0; transition: opacity 0.15s ease; +} +.artmap-explore-result:hover .artmap-explore-result-go { opacity: 1; } +.artmap-explore-empty { padding: 18px; text-align: center; color: rgba(255,255,255,0.35); font-size: 13px; } + /* Genre picker modal */ .artmap-genre-picker-modal { background: linear-gradient(165deg, #1e1e32 0%, #181828 100%); From 77d6d49069d67ff8d32e4275598e7a2e73c9d500 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 07:34:41 -0700 Subject: [PATCH 04/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20fix=20reg?= =?UTF-8?q?ression=20+=20add=20perf=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - REVERT the spatial-grid hit-test I added in Phase 1. It inserted each node into every grid cell its bounding box overlaps; the genre map's huge cluster nodes span an enormous number of cells, so the first hover/click triggered a multi-second synchronous build → 'can't hover or click' freeze. Back to a flat O(N) single-pass hit-test (no per-move sort) — sub-ms even for thousands of nodes, can't lock up. - Keep the safe Phase 1 wins (render coalescing, tooltip de-churn, solid-stroke connection lines). - Add a perf overlay toggled with 'd' on the map: shows node/edge counts, the offscreen buffer size + scale, zoom, and the last buffer-rebuild + draw times. So we can measure the real drag/zoom bottleneck (buffer rebuild) instead of optimising blind. JS clean; 64 integrity tests pass. --- webui/static/discover.js | 79 +++++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index bd378cc7..6e3230b3 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5515,6 +5515,7 @@ function _artMapRender() { function _artMapDraw() { /**Blit offscreen buffer to screen canvas with pan/zoom. Near-zero cost.**/ + const _t0 = _artMap._perf ? performance.now() : 0; const ctx = _artMap.ctx; const w = _artMap.width; const h = _artMap.height; @@ -5522,8 +5523,12 @@ function _artMapDraw() { ctx.fillStyle = '#0a0a14'; ctx.fillRect(0, 0, w, h); - if (_artMap.dirty || !_artMap.offscreen) _artMapRebuildBuffer(); - if (!_artMap.offscreen) return; + if (_artMap.dirty || !_artMap.offscreen) { + const _rt = _artMap._perf ? performance.now() : 0; + _artMapRebuildBuffer(); + if (_artMap._perf) _artMap._rebuildMs = performance.now() - _rt; + } + if (!_artMap.offscreen) { if (_artMap._perf) _artMapDrawPerf(ctx, _t0); return; } const oc = _artMap.offscreen; const s = _artMap._bufferScale; @@ -5702,6 +5707,37 @@ function _artMapDraw() { _artMap._ripple = null; } } + + if (_artMap._perf) _artMapDrawPerf(ctx, _t0); +} + +// Toggle with 'd' on the map. Shows where frame time goes so we optimise the +// real bottleneck (buffer rebuild on zoom vs. blit on pan) instead of guessing. +function _artMapDrawPerf(ctx, t0) { + const drawMs = performance.now() - t0; + const now = performance.now(); + const dt = _artMap._lastPerfTs ? now - _artMap._lastPerfTs : 0; + _artMap._lastPerfTs = now; + const fps = dt > 0 ? Math.round(1000 / dt) : 0; + const oc = _artMap.offscreen; + const lines = [ + `nodes ${_artMap.placed.length} edges ${(_artMap.edges || []).length}`, + `buffer ${oc ? oc.width + '×' + oc.height : '—'} scale ${(_artMap._bufferScale || 0).toFixed(3)}`, + `zoom ${_artMap.zoom.toFixed(3)}`, + `rebuild ${(_artMap._rebuildMs || 0).toFixed(1)}ms draw ${drawMs.toFixed(1)}ms`, + `~${fps} fps (while interacting)`, + ]; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); // device pixels, ignore dpr scale + ctx.font = '12px monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + const pad = 8, lh = 16; + ctx.fillStyle = 'rgba(0,0,0,0.72)'; + ctx.fillRect(10, 10, 270, lines.length * lh + pad * 2); + ctx.fillStyle = '#7CFC00'; + lines.forEach((l, i) => ctx.fillText(l, 10 + pad, 10 + pad + i * lh)); + ctx.restore(); } function artMapSearch(query) { @@ -6597,6 +6633,7 @@ function _artMapSetupInteraction(canvas) { else if (e.key === '-') { artMapZoom(0.7); e.preventDefault(); } else if (e.key === '0') { artMapFitToView(); e.preventDefault(); } else if (e.key === 'f' || e.key === 'F') { artMapFitToView(); e.preventDefault(); } + else if (e.key === 'd' || e.key === 'D') { _artMap._perf = !_artMap._perf; _artMapRender(); e.preventDefault(); } else if (e.key === 's' || e.key === 'S') { const input = document.getElementById('artist-map-search'); if (input) { input.focus(); e.preventDefault(); } @@ -6804,40 +6841,14 @@ function _artMapScreenToWorld(e, canvas) { }; } -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); - } - } - } - _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. + // Single O(N) pass, no per-move sort, no allocation. Watchlist nodes draw on + // top so they win ties; otherwise the first node whose circle contains the + // point wins. (A spatial grid was tried and reverted — it exploded building + // cells for large-radius genre cluster nodes. A flat scan of even thousands + // of nodes is sub-millisecond and can't lock up.) let similarHit = null; - for (const n of candidates) { + for (const n of _artMap.placed) { 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) { From 884c3b5c07d039c87f37d08abbcd2c1ec604ef11 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 07:54:32 -0700 Subject: [PATCH 05/42] Artist Map perf overlay: also POST timings to app.log (readable server-side) The on-canvas overlay text can't be copied (and can't be grabbed mid-freeze), so when perf mode is on ('d'), the frontend now also POSTs the render timings to /api/discover/artist-map/perf ~1.5x/sec, which logs them as [ARTMAP-PERF] in app.log. Lets the bottleneck be diagnosed from the server side with no manual copying. --- web_server.py | 13 +++++++++++++ webui/static/discover.js | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/web_server.py b/web_server.py index 0e8c0d68..13eb3368 100644 --- a/web_server.py +++ b/web_server.py @@ -28284,6 +28284,19 @@ def get_artist_map_explore(): return _artists_map_get_artist_map_explore() +@app.route('/api/discover/artist-map/perf', methods=['POST']) +def log_artist_map_perf(): + """Debug sink: the artist-map frontend POSTs its render timings here (toggled + with 'd' on the map) so they land in app.log — the on-canvas overlay text + can't be copied. Used to find the real drag/zoom bottleneck.""" + try: + data = request.get_json(silent=True) or {} + logger.info("[ARTMAP-PERF] %s", json.dumps(data, ensure_ascii=False)) + except Exception: + pass + return ('', 204) + + @app.route('/api/discover/build-playlist/search-artists', methods=['GET']) def search_artists_for_playlist(): """Search for artists to use as seeds for custom playlist building""" diff --git a/webui/static/discover.js b/webui/static/discover.js index 6e3230b3..ab244807 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5720,6 +5720,26 @@ function _artMapDrawPerf(ctx, t0) { _artMap._lastPerfTs = now; const fps = dt > 0 ? Math.round(1000 / dt) : 0; const oc = _artMap.offscreen; + + // Ship the numbers to app.log (~1.5/s) so they can be read server-side — + // the on-canvas text below can't be copied, especially mid-lag. + if (!_artMap._perfPostTs || now - _artMap._perfPostTs > 700) { + _artMap._perfPostTs = now; + try { + fetch('/api/discover/artist-map/perf', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + nodes: _artMap.placed.length, edges: (_artMap.edges || []).length, + buffer: oc ? oc.width + 'x' + oc.height : '-', + scale: +(_artMap._bufferScale || 0).toFixed(3), + zoom: +_artMap.zoom.toFixed(3), + rebuildMs: +(_artMap._rebuildMs || 0).toFixed(1), + drawMs: +drawMs.toFixed(1), fps, + }), + }).catch(() => { }); + } catch (e) { /* ignore */ } + } + const lines = [ `nodes ${_artMap.placed.length} edges ${(_artMap.edges || []).length}`, `buffer ${oc ? oc.width + '×' + oc.height : '—'} scale ${(_artMap._bufferScale || 0).toFixed(3)}`, From 6fa733dc6e4e2ab8f159e157aeb47f73a0f5d5e6 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 08:03:30 -0700 Subject: [PATCH 06/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20fix=20the?= =?UTF-8?q?=20real=20bottleneck:=20cap=20the=20offscreen=20buffer=20(76MP?= =?UTF-8?q?=20=E2=86=92=20~12MP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perf telemetry from the genre map (2004 nodes) proved it: the offscreen buffer was 7465×10240 (76 megapixels) — rebuilt in ~979ms on every zoom and blitted at ~150ms/frame (3 fps), with the constellation overlay piling on top. The buffer renders the WHOLE world, and the size cap was 10240px. Cap the max buffer dimension to 4096 (MAX_BUFFER_PX). On the dense genre map that's ~12MP instead of 76MP → ~6x faster rebuild and blit, and more nodes drop under the LOD dot threshold so the rebuild also draws fewer image-clips. The cap only binds on large worlds; small watchlist/explorer maps don't reach it and stay full-resolution. Tunable; perf overlay ('d' → app.log) stays so we can confirm the new numbers. --- webui/static/discover.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index ab244807..4135dbfc 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5004,6 +5004,13 @@ const _artMap = { dirty: true, // true = need to rebuild offscreen buffer WATCHLIST_R: 320, BUFFER: 8, + // Max offscreen-buffer dimension (px). The buffer renders the whole world, + // so on big/dense maps (e.g. 2000-node genre map) this was 10240 → a 76 MP + // canvas that took ~1s to rebuild and ~150ms to blit per frame (3 fps). + // Capping it far lower makes rebuild + blit cheap (and pushes more nodes + // under the LOD dot threshold). Only binds on large worlds — small maps + // stay crisp via the z*2 / 1.0 caps. Tunable. + MAX_BUFFER_PX: 4096, }; async function openArtistMap() { @@ -5348,7 +5355,7 @@ function _artMapRebuildBuffer() { const bh = maxY - minY; // Scale based on zoom — higher zoom = higher res buffer, capped for memory const z = _artMap.zoom || 0.1; - const scale = Math.min(z * 2, 1.0, 10240 / Math.max(bw, bh)); + const scale = Math.min(z * 2, 1.0, _artMap.MAX_BUFFER_PX / Math.max(bw, bh)); if (!_artMap.offscreen) _artMap.offscreen = document.createElement('canvas'); const oc = _artMap.offscreen; From 20b77a774ac04d0c38889e139c531f9e98e70643 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 08:18:41 -0700 Subject: [PATCH 07/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20the=20rea?= =?UTF-8?q?l=20lock:=20downscale=20artist=20images=20on=20decode=20(6GB=20?= =?UTF-8?q?=E2=86=92=20~100MB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perf telemetry was the giveaway: after the buffer cap, rebuild + draw were both ~10ms, yet fps stayed 1-3 and the browser 'locked'. Cheap draw + locked system = memory/GPU thrash, not drawing. Cause: artist images load at up to 1000×1000, and a dense map holds ~1500 of them — ~1500 × 1000² × 4B ≈ 6 GB of decoded ImageBitmap memory. The browser GCs/ evicts textures constantly → systemic lag the canvas timers don't see. Fix: decode straight to a 128px avatar via createImageBitmap resize options (nodes render tiny anyway). ~1500 × 128² × 4B ≈ 100 MB instead of 6 GB. Falls back to full decode on engines that ignore the resize opts. This is the one that should actually make it smooth. Perf overlay stays on 'd'. --- webui/static/discover.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 4135dbfc..f638acdf 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -6606,16 +6606,33 @@ function artMapToggleSimilar() { showToast(_artMap._hideSimilar ? 'Showing watchlist only' : 'Showing all artists', 'info', 1500); } +// Artist images come in at up to 1000×1000. Nodes are drawn tiny, so holding +// full-res bitmaps is pointless and ruinous: ~1500 nodes × 1000² × 4 bytes ≈ 6 GB +// 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) { + if (!blob) return Promise.resolve(null); + try { + return createImageBitmap(blob, { + resizeWidth: _ARTMAP_IMG_PX, resizeHeight: _ARTMAP_IMG_PX, resizeQuality: 'medium', + }).catch(() => createImageBitmap(blob).catch(() => null)); // older engines ignore opts + } catch (e) { + return createImageBitmap(blob).catch(() => null); + } +} + function _artMapLoadImage(url) { // 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 => createImageBitmap(b)) + .then(b => _artMapDecodeSmall(b)) .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 => b ? createImageBitmap(b) : null) + .then(b => _artMapDecodeSmall(b)) .catch(() => null); }); } From c97108ff6bf247f7e6164e1a6df91bf8bd715dfe Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 08:25:34 -0700 Subject: [PATCH 08/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=203?= =?UTF-8?q?=20polish:=20crisp=20adaptive=20images,=20snappier=20glowing=20?= =?UTF-8?q?hover,=20premium=20backdrop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- webui/static/discover.js | 64 ++++++++++++++++++++++++++++++---------- 1 file changed, 48 insertions(+), 16 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index f638acdf..c40f30b9 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -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(); } From ff2bddf1db6e2c26e6f0ec3c6d41c78abecd179c Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 08:46:11 -0700 Subject: [PATCH 09/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=204?= =?UTF-8?q?:=20progressive=20image=20streaming=20(paint-first,=20click-cli?= =?UTF-8?q?ck-click)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- webui/static/discover.js | 140 +++++++++++++++++---------------------- 1 file changed, 59 insertions(+), 81 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index c40f30b9..827b6902 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -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'; From 8d4de4dc49ef6433ec1fa6e98abe8927f34025c2 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 08:54:32 -0700 Subject: [PATCH 10/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=205?= =?UTF-8?q?:=20incremental=20buffer=20compositing=20(no=20lag=20while=20im?= =?UTF-8?q?ages=20stream)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming fix in Phase 4 still rebuilt the ENTIRE offscreen buffer (~1500 nodes) on each image wave, and any hover/pan during streaming hit that same dirty flag — so interacting while images loaded redrew the whole world over and over (the 'laggy until all images load' jank). Now each arriving image composites ONLY its own node into the existing buffer (_artMapCompositeNode) and does a cheap rAF-coalesced blit — no full rebuild. The per-node draw is extracted into _artMapDrawNodeToBuffer so the full rebuild and the incremental compositor share identical drawing (can't drift). Falls back to a full rebuild only if the buffer isn't built yet. Pan/hover stay at blit-speed the entire time images stream in. --- webui/static/discover.js | 242 ++++++++++++++++++++++----------------- 1 file changed, 139 insertions(+), 103 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 827b6902..ddd9b646 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5370,108 +5370,7 @@ function _artMapRebuildBuffer() { else if (pass === 2 && !n._isLabel && (n.type === 'watchlist' || n.type === 'center' || n.ring === 1)) { /* draw */ } else continue; if (hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue; - const op = n.opacity || 0; - if (op < 0.01) continue; - const r = n.radius; - const isW = n.type === 'watchlist' || n.type === 'center'; - octx.globalAlpha = op; - - // Genre label node — transparent circle with large text - if (n._isLabel) { - octx.globalAlpha = 0.6; - octx.beginPath(); - octx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); - octx.fillStyle = 'rgba(138,43,226,0.04)'; - octx.fill(); - octx.strokeStyle = 'rgba(138,43,226,0.08)'; - octx.lineWidth = 1; - octx.stroke(); - const labelSize = Math.max(12, n.radius * 0.25); - octx.font = `800 ${labelSize}px system-ui`; - octx.textAlign = 'center'; - octx.textBaseline = 'middle'; - octx.fillStyle = 'rgba(138,43,226,0.35)'; - octx.fillText(n.name, n.x, n.y - labelSize * 0.3); - octx.font = `500 ${labelSize * 0.5}px system-ui`; - octx.fillStyle = 'rgba(255,255,255,0.15)'; - octx.fillText(`${n._count || 0} artists`, n.x, n.y + labelSize * 0.5); - octx.globalAlpha = 1; - continue; - } - - // Render quality based on node size in buffer pixels - const rScaled = r * scale; - const isSmall = rScaled < 8; - const isTiny = rScaled < 3; - - // Tiny nodes: just a colored dot (no clip, no image, no text) - if (isTiny) { - octx.beginPath(); - octx.arc(n.x, n.y, r, 0, Math.PI * 2); - octx.fillStyle = isW ? '#6b21a8' : '#2a2a40'; - octx.fill(); - continue; - } - - // Small nodes: filled circle + border, no image clip - if (isSmall) { - octx.beginPath(); - octx.arc(n.x, n.y, r, 0, Math.PI * 2); - const img = _artMap.images[n.id]; - if (img) { - octx.save(); octx.clip(); - octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2); - octx.restore(); - } else { - octx.fillStyle = isW ? '#1a0a30' : '#141420'; - octx.fill(); - } - octx.strokeStyle = isW ? 'rgba(138,43,226,0.3)' : 'rgba(255,255,255,0.06)'; - octx.lineWidth = isW ? 1.5 : 0.5; - octx.stroke(); - continue; - } - - // Full quality: glow + clip + image + text - if (isW) { - octx.beginPath(); - octx.arc(n.x, n.y, r + 4, 0, Math.PI * 2); - octx.strokeStyle = 'rgba(138,43,226,0.25)'; - octx.lineWidth = 5; - octx.stroke(); - } - - octx.save(); - octx.beginPath(); - octx.arc(n.x, n.y, r, 0, Math.PI * 2); - octx.closePath(); - octx.clip(); - - const img = _artMap.images[n.id]; - if (img) { - octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2); - octx.fillStyle = 'rgba(0,0,0,0.45)'; - octx.fillRect(n.x - r, n.y - r, r * 2, r * 2); - } else { - octx.fillStyle = isW ? '#1a0a30' : '#141420'; - octx.fillRect(n.x - r, n.y - r, r * 2, r * 2); - } - octx.restore(); - - octx.beginPath(); - octx.arc(n.x, n.y, r, 0, Math.PI * 2); - octx.strokeStyle = isW ? 'rgba(138,43,226,0.4)' : 'rgba(255,255,255,0.08)'; - octx.lineWidth = isW ? 2 : 0.5; - octx.stroke(); - - const fontSize = isW ? Math.max(16, r * 0.14) : Math.max(8, r * 0.3); - octx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`; - octx.textAlign = 'center'; - octx.textBaseline = 'middle'; - octx.fillStyle = '#fff'; - const maxC = isW ? 20 : 12; - const label = n.name.length > maxC ? n.name.substring(0, maxC - 1) + '…' : n.name; - octx.fillText(label, n.x, n.y); + _artMapDrawNodeToBuffer(octx, n, scale); } } @@ -5480,6 +5379,136 @@ function _artMapRebuildBuffer() { _artMap.dirty = false; } +// Draw a SINGLE node into the offscreen buffer (in world coords; caller has +// already applied the buffer's scale+translate). Shared by the full rebuild and +// the incremental image compositor so the two can never drift visually. +function _artMapDrawNodeToBuffer(octx, n, scale) { + const op = n.opacity || 0; + if (op < 0.01) return; + const r = n.radius; + const isW = n.type === 'watchlist' || n.type === 'center'; + octx.globalAlpha = op; + + // Genre label node — transparent circle with large text + if (n._isLabel) { + octx.globalAlpha = 0.6; + octx.beginPath(); + octx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); + octx.fillStyle = 'rgba(138,43,226,0.04)'; + octx.fill(); + octx.strokeStyle = 'rgba(138,43,226,0.08)'; + octx.lineWidth = 1; + octx.stroke(); + const labelSize = Math.max(12, n.radius * 0.25); + octx.font = `800 ${labelSize}px system-ui`; + octx.textAlign = 'center'; + octx.textBaseline = 'middle'; + octx.fillStyle = 'rgba(138,43,226,0.35)'; + octx.fillText(n.name, n.x, n.y - labelSize * 0.3); + octx.font = `500 ${labelSize * 0.5}px system-ui`; + octx.fillStyle = 'rgba(255,255,255,0.15)'; + octx.fillText(`${n._count || 0} artists`, n.x, n.y + labelSize * 0.5); + octx.globalAlpha = 1; + return; + } + + // Render quality based on node size in buffer pixels + const rScaled = r * scale; + const isSmall = rScaled < 8; + const isTiny = rScaled < 3; + + // Tiny nodes: just a colored dot (no clip, no image, no text) + if (isTiny) { + octx.beginPath(); + octx.arc(n.x, n.y, r, 0, Math.PI * 2); + octx.fillStyle = isW ? '#6b21a8' : '#2a2a40'; + octx.fill(); + return; + } + + // Small nodes: filled circle + border, no image clip + if (isSmall) { + octx.beginPath(); + octx.arc(n.x, n.y, r, 0, Math.PI * 2); + const img = _artMap.images[n.id]; + if (img) { + octx.save(); octx.clip(); + octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2); + octx.restore(); + } else { + octx.fillStyle = isW ? '#1a0a30' : '#141420'; + octx.fill(); + } + octx.strokeStyle = isW ? 'rgba(138,43,226,0.3)' : 'rgba(255,255,255,0.06)'; + octx.lineWidth = isW ? 1.5 : 0.5; + octx.stroke(); + return; + } + + // Full quality: glow + clip + image + text + if (isW) { + octx.beginPath(); + octx.arc(n.x, n.y, r + 4, 0, Math.PI * 2); + octx.strokeStyle = 'rgba(138,43,226,0.25)'; + octx.lineWidth = 5; + octx.stroke(); + } + + octx.save(); + octx.beginPath(); + octx.arc(n.x, n.y, r, 0, Math.PI * 2); + octx.closePath(); + octx.clip(); + + const img = _artMap.images[n.id]; + if (img) { + octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2); + octx.fillStyle = 'rgba(0,0,0,0.45)'; + octx.fillRect(n.x - r, n.y - r, r * 2, r * 2); + } else { + octx.fillStyle = isW ? '#1a0a30' : '#141420'; + octx.fillRect(n.x - r, n.y - r, r * 2, r * 2); + } + octx.restore(); + + octx.beginPath(); + octx.arc(n.x, n.y, r, 0, Math.PI * 2); + octx.strokeStyle = isW ? 'rgba(138,43,226,0.4)' : 'rgba(255,255,255,0.08)'; + octx.lineWidth = isW ? 2 : 0.5; + octx.stroke(); + + const fontSize = isW ? Math.max(16, r * 0.14) : Math.max(8, r * 0.3); + octx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`; + octx.textAlign = 'center'; + octx.textBaseline = 'middle'; + octx.fillStyle = '#fff'; + const maxC = isW ? 20 : 12; + const label = n.name.length > maxC ? n.name.substring(0, maxC - 1) + '…' : n.name; + octx.fillText(label, n.x, n.y); +} + +// Composite ONE node into the EXISTING buffer without a full rebuild. This is +// what makes image streaming cheap: when a bitmap arrives we redraw only that +// node (in place, over its placeholder) instead of redrawing all ~1500 nodes. +// Returns false if there's no buffer yet (or the node is hidden) so the caller +// can fall back to a full rebuild. Zoom changes rebuild the buffer at a new +// scale; this always reads the CURRENT buffer scale/origin, so it stays correct. +function _artMapCompositeNode(n) { + const oc = _artMap.offscreen; + const scale = _artMap._bufferScale; + if (!oc || scale == null) return false; + if (_artMap._hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) return false; + if ((n.opacity || 0) < 0.01) return false; + const octx = oc.getContext('2d'); + octx.save(); + octx.scale(scale, scale); + octx.translate(-_artMap._bufferMinX, -_artMap._bufferMinY); + _artMapDrawNodeToBuffer(octx, n, scale); + octx.restore(); + octx.globalAlpha = 1; + return true; +} + 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. @@ -6616,6 +6645,8 @@ function _artMapStreamImages(imgNodes, concurrent = 24) { 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; + // Fallback full-rebuild path, used only if the buffer isn't ready yet + // (e.g. images returned before the first paint built the offscreen buffer). const scheduleRedraw = () => { if (redrawPending || token !== _artMap._loadToken) return; redrawPending = true; @@ -6637,7 +6668,12 @@ function _artMapStreamImages(imgNodes, concurrent = 24) { .then(bmp => { if (bmp && token === _artMap._loadToken) { _artMap.images[n.id] = bmp; - scheduleRedraw(); + // Composite just this node into the existing buffer (cheap) + // and blit. Only fall back to a full rebuild if the buffer + // isn't built yet. This keeps pan/hover at blit-speed the + // whole time images are streaming in — the lag fix. + if (_artMapCompositeNode(n)) _artMapRender(); + else scheduleRedraw(); } }) .finally(() => { inFlight--; pump(); }); From 4b04f765a0e9fe61b7e683f0848c6b3cf9f2167f Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 09:22:40 -0700 Subject: [PATCH 11/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=20A?= =?UTF-8?q?:=20two-layer=20live=20animation=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the water/ripple redesign. Splits rendering into: - Static far-field buffer: small/distant bubbles, baked once (cheap blit). - Live overlay layer: every bubble big enough to read (radius*zoom >= LIVE_PX) redrawn each frame in world space, so it can scale/bob/ripple. Viewport- culled + capped at 600 draws. The partition is frozen at buffer-build zoom (_liveBuildZoom) so the two sets stay exact complements even mid-zoom — no flicker, no double-draw. Adds an idle-capable rAF loop (_artMapStartLoop/_artMapStepAnimations) that runs only while something animates and stops when still. First payload: a reveal — the far field fades in globally while live bubbles pop outward from the camera centre (ease-out-back, staggered by distance). Wired into all three loaders. Bonus: live bubbles now draw full-res at the current zoom instead of through the 4096px-capped buffer, so zoomed-in artwork is crisp (addresses the earlier low-res complaint structurally). Engine only — the island layout, ripple choreography and click physics build on this in B–E. 64 JS integrity tests pass. --- webui/static/discover.js | 187 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 180 insertions(+), 7 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index ddd9b646..41ce56d7 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5011,6 +5011,20 @@ const _artMap = { // under the LOD dot threshold). Only binds on large worlds — small maps // stay crisp via the z*2 / 1.0 caps. Tunable. MAX_BUFFER_PX: 4096, + + // ── v2 live-animation engine ────────────────────────────────────────── + // Two-layer render: the offscreen buffer holds the STATIC far field (small + // bubbles, drawn once); a LIVE overlay redraws only the bubbles big enough + // to read on screen, every frame, so they can scale/bob/ripple. A node is + // "live" when its on-screen radius (radius*zoom) clears LIVE_PX; everything + // smaller stays baked in the buffer. This bounds per-frame work to what you + // can actually see, not the full 2000-node world. + LIVE_PX: 12, + // The rAF loop runs ONLY while something is animating (reveal/ripple) and + // idles otherwise, falling back to on-demand blits. _anim tracks it. + _anim: { running: false, raf: null, last: 0 }, + _fieldAlpha: 1, // global fade for the static far-field buffer (reveal) + _revealT0: 0, // performance.now() when the current reveal began }; async function openArtistMap() { @@ -5215,16 +5229,15 @@ async function openArtistMap() { _artMap.placed.forEach(n => { n.opacity = 1; }); // 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. + // before a single image is fetched. The reveal animation blooms the + // bubbles in (far field fades, near bubbles pop outward); images then + // stream in behind it and sharpen in place. No blocking on N fetches. _artMap.dirty = true; - _artMapRender(); const le = document.getElementById('artist-map-loading'); if (le) le.remove(); + _artMapBeginReveal(); _artMapStreamImages(_artMap.placed); }, 50); @@ -5335,6 +5348,8 @@ function _artMapRebuildBuffer() { _artMap._bufferScale = scale; _artMap._bufferMinX = minX; _artMap._bufferMinY = minY; + // Freeze the live/buffer partition to this build's zoom (see _artMapIsLiveSize). + _artMap._liveBuildZoom = _artMap.zoom; octx.scale(scale, scale); octx.translate(-minX, -minY); @@ -5370,6 +5385,7 @@ function _artMapRebuildBuffer() { else if (pass === 2 && !n._isLabel && (n.type === 'watchlist' || n.type === 'center' || n.ring === 1)) { /* draw */ } else continue; if (hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue; + if (_artMapIsLiveSize(n)) continue; // big enough to read → drawn live on the overlay _artMapDrawNodeToBuffer(octx, n, scale); } } @@ -5499,6 +5515,10 @@ function _artMapCompositeNode(n) { if (!oc || scale == null) return false; if (_artMap._hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) return false; if ((n.opacity || 0) < 0.01) return false; + // Live-layer bubbles aren't in the buffer — they read their image fresh each + // frame, so just signal "blit" and let the overlay pick it up. Compositing + // them here would double-draw (buffer copy + live copy). + if (_artMapIsLiveSize(n)) return true; const octx = oc.getContext('2d'); octx.save(); octx.scale(scale, scale); @@ -5509,6 +5529,151 @@ function _artMapCompositeNode(n) { return true; } +// A node renders on the live overlay when it's big enough on screen to read. +// Labels stay baked in the static buffer (no per-frame motion needed in v2). +// IMPORTANT: this uses the zoom the BUFFER WAS BUILT AT (_liveBuildZoom), not +// the live zoom. The buffer only rebuilds ~300ms after zooming stops, so the +// live/buffer split must stay frozen to whatever the (possibly stale) buffer +// excluded — otherwise a bubble could fall out of both during an active zoom +// and flicker. Both the buffer-exclude and the live-draw read this same value, +// so the two sets are always exact complements. +function _artMapIsLiveSize(n) { + if (n._isLabel) return false; + const z = _artMap._liveBuildZoom || _artMap.zoom; + return (n.radius || 0) * z >= _artMap.LIVE_PX; +} + +// Draw the live overlay: every big/near bubble, in world space, honouring its +// per-node animation transform (aScale for reveal/pop, opacity for fade). Kept +// cheap by viewport culling + a hard cap; the static far field is already on +// screen via the buffer blit. +function _artMapDrawLiveLayer(ctx) { + const placed = _artMap.placed; + if (!placed || !placed.length) return; + const z = _artMap.zoom; + const ox = _artMap.offsetX, oy = _artMap.offsetY; + const w = _artMap.width, h = _artMap.height; + const margin = 80; + + ctx.save(); + ctx.translate(ox, oy); + ctx.scale(z, z); + + let drawn = 0; + const CAP = 600; + for (const n of placed) { + if (!_artMapIsLiveSize(n)) continue; + if (_artMap._hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue; + // Viewport cull (screen space) + const sx = ox + n.x * z, sy = oy + n.y * z; + const rPx = (n.radius || 0) * z; + if (sx + rPx < -margin || sx - rPx > w + margin || sy + rPx < -margin || sy - rPx > h + margin) continue; + _artMapDrawLiveNode(ctx, n); + if (++drawn >= CAP) break; + } + ctx.restore(); + ctx.globalAlpha = 1; +} + +// Draw one live bubble with its animation transform. aScale scales about the +// node centre; opacity fades it. Reuses the shared node painter so the live +// bubble is pixel-identical to its baked form once settled. +function _artMapDrawLiveNode(ctx, n) { + const sc = n.aScale == null ? 1 : n.aScale; + if (sc <= 0.001) return; + if (sc !== 1) { + ctx.save(); + ctx.translate(n.x, n.y); + ctx.scale(sc, sc); + ctx.translate(-n.x, -n.y); + _artMapDrawNodeToBuffer(ctx, n, _artMap.zoom); + ctx.restore(); + } else { + _artMapDrawNodeToBuffer(ctx, n, _artMap.zoom); + } + ctx.globalAlpha = 1; +} + +// ── Animation loop ──────────────────────────────────────────────────────── +// Runs only while something is animating; idles otherwise. Each tick advances +// the active animations (reveal field-fade + per-node pop), draws one frame, +// and re-arms itself only if work remains — so a still map costs nothing. +function _artMapStartLoop() { + const a = _artMap._anim; + if (a.running) return; + a.running = true; + a.last = performance.now(); + const tick = (t) => { + if (!a.running) return; + const more = _artMapStepAnimations(t); + _artMapDraw(); + if (more) { + a.raf = requestAnimationFrame(tick); + } else { + a.running = false; + a.raf = null; + } + }; + a.raf = requestAnimationFrame(tick); +} + +// Advance every active animation by absolute time t (ms). Returns true while +// anything is still moving. Phase A: global field fade-in + staggered per-node +// reveal pop (ease-out-back) for the live bubbles. +function _artMapStepAnimations(t) { + let active = false; + + // Global far-field fade-in over the reveal window. + if (_artMap._fieldAlpha < 1) { + const FIELD_MS = 700; + const p = Math.min(1, (t - _artMap._revealT0) / FIELD_MS); + _artMap._fieldAlpha = p; + if (p < 1) active = true; + } + + // Per-node reveal pop: each node eases its aScale 0→1 once past its + // staggered start time. Ease-out-back gives a subtle overshoot ("bloom"). + const placed = _artMap.placed; + if (placed) { + for (const n of placed) { + if (n.aScale == null || n.aScale >= 1) continue; + const start = n._revealAt || _artMap._revealT0; + if (t < start) { active = true; continue; } + const DUR = 520; + const p = Math.min(1, (t - start) / DUR); + // ease-out-back + const c1 = 1.70158, c3 = c1 + 1; + const e = 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2); + n.aScale = p >= 1 ? 1 : e; + if (p < 1) active = true; + } + } + + return active; +} + +// Kick off the reveal: fade the far field in, and stagger each live bubble's +// pop by its distance from the camera centre so islands bloom outward. +function _artMapBeginReveal() { + const t0 = performance.now(); + _artMap._revealT0 = t0; + _artMap._fieldAlpha = 0; + const cx = (_artMap.width / 2 - _artMap.offsetX) / _artMap.zoom; + const cy = (_artMap.height / 2 - _artMap.offsetY) / _artMap.zoom; + let maxD = 1; + for (const n of _artMap.placed) { + n._d2 = (n.x - cx) * (n.x - cx) + (n.y - cy) * (n.y - cy); + if (n._d2 > maxD) maxD = n._d2; + } + const maxD2 = Math.sqrt(maxD) || 1; + for (const n of _artMap.placed) { + n.aScale = 0; + const d = Math.sqrt(n._d2); + n._revealAt = t0 + (d / maxD2) * 520; // farther = later → outward bloom + } + _artMapStartLoop(); +} + 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. @@ -5560,12 +5725,20 @@ function _artMapDraw() { // Screen position of world (wx,wy) = offsetX + wx*zoom, offsetY + wy*zoom // Therefore buffer origin on screen = offsetX + minX*zoom, offsetY + minY*zoom // And buffer is drawn at size (bufferWidth * zoom/s, bufferHeight * zoom/s) + const fieldAlpha = _artMap._fieldAlpha == null ? 1 : _artMap._fieldAlpha; + if (fieldAlpha < 0.999) ctx.globalAlpha = fieldAlpha; ctx.drawImage(oc, _artMap.offsetX + mx * z, _artMap.offsetY + my * z, oc.width * z / s, oc.height * z / s ); + ctx.globalAlpha = 1; + + // ── Live overlay layer: redraw the big/near bubbles every frame so they can + // scale (reveal/pop), bob and ripple. These are excluded from the static + // buffer above, so there's no double-draw. Viewport-culled + capped. ── + _artMapDrawLiveLayer(ctx); // ── Interactive overlay (drawn on main canvas, not buffer) ── const cFade = _artMap._constellationFade || 0; @@ -6253,7 +6426,7 @@ async function _openGenreMapWithSelection(selectedGenre) { if (le) le.remove(); _artMap.dirty = true; - _artMapRender(); + _artMapBeginReveal(); // Stream images in throttled waves — interactive immediately, sharpens in place. _artMapStreamImages(_artMap.placed.filter(n => !n._isLabel)); @@ -6463,7 +6636,7 @@ async function _openArtistMapExplorerWithName(name) { if (le) le.remove(); _artMap.dirty = true; - _artMapRender(); + _artMapBeginReveal(); // Stream images in throttled waves — interactive immediately, sharpens in place. _artMapStreamImages(_artMap.placed); From 0f63024d4b5242f115a341c05db07515ae4cb11f Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 09:37:17 -0700 Subject: [PATCH 12/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=20A?= =?UTF-8?q?.1:=20pre-masked=20circular=20images,=20consistent=20art,=20cle?= =?UTF-8?q?an=20reveal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the screenshot feedback (mix of detailed covers + blank dots, lag, 'weird' load): - Pre-mask each album image into a circle ONCE at load (a canvas), so every draw is a plain drawImage instead of a per-frame ctx.clip(). Clipping was the live-layer stutter — hundreds of clips per frame. Now free. - Draw album art at nearly every on-screen size (only sub-2.2px fall back to a dot), instead of detailed-vs-blank-dot tiers. Consistent 'sea of covers'. - Reveal is now a clean ease-out-cubic fade of the whole map (buffer blit + live layer ramp together via _drawAlphaMul) — dropped the bouncy per-node pop that read as 'weird'. The real island ripple bloom comes in Phase C. 64 JS integrity tests pass. --- webui/static/discover.js | 170 ++++++++++++++++++--------------------- 1 file changed, 79 insertions(+), 91 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 41ce56d7..7cfe0091 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5350,6 +5350,7 @@ function _artMapRebuildBuffer() { _artMap._bufferMinY = minY; // Freeze the live/buffer partition to this build's zoom (see _artMapIsLiveSize). _artMap._liveBuildZoom = _artMap.zoom; + _artMap._drawAlphaMul = 1; // buffer bakes at full alpha; the blit applies the reveal fade octx.scale(scale, scale); octx.translate(-minX, -minY); @@ -5403,11 +5404,14 @@ function _artMapDrawNodeToBuffer(octx, n, scale) { if (op < 0.01) return; const r = n.radius; const isW = n.type === 'watchlist' || n.type === 'center'; - octx.globalAlpha = op; + // Global fade multiplier (reveal). Lets the whole map fade in cleanly while + // each painter keeps its own per-element alpha. + const mul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul; + octx.globalAlpha = op * mul; // Genre label node — transparent circle with large text if (n._isLabel) { - octx.globalAlpha = 0.6; + octx.globalAlpha = 0.6 * mul; octx.beginPath(); octx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); octx.fillStyle = 'rgba(138,43,226,0.04)'; @@ -5428,13 +5432,14 @@ function _artMapDrawNodeToBuffer(octx, n, scale) { return; } - // Render quality based on node size in buffer pixels + // On-screen size drives detail. Album art shows at nearly every size (the + // images are pre-masked to circles, so this is just a cheap drawImage — no + // per-frame clip) for a consistent "sea of covers" look. Only the very + // smallest fall back to a coloured dot. const rScaled = r * scale; - const isSmall = rScaled < 8; - const isTiny = rScaled < 3; + const img = _artMap.images[n.id]; - // Tiny nodes: just a colored dot (no clip, no image, no text) - if (isTiny) { + if (rScaled < 2.2) { octx.beginPath(); octx.arc(n.x, n.y, r, 0, Math.PI * 2); octx.fillStyle = isW ? '#6b21a8' : '#2a2a40'; @@ -5442,27 +5447,8 @@ function _artMapDrawNodeToBuffer(octx, n, scale) { return; } - // Small nodes: filled circle + border, no image clip - if (isSmall) { - octx.beginPath(); - octx.arc(n.x, n.y, r, 0, Math.PI * 2); - const img = _artMap.images[n.id]; - if (img) { - octx.save(); octx.clip(); - octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2); - octx.restore(); - } else { - octx.fillStyle = isW ? '#1a0a30' : '#141420'; - octx.fill(); - } - octx.strokeStyle = isW ? 'rgba(138,43,226,0.3)' : 'rgba(255,255,255,0.06)'; - octx.lineWidth = isW ? 1.5 : 0.5; - octx.stroke(); - return; - } - - // Full quality: glow + clip + image + text - if (isW) { + // Focal glow ring for watchlist/center bubbles + if (isW && rScaled >= 7) { octx.beginPath(); octx.arc(n.x, n.y, r + 4, 0, Math.PI * 2); octx.strokeStyle = 'rgba(138,43,226,0.25)'; @@ -5470,37 +5456,43 @@ function _artMapDrawNodeToBuffer(octx, n, scale) { octx.stroke(); } - octx.save(); - octx.beginPath(); - octx.arc(n.x, n.y, r, 0, Math.PI * 2); - octx.closePath(); - octx.clip(); - - const img = _artMap.images[n.id]; + // Body — pre-masked circular image (no clip) or a placeholder disc. if (img) { octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2); - octx.fillStyle = 'rgba(0,0,0,0.45)'; - octx.fillRect(n.x - r, n.y - r, r * 2, r * 2); } else { + octx.beginPath(); + octx.arc(n.x, n.y, r, 0, Math.PI * 2); octx.fillStyle = isW ? '#1a0a30' : '#141420'; - octx.fillRect(n.x - r, n.y - r, r * 2, r * 2); + octx.fill(); } - octx.restore(); + const showLabel = rScaled >= 13; + + // Darken art behind the label so the name stays legible. + if (showLabel && img) { + octx.beginPath(); + octx.arc(n.x, n.y, r, 0, Math.PI * 2); + octx.fillStyle = 'rgba(0,0,0,0.42)'; + octx.fill(); + } + + // Border octx.beginPath(); octx.arc(n.x, n.y, r, 0, Math.PI * 2); - octx.strokeStyle = isW ? 'rgba(138,43,226,0.4)' : 'rgba(255,255,255,0.08)'; - octx.lineWidth = isW ? 2 : 0.5; + octx.strokeStyle = isW ? 'rgba(138,43,226,0.45)' : 'rgba(255,255,255,0.10)'; + octx.lineWidth = isW ? 2 : (rScaled >= 7 ? 1 : 0.5); octx.stroke(); - const fontSize = isW ? Math.max(16, r * 0.14) : Math.max(8, r * 0.3); - octx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`; - octx.textAlign = 'center'; - octx.textBaseline = 'middle'; - octx.fillStyle = '#fff'; - const maxC = isW ? 20 : 12; - const label = n.name.length > maxC ? n.name.substring(0, maxC - 1) + '…' : n.name; - octx.fillText(label, n.x, n.y); + if (showLabel) { + const fontSize = isW ? Math.max(16, r * 0.14) : Math.max(8, r * 0.3); + octx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`; + octx.textAlign = 'center'; + octx.textBaseline = 'middle'; + octx.fillStyle = '#fff'; + const maxC = isW ? 20 : 12; + const label = n.name.length > maxC ? n.name.substring(0, maxC - 1) + '…' : n.name; + octx.fillText(label, n.x, n.y); + } } // Composite ONE node into the EXISTING buffer without a full rebuild. This is @@ -5558,6 +5550,8 @@ function _artMapDrawLiveLayer(ctx) { ctx.save(); ctx.translate(ox, oy); ctx.scale(z, z); + // The live layer fades in together with the far field during the reveal. + _artMap._drawAlphaMul = _artMap._fieldAlpha == null ? 1 : _artMap._fieldAlpha; let drawn = 0; const CAP = 600; @@ -5571,6 +5565,7 @@ function _artMapDrawLiveLayer(ctx) { _artMapDrawLiveNode(ctx, n); if (++drawn >= CAP) break; } + _artMap._drawAlphaMul = 1; ctx.restore(); ctx.globalAlpha = 1; } @@ -5618,59 +5613,26 @@ function _artMapStartLoop() { } // Advance every active animation by absolute time t (ms). Returns true while -// anything is still moving. Phase A: global field fade-in + staggered per-node -// reveal pop (ease-out-back) for the live bubbles. +// anything is still moving. Phase A: a clean global fade-in (ease-out-cubic). +// The bubbly per-island ripple bloom is layered on in Phase C. function _artMapStepAnimations(t) { let active = false; - // Global far-field fade-in over the reveal window. if (_artMap._fieldAlpha < 1) { - const FIELD_MS = 700; + const FIELD_MS = 600; const p = Math.min(1, (t - _artMap._revealT0) / FIELD_MS); - _artMap._fieldAlpha = p; + _artMap._fieldAlpha = 1 - Math.pow(1 - p, 3); // ease-out-cubic if (p < 1) active = true; } - // Per-node reveal pop: each node eases its aScale 0→1 once past its - // staggered start time. Ease-out-back gives a subtle overshoot ("bloom"). - const placed = _artMap.placed; - if (placed) { - for (const n of placed) { - if (n.aScale == null || n.aScale >= 1) continue; - const start = n._revealAt || _artMap._revealT0; - if (t < start) { active = true; continue; } - const DUR = 520; - const p = Math.min(1, (t - start) / DUR); - // ease-out-back - const c1 = 1.70158, c3 = c1 + 1; - const e = 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2); - n.aScale = p >= 1 ? 1 : e; - if (p < 1) active = true; - } - } - return active; } -// Kick off the reveal: fade the far field in, and stagger each live bubble's -// pop by its distance from the camera centre so islands bloom outward. +// Kick off the reveal: a clean fade-in of the whole map (far field via the +// buffer-blit alpha, live bubbles via _drawAlphaMul — they ramp together). function _artMapBeginReveal() { - const t0 = performance.now(); - _artMap._revealT0 = t0; + _artMap._revealT0 = performance.now(); _artMap._fieldAlpha = 0; - const cx = (_artMap.width / 2 - _artMap.offsetX) / _artMap.zoom; - const cy = (_artMap.height / 2 - _artMap.offsetY) / _artMap.zoom; - let maxD = 1; - for (const n of _artMap.placed) { - n._d2 = (n.x - cx) * (n.x - cx) + (n.y - cy) * (n.y - cy); - if (n._d2 > maxD) maxD = n._d2; - } - const maxD2 = Math.sqrt(maxD) || 1; - for (const n of _artMap.placed) { - n.aScale = 0; - const d = Math.sqrt(n._d2); - n._revealAt = t0 + (d / maxD2) * 520; // farther = later → outward bloom - } _artMapStartLoop(); } @@ -6778,9 +6740,35 @@ function _artMapDecodeSmall(blob, px) { try { return createImageBitmap(blob, { resizeWidth: d, resizeHeight: d, resizeQuality: 'high', - }).catch(() => createImageBitmap(blob).catch(() => null)); // older engines ignore opts + }).then(_artMapCircleMask) + .catch(() => createImageBitmap(blob).then(_artMapCircleMask).catch(() => null)); } catch (e) { - return createImageBitmap(blob).catch(() => null); + return createImageBitmap(blob).then(_artMapCircleMask).catch(() => null); + } +} + +// Pre-mask a decoded bitmap into a CIRCLE once, at load time, returning a +// canvas. The whole map then draws bubbles with a plain drawImage (the canvas +// is already round) instead of a per-frame ctx.clip() per bubble — clipping is +// one of the most expensive canvas ops and, at hundreds of visible bubbles per +// frame, was the live-layer stutter. Done once here, it's free forever after. +function _artMapCircleMask(src) { + if (!src) return null; + const w = src.width || 0; + if (!w) return src; + try { + const c = document.createElement('canvas'); + c.width = w; c.height = w; + const cx = c.getContext('2d'); + cx.beginPath(); + cx.arc(w / 2, w / 2, w / 2, 0, Math.PI * 2); + cx.closePath(); + cx.clip(); + cx.drawImage(src, 0, 0, w, w); + if (src.close) src.close(); // free the ImageBitmap; we keep the canvas + return c; + } catch (e) { + return src; // fall back to the raw bitmap (draw path still clips defensively) } } From f311ff0440beb2427832e802bf769dbe2c823d13 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 09:48:18 -0700 Subject: [PATCH 13/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=20B?= =?UTF-8?q?:=20genre=20islands=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three maps (watchlist / genre / explore) now lay out as genre 'islands' on the water via one shared engine (_artMapLayoutIslands): - Group artists by primary genre (long tail folds into 'Other'; max 14 islands). - Each island is a FILLED disc of covers packed centre-out (no empty donut hole), most-popular nearest the middle, focal artists sized up + centre-most. - Islands spread by golden spiral + push-apart with generous water between. - Clean floating genre TITLE above each island (hue-tinted, glow) instead of the old giant translucent label bubble. - Per-genre accent hue tints member-bubble borders so clusters read as a family. - Discovery edges (watchlist→similar, center→ring1→ring2) remapped to the new node ids so the hover constellation still works across islands. Replaces the per-artist donut clusters from the screenshots. Shared helpers: _artMapGroupByGenre, _artMapPackDisc, _artMapRemapEdges, _artMapFitToContent. 64 JS integrity tests pass. --- webui/static/discover.js | 617 +++++++++++++-------------------------- 1 file changed, 207 insertions(+), 410 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 7cfe0091..e097d773 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5027,6 +5027,164 @@ const _artMap = { _revealT0: 0, // performance.now() when the current reveal began }; +// ── Genre-island layout (shared by watchlist / genre / explore) ─────────── +// Every map groups its artists into genre "islands" floating on the water: +// each island is a FILLED disc of album covers (packed centre-out, no empty +// donut hole), with a floating genre title above it and a per-genre accent hue. +// Islands are spread by a golden spiral + push-apart with generous spacing. + +// Deterministic hue (0–360) from a genre name, so each island has a stable tint. +function _artMapGenreHue(name) { + let h = 0; + const s = (name || '').toLowerCase(); + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; + return h; +} + +// Pack members into a filled disc, centre outward. Returns relative offsets and +// the disc radius. Most-popular members land nearest the centre. +function _artMapPackDisc(members, nodeR, gap) { + const placements = []; + if (!members.length) return { placements, islandR: nodeR }; + placements.push({ node: members[0], dx: 0, dy: 0 }); + let idx = 1, ring = 1, ringDist = nodeR * 2 + gap; + const step = nodeR * 2 + gap; + while (idx < members.length) { + const circ = 2 * Math.PI * ringDist; + const cap = Math.max(1, Math.floor(circ / step)); + const cnt = Math.min(cap, members.length - idx); + const aStep = (2 * Math.PI) / cnt; + const off = ring * 2.399963; // golden offset per ring → no spokes + for (let i = 0; i < cnt; i++) { + const a = off + i * aStep; + placements.push({ node: members[idx + i], dx: Math.cos(a) * ringDist, dy: Math.sin(a) * ringDist }); + } + idx += cnt; + ringDist += step; + ring++; + } + return { placements, islandR: ringDist }; +} + +// Group flat nodes by primary genre into {name, count, nodes[]}, largest first. +// Nodes with no genre fall into "Other". Focal nodes (watchlist/center) keep +// their flag so the layout can size them up. +function _artMapGroupByGenre(nodes, maxIslands = 14) { + const byGenre = {}; + for (const n of nodes) { + const g = (n.genres && n.genres.length) ? String(n.genres[0]) : 'Other'; + const key = g.replace(/\b\w/g, c => c.toUpperCase()); + (byGenre[key] = byGenre[key] || []).push(n); + } + let groups = Object.keys(byGenre).map(name => ({ name, nodes: byGenre[name], count: byGenre[name].length })); + groups.sort((a, b) => b.count - a.count); + if (groups.length > maxIslands) { + // Fold the long tail of tiny genres into one "Other" island. + const head = groups.slice(0, maxIslands - 1); + const tail = groups.slice(maxIslands - 1); + const tailNodes = tail.flatMap(g => g.nodes); + head.push({ name: 'Other', nodes: tailNodes, count: tailNodes.length }); + groups = head; + } + return groups; +} + +// Lay out islands → fills _artMap.placed and _artMap._islands. groups is an +// array of {name, count, nodes[]} where each node has name/image_url/genres/ids. +function _artMapLayoutIslands(groups, opts = {}) { + _artMap.placed = []; + _artMap._islands = []; + const nodeR = opts.nodeR || (_artMap.WATCHLIST_R * 0.22); + const gap = opts.gap || (_artMap.BUFFER * 2.2); + const cap = opts.maxPerIsland || 500; + let pid = 0; + + const islands = groups.map(g => { + const members = g.nodes.slice() + .sort((a, b) => (b._focal ? 1 : 0) - (a._focal ? 1 : 0) || (b.popularity || 0) - (a.popularity || 0)) + .slice(0, cap); + const { placements, islandR } = _artMapPackDisc(members, nodeR, gap); + return { name: g.name, count: g.count != null ? g.count : members.length, placements, islandR, hue: _artMapGenreHue(g.name) }; + }); + + // Golden-spiral seed placement + islands.forEach((isl, i) => { + if (i === 0) { isl.cx = 0; isl.cy = 0; } + else { const a = i * 2.399963; const r = isl.islandR * Math.sqrt(i) * 1.05; isl.cx = Math.cos(a) * r; isl.cy = Math.sin(a) * r; } + }); + // Push apart — generous water between islands + for (let pass = 0; pass < 160; pass++) { + let moved = false; + for (let i = 0; i < islands.length; i++) { + for (let j = i + 1; j < islands.length; j++) { + const dx = islands[j].cx - islands[i].cx, dy = islands[j].cy - islands[i].cy; + const dist = Math.hypot(dx, dy) || 1; + const minD = islands[i].islandR + islands[j].islandR + nodeR * 7; + if (dist < minD) { + const push = (minD - dist) / 2 + 1; + islands[i].cx -= dx / dist * push; islands[i].cy -= dy / dist * push; + islands[j].cx += dx / dist * push; islands[j].cy += dy / dist * push; + moved = true; + } + } + } + if (!moved) break; + } + + for (const isl of islands) { + // Floating genre title above the island + _artMap.placed.push({ + id: `label_${isl.name}`, name: isl.name, x: isl.cx, y: isl.cy - isl.islandR - nodeR * 1.4, + radius: Math.max(nodeR * 1.3, isl.islandR * 0.16), opacity: 1, + type: 'genre_label', _isLabel: true, _count: isl.count, _hue: isl.hue, image_url: '', + }); + for (const p of isl.placements) { + const n = p.node; + _artMap.placed.push({ + id: pid++, _origId: n.id, name: n.name, + x: isl.cx + p.dx, y: isl.cy + p.dy, + radius: nodeR * (n._focal ? 1.45 : 1), opacity: 1, + type: n.type || 'similar', + image_url: n.image_url || '', genres: n.genres || [], + spotify_id: n.spotify_id || '', itunes_id: n.itunes_id || '', + deezer_id: n.deezer_id || '', discogs_id: n.discogs_id || '', + musicbrainz_id: n.musicbrainz_id || '', + popularity: n.popularity || 0, _hue: isl.hue, _island: isl.name, + }); + } + _artMap._islands.push({ name: isl.name, cx: isl.cx, cy: isl.cy, r: isl.islandR, hue: isl.hue, count: isl.count }); + } + + _artMap._nodeById = {}; + _artMap.placed.forEach(n => { _artMap._nodeById[n.id] = n; }); +} + +// Remap edges that used original node ids to the new placed-node ids. +function _artMapRemapEdges(edges) { + const map = {}; + for (const n of _artMap.placed) if (n._origId != null && map[n._origId] == null) map[n._origId] = n.id; + const out = []; + for (const e of (edges || [])) { + const s = map[e.source], t = map[e.target]; + if (s != null && t != null && s !== t) out.push({ source: s, target: t, weight: e.weight || 1 }); + } + return out; +} + +// Auto-zoom/pan so all placed nodes fit the viewport with a margin. +function _artMapFitToContent(marginPx = 120) { + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const n of _artMap.placed) { + minX = Math.min(minX, n.x - n.radius); maxX = Math.max(maxX, n.x + n.radius); + minY = Math.min(minY, n.y - n.radius); maxY = Math.max(maxY, n.y + n.radius); + } + if (!isFinite(minX)) return; + const mapW = maxX - minX + marginPx * 2, mapH = maxY - minY + marginPx * 2; + _artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); + _artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom; + _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; +} + async function openArtistMap() { const container = document.getElementById('artist-map-container'); if (!container) return; @@ -5073,141 +5231,15 @@ async function openArtistMap() { document.getElementById('artist-map-stats').textContent = `${data.watchlist_count} watchlist · ${data.similar_count} similar`; - _artMap.edges = data.edges; - const WR = _artMap.WATCHLIST_R; - const BUF = _artMap.BUFFER; - - // ── PHASE 1: Place watchlist artists with guaranteed no-overlap ── - const wNodes = data.nodes.filter(n => n.type === 'watchlist'); - // Minimum center-to-center distance between watchlist nodes - const minCenterDist = WR * 3.5; // WR*2 for radii + WR*1.5 gap — similar artists fill the gaps via spiral packing - - // Place watchlist nodes in a spiral — deterministic, guaranteed spacing - wNodes.forEach((n, i) => { - n.radius = WR; - n.opacity = 0; - if (i === 0) { - n.x = 0; n.y = 0; - } else { - // Golden angle spiral for even distribution - const angle = i * 2.399963; // golden angle in radians - const r = minCenterDist * Math.sqrt(i) * 0.7; - n.x = Math.cos(angle) * r; - n.y = Math.sin(angle) * r; - } - }); - - // Post-process: push apart any watchlist nodes that ended up too close - for (let pass = 0; pass < 50; pass++) { - let moved = false; - for (let i = 0; i < wNodes.length; i++) { - for (let j = i + 1; j < wNodes.length; j++) { - const dx = wNodes[j].x - wNodes[i].x; - const dy = wNodes[j].y - wNodes[i].y; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - if (dist < minCenterDist) { - const push = (minCenterDist - dist) / 2 + 1; - const nx = (dx / dist) * push; - const ny = (dy / dist) * push; - wNodes[i].x -= nx; wNodes[i].y -= ny; - wNodes[j].x += nx; wNodes[j].y += ny; - moved = true; - } - } - } - if (!moved) break; - } - - wNodes.forEach(n => { _artMap.placed.push(n); }); - - // ── PHASE 2: Place similar artists around their source watchlist nodes ── - const sNodes = data.nodes.filter(n => n.type === 'similar'); - sNodes.forEach(n => { - const occ = n.occurrence || 1; - const rank = n.rank || 5; - // Bigger overall: min 25% of WR, max 55%, scaled by relevance - n.radius = Math.min(WR * 0.55, Math.max(WR * 0.25, WR * 0.2 + occ * WR * 0.06 + (10 - rank) * WR * 0.025)); - }); - sNodes.sort((a, b) => b.radius - a.radius); - - // Build edge lookup: target_id → source node (O(1) instead of .find()) - const edgeMap = {}; - _artMap.edges.forEach(e => { edgeMap[e.target] = e.source; }); - const nodeById = {}; - _artMap.placed.forEach(n => { nodeById[n.id] = n; }); - - // Spatial grid for fast collision detection - // Cell size must cover the largest possible bubble diameter + buffer - const CELL = WR * 2 + BUF * 2; - const grid = {}; - function _gridKey(x, y) { return `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`; } - function _gridAdd(n) { - const k = _gridKey(n.x, n.y); - if (!grid[k]) grid[k] = []; - grid[k].push(n); - } - function _gridCheck(x, y, r) { - const cx = Math.floor(x / CELL); - const cy = Math.floor(y / CELL); - // Search wider radius to catch large watchlist bubbles - for (let dx = -3; dx <= 3; dx++) { - for (let dy = -3; dy <= 3; dy++) { - const cell = grid[`${cx + dx},${cy + dy}`]; - if (!cell) continue; - for (const p of cell) { - const ddx = x - p.x, ddy = y - p.y; - const minD = r + p.radius + BUF; - if (ddx * ddx + ddy * ddy < minD * minD) return true; - } - } - } - return false; - } - // Add watchlist nodes to grid - _artMap.placed.forEach(n => _gridAdd(n)); - - // Place similar nodes with spatial grid collision - for (const sn of sNodes) { - const srcId = edgeMap[sn.id]; - const src = srcId != null ? nodeById[srcId] : null; - const cx = src ? src.x : 0; - const cy = src ? src.y : 0; - const startDist = (src ? src.radius : WR) + sn.radius + BUF; - - let placed = false; - for (let dist = startDist; dist < startDist + WR * 3; dist += sn.radius * 0.5) { - const steps = Math.max(8, Math.floor(dist * 0.1)); - const off = Math.random() * Math.PI * 2; - for (let a = 0; a < steps; a++) { - const angle = off + (a / steps) * Math.PI * 2; - const tx = cx + Math.cos(angle) * dist; - const ty = cy + Math.sin(angle) * dist; - if (!_gridCheck(tx, ty, sn.radius)) { - sn.x = tx; sn.y = ty; sn.opacity = 0; - _artMap.placed.push(sn); - nodeById[sn.id] = sn; - _gridAdd(sn); - placed = true; - break; - } - } - if (placed) break; - } - } - - // Auto-zoom to fit all nodes - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - _artMap.placed.forEach(n => { - minX = Math.min(minX, n.x - n.radius); - maxX = Math.max(maxX, n.x + n.radius); - minY = Math.min(minY, n.y - n.radius); - maxY = Math.max(maxY, n.y + n.radius); - }); - const mapW = maxX - minX + 200; - const mapH = maxY - minY + 200; - _artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); - _artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom; - _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; + // Group every watchlist + similar artist into genre islands. Watchlist + // artists are focal (sit centre-most + sized up). The discovery edges + // (watchlist → similar) are remapped to the new node ids so the hover + // constellation still shows who's related across islands. + const rawNodes = data.nodes.map(n => ({ ...n, _focal: n.type === 'watchlist' })); + const groups = _artMapGroupByGenre(rawNodes); + _artMapLayoutIslands(groups); + _artMap.edges = _artMapRemapEdges(data.edges); + _artMapFitToContent(); // Setup interaction _artMapSetupInteraction(canvas); @@ -5409,25 +5441,26 @@ function _artMapDrawNodeToBuffer(octx, n, scale) { const mul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul; octx.globalAlpha = op * mul; - // Genre label node — transparent circle with large text + // Genre title — a clean floating label above its island (no big bubble). if (n._isLabel) { - octx.globalAlpha = 0.6 * mul; - octx.beginPath(); - octx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); - octx.fillStyle = 'rgba(138,43,226,0.04)'; - octx.fill(); - octx.strokeStyle = 'rgba(138,43,226,0.08)'; - octx.lineWidth = 1; - octx.stroke(); - const labelSize = Math.max(12, n.radius * 0.25); - octx.font = `800 ${labelSize}px system-ui`; + const hue = n._hue == null ? 270 : n._hue; + const titleSize = Math.max(13, n.radius * 0.42); + const name = (n.name || '').toUpperCase(); octx.textAlign = 'center'; octx.textBaseline = 'middle'; - octx.fillStyle = 'rgba(138,43,226,0.35)'; - octx.fillText(n.name, n.x, n.y - labelSize * 0.3); - octx.font = `500 ${labelSize * 0.5}px system-ui`; - octx.fillStyle = 'rgba(255,255,255,0.15)'; - octx.fillText(`${n._count || 0} artists`, n.x, n.y + labelSize * 0.5); + // Soft glow behind the title for legibility over the water. + octx.globalAlpha = mul; + octx.font = `800 ${titleSize}px system-ui, sans-serif`; + octx.shadowColor = `hsla(${hue},70%,12%,0.9)`; + octx.shadowBlur = titleSize * 0.6; + octx.fillStyle = `hsla(${hue},85%,82%,0.96)`; + octx.fillText(name, n.x, n.y); + octx.shadowBlur = 0; + // Count subtitle + octx.globalAlpha = 0.55 * mul; + octx.font = `600 ${titleSize * 0.42}px system-ui, sans-serif`; + octx.fillStyle = 'rgba(255,255,255,0.7)'; + octx.fillText(`${n._count || 0} artists`, n.x, n.y + titleSize * 0.85); octx.globalAlpha = 1; return; } @@ -5476,10 +5509,12 @@ function _artMapDrawNodeToBuffer(octx, n, scale) { octx.fill(); } - // Border + // Border — tinted with the island's genre hue so clusters read as a family. octx.beginPath(); octx.arc(n.x, n.y, r, 0, Math.PI * 2); - octx.strokeStyle = isW ? 'rgba(138,43,226,0.45)' : 'rgba(255,255,255,0.10)'; + if (isW) octx.strokeStyle = 'rgba(138,43,226,0.5)'; + else if (n._hue != null) octx.strokeStyle = `hsla(${n._hue},70%,70%,0.22)`; + else octx.strokeStyle = 'rgba(255,255,255,0.10)'; octx.lineWidth = isW ? 2 : (rScaled >= 7 ? 1 : 0.5); octx.stroke(); @@ -6230,154 +6265,17 @@ async function _openGenreMapWithSelection(selectedGenre) { document.getElementById('artist-map-stats').innerHTML = `${escapeHtml(selectedGenre)} ▾ · ${genres.length} genre${genres.length > 1 ? 's' : ''} · ${totalArtists} artists`; - const WR = _artMap.WATCHLIST_R; - const BUF = _artMap.BUFFER; - - const maxPerGenre = 500; - const nodeR = WR * 0.2; - - // Calculate actual cluster radius for each genre based on ring count - function getClusterRadius(artistCount) { - const count = Math.min(artistCount, maxPerGenre); - let ringDist = WR + nodeR * 2 + BUF; - let placed = 0; - while (placed < count) { - const circ = 2 * Math.PI * ringDist; - const inRing = Math.max(1, Math.floor(circ / (nodeR * 2 + BUF))); - placed += Math.min(inRing, count - placed); - ringDist += nodeR * 2 + BUF; - } - return ringDist; - } - - // Pre-compute cluster radii - genres.forEach(g => { g._clusterR = getClusterRadius(g.artist_ids.length); }); - - // Golden spiral placement - genres.forEach((g, i) => { - if (i === 0) { g._cx = 0; g._cy = 0; } - else { - const angle = i * 2.399963; - const r = g._clusterR * Math.sqrt(i) * 0.9; - g._cx = Math.cos(angle) * r; - g._cy = Math.sin(angle) * r; - } - }); - - // Push apart using actual cluster radii — no overlap possible - for (let pass = 0; pass < 80; pass++) { - let moved = false; - for (let i = 0; i < genres.length; i++) { - for (let j = i + 1; j < genres.length; j++) { - const dx = genres[j]._cx - genres[i]._cx; - const dy = genres[j]._cy - genres[i]._cy; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - const minDist = genres[i]._clusterR + genres[j]._clusterR + BUF * 4; - if (dist < minDist) { - const push = (minDist - dist) / 2 + 1; - genres[i]._cx -= (dx / dist) * push; genres[i]._cy -= (dy / dist) * push; - genres[j]._cx += (dx / dist) * push; genres[j]._cy += (dy / dist) * push; - moved = true; - } - } - } - if (!moved) break; - } - - let placedCount = 0; - - // Place genre labels as big watchlist-style bubbles - for (const g of genres) { - _artMap.placed.push({ - id: `genre_${g.name}`, name: g.name.toUpperCase(), - x: g._cx, y: g._cy, radius: WR, opacity: 1, - type: 'genre_label', image_url: '', genres: [g.name], - _isLabel: true, _count: g.count - }); - } - - // Place artists in concentric rings — deterministic O(1) per node, handles 10K+ instantly - let genreIdx = 0; - - async function placeGenreArtists() { - for (; genreIdx < genres.length; genreIdx++) { - const genre = genres[genreIdx]; - const artists = genre.artist_ids.slice(0, maxPerGenre); - const sorted = artists.map(nid => data.nodes[nid]).filter(Boolean).sort((a, b) => (b.popularity || 0) - (a.popularity || 0)); - - let ringDist = WR + nodeR * 2 + BUF; - let ringNum = 0; - let placed = 0; - - while (placed < sorted.length) { - const circumference = 2 * Math.PI * ringDist; - const nodesInRing = Math.max(1, Math.floor(circumference / (nodeR * 2 + BUF))); - const count = Math.min(nodesInRing, sorted.length - placed); - const angleStep = (2 * Math.PI) / nodesInRing; - const angleOffset = ringNum * 0.618; - - for (let i = 0; i < count; i++) { - const n = sorted[placed + i]; - if (!n) continue; - const isW = n.type === 'watchlist' || n.type === 'center'; - const r = isW ? nodeR * 1.5 : nodeR; - const angle = angleOffset + i * angleStep; - - _artMap.placed.push({ - id: placedCount + 1000, _origId: n.id, name: n.name, - x: genre._cx + Math.cos(angle) * ringDist, - y: genre._cy + Math.sin(angle) * ringDist, - radius: r, opacity: 1, - type: isW ? 'watchlist' : 'similar', - image_url: n.image_url || '', genres: n.genres || [], - spotify_id: n.spotify_id || '', itunes_id: n.itunes_id || '', - deezer_id: n.deezer_id || '', discogs_id: n.discogs_id || '', - }); - placedCount++; - } - placed += count; - ringDist += nodeR * 2 + BUF; - ringNum++; - } - - if (loadingText) loadingText.textContent = `Placing artists... ${genreIdx + 1}/${genres.length} genres (${placedCount} placed)`; - if (genreIdx % 5 === 0) await new Promise(r => setTimeout(r, 0)); - } - } - await placeGenreArtists(); - - // Build edges: connect artists that appear in multiple genre clusters + // Build genre-island groups from the selected + related genres and lay + // them out as filled-disc islands on the water (shared engine). + const groups = genres.map(g => ({ + name: g.name, + count: g.count, + nodes: (g.artist_ids || []).map(nid => data.nodes[nid]).filter(Boolean), + })); + _artMapLayoutIslands(groups); _artMap.edges = []; - const artistNodes = {}; - _artMap.placed.forEach(n => { - if (n._origId != null) { - if (!artistNodes[n._origId]) artistNodes[n._origId] = []; - artistNodes[n._origId].push(n.id); - } - }); - Object.values(artistNodes).forEach(ids => { - if (ids.length > 1) { - for (let i = 0; i < ids.length - 1; i++) { - _artMap.edges.push({ source: ids[i], target: ids[i + 1], weight: 5 }); - } - } - }); - - _artMap._nodeById = {}; - _artMap.placed.forEach(n => { _artMap._nodeById[n.id] = n; }); - - // Auto-zoom - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - _artMap.placed.forEach(n => { - minX = Math.min(minX, n.x - n.radius); - maxX = Math.max(maxX, n.x + n.radius); - minY = Math.min(minY, n.y - n.radius); - maxY = Math.max(maxY, n.y + n.radius); - }); - const mapW = maxX - minX + 200, mapH = maxY - minY + 200; - _artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); - _artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom; - _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; + _artMapFitToContent(); + const placedCount = _artMap.placed.filter(n => !n._isLabel).length; _artMapSetupInteraction(canvas); @@ -6477,116 +6375,15 @@ async function _openArtistMapExplorerWithName(name) { document.getElementById('artist-map-stats').textContent = `${data.center} · ${ring1Count} similar · ${ring2Count} extended`; - _artMap.edges = data.edges; - const WR = _artMap.WATCHLIST_R; - const BUF = _artMap.BUFFER; - - // Layout: center node at origin, ring 1 in circle around it, ring 2 around ring 1 - const centerNode = data.nodes[0]; - centerNode.x = 0; centerNode.y = 0; - centerNode.radius = WR * 1.2; // Extra large center - centerNode.opacity = 1; - centerNode.type = 'center'; - _artMap.placed.push(centerNode); - - const CELL = WR * 2 + BUF * 2; - const grid = {}; - function _gk(x, y) { return `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`; } - function _ga(n) { const k = _gk(n.x, n.y); if (!grid[k]) grid[k] = []; grid[k].push(n); } - function _gc(x, y, r) { - const cx = Math.floor(x / CELL), cy = Math.floor(y / CELL); - for (let dx = -3; dx <= 3; dx++) for (let dy = -3; dy <= 3; dy++) { - const cell = grid[`${cx + dx},${cy + dy}`]; - if (!cell) continue; - for (const p of cell) { - const ddx = x - p.x, ddy = y - p.y; - if (ddx * ddx + ddy * ddy < (r + p.radius + BUF) * (r + p.radius + BUF)) return true; - } - } - return false; - } - _ga(centerNode); - - // Place ring 1 in a circle - const ring1 = data.nodes.filter(n => n.ring === 1); - const ring1Dist = WR * 2.5; - ring1.forEach((n, i) => { - const angle = (i / ring1.length) * Math.PI * 2; - const rank = n.rank || 5; - n.radius = WR * 0.4 + (10 - rank) * WR * 0.03; - n.opacity = 1; - - // Find non-colliding position near ideal - let placed = false; - for (let dist = ring1Dist; dist < ring1Dist + WR * 3; dist += n.radius * 0.5) { - for (let ao = 0; ao < 6; ao++) { - const a = angle + (ao * 0.1 * (ao % 2 ? 1 : -1)); - const tx = Math.cos(a) * dist; - const ty = Math.sin(a) * dist; - if (!_gc(tx, ty, n.radius)) { - n.x = tx; n.y = ty; - _artMap.placed.push(n); - _ga(n); - placed = true; - break; - } - } - if (placed) break; - } - }); - - // Place ring 2 around their ring 1 sources - const ring2 = data.nodes.filter(n => n.ring === 2); - const nodeById = {}; - _artMap.placed.forEach(n => { nodeById[n.id] = n; }); - - ring2.forEach(n => { - // Find the ring 1 node that connects to this - const edge = data.edges.find(e => e.target === n.id); - const src = edge ? nodeById[edge.source] : null; - const cx = src ? src.x : 0; - const cy = src ? src.y : 0; - - n.radius = WR * 0.2 + (n.popularity || 0) / 100 * WR * 0.1; - n.opacity = 1; - - const startDist = (src ? src.radius : WR) + n.radius + BUF; - let placed = false; - for (let dist = startDist; dist < startDist + WR * 2; dist += n.radius * 0.5) { - const steps = Math.max(8, Math.floor(dist * 0.08)); - const off = Math.random() * Math.PI * 2; - for (let a = 0; a < steps; a++) { - const angle = off + (a / steps) * Math.PI * 2; - const tx = cx + Math.cos(angle) * dist; - const ty = cy + Math.sin(angle) * dist; - if (!_gc(tx, ty, n.radius)) { - n.x = tx; n.y = ty; - _artMap.placed.push(n); - _ga(n); - placed = true; - break; - } - } - if (placed) break; - } - }); - - // Build node lookup for edges - _artMap._nodeById = {}; - _artMap.placed.forEach(n => { _artMap._nodeById[n.id] = n; }); - - // Auto-zoom - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - _artMap.placed.forEach(n => { - minX = Math.min(minX, n.x - n.radius); - maxX = Math.max(maxX, n.x + n.radius); - minY = Math.min(minY, n.y - n.radius); - maxY = Math.max(maxY, n.y + n.radius); - }); - const mapW = maxX - minX + 200, mapH = maxY - minY + 200; - _artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); - _artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom; - _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; + // Group the center + all discovered artists into genre islands. The + // center artist is focal. Discovery edges (center → similar → extended) + // are remapped so the hover constellation still traces how you got from + // one artist to another across the islands. + const rawNodes = data.nodes.map(n => ({ ...n, _focal: n.ring === 0 || n.type === 'center' })); + const groups = _artMapGroupByGenre(rawNodes); + _artMapLayoutIslands(groups); + _artMap.edges = _artMapRemapEdges(data.edges); + _artMapFitToContent(); _artMapSetupInteraction(canvas); From 9cce7810ea23fc4b6fb385a7791ffa29927e1f53 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 09:52:38 -0700 Subject: [PATCH 14/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=20C?= =?UTF-8?q?:=20ripple-bloom=20reveal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Islands now bloom in like drops on water instead of a flat fade: - Each island reveals in turn (staggered by island order); within an island, bubbles fade + scale (0.55→1, ease-out) outward from the centre by radial distance — a drop-in-water bloom. Genre titles fade in just after. - A hue-tinted water ripple ring expands from each island centre as it blooms (_artMapDrawRipples — reused by click ripples in Phase E). - During the reveal the static buffer is bypassed so EVERY bubble can animate (live layer, cap 2200); when the bloom ends it bakes into the buffer once and steady-state returns to the cheap two-layer path. - aAlpha folds into the global draw-alpha multiplier so fades compose cleanly. 64 JS integrity tests pass. --- webui/static/discover.js | 168 ++++++++++++++++++++++++++++----------- 1 file changed, 123 insertions(+), 45 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index e097d773..57407b6f 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5585,13 +5585,16 @@ function _artMapDrawLiveLayer(ctx) { ctx.save(); ctx.translate(ox, oy); ctx.scale(z, z); - // The live layer fades in together with the far field during the reveal. - _artMap._drawAlphaMul = _artMap._fieldAlpha == null ? 1 : _artMap._fieldAlpha; + _artMap._drawAlphaMul = 1; + // During the reveal the buffer is bypassed, so the live layer draws EVERY + // bubble (and the genre titles) so they can all bloom. Otherwise it draws + // only the big/near ones; the rest live in the static buffer. + const revealing = _artMap._revealing; let drawn = 0; - const CAP = 600; + const CAP = revealing ? 2200 : 600; for (const n of placed) { - if (!_artMapIsLiveSize(n)) continue; + if (!revealing && !_artMapIsLiveSize(n)) continue; if (_artMap._hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue; // Viewport cull (screen space) const sx = ox + n.x * z, sy = oy + n.y * z; @@ -5606,11 +5609,14 @@ function _artMapDrawLiveLayer(ctx) { } // Draw one live bubble with its animation transform. aScale scales about the -// node centre; opacity fades it. Reuses the shared node painter so the live -// bubble is pixel-identical to its baked form once settled. +// node centre; aAlpha fades it (folded into the global draw-alpha multiplier). +// Reuses the shared node painter so the bubble is identical to its baked form +// once settled. function _artMapDrawLiveNode(ctx, n) { const sc = n.aScale == null ? 1 : n.aScale; if (sc <= 0.001) return; + const baseMul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul; + _artMap._drawAlphaMul = baseMul * (n.aAlpha == null ? 1 : n.aAlpha); if (sc !== 1) { ctx.save(); ctx.translate(n.x, n.y); @@ -5621,6 +5627,7 @@ function _artMapDrawLiveNode(ctx, n) { } else { _artMapDrawNodeToBuffer(ctx, n, _artMap.zoom); } + _artMap._drawAlphaMul = baseMul; ctx.globalAlpha = 1; } @@ -5648,29 +5655,99 @@ function _artMapStartLoop() { } // Advance every active animation by absolute time t (ms). Returns true while -// anything is still moving. Phase A: a clean global fade-in (ease-out-cubic). -// The bubbly per-island ripple bloom is layered on in Phase C. +// anything is still moving. Phase C: each bubble scales+fades in (ease-out) +// once past its staggered start, and a water ripple expands from each island. function _artMapStepAnimations(t) { let active = false; - if (_artMap._fieldAlpha < 1) { - const FIELD_MS = 600; - const p = Math.min(1, (t - _artMap._revealT0) / FIELD_MS); - _artMap._fieldAlpha = 1 - Math.pow(1 - p, 3); // ease-out-cubic - if (p < 1) active = true; + const placed = _artMap.placed; + if (placed) { + for (const n of placed) { + if (n.aScale == null || n.aScale >= 1) continue; + if (t < n._revealAt) { active = true; continue; } + const p = Math.min(1, (t - n._revealAt) / (n._revealDur || 480)); + const e = 1 - Math.pow(1 - p, 3); // ease-out-cubic + if (p >= 1) { n.aScale = 1; n.aAlpha = 1; } + else { n.aScale = 0.55 + 0.45 * e; n.aAlpha = e; active = true; } + } } + const rip = _artMap._ripples; + if (rip && rip.length) { + let anyAlive = false; + for (const r of rip) if (t < r.t0 + r.dur) anyAlive = true; + if (anyAlive) active = true; else _artMap._ripples = []; + } + + // When the bloom finishes, leave reveal mode and bake everything into the + // static buffer (one rebuild) so steady-state goes back to the cheap path. + if (!active && _artMap._revealing) { + _artMap._revealing = false; + _artMap.dirty = true; + return true; // one more frame to do the rebuild + final blit + } return active; } -// Kick off the reveal: a clean fade-in of the whole map (far field via the -// buffer-blit alpha, live bubbles via _drawAlphaMul — they ramp together). +// Kick off the ripple-bloom reveal. Each island blooms in turn (staggered by +// island order); within an island, bubbles fade+scale outward from the centre +// like a drop hitting water, and a ripple ring expands from each island centre. +// During the reveal the whole map renders on the live layer (the static buffer +// is bypassed) so every bubble can animate; it bakes into the buffer at the end. function _artMapBeginReveal() { - _artMap._revealT0 = performance.now(); - _artMap._fieldAlpha = 0; + const t0 = performance.now(); + _artMap._revealT0 = t0; + _artMap._revealing = true; + _artMap._fieldAlpha = 1; // buffer is bypassed while revealing; live layer draws all + + const islands = _artMap._islands || []; + const islByName = {}; + islands.forEach((isl, i) => { isl._order = i; islByName[isl.name] = isl; }); + + const ISL_STAGGER = 145, RADIAL_MS = 430, NODE_DUR = 470; + for (const n of _artMap.placed) { + n.aScale = 0; n.aAlpha = 0; + const isl = islByName[n._island] || (n._isLabel ? islByName[n.name] : null); + const order = isl ? isl._order : 0; + let radial = 0; + if (isl && isl.r > 0) radial = Math.min(1, Math.hypot(n.x - isl.cx, n.y - isl.cy) / isl.r); + n._revealAt = t0 + order * ISL_STAGGER + radial * RADIAL_MS + (n._isLabel ? 90 : 0); + n._revealDur = NODE_DUR; + } + + _artMap._ripples = islands.map(isl => ({ + cx: isl.cx, cy: isl.cy, hue: isl.hue, + maxR: isl.r * 1.45, t0: t0 + isl._order * ISL_STAGGER, dur: 1150, + })); + _artMapStartLoop(); } +// Draw the expanding water-ripple rings (reveal + click). Cheap stroked arcs, +// hue-tinted, fading as they grow. Drawn in world space with a screen-constant +// line width. +function _artMapDrawRipples(ctx) { + const rip = _artMap._ripples; + if (!rip || !rip.length) return; + const t = performance.now(); + const z = _artMap.zoom; + ctx.save(); + ctx.translate(_artMap.offsetX, _artMap.offsetY); + ctx.scale(z, z); + for (const r of rip) { + const p = (t - r.t0) / r.dur; + if (p < 0 || p > 1) continue; + const radius = r.maxR * (0.08 + 0.92 * (1 - Math.pow(1 - p, 2))); + const alpha = (1 - p) * 0.55; + ctx.beginPath(); + ctx.arc(r.cx, r.cy, radius, 0, Math.PI * 2); + ctx.strokeStyle = `hsla(${r.hue},85%,72%,${alpha})`; + ctx.lineWidth = (1.5 + 6 * (1 - p)) / z; // ~constant on screen + ctx.stroke(); + } + ctx.restore(); +} + 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. @@ -5704,38 +5781,39 @@ function _artMapDraw() { ctx.fillStyle = _artMap._bgGrad; ctx.fillRect(0, 0, w, h); - if (_artMap.dirty || !_artMap.offscreen) { - const _rt = _artMap._perf ? performance.now() : 0; - _artMapRebuildBuffer(); - if (_artMap._perf) _artMap._rebuildMs = performance.now() - _rt; - } - if (!_artMap.offscreen) { if (_artMap._perf) _artMapDrawPerf(ctx, _t0); return; } - - const oc = _artMap.offscreen; - const s = _artMap._bufferScale; - const mx = _artMap._bufferMinX; - const my = _artMap._bufferMinY; const z = _artMap.zoom; - // Blit offscreen buffer: the buffer was drawn with scale(s) + translate(-minX,-minY) - // So buffer pixel (bx,by) corresponds to world (bx/s + minX, by/s + minY) - // Screen position of world (wx,wy) = offsetX + wx*zoom, offsetY + wy*zoom - // Therefore buffer origin on screen = offsetX + minX*zoom, offsetY + minY*zoom - // And buffer is drawn at size (bufferWidth * zoom/s, bufferHeight * zoom/s) - const fieldAlpha = _artMap._fieldAlpha == null ? 1 : _artMap._fieldAlpha; - if (fieldAlpha < 0.999) ctx.globalAlpha = fieldAlpha; - ctx.drawImage(oc, - _artMap.offsetX + mx * z, - _artMap.offsetY + my * z, - oc.width * z / s, - oc.height * z / s - ); - ctx.globalAlpha = 1; + // While the ripple-bloom reveal is running, bypass the static buffer + // entirely and let the live layer draw every bubble (so each can animate). + // The buffer is (re)built once when the reveal ends. + if (!_artMap._revealing) { + if (_artMap.dirty || !_artMap.offscreen) { + const _rt = _artMap._perf ? performance.now() : 0; + _artMapRebuildBuffer(); + if (_artMap._perf) _artMap._rebuildMs = performance.now() - _rt; + } + if (_artMap.offscreen) { + const oc = _artMap.offscreen; + const s = _artMap._bufferScale; + const mx = _artMap._bufferMinX; + const my = _artMap._bufferMinY; + // Blit offscreen buffer (built with scale(s) + translate(-minX,-minY)). + const fieldAlpha = _artMap._fieldAlpha == null ? 1 : _artMap._fieldAlpha; + if (fieldAlpha < 0.999) ctx.globalAlpha = fieldAlpha; + ctx.drawImage(oc, + _artMap.offsetX + mx * z, + _artMap.offsetY + my * z, + oc.width * z / s, + oc.height * z / s + ); + ctx.globalAlpha = 1; + } + } - // ── Live overlay layer: redraw the big/near bubbles every frame so they can - // scale (reveal/pop), bob and ripple. These are excluded from the static - // buffer above, so there's no double-draw. Viewport-culled + capped. ── + // ── Live overlay layer: big/near bubbles every frame (during the reveal, + // ALL bubbles) so they can scale/bob/ripple. Viewport-culled + capped. ── _artMapDrawLiveLayer(ctx); + _artMapDrawRipples(ctx); // ── Interactive overlay (drawn on main canvas, not buffer) ── const cFade = _artMap._constellationFade || 0; From 33c4eea2010bc7a2eb5259feac0c5affee6e5477 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 10:13:05 -0700 Subject: [PATCH 15/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=20D?= =?UTF-8?q?:=20ambient=20buoyancy=20+=20glassy=20bubbles=20+=20tighter=20i?= =?UTF-8?q?slands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ambient bob: bubbles gently float (sine offset, phase varies by position so they move in a wave, not in unison). Driven by a persistent rAF loop that runs only while bubbles are on screen + the tab is visible, and parks when zoomed out (_liveCount==0) or the map closes — so an idle overview costs nothing. - Glassy specular highlight (cached sprite, cheap drawImage per bubble) so bubbles read as glossy orbs at every size. - Tighter island spacing (water gap 7*nodeR → 3.5*nodeR) so the settled overview is more substantial, not thin-spread — addresses the 'mini version' feel after the reveal ripples fade. - Ambient resumes on zoom and on tab re-focus; stops cleanly on close. 64 JS integrity tests pass. --- webui/static/discover.js | 76 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 5 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 57407b6f..c0842bfc 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5033,6 +5033,32 @@ const _artMap = { // donut hole), with a floating genre title above it and a per-genre accent hue. // Islands are spread by a golden spiral + push-apart with generous spacing. +// A cached circular "gloss" sprite — a soft top-left specular highlight that, +// drawn over each bubble, makes it read as a glassy orb. One radial gradient, +// rendered once; per-bubble it's just a cheap drawImage (no per-frame gradient). +function _artMapGlossSprite() { + if (_artMap._gloss) return _artMap._gloss; + const S = 128; + const c = document.createElement('canvas'); + c.width = S; c.height = S; + const cx = c.getContext('2d'); + cx.beginPath(); cx.arc(S / 2, S / 2, S / 2, 0, Math.PI * 2); cx.clip(); + const g = cx.createRadialGradient(S * 0.34, S * 0.28, S * 0.02, S * 0.5, S * 0.5, S * 0.62); + g.addColorStop(0, 'rgba(255,255,255,0.40)'); + g.addColorStop(0.32, 'rgba(255,255,255,0.10)'); + g.addColorStop(0.6, 'rgba(255,255,255,0.0)'); + cx.fillStyle = g; + cx.fillRect(0, 0, S, S); + // A faint inner-bottom shade for roundness + const g2 = cx.createRadialGradient(S * 0.5, S * 0.78, S * 0.05, S * 0.5, S * 0.5, S * 0.7); + g2.addColorStop(0, 'rgba(0,0,0,0.18)'); + g2.addColorStop(0.5, 'rgba(0,0,0,0.0)'); + cx.fillStyle = g2; + cx.fillRect(0, 0, S, S); + _artMap._gloss = c; + return c; +} + // Deterministic hue (0–360) from a genre name, so each island has a stable tint. function _artMapGenreHue(name) { let h = 0; @@ -5119,7 +5145,7 @@ function _artMapLayoutIslands(groups, opts = {}) { for (let j = i + 1; j < islands.length; j++) { const dx = islands[j].cx - islands[i].cx, dy = islands[j].cy - islands[i].cy; const dist = Math.hypot(dx, dy) || 1; - const minD = islands[i].islandR + islands[j].islandR + nodeR * 7; + const minD = islands[i].islandR + islands[j].islandR + nodeR * 3.5; if (dist < minD) { const push = (minD - dist) / 2 + 1; islands[i].cx -= dx / dist * push; islands[i].cy -= dy / dist * push; @@ -5150,6 +5176,10 @@ function _artMapLayoutIslands(groups, opts = {}) { deezer_id: n.deezer_id || '', discogs_id: n.discogs_id || '', musicbrainz_id: n.musicbrainz_id || '', popularity: n.popularity || 0, _hue: isl.hue, _island: isl.name, + // Ambient buoyancy — phase varies by position so bubbles bob in a + // gentle wave (not in unison); amplitude in world units. + _bobPhase: (isl.cx + p.dx + isl.cy + p.dy) * 0.0022, + _bobAmp: nodeR * 0.12, }); } _artMap._islands.push({ name: isl.name, cx: isl.cx, cy: isl.cy, r: isl.islandR, hue: isl.hue, count: isl.count }); @@ -5338,6 +5368,10 @@ function closeArtistMap() { const sidebar = document.getElementById('artmap-genre-sidebar'); if (sidebar) sidebar.style.display = 'none'; if (_artMap.animFrame) cancelAnimationFrame(_artMap.animFrame); + // Stop the ambient buoyancy loop so it doesn't run with the map hidden. + _artMap._ambient = false; + _artMap._anim.running = false; + if (_artMap._anim.raf) { cancelAnimationFrame(_artMap._anim.raf); _artMap._anim.raf = null; } if (_artMap._keyHandler) window.removeEventListener('keydown', _artMap._keyHandler); _artMapHideContextMenu(); @@ -5499,6 +5533,11 @@ function _artMapDrawNodeToBuffer(octx, n, scale) { octx.fill(); } + // Glassy specular highlight (orb look) — skip the tiniest where it'd be noise. + if (rScaled >= 5) { + octx.drawImage(_artMapGlossSprite(), n.x - r, n.y - r, r * 2, r * 2); + } + const showLabel = rScaled >= 13; // Darken art behind the label so the name stays legible. @@ -5606,6 +5645,9 @@ function _artMapDrawLiveLayer(ctx) { _artMap._drawAlphaMul = 1; ctx.restore(); ctx.globalAlpha = 1; + // Count of non-label bubbles drawn live — drives whether the ambient loop + // keeps running (zoomed out = 0 = loop parks). + _artMap._liveCount = revealing ? 0 : drawn; } // Draw one live bubble with its animation transform. aScale scales about the @@ -5617,9 +5659,14 @@ function _artMapDrawLiveNode(ctx, n) { if (sc <= 0.001) return; const baseMul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul; _artMap._drawAlphaMul = baseMul * (n.aAlpha == null ? 1 : n.aAlpha); - if (sc !== 1) { + // Ambient buoyancy (steady state only — the reveal has its own motion). + let bobY = 0; + if (!_artMap._revealing && n._bobAmp) { + bobY = Math.sin((_artMap._now || 0) * 0.0016 + (n._bobPhase || 0)) * n._bobAmp; + } + if (sc !== 1 || bobY) { ctx.save(); - ctx.translate(n.x, n.y); + ctx.translate(n.x, n.y + bobY); ctx.scale(sc, sc); ctx.translate(-n.x, -n.y); _artMapDrawNodeToBuffer(ctx, n, _artMap.zoom); @@ -5642,9 +5689,14 @@ function _artMapStartLoop() { a.last = performance.now(); const tick = (t) => { if (!a.running) return; + _artMap._now = t; const more = _artMapStepAnimations(t); - _artMapDraw(); - if (more) { + _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). + const keep = more || (_artMap._ambient && _artMap._liveCount > 0 && !document.hidden); + if (keep) { a.raf = requestAnimationFrame(tick); } else { a.running = false; @@ -5654,6 +5706,12 @@ function _artMapStartLoop() { a.raf = requestAnimationFrame(tick); } +// (Re)start the ambient loop if buoyancy is on and it isn't already running — +// called after the reveal and on zoom/pan so bob resumes when bubbles appear. +function _artMapEnsureAmbient() { + if (_artMap._ambient && !_artMap._anim.running && !document.hidden) _artMapStartLoop(); +} + // Advance every active animation by absolute time t (ms). Returns true while // anything is still moving. Phase C: each bubble scales+fades in (ease-out) // once past its staggered start, and a water ripple expands from each island. @@ -5698,6 +5756,7 @@ function _artMapBeginReveal() { const t0 = performance.now(); _artMap._revealT0 = t0; _artMap._revealing = true; + _artMap._ambient = true; // keep the loop alive afterwards for buoyancy _artMap._fieldAlpha = 1; // buffer is bypassed while revealing; live layer draws all const islands = _artMap._islands || []; @@ -5761,6 +5820,7 @@ function _artMapRender() { function _artMapDraw() { /**Blit offscreen buffer to screen canvas with pan/zoom. Near-zero cost.**/ const _t0 = _artMap._perf ? performance.now() : 0; + if (!_artMap._anim.running) _artMap._now = performance.now(); // keep bob current on on-demand draws const ctx = _artMap.ctx; const w = _artMap.width; const h = _artMap.height; @@ -6728,6 +6788,11 @@ function _artMapSetupInteraction(canvas) { if (canvas._artMapListenersAttached) return; canvas._artMapListenersAttached = true; + // Pause ambient buoyancy when the tab is hidden; resume on return. + document.addEventListener('visibilitychange', () => { + if (!document.hidden) _artMapEnsureAmbient(); + }); + let isPanning = false, panStartX = 0, panStartY = 0; canvas.addEventListener('wheel', (e) => { @@ -6742,6 +6807,7 @@ function _artMapSetupInteraction(canvas) { _artMap.offsetY = my - (my - _artMap.offsetY) * (newZoom / _artMap.zoom); _artMap.zoom = newZoom; _artMapRender(); // fast blit + _artMapEnsureAmbient(); // resume buoyancy if we zoomed bubbles into view // Debounce hi-res rebuild after zoom settles clearTimeout(_artMap._zoomRebuild); _artMap._zoomRebuild = setTimeout(() => { _artMap.dirty = true; _artMapRender(); }, 300); From cdc1f0bc107dc4a6ca59a1016166c1e631d87a02 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 10:16:27 -0700 Subject: [PATCH 16/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=20E?= =?UTF-8?q?:=20click=20ripple=20shoves=20nearby=20bubbles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking (or tapping) the map now drops a water ripple: a hue-tinted ring expands from the point AND nearby bubbles get shoved radially outward at the wavefront, then settle back as it passes and decays (_artMapNodeDisplacement — a gaussian bump at the expanding front, world-space radial push). Ripples emit from the clicked bubble's centre in its genre hue (or the bare click point), and still open the artist after a beat. Replaces the old single purple ring. Note: the physical shove acts on live-layer (zoomed-in) bubbles; at the far-out overview the ring shows but the tiny baked bubbles don't move. 64 tests pass. --- webui/static/discover.js | 87 ++++++++++++++++++++++++---------------- 1 file changed, 52 insertions(+), 35 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index c0842bfc..b4b1e4d8 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5659,14 +5659,17 @@ function _artMapDrawLiveNode(ctx, n) { if (sc <= 0.001) return; const baseMul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul; _artMap._drawAlphaMul = baseMul * (n.aAlpha == null ? 1 : n.aAlpha); - // Ambient buoyancy (steady state only — the reveal has its own motion). - let bobY = 0; - if (!_artMap._revealing && n._bobAmp) { - bobY = Math.sin((_artMap._now || 0) * 0.0016 + (n._bobPhase || 0)) * n._bobAmp; + // Ambient buoyancy + ripple shove (steady state only — the reveal has its + // own motion). Both are world-space offsets applied about the node centre. + let ox = 0, oy = 0; + if (!_artMap._revealing) { + if (n._bobAmp) oy += Math.sin((_artMap._now || 0) * 0.0016 + (n._bobPhase || 0)) * n._bobAmp; + const disp = _artMapNodeDisplacement(n); + if (disp) { ox += disp.dx; oy += disp.dy; } } - if (sc !== 1 || bobY) { + if (sc !== 1 || ox || oy) { ctx.save(); - ctx.translate(n.x, n.y + bobY); + ctx.translate(n.x + ox, n.y + oy); ctx.scale(sc, sc); ctx.translate(-n.x, -n.y); _artMapDrawNodeToBuffer(ctx, n, _artMap.zoom); @@ -5807,6 +5810,43 @@ function _artMapDrawRipples(ctx) { ctx.restore(); } +// Total world-space displacement on a node from all active "push" ripples — the +// expanding wavefront shoves nearby bubbles radially outward, then they settle +// back as the wave passes and decays. Returns null when nothing is pushing. +function _artMapNodeDisplacement(n) { + const rip = _artMap._ripples; + if (!rip || !rip.length) return null; + const t = _artMap._now || performance.now(); + let dx = 0, dy = 0; + for (const r of rip) { + if (!r.push) continue; + const p = (t - r.t0) / r.dur; + if (p < 0 || p > 1) continue; + const front = r.maxR * (0.08 + 0.92 * (1 - Math.pow(1 - p, 2))); + const ddx = n.x - r.cx, ddy = n.y - r.cy; + const d = Math.hypot(ddx, ddy) || 1; + const delta = d - front; + const width = r.width || (r.maxR * 0.2); + const env = Math.exp(-(delta * delta) / (2 * width * width)); // bump at the wavefront + const push = r.push * env * (1 - p); // decays over the ripple's life + if (push > 0.05) { dx += (ddx / d) * push; dy += (ddy / d) * push; } + } + return (dx || dy) ? { dx, dy } : null; +} + +// Emit a water ripple at a world point — a fading ring plus a radial shove of +// nearby bubbles. Used for click/tap feedback. +function _artMapEmitRipple(wx, wy, hue) { + if (!_artMap._ripples) _artMap._ripples = []; + const WR = _artMap.WATCHLIST_R; + _artMap._ripples.push({ + cx: wx, cy: wy, hue: hue == null ? 270 : hue, + maxR: WR * 2.6, t0: performance.now(), dur: 900, + push: WR * 0.22, width: WR * 0.6, + }); + _artMapStartLoop(); // animate the ripple (guards against double-start) +} + 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. @@ -6017,28 +6057,6 @@ function _artMapDraw() { ctx.restore(); } - // Click ripple animation - if (_artMap._ripple) { - const rip = _artMap._ripple; - const elapsed = performance.now() - rip.start; - const progress = elapsed / 400; // 400ms duration - if (progress < 1) { - ctx.save(); - ctx.translate(_artMap.offsetX, _artMap.offsetY); - ctx.scale(z, z); - const ripR = rip.radius + rip.radius * progress * 0.5; - ctx.beginPath(); - ctx.arc(rip.x, rip.y, ripR, 0, Math.PI * 2); - ctx.strokeStyle = `rgba(138,43,226,${0.5 * (1 - progress)})`; - ctx.lineWidth = 3 * (1 - progress); - ctx.stroke(); - ctx.restore(); - requestAnimationFrame(() => _artMapRender()); - } else { - _artMap._ripple = null; - } - } - if (_artMap._perf) _artMapDrawPerf(ctx, _t0); } @@ -6931,15 +6949,13 @@ function _artMapSetupInteraction(canvas) { isPanning = false; if (!wasDrag && clickStart) { - // It was a click — find the node under cursor + // It was a click — emit a water ripple (shoves nearby bubbles) and, + // if it landed on an artist, open it after the ripple kicks off. const { nx, ny } = _artMapScreenToWorld(e, canvas); const node = _artMapHitTest(nx, ny); - if (node) { - _artMap._ripple = { x: node.x, y: node.y, radius: node.radius, start: performance.now() }; - _artMapRender(); - if (node.spotify_id || node.itunes_id || node.deezer_id) { - setTimeout(() => openYourArtistInfoModal_direct(node), 200); - } + _artMapEmitRipple(node ? node.x : nx, node ? node.y : ny, node ? node._hue : null); + if (node && (node.spotify_id || node.itunes_id || node.deezer_id)) { + setTimeout(() => openYourArtistInfoModal_direct(node), 200); } } @@ -7001,6 +7017,7 @@ function _artMapSetupInteraction(canvas) { const wx = (t.clientX - rect.left - _artMap.offsetX) / _artMap.zoom; const wy = (t.clientY - rect.top - _artMap.offsetY) / _artMap.zoom; const node = _artMapHitTest(wx, wy); + _artMapEmitRipple(node ? node.x : wx, node ? node.y : wy, node ? node._hue : null); if (node && (node.spotify_id || node.itunes_id || node.deezer_id)) { openYourArtistInfoModal_direct(node); } From 682bdc7fb111b29cd801f5caa519683ee297c996 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 10:31:54 -0700 Subject: [PATCH 17/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=20F?= =?UTF-8?q?=20(perf):=20de-lag=20hover,=20dense=20zoom,=20fix=20tooltip=20?= =?UTF-8?q?photo?= 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) From 21b6d17d3c82ddb0fa70cea31771e734e965d732 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 10:46:41 -0700 Subject: [PATCH 18/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20Phase=20F?= =?UTF-8?q?=20(fix):=20robust=20live/buffer=20partition=20+=2030fps=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the genre-map 'renders small/sparse after the reveal, zoom fixes it' bug. Root cause: tighter islands (Phase D) raised the fit-zoom so nearly every bubble crossed the live-size threshold → the buffer excluded them all (thought they were live) but the live layer is capped, so only ~600 of 1800 drew until a zoom rebuilt the partition. Fix: _artMapRebuildBuffer now counts would-be-live bubbles; if more than the live layer can draw (>450), it sets _liveOverflow and bakes EVERYTHING into the buffer (full, correct render). The live layer + bob only take over once zoomed in enough that few bubbles qualify. So the overview is always complete, regardless of zoom. Trade-off: very large maps (genre 1800) render from the buffer (no per-bubble bob, slightly softer when deeply zoomed until the zoom-rebuild sharpens) — correctness over flourish on the crowd. Also: whole animation loop capped at ~30fps (reveal/ripple/bob all read fine at 30) to cut the churn on dense maps; a pending rebuild (dirty) always draws so the throttle can't skip the post-reveal bake. 64 JS integrity tests pass. --- webui/static/discover.js | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index c3b0300a..732727c0 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5418,6 +5418,16 @@ function _artMapRebuildBuffer() { _artMap._liveBuildZoom = _artMap.zoom; _artMap._drawAlphaMul = 1; // buffer bakes at full alpha; the blit applies the reveal fade + // If more bubbles would be "live" than the live layer can draw, bake them ALL + // into the buffer (set the overflow flag BEFORE the draw loop so the live-size + // check below returns false and nothing is skipped). This is what prevents the + // genre-overview "small/sparse" render: the live layer caps out, so let the + // buffer own the whole crowd; live + bob only kick in once zoomed in. + const bz = _artMap.zoom; + let liveN = 0; + for (const n of visible) { if (!n._isLabel && (n.radius || 0) * bz >= _artMap.LIVE_PX) liveN++; } + _artMap._liveOverflow = liveN > 450; + octx.scale(scale, scale); octx.translate(-minX, -minY); @@ -5606,6 +5616,12 @@ function _artMapCompositeNode(n) { // so the two sets are always exact complements. function _artMapIsLiveSize(n) { if (n._isLabel) return false; + // When too many bubbles would be "live" at once (e.g. the genre overview), + // the live layer's cap can't draw them all and the buffer would exclude them + // → a sparse/half-rendered map. In that case treat NOTHING as live so the + // buffer bakes everything (full, correct render); the live layer + bob only + // take over once you've zoomed in to where few bubbles are big. + if (_artMap._liveOverflow) return false; const z = _artMap._liveBuildZoom || _artMap.zoom; return (n.radius || 0) * z >= _artMap.LIVE_PX; } @@ -5695,11 +5711,12 @@ function _artMapStartLoop() { if (!a.running) return; _artMap._now = t; const more = _artMapStepAnimations(t); - // 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) { + // Cap the whole animation loop at ~30fps. The reveal bloom, ripples and + // ambient bob all read fine at 30, and halving the redraws keeps the + // 1800-bubble genre map smooth instead of churning every frame. Always + // honour a pending buffer rebuild (dirty) so the throttle can't skip the + // frame that bakes the map after the reveal ends. + if (_artMap.dirty || (t - (a._lastDraw || 0)) >= 31) { _artMapDraw(); // sets _artMap._liveCount a._lastDraw = t; } @@ -6840,9 +6857,10 @@ function _artMapSetupInteraction(canvas) { _artMap.zoom = newZoom; _artMapRender(); // fast blit _artMapEnsureAmbient(); // resume buoyancy if we zoomed bubbles into view - // Debounce hi-res rebuild after zoom settles + // Debounce hi-res rebuild after zoom settles; then resume buoyancy (the + // rebuild may have flipped the live/overflow partition). clearTimeout(_artMap._zoomRebuild); - _artMap._zoomRebuild = setTimeout(() => { _artMap.dirty = true; _artMapRender(); }, 300); + _artMap._zoomRebuild = setTimeout(() => { _artMap.dirty = true; _artMapRender(); _artMapEnsureAmbient(); }, 300); }, { passive: false }); let clickStart = null; From 80d775a5da8087025971fd4ebdfc311973316f36 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 11:00:10 -0700 Subject: [PATCH 19/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20one-islan?= =?UTF-8?q?d-at-a-time=20view=20+=20API-backed=20toolbar=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things from feedback: 1) Toolbar search now queries the metadata source for ANY artist (like the discover page) and launches an exploration on click — instead of only filtering the current map's nodes (which showed nothing for off-map names). 2) Genre + watchlist maps now frame ONE genre island at a time, with prev/next nav (and ← / → keys) through the genres. This sidesteps the persistent 'renders small/sparse' bug entirely: only the focused island is visible, so the buffer covers a small region at HIGH res (crisp covers, no more shrunk images) and the live layer handles just ~hundreds of bubbles (bob works, no overflow). Each island blooms in (drop-in-water) on focus. Explore stays multi-island (it's small). A bottom nav bar shows genre name + i/N. Streaming caches off-island images silently (no redraw) so navigating is instant. 64 JS integrity tests pass. --- webui/static/discover.js | 172 ++++++++++++++++++++++++++++++++++----- 1 file changed, 152 insertions(+), 20 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 732727c0..7f7ebead 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5215,6 +5215,98 @@ function _artMapFitToContent(marginPx = 120) { _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; } +// ── One-island-at-a-time view (genre + watchlist) ───────────────────────── +// Big maps spread thousands of artists thin; fitting them all makes every cover +// tiny. Instead we frame ONE island filling the view (big crisp covers, live +// layer + bob viable on ~hundreds of bubbles) and navigate between them. Only +// the focused island is visible, so the buffer covers a small region at high +// res and the whole-world-buffer "small version" problem disappears. + +function _artMapFocusIsland(idx, opts = {}) { + const islands = _artMap._islands || []; + if (!islands.length) return; + idx = Math.max(0, Math.min(islands.length - 1, idx)); + _artMap._focusIdx = idx; + const isl = islands[idx]; + + // Show only this island's bubbles + its title; hide everything else so the + // buffer/zoom frame just this island. + for (const n of _artMap.placed) { + const mine = n._isLabel ? (n.name === isl.name) : (n._island === isl.name); + n.opacity = mine ? 1 : 0; + } + + // Frame the island in ~80% of the viewport. + const span = (isl.r * 2.3) + 120; + const z = Math.min(_artMap.width / span, _artMap.height / span, 1.2); + _artMap.zoom = z; + _artMap.offsetX = _artMap.width / 2 - isl.cx * z; + _artMap.offsetY = _artMap.height / 2 - isl.cy * z; + + _artMapUpdateIslandNav(); + + if (opts.bloom !== false) { + _artMapBloomIsland(isl); + } else { + _artMap.dirty = true; + _artMapRender(); + } +} + +// Bloom one island's bubbles (drop-in-water) + a ripple. Reuses the reveal loop. +function _artMapBloomIsland(isl) { + const t0 = performance.now(); + _artMap._revealing = true; + _artMap._ambient = true; + for (const n of _artMap.placed) { + if ((n.opacity || 0) < 0.01) continue; + n.aScale = 0; n.aAlpha = 0; + let radial = 0; + if (isl.r > 0) radial = Math.min(1, Math.hypot(n.x - isl.cx, n.y - isl.cy) / isl.r); + n._revealAt = t0 + (n._isLabel ? 60 : radial * 360); + n._revealDur = 430; + } + _artMap._ripples = [{ cx: isl.cx, cy: isl.cy, hue: isl.hue, maxR: isl.r * 1.4, t0, dur: 1000 }]; + _artMapStartLoop(); +} + +// Step to the prev/next island (wraps). +function _artMapIslandNav(dir) { + const islands = _artMap._islands || []; + if (islands.length < 2) return; + let idx = (_artMap._focusIdx || 0) + dir; + if (idx < 0) idx = islands.length - 1; + if (idx >= islands.length) idx = 0; + _artMapFocusIsland(idx, { bloom: true }); +} + +// Build/update the bottom nav bar (prev / genre name + i/N / next). Inline-styled +// so it doesn't depend on CSS that might not exist. +function _artMapUpdateIslandNav() { + const islands = _artMap._islands || []; + let nav = document.getElementById('artmap-island-nav'); + if (!_artMap._oneIsland || islands.length < 1) { if (nav) nav.remove(); return; } + const container = document.getElementById('artist-map-container'); + if (!container) return; + if (!nav) { + nav = document.createElement('div'); + nav.id = 'artmap-island-nav'; + nav.style.cssText = 'position:absolute;bottom:18px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:14px;padding:8px 14px;background:rgba(16,12,28,0.82);backdrop-filter:blur(10px);border:1px solid rgba(168,85,247,0.25);border-radius:999px;z-index:30;box-shadow:0 6px 24px rgba(0,0,0,0.45);user-select:none;'; + container.appendChild(nav); + } + const idx = _artMap._focusIdx || 0; + const isl = islands[idx]; + const btn = 'width:30px;height:30px;border-radius:50%;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.06);color:#fff;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;'; + nav.innerHTML = ` + +
+
${escapeHtml((isl.name || '').toUpperCase())}
+
${isl.count} artists · ${idx + 1} / ${islands.length}
+
+ + `; +} + async function openArtistMap() { const container = document.getElementById('artist-map-container'); if (!container) return; @@ -5269,7 +5361,7 @@ async function openArtistMap() { const groups = _artMapGroupByGenre(rawNodes); _artMapLayoutIslands(groups); _artMap.edges = _artMapRemapEdges(data.edges); - _artMapFitToContent(); + _artMap._oneIsland = true; // focus one genre island at a time // Setup interaction _artMapSetupInteraction(canvas); @@ -5299,7 +5391,7 @@ async function openArtistMap() { const le = document.getElementById('artist-map-loading'); if (le) le.remove(); - _artMapBeginReveal(); + _artMapFocusIsland(0, { bloom: true }); // frame + bloom the first genre island _artMapStreamImages(_artMap.placed); }, 50); @@ -5372,6 +5464,9 @@ function closeArtistMap() { _artMap._ambient = false; _artMap._anim.running = false; if (_artMap._anim.raf) { cancelAnimationFrame(_artMap._anim.raf); _artMap._anim.raf = null; } + _artMap._oneIsland = false; + const navEl = document.getElementById('artmap-island-nav'); + if (navEl) navEl.remove(); if (_artMap._keyHandler) window.removeEventListener('keydown', _artMap._keyHandler); _artMapHideContextMenu(); @@ -5426,7 +5521,10 @@ function _artMapRebuildBuffer() { const bz = _artMap.zoom; let liveN = 0; for (const n of visible) { if (!n._isLabel && (n.radius || 0) * bz >= _artMap.LIVE_PX) liveN++; } - _artMap._liveOverflow = liveN > 450; + // A single focused island (≤ maxPerIsland 500) should render via the crisp + // live layer; only fall back to baking-everything when there are clearly more + // bubbles than the live layer can draw. + _artMap._liveOverflow = liveN > 650; octx.scale(scale, scale); octx.translate(-minX, -minY); @@ -6129,23 +6227,51 @@ function _artMapDrawPerf(ctx, t0) { ctx.restore(); } +// Toolbar search: query the metadata source for ANY artist (like the discover +// page) and launch an exploration on click — not just filter the current map. function artMapSearch(query) { const results = document.getElementById('artist-map-search-results'); if (!results) return; - if (!query || query.length < 2) { results.style.display = 'none'; return; } + const q = (query || '').trim(); + if (q.length < 2) { results.style.display = 'none'; results.innerHTML = ''; return; } - const q = query.toLowerCase(); - const matches = _artMap.placed.filter(n => (n.opacity || 0) > 0.5 && n.name.toLowerCase().includes(q)).slice(0, 8); + clearTimeout(_artMap._searchTimer); + _artMap._searchTimer = setTimeout(async () => { + const myToken = (_artMap._searchToken = (_artMap._searchToken || 0) + 1); + results.style.display = 'block'; + results.innerHTML = '
Searching…
'; + try { + const resp = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(q)}`); + const data = await resp.json(); + if (myToken !== _artMap._searchToken) return; // superseded by a newer keystroke + const artists = (data && data.success && Array.isArray(data.artists)) ? data.artists : []; + if (!artists.length) { + results.innerHTML = '
No artists found
'; + return; + } + results.innerHTML = artists.slice(0, 8).map(a => + `
+ + ${escapeHtml(a.name)} + Explore → +
` + ).join(''); + } catch (e) { + if (myToken === _artMap._searchToken) { + results.innerHTML = '
Search failed — try again
'; + } + } + }, 300); +} - if (!matches.length) { results.style.display = 'none'; return; } - - results.style.display = 'block'; - results.innerHTML = matches.map(n => - `
- ${n.type === 'watchlist' ? '★' : '○'} - ${escapeHtml(n.name)} -
` - ).join(''); +// Launch the explorer for a searched artist (closes the dropdown first). +function artMapExploreArtist(name) { + const results = document.getElementById('artist-map-search-results'); + if (results) { results.style.display = 'none'; results.innerHTML = ''; } + const input = document.getElementById('artist-map-search'); + if (input) input.value = ''; + _artMap._skipSectionToggle = true; + _openArtistMapExplorerWithName(name); } function artMapZoomToNode(nodeId) { @@ -6461,7 +6587,7 @@ async function _openGenreMapWithSelection(selectedGenre) { })); _artMapLayoutIslands(groups); _artMap.edges = []; - _artMapFitToContent(); + _artMap._oneIsland = true; // focus one genre island at a time const placedCount = _artMap.placed.filter(n => !n._isLabel).length; _artMapSetupInteraction(canvas); @@ -6472,8 +6598,7 @@ async function _openGenreMapWithSelection(selectedGenre) { const le = document.getElementById('artist-map-loading'); if (le) le.remove(); - _artMap.dirty = true; - _artMapBeginReveal(); + _artMapFocusIsland(0, { bloom: true }); // frame + bloom the selected genre island // Stream images in throttled waves — interactive immediately, sharpens in place. _artMapStreamImages(_artMap.placed.filter(n => !n._isLabel)); @@ -6570,6 +6695,8 @@ async function _openArtistMapExplorerWithName(name) { const groups = _artMapGroupByGenre(rawNodes); _artMapLayoutIslands(groups); _artMap.edges = _artMapRemapEdges(data.edges); + _artMap._oneIsland = false; // explore stays multi-island (it's small) + _artMapUpdateIslandNav(); // tear down any leftover nav from a prior map _artMapFitToContent(); _artMapSetupInteraction(canvas); @@ -6813,10 +6940,13 @@ function _artMapStreamImages(imgNodes, concurrent = 24) { .then(bmp => { if (bmp && token === _artMap._loadToken) { _artMap.images[n.id] = bmp; + // Hidden bubbles (other islands in one-island mode): just + // cache the image for when you navigate there — don't + // redraw/rebuild for something off-screen. + if ((n.opacity || 0) < 0.01) return; // Composite just this node into the existing buffer (cheap) // and blit. Only fall back to a full rebuild if the buffer - // isn't built yet. This keeps pan/hover at blit-speed the - // whole time images are streaming in — the lag fix. + // isn't built yet. Keeps pan/hover at blit-speed while images stream. if (_artMapCompositeNode(n)) _artMapRender(); else scheduleRedraw(); } @@ -6885,6 +7015,8 @@ function _artMapSetupInteraction(canvas) { _artMap.dirty = true; _artMapRender(); } + else if (_artMap._oneIsland && e.key === 'ArrowLeft') { _artMapIslandNav(-1); e.preventDefault(); } + else if (_artMap._oneIsland && e.key === 'ArrowRight') { _artMapIslandNav(1); e.preventDefault(); } } window.addEventListener('keydown', _artMapKeyHandler); _artMap._keyHandler = _artMapKeyHandler; From e2eac64dce16bd0860f8a8586e94cfbe68602a41 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 11:09:02 -0700 Subject: [PATCH 20/42] Artist Map v2: de-lag one-island genre view + move island nav to top-left MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Focused islands now render from the high-res buffer (one cheap crisp blit) instead of redrawing every bubble each frame for the bob. In one-island mode the buffer already covers just that island at high resolution, so this is crisp AND cheap — kills the genre lag. Bob/shove stay live only for small views (zoomed-in subsets, explore) where per-frame redraw is cheap. (Overflow threshold 650→140; the loop parks once the island bakes.) - Fewer bubbles per island (maxPerIsland 500→300) — less cramped, lighter bloom. - Island nav bar moved from bottom-center to top-left (clears the genre sidebar + toolbar). 64 JS integrity tests pass. --- webui/static/discover.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 7f7ebead..245d5c3c 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5122,7 +5122,7 @@ function _artMapLayoutIslands(groups, opts = {}) { _artMap._islands = []; const nodeR = opts.nodeR || (_artMap.WATCHLIST_R * 0.22); const gap = opts.gap || (_artMap.BUFFER * 2.2); - const cap = opts.maxPerIsland || 500; + const cap = opts.maxPerIsland || 300; let pid = 0; const islands = groups.map(g => { @@ -5291,9 +5291,12 @@ function _artMapUpdateIslandNav() { if (!nav) { nav = document.createElement('div'); nav.id = 'artmap-island-nav'; - nav.style.cssText = 'position:absolute;bottom:18px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:14px;padding:8px 14px;background:rgba(16,12,28,0.82);backdrop-filter:blur(10px);border:1px solid rgba(168,85,247,0.25);border-radius:999px;z-index:30;box-shadow:0 6px 24px rgba(0,0,0,0.45);user-select:none;'; container.appendChild(nav); } + // Position top-left, clearing the genre sidebar (when shown) and the toolbar. + const sb = document.getElementById('artmap-genre-sidebar'); + const sbW = (sb && sb.style.display !== 'none') ? (sb.offsetWidth || 0) : 0; + nav.style.cssText = `position:absolute;top:64px;left:${sbW + 16}px;display:flex;align-items:center;gap:12px;padding:7px 12px;background:rgba(16,12,28,0.82);backdrop-filter:blur(10px);border:1px solid rgba(168,85,247,0.25);border-radius:14px;z-index:30;box-shadow:0 6px 24px rgba(0,0,0,0.45);user-select:none;`; const idx = _artMap._focusIdx || 0; const isl = islands[idx]; const btn = 'width:30px;height:30px;border-radius:50%;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.06);color:#fff;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;'; @@ -5521,10 +5524,12 @@ function _artMapRebuildBuffer() { const bz = _artMap.zoom; let liveN = 0; for (const n of visible) { if (!n._isLabel && (n.radius || 0) * bz >= _artMap.LIVE_PX) liveN++; } - // A single focused island (≤ maxPerIsland 500) should render via the crisp - // live layer; only fall back to baking-everything when there are clearly more - // bubbles than the live layer can draw. - _artMap._liveOverflow = liveN > 650; + // In one-island mode the buffer already covers just the focused island at + // high resolution, so let the BUFFER own any non-trivial crowd (one cheap + // crisp blit, no per-frame redraw → no lag). The live layer + bob/shove only + // take over for small views (zoomed-in subsets, explore) where redrawing a + // handful of bubbles each frame is cheap. + _artMap._liveOverflow = liveN > 140; octx.scale(scale, scale); octx.translate(-minX, -minY); From f95b52e3c42d879313d09102fe0aa5fddbb18c0d Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 13:01:58 -0700 Subject: [PATCH 21/42] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20polish:?= =?UTF-8?q?=20island=20halo,=20hover-pop,=20genre=20quick-jump,=20declutte?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Soft genre-hued halo glows behind the focused island (cached per-hue sprite → one drawImage, no per-frame gradient) so it reads as a place on the water. - Hover-pop: hovered bubbles scale up + get a bright hue ring + glow, even on static genre islands (drawn on top), so hover always feels tactile/responsive. - Genre quick-jump: click the genre name in the nav for a dropdown of every genre island — jump straight to one instead of only prev/next. - Decluttered: dropped the redundant in-world island titles in one-island mode (the nav bar already names the genre, and they could clip off the top). 64 JS integrity tests pass. --- webui/static/discover.js | 127 +++++++++++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 20 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 245d5c3c..dad9bad8 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5059,6 +5059,26 @@ function _artMapGlossSprite() { return c; } +// A cached soft radial "halo" sprite per genre hue — drawn behind the focused +// island so it reads as a glowing place on the water. Cached per hue (≤ a few), +// so it's just a drawImage per frame, never a per-frame gradient. +function _artMapHaloSprite(hue) { + _artMap._halos = _artMap._halos || {}; + if (_artMap._halos[hue]) return _artMap._halos[hue]; + const S = 256; + const c = document.createElement('canvas'); + c.width = S; c.height = S; + const cx = c.getContext('2d'); + const g = cx.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2); + g.addColorStop(0, `hsla(${hue},75%,55%,0.22)`); + g.addColorStop(0.45, `hsla(${hue},75%,50%,0.08)`); + g.addColorStop(1, `hsla(${hue},75%,50%,0)`); + cx.fillStyle = g; + cx.fillRect(0, 0, S, S); + _artMap._halos[hue] = c; + return c; +} + // Deterministic hue (0–360) from a genre name, so each island has a stable tint. function _artMapGenreHue(name) { let h = 0; @@ -5229,11 +5249,11 @@ function _artMapFocusIsland(idx, opts = {}) { _artMap._focusIdx = idx; const isl = islands[idx]; - // Show only this island's bubbles + its title; hide everything else so the - // buffer/zoom frame just this island. + // Show only this island's bubbles; hide everything else (and the in-world + // titles — the nav bar already names the genre) so the frame is just this + // island's covers. for (const n of _artMap.placed) { - const mine = n._isLabel ? (n.name === isl.name) : (n._island === isl.name); - n.opacity = mine ? 1 : 0; + n.opacity = (!n._isLabel && n._island === isl.name) ? 1 : 0; } // Frame the island in ~80% of the viewport. @@ -5299,15 +5319,53 @@ function _artMapUpdateIslandNav() { nav.style.cssText = `position:absolute;top:64px;left:${sbW + 16}px;display:flex;align-items:center;gap:12px;padding:7px 12px;background:rgba(16,12,28,0.82);backdrop-filter:blur(10px);border:1px solid rgba(168,85,247,0.25);border-radius:14px;z-index:30;box-shadow:0 6px 24px rgba(0,0,0,0.45);user-select:none;`; const idx = _artMap._focusIdx || 0; const isl = islands[idx]; - const btn = 'width:30px;height:30px;border-radius:50%;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.06);color:#fff;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;'; + const btn = 'width:30px;height:30px;border-radius:50%;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.06);color:#fff;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex:none;'; nav.innerHTML = ` -
-
${escapeHtml((isl.name || '').toUpperCase())}
+
+
${escapeHtml((isl.name || '').toUpperCase())} ▾
${isl.count} artists · ${idx + 1} / ${islands.length}
`; + // Re-anchor an open menu (or leave it closed). + const menu = document.getElementById('artmap-island-menu'); + if (menu) menu.remove(); +} + +// Quick-jump dropdown: list every genre island; click to jump straight to it. +function _artMapToggleIslandMenu(ev) { + if (ev) ev.stopPropagation(); + const existing = document.getElementById('artmap-island-menu'); + if (existing) { existing.remove(); return; } + const islands = _artMap._islands || []; + const nav = document.getElementById('artmap-island-nav'); + if (!nav || !islands.length) return; + const menu = document.createElement('div'); + menu.id = 'artmap-island-menu'; + menu.style.cssText = `position:absolute;top:${nav.offsetTop + nav.offsetHeight + 6}px;left:${nav.offsetLeft}px;min-width:${Math.max(180, nav.offsetWidth)}px;max-height:50vh;overflow-y:auto;background:rgba(16,12,28,0.96);backdrop-filter:blur(10px);border:1px solid rgba(168,85,247,0.25);border-radius:12px;z-index:31;box-shadow:0 8px 28px rgba(0,0,0,0.5);padding:6px;`; + const cur = _artMap._focusIdx || 0; + menu.innerHTML = islands.map((isl, i) => ` +
+ + + ${escapeHtml(isl.name)} + + ${isl.count} +
`).join(''); + nav.parentElement.appendChild(menu); + // Close on next outside click. + setTimeout(() => { + const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('mousedown', close); } }; + document.addEventListener('mousedown', close); + }, 0); +} + +function _artMapJumpIsland(i) { + const menu = document.getElementById('artmap-island-menu'); + if (menu) menu.remove(); + _artMapFocusIsland(i, { bloom: true }); } async function openArtistMap() { @@ -5770,6 +5828,32 @@ function _artMapDrawLiveLayer(ctx) { _artMap._liveCount = revealing ? 0 : drawn; } +// Tactile hover-pop: redraw the hovered bubble slightly larger with its cover +// + a bright hue ring, on top of everything. Works even when the bubble lives +// in the static buffer (genre islands), so hover always feels responsive. +// ctx is already in world space (translate(offset) + scale(zoom)). +function _artMapDrawHoverPop(ctx, n) { + const r = n.radius; + const hue = n._hue == null ? 270 : n._hue; + const s = 1.16; + const img = _artMap.images[n.id]; + ctx.save(); + ctx.translate(n.x, n.y); ctx.scale(s, s); ctx.translate(-n.x, -n.y); + if (img) { + ctx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2); + } else { + ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2); + ctx.fillStyle = '#1a0a30'; ctx.fill(); + } + ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2); + ctx.strokeStyle = `hsla(${hue},90%,78%,0.95)`; + ctx.lineWidth = 2.5 / s; ctx.stroke(); + ctx.restore(); + ctx.beginPath(); ctx.arc(n.x, n.y, r * s + 5, 0, Math.PI * 2); + ctx.strokeStyle = `hsla(${hue},85%,66%,0.45)`; + ctx.lineWidth = 3; ctx.stroke(); +} + // Draw one live bubble with its animation transform. aScale scales about the // node centre; aAlpha fades it (folded into the global draw-alpha multiplier). // Reuses the shared node painter so the bubble is identical to its baked form @@ -6008,6 +6092,18 @@ function _artMapDraw() { const z = _artMap.zoom; + // Soft genre-hued halo behind the focused island (one-island mode) — gives + // the island a sense of place on the water. Cached sprite → one drawImage. + if (_artMap._oneIsland && _artMap._islands && _artMap._islands.length) { + const isl = _artMap._islands[_artMap._focusIdx || 0]; + if (isl) { + const hr = (isl.r * 2.5) * z; + const hsx = _artMap.offsetX + isl.cx * z; + const hsy = _artMap.offsetY + isl.cy * z; + ctx.drawImage(_artMapHaloSprite(isl.hue), hsx - hr, hsy - hr, hr * 2, hr * 2); + } + } + // While the ripple-bloom reveal is running, bypass the static buffer // entirely and let the live layer draw every bubble (so each can animate). // The buffer is (re)built once when the reveal ends. @@ -6156,27 +6252,18 @@ function _artMapDraw() { } ctx.globalAlpha = 1; } else { - // Single node, no connections - ctx.beginPath(); - ctx.arc(n.x, n.y, n.radius + 4, 0, Math.PI * 2); - ctx.strokeStyle = 'rgba(255,255,255,0.5)'; - ctx.lineWidth = 3; - ctx.stroke(); + // Single node, no connections — pop the hovered bubble. + _artMapDrawHoverPop(ctx, n); } ctx.restore(); } // end if(n) } else if (_artMap.hoveredNode && !_artMap._constellationActive) { - // Pre-constellation: just show a simple highlight ring (instant, no delay) - const n = _artMap.hoveredNode; + // Pre-constellation: instant tactile pop on the hovered bubble. ctx.save(); ctx.translate(_artMap.offsetX, _artMap.offsetY); ctx.scale(z, z); - ctx.beginPath(); - ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2); - ctx.strokeStyle = 'rgba(255,255,255,0.35)'; - ctx.lineWidth = 2; - ctx.stroke(); + _artMapDrawHoverPop(ctx, _artMap.hoveredNode); ctx.restore(); } From 37595314f153a3703aac15d4b5dfb0c934cf1739 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 13:13:09 -0700 Subject: [PATCH 22/42] Artist Map v2 fix: streamed art now appears on its own (no manual zoom) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the 'loads as placeholder orbs, only pops in after a zoom' bug: streamed images were cached in _artMap.images but written into the buffer via the per-node composite path, which didn't reliably refresh in one-island / overflow mode — so covers stayed as placeholders until a zoom forced a full rebuild that picked up the cached bitmaps. Now that each map's buffer is small (one focused island, or a small explore map), a throttled FULL rebuild on image arrival is cheap and always bakes every cached image. Dropped the composite call from the stream; art fills in by itself as it loads. 64 JS integrity tests pass. --- webui/static/discover.js | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index dad9bad8..d6c3a3e0 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -7009,8 +7009,11 @@ function _artMapStreamImages(imgNodes, concurrent = 24) { 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; - // Fallback full-rebuild path, used only if the buffer isn't ready yet - // (e.g. images returned before the first paint built the offscreen buffer). + // Throttled FULL rebuild as images arrive. The per-map buffer is now small + // (one focused island / a small explore map), so a full rebuild is cheap and + // — unlike the per-node composite — is guaranteed to pick up every cached + // image. This is what makes streamed art appear on its own instead of only + // after a manual zoom forced a rebuild. const scheduleRedraw = () => { if (redrawPending || token !== _artMap._loadToken) return; redrawPending = true; @@ -7019,7 +7022,8 @@ function _artMapStreamImages(imgNodes, concurrent = 24) { if (token !== _artMap._loadToken) return; _artMap.dirty = true; _artMapRender(); - }, 280); + _artMapEnsureAmbient(); + }, 200); }; function pump() { @@ -7034,13 +7038,11 @@ function _artMapStreamImages(imgNodes, concurrent = 24) { _artMap.images[n.id] = bmp; // Hidden bubbles (other islands in one-island mode): just // cache the image for when you navigate there — don't - // redraw/rebuild for something off-screen. + // redraw for something off-screen. if ((n.opacity || 0) < 0.01) return; - // Composite just this node into the existing buffer (cheap) - // and blit. Only fall back to a full rebuild if the buffer - // isn't built yet. Keeps pan/hover at blit-speed while images stream. - if (_artMapCompositeNode(n)) _artMapRender(); - else scheduleRedraw(); + // Throttled full rebuild — reliably bakes newly-arrived art + // into the (small) buffer. No manual zoom needed. + scheduleRedraw(); } }) .finally(() => { inFlight--; pump(); }); From 0d91bb78db91314407e08641898b91891c814e62 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 13:19:25 -0700 Subject: [PATCH 23/42] =?UTF-8?q?Artist=20Map=20v2:=20fancier=20bloom=20?= =?UTF-8?q?=E2=80=94=20bubbles=20surface=20up=20into=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bubbles now rise up into position (water-surfacing) with a soft ease-out-back settle and alpha fading in a touch faster than scale. Stagger is continuous radial + a deterministic per-bubble jitter so they fill in organically instead of popping in visible rings/segments. 64 JS integrity tests pass. --- webui/static/discover.js | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index d6c3a3e0..42e4b8aa 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5283,10 +5283,15 @@ function _artMapBloomIsland(isl) { n.aScale = 0; n.aAlpha = 0; let radial = 0; if (isl.r > 0) radial = Math.min(1, Math.hypot(n.x - isl.cx, n.y - isl.cy) / isl.r); - n._revealAt = t0 + (n._isLabel ? 60 : radial * 360); - n._revealDur = 430; + // Continuous radial stagger + deterministic per-bubble jitter so they + // surface organically rather than in visible rings/segments. + const jitter = ((Math.abs((n.id | 0) * 1103515245 + 12345) % 1000) / 1000) * 200; + n._revealAt = t0 + radial * 300 + jitter; + n._revealDur = 560; + n._riseAmp = (n.radius || 20) * 1.15; // bubbles rise up into place (surfacing) + n._revealRise = n._riseAmp; } - _artMap._ripples = [{ cx: isl.cx, cy: isl.cy, hue: isl.hue, maxR: isl.r * 1.4, t0, dur: 1000 }]; + _artMap._ripples = [{ cx: isl.cx, cy: isl.cy, hue: isl.hue, maxR: isl.r * 1.45, t0, dur: 1100 }]; _artMapStartLoop(); } @@ -5866,7 +5871,9 @@ function _artMapDrawLiveNode(ctx, n) { // Ambient buoyancy + ripple shove (steady state only — the reveal has its // own motion). Both are world-space offsets applied about the node centre. let ox = 0, oy = 0; - if (!_artMap._revealing) { + if (_artMap._revealing) { + if (n._revealRise) oy += n._revealRise; // surfacing rise during the bloom + } else { if (n._bobAmp) oy += Math.sin((_artMap._now || 0) * 0.0016 + (n._bobPhase || 0)) * n._bobAmp; const disp = _artMapNodeDisplacement(n); if (disp) { ox += disp.dx; oy += disp.dy; } @@ -5936,9 +5943,19 @@ function _artMapStepAnimations(t) { if (n.aScale == null || n.aScale >= 1) continue; if (t < n._revealAt) { active = true; continue; } const p = Math.min(1, (t - n._revealAt) / (n._revealDur || 480)); - const e = 1 - Math.pow(1 - p, 3); // ease-out-cubic - if (p >= 1) { n.aScale = 1; n.aAlpha = 1; } - else { n.aScale = 0.55 + 0.45 * e; n.aAlpha = e; active = true; } + if (p >= 1) { n.aScale = 1; n.aAlpha = 1; n._revealRise = 0; } + else { + // Scale eases in with a gentle overshoot (ease-out-back, subtle), + // alpha fades a touch faster, and the bubble rises up into place + // like it's surfacing through water — the remaining rise decays + // as (1-p)^3. + const c1 = 1.18, c3 = c1 + 1; + const back = 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2); + n.aScale = back; + n.aAlpha = Math.min(1, p * 1.6); + n._revealRise = Math.pow(1 - p, 3) * (n._riseAmp || 0); + active = true; + } } } From b0c0acb379225470ef029309629281a2aaae8a3f Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 13:28:57 -0700 Subject: [PATCH 24/42] Artist Map v2: right-side info panel (dashboard + coverage + top artists + artist card) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A polished detail panel on the right of every map (never collides with the genre sidebar; islands now frame in the space left of it): - Header dashboard: view title, Artists / Watchlist / Genres stat tiles, and a watchlist-coverage bar for the current genre/view. - Top-artists list: the current island's biggest artists, clickable (shows their card + ripples them on the map). - Rich artist card on hover/click: large art (from the decoded bitmap), genre chips, popularity bar, connection count, watchlist/discovered badge, and actions — Explore from here, Details, Watchlist toggle, Open artist page. Card stays pinned (no auto-revert) so you can reach its buttons; a back button returns to the list. 64 JS integrity tests pass. --- webui/static/discover.js | 186 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 181 insertions(+), 5 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 42e4b8aa..e85404bc 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5025,6 +5025,7 @@ const _artMap = { _anim: { running: false, raf: null, last: 0 }, _fieldAlpha: 1, // global fade for the static far-field buffer (reveal) _revealT0: 0, // performance.now() when the current reveal began + _panelW: 320, // right-side info panel width (reserved when framing islands) }; // ── Genre-island layout (shared by watchlist / genre / explore) ─────────── @@ -5229,9 +5230,10 @@ function _artMapFitToContent(marginPx = 120) { minY = Math.min(minY, n.y - n.radius); maxY = Math.max(maxY, n.y + n.radius); } if (!isFinite(minX)) return; + const usableW = Math.max(200, _artMap.width - _artMap._panelW); const mapW = maxX - minX + marginPx * 2, mapH = maxY - minY + marginPx * 2; - _artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); - _artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom; + _artMap.zoom = Math.min(usableW / mapW, _artMap.height / mapH, 1); + _artMap.offsetX = usableW / 2 - ((minX + maxX) / 2) * _artMap.zoom; _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; } @@ -5256,14 +5258,16 @@ function _artMapFocusIsland(idx, opts = {}) { n.opacity = (!n._isLabel && n._island === isl.name) ? 1 : 0; } - // Frame the island in ~80% of the viewport. + // Frame the island in the space LEFT of the info panel (~80% of it). + const usableW = Math.max(200, _artMap.width - _artMap._panelW); const span = (isl.r * 2.3) + 120; - const z = Math.min(_artMap.width / span, _artMap.height / span, 1.2); + const z = Math.min(usableW / span, _artMap.height / span, 1.2); _artMap.zoom = z; - _artMap.offsetX = _artMap.width / 2 - isl.cx * z; + _artMap.offsetX = usableW / 2 - isl.cx * z; _artMap.offsetY = _artMap.height / 2 - isl.cy * z; _artMapUpdateIslandNav(); + _artMapRefreshPanel(); if (opts.bloom !== false) { _artMapBloomIsland(isl); @@ -5373,6 +5377,172 @@ function _artMapJumpIsland(i) { _artMapFocusIsland(i, { bloom: true }); } +// ── Right-side info panel ────────────────────────────────────────────────── +// A polished detail panel: a discovery dashboard + current-view coverage at the +// top, a clickable top-artists list, and a rich artist card when you hover/click +// a bubble. Lives on the right so it never collides with the genre sidebar. + +function _artMapNodeBest(n) { + const map = [['spotify_id', 'spotify'], ['itunes_id', 'itunes'], ['deezer_id', 'deezer'], ['discogs_id', 'discogs'], ['musicbrainz_id', 'musicbrainz']]; + for (const [k, s] of map) if (n && n[k]) return { id: n[k], source: s }; + return { id: '', source: '' }; +} + +function _artMapConnCount(n) { + let c = 0; + for (const e of (_artMap.edges || [])) if (e.source === n.id || e.target === n.id) c++; + return c; +} + +function _artMapEnsurePanel() { + const container = document.getElementById('artist-map-container'); + if (!container) return null; + let p = document.getElementById('artmap-info-panel'); + if (!p) { + p = document.createElement('div'); + p.id = 'artmap-info-panel'; + p.style.cssText = `position:absolute;top:0;right:0;width:${_artMap._panelW}px;height:100%;` + + `background:linear-gradient(180deg,rgba(20,15,34,0.92),rgba(11,8,20,0.96));backdrop-filter:blur(16px);` + + `border-left:1px solid rgba(168,85,247,0.18);z-index:26;display:flex;flex-direction:column;` + + `color:#fff;overflow:hidden;box-shadow:-10px 0 36px rgba(0,0,0,0.45);font-size:13px;`; + p.innerHTML = `
` + + `
`; + container.appendChild(p); + } + return p; +} + +function _artMapClosePanel() { + const p = document.getElementById('artmap-info-panel'); + if (p) p.remove(); + _artMap._panelArtistId = null; +} + +// Stat tile helper +function _miniStat(label, value, hue) { + return `
+
${value}
+
${label}
+
`; +} + +// Build the panel header (dashboard) + body (top-artists list) for the view. +function _artMapRefreshPanel() { + const p = _artMapEnsurePanel(); + if (!p) return; + const head = document.getElementById('artmap-panel-head'); + const body = document.getElementById('artmap-panel-body'); + if (!head || !body) return; + + const nodes = (_artMap.placed || []).filter(n => !n._isLabel); + const total = nodes.length; + const watch = nodes.filter(n => n.type === 'watchlist' || n.type === 'center').length; + const islands = _artMap._islands || []; + const oneIsland = _artMap._oneIsland; + const isl = oneIsland && islands.length ? islands[_artMap._focusIdx || 0] : null; + + // Scope (current island vs whole map) + const scope = isl ? nodes.filter(n => n._island === isl.name) : nodes; + const scopeTotal = isl ? isl.count : total; + const scopeWatch = scope.filter(n => n.type === 'watchlist' || n.type === 'center').length; + const cov = scopeTotal ? Math.round((scopeWatch / scopeTotal) * 100) : 0; + const hue = isl ? isl.hue : 270; + + const title = _artMap._mapTitle || 'Artist Map'; + head.innerHTML = ` +
${title}
+ ${isl ? `
${escapeHtml(isl.name)}
` : `
Overview
`} +
+ ${_miniStat('Artists', scopeTotal, hue)} + ${_miniStat('Watchlist', scopeWatch)} + ${_miniStat(isl ? 'Genre' : 'Genres', isl ? '1' : (islands.length || 1))} +
+
+
+ On your watchlist${scopeWatch}/${scopeTotal} +
+
+
+
+
`; + + // Body: top artists (by popularity) in the current scope. + _artMap._panelArtistId = null; + const top = scope.slice().sort((a, b) => (b.popularity || 0) - (a.popularity || 0)).slice(0, 14); + body.innerHTML = ` +
Top artists
+ ${top.map((n, i) => ` +
+ ${i + 1} + + ${n.image_url ? `` : '♫'} + + ${escapeHtml(n.name)} + ${n.type === 'watchlist' ? '' : ''} +
`).join('') || '
No artists
'}`; +} + +// Show a rich artist card in the body (hover/click); pass null to restore the list. +function _artMapPanelArtist(node) { + const body = document.getElementById('artmap-panel-body'); + if (!body) return; + if (!node) { if (_artMap._panelArtistId != null) _artMapRefreshPanel(); return; } + if (_artMap._panelArtistId === node.id) return; // already showing + _artMap._panelArtistId = node.id; + + const hue = node._hue == null ? 270 : node._hue; + const conn = _artMapConnCount(node); + const pop = Math.max(0, Math.min(100, Math.round(node.popularity || 0))); + const genres = (node.genres || []).slice(0, 5); + const best = _artMapNodeBest(node); + const typeLabel = (node.type === 'watchlist' || node.type === 'center') ? 'On watchlist' : 'Discovered'; + const bmp = _artMap.images[node.id]; + + body.innerHTML = ` + +
+
+ ${bmp ? `` + : (node.image_url ? `` : '')} +
+
${escapeHtml(node.name)}
+
${typeLabel}
+
+ ${genres.length ? `
+ ${genres.map(g => `${escapeHtml(g)}`).join('')} +
` : ''} +
+ ${_miniStat('Popularity', pop, hue)} + ${_miniStat('Connections', conn)} +
+
+
+
+
+ +
+ + +
+ ${best.id ? `Open artist page` : ''} +
`; + + if (bmp) { + const c = document.getElementById('artmap-card-canvas'); + if (c) { try { c.getContext('2d').drawImage(bmp, 0, 0, 120, 120); } catch (e) { /* ignore */ } } + } +} + +// Top-list / external entry: show a node's card by id (also ripples it on the map). +function _artMapPanelArtistById(id) { + const n = (_artMap._nodeById || {})[id]; + if (!n) return; + _artMapPanelArtist(n); + _artMapEmitRipple(n.x, n.y, n._hue); +} + async function openArtistMap() { const container = document.getElementById('artist-map-container'); if (!container) return; @@ -5428,6 +5598,7 @@ async function openArtistMap() { _artMapLayoutIslands(groups); _artMap.edges = _artMapRemapEdges(data.edges); _artMap._oneIsland = true; // focus one genre island at a time + _artMap._mapTitle = 'Watchlist Map'; // Setup interaction _artMapSetupInteraction(canvas); @@ -5533,6 +5704,7 @@ function closeArtistMap() { _artMap._oneIsland = false; const navEl = document.getElementById('artmap-island-nav'); if (navEl) navEl.remove(); + _artMapClosePanel(); if (_artMap._keyHandler) window.removeEventListener('keydown', _artMap._keyHandler); _artMapHideContextMenu(); @@ -6697,6 +6869,7 @@ async function _openGenreMapWithSelection(selectedGenre) { _artMapLayoutIslands(groups); _artMap.edges = []; _artMap._oneIsland = true; // focus one genre island at a time + _artMap._mapTitle = 'Genre Map'; const placedCount = _artMap.placed.filter(n => !n._isLabel).length; _artMapSetupInteraction(canvas); @@ -6805,8 +6978,10 @@ async function _openArtistMapExplorerWithName(name) { _artMapLayoutIslands(groups); _artMap.edges = _artMapRemapEdges(data.edges); _artMap._oneIsland = false; // explore stays multi-island (it's small) + _artMap._mapTitle = 'Explore: ' + (data.center || name); _artMapUpdateIslandNav(); // tear down any leftover nav from a prior map _artMapFitToContent(); + _artMapRefreshPanel(); _artMapSetupInteraction(canvas); @@ -7194,6 +7369,7 @@ function _artMapSetupInteraction(canvas) { _artMap.hoveredNode = _artMapHitTest(nx, ny); canvas.style.cursor = _artMap.hoveredNode ? 'pointer' : 'grab'; _artMapShowTooltip(e, _artMap.hoveredNode); + if (_artMap.hoveredNode) _artMapPanelArtist(_artMap.hoveredNode); // rich card in side panel if (prev !== _artMap.hoveredNode) { // Reset constellation highlight timer clearTimeout(_artMap._constellationTimer); From ed2a97d7010319b7b96c22a70dcf911ae4e3565b Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 13:40:00 -0700 Subject: [PATCH 25/42] Fix: artist detail forced Enhanced view on source-only artists, hiding discog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persisted Standard/Enhanced preference was re-applied on every artist load BEFORE the data came back — so for an artist not in the library (source-only, no Enhanced view) it still flipped to Enhanced, which showed an empty Enhanced pane and never rendered the discography. Now the preference is applied inside loadArtistDetailData, after we know the artist's status (data.artist.server_source). Only library artists honour a saved 'enhanced' choice; source-only artists always stay on Standard (discography). --- webui/static/library.js | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/webui/static/library.js b/webui/static/library.js index 2eae1a2c..e9850567 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -879,15 +879,6 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt const bulkBar = document.getElementById('enhanced-bulk-bar'); if (bulkBar) bulkBar.classList.remove('visible'); - // Restore persisted view preference. Non-admins can't see / toggle the - // Enhanced control so only honour the saved choice for admins; default - // is still Standard. Wrapped in try/catch because localStorage can be - // disabled in private-browsing modes. - let _preferEnhanced = false; - try { - _preferEnhanced = localStorage.getItem(_libraryViewModeKey()) === 'enhanced'; - } catch (_) { /* localStorage unavailable */ } - // Navigate to artist detail page navigateToPage('artist-detail', { artistId, @@ -902,15 +893,11 @@ function navigateToArtistDetail(artistId, artistName, sourceOverride = null, opt _updateArtistDetailBackButtonLabel(); - // Load artist data + // Load artist data. The persisted Enhanced-view preference is applied INSIDE + // loadArtistDetailData, once we know whether this artist is in the library — + // source-only artists have no Enhanced view, so forcing it there left the + // Enhanced pane empty and hid the discography. loadArtistDetailData(artistId, artistName); - - // Apply persisted Enhanced view after the standard data load is kicked off. - // toggleEnhancedView() triggers its own loadEnhancedViewData() in parallel; - // the brief Standard render is hidden as soon as the toggle flips. - if (_preferEnhanced && isEnhancedAdmin()) { - toggleEnhancedView(true); - } } function _updateArtistDetailBackButtonLabel() { @@ -1095,6 +1082,18 @@ async function loadArtistDetailData(artistId, artistName) { checkArtistEnhanceEligibility(artistDetailPageState.currentArtistId); } + // Apply the persisted Enhanced/Standard preference now that we know the + // artist's status. Only LIBRARY artists have an Enhanced view — forcing + // it on a source-only artist (no DB record) showed an empty Enhanced pane + // and hid the discography. Source-only artists always stay on Standard. + if (!isSourceOnlyArtist && isEnhancedAdmin()) { + let _preferEnhanced = false; + try { + _preferEnhanced = localStorage.getItem(_libraryViewModeKey()) === 'enhanced'; + } catch (_) { /* localStorage unavailable */ } + if (_preferEnhanced) toggleEnhancedView(true); + } + } catch (error) { console.error(`❌ Error loading artist detail data:`, error); From 409595974e8c024574c2f1fb1e6259c29f95ff28 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 13:49:56 -0700 Subject: [PATCH 26/42] =?UTF-8?q?Artist=20Map=20v2:=20bring=20the=20info?= =?UTF-8?q?=20panel=20to=20life=20=E2=80=94=20live=20watchlist=20state,=20?= =?UTF-8?q?debounced=20select,=20navbar=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Watchlist button now reflects real state: shows 'On watchlist' (filled) vs 'Watchlist' (outline), confirmed per-artist via /api/watchlist/check, and flips instantly when you add/remove (cached in _watchSet so it stays correct as you browse). Uses the artist's source id, works on any map. - Debounced hover-select: the card only swaps to a bubble you've settled on for ~0.8s, so sweeping toward the panel no longer keeps changing the card on bubbles you pass over. Clicking a bubble selects it instantly (bypasses the debounce, pins the card) instead of auto-opening the modal — Details button still opens it. - Fix: panel started at top:0 and covered the navbar; now it starts below the .artist-map-toolbar (measured) so the toolbar stays clear. 64 tests pass. --- webui/static/discover.js | 108 +++++++++++++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 10 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index e85404bc..b601274f 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5394,6 +5394,73 @@ function _artMapConnCount(n) { return c; } +// ── Watchlist state for the panel card ── +function _artMapIsWatched(n) { + if (!n) return false; + if (n.type === 'watchlist') return true; + const best = _artMapNodeBest(n); + return !!(best.id && _artMap._watchSet && _artMap._watchSet.has(best.id)); +} + +// The watchlist button's markup, reflecting current state (filled star + "On +// watchlist" when watched, outline + "Watchlist" when not). +function _artMapWatchBtnHtml(n) { + const watched = _artMapIsWatched(n); + const idArg = typeof n.id === 'number' ? n.id : `'${n.id}'`; + return ``; +} + +function _artMapRenderWatchBtn(n) { + const b = document.getElementById('artmap-card-watch'); + if (b) b.outerHTML = _artMapWatchBtnHtml(n); // inline onclick survives outerHTML swap +} + +// Toggle watchlist membership for a node, updating the cache + button in place. +async function _artMapToggleWatch(id) { + const n = (_artMap._nodeById || {})[id]; + if (!n) return; + const best = _artMapNodeBest(n); + if (!best.id) { showToast('No source id for this artist', 'error'); return; } + _artMap._watchSet = _artMap._watchSet || new Set(); + const watched = _artMapIsWatched(n); + try { + const resp = await fetch(watched ? '/api/watchlist/remove' : '/api/watchlist/add', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(watched ? { artist_id: best.id } : { artist_id: best.id, artist_name: n.name, source: best.source }), + }); + if (!resp.ok) { showToast('Failed to update watchlist', 'error'); return; } + if (watched) { + _artMap._watchSet.delete(best.id); + if (n.type === 'watchlist') n.type = 'similar'; + } else { + _artMap._watchSet.add(best.id); + } + showToast(watched ? `Removed ${n.name} from watchlist` : `Added ${n.name} to watchlist`, watched ? 'info' : 'success'); + _artMapRenderWatchBtn(n); + } catch (e) { + showToast('Failed to update watchlist', 'error'); + } +} + +// Lazily confirm watchlist membership from the server, then refresh the button. +function _artMapCheckWatched(n) { + const best = _artMapNodeBest(n); + _artMap._watchSet = _artMap._watchSet || new Set(); + _artMap._watchChecked = _artMap._watchChecked || new Set(); + if (!best.id || _artMap._watchChecked.has(best.id)) return; + fetch('/api/watchlist/check', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: best.id }), + }).then(r => r.json()).then(d => { + _artMap._watchChecked.add(best.id); + if (d && d.success) { + if (d.is_watching) _artMap._watchSet.add(best.id); else _artMap._watchSet.delete(best.id); + if (_artMap._panelArtistId === n.id) _artMapRenderWatchBtn(n); + } + }).catch(() => { }); +} + function _artMapEnsurePanel() { const container = document.getElementById('artist-map-container'); if (!container) return null; @@ -5401,14 +5468,18 @@ function _artMapEnsurePanel() { if (!p) { p = document.createElement('div'); p.id = 'artmap-info-panel'; - p.style.cssText = `position:absolute;top:0;right:0;width:${_artMap._panelW}px;height:100%;` - + `background:linear-gradient(180deg,rgba(20,15,34,0.92),rgba(11,8,20,0.96));backdrop-filter:blur(16px);` - + `border-left:1px solid rgba(168,85,247,0.18);z-index:26;display:flex;flex-direction:column;` - + `color:#fff;overflow:hidden;box-shadow:-10px 0 36px rgba(0,0,0,0.45);font-size:13px;`; p.innerHTML = `
` + `
`; container.appendChild(p); } + // Start below the toolbar so it never covers the navbar (measured each call + // in case the toolbar height changes). + const tb = container.querySelector('.artist-map-toolbar'); + const top = tb ? tb.offsetHeight : 56; + p.style.cssText = `position:absolute;top:${top}px;right:0;width:${_artMap._panelW}px;height:calc(100% - ${top}px);` + + `background:linear-gradient(180deg,rgba(20,15,34,0.92),rgba(11,8,20,0.96));backdrop-filter:blur(16px);` + + `border-left:1px solid rgba(168,85,247,0.18);z-index:20;display:flex;flex-direction:column;` + + `color:#fff;overflow:hidden;box-shadow:-10px 0 36px rgba(0,0,0,0.45);font-size:13px;`; return p; } @@ -5524,7 +5595,7 @@ function _artMapPanelArtist(node) {
- + ${_artMapWatchBtnHtml(node)}
${best.id ? `Open artist page` : ''}
`; @@ -5533,6 +5604,10 @@ function _artMapPanelArtist(node) { const c = document.getElementById('artmap-card-canvas'); if (c) { try { c.getContext('2d').drawImage(bmp, 0, 0, 120, 120); } catch (e) { /* ignore */ } } } + + // Confirm watchlist membership from the server (refreshes the button if it + // differs from the optimistic guess). + _artMapCheckWatched(node); } // Top-list / external entry: show a node's card by id (also ripples it on the map). @@ -7369,7 +7444,18 @@ function _artMapSetupInteraction(canvas) { _artMap.hoveredNode = _artMapHitTest(nx, ny); canvas.style.cursor = _artMap.hoveredNode ? 'pointer' : 'grab'; _artMapShowTooltip(e, _artMap.hoveredNode); - if (_artMap.hoveredNode) _artMapPanelArtist(_artMap.hoveredNode); // rich card in side panel + if (prev !== _artMap.hoveredNode) { + // Debounce the side-panel card: only swap to a bubble you've + // settled on for ~0.8s, so sweeping toward the panel doesn't keep + // changing the card on bubbles you pass over en route. + clearTimeout(_artMap._panelTimer); + if (_artMap.hoveredNode) { + const target = _artMap.hoveredNode; + _artMap._panelTimer = setTimeout(() => { + if (_artMap.hoveredNode === target) _artMapPanelArtist(target); + }, 800); + } + } if (prev !== _artMap.hoveredNode) { // Reset constellation highlight timer clearTimeout(_artMap._constellationTimer); @@ -7400,13 +7486,15 @@ function _artMapSetupInteraction(canvas) { isPanning = false; if (!wasDrag && clickStart) { - // It was a click — emit a water ripple (shoves nearby bubbles) and, - // if it landed on an artist, open it after the ripple kicks off. + // A click is a deliberate select — ripple it and pin its card in the + // side panel immediately (bypassing the hover debounce). The card's + // Details button opens the full modal; click no longer auto-opens it. const { nx, ny } = _artMapScreenToWorld(e, canvas); const node = _artMapHitTest(nx, ny); _artMapEmitRipple(node ? node.x : nx, node ? node.y : ny, node ? node._hue : null); - if (node && (node.spotify_id || node.itunes_id || node.deezer_id)) { - setTimeout(() => openYourArtistInfoModal_direct(node), 200); + if (node) { + clearTimeout(_artMap._panelTimer); + _artMapPanelArtist(node); } } From e00589f40a1363b198e230878c111b9ae3296ae6 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 14:00:07 -0700 Subject: [PATCH 27/42] Artist Map v2: mobile responsive (bottom-sheet panel, full-width map, resize reflow) - Info panel becomes a bottom SHEET on phones (<=760px): slides up when you tap a bubble, doesn't steal map width (islands frame full-width via _artMapReservedW = 0 on mobile). Grip/handle to dismiss; a floating menu FAB opens it to the dashboard + top-artists. Desktop stays the right sidebar. - Genre sidebar hidden on mobile (the top-left quick-jump nav handles genre switching; no room for a sidebar). - Touch tap now selects a bubble (card in the sheet) instead of opening the modal, matching desktop click; ignores taps that were drags. - Resize/orientation: debounced reflow that re-styles the panel for the new breakpoint, recomputes canvas size (minus sidebar/toolbar), and re-frames the focused island / fit. 64 JS integrity tests pass. --- webui/static/discover.js | 137 +++++++++++++++++++++++++++++++-------- 1 file changed, 111 insertions(+), 26 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index b601274f..cd8b9270 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5230,7 +5230,7 @@ function _artMapFitToContent(marginPx = 120) { minY = Math.min(minY, n.y - n.radius); maxY = Math.max(maxY, n.y + n.radius); } if (!isFinite(minX)) return; - const usableW = Math.max(200, _artMap.width - _artMap._panelW); + const usableW = Math.max(200, _artMap.width - _artMapReservedW()); const mapW = maxX - minX + marginPx * 2, mapH = maxY - minY + marginPx * 2; _artMap.zoom = Math.min(usableW / mapW, _artMap.height / mapH, 1); _artMap.offsetX = usableW / 2 - ((minX + maxX) / 2) * _artMap.zoom; @@ -5259,7 +5259,7 @@ function _artMapFocusIsland(idx, opts = {}) { } // Frame the island in the space LEFT of the info panel (~80% of it). - const usableW = Math.max(200, _artMap.width - _artMap._panelW); + const usableW = Math.max(200, _artMap.width - _artMapReservedW()); const span = (isl.r * 2.3) + 120; const z = Math.min(usableW / span, _artMap.height / span, 1.2); _artMap.zoom = z; @@ -5380,7 +5380,18 @@ function _artMapJumpIsland(i) { // ── Right-side info panel ────────────────────────────────────────────────── // A polished detail panel: a discovery dashboard + current-view coverage at the // top, a clickable top-artists list, and a rich artist card when you hover/click -// a bubble. Lives on the right so it never collides with the genre sidebar. +// a bubble. On desktop it's a right sidebar; on mobile it's a bottom sheet that +// slides up on tap and doesn't steal map width. + +function _artMapIsMobile() { + return (window.innerWidth || document.documentElement.clientWidth || 9999) <= 760; +} + +// Horizontal space the panel reserves when framing islands — none on mobile +// (the bottom sheet overlays instead of sitting beside the map). +function _artMapReservedW() { + return _artMapIsMobile() ? 0 : _artMap._panelW; +} function _artMapNodeBest(n) { const map = [['spotify_id', 'spotify'], ['itunes_id', 'itunes'], ['deezer_id', 'deezer'], ['discogs_id', 'discogs'], ['musicbrainz_id', 'musicbrainz']]; @@ -5468,25 +5479,74 @@ function _artMapEnsurePanel() { if (!p) { p = document.createElement('div'); p.id = 'artmap-info-panel'; - p.innerHTML = `
` - + `
`; + p.innerHTML = `
` + + `
` + + `
`; container.appendChild(p); } - // Start below the toolbar so it never covers the navbar (measured each call - // in case the toolbar height changes). - const tb = container.querySelector('.artist-map-toolbar'); - const top = tb ? tb.offsetHeight : 56; - p.style.cssText = `position:absolute;top:${top}px;right:0;width:${_artMap._panelW}px;height:calc(100% - ${top}px);` - + `background:linear-gradient(180deg,rgba(20,15,34,0.92),rgba(11,8,20,0.96));backdrop-filter:blur(16px);` - + `border-left:1px solid rgba(168,85,247,0.18);z-index:20;display:flex;flex-direction:column;` - + `color:#fff;overflow:hidden;box-shadow:-10px 0 36px rgba(0,0,0,0.45);font-size:13px;`; + const base = `background:linear-gradient(180deg,rgba(20,15,34,0.95),rgba(11,8,20,0.97));backdrop-filter:blur(16px);` + + `z-index:22;display:flex;flex-direction:column;color:#fff;overflow:hidden;font-size:13px;`; + const grip = document.getElementById('artmap-panel-grip'); + if (_artMapIsMobile()) { + // Bottom sheet — overlays the map, slides up/down, doesn't steal width. + const open = !!_artMap._panelOpen; + p.style.cssText = base + `position:absolute;left:0;right:0;bottom:0;width:auto;max-height:62%;` + + `border-top:1px solid rgba(168,85,247,0.25);border-radius:18px 18px 0 0;` + + `box-shadow:0 -10px 36px rgba(0,0,0,0.5);transition:transform 0.28s cubic-bezier(.4,0,.2,1);` + + `transform:translateY(${open ? '0' : '100%'});`; + if (grip) grip.style.cssText = `flex:none;height:22px;display:flex;align-items:center;justify-content:center;cursor:grab;` + + `position:relative;` ; + if (grip && !grip.dataset.wired) { + grip.dataset.wired = '1'; + grip.onclick = () => _artMapTogglePanelSheet(false); + grip.innerHTML = ``; + } + } else { + // Right sidebar — below the toolbar so it never covers the navbar. + const tb = container.querySelector('.artist-map-toolbar'); + const top = tb ? tb.offsetHeight : 56; + p.style.cssText = base + `position:absolute;top:${top}px;right:0;width:${_artMap._panelW}px;height:calc(100% - ${top}px);` + + `border-left:1px solid rgba(168,85,247,0.18);box-shadow:-10px 0 36px rgba(0,0,0,0.45);transform:none;`; + if (grip) grip.style.display = 'none'; + } return p; } +// Mobile bottom-sheet open/close. +function _artMapTogglePanelSheet(open) { + _artMap._panelOpen = open; + const p = document.getElementById('artmap-info-panel'); + if (p && _artMapIsMobile()) p.style.transform = `translateY(${open ? '0' : '100%'})`; + _artMapEnsureStatsFab(); +} + +// A floating button (mobile only) to open the panel to the dashboard/top list. +function _artMapEnsureStatsFab() { + const container = document.getElementById('artist-map-container'); + if (!container) return; + let fab = document.getElementById('artmap-stats-fab'); + if (!_artMapIsMobile()) { if (fab) fab.remove(); return; } + if (!fab) { + fab = document.createElement('button'); + fab.id = 'artmap-stats-fab'; + fab.innerHTML = '☰'; + fab.title = 'View stats & artists'; + fab.style.cssText = `position:absolute;right:14px;bottom:14px;width:46px;height:46px;border-radius:50%;` + + `background:linear-gradient(135deg,#7c3aed,#a855f7);border:none;color:#fff;font-size:18px;` + + `box-shadow:0 6px 20px rgba(0,0,0,0.5);z-index:21;cursor:pointer;`; + fab.onclick = () => { _artMap._panelArtistId = null; _artMapRefreshPanel(); _artMapTogglePanelSheet(true); }; + container.appendChild(fab); + } + fab.style.display = _artMap._panelOpen ? 'none' : 'flex'; +} + function _artMapClosePanel() { const p = document.getElementById('artmap-info-panel'); if (p) p.remove(); + const fab = document.getElementById('artmap-stats-fab'); + if (fab) fab.remove(); _artMap._panelArtistId = null; + _artMap._panelOpen = false; } // Stat tile helper @@ -5501,6 +5561,7 @@ function _miniStat(label, value, hue) { function _artMapRefreshPanel() { const p = _artMapEnsurePanel(); if (!p) return; + _artMapEnsureStatsFab(); const head = document.getElementById('artmap-panel-head'); const body = document.getElementById('artmap-panel-body'); if (!head || !body) return; @@ -5608,6 +5669,9 @@ function _artMapPanelArtist(node) { // Confirm watchlist membership from the server (refreshes the button if it // differs from the optimistic guess). _artMapCheckWatched(node); + + // On mobile, sliding the bottom sheet up reveals the card. + if (_artMapIsMobile()) _artMapTogglePanelSheet(true); } // Top-list / external entry: show a node's card by id (also ripples it on the map). @@ -6852,10 +6916,13 @@ async function _openGenreMapWithSelection(selectedGenre) { } container.style.display = 'flex'; - // Show + populate genre sidebar + // Show + populate genre sidebar (hidden on mobile — the top-left quick-jump + // nav handles genre switching there, and there's no room for a sidebar). const sidebar = document.getElementById('artmap-genre-sidebar'); const genreListData = window._artMapGenreList || window._artMapGenreData; - if (sidebar && genreListData?.genres) { + if (sidebar && _artMapIsMobile()) { + sidebar.style.display = 'none'; + } else if (sidebar && genreListData?.genres) { sidebar.style.display = 'flex'; const list = document.getElementById('artmap-genre-sidebar-list'); if (list) { @@ -7556,25 +7623,43 @@ function _artMapSetupInteraction(canvas) { const wx = (t.clientX - rect.left - _artMap.offsetX) / _artMap.zoom; const wy = (t.clientY - rect.top - _artMap.offsetY) / _artMap.zoom; const node = _artMapHitTest(wx, wy); - _artMapEmitRipple(node ? node.x : wx, node ? node.y : wy, node ? node._hue : null); - if (node && (node.spotify_id || node.itunes_id || node.deezer_id)) { - openYourArtistInfoModal_direct(node); + const moved = Math.abs(t.clientX - (lastTouches[0].clientX)) > 8 || Math.abs(t.clientY - (lastTouches[0].clientY)) > 8; + if (!moved) { + _artMapEmitRipple(node ? node.x : wx, node ? node.y : wy, node ? node._hue : null); + if (node) _artMapPanelArtist(node); // tap selects → card in the bottom sheet } } lastTouches = null; }, { passive: false }); - // Handle resize + // Handle resize / orientation change — debounced, and re-frames for the new + // breakpoint (mobile bottom-sheet ⇄ desktop sidebar). window.addEventListener('resize', () => { const container = document.getElementById('artist-map-container'); if (!container || container.style.display === 'none') return; - _artMap.width = container.clientWidth; - _artMap.height = container.clientHeight - 50; - canvas.width = _artMap.width * window.devicePixelRatio; - canvas.height = _artMap.height * window.devicePixelRatio; - canvas.style.width = _artMap.width + 'px'; - canvas.style.height = _artMap.height + 'px'; - _artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + clearTimeout(_artMap._resizeTimer); + _artMap._resizeTimer = setTimeout(() => { + if (container.style.display === 'none') return; + const sb = document.getElementById('artmap-genre-sidebar'); + const sbW = (sb && sb.style.display !== 'none') ? (sb.offsetWidth || 0) : 0; + const tb = container.querySelector('.artist-map-toolbar'); + _artMap.width = Math.max(120, container.clientWidth - sbW); + _artMap.height = Math.max(120, container.clientHeight - (tb ? tb.offsetHeight : 50)); + canvas.width = _artMap.width * window.devicePixelRatio; // resets ctx transform + canvas.height = _artMap.height * window.devicePixelRatio; + canvas.style.width = _artMap.width + 'px'; + canvas.style.height = _artMap.height + 'px'; + _artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + _artMapEnsurePanel(); // re-style sidebar ⇄ bottom sheet + if (_artMap._oneIsland && (_artMap._islands || []).length) { + _artMapFocusIsland(_artMap._focusIdx || 0, { bloom: false }); + } else { + _artMapFitToContent(); + _artMapRefreshPanel(); + } + _artMap.dirty = true; + _artMapRender(); + }, 160); }); } From c5e12b904f98a54878010ed72e18083a202736ce Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 14:14:07 -0700 Subject: [PATCH 28/42] Artist Map v2: mobile-responsive toolbar/header - Toolbar wraps on phones (<=760px): back + title + stats and the compact tools stay on row 1, the search drops to its own full-width row below so nothing gets crushed. Brand text hidden, stats truncate with ellipsis. - Island nav + canvas height now MEASURE the toolbar height instead of assuming ~50px, so the taller wrapped header doesn't overlap the nav or clip the canvas. 64 JS integrity tests pass. --- webui/static/discover.js | 13 +++++++++---- webui/static/style.css | 13 +++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index cd8b9270..19ee8809 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5322,10 +5322,13 @@ function _artMapUpdateIslandNav() { nav.id = 'artmap-island-nav'; container.appendChild(nav); } - // Position top-left, clearing the genre sidebar (when shown) and the toolbar. + // Position top-left, clearing the genre sidebar (when shown) and the toolbar + // (measured — the toolbar wraps taller on mobile). const sb = document.getElementById('artmap-genre-sidebar'); const sbW = (sb && sb.style.display !== 'none') ? (sb.offsetWidth || 0) : 0; - nav.style.cssText = `position:absolute;top:64px;left:${sbW + 16}px;display:flex;align-items:center;gap:12px;padding:7px 12px;background:rgba(16,12,28,0.82);backdrop-filter:blur(10px);border:1px solid rgba(168,85,247,0.25);border-radius:14px;z-index:30;box-shadow:0 6px 24px rgba(0,0,0,0.45);user-select:none;`; + const tb = document.querySelector('.artist-map-toolbar'); + const top = (tb ? tb.offsetHeight : 56) + 10; + nav.style.cssText = `position:absolute;top:${top}px;left:${sbW + 16}px;display:flex;align-items:center;gap:12px;padding:7px 12px;background:rgba(16,12,28,0.82);backdrop-filter:blur(10px);border:1px solid rgba(168,85,247,0.25);border-radius:14px;z-index:30;box-shadow:0 6px 24px rgba(0,0,0,0.45);user-select:none;max-width:calc(100vw - ${sbW + 32}px);`; const idx = _artMap._focusIdx || 0; const isl = islands[idx]; const btn = 'width:30px;height:30px;border-radius:50%;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.06);color:#fff;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;flex:none;'; @@ -5697,7 +5700,8 @@ async function openArtistMap() { _artMap.canvas = canvas; _artMap.ctx = canvas.getContext('2d'); _artMap.width = container.clientWidth; - _artMap.height = container.clientHeight - 50; + const _wtb = container.querySelector('.artist-map-toolbar'); + _artMap.height = container.clientHeight - (_wtb ? _wtb.offsetHeight : 50); canvas.width = _artMap.width * window.devicePixelRatio; canvas.height = _artMap.height * window.devicePixelRatio; canvas.style.width = _artMap.width + 'px'; @@ -7067,7 +7071,8 @@ async function _openArtistMapExplorerWithName(name) { _artMap.canvas = canvas; _artMap.ctx = canvas.getContext('2d'); _artMap.width = container.clientWidth; - _artMap.height = container.clientHeight - 50; + const _wtb = container.querySelector('.artist-map-toolbar'); + _artMap.height = container.clientHeight - (_wtb ? _wtb.offsetHeight : 50); canvas.width = _artMap.width * window.devicePixelRatio; canvas.height = _artMap.height * window.devicePixelRatio; canvas.style.width = _artMap.width + 'px'; diff --git a/webui/static/style.css b/webui/static/style.css index 35afbdef..3abe87c9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -34248,6 +34248,19 @@ div.artist-hero-badge { .artmap-tool-btn.artmap-zoom { border: none; background: none; width: 30px; padding: 0; justify-content: center; border-radius: 6px; } .artmap-tool-btn.artmap-zoom:hover { background: rgba(255,255,255,0.06); } +/* Mobile: the toolbar wraps — back/title/stats + compact tools on row 1, the + search drops to its own full-width row below so nothing gets crushed. */ +@media (max-width: 760px) { + .artist-map-toolbar { flex-wrap: wrap; padding: 8px 10px; gap: 8px; } + .artmap-nav-left { flex: 1 1 auto; min-width: 0; gap: 6px; } + .artmap-brand-text { display: none; } + .artmap-brand { flex: none; } + .artmap-stats { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .artmap-nav-right { flex: 0 0 auto; gap: 3px; } + .artmap-nav-center { order: 3; flex: 1 1 100%; max-width: none; margin: 0; } + .artmap-search-wrap { width: 100%; } +} + /* Shortcuts modal */ .artmap-shortcuts-modal { background: rgba(20,20,32,0.95); border-radius: 14px; width: 340px; max-width: 90vw; From c8626b1e83feea3caa7a6b2458ce89fc6b8c1e75 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 14:32:44 -0700 Subject: [PATCH 29/42] Artist Map v2: offset toolbar back button clear of the mobile hamburger menu The fixed hamburger (top:16 left:16, 44px, z9999) sat on top of the map's back button on mobile. Push .artmap-nav-left right by 52px on <=760px so the back button clears it. --- webui/static/style.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webui/static/style.css b/webui/static/style.css index 3abe87c9..d50d70e9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -34252,7 +34252,9 @@ div.artist-hero-badge { search drops to its own full-width row below so nothing gets crushed. */ @media (max-width: 760px) { .artist-map-toolbar { flex-wrap: wrap; padding: 8px 10px; gap: 8px; } - .artmap-nav-left { flex: 1 1 auto; min-width: 0; gap: 6px; } + /* Clear the fixed hamburger menu (top:16 left:16, 44px wide) so the map's + back button isn't hidden under it. */ + .artmap-nav-left { flex: 1 1 auto; min-width: 0; gap: 6px; margin-left: 52px; } .artmap-brand-text { display: none; } .artmap-brand { flex: none; } .artmap-stats { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } From 89e3486e8460da59246398215fe6eb14e0de6f8f Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 15:07:49 -0700 Subject: [PATCH 30/42] =?UTF-8?q?Similar=20Artists=20enrichment=20worker?= =?UTF-8?q?=20(MusicMap=20=E2=86=92=20match=20=E2=86=92=20store)=20for=20l?= =?UTF-8?q?ibrary=20artists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap where similar artists only existed for WATCHLIST artists: a new background worker populates them for the whole LIBRARY, slotting into the existing enrichment-worker pattern (bubble + Manage Enrichment Workers modal, status/pause/resume, matched/not_found/pending/errors). Per source-matched library artist → get_musicmap_similar_artists(name, 25) (the same matcher the artist-detail page uses: fetches MusicMap names, matches each to the user's source chain — primary + active fallbacks — returns only matched artists) → store via add_or_update_similar_artist keyed by the artist's metadata source id, the SAME key the watchlist scanner + artist map use, so the two cooperate (idempotent upsert + retry_days window). - core/similar_artists_worker.py: pure seams (pick_source_artist_id, map_payload_to_store_kwargs, process_artist) + the threaded worker; skips artists not yet source-matched; classifies not_found vs transient error (retry after 30d). - DB migration: similar_artists_match_status / _last_attempted on artists (mirrors every other source worker's tracking columns). - Registered in EnrichmentService + instantiated in web_server, DEFAULT-PAUSED (opt-in) like Amazon — MusicMap is scraped/outage-prone + this is library-wide. - SERVICE_ENTITY_SUPPORT['similar_artists']=('artist',) so the modal breakdown ('artists with / without similars') + Retry work; manual-match (inapplicable to a relationship) is gated out via relationship:true. - 10 seam tests; existing 80 enrichment tests still pass. Note: keys under profile 1 (single-profile setups); multi-profile is future work. --- core/enrichment/unmatched.py | 6 + core/similar_artists_worker.py | 296 +++++++++++++++++++++++++++ database/music_database.py | 25 +++ tests/test_similar_artists_worker.py | 127 ++++++++++++ web_server.py | 26 +++ webui/static/enrichment-manager.js | 13 +- 6 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 core/similar_artists_worker.py create mode 100644 tests/test_similar_artists_worker.py diff --git a/core/enrichment/unmatched.py b/core/enrichment/unmatched.py index 1bcd5eae..0d63ac79 100644 --- a/core/enrichment/unmatched.py +++ b/core/enrichment/unmatched.py @@ -32,6 +32,12 @@ SERVICE_ENTITY_SUPPORT = { 'tidal': ('artist', 'album', 'track'), 'qobuz': ('artist', 'album', 'track'), 'amazon': ('artist', 'album', 'track'), + # Relationship enrichment (not a metadata source): the Similar Artists worker + # only operates at the artist level, and its _match_status tracks + # whether MusicMap similars were fetched (not a source-id match). So the + # breakdown / unmatched list here means "artists we have / don't have + # similars for" — informative, even though there's no manual-match action. + 'similar_artists': ('artist',), } # entity_type -> table / display-name column / image expression / optional join diff --git a/core/similar_artists_worker.py b/core/similar_artists_worker.py new file mode 100644 index 00000000..93db6416 --- /dev/null +++ b/core/similar_artists_worker.py @@ -0,0 +1,296 @@ +"""Background worker that fills the ``similar_artists`` table for LIBRARY artists. + +The watchlist scanner only populates similar artists for *watchlist* artists, so +the artist map / discover surfaces are rich for watchlisted artists and sparse +for the rest of the library. This worker closes that gap: for every +source-matched library artist it asks MusicMap for ~25 similar artists, matches +each one to the user's metadata source chain (primary + active fallbacks) via the +shared :func:`core.metadata.similar_artists.get_musicmap_similar_artists`, and +stores the matched results keyed by the library artist's **metadata source id** — +the same key the watchlist scanner and the artist map use, so the two cooperate +(idempotent upsert + a retry window keep them from double-fetching). + +It plugs into the existing enrichment-worker pattern (background thread, status / +pause / resume, ``matched / not_found / pending / errors`` stats) so it shows up +as a bubble in the dashboard / Manage Enrichment Workers modal like every other +source worker. + +The pure seams below (:func:`pick_source_artist_id`, +:func:`map_payload_to_store_kwargs`, :func:`process_artist`) carry the logic and +are unit-tested in isolation; the class wires them to the DB + MusicMap. +""" + +from __future__ import annotations + +import threading +from typing import Any, Callable, Dict, Optional + +from utils.logging_config import get_logger +from core.worker_utils import interruptible_sleep + +logger = get_logger("similar_artists_worker") + + +# A matched MusicMap payload is {id, source, name, image_url, genres, popularity}. +# Map its single (id, source) onto the right add_or_update_similar_artist id +# kwarg. The table only has columns for these four providers; a match on any +# other source (e.g. discogs) is still stored, name-only. +_SOURCE_ID_FIELD = { + 'spotify': 'similar_artist_spotify_id', + 'itunes': 'similar_artist_itunes_id', + 'deezer': 'similar_artist_deezer_id', + 'musicbrainz': 'similar_artist_musicbrainz_id', +} + +# Library-artist source-id columns, in the same priority the watchlist scanner +# uses to key its rows — so a library artist and (if also watchlisted) its +# watchlist row resolve to the SAME source_artist_id and don't duplicate work. +_LIBRARY_ID_COLUMNS = ('spotify_artist_id', 'itunes_artist_id', 'deezer_id', 'musicbrainz_id') + + +def map_payload_to_store_kwargs(payload: Dict[str, Any]) -> Dict[str, Optional[str]]: + """Turn a matched MusicMap payload into the id kwarg for the store call.""" + src = str(payload.get('source') or '').lower() + pid = str(payload.get('id') or '') + field = _SOURCE_ID_FIELD.get(src) + return {field: pid} if (field and pid) else {} + + +def pick_source_artist_id(row: Dict[str, Any]) -> Optional[str]: + """The metadata source id to key a library artist's similars by, or None if + the artist isn't matched to any metadata source yet (→ skip it).""" + for key in _LIBRARY_ID_COLUMNS: + v = row.get(key) + if v: + return str(v) + return None + + +def process_artist( + source_artist_id: str, + artist_name: str, + fetch_similars: Callable[[str, int], Dict[str, Any]], + store_similar: Callable[..., bool], + limit: int = 25, + profile_id: int = 1, +) -> tuple: + """Fetch + store similar artists for one library artist. + + ``fetch_similars(name, limit)`` returns the ``get_musicmap_similar_artists`` + payload; ``store_similar(**kwargs)`` is ``add_or_update_similar_artist``. + Returns ``(status, stored_count)`` where status is one of: + - ``'matched'`` — stored ≥1 similar artist + - ``'not_found'`` — MusicMap had no entry / nothing matched a source + - ``'error'`` — MusicMap/source failure (transient; eligible for retry) + """ + try: + result = fetch_similars(artist_name, limit) or {} + except Exception as exc: + logger.debug("MusicMap fetch raised for %s: %s", artist_name, exc) + return ('error', 0) + + if not result.get('success'): + # 404/400 = genuinely no MusicMap entry → 'not_found' (don't keep retrying); + # anything else (timeout, 5xx, no providers) = transient → 'error' (retry). + code = result.get('status_code') + return ('not_found' if code in (400, 404) else 'error', 0) + + sims = result.get('similar_artists') or [] + if not sims: + return ('not_found', 0) + + stored = 0 + for rank, s in enumerate(sims, 1): + name = s.get('name') + if not name: + continue + kwargs = map_payload_to_store_kwargs(s) + try: + ok = store_similar( + source_artist_id=source_artist_id, + similar_artist_name=name, + similarity_rank=rank, + profile_id=profile_id, + image_url=s.get('image_url'), + genres=s.get('genres'), + popularity=s.get('popularity', 0) or 0, + **kwargs, + ) + if ok: + stored += 1 + except Exception as exc: + logger.debug("store similar failed for %s: %s", name, exc) + + return ('matched' if stored else 'not_found', stored) + + +class SimilarArtistsWorker: + """Background worker that populates similar artists for library artists.""" + + def __init__(self, database): + self.db = database + self.running = False + self.paused = False + self.should_stop = False + self.thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + self.current_item: Optional[str] = None + self.stats = {'matched': 0, 'not_found': 0, 'pending': 0, 'errors': 0} + self.retry_days = 30 + self.limit = 25 + # similar_artists rows are profile-scoped; the library is shared. v1 keys + # under the default profile (matches single-profile setups, which is the + # common case). Multi-profile per-source-chain population is future work. + self.profile_id = 1 + logger.info("Similar Artists background worker initialized") + + # ── lifecycle (mirrors the other enrichment workers) ────────────────── + def start(self): + if self.running: + return + self.running = True + self.should_stop = False + self._stop_event.clear() + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + logger.info("Similar Artists worker started") + + def stop(self): + self.should_stop = True + self.running = False + self._stop_event.set() + + def pause(self): + self.paused = True + + def resume(self): + self.paused = False + + def get_stats(self) -> Dict[str, Any]: + try: + self.stats['pending'] = self._count_pending() + except Exception: + pass + is_running = self.running and (self.thread is not None and self.thread.is_alive()) + is_idle = is_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None + return { + 'enabled': True, + 'running': is_running and not self.paused, + 'paused': self.paused, + 'idle': is_idle, + 'current_item': self.current_item, + 'stats': self.stats.copy(), + 'progress': {}, + } + + # ── worker loop ──────────────────────────────────────────────────────── + def _run(self): + logger.info("Similar Artists worker thread started") + # Imported lazily so the worker module stays import-light for tests. + from core.metadata.similar_artists import get_musicmap_similar_artists + + while not self.should_stop: + try: + if self.paused: + interruptible_sleep(self._stop_event, 1) + continue + + self.current_item = None + artist = self._get_next_artist() + if not artist: + interruptible_sleep(self._stop_event, 15) + continue + + sid = pick_source_artist_id(artist) + if not sid: + # Query already filters to artists with a source id; guard anyway. + self._mark(artist['id'], 'error') + continue + + self.current_item = artist.get('name') + status, count = process_artist( + sid, artist['name'], + get_musicmap_similar_artists, + self.db.add_or_update_similar_artist, + limit=self.limit, profile_id=self.profile_id, + ) + self._mark(artist['id'], status) + if status == 'matched': + self.stats['matched'] += 1 + logger.debug("Similar artists: %s → stored %d", artist['name'], count) + elif status == 'not_found': + self.stats['not_found'] += 1 + else: + self.stats['errors'] += 1 + + # Pace MusicMap (name search per candidate is heavy + rate-limited). + interruptible_sleep(self._stop_event, 3) + except Exception as exc: + logger.error("Similar Artists worker loop error: %s", exc) + interruptible_sleep(self._stop_event, 5) + + logger.info("Similar Artists worker thread finished") + + # ── DB helpers (thin; the testable logic lives in the pure seams above) ── + def _has_source_id_clause(self) -> str: + return '(' + ' OR '.join(f"{c} IS NOT NULL AND {c} != ''" for c in _LIBRARY_ID_COLUMNS) + ')' + + def _get_next_artist(self) -> Optional[Dict[str, Any]]: + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cols = "id, name, " + ", ".join(_LIBRARY_ID_COLUMNS) + have_id = self._has_source_id_clause() + # 1) Unattempted source-matched artists. + cursor.execute(f""" + SELECT {cols} FROM artists + WHERE id IS NOT NULL AND name IS NOT NULL + AND similar_artists_match_status IS NULL + AND {have_id} + ORDER BY id ASC LIMIT 1 + """) + row = cursor.fetchone() + # 2) Retry transient failures (and re-check 'not_found') after retry_days. + if not row: + cursor.execute(f""" + SELECT {cols} FROM artists + WHERE id IS NOT NULL AND name IS NOT NULL + AND similar_artists_match_status IN ('error', 'not_found') + AND (similar_artists_last_attempted IS NULL + OR similar_artists_last_attempted < datetime('now', ?)) + AND {have_id} + ORDER BY similar_artists_last_attempted ASC LIMIT 1 + """, (f'-{self.retry_days} days',)) + row = cursor.fetchone() + if not row: + return None + keys = ['id', 'name'] + list(_LIBRARY_ID_COLUMNS) + return dict(zip(keys, row)) + except Exception as exc: + logger.debug("Similar Artists _get_next_artist failed: %s", exc) + return None + + def _mark(self, artist_id, status: str): + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE artists SET similar_artists_match_status = ?, " + "similar_artists_last_attempted = CURRENT_TIMESTAMP WHERE id = ?", + (status, artist_id), + ) + conn.commit() + except Exception as exc: + logger.debug("Similar Artists _mark failed for %s: %s", artist_id, exc) + + def _count_pending(self) -> int: + try: + conn = self.db._get_connection() + cursor = conn.cursor() + cursor.execute( + f"SELECT COUNT(*) FROM artists WHERE similar_artists_match_status IS NULL " + f"AND {self._has_source_id_clause()}" + ) + return int(cursor.fetchone()[0] or 0) + except Exception: + return 0 diff --git a/database/music_database.py b/database/music_database.py index aceb49c4..3e7cdd67 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -427,6 +427,9 @@ class MusicDatabase: # Add Amazon artist ID column (migration) self._add_amazon_columns(cursor) + # Add Similar-Artists worker tracking columns (migration) + self._add_similar_artists_worker_columns(cursor) + # Backfill match_status for rows that already have an external ID but # NULL status. Prevents enrichment workers from re-processing these # rows forever. Must run AFTER all *_match_status columns have been @@ -2417,6 +2420,28 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding Discogs columns: {e}") + def _add_similar_artists_worker_columns(self, cursor): + """Add Similar-Artists worker tracking columns to the artists table. + + Mirrors the per-source enrichment pattern: a match_status (NULL = + unattempted, then 'matched'/'not_found'/'error') + last_attempted + timestamp so the SimilarArtistsWorker can pick the next library artist to + fetch MusicMap similars for and retry transient failures after a window. + Idempotent — only adds columns that aren't already present. + """ + try: + cursor.execute("PRAGMA table_info(artists)") + artists_columns = [column[1] for column in cursor.fetchall()] + + if 'similar_artists_match_status' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN similar_artists_match_status TEXT") + if 'similar_artists_last_attempted' not in artists_columns: + cursor.execute("ALTER TABLE artists ADD COLUMN similar_artists_last_attempted TIMESTAMP") + + cursor.execute("CREATE INDEX IF NOT EXISTS idx_artists_similarartists_status ON artists (similar_artists_match_status)") + except Exception as e: + logger.error(f"Error adding similar-artists worker columns: {e}") + def _add_amazon_columns(self, cursor): """Add Amazon enrichment tracking columns to artists, albums, and tracks.""" try: diff --git a/tests/test_similar_artists_worker.py b/tests/test_similar_artists_worker.py new file mode 100644 index 00000000..ad23f15d --- /dev/null +++ b/tests/test_similar_artists_worker.py @@ -0,0 +1,127 @@ +"""Seam tests for the Similar-Artists enrichment worker's pure logic. + +The worker fills the similar_artists table for LIBRARY artists (the watchlist +scanner only does watchlist artists). These tests exercise the import-light +seams in isolation — no DB, no MusicMap — via injected fakes: + + - pick_source_artist_id → keying priority (and skip un-matched artists) + - map_payload_to_store_kwargs → MusicMap {id,source} → the right id column + - process_artist → fetch→match→store orchestration + status codes +""" + +from __future__ import annotations + +import core.similar_artists_worker as w + + +# -------------------------------------------------------------------------- +# pick_source_artist_id — which id keys the artist's similars (must match the +# watchlist scanner's priority so both write the SAME source_artist_id). +# -------------------------------------------------------------------------- + +def test_pick_source_artist_id_priority(): + row = {'spotify_artist_id': 'sp1', 'itunes_artist_id': 'it1', 'deezer_id': 'dz1'} + assert w.pick_source_artist_id(row) == 'sp1' # spotify wins + assert w.pick_source_artist_id({'itunes_artist_id': 'it1', 'deezer_id': 'dz1'}) == 'it1' + assert w.pick_source_artist_id({'deezer_id': 'dz1'}) == 'dz1' + assert w.pick_source_artist_id({'musicbrainz_id': 'mb1'}) == 'mb1' + + +def test_pick_source_artist_id_none_when_unmatched(): + # Library artist not matched to any metadata source yet → skip (None). + assert w.pick_source_artist_id({'spotify_artist_id': None, 'itunes_artist_id': ''}) is None + assert w.pick_source_artist_id({}) is None + + +# -------------------------------------------------------------------------- +# map_payload_to_store_kwargs — MusicMap payload {id, source} → store kwarg. +# -------------------------------------------------------------------------- + +def test_map_payload_each_source(): + assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'spotify'}) == {'similar_artist_spotify_id': 'x'} + assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'itunes'}) == {'similar_artist_itunes_id': 'x'} + assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'deezer'}) == {'similar_artist_deezer_id': 'x'} + assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'musicbrainz'}) == {'similar_artist_musicbrainz_id': 'x'} + + +def test_map_payload_unknown_source_or_no_id(): + # discogs has no column → name-only (empty kwargs), not a crash. + assert w.map_payload_to_store_kwargs({'id': 'x', 'source': 'discogs'}) == {} + assert w.map_payload_to_store_kwargs({'source': 'spotify'}) == {} # no id + + +# -------------------------------------------------------------------------- +# process_artist — fetch → store, status classification, keying. +# -------------------------------------------------------------------------- + +def _capture_store(): + calls = [] + + def store(**kwargs): + calls.append(kwargs) + return True + return store, calls + + +def test_process_artist_matched_stores_with_keying(): + store, calls = _capture_store() + payload = { + 'success': True, + 'similar_artists': [ + {'name': 'B', 'id': 'sp_b', 'source': 'spotify', 'genres': ['rap'], 'popularity': 70}, + {'name': 'C', 'id': 'it_c', 'source': 'itunes', 'image_url': 'http://x'}, + ], + } + status, count = w.process_artist('SRC1', 'A', lambda n, l: payload, store, limit=25, profile_id=1) + assert status == 'matched' and count == 2 + # All similars keyed by the SOURCE artist id we passed (not the library PK). + assert all(c['source_artist_id'] == 'SRC1' for c in calls) + assert all(c['profile_id'] == 1 for c in calls) + # Provider id mapped to the right column; rank preserved in order. + assert calls[0]['similar_artist_spotify_id'] == 'sp_b' and calls[0]['similarity_rank'] == 1 + assert calls[1]['similar_artist_itunes_id'] == 'it_c' and calls[1]['similarity_rank'] == 2 + assert calls[0]['genres'] == ['rap'] and calls[0]['popularity'] == 70 + + +def test_process_artist_not_found_when_no_matches(): + store, calls = _capture_store() + status, count = w.process_artist('S', 'A', lambda n, l: {'success': True, 'similar_artists': []}, store) + assert status == 'not_found' and count == 0 and calls == [] + + +def test_process_artist_not_found_on_404(): + # Genuinely no MusicMap entry — shouldn't be retried as an error. + store, _ = _capture_store() + status, _ = w.process_artist('S', 'A', lambda n, l: {'success': False, 'status_code': 404}, store) + assert status == 'not_found' + + +def test_process_artist_error_on_outage_is_retriable(): + # 5xx / no providers → transient error (worker retries after retry_days). + store, _ = _capture_store() + status, _ = w.process_artist('S', 'A', lambda n, l: {'success': False, 'status_code': 502}, store) + assert status == 'error' + + +def test_process_artist_error_when_fetch_raises(): + store, _ = _capture_store() + + def boom(n, l): + raise RuntimeError('musicmap down') + status, count = w.process_artist('S', 'A', boom, store) + assert status == 'error' and count == 0 + + +def test_process_artist_skips_unstorable_but_counts_real(): + # A store() that fails for one row shouldn't abort the rest. + calls = [] + + def store(**kwargs): + calls.append(kwargs) + return kwargs['similar_artist_name'] != 'B' # B fails to store + payload = {'success': True, 'similar_artists': [ + {'name': 'B', 'id': '1', 'source': 'spotify'}, + {'name': 'C', 'id': '2', 'source': 'spotify'}, + ]} + status, count = w.process_artist('S', 'A', lambda n, l: payload, store) + assert status == 'matched' and count == 1 and len(calls) == 2 diff --git a/web_server.py b/web_server.py index 13eb3368..7136fbb9 100644 --- a/web_server.py +++ b/web_server.py @@ -32889,6 +32889,27 @@ except Exception as e: amazon_worker = None +# --- Similar Artists Worker Initialization --- +# Fills the similar_artists table for LIBRARY artists (the watchlist scanner only +# covers watchlist artists). Opt-in by default like Amazon: it leans on MusicMap +# (a scraped, outage-prone source) and does per-candidate metadata-source +# searches across the whole library, so it stays paused until the user enables it. +similar_artists_worker = None +try: + from core.similar_artists_worker import SimilarArtistsWorker + similar_artists_db = MusicDatabase() + similar_artists_worker = SimilarArtistsWorker(database=similar_artists_db) + similar_artists_worker.start() + if config_manager.get('similar_artists_enrichment_paused', True): + similar_artists_worker.pause() + logger.info("Similar Artists worker initialized (paused — enable it to populate library similars)") + else: + logger.info("Similar Artists worker initialized and started") +except Exception as e: + logger.error(f"Similar Artists worker initialization failed: {e}") + similar_artists_worker = None + + # ================================================================================================ # SPOTIFY ENRICHMENT INTEGRATION # ================================================================================================ @@ -34653,6 +34674,11 @@ _register_enrichment_services([ worker_getter=lambda: amazon_worker, config_paused_key='amazon_enrichment_paused', ), + _EnrichmentService( + id='similar_artists', display_name='Similar Artists', + worker_getter=lambda: similar_artists_worker, + config_paused_key='similar_artists_enrichment_paused', + ), ]) _configure_enrichment_api( diff --git a/webui/static/enrichment-manager.js b/webui/static/enrichment-manager.js index 9e0eb173..94b35299 100644 --- a/webui/static/enrichment-manager.js +++ b/webui/static/enrichment-manager.js @@ -32,6 +32,12 @@ const ENRICHMENT_WORKERS = [ { id: 'tidal', name: 'Tidal', color: '#00cfe6', logoSel: '.tidal-enrich-logo', imgFilter: 'invert(1) brightness(1.8)', imgRound: true }, { id: 'qobuz', name: 'Qobuz', color: '#0070ef', logoSel: '.qobuz-enrich-logo', imgFilter: 'invert(1)', imgRound: true }, { id: 'amazon', name: 'Amazon Music', color: '#ff9900', logoSel: '.amazon-enrich-logo', imgFilter: 'brightness(0) invert(1)' }, + // Relationship worker (MusicMap → similar artists), not a metadata source. + // No dashboard logo element, so it uses a direct MusicMap logo URL. + // relationship:true tells the detail panel to drop the (inapplicable) + // manual-match affordance. + { id: 'similar_artists', name: 'Similar Artists', color: '#a855f7', relationship: true, + logoUrl: 'https://www.music-map.com/elements/objects/og_logo.png', imgRound: true }, ]; const _emWorkerById = Object.fromEntries(ENRICHMENT_WORKERS.map(w => [w.id, w])); @@ -48,7 +54,9 @@ function _emHexToRgb(hex) { // Resolve a worker's logo URL from the live dashboard bubble (null if absent). function _emLogoSrc(workerId) { const w = _emWorkerById[workerId]; - if (!w || !w.logoSel) return null; + if (!w) return null; + if (w.logoUrl) return w.logoUrl; // direct URL (workers w/o a dashboard logo) + if (!w.logoSel) return null; const img = document.querySelector(w.logoSel); return img && img.src ? img.src : null; } @@ -825,7 +833,8 @@ function _emRenderUnmatchedList() {
${statusBadge} ${last}
- + ${(_emWorkerById[id] && _emWorkerById[id].relationship) ? '' + : ``}
From d70d410f6f7bb2ebefb352c36a3ddee769c86287 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 15:19:46 -0700 Subject: [PATCH 31/42] Similar Artists worker: dashboard enrichment bubble Adds the dashboard status bubble (the small icon row) for the Similar Artists worker, alongside the modal entry. Mirrors the per-source bubbles: MusicMap logo, purple accent, spinner + active/complete/paused states, hover tooltip, and a 2s status poll against /api/enrichment/similar_artists/status. Click toggles pause/resume. Tooltip shows matched/pending (the worker has no artist/album/track phases). 74 JS integrity tests pass. --- webui/index.html | 23 ++++++++++++ webui/static/enrichment.js | 74 ++++++++++++++++++++++++++++++++++++++ webui/static/style.css | 68 +++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+) diff --git a/webui/index.html b/webui/index.html index eb3f5ac6..2d8d7d97 100644 --- a/webui/index.html +++ b/webui/index.html @@ -565,6 +565,29 @@ + +
+ +
+
+
Similar Artists Enrichment
+
+
Status: Idle +
+
No active matches +
+
Progress: 0 / 0 +
+
+
+
+
-
-
-