This repository has no description
18 kB
572 lines
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 <title>where is lassulus?</title>
7 <link rel="stylesheet" href="/leaflet/leaflet.css" />
8 <link rel="stylesheet" href="/maps.css" />
9 <style>
10 html,
11 body {
12 margin: 0;
13 height: 100%;
14 background: #111;
15 }
16 #map {
17 position: absolute;
18 inset: 0;
19 }
20 #info {
21 position: absolute;
22 z-index: 1000;
23 left: 0;
24 right: 0;
25 bottom: 0;
26 padding: 0.5em 1em;
27 font: 14px/1.4 monospace;
28 color: #eee;
29 background: rgba(0, 0, 0, 0.7);
30 }
31 #info a {
32 color: #9cf;
33 }
34 .ctl a {
35 font-size: 18px;
36 text-decoration: none;
37 }
38 .ctl a.on {
39 background: #39f;
40 }
41 #compass {
42 display: none;
43 position: absolute;
44 z-index: 1000;
45 left: 50%;
46 top: 50%;
47 transform: translate(-50%, -50%);
48 width: 14em;
49 padding: 1em;
50 border-radius: 1em;
51 text-align: center;
52 font: 14px/1.4 monospace;
53 color: #eee;
54 background: rgba(0, 0, 0, 0.75);
55 pointer-events: none;
56 }
57 body.nav #compass {
58 display: block;
59 }
60 #arrow {
61 width: 10em;
62 height: 10em;
63 transition: transform 0.2s linear;
64 }
65 #dist {
66 font-size: 2em;
67 font-weight: bold;
68 }
69 #sheet {
70 display: none;
71 position: absolute;
72 z-index: 1001;
73 left: 0;
74 right: 0;
75 bottom: 0;
76 padding: 1em;
77 border-radius: 1em 1em 0 0;
78 font: 14px/1.4 monospace;
79 color: #eee;
80 background: #111;
81 }
82 body.sheet #sheet {
83 display: block;
84 }
85 #sheet-head {
86 display: flex;
87 justify-content: space-between;
88 align-items: center;
89 margin-bottom: 0.75em;
90 }
91 #sheet-close {
92 border: 0;
93 font: inherit;
94 font-size: 1.4em;
95 color: #eee;
96 background: none;
97 cursor: pointer;
98 }
99 #sheet-share {
100 margin-top: 0.5em;
101 }
102 </style>
103 </head>
104 <body>
105 <div id="map"></div>
106 <div id="compass">
107 <svg id="arrow" viewBox="-50 -50 100 100">
108 <circle r="46" fill="none" stroke="#666" stroke-width="2" />
109 <path d="M0,-42 L18,20 L0,10 L-18,20 Z" fill="#e33" />
110 </svg>
111 <div id="dist">…</div>
112 <div id="dir"></div>
113 </div>
114 <div id="info">loading…</div>
115 <div id="sheet">
116 <div id="sheet-head">
117 <span>open lassulus' location in…</span>
118 <button id="sheet-close" aria-label="close">✕</button>
119 </div>
120 <div id="sheet-open" class="open"></div>
121 <div id="sheet-share" class="open">
122 <button id="share-link">share link…</button>
123 <button id="copy-link">copy link</button>
124 </div>
125 </div>
126 <script src="/leaflet/leaflet.js"></script>
127 <script src="/maps.js"></script>
128 <script>
129 (function () {
130 var map = L.map("map", { zoomControl: true });
131 L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
132 maxZoom: 19,
133 attribution:
134 '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
135 }).addTo(map);
136
137 var layer = L.layerGroup().addTo(map);
138 var meLayer = L.layerGroup().addTo(map);
139 var info = document.getElementById("info");
140 var arrow = document.getElementById("arrow");
141 var distEl = document.getElementById("dist");
142 var dirEl = document.getElementById("dir");
143
144 var target = null; // L.LatLng of lassulus' last point
145 var targetTime = null; // ISO timestamp of that point
146 var me = null; // L.LatLng of the viewer
147 var meError = null;
148 var heading = null; // device compass heading, degrees clockwise from north
149 var compassNote = "no compass"; // why heading is null
150 var navMode = false;
151 var trailBounds = null; // bounds of the first trail we loaded
152 var loaded = false; // first location.json fetched (even if empty)
153 var meFitted = false; // initial view already includes the viewer
154
155 // initial view: trail plus viewer, once each is first known
156 function initialView() {
157 if (!loaded) return;
158 var b = trailBounds ? L.latLngBounds([]).extend(trailBounds) : null;
159 if (me) {
160 b = b ? b.extend(me) : L.latLngBounds([me]);
161 meFitted = true;
162 }
163 if (b) map.fitBounds(b.pad(0.1), { maxZoom: 16 });
164 else map.setView([0, 0], 2);
165 }
166
167 function ago(iso) {
168 var s = Math.round((Date.now() - Date.parse(iso)) / 1000);
169 if (s < 60) return s + "s";
170 if (s < 3600) return Math.round(s / 60) + "min";
171 if (s < 86400) return (s / 3600).toFixed(1) + "h";
172 return (s / 86400).toFixed(1) + "d";
173 }
174
175 function fmtDist(m) {
176 if (m < 1000) return Math.round(m) + " m";
177 if (m < 10000) return (m / 1000).toFixed(1) + " km";
178 return Math.round(m / 1000) + " km";
179 }
180
181 // initial great-circle bearing from a to b, degrees clockwise from north
182 function bearing(a, b) {
183 var r = Math.PI / 180;
184 var p1 = a.lat * r,
185 p2 = b.lat * r,
186 dl = (b.lng - a.lng) * r;
187 var y = Math.sin(dl) * Math.cos(p2);
188 var x =
189 Math.cos(p1) * Math.sin(p2) -
190 Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
191 return (Math.atan2(y, x) / r + 360) % 360;
192 }
193
194 var points = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
195 function cardinal(deg) {
196 return points[Math.round(deg / 45) % 8];
197 }
198
199 // rotate along the shortest path so the css transition never spins 350°
200 var shown = 0;
201 function rotateArrow(deg) {
202 var d = ((((deg - shown) % 360) + 540) % 360) - 180;
203 shown += d;
204 arrow.style.transform = "rotate(" + shown + "deg)";
205 }
206
207 // distance/direction between viewer and lassulus: info bar + compass
208 function updateRel() {
209 var rel = document.getElementById("rel");
210 if (!target) return;
211 if (!me) {
212 if (rel)
213 rel.textContent = meError ? " · your location: " + meError : "";
214 distEl.textContent = "…";
215 dirEl.textContent = meError || "waiting for your location";
216 return;
217 }
218 var d = me.distanceTo(target);
219 var b = bearing(me, target);
220 if (rel) {
221 rel.innerHTML =
222 " · " +
223 fmtDist(d) +
224 " " +
225 cardinal(b) +
226 ' of you · <a href="https://www.openstreetmap.org/directions?route=' +
227 me.lat +
228 "%2C" +
229 me.lng +
230 "%3B" +
231 target.lat +
232 "%2C" +
233 target.lng +
234 '">route</a>';
235 }
236 distEl.textContent = fmtDist(d);
237 if (heading === null) {
238 // no compass: arrow is relative to north (map up)
239 rotateArrow(b);
240 dirEl.textContent =
241 Math.round(b) +
242 "° " +
243 cardinal(b) +
244 " (" +
245 compassNote +
246 ", north up)";
247 } else {
248 rotateArrow(b - heading);
249 dirEl.textContent = Math.round(b) + "° " + cardinal(b);
250 }
251 }
252
253 // trail fades from transparent (24h ago) to solid (now); one segment per point pair
254 function render(data) {
255 layer.clearLayers();
256 var pts = data.points;
257 if (!pts.length) {
258 target = null;
259 info.textContent = "no location in the last 24h";
260 return;
261 }
262 var t0 = Date.parse(pts[0].t),
263 t1 = Date.parse(pts[pts.length - 1].t) || t0 + 1;
264 for (var i = 1; i < pts.length; i++) {
265 var a = pts[i - 1],
266 b = pts[i];
267 var age = (Date.parse(b.t) - t0) / (t1 - t0);
268 L.polyline(
269 [
270 [a.lat, a.lon],
271 [b.lat, b.lon],
272 ],
273 {
274 color: "#e33",
275 weight: 4,
276 opacity: 0.15 + 0.85 * age,
277 },
278 ).addTo(layer);
279 }
280 var last = pts[pts.length - 1];
281 target = L.latLng(last.lat, last.lon);
282 targetTime = last.t;
283 updateSheet();
284 if (last.acc) {
285 L.circle(target, {
286 radius: last.acc,
287 color: "#39f",
288 weight: 1,
289 fillOpacity: 0.1,
290 }).addTo(layer);
291 }
292 L.marker(target)
293 .addTo(layer)
294 .bindPopup(
295 "last seen " +
296 ago(last.t) +
297 " ago<br>" +
298 new Date(last.t).toLocaleString(),
299 );
300 info.innerHTML =
301 "<b>lassulus</b> is <b>" +
302 last.state +
303 "</b> · last seen " +
304 ago(last.t) +
305 " ago" +
306 " · " +
307 pts.length +
308 " points in the last 24h" +
309 ' · <a href="https://www.openstreetmap.org/?mlat=' +
310 last.lat +
311 "&mlon=" +
312 last.lon +
313 "#map=16/" +
314 last.lat +
315 "/" +
316 last.lon +
317 '">osm</a><span id="rel"></span>';
318 updateRel();
319 return L.latLngBounds(
320 pts.map(function (p) {
321 return [p.lat, p.lon];
322 }),
323 );
324 }
325
326 function fitBoth() {
327 if (!me || !target) return;
328 map.fitBounds(L.latLngBounds([me, target]).pad(0.2), {
329 maxZoom: 17,
330 });
331 }
332
333 // viewer position: blue dot + accuracy circle
334 var meDot = null,
335 meAcc = null;
336 function onPosition(pos) {
337 me = L.latLng(pos.coords.latitude, pos.coords.longitude);
338 meError = null;
339 if (!meDot) {
340 meAcc = L.circle(me, {
341 radius: pos.coords.accuracy,
342 color: "#3c3",
343 weight: 1,
344 fillOpacity: 0.1,
345 }).addTo(meLayer);
346 meDot = L.circleMarker(me, {
347 radius: 7,
348 color: "#fff",
349 weight: 2,
350 fillColor: "#3c3",
351 fillOpacity: 1,
352 })
353 .addTo(meLayer)
354 .bindPopup("you");
355 } else {
356 meDot.setLatLng(me);
357 meAcc.setLatLng(me).setRadius(pos.coords.accuracy);
358 }
359 if (!meFitted) initialView();
360 updateRel();
361 }
362 function onPositionError(e) {
363 meError = e.code === 1 ? "permission denied" : "unavailable";
364 updateRel();
365 }
366 var watchId = null;
367 function watchMe() {
368 if (!navigator.geolocation) {
369 meError = "not supported";
370 return;
371 }
372 if (watchId !== null) navigator.geolocation.clearWatch(watchId);
373 watchId = navigator.geolocation.watchPosition(
374 onPosition,
375 onPositionError,
376 { enableHighAccuracy: true, maximumAge: 10000 },
377 );
378 }
379
380 // device compass. iOS exposes webkitCompassHeading; others give an
381 // absolute alpha (counter-clockwise from north) via deviceorientationabsolute.
382 function onOrientation(e) {
383 var h;
384 if (typeof e.webkitCompassHeading === "number") {
385 h = e.webkitCompassHeading;
386 } else if (e.absolute && e.alpha !== null) {
387 h = 360 - e.alpha;
388 } else {
389 return;
390 }
391 var screenAngle =
392 (screen.orientation && screen.orientation.angle) ||
393 window.orientation ||
394 0;
395 heading = (h + screenAngle + 360) % 360;
396 updateRel();
397 }
398 var orientationEvent =
399 "ondeviceorientationabsolute" in window
400 ? "deviceorientationabsolute"
401 : "deviceorientation";
402
403 function setNav(on) {
404 navMode = on;
405 document.body.classList.toggle("nav", on);
406 navBtn.classList.toggle("on", on);
407 if (!on) {
408 window.removeEventListener(orientationEvent, onOrientation);
409 heading = null;
410 compassNote = "no compass";
411 return;
412 }
413 var listen = function () {
414 window.addEventListener(orientationEvent, onOrientation);
415 };
416 // iOS 13+ (and newer Chromium): permission must be requested from
417 // the click handler
418 if (
419 window.DeviceOrientationEvent &&
420 typeof DeviceOrientationEvent.requestPermission === "function"
421 ) {
422 DeviceOrientationEvent.requestPermission()
423 .then(function (state) {
424 if (state === "granted") listen();
425 else compassNote = "compass " + state;
426 updateRel();
427 })
428 .catch(function () {
429 compassNote = "compass unavailable";
430 updateRel();
431 });
432 } else {
433 listen();
434 }
435 if (!me) watchMe();
436 fitBoth();
437 updateRel();
438 }
439
440 function button(container, text, title, onClick) {
441 var a = L.DomUtil.create("a", "", container);
442 a.href = "#";
443 a.setAttribute("role", "button");
444 a.title = title;
445 a.textContent = text;
446 L.DomEvent.on(a, "click", function (e) {
447 L.DomEvent.preventDefault(e);
448 onClick();
449 });
450 return a;
451 }
452
453 // snapshot link to the /at page, which offers the same "open in"
454 // choices to whoever receives it
455 function shareUrl() {
456 return (
457 location.origin +
458 "/at#" +
459 target.lat +
460 "," +
461 target.lng +
462 "@" +
463 Math.floor(Date.parse(targetTime) / 1000)
464 );
465 }
466 var sheetOpen = document.getElementById("sheet-open");
467 var shareLinkBtn = document.getElementById("share-link");
468 var copyLinkBtn = document.getElementById("copy-link");
469 // the share sheet only lists apps that accept shared text/urls, which
470 // is why the "open in" links above exist at all
471 if (!navigator.share) shareLinkBtn.remove();
472 function copyLink() {
473 var url = shareUrl();
474 var ask = function () {
475 window.prompt("copy this link", url);
476 };
477 if (!navigator.clipboard) return ask();
478 navigator.clipboard.writeText(url).then(function () {
479 copyLinkBtn.textContent = "copied ✓";
480 setTimeout(function () {
481 copyLinkBtn.textContent = "copy link";
482 }, 1500);
483 }, ask);
484 }
485 function shareLink() {
486 navigator
487 .share({
488 title: "where is lassulus?",
489 text:
490 "lassulus, last seen " + new Date(targetTime).toLocaleString(),
491 url: shareUrl(),
492 })
493 .catch(function (e) {
494 // AbortError: user closed the share sheet
495 if (e.name !== "AbortError") copyLink();
496 });
497 }
498 function updateSheet() {
499 if (target) renderMapLinks(sheetOpen, target.lat, target.lng);
500 }
501 function setSheet(on) {
502 if (on && !target) return;
503 document.body.classList.toggle("sheet", on);
504 shareBtn.classList.toggle("on", on);
505 if (on) updateSheet();
506 }
507 shareLinkBtn.addEventListener("click", shareLink);
508 copyLinkBtn.addEventListener("click", copyLink);
509 document
510 .getElementById("sheet-close")
511 .addEventListener("click", function () {
512 setSheet(false);
513 });
514 map.on("click", function () {
515 setSheet(false);
516 });
517
518 var navBtn, shareBtn;
519 var Controls = L.Control.extend({
520 options: { position: "topleft" },
521 onAdd: function () {
522 var div = L.DomUtil.create("div", "leaflet-bar ctl");
523 button(div, "◎", "show me and lassulus", function () {
524 if (!me) watchMe();
525 fitBoth();
526 });
527 navBtn = button(
528 div,
529 "➤",
530 "compass: point to lassulus",
531 function () {
532 setNav(!navMode);
533 },
534 );
535 shareBtn = button(
536 div,
537 "⤴",
538 "open in maps app / share lassulus' location",
539 function () {
540 setSheet(!document.body.classList.contains("sheet"));
541 },
542 );
543 L.DomEvent.disableClickPropagation(div);
544 return div;
545 },
546 });
547 new Controls().addTo(map);
548
549 function refresh() {
550 fetch("/location.json", { cache: "no-cache" })
551 .then(function (r) {
552 return r.json();
553 })
554 .then(function (data) {
555 var bounds = render(data);
556 if (!loaded) {
557 loaded = true;
558 trailBounds = bounds || null;
559 initialView();
560 }
561 })
562 .catch(function (e) {
563 info.textContent = "failed to load location: " + e;
564 });
565 }
566 refresh();
567 setInterval(refresh, 60 * 1000);
568 watchMe();
569 })();
570 </script>
571 </body>
572</html>