Add Artist Map — force-directed constellation of watchlist + similar artists

Visual canvas map on Discover page showing watchlist artists as large
anchor bubbles surrounded by their similar artists, sized by relevance.

Layout: golden angle spiral for watchlist nodes with push-apart guarantee,
spiral packing with spatial grid collision detection for similar artists.
Offscreen buffer rendering for smooth pan/zoom (single drawImage blit).

Features:
- 320px watchlist bubbles, similar sized 25-55% by rank/occurrence
- Search bar with instant filter + animated zoom-to-node
- Tooltip with artist photo, name, genre tags on hover
- Touch support: single finger pan, pinch zoom, tap to click
- Zoom +/- buttons and fit-to-screen with smooth 250ms animation
- Click opens artist info modal (same as Your Artists)
- Loading overlay with image count progress
- Async image loading via createImageBitmap (non-blocking)
This commit is contained in:
Broque Thomas 2026-04-04 10:22:15 -07:00
parent 58d8e830c6
commit cfac226eed
4 changed files with 1003 additions and 0 deletions

View file

@ -41053,6 +41053,145 @@ def get_your_artist_info(artist_id):
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/artist-map', methods=['GET'])
def get_artist_map_data():
"""Get watchlist artists + their similar artists for the force-directed artist map."""
try:
database = get_database()
profile_id = get_current_profile_id()
# Get all watchlist artists
conn = database._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, artist_name, spotify_artist_id, itunes_artist_id, deezer_artist_id,
discogs_artist_id, image_url
FROM watchlist_artists WHERE profile_id = ?
""", (profile_id,))
watchlist_rows = cursor.fetchall()
nodes = [] # {id, name, image_url, type: 'watchlist'|'similar', genres, size}
edges = [] # {source, target, weight}
seen_names = {} # normalized_name → node index
def _norm(name):
return (name or '').lower().strip()
# Add watchlist artists as anchor nodes
for wa in watchlist_rows:
w = dict(wa)
norm = _norm(w['artist_name'])
if norm in seen_names:
continue
idx = len(nodes)
seen_names[norm] = idx
# Get image — prefer HTTP URLs
img = w.get('image_url', '') or ''
if img and not img.startswith('http'):
img = ''
nodes.append({
'id': idx,
'name': w['artist_name'],
'image_url': img,
'type': 'watchlist',
'genres': [],
'spotify_id': w.get('spotify_artist_id', ''),
'itunes_id': w.get('itunes_artist_id', ''),
'deezer_id': w.get('deezer_artist_id', ''),
'source_db_id': str(w['id']),
})
# Get all similar artists for all watchlist artists
watchlist_ids = [dict(wa)['spotify_artist_id'] or dict(wa)['itunes_artist_id'] or str(dict(wa)['id']) for wa in watchlist_rows]
if watchlist_ids:
placeholders = ','.join(['?'] * len(watchlist_ids))
cursor.execute(f"""
SELECT source_artist_id, similar_artist_name, similar_artist_spotify_id,
similar_artist_itunes_id, similar_artist_deezer_id,
similarity_rank, occurrence_count, image_url, genres, popularity
FROM similar_artists
WHERE profile_id = ? AND source_artist_id IN ({placeholders})
ORDER BY similarity_rank ASC
""", [profile_id] + watchlist_ids)
for row in cursor.fetchall():
r = dict(row)
sim_norm = _norm(r['similar_artist_name'])
# Find or create similar artist node
if sim_norm not in seen_names:
idx = len(nodes)
seen_names[sim_norm] = idx
img = r.get('image_url', '') or ''
if img and not img.startswith('http'):
img = ''
genres = []
if r.get('genres'):
try:
genres = json.loads(r['genres'])
except Exception:
pass
nodes.append({
'id': idx,
'name': r['similar_artist_name'],
'image_url': img,
'type': 'similar',
'genres': genres,
'spotify_id': r.get('similar_artist_spotify_id', ''),
'itunes_id': r.get('similar_artist_itunes_id', ''),
'deezer_id': r.get('similar_artist_deezer_id', ''),
'rank': r.get('similarity_rank', 5),
'occurrence': r.get('occurrence_count', 1),
'popularity': r.get('popularity', 0),
})
sim_idx = seen_names[sim_norm]
# Find the watchlist node that sourced this similar artist
source_norm = None
for wa in watchlist_rows:
w = dict(wa)
sid = w.get('spotify_artist_id') or w.get('itunes_artist_id') or str(w['id'])
if sid == r['source_artist_id']:
source_norm = _norm(w['artist_name'])
break
if source_norm and source_norm in seen_names:
source_idx = seen_names[source_norm]
# Weight: inverse of rank (rank 1 = strongest connection)
weight = max(1, 11 - (r.get('similarity_rank', 5)))
edges.append({
'source': source_idx,
'target': sim_idx,
'weight': weight,
})
# Also check if any similar artists ARE watchlist artists (cross-links)
# These create extra connections between watchlist nodes
for i, node in enumerate(nodes):
if node['type'] == 'similar':
# Check if this similar artist is also a watchlist artist
for j, wnode in enumerate(nodes):
if wnode['type'] == 'watchlist' and i != j:
if _norm(node['name']) == _norm(wnode['name']):
# Merge: upgrade the similar node to watchlist
node['type'] = 'watchlist'
break
return jsonify({
'success': True,
'nodes': nodes,
'edges': edges,
'watchlist_count': sum(1 for n in nodes if n['type'] == 'watchlist'),
'similar_count': sum(1 for n in nodes if n['type'] == 'similar'),
})
except Exception as e:
logger.error(f"Error getting artist map data: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
@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"""

View file

@ -3060,9 +3060,42 @@
<button class="discover-hero-view-all" id="discover-hero-view-all" onclick="openRecommendedArtistsModal()">
View Recommended
</button>
<button class="discover-hero-artist-map-btn" onclick="openArtistMap()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/><circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/><line x1="6" y1="7" x2="10" y2="10"/><line x1="14" y1="10" x2="18" y2="7"/><line x1="6" y1="17" x2="10" y2="14"/><line x1="14" y1="14" x2="18" y2="17"/></svg>
Artist Map
</button>
</div>
</div>
<!-- Artist Map (fullscreen canvas, hidden by default) -->
<div class="artist-map-container" id="artist-map-container" style="display:none;">
<div class="artist-map-toolbar">
<button class="artist-map-back-btn" onclick="closeArtistMap()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
Back to Discover
</button>
<div class="artist-map-title">Artist Map</div>
<div class="artist-map-stats" id="artist-map-stats"></div>
<div class="artist-map-search">
<input type="text" id="artist-map-search" placeholder="Search artists..." oninput="artMapSearch(this.value)">
</div>
<div class="artist-map-zoom-controls">
<button class="artist-map-zoom-btn" onclick="artMapZoom(1.3)" title="Zoom in">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="artist-map-zoom-btn" onclick="artMapZoom(0.7)" title="Zoom out">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="artist-map-zoom-btn" onclick="artMapFitToView()" title="Fit to screen">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>
</button>
</div>
</div>
<canvas id="artist-map-canvas"></canvas>
<div class="artist-map-tooltip" id="artist-map-tooltip"></div>
<div class="artist-map-search-results" id="artist-map-search-results"></div>
</div>
<!-- Your Artists Section -->
<div class="discover-section" id="your-artists-section" style="display: none;">
<div class="discover-section-header">

View file

@ -55422,6 +55422,737 @@ async function _yaLoadModal() {
function loadDiscoveryBlacklist() {}
// ── Artist Map — Circle-packed staged canvas visualization ──
const _artMap = {
placed: [],
edges: [],
images: {},
canvas: null, ctx: null,
offscreen: null, offCtx: null, // offscreen buffer for fast pan/zoom
width: 0, height: 0,
offsetX: 0, offsetY: 0, zoom: 0.15,
hoveredNode: null, animFrame: null,
dirty: true, // true = need to rebuild offscreen buffer
WATCHLIST_R: 320,
BUFFER: 8,
};
async function openArtistMap() {
const container = document.getElementById('artist-map-container');
if (!container) return;
// Hide discover sections, show map
document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => {
el._prevDisplay = el.style.display;
el.style.display = 'none';
});
container.style.display = 'flex';
const canvas = document.getElementById('artist-map-canvas');
_artMap.canvas = canvas;
_artMap.ctx = canvas.getContext('2d');
_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);
_artMap.offsetX = _artMap.width / 2;
_artMap.offsetY = _artMap.height / 2;
_artMap.placed = [];
_artMap.images = {};
_artMap._nodeById = null;
// Loading screen
_artMap.ctx.fillStyle = '#0a0a14';
_artMap.ctx.fillRect(0, 0, _artMap.width, _artMap.height);
_artMap.ctx.fillStyle = 'rgba(255,255,255,0.3)';
_artMap.ctx.font = '14px system-ui';
_artMap.ctx.textAlign = 'center';
_artMap.ctx.fillText('Building artist map...', _artMap.width / 2, _artMap.height / 2);
try {
const resp = await fetch('/api/discover/artist-map');
const data = await resp.json();
if (!data.success || !data.nodes.length) {
_artMap.ctx.fillText('No watchlist artists. Add artists to your watchlist first.', _artMap.width / 2, _artMap.height / 2 + 30);
return;
}
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;
// Setup interaction
_artMapSetupInteraction(canvas);
// ── PHASE 3: Set all visible, build buffer, render ──
// Show loading overlay while buffer builds
const loadingEl = document.createElement('div');
loadingEl.id = 'artist-map-loading';
loadingEl.innerHTML = `
<div class="artist-map-loading-content">
<div class="watch-all-loading-spinner"></div>
<div class="artist-map-loading-text">Building map ${_artMap.placed.length} artists</div>
</div>
`;
container.appendChild(loadingEl);
// Defer heavy work so loading overlay renders first
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(
fetch(n.image_url, { mode: 'cors' })
.then(r => r.ok ? r.blob() : null)
.then(blob => blob ? createImageBitmap(blob) : null)
.then(bmp => { if (bmp) _artMap.images[n.id] = bmp; })
.catch(() => {})
.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
_artMap.dirty = true;
_artMapRender();
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
}, 50);
} catch (err) {
console.error('Artist map error:', err);
}
}
function artMapZoom(factor) {
const cx = _artMap.width / 2;
const cy = _artMap.height / 2;
const targetZoom = Math.max(0.02, Math.min(3, _artMap.zoom * factor));
const targetOX = cx - (cx - _artMap.offsetX) * (targetZoom / _artMap.zoom);
const targetOY = cy - (cy - _artMap.offsetY) * (targetZoom / _artMap.zoom);
_artMapAnimateTo(targetZoom, targetOX, targetOY);
}
function artMapFitToView() {
if (!_artMap.placed.length) return;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
_artMap.placed.forEach(n => {
if ((n.opacity || 0) < 0.01) return;
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 + 100;
const mapH = maxY - minY + 100;
const targetZoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1);
const targetOX = _artMap.width / 2 - ((minX + maxX) / 2) * targetZoom;
const targetOY = _artMap.height / 2 - ((minY + maxY) / 2) * targetZoom;
_artMapAnimateTo(targetZoom, targetOX, targetOY);
}
function _artMapAnimateTo(targetZoom, targetOX, targetOY) {
if (_artMap._animating) cancelAnimationFrame(_artMap._animating);
const startZoom = _artMap.zoom;
const startOX = _artMap.offsetX;
const startOY = _artMap.offsetY;
const duration = 250;
const start = performance.now();
function step(now) {
const t = Math.min(1, (now - start) / duration);
// Ease out cubic
const e = 1 - Math.pow(1 - t, 3);
_artMap.zoom = startZoom + (targetZoom - startZoom) * e;
_artMap.offsetX = startOX + (targetOX - startOX) * e;
_artMap.offsetY = startOY + (targetOY - startOY) * e;
_artMapRender(); // blit only, no rebuild
if (t < 1) {
_artMap._animating = requestAnimationFrame(step);
} else {
_artMap._animating = null;
}
}
_artMap._animating = requestAnimationFrame(step);
}
function closeArtistMap() {
const container = document.getElementById('artist-map-container');
if (container) container.style.display = 'none';
if (_artMap.animFrame) cancelAnimationFrame(_artMap.animFrame);
// Restore discover sections
document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => {
el.style.display = el._prevDisplay !== undefined ? el._prevDisplay : '';
});
}
// No force simulation — layout is pre-computed via circle packing
function _artMapRebuildBuffer() {
/**Render ALL nodes once to offscreen canvas. Only called on data changes, not pan/zoom.**/
const placed = _artMap.placed;
if (!placed.length) return;
const visible = placed.filter(n => (n.opacity || 0) > 0.01);
if (!visible.length) return;
// World bounds
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
visible.forEach(n => {
minX = Math.min(minX, n.x - n.radius - 10);
maxX = Math.max(maxX, n.x + n.radius + 10);
minY = Math.min(minY, n.y - n.radius - 10);
maxY = Math.max(maxY, n.y + n.radius + 10);
});
const bw = maxX - minX;
const bh = maxY - minY;
// Fixed scale — high enough for reasonable quality, capped for memory
const scale = Math.min(0.5, 8192 / Math.max(bw, bh));
if (!_artMap.offscreen) _artMap.offscreen = document.createElement('canvas');
const oc = _artMap.offscreen;
oc.width = Math.ceil(bw * scale);
oc.height = Math.ceil(bh * scale);
const octx = oc.getContext('2d');
_artMap._bufferScale = scale;
_artMap._bufferMinX = minX;
_artMap._bufferMinY = minY;
octx.scale(scale, scale);
octx.translate(-minX, -minY);
// Draw edges (only between visible nodes)
if (!_artMap._nodeById) {
_artMap._nodeById = {};
placed.forEach(n => { _artMap._nodeById[n.id] = n; });
}
// Draw ALL nodes — similar first (background), watchlist on top
// Two passes for z-order
for (let pass = 0; pass < 2; pass++) {
const isWatchlistPass = pass === 1;
for (const n of visible) {
if ((n.type === 'watchlist') !== isWatchlistPass) continue;
const op = n.opacity || 0;
if (op < 0.01) continue;
const r = n.radius;
const isW = n.type === 'watchlist';
octx.globalAlpha = op;
// Watchlist glow ring
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();
}
// Circle clip + image
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);
// Dark overlay for text readability
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();
// 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.stroke();
// Name — CENTERED in bubble, shown on ALL nodes
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);
}
}
octx.globalAlpha = 1;
_artMap.dirty = false;
}
function _artMapRender() {
/**Blit offscreen buffer to screen canvas with pan/zoom. Near-zero cost.**/
const ctx = _artMap.ctx;
const w = _artMap.width;
const h = _artMap.height;
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, w, h);
if (_artMap.dirty || !_artMap.offscreen) _artMapRebuildBuffer();
if (!_artMap.offscreen) 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)
ctx.drawImage(oc,
_artMap.offsetX + mx * z,
_artMap.offsetY + my * z,
oc.width * z / s,
oc.height * z / s
);
// Draw hover highlight on main canvas (only 1 node, very fast)
if (_artMap.hoveredNode) {
const n = _artMap.hoveredNode;
ctx.save();
ctx.translate(_artMap.offsetX, _artMap.offsetY);
ctx.scale(z, z);
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();
ctx.restore();
}
}
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.toLowerCase();
const matches = _artMap.placed.filter(n => (n.opacity || 0) > 0.5 && n.name.toLowerCase().includes(q)).slice(0, 8);
if (!matches.length) { results.style.display = 'none'; return; }
results.style.display = 'block';
results.innerHTML = matches.map(n =>
`<div class="artist-map-search-item" onclick="artMapZoomToNode(${n.id})">
<span class="artist-map-search-type ${n.type}">${n.type === 'watchlist' ? '★' : '○'}</span>
${escapeHtml(n.name)}
</div>`
).join('');
}
function artMapZoomToNode(nodeId) {
const n = _artMap.placed.find(p => p.id === nodeId);
if (!n) return;
// Zoom to show this node centered, at a comfortable zoom level
const targetZoom = Math.max(0.3, Math.min(1, 200 / n.radius));
const targetOX = _artMap.width / 2 - n.x * targetZoom;
const targetOY = _artMap.height / 2 - n.y * targetZoom;
_artMapAnimateTo(targetZoom, targetOX, targetOY);
// Highlight briefly after animation
setTimeout(() => { _artMap.hoveredNode = n; _artMapRender(); }, 300);
setTimeout(() => { _artMap.hoveredNode = null; _artMapRender(); }, 2500);
// Close search
const results = document.getElementById('artist-map-search-results');
if (results) results.style.display = 'none';
const input = document.getElementById('artist-map-search');
if (input) input.value = '';
}
function _artMapShowTooltip(e, node) {
const tip = document.getElementById('artist-map-tooltip');
if (!tip) return;
if (!node) { tip.style.display = 'none'; return; }
const img = node.image_url ? `<img class="artmap-tip-img" src="${escapeHtml(node.image_url)}" alt="">` : '<div class="artmap-tip-img artmap-tip-img-fallback">&#9835;</div>';
const genres = (node.genres || []).slice(0, 3);
const genreHTML = genres.length ? `<div class="artmap-tip-genres">${genres.map(g => `<span>${escapeHtml(g)}</span>`).join('')}</div>` : '';
const typeLabel = node.type === 'watchlist' ? '<span class="artmap-tip-badge">★ Watchlist</span>' : '';
tip.innerHTML = `
<div class="artmap-tip-row">
${img}
<div class="artmap-tip-info">
<div class="artmap-tip-name">${escapeHtml(node.name)}</div>
${typeLabel}
${genreHTML}
</div>
</div>
`;
tip.style.display = 'block';
// Position — keep on screen
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';
tip.style.top = y + 'px';
}
function _artMapSetupInteraction(canvas) {
let isPanning = false, panStartX = 0, panStartY = 0;
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
const newZoom = Math.max(0.1, Math.min(5, _artMap.zoom * delta));
// Zoom toward mouse
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
_artMap.offsetX = mx - (mx - _artMap.offsetX) * (newZoom / _artMap.zoom);
_artMap.offsetY = my - (my - _artMap.offsetY) * (newZoom / _artMap.zoom);
_artMap.zoom = newZoom;
_artMapRender();
}, { passive: false });
let clickStart = null;
canvas.addEventListener('mousedown', (e) => {
const { nx, ny } = _artMapScreenToWorld(e, canvas);
const node = _artMapHitTest(nx, ny);
clickStart = { x: e.clientX, y: e.clientY, time: Date.now() };
if (node) {
_artMap.dragNode = node;
} else {
isPanning = true;
panStartX = e.clientX;
panStartY = e.clientY;
}
});
canvas.addEventListener('mousemove', (e) => {
if (_artMap.dragNode) {
const { nx, ny } = _artMapScreenToWorld(e, canvas);
_artMap.dragNode.x = nx;
_artMap.dragNode.y = ny;
_artMapRender();
} else if (isPanning) {
_artMap.offsetX += e.clientX - panStartX;
_artMap.offsetY += e.clientY - panStartY;
panStartX = e.clientX;
panStartY = e.clientY;
_artMapRender();
} else {
const { nx, ny } = _artMapScreenToWorld(e, canvas);
const prev = _artMap.hoveredNode;
_artMap.hoveredNode = _artMapHitTest(nx, ny);
canvas.style.cursor = _artMap.hoveredNode ? 'pointer' : 'grab';
_artMapShowTooltip(e, _artMap.hoveredNode);
if (prev !== _artMap.hoveredNode) _artMapRender();
}
});
canvas.addEventListener('mouseup', (e) => {
const wasDrag = clickStart && (Math.abs(e.clientX - clickStart.x) > 5 || Math.abs(e.clientY - clickStart.y) > 5);
if (_artMap.dragNode && !wasDrag) {
// It was a click, not a drag — open info modal
const node = _artMap.dragNode;
_artMap.dragNode = null;
if (node.spotify_id || node.itunes_id || node.deezer_id) {
openYourArtistInfoModal_direct(node);
}
} else {
_artMap.dragNode = null;
}
isPanning = false;
clickStart = null;
_artMapShowTooltip(e, null);
});
canvas.addEventListener('mouseleave', () => {
_artMapShowTooltip(null, null);
if (_artMap.hoveredNode) { _artMap.hoveredNode = null; _artMapRender(); }
});
// Touch support — single finger pan, pinch to zoom
let lastTouches = null;
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
lastTouches = [...e.touches];
}, { passive: false });
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (!lastTouches) return;
const touches = [...e.touches];
if (touches.length === 1 && lastTouches.length === 1) {
// Pan
_artMap.offsetX += touches[0].clientX - lastTouches[0].clientX;
_artMap.offsetY += touches[0].clientY - lastTouches[0].clientY;
_artMapRender();
} else if (touches.length === 2 && lastTouches.length === 2) {
// Pinch zoom
const prevDist = Math.hypot(lastTouches[1].clientX - lastTouches[0].clientX, lastTouches[1].clientY - lastTouches[0].clientY);
const curDist = Math.hypot(touches[1].clientX - touches[0].clientX, touches[1].clientY - touches[0].clientY);
const factor = curDist / prevDist;
const cx = (touches[0].clientX + touches[1].clientX) / 2;
const cy = (touches[0].clientY + touches[1].clientY) / 2;
const newZoom = Math.max(0.02, Math.min(3, _artMap.zoom * factor));
_artMap.offsetX = cx - (cx - _artMap.offsetX) * (newZoom / _artMap.zoom);
_artMap.offsetY = cy - (cy - _artMap.offsetY) * (newZoom / _artMap.zoom);
_artMap.zoom = newZoom;
_artMap.dirty = true;
_artMapRender();
}
lastTouches = touches;
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
// Tap to click
if (lastTouches && lastTouches.length === 1 && e.changedTouches.length === 1) {
const t = e.changedTouches[0];
const rect = canvas.getBoundingClientRect();
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);
if (node && (node.spotify_id || node.itunes_id || node.deezer_id)) {
openYourArtistInfoModal_direct(node);
}
}
lastTouches = null;
}, { passive: false });
// Handle resize
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);
});
}
function _artMapScreenToWorld(e, canvas) {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
// Inverse of: translate(offsetX, offsetY) → scale(zoom)
return {
nx: (sx - _artMap.offsetX) / _artMap.zoom,
ny: (sy - _artMap.offsetY) / _artMap.zoom,
};
}
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;
}
return null;
}
async function openYourArtistInfoModal_direct(node) {
// Create a pool-like object from the map node for the info modal
const poolEntry = {
id: node.id,
artist_name: node.name,
active_source_id: node.spotify_id || node.itunes_id || node.deezer_id || '',
active_source: node.spotify_id ? 'spotify' : node.itunes_id ? 'itunes' : node.deezer_id ? 'deezer' : '',
image_url: node.image_url || '',
spotify_artist_id: node.spotify_id || '',
itunes_artist_id: node.itunes_id || '',
deezer_artist_id: node.deezer_id || '',
discogs_artist_id: '',
source_services: [],
on_watchlist: node.type === 'watchlist' ? 1 : 0,
};
if (!window._yaArtists) window._yaArtists = {};
window._yaArtists[node.id] = poolEntry;
openYourArtistInfoModal(node.id);
}
async function loadDiscoveryShuffle() {
try {
const container = document.getElementById('personalized-discovery-shuffle');

View file

@ -29654,6 +29654,106 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.ya-info-empty { text-align: center; padding: 30px 0; color: rgba(255,255,255,0.2); font-size: 13px; }
.ya-info-footer { padding: 16px 24px; border-top: 1px solid rgba(255,255,255,0.05); display: flex; justify-content: center; gap: 10px; }
/* ── Artist Map ── */
.discover-hero-artist-map-btn {
display: flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 10px; font-size: 12px; font-weight: 600;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.6); cursor: pointer; transition: all 0.2s;
}
.discover-hero-artist-map-btn:hover {
background: rgba(138,43,226,0.15); border-color: rgba(138,43,226,0.3);
color: #fff; transform: translateY(-1px);
}
.artist-map-container {
position: fixed; inset: 0; z-index: 100;
background: #0a0a14; flex-direction: column;
}
.artist-map-toolbar {
display: flex; align-items: center; gap: 16px;
padding: 10px 20px; background: rgba(20,20,32,0.95);
border-bottom: 1px solid rgba(255,255,255,0.06);
backdrop-filter: blur(12px); z-index: 1;
}
.artist-map-back-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; font-size: 12px; font-weight: 600;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
color: rgba(255,255,255,0.6); cursor: pointer; transition: all 0.2s;
}
.artist-map-back-btn:hover { background: rgba(255,255,255,0.1); color: #fff; }
.artist-map-title { font-size: 16px; font-weight: 700; color: #fff; flex: 1; }
.artist-map-stats { font-size: 12px; color: rgba(255,255,255,0.35); flex: 1; text-align: right; }
.artist-map-zoom-controls { display: flex; gap: 4px; }
.artist-map-zoom-btn {
width: 32px; height: 32px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.08);
background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.6);
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.15s;
}
.artist-map-zoom-btn:hover { background: rgba(255,255,255,0.1); color: #fff; border-color: rgba(255,255,255,0.15); }
/* Search */
.artist-map-search { position: relative; }
.artist-map-search input {
padding: 6px 12px; border-radius: 8px; font-size: 12px; width: 180px;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08);
color: #fff; outline: none; transition: border-color 0.2s;
}
.artist-map-search input::placeholder { color: rgba(255,255,255,0.25); }
.artist-map-search input:focus { border-color: rgba(138,43,226,0.4); }
.artist-map-search-results {
display: none; position: absolute; top: 50px; right: 60px; z-index: 20;
background: rgba(20,20,32,0.95); border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; min-width: 220px; max-height: 300px; overflow-y: auto;
backdrop-filter: blur(12px); box-shadow: 0 8px 24px rgba(0,0,0,0.5);
}
.artist-map-search-item {
padding: 10px 14px; font-size: 13px; color: rgba(255,255,255,0.7);
cursor: pointer; display: flex; align-items: center; gap: 8px;
border-bottom: 1px solid rgba(255,255,255,0.04);
}
.artist-map-search-item:last-child { border-bottom: none; }
.artist-map-search-item:hover { background: rgba(138,43,226,0.1); color: #fff; }
.artist-map-search-type { font-size: 11px; width: 16px; text-align: center; }
.artist-map-search-type.watchlist { color: #fbbf24; }
.artist-map-search-type.similar { color: rgba(255,255,255,0.3); }
/* Tooltip */
.artist-map-tooltip {
display: none; position: fixed; z-index: 30; pointer-events: none;
padding: 10px 14px; border-radius: 12px;
background: rgba(20,20,32,0.95); border: 1px solid rgba(255,255,255,0.1);
color: #fff; backdrop-filter: blur(12px);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
max-width: 280px;
}
.artmap-tip-row { display: flex; align-items: center; gap: 10px; }
.artmap-tip-img { width: 44px; height: 44px; border-radius: 8px; object-fit: cover; flex-shrink: 0; background: rgba(255,255,255,0.04); }
.artmap-tip-img-fallback { display: flex; align-items: center; justify-content: center; font-size: 18px; color: rgba(255,255,255,0.1); }
.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-genres { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 4px; }
.artmap-tip-genres span {
font-size: 9px; padding: 2px 6px; border-radius: 4px;
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5);
text-transform: capitalize;
}
#artist-map-canvas { flex: 1; display: block; cursor: grab; }
#artist-map-canvas:active { cursor: grabbing; }
#artist-map-loading {
position: absolute; inset: 0; z-index: 10;
display: flex; align-items: center; justify-content: center;
background: rgba(10,10,20,0.9); backdrop-filter: blur(8px);
}
.artist-map-loading-content {
display: flex; flex-direction: column; align-items: center; gap: 14px;
}
.artist-map-loading-text {
font-size: 14px; color: rgba(255,255,255,0.5); font-weight: 500;
}
/* Loading state */
.ya-loading {
display: flex; align-items: center; gap: 12px; padding: 30px 20px;