UNPKG

gps

Version:
315 lines (265 loc) 9.08 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>gps.js — Live Position</title> <style> :root { --bg: #0f1115; --panel: #161a22; --text: #e6edf3; --muted: #9aa4b2; --accent: #4cc3ff; --ok: #22c55e; --warn: #f59e0b; --err: #ef4444; } * { box-sizing: border-box; } html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; } #map { position: absolute; inset: 0; } .hud { position: absolute; top: 12px; left: 12px; display: grid; gap: 8px; background: color-mix(in srgb, var(--panel) 92%, transparent); padding: 12px 14px; border-radius: 12px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35); border: 1px solid rgba(255, 255, 255, 0.06); backdrop-filter: blur(6px); max-width: min(92vw, 380px); } .hud h1 { margin: 0 0 6px; font-size: 16px; font-weight: 600; letter-spacing: 0.2px; } .row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; } .kv { display: grid; grid-template-columns: auto auto; gap: 6px 10px; align-items: baseline; } .kv .k { color: var(--muted); font-size: 12px; } .kv .v { font-variant-numeric: tabular-nums; } .controls { display: flex; gap: 8px; align-items: center; } button, .pill { appearance: none; border: 1px solid rgba(255, 255, 255, 0.08); background: #1b2130; color: var(--text); padding: 6px 10px; border-radius: 9px; cursor: pointer; font-size: 13px; } button:hover { border-color: rgba(255, 255, 255, 0.18); } .pill { cursor: default; } .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; vertical-align: middle; margin-right: 6px; } .ok { background: var(--ok); } .warn { background: var(--warn); } .err { background: var(--err); } a { color: var(--accent); text-decoration: none; } .footer { position: absolute; right: 12px; bottom: 12px; background: color-mix(in srgb, var(--panel) 92%, transparent); padding: 8px 10px; border-radius: 10px; font-size: 12px; color: var(--muted); border: 1px solid rgba(255, 255, 255, 0.06); backdrop-filter: blur(6px); } </style> </head> <body> <div id="map" aria-label="Map"></div> <div class="hud" role="region" aria-label="Live GPS HUD"> <h1>gps.js — Live Position</h1> <div class="row"> <div class="kv"> <div class="k">Lat</div> <div class="v" id="kv-lat"></div> <div class="k">Lon</div> <div class="v" id="kv-lon"></div> <div class="k">Alt</div> <div class="v" id="kv-alt"></div> <div class="k">Speed</div> <div class="v" id="kv-speed"></div> <div class="k">Track</div> <div class="v" id="kv-track"></div> <div class="k">HDOP</div> <div class="v" id="kv-hdop"></div> <div class="k">Sats used</div> <div class="v" id="kv-sats"></div> </div> <div class="controls"> <button id="btn-follow" aria-pressed="true">Follow: On</button> <span class="pill"><span class="dot err" id="dot"></span><span id="ws-status">Disconnected</span></span> </div> </div> </div> <div class="footer">Powered by <a href="https://raw.org/" target="_blank">raw.org</a></div> <script> // --- Utilities --- const fmt = (n, d = 5) => (n == null ? '–' : Number(n).toFixed(d)); const fmt0 = (n) => (n == null ? '–' : Number(n).toFixed(0)); // Stable 2×2 covariance ellipse at confidence p (default 0.95). // Returns array of {lat, lng} suitable for Google Maps Polygon path. function errorEllipse(mu, Sigma, p = 0.95, segments = 64) { // Sigma = [[a, b],[c, d]]. Assume symmetric; use average for off-diagonal. const a = +Sigma[0][0], b = 0.5 * (+Sigma[0][1] + +Sigma[1][0]), d = +Sigma[1][1]; // Chi-square inverse for 2 DOF: s = -2 ln(1-p) const s = -2 * Math.log(1 - p); // Eigenvalues const T = a + d; const D = a * d - b * b; const disc = Math.sqrt(Math.max(0, T * T / 4 - D)); const l1 = T / 2 + disc; // major const l2 = T / 2 - disc; // minor // Eigenvector for l1: (b, l1 - a) with fallbacks let vx = b, vy = l1 - a; if (Math.abs(vx) + Math.abs(vy) < 1e-12) { // nearly diagonal if (a >= d) { vx = 1; vy = 0; } else { vx = 0; vy = 1; } } const nrm = Math.hypot(vx, vy) || 1; vx /= nrm; vy /= nrm; const rx = Math.sqrt(Math.max(0, s * l1)); const ry = Math.sqrt(Math.max(0, s * l2)); const cos = Math.cos, sin = Math.sin; const out = new Array(segments + 1); const [lat0, lon0] = [+mu[0], +mu[1]]; for (let i = 0; i <= segments; i++) { const t = (i / segments) * Math.PI * 2; // ellipse param in eigen basis, rotate by [vx,vy] const ex = rx * cos(t), ey = ry * sin(t); const dx = vx * ex - vy * ey; const dy = vy * ex + vx * ey; out[i] = { lat: lat0 + dx, lng: lon0 + dy }; } return out; } // --- Google Maps setup --- let map, marker, ellipse, follow = true, firstFix = true; function initMap() { const center = { lat: 0, lng: 0 }; map = new google.maps.Map(document.getElementById('map'), { center, zoom: 15, mapId: 'f2a2d2b9c5d2b1a1', // custom map style id optional clickableIcons: false, streetViewControl: false, mapTypeControl: false, fullscreenControl: false, gestureHandling: 'greedy' }); marker = new google.maps.Marker({ position: center, map, title: 'Position' }); ellipse = new google.maps.Polygon({ paths: [], strokeColor: '#ffffff', strokeOpacity: 0.7, strokeWeight: 1, fillColor: '#4cc3ff', fillOpacity: 0.25, map }); document.getElementById('btn-follow').addEventListener('click', () => { follow = !follow; document.getElementById('btn-follow').textContent = `Follow: ${follow ? 'On' : 'Off'}`; document.getElementById('btn-follow').setAttribute('aria-pressed', String(follow)); }); startWS(); } // --- WebSocket with simple reconnect --- let ws, reconnectDelay = 500; function startWS() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/ws`); ws.onopen = () => { reconnectDelay = 500; document.getElementById('ws-status').textContent = 'Live'; document.getElementById('dot').className = 'dot ok'; }; ws.onmessage = (ev) => { let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.type === 'position') updateUI(msg.payload); }; ws.onclose = ws.onerror = () => { document.getElementById('ws-status').textContent = 'Disconnected'; document.getElementById('dot').className = 'dot err'; setTimeout(startWS, reconnectDelay); reconnectDelay = Math.min(8000, reconnectDelay * 1.8); }; } // --- UI update --- function updateUI(state) { const pos = state.position; if (!pos || !pos.pos) return; const lat = +pos.pos[0], lon = +pos.pos[1]; marker.setPosition({ lat, lng: lon }); const cov = pos.cov && pos.cov.length === 2 ? pos.cov : [[0, 0], [0, 0]]; const path = errorEllipse([lat, lon], cov, 0.95, 64); ellipse.setPaths(path); if (firstFix || follow) { firstFix = false; map.setCenter({ lat, lng: lon }); } document.getElementById('kv-lat').textContent = fmt(lat, 6); document.getElementById('kv-lon').textContent = fmt(lon, 6); document.getElementById('kv-alt').textContent = state.alt != null ? `${fmt(state.alt, 1)} m` : '–'; document.getElementById('kv-speed').textContent = state.speed != null ? `${fmt(state.speed, 1)} km/h` : '–'; document.getElementById('kv-track').textContent = state.track != null ? `${fmt(state.track, 0)}°` : '–'; document.getElementById('kv-hdop').textContent = state.hdop != null ? fmt(state.hdop, 2) : '–'; const sats = Array.isArray(state.satsActive) ? state.satsActive.length : (state.satsUsed ?? '–'); document.getElementById('kv-sats').textContent = sats; } </script> <script src="https://maps.googleapis.com/maps/api/js?key=AIzaSyADf2Ss5DEZdZMFEJ0f8fmi1KcYRZMYLZI&callback=initMap" async defer></script> </body> </html>