metalfrom.eu/apps/web/site/app.js
Nicolas FRYDER fcb20e2e11 perf(api): compression des réponses, et troncature de la carte enfin signalée
Le site public charge l'intégralité du corpus une seconde après l'ouverture de
la page (loadAllHeatPoints), pour la heatmap et la recherche. Rien ne
compressait dans la chaîne — ni Fastify, ni Traefik. Ce n'était donc pas un
risque théorique mais la facture de chaque visiteur, à chaque visite.

Mesuré sur 100 000 groupes avec des données réalistes, en-tête Accept-Encoding
d'un vrai navigateur : 21,5 Mo deviennent 1,9 Mo.

gzip est préféré à brotli, contre l'intuition : br sort 2,3 Mo là où gzip fait
1,9 Mo. La qualité brotli par défaut est réglée pour la vitesse, et sur ce JSON
très répétitif elle perd sur les deux tableaux à la fois — taille ET CPU. Seuil
à 1 Ko, en dessous duquel compresser coûte plus que ça ne rapporte.

/api/clusters plafonnait par ailleurs sa réponse à 2000 groupes ou 1000
clusters sans le dire, ni côté API ni côté client : dans une zone dense, des
groupes disparaissaient de la carte sans aucun signal. La réponse porte
désormais `truncated` et `limit`, et l'interface affiche un avertissement
invitant à zoomer. Clé de traduction ajoutée dans les 21 langues, la
convention du fichier étant une couverture complète.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:35:12 +02:00

1703 lines
55 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 = [];
const markersById = new Map(); // ma_id -> marker
const listElById = new Map(); // ma_id -> element
const 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);
};
}
// Les helpers purs vivent dans pure.js (testés unitairement). app.js garde
// l'état (les Map de facettes cochées) et ne fait que le lui passer.
const escapeHtml = BMPure.escapeHtml;
const norm = BMPure.norm;
const parseYear = BMPure.parseYear;
const splitThemes = BMPure.splitThemes;
function currentQuery() {
return norm($("q").value).toLowerCase();
}
function bandMatchesQuery(b, q) { return BMPure.matchesQuery(b, q); }
function bandMatchesStatus(b) { return BMPure.matchesFacet(b.status, statusEnabled, "Unknown"); }
function bandMatchesCountry(b) { return BMPure.matchesFacet(b.country, countryEnabled, "??"); }
function bandMatchesMacroGenre(b) { return BMPure.matchesMacroGenre(b, MACRO_GENRES, macroGenreEnabled, macroActiveCount); }
function bandMatchesGenre(b) {
if (!bandMatchesMacroGenre(b)) return false;
return BMPure.matchesFacet(b.genre, genreEnabled, "Unknown");
}
function bandMatchesTheme(b) { return BMPure.matchesTheme(b, themeEnabled); }
function bandMatchesYear(b) { return BMPure.matchesYear(b, yearFilter, yearRange); }
function keyFromLatLon(lat, lon) { return BMPure.keyFromLatLon(lat, lon); }
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, timeoutMs = 15000) {
const url = `${API_BASE}${path}`;
// Timeout explicite : sans lui, une API qui ne répond jamais laisse l'UI (et
// le rechargement du viewport de la carte) bloquée indéfiniment.
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const r = await fetch(url, { mode: "cors", signal: ctrl.signal });
if (!r.ok) throw new Error(`API ${path} failed: ${r.status}`);
return await r.json();
} finally {
clearTimeout(t);
}
}
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) {
const 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();
// Élargie de 20 % pour précharger les bords, puis bornée : voir
// BMPure.viewportBbox pour le détail (une bbox hors bornes fait répondre
// 400 à l'API, et la carte reste vide sans message).
const { minLon, minLat, maxLon, maxLat } = BMPure.viewportBbox({
south: bounds.getSouth(), west: bounds.getWest(),
north: bounds.getNorth(), east: bounds.getEast(),
}, 0.2);
const bbox = [minLon, minLat, maxLon, maxLat].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(([, v]) => v)
.map(([k]) => k);
if (activeStatuses.length && activeStatuses.length < statusEnabled.size) {
params.set("status", activeStatuses.join(","));
}
const activeCountries = Array.from(countryEnabled.entries())
.filter(([, 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);
// Le serveur plafonne sa réponse (2000 groupes, 1000 clusters). Il le dit
// désormais ; sans cet indicateur, des groupes disparaissaient de la carte
// dans les zones denses sans que rien ne le signale.
const note = $("truncNote");
if (note) {
note.hidden = !j.truncated;
if (j.truncated) {
note.textContent = "⚠";
note.title = t("truncated_hint").replace("{n}", String(j.limit ?? ""));
}
}
}
} 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";
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) {
return BMPure.sortBands(arr, $("sortSelect")?.value || "az");
}
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");
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>`;
}
}
// ------------------------------------------------------------------
// Modales — focus
//
// Les deux modales déclarent aria-modal="true", ce qui affirme aux lecteurs
// d'écran que tout le reste de la page est inerte. Sans piège à focus, c'était
// faux : la tabulation ressortait derrière le voile, sur des commandes
// invisibles, et la fermeture ne rendait pas le focus à son point de départ.
// Même traitement que le dashboard admin, qui le faisait déjà correctement.
// ------------------------------------------------------------------
const FOCUSABLES = 'button, [href], input, select, textarea, summary, [tabindex]:not([tabindex="-1"])';
let lastFocusedBeforeModal = null;
function trapModalFocus(backdrop) {
if (!backdrop || backdrop.__trap) return;
const onKeydown = (e) => {
if (e.key !== "Tab") return;
const f = backdrop.querySelectorAll(FOCUSABLES);
if (!f.length) return;
const first = f[0];
const last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
};
backdrop.addEventListener("keydown", onKeydown);
backdrop.__trap = onKeydown;
}
function openBackdrop(backdrop) {
if (!backdrop) return;
lastFocusedBeforeModal = document.activeElement;
backdrop.classList.add("on");
backdrop.setAttribute("aria-hidden", "false");
trapModalFocus(backdrop);
const f = backdrop.querySelectorAll(FOCUSABLES);
if (f.length) f[0].focus();
}
function closeBackdrop(backdrop) {
if (!backdrop || !backdrop.classList.contains("on")) return;
backdrop.classList.remove("on");
backdrop.setAttribute("aria-hidden", "true");
if (lastFocusedBeforeModal && document.contains(lastFocusedBeforeModal)) {
lastFocusedBeforeModal.focus();
lastFocusedBeforeModal = null;
}
}
function closeModal() {
closeBackdrop(modalBackdrop);
}
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;
openBackdrop(infoBackdrop);
}
function closeInfoModal() {
closeBackdrop(infoBackdrop);
}
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" };
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 => {
const isCurrent = b.dataset.lang === lang;
b.classList.toggle("current", isCurrent);
// Le conteneur est un role="listbox" : les lecteurs d'écran s'appuient sur
// aria-selected, pas sur la classe CSS, pour annoncer la langue active.
b.setAttribute("aria-selected", String(isCurrent));
});
}
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");
openBackdrop(modalBackdrop);
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();
}