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 ``;
+}
+
+// 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);