From 80d775a5da8087025971fd4ebdfc311973316f36 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 11:00:10 -0700 Subject: [PATCH] =?UTF-8?q?Artist=20Map=20v2=20=E2=80=94=20one-island-at-a?= =?UTF-8?q?-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;