PageSourceSearch

https://circularity.fm/wp-content/themes/circularity-fm/assets/js/home-events.js?ver=1.3.5

js circularity.fm collected 2026-10-02 10:43:43 UTC 14,419 bytes, 457 lines download raw bytes

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: '&copy; <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.