PageSourceSearch

https://tibillet.coop/static/seo/explorer.js

js tibillet.coop collected 2026-10-03 21:29:58 UTC 64,450 bytes, 1,438 lines download raw bytes

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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, style <a href="https://www.hotosm.org/">Humanitarian OSM Team</a> &middot; <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: '&copy; <a href="https://www.maptiler.com/copyright/">MapTiler</a> &copy; <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.