1/** 2 * EXPLORER â carte Leaflet + liste filtrée + toggle mobile. 3 * / EXPLORER â Leaflet map + filtered list + mobile toggle. 4 * 5 * LOCALISATION : seo/static/seo/explorer.js 6 * UTILISà PAR : public /explorer/ + tenant /federation/ (même code, même comportement). 7 * 8 * USAGE : 9 * 1. Inclure leaflet.css + MarkerCluster.css + MarkerCluster.Default.css en <head> 10 * 2. Inclure leaflet.js + markercluster.js + explorer.js en fin de <body> 11 * 3. Rendre dans le template : #explorer-root (avec data-*), #explorer-list, 12 * #explorer-map, <script id="explorer-data" type="application/json"> 13 * 14 * DATA FLOW : 15 * 1. init() lit #explorer-data (JSON) + #explorer-root (data-i18n-*, data-current-tenant-uuid) 16 * 2. bindControls() attache 1 listener delegated sur #explorer-list + 1 sur #explorer-pills 17 * 3. applyFilters() filtre data â renderList() + updateMarkers() 18 * 4. Click carte/liste â focusOnLieu() : zoom + popup + accordéon 19 * 20 * STATE (encapsulé, jamais sur window) : 21 * - state.data : { points: [...], tenants: [...] } (immutable après init) 22 * points = 1 entry per PostalAddress active (markers carte) 23 * tenants = 1 entry per alive tenant (cards liste) 24 * - state.filters : { text: '', category: 'all' } (muable) 25 * - state.map, state.markers (indexes par pa_id), state.markerCluster 26 * 27 * DEPENDANCES : 28 * - Leaflet 1.9.x (vendoré dans /static/seo/vendor/leaflet/) 29 * - Leaflet.markercluster 1.5.x (idem) 30 * - Bootstrap 5.3 icons (pour les <i class="bi-*">) 31 * 32 * TEARDOWN : 33 * destroy() expose un cleanup map + listeners pour swap HTMX/SPA futur. 34 */ 35(function () { 36 'use strict'; 37 38 // ============================================================ 39 // CONFIG â lu depuis #explorer-root data-* 40 // / CONFIG â read from #explorer-root data-* 41 // ============================================================ 42 const config = { 43 currentTenantUuid: '', 44 i18n: { 45 empty: 'Aucun résultat trouvé.', 46 visit: 'Visiter le lieu', 47 lieu: 'Lieu', 48 event: 'Ãvénement', 49 current: 'Vous êtes ici', 50 lieuSingular: 'lieu', 51 lieuPlural: 'lieux', 52 eventSingular: 'événement', 53 eventPlural: 'événements', 54 nextEvents: 'Prochains événements :', 55 more: 'autre(s)', 56 list: 'Liste', 57 map: 'Carte', 58 clearTag: 'Effacer le filtre', 59 tagEmpty: 'Aucun événement « {tag} » dans la zone visible.', 60 moreTags: '+ {count} tags', 61 }, 62 }; 63 64 // Niveau de zoom applique quand on met un marker au centre de la carte (clic sur 65 // une carte de lieu ou d'evenement dans la liste). Assez proche pour lire la rue. 66 // / Zoom level applied when a marker is centered (click on a venue or event card). 67 const ZOOM_FOCUS_MARKER = 15; 68 69 // ============================================================ 70 // STATE â encapsulé, jamais sur window 71 // / STATE â encapsulated, never on window 72 // ============================================================ 73 const state = { 74 data: null, 75 filters: { text: '', view: 'lieu', tag: null }, 76 map: null, 77 markers: {}, 78 markerCluster: null, 79 currentView: 'list', 80 mapInitialized: false, 81 }; 82 83 // ============================================================ 84 // DOM â references mises en cache au init 85 // / DOM â references cached at init 86 // ============================================================ 87 const dom = { 88 root: null, 89 list: null, 90 map: null, 91 mapLoading: null, 92 search: null, 93 pills: null, 94 counter: null, 95 fab: null, 96 tags: null, // conteneur chips tags / tag chips container 97 }; 98 99 // ============================================================ 100 // HELPERS 101 // ============================================================ 102 103 function escapeHtml(text) { 104 if (!text) return ''; 105 const div = document.createElement('div'); 106 div.appendChild(document.createTextNode(text)); 107 return div.innerHTML; 108 } 109 110 function pluralize(count, singular, plural) { 111 return count + ' ' + (count > 1 ? plural : singular); 112 } 113 114 function formatShortDate(isoString) { 115 if (!isoString) return ''; 116 try { 117 return new Date(isoString).toLocaleDateString('fr-FR', { 118 day: 'numeric', month: 'short', 119 }); 120 } catch (e) { 121 return ''; 122 } 123 } 124 125 function readConfigFromDom() { 126 if (!dom.root) return; 127 const ds = dom.root.dataset; 128 config.currentTenantUuid = ds.currentTenantUuid || ''; 129 // Cle MapTiler (optionnelle) : si presente, on utilise MapTiler ; sinon HOT. 130 // / MapTiler key (optional): if present, use MapTiler; otherwise HOT. 131 config.maptilerKey = ds.maptilerKey || ''; 132 // Surcharge i18n depuis data-i18n-* / Override i18n from data-i18n-* 133 if (ds.i18nEmpty) config.i18n.empty = ds.i18nEmpty; 134 if (ds.i18nVisit) config.i18n.visit = ds.i18nVisit; 135 if (ds.i18nLieu) config.i18n.lieu = ds.i18nLieu; 136 if (ds.i18nEvent) config.i18n.event = ds.i18nEvent; 137 if (ds.i18nCurrent) config.i18n.current = ds.i18nCurrent; 138 if (ds.i18nLieuSingular) config.i18n.lieuSingular = ds.i18nLieuSingular; 139 if (ds.i18nLieuPlural) config.i18n.lieuPlural = ds.i18nLieuPlural; 140 if (ds.i18nEventSingular) config.i18n.eventSingular = ds.i18nEventSingular; 141 if (ds.i18nEventPlural) config.i18n.eventPlural = ds.i18nEventPlural; 142 if (ds.i18nNextEvents) config.i18n.nextEvents = ds.i18nNextEvents; 143 if (ds.i18nMore) config.i18n.more = ds.i18nMore; 144 if (ds.i18nList) config.i18n.list = ds.i18nList; 145 if (ds.i18nMap) config.i18n.map = ds.i18nMap; 146 if (ds.i18nClearTag) config.i18n.clearTag = ds.i18nClearTag; 147 if (ds.i18nTagEmpty) config.i18n.tagEmpty = ds.i18nTagEmpty; 148 if (ds.i18nMoreTags) config.i18n.moreTags = ds.i18nMoreTags; 149 } 150 151 // ============================================================ 152 // INIT â point d'entree principal 153 // / INIT â main entry point 154 // ============================================================ 155
156 function init() { 157 // Cacher les references DOM critiques / Cache critical DOM refs 158 dom.root = document.getElementById('explorer-root'); 159 dom.list = document.getElementById('explorer-list'); 160 dom.map = document.getElementById('explorer-map'); 161 dom.mapLoading = document.getElementById('explorer-map-loading'); 162 dom.search = document.getElementById('explorer-search'); 163 dom.pills = document.getElementById('explorer-pills'); 164 dom.counter = document.getElementById('explorer-counter'); 165 dom.fab = document.getElementById('explorer-fab'); 166 dom.tags = document.getElementById('explorer-tags'); 167 168 // Garde-fou : si les elements essentiels manquent, abandonner 169 // / Guard: if essential elements are missing, abort 170 if (!dom.root || !dom.list || !dom.map) { 171 console.warn('explorer: required DOM elements missing, aborting init'); 172 return; 173 } 174 175 readConfigFromDom(); 176 state.data = loadData(); 177 if (!state.data) { 178 renderEmptyState(); 179 return; 180 } 181 182 bootFromURL(); // pré-remplit state.filters depuis l'URL / pre-fills state.filters from URL 183 184 bindControls(); 185 applyFilters(); 186 187 if (window.innerWidth >= 992) { 188 initMap(); 189 } else { 190 // Mobile : la carte est initialisee au 1er toggle vers "Carte" 191 // / Mobile: map initialized on first toggle to "Carte" 192 hideMapLoadingSpinner(); 193 } 194 } 195 196 function destroy() { 197 // Cleanup map + listeners pour swap HTMX/SPA futur 198 // / Cleanup map + listeners for HTMX/SPA swap (future-proof) 199 if (state.map) { 200 state.map.remove(); 201 state.map = null; 202 } 203 state.markers = {}; 204 state.markerCluster = null; 205 state.mapInitialized = false; 206 } 207 208 // ============================================================ 209 // DATA â chargement + filtrage 210 // / DATA â loading + filtering 211 // ============================================================ 212 213 function loadData() { 214 const el = document.getElementById('explorer-data'); 215 if (!el) { 216 console.warn('explorer: #explorer-data element missing'); 217 return null; 218 } 219 try { 220 return JSON.parse(el.textContent); 221 } catch (e) { 222 console.error('explorer: invalid JSON in #explorer-data', e); 223 return null; 224 } 225 } 226 227 function matchesText(item) { 228 if (!state.filters.text) return true; 229 const q = state.filters.text; 230 const fields = [item.name, item.locality, item.short_description, item.lieu_name]; 231 for (let i = 0; i < fields.length; i++) { 232 if ((fields[i] || '').toLowerCase().indexOf(q) !== -1) return true; 233 } 234 return false; 235 } 236 237 // ============================================================ 238 // FILTERS â helpers PA-level + event-level 239 // / FILTERS â PA-level + event-level helpers 240 // ============================================================ 241 242 function paMatchesText(point) { 243 // Match sur nom PA, nom du tenant, OU nom d'au moins 1 event futur. 244 // / Match on PA name, tenant name, OR at least 1 future event name. 245 if (!state.filters.text) return true; 246 const q = state.filters.text; 247 const champs = [ 248 point.pa_name, 249 point.tenant_organisation, 250 point.address_display, 251 ]; 252 for (let i = 0; i < champs.length; i++) { 253 if ((champs[i] || '').toLowerCase().indexOf(q) !== -1) return true; 254 } 255 const events = point.events_futurs || []; 256 for (let j = 0; j < events.length; j++) { 257 if ((events[j].name || '').toLowerCase().indexOf(q) !== -1) return true; 258 } 259 return false; 260 } 261 262 function paMatchesTag(point) { 263 // Si pas de tag actif, tout passe. Sinon, au moins 1 event de la PA 264 // doit porter le tag. 265 // / If no active tag, pass. Otherwise, at least 1 event must carry it. 266 if (!state.filters.tag) return true; 267 const events = point.events_futurs || []; 268 for (let i = 0; i < events.length; i++) { 269 const tags = events[i].tags || []; 270 for (let j = 0; j < tags.length; j++) { 271 if (tags[j].slug === state.filters.tag) return true; 272 } 273 } 274 return false;
275 } 276 277 function filterPAsByTextAndTag(points) { 278 const result = []; 279 for (let i = 0; i < points.length; i++) { 280 const point = points[i]; 281 if (paMatchesText(point) && paMatchesTag(point)) { 282 result.push(point); 283 } 284 } 285 return result; 286 } 287 288 function collectVisibleEvents(paVisibles) { 289 // Aplatit les events futurs de toutes les PA visibles. 290 // Si tag actif, ne garde que les events qui portent ce tag. 291 // Si text actif, ne garde que les events dont le nom matche (ou la PA matche). 292 // / Flatten future events of all visible PAs. 293 // Filter by tag and text accordingly. 294 const events = []; 295 const q = state.filters.text; 296 const tagSlug = state.filters.tag; 297 for (let i = 0; i < paVisibles.length; i++) { 298 const point = paVisibles[i]; 299 const evList = point.events_futurs || []; 300 for (let j = 0; j < evList.length; j++) { 301 const ev = evList[j]; 302 if (tagSlug) { 303 const tags = ev.tags || []; 304 let porteTag = false; 305 for (let k = 0; k < tags.length; k++) { 306 if (tags[k].slug === tagSlug) { porteTag = true; break; } 307 } 308 if (!porteTag) continue; 309 } 310 if (q) { 311 // Conserver si nom d'event matche OU nom de PA/tenant matche 312 // (la PA est déjà filtrée, mais on filtre encore en mode event). 313 const evMatch = (ev.name || '').toLowerCase().indexOf(q) !== -1; 314 const paMatch = paMatchesText(point); 315 if (!evMatch && !paMatch) continue; 316 } 317 events.push({ 318 uuid: ev.uuid, 319 name: ev.name, 320 datetime_iso: ev.datetime_iso, 321 slug: ev.slug, 322 tags: ev.tags || [], 323 image_url: ev.image_url || '', 324 pa_id: point.pa_id, 325 pa_name: point.pa_name, 326 address_display: point.address_display, 327 tenant_id: point.tenant_id, 328 tenant_organisation: point.tenant_organisation, 329 tenant_domain: point.tenant_domain, 330 tenant_logo_url: point.tenant_logo_url, 331 }); 332 } 333 } 334 events.sort(function (a, b) { 335 return (a.datetime_iso || '').localeCompare(b.datetime_iso || ''); 336 }); 337 return events; 338 } 339 340 function buildLieuCardsFromPAs(paVisibles) { 341 // Regroupe les PA visibles par tenant_id. Renvoie liste de "lieu cards" 342 // au meme format que state.data.tenants, mais enrichi avec eventsAggregated. 343 // / Group visible PAs by tenant_id. Returns "lieu cards" matching 344 // state.data.tenants format, enriched with eventsAggregated. 345 const parTenant = {}; 346 for (let i = 0; i < paVisibles.length; i++) { 347 const point = paVisibles[i]; 348 const tid = point.tenant_id; 349 if (!parTenant[tid]) parTenant[tid] = { pas: [], events: [] }; 350 parTenant[tid].pas.push(point); 351 const evList = point.events_futurs || []; 352 for (let j = 0; j < evList.length; j++) { 353 parTenant[tid].events.push(evList[j]); 354 } 355 } 356 // Index des tenants depuis state.data.tenants pour récupérer infos tenant-level 357 // / Tenant index from state.data.tenants for tenant-level info 358 const tenantsById = {}; 359 for (let k = 0; k < (state.data.tenants || []).length; k++) { 360 const t = state.data.tenants[k]; 361 tenantsById[t.tenant_id] = t; 362 } 363 const cards = []; 364 for (const tid in parTenant) { 365 const t = tenantsById[tid]; 366 if (!t) continue; 367 // Tri events agrégés par date asc, dédoublonné par uuid 368 // / Sort aggregated events asc, dedup by uuid 369 const seen = {}; 370 const eventsUniques = []; 371 for (let m = 0; m < parTenant[tid].events.length; m++) { 372 const ev = parTenant[tid].events[m]; 373 if (!seen[ev.uuid]) { 374 seen[ev.uuid] = true; 375 eventsUniques.push(ev); 376 } 377 } 378 eventsUniques.sort(function (a, b) { 379 return (a.datetime_iso || '').localeCompare(b.datetime_iso || ''); 380 }); 381 cards.push({ 382 tenant_id: t.tenant_id, 383 name: t.name, 384 domain: t.domain, 385 slug: t.slug || '', 386 short_description: t.short_description, 387 locality: t.locality, 388 country: t.country, 389 logo_url: t.logo_url, 390 image_url: t.image_url || '', 391 events: eventsUniques, 392 }); 393 } 394 return cards; 395 } 396 397 function applyFilters() { 398 if (!state.data) return; 399 400 // 1. Filtre les PA selon text + tag (independamment du mode view).
401 // / Filter PAs by text + tag (independent of view mode). 402 const paVisibles = filterPAsByTextAndTag(state.data.points || []); 403 404 // 2. Construit la liste affichee selon le mode pill. 405 // / Build displayed list according to pill mode. 406 let lieuxCards = []; 407 let eventCards = []; 408 if (state.filters.view === 'lieu') { 409 lieuxCards = buildLieuCardsFromPAs(paVisibles); 410 } else { 411 eventCards = collectVisibleEvents(paVisibles); 412 } 413 414 renderList(lieuxCards, eventCards); 415 updateCounters(lieuxCards.length, eventCards.length || countEventsInPAs(paVisibles)); 416 417 // 3. Markers visibles : la source depend du mode. 418 // - Mode "lieu" : 1 marker par PA visible (filtre text+tag). 419 // - Mode "evenement" : seulement les lieux ayant AU MOINS un event 420 // visible (eventCards porte le pa_id de chaque event). Ainsi les 421 // puces se synchronisent avec la liste d'evenements : un lieu sans 422 // event a venir disparait de la carte en mode evenement. 423 // / Visible markers: source depends on the mode. 424 // - "lieu" mode: 1 marker per visible PA (text+tag filter). 425 // - "event" mode: only venues with at least one visible event, so the 426 // markers stay in sync with the event list. 427 const visiblePaIds = {}; 428 if (state.filters.view === 'event') { 429 for (let i = 0; i < eventCards.length; i++) { 430 visiblePaIds[eventCards[i].pa_id] = true; 431 } 432 } else { 433 for (let i = 0; i < paVisibles.length; i++) { 434 visiblePaIds[paVisibles[i].pa_id] = true; 435 } 436 } 437 if (state.mapInitialized) { 438 updateMapMarkersByPA(visiblePaIds); 439 } 440 441 // 4. Recalculer les chips (Task 8 ajoutera updateChips). 442 // / Recompute chips (Task 8 will add updateChips). 443 if (typeof updateChips === 'function') { 444 updateChips(paVisibles); 445 } 446 447 // 5. Synchroniser l'URL (Task 9 ajoutera syncURL). 448 // / Sync URL (Task 9 will add syncURL). 449 if (typeof syncURL === 'function') { 450 syncURL(); 451 } 452 } 453 454 function countEventsInPAs(paVisibles) { 455 let n = 0; 456 for (let i = 0; i < paVisibles.length; i++) { 457 n += ((paVisibles[i].events_futurs || []).length); 458 } 459 return n; 460 } 461 462 function updateCounters(lieuxN, eventsN) { 463 if (!dom.counter) return; 464 const parts = [ 465 pluralize(lieuxN, config.i18n.lieuSingular, config.i18n.lieuPlural), 466 pluralize(eventsN, config.i18n.eventSingular, config.i18n.eventPlural), 467 ]; 468 dom.counter.textContent = parts.join(' · '); 469 } 470 471 // ============================================================ 472 // CONTROLLERS â bind inputs + event delegation 473 // / CONTROLLERS â input bindings + event delegation 474 // ============================================================ 475
476 function bindControls() { 477 bindSearch(); 478 bindPills(); 479 bindFAB(); 480 bindListDelegation(); 481 bindTagChips(); 482 } 483 484 function bindSearch() { 485 if (!dom.search) return; 486 // Priorité au state (déjà rempli par bootFromURL), sinon valeur du DOM. 487 // / state takes priority (filled by bootFromURL), otherwise DOM value. 488 if (state.filters.text) { 489 dom.search.value = state.filters.text; 490 } else { 491 const initialValue = dom.search.value.trim().toLowerCase(); 492 if (initialValue) state.filters.text = initialValue; 493 } 494 495 let timer = null; 496 dom.search.addEventListener('input', function () { 497 clearTimeout(timer); 498 timer = setTimeout(function () { 499 state.filters.text = dom.search.value.trim().toLowerCase(); 500 applyFilters(); 501 }, 300); 502 }); 503 } 504 505 function bindPills() { 506 if (!dom.pills) return; 507 // Synchroniser la pill active avec state.filters.view (depuis URL). 508 // / Sync active pill with state.filters.view (from URL). 509 const allPills = dom.pills.querySelectorAll('.explorer-pill'); 510 for (let i = 0; i < allPills.length; i++) { 511 allPills[i].classList.toggle( 512 'active', 513 allPills[i].getAttribute('data-category') === state.filters.view 514 ); 515 } 516 dom.pills.addEventListener('click', function (ev) { 517 const pill = ev.target.closest('.explorer-pill'); 518 if (!pill) return; 519 const allPills = dom.pills.querySelectorAll('.explorer-pill'); 520 allPills.forEach(function (p) { p.classList.remove('active'); }); 521 pill.classList.add('active'); 522 state.filters.view = pill.getAttribute('data-category') || 'lieu'; 523 applyFilters(); 524 }); 525 } 526 527 function bindFAB() { 528 if (!dom.fab) return; 529 dom.fab.addEventListener('click', toggleView); 530 } 531 532 function bindTagChips() { 533 if (!dom.tags) return; 534 dom.tags.addEventListener('click', function (ev) { 535 const moreBtn = ev.target.closest('.explorer-tag-chip-more'); 536 if (moreBtn) { 537 const rest = dom.tags.querySelector('.explorer-tag-chip-rest'); 538 if (rest) { 539 const willOpen = rest.hidden; 540 rest.hidden = !willOpen; 541 moreBtn.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); 542 } 543 return; 544 } 545 const chip = ev.target.closest('.explorer-tag-chip'); 546 if (!chip) return; 547 const clickedSlug = chip.getAttribute('data-tag-slug'); 548 // Toggle exclusif : si déjà actif -> désactiver, sinon -> activer (et désactiver les autres). 549 // / Exclusive toggle: if active -> deactivate, otherwise activate (and deactivate others). 550 if (state.filters.tag === clickedSlug) { 551 state.filters.tag = null; 552 } else { 553 state.filters.tag = clickedSlug; 554 } 555 applyFilters(); 556 }); 557 } 558 559 /** 560 * Event delegation sur #explorer-list : un seul listener gere 561 * tous les clicks sur cards lieu, cards event, toggles d'accordeon. 562 * / Event delegation on #explorer-list: a single listener handles 563 * all clicks on lieu cards, event cards, accordion toggles. 564 */ 565 function bindListDelegation() { 566 dom.list.addEventListener('click', function (ev) { 567 // Bouton "Effacer le filtre tag" (empty state) 568 // / "Clear tag filter" button (empty state) 569 const clearBtn = ev.target.closest('#explorer-clear-tag'); 570 if (clearBtn) { 571 state.filters.tag = null; 572 applyFilters(); 573 return; 574 } 575 // Toggle accordeon (a tester en premier â plus specifique)
576 // / Accordion toggle (check first â more specific) 577 const toggle = ev.target.closest('.explorer-accordion-toggle'); 578 if (toggle) { 579 ev.stopPropagation(); 580 handleAccordionToggle(toggle); 581 return; 582 } 583 // Click sur une card lieu (data-lieu-id) : focus carte sur le lieu. 584 // / Click on a lieu card: focus map on the venue. 585 const card = ev.target.closest('[data-lieu-id]'); 586 if (card) { 587 const lieuId = card.getAttribute('data-lieu-id'); 588 if (lieuId) focusOnLieu(lieuId); 589 return; 590 } 591 // Click sur une card event : focus carte sur l'adresse (PA) de l'event. 592 // / Click on an event card: focus map on the event's address (PA). 593 const eventCard = ev.target.closest('.explorer-card--event[data-pa-id]'); 594 if (eventCard) { 595 const paId = eventCard.getAttribute('data-pa-id'); 596 const tenantId = eventCard.getAttribute('data-tenant-id'); 597 if (paId) focusOnPA(paId, tenantId); 598 } 599 }); 600 601 // Hover desktop : highlight pin sur carte 602 // / Desktop hover: highlight pin on map 603 if (window.innerWidth >= 992) { 604 dom.list.addEventListener('mouseover', function (ev) { 605 const card = ev.target.closest('[data-lieu-id]'); 606 if (card) highlightPinClass(card.getAttribute('data-lieu-id'), true); 607 }); 608 dom.list.addEventListener('mouseout', function (ev) { 609 const card = ev.target.closest('[data-lieu-id]'); 610 if (card) highlightPinClass(card.getAttribute('data-lieu-id'), false); 611 }); 612 } 613 } 614 615 // ============================================================ 616 // RENDER LIST â DOM cards (templating cote JS, encapsule) 617 // / RENDER LIST â DOM cards (JS templating, encapsulated) 618 // ============================================================ 619 620 function renderList(lieux, events) { 621 if (!dom.list) return; 622 let html = ''; 623 for (let i = 0; i < lieux.length; i++) html += buildLieuCard(lieux[i]); 624 for (let j = 0; j < events.length; j++) html += buildEventCard(events[j]); 625 if (!html) { 626 html = buildEmptyStateHtml(); 627 } 628 dom.list.innerHTML = html; 629 } 630 631 function buildEmptyStateHtml() { 632 // Si tag actif et 0 résultat : message + lien "Effacer le filtre". 633 // / If tag is active and 0 results: message + "Clear filter" link. 634 if (state.filters.tag) { 635 const activeTagName = findTagNameBySlug(state.filters.tag) || state.filters.tag; 636 const msg = config.i18n.tagEmpty.replace('{tag}', activeTagName); 637 return '<div class="explorer-empty-state" data-testid="explorer-empty-state">' 638 + '<p class="text-muted text-center py-3">' + escapeHtml(msg) + '</p>' 639 + '<p class="text-center">' 640 + '<button type="button" class="btn btn-link"' 641 + ' id="explorer-clear-tag" data-testid="explorer-clear-tag">' 642 + escapeHtml(config.i18n.clearTag) + '</button></p></div>'; 643 } 644 return '<p class="text-muted text-center py-4">' + escapeHtml(config.i18n.empty) + '</p>'; 645 } 646 647 function findTagNameBySlug(slug) { 648 // Cherche dans les events de state.data.points. 649 // / Search in events of state.data.points. 650 for (let i = 0; i < (state.data.points || []).length; i++) { 651 const evs = state.data.points[i].events_futurs || []; 652 for (let j = 0; j < evs.length; j++) { 653 const tags = evs[j].tags || []; 654 for (let k = 0; k < tags.length; k++) { 655 if (tags[k].slug === slug) return tags[k].name; 656 } 657 } 658 } 659 return null; 660 } 661 662 function buildLieuCard(lieu) { 663 const tenantId = escapeHtml(lieu.tenant_id); 664 const domain = lieu.domain || ''; 665 const href = domain ? 'https://' + domain + '/' : '#'; 666 const isCurrent = lieu.tenant_id === config.currentTenantUuid; 667 668 // Logo en priorite, sinon image principale du lieu, sinon emoji. 669 // / Logo first, then venue main image, then emoji fallback. 670 const lieuImgSrc = lieu.logo_url || lieu.image_url || ''; 671 const logo = lieuImgSrc 672 ? '<img src="' + escapeHtml(lieuImgSrc) + '" alt="" class="explorer-card-icon lieu" style="object-fit:contain;padding:4px;">' 673 : '<div class="explorer-card-icon lieu">\u{1F3DB}</div>'; 674 675 const meta = lieu.locality 676 ? '<div class="explorer-card-meta">\u{1F4CD} ' + escapeHtml(lieu.locality) 677 + (lieu.country ? ', ' + escapeHtml(lieu.country) : '') + '</div>' 678 : ''; 679 const desc = lieu.short_description 680 ? '<div class="explorer-card-desc">' + escapeHtml(lieu.short_description) + '</div>' 681 : ''; 682 const currentBadge = isCurrent 683 ? ' <span class="explorer-badge explorer-badge--current">' + escapeHtml(config.i18n.current) + '</span>' 684 : ''; 685 686 return '' 687 + '<div class="explorer-card explorer-card--lieu' + (isCurrent ? ' explorer-card--current' : '') + '"' 688 + ' data-lieu-id="' + tenantId + '" data-type="lieu"' 689 + ' data-testid="explorer-card-lieu-' + tenantId + '">' 690 + '<div class="explorer-card-focus" role="button" tabindex="0">' 691 + logo 692 + '<div class="explorer-card-body">' 693 + '<div class="explorer-card-header">' 694 + '<h3 class="explorer-card-title">' + escapeHtml(lieu.name) + '</h3>' 695 + '<span class="explorer-badge lieu">' + escapeHtml(config.i18n.lieu) + '</span>' 696 + currentBadge 697 + '</div>' 698 + meta 699 + desc 700 + '</div>' 701 + '</div>' 702 + buildLieuAccordion(lieu, domain) 703 + '<div class="explorer-card-footer">' 704 + '<a href="' + escapeHtml(href) + '" target="_blank" rel="noopener" class="explorer-card-link">' 705 + escapeHtml(config.i18n.visit) + ' â</a>' 706 + '</div>' 707 + '</div>'; 708 } 709 710 function buildLieuAccordion(lieu, domain) { 711 // Source : lieu.events injecté par buildLieuCardsFromPAs (Task 6). 712 // Forme : [{uuid, name, datetime_iso, slug, tags}, ...] 713 // / Source: lieu.events injected by buildLieuCardsFromPAs (Task 6). 714 const events = lieu.events || []; 715 if (events.length === 0) return ''; 716 const label = pluralize(events.length, config.i18n.eventSingular, config.i18n.eventPlural); 717 let items = ''; 718 for (let i = 0; i < events.length; i++) { 719 const ev = events[i]; 720 const evHref = domain && ev.slug ? 'https://' + domain + '/event/' + ev.slug + '/' : '#'; 721 const dateLabel = ev.datetime_iso ? formatShortDate(ev.datetime_iso) : ''; 722 const accIcon = ev.image_url 723 ? '<img src="' + escapeHtml(ev.image_url) + '" alt="" class="explorer-accordion-icon" style="width:28px;height:28px;border-radius:4px;object-fit:cover;">' 724 : '<span class="explorer-accordion-icon">\u{1F3B6}</span>'; 725 items += '' 726 + '<a class="explorer-accordion-item" href="' + escapeHtml(evHref) + '" target="_blank" rel="noopener">' 727 + accIcon 728 + '<span class="explorer-accordion-name">' + escapeHtml(ev.name) + '</span>' 729 + (dateLabel ? '<span class="explorer-accordion-date">' + escapeHtml(dateLabel) + '</span>' : '') 730 + '</a>'; 731 } 732 return '' 733 + '<div class="explorer-accordion">' 734 + '<button class="explorer-accordion-toggle" type="button">' 735 + '<span>' + escapeHtml(label) + '</span>' 736 + '<i class="bi bi-chevron-down explorer-accordion-chevron" aria-hidden="true"></i>' 737 + '</button>' 738 + '<div class="explorer-accordion-panel"><div class="explorer-accordion-panel-inner">' + items + '</div></div>' 739 + '</div>'; 740 } 741 742 function buildEventCard(event) { 743 const tenantId = escapeHtml(event.tenant_id || ''); 744 const evUrl = (event.tenant_domain && event.slug) 745 ? 'https://' + event.tenant_domain + '/event/' + event.slug + '/' 746 : '#'; 747 const datePart = event.datetime_iso 748 ? '\u{1F4C5} ' + escapeHtml(formatShortDate(event.datetime_iso)) 749 : ''; 750 const lieuPart = event.pa_name ? ' · \u{1F4CD} ' + escapeHtml(event.pa_name) : ''; 751 const tenantPart = event.tenant_organisation 752 ? ' â ' + escapeHtml(event.tenant_organisation) 753 : ''; 754 const metaText = datePart + lieuPart + tenantPart; 755 756 // Tags inline : max 3 affichés. 757 // / Inline tags: max 3 displayed. 758 let tagsHtml = ''; 759 const tags = (event.tags || []).slice(0, 3); 760 if (tags.length > 0) { 761 tagsHtml = '<div class="explorer-card-tags">'; 762 for (let i = 0; i < tags.length; i++) { 763 const t = tags[i]; 764 tagsHtml += '<span class="explorer-card-tag" style="background-color:' 765 + escapeHtml(t.color || '#0dcaf0') + '">' 766 + escapeHtml(t.name || t.slug) + '</span>'; 767 } 768 tagsHtml += '</div>'; 769 } 770 771 // Vignette de l'event si dispo, sinon emoji note de musique. 772 // / Event thumbnail if available, otherwise music-note emoji. 773 const eventIcon = event.image_url 774 ? '<img src="' + escapeHtml(event.image_url) + '" alt="" class="explorer-card-icon event" style="object-fit:cover;">' 775 : '<div class="explorer-card-icon event">\u{1F3B6}</div>'; 776 777 return '' 778 + '<div class="explorer-card explorer-card--event"' 779 + ' data-event-uuid="' + escapeHtml(event.uuid || '') + '"' 780 + ' data-tenant-id="' + tenantId + '"' 781 + ' data-pa-id="' + escapeHtml(event.pa_id || '') + '"' 782 + ' data-type="event"' 783 + ' data-testid="explorer-card-event">' 784 + eventIcon 785 + '<div class="explorer-card-body">' 786 + '<div class="explorer-card-header">' 787 + '<h3 class="explorer-card-title">' 788 + '<a href="' + escapeHtml(evUrl) + '" target="_blank" rel="noopener">' 789 + escapeHtml(event.name) + '</a></h3>' 790 + '<span class="explorer-badge event">' + escapeHtml(config.i18n.event) + '</span>' 791 + '</div>' 792 + '<div class="explorer-card-meta">' + metaText + '</div>' 793 + tagsHtml 794 + '</div>' 795 + '</div>'; 796 } 797 798 function renderEmptyState() { 799 if (!dom.list) return; 800 dom.list.innerHTML = '<p class="text-muted text-center py-4">' + escapeHtml(config.i18n.empty) + '</p>'; 801 hideMapLoadingSpinner(); 802 } 803 804 // ============================================================ 805 // TAG CHIPS â top 10 par fréquence parmi events visibles 806 // / TAG CHIPS â top 10 by frequency among visible events 807 // ============================================================ 808 809 function computeVisibleTagsTop10(paVisibles) { 810 // Agrege les tags des events visibles (mode lieu : events de toutes les PA 811 // visibles ; mode event : meme chose, le filtre tag s'applique ailleurs). 812 // Ne tient pas compte de state.filters.tag (sinon on cacherait le chip actif). 813 // / Aggregate tags of visible events. Ignores state.filters.tag (otherwise 814 // the active chip would disappear). 815 const compteur = {}; // slug -> {slug, name, color, count} 816 for (let i = 0; i < paVisibles.length; i++) { 817 const evList = paVisibles[i].events_futurs || []; 818 for (let j = 0; j < evList.length; j++) { 819 const tags = evList[j].tags || []; 820 for (let k = 0; k < tags.length; k++) { 821 const t = tags[k]; 822 if (!compteur[t.slug]) { 823 compteur[t.slug] = { 824 slug: t.slug, name: t.name, color: t.color, count: 0, 825 }; 826 } 827 compteur[t.slug].count += 1; 828 } 829 } 830 } 831 const liste = Object.keys(compteur).map(function (k) { return compteur[k]; }); 832 liste.sort(function (a, b) {
833 if (b.count !== a.count) return b.count - a.count; 834 return a.name.localeCompare(b.name); 835 }); 836 return { 837 top: liste.slice(0, 10), 838 rest: liste.slice(10), 839 }; 840 } 841 842 function updateChips(paVisibles) { 843 if (!dom.tags) return; 844 const grouped = computeVisibleTagsTop10(paVisibles); 845 const activeSlug = state.filters.tag; 846 847 // Cache le conteneur si aucun tag a afficher 848 // / Hide container if no tag to display 849 if (grouped.top.length === 0 && grouped.rest.length === 0) { 850 dom.tags.innerHTML = ''; 851 dom.tags.hidden = true; 852 return; 853 } 854 dom.tags.hidden = false; 855 856 let html = ''; 857 for (let i = 0; i < grouped.top.length; i++) { 858 const t = grouped.top[i]; 859 html += buildChipHtml(t, t.slug === activeSlug); 860 } 861 if (grouped.rest.length > 0) { 862 const moreLabel = config.i18n.moreTags.replace('{count}', grouped.rest.length); 863 html += '<button type="button" class="explorer-tag-chip-more"' 864 + ' data-testid="explorer-tag-chip-more"' 865 + ' aria-haspopup="true" aria-expanded="false">' 866 + escapeHtml(moreLabel) + '</button>' 867 + '<div class="explorer-tag-chip-rest" hidden>'; 868 for (let j = 0; j < grouped.rest.length; j++) { 869 html += buildChipHtml(grouped.rest[j], grouped.rest[j].slug === activeSlug); 870 } 871 html += '</div>'; 872 } 873 dom.tags.innerHTML = html; 874 } 875 876 function buildChipHtml(tag, isActive) { 877 // Chip bouton : fond = tag.color, état actif = bordure + check. 878 // / Chip button: background = tag.color, active state = border + check. 879 const cls = 'explorer-tag-chip' + (isActive ? ' explorer-tag-chip--active' : ''); 880 const check = isActive 881 ? '<i class="bi bi-check2" aria-hidden="true"></i> ' 882 : ''; 883 return '<button type="button" class="' + cls + '"' 884 + ' data-tag-slug="' + escapeHtml(tag.slug) + '"' 885 + ' data-testid="explorer-tag-chip-' + escapeHtml(tag.slug) + '"' 886 + ' style="--chip-color:' + escapeHtml(tag.color || '#0dcaf0') + '"' 887 + ' aria-pressed="' + (isActive ? 'true' : 'false') + '">' 888 + check + escapeHtml(tag.name || tag.slug) + '</button>'; 889 } 890 891 // ============================================================ 892 // RENDER MAP â Leaflet integration 893 // / RENDER MAP â Leaflet integration 894 // ============================================================ 895 896 function initMap() { 897 if (state.mapInitialized) return; 898 if (!dom.map) return; 899 if (typeof L === 'undefined') { 900 console.warn('explorer: Leaflet not loaded, skipping map init'); 901 hideMapLoadingSpinner(); 902 return; 903 } 904 905 state.map = L.map('explorer-map', { zoomControl: true, scrollWheelZoom: true }); 906 907 // Fond de carte. Si une cle MapTiler est fournie (settings.MAPTILER_KEY -> 908 // data-maptiler-key), on utilise MapTiler (style "dataviz-v4" epure, en 909 // francais, tuiles HD). Sinon, repli sur les tuiles "Humanitarian" (HOT) par 910 // OpenStreetMap France : labels en francais, sans cle API, OK en localhost. 911 // / Basemap. If a MapTiler key is provided, use MapTiler (epured "dataviz-v4", 912 // HD tiles). Otherwise fall back to Humanitarian (HOT) tiles hosted by OSM 913 // France: French labels, no API key, works on localhost. 914 function creerCoucheOsmHot() { 915 return L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', { 916 attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, style <a href="https://www.hotosm.org/">Humanitarian OSM Team</a> · <a href="https://openstreetmap.fr/">OpenStreetMap France</a>', 917 maxZoom: 20, 918 subdomains: 'abc', 919 }); 920 } 921 922 if (config.maptilerKey) { 923 // tuiles 512px (HD) -> tileSize 512 + zoomOffset -1 cote Leaflet. 924 // language=fr force les labels en francais. crossOrigin pour le retina. 925 // / 512px (HD) tiles -> tileSize 512 + zoomOffset -1. language=fr forces French. 926 const coucheMaptiler = L.tileLayer( 927 'https://api.maptiler.com/maps/dataviz-v4/{z}/{x}/{y}.png?key=' 928 + config.maptilerKey + '&language=fr', 929 { 930 attribution: '© <a href="https://www.maptiler.com/copyright/">MapTiler</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', 931 tileSize: 512, 932 zoomOffset: -1, 933 minZoom: 1, 934 maxZoom: 20, 935 crossOrigin: true, 936 } 937 ); 938 939 // Repli automatique MapTiler -> OSM France HOT (quota epuise, cle ou 940 // origine refusee). Bascule UNE SEULE FOIS si aucune tuile n'a jamais 941 // reussi au premier affichage, ou si le total d'erreurs atteint 942 // SEUIL_ERREURS_TUILES. Retrait DIFFERE (setTimeout 0) : Leaflet 1.9.4 943 // lit this._map juste apres fire("load"). 944 // Spec : TECH_DOC/SESSIONS/WIDGET_GEO/04-fonds-de-carte-maptiler-repli-osm.md 945 // / Automatic MapTiler -> OSM France HOT fallback. Switch ONCE. 946 // Layer removal is DEFERRED. 947 const SEUIL_ERREURS_TUILES = 5; 948 let bascule_osm_faite = false;
949 let tuilesMaptilerOk = 0; 950 let tuilesMaptilerEnErreur = 0; 951 952 function basculerVersOsmHot() { 953 if (bascule_osm_faite) return; 954 bascule_osm_faite = true; 955 console.warn('explorer: MapTiler indisponible, bascule sur OSM France HOT'); 956 setTimeout(function () { 957 state.map.removeLayer(coucheMaptiler); 958 creerCoucheOsmHot().addTo(state.map); 959 }, 0); 960 } 961 962 coucheMaptiler.on('tileload', function () { 963 tuilesMaptilerOk = tuilesMaptilerOk + 1; 964 }); 965 coucheMaptiler.on('tileerror', function () { 966 tuilesMaptilerEnErreur = tuilesMaptilerEnErreur + 1; 967 if (tuilesMaptilerEnErreur >= SEUIL_ERREURS_TUILES) { 968 basculerVersOsmHot(); 969 } 970 }); 971 coucheMaptiler.on('load', function () { 972 const aucuneTuileNaJamaisReussi = tuilesMaptilerOk === 0; 973 if (aucuneTuileNaJamaisReussi && tuilesMaptilerEnErreur > 0) { 974 basculerVersOsmHot(); 975 } 976 }); 977 978 coucheMaptiler.addTo(state.map); 979 } else { 980 creerCoucheOsmHot().addTo(state.map); 981 } 982 983 state.markerCluster = L.markerClusterGroup(); 984 state.map.addLayer(state.markerCluster); 985 986 addMarkers(state.data.points); 987 state.mapInitialized = true; 988 989 applyFilters(); 990 hideMapLoadingSpinner(); 991 } 992 993 function hideMapLoadingSpinner() { 994 if (!dom.mapLoading) return; 995 dom.mapLoading.classList.add('explorer-map-loading--fading'); 996 setTimeout(function () { 997 if (dom.mapLoading && dom.mapLoading.parentElement) { 998 dom.mapLoading.parentElement.removeChild(dom.mapLoading); 999 } 1000 }, 250); 1001 } 1002 1003 function addMarkers(points) { 1004 // 1 marker par point (= 1 par PostalAddress active). 1005 // Indexes state.markers par pa_id (clé unique). state.markersByTenant 1006 // garde une vue inverse pour focusOnLieu(tenantId). 1007 // / 1 marker per point (= 1 per active PostalAddress). 1008 // state.markers indexed by pa_id; state.markersByTenant for reverse lookup. 1009 if (!state.map || !state.markerCluster) return; 1010 state.markerCluster.clearLayers(); 1011 state.markers = {}; 1012 state.markersByTenant = {}; 1013 const bounds = []; 1014 1015 for (let i = 0; i < points.length; i++) { 1016 const point = points[i]; 1017 const lat = parseFloat(point.latitude); 1018 const lng = parseFloat(point.longitude); 1019 if (isNaN(lat) || isNaN(lng)) continue; 1020 1021 const isCurrent = point.tenant_id === config.currentTenantUuid; 1022 const pinClass = 'explorer-pin' + (isCurrent ? ' explorer-pin--current' : ''); 1023 // Label du pin = nom de l'adresse (pa_name). Fallback nom du tenant. 1024 // / Pin label = PA name. Fallback to tenant name. 1025 const labelTexte = point.pa_name || point.tenant_organisation || ''; 1026 const icon = L.divIcon({ 1027 className: '', 1028 html: '<div class="' + pinClass + '" data-pa-id="' + escapeHtml(String(point.pa_id)) 1029 + '" data-tenant-id="' + escapeHtml(point.tenant_id || '') + '">' 1030 + escapeHtml(labelTexte) + '</div>', 1031 iconSize: null, 1032 iconAnchor: [0, 0], 1033 }); 1034 1035 const marker = L.marker([lat, lng], { icon: icon, title: labelTexte }); 1036 marker.bindPopup(buildPopupContent(point), { maxWidth: 320 }); 1037 1038 (function (tenantId) { 1039 marker.on('click', function () { scrollToCard(tenantId); }); 1040 })(point.tenant_id); 1041 1042 state.markers[point.pa_id] = marker; 1043 // Reverse lookup tenant_id -> [pa_id...] pour focusOnLieu() 1044 if (!state.markersByTenant[point.tenant_id]) { 1045 state.markersByTenant[point.tenant_id] = []; 1046 } 1047 // L'adresse principale du lieu passe en tete de la liste : c'est elle que 1048 // focusOnLieu() vise au clic sur une carte de lieu. Les autres adresses 1049 // suivent, dans l'ordre du cache (pk croissant). 1050 // / The venue main address goes first: focusOnLieu() targets it on card 1051 // click. Other addresses follow, in cache order (ascending pk). 1052 if (point.is_main_address) { 1053 state.markersByTenant[point.tenant_id].unshift(point.pa_id); 1054 } else { 1055 state.markersByTenant[point.tenant_id].push(point.pa_id); 1056 } 1057 1058 state.markerCluster.addLayer(marker); 1059 bounds.push([lat, lng]); 1060 } 1061 1062 if (bounds.length > 0) state.map.fitBounds(bounds, { padding: [20, 20] });
1063 else state.map.setView([46.603354, 1.888334], 6); 1064 } 1065 1066 function buildPopupContent(point) { 1067 // Popup riche : nom PA + adresse + tenant + events futurs (top 5). 1068 // / Rich popup: PA name + address + tenant + future events (top 5). 1069 const tenantHref = point.tenant_domain ? 'https://' + point.tenant_domain + '/' : '#'; 1070 let html = '<div class="explorer-popup">' 1071 + '<h4 class="explorer-popup-title">' + escapeHtml(point.pa_name) + '</h4>'; 1072 if (point.address_display) { 1073 html += '<p class="explorer-popup-address">' + escapeHtml(point.address_display) + '</p>'; 1074 } 1075 // Lien tenant (avec logo si dispo) 1076 // / Tenant link (with logo if available) 1077 if (point.tenant_domain) { 1078 // Logo en priorite, sinon image principale du lieu (coherent avec les cartes). 1079 // / Logo first, otherwise venue main image (consistent with the cards). 1080 const popupImgSrc = point.tenant_logo_url || point.tenant_image_url || ''; 1081 const logoImg = popupImgSrc 1082 ? '<img src="' + escapeHtml(popupImgSrc) + '" alt="" class="explorer-popup-logo">' 1083 : ''; 1084 html += '<p class="explorer-popup-tenant">' + logoImg 1085 + '<a href="' + escapeHtml(tenantHref) + '" target="_blank" rel="noopener">' 1086 + escapeHtml(point.tenant_organisation || '') + ' â</a></p>'; 1087 } 1088 // Events futurs (top 5) 1089 // / Future events (top 5) 1090 const events = point.events_futurs || []; 1091 const totalEvents = point.events_futurs_count_total || events.length; 1092 if (events.length > 0) { 1093 html += '<div class="explorer-popup-events"><strong>' 1094 + escapeHtml(config.i18n.nextEvents) + ' (' + totalEvents + ')</strong>' 1095 + '<ul class="explorer-popup-events-list">'; 1096 for (let i = 0; i < events.length; i++) { 1097 const ev = events[i]; 1098 const evUrl = (point.tenant_domain && ev.slug) 1099 ? 'https://' + point.tenant_domain + '/event/' + ev.slug + '/' 1100 : tenantHref; 1101 html += '<li><a href="' + escapeHtml(evUrl) + '" target="_blank" rel="noopener">' 1102 + escapeHtml(ev.name) 1103 + (ev.datetime_iso ? ' â ' + escapeHtml(formatShortDate(ev.datetime_iso)) : '') 1104 + '</a></li>'; 1105 } 1106 html += '</ul>'; 1107 if (totalEvents > events.length) { 1108 html += '<span class="explorer-popup-events-more">+ ' 1109 + (totalEvents - events.length) + ' ' + escapeHtml(config.i18n.more) + '</span>'; 1110 } 1111 html += '</div>'; 1112 } 1113 html += '<a class="explorer-popup-link" href="' + escapeHtml(tenantHref) + '" target="_blank" rel="noopener">' 1114 + escapeHtml(config.i18n.visit) + ' â</a></div>'; 1115 return html; 1116 } 1117 1118 function updateMapMarkersByPA(visiblePaIds) { 1119 // Garde les markers des PA visibles, cache les autres. 1120 // visiblePaIds = dict {pa_id: true}. 1121 // / Keep visible PA markers, hide others. 1122 if (!state.mapInitialized) return; 1123 1124 for (const paId in state.markers) { 1125 const marker = state.markers[paId]; 1126 const keep = !!visiblePaIds[paId]; 1127 const isVisible = state.markerCluster.hasLayer(marker); 1128 if (keep && !isVisible) state.markerCluster.addLayer(marker); 1129 else if (!keep && isVisible) state.markerCluster.removeLayer(marker); 1130 } 1131 } 1132 1133 // ============================================================ 1134 // FOCUS LIEU â zoom + popup + accordeon + highlight 1135 // / FOCUS LIEU â zoom + popup + accordion + highlight 1136 // ============================================================ 1137 1138 function markerEstSurLaCarte(marker) { 1139 // Un marker enferme dans un cluster n'est PAS sur la carte : il n'a pas d'icone 1140 // dans le DOM. C'est la difference qui decide comment l'atteindre (cf. 1141 // montrerLeMarkerPuisOuvrirLePopup). 1142 // / A clustered marker is NOT on the map: it has no icon in the DOM. 1143 return !!(marker && marker._icon); 1144 } 1145
1146 function montrerLeMarkerPuisOuvrirLePopup(marker, tenantId) { 1147 // INDISPENSABLE : openPopup() ne fait RIEN sur un marker enferme dans un 1148 // cluster, car ce marker n'est pas sur la carte. C'est le cas des que deux 1149 // adresses sont proches, et TOUJOURS quand deux adresses ont les memes 1150 // coordonnees (un lieu du reseau et le lieu courant a la meme adresse) : le 1151 // cluster ne se defait alors a aucun zoom. zoomToShowLayer() est l'API de 1152 // markercluster pour ce cas : elle zoome, ou fait le spiderfy si les 1153 // coordonnees sont identiques, puis rend la main. 1154 // / openPopup() is a no-op on a clustered marker (it is not on the map). Two 1155 // addresses with identical coordinates stay clustered at every zoom level. 1156 // zoomToShowLayer() zooms â or spiderfies for identical coordinates â then 1157 // calls back. 1158 const ouvrirLePopup = function () { 1159 marker.openPopup(); 1160 // Le highlight lit les pins dans le DOM. Un pin clusterise n'y est pas 1161 // encore : on surligne donc APRES que le cluster se soit ouvert. 1162 // / Highlight reads pins from the DOM: do it AFTER the cluster opened. 1163 if (tenantId) highlightPin(tenantId); 1164 }; 1165 1166 if (markerEstSurLaCarte(marker)) { 1167 // Marker deja visible : on se rapproche sans jamais dezoomer (dezoomer 1168 // pourrait le re-clusteriser et refermer le popup). 1169 // / Already visible: zoom in, never out (zooming out could re-cluster it). 1170 if (state.map.getZoom() < ZOOM_FOCUS_MARKER) { 1171 state.map.setView(marker.getLatLng(), ZOOM_FOCUS_MARKER, { animate: true }); 1172 } 1173 ouvrirLePopup(); 1174 return; 1175 } 1176 1177 if (state.markerCluster && typeof state.markerCluster.zoomToShowLayer === 'function') { 1178 state.markerCluster.zoomToShowLayer(marker, ouvrirLePopup); 1179 return; 1180 } 1181 1182 // Repli si la lib markercluster est absente : comportement historique. 1183 // / Fallback when markercluster is missing: historical behaviour. 1184 state.map.setView(marker.getLatLng(), ZOOM_FOCUS_MARKER, { animate: true }); 1185 setTimeout(ouvrirLePopup, 200); 1186 } 1187 1188 function trouverMarkerVisibleDuLieu(tenantId) { 1189 // 1 lieu peut avoir N adresses, donc N markers. On rend le PREMIER qui est 1190 // encore dans le cluster : les filtres (texte, tag) en retirent, et 1191 // zoomToShowLayer() leve une TypeError sur un marker retire (markercluster 1192 // efface son __parent au removeLayer). 1193 // L'ordre de la liste place l'adresse principale en tete (cf. addMarkers). 1194 // / A venue may have N addresses. Return the FIRST marker still in the cluster: 1195 // filters remove markers, and zoomToShowLayer() throws on a removed one. The 1196 // main address comes first in the list (cf. addMarkers). 1197 const paIds = (state.markersByTenant && state.markersByTenant[tenantId]) || []; 1198 for (let i = 0; i < paIds.length; i++) { 1199 const marker = state.markers[paIds[i]]; 1200 if (!marker) continue; 1201 if (state.markerCluster && !state.markerCluster.hasLayer(marker)) continue; 1202 return marker; 1203 } 1204 return null; 1205 } 1206 1207 function focusOnLieu(tenantId) { 1208 if (window.innerWidth < 992 && state.currentView === 'list') toggleView(); 1209 if (!state.mapInitialized) initMap(); 1210 1211 const marker = trouverMarkerVisibleDuLieu(tenantId); 1212 if (!marker) return; 1213 1214 montrerLeMarkerPuisOuvrirLePopup(marker, tenantId); 1215 1216 if (window.innerWidth >= 992) { 1217 openLieuAccordion(tenantId); 1218 } 1219 } 1220 1221 function focusOnPA(paId, tenantId) { 1222 // Centre la carte sur une adresse precise (PA), utilise au clic d'une card event. 1223 // Le marker peut ne pas exister (lieu sans coordonnees), ou avoir ete retire du 1224 // cluster par un filtre : on sort proprement. 1225 // / Center the map on a specific address (PA), used on event card click. The
1226 // marker may not exist (addressless venue), or may have been filtered out of the 1227 // cluster: bail out gracefully. 1228 if (window.innerWidth < 992 && state.currentView === 'list') toggleView(); 1229 if (!state.mapInitialized) initMap(); 1230 1231 const marker = state.markers[paId]; 1232 if (!marker) return; 1233 if (state.markerCluster && !state.markerCluster.hasLayer(marker)) return; 1234 1235 montrerLeMarkerPuisOuvrirLePopup(marker, tenantId); 1236 } 1237 1238 function highlightPin(tenantId) { 1239 // Les pins portent data-tenant-id (cf. addMarkers), pas data-lieu-id. 1240 // Un lieu peut avoir plusieurs adresses : on surligne tous ses pins. 1241 // / Pins carry data-tenant-id (cf. addMarkers), not data-lieu-id. 1242 // A venue may have several addresses: highlight all its pins. 1243 const pins = document.querySelectorAll('.explorer-pin[data-tenant-id="' + cssEscape(tenantId) + '"]'); 1244 pins.forEach(function (pin) { 1245 pin.classList.add('selected'); 1246 setTimeout(function () { pin.classList.remove('selected'); }, 3000); 1247 }); 1248 } 1249 1250 function highlightPinClass(tenantId, isOn) { 1251 const pins = document.querySelectorAll('.explorer-pin[data-tenant-id="' + cssEscape(tenantId) + '"]'); 1252 pins.forEach(function (pin) { pin.classList.toggle('selected', isOn); }); 1253 } 1254 1255 function cssEscape(value) { 1256 // CSS.escape() est dispo dans tous les navigateurs modernes (Chrome 46+, 1257 // Firefox 31+, Safari 10+) et gere correctement TOUS les caracteres CSS 1258 // dangereux. Fallback minimal pour les vieux navigateurs (échappe juste 1259 // " et \ â suffisant pour des UUIDs). 1260 // / CSS.escape() is available in all modern browsers (Chrome 46+, 1261 // Firefox 31+, Safari 10+) and handles ALL CSS-dangerous characters 1262 // correctly. Minimal fallback for legacy browsers (escapes only " and \ 1263 // â sufficient for UUIDs). 1264 if (window.CSS && typeof window.CSS.escape === 'function') { 1265 return window.CSS.escape(String(value)); 1266 } 1267 return String(value).replace(/(["\\])/g, '\\$1'); 1268 } 1269 1270 function openLieuAccordion(tenantId) { 1271 const card = document.querySelector('.explorer-card--lieu[data-lieu-id="' + cssEscape(tenantId) + '"]'); 1272 if (!card) return; 1273 const panel = card.querySelector('.explorer-accordion-panel'); 1274 const wasOpen = panel && panel.classList.contains('open'); 1275 closeAllAccordionsExcept(tenantId); 1276 if (panel) setAccordionState(card, !wasOpen); 1277 card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 1278 } 1279 1280 function closeAllAccordionsExcept(cardId) { 1281 const allCards = document.querySelectorAll('.explorer-card--lieu'); 1282 for (let i = 0; i < allCards.length; i++) { 1283 const card = allCards[i]; 1284 if (card.getAttribute('data-lieu-id') === cardId) continue;
1285 setAccordionState(card, false); 1286 } 1287 } 1288 1289 function setAccordionState(card, shouldBeOpen) { 1290 const panel = card.querySelector('.explorer-accordion-panel'); 1291 const chevron = card.querySelector('.explorer-accordion-chevron'); 1292 if (!panel) return; 1293 panel.classList.toggle('open', shouldBeOpen); 1294 if (chevron) chevron.style.transform = shouldBeOpen ? 'rotate(180deg)' : ''; 1295 } 1296 1297 function handleAccordionToggle(button) { 1298 const card = button.closest('.explorer-card--lieu'); 1299 if (!card) return; 1300 const panel = card.querySelector('.explorer-accordion-panel'); 1301 const willOpen = !panel.classList.contains('open'); 1302 if (willOpen) { 1303 closeAllAccordionsExcept(card.getAttribute('data-lieu-id')); 1304 } 1305 setAccordionState(card, willOpen); 1306 } 1307 1308 function scrollToCard(tenantId) { 1309 // Mode "lieu" : carte lieu du tenant. Mode "event" : il n'y a pas de carte 1310 // lieu, on retombe sur la 1ere carte event du tenant. 1311 // / "lieu" mode: venue card. "event" mode: no venue card, fall back to the 1312 // tenant's first event card. 1313 const tid = cssEscape(tenantId); 1314 let card = document.querySelector('.explorer-card[data-lieu-id="' + tid + '"][data-type="lieu"]'); 1315 if (!card) { 1316 card = document.querySelector('.explorer-card--event[data-tenant-id="' + tid + '"]'); 1317 } 1318 if (!card) return; 1319 card.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1320 card.setAttribute('data-highlighted', 'true'); 1321 setTimeout(function () { card.removeAttribute('data-highlighted'); }, 2000); 1322 } 1323 1324 // ============================================================ 1325 // TOGGLE VIEW MOBILE 1326 // ============================================================ 1327 1328 function toggleView() { 1329 const container = document.querySelector('.explorer-container'); 1330 if (!container || !dom.fab) return; 1331 const label = dom.fab.querySelector('.explorer-fab-label'); 1332 1333 if (state.currentView === 'list') { 1334 container.classList.add('explorer-view-map'); 1335 document.body.classList.add('explorer-map-active'); 1336 if (label) label.textContent = config.i18n.list; 1337 state.currentView = 'map'; 1338 window.scrollTo({ top: 0, behavior: 'instant' }); 1339 if (!state.mapInitialized) initMap(); 1340 else state.map.invalidateSize(); 1341 } else { 1342 container.classList.remove('explorer-view-map'); 1343 document.body.classList.remove('explorer-map-active'); 1344 if (label) label.textContent = config.i18n.map; 1345 state.currentView = 'list'; 1346 } 1347 } 1348 1349 // ============================================================ 1350 // URL STATE â sync filters <-> URL via history.replaceState 1351 // / URL STATE â sync filters <-> URL via history.replaceState 1352 // ============================================================ 1353 1354 let urlSyncTimer = null; 1355 1356 function syncURL() { 1357 // Debounce 300ms pour eviter les rafales lors de la saisie texte. 1358 // / Debounce 300ms to avoid bursts during text input. 1359 clearTimeout(urlSyncTimer); 1360 urlSyncTimer = setTimeout(function () { 1361 const params = new URLSearchParams(); 1362 if (state.filters.view && state.filters.view !== 'lieu') { 1363 params.set('v', state.filters.view); 1364 } 1365 if (state.filters.text) { 1366 params.set('q', state.filters.text); 1367 } 1368 if (state.filters.tag) { 1369 params.set('tag', state.filters.tag); 1370 } 1371 const qs = params.toString(); 1372 const newUrl = window.location.pathname + (qs ? ('?' + qs) : ''); 1373 try { 1374 window.history.replaceState({}, '', newUrl); 1375 } catch (err) { 1376 // Silently fail si l'historique est restreint (file://, etc.) 1377 // / Silent fail if history is restricted. 1378 } 1379 }, 300); 1380 } 1381 1382 function bootFromURL() {
1383 // Lit les params URL au chargement et pré-sélectionne state.filters. 1384 // / Read URL params at load, pre-select state.filters. 1385 try { 1386 const params = new URLSearchParams(window.location.search); 1387 const v = params.get('v'); 1388 if (v === 'event' || v === 'lieu') { 1389 state.filters.view = v; 1390 } 1391 const q = params.get('q'); 1392 if (q) { 1393 state.filters.text = q.toLowerCase(); 1394 } 1395 const tag = params.get('tag'); 1396 if (tag) { 1397 // On valide plus tard : si aucun event ne porte ce tag, applyFilters 1398 // affichera l'empty state â pas de plantage. 1399 // / Validate later: if no event carries it, applyFilters shows empty state. 1400 state.filters.tag = tag; 1401 } 1402 } catch (err) { 1403 console.warn('explorer: cannot parse URL params', err); 1404 } 1405 } 1406 1407 // ============================================================ 1408 // ENTRY POINT 1409 // ============================================================ 1410 // 1411 // On veut lancer init() dans 2 contextes differents : 1412 // 1413 // 1. Chargement complet de la page (F5, navigation directe) : 1414 // explorer.js est execute pendant le parse du HTML, le DOM 1415 // n'est pas encore pret. On attend l'evenement DOMContentLoaded. 1416 // 1417 // 2. Navigation interne via HTMX (la navbar fait hx-target="body" 1418 // + hx-swap="innerHTML") : explorer.js est reinjecte dans la 1419 // page deja chargee. DOMContentLoaded a deja ete declenche au 1420 // chargement initial et ne se redeclenchera plus. Il faut donc 1421 // appeler init() immediatement. 1422 // 1423 // document.readyState distingue les 2 cas : 1424 // - 'loading' : on est dans le cas 1, on attend l'evenement 1425 // - 'interactive' ou 'complete' : on est dans le cas 2, on lance direct 1426 // 1427 // / Run init() in two contexts: 1428 // / 1. Full page load (F5, direct navigation): DOM not yet ready, 1429 // / wait for DOMContentLoaded. 1430 // / 2. HTMX navigation (navbar uses hx-target="body" + innerHTML 1431 // / swap): script is reinjected after DOMContentLoaded already 1432 // / fired, so it would never trigger again. Call init() now. 1433 if (document.readyState === 'loading') { 1434 document.addEventListener('DOMContentLoaded', init); 1435 } else { 1436 init(); 1437 } 1438})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.