From b0c0acb379225470ef029309629281a2aaae8a3f Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 3 Jun 2026 13:28:57 -0700 Subject: [PATCH] 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);