metalfrom.eu/apps/web/site/app.js
Nicolas Fryder 39e6b2a66f feat(map): carte multi-localisations — un point par step géocodé
La carte clusterisait depuis bands.geom (un seul point par groupe). Désormais
/api/clusters source band_locations : un groupe multi-périodes (ex. Thessaloniki
puis Boston) apparaît comme plusieurs points distincts.

- migration 010: colonne geom générée (Point,4326) + index GIST sur
  band_locations (évite le seq-scan sur les requêtes bbox)
- /api/clusters: FROM band_locations bl JOIN bands b, bbox via geom && envelope,
  filtres (pays/statut/genre/année) sur b.*, points issus de bl.lat/lon ;
  la JSON des clusters porte location_raw + step_label
- web parseBandData: expose location_raw + step_label (base pour différencier
  plus tard un groupe qui a déménagé d'un groupe resté sur place)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 19:52:40 +02:00

1785 lines
59 KiB
JavaScript

/* Black Metal Map UI — clusters/heat/timeline/filter/sort/highlight */
/* Performance-optimized with viewport-based loading */
const API_BASE = "https://bm.nicolasfryder.ovh";
// --- i18n ---
const SUPPORTED_LANGS = ["fr", "en", "de", "es", "it", "pl", "nl", "ro", "pt", "cs", "sv", "hu", "da", "fi", "sk", "hr", "sl", "lt", "lv", "et", "nb"];
function detectLang() {
const langs = navigator.languages?.length ? navigator.languages : [navigator.language || "en"];
for (const l of langs) {
const code = l.split("-")[0].toLowerCase();
if (SUPPORTED_LANGS.includes(code)) return code;
}
return "en";
}
let currentLang = localStorage.getItem("lang") || detectLang();
function t(key) {
return window.LOCALES?.[currentLang]?.[key] ?? window.LOCALES?.["en"]?.[key] ?? key;
}
function applyI18n() {
document.documentElement.lang = currentLang;
document.querySelectorAll("[data-i18n]").forEach(el => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll("[data-i18n-placeholder]").forEach(el => {
el.placeholder = t(el.dataset.i18nPlaceholder);
});
// Sort select options (can't use data-i18n on options in all browsers)
const sel = document.getElementById("sortSelect");
if (sel) {
[["sort_az",0],["sort_status",1],["sort_genre",2],["sort_country",3],["sort_year",4]].forEach(([k,i]) => {
if (sel.options[i]) sel.options[i].text = t(k);
});
}
}
const DARK_TILES = "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
const DARK_ATTRIB = '&copy; OpenStreetMap contributors &copy; CARTO';
// --- Configuration ---
const USE_VIEWPORT_LOADING = true; // Enable viewport-based loading
const INITIAL_LOAD_LIMIT = 5000; // Initial bands to load for filters
const VIEWPORT_DEBOUNCE_MS = 300; // Debounce for viewport changes
// --- State ---
let allBands = []; // all loaded bands (geocoded + non geocoded)
let geocodedBands = []; // subset lat/lon ok
let noLocationBands = []; // subset missing coords
let viewportBands = []; // bands in current viewport
let filtered = [];
let markersById = new Map(); // ma_id -> marker
let listElById = new Map(); // ma_id -> element
let coordIndex = new Map(); // "lat,lon" -> bands[]
let heatLayer = null;
let clusterLayer = null;
let currentMode = "clusters";
// All heat points (for full heatmap even when viewing clusters)
let allHeatPoints = [];
let heatPointsLoaded = false;
const statusEnabled = new Map();
const genreEnabled = new Map();
const countryEnabled = new Map();
const macroGenreEnabled = new Map();
const themeEnabled = new Map();
let yearRange = { min: null, max: null }; // global
let yearFilter = { min: null, max: null }; // current
let macroActiveCount = 0;
// Facet data from server (for filters)
let facetData = null;
// Loading state
let isLoadingViewport = false;
let pendingViewportLoad = false;
let currentViewportData = [];
// --- Utils ---
const $ = (id) => document.getElementById(id);
const appEl = $("app");
async function loadGoatCounterFooterStats() {
const totalEl = document.getElementById("gcTotal");
const monthEl = document.getElementById("gcMonth");
if (!totalEl || !monthEl) return;
// 1er jour du mois courant (ce mois-ci)
const d = new Date();
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const startOfMonth = `${yyyy}-${mm}-01`;
const base = "https://metalfromeurope.goatcounter.com/counter/";
const totalUrl = `${base}TOTAL.json`;
const monthUrl = `${base}TOTAL.json?start=${encodeURIComponent(startOfMonth)}`;
try {
const [t, m] = await Promise.all([
fetch(totalUrl, { cache: "no-store" }).then(r => (r.ok ? r.json() : Promise.reject(r.status))),
fetch(monthUrl, { cache: "no-store" }).then(r => (r.ok ? r.json() : Promise.reject(r.status))),
]);
// GoatCounter renvoie `count` comme string formatée (séparateurs milliers). :contentReference[oaicite:2]{index=2}
totalEl.textContent = t.count ?? "—";
monthEl.textContent = m.count ?? "—";
} catch (e) {
console.warn("GoatCounter footer stats failed:", e);
totalEl.textContent = "—";
monthEl.textContent = "—";
}
}
// Debounce function for search
let searchDebounceTimer = null;
function debounce(func, delay) {
return function(...args) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => func.apply(this, args), delay);
};
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;' }[c]));
}
function norm(s) {
if (s == null || s === undefined || s === "") return "";
return String(s).trim();
}
function parseYear(val) {
if (val == null) return null;
const n = Number(val);
if (Number.isFinite(n)) return n;
const m = String(val).match(/(19|20)\d{2}/);
return m ? Number(m[0]) : null;
}
function splitThemes(val) {
if (!val) return [];
if (Array.isArray(val)) return val.map(norm).filter(Boolean);
return String(val).split(/[,;/]/).map(norm).filter(Boolean);
}
function currentQuery() {
return norm($("q").value).toLowerCase();
}
function bandMatchesQuery(b, q) {
if (!q) return true;
const themes = Array.isArray(b.themes) ? b.themes.join(" ") : "";
const hay = `${b.name} ${b.genre} ${b.status} ${b.country} ${b.location_text} ${themes}`.toLowerCase();
return hay.includes(q);
}
function bandMatchesStatus(b) {
const s = norm(b.status);
if (!s) {
// If no status, check if "Unknown" is enabled
if (!statusEnabled.has("Unknown")) return true;
return statusEnabled.get("Unknown") === true;
}
if (!statusEnabled.has(s)) return true;
return statusEnabled.get(s) === true;
}
function bandMatchesCountry(b) {
const c = norm(b.country);
if (!c) {
// If no country, check if "??" is enabled
if (!countryEnabled.has("??")) return true;
return countryEnabled.get("??") === true;
}
if (!countryEnabled.has(c)) return true;
return countryEnabled.get(c) === true;
}
function bandMatchesMacroGenre(b) {
if (macroActiveCount === 0) return true;
const g = norm(b.genre).toLowerCase();
if (!g) return false;
return MACRO_GENRES.some(m => macroGenreEnabled.get(m.key) === true && m.terms.some(t => g.includes(t)));
}
function bandMatchesGenre(b) {
if (!bandMatchesMacroGenre(b)) return false;
const g = norm(b.genre);
if (!g) {
// If no genre, check if "Unknown" is enabled
if (!genreEnabled.has("Unknown")) return true;
return genreEnabled.get("Unknown") === true;
}
if (!genreEnabled.has(g)) return true;
return genreEnabled.get(g) === true;
}
function bandMatchesTheme(b) {
if (!themeEnabled.size) return true;
const allOn = Array.from(themeEnabled.values()).every(v => v === true);
if (allOn) return true;
const tokens = Array.isArray(b.themes) ? b.themes : [];
if (!tokens.length) return false;
return tokens.some(t => themeEnabled.get(t) === true);
}
function bandMatchesYear(b) {
const y = b.formed_year;
// If no filter is set, pass all
if (yearFilter.min == null || yearFilter.max == null) return true;
// If yearRange not initialized yet, pass all
if (yearRange.min == null || yearRange.max == null) return true;
// If filter is at full range, pass all (including those without years)
const isFullRange = (yearFilter.min === yearRange.min && yearFilter.max === yearRange.max);
if (isFullRange) {
return true;
}
// If user has narrowed the range, only show bands with valid years in range
if (!Number.isFinite(y)) {
// Band has no year and user has filtered by year -> exclude
return false;
}
return y >= yearFilter.min && y <= yearFilter.max;
}
function keyFromLatLon(lat, lon) {
const la = Number(lat).toFixed(6);
const lo = Number(lon).toFixed(6);
return `${la},${lo}`;
}
const MACRO_GENRES = [
{ key: "Black", terms: ["black"] },
{ key: "Death", terms: ["death"] },
{ key: "Doom/Stoner/Sludge", terms: ["doom", "stoner", "sludge"] },
{ key: "Electronic/Industrial", terms: ["electronic", "industrial", "electro"] },
{ key: "Experimental/Avant-garde", terms: ["experimental", "avant", "avant-garde", "avantgarde"] },
{ key: "Folk/Viking/Pagan", terms: ["folk", "viking", "pagan"] },
{ key: "Gothic", terms: ["gothic"] },
{ key: "Grindcore", terms: ["grind"] },
{ key: "Groove", terms: ["groove"] },
{ key: "Heavy", terms: ["heavy"] },
{ key: "Metalcore/Deathcore", terms: ["metalcore", "deathcore"] },
{ key: "Power", terms: ["power"] },
{ key: "Progressive", terms: ["progressive", "prog"] },
{ key: "Speed", terms: ["speed"] },
{ key: "Symphonic", terms: ["symphonic"] },
{ key: "Thrash", terms: ["thrash"] },
];
// --- API ---
async function apiGet(path) {
const url = `${API_BASE}${path}`;
const r = await fetch(url, { mode: "cors" });
if (!r.ok) throw new Error(`API ${path} failed: ${r.status}`);
return await r.json();
}
async function loadStats() {
// tolérant : si /api/stats n'existe pas encore, on ignore
try {
const j = await apiGet("/api/stats");
if (j && j.ok) {
$("total").textContent = String(j.total || 0);
$("geocoded").textContent = String(j.geocoded || 0);
$("noLocCount").textContent = String(j.no_location || 0);
}
} catch (_) {
// Silently ignore stats errors
}
}
/**
* Load facet data for filters (fast, minimal data)
*/
async function loadFacets() {
try {
const j = await apiGet("/api/facets");
if (j.ok) {
facetData = j;
console.log("Facets loaded:", {
statuses: j.statuses?.length,
countries: j.countries?.length,
genres: j.genres?.length,
yearRange: j.year_range
});
}
} catch (e) {
console.warn("Failed to load facets, will compute from bands:", e);
}
}
/**
* Parse band data from API response
*/
function parseBandData(x) {
let status = x.status || x.data?.status || x.data?.band_status || x.data?.status_label;
return {
ma_id: Number(x.ma_id),
name: norm(x.name),
url: x.url || (x.data?.url) || (x.data?.band_page?.url) || null,
country: norm(x.country || x.data?.country),
status: norm(status),
genre: norm(x.genre || x.data?.genre || x.data?.genres || x.data?.style),
themes: splitThemes(x.themes || x.data?.themes || x.data?.theme || x.data?.thematic),
location_text: norm(x.location_text || x.data?.location_text || x.data?.location),
formed_year: parseYear(x.formed_year ?? x.data?.formed_year ?? x.data?.formed ?? x.data?.formation_year ?? x.data?.year_formed),
lat: (x.lat == null ? null : Number(x.lat)),
lon: (x.lon == null ? null : Number(x.lon)),
// Localisation précise (band_locations) : présent quand le point vient
// d'un step géocodé plutôt que du point unique du groupe.
location_raw: x.location_raw != null ? norm(x.location_raw) : null,
step_label: x.step_label != null ? norm(x.step_label) : null,
data: x.data || {},
geocoded_at: x.geocoded_at || null,
};
}
/**
* Load initial sample of bands for quick startup
*/
async function loadBands() {
// Load a limited set initially for faster startup
const limit = USE_VIEWPORT_LOADING ? INITIAL_LOAD_LIMIT : 200000;
const j = await apiGet(`/api/bands?limit=${limit}`);
const items = j.items || j.bands || [];
console.log(`Loaded ${items.length} bands initially`);
allBands = items.map(parseBandData);
geocodedBands = allBands.filter(b => Number.isFinite(b.lat) && Number.isFinite(b.lon));
noLocationBands = allBands.filter(b => !(Number.isFinite(b.lat) && Number.isFinite(b.lon)));
// Update counts
$("total").textContent = String(allBands.length);
$("geocoded").textContent = String(geocodedBands.length);
$("noLocCount").textContent = String(noLocationBands.length);
}
/**
* Load all heat points for the full heatmap (done once in background)
*/
async function loadAllHeatPoints() {
if (heatPointsLoaded) return;
try {
console.log("Loading all bands for heatmap...");
const j = await apiGet("/api/bands?limit=200000");
const items = j.items || [];
allHeatPoints = [];
// If allBands is empty, populate it
if (!allBands.length) {
allBands = items.map(parseBandData);
geocodedBands = allBands.filter(b => Number.isFinite(b.lat) && Number.isFinite(b.lon));
noLocationBands = allBands.filter(b => !(Number.isFinite(b.lat) && Number.isFinite(b.lon)));
}
// Index by coords for heatmap intensity
const coordCounts = new Map();
for (const x of items) {
const lat = x.lat == null ? null : Number(x.lat);
const lon = x.lon == null ? null : Number(x.lon);
if (Number.isFinite(lat) && Number.isFinite(lon)) {
const key = `${lat.toFixed(4)},${lon.toFixed(4)}`;
coordCounts.set(key, (coordCounts.get(key) || 0) + 1);
}
}
// Convert to heat points
for (const [key, count] of coordCounts) {
const [lat, lon] = key.split(",").map(Number);
allHeatPoints.push([lat, lon, count]);
}
heatPointsLoaded = true;
console.log(`Loaded ${allHeatPoints.length} unique heat points from ${items.length} bands`);
// Update stats
$("total").textContent = String(items.length);
$("geocoded").textContent = String(geocodedBands.length);
$("noLocCount").textContent = String(noLocationBands.length);
// Rebuild heatmap if in heat mode
if (currentMode === "heat") {
buildHeatLayer(allHeatPoints);
}
} catch (e) {
console.error("Failed to load heat points:", e);
}
}
/**
* Load bands for current viewport from server
*/
async function loadViewportBands() {
if (!USE_VIEWPORT_LOADING) return;
if (isLoadingViewport) {
pendingViewportLoad = true;
return;
}
isLoadingViewport = true;
try {
const bounds = map.getBounds();
const zoom = map.getZoom();
// Expand bounds slightly to preload edges
const expandFactor = 0.2;
const latDiff = (bounds.getNorth() - bounds.getSouth()) * expandFactor;
const lonDiff = (bounds.getEast() - bounds.getWest()) * expandFactor;
const bbox = [
bounds.getWest() - lonDiff,
bounds.getSouth() - latDiff,
bounds.getEast() + lonDiff,
bounds.getNorth() + latDiff
].join(",");
// Build query params for filters
const params = new URLSearchParams({
bbox,
zoom: String(Math.floor(zoom))
});
// Add active filters
const activeStatuses = Array.from(statusEnabled.entries())
.filter(([k, v]) => v)
.map(([k]) => k);
if (activeStatuses.length && activeStatuses.length < statusEnabled.size) {
params.set("status", activeStatuses.join(","));
}
const activeCountries = Array.from(countryEnabled.entries())
.filter(([k, v]) => v)
.map(([k]) => k);
if (activeCountries.length && activeCountries.length < countryEnabled.size) {
params.set("countries", activeCountries.join(","));
}
if (yearFilter.min != null && yearFilter.max != null) {
if (yearFilter.min !== yearRange.min) params.set("year_min", String(yearFilter.min));
if (yearFilter.max !== yearRange.max) params.set("year_max", String(yearFilter.max));
}
// Add genre filter if macro genres are active
if (macroActiveCount > 0) {
const activeTerms = MACRO_GENRES
.filter(m => macroGenreEnabled.get(m.key) === true)
.flatMap(m => m.terms);
if (activeTerms.length) {
params.set("genre", activeTerms[0]); // API supports single genre filter
}
}
const url = `/api/clusters?${params.toString()}`;
console.log("Loading viewport:", url);
const j = await apiGet(url);
if (j.ok) {
currentViewportData = j.items || [];
if (j.type === "bands") {
// High zoom: got individual bands
viewportBands = currentViewportData.map(parseBandData);
rebuildLayersFromBands(viewportBands);
} else {
// Low zoom: got clusters
rebuildLayersFromClusters(currentViewportData);
}
$("count").textContent = String(j.count || currentViewportData.length || 0);
}
} catch (e) {
console.error("Failed to load viewport bands:", e);
} finally {
isLoadingViewport = false;
if (pendingViewportLoad) {
pendingViewportLoad = false;
setTimeout(loadViewportBands, 100);
}
}
}
/**
* Create a cluster marker (zoomable, not final location)
* These have a dashed border to indicate they can be zoomed
* Burgundy/dark red color, always white text
*/
function createClusterMarker(lat, lon, count, bands) {
// Size based on count (logarithmic scale)
const size = Math.min(24 + Math.log2(count + 1) * 8, 55);
// Constant burgundy/dark red color for all clusters
const color = 'rgba(120, 40, 60, 0.92)';
const borderColor = 'rgba(180, 80, 100, 0.9)';
const textColor = '#fff';
// Create custom div icon with count - dashed border indicates "zoomable"
const icon = L.divIcon({
className: 'cluster-marker cluster-zoomable',
html: `<div style="
width: ${size}px;
height: ${size}px;
background: ${color};
border: 3px dashed ${borderColor};
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: ${Math.max(11, size / 3)}px;
font-weight: bold;
color: ${textColor};
box-shadow: 0 3px 12px rgba(0,0,0,0.4);
cursor: zoom-in;
transition: transform 0.15s ease;
" onmouseover="this.style.transform='scale(1.1)'" onmouseout="this.style.transform='scale(1)'">${count > 999 ? Math.round(count/1000) + 'k' : count}</div>`,
iconSize: [size, size],
iconAnchor: [size/2, size/2]
});
const marker = L.marker([lat, lon], { icon });
// Store data for click handling
marker.__bands = bands;
marker.__count = count;
marker.__isCluster = true; // Flag to identify as zoomable cluster
// Click handler - always zoom for clusters
marker.on("click", (e) => {
L.DomEvent.stopPropagation(e);
map.setView([lat, lon], map.getZoom() + 2, { animate: true });
});
return marker;
}
/**
* Create a location marker (final level - shows popup with all bands)
* These have a solid border and bright red color to indicate they show details
*/
function createLocationMarker(lat, lon, count, bands, locationName) {
// Size based on count (smaller than clusters)
const size = Math.min(18 + Math.log2(count + 1) * 6, 42);
// Bright red color for final locations
let color, borderColor;
if (count === 1) {
color = 'rgba(180, 30, 30, 0.9)';
borderColor = 'rgba(255, 100, 100, 0.9)';
} else if (count < 5) {
color = 'rgba(198, 26, 26, 0.9)';
borderColor = 'rgba(255, 120, 120, 0.9)';
} else if (count < 20) {
color = 'rgba(210, 50, 50, 0.9)';
borderColor = 'rgba(255, 150, 150, 0.9)';
} else {
color = 'rgba(220, 80, 80, 0.9)';
borderColor = 'rgba(255, 180, 180, 0.95)';
}
// Create custom div icon with count - solid border indicates "clickable for details"
const icon = L.divIcon({
className: 'cluster-marker cluster-location',
html: `<div style="
width: ${size}px;
height: ${size}px;
background: ${color};
border: 2px solid ${borderColor};
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: ${Math.max(10, size / 3.2)}px;
font-weight: bold;
color: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
" onmouseover="this.style.transform='scale(1.15)';this.style.boxShadow='0 4px 16px rgba(198,26,26,0.6)'" onmouseout="this.style.transform='scale(1)';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.5)'">${count}</div>`,
iconSize: [size, size],
iconAnchor: [size/2, size/2]
});
const marker = L.marker([lat, lon], { icon });
// Store data
marker.__bands = bands;
marker.__count = count;
marker.__isCluster = false; // Flag to identify as final location
marker.__locationName = locationName;
// Build popup HTML with ALL bands (scrollable)
const bandListHtml = bands.map(b => {
const maUrl = b.url || (b.ma_id ? `https://www.metal-archives.com/bands/x/${b.ma_id}` : null);
const genre = b.genre || '—';
const status = b.status || '—';
const year = Number.isFinite(b.formed_year) ? b.formed_year : '—';
if (maUrl) {
return `<a href="${escapeHtml(maUrl)}" target="_blank" rel="noopener noreferrer" style="display:block;padding:6px 8px;margin:2px 0;background:rgba(255,255,255,0.03);border-radius:4px;text-decoration:none;transition:background 0.15s;" onmouseover="this.style.background='rgba(198,26,26,0.15)'" onmouseout="this.style.background='rgba(255,255,255,0.03)'">
<div style="color:#e9eef5;font-weight:600;font-size:13px;">${escapeHtml(b.name || 'Unknown')}</div>
<div style="color:#888;font-size:11px;margin-top:2px;">${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}</div>
</a>`;
}
return `<div style="display:block;padding:6px 8px;margin:2px 0;background:rgba(255,255,255,0.03);border-radius:4px;">
<div style="color:#e9eef5;font-weight:600;font-size:13px;">${escapeHtml(b.name || 'Unknown')}</div>
<div style="color:#888;font-size:11px;margin-top:2px;">${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}</div>
</div>`;
}).join("");
const popupHtml = `
<div style="min-width:280px; max-width:350px;">
<div style="font-weight:900; margin-bottom:8px; padding-bottom:8px; font-size:14px; color:#c61a1a; border-bottom:1px solid rgba(255,255,255,0.1);">
📍 ${escapeHtml(locationName || t('location_fallback'))}
<span style="font-weight:normal;color:#a2b0c2;font-size:12px;margin-left:8px;">${count} ${count === 1 ? t('group_s') : t('group_p')}</span>
</div>
<div style="max-height:300px; overflow-y:auto; margin:-4px;">
${bandListHtml}
</div>
</div>
`;
// Bind popup
marker.bindPopup(popupHtml, {
maxWidth: 380,
maxHeight: 400,
className: 'location-popup'
});
// Click handler - update sidebar list AND open popup
marker.on("click", (e) => {
L.DomEvent.stopPropagation(e);
const gLabel = count === 1 ? t('group_s') : t('group_p');
const hint = locationName ? `${count} ${gLabel}${locationName}` : `${count} ${gLabel}`;
renderList(bands, hint);
// Small delay to ensure popup opens correctly
setTimeout(() => marker.openPopup(), 10);
});
return marker;
}
/**
* Check if all bands share the same location_text
*/
function bandsShareLocation(bands) {
if (!bands || bands.length === 0) return false;
if (bands.length === 1) return true;
const firstLoc = norm(bands[0]?.location_text || '');
if (!firstLoc) return false;
return bands.every(b => norm(b?.location_text || '') === firstLoc);
}
/**
* Rebuild layers from cluster data (server-side aggregated)
*/
function rebuildLayersFromClusters(clusters) {
markersById.clear();
coordIndex.clear();
clusterLayer.clearLayers();
for (const cluster of clusters) {
const { lat, lon, count, sample_bands, location_text } = cluster;
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Parse sample bands
const bands = (sample_bands || []).map(b => {
if (typeof b === 'string') {
try { return JSON.parse(b); } catch { return null; }
}
return b;
}).filter(Boolean);
const bandCount = count || bands.length;
// Check if all bands share the same location
const allSameLocation = bandsShareLocation(bands);
const locationName = allSameLocation ? (location_text || bands[0]?.location_text || null) : null;
// Use location marker (popup) if all bands share same location
// Use cluster marker (zoom) if bands come from different locations
const useLocationMarker = allSameLocation;
const marker = useLocationMarker
? createLocationMarker(lat, lon, bandCount, bands, locationName)
: createClusterMarker(lat, lon, bandCount, bands);
clusterLayer.addLayer(marker);
// Store reference for each band in cluster
for (const b of bands) {
if (b.ma_id) markersById.set(b.ma_id, marker);
}
}
if (currentMode === "clusters") {
if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
}
$("unique").textContent = String(clusters.length);
// Render list with visible bands
const visibleBands = clusters.flatMap(c => c.sample_bands || [])
.map(b => typeof b === 'string' ? JSON.parse(b) : b)
.filter(Boolean);
renderList(visibleBands.slice(0, 200));
}
/**
* Rebuild layers from individual band data
*/
function rebuildLayersFromBands(bands) {
markersById.clear();
coordIndex.clear();
clusterLayer.clearLayers();
// Index by coords
for (const b of bands) {
if (!Number.isFinite(b.lat) || !Number.isFinite(b.lon)) continue;
const k = keyFromLatLon(b.lat, b.lon);
if (!coordIndex.has(k)) coordIndex.set(k, []);
coordIndex.get(k).push(b);
}
for (const [k, coordBands] of coordIndex.entries()) {
const [la, lo] = k.split(",").map(Number);
if (!Number.isFinite(la) || !Number.isFinite(lo)) continue;
const locationName = coordBands[0]?.location_text || null;
// Always use location marker for individual bands (final level)
const marker = createLocationMarker(la, lo, coordBands.length, coordBands, locationName);
for (const b of coordBands) markersById.set(b.ma_id, marker);
clusterLayer.addLayer(marker);
}
if (currentMode === "clusters") {
if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
}
$("count").textContent = String(bands.length);
$("unique").textContent = String(coordIndex.size);
renderList(bands);
}
/**
* Rebuild layers from locally filtered data (legacy mode)
*/
function rebuildLayers() {
markersById.clear();
coordIndex.clear();
clusterLayer.clearLayers();
if (currentMode === "clusters") {
if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
} else if (map.hasLayer(clusterLayer)) {
map.removeLayer(clusterLayer);
}
// index by coords
for (const b of filtered) {
const k = keyFromLatLon(b.lat, b.lon);
if (!coordIndex.has(k)) coordIndex.set(k, []);
coordIndex.get(k).push(b);
}
console.log(`Rebuilding layers: ${filtered.length} bands, ${coordIndex.size} unique coords`);
let markerCount = 0;
const heatPoints = [];
let heatMax = 1;
for (const [k, bands] of coordIndex.entries()) {
const [la, lo] = k.split(",").map(Number);
// Validate coordinates
if (!Number.isFinite(la) || !Number.isFinite(lo)) {
console.warn(`Invalid coords for key ${k}:`, { la, lo });
continue;
}
const intensity = bands.length;
heatPoints.push([la, lo, intensity]);
if (intensity > heatMax) heatMax = intensity;
const locationName = bands[0]?.location_text || null;
// Use location marker (final level) for all in legacy mode
const marker = createLocationMarker(la, lo, bands.length, bands, locationName);
markerCount++;
for (const b of bands) markersById.set(b.ma_id, marker);
clusterLayer.addLayer(marker);
}
console.log(`Added ${markerCount} markers to clusterLayer`);
// heatmap (use all points if loaded, otherwise use filtered)
if (heatPointsLoaded) {
buildHeatLayer(allHeatPoints);
} else {
buildHeatLayer(heatPoints, heatMax);
}
if (currentMode === "heat") {
if (heatLayer && !map.hasLayer(heatLayer)) map.addLayer(heatLayer);
} else if (heatLayer && map.hasLayer(heatLayer)) {
map.removeLayer(heatLayer);
}
// counters
$("count").textContent = String(filtered.length);
$("unique").textContent = String(coordIndex.size);
}
// --- Map init ---
const map = L.map("map", {
preferCanvas: true,
tap: true, // Enable tap for mobile
touchZoom: true,
dragging: true,
zoomControl: false // Disable default, we'll add our own on the right
}).setView([50.2, 10.2], 4);
// Add zoom control on the right side
L.control.zoom({
position: 'topright'
}).addTo(map);
L.tileLayer(DARK_TILES, { maxZoom: 19, attribution: DARK_ATTRIB }).addTo(map);
// Force map to render properly on mobile
setTimeout(() => {
if (map && map.invalidateSize) {
map.invalidateSize(true);
}
}, 100);
// Also invalidate on window resize
window.addEventListener('resize', () => {
if (map && map.invalidateSize) {
map.invalidateSize();
}
});
// Invalidate when page becomes visible (mobile browsers)
document.addEventListener('visibilitychange', () => {
if (!document.hidden && map && map.invalidateSize) {
setTimeout(() => map.invalidateSize(true), 100);
}
});
// Layer for custom cluster markers
clusterLayer = L.layerGroup();
map.addLayer(clusterLayer);
// Debounced viewport change handler
let viewportDebounceTimer = null;
function onViewportChange() {
if (!USE_VIEWPORT_LOADING) return;
clearTimeout(viewportDebounceTimer);
viewportDebounceTimer = setTimeout(() => {
loadViewportBands();
}, VIEWPORT_DEBOUNCE_MS);
}
// Listen for map movements
map.on("moveend", onViewportChange);
map.on("zoomend", onViewportChange);
function buildHeatLayer(points, maxIntensity) {
if (!points || !points.length) {
if (heatLayer) map.removeLayer(heatLayer);
return;
}
// Calcul des statistiques pour calibrer l'échelle
const intensities = points.map(p => p[2]).sort((a, b) => b - a);
const actualMax = intensities[0] || 1;
const p95 = intensities[Math.floor(intensities.length * 0.05)] || 1; // 95e percentile
const p90 = intensities[Math.floor(intensities.length * 0.10)] || 1; // 90e percentile
const p75 = intensities[Math.floor(intensities.length * 0.25)] || 1; // 75e percentile
const p50 = intensities[Math.floor(intensities.length * 0.5)] || 1; // Médiane
// Utilise le 75e percentile pour une heatmap moins saturée
// Multiplié par 2 pour étaler davantage les couleurs
const max = Math.max(15, (maxIntensity || p75) * 2);
console.log(`Heatmap: ${points.length} points, actualMax=${actualMax}, p95=${p95}, p90=${p90}, p75=${p75}, p50=${p50}, using max=${max}`);
if (heatLayer) map.removeLayer(heatLayer);
heatLayer = L.heatLayer(points, {
radius: 20, // Réduit de 28 à 20 pour moins de chevauchement
blur: 18, // Réduit de 22 à 18
maxZoom: 10, // Augmenté de 8 à 10 pour garder l'effet plus longtemps
minOpacity: 0.15, // Réduit de 0.25 à 0.15 pour moins de saturation
max: max,
gradient: {
0.0: '#000033', // Bleu très foncé (presque invisible)
0.15: '#000066', // Bleu foncé
0.3: '#0066cc', // Bleu moyen
0.45: '#00ccff', // Cyan
0.58: '#44ff44', // Vert
0.72: '#ffff00', // Jaune
0.86: '#ff8800', // Orange
1.0: '#ff0000' // Rouge
}
});
if (currentMode === "heat") {
map.addLayer(heatLayer);
}
}
function setMode(mode) {
// mode: "clusters" | "heat"
currentMode = mode;
const cOn = mode === "clusters";
const hOn = mode === "heat";
if (cOn) {
if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
if (heatLayer && map.hasLayer(heatLayer)) map.removeLayer(heatLayer);
} else {
if (map.hasLayer(clusterLayer)) map.removeLayer(clusterLayer);
if (!heatPointsLoaded) {
loadAllHeatPoints();
} else {
buildHeatLayer(allHeatPoints);
if (heatLayer && !map.hasLayer(heatLayer)) map.addLayer(heatLayer);
}
}
}
// --- UI widgets (multiselect) ---
function buildMultiSelect(containerId, items, enabledMap, labelFn, options = {}) {
const container = $(containerId);
if (!container) return;
const summaryEl = options.summaryEl || null;
const emptyLabel = options.emptyLabel || "Aucun élément";
container.innerHTML = `
<div class="ms-head">
<input class="ms-search" type="search" placeholder="Chercher…" />
<div class="ms-actions">
<button class="btn-mini" type="button" data-act="all">Tout</button>
<button class="btn-mini" type="button" data-act="none">Rien</button>
</div>
</div>
<div class="ms-list"></div>
`;
const searchEl = container.querySelector(".ms-search");
const listEl = container.querySelector(".ms-list");
function updateSummary() {
if (!summaryEl) return;
const total = items.length;
if (!total) {
summaryEl.textContent = "Aucun";
return;
}
const selected = items.filter(it => enabledMap.get(it.key) !== false).length;
if (selected === total) summaryEl.textContent = "Tous";
else if (selected === 0) summaryEl.textContent = "Aucun";
else summaryEl.textContent = `${selected}/${total}`;
}
function render() {
const q = norm(searchEl.value).toLowerCase();
listEl.innerHTML = "";
if (!items.length) {
listEl.innerHTML = `<div class="ms-empty">${escapeHtml(emptyLabel)}</div>`;
updateSummary();
return;
}
const filteredItems = items.filter(it => labelFn(it).toLowerCase().includes(q));
if (!filteredItems.length) {
listEl.innerHTML = `<div class="ms-empty">Aucun résultat</div>`;
updateSummary();
return;
}
for (const it of filteredItems) {
const key = it.key;
const on = enabledMap.get(key) === true;
const div = document.createElement("div");
div.className = `ms-item ${on ? "on" : ""}`;
div.dataset.key = key;
div.innerHTML = `
<div>${escapeHtml(labelFn(it))}</div>
<div class="count-badge">${it.count}</div>
`;
div.addEventListener("click", () => {
enabledMap.set(key, !(enabledMap.get(key) === true));
render();
applyFilters();
});
listEl.appendChild(div);
}
updateSummary();
}
container.querySelector('[data-act="all"]').addEventListener("click", () => {
for (const it of items) enabledMap.set(it.key, true);
render();
applyFilters();
});
container.querySelector('[data-act="none"]').addEventListener("click", () => {
for (const it of items) enabledMap.set(it.key, false);
render();
applyFilters();
});
searchEl.addEventListener("input", render);
render();
}
function buildStatusToggles(statusItems) {
const grid = $("statusGrid");
grid.innerHTML = "";
for (const it of statusItems) {
statusEnabled.set(it.key, true);
const btn = document.createElement("div");
btn.className = "toggle on";
btn.dataset.status = it.key;
btn.innerHTML = `<span class="box"></span><span>${escapeHtml(it.key)}</span><span class="count-badge">${it.count}</span>`;
btn.addEventListener("click", () => {
const cur = statusEnabled.get(it.key) === true;
statusEnabled.set(it.key, !cur);
btn.classList.toggle("on", !cur);
applyFilters();
});
grid.appendChild(btn);
}
}
function buildMacroGenreButtons(genreFacets = []) {
const container = $("macroGenres");
if (!container) return;
container.innerHTML = "";
macroActiveCount = 0;
const counts = new Map();
for (const m of MACRO_GENRES) counts.set(m.key, 0);
// Calculate macro genre counts from genre facets
for (const facet of genreFacets) {
const g = (facet.key || "").toLowerCase();
if (!g) continue;
for (const m of MACRO_GENRES) {
if (m.terms.some(t => g.includes(t))) {
counts.set(m.key, (counts.get(m.key) || 0) + facet.count);
}
}
}
for (const m of MACRO_GENRES) {
macroGenreEnabled.set(m.key, false);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "macro-btn";
btn.dataset.key = m.key;
btn.innerHTML = `<span>${escapeHtml(m.key)}</span><span class="count-badge">${counts.get(m.key) || 0}</span>`;
btn.addEventListener("click", () => {
const cur = macroGenreEnabled.get(m.key) === true;
macroGenreEnabled.set(m.key, !cur);
btn.classList.toggle("on", !cur);
macroActiveCount += cur ? -1 : 1;
applyFilters();
});
container.appendChild(btn);
}
}
function setupDropdown(triggerId, panelId) {
const trigger = $(triggerId);
const panel = $(panelId);
if (!trigger || !panel) return;
trigger.addEventListener("click", () => {
const nowCollapsed = panel.classList.toggle("is-collapsed");
trigger.setAttribute("aria-expanded", String(!nowCollapsed));
});
}
// --- List + details + highlight ---
function sortBands(arr) {
const mode = $("sortSelect")?.value || "az";
const a = [...arr];
if (mode === "az") {
a.sort((x,y) => (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
} else if (mode === "status") {
a.sort((x,y) => (x.status || "").localeCompare(y.status || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
} else if (mode === "genre") {
a.sort((x,y) => (x.genre || "").localeCompare(y.genre || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
} else if (mode === "country") {
a.sort((x,y) => (x.country || "").localeCompare(y.country || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
} else if (mode === "year") {
a.sort((x,y) => (Number.isFinite(y.formed_year) ? y.formed_year : -1) - (Number.isFinite(x.formed_year) ? x.formed_year : -1));
}
return a;
}
function setActive(ma_id) {
// list highlight
for (const [id, el] of listElById.entries()) el.classList.toggle("active", id === ma_id);
// marker highlight
const m = markersById.get(ma_id);
if (m && m.setStyle) {
m.setStyle({ radius: 8, fillOpacity: 0.85, weight: 2 });
// reset others
for (const [id, mm] of markersById.entries()) {
if (id !== ma_id && mm.setStyle) mm.setStyle({ radius: 6, fillOpacity: 0.55, weight: 1 });
}
}
}
function renderList(items, hintOverride) {
const list = $("list");
list.innerHTML = "";
listElById.clear();
const sorted = sortBands(items);
$("selectionHint").textContent = hintOverride || `${sorted.length} ${t("results_label")}`;
const q = currentQuery();
for (const b of sorted.slice(0, 4000)) {
const div = document.createElement("div");
div.className = "band";
div.dataset.id = String(b.ma_id);
const name = escapeHtml(b.name || "Unknown");
const genre = escapeHtml(b.genre || "—");
const loc = escapeHtml(b.location_text || "—");
const st = escapeHtml(b.status || "—");
const yr = Number.isFinite(b.formed_year) ? String(b.formed_year) : "—";
div.innerHTML = `
<div class="name"><span class="sigil"></span><div>${name}</div></div>
<div class="meta">
<div><b>${escapeHtml(b.country || "—")}</b> • <b>${st}</b> • ${yr}</div>
<div>${genre}</div>
<div>${loc}</div>
</div>
`;
div.addEventListener("mouseenter", () => {
setActive(b.ma_id);
const m = markersById.get(b.ma_id);
if (m) m.openPopup?.();
});
div.addEventListener("click", () => {
setActive(b.ma_id);
const m = markersById.get(b.ma_id);
if (m && m.getLatLng) {
map.setView(m.getLatLng(), Math.max(map.getZoom(), 8), { animate: true });
m.openPopup?.();
} else if (Number.isFinite(b.lat) && Number.isFinite(b.lon)) {
map.setView([b.lat, b.lon], Math.max(map.getZoom(), 10), { animate: true });
}
});
list.appendChild(div);
listElById.set(b.ma_id, div);
// highlight visuel dans liste
if (q && bandMatchesQuery(b, q)) {
div.style.boxShadow = "0 0 0 1px rgba(198,26,26,0.15) inset";
}
}
}
function applyFilters() {
const q = currentQuery();
console.log("Applying filters...", {
query: q,
useViewportLoading: USE_VIEWPORT_LOADING
});
if (USE_VIEWPORT_LOADING) {
// With viewport loading, trigger a reload from server
loadViewportBands();
} else {
// Legacy mode: filter locally loaded data
filtered = geocodedBands
.filter(b => bandMatchesQuery(b, q))
.filter(b => bandMatchesCountry(b))
.filter(b => bandMatchesStatus(b))
.filter(b => bandMatchesGenre(b))
.filter(b => bandMatchesTheme(b))
.filter(b => bandMatchesYear(b));
console.log(`Filtered: ${filtered.length} bands match filters`);
rebuildLayers();
renderList(filtered);
}
}
// --- Modal for no coords ---
const modalBackdrop = $("modalBackdrop");
const modalBody = $("modalBody");
const modalClose = $("modalClose");
const modalTitle = $("modalTitle");
function openModal(title, bands) {
modalTitle.textContent = title;
modalBody.innerHTML = "";
const q = currentQuery();
const items = bands
.filter(b => bandMatchesQuery(b, q))
.filter(b => bandMatchesCountry(b))
.filter(b => bandMatchesStatus(b))
.filter(b => bandMatchesGenre(b))
.filter(b => bandMatchesTheme(b))
.filter(b => bandMatchesYear(b));
if (!items.length) {
modalBody.innerHTML = `<div style="padding:10px;color:rgba(162,176,194,0.85);">Aucun résultat</div>`;
} else {
const sorted = sortBands(items);
for (const b of sorted.slice(0, 8000)) {
const div = document.createElement("div");
div.className = "band";
const maUrl = b.url || (b.ma_id ? `https://www.metal-archives.com/bands/x/${b.ma_id}` : "#");
div.innerHTML = `
<div class="name">
<span class="sigil"></span>
<a href="${escapeHtml(maUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(b.name)}</a>
</div>
<div class="meta">
<div><b>${escapeHtml(b.country||"—")}</b> • <b>${escapeHtml(b.status||"—")}</b></div>
<div><b>Genre:</b> ${escapeHtml(b.genre||"—")}</div>
<div><b>Lieu:</b> ${escapeHtml(b.location_text||"—")}</div>
</div>
`;
modalBody.appendChild(div);
}
}
modalBackdrop.classList.add("on");
modalBackdrop.setAttribute("aria-hidden", "false");
}
async function loadNoLocationBands() {
try {
modalBody.innerHTML = "<div style='padding:20px;color:rgba(162,176,194,0.85);'>Chargement...</div>";
const j = await apiGet("/api/bands?limit=10000&geocoded=0");
const items = j.items || [];
modalBody.innerHTML = "";
if (!items.length) {
modalBody.innerHTML = `<div style="padding:10px;color:rgba(162,176,194,0.85);">Aucun groupe sans coordonnées</div>`;
return;
}
for (const x of items.slice(0, 500)) {
const div = document.createElement("div");
div.className = "band";
const maUrl = x.ma_id ? `https://www.metal-archives.com/bands/x/${x.ma_id}` : "#";
div.innerHTML = `
<div class="name">
<span class="sigil"></span>
<a href="${escapeHtml(maUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(x.name || "Unknown")}</a>
</div>
<div class="meta">
<div><b>${escapeHtml(x.country || "—")}</b> • <b>${escapeHtml(x.status || "—")}</b></div>
<div><b>Genre:</b> ${escapeHtml(x.genre || "—")}</div>
<div><b>Lieu:</b> ${escapeHtml(x.location_text || "—")}</div>
</div>
`;
modalBody.appendChild(div);
}
if (items.length > 500) {
const more = document.createElement("div");
more.style.cssText = "padding:10px;color:rgba(162,176,194,0.85);text-align:center;";
more.textContent = `... et ${items.length - 500} autres`;
modalBody.appendChild(more);
}
} catch (e) {
modalBody.innerHTML = `<div style="padding:10px;color:rgba(162,176,194,0.85);">Erreur: ${escapeHtml(e.message)}</div>`;
}
}
function closeModal() {
modalBackdrop.classList.remove("on");
modalBackdrop.setAttribute("aria-hidden", "true");
}
modalClose?.addEventListener("click", closeModal);
modalBackdrop?.addEventListener("click", (e) => {
if (e.target === modalBackdrop) closeModal();
});
// Info modal (legal/FAQ)
const infoBackdrop = $("infoBackdrop");
const infoBody = $("infoBody");
const infoClose = $("infoClose");
const infoTitle = $("infoTitle");
// LEGAL_HTML and FAQ_HTML are now generated per-language in locales.js via t('legal_html') / t('faq_html')
function openInfoModal(title, html) {
if (!infoBackdrop || !infoBody || !infoTitle) return;
infoTitle.textContent = title;
infoBody.innerHTML = html;
infoBackdrop.classList.add("on");
infoBackdrop.setAttribute("aria-hidden", "false");
}
function closeInfoModal() {
if (!infoBackdrop) return;
infoBackdrop.classList.remove("on");
infoBackdrop.setAttribute("aria-hidden", "true");
}
if (infoClose) infoClose.addEventListener("click", closeInfoModal);
if (infoBackdrop) {
infoBackdrop.addEventListener("click", (e) => {
if (e.target === infoBackdrop) closeInfoModal();
});
}
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
closeModal();
closeInfoModal();
}
});
// Sidebar toggle
function setSidebarCollapsed(collapsed) {
if (!appEl) return;
appEl.classList.toggle("sidebar-collapsed", collapsed);
const toggle = $("sidebarToggle");
if (toggle) {
toggle.setAttribute("aria-expanded", String(!collapsed));
// Update button text
const label = toggle.querySelector(".label");
if (label) {
label.textContent = collapsed ? t("options_btn") : t("close_btn");
}
}
// Refresh map size after sidebar animation
setTimeout(() => map?.invalidateSize?.(), 220);
}
const sidebarToggle = $("sidebarToggle");
if (sidebarToggle) {
sidebarToggle.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const collapsed = appEl.classList.contains("sidebar-collapsed");
setSidebarCollapsed(!collapsed);
});
}
// Note: Sidebar closing is now controlled only via the toggle button
// We removed the overlay auto-close behavior since users want to interact
// with the map while the sidebar is open
// Collapse sidebar by default on smaller screens
if (window.matchMedia("(max-width: 1200px)").matches) {
setTimeout(() => {
setSidebarCollapsed(true);
}, 100);
}
// Language dropdown (flag-icons custom widget)
const LANG_FLAGS = { en:"gb", fr:"fr", de:"de", es:"es", it:"it", pl:"pl", nl:"nl", ro:"ro", pt:"pt", cs:"cz", sv:"se", hu:"hu", da:"dk", fi:"fi", sk:"sk", hr:"hr", sl:"si", lt:"lt", lv:"lv", et:"ee", nb:"no" };
const LANG_NAMES = { en:"English", fr:"Français", de:"Deutsch", es:"Español", it:"Italiano", pl:"Polski", nl:"Nederlands", ro:"Română", pt:"Português", cs:"Čeština", sv:"Svenska", hu:"Magyar", da:"Dansk", fi:"Suomi", sk:"Slovenčina", hr:"Hrvatski", sl:"Slovenščina", lt:"Lietuvių", lv:"Latviešu", et:"Eesti", nb:"Norsk bokmål" };
function syncLangTrigger(lang) {
const trigger = document.getElementById("langTrigger");
if (!trigger) return;
const flagEl = trigger.querySelector(".lang-flag");
if (flagEl) flagEl.className = `fi fi-${LANG_FLAGS[lang] || "un"} lang-flag`;
const codeEl = trigger.querySelector(".lang-code");
if (codeEl) codeEl.textContent = lang.toUpperCase();
document.querySelectorAll(".lang-opt").forEach(b =>
b.classList.toggle("current", b.dataset.lang === lang)
);
}
const langTrigger = document.getElementById("langTrigger");
const langOptions = document.getElementById("langOptions");
if (langTrigger && langOptions) {
langTrigger.addEventListener("click", (e) => {
e.stopPropagation();
const open = langOptions.classList.toggle("hidden") === false;
langTrigger.setAttribute("aria-expanded", String(open));
});
document.addEventListener("click", () => {
langOptions.classList.add("hidden");
langTrigger.setAttribute("aria-expanded", "false");
});
langOptions.querySelectorAll(".lang-opt").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
currentLang = btn.dataset.lang;
localStorage.setItem("lang", currentLang);
langOptions.classList.add("hidden");
langTrigger.setAttribute("aria-expanded", "false");
syncLangTrigger(currentLang);
applyI18n();
});
});
syncLangTrigger(currentLang);
}
applyI18n();
// Legal/FAQ links
const openLegal = $("openLegal");
if (openLegal) openLegal.addEventListener("click", () => openInfoModal(t("legal_title"), t("legal_html")));
const openFaq = $("openFaq");
if (openFaq) openFaq.addEventListener("click", () => openInfoModal(t("faq_title"), t("faq_html")));
// Setup dropdowns
setupDropdown("countryToggle", "countrySelect");
setupDropdown("genreToggle", "genreSelect");
setupDropdown("themeToggle", "themeSelect");
// --- Buttons / controls ---
$("btnNoLocation")?.addEventListener("click", () => {
modalTitle.textContent = t("without_coords_modal");
modalBackdrop.classList.add("on");
modalBackdrop.setAttribute("aria-hidden", "false");
loadNoLocationBands();
});
// Search with suggestions
const searchInput = $("q");
const suggestionsEl = $("searchSuggestions");
function showSearchSuggestions(query) {
if (!query || query.length < 2 || !allBands.length) {
suggestionsEl?.classList.remove("visible");
return;
}
const matches = allBands
.filter(b => bandMatchesQuery(b, query))
.slice(0, 8); // Show max 8 suggestions
if (!matches.length) {
suggestionsEl?.classList.remove("visible");
return;
}
if (suggestionsEl) {
suggestionsEl.innerHTML = matches.map(b => {
const statusText = b.status || "Unknown";
const yearText = Number.isFinite(b.formed_year) ? b.formed_year : "—";
return `
<div class="suggestion-item" data-id="${b.ma_id}">
<div class="suggestion-name">${escapeHtml(b.name)}</div>
<div class="suggestion-meta">
${escapeHtml(b.country || "—")}${escapeHtml(statusText)}${yearText}${escapeHtml(b.genre || "—")}
</div>
</div>
`;
}).join("");
// Add click handlers
suggestionsEl.querySelectorAll(".suggestion-item").forEach(el => {
el.addEventListener("click", () => {
const bandId = Number(el.dataset.id);
const band = allBands.find(b => b.ma_id === bandId);
if (band && Number.isFinite(band.lat) && Number.isFinite(band.lon)) {
const marker = markersById.get(bandId);
if (marker && marker.getLatLng) {
map.setView(marker.getLatLng(), 10, { animate: true });
marker.openPopup();
setActive(bandId);
} else {
map.setView([band.lat, band.lon], 12, { animate: true });
}
}
suggestionsEl.classList.remove("visible");
if (searchInput) searchInput.value = band?.name || "";
});
});
suggestionsEl.classList.add("visible");
}
}
const debouncedSearch = debounce(() => {
applyFilters();
}, 400); // 400ms delay
const debouncedSuggestions = debounce((query) => {
showSearchSuggestions(query);
}, 200); // 200ms delay for suggestions
searchInput?.addEventListener("input", (e) => {
const query = e.target.value.toLowerCase().trim();
debouncedSuggestions(query);
debouncedSearch();
});
searchInput?.addEventListener("focus", () => {
const query = searchInput.value.toLowerCase().trim();
if (query.length >= 2) {
showSearchSuggestions(query);
}
});
// Close suggestions when clicking outside
document.addEventListener("click", (e) => {
if (!searchInput?.contains(e.target) && !suggestionsEl?.contains(e.target)) {
suggestionsEl?.classList.remove("visible");
}
});
$("sortSelect")?.addEventListener("change", () => {
// Re-render list with current data
const visibleBands = currentViewportData.flatMap(c => c.sample_bands || [c])
.map(b => typeof b === 'string' ? JSON.parse(b) : b)
.filter(Boolean);
renderList(visibleBands.slice(0, 200));
});
const heatToggle = $("toggleHeat");
if (heatToggle) {
heatToggle.addEventListener("change", () => setMode(heatToggle.checked ? "heat" : "clusters"));
}
$("btnReset")?.addEventListener("click", () => {
if (filtered.length) {
const pts = filtered.map(b => L.latLng(b.lat, b.lon));
const bounds = L.latLngBounds(pts);
if (bounds.isValid()) map.fitBounds(bounds.pad(0.12));
} else if (geocodedBands.length) {
const pts = geocodedBands.map(b => L.latLng(b.lat, b.lon));
const bounds = L.latLngBounds(pts);
if (bounds.isValid()) map.fitBounds(bounds.pad(0.12));
} else {
map.setView([50.2, 10.2], 4, { animate: true });
}
});
map.on("click", () => {
const visibleBands = currentViewportData.flatMap(c => c.sample_bands || [c])
.map(b => typeof b === 'string' ? JSON.parse(b) : b)
.filter(Boolean);
renderList(visibleBands.slice(0, 200));
});
// --- Boot ---
function computeFacetCounts(arr, getKey) {
const m = new Map();
for (const b of arr) {
const val = getKey(b);
const k = val ? norm(val) : "Unknown";
m.set(k, (m.get(k) || 0) + 1);
}
return [...m.entries()]
.map(([key, count]) => ({ key, count }))
.sort((a,b) => b.count - a.count);
}
function computeTokenFacetCounts(arr, getTokens) {
const m = new Map();
for (const b of arr) {
const tokens = getTokens(b) || [];
if (!tokens.length) {
// Count bands without tokens as "Unknown"
m.set("Unknown", (m.get("Unknown") || 0) + 1);
} else {
for (const t of tokens) {
const k = norm(t) || "Unknown";
m.set(k, (m.get(k) || 0) + 1);
}
}
}
return [...m.entries()]
.map(([key, count]) => ({ key, count }))
.sort((a,b) => b.count - a.count);
}
/**
* Build timeline slider from year range (from facets or computed)
*/
function buildTimelineFromRange() {
const slider = $("yearSlider");
slider.innerHTML = "";
if (!yearRange.min || !yearRange.max) {
$("yearMin").textContent = "—";
$("yearMax").textContent = "—";
$("yearHint").textContent = t("year_data_missing");
slider.innerHTML = `<div class="timeline-empty">${t("year_data_unavailable")}</div>`;
return;
}
const { min, max } = yearRange;
console.log(`Timeline: ${min} - ${max}`);
$("yearMin").textContent = String(min);
$("yearMax").textContent = String(max);
$("yearHint").textContent = t("all_years_label");
noUiSlider.create(slider, {
start: [min, max],
connect: true,
step: 1,
range: { min, max },
behaviour: "tap-drag",
tooltips: false,
});
slider.noUiSlider.on("update", (vals) => {
const a = Math.round(Number(vals[0]));
const b = Math.round(Number(vals[1]));
yearFilter = { min: a, max: b };
$("yearHint").textContent = (a === min && b === max) ? t("all_years_label") : `${a}${b}`;
});
slider.noUiSlider.on("change", () => {
console.log(`Year filter changed: ${yearFilter.min} - ${yearFilter.max}`);
applyFilters();
});
}
async function boot() {
try {
console.log("Boot: Loading data...");
console.log("Viewport loading mode:", USE_VIEWPORT_LOADING ? "ENABLED" : "DISABLED");
// Wait for fonts to load (can affect layout)
if (document.fonts && document.fonts.ready) {
await document.fonts.ready;
}
// Load facets and stats first (fast)
await Promise.all([
loadFacets(),
loadStats()
]);
loadGoatCounterFooterStats(); // pas besoin d'await
// Build filters from facets if available, otherwise load bands first
let statuses, genres, countries, themes;
if (facetData) {
console.log("Using server-side facets for filters");
statuses = facetData.statuses.map(s => ({ key: s.value, count: s.count }));
countries = facetData.countries.map(c => ({ key: c.value, count: c.count }));
genres = facetData.genres.map(g => ({ key: g.value, count: g.count }));
themes = []; // Themes need to be computed from bands
// Set year range from facets
if (facetData.year_range) {
yearRange = {
min: facetData.year_range.min_year,
max: facetData.year_range.max_year
};
yearFilter = { ...yearRange };
}
} else {
// Fallback: load bands and compute facets locally
console.log("Loading bands for local facet computation...");
await loadBands();
statuses = computeFacetCounts(allBands, b => b.status);
genres = computeFacetCounts(allBands, b => b.genre);
countries = computeFacetCounts(allBands, b => b.country);
themes = computeTokenFacetCounts(allBands, b => b.themes);
}
console.log(`Boot: Facets ready - ${statuses.length} statuses, ${countries.length} countries, ${genres.length} genres`);
// Init enabled maps = true
for (const it of genres) genreEnabled.set(it.key, true);
for (const it of countries) countryEnabled.set(it.key, true);
for (const it of themes) themeEnabled.set(it.key, true);
buildStatusToggles(statuses);
buildMacroGenreButtons(genres);
buildMultiSelect("genreSelect", genres, genreEnabled, (it) => it.key, { summaryEl: $("genreSummary") });
buildMultiSelect("countrySelect", countries, countryEnabled, (it) => it.key, { summaryEl: $("countrySummary") });
if (themes.length) {
$("themeBox").style.display = "";
buildMultiSelect("themeSelect", themes, themeEnabled, (it) => it.key, { summaryEl: $("themeSummary"), emptyLabel: "Aucun thème" });
} else {
$("themeBox").style.display = "none";
}
// Build timeline from facet data or computed range
buildTimelineFromRange();
// Set mode BEFORE applying filters
setMode(heatToggle && heatToggle.checked ? "heat" : "clusters");
// Force map size calculation
console.log("Forcing map size recalculation...");
if (map && map.invalidateSize) {
map.invalidateSize(true);
}
// Initial data load
console.log("Boot: Loading initial viewport data...");
if (USE_VIEWPORT_LOADING) {
// Load viewport data
await loadViewportBands();
} else {
// Legacy: use all loaded bands
if (!allBands.length) await loadBands();
applyFilters();
}
console.log("Boot: Initial load complete");
// Fit to Europe by default
setTimeout(() => {
if (map && map.invalidateSize) {
map.invalidateSize(true);
}
// Default view: Europe
map.setView([50.2, 10.2], 4);
}, 100);
// Load all bands in background for heatmap and search
setTimeout(() => loadAllHeatPoints(), 1000);
} catch (err) {
console.error("Boot error:", err);
$("list").innerHTML = `<div style="padding:20px;color:rgba(162,176,194,0.85);">Erreur de chargement: ${escapeHtml(err.message || String(err))}</div>`;
}
}
// Make sure DOM is ready before booting
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}