This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

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 '&copy; <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>