${escapeHtml(album.Album)}
${escapeHtml(album.Artist)}
${album.Info ? `${escapeHtml(album.Info)}
` : ''}// Global variables
let albumsData = [];
let filteredData = [];
let currentPage = 1;
const itemsPerPage = 50;
let isReversed = false;
let wikipediaUrlMappings = {};
let spotifyUrlMappings = {};
// DOM elements
const albumsGrid = document.getElementById('albumsGrid');
const searchInput = document.getElementById('searchInput');
const statusFilter = document.getElementById('statusFilter');
const sortBy = document.getElementById('sortBy');
const reverseButton = document.getElementById('reverseButton');
const jumpToRank = document.getElementById('jumpToRank');
const jumpButton = document.getElementById('jumpButton');
const themeSelect = document.getElementById('themeSelect');
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const stats = document.getElementById('stats');
// Initialize the application
document.addEventListener('DOMContentLoaded', function() {
loadWikipediaMapping();
loadSpotifyMapping();
loadAlbumsData();
setupEventListeners();
loadTheme();
handleUrlParams();
});
// Load Wikipedia URL mappings
async function loadWikipediaMapping() {
try {
const response = await fetch('wikipedia_urls_mapping.json');
if (response.ok) {
wikipediaUrlMappings = await response.json();
}
} catch (err) {
console.warn('Could not load Wikipedia URL mappings:', err);
}
}
// Load Spotify URL mappings
async function loadSpotifyMapping() {
try {
const response = await fetch('spotify_urls_mapping.json');
if (response.ok) {
spotifyUrlMappings = await response.json();
}
} catch (err) {
console.warn('Could not load Spotify URL mappings:', err);
}
}
// Setup event listeners
function setupEventListeners() {
searchInput.addEventListener('input', debounce(handleSearch, 300));
statusFilter.addEventListener('change', handleFilter);
sortBy.addEventListener('change', handleSort);
reverseButton.addEventListener('click', handleReverse);
jumpButton.addEventListener('click', handleJumpToRank);
jumpToRank.addEventListener('keypress', handleRankKeypress);
themeSelect.addEventListener('change', handleThemeChange);
// Add scroll listener for infinite scroll
window.addEventListener('scroll', handleScroll);
// Add click listeners to stats cards
setupStatsClickHandlers();
}
// Setup click handlers for stats cards
function setupStatsClickHandlers() {
// Stats cards are now buttons with data-filter attributes
const statButtons = document.querySelectorAll('.stat-item[data-filter]');
statButtons.forEach(button => {
button.addEventListener('click', () => {
const filterValue = button.getAttribute('data-filter');
handleStatsCardClick(filterValue);
});
});
}
// Handle stats card clicks
function handleStatsCardClick(filterValue) {
// Update the status filter dropdown
statusFilter.value = filterValue;
// Trigger the filter change
statusFilter.dispatchEvent(new Event('change'));
// Scroll to the albums grid
albumsGrid.scrollIntoView({ behavior: 'smooth' });
}
// Theme handling functions
function handleThemeChange(event) {
const theme = event.target.value;
setTheme(theme);
saveTheme(theme);
}
function setTheme(theme) {
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function saveTheme(theme) {
try {
localStorage.setItem('selectedTheme', theme);
} catch (e) {
console.warn('Could not save theme to localStorage:', e);
}
}
function loadTheme() {
try {
const savedTheme = localStorage.getItem('selectedTheme');
if (savedTheme !== null) {
setTheme(savedTheme);
themeSelect.value = savedTheme;
}
} catch (e) {
console.warn('Could not load theme from localStorage:', e);
}
}
// Load albums data from CSV
async function loadAlbumsData() {
try {
const response = await fetch('top_500_albums_2023.csv');
if (!response.ok) {
throw new Error('Failed to load data');
}
const csvText = await response.text();
albumsData = parseCSV(csvText);
filteredData = [...albumsData];
hideLoading();
renderAlbums();
initializeStats();
} catch (err) {
console.error('Error loading data:', err);
showError();
}
}
// Parse CSV data
function parseCSV(csvText) {
const lines = csvText.split('\n');
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
const albums = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Handle CSV parsing with proper quote handling
const values = parseCSVLine(line);
if (values.length < headers.length) continue;
const album = {};
headers.forEach((header, index) => {
album[header] = values[index] ? values[index].replace(/"/g, '').trim() : '';
});
// Convert rank to number
album.Rank = parseInt(album.Rank) || 0;
// Extract year from Info field
const yearMatch = album.Info ? album.Info.match(/\b(19|20)\d{2}\b/) : null;
album.Year = yearMatch ? parseInt(yearMatch[0]) : null;
albums.push(album);
}
return albums;
}
// Parse a single CSV line handling quotes and commas
function parseCSVLine(line) {
const values = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
values.push(current);
current = '';
} else {
current += char;
}
}
values.push(current);
return values;
}
// Render albums
function renderAlbums() {
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const albumsToShow = filteredData.slice(0, endIndex);
if (currentPage === 1) {
albumsGrid.innerHTML = '';
// Announce results to screen readers
const announcement = document.getElementById('resultsAnnouncement');
if (announcement) {
announcement.textContent = `Showing ${filteredData.length} albums`;
}
}
albumsToShow.slice(startIndex).forEach(album => {
const albumCard = createAlbumCard(album);
albumsGrid.appendChild(albumCard);
});
}
// Get cover image path for an album
function getCoverImagePath(album) {
if (!album.Artist || !album.Album || !album.Rank) return null;
const rank = album.Rank.toString().padStart(3, '0');
// For rank 32 (Beyoncé - Lemonade) and other special cases with Unicode
// we need to preserve the accented characters
let safeArtist = album.Artist;
let safeAlbum = album.Album;
// Match the Python script pattern: remove all non-word chars except spaces, hyphens, underscores, and dots
// But preserve Unicode characters (Python's \w includes Unicode, JS needs a different approach)
safeArtist = safeArtist.replace(/[<>:"/\\|?*]/g, ''); // Remove definitely problematic chars first
safeAlbum = safeAlbum.replace(/[<>:"/\\|?*]/g, '');
// Remove other punctuation but keep Unicode letters, digits, spaces, hyphens, underscores, dots
safeArtist = safeArtist.replace(/[^\p{L}\p{N}\s\-_.]/gu, '');
safeAlbum = safeAlbum.replace(/[^\p{L}\p{N}\s\-_.]/gu, '');
// Replace spaces with underscores
safeArtist = safeArtist.replace(/\s+/g, '_').slice(0, 100);
safeAlbum = safeAlbum.replace(/\s+/g, '_').slice(0, 100);
const filename = `rank_${rank}_${safeArtist}_${safeAlbum}.jpg`;
return `covers/${filename}`;
}
// Create album card element
function createAlbumCard(album) {
const card = document.createElement('div');
card.className = 'album-card';
card.setAttribute('data-rank', album.Rank);
const statusClass = getStatusClass(album.Status);
const statusText = formatStatus(album.Status);
const coverImagePath = getCoverImagePath(album);
// Determine if this album has a next album in the current view
const currentIndex = filteredData.findIndex(a => a.Rank === album.Rank);
const hasNext = currentIndex !== -1 && currentIndex < filteredData.length - 1;
const nextAlbum = hasNext ? filteredData[currentIndex + 1] : null;
card.innerHTML = `
${escapeHtml(album.Artist)} ${escapeHtml(album.Info)}${escapeHtml(album.Album)}
${escapeHtml(p.trim())}
`).join(''); } } return paragraphs.map(p => `${escapeHtml(p)}
`).join(''); } function generateWikipediaUrl(album, artist) { // Clean up album and artist names const albumName = album.trim(); const artistName = artist.trim(); // Check if we have the exact Wikipedia URL from our mapping if (wikipediaUrlMappings[albumName]) { return `https://en.wikipedia.org/wiki/${wikipediaUrlMappings[albumName]}`; } // Remove "The" from the beginning of artist names for URL formatting const artistForUrl = artistName.replace(/^The /, ''); // Format for Wikipedia URL - replace spaces with underscores const albumUrl = albumName.replace(/ /g, '_'); const artistUrl = artistForUrl.replace(/ /g, '_'); // Default pattern: Album_Name_(Artist_Name_album) return `https://en.wikipedia.org/wiki/${encodeURIComponent(albumUrl)}_(${encodeURIComponent(artistUrl)}_album)`; } function generateSpotifyUrl(album, artist) { // Clean up album and artist names const albumName = album.trim(); const artistName = artist.trim(); // Check if we have the exact Spotify URL from our mapping if (spotifyUrlMappings[albumName]) { return spotifyUrlMappings[albumName]; } // If no mapping found, create a Spotify search URL const searchQuery = `${albumName} ${artistName}`; const encodedQuery = encodeURIComponent(searchQuery); return `https://open.spotify.com/search/${encodedQuery}`; } function hideLoading() { loading.style.display = 'none'; albumsGrid.style.display = 'grid'; } function showError() { loading.style.display = 'none'; error.style.display = 'block'; } // Bookmark and navigation functions function handleUrlParams() { const urlParams = new URLSearchParams(window.location.search); const hash = window.location.hash.slice(1); // Handle rank from hash (#rank123) or query parameter (?rank=123) const rankFromHash = hash.match(/^rank(\d+)$/); const rankFromQuery = urlParams.get('rank'); const targetRank = rankFromHash ? parseInt(rankFromHash[1]) : rankFromQuery ? parseInt(rankFromQuery) : null; // Handle other URL parameters for filters/search first const search = urlParams.get('search'); const status = urlParams.get('status'); const sort = urlParams.get('sort'); const reversed = urlParams.get('reversed') === 'true'; // Set up the state based on URL parameters let delay = 1000; if (search) { searchInput.value = search; setTimeout(() => handleSearch({target: searchInput}), delay); delay += 100; } if (status) { statusFilter.value = status; setTimeout(() => handleFilter({target: statusFilter}), delay); delay += 100; } if (sort) { sortBy.value = sort; setTimeout(() => handleSort({target: sortBy}), delay); delay += 100; } if (reversed) { setTimeout(() => handleReverse(), delay); delay += 100; } // Finally, jump to the rank if specified if (targetRank) { setTimeout(() => jumpToRankWithCurrentState(targetRank), delay); } } function handleJumpToRank() { const inputValue = jumpToRank.value.trim(); let rank; if (inputValue === '') { // Default to rank 1 if no input rank = 1; jumpToRank.value = '1'; } else { rank = parseInt(inputValue); if (isNaN(rank) || rank < 1 || rank > 589) { alert('Please enter a valid rank between 1 and 589'); return; } } jumpToRankNumber(rank); } function handleRankKeypress(event) { if (event.key === 'Enter') { handleJumpToRank(); } } function jumpToRankNumber(rank) { if (!albumsData.length) return; // Reset filters to show all albums by rank searchInput.value = ''; statusFilter.value = ''; sortBy.value = 'rank'; isReversed = false; reverseButton.classList.remove('reversed'); // Reset data and apply rank sorting filteredData = [...albumsData]; filteredData.sort((a, b) => a.Rank - b.Rank); // Update URL - clear all params if jumping to rank 1, otherwise set rank if (rank === 1) { clearUrl(); } else { updateUrl({rank}); } // Find the album card and scroll to it currentPage = Math.ceil(rank / itemsPerPage); renderAlbums(); // Wait for render then scroll setTimeout(() => scrollToRank(rank), 100); updateStats(); } function jumpToRankWithCurrentState(rank) { if (!albumsData.length) return; // Don't reset filters - preserve current state // Just ensure the target album is visible in current filtered data // Find the album card and scroll to it // First, make sure enough albums are loaded const albumInFiltered = filteredData.find(album => album.Rank === rank); if (albumInFiltered) { const indexInFiltered = filteredData.indexOf(albumInFiltered); currentPage = Math.ceil((indexInFiltered + 1) / itemsPerPage); renderAlbums(); } // Wait for render then scroll setTimeout(() => scrollToRank(rank), 100); updateStats(); } function scrollToRank(rank) { const albumCard = document.querySelector(`[data-rank="${rank}"]`); if (albumCard) { albumCard.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Highlight the album briefly albumCard.style.boxShadow = '0 0 20px rgba(102, 126, 234, 0.5)'; albumCard.style.transform = 'scale(1.02)'; setTimeout(() => { albumCard.style.boxShadow = ''; albumCard.style.transform = ''; }, 2000); } } function navigateToNextAlbum(currentRank) { // Find the current album in the filtered data const currentIndex = filteredData.findIndex(album => album.Rank === currentRank); if (currentIndex === -1) return; // Get the next album in the current view const nextIndex = currentIndex + 1; if (nextIndex < filteredData.length) { const nextAlbum = filteredData[nextIndex]; // Ensure we've loaded enough albums const requiredPage = Math.ceil((nextIndex + 1) / itemsPerPage); if (currentPage < requiredPage) { currentPage = requiredPage; renderAlbums(); } // Scroll to the next album setTimeout(() => scrollToRank(nextAlbum.Rank), 100); } } function handleAlbumShare(rank, buttonElement) { const albumUrl = generateAlbumUrl(rank); // Copy to clipboard if (navigator.clipboard) { navigator.clipboard.writeText(albumUrl).then(() => { showAlbumShareFeedback(buttonElement); }).catch(() => { fallbackCopyToClipboard(albumUrl, buttonElement); }); } else { fallbackCopyToClipboard(albumUrl, buttonElement); } } function generateAlbumUrl(rank) { const baseUrl = window.location.origin + window.location.pathname; const params = new URLSearchParams(); // Add current state parameters if (statusFilter.value) { params.set('status', statusFilter.value); } if (sortBy.value !== 'rank') { params.set('sort', sortBy.value); } if (isReversed) { params.set('reversed', 'true'); } // Add search if present if (searchInput.value.trim()) { params.set('search', searchInput.value.trim()); } // Always add the rank for direct navigation params.set('rank', rank); const queryString = params.toString(); return queryString ? `${baseUrl}?${queryString}` : `${baseUrl}#rank${rank}`; } function generateShareableUrl() { const baseUrl = window.location.origin + window.location.pathname; const params = new URLSearchParams(); // Add current filters/search if (searchInput.value.trim()) { params.set('search', searchInput.value.trim()); } if (statusFilter.value) { params.set('status', statusFilter.value); } if (sortBy.value !== 'rank') { params.set('sort', sortBy.value); } if (isReversed) { params.set('reversed', 'true'); } // If we're viewing a specific rank area, add it const visibleCards = document.querySelectorAll('.album-card'); if (visibleCards.length > 0 && sortBy.value === 'rank' && !isReversed) { const firstVisibleRank = visibleCards[0].getAttribute('data-rank'); if (firstVisibleRank) { params.set('rank', firstVisibleRank); } } const queryString = params.toString(); return queryString ? `${baseUrl}?${queryString}` : baseUrl; } function updateUrl(options = {}) { const params = new URLSearchParams(); if (options.rank) { window.location.hash = `rank${options.rank}`; return; } // Update query parameters based on current state if (searchInput.value.trim()) { params.set('search', searchInput.value.trim()); } if (statusFilter.value) { params.set('status', statusFilter.value); } if (sortBy.value !== 'rank') { params.set('sort', sortBy.value); } if (isReversed) { params.set('reversed', 'true'); } const queryString = params.toString(); const newUrl = queryString ? `${window.location.pathname}?${queryString}` : window.location.pathname; window.history.replaceState({}, '', newUrl); } function clearUrl() { // Clear both query parameters and hash window.history.replaceState({}, '', window.location.pathname); } function showAlbumShareFeedback(buttonElement) { const originalText = buttonElement.innerHTML; buttonElement.innerHTML = '✓'; buttonElement.classList.add('copied'); setTimeout(() => { buttonElement.innerHTML = originalText; buttonElement.classList.remove('copied'); }, 2000); } function fallbackCopyToClipboard(text, buttonElement) { const textArea = document.createElement('textarea'); textArea.value = text; document.body.appendChild(textArea); textArea.focus(); textArea.select(); try { document.execCommand('copy'); if (buttonElement) { showAlbumShareFeedback(buttonElement); } } catch (err) { alert('Unable to copy to clipboard. URL: ' + text); } document.body.removeChild(textArea); }