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 })();