';
}
if (bgEl && artist.image_url) {
bgEl.style.backgroundImage = `url('${artist.image_url}')`;
bgEl.style.backgroundSize = 'cover';
bgEl.style.backgroundPosition = 'center';
}
// Store artist ID for both buttons and update watchlist state
// Use artist_id which is set by the backend to the appropriate ID for the active source
const addBtn = document.getElementById('discover-hero-add');
const discographyBtn = document.getElementById('discover-hero-discography');
const artistId = artist.artist_id || artist.spotify_artist_id || artist.itunes_artist_id;
if (addBtn && artistId) {
addBtn.setAttribute('data-artist-id', artistId);
addBtn.setAttribute('data-artist-name', artist.artist_name);
// Also store both IDs for cross-source operations
if (artist.spotify_artist_id) addBtn.setAttribute('data-spotify-id', artist.spotify_artist_id);
if (artist.itunes_artist_id) addBtn.setAttribute('data-itunes-id', artist.itunes_artist_id);
// Check if this artist is already in watchlist and update button appearance
checkAndUpdateDiscoverHeroWatchlistButton(artistId);
}
if (discographyBtn && artistId) {
discographyBtn.setAttribute('data-artist-id', artistId);
discographyBtn.setAttribute('data-artist-name', artist.artist_name);
discographyBtn.href = buildArtistDetailPath(artistId, artist.source || null);
// Keep the source on the link so source-only hero artists resolve to
// the correct artist-detail URL instead of being treated as library IDs.
if (artist.source) discographyBtn.setAttribute('data-source', artist.source);
else discographyBtn.removeAttribute('data-source');
// Also store both IDs for cross-source operations
if (artist.spotify_artist_id) discographyBtn.setAttribute('data-spotify-id', artist.spotify_artist_id);
if (artist.itunes_artist_id) discographyBtn.setAttribute('data-itunes-id', artist.itunes_artist_id);
}
// Update slideshow indicators
updateDiscoverHeroIndicators();
}
async function checkAndUpdateDiscoverHeroWatchlistButton(artistId) {
try {
const response = await fetch('/api/watchlist/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_id: artistId })
});
const data = await response.json();
if (!data.success) return;
const addBtn = document.getElementById('discover-hero-add');
if (!addBtn) return;
const icon = addBtn.querySelector('.watchlist-icon');
const text = addBtn.querySelector('.watchlist-text');
if (data.is_watching) {
// Artist is in watchlist
if (icon) icon.textContent = '๐๏ธ';
if (text) text.textContent = 'Watching...';
addBtn.classList.add('watching');
} else {
// Artist not in watchlist
if (icon) icon.textContent = '๐๏ธ';
if (text) text.textContent = 'Add to Watchlist';
addBtn.classList.remove('watching');
}
} catch (error) {
console.error('Error checking watchlist status for hero:', error);
}
}
function toggleDiscoverHeroWatchlist(event) {
event.stopPropagation();
const button = document.getElementById('discover-hero-add');
if (!button) return;
const artistId = button.getAttribute('data-artist-id');
const artistName = button.getAttribute('data-artist-name');
if (!artistId || !artistName) {
console.error('No artist data found on discover hero button');
return;
}
// Call the existing toggleWatchlist function
toggleWatchlist(event, artistId, artistName);
}
async function watchAllHeroArtists(btn) {
if (!discoverHeroArtists || discoverHeroArtists.length === 0) return;
if (btn.classList.contains('all-watched')) return;
const textEl = btn.querySelector('.watch-all-text');
const originalText = textEl ? textEl.textContent : '';
// Loading state
btn.disabled = true;
if (textEl) textEl.textContent = 'Adding...';
try {
const artists = discoverHeroArtists.map(a => ({
artist_id: a.artist_id,
artist_name: a.artist_name
}));
const response = await fetch('/api/watchlist/add-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artists })
});
const data = await response.json();
if (data.success) {
if (textEl) textEl.textContent = 'All Watched';
btn.classList.add('all-watched');
btn.disabled = true;
// Sync the per-slide watchlist button for current artist
const currentArtist = discoverHeroArtists[discoverHeroIndex];
if (currentArtist) {
checkAndUpdateDiscoverHeroWatchlistButton(currentArtist.artist_id);
}
// Update watchlist count badge
if (typeof updateWatchlistButtonCount === 'function') {
updateWatchlistButtonCount();
}
} else {
if (textEl) textEl.textContent = originalText;
btn.disabled = false;
}
} catch (error) {
console.error('Error watching all hero artists:', error);
if (textEl) textEl.textContent = originalText;
btn.disabled = false;
}
}
// Cache for recommended artists data so reopening is instant
let _recommendedArtistsCache = null;
let _recommendedArtistsSource = null;
// Builds the "because you have X, Y" explanation for a recommendation.
// Prefers the real contributing artist names from the backend (the `because`
// array); falls back to an occurrence count โ which is now LIBRARY-wide, not
// just watchlist, thanks to the similar-artists enrichment worker.
function _recommendationReason(artist) {
const names = (artist && artist.because) || [];
if (names.length === 1) return `Because you have ${escapeHtml(names[0])}`;
if (names.length === 2) return `Because you have ${escapeHtml(names[0])} & ${escapeHtml(names[1])}`;
if (names.length >= 3) {
const shown = names.slice(0, 2).map(escapeHtml).join(', ');
return `Because you have ${shown} +${names.length - 2} more`;
}
const n = (artist && artist.occurrence_count) || 0;
return n > 1
? `Similar to ${n} artists in your library`
: 'Similar to an artist in your library';
}
// Full contributing-artist list for a hover tooltip (when there are names).
function _recommendationReasonTitle(artist) {
const names = (artist && artist.because) || [];
return names.length ? `In your library: ${names.join(', ')}` : '';
}
async function openRecommendedArtistsModal() {
let modal = document.getElementById('recommended-artists-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'recommended-artists-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
modal.addEventListener('click', function (e) {
if (e.target === modal) closeRecommendedArtistsModal();
});
}
// If cached, render instantly and refresh watchlist statuses
if (_recommendedArtistsCache) {
modal.style.display = 'flex';
renderRecommendedArtistsModal(modal, _recommendedArtistsCache, _recommendedArtistsSource);
checkRecommendedWatchlistStatuses(_recommendedArtistsCache);
return;
}
// Show loading
modal.innerHTML = `
Recommended Artists
Loading...
×
Loading recommended artists...
`;
modal.style.display = 'flex';
try {
// Phase 1: Fetch basic data (instant โ no API enrichment)
const response = await fetch('/api/discover/similar-artists');
const data = await response.json();
if (!data.success || !data.artists || data.artists.length === 0) {
modal.querySelector('.playlist-modal-body').innerHTML = `
No recommended artists yet.
Run a watchlist scan to generate recommendations.
`;
modal.querySelector('.playlist-track-count').textContent = '0 artists';
return;
}
// Phase 2: Enrich with images/genres progressively in batches of 50
// Skip artists that already have cached metadata from the initial response
const source = data.source || 'spotify';
// Render cards immediately with fallback images
_recommendedArtistsCache = data.artists;
_recommendedArtistsSource = source;
renderRecommendedArtistsModal(modal, data.artists, source);
const idKey = source === 'spotify' ? 'spotify_artist_id' : source === 'deezer' ? 'deezer_artist_id' : 'itunes_artist_id';
const allIds = data.artists
.filter(a => !a.image_url) // Only enrich artists without cached images
.map(a => a[idKey]).filter(Boolean);
for (let i = 0; i < allIds.length; i += 50) {
const batchIds = allIds.slice(i, i + 50);
try {
const enrichResp = await fetch('/api/discover/similar-artists/enrich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist_ids: batchIds, source })
});
const enrichData = await enrichResp.json();
if (enrichData.success && enrichData.artists) {
// Update cards and cache as each batch arrives
for (const [aid, info] of Object.entries(enrichData.artists)) {
// Update the card in DOM
const card = modal.querySelector(`.recommended-artist-card[data-artist-id="${aid}"]`);
if (card && info.image_url) {
const imgContainer = card.querySelector('.recommended-card-image');
if (imgContainer) {
imgContainer.innerHTML = ``;
}
}
if (card && info.genres && info.genres.length > 0) {
const genresContainer = card.querySelector('.recommended-card-genres');
if (genresContainer) {
genresContainer.innerHTML = info.genres.map(g =>
`${escapeHtml(g)}`
).join('');
} else {
const infoDiv = card.querySelector('.recommended-card-info');
if (infoDiv) {
const genreDiv = document.createElement('div');
genreDiv.className = 'recommended-card-genres';
genreDiv.innerHTML = info.genres.map(g =>
`${escapeHtml(g)}`
).join('');
infoDiv.appendChild(genreDiv);
}
}
}
// Update cache
const cached = _recommendedArtistsCache.find(a => a.artist_id === aid || a.spotify_artist_id === aid || a.itunes_artist_id === aid);
if (cached) {
if (info.image_url) cached.image_url = info.image_url;
if (info.genres) cached.genres = info.genres;
if (info.artist_name) cached.artist_name = info.artist_name;
}
}
}
} catch (enrichErr) {
console.error('Error enriching batch:', enrichErr);
}
}
// Phase 3: Check watchlist statuses
checkRecommendedWatchlistStatuses(data.artists);
} catch (error) {
console.error('Error loading recommended artists:', error);
modal.querySelector('.playlist-modal-body').innerHTML = `
`;
});
grid.innerHTML = html;
}
function _renderYourAlbumsPagination(total, page) {
const container = document.getElementById('your-albums-pagination');
if (!container) return;
if (total <= YOUR_ALBUMS_PAGE_SIZE) { container.style.display = 'none'; return; }
container.style.display = '';
const totalPages = Math.ceil(total / YOUR_ALBUMS_PAGE_SIZE);
const start = (page - 1) * YOUR_ALBUMS_PAGE_SIZE + 1;
const end = Math.min(page * YOUR_ALBUMS_PAGE_SIZE, total);
container.innerHTML = `
${start}\u2013${end} of ${total}
`;
}
function _yourAlbumsPrevPage() {
if (yourAlbumsPage > 1) { yourAlbumsPage--; loadYourAlbumsGrid(); }
}
function _yourAlbumsNextPage() {
const totalPages = Math.ceil(yourAlbumsTotal / YOUR_ALBUMS_PAGE_SIZE);
if (yourAlbumsPage < totalPages) { yourAlbumsPage++; loadYourAlbumsGrid(); }
}
async function openYourAlbumDownload(index) {
const album = yourAlbums[index];
if (!album) { showToast('Album data not found', 'error'); return; }
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
try {
// Per-source dispatch: open with whichever source has an ID for
// this album. For pure-Discogs collection items (no Spotify/
// Deezer match), dispatch goes straight to Discogs so the
// modal opens with Discogs context (vinyl/CD release detail,
// tracklist from Discogs). For Spotify saved albums (no
// discogs id), goes to Spotify. For multi-source albums
// (album exists in BOTH Spotify saved and Discogs collection,
// rare), tries streaming sources first since they have
// tracklists with proper IDs ready for download.
let albumData = null;
const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
const discogsId = album.discogs_release_id || album.discogs_id;
const trySources = [];
if (album.spotify_album_id) trySources.push(['spotify', album.spotify_album_id]);
if (album.deezer_album_id) trySources.push(['deezer', album.deezer_album_id]);
if (album.tidal_album_id) trySources.push(['tidal', album.tidal_album_id]);
if (discogsId) trySources.push(['discogs', discogsId]);
for (const [src, id] of trySources) {
const r = await fetch(`/api/discover/album/${src}/${id}?${nameParams}`);
if (r.ok) {
albumData = await r.json();
if (albumData && albumData.tracks && albumData.tracks.length > 0) break;
albumData = null; // empty payload โ try next
}
}
if (!albumData) {
// Last resort โ search by name
const r = await fetch(`/api/discover/album/spotify/search?${nameParams}`);
if (r.ok) albumData = await r.json();
}
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
throw new Error('No tracks found for this album');
}
const tracks = albumData.tracks.map(track => {
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
return {
id: track.id, name: track.name, artists,
album: {
id: albumData.id, name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
},
duration_ms: track.duration_ms || 0,
track_number: track.track_number || 0
};
});
const virtualId = `discover_album_${album.spotify_album_id || album.deezer_album_id || album.tidal_album_id || index}`;
const albumObj = {
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
images: albumData.images || [], artists: [{ name: album.artist_name }]
};
const artistObj = { id: null, name: album.artist_name };
await openDownloadMissingModalForArtistAlbum(virtualId, albumData.name, tracks, albumObj, artistObj, false);
hideLoadingOverlay();
} catch (e) {
console.error('Error opening your album download:', e);
showToast(`Failed to load album: ${e.message}`, 'error');
hideLoadingOverlay();
}
}
async function refreshYourAlbums() {
const btn = document.getElementById('your-albums-refresh-btn');
if (btn) btn.disabled = true;
const subtitle = document.getElementById('your-albums-subtitle');
if (subtitle) subtitle.textContent = 'Refreshing from connected services...';
try {
await fetch('/api/discover/your-albums/refresh?clear=true', { method: 'POST' });
showToast('Refresh started โ checking for new albums...', 'info');
const poll = setInterval(async () => {
try {
const resp = await fetch('/api/discover/your-albums?page=1&per_page=48');
const data = await resp.json();
if (data.success && data.stats && data.stats.total > 0) {
clearInterval(poll);
loadYourAlbums();
if (btn) btn.disabled = false;
}
} catch (e) { }
}, 4000);
setTimeout(() => { clearInterval(poll); if (btn) btn.disabled = false; }, 60000);
} catch (e) {
showToast('Failed to start refresh', 'error');
if (btn) btn.disabled = false;
}
}
async function openYourAlbumsSourcesModal() {
const existing = document.getElementById('ya-albums-sources-modal-overlay');
if (existing) existing.remove();
let enabled = ['spotify', 'tidal', 'deezer'];
let connected = [];
try {
const resp = await fetch('/api/discover/your-albums/sources');
if (resp.ok) {
const data = await resp.json();
if (data.enabled) enabled = data.enabled;
if (data.connected) connected = data.connected;
}
} catch (e) { }
const sourceInfo = [
{ id: 'spotify', label: 'Spotify', icon: '\uD83C\uDFB5' },
{ id: 'tidal', label: 'Tidal', icon: '\uD83C\uDF0A' },
{ id: 'deezer', label: 'Deezer', icon: '\uD83C\uDFB6' },
{ id: 'discogs', label: 'Discogs', icon: '\uD83D\uDCBF' },
];
const state = {};
sourceInfo.forEach(s => { state[s.id] = enabled.includes(s.id); });
const overlay = document.createElement('div');
overlay.id = 'ya-albums-sources-modal-overlay';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const rows = sourceInfo.map(s => {
const isConnected = connected.includes(s.id);
const isOn = state[s.id];
return `
${s.icon}
${s.label}
${isConnected ? 'Connected' : 'Not connected'}
`;
}).join('');
overlay.innerHTML = `
Your Albums Sources
Choose which connected services contribute albums to this section.
${rows}
`;
document.body.appendChild(overlay);
window._yaaSourcesState = state;
}
// Source-id โ human label + setup hint shown when user tries to enable
// a disconnected source. Without this, the toggle silently bailed and
// users saw no feedback โ just a non-responsive switch.
const _YAA_DISCONNECTED_HINTS = {
spotify: 'Spotify not connected โ log in at Settings โ Connections first',
tidal: 'Tidal not connected โ set up Tidal in Settings โ Connections first',
deezer: 'Deezer not connected โ log in or set ARL token at Settings โ Connections first',
discogs: 'Discogs not connected โ paste your personal access token at Settings โ Connections first',
};
function _yaaShowDisconnectedHint(id) {
const msg = _YAA_DISCONNECTED_HINTS[id]
|| `${id} not connected โ set it up in Settings โ Connections first`;
if (typeof showToast === 'function') showToast(msg, 'warning');
}
function _yaaSourceRowClick(id) {
const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`);
if (row && row.classList.contains('disconnected')) {
_yaaShowDisconnectedHint(id);
return;
}
_yaaSourceToggle(id);
}
function _yaaSourceToggle(id) {
const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`);
if (row && row.classList.contains('disconnected')) {
_yaaShowDisconnectedHint(id);
return;
}
window._yaaSourcesState[id] = !window._yaaSourcesState[id];
const btn = document.getElementById(`yaa-toggle-${id}`);
if (btn) btn.classList.toggle('on', window._yaaSourcesState[id]);
}
async function _yaaSourcesSave() {
const enabledArr = Object.entries(window._yaaSourcesState).filter(([, v]) => v).map(([k]) => k);
if (enabledArr.length === 0) { showToast('Select at least one source', 'error'); return; }
try {
const resp = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ discover: { your_albums_sources: enabledArr.join(',') } })
});
if (resp.ok) {
document.getElementById('ya-albums-sources-modal-overlay')?.remove();
showToast('Sources saved โ refresh to apply', 'success');
const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', discogs: 'Discogs' };
const subtitle = document.getElementById('your-albums-subtitle');
if (subtitle) {
const names = enabledArr.map(s => sourceNames[s] || s).join(' and ');
subtitle.textContent = `Albums you\u2019ve saved on ${names}`;
}
} else {
showToast('Failed to save sources', 'error');
}
} catch (e) {
showToast('Failed to save sources', 'error');
}
}
async function downloadMissingYourAlbums() {
// Opens the same selectable-grid modal pattern used by Download
// Discography on the library page. User picks which missing albums
// they want, clicks Add to Wishlist, each album's tracks get
// resolved + added to the wishlist for the existing auto-download
// processor to pick up. Replaces the prior per-album direct-download
// loop which was silently failing โ actual downloads should go
// through the wishlist queue, not bypass it.
try {
const resp = await fetch('/api/discover/your-albums?page=1&per_page=1000&status=missing');
const data = await resp.json();
if (!data.success || !data.albums || data.albums.length === 0) {
showToast('No missing albums to download', 'info');
return;
}
const missing = data.albums.filter(a => !a.in_library);
if (missing.length === 0) {
showToast('All albums are already in your library!', 'success');
return;
}
_openYourAlbumsBatchModal(missing);
} catch (e) {
console.error('Error loading missing your albums:', e);
showToast(`Error: ${e.message}`, 'error');
}
}
// Map a Your Albums row to the single best source-id the
// /api/artist//download-discography endpoint can resolve. Each row
// in the missing list typically only has one populated source-id (the
// service it was saved on), so this is just a priority pick.
function _yourAlbumsPickSource(album) {
if (album.spotify_album_id) return { id: String(album.spotify_album_id), source: 'spotify' };
if (album.deezer_album_id) return { id: String(album.deezer_album_id), source: 'deezer' };
if (album.tidal_album_id) return { id: String(album.tidal_album_id), source: 'tidal' };
const discogsId = album.discogs_release_id || album.discogs_id;
if (discogsId) return { id: String(discogsId), source: 'discogs' };
return null;
}
function _openYourAlbumsBatchModal(missingAlbums) {
// Reuses the .discog-modal styling from the library Download
// Discography flow โ same checkboxes, same Select All / Deselect
// All semantics, same footer. Single difference: each card carries
// its own artist+source (multi-artist) instead of all being one
// artist's discography.
const existing = document.getElementById('your-albums-batch-modal-overlay');
if (existing) existing.remove();
// Stash the source-id picks on the cards so the submit handler
// can build the per-album payload without re-mapping the array.
const rows = missingAlbums
.map((a, i) => ({ ...a, _src: _yourAlbumsPickSource(a), _index: i }))
.filter(a => a._src); // Skip albums with no usable source-id
if (rows.length === 0) {
showToast('No missing albums have a usable source ID to resolve', 'warning');
return;
}
const overlay = document.createElement('div');
overlay.className = 'discog-modal-overlay';
overlay.id = 'your-albums-batch-modal-overlay';
overlay.innerHTML = `
`;
},
loadingMessage: 'Curating your discovery playlist...',
emptyMessage: 'No tracks available yet',
errorMessage: 'Failed to load discovery weekly',
verboseErrors: true,
showErrorToast: true,
});
}
return _weeklyCtrl.load();
}
// ===============================
// DECADE BROWSER
// ===============================
let selectedDecade = null;
let decadeTracks = [];
function _renderDecadeCard(decade) {
const icon = getDecadeIcon(decade.year);
const label = `${decade.year}s`;
return `
${icon}
${label}
${decade.track_count} tracks
Classics
`;
}
let _decadeBrowserCtrl = null;
async function loadDecadeBrowser() {
if (!_decadeBrowserCtrl) {
_decadeBrowserCtrl = createDiscoverSectionController({
id: 'decade-browser',
contentEl: '#decade-browser-carousel',
fetchUrl: '/api/discover/decades/available',
extractItems: (data) => data.decades || [],
renderItems: (items) => items.map(d => _renderDecadeCard(d)).join(''),
loadingMessage: 'Loading decades...',
emptyMessage: 'No decade content available yet. Run a watchlist scan to populate your discovery pool!',
errorMessage: 'Failed to load decades',
verboseErrors: true,
showErrorToast: true,
});
}
return _decadeBrowserCtrl.load();
}
function getDecadeIcon(year) {
const icons = {
1950: '๐บ',
1960: '๐ธ',
1970: '๐บ',
1980: '๐ป',
1990: '๐ฟ',
2000: '๐ฑ',
2010: '๐ง',
2020: '๐'
};
return icons[year] || '๐ต';
}
async function openDecadePlaylist(decade) {
try {
showLoadingOverlay(`Loading ${decade}s playlist...`);
const response = await fetch(`/api/discover/decade/${decade}`);
if (!response.ok) {
throw new Error('Failed to fetch decade playlist');
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
const message = data.message || `No tracks found for the ${decade}s`;
showToast(message, 'info');
hideLoadingOverlay();
return;
}
selectedDecade = decade;
decadeTracks = data.tracks;
// Open download modal
const playlistName = `${decade}s Classics`;
const virtualPlaylistId = `decade_${decade}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks);
hideLoadingOverlay();
} catch (error) {
console.error(`Error opening ${decade}s playlist:`, error);
showToast(`Failed to load ${decade}s playlist`, 'error');
hideLoadingOverlay();
}
}
// ===============================
// GENRE BROWSER
// ===============================
let selectedGenre = null;
let genreTracks = [];
function _renderGenreCard(genre) {
const icon = getGenreIcon(genre.name);
const displayName = capitalizeGenre(genre.name);
return `
${icon}
${displayName}
${genre.track_count} tracks
Curated
`;
}
let _genreBrowserCtrl = null;
async function loadGenreBrowser() {
if (!_genreBrowserCtrl) {
_genreBrowserCtrl = createDiscoverSectionController({
id: 'genre-browser',
contentEl: '#genre-browser-carousel',
fetchUrl: '/api/discover/genres/available',
extractItems: (data) => data.genres || [],
renderItems: (items) => items.map(g => _renderGenreCard(g)).join(''),
loadingMessage: 'Loading genres...',
emptyMessage: 'No genre content available yet. Run a watchlist scan to populate your discovery pool!',
errorMessage: 'Failed to load genres',
verboseErrors: true,
showErrorToast: true,
});
}
return _genreBrowserCtrl.load();
}
function getGenreIcon(genreName) {
const genre = genreName.toLowerCase();
// Parent genre exact matches (consolidated categories)
if (genre === 'electronic/dance') return '๐น';
if (genre === 'hip hop/rap') return '๐ค';
if (genre === 'rock') return '๐ธ';
if (genre === 'pop') return '๐ต';
if (genre === 'r&b/soul') return '๐๏ธ';
if (genre === 'jazz') return '๐บ';
if (genre === 'classical') return '๐ป';
if (genre === 'metal') return '๐ค';
if (genre === 'country') return '๐ช';
if (genre === 'folk/indie') return '๐ง';
if (genre === 'latin') return '๐';
if (genre === 'reggae/dancehall') return '๐ด';
if (genre === 'world') return '๐';
if (genre === 'alternative') return '๐ญ';
if (genre === 'blues') return '๐ธ';
if (genre === 'funk/disco') return '๐บ';
// Fallback: partial matching for specific genres
if (genre.includes('house') || genre.includes('techno') || genre.includes('edm') ||
genre.includes('electro') || genre.includes('trance') || genre.includes('electronic')) {
return '๐น';
}
if (genre.includes('hip hop') || genre.includes('rap') || genre.includes('trap')) {
return '๐ค';
}
if (genre.includes('rock') || genre.includes('punk')) {
return '๐ธ';
}
if (genre.includes('metal')) {
return '๐ค';
}
if (genre.includes('jazz') || genre.includes('blues')) {
return '๐บ';
}
if (genre.includes('pop')) {
return '๐ต';
}
if (genre.includes('r&b') || genre.includes('soul')) {
return '๐๏ธ';
}
if (genre.includes('country') || genre.includes('folk')) {
return '๐ช';
}
if (genre.includes('classical') || genre.includes('orchestra')) {
return '๐ป';
}
if (genre.includes('indie') || genre.includes('alternative')) {
return '๐ง';
}
if (genre.includes('latin') || genre.includes('reggaeton') || genre.includes('salsa')) {
return '๐';
}
if (genre.includes('reggae') || genre.includes('dancehall')) {
return '๐ด';
}
if (genre.includes('funk') || genre.includes('disco')) {
return '๐บ';
}
// Default
return '๐ถ';
}
function capitalizeGenre(genre) {
// Capitalize each word in genre, handling both spaces and slashes
return genre.split(/(\s|\/)/g)
.map(part => {
if (part === ' ' || part === '/') return part;
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join('');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function openGenrePlaylist(genre) {
try {
showLoadingOverlay(`Loading ${capitalizeGenre(genre)} playlist...`);
const response = await fetch(`/api/discover/genre/${encodeURIComponent(genre)}`);
if (!response.ok) {
throw new Error('Failed to fetch genre playlist');
}
const data = await response.json();
if (!data.success || !data.tracks || data.tracks.length === 0) {
const message = data.message || `No tracks found for ${genre}`;
showToast(message, 'info');
hideLoadingOverlay();
return;
}
selectedGenre = genre;
genreTracks = data.tracks;
// Open download modal
const playlistName = `${capitalizeGenre(genre)} Mix`;
const virtualPlaylistId = `genre_${genre.replace(/\s+/g, '_')}`;
await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks);
hideLoadingOverlay();
} catch (error) {
console.error(`Error opening ${genre} playlist:`, error);
showToast(`Failed to load ${genre} playlist`, 'error');
hideLoadingOverlay();
}
}
// ===============================
// TIME MACHINE (TABBED BY DECADE)
// ===============================
let decadeTracksCache = {}; // Store tracks for each decade
let activeDecade = null;
// Shared sync-status display block. Used by per-tab playlists
// (decade browser, genre browser) where we show download progress
// in the standard "โ completed | โณ pending | โ failed (N%)" format.
// ListenBrainz playlists use a different shape (total/matched/failed)
// because they show MATCHING progress against the library, not
// download progress, so they intentionally don't use this helper.
function _renderSyncStatusBlock(idPrefix) {
return `
โณSyncing to media server...
โ 0โณ 0โ 0(0%)
`;
}
// ===============================
// TABBED BROWSER HELPER
// ===============================
//
// Drives the lifecycle the decade browser ("Time Machine") and
// genre browser ("Browse by Genre") share: fetch tab list โ paint
// tab strip + per-tab content shells โ fetch + render content for
// the active tab โ handle empty / error states.
//
// The two browsers paint slightly different markup (different CSS
// prefixes, different action buttons, different sync handlers) but
// the lifecycle is identical. Each browser registers a config; the
// helper handles the rest.
//
// Two-phase render:
// Phase 1 (loadTabs) โ paint tab strip + N content shells,
// each shell containing a loading
// spinner in its playlist container,
// then trigger Phase 2 for first tab.
// Phase 2 (loadTabContent) โ fetch tracks for one tab, swap the
// spinner in its playlist container
// for the rendered track list.
//
// Renderers stay per-browser because action buttons + classes
// legitimately differ. The helper owns the lifecycle, not the look.
function createTabbedBrowserSection(config) {
const cfg = Object.assign({
// Diagnostic id used in console errors.
id: 'tabbed-browser',
// DOM IDs of the tab-strip + per-tab-contents containers.
tabsContainerId: null,
contentsContainerId: null,
// Async fn returning array of tab descriptors (e.g. decades).
fetchTabs: null,
// (tab) => string unique id for one tab (e.g. 'decade-1980').
// Used as the prefix for that tab's content + playlist + sync IDs.
tabId: null,
// (tab) => string HTML for one tab button. Receives `(tab, isActive)`.
renderTabButton: null,
// (tab) => string HTML for one tab's content shell (action
// buttons + sync-status block + empty playlist container).
// The playlist container inside MUST have id `${tabId}-playlist`
// so the helper can fill it during Phase 2.
renderTabShell: null,
// Async fn (tab) => array of tracks for that tab.
fetchTabContent: null,
// (tracks, tab) => string HTML for the playlist container.
renderTabTracks: null,
// Copy / messages.
emptyTabsMessage: 'No content available',
emptyContentMessage: (tab) => 'No tracks found',
errorTabsMessage: 'Failed to load',
errorContentMessage: 'Failed to load tracks',
// Fired after Phase 1 paints the tab strip + shells, before
// Phase 2 is triggered for the first tab. Useful for caching
// the tab list (e.g. `availableGenres = ...`).
onTabsRendered: null,
}, config || {});
async function loadTabs() {
try {
const tabsContainer = document.getElementById(cfg.tabsContainerId);
const contentsContainer = document.getElementById(cfg.contentsContainerId);
if (!tabsContainer || !contentsContainer) return;
const tabs = await cfg.fetchTabs();
if (!Array.isArray(tabs) || tabs.length === 0) {
tabsContainer.innerHTML = `
${cfg.emptyTabsMessage}
`;
return;
}
let tabsHTML = '';
let contentsHTML = '';
tabs.forEach((tab, index) => {
const isActive = index === 0;
tabsHTML += cfg.renderTabButton(tab, isActive);
contentsHTML += cfg.renderTabShell(tab, isActive);
});
tabsContainer.innerHTML = tabsHTML;
contentsContainer.innerHTML = contentsHTML;
if (typeof cfg.onTabsRendered === 'function') {
try { cfg.onTabsRendered(tabs); }
catch (err) { console.debug(`[${cfg.id}] onTabsRendered threw:`, err); }
}
// Phase 2: kick off content load for the first tab.
await loadTabContent(tabs[0]);
} catch (error) {
console.error(`Error loading ${cfg.id} tabs:`, error);
const tabsContainer = document.getElementById(cfg.tabsContainerId);
if (tabsContainer) {
tabsContainer.innerHTML = `
`;
},
fetchTabContent: async (decade) => {
const response = await fetch(`/api/discover/decade/${decade.year}`);
if (!response.ok) throw new Error('Failed to fetch decade playlist');
const data = await response.json();
if (!data.success) return [];
// Side-effect: cache + active marker, exactly as old code did.
decadeTracksCache[decade.year] = data.tracks || [];
activeDecade = decade.year;
return data.tracks || [];
},
renderTabTracks: (tracks) => _renderTabbedTrackList(tracks),
emptyContentMessage: (decade) => `No tracks found for the ${decade.year}s`,
errorTabsMessage: 'Failed to load decades',
errorContentMessage: 'Failed to load decade tracks',
emptyTabsMessage: 'No decade content available yet. Run a watchlist scan to populate your discovery pool!',
});
return _decadeBrowserTabsCtrl;
}
// Shared track-row markup for tabbed browsers. Decade + genre rows
// have the same shape โ both pull from `track_data_json` first then
// fall back to top-level fields. Lifted so the helper-driven
// renderers don't each carry a copy.
function _renderTabbedTrackList(tracks) {
let html = '
';
return;
}
// For recommendations tab with multiple playlists, group into sub-tabs
if (tabId === 'recommendations' && playlists.length > 1) {
const { groups, groupOrder } = groupListenBrainzPlaylists(playlists);
// If only one group, no need for sub-tabs
if (groupOrder.length <= 1) {
const html = buildListenBrainzPlaylistsHtml(playlists, tabId);
container.innerHTML = html;
loadTracksForPlaylists(playlists);
return;
}
// Build sub-tabs bar
const firstGroup = activeListenBrainzSubTab && groupOrder.includes(activeListenBrainzSubTab)
? activeListenBrainzSubTab
: groupOrder[0];
activeListenBrainzSubTab = firstGroup;
let subTabsHtml = '
`;
if (bmp) {
const c = document.getElementById('artmap-card-canvas');
if (c) { try { c.getContext('2d').drawImage(bmp, 0, 0, 120, 120); } catch (e) { /* ignore */ } }
}
// Confirm watchlist membership from the server (refreshes the button if it
// differs from the optimistic guess).
_artMapCheckWatched(node);
// On mobile, sliding the bottom sheet up reveals the card.
if (_artMapIsMobile()) _artMapTogglePanelSheet(true);
}
// 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;
// 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;
const _wtb = container.querySelector('.artist-map-toolbar');
_artMap.height = container.clientHeight - (_wtb ? _wtb.offsetHeight : 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`;
// Group every watchlist + similar artist into genre islands. Watchlist
// artists are focal (sit centre-most + sized up). The discovery edges
// (watchlist โ similar) are remapped to the new node ids so the hover
// constellation still shows who's related across islands.
const rawNodes = data.nodes.map(n => ({ ...n, _focal: n.type === 'watchlist' }));
const groups = _artMapGroupByGenre(rawNodes);
_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);
// โโ 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 = `
Placing ${_artMap.placed.length} artists on the map...
`;
container.appendChild(loadingEl);
// Defer heavy work so loading overlay renders first
setTimeout(async () => {
_artMap.placed.forEach(n => { n.opacity = 1; });
// Paint NOW โ the map is fully interactive (pan/zoom/hover/click)
// before a single image is fetched. The reveal animation blooms the
// bubbles in (far field fades, near bubbles pop outward); images then
// stream in behind it and sharpen in place. No blocking on N fetches.
_artMap.dirty = true;
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
_artMapFocusIsland(0, { bloom: true }); // frame + bloom the first genre island
_artMapStreamImages(_artMap.placed);
}, 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.dirty = true;
_artMapRender(); // rebuild at final zoom level
}
}
_artMap._animating = requestAnimationFrame(step);
}
function closeArtistMap() {
const container = document.getElementById('artist-map-container');
if (container) container.style.display = 'none';
const sidebar = document.getElementById('artmap-genre-sidebar');
if (sidebar) sidebar.style.display = 'none';
if (_artMap.animFrame) cancelAnimationFrame(_artMap.animFrame);
// Stop the ambient buoyancy loop so it doesn't run with the map hidden.
_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();
_artMapClosePanel();
if (_artMap._keyHandler) window.removeEventListener('keydown', _artMap._keyHandler);
_artMapHideContextMenu();
// 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;
// Scale based on zoom โ higher zoom = higher res buffer, capped for memory
const z = _artMap.zoom || 0.1;
const scale = Math.min(z * 2, 1.0, _artMap.MAX_BUFFER_PX / 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;
// Freeze the live/buffer partition to this build's zoom (see _artMapIsLiveSize).
_artMap._liveBuildZoom = _artMap.zoom;
_artMap._drawAlphaMul = 1; // buffer bakes at full alpha; the blit applies the reveal fade
// If more bubbles would be "live" than the live layer can draw, bake them ALL
// into the buffer (set the overflow flag BEFORE the draw loop so the live-size
// check below returns false and nothing is skipped). This is what prevents the
// genre-overview "small/sparse" render: the live layer caps out, so let the
// buffer own the whole crowd; live + bob only kick in once zoomed in.
const bz = _artMap.zoom;
let liveN = 0;
for (const n of visible) { if (!n._isLabel && (n.radius || 0) * bz >= _artMap.LIVE_PX) liveN++; }
// In one-island mode the buffer already covers just the focused island at
// high resolution, so let the BUFFER own any non-trivial crowd (one cheap
// crisp blit, no per-frame redraw โ no lag). The live layer + bob/shove only
// take over for small views (zoomed-in subsets, explore) where redrawing a
// handful of bubbles each frame is cheap.
_artMap._liveOverflow = liveN > 140;
octx.scale(scale, scale);
octx.translate(-minX, -minY);
// Build node lookup
if (!_artMap._nodeById) {
_artMap._nodeById = {};
placed.forEach(n => { _artMap._nodeById[n.id] = n; });
}
// Draw edges (connection lines between related nodes)
if (_artMap.edges && _artMap.edges.length > 0) {
octx.lineWidth = 1;
octx.strokeStyle = 'rgba(138,43,226,0.08)';
octx.beginPath();
for (const edge of _artMap.edges) {
const s = _artMap._nodeById[edge.source];
const t = _artMap._nodeById[edge.target];
if (!s || !t || (s.opacity || 0) < 0.05 || (t.opacity || 0) < 0.05) continue;
octx.moveTo(s.x, s.y);
octx.lineTo(t.x, t.y);
}
octx.stroke();
}
// Draw ALL nodes โ genre labels first, similar next, watchlist on top
const hideSimilar = _artMap._hideSimilar || false;
// Pass 0: genre labels, Pass 1: similar/ring2, Pass 2: watchlist/center/ring1
for (let pass = 0; pass < 3; pass++) {
for (const n of visible) {
if (pass === 0 && n._isLabel) { /* draw */ }
else if (pass === 1 && !n._isLabel && n.type !== 'watchlist' && n.type !== 'center' && n.ring !== 1) { /* draw */ }
else if (pass === 2 && !n._isLabel && (n.type === 'watchlist' || n.type === 'center' || n.ring === 1)) { /* draw */ }
else continue;
if (hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue;
if (_artMapIsLiveSize(n)) continue; // big enough to read โ drawn live on the overlay
_artMapDrawNodeToBuffer(octx, n, scale);
}
}
octx.globalAlpha = 1;
_artMap.dirty = false;
}
// Draw a SINGLE node into the offscreen buffer (in world coords; caller has
// already applied the buffer's scale+translate). Shared by the full rebuild and
// the incremental image compositor so the two can never drift visually.
function _artMapDrawNodeToBuffer(octx, n, scale) {
const op = n.opacity || 0;
if (op < 0.01) return;
const r = n.radius;
const isW = n.type === 'watchlist' || n.type === 'center';
// Global fade multiplier (reveal). Lets the whole map fade in cleanly while
// each painter keeps its own per-element alpha.
const mul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul;
octx.globalAlpha = op * mul;
// Genre title โ a clean floating label above its island (no big bubble).
if (n._isLabel) {
const hue = n._hue == null ? 270 : n._hue;
const titleSize = Math.max(13, n.radius * 0.42);
const name = (n.name || '').toUpperCase();
octx.textAlign = 'center';
octx.textBaseline = 'middle';
// Soft glow behind the title for legibility over the water.
octx.globalAlpha = mul;
octx.font = `800 ${titleSize}px system-ui, sans-serif`;
octx.shadowColor = `hsla(${hue},70%,12%,0.9)`;
octx.shadowBlur = titleSize * 0.6;
octx.fillStyle = `hsla(${hue},85%,82%,0.96)`;
octx.fillText(name, n.x, n.y);
octx.shadowBlur = 0;
// Count subtitle
octx.globalAlpha = 0.55 * mul;
octx.font = `600 ${titleSize * 0.42}px system-ui, sans-serif`;
octx.fillStyle = 'rgba(255,255,255,0.7)';
octx.fillText(`${n._count || 0} artists`, n.x, n.y + titleSize * 0.85);
octx.globalAlpha = 1;
return;
}
// On-screen size drives detail. Album art shows at nearly every size (the
// images are pre-masked to circles, so this is just a cheap drawImage โ no
// per-frame clip) for a consistent "sea of covers" look. Only the very
// smallest fall back to a coloured dot.
const rScaled = r * scale;
const img = _artMap.images[n.id];
if (rScaled < 2.2) {
octx.beginPath();
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
octx.fillStyle = isW ? '#6b21a8' : '#2a2a40';
octx.fill();
return;
}
// Focal glow ring for watchlist/center bubbles
if (isW && rScaled >= 7) {
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();
}
// Body โ pre-masked circular image (no clip) or a placeholder disc.
if (img) {
octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2);
} else {
octx.beginPath();
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
octx.fillStyle = isW ? '#1a0a30' : '#141420';
octx.fill();
}
// Glassy specular highlight (orb look) โ only on bubbles big enough to read
// it; skipping the dense swarm halves per-frame drawImage cost when zoomed in.
if (rScaled >= 12) {
octx.drawImage(_artMapGlossSprite(), n.x - r, n.y - r, r * 2, r * 2);
}
const showLabel = rScaled >= 13;
// Darken art behind the label so the name stays legible.
if (showLabel && img) {
octx.beginPath();
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
octx.fillStyle = 'rgba(0,0,0,0.42)';
octx.fill();
}
// Border โ tinted with the island's genre hue so clusters read as a family.
octx.beginPath();
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
if (isW) octx.strokeStyle = 'rgba(138,43,226,0.5)';
else if (n._hue != null) octx.strokeStyle = `hsla(${n._hue},70%,70%,0.22)`;
else octx.strokeStyle = 'rgba(255,255,255,0.10)';
octx.lineWidth = isW ? 2 : (rScaled >= 7 ? 1 : 0.5);
octx.stroke();
if (showLabel) {
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);
}
}
// Composite ONE node into the EXISTING buffer without a full rebuild. This is
// what makes image streaming cheap: when a bitmap arrives we redraw only that
// node (in place, over its placeholder) instead of redrawing all ~1500 nodes.
// Returns false if there's no buffer yet (or the node is hidden) so the caller
// can fall back to a full rebuild. Zoom changes rebuild the buffer at a new
// scale; this always reads the CURRENT buffer scale/origin, so it stays correct.
function _artMapCompositeNode(n) {
const oc = _artMap.offscreen;
const scale = _artMap._bufferScale;
if (!oc || scale == null) return false;
if (_artMap._hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) return false;
if ((n.opacity || 0) < 0.01) return false;
// Live-layer bubbles aren't in the buffer โ they read their image fresh each
// frame, so just signal "blit" and let the overlay pick it up. Compositing
// them here would double-draw (buffer copy + live copy).
if (_artMapIsLiveSize(n)) return true;
const octx = oc.getContext('2d');
octx.save();
octx.scale(scale, scale);
octx.translate(-_artMap._bufferMinX, -_artMap._bufferMinY);
_artMapDrawNodeToBuffer(octx, n, scale);
octx.restore();
octx.globalAlpha = 1;
return true;
}
// A node renders on the live overlay when it's big enough on screen to read.
// Labels stay baked in the static buffer (no per-frame motion needed in v2).
// IMPORTANT: this uses the zoom the BUFFER WAS BUILT AT (_liveBuildZoom), not
// the live zoom. The buffer only rebuilds ~300ms after zooming stops, so the
// live/buffer split must stay frozen to whatever the (possibly stale) buffer
// excluded โ otherwise a bubble could fall out of both during an active zoom
// and flicker. Both the buffer-exclude and the live-draw read this same value,
// so the two sets are always exact complements.
function _artMapIsLiveSize(n) {
if (n._isLabel) return false;
// When too many bubbles would be "live" at once (e.g. the genre overview),
// the live layer's cap can't draw them all and the buffer would exclude them
// โ a sparse/half-rendered map. In that case treat NOTHING as live so the
// buffer bakes everything (full, correct render); the live layer + bob only
// take over once you've zoomed in to where few bubbles are big.
if (_artMap._liveOverflow) return false;
const z = _artMap._liveBuildZoom || _artMap.zoom;
return (n.radius || 0) * z >= _artMap.LIVE_PX;
}
// Draw the live overlay: every big/near bubble, in world space, honouring its
// per-node animation transform (aScale for reveal/pop, opacity for fade). Kept
// cheap by viewport culling + a hard cap; the static far field is already on
// screen via the buffer blit.
function _artMapDrawLiveLayer(ctx) {
const placed = _artMap.placed;
if (!placed || !placed.length) return;
const z = _artMap.zoom;
const ox = _artMap.offsetX, oy = _artMap.offsetY;
const w = _artMap.width, h = _artMap.height;
const margin = 80;
ctx.save();
ctx.translate(ox, oy);
ctx.scale(z, z);
_artMap._drawAlphaMul = 1;
// During the reveal the buffer is bypassed, so the live layer draws EVERY
// bubble (and the genre titles) so they can all bloom. Otherwise it draws
// only the big/near ones; the rest live in the static buffer.
const revealing = _artMap._revealing;
let drawn = 0;
const CAP = revealing ? 2200 : 600;
for (const n of placed) {
if (!revealing && !_artMapIsLiveSize(n)) continue;
if (_artMap._hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue;
// Viewport cull (screen space)
const sx = ox + n.x * z, sy = oy + n.y * z;
const rPx = (n.radius || 0) * z;
if (sx + rPx < -margin || sx - rPx > w + margin || sy + rPx < -margin || sy - rPx > h + margin) continue;
_artMapDrawLiveNode(ctx, n);
if (++drawn >= CAP) break;
}
_artMap._drawAlphaMul = 1;
ctx.restore();
ctx.globalAlpha = 1;
// Count of non-label bubbles drawn live โ drives whether the ambient loop
// keeps running (zoomed out = 0 = loop parks).
_artMap._liveCount = revealing ? 0 : drawn;
}
// Tactile hover-pop: redraw the hovered bubble slightly larger with its cover
// + a bright hue ring, on top of everything. Works even when the bubble lives
// in the static buffer (genre islands), so hover always feels responsive.
// ctx is already in world space (translate(offset) + scale(zoom)).
function _artMapDrawHoverPop(ctx, n) {
const r = n.radius;
const hue = n._hue == null ? 270 : n._hue;
const s = 1.16;
const img = _artMap.images[n.id];
ctx.save();
ctx.translate(n.x, n.y); ctx.scale(s, s); ctx.translate(-n.x, -n.y);
if (img) {
ctx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2);
} else {
ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fillStyle = '#1a0a30'; ctx.fill();
}
ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.strokeStyle = `hsla(${hue},90%,78%,0.95)`;
ctx.lineWidth = 2.5 / s; ctx.stroke();
ctx.restore();
ctx.beginPath(); ctx.arc(n.x, n.y, r * s + 5, 0, Math.PI * 2);
ctx.strokeStyle = `hsla(${hue},85%,66%,0.45)`;
ctx.lineWidth = 3; ctx.stroke();
}
// Draw one live bubble with its animation transform. aScale scales about the
// node centre; aAlpha fades it (folded into the global draw-alpha multiplier).
// Reuses the shared node painter so the bubble is identical to its baked form
// once settled.
function _artMapDrawLiveNode(ctx, n) {
const sc = n.aScale == null ? 1 : n.aScale;
if (sc <= 0.001) return;
const baseMul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul;
_artMap._drawAlphaMul = baseMul * (n.aAlpha == null ? 1 : n.aAlpha);
// Ambient buoyancy + ripple shove (steady state only โ the reveal has its
// own motion). Both are world-space offsets applied about the node centre.
let ox = 0, oy = 0;
if (_artMap._revealing) {
if (n._revealRise) oy += n._revealRise; // surfacing rise during the bloom
} else {
if (n._bobAmp) oy += Math.sin((_artMap._now || 0) * 0.0016 + (n._bobPhase || 0)) * n._bobAmp;
const disp = _artMapNodeDisplacement(n);
if (disp) { ox += disp.dx; oy += disp.dy; }
}
if (sc !== 1 || ox || oy) {
ctx.save();
ctx.translate(n.x + ox, n.y + oy);
ctx.scale(sc, sc);
ctx.translate(-n.x, -n.y);
_artMapDrawNodeToBuffer(ctx, n, _artMap.zoom);
ctx.restore();
} else {
_artMapDrawNodeToBuffer(ctx, n, _artMap.zoom);
}
_artMap._drawAlphaMul = baseMul;
ctx.globalAlpha = 1;
}
// โโ Animation loop โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Runs only while something is animating; idles otherwise. Each tick advances
// the active animations (reveal field-fade + per-node pop), draws one frame,
// and re-arms itself only if work remains โ so a still map costs nothing.
function _artMapStartLoop() {
const a = _artMap._anim;
if (a.running) return;
a.running = true;
a.last = performance.now();
const tick = (t) => {
if (!a.running) return;
_artMap._now = t;
const more = _artMapStepAnimations(t);
// Cap the whole animation loop at ~30fps. The reveal bloom, ripples and
// ambient bob all read fine at 30, and halving the redraws keeps the
// 1800-bubble genre map smooth instead of churning every frame. Always
// honour a pending buffer rebuild (dirty) so the throttle can't skip the
// frame that bakes the map after the reveal ends.
if (_artMap.dirty || (t - (a._lastDraw || 0)) >= 31) {
_artMapDraw(); // sets _artMap._liveCount
a._lastDraw = t;
}
const keep = more || (_artMap._ambient && _artMap._liveCount > 0 && !document.hidden);
if (keep) {
a.raf = requestAnimationFrame(tick);
} else {
a.running = false;
a.raf = null;
}
};
a.raf = requestAnimationFrame(tick);
}
// (Re)start the ambient loop if buoyancy is on and it isn't already running โ
// called after the reveal and on zoom/pan so bob resumes when bubbles appear.
function _artMapEnsureAmbient() {
if (_artMap._ambient && !_artMap._anim.running && !document.hidden) _artMapStartLoop();
}
// Advance every active animation by absolute time t (ms). Returns true while
// anything is still moving. Phase C: each bubble scales+fades in (ease-out)
// once past its staggered start, and a water ripple expands from each island.
function _artMapStepAnimations(t) {
let active = false;
const placed = _artMap.placed;
if (placed) {
for (const n of placed) {
if (n.aScale == null || n.aScale >= 1) continue;
if (t < n._revealAt) { active = true; continue; }
const p = Math.min(1, (t - n._revealAt) / (n._revealDur || 480));
if (p >= 1) { n.aScale = 1; n.aAlpha = 1; n._revealRise = 0; }
else {
// Scale eases in with a gentle overshoot (ease-out-back, subtle),
// alpha fades a touch faster, and the bubble rises up into place
// like it's surfacing through water โ the remaining rise decays
// as (1-p)^3.
const c1 = 1.18, c3 = c1 + 1;
const back = 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2);
n.aScale = back;
n.aAlpha = Math.min(1, p * 1.6);
n._revealRise = Math.pow(1 - p, 3) * (n._riseAmp || 0);
active = true;
}
}
}
const rip = _artMap._ripples;
if (rip && rip.length) {
let anyAlive = false;
for (const r of rip) if (t < r.t0 + r.dur) anyAlive = true;
if (anyAlive) active = true; else _artMap._ripples = [];
}
// When the bloom finishes, leave reveal mode and bake everything into the
// static buffer (one rebuild) so steady-state goes back to the cheap path.
if (!active && _artMap._revealing) {
_artMap._revealing = false;
_artMap.dirty = true;
return true; // one more frame to do the rebuild + final blit
}
return active;
}
// Kick off the ripple-bloom reveal. Each island blooms in turn (staggered by
// island order); within an island, bubbles fade+scale outward from the centre
// like a drop hitting water, and a ripple ring expands from each island centre.
// During the reveal the whole map renders on the live layer (the static buffer
// is bypassed) so every bubble can animate; it bakes into the buffer at the end.
function _artMapBeginReveal() {
const t0 = performance.now();
_artMap._revealT0 = t0;
_artMap._revealing = true;
_artMap._ambient = true; // keep the loop alive afterwards for buoyancy
_artMap._fieldAlpha = 1; // buffer is bypassed while revealing; live layer draws all
const islands = _artMap._islands || [];
const islByName = {};
islands.forEach((isl, i) => { isl._order = i; islByName[isl.name] = isl; });
const ISL_STAGGER = 145, RADIAL_MS = 430, NODE_DUR = 470;
for (const n of _artMap.placed) {
n.aScale = 0; n.aAlpha = 0;
const isl = islByName[n._island] || (n._isLabel ? islByName[n.name] : null);
const order = isl ? isl._order : 0;
let radial = 0;
if (isl && isl.r > 0) radial = Math.min(1, Math.hypot(n.x - isl.cx, n.y - isl.cy) / isl.r);
n._revealAt = t0 + order * ISL_STAGGER + radial * RADIAL_MS + (n._isLabel ? 90 : 0);
n._revealDur = NODE_DUR;
}
_artMap._ripples = islands.map(isl => ({
cx: isl.cx, cy: isl.cy, hue: isl.hue,
maxR: isl.r * 1.45, t0: t0 + isl._order * ISL_STAGGER, dur: 1150,
}));
_artMapStartLoop();
}
// Draw the expanding water-ripple rings (reveal + click). Cheap stroked arcs,
// hue-tinted, fading as they grow. Drawn in world space with a screen-constant
// line width.
function _artMapDrawRipples(ctx) {
const rip = _artMap._ripples;
if (!rip || !rip.length) return;
const t = performance.now();
const z = _artMap.zoom;
ctx.save();
ctx.translate(_artMap.offsetX, _artMap.offsetY);
ctx.scale(z, z);
for (const r of rip) {
const p = (t - r.t0) / r.dur;
if (p < 0 || p > 1) continue;
const radius = r.maxR * (0.08 + 0.92 * (1 - Math.pow(1 - p, 2)));
const alpha = (1 - p) * 0.55;
ctx.beginPath();
ctx.arc(r.cx, r.cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = `hsla(${r.hue},85%,72%,${alpha})`;
ctx.lineWidth = (1.5 + 6 * (1 - p)) / z; // ~constant on screen
ctx.stroke();
}
ctx.restore();
}
// Total world-space displacement on a node from all active "push" ripples โ the
// expanding wavefront shoves nearby bubbles radially outward, then they settle
// back as the wave passes and decays. Returns null when nothing is pushing.
function _artMapNodeDisplacement(n) {
const rip = _artMap._ripples;
if (!rip || !rip.length) return null;
const t = _artMap._now || performance.now();
let dx = 0, dy = 0;
for (const r of rip) {
if (!r.push) continue;
const p = (t - r.t0) / r.dur;
if (p < 0 || p > 1) continue;
const front = r.maxR * (0.08 + 0.92 * (1 - Math.pow(1 - p, 2)));
const ddx = n.x - r.cx, ddy = n.y - r.cy;
const d = Math.hypot(ddx, ddy) || 1;
const delta = d - front;
const width = r.width || (r.maxR * 0.2);
const env = Math.exp(-(delta * delta) / (2 * width * width)); // bump at the wavefront
const push = r.push * env * (1 - p); // decays over the ripple's life
if (push > 0.05) { dx += (ddx / d) * push; dy += (ddy / d) * push; }
}
return (dx || dy) ? { dx, dy } : null;
}
// Emit a water ripple at a world point โ a fading ring plus a radial shove of
// nearby bubbles. Used for click/tap feedback.
function _artMapEmitRipple(wx, wy, hue) {
if (!_artMap._ripples) _artMap._ripples = [];
const WR = _artMap.WATCHLIST_R;
_artMap._ripples.push({
cx: wx, cy: wy, hue: hue == null ? 270 : hue,
maxR: WR * 2.6, t0: performance.now(), dur: 900,
push: WR * 0.22, width: WR * 0.6,
});
_artMapStartLoop(); // animate the ripple (guards against double-start)
}
function _artMapRender() {
// v2 perf: coalesce every render request into a single rAF, so a burst of
// mousemove/pan/animation calls never draws more than once per frame.
if (_artMap._rafPending) return;
_artMap._rafPending = requestAnimationFrame(() => {
_artMap._rafPending = null;
_artMapDraw();
});
}
function _artMapDraw() {
/**Blit offscreen buffer to screen canvas with pan/zoom. Near-zero cost.**/
const _t0 = _artMap._perf ? performance.now() : 0;
if (!_artMap._anim.running) _artMap._now = performance.now(); // keep bob current on on-demand draws
const ctx = _artMap.ctx;
const w = _artMap.width;
const h = _artMap.height;
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, w, h);
// Premium backdrop: a soft central glow fading to a dark vignette. The
// gradient is cached and only rebuilt on resize, so it's one cheap fillRect.
if (!_artMap._bgGrad || _artMap._bgW !== w || _artMap._bgH !== h) {
const g = ctx.createRadialGradient(w / 2, h * 0.42, Math.min(w, h) * 0.12,
w / 2, h / 2, Math.max(w, h) * 0.78);
g.addColorStop(0, 'rgba(46,34,78,0.40)');
g.addColorStop(0.5, 'rgba(16,12,28,0.0)');
g.addColorStop(1, 'rgba(0,0,0,0.55)');
_artMap._bgGrad = g; _artMap._bgW = w; _artMap._bgH = h;
}
ctx.fillStyle = _artMap._bgGrad;
ctx.fillRect(0, 0, w, h);
const z = _artMap.zoom;
// Soft genre-hued halo behind the focused island (one-island mode) โ gives
// the island a sense of place on the water. Cached sprite โ one drawImage.
if (_artMap._oneIsland && _artMap._islands && _artMap._islands.length) {
const isl = _artMap._islands[_artMap._focusIdx || 0];
if (isl) {
const hr = (isl.r * 2.5) * z;
const hsx = _artMap.offsetX + isl.cx * z;
const hsy = _artMap.offsetY + isl.cy * z;
ctx.drawImage(_artMapHaloSprite(isl.hue), hsx - hr, hsy - hr, hr * 2, hr * 2);
}
}
// While the ripple-bloom reveal is running, bypass the static buffer
// entirely and let the live layer draw every bubble (so each can animate).
// The buffer is (re)built once when the reveal ends.
if (!_artMap._revealing) {
if (_artMap.dirty || !_artMap.offscreen) {
const _rt = _artMap._perf ? performance.now() : 0;
_artMapRebuildBuffer();
if (_artMap._perf) _artMap._rebuildMs = performance.now() - _rt;
}
if (_artMap.offscreen) {
const oc = _artMap.offscreen;
const s = _artMap._bufferScale;
const mx = _artMap._bufferMinX;
const my = _artMap._bufferMinY;
// Blit offscreen buffer (built with scale(s) + translate(-minX,-minY)).
const fieldAlpha = _artMap._fieldAlpha == null ? 1 : _artMap._fieldAlpha;
if (fieldAlpha < 0.999) ctx.globalAlpha = fieldAlpha;
ctx.drawImage(oc,
_artMap.offsetX + mx * z,
_artMap.offsetY + my * z,
oc.width * z / s,
oc.height * z / s
);
ctx.globalAlpha = 1;
}
}
// โโ Live overlay layer: big/near bubbles every frame (during the reveal,
// ALL bubbles) so they can scale/bob/ripple. Viewport-culled + capped. โโ
_artMapDrawLiveLayer(ctx);
_artMapDrawRipples(ctx);
// โโ Interactive overlay (drawn on main canvas, not buffer) โโ
const cFade = _artMap._constellationFade || 0;
if (cFade > 0 && (_artMap.hoveredNode || _artMap._constellationCache)) {
const n = _artMap.hoveredNode || (_artMap._constellationCache ? (_artMap._nodeById || {})[_artMap._constellationCache.nodeId] : null);
if (!n) { _artMap._constellationFade = 0; _artMap._constellationCache = null; }
if (n) {
ctx.save();
ctx.translate(_artMap.offsetX, _artMap.offsetY);
ctx.scale(z, z);
// Cache connected node lookup (don't recompute every frame)
if (!_artMap._constellationCache || _artMap._constellationCache.nodeId !== n.id) {
const connectedIds = new Set();
if (n.type === 'watchlist') {
for (const e of _artMap.edges) {
if (e.source === n.id) connectedIds.add(e.target);
}
} else {
const sourceIds = new Set();
for (const e of _artMap.edges) {
if (e.target === n.id) sourceIds.add(e.source);
}
for (const sid of sourceIds) {
connectedIds.add(sid);
for (const e of _artMap.edges) {
if (e.source === sid) connectedIds.add(e.target);
}
}
}
const nById = _artMap._nodeById || {};
_artMap._constellationCache = {
nodeId: n.id,
nodes: [n, ...[...connectedIds].map(id => nById[id]).filter(Boolean)],
};
}
const highlightNodes = _artMap._constellationCache.nodes;
if (highlightNodes.length > 1) {
// Semi-transparent dark overlay on entire visible area
ctx.save();
ctx.resetTransform();
ctx.globalAlpha = 0.6 * cFade;
ctx.fillStyle = '#0a0a14';
ctx.fillRect(0, 0, _artMap.canvas.width, _artMap.canvas.height);
ctx.globalAlpha = 1;
ctx.restore();
// Connection lines โ build the path ONCE, then two cheap strokes
// (wide faint halo + crisp core) for a glow look without per-frame
// gradients or shadowBlur (those were the hover-lag culprits).
ctx.lineCap = 'round';
ctx.beginPath();
for (const cn of highlightNodes) {
if (cn === n) continue;
ctx.moveTo(n.x, n.y);
ctx.lineTo(cn.x, cn.y);
}
ctx.strokeStyle = `rgba(168,85,247,${0.18 * cFade})`;
ctx.lineWidth = 6;
ctx.stroke();
ctx.strokeStyle = `rgba(201,150,255,${0.6 * cFade})`;
ctx.lineWidth = 1.5;
ctx.stroke();
// Redraw highlighted nodes on top
ctx.globalAlpha = cFade;
for (const hn of highlightNodes) {
const r = hn.radius;
const isW = hn.type === 'watchlist';
const isHov = hn === n;
// Glow
if (isHov) {
ctx.beginPath();
ctx.arc(hn.x, hn.y, r + 8, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(138,43,226,0.4)';
ctx.lineWidth = 6;
ctx.stroke();
}
// Circle + image โ pre-masked, so no per-frame clip (that was
// the hover-lag culprit once the ambient loop forced redraws).
const img = _artMap.images[hn.id];
if (img) {
ctx.drawImage(img, hn.x - r, hn.y - r, r * 2, r * 2);
ctx.beginPath();
ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,0.35)'; // keep the name legible over art
ctx.fill();
} else {
ctx.beginPath();
ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2);
ctx.fillStyle = isW ? '#1a0a30' : '#141420';
ctx.fill();
}
// Border
ctx.beginPath();
ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2);
ctx.strokeStyle = isHov ? 'rgba(255,255,255,0.7)' : isW ? 'rgba(138,43,226,0.5)' : 'rgba(255,255,255,0.3)';
ctx.lineWidth = isHov ? 3 : 1.5;
ctx.stroke();
// Name
const fontSize = isW ? Math.max(14, r * 0.14) : Math.max(8, r * 0.3);
ctx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#fff';
const maxC = isW ? 20 : 12;
const label = hn.name.length > maxC ? hn.name.substring(0, maxC - 1) + 'โฆ' : hn.name;
ctx.fillText(label, hn.x, hn.y);
}
ctx.globalAlpha = 1;
} else {
// Single node, no connections โ pop the hovered bubble.
_artMapDrawHoverPop(ctx, n);
}
ctx.restore();
} // end if(n)
} else if (_artMap.hoveredNode && !_artMap._constellationActive) {
// Pre-constellation: instant tactile pop on the hovered bubble.
ctx.save();
ctx.translate(_artMap.offsetX, _artMap.offsetY);
ctx.scale(z, z);
_artMapDrawHoverPop(ctx, _artMap.hoveredNode);
ctx.restore();
}
if (_artMap._perf) _artMapDrawPerf(ctx, _t0);
}
// Toggle with 'd' on the map. Shows where frame time goes so we optimise the
// real bottleneck (buffer rebuild on zoom vs. blit on pan) instead of guessing.
function _artMapDrawPerf(ctx, t0) {
const drawMs = performance.now() - t0;
const now = performance.now();
const dt = _artMap._lastPerfTs ? now - _artMap._lastPerfTs : 0;
_artMap._lastPerfTs = now;
const fps = dt > 0 ? Math.round(1000 / dt) : 0;
const oc = _artMap.offscreen;
// Ship the numbers to app.log (~1.5/s) so they can be read server-side โ
// the on-canvas text below can't be copied, especially mid-lag.
if (!_artMap._perfPostTs || now - _artMap._perfPostTs > 700) {
_artMap._perfPostTs = now;
try {
fetch('/api/discover/artist-map/perf', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
nodes: _artMap.placed.length, edges: (_artMap.edges || []).length,
buffer: oc ? oc.width + 'x' + oc.height : '-',
scale: +(_artMap._bufferScale || 0).toFixed(3),
zoom: +_artMap.zoom.toFixed(3),
rebuildMs: +(_artMap._rebuildMs || 0).toFixed(1),
drawMs: +drawMs.toFixed(1), fps,
}),
}).catch(() => { });
} catch (e) { /* ignore */ }
}
const lines = [
`nodes ${_artMap.placed.length} edges ${(_artMap.edges || []).length}`,
`buffer ${oc ? oc.width + 'ร' + oc.height : 'โ'} scale ${(_artMap._bufferScale || 0).toFixed(3)}`,
`zoom ${_artMap.zoom.toFixed(3)}`,
`rebuild ${(_artMap._rebuildMs || 0).toFixed(1)}ms draw ${drawMs.toFixed(1)}ms`,
`~${fps} fps (while interacting)`,
];
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0); // device pixels, ignore dpr scale
ctx.font = '12px monospace';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const pad = 8, lh = 16;
ctx.fillStyle = 'rgba(0,0,0,0.72)';
ctx.fillRect(10, 10, 270, lines.length * lh + pad * 2);
ctx.fillStyle = '#7CFC00';
lines.forEach((l, i) => ctx.fillText(l, 10 + pad, 10 + pad + i * lh));
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;
const q = (query || '').trim();
if (q.length < 2) { results.style.display = 'none'; results.innerHTML = ''; return; }
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 = '
`;
container.appendChild(loadingEl);
// Update toolbar
document.querySelector('.artmap-brand-text').textContent = 'Genre Map';
document.getElementById('artist-map-stats').textContent = 'Loading...';
try {
// Use cached data from picker or fetch fresh
const data = window._artMapGenreData || await fetch('/api/discover/artist-map/genres').then(r => r.json());
const loadingText = document.getElementById('artmap-genre-loading-text');
if (!data.success || !data.nodes.length) {
if (loadingText) loadingText.textContent = 'No artists with genre data found.';
return;
}
// Find the selected genre + closely related genres (high artist overlap)
const allGenres = data.genres;
const primary = allGenres.find(g => g.name === selectedGenre);
if (!primary) {
if (loadingText) loadingText.textContent = `Genre "${selectedGenre}" not found.`;
return;
}
const primarySet = new Set(primary.artist_ids);
// Find up to 4 related genres by artist overlap
const related = allGenres
.filter(g => g.name !== selectedGenre)
.map(g => {
const overlap = g.artist_ids.filter(id => primarySet.has(id)).length;
return { ...g, overlap };
})
.filter(g => g.overlap > primarySet.size * 0.1) // At least 10% overlap
.sort((a, b) => b.overlap - a.overlap)
.slice(0, 4);
const genres = [primary, ...related];
const totalArtists = genres.reduce((sum, g) => sum + g.artist_ids.length, 0);
document.getElementById('artist-map-stats').innerHTML =
`${escapeHtml(selectedGenre)} โพ ยท ${genres.length} genre${genres.length > 1 ? 's' : ''} ยท ${totalArtists} artists`;
// Build genre-island groups from the selected + related genres and lay
// them out as filled-disc islands on the water (shared engine).
const groups = genres.map(g => ({
name: g.name,
count: g.count,
nodes: (g.artist_ids || []).map(nid => data.nodes[nid]).filter(Boolean),
}));
_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);
// Load images + render
if (loadingText) loadingText.textContent = `Rendering ${placedCount} artists...`;
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
_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));
} catch (err) {
console.error('Genre map error:', err);
const lt = container.querySelector('.artist-map-loading-text');
if (lt) lt.textContent = 'Error loading genre map';
}
}
function openArtistMapExplorerDirect(name) {
if (!name) return;
// Already in map โ just reload with new data, don't re-hide sections
_artMap._skipSectionToggle = true;
_openArtistMapExplorerWithName(name);
}
async function openArtistMapExplorer() {
const name = await _showArtistMapSearchPrompt();
if (!name) return;
_openArtistMapExplorerWithName(name);
}
async function _openArtistMapExplorerWithName(name) {
const container = document.getElementById('artist-map-container');
if (!container) return;
const skipToggle = _artMap._skipSectionToggle;
_artMap._skipSectionToggle = false;
if (!skipToggle) {
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;
const _wtb = container.querySelector('.artist-map-toolbar');
_artMap.height = container.clientHeight - (_wtb ? _wtb.offsetHeight : 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.edges = [];
_artMap.images = {};
_artMap._nodeById = null;
_artMap.dirty = true;
const loadingEl = document.createElement('div');
loadingEl.id = 'artist-map-loading';
loadingEl.innerHTML = `
Exploring ${escapeHtml(name)}...
`;
container.appendChild(loadingEl);
document.querySelector('.artmap-brand-text').textContent = 'Artist Explorer';
try {
const resp = await fetch(`/api/discover/artist-map/explore?name=${encodeURIComponent(name.trim())}`);
const data = await resp.json();
if (!data.success || !data.nodes.length) {
const lt = document.querySelector('.artist-map-loading-text');
if (lt) {
lt.textContent = resp.status === 404
? `"${name}" doesn't appear to be a real artist. Try a different name.`
: `No data found for "${name}". Try a different artist.`;
}
setTimeout(() => {
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
closeArtistMap();
}, 2500);
return;
}
const ring1Count = data.nodes.filter(n => n.ring === 1).length;
const ring2Count = data.nodes.filter(n => n.ring === 2).length;
document.getElementById('artist-map-stats').textContent =
`${data.center} ยท ${ring1Count} similar ยท ${ring2Count} extended`;
// Group the center + all discovered artists into genre islands. The
// center artist is focal. Discovery edges (center โ similar โ extended)
// are remapped so the hover constellation still traces how you got from
// one artist to another across the islands.
const rawNodes = data.nodes.map(n => ({ ...n, _focal: n.ring === 0 || n.type === 'center' }));
const groups = _artMapGroupByGenre(rawNodes);
_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);
// Load images
const loadingText = container.querySelector('.artist-map-loading-text');
if (loadingText) loadingText.textContent = `Loading ${_artMap.placed.length} artists...`;
const le = document.getElementById('artist-map-loading');
if (le) le.remove();
_artMap.dirty = true;
_artMapBeginReveal();
// Stream images in throttled waves โ interactive immediately, sharpens in place.
_artMapStreamImages(_artMap.placed);
} catch (err) {
console.error('Artist explorer error:', err);
const lt = container.querySelector('.artist-map-loading-text');
if (lt) lt.textContent = 'Error loading explorer';
}
}
function _showArtistMapSearchPrompt() {
// Search the metadata source and make the user PICK a real artist, rather
// than exploring whatever loose text they typed. Resolves with the chosen
// artist's resolved name (which the explorer hands to /artist-map/explore),
// or null if cancelled.
return new Promise(resolve => {
const existing = document.getElementById('artmap-search-prompt');
if (existing) existing.remove();
let done = false;
let overlay;
const finish = (val) => { if (done) return; done = true; if (overlay) overlay.remove(); resolve(val); };
overlay = document.createElement('div');
overlay.id = 'artmap-search-prompt';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) finish(null); };
overlay.innerHTML = `
';
} finally {
if (myToken === token) spinner.style.display = 'none';
}
}, 350);
};
input.addEventListener('input', doSearch);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const first = results.querySelector('.artmap-explore-result');
if (first) first.click(); // Enter = pick top match, never raw text
} else if (e.key === 'Escape') {
finish(null);
}
});
setTimeout(() => input.focus(), 50);
});
}
function artMapToggleSimilar() {
_artMap._hideSimilar = !_artMap._hideSimilar;
_artMap.dirty = true;
_artMapRender();
const btn = document.getElementById('artmap-toggle-similar');
if (btn) btn.style.opacity = _artMap._hideSimilar ? '0.4' : '1';
showToast(_artMap._hideSimilar ? 'Showing watchlist only' : 'Showing all artists', 'info', 1500);
}
// Artist images come in at up to 1000ร1000. Nodes are drawn tiny, so holding
// full-res bitmaps is pointless and ruinous: ~1500 nodes ร 1000ยฒ ร 4 bytes โ 6 GB
// of decoded image memory โ GC/GPU thrash that locks the browser even though the
// per-frame draw is cheap. Decode straight to a small avatar (~128px) so the
// whole map's images fit in ~100 MB instead of gigabytes.
// Decode to a sensible avatar size for how big the node actually draws โ crisp
// where it matters (focal/watchlist nodes), light for the swarm of small ones โ
// so total image memory stays ~150-250 MB instead of multiple GB.
function _artMapImgPx(px) {
return Math.min(384, Math.max(112, Math.round(px || 144)));
}
function _artMapDecodeSmall(blob, px) {
if (!blob) return Promise.resolve(null);
const d = _artMapImgPx(px);
try {
return createImageBitmap(blob, {
resizeWidth: d, resizeHeight: d, resizeQuality: 'high',
}).then(_artMapCircleMask)
.catch(() => createImageBitmap(blob).then(_artMapCircleMask).catch(() => null));
} catch (e) {
return createImageBitmap(blob).then(_artMapCircleMask).catch(() => null);
}
}
// Pre-mask a decoded bitmap into a CIRCLE once, at load time, returning a
// canvas. The whole map then draws bubbles with a plain drawImage (the canvas
// is already round) instead of a per-frame ctx.clip() per bubble โ clipping is
// one of the most expensive canvas ops and, at hundreds of visible bubbles per
// frame, was the live-layer stutter. Done once here, it's free forever after.
function _artMapCircleMask(src) {
if (!src) return null;
const w = src.width || 0;
if (!w) return src;
try {
const c = document.createElement('canvas');
c.width = w; c.height = w;
const cx = c.getContext('2d');
cx.beginPath();
cx.arc(w / 2, w / 2, w / 2, 0, Math.PI * 2);
cx.closePath();
cx.clip();
cx.drawImage(src, 0, 0, w, w);
if (src.close) src.close(); // free the ImageBitmap; we keep the canvas
return c;
} catch (e) {
return src; // fall back to the raw bitmap (draw path still clips defensively)
}
}
function _artMapLoadImage(url, px) {
// Try direct CORS fetch first (zero server load, works for Spotify/iTunes/Discogs)
return fetch(url, { mode: 'cors' })
.then(r => r.ok ? r.blob() : Promise.reject('not ok'))
.then(b => _artMapDecodeSmall(b, px))
.catch(() => {
// Fallback: server proxy for CDNs without CORS headers
return fetch('/api/image-proxy?url=' + encodeURIComponent(url))
.then(r => r.ok ? r.blob() : null)
.then(b => _artMapDecodeSmall(b, px))
.catch(() => null);
});
}
// Target avatar px for a node, based on its world radius (โ its on-screen size
// at full zoom). Focal/watchlist nodes get a big crisp avatar; small ones stay
// light. Used by every map's image loader.
function _artMapNodeImgPx(n) {
const isFocal = n.type === 'watchlist' || n.type === 'center' || n.ring === 1;
return _artMapImgPx(isFocal ? Math.max(256, (n.radius || 0) * 1.4) : (n.radius || 0) * 1.6);
}
// Stream node images in the background WITHOUT blocking the first paint. The map
// is drawn immediately with placeholder circles and stays fully interactive
// (click/hover/pan) while images fill in. Redraws are throttled into ~waves so
// 1000s of arrivals don't trigger 1000s of buffer rebuilds. A load token makes
// opening another map cancel this stream (stale bitmaps are dropped). Focal
// nodes are fetched first so what you're looking at sharpens soonest.
function _artMapStreamImages(imgNodes, concurrent = 24) {
const token = (_artMap._loadToken = (_artMap._loadToken || 0) + 1);
// Focal/large nodes first โ the user's eye lands there.
const queue = imgNodes.filter(n => n.image_url).slice().sort((a, b) => (b.radius || 0) - (a.radius || 0));
let idx = 0, inFlight = 0, redrawPending = false;
// Throttled FULL rebuild as images arrive. The per-map buffer is now small
// (one focused island / a small explore map), so a full rebuild is cheap and
// โ unlike the per-node composite โ is guaranteed to pick up every cached
// image. This is what makes streamed art appear on its own instead of only
// after a manual zoom forced a rebuild.
const scheduleRedraw = () => {
if (redrawPending || token !== _artMap._loadToken) return;
redrawPending = true;
setTimeout(() => {
redrawPending = false;
if (token !== _artMap._loadToken) return;
_artMap.dirty = true;
_artMapRender();
_artMapEnsureAmbient();
}, 200);
};
function pump() {
if (token !== _artMap._loadToken) return; // a newer map took over
while (inFlight < concurrent && idx < queue.length) {
const n = queue[idx++];
if (_artMap.images[n.id]) continue;
inFlight++;
_artMapLoadImage(n.image_url, _artMapNodeImgPx(n))
.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 for something off-screen.
if ((n.opacity || 0) < 0.01) return;
// Throttled full rebuild โ reliably bakes newly-arrived art
// into the (small) buffer. No manual zoom needed.
scheduleRedraw();
}
})
.finally(() => { inFlight--; pump(); });
}
}
pump();
}
function _artMapHideContextMenu() {
const m = document.getElementById('artist-map-context');
if (m) m.style.display = 'none';
}
function _artMapSetupInteraction(canvas) {
// Prevent stacking listeners on repeated opens
if (canvas._artMapListenersAttached) return;
canvas._artMapListenersAttached = true;
// Pause ambient buoyancy when the tab is hidden; resume on return.
document.addEventListener('visibilitychange', () => {
if (!document.hidden) _artMapEnsureAmbient();
});
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.02, 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(); // fast blit
_artMapEnsureAmbient(); // resume buoyancy if we zoomed bubbles into view
// Debounce hi-res rebuild after zoom settles; then resume buoyancy (the
// rebuild may have flipped the live/overflow partition).
clearTimeout(_artMap._zoomRebuild);
_artMap._zoomRebuild = setTimeout(() => { _artMap.dirty = true; _artMapRender(); _artMapEnsureAmbient(); }, 300);
}, { passive: false });
let clickStart = null;
// Keyboard shortcuts
function _artMapKeyHandler(e) {
if (!document.getElementById('artist-map-container') || document.getElementById('artist-map-container').style.display === 'none') return;
if (e.target.tagName === 'INPUT') return; // don't intercept search typing
if (e.key === 'Escape') { closeArtistMap(); e.preventDefault(); }
else if (e.key === '=' || e.key === '+') { artMapZoom(1.3); e.preventDefault(); }
else if (e.key === '-') { artMapZoom(0.7); e.preventDefault(); }
else if (e.key === '0') { artMapFitToView(); e.preventDefault(); }
else if (e.key === 'f' || e.key === 'F') { artMapFitToView(); e.preventDefault(); }
else if (e.key === 'd' || e.key === 'D') { _artMap._perf = !_artMap._perf; _artMapRender(); e.preventDefault(); }
else if (e.key === 's' || e.key === 'S') {
const input = document.getElementById('artist-map-search');
if (input) { input.focus(); e.preventDefault(); }
}
else if (e.key === 'h' || e.key === 'H') {
// Toggle similar artists visibility
_artMap._hideSimilar = !_artMap._hideSimilar;
_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;
// Right-click context menu
canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
const { nx, ny } = _artMapScreenToWorld(e, canvas);
const node = _artMapHitTest(nx, ny);
if (!node || node._isLabel) { _artMapHideContextMenu(); return; }
const menu = document.getElementById('artist-map-context') || (() => {
const m = document.createElement('div');
m.id = 'artist-map-context';
m.className = 'artmap-context-menu';
document.getElementById('artist-map-container').appendChild(m);
return m;
})();
const hasId = node.spotify_id || node.itunes_id || node.deezer_id;
const activeSource = window._yaActiveSource || 'spotify';
const bestId = node[activeSource + '_id'] || node.spotify_id || node.itunes_id || node.deezer_id || '';
const bestSource = node[activeSource + '_id'] ? activeSource : node.spotify_id ? 'spotify' : node.itunes_id ? 'itunes' : 'deezer';
menu.innerHTML = `
`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
document.body.appendChild(overlay);
try {
const resp = await fetch(`/api/discover/genre-deep-dive?genre=${encodeURIComponent(genre)}`);
if (!resp.ok) throw new Error('Failed to load');
const data = await resp.json();
if (!data.success) throw new Error('Failed');
const body = document.getElementById('genre-dive-body');
if (!body) return;
// Update header with counts
const subtitle = document.querySelector('.genre-dive-subtitle');
if (subtitle) {
const parts = [];
if (data.artists?.length) parts.push(`${data.artists.length} artist${data.artists.length !== 1 ? 's' : ''}`);
if (data.tracks?.length) parts.push(`${data.tracks.length} track${data.tracks.length !== 1 ? 's' : ''}`);
if (data.albums?.length) parts.push(`${data.albums.length} album${data.albums.length !== 1 ? 's' : ''}`);
subtitle.textContent = parts.length ? parts.join(' ยท ') : 'Genre Deep Dive';
}
let html = '';
// Related genres โ clickable pills that reload the modal
if (data.related_genres && data.related_genres.length) {
html += `
Related Genres
${data.related_genres.map(rg => `
`).join('')}
`;
}
// Artists section โ clickable, navigates to artist page
// Uses library_id for in-library artists (source-agnostic), falls back to search by name
if (data.artists && data.artists.length) {
html += `