nav.js (18.1 KB)
1 // Nav mode: tap the compass to request location + orientation, drop a marker at 2 // the user's position, and follow them heading-up. Panning or pinch-rotating away 3 // suspends following; after 3s of no interaction the ladybug eases back and follow 4 // resumes. Touch only - script.js calls this from initMap once rotation is ready. 5 6 (function () { 7 var LADYBUG = "🐞"; 8 var FOLLOW_IDLE_MS = 3000; 9 var RING_REWIND_MS = 1200; // arc drain on touch; must stay under FOLLOW_IDLE_MS 10 var RING_FADE_MS = 200; // matches the 0.2s #compass box-shadow transition 11 var RING_LEAD_MS = 100; // arc completes a hair early so takeover feels instant 12 // ease-in-out with a firm landing: a flat tail reads as lag at takeover 13 var RING_FILL_EASE = "cubic-bezier(0.45, 0, 0.6, 0.9)"; 14 var FOLLOW_ANCHOR_Y = 0.80; // fraction down the panel: leaves room "ahead" 15 var NAV_TRANSITION_MS = 900; 16 var EASE_TAU_MS = 600; // dead-reckon correction smoothing (bigger = smoother/laggier) 17 var VEL_SMOOTH = 0.5; // per-fix blend of new velocity into the running estimate 18 var MAX_PREDICT_MS = 4000; // stop extrapolating this long after the last fix 19 var SNAP_DIST_M = 500; // a fix this far from the rendered bug just teleports 20 21 window.initNav = function (map, rotation) { 22 var compass = rotation.compass; 23 var panel = rotation.panel; 24 var ringArc = compass.querySelector(".ring-arc"); 25 26 // require an orientation sensor: keeps the compass off desktop touchscreens that 27 // can't give a heading. 28 if (!("DeviceOrientationEvent" in window)) { return; } 29 compass.classList.add("compass-enabled"); 30 31 // dedicated pane keeps the ladybug above every event square regardless of z math 32 map.createPane("ladybugPane").style.zIndex = 700; 33 34 // Pane for the location->event line, between tilePane (200) and markerPane (600). 35 // We position the line ourselves every frame (see renderLine), so it must NOT carry 36 // leaflet-zoom-animated, or Leaflet's zoom transform would fight our writes. 37 var linePane = map.createPane("linePane"); 38 linePane.style.zIndex = 250; 39 40 var heading = 0; // device heading in deg (0 = north) 41 var compassOn = false; // heading-up follow mode 42 var compassStarted = false; 43 var headingReady = false; // a real reading has arrived 44 var pendingEntry = false; // entered nav but waiting for first heading 45 46 var following = false; 47 var followIdleTimer = null; 48 var navAnim = null; 49 var rotAnimating = false; 50 51 var marker = null; // Leaflet marker for the ladybug 52 var lastFix = null; // { lat, lng, t } latest raw reading 53 var vel = { lat: 0, lng: 0 }; // deg/s, smoothed across fixes 54 var predictOn = false; 55 var watching = false; 56 57 // Line to the active event: a standalone SVG overlay. We reproject 58 // the two endpoints every frame via latLngToContainerPoint, so the 59 // line stays glued with constant 4px stroke. 60 var DRAW_MS = 300; 61 var lineSvg = null; 62 var lineEl = null; 63 var activeTarget = null; // { latlng, color } 64 var drawStart = 0; 65 var lineRAF = null; 66 67 68 // the panel-screen point the ladybug is pinned to while following 69 function followAnchorPoint() { 70 var r = panel.getBoundingClientRect(); 71 return { x: r.left + r.width / 2, y: r.top + r.height * FOLLOW_ANCHOR_Y }; 72 } 73 74 function makeIcon() { 75 return L.divIcon({ 76 className: "", 77 html: "<div class=\"ladybug-pin\">" + LADYBUG + "</div>", 78 iconSize: [40, 40], 79 iconAnchor: [20, 20] 80 }); 81 } 82 83 // --- line to active event --- 84 85 function ensureLineSvg() { 86 if (lineSvg) { return; } 87 var NS = "http://www.w3.org/2000/svg"; 88 lineSvg = document.createElementNS(NS, "svg"); 89 lineSvg.setAttribute("class", "ladybug-line-svg"); 90 lineEl = document.createElementNS(NS, "line"); 91 lineEl.setAttribute("class", "ladybug-line"); 92 lineSvg.appendChild(lineEl); 93 linePane.appendChild(lineSvg); 94 } 95 96 function setLineTarget(latlng, color) { 97 ensureLineSvg(); 98 activeTarget = { latlng: latlng, color: color }; 99 lineEl.setAttribute("stroke", color); 100 drawStart = performance.now(); 101 lineSvg.classList.add("visible"); 102 renderLine(); 103 if (!lineRAF) { lineRAF = requestAnimationFrame(tickLine); } 104 } 105 106 function clearLine() { 107 activeTarget = null; 108 if (lineRAF) { cancelAnimationFrame(lineRAF); lineRAF = null; } 109 if (lineSvg) { lineSvg.classList.remove("visible"); } 110 } 111 112 // linePane's local space is the map pane's space, offset from the container by the 113 // pane's live translation; subtract it to place a container point inside the pane. 114 // Grow the visible end from the ladybug toward the event over DRAW_MS. 115 function renderLine() { 116 if (!activeTarget || !marker || !lineEl) { return; } 117 var off = L.DomUtil.getPosition(map.getPane("mapPane")) || L.point(0, 0); 118 var ac = map.latLngToContainerPoint(marker.getLatLng()); 119 var bc = map.latLngToContainerPoint(activeTarget.latlng); 120 var a = { x: ac.x - off.x, y: ac.y - off.y }; 121 var b = { x: bc.x - off.x, y: bc.y - off.y }; 122 var p = Math.min(1, (performance.now() - drawStart) / DRAW_MS); 123 var e = p * p * (3 - 2 * p); // smoothstep 124 lineEl.setAttribute("x1", a.x); 125 lineEl.setAttribute("y1", a.y); 126 lineEl.setAttribute("x2", a.x + (b.x - a.x) * e); 127 lineEl.setAttribute("y2", a.y + (b.y - a.y) * e); 128 } 129 130 // keeps the line glued during one-finger pan and the ladybug glide (both move the 131 // map outside Leaflet's zoom rAF) 132 function tickLine() { 133 if (!activeTarget || !marker) { clearLine(); return; } 134 renderLine(); 135 lineRAF = requestAnimationFrame(tickLine); 136 } 137 138 // two-finger gestures move the map on Leaflet's own rAF; rendering on its move/zoom 139 // events puts the line in the same phase as the markers 140 map.on("move zoom", renderLine); 141 142 window.navLine = { setTarget: setLineTarget, clear: clearLine }; 143 144 // --- following --- 145 146 function startFollowing() { 147 following = true; 148 clearTimeout(followIdleTimer); 149 followIdleTimer = null; 150 pinToAnchor(); 151 if (compassOn) { animateRotation(); } 152 } 153 154 function stopFollowing() { 155 following = false; 156 pendingEntry = false; 157 if (navAnim) { cancelAnimationFrame(navAnim); navAnim = null; } 158 clearTimeout(followIdleTimer); 159 followIdleTimer = null; 160 } 161 162 // pan (no animation) so the ladybug lands on the anchor point under the current 163 // rotation. Inverting the anchor through the rotation makes the map spin about it. 164 function pinToAnchor() { 165 if (!following || !marker) { return; } 166 var a = followAnchorPoint(); 167 var target = rotation.clientToStage(a.x, a.y); 168 var cur = map.latLngToContainerPoint(marker.getLatLng()); 169 var dx = cur.x - target.x, dy = cur.y - target.y; 170 if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) { return; } 171 map.panBy([dx, dy], { animate: false }); 172 } 173 174 // Dead-reckon: extrapolate the last fix along its velocity and ease the rendered 175 // position toward that moving target, so the ladybug glides between readings. Fresh 176 // fixes steer the target; the ease supplies accel/decel. Extrapolation is capped 177 // so a stalled GPS doesn't walk the bug off; once converged the loop idles. 178 function startPredict() { 179 if (predictOn) { return; } 180 predictOn = true; 181 var prev = performance.now(); 182 var step = function (now) { 183 if (!marker || !lastFix) { predictOn = false; return; } 184 var dt = Math.min(now - prev, 100); // clamp tab-switch gaps 185 prev = now; 186 var ahead = Math.min(now - lastFix.t, MAX_PREDICT_MS) / 1000; 187 var tLat = lastFix.lat + vel.lat * ahead; 188 var tLng = lastFix.lng + vel.lng * ahead; 189 var cur = marker.getLatLng(); 190 var k = 1 - Math.exp(-dt / EASE_TAU_MS); // frame-rate independent 191 marker.setLatLng([cur.lat + (tLat - cur.lat) * k, cur.lng + (tLng - cur.lng) * k]); 192 if (following) { pinToAnchor(); } 193 var z = map.getZoom(); 194 var px = map.project(marker.getLatLng(), z).distanceTo(map.project(L.latLng(tLat, tLng), z)); 195 var still = Math.hypot(vel.lat, vel.lng) < 1e-7; 196 if (px <= 0.5 && (still || now - lastFix.t >= MAX_PREDICT_MS)) { predictOn = false; return; } 197 requestAnimationFrame(step); 198 }; 199 requestAnimationFrame(step); 200 } 201 202 // --- compass ring (suspend/countdown visuals) --- 203 204 var ringFadeTimer = null; 205 var ringFillTimer = null; // delays the refill until fade/rewind lands 206 var ringWaitUntil = 0; // when the in-flight fade or rewind ends 207 208 function ringSet(offset, transition) { 209 ringArc.style.transition = transition; 210 ringArc.style.strokeDashoffset = offset; 211 } 212 213 // snap the arc to empty, flushed so a transition set right after still runs 214 function ringReset() { 215 clearTimeout(ringFadeTimer); 216 ringFadeTimer = null; 217 clearTimeout(ringFillTimer); 218 ringFillTimer = null; 219 ringArc.style.transition = "none"; 220 ringArc.style.strokeDashoffset = 100; 221 ringArc.style.opacity = ""; 222 void ringArc.getBoundingClientRect(); 223 } 224 225 // touching the map suspends following (free-look). The countdown is NOT started 226 // here (only on release) so holding a finger down keeps control indefinitely. 227 function suspendFollow() { 228 if (!compassOn) { return; } 229 following = false; 230 if (navAnim) { cancelAnimationFrame(navAnim); navAnim = null; } 231 clearTimeout(followIdleTimer); // a new touch cancels a running countdown 232 followIdleTimer = null; 233 clearTimeout(ringFadeTimer); 234 ringFadeTimer = null; 235 clearTimeout(ringFillTimer); 236 ringFillTimer = null; 237 ringArc.style.opacity = ""; 238 if (!compass.classList.contains("paused")) { 239 // engaged follow: blue ring fades to the grey track; arc regrows later 240 compass.classList.add("paused"); 241 ringSet(100, "none"); 242 ringWaitUntil = performance.now() + RING_FADE_MS; 243 } else if (ringArc.style.strokeDashoffset !== "100") { 244 // arc has charge: drain it (a drain already in flight keeps its clock) 245 ringSet(100, "stroke-dashoffset " + RING_REWIND_MS + "ms ease-out"); 246 ringWaitUntil = performance.now() + RING_REWIND_MS; 247 } 248 } 249 250 // start the countdown once the LAST finger lifts; if fingers remain, keep waiting 251 function armEaseBack(e) { 252 if (!compassOn) { return; } 253 if (e && e.touches && e.touches.length > 0) { return; } 254 clearTimeout(followIdleTimer); 255 followIdleTimer = setTimeout(easeBack, FOLLOW_IDLE_MS); 256 // arc refills in sync with the countdown; let any in-flight fade/rewind land 257 // first, landing RING_LEAD_MS early so takeover feels instant 258 var wait = Math.max(0, ringWaitUntil - performance.now()); 259 var dur = FOLLOW_IDLE_MS - wait - RING_LEAD_MS; 260 clearTimeout(ringFillTimer); 261 if (wait > 0) { 262 ringFillTimer = setTimeout(function () { 263 ringFillTimer = null; 264 ringSet(0, "stroke-dashoffset " + dur + "ms " + RING_FILL_EASE); 265 }, wait); 266 } else { 267 ringReset(); 268 ringSet(0, "stroke-dashoffset " + dur + "ms " + RING_FILL_EASE); 269 } 270 } 271 272 // timer fired: the app takes over. The completed arc fades over the same 0.2s the 273 // inset ring fades grey->blue. 274 function easeBack() { 275 followIdleTimer = null; 276 compass.classList.remove("paused"); 277 ringArc.style.transition = "opacity 0.2s ease"; 278 ringArc.style.opacity = "0"; 279 clearTimeout(ringFadeTimer); 280 ringFadeTimer = setTimeout(ringReset, 200); 281 animateToAnchor(); 282 } 283 284 // glide the ladybug to the anchor AND rotate heading-up together off one ease, then 285 // engage following. Re-aims at the LIVE heading every frame so the glide lands with 286 // no leftover snap (the heading drifts during the 600ms). 287 function animateToAnchor() { 288 if (!compassOn) { return; } 289 if (navAnim) { cancelAnimationFrame(navAnim); navAnim = null; } 290 if (!marker) { startFollowing(); return; } 291 292 var startAngle = rotation.getAngle(); 293 var dAngle = rotation.normalizeDeg(rotation.normalizeDeg(-heading) - startAngle); 294 var t0 = performance.now(); 295 var ePrev = 0; 296 297 var step = function (now) { 298 if (!compassOn) { navAnim = null; return; } 299 if (followIdleTimer) { navAnim = null; return; } // user interrupted 300 var p = (now - t0) / NAV_TRANSITION_MS; 301 if (p >= 1) { 302 rotation.setAngle(rotation.normalizeDeg(-heading)); 303 navAnim = null; 304 startFollowing(); 305 return; 306 } 307 // keep turning the same way if the target slips across the ±180 seam 308 var d = rotation.normalizeDeg(rotation.normalizeDeg(-heading) - startAngle); 309 if (d - dAngle > 180) { d -= 360; } else if (dAngle - d > 180) { d += 360; } 310 dAngle = d; 311 var e = p * p * p * (p * (p * 6 - 15) + 10); // smootherstep 312 // move by the fraction of REMAINING distance this step covers, so cumulative 313 // progress is exactly `e` (frame-invariant despite panBy shifting the frame) 314 var frac = ePrev < 1 ? (e - ePrev) / (1 - ePrev) : 1; 315 ePrev = e; 316 rotation.setAngle(rotation.normalizeDeg(startAngle + dAngle * e)); 317 var a = followAnchorPoint(); 318 var target = rotation.clientToStage(a.x, a.y); 319 var cur = map.latLngToContainerPoint(marker.getLatLng()); 320 map.panBy([(cur.x - target.x) * frac, (cur.y - target.y) * frac], { animate: false }); 321 navAnim = requestAnimationFrame(step); 322 }; 323 navAnim = requestAnimationFrame(step); 324 } 325 326 // --- rotation (heading-up) --- 327 328 // ease stageAngle toward the live heading; runs only while following. 329 function animateRotation() { 330 if (rotAnimating) { return; } 331 rotAnimating = true; 332 var step = function () { 333 if (!compassOn || !following) { rotAnimating = false; return; } 334 var delta = rotation.normalizeDeg(-heading - rotation.getAngle()); 335 if (Math.abs(delta) < 0.05) { 336 rotation.setAngle(rotation.normalizeDeg(-heading)); 337 pinToAnchor(); 338 rotAnimating = false; 339 return; 340 } 341 rotation.setAngle(rotation.getAngle() + delta * 0.2); // exp ease 342 pinToAnchor(); // re-pin same tick so the ladybug never lags the spin 343 requestAnimationFrame(step); 344 }; 345 requestAnimationFrame(step); 346 } 347 348 // --- orientation --- 349 350 function startCompass() { 351 if (compassStarted) { return; } 352 compassStarted = true; 353 var onReading = function (e) { 354 var h; 355 if (typeof e.webkitCompassHeading === "number") { 356 h = e.webkitCompassHeading; // iOS: deg clockwise from north 357 } else if (typeof e.alpha === "number") { 358 h = 360 - e.alpha; 359 } else { 360 return; 361 } 362 heading = h; 363 var first = !headingReady; 364 headingReady = true; 365 if (first && pendingEntry) { pendingEntry = false; animateToAnchor(); return; } 366 if (compassOn) { animateRotation(); } 367 }; 368 window.addEventListener("deviceorientationabsolute", onReading, true); 369 window.addEventListener("deviceorientation", onReading, true); 370 } 371 372 function requestOrientationPermission() { 373 var DOE = window.DeviceOrientationEvent; 374 if (DOE && typeof DOE.requestPermission === "function") { 375 return DOE.requestPermission().then(function (s) { return s === "granted"; }).catch(function () { return false; }); 376 } 377 return Promise.resolve(true); 378 } 379 380 // --- location --- 381 382 map.on("locationfound", function (e) { 383 var now = performance.now(); 384 var firstFix = !marker; 385 if (firstFix) { 386 marker = L.marker(e.latlng, { icon: makeIcon(), interactive: false, pane: "ladybugPane" }).addTo(map); 387 // fade in: add .visible next frame so the opacity transition runs from 0 388 var pin = marker._icon && marker._icon.querySelector(".ladybug-pin"); 389 if (pin) { requestAnimationFrame(function () { pin.classList.add("visible"); }); } 390 // event already selected before location came on: draw the line now 391 var active = window.getActiveEvent && window.getActiveEvent(); 392 if (active) { setLineTarget(active.latlng, active.color); } 393 } else { 394 var dt = (now - lastFix.t) / 1000; 395 if (map.distance(e.latlng, marker.getLatLng()) >= SNAP_DIST_M) { 396 // huge jump (first good fix after a bad one): no glide across town 397 vel.lat = 0; 398 vel.lng = 0; 399 marker.setLatLng(e.latlng); 400 if (following) { pinToAnchor(); } 401 } else if (dt >= 10) { 402 // stale gap: old velocity means nothing 403 vel.lat = 0; 404 vel.lng = 0; 405 } else if (dt > 0.05) { 406 // velocity from the last two fixes drives the dead-reckoner; blending 407 // damps jitter (sub-50ms bursts keep the running value) 408 vel.lat += ((e.latlng.lat - lastFix.lat) / dt - vel.lat) * VEL_SMOOTH; 409 vel.lng += ((e.latlng.lng - lastFix.lng) / dt - vel.lng) * VEL_SMOOTH; 410 } 411 } 412 lastFix = { lat: e.latlng.lat, lng: e.latlng.lng, t: now }; 413 if (!firstFix) { startPredict(); } 414 // first fix: ease from the current view to the anchored ladybug rather than 415 // hard-jumping. animateToAnchor pans relative to the marker, so just engaging 416 // it (once a heading is ready) gives the fly-in for free. 417 if (firstFix && compassOn) { 418 if (headingReady) { animateToAnchor(); } 419 else { pendingEntry = true; } 420 } 421 }); 422 423 function startWatching() { 424 if (watching) { return; } 425 watching = true; 426 map.locate({ watch: true, enableHighAccuracy: true, maximumAge: 10000 }); 427 } 428 429 // --- compass tap (enter/exit nav mode) --- 430 function enterNav() { 431 requestOrientationPermission().then(function (ok) { 432 if (!ok) { return; } // orientation denied: stay off 433 startCompass(); 434 startWatching(); 435 compassOn = true; 436 compass.classList.remove("paused"); 437 ringReset(); 438 compass.classList.add("active"); 439 // ease to anchor + rotate heading-up together; defer to the first reading 440 // if none yet, so it rotates too (not move-then-rotate) 441 if (headingReady) { animateToAnchor(); } 442 else { pendingEntry = true; } 443 }); 444 } 445 446 function exitNav() { 447 compassOn = false; 448 compass.classList.remove("active", "paused"); 449 ringReset(); 450 stopFollowing(); 451 // angle holds where it is - no snap back to north 452 } 453 454 compass.style.pointerEvents = "auto"; // CSS sets none; nav makes it tappable 455 compass.style.cursor = "pointer"; 456 compass.addEventListener("click", function () { 457 if (compassOn) { exitNav(); } else { enterNav(); } 458 }); 459 460 // a map gesture in nav mode is a free-look: suspend on touchdown, start the 461 // ease-back only once the finger(s) release 462 var mapEl = map.getContainer(); 463 mapEl.addEventListener("touchstart", suspendFollow, { passive: true }); 464 mapEl.addEventListener("touchend", armEaseBack, { passive: true }); 465 mapEl.addEventListener("touchcancel", armEaseBack, { passive: true }); 466 467 // selecting an event counts as an interaction: suspend follow so the pan plays 468 // out, then restart the countdown. No-op outside nav mode (compassOn guards). 469 window.navInteract = function () { 470 suspendFollow(); 471 armEaseBack(); 472 }; 473 }; 474 })();