/* 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 = '© OpenStreetMap contributors © 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: `
${count > 999 ? Math.round(count/1000) + 'k' : count}
`, 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: `
${count}
`, 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 `
${escapeHtml(b.name || 'Unknown')}
${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}
`; } return `
${escapeHtml(b.name || 'Unknown')}
${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}
`; }).join(""); const popupHtml = `
📍 ${escapeHtml(locationName || t('location_fallback'))} ${count} ${count === 1 ? t('group_s') : t('group_p')}
${bandListHtml}
`; // 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 = `
`; 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 = `
${escapeHtml(emptyLabel)}
`; updateSummary(); return; } const filteredItems = items.filter(it => labelFn(it).toLowerCase().includes(q)); if (!filteredItems.length) { listEl.innerHTML = `
Aucun résultat
`; 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 = `
${escapeHtml(labelFn(it))}
${it.count}
`; 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 = `${escapeHtml(it.key)}${it.count}`; 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 = `${escapeHtml(m.key)}${counts.get(m.key) || 0}`; 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 = `
${name}
${escapeHtml(b.country || "—")}${st} • ${yr}
${genre}
${loc}
`; 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 = "
Chargement...
"; const j = await apiGet("/api/bands?limit=10000&geocoded=0"); const items = j.items || []; modalBody.innerHTML = ""; if (!items.length) { modalBody.innerHTML = `
Aucun groupe sans coordonnées
`; 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 = `
${escapeHtml(x.name || "Unknown")}
${escapeHtml(x.country || "—")}${escapeHtml(x.status || "—")}
Genre: ${escapeHtml(x.genre || "—")}
Lieu: ${escapeHtml(x.location_text || "—")}
`; 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 = `
Erreur: ${escapeHtml(e.message)}
`; } } // ------------------------------------------------------------------ // 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 `
${escapeHtml(b.name)}
${escapeHtml(b.country || "—")} • ${escapeHtml(statusText)} • ${yearText} • ${escapeHtml(b.genre || "—")}
`; }).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 = `
${t("year_data_unavailable")}
`; 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 = `
Erreur de chargement: ${escapeHtml(err.message || String(err))}
`; } } // Make sure DOM is ready before booting if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); }