1/** 2 * Front page events map. 3 * 4 * Builds the Leaflet world map for the home events section from marker 5 * data localised by WordPress (no AJAX, no nonce: the payload is baked in 6 * at render time from the same cache the events page uses). The map is 7 * lazy-initialised when the section approaches the viewport so the front 8 * page pays no Leaflet cost up front. Zoom discipline matches the events 9 * page: one world, no wrap, no repeated tiles. 10 * 11 * The two-column map layout only activates when this script confirms it 12 * can build the map (the CSS keys on .cfm-home-events--map-live), so 13 * no-JS visitors and failed loads keep the clean list-only band. 14 * 15 * Markers are clustered per zoom level in pixel space (greedy pass plus a 16 * merge-to-fixpoint pass, no plugin) so marks can never overlap: nearby 17 * places merge into one house diamond carrying the summed count, and 18 * split apart as the visitor zooms in. Activating a multi-place diamond 19 * (click, Enter, or Space) zooms to the level where it splits; if its 20 * places sit too close to ever separate, it opens a popup listing them 21 * instead. Single places open the editorial popup. 22 * 23 * The initial view fits the continental region holding the majority of 24 * events (found by a coarse zoom-0 clustering), so the map opens on the 25 * heart of the calendar with its marks already split, not on a world of 26 * ocean. When no region holds more than half the events, it fits all. 27 */ 28(function () { 29 'use strict'; 30 31 var data = window.cfm_home_events || null; 32 var I18N = (data && data.i18n) || {}; 33 34 function t(key, fallback) { 35 return typeof I18N[key] === 'string' && I18N[key] !== '' ? I18N[key] : fallback; 36 } 37 38 var REDUCED_MOTION = !!(window.matchMedia && 39 window.matchMedia('(prefers-reduced-motion: reduce)').matches); 40 41 /* Minimum pixel gap between mark centres before they merge. ~2.5 pin 42 * widths keeps clear water between any two marks. */ 43 var CLUSTER_RADIUS = 44; 44 45 function init() { 46 var mapEl = document.getElementById('cfm-home-events-map'); 47 if ( 48 !mapEl || 49 !data || 50 !Array.isArray(data.markers) || 51 data.markers.length === 0 || 52 typeof window.L === 'undefined' 53 ) { 54 return; 55 } 56 57 /* Validated place groups (one entry per city, count = events there). */ 58 var groups = []; 59 for (var gi = 0; gi < data.markers.length; gi++) { 60 var g = data.markers[gi]; 61 if (typeof g.lat === 'number' && typeof g.lng === 'number') { 62 groups.push(g); 63 } 64 } 65 if (groups.length === 0) { 66 return; 67 } 68 69 var section = document.getElementById('cfm-home-events'); 70 if (section) { 71 section.classList.add('cfm-home-events--map-live'); 72 } 73 74 var built = false; 75 76 /* Memberships from the previous render: re-rendering tears down 77 * popups, so skip it when a zoom didn't change any cluster. */ 78 var lastSignature = ''; 79 80 function build() { 81 if (built) { 82 return; 83 } 84 built = true; 85 86 // Lock the minimum zoom to the level where one world fills the 87 // container width (world = 256 * 2^z px), so zooming out can 88 // never expose grey bands beside the map. 89 var mapWidth = mapEl.clientWidth || 1024; 90 var minZoom = Math.max(2, Math.ceil(Math.log(mapWidth / 256) / Math.LN2)); 91 92 var map = window.L.map(mapEl, { 93 zoomControl: true, 94 scrollWheelZoom: false, /* never hijack page scrolling */ 95 attributionControl: true, 96 minZoom: minZoom, 97 maxZoom: 19, 98 maxBounds: [[-85, -180], [85, 180]], 99 maxBoundsViscosity: 1.0, 100 zoomAnimation: !REDUCED_MOTION, 101 fadeAnimation: !REDUCED_MOTION, 102 markerZoomAnimation: !REDUCED_MOTION 103 }); 104 105 /* Plain-text prefix: Leaflet's default carries a colored flag 106 * glyph that fights the monochrome chrome. The required OSM 107 * credit on the tile layer stays untouched. */ 108 map.attributionControl.setPrefix( 109 '<a href="https://leafletjs.com" target="_blank" rel="noopener noreferrer">Leaflet</a>' 110 ); 111
112 map.setView([20, 0], minZoom); 113 114 window.L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 115 attribution: '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors', 116 noWrap: true, 117 maxZoom: 19 118 }).addTo(map); 119 120 var markerLayer = window.L.layerGroup().addTo(map); 121 122 map.on('zoomend', function () { 123 render(map, markerLayer); 124 }); 125 126 /* Open on the heart of the calendar, not the whole world: a 127 * coarse zoom-0 clustering finds the continental region 128 * holding most of the events, and when one clearly dominates 129 * (more than half of them) the initial view fits that region, 130 * so its places arrive already split into local marks. Far 131 * outliers stay one zoom-out away. Falls back to fitting 132 * everything when no region dominates. */ 133 var focus = groups; 134 var coarse = clusterAt(map, 0); 135 var heaviest = null; 136 var totalCount = 0; 137 for (var ci = 0; ci < coarse.length; ci++) { 138 totalCount += coarse[ci].count; 139 if (!heaviest || coarse[ci].count > heaviest.count) { 140 heaviest = coarse[ci]; 141 } 142 } 143 if (heaviest && heaviest.count * 2 > totalCount) { 144 focus = heaviest.members; 145 } 146 147 var pts = []; 148 for (var i = 0; i < focus.length; i++) { 149 pts.push([focus[i].lat, focus[i].lng]); 150 } 151 if (pts.length === 1) {
152 map.setView(pts[0], 5, { animate: false }); 153 } else { 154 map.fitBounds(window.L.latLngBounds(pts), { 155 padding: [48, 48], 156 maxZoom: 6, 157 animate: false 158 }); 159 } 160 161 render(map, markerLayer); 162 } 163 164 /* Greedy clustering in projected pixel space at the given zoom, 165 * then pairwise merging to a fixpoint: the greedy pass alone lets 166 * a cluster's centroid drift towards a neighbour, so only the 167 * merge pass guarantees every two final marks sit further apart 168 * than CLUSTER_RADIUS. n is tiny; the O(k^2) loop is free. */ 169 function clusterAt(map, zoom) { 170 var clusters = []; 171 172 for (var i = 0; i < groups.length; i++) { 173 var place = groups[i]; 174 var p = map.project([place.lat, place.lng], zoom); 175 var hit = null; 176 177 for (var j = 0; j < clusters.length; j++) { 178 if (centroidDistSq(clusters[j], p.x, p.y) <= CLUSTER_RADIUS * CLUSTER_RADIUS) { 179 hit = clusters[j]; 180 break; 181 } 182 } 183 184 if (!hit) { 185 hit = { sumX: 0, sumY: 0, count: 0, members: [] }; 186 clusters.push(hit); 187 } 188 189 hit.sumX += p.x; 190 hit.sumY += p.y; 191 hit.count += (typeof place.count === 'number' && place.count > 0) ? place.count : 1; 192 hit.members.push(place); 193 } 194 195 var merged = true; 196 while (merged) { 197 merged = false; 198 for (var a = 0; a < clusters.length && !merged; a++) { 199 for (var b = a + 1; b < clusters.length && !merged; b++) { 200 var cb = clusters[b]; 201 var cx = cb.sumX / cb.members.length; 202 var cy = cb.sumY / cb.members.length; 203 if (centroidDistSq(clusters[a], cx, cy) <= CLUSTER_RADIUS * CLUSTER_RADIUS) { 204 clusters[a].sumX += cb.sumX; 205 clusters[a].sumY += cb.sumY; 206 clusters[a].count += cb.count; 207 clusters[a].members = clusters[a].members.concat(cb.members); 208 clusters.splice(b, 1); 209 merged = true; 210 } 211 } 212 } 213 } 214 215 for (var k = 0; k < clusters.length; k++) { 216 var cl = clusters[k]; 217 var centre = map.unproject( 218 window.L.point(cl.sumX / cl.members.length, cl.sumY / cl.members.length), 219 zoom 220 ); 221 cl.lat = centre.lat; 222 cl.lng = centre.lng; 223 } 224 225 return clusters; 226 } 227 228 function centroidDistSq(cluster, x, y) { 229 var dx = cluster.sumX / cluster.members.length - x; 230 var dy = cluster.sumY / cluster.members.length - y; 231 return dx * dx + dy * dy; 232 } 233 234 function signature(clusters) { 235 var parts = []; 236 for (var i = 0; i < clusters.length; i++) { 237 var keys = []; 238 for (var j = 0; j < clusters[i].members.length; j++) { 239 keys.push(clusters[i].members[j].lat + ',' + clusters[i].members[j].lng); 240 } 241 keys.sort(); 242 parts.push(keys.join(';')); 243 } 244 parts.sort(); 245 return parts.join('|'); 246 } 247 248 function render(map, layer) { 249 var clusters = clusterAt(map, map.getZoom()); 250 var sig = signature(clusters); 251 if (sig === lastSignature) { 252 return; 253 } 254 lastSignature = sig; 255 256 layer.clearLayers(); 257 for (var i = 0; i < clusters.length; i++) {
258 addMarker(map, layer, clusters[i]); 259 } 260 } 261 262 /* The zoom level at which the cluster's two farthest members come 263 * apart (pixel distances double per zoom step). Null when the 264 * members are too close to ever separate. */ 265 function splitZoom(map, cluster) { 266 var zoom = map.getZoom(); 267 var maxSq = 0; 268 269 for (var i = 0; i < cluster.members.length; i++) { 270 var pi = map.project([cluster.members[i].lat, cluster.members[i].lng], zoom); 271 for (var j = i + 1; j < cluster.members.length; j++) { 272 var pj = map.project([cluster.members[j].lat, cluster.members[j].lng], zoom); 273 var dx = pi.x - pj.x; 274 var dy = pi.y - pj.y; 275 var dSq = dx * dx + dy * dy; 276 if (dSq > maxSq) { 277 maxSq = dSq; 278 } 279 } 280 } 281 282 if (maxSq === 0) { 283 return null; 284 } 285 286 var d = Math.sqrt(maxSq); 287 var target = zoom + Math.ceil(Math.log((CLUSTER_RADIUS + 1) / d) / Math.LN2); 288 target = Math.max(target, zoom + 1); 289 290 return target > map.getMaxZoom() ? null : target; 291 } 292 293 function addMarker(map, layer, cluster) { 294 var single = cluster.members.length === 1; 295 var latlng = single 296 ? [cluster.members[0].lat, cluster.members[0].lng] 297 : [cluster.lat, cluster.lng]; 298 299 var marker = window.L.marker(latlng, { icon: buildIcon(cluster.count) }); 300 marker.addTo(layer); 301 302 var label = ''; 303 304 if (single) { 305 var place = cluster.members[0]; 306 marker.bindPopup(buildPopup([place]), { closeButton: false }); 307 if (typeof place.place === 'string' && place.place !== '') { 308 label = place.count === 1 309 ? t('oneEventIn', '1 event in') + ' ' + place.place 310 : place.count + ' ' + t('eventsIn', 'events in') + ' ' + place.place; 311 } 312 } else { 313 var activate = function () { 314 if (marker.getPopup()) { 315 /* Dead-end cluster already fell back to a popup; 316 * Leaflet's own click/Enter toggle owns it now. */ 317 return; 318 } 319 var target = splitZoom(map, cluster); 320 if (target === null) { 321 /* The places can never separate (same or nearly 322 * identical coordinates): show what's inside 323 * instead of a zoom that goes nowhere. */ 324 marker.bindPopup(buildPopup(cluster.members), { closeButton: false }); 325 marker.openPopup(); 326 return; 327 } 328 var b = []; 329 for (var j = 0; j < cluster.members.length; j++) { 330 b.push([cluster.members[j].lat, cluster.members[j].lng]); 331 } 332 map.fitBounds(window.L.latLngBounds(b), { 333 padding: [48, 48], 334 maxZoom: target, 335 animate: !REDUCED_MOTION 336 }); 337 }; 338 339 marker.on('click', activate); 340 /* Leaflet only translates Enter into an action for 341 * popup-bound markers; wire the keys role="button" 342 * promises. */ 343 marker.on('keypress', function (e) { 344 if (e.originalEvent && e.originalEvent.keyCode === 13) { 345 activate(); 346 } 347 }); 348 marker.on('keydown', function (e) { 349 if (e.originalEvent && e.originalEvent.keyCode === 32) { 350 e.originalEvent.preventDefault(); 351 activate(); 352 } 353 }); 354 355 label = t('clusterLabel', '%1$s events in %2$s places. Select to zoom in.') 356 .replace('%1$s', String(cluster.count)) 357 .replace('%2$s', String(cluster.members.length)); 358 } 359 360 var el = marker.getElement(); 361 if (el) { 362 el.setAttribute('role', 'button'); 363 if (label !== '') { 364 el.setAttribute('aria-label', label); 365 } 366 } 367 } 368 369 function buildIcon(count) { 370 /* Static markup plus an integer â never event-supplied strings. 371 * One event: a solid ink diamond. Several: a paper-filled 372 * diamond carrying its count, stepped up a size per digit 373 * (the same house mark events.js draws; keep in lockstep). */ 374 if (count <= 1) { 375 return window.L.divIcon({ 376 className: 'cfm-events-pin', 377 iconSize: [18, 18], 378 iconAnchor: [9, 9], 379 popupAnchor: [0, -10], 380 html: '<span class="cfm-events-pin__dot"></span>' 381 }); 382 } 383 384 var shown = count > 99 ? '99+' : String(count); 385 var box = shown.length === 1 ? 30 : (shown.length === 2 ? 36 : 40); 386 return window.L.divIcon({ 387 className: 'cfm-events-pin cfm-events-pin--multi cfm-events-pin--m' + shown.length, 388 iconSize: [box, box], 389 iconAnchor: [box / 2, box / 2], 390 popupAnchor: [0, -(box / 2 + 2)], 391 html: '<span class="cfm-events-pin__dot"><span class="cfm-events-pin__count">' + shown + '</span></span>' 392 }); 393 } 394 395 /* One popup for both cases: a single place, or the member list of 396 * a cluster that cannot split. Built with textContent throughout â 397 * event-supplied strings never reach innerHTML. */ 398 function buildPopup(members) { 399 var wrap = document.createElement('div'); 400 wrap.className = 'cfm-home-events__popup'; 401 402 for (var i = 0; i < members.length; i++) { 403 var m = members[i]; 404 405 var place = document.createElement('p'); 406 place.className = 'cfm-home-events__popup-place'; 407 place.textContent = m.place || ''; 408 wrap.appendChild(place); 409 410 var count = document.createElement('p'); 411 count.className = 'cfm-home-events__popup-count'; 412 count.textContent = m.count === 1 413 ? t('oneEvent', '1 event') 414 : t('manyEvents', '%s events').replace('%s', String(m.count)); 415 wrap.appendChild(count); 416 } 417 418 if (data.eventsUrl) { 419 var link = document.createElement('a'); 420 link.className = 'cfm-home-events__popup-link'; 421 // Deep-link to the first event at this place so the directory 422 // scrolls straight to it instead of opening at the top. 423 var href = data.eventsUrl; 424 var firstId = members[0] && members[0].id; 425 if (firstId) { 426 href += '#event-' + encodeURIComponent(firstId); 427 } 428 link.setAttribute('href', href); 429 link.textContent = t('viewDirectory', 'View in the directory'); 430 wrap.appendChild(link); 431 } 432 433 return wrap; 434 } 435 436 if ('IntersectionObserver' in window) { 437 var io = new IntersectionObserver(function (entries) { 438 for (var i = 0; i < entries.length; i++) { 439 if (entries[i].isIntersecting) { 440 io.disconnect(); 441 build(); 442 return; 443 } 444 } 445 }, { rootMargin: '300px 0px' }); 446 io.observe(mapEl); 447 } else { 448 build(); 449 } 450 } 451 452 if (document.readyState === 'loading') { 453 document.addEventListener('DOMContentLoaded', init); 454 } else { 455 init(); 456 } 457})();
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.