Artist Map v2 — one-island-at-a-time view + API-backed toolbar search
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.
This commit is contained in:
parent
21b6d17d3c
commit
80d775a5da
1 changed files with 152 additions and 20 deletions
|
|
@ -5215,6 +5215,98 @@ function _artMapFitToContent(marginPx = 120) {
|
||||||
_artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom;
|
_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 = `
|
||||||
|
<button style="${btn}" onclick="_artMapIslandNav(-1)" title="Previous genre (←)">◀</button>
|
||||||
|
<div style="text-align:center;min-width:120px;">
|
||||||
|
<div style="font-weight:700;font-size:13px;letter-spacing:0.04em;color:hsl(${isl.hue},80%,80%);">${escapeHtml((isl.name || '').toUpperCase())}</div>
|
||||||
|
<div style="font-size:11px;color:rgba(255,255,255,0.55);margin-top:1px;">${isl.count} artists · ${idx + 1} / ${islands.length}</div>
|
||||||
|
</div>
|
||||||
|
<button style="${btn}" onclick="_artMapIslandNav(1)" title="Next genre (→)">▶</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
async function openArtistMap() {
|
async function openArtistMap() {
|
||||||
const container = document.getElementById('artist-map-container');
|
const container = document.getElementById('artist-map-container');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
@ -5269,7 +5361,7 @@ async function openArtistMap() {
|
||||||
const groups = _artMapGroupByGenre(rawNodes);
|
const groups = _artMapGroupByGenre(rawNodes);
|
||||||
_artMapLayoutIslands(groups);
|
_artMapLayoutIslands(groups);
|
||||||
_artMap.edges = _artMapRemapEdges(data.edges);
|
_artMap.edges = _artMapRemapEdges(data.edges);
|
||||||
_artMapFitToContent();
|
_artMap._oneIsland = true; // focus one genre island at a time
|
||||||
|
|
||||||
// Setup interaction
|
// Setup interaction
|
||||||
_artMapSetupInteraction(canvas);
|
_artMapSetupInteraction(canvas);
|
||||||
|
|
@ -5299,7 +5391,7 @@ async function openArtistMap() {
|
||||||
const le = document.getElementById('artist-map-loading');
|
const le = document.getElementById('artist-map-loading');
|
||||||
if (le) le.remove();
|
if (le) le.remove();
|
||||||
|
|
||||||
_artMapBeginReveal();
|
_artMapFocusIsland(0, { bloom: true }); // frame + bloom the first genre island
|
||||||
_artMapStreamImages(_artMap.placed);
|
_artMapStreamImages(_artMap.placed);
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
|
|
@ -5372,6 +5464,9 @@ function closeArtistMap() {
|
||||||
_artMap._ambient = false;
|
_artMap._ambient = false;
|
||||||
_artMap._anim.running = false;
|
_artMap._anim.running = false;
|
||||||
if (_artMap._anim.raf) { cancelAnimationFrame(_artMap._anim.raf); _artMap._anim.raf = null; }
|
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);
|
if (_artMap._keyHandler) window.removeEventListener('keydown', _artMap._keyHandler);
|
||||||
_artMapHideContextMenu();
|
_artMapHideContextMenu();
|
||||||
|
|
||||||
|
|
@ -5426,7 +5521,10 @@ function _artMapRebuildBuffer() {
|
||||||
const bz = _artMap.zoom;
|
const bz = _artMap.zoom;
|
||||||
let liveN = 0;
|
let liveN = 0;
|
||||||
for (const n of visible) { if (!n._isLabel && (n.radius || 0) * bz >= _artMap.LIVE_PX) liveN++; }
|
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.scale(scale, scale);
|
||||||
octx.translate(-minX, -minY);
|
octx.translate(-minX, -minY);
|
||||||
|
|
@ -6129,23 +6227,51 @@ function _artMapDrawPerf(ctx, t0) {
|
||||||
ctx.restore();
|
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) {
|
function artMapSearch(query) {
|
||||||
const results = document.getElementById('artist-map-search-results');
|
const results = document.getElementById('artist-map-search-results');
|
||||||
if (!results) return;
|
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();
|
clearTimeout(_artMap._searchTimer);
|
||||||
const matches = _artMap.placed.filter(n => (n.opacity || 0) > 0.5 && n.name.toLowerCase().includes(q)).slice(0, 8);
|
_artMap._searchTimer = setTimeout(async () => {
|
||||||
|
const myToken = (_artMap._searchToken = (_artMap._searchToken || 0) + 1);
|
||||||
|
results.style.display = 'block';
|
||||||
|
results.innerHTML = '<div class="artist-map-search-item artist-map-search-empty">Searching…</div>';
|
||||||
|
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 = '<div class="artist-map-search-item artist-map-search-empty">No artists found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results.innerHTML = artists.slice(0, 8).map(a =>
|
||||||
|
`<div class="artist-map-search-item" onclick="artMapExploreArtist('${escapeForInlineJs(a.name)}')">
|
||||||
|
<span class="artist-map-search-type similar">○</span>
|
||||||
|
${escapeHtml(a.name)}
|
||||||
|
<span class="artist-map-search-go">Explore →</span>
|
||||||
|
</div>`
|
||||||
|
).join('');
|
||||||
|
} catch (e) {
|
||||||
|
if (myToken === _artMap._searchToken) {
|
||||||
|
results.innerHTML = '<div class="artist-map-search-item artist-map-search-empty">Search failed — try again</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
if (!matches.length) { results.style.display = 'none'; return; }
|
// Launch the explorer for a searched artist (closes the dropdown first).
|
||||||
|
function artMapExploreArtist(name) {
|
||||||
results.style.display = 'block';
|
const results = document.getElementById('artist-map-search-results');
|
||||||
results.innerHTML = matches.map(n =>
|
if (results) { results.style.display = 'none'; results.innerHTML = ''; }
|
||||||
`<div class="artist-map-search-item" onclick="artMapZoomToNode(${n.id})">
|
const input = document.getElementById('artist-map-search');
|
||||||
<span class="artist-map-search-type ${n.type}">${n.type === 'watchlist' ? '★' : '○'}</span>
|
if (input) input.value = '';
|
||||||
${escapeHtml(n.name)}
|
_artMap._skipSectionToggle = true;
|
||||||
</div>`
|
_openArtistMapExplorerWithName(name);
|
||||||
).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function artMapZoomToNode(nodeId) {
|
function artMapZoomToNode(nodeId) {
|
||||||
|
|
@ -6461,7 +6587,7 @@ async function _openGenreMapWithSelection(selectedGenre) {
|
||||||
}));
|
}));
|
||||||
_artMapLayoutIslands(groups);
|
_artMapLayoutIslands(groups);
|
||||||
_artMap.edges = [];
|
_artMap.edges = [];
|
||||||
_artMapFitToContent();
|
_artMap._oneIsland = true; // focus one genre island at a time
|
||||||
const placedCount = _artMap.placed.filter(n => !n._isLabel).length;
|
const placedCount = _artMap.placed.filter(n => !n._isLabel).length;
|
||||||
|
|
||||||
_artMapSetupInteraction(canvas);
|
_artMapSetupInteraction(canvas);
|
||||||
|
|
@ -6472,8 +6598,7 @@ async function _openGenreMapWithSelection(selectedGenre) {
|
||||||
const le = document.getElementById('artist-map-loading');
|
const le = document.getElementById('artist-map-loading');
|
||||||
if (le) le.remove();
|
if (le) le.remove();
|
||||||
|
|
||||||
_artMap.dirty = true;
|
_artMapFocusIsland(0, { bloom: true }); // frame + bloom the selected genre island
|
||||||
_artMapBeginReveal();
|
|
||||||
|
|
||||||
// Stream images in throttled waves — interactive immediately, sharpens in place.
|
// Stream images in throttled waves — interactive immediately, sharpens in place.
|
||||||
_artMapStreamImages(_artMap.placed.filter(n => !n._isLabel));
|
_artMapStreamImages(_artMap.placed.filter(n => !n._isLabel));
|
||||||
|
|
@ -6570,6 +6695,8 @@ async function _openArtistMapExplorerWithName(name) {
|
||||||
const groups = _artMapGroupByGenre(rawNodes);
|
const groups = _artMapGroupByGenre(rawNodes);
|
||||||
_artMapLayoutIslands(groups);
|
_artMapLayoutIslands(groups);
|
||||||
_artMap.edges = _artMapRemapEdges(data.edges);
|
_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();
|
_artMapFitToContent();
|
||||||
|
|
||||||
_artMapSetupInteraction(canvas);
|
_artMapSetupInteraction(canvas);
|
||||||
|
|
@ -6813,10 +6940,13 @@ function _artMapStreamImages(imgNodes, concurrent = 24) {
|
||||||
.then(bmp => {
|
.then(bmp => {
|
||||||
if (bmp && token === _artMap._loadToken) {
|
if (bmp && token === _artMap._loadToken) {
|
||||||
_artMap.images[n.id] = bmp;
|
_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)
|
// Composite just this node into the existing buffer (cheap)
|
||||||
// and blit. Only fall back to a full rebuild if the buffer
|
// and blit. Only fall back to a full rebuild if the buffer
|
||||||
// isn't built yet. This keeps pan/hover at blit-speed the
|
// isn't built yet. Keeps pan/hover at blit-speed while images stream.
|
||||||
// whole time images are streaming in — the lag fix.
|
|
||||||
if (_artMapCompositeNode(n)) _artMapRender();
|
if (_artMapCompositeNode(n)) _artMapRender();
|
||||||
else scheduleRedraw();
|
else scheduleRedraw();
|
||||||
}
|
}
|
||||||
|
|
@ -6885,6 +7015,8 @@ function _artMapSetupInteraction(canvas) {
|
||||||
_artMap.dirty = true;
|
_artMap.dirty = true;
|
||||||
_artMapRender();
|
_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);
|
window.addEventListener('keydown', _artMapKeyHandler);
|
||||||
_artMap._keyHandler = _artMapKeyHandler;
|
_artMap._keyHandler = _artMapKeyHandler;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue