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