1(function(){ 2 'use strict'; 3 4 const places = Array.isArray(window.VPI_MAP_PLACES) ? window.VPI_MAP_PLACES : []; 5 const apps = document.querySelectorAll('[data-vpi-map]'); 6 if (!apps.length) return; 7 8 apps.forEach(initApp); 9 10 function initApp(app){ 11 const pins = app.querySelector('[data-vpi-pins]'); 12 const list = app.querySelector('[data-vpi-list]'); 13 const count = app.querySelector('[data-vpi-count]'); 14 const sheet = app.querySelector('[data-vpi-sheet]'); 15 const sheetContent = app.querySelector('[data-vpi-sheet-content]'); 16 const status = app.querySelector('[data-vpi-status]'); 17 const locateBtn = app.querySelector('[data-vpi-locate]'); 18 const listBtn = app.querySelector('[data-vpi-list-toggle]'); 19 const openNowBtn = app.querySelector('[data-vpi-open-now]'); 20 const listWrap = app.querySelector('[data-vpi-list-wrap]'); 21 const closeBtn = app.querySelector('[data-vpi-close]'); 22 const searchInput = app.querySelector('[data-vpi-search]'); 23 24 let userLocation = null; 25 let openFilter = false; 26 let query = ''; 27 28 function esc(s){ 29 return String(s == null ? '' : s).replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); 30 } 31 32 function normalize(s){ 33 return String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().trim(); 34 } 35 36 function isOpenNow(place){ 37 if (!place.open_from || !place.open_to || !place.exhibitions || !place.exhibitions.length) return null; 38 const now = new Date(); 39 const start = new Date(2026, 7, 29, 0, 0, 0); // 29 Aug 2026 40 const end = new Date(2026, 8, 13, 23, 59, 59); // 13 Sep 2026 41 if (now < start || now > end) return false; 42 const mins = now.getHours()*60 + now.getMinutes(); 43 const a = place.open_from.split(':').map(Number); 44 const b = place.open_to.split(':').map(Number); 45 const from = a[0]*60 + a[1]; 46 const to = b[0]*60 + b[1]; 47 return mins >= from && mins <= to; 48 } 49 50 function hoursLabel(place){ 51 if (place.hours_note) return place.hours_note; 52 if (place.open_from && place.open_to) return 'Ouvert ' + place.open_from.replace(':','h') + ' â ' + place.open_to.replace(':','h'); 53 return ''; 54 } 55 56 function distanceMeters(aLat,aLng,bLat,bLng){ 57 const R=6371000, rad=Math.PI/180; 58 const p1=aLat*rad,p2=bLat*rad,dp=(bLat-aLat)*rad,dl=(bLng-aLng)*rad; 59 const x=Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2; 60 return 2*R*Math.atan2(Math.sqrt(x),Math.sqrt(1-x)); 61 } 62 63 function distanceLabel(place){ 64 if (!userLocation || place.lat == null || place.lng == null) return ''; 65 const m = distanceMeters(userLocation.lat,userLocation.lng,place.lat,place.lng); 66 place._distance = m; 67 if (m < 1000) return Math.max(10,Math.round(m/10)*10) + ' m'; 68 return (m/1000).toFixed(1).replace('.',',') + ' km'; 69 } 70 71 function directionsUrl(place){ 72 const destination = place.address || ((place.lat != null && place.lng != null) ? (place.lat + ',' + place.lng) : ''); 73 if (!destination) return ''; 74 return 'https://www.google.com/maps/dir/?api=1&destination=' + encodeURIComponent(destination) + '&travelmode=walking'; 75 } 76 77 function placeMatches(place){ 78 if (!query) return true; 79 const q = normalize(query); 80 const placeText = normalize([place.name, place.address, place.special_note].join(' ')); 81 if (placeText.includes(q)) return true; 82 return (place.exhibitions || []).some(ex => normalize([ex.title, ex.photographer].join(' ')).includes(q)); 83 } 84 85 function matchingExhibitions(place){ 86 const expos = Array.isArray(place.exhibitions) ? place.exhibitions : []; 87 if (!query) return expos; 88 const q = normalize(query); 89 if (normalize([place.name, place.address].join(' ')).includes(q)) return expos; 90 return expos.filter(ex => normalize([ex.title, ex.photographer].join(' ')).includes(q)); 91 } 92 93 function filteredPlaces(){ 94 let arr = places.filter(placeMatches); 95 if (openFilter) arr = arr.filter(p => isOpenNow(p) === true); 96 if (userLocation) { 97 arr.forEach(p => distanceLabel(p)); 98 arr.sort((a,b) => (a._distance ?? Infinity) - (b._distance ?? Infinity)); 99 } else { 100 arr.sort((a,b) => (a.number||999) - (b.number||999)); 101 } 102 return arr; 103 } 104 105 function expoSummary(place){ 106 const expos = matchingExhibitions(place); 107 if (!expos.length) return place.special_note || ''; 108 const first = expos[0]; 109 let html = '<div class="vpi-card-exhibition">'+esc(first.title)+'</div>'; 110 if (first.photographer) html += '<div class="vpi-card-photographer">
110'+esc(first.photographer)+'</div>'; 111 if (expos.length > 1) html += '<div class="vpi-card-more">+'+(expos.length-1)+' autre'+(expos.length > 2 ? 's' : '')+' exposition'+(expos.length > 2 ? 's' : '')+'</div>'; 112 return html; 113 } 114 115 function render(){ 116 const current = filteredPlaces(); 117 const expoTotal = current.reduce((sum,p) => sum + matchingExhibitions(p).length, 0); 118 count.textContent = current.length + (current.length > 1 ? ' lieux' : ' lieu') + (query ? ' · ' + expoTotal + ' expo' + (expoTotal > 1 ? 's' : '') : ''); 119 120 pins.innerHTML = ''; 121 current.forEach(place => { 122 if (place.x == null || place.y == null) return; 123 const open = isOpenNow(place); 124 const b = document.createElement('button'); 125 b.type = 'button'; 126 b.className = 'vpi-pin' + (open === false ? ' is-closed' : ''); 127 b.style.left = place.x + '%'; 128 b.style.top = place.y + '%'; 129 b.textContent = place.number || 'â¢'; 130 b.dataset.placeNumber = place.number || ''; 131 b.setAttribute('aria-label', (place.number ? place.number + '. ' : '') + place.name + ((place.exhibitions || []).length ? ', ' + place.exhibitions.length + ' exposition(s)' : '')); 132 b.addEventListener('click', () => showPlace(place)); 133 pins.appendChild(b); 134 }); 135 136 list.innerHTML = ''; 137 if (!current.length) { 138 list.innerHTML = '<div class="vpi-map-empty">Aucun résultat. Essayez le nom dâun photographe, dâune exposition ou dâun lieu.</div>'; 139 return; 140 } 141 142 current.forEach(place => { 143 const card = document.createElement('button'); 144 card.type = 'button'; 145 const open = isOpenNow(place); 146 card.className = 'vpi-place-card' + (open === false ? ' is-closed' : '') + (place.image ? ' has-image' : ''); 147 const img = place.image ? '<img src="'+esc(place.image)+'" alt="">' : ''; 148 const distance = distanceLabel(place); 149 const expoCount = (place.exhibitions || []).length; 150 card.innerHTML = img + '<div class="vpi-card-body">' + 151 '<div class="vpi-card-title"><span class="vpi-card-number">'+esc(place.number||'â¢')+'</span><span>'+esc(place.name)+'</span>'+(expoCount ? '<span class="vpi-card-count">'+expoCount+' expo'+(expoCount>1?'s':'')+'</span>' : '')+'</div>' + 152 '<div class="vpi-card-address">'+esc(place.address || '')+'</div>' + 153 expoSummary(place) + 154 '<div class="vpi-card-meta">'+esc([hoursLabel(place), distance].filter(Boolean).join(' · '))+'</div>' + 155 '</div>'; 156 card.addEventListener('click', () => showPlace(place)); 157 list.appendChild(card); 158 }); 159 } 160 161 function exhibitionRows(place){ 162 const expos = matchingExhibitions(place); 163 if (!expos.length) return ''; 164 return '<div class="vpi-sheet-expos"><div class="vpi-sheet-section-title">'+expos.length+' exposition'+(expos.length > 1 ? 's' : '')+'</div>' + 165 expos.map(ex => { 166 const inner = (ex.photographer ? '<div class="vpi-sheet-photographer">'+esc(ex.photographer)+'</div>' : '') + 167 '<div class="vpi-sheet-exhibition">'+esc(ex.title)+'</div>' + 168 (ex.url ? '<span class="vpi-sheet-expo-arrow">â</span>' : ''); 169 return ex.url ? '<a class="vpi-sheet-expo-row" href="'+esc(ex.url)+'">'+inner+'</a>' : '<div class="vpi-sheet-expo-row">'+inner+'</div>'; 170 }).join('') + '</div>'; 171 } 172 173 function showPlace(place){ 174 const d = distanceLabel(place); 175 const dir = directionsUrl(place); 176 sheetContent.innerHTML = 177 '<h3 class="vpi-sheet-title"><span class="vpi-sheet-number">'+esc(place.number||'â¢')+'</span><span>'+esc(place.name)+'</span></h3>'+ 178 (place.address ? '<p class="vpi-sheet-address">'+esc(place.address)+'</p>' : '')+ 179 '<div class="vpi-sheet-meta">'+ 180 (hoursLabel(place) ? '<div><span aria-hidden="true">â·</span> '+esc(hoursLabel(place))+'</div>' : '')+ 181 (d ? '<div><span aria-hidden="true">â</span> à '+esc(d)+'</div>' : '')+ 182 (place.special_note ? '<div>'+esc(place.special_note)+'</div>' : '')+ 183 '</div>'+ 184 exhibitionRows(place)+ 185 '<div class="vpi-sheet-actions">'+ 186 (dir ? '<a class="vpi-sheet-link vpi-sheet-link-primary" href="'+esc(dir)+'" target="_blank" rel="noopener">Itinéraire à pied <span>â</span></a>' : '')+ 187 '</div>'; 188 189 sheet.classList.add('is-open'); 190 sheet.setAttribute('aria-hidden','false'); 191 192 app.querySelectorAll('.vpi-pin').forEach(p => p.classList.remove('is-active')); 193 const target = app.querySelector('.vpi-pin[data-place-number="'+String(place.number)+'"]'); 194 if (target) target.classList.add('is-active'); 195 } 196 197 function closeSheet(){ 198 sheet.classList.remove('is-open'); 199 sheet.setAttribute('aria-hidden','true'); 200 app.querySelectorAll('.vpi-pin').forEach(p => p.classList.remove('is-active')); 201 } 202 203 locateBtn.addEventListener('click', function(){ 204 if (!navigator.geolocation) { 205 status.textContent = 'La géolocalisation nâest pas disponible sur cet appareil.'; 206 return; 207 } 208 status.textContent = 'Localisation en coursâ¦'; 209 navigator.geolocation.getCurrentPosition(function(pos){ 210 userLocation = {lat:pos.coords.latitude,lng:pos.coords.longitude}; 211 status.textContent = 'Les lieux sont classés du plus proche au plus éloigné.'; 212 locateBtn.classList.add('is-active'); 213 render(); 214 if (window.innerWidth < 768) listWrap.scrollIntoView({behavior:'smooth',block:'start'}); 215 }, function(){ 216 status.textContent = 'Impossible dâobtenir votre position. Vérifiez lâautorisation de localisation du navigateur.'; 217 }, {enableHighAccuracy:true,timeout:8000,maximumAge:60000}); 218 }); 219 220 listBtn.addEventListener('click', function(){ 221 listWrap.scrollIntoView({behavior:'smooth',block:'start'}); 222 }); 223 224 openNowBtn.addEventListener('click', function(){ 225 openFilter = !openFilter; 226 openNowBtn.classList.toggle('is-active',openFilter); 227 render(); 228 }); 229 230 searchInput.addEventListener('input', function(){ 231 query = this.value || ''; 232 closeSheet(); 233 render(); 234 }); 235 236 closeBtn.addEventListener('click', closeSheet); 237 document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSheet(); }); 238 239 render(); 240 } 241})();
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.