map.js (16.9 KB)


  1 // The Leaflet map: event markers, the rotate gesture engine (#map-stage spun as a unit,
  2 // since Leaflet 1.x can't rotate its own panes), and the loader for vendored Leaflet +
  3 // the NoGap tile layer. Taps forward to window.panel; rotation is handed to window.initNav.
  4 
  5 (function () {
  6 	var fmt = window.fmt;
  7 
  8 	var events = [];
  9 	var map = null;
 10 	var IS_TOUCH = window.matchMedia("(pointer: coarse)").matches;
 11 	var stageAngle = 0; // current map rotation in degrees, set by the rotate gesture
 12 
 13 	// Average lat/long of all events, so the page opens framing the events (no location
 14 	// permission until the compass is tapped). Fallback: Seattle.
 15 	function eventsCenter() {
 16 		if (!events.length) { return [47.6062, -122.3321]; }
 17 		var lat = 0, lng = 0;
 18 		events.forEach(function (m) { lat += m.latitude; lng += m.longitude; });
 19 		return [lat / events.length, lng / events.length];
 20 	}
 21 
 22 	// If the URL carries ?<slug>, open that event. Returns true if it selected one.
 23 	function selectFromUrl(select) {
 24 		var m = fmt.eventFromUrl(events);
 25 		if (!m) { return false; }
 26 		select(m, false);
 27 		return true;
 28 	}
 29 
 30 	// Rewrite the address bar to the event's deep link (no reload / history entry).
 31 	function syncUrl(m) {
 32 		if (window.history && history.replaceState) {
 33 			history.replaceState(null, "", fmt.eventUrl(m));
 34 		}
 35 	}
 36 
 37 	function clearUrl() {
 38 		if (window.history && history.replaceState && window.location.search) {
 39 			history.replaceState(null, "", window.location.pathname);
 40 		}
 41 	}
 42 
 43 	function initMap() {
 44 		// snapshot "now" once so marker filtering, z-ordering, and the intro all agree
 45 		var now = new Date();
 46 		// soonest first; passed events removed. With nothing upcoming, fall back to the
 47 		// 10 most-recently-ended events (most recent first) so the map isn't empty.
 48 		var upcoming = fmt.upcomingEvents(events, now);
 49 		var pastMode = upcoming.length === 0;
 50 		events = pastMode ? fmt.pastEvents(events, now, 10) : upcoming;
 51 
 52 		map = L.map("map-container", { attributionControl: false, doubleClickZoom: false, zoomControl: false, scrollWheelZoom: false, keyboard: false, maxZoom: 19, bounceAtZoomLimits: false, zoomSnap: 0 }).setView(eventsCenter(), 12.5);
 53 
 54 		// CartoDB tile scale matching display density for crisp tiles. Hardcoded (not
 55 		// detectRetina, which shifts zoom and shrinks labels). carto serves up to @4x.
 56 		var dpr = window.devicePixelRatio || 1;
 57 		var scale = dpr >= 4 ? "@4x" : dpr >= 3 ? "@3x" : dpr >= 2 ? "@2x" : "";
 58 		var carto = L.tileLayer("https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}" + scale + ".png", {
 59 			attribution: "",
 60 			subdomains: "abcd",
 61 			maxZoom: 19,
 62 			// NoGap canvas spans keepBuffer; at full DPR each canvas is ratio² in memory,
 63 			// so a small buffer keeps it under Safari's renderer limit (default 2)
 64 			keepBuffer: 1
 65 		});
 66 
 67 		carto.addTo(map);
 68 
 69 		var mapEl = map.getContainer();
 70 		map.on("mousedown", function () { mapEl.classList.add("map-grabbing"); });
 71 		document.addEventListener("mouseup", function () { mapEl.classList.remove("map-grabbing"); });
 72 
 73 		initSmoothWheel();
 74 
 75 		// background tap (not a marker, not a drag/hold) deselects; Leaflet's "click"
 76 		// fires only for genuine background taps
 77 		map.on("click", function () { window.panel.deselectEvent(); clearUrl(); });
 78 
 79 		// container may have been sized after init; force a recalc
 80 		setTimeout(function () {
 81 			if (map) { map.invalidateSize(); }
 82 		}, 200);
 83 
 84 		function makeIcon(m) {
 85 			var d = fmt.parseLocal(m.start);
 86 			return L.divIcon({
 87 				className: "",
 88 				html: "<div class=\"event-pin\" style=\"background:" + m.color + "\">" +
 89 					"<span class=\"pin-month\">" + fmt.MONTH_ABBR[d.getMonth()] + "</span>" +
 90 					"<span class=\"pin-day\">" + d.getDate() + "</span>" +
 91 					"</div>",
 92 				iconSize: [48, 48],
 93 				iconAnchor: [24, 24]
 94 			});
 95 		}
 96 
 97 		// Leaflet bakes a latitude term into each marker's z-index, and that spread can
 98 		// exceed a +1 step, so bump by a large amount to guarantee the newest tap wins.
 99 		var Z_STEP = 10000;
100 		// seed z by date (events sorted soonest-first) so soonest sit on top at load;
101 		// topEventZ starts above every seed so the first tap still lifts over all
102 		var topEventZ = (events.length + 1) * Z_STEP;
103 
104 		// Lift the event, type its panel, recenter - shared by marker taps and deep links.
105 		function selectMarker(m, animate) {
106 			topEventZ += Z_STEP;
107 			m._marker.setZIndexOffset(topEventZ);
108 			window.panel.selectEvent(m);
109 			syncUrl(m);
110 			// nav mode: stop following so the pan isn't fought + restart the ease-back
111 			if (window.navInteract) { window.navInteract(); }
112 			// low easeLinearity bends the default pan into a clear ease-in-out
113 			map.panTo([m.latitude, m.longitude], { animate: animate, duration: 0.7, easeLinearity: 0.1 });
114 		}
115 
116 		events.forEach(function (m, i) {
117 			var marker = L.marker([m.latitude, m.longitude], { icon: makeIcon(m) }).addTo(map);
118 			marker.setZIndexOffset((events.length - i) * Z_STEP);
119 			m._marker = marker; // so selectEvent can reach this pin's DOM
120 			marker.on("click", function () { selectMarker(m, true); });
121 		});
122 
123 		window.panel.setIntroData(events, selectMarker, pastMode);
124 
125 		// deep-linked? open it. otherwise show intro.
126 		if (!selectFromUrl(selectMarker)) { window.panel.paintIntro(); }
127 
128 		if (IS_TOUCH) {
129 			var rotation = initRotate();
130 			if (window.initNav) { window.initNav(map, rotation); }
131 		}
132 	}
133 
134 	// Desktop wheel/trackpad. Leaflet's stock scrollWheelZoom debounces and steps, which
135 	// feels sluggish; instead ease map._move() toward a goal zoom every frame (the touch
136 	// pinch path) anchored under the cursor, and pan via _rawPanBy so markers ride along
137 	// (_move only re-renders layers on zoom change). zoomSnap 0 lets fractional zoom flow.
138 	function initSmoothWheel() {
139 		var el = map.getContainer();
140 		var mode = null; // "pinch" | "wheel" | "pan" while a gesture is in flight
141 		var goalZoom = 0, cursorPoint = null, centerPoint = null, anchorLatLng = null;
142 		var rafId = null, idleTimer = null, moved = false, panning = false;
143 		var prevCenter = null, prevZoom = 0;
144 
145 		function step() {
146 			// something else moved the map (marker panTo, drag) - yield to it
147 			if (moved && (!map.getCenter().equals(prevCenter) || map.getZoom() !== prevZoom)) {
148 				clearTimeout(idleTimer);
149 				settle();
150 				return;
151 			}
152 			var zoom = map.getZoom() + (goalZoom - map.getZoom()) * 0.3;
153 			// keep the latlng under the cursor fixed while zooming
154 			var center = map.unproject(map.project(anchorLatLng, zoom).subtract(cursorPoint.subtract(centerPoint)), zoom);
155 			if (!moved) { map._moveStart(true, false); moved = true; }
156 			map._move(center, zoom);
157 			prevCenter = map.getCenter();
158 			prevZoom = map.getZoom();
159 			rafId = requestAnimationFrame(step);
160 		}
161 
162 		// end whichever gesture is in flight so tiles settle and moveend fires
163 		function settle() {
164 			mode = null;
165 			if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; }
166 			if (moved) { moved = false; map._moveEnd(true); }
167 			if (panning) { panning = false; map.fire("moveend"); }
168 		}
169 
170 		function armIdle() {
171 			clearTimeout(idleTimer);
172 			idleTimer = setTimeout(settle, 200);
173 		}
174 
175 		function beginZoom(clientX, clientY) {
176 			if (rafId === null) {
177 				map._stop();
178 				goalZoom = map.getZoom();
179 				centerPoint = map.getSize().divideBy(2);
180 				cursorPoint = map.mouseEventToContainerPoint({ clientX: clientX, clientY: clientY });
181 				anchorLatLng = map.containerPointToLatLng(cursorPoint);
182 				rafId = requestAnimationFrame(step);
183 			}
184 			armIdle();
185 		}
186 
187 		function looksLikeMouse(e) {
188 			if (e.deltaMode !== 0) { return true; }
189 			return typeof e.wheelDeltaY === "number" && e.wheelDeltaY !== 0 && e.wheelDeltaY % 120 === 0;
190 		}
191 
192 		el.addEventListener("wheel", function (e) {
193 			e.preventDefault();
194 			var next;
195 			if (e.ctrlKey) { next = "pinch"; }
196 			else if (mode === "pan" || mode === "wheel") { next = mode; } // locked while in flight
197 			else { next = looksLikeMouse(e) ? "wheel" : "pan"; }
198 			if (mode !== null && next !== mode) { clearTimeout(idleTimer); settle(); }
199 			mode = next;
200 			if (mode === "pan") {
201 				if (!panning) { panning = true; map.fire("movestart"); }
202 				map._rawPanBy(L.point(e.deltaX, e.deltaY));
203 				map.fire("move");
204 				armIdle();
205 			} else {
206 				beginZoom(e.clientX, e.clientY);
207 				var dy = e.deltaMode === 1 ? e.deltaY * 20 : e.deltaY; // line deltas (firefox mice) -> px
208 				goalZoom = map._limitZoom(goalZoom - dy * (mode === "pinch" ? 0.01 : 0.003));
209 				cursorPoint = map.mouseEventToContainerPoint(e);
210 			}
211 		}, { passive: false });
212 
213 		// Safari desktop fires gesture* events with a cumulative scale instead of
214 		// ctrl+wheel. Touch devices also have GestureEvent - skip them for native pinch.
215 		if (!IS_TOUCH && "GestureEvent" in window) {
216 			var gestureBaseZoom = 0;
217 			el.addEventListener("gesturestart", function (e) {
218 				e.preventDefault();
219 				if (mode !== null && mode !== "pinch") { clearTimeout(idleTimer); settle(); }
220 				mode = "pinch";
221 				beginZoom(e.clientX, e.clientY);
222 				gestureBaseZoom = goalZoom;
223 			});
224 			el.addEventListener("gesturechange", function (e) {
225 				e.preventDefault();
226 				if (rafId === null) { return; }
227 				goalZoom = map._limitZoom(gestureBaseZoom + Math.log2(e.scale));
228 				cursorPoint = map.mouseEventToContainerPoint(e);
229 				armIdle();
230 			});
231 			el.addEventListener("gestureend", function (e) { e.preventDefault(); });
232 		}
233 	}
234 
235 	// Rotate the whole #map-stage (oversized to the panel diagonal) via a CSS transform
236 	// and patch Leaflet's coordinate math so its native pinch-zoom/pan still land. Touch only.
237 	function initRotate() {
238 		var stage = document.getElementById("map-stage");
239 		var panel = document.getElementById("left-box");
240 		var compass = document.getElementById("compass");
241 
242 		// Tapping a marker in the rotated stage's clipped corner makes iOS Safari "reveal"
243 		// it by auto-scrolling the container (despite overflow:hidden), shifting the stage
244 		// off-center. Snap any such scroll back to origin. The reveal may move #left-box or
245 		// the document, so neutralize both; the root scroller emits on window.
246 		function pinScroll(eventTarget, scroller) {
247 			eventTarget.addEventListener("scroll", function () {
248 				if (scroller.scrollLeft || scroller.scrollTop) { scroller.scrollTo(0, 0); }
249 			}, { passive: true });
250 		}
251 		pinScroll(panel, panel);
252 		var root = document.scrollingElement || document.documentElement;
253 		pinScroll(window, root);
254 
255 		// square of the panel's diagonal so a rotated map never exposes the corners
256 		function sizeStage() {
257 			var w = panel.clientWidth, h = panel.clientHeight;
258 			var diag = Math.ceil(Math.hypot(w, h));
259 			stage.style.width = diag + "px";
260 			stage.style.height = diag + "px";
261 			applyStageTransform();
262 			map.invalidateSize();
263 		}
264 
265 		// stage is anchored at the panel center (top/left 50%); translate by half its size
266 		// to center, then rotate (translate before rotate so the pivot is the panel center)
267 		function applyStageTransform() {
268 			var half = (parseFloat(stage.style.width) || 0) / 2;
269 			stage.style.transform = "translate(" + (-half) + "px, " + (-half) + "px) rotate(" + stageAngle + "deg)";
270 			// pins counter-rotate so their date text stays upright
271 			stage.style.setProperty("--pin-counter", (-stageAngle) + "deg");
272 			// needle turns with the map so red keeps pointing to true north
273 			compass.style.setProperty("--compass-angle", stageAngle + "deg");
274 		}
275 
276 		function normalizeDeg(d) {
277 			return ((d + 180) % 360 + 360) % 360 - 180;
278 		}
279 
280 		// invert the stage rotation about the panel center to map a screen point into
281 		// stage-local coords (getBoundingClientRect gives only the axis-aligned box)
282 		function clientToStage(clientX, clientY) {
283 			var r = panel.getBoundingClientRect();
284 			var cx = r.left + r.width / 2;
285 			var cy = r.top + r.height / 2;
286 			var rad = (-stageAngle * Math.PI) / 180;
287 			var dx = clientX - cx, dy = clientY - cy;
288 			var rx = dx * Math.cos(rad) - dy * Math.sin(rad);
289 			var ry = dx * Math.sin(rad) + dy * Math.cos(rad);
290 			var half = (parseFloat(stage.style.width) || 0) / 2;
291 			return { x: rx + half, y: ry + half };
292 		}
293 
294 		// Leaflet derives container coords from the bounding rect, wrong once rotated.
295 		map.mouseEventToContainerPoint = function (e) {
296 			var p = clientToStage(e.clientX, e.clientY);
297 			return L.point(p.x, p.y);
298 		};
299 
300 		// one-finger pan: Leaflet computes the delta in raw screen px, so rotate it by
301 		// -stageAngle so the map tracks the finger.
302 		var _dragUpdate = L.Draggable.prototype._updatePosition;
303 		L.Draggable.prototype._updatePosition = function () {
304 			if (stageAngle && this._startPos && this._newPos) {
305 				var rad = (-stageAngle * Math.PI) / 180;
306 				var cos = Math.cos(rad), sin = Math.sin(rad);
307 				var dx = this._newPos.x - this._startPos.x;
308 				var dy = this._newPos.y - this._startPos.y;
309 				this._newPos = this._startPos.add(L.point(dx * cos - dy * sin, dx * sin + dy * cos));
310 			}
311 			_dragUpdate.call(this);
312 		};
313 
314 		// a panel touch sliding onto the map is handed off by iOS as a fresh map touchstart;
315 		// flag gestures starting outside the stage so the drag below can refuse it
316 		var panelGesture = false;
317 		var panelGestureEnded = 0;
318 		document.addEventListener("touchstart", function (e) {
319 			if (!stage.contains(e.target)) { panelGesture = true; }
320 		}, { passive: true, capture: true });
321 		function endPanelGesture(e) {
322 			if (e.touches.length === 0 && panelGesture) {
323 				panelGesture = false;
324 				panelGestureEnded = Date.now();
325 			}
326 		}
327 		document.addEventListener("touchend", endPanelGesture, { passive: true, capture: true });
328 		document.addEventListener("touchcancel", endPanelGesture, { passive: true, capture: true });
329 
330 		// the rotated ancestor inflates the pane's bounding box, so Leaflet's cached
331 		// "parent scale" is bogus and distorts the drag. Force it to 1:1.
332 		var _dragDown = L.Draggable.prototype._onDown;
333 		L.Draggable.prototype._onDown = function (e) {
334 			if (panelGesture || Date.now() - panelGestureEnded < 250) { return; }
335 			_dragDown.call(this, e);
336 			if (this._parentScale) { this._parentScale = { x: 1, y: 1 }; }
337 		};
338 
339 		// --- two-finger rotate, layered on Leaflet's native pinch ---
340 		var ROTATE_DEADZONE = 8; // deg of twist before rotation engages
341 		var rotGesture = null;
342 
343 		function fingerAngle(t0, t1) {
344 			return Math.atan2(t1.clientY - t0.clientY, t1.clientX - t0.clientX) * 180 / Math.PI;
345 		}
346 
347 		// A one-finger touch landing while a zoom animates is dropped (Leaflet won't route
348 		// to the drag handler until the anim ends), so end the in-flight zoom at the
349 		// earliest capture point. _stop() doesn't cancel the zoom anim; _onZoomTransitionEnd
350 		// clears _animatingZoom. Capture phase so it runs first.
351 		stage.addEventListener("touchstart", function (e) {
352 			if (e.touches.length === 1 && map._animatingZoom) {
353 				map._onZoomTransitionEnd();
354 			}
355 		}, { passive: true, capture: true });
356 
357 		stage.addEventListener("touchstart", function (e) {
358 			// rotation allowed in nav mode too; nav.js treats a manual twist as free-look
359 			if (e.touches.length === 2) {
360 				rotGesture = { startAngle: fingerAngle(e.touches[0], e.touches[1]), engaged: false, baseAngle: stageAngle };
361 			}
362 		}, { passive: true });
363 
364 		stage.addEventListener("touchmove", function (e) {
365 			if (!rotGesture || e.touches.length !== 2) { return; }
366 			var cur = fingerAngle(e.touches[0], e.touches[1]);
367 			var twist = normalizeDeg(cur - rotGesture.startAngle);
368 			if (!rotGesture.engaged) {
369 				if (Math.abs(twist) < ROTATE_DEADZONE) { return; } // dead-zone keeps pure pinch from rotating
370 				rotGesture.engaged = true;
371 				rotGesture.startAngle = cur; // take accumulated twist as the new zero so it doesn't jump
372 				rotGesture.baseAngle = stageAngle;
373 				twist = 0;
374 			}
375 			stageAngle = normalizeDeg(rotGesture.baseAngle + twist);
376 			applyStageTransform();
377 		}, { passive: true });
378 
379 		function endRotate(e) {
380 			if (rotGesture && e.touches.length < 2) { rotGesture = null; }
381 		}
382 		stage.addEventListener("touchend", endRotate);
383 		stage.addEventListener("touchcancel", endRotate);
384 
385 		window.addEventListener("resize", sizeStage);
386 		sizeStage();
387 
388 		// interface consumed by nav.js
389 		return {
390 			stage: stage,
391 			panel: panel,
392 			compass: compass,
393 			getAngle: function () { return stageAngle; },
394 			setAngle: function (deg) { stageAngle = deg; applyStageTransform(); },
395 			applyTransform: applyStageTransform,
396 			clientToStage: clientToStage,
397 			normalizeDeg: normalizeDeg
398 		};
399 	}
400 
401 	function mapFailed() {
402 		var el = document.getElementById("map-container");
403 		el.className = "map-error";
404 		el.textContent = "Unable to load map.";
405 	}
406 
407 	// NoGap composites each level's tiles onto one <canvas> so fractional zoom has no
408 	// per-tile seams. Loads after Leaflet (extends L.TileLayer); init regardless of failure.
409 	function loadNoGapThenInit() {
410 		var s = document.createElement("script");
411 		s.src = "resources/leaflet/leaflet-nogap.js";
412 		s.onload = initMap;
413 		s.onerror = initMap;
414 		document.body.appendChild(s);
415 	}
416 
417 	// Leaflet is vendored under resources/leaflet/ so the map works offline from first load.
418 	function loadLeaflet() {
419 		if (window.L) { loadNoGapThenInit(); return; }
420 		var s = document.createElement("script");
421 		s.src = "resources/leaflet/leaflet.js";
422 		s.onload = function () { window.L ? loadNoGapThenInit() : mapFailed(); };
423 		s.onerror = mapFailed;
424 		document.body.appendChild(s);
425 	}
426 
427 	window.eventsMap = {
428 		setEvents: function (data) { events = data; },
429 		load: loadLeaflet
430 	};
431 })();