- apps/api : API REST Node.js/Fastify + PostgreSQL/PostGIS - apps/geocoder : Worker Python géocodage Nominatim - apps/worker : Scraper Python Metal-Archives - apps/web : Frontend statique Leaflet/clustering/heatmap - infra/ : docker-compose + init.sql Déployé via Coolify + Traefik sur VPS OVH.
1752 lines
60 KiB
JavaScript
1752 lines
60 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";
|
||
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 = [];
|
||
let markersById = new Map(); // ma_id -> marker
|
||
let listElById = new Map(); // ma_id -> element
|
||
let coordIndex = new Map(); // "lat,lon" -> bands[]
|
||
|
||
let heatLayer = null;
|
||
let clusterLayer = null;
|
||
let currentMode = "clusters";
|
||
|
||
// All heat points (for full heatmap even when viewing clusters)
|
||
let allHeatPoints = [];
|
||
let heatPointsLoaded = false;
|
||
|
||
const statusEnabled = new Map();
|
||
const genreEnabled = new Map();
|
||
const countryEnabled = new Map();
|
||
const macroGenreEnabled = new Map();
|
||
const themeEnabled = new Map();
|
||
|
||
let yearRange = { min: null, max: null }; // global
|
||
let yearFilter = { min: null, max: null }; // current
|
||
|
||
let macroActiveCount = 0;
|
||
|
||
// Facet data from server (for filters)
|
||
let facetData = null;
|
||
|
||
// Loading state
|
||
let isLoadingViewport = false;
|
||
let pendingViewportLoad = false;
|
||
let currentViewportData = [];
|
||
|
||
// --- Utils ---
|
||
const $ = (id) => document.getElementById(id);
|
||
const appEl = $("app");
|
||
|
||
async function loadGoatCounterFooterStats() {
|
||
const totalEl = document.getElementById("gcTotal");
|
||
const monthEl = document.getElementById("gcMonth");
|
||
if (!totalEl || !monthEl) return;
|
||
|
||
// 1er jour du mois courant (ce mois-ci)
|
||
const d = new Date();
|
||
const yyyy = d.getFullYear();
|
||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||
const startOfMonth = `${yyyy}-${mm}-01`;
|
||
|
||
const base = "https://metalfromeurope.goatcounter.com/counter/";
|
||
const totalUrl = `${base}TOTAL.json`;
|
||
const monthUrl = `${base}TOTAL.json?start=${encodeURIComponent(startOfMonth)}`;
|
||
|
||
try {
|
||
const [t, m] = await Promise.all([
|
||
fetch(totalUrl, { cache: "no-store" }).then(r => (r.ok ? r.json() : Promise.reject(r.status))),
|
||
fetch(monthUrl, { cache: "no-store" }).then(r => (r.ok ? r.json() : Promise.reject(r.status))),
|
||
]);
|
||
|
||
// GoatCounter renvoie `count` comme string formatée (séparateurs milliers). :contentReference[oaicite:2]{index=2}
|
||
totalEl.textContent = t.count ?? "—";
|
||
monthEl.textContent = m.count ?? "—";
|
||
} catch (e) {
|
||
console.warn("GoatCounter footer stats failed:", e);
|
||
totalEl.textContent = "—";
|
||
monthEl.textContent = "—";
|
||
}
|
||
}
|
||
|
||
// Debounce function for search
|
||
let searchDebounceTimer = null;
|
||
function debounce(func, delay) {
|
||
return function(...args) {
|
||
clearTimeout(searchDebounceTimer);
|
||
searchDebounceTimer = setTimeout(() => func.apply(this, args), delay);
|
||
};
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s ?? "").replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||
}
|
||
|
||
function norm(s) {
|
||
if (s == null || s === undefined || s === "") return "";
|
||
return String(s).trim();
|
||
}
|
||
|
||
function parseYear(val) {
|
||
if (val == null) return null;
|
||
const n = Number(val);
|
||
if (Number.isFinite(n)) return n;
|
||
const m = String(val).match(/(19|20)\d{2}/);
|
||
return m ? Number(m[0]) : null;
|
||
}
|
||
|
||
function splitThemes(val) {
|
||
if (!val) return [];
|
||
if (Array.isArray(val)) return val.map(norm).filter(Boolean);
|
||
return String(val).split(/[,;/]/).map(norm).filter(Boolean);
|
||
}
|
||
|
||
function currentQuery() {
|
||
return norm($("q").value).toLowerCase();
|
||
}
|
||
|
||
function bandMatchesQuery(b, q) {
|
||
if (!q) return true;
|
||
const themes = Array.isArray(b.themes) ? b.themes.join(" ") : "";
|
||
const hay = `${b.name} ${b.genre} ${b.status} ${b.country} ${b.location_text} ${themes}`.toLowerCase();
|
||
return hay.includes(q);
|
||
}
|
||
|
||
function bandMatchesStatus(b) {
|
||
const s = norm(b.status);
|
||
if (!s) {
|
||
// If no status, check if "Unknown" is enabled
|
||
if (!statusEnabled.has("Unknown")) return true;
|
||
return statusEnabled.get("Unknown") === true;
|
||
}
|
||
if (!statusEnabled.has(s)) return true;
|
||
return statusEnabled.get(s) === true;
|
||
}
|
||
|
||
function bandMatchesCountry(b) {
|
||
const c = norm(b.country);
|
||
if (!c) {
|
||
// If no country, check if "??" is enabled
|
||
if (!countryEnabled.has("??")) return true;
|
||
return countryEnabled.get("??") === true;
|
||
}
|
||
if (!countryEnabled.has(c)) return true;
|
||
return countryEnabled.get(c) === true;
|
||
}
|
||
|
||
function bandMatchesMacroGenre(b) {
|
||
if (macroActiveCount === 0) return true;
|
||
const g = norm(b.genre).toLowerCase();
|
||
if (!g) return false;
|
||
return MACRO_GENRES.some(m => macroGenreEnabled.get(m.key) === true && m.terms.some(t => g.includes(t)));
|
||
}
|
||
|
||
function bandMatchesGenre(b) {
|
||
if (!bandMatchesMacroGenre(b)) return false;
|
||
const g = norm(b.genre);
|
||
if (!g) {
|
||
// If no genre, check if "Unknown" is enabled
|
||
if (!genreEnabled.has("Unknown")) return true;
|
||
return genreEnabled.get("Unknown") === true;
|
||
}
|
||
if (!genreEnabled.has(g)) return true;
|
||
return genreEnabled.get(g) === true;
|
||
}
|
||
|
||
function bandMatchesTheme(b) {
|
||
if (!themeEnabled.size) return true;
|
||
const allOn = Array.from(themeEnabled.values()).every(v => v === true);
|
||
if (allOn) return true;
|
||
const tokens = Array.isArray(b.themes) ? b.themes : [];
|
||
if (!tokens.length) return false;
|
||
return tokens.some(t => themeEnabled.get(t) === true);
|
||
}
|
||
|
||
function bandMatchesYear(b) {
|
||
const y = b.formed_year;
|
||
|
||
// If no filter is set, pass all
|
||
if (yearFilter.min == null || yearFilter.max == null) return true;
|
||
|
||
// If yearRange not initialized yet, pass all
|
||
if (yearRange.min == null || yearRange.max == null) return true;
|
||
|
||
// If filter is at full range, pass all (including those without years)
|
||
const isFullRange = (yearFilter.min === yearRange.min && yearFilter.max === yearRange.max);
|
||
|
||
if (isFullRange) {
|
||
return true;
|
||
}
|
||
|
||
// If user has narrowed the range, only show bands with valid years in range
|
||
if (!Number.isFinite(y)) {
|
||
// Band has no year and user has filtered by year -> exclude
|
||
return false;
|
||
}
|
||
|
||
return y >= yearFilter.min && y <= yearFilter.max;
|
||
}
|
||
|
||
function keyFromLatLon(lat, lon) {
|
||
const la = Number(lat).toFixed(6);
|
||
const lo = Number(lon).toFixed(6);
|
||
return `${la},${lo}`;
|
||
}
|
||
|
||
const MACRO_GENRES = [
|
||
{ key: "Black", terms: ["black"] },
|
||
{ key: "Death", terms: ["death"] },
|
||
{ key: "Doom/Stoner/Sludge", terms: ["doom", "stoner", "sludge"] },
|
||
{ key: "Electronic/Industrial", terms: ["electronic", "industrial", "electro"] },
|
||
{ key: "Experimental/Avant-garde", terms: ["experimental", "avant", "avant-garde", "avantgarde"] },
|
||
{ key: "Folk/Viking/Pagan", terms: ["folk", "viking", "pagan"] },
|
||
{ key: "Gothic", terms: ["gothic"] },
|
||
{ key: "Grindcore", terms: ["grind"] },
|
||
{ key: "Groove", terms: ["groove"] },
|
||
{ key: "Heavy", terms: ["heavy"] },
|
||
{ key: "Metalcore/Deathcore", terms: ["metalcore", "deathcore"] },
|
||
{ key: "Power", terms: ["power"] },
|
||
{ key: "Progressive", terms: ["progressive", "prog"] },
|
||
{ key: "Speed", terms: ["speed"] },
|
||
{ key: "Symphonic", terms: ["symphonic"] },
|
||
{ key: "Thrash", terms: ["thrash"] },
|
||
];
|
||
|
||
// --- API ---
|
||
async function apiGet(path) {
|
||
const url = `${API_BASE}${path}`;
|
||
const r = await fetch(url, { mode: "cors" });
|
||
if (!r.ok) throw new Error(`API ${path} failed: ${r.status}`);
|
||
return await r.json();
|
||
}
|
||
|
||
async function loadStats() {
|
||
// tolérant : si /api/stats n'existe pas encore, on ignore
|
||
try {
|
||
const j = await apiGet("/api/stats");
|
||
if (j && j.ok) {
|
||
$("total").textContent = String(j.total || 0);
|
||
$("geocoded").textContent = String(j.geocoded || 0);
|
||
$("noLocCount").textContent = String(j.no_location || 0);
|
||
}
|
||
} catch (_) {
|
||
// Silently ignore stats errors
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load facet data for filters (fast, minimal data)
|
||
*/
|
||
async function loadFacets() {
|
||
try {
|
||
const j = await apiGet("/api/facets");
|
||
if (j.ok) {
|
||
facetData = j;
|
||
console.log("Facets loaded:", {
|
||
statuses: j.statuses?.length,
|
||
countries: j.countries?.length,
|
||
genres: j.genres?.length,
|
||
yearRange: j.year_range
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.warn("Failed to load facets, will compute from bands:", e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parse band data from API response
|
||
*/
|
||
function parseBandData(x) {
|
||
let status = x.status || x.data?.status || x.data?.band_status || x.data?.status_label;
|
||
|
||
return {
|
||
ma_id: Number(x.ma_id),
|
||
name: norm(x.name),
|
||
url: x.url || (x.data?.url) || (x.data?.band_page?.url) || null,
|
||
country: norm(x.country || x.data?.country),
|
||
status: norm(status),
|
||
genre: norm(x.genre || x.data?.genre || x.data?.genres || x.data?.style),
|
||
themes: splitThemes(x.themes || x.data?.themes || x.data?.theme || x.data?.thematic),
|
||
location_text: norm(x.location_text || x.data?.location_text || x.data?.location),
|
||
formed_year: parseYear(x.formed_year ?? x.data?.formed_year ?? x.data?.formed ?? x.data?.formation_year ?? x.data?.year_formed),
|
||
lat: (x.lat == null ? null : Number(x.lat)),
|
||
lon: (x.lon == null ? null : Number(x.lon)),
|
||
data: x.data || {},
|
||
geocoded_at: x.geocoded_at || null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Load initial sample of bands for quick startup
|
||
*/
|
||
async function loadBands() {
|
||
// Load a limited set initially for faster startup
|
||
const limit = USE_VIEWPORT_LOADING ? INITIAL_LOAD_LIMIT : 200000;
|
||
const j = await apiGet(`/api/bands?limit=${limit}`);
|
||
const items = j.items || j.bands || [];
|
||
|
||
console.log(`Loaded ${items.length} bands initially`);
|
||
|
||
allBands = items.map(parseBandData);
|
||
|
||
geocodedBands = allBands.filter(b => Number.isFinite(b.lat) && Number.isFinite(b.lon));
|
||
noLocationBands = allBands.filter(b => !(Number.isFinite(b.lat) && Number.isFinite(b.lon)));
|
||
|
||
// Update counts
|
||
$("total").textContent = String(allBands.length);
|
||
$("geocoded").textContent = String(geocodedBands.length);
|
||
$("noLocCount").textContent = String(noLocationBands.length);
|
||
}
|
||
|
||
/**
|
||
* Load all heat points for the full heatmap (done once in background)
|
||
*/
|
||
async function loadAllHeatPoints() {
|
||
if (heatPointsLoaded) return;
|
||
|
||
try {
|
||
console.log("Loading all bands for heatmap...");
|
||
const j = await apiGet("/api/bands?limit=200000");
|
||
const items = j.items || [];
|
||
|
||
allHeatPoints = [];
|
||
|
||
// If allBands is empty, populate it
|
||
if (!allBands.length) {
|
||
allBands = items.map(parseBandData);
|
||
geocodedBands = allBands.filter(b => Number.isFinite(b.lat) && Number.isFinite(b.lon));
|
||
noLocationBands = allBands.filter(b => !(Number.isFinite(b.lat) && Number.isFinite(b.lon)));
|
||
}
|
||
|
||
// Index by coords for heatmap intensity
|
||
const coordCounts = new Map();
|
||
|
||
for (const x of items) {
|
||
const lat = x.lat == null ? null : Number(x.lat);
|
||
const lon = x.lon == null ? null : Number(x.lon);
|
||
|
||
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||
const key = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
||
coordCounts.set(key, (coordCounts.get(key) || 0) + 1);
|
||
}
|
||
}
|
||
|
||
// Convert to heat points
|
||
for (const [key, count] of coordCounts) {
|
||
const [lat, lon] = key.split(",").map(Number);
|
||
allHeatPoints.push([lat, lon, count]);
|
||
}
|
||
|
||
heatPointsLoaded = true;
|
||
console.log(`Loaded ${allHeatPoints.length} unique heat points from ${items.length} bands`);
|
||
|
||
// Update stats
|
||
$("total").textContent = String(items.length);
|
||
$("geocoded").textContent = String(geocodedBands.length);
|
||
$("noLocCount").textContent = String(noLocationBands.length);
|
||
|
||
// Rebuild heatmap if in heat mode
|
||
if (currentMode === "heat") {
|
||
buildHeatLayer(allHeatPoints);
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to load heat points:", e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load bands for current viewport from server
|
||
*/
|
||
async function loadViewportBands() {
|
||
if (!USE_VIEWPORT_LOADING) return;
|
||
if (isLoadingViewport) {
|
||
pendingViewportLoad = true;
|
||
return;
|
||
}
|
||
|
||
isLoadingViewport = true;
|
||
|
||
try {
|
||
const bounds = map.getBounds();
|
||
const zoom = map.getZoom();
|
||
|
||
// Expand bounds slightly to preload edges
|
||
const expandFactor = 0.2;
|
||
const latDiff = (bounds.getNorth() - bounds.getSouth()) * expandFactor;
|
||
const lonDiff = (bounds.getEast() - bounds.getWest()) * expandFactor;
|
||
|
||
const bbox = [
|
||
bounds.getWest() - lonDiff,
|
||
bounds.getSouth() - latDiff,
|
||
bounds.getEast() + lonDiff,
|
||
bounds.getNorth() + latDiff
|
||
].join(",");
|
||
|
||
// Build query params for filters
|
||
const params = new URLSearchParams({
|
||
bbox,
|
||
zoom: String(Math.floor(zoom))
|
||
});
|
||
|
||
// Add active filters
|
||
const activeStatuses = Array.from(statusEnabled.entries())
|
||
.filter(([k, v]) => v)
|
||
.map(([k]) => k);
|
||
if (activeStatuses.length && activeStatuses.length < statusEnabled.size) {
|
||
params.set("status", activeStatuses.join(","));
|
||
}
|
||
|
||
const activeCountries = Array.from(countryEnabled.entries())
|
||
.filter(([k, v]) => v)
|
||
.map(([k]) => k);
|
||
if (activeCountries.length && activeCountries.length < countryEnabled.size) {
|
||
params.set("countries", activeCountries.join(","));
|
||
}
|
||
|
||
if (yearFilter.min != null && yearFilter.max != null) {
|
||
if (yearFilter.min !== yearRange.min) params.set("year_min", String(yearFilter.min));
|
||
if (yearFilter.max !== yearRange.max) params.set("year_max", String(yearFilter.max));
|
||
}
|
||
|
||
// Add genre filter if macro genres are active
|
||
if (macroActiveCount > 0) {
|
||
const activeTerms = MACRO_GENRES
|
||
.filter(m => macroGenreEnabled.get(m.key) === true)
|
||
.flatMap(m => m.terms);
|
||
if (activeTerms.length) {
|
||
params.set("genre", activeTerms[0]); // API supports single genre filter
|
||
}
|
||
}
|
||
|
||
const url = `/api/clusters?${params.toString()}`;
|
||
console.log("Loading viewport:", url);
|
||
|
||
const j = await apiGet(url);
|
||
|
||
if (j.ok) {
|
||
currentViewportData = j.items || [];
|
||
|
||
if (j.type === "bands") {
|
||
// High zoom: got individual bands
|
||
viewportBands = currentViewportData.map(parseBandData);
|
||
rebuildLayersFromBands(viewportBands);
|
||
} else {
|
||
// Low zoom: got clusters
|
||
rebuildLayersFromClusters(currentViewportData);
|
||
}
|
||
|
||
$("count").textContent = String(j.count || currentViewportData.length || 0);
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to load viewport bands:", e);
|
||
} finally {
|
||
isLoadingViewport = false;
|
||
|
||
if (pendingViewportLoad) {
|
||
pendingViewportLoad = false;
|
||
setTimeout(loadViewportBands, 100);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Create a cluster marker (zoomable, not final location)
|
||
* These have a dashed border to indicate they can be zoomed
|
||
* Burgundy/dark red color, always white text
|
||
*/
|
||
function createClusterMarker(lat, lon, count, bands) {
|
||
// Size based on count (logarithmic scale)
|
||
const size = Math.min(24 + Math.log2(count + 1) * 8, 55);
|
||
|
||
// Constant burgundy/dark red color for all clusters
|
||
const color = 'rgba(120, 40, 60, 0.92)';
|
||
const borderColor = 'rgba(180, 80, 100, 0.9)';
|
||
const textColor = '#fff';
|
||
|
||
// Create custom div icon with count - dashed border indicates "zoomable"
|
||
const icon = L.divIcon({
|
||
className: 'cluster-marker cluster-zoomable',
|
||
html: `<div style="
|
||
width: ${size}px;
|
||
height: ${size}px;
|
||
background: ${color};
|
||
border: 3px dashed ${borderColor};
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: ${Math.max(11, size / 3)}px;
|
||
font-weight: bold;
|
||
color: ${textColor};
|
||
box-shadow: 0 3px 12px rgba(0,0,0,0.4);
|
||
cursor: zoom-in;
|
||
transition: transform 0.15s ease;
|
||
" onmouseover="this.style.transform='scale(1.1)'" onmouseout="this.style.transform='scale(1)'">${count > 999 ? Math.round(count/1000) + 'k' : count}</div>`,
|
||
iconSize: [size, size],
|
||
iconAnchor: [size/2, size/2]
|
||
});
|
||
|
||
const marker = L.marker([lat, lon], { icon });
|
||
|
||
// Store data for click handling
|
||
marker.__bands = bands;
|
||
marker.__count = count;
|
||
marker.__isCluster = true; // Flag to identify as zoomable cluster
|
||
|
||
// Click handler - always zoom for clusters
|
||
marker.on("click", (e) => {
|
||
L.DomEvent.stopPropagation(e);
|
||
map.setView([lat, lon], map.getZoom() + 2, { animate: true });
|
||
});
|
||
|
||
return marker;
|
||
}
|
||
|
||
/**
|
||
* Create a location marker (final level - shows popup with all bands)
|
||
* These have a solid border and bright red color to indicate they show details
|
||
*/
|
||
function createLocationMarker(lat, lon, count, bands, locationName) {
|
||
// Size based on count (smaller than clusters)
|
||
const size = Math.min(18 + Math.log2(count + 1) * 6, 42);
|
||
|
||
// Bright red color for final locations
|
||
let color, borderColor;
|
||
if (count === 1) {
|
||
color = 'rgba(180, 30, 30, 0.9)';
|
||
borderColor = 'rgba(255, 100, 100, 0.9)';
|
||
} else if (count < 5) {
|
||
color = 'rgba(198, 26, 26, 0.9)';
|
||
borderColor = 'rgba(255, 120, 120, 0.9)';
|
||
} else if (count < 20) {
|
||
color = 'rgba(210, 50, 50, 0.9)';
|
||
borderColor = 'rgba(255, 150, 150, 0.9)';
|
||
} else {
|
||
color = 'rgba(220, 80, 80, 0.9)';
|
||
borderColor = 'rgba(255, 180, 180, 0.95)';
|
||
}
|
||
|
||
// Create custom div icon with count - solid border indicates "clickable for details"
|
||
const icon = L.divIcon({
|
||
className: 'cluster-marker cluster-location',
|
||
html: `<div style="
|
||
width: ${size}px;
|
||
height: ${size}px;
|
||
background: ${color};
|
||
border: 2px solid ${borderColor};
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: ${Math.max(10, size / 3.2)}px;
|
||
font-weight: bold;
|
||
color: #fff;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||
" onmouseover="this.style.transform='scale(1.15)';this.style.boxShadow='0 4px 16px rgba(198,26,26,0.6)'" onmouseout="this.style.transform='scale(1)';this.style.boxShadow='0 2px 8px rgba(0,0,0,0.5)'">${count}</div>`,
|
||
iconSize: [size, size],
|
||
iconAnchor: [size/2, size/2]
|
||
});
|
||
|
||
const marker = L.marker([lat, lon], { icon });
|
||
|
||
// Store data
|
||
marker.__bands = bands;
|
||
marker.__count = count;
|
||
marker.__isCluster = false; // Flag to identify as final location
|
||
marker.__locationName = locationName;
|
||
|
||
// Build popup HTML with ALL bands (scrollable)
|
||
const bandListHtml = bands.map(b => {
|
||
const maUrl = b.url || (b.ma_id ? `https://www.metal-archives.com/bands/x/${b.ma_id}` : null);
|
||
const genre = b.genre || '—';
|
||
const status = b.status || '—';
|
||
const year = Number.isFinite(b.formed_year) ? b.formed_year : '—';
|
||
|
||
if (maUrl) {
|
||
return `<a href="${escapeHtml(maUrl)}" target="_blank" rel="noopener noreferrer" style="display:block;padding:6px 8px;margin:2px 0;background:rgba(255,255,255,0.03);border-radius:4px;text-decoration:none;transition:background 0.15s;" onmouseover="this.style.background='rgba(198,26,26,0.15)'" onmouseout="this.style.background='rgba(255,255,255,0.03)'">
|
||
<div style="color:#e9eef5;font-weight:600;font-size:13px;">${escapeHtml(b.name || 'Unknown')}</div>
|
||
<div style="color:#888;font-size:11px;margin-top:2px;">${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}</div>
|
||
</a>`;
|
||
}
|
||
return `<div style="display:block;padding:6px 8px;margin:2px 0;background:rgba(255,255,255,0.03);border-radius:4px;">
|
||
<div style="color:#e9eef5;font-weight:600;font-size:13px;">${escapeHtml(b.name || 'Unknown')}</div>
|
||
<div style="color:#888;font-size:11px;margin-top:2px;">${escapeHtml(genre)} · ${escapeHtml(status)} · ${year}</div>
|
||
</div>`;
|
||
}).join("");
|
||
|
||
const popupHtml = `
|
||
<div style="min-width:280px; max-width:350px;">
|
||
<div style="font-weight:900; margin-bottom:8px; padding-bottom:8px; font-size:14px; color:#c61a1a; border-bottom:1px solid rgba(255,255,255,0.1);">
|
||
📍 ${escapeHtml(locationName || 'Localisation')}
|
||
<span style="font-weight:normal;color:#a2b0c2;font-size:12px;margin-left:8px;">${count} groupe${count > 1 ? 's' : ''}</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 hint = locationName ? `${count} groupe(s) — ${locationName}` : `${count} groupe(s)`;
|
||
renderList(bands, hint);
|
||
// Small delay to ensure popup opens correctly
|
||
setTimeout(() => marker.openPopup(), 10);
|
||
});
|
||
|
||
return marker;
|
||
}
|
||
|
||
/**
|
||
* Check if all bands share the same location_text
|
||
*/
|
||
function bandsShareLocation(bands) {
|
||
if (!bands || bands.length === 0) return false;
|
||
if (bands.length === 1) return true;
|
||
|
||
const firstLoc = norm(bands[0]?.location_text || '');
|
||
if (!firstLoc) return false;
|
||
|
||
return bands.every(b => norm(b?.location_text || '') === firstLoc);
|
||
}
|
||
|
||
/**
|
||
* Rebuild layers from cluster data (server-side aggregated)
|
||
*/
|
||
function rebuildLayersFromClusters(clusters) {
|
||
markersById.clear();
|
||
coordIndex.clear();
|
||
clusterLayer.clearLayers();
|
||
|
||
for (const cluster of clusters) {
|
||
const { lat, lon, count, sample_bands, location_text } = cluster;
|
||
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||
|
||
// Parse sample bands
|
||
const bands = (sample_bands || []).map(b => {
|
||
if (typeof b === 'string') {
|
||
try { return JSON.parse(b); } catch { return null; }
|
||
}
|
||
return b;
|
||
}).filter(Boolean);
|
||
|
||
const bandCount = count || bands.length;
|
||
|
||
// Check if all bands share the same location
|
||
const allSameLocation = bandsShareLocation(bands);
|
||
const locationName = allSameLocation ? (location_text || bands[0]?.location_text || null) : null;
|
||
|
||
// Use location marker (popup) if all bands share same location
|
||
// Use cluster marker (zoom) if bands come from different locations
|
||
const useLocationMarker = allSameLocation;
|
||
|
||
const marker = useLocationMarker
|
||
? createLocationMarker(lat, lon, bandCount, bands, locationName)
|
||
: createClusterMarker(lat, lon, bandCount, bands);
|
||
|
||
clusterLayer.addLayer(marker);
|
||
|
||
// Store reference for each band in cluster
|
||
for (const b of bands) {
|
||
if (b.ma_id) markersById.set(b.ma_id, marker);
|
||
}
|
||
}
|
||
|
||
if (currentMode === "clusters") {
|
||
if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
|
||
}
|
||
|
||
$("unique").textContent = String(clusters.length);
|
||
|
||
// Render list with visible bands
|
||
const visibleBands = clusters.flatMap(c => c.sample_bands || [])
|
||
.map(b => typeof b === 'string' ? JSON.parse(b) : b)
|
||
.filter(Boolean);
|
||
renderList(visibleBands.slice(0, 200));
|
||
}
|
||
|
||
/**
|
||
* Rebuild layers from individual band data
|
||
*/
|
||
function rebuildLayersFromBands(bands) {
|
||
markersById.clear();
|
||
coordIndex.clear();
|
||
clusterLayer.clearLayers();
|
||
|
||
// Index by coords
|
||
for (const b of bands) {
|
||
if (!Number.isFinite(b.lat) || !Number.isFinite(b.lon)) continue;
|
||
const k = keyFromLatLon(b.lat, b.lon);
|
||
if (!coordIndex.has(k)) coordIndex.set(k, []);
|
||
coordIndex.get(k).push(b);
|
||
}
|
||
|
||
for (const [k, coordBands] of coordIndex.entries()) {
|
||
const [la, lo] = k.split(",").map(Number);
|
||
|
||
if (!Number.isFinite(la) || !Number.isFinite(lo)) continue;
|
||
|
||
const locationName = coordBands[0]?.location_text || null;
|
||
|
||
// Always use location marker for individual bands (final level)
|
||
const marker = createLocationMarker(la, lo, coordBands.length, coordBands, locationName);
|
||
|
||
for (const b of coordBands) markersById.set(b.ma_id, marker);
|
||
|
||
clusterLayer.addLayer(marker);
|
||
}
|
||
|
||
if (currentMode === "clusters") {
|
||
if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
|
||
}
|
||
|
||
$("count").textContent = String(bands.length);
|
||
$("unique").textContent = String(coordIndex.size);
|
||
renderList(bands);
|
||
}
|
||
|
||
/**
|
||
* Rebuild layers from locally filtered data (legacy mode)
|
||
*/
|
||
function rebuildLayers() {
|
||
markersById.clear();
|
||
coordIndex.clear();
|
||
|
||
clusterLayer.clearLayers();
|
||
|
||
if (currentMode === "clusters") {
|
||
if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
|
||
} else if (map.hasLayer(clusterLayer)) {
|
||
map.removeLayer(clusterLayer);
|
||
}
|
||
|
||
// index by coords
|
||
for (const b of filtered) {
|
||
const k = keyFromLatLon(b.lat, b.lon);
|
||
if (!coordIndex.has(k)) coordIndex.set(k, []);
|
||
coordIndex.get(k).push(b);
|
||
}
|
||
|
||
console.log(`Rebuilding layers: ${filtered.length} bands, ${coordIndex.size} unique coords`);
|
||
|
||
let markerCount = 0;
|
||
const heatPoints = [];
|
||
let heatMax = 1;
|
||
for (const [k, bands] of coordIndex.entries()) {
|
||
const [la, lo] = k.split(",").map(Number);
|
||
|
||
// Validate coordinates
|
||
if (!Number.isFinite(la) || !Number.isFinite(lo)) {
|
||
console.warn(`Invalid coords for key ${k}:`, { la, lo });
|
||
continue;
|
||
}
|
||
|
||
const intensity = bands.length;
|
||
heatPoints.push([la, lo, intensity]);
|
||
if (intensity > heatMax) heatMax = intensity;
|
||
|
||
const locationName = bands[0]?.location_text || null;
|
||
|
||
// Use location marker (final level) for all in legacy mode
|
||
const marker = createLocationMarker(la, lo, bands.length, bands, locationName);
|
||
markerCount++;
|
||
|
||
for (const b of bands) markersById.set(b.ma_id, marker);
|
||
|
||
clusterLayer.addLayer(marker);
|
||
}
|
||
|
||
console.log(`Added ${markerCount} markers to clusterLayer`);
|
||
|
||
// heatmap (use all points if loaded, otherwise use filtered)
|
||
if (heatPointsLoaded) {
|
||
buildHeatLayer(allHeatPoints);
|
||
} else {
|
||
buildHeatLayer(heatPoints, heatMax);
|
||
}
|
||
|
||
if (currentMode === "heat") {
|
||
if (heatLayer && !map.hasLayer(heatLayer)) map.addLayer(heatLayer);
|
||
} else if (heatLayer && map.hasLayer(heatLayer)) {
|
||
map.removeLayer(heatLayer);
|
||
}
|
||
|
||
// counters
|
||
$("count").textContent = String(filtered.length);
|
||
$("unique").textContent = String(coordIndex.size);
|
||
}
|
||
|
||
// --- Map init ---
|
||
const map = L.map("map", {
|
||
preferCanvas: true,
|
||
tap: true, // Enable tap for mobile
|
||
touchZoom: true,
|
||
dragging: true,
|
||
zoomControl: false // Disable default, we'll add our own on the right
|
||
}).setView([50.2, 10.2], 4);
|
||
|
||
// Add zoom control on the right side
|
||
L.control.zoom({
|
||
position: 'topright'
|
||
}).addTo(map);
|
||
|
||
L.tileLayer(DARK_TILES, { maxZoom: 19, attribution: DARK_ATTRIB }).addTo(map);
|
||
|
||
// Force map to render properly on mobile
|
||
setTimeout(() => {
|
||
if (map && map.invalidateSize) {
|
||
map.invalidateSize(true);
|
||
}
|
||
}, 100);
|
||
|
||
// Also invalidate on window resize
|
||
window.addEventListener('resize', () => {
|
||
if (map && map.invalidateSize) {
|
||
map.invalidateSize();
|
||
}
|
||
});
|
||
|
||
// Invalidate when page becomes visible (mobile browsers)
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (!document.hidden && map && map.invalidateSize) {
|
||
setTimeout(() => map.invalidateSize(true), 100);
|
||
}
|
||
});
|
||
|
||
// Layer for custom cluster markers
|
||
clusterLayer = L.layerGroup();
|
||
map.addLayer(clusterLayer);
|
||
|
||
// Debounced viewport change handler
|
||
let viewportDebounceTimer = null;
|
||
function onViewportChange() {
|
||
if (!USE_VIEWPORT_LOADING) return;
|
||
|
||
clearTimeout(viewportDebounceTimer);
|
||
viewportDebounceTimer = setTimeout(() => {
|
||
loadViewportBands();
|
||
}, VIEWPORT_DEBOUNCE_MS);
|
||
}
|
||
|
||
// Listen for map movements
|
||
map.on("moveend", onViewportChange);
|
||
map.on("zoomend", onViewportChange);
|
||
|
||
function buildHeatLayer(points, maxIntensity) {
|
||
if (!points || !points.length) {
|
||
if (heatLayer) map.removeLayer(heatLayer);
|
||
return;
|
||
}
|
||
|
||
// Calcul des statistiques pour calibrer l'échelle
|
||
const intensities = points.map(p => p[2]).sort((a, b) => b - a);
|
||
const actualMax = intensities[0] || 1;
|
||
const p95 = intensities[Math.floor(intensities.length * 0.05)] || 1; // 95e percentile
|
||
const p90 = intensities[Math.floor(intensities.length * 0.10)] || 1; // 90e percentile
|
||
const p75 = intensities[Math.floor(intensities.length * 0.25)] || 1; // 75e percentile
|
||
const p50 = intensities[Math.floor(intensities.length * 0.5)] || 1; // Médiane
|
||
|
||
// Utilise le 75e percentile pour une heatmap moins saturée
|
||
// Multiplié par 2 pour étaler davantage les couleurs
|
||
const max = Math.max(15, (maxIntensity || p75) * 2);
|
||
|
||
console.log(`Heatmap: ${points.length} points, actualMax=${actualMax}, p95=${p95}, p90=${p90}, p75=${p75}, p50=${p50}, using max=${max}`);
|
||
|
||
if (heatLayer) map.removeLayer(heatLayer);
|
||
heatLayer = L.heatLayer(points, {
|
||
radius: 20, // Réduit de 28 à 20 pour moins de chevauchement
|
||
blur: 18, // Réduit de 22 à 18
|
||
maxZoom: 10, // Augmenté de 8 à 10 pour garder l'effet plus longtemps
|
||
minOpacity: 0.15, // Réduit de 0.25 à 0.15 pour moins de saturation
|
||
max: max,
|
||
gradient: {
|
||
0.0: '#000033', // Bleu très foncé (presque invisible)
|
||
0.15: '#000066', // Bleu foncé
|
||
0.3: '#0066cc', // Bleu moyen
|
||
0.45: '#00ccff', // Cyan
|
||
0.58: '#44ff44', // Vert
|
||
0.72: '#ffff00', // Jaune
|
||
0.86: '#ff8800', // Orange
|
||
1.0: '#ff0000' // Rouge
|
||
}
|
||
});
|
||
|
||
if (currentMode === "heat") {
|
||
map.addLayer(heatLayer);
|
||
}
|
||
}
|
||
|
||
function setMode(mode) {
|
||
// mode: "clusters" | "heat"
|
||
currentMode = mode;
|
||
const cOn = mode === "clusters";
|
||
const hOn = mode === "heat";
|
||
|
||
if (cOn) {
|
||
if (!map.hasLayer(clusterLayer)) map.addLayer(clusterLayer);
|
||
if (heatLayer && map.hasLayer(heatLayer)) map.removeLayer(heatLayer);
|
||
} else {
|
||
if (map.hasLayer(clusterLayer)) map.removeLayer(clusterLayer);
|
||
if (!heatPointsLoaded) {
|
||
loadAllHeatPoints();
|
||
} else {
|
||
buildHeatLayer(allHeatPoints);
|
||
if (heatLayer && !map.hasLayer(heatLayer)) map.addLayer(heatLayer);
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- UI widgets (multiselect) ---
|
||
function buildMultiSelect(containerId, items, enabledMap, labelFn, options = {}) {
|
||
const container = $(containerId);
|
||
if (!container) return;
|
||
|
||
const summaryEl = options.summaryEl || null;
|
||
const emptyLabel = options.emptyLabel || "Aucun élément";
|
||
|
||
container.innerHTML = `
|
||
<div class="ms-head">
|
||
<input class="ms-search" type="search" placeholder="Chercher…" />
|
||
<div class="ms-actions">
|
||
<button class="btn-mini" type="button" data-act="all">Tout</button>
|
||
<button class="btn-mini" type="button" data-act="none">Rien</button>
|
||
</div>
|
||
</div>
|
||
<div class="ms-list"></div>
|
||
`;
|
||
|
||
const searchEl = container.querySelector(".ms-search");
|
||
const listEl = container.querySelector(".ms-list");
|
||
|
||
function updateSummary() {
|
||
if (!summaryEl) return;
|
||
const total = items.length;
|
||
if (!total) {
|
||
summaryEl.textContent = "Aucun";
|
||
return;
|
||
}
|
||
const selected = items.filter(it => enabledMap.get(it.key) !== false).length;
|
||
if (selected === total) summaryEl.textContent = "Tous";
|
||
else if (selected === 0) summaryEl.textContent = "Aucun";
|
||
else summaryEl.textContent = `${selected}/${total}`;
|
||
}
|
||
|
||
function render() {
|
||
const q = norm(searchEl.value).toLowerCase();
|
||
listEl.innerHTML = "";
|
||
|
||
if (!items.length) {
|
||
listEl.innerHTML = `<div class="ms-empty">${escapeHtml(emptyLabel)}</div>`;
|
||
updateSummary();
|
||
return;
|
||
}
|
||
|
||
const filteredItems = items.filter(it => labelFn(it).toLowerCase().includes(q));
|
||
if (!filteredItems.length) {
|
||
listEl.innerHTML = `<div class="ms-empty">Aucun résultat</div>`;
|
||
updateSummary();
|
||
return;
|
||
}
|
||
|
||
for (const it of filteredItems) {
|
||
const key = it.key;
|
||
const on = enabledMap.get(key) === true;
|
||
const div = document.createElement("div");
|
||
div.className = `ms-item ${on ? "on" : ""}`;
|
||
div.dataset.key = key;
|
||
div.innerHTML = `
|
||
<div>${escapeHtml(labelFn(it))}</div>
|
||
<div class="count-badge">${it.count}</div>
|
||
`;
|
||
div.addEventListener("click", () => {
|
||
enabledMap.set(key, !(enabledMap.get(key) === true));
|
||
render();
|
||
applyFilters();
|
||
});
|
||
listEl.appendChild(div);
|
||
}
|
||
|
||
updateSummary();
|
||
}
|
||
|
||
container.querySelector('[data-act="all"]').addEventListener("click", () => {
|
||
for (const it of items) enabledMap.set(it.key, true);
|
||
render();
|
||
applyFilters();
|
||
});
|
||
container.querySelector('[data-act="none"]').addEventListener("click", () => {
|
||
for (const it of items) enabledMap.set(it.key, false);
|
||
render();
|
||
applyFilters();
|
||
});
|
||
searchEl.addEventListener("input", render);
|
||
|
||
render();
|
||
}
|
||
|
||
function buildStatusToggles(statusItems) {
|
||
const grid = $("statusGrid");
|
||
grid.innerHTML = "";
|
||
for (const it of statusItems) {
|
||
statusEnabled.set(it.key, true);
|
||
const btn = document.createElement("div");
|
||
btn.className = "toggle on";
|
||
btn.dataset.status = it.key;
|
||
btn.innerHTML = `<span class="box"></span><span>${escapeHtml(it.key)}</span><span class="count-badge">${it.count}</span>`;
|
||
btn.addEventListener("click", () => {
|
||
const cur = statusEnabled.get(it.key) === true;
|
||
statusEnabled.set(it.key, !cur);
|
||
btn.classList.toggle("on", !cur);
|
||
applyFilters();
|
||
});
|
||
grid.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
function buildMacroGenreButtons(genreFacets = []) {
|
||
const container = $("macroGenres");
|
||
if (!container) return;
|
||
container.innerHTML = "";
|
||
macroActiveCount = 0;
|
||
|
||
const counts = new Map();
|
||
for (const m of MACRO_GENRES) counts.set(m.key, 0);
|
||
|
||
// Calculate macro genre counts from genre facets
|
||
for (const facet of genreFacets) {
|
||
const g = (facet.key || "").toLowerCase();
|
||
if (!g) continue;
|
||
for (const m of MACRO_GENRES) {
|
||
if (m.terms.some(t => g.includes(t))) {
|
||
counts.set(m.key, (counts.get(m.key) || 0) + facet.count);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const m of MACRO_GENRES) {
|
||
macroGenreEnabled.set(m.key, false);
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "macro-btn";
|
||
btn.dataset.key = m.key;
|
||
btn.innerHTML = `<span>${escapeHtml(m.key)}</span><span class="count-badge">${counts.get(m.key) || 0}</span>`;
|
||
btn.addEventListener("click", () => {
|
||
const cur = macroGenreEnabled.get(m.key) === true;
|
||
macroGenreEnabled.set(m.key, !cur);
|
||
btn.classList.toggle("on", !cur);
|
||
macroActiveCount += cur ? -1 : 1;
|
||
applyFilters();
|
||
});
|
||
container.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
function setupDropdown(triggerId, panelId) {
|
||
const trigger = $(triggerId);
|
||
const panel = $(panelId);
|
||
if (!trigger || !panel) return;
|
||
trigger.addEventListener("click", () => {
|
||
const nowCollapsed = panel.classList.toggle("is-collapsed");
|
||
trigger.setAttribute("aria-expanded", String(!nowCollapsed));
|
||
});
|
||
}
|
||
|
||
// --- List + details + highlight ---
|
||
function sortBands(arr) {
|
||
const mode = $("sortSelect")?.value || "az";
|
||
const a = [...arr];
|
||
|
||
if (mode === "az") {
|
||
a.sort((x,y) => (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
|
||
} else if (mode === "status") {
|
||
a.sort((x,y) => (x.status || "").localeCompare(y.status || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
|
||
} else if (mode === "genre") {
|
||
a.sort((x,y) => (x.genre || "").localeCompare(y.genre || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
|
||
} else if (mode === "country") {
|
||
a.sort((x,y) => (x.country || "").localeCompare(y.country || "", "fr", { sensitivity: "base" }) || (x.name || "").localeCompare(y.name || "", "fr", { sensitivity: "base" }));
|
||
} else if (mode === "year") {
|
||
a.sort((x,y) => (Number.isFinite(y.formed_year) ? y.formed_year : -1) - (Number.isFinite(x.formed_year) ? x.formed_year : -1));
|
||
}
|
||
|
||
return a;
|
||
}
|
||
|
||
function setActive(ma_id) {
|
||
// list highlight
|
||
for (const [id, el] of listElById.entries()) el.classList.toggle("active", id === ma_id);
|
||
|
||
// marker highlight
|
||
const m = markersById.get(ma_id);
|
||
if (m && m.setStyle) {
|
||
m.setStyle({ radius: 8, fillOpacity: 0.85, weight: 2 });
|
||
// reset others
|
||
for (const [id, mm] of markersById.entries()) {
|
||
if (id !== ma_id && mm.setStyle) mm.setStyle({ radius: 6, fillOpacity: 0.55, weight: 1 });
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderList(items, hintOverride) {
|
||
const list = $("list");
|
||
list.innerHTML = "";
|
||
listElById.clear();
|
||
|
||
const sorted = sortBands(items);
|
||
$("selectionHint").textContent = hintOverride || `${sorted.length} résultat(s)`;
|
||
|
||
const q = currentQuery();
|
||
|
||
for (const b of sorted.slice(0, 4000)) {
|
||
const div = document.createElement("div");
|
||
div.className = "band";
|
||
div.dataset.id = String(b.ma_id);
|
||
|
||
const name = escapeHtml(b.name || "Unknown");
|
||
const genre = escapeHtml(b.genre || "—");
|
||
const loc = escapeHtml(b.location_text || "—");
|
||
const st = escapeHtml(b.status || "—");
|
||
const yr = Number.isFinite(b.formed_year) ? String(b.formed_year) : "—";
|
||
|
||
div.innerHTML = `
|
||
<div class="name"><span class="sigil"></span><div>${name}</div></div>
|
||
<div class="meta">
|
||
<div><b>${escapeHtml(b.country || "—")}</b> • <b>${st}</b> • ${yr}</div>
|
||
<div>${genre}</div>
|
||
<div>${loc}</div>
|
||
</div>
|
||
`;
|
||
|
||
div.addEventListener("mouseenter", () => {
|
||
setActive(b.ma_id);
|
||
const m = markersById.get(b.ma_id);
|
||
if (m) m.openPopup?.();
|
||
});
|
||
|
||
div.addEventListener("click", () => {
|
||
setActive(b.ma_id);
|
||
const m = markersById.get(b.ma_id);
|
||
if (m && m.getLatLng) {
|
||
map.setView(m.getLatLng(), Math.max(map.getZoom(), 8), { animate: true });
|
||
m.openPopup?.();
|
||
} else if (Number.isFinite(b.lat) && Number.isFinite(b.lon)) {
|
||
map.setView([b.lat, b.lon], Math.max(map.getZoom(), 10), { animate: true });
|
||
}
|
||
});
|
||
|
||
list.appendChild(div);
|
||
listElById.set(b.ma_id, div);
|
||
|
||
// highlight visuel dans liste
|
||
if (q && bandMatchesQuery(b, q)) {
|
||
div.style.boxShadow = "0 0 0 1px rgba(198,26,26,0.15) inset";
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyFilters() {
|
||
const q = currentQuery();
|
||
|
||
console.log("Applying filters...", {
|
||
query: q,
|
||
useViewportLoading: USE_VIEWPORT_LOADING
|
||
});
|
||
|
||
if (USE_VIEWPORT_LOADING) {
|
||
// With viewport loading, trigger a reload from server
|
||
loadViewportBands();
|
||
} else {
|
||
// Legacy mode: filter locally loaded data
|
||
filtered = geocodedBands
|
||
.filter(b => bandMatchesQuery(b, q))
|
||
.filter(b => bandMatchesCountry(b))
|
||
.filter(b => bandMatchesStatus(b))
|
||
.filter(b => bandMatchesGenre(b))
|
||
.filter(b => bandMatchesTheme(b))
|
||
.filter(b => bandMatchesYear(b));
|
||
|
||
console.log(`Filtered: ${filtered.length} bands match filters`);
|
||
|
||
rebuildLayers();
|
||
renderList(filtered);
|
||
}
|
||
}
|
||
|
||
// --- Modal for no coords ---
|
||
const modalBackdrop = $("modalBackdrop");
|
||
const modalBody = $("modalBody");
|
||
const modalClose = $("modalClose");
|
||
const modalTitle = $("modalTitle");
|
||
|
||
function openModal(title, bands) {
|
||
modalTitle.textContent = title;
|
||
modalBody.innerHTML = "";
|
||
|
||
const q = currentQuery();
|
||
const items = bands
|
||
.filter(b => bandMatchesQuery(b, q))
|
||
.filter(b => bandMatchesCountry(b))
|
||
.filter(b => bandMatchesStatus(b))
|
||
.filter(b => bandMatchesGenre(b))
|
||
.filter(b => bandMatchesTheme(b))
|
||
.filter(b => bandMatchesYear(b));
|
||
|
||
if (!items.length) {
|
||
modalBody.innerHTML = `<div style="padding:10px;color:rgba(162,176,194,0.85);">Aucun résultat</div>`;
|
||
} else {
|
||
const sorted = sortBands(items);
|
||
for (const b of sorted.slice(0, 8000)) {
|
||
const div = document.createElement("div");
|
||
div.className = "band";
|
||
const maUrl = b.url || (b.ma_id ? `https://www.metal-archives.com/bands/x/${b.ma_id}` : "#");
|
||
div.innerHTML = `
|
||
<div class="name">
|
||
<span class="sigil"></span>
|
||
<a href="${escapeHtml(maUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(b.name)}</a>
|
||
</div>
|
||
<div class="meta">
|
||
<div><b>${escapeHtml(b.country||"—")}</b> • <b>${escapeHtml(b.status||"—")}</b></div>
|
||
<div><b>Genre:</b> ${escapeHtml(b.genre||"—")}</div>
|
||
<div><b>Lieu:</b> ${escapeHtml(b.location_text||"—")}</div>
|
||
</div>
|
||
`;
|
||
modalBody.appendChild(div);
|
||
}
|
||
}
|
||
|
||
modalBackdrop.classList.add("on");
|
||
modalBackdrop.setAttribute("aria-hidden", "false");
|
||
}
|
||
|
||
async function loadNoLocationBands() {
|
||
try {
|
||
modalBody.innerHTML = "<div style='padding:20px;color:rgba(162,176,194,0.85);'>Chargement...</div>";
|
||
|
||
const j = await apiGet("/api/bands?limit=10000&geocoded=0");
|
||
const items = j.items || [];
|
||
|
||
modalBody.innerHTML = "";
|
||
|
||
if (!items.length) {
|
||
modalBody.innerHTML = `<div style="padding:10px;color:rgba(162,176,194,0.85);">Aucun groupe sans coordonnées</div>`;
|
||
return;
|
||
}
|
||
|
||
for (const x of items.slice(0, 500)) {
|
||
const div = document.createElement("div");
|
||
div.className = "band";
|
||
const maUrl = x.ma_id ? `https://www.metal-archives.com/bands/x/${x.ma_id}` : "#";
|
||
div.innerHTML = `
|
||
<div class="name">
|
||
<span class="sigil"></span>
|
||
<a href="${escapeHtml(maUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(x.name || "Unknown")}</a>
|
||
</div>
|
||
<div class="meta">
|
||
<div><b>${escapeHtml(x.country || "—")}</b> • <b>${escapeHtml(x.status || "—")}</b></div>
|
||
<div><b>Genre:</b> ${escapeHtml(x.genre || "—")}</div>
|
||
<div><b>Lieu:</b> ${escapeHtml(x.location_text || "—")}</div>
|
||
</div>
|
||
`;
|
||
modalBody.appendChild(div);
|
||
}
|
||
|
||
if (items.length > 500) {
|
||
const more = document.createElement("div");
|
||
more.style.cssText = "padding:10px;color:rgba(162,176,194,0.85);text-align:center;";
|
||
more.textContent = `... et ${items.length - 500} autres`;
|
||
modalBody.appendChild(more);
|
||
}
|
||
} catch (e) {
|
||
modalBody.innerHTML = `<div style="padding:10px;color:rgba(162,176,194,0.85);">Erreur: ${escapeHtml(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function closeModal() {
|
||
modalBackdrop.classList.remove("on");
|
||
modalBackdrop.setAttribute("aria-hidden", "true");
|
||
}
|
||
|
||
modalClose?.addEventListener("click", closeModal);
|
||
modalBackdrop?.addEventListener("click", (e) => {
|
||
if (e.target === modalBackdrop) closeModal();
|
||
});
|
||
|
||
// Info modal (legal/FAQ)
|
||
const infoBackdrop = $("infoBackdrop");
|
||
const infoBody = $("infoBody");
|
||
const infoClose = $("infoClose");
|
||
const infoTitle = $("infoTitle");
|
||
|
||
const LEGAL_HTML = `
|
||
<div style="display:flex;flex-direction:column;gap:16px;">
|
||
<div>
|
||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Éditeur du site</b><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">Auteur: Nico</span><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">Statut : particulier</span><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">Contact : <a href="mailto:contact@metalfrom.eu" style="color: rgba(198,26,26,0.85); text-decoration: none;">contact@metalfrom.eu</a></span>
|
||
</div>
|
||
<div>
|
||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Hébergeur</b><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">OVH SAS – 2 rue Kellermann - 59100 Roubaix - France</span>
|
||
</div>
|
||
<div>
|
||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Propriété intellectuelle</b><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">Les contenus (textes, visuels, données agrégées) sont proposés à titre informatif. Les logos et noms de groupes restent la propriété de leurs auteurs. L'ensemble des données provient du site <a href="https://www.metal-archives.com/" target="_blank" rel="noopener noreferrer" style="color: rgba(198,26,26,0.85); text-decoration: none;">https://www.metal-archives.com/</a>, avec l'autorisation des webmasters</span>
|
||
</div>
|
||
<div>
|
||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Données personnelles</b><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">Ce site ne collecte pas d'informations personnelles ni ne dépose de cookies de suivi. Des logs techniques (adresses IP, user agent, horodatage) peuvent être conservés par l'hébergeur à des fins de sécurité et de dépannage.</span>
|
||
</div>
|
||
<div>
|
||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Responsable de publication</b><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">Nico</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
const FAQ_HTML = `
|
||
<div style="display:flex;flex-direction:column;gap:14px;">
|
||
<div>
|
||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Q : D'où viennent les données ?</b><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">R : Metal Archives + enrichissement géographique automatisés. Quelques changements à la main, mais c'est fastidieux.</span>
|
||
</div>
|
||
<div>
|
||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Q : Puis-je corriger une erreur ?</b><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">R : Oui si elle est en rapport avec le site, contacte-moi via l'adresse indiquée dans les mentions légales. Si elle est en rapport avec les données, alors c'est sur metal archives qu'il faut le changer, et lors de la prochaine synchronisation (manuelle) ce sera corrigé (on espère)</span>
|
||
</div>
|
||
<div>
|
||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Q : Pourquoi certains groupes ne sont pas visibles?</b><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">R : Il y a des erreurs inhérentes au géocodage automatique. La correction manuelle des localisation étant fastidieuse, il est inévitable que certaines données soient faussées.</span>
|
||
</div>
|
||
<div>
|
||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Q : Je viens de créer mon groupe sur Metal Archives mais il n'apparaît pas</b><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">R : Pour le moment aucune synchronisation automatique avec Metal Archives n'est implémentée. Si les Webmasters de Metal Archive souhaitent mettre cela en place, je suis évidemment à l'écoute</span>
|
||
</div>
|
||
<div>
|
||
<b style="font-size: 14px; color: rgba(231,226,218,0.95);">Q : Est-ce que le site me traque ou collecte mes données?</b><br/>
|
||
<span style="color: rgba(162,176,194,0.90);">R : La seule forme de traçage effectuée par le site est à des fins d'analyse de trafic, effectuée par GoatCounter (goatcounter.com) pour savoir à peu près d'où vous venez, sur quel matos vous regardez le site et autres petites infos. A ma connaissance GoatCounter ne dépose aucun cookie sur vos machines, et moi non plus. Les appels à l'API de goatcounter sont néanmoins bloqués par les adblocker chez moi, donc aucun souci pour vous si vous souhaitez ne pas participer !</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
function openInfoModal(title, html) {
|
||
if (!infoBackdrop || !infoBody || !infoTitle) return;
|
||
infoTitle.textContent = title;
|
||
infoBody.innerHTML = html;
|
||
infoBackdrop.classList.add("on");
|
||
infoBackdrop.setAttribute("aria-hidden", "false");
|
||
}
|
||
|
||
function closeInfoModal() {
|
||
if (!infoBackdrop) return;
|
||
infoBackdrop.classList.remove("on");
|
||
infoBackdrop.setAttribute("aria-hidden", "true");
|
||
}
|
||
|
||
if (infoClose) infoClose.addEventListener("click", closeInfoModal);
|
||
if (infoBackdrop) {
|
||
infoBackdrop.addEventListener("click", (e) => {
|
||
if (e.target === infoBackdrop) closeInfoModal();
|
||
});
|
||
}
|
||
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") {
|
||
closeModal();
|
||
closeInfoModal();
|
||
}
|
||
});
|
||
|
||
// Sidebar toggle
|
||
function setSidebarCollapsed(collapsed) {
|
||
if (!appEl) return;
|
||
appEl.classList.toggle("sidebar-collapsed", collapsed);
|
||
const toggle = $("sidebarToggle");
|
||
if (toggle) {
|
||
toggle.setAttribute("aria-expanded", String(!collapsed));
|
||
// Update button text
|
||
const label = toggle.querySelector(".label");
|
||
if (label) {
|
||
label.textContent = collapsed ? "Options" : "Fermer";
|
||
}
|
||
}
|
||
// 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);
|
||
}
|
||
|
||
// Legal/FAQ links
|
||
const openLegal = $("openLegal");
|
||
if (openLegal) openLegal.addEventListener("click", () => openInfoModal("Mentions légales", LEGAL_HTML));
|
||
|
||
const openFaq = $("openFaq");
|
||
if (openFaq) openFaq.addEventListener("click", () => openInfoModal("FAQ", FAQ_HTML));
|
||
|
||
// Setup dropdowns
|
||
setupDropdown("countryToggle", "countrySelect");
|
||
setupDropdown("genreToggle", "genreSelect");
|
||
setupDropdown("themeToggle", "themeSelect");
|
||
|
||
// --- Buttons / controls ---
|
||
$("btnNoLocation")?.addEventListener("click", () => {
|
||
modalTitle.textContent = "Groupes sans coordonnées";
|
||
modalBackdrop.classList.add("on");
|
||
modalBackdrop.setAttribute("aria-hidden", "false");
|
||
loadNoLocationBands();
|
||
});
|
||
|
||
// Search with suggestions
|
||
const searchInput = $("q");
|
||
const suggestionsEl = $("searchSuggestions");
|
||
|
||
function showSearchSuggestions(query) {
|
||
if (!query || query.length < 2 || !allBands.length) {
|
||
suggestionsEl?.classList.remove("visible");
|
||
return;
|
||
}
|
||
|
||
const matches = allBands
|
||
.filter(b => bandMatchesQuery(b, query))
|
||
.slice(0, 8); // Show max 8 suggestions
|
||
|
||
if (!matches.length) {
|
||
suggestionsEl?.classList.remove("visible");
|
||
return;
|
||
}
|
||
|
||
if (suggestionsEl) {
|
||
suggestionsEl.innerHTML = matches.map(b => {
|
||
const statusText = b.status || "Unknown";
|
||
const yearText = Number.isFinite(b.formed_year) ? b.formed_year : "—";
|
||
return `
|
||
<div class="suggestion-item" data-id="${b.ma_id}">
|
||
<div class="suggestion-name">${escapeHtml(b.name)}</div>
|
||
<div class="suggestion-meta">
|
||
${escapeHtml(b.country || "—")} • ${escapeHtml(statusText)} • ${yearText} • ${escapeHtml(b.genre || "—")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join("");
|
||
|
||
// Add click handlers
|
||
suggestionsEl.querySelectorAll(".suggestion-item").forEach(el => {
|
||
el.addEventListener("click", () => {
|
||
const bandId = Number(el.dataset.id);
|
||
const band = allBands.find(b => b.ma_id === bandId);
|
||
if (band && Number.isFinite(band.lat) && Number.isFinite(band.lon)) {
|
||
const marker = markersById.get(bandId);
|
||
if (marker && marker.getLatLng) {
|
||
map.setView(marker.getLatLng(), 10, { animate: true });
|
||
marker.openPopup();
|
||
setActive(bandId);
|
||
} else {
|
||
map.setView([band.lat, band.lon], 12, { animate: true });
|
||
}
|
||
}
|
||
suggestionsEl.classList.remove("visible");
|
||
if (searchInput) searchInput.value = band?.name || "";
|
||
});
|
||
});
|
||
|
||
suggestionsEl.classList.add("visible");
|
||
}
|
||
}
|
||
|
||
const debouncedSearch = debounce(() => {
|
||
applyFilters();
|
||
}, 400); // 400ms delay
|
||
|
||
const debouncedSuggestions = debounce((query) => {
|
||
showSearchSuggestions(query);
|
||
}, 200); // 200ms delay for suggestions
|
||
|
||
searchInput?.addEventListener("input", (e) => {
|
||
const query = e.target.value.toLowerCase().trim();
|
||
debouncedSuggestions(query);
|
||
debouncedSearch();
|
||
});
|
||
|
||
searchInput?.addEventListener("focus", () => {
|
||
const query = searchInput.value.toLowerCase().trim();
|
||
if (query.length >= 2) {
|
||
showSearchSuggestions(query);
|
||
}
|
||
});
|
||
|
||
// Close suggestions when clicking outside
|
||
document.addEventListener("click", (e) => {
|
||
if (!searchInput?.contains(e.target) && !suggestionsEl?.contains(e.target)) {
|
||
suggestionsEl?.classList.remove("visible");
|
||
}
|
||
});
|
||
|
||
$("sortSelect")?.addEventListener("change", () => {
|
||
// Re-render list with current data
|
||
const visibleBands = currentViewportData.flatMap(c => c.sample_bands || [c])
|
||
.map(b => typeof b === 'string' ? JSON.parse(b) : b)
|
||
.filter(Boolean);
|
||
renderList(visibleBands.slice(0, 200));
|
||
});
|
||
|
||
const heatToggle = $("toggleHeat");
|
||
if (heatToggle) {
|
||
heatToggle.addEventListener("change", () => setMode(heatToggle.checked ? "heat" : "clusters"));
|
||
}
|
||
|
||
$("btnReset")?.addEventListener("click", () => {
|
||
if (filtered.length) {
|
||
const pts = filtered.map(b => L.latLng(b.lat, b.lon));
|
||
const bounds = L.latLngBounds(pts);
|
||
if (bounds.isValid()) map.fitBounds(bounds.pad(0.12));
|
||
} else if (geocodedBands.length) {
|
||
const pts = geocodedBands.map(b => L.latLng(b.lat, b.lon));
|
||
const bounds = L.latLngBounds(pts);
|
||
if (bounds.isValid()) map.fitBounds(bounds.pad(0.12));
|
||
} else {
|
||
map.setView([50.2, 10.2], 4, { animate: true });
|
||
}
|
||
});
|
||
|
||
map.on("click", () => {
|
||
const visibleBands = currentViewportData.flatMap(c => c.sample_bands || [c])
|
||
.map(b => typeof b === 'string' ? JSON.parse(b) : b)
|
||
.filter(Boolean);
|
||
renderList(visibleBands.slice(0, 200));
|
||
});
|
||
|
||
// --- Boot ---
|
||
function computeFacetCounts(arr, getKey) {
|
||
const m = new Map();
|
||
for (const b of arr) {
|
||
const val = getKey(b);
|
||
const k = val ? norm(val) : "Unknown";
|
||
m.set(k, (m.get(k) || 0) + 1);
|
||
}
|
||
return [...m.entries()]
|
||
.map(([key, count]) => ({ key, count }))
|
||
.sort((a,b) => b.count - a.count);
|
||
}
|
||
|
||
function computeTokenFacetCounts(arr, getTokens) {
|
||
const m = new Map();
|
||
for (const b of arr) {
|
||
const tokens = getTokens(b) || [];
|
||
if (!tokens.length) {
|
||
// Count bands without tokens as "Unknown"
|
||
m.set("Unknown", (m.get("Unknown") || 0) + 1);
|
||
} else {
|
||
for (const t of tokens) {
|
||
const k = norm(t) || "Unknown";
|
||
m.set(k, (m.get(k) || 0) + 1);
|
||
}
|
||
}
|
||
}
|
||
return [...m.entries()]
|
||
.map(([key, count]) => ({ key, count }))
|
||
.sort((a,b) => b.count - a.count);
|
||
}
|
||
|
||
/**
|
||
* Build timeline slider from year range (from facets or computed)
|
||
*/
|
||
function buildTimelineFromRange() {
|
||
const slider = $("yearSlider");
|
||
slider.innerHTML = "";
|
||
|
||
if (!yearRange.min || !yearRange.max) {
|
||
$("yearMin").textContent = "—";
|
||
$("yearMax").textContent = "—";
|
||
$("yearHint").textContent = "données manquantes";
|
||
slider.innerHTML = `<div class="timeline-empty">Données d'année indisponibles</div>`;
|
||
return;
|
||
}
|
||
|
||
const { min, max } = yearRange;
|
||
|
||
console.log(`Timeline: ${min} - ${max}`);
|
||
|
||
$("yearMin").textContent = String(min);
|
||
$("yearMax").textContent = String(max);
|
||
$("yearHint").textContent = "toutes";
|
||
|
||
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) ? "toutes" : `${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();
|
||
}
|