UNPKG

gps

Version:
568 lines (503 loc) 19.6 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>gps.js — Satellites Dashboard</title> <style> :root { --bg: #0f1115; --panel: #161a22; --text: #e6edf3; --muted: #9aa4b2; --line: #2a3142; --accent: #4cc3ff; --active: #22c55e; --warn: #f59e0b; --err: #ef4444; --red: #ef4444; --blue: #60a5fa; } * { box-sizing: border-box } html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, Arial, sans-serif } .wrap { display: grid; grid-template-columns: 1fr 420px; gap: 14px; min-height: 100%; padding: 14px } @media (max-width:980px) { .wrap { grid-template-columns: 1fr } } .card { background: var(--panel); border: 1px solid rgba(255, 255, 255, .06); border-radius: 12px; padding: 12px; box-shadow: 0 12px 30px rgba(0, 0, 0, .25) } h1 { margin: 0 0 8px; font-size: 18px } h2 { margin: 0 0 8px; font-size: 14px; color: var(--muted); font-weight: 600 } .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px } .kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 10px } .kv .k { color: var(--muted) } .kv .v { font-variant-numeric: tabular-nums } .status { display: flex; gap: 8px; align-items: center; margin-top: 4px } .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block } .ok { background: var(--active) } .bad { background: var(--err) } .pill { padding: 4px 8px; border: 1px solid rgba(255, 255, 255, .08); border-radius: 999px; background: #1b2130; color: var(--text) } .row { display: flex; align-items: center; justify-content: space-between; gap: 8px } .panel-title { display: flex; align-items: center; gap: 10px } .small { font-size: 12px; color: var(--muted) } /* SVG containers */ .svg-box { width: 100%; height: 100%; display: grid; place-items: center } #snr, #sky, #gauge { width: 100%; height: 320px } #snr { height: 220px } /* table */ table { width: 100%; border-collapse: collapse } th, td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: left } th { color: var(--muted); font-weight: 500; width: 40% } </style> </head> <body> <div class="wrap"> <!-- Left: Charts --> <div class="card"> <div class="row"> <div class="panel-title"> <h1>Satellites</h1> <span class="pill"><span id="conn-dot" class="dot bad"></span><span id="conn">Disconnected</span></span> </div> <div class="small" id="counts"></div> </div> <div class="grid-2" style="margin-top:8px"> <div class="card" style="padding:8px"> <h2>Sky View</h2> <div class="svg-box"><svg id="sky" viewBox="0 0 260 260" aria-label="Sky plot"></svg></div> </div> <div class="card" style="padding:8px"> <h2>Speed / Heading</h2> <div class="svg-box"><svg id="gauge" viewBox="0 0 180 180" aria-label="Speed & heading"></svg></div> </div> </div> <div class="card" style="margin-top:10px;padding:8px"> <h2>SNR (signal-to-noise)</h2> <div class="svg-box"><svg id="snr" viewBox="0 0 640 220" preserveAspectRatio="none" aria-label="SNR bars"></svg> </div> </div> </div> <!-- Right: State --> <div class="card"> <h1>Information</h1> <div class="kv" style="margin-bottom:8px"> <div class="k">Time</div> <div class="v" id="v-time"></div> <div class="k">Latitude</div> <div class="v" id="v-lat"></div> <div class="k">Longitude</div> <div class="v" id="v-lon"></div> <div class="k">Altitude</div> <div class="v" id="v-alt"></div> <div class="k">Speed</div> <div class="v" id="v-speed"></div> <div class="k">Track</div> <div class="v" id="v-track"></div> <div class="k">Bearing</div> <div class="v" id="v-bearing"></div> <div class="k">Fix</div> <div class="v" id="v-fix"></div> <div class="k">PDOP</div> <div class="v" id="v-pdop"></div> <div class="k">VDOP</div> <div class="v" id="v-vdop"></div> <div class="k">HDOP</div> <div class="v" id="v-hdop"></div> </div> <table> <tr> <th>Satellites in Use</th> <td id="v-active"></td> </tr> <tr> <th>Satellites in View</th> <td id="v-view"></td> </tr> </table> </div> </div> <script> 'use strict'; // ---------- helpers ---------- const $ = id => document.getElementById(id); const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); const fmt = (n, d = 5) => (n == null ? '—' : Number(n).toFixed(d)); const fmt0 = n => (n == null ? '—' : Number(n).toFixed(0)); const fmt1 = n => (n == null ? '—' : Number(n).toFixed(1)); // ---------- WebSocket ---------- let ws, reconnect = 500; function connect() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/ws`); ws.onopen = () => { reconnect = 500; $('conn').textContent = 'Live'; $('conn-dot').className = 'dot ok'; }; ws.onmessage = ev => { try { const msg = JSON.parse(ev.data); if (msg.type === 'state') queueUpdate(msg.payload); } catch { } }; ws.onclose = ws.onerror = () => { $('conn').textContent = 'Disconnected'; $('conn-dot').className = 'dot bad'; setTimeout(connect, reconnect); reconnect = Math.min(8000, reconnect * 1.8); }; } connect(); // create basic SVG elements function svgEl(tag) { return document.createElementNS('http://www.w3.org/2000/svg', tag); } const line = (x1, y1, x2, y2) => { const e = svgEl('line'); e.setAttribute('x1', x1); e.setAttribute('y1', y1); e.setAttribute('x2', x2); e.setAttribute('y2', y2); return e; }; const circle = (cx, cy, r) => { const e = svgEl('circle'); e.setAttribute('cx', cx); e.setAttribute('cy', cy); e.setAttribute('r', r); return e; }; const text = (x, y, s) => { const e = svgEl('text'); e.setAttribute('x', x); e.setAttribute('y', y); e.textContent = s; return e; }; const g = () => svgEl('g'); const rect = () => svgEl('rect'); const path = d => { const e = svgEl('path'); if (d) e.setAttribute('d', d); return e; }; // ---------- Sky plot ---------- (function initSky() { const svg = $('sky'); const cx = 130, cy = 130; const R = 110; // outer radius (horizon) // rings (0°,30°,60°): 0->R, 90->0 const ring = deg => (1 - deg / 90) * R; // rings [0, 30, 60].forEach(d => { const c = circle(cx, cy, ring(d)); c.setAttribute('fill', 'none'); c.setAttribute('stroke', 'rgba(255,255,255,.25)'); svg.appendChild(c); if (d !== 0) { const t = text(cx + ring(d) - 4, cy + 10, `${90 - d}°`); t.setAttribute('font-size', '9'); t.setAttribute('fill', '#9aa4b2'); svg.appendChild(t); } }); // crosshair (N/E/S/W) const cross = [0, 90, 180, 270].map(a => { const rad = a * Math.PI / 180; const x1 = cx + Math.cos(rad) * ring(60); const y1 = cy + Math.sin(rad) * ring(60); const x2 = cx + Math.cos(rad) * ring(0); const y2 = cy + Math.sin(rad) * ring(0); const l = line(x1, y1, x2, y2); l.setAttribute('stroke', 'rgba(255,255,255,.25)'); svg.appendChild(l); }); // ticks every 10° for (let i = 0; i < 36; i++) { const ang = (i * 10 - 90) * Math.PI / 180; const L = (i % 3 === 0) ? 12 : 6; const x1 = cx + Math.cos(ang) * R; const y1 = cy + Math.sin(ang) * R; const x2 = cx + Math.cos(ang) * (R - L); const y2 = cy + Math.sin(ang) * (R - L); const l = line(x1, y1, x2, y2); l.setAttribute('stroke', 'rgba(255,255,255,.25)'); svg.appendChild(l); if (i % 3 === 0) { const tx = cx + Math.cos(ang) * (R - 18); const ty = cy + Math.sin(ang) * (R - 18) + 4; const t = text(tx, ty, `${(i * 10 + 90) % 360}°`); t.setAttribute('font-size', '9'); t.setAttribute('fill', '#9aa4b2'); t.setAttribute('text-anchor', 'middle'); svg.appendChild(t); } } // satellites layer const group = g(); svg.appendChild(group); window.__sky__ = { svg, group, cx, cy, R, ring }; })(); function updateSky(state) { const { group, cx, cy, R, ring } = window.__sky__; const sats = Array.isArray(state.satsVisible) ? state.satsVisible : []; const act = new Set(Array.isArray(state.satsActive) ? state.satsActive : []); const byKey = new Map(); for (const child of group.children) { byKey.set(child.dataset.key, child); } const seen = new Set(); for (const s of sats) { const key = s.key || String(s.prn); seen.add(key); const az = (+s.azimuth || 0) * Math.PI / 180 - Math.PI / 2; // 0° = North const el = +s.elevation || 0; const r = ring(el); const x = cx + Math.cos(az) * r; const y = cy + Math.sin(az) * r; let node = byKey.get(key); if (!node) { node = circle(x, y, 5); node.dataset.key = key; node.appendChild(titleEl(`${s.system || ''} ${s.prn ?? ''}`)); group.appendChild(node); } node.setAttribute('cx', x); node.setAttribute('cy', y); const v = s.snr ?? 0; const c = clamp(v, 0, 60) / 60; // normalize const isActive = act.has(s.prn); // active → blue-ish; in view → red-ish; add intensity by snr const rC = Math.round((isActive ? 50 : 255) * (1 - c)); const gC = 0; const bC = Math.round((isActive ? 255 : 50) * c); node.setAttribute('fill', `rgb(${rC},${gC},${bC})`); node.setAttribute('stroke', 'rgba(255,255,255,.7)'); node.setAttribute('stroke-width', '1'); } // remove stale for (const [key, node] of byKey) { if (!seen.has(key)) node.remove(); } } function titleEl(txt) { const t = svgEl('title'); t.textContent = txt; return t; } // ---------- SNR bars ---------- (function initSNR() { const svg = $('snr'); const margin = { l: 28, r: 10, t: 12, b: 24 }; const W = 640, H = 220; // axes const axis = g(); // grid for SNR (0..60) for (let s = 0; s <= 60; s += 10) { const y = H - margin.b - (s / 60) * (H - margin.t - margin.b); const l = line(margin.l, y, W - margin.r, y); l.setAttribute('stroke', 'rgba(255,255,255,.12)'); axis.appendChild(l); const label = text(4, y + 4, s.toString()); label.setAttribute('fill', '#9aa4b2'); label.setAttribute('font-size', '10'); svg.appendChild(label); } svg.appendChild(axis); // bars group + labels group const bars = g(); const labels = g(); svg.appendChild(bars); svg.appendChild(labels); window.__snr__ = { svg, W, H, margin, bars, labels }; })(); function updateSNR(state) { const { svg, W, H, margin, bars, labels } = window.__snr__; const sats = Array.isArray(state.satsVisible) ? state.satsVisible : []; const act = new Set(Array.isArray(state.satsActive) ? state.satsActive : []); // clear current bars/labels bars.innerHTML = ''; labels.innerHTML = ''; const n = Math.max(1, sats.length); const innerW = W - margin.l - margin.r; const innerH = H - margin.t - margin.b; const bw = innerW / n; sats.forEach((s, i) => { const snr = clamp(s.snr ?? 0, 0, 60); const h = innerH * (snr / 60); const x = margin.l + i * bw + 1; const y = H - margin.b - h; const r = rect(); r.setAttribute('x', x); r.setAttribute('y', y); r.setAttribute('width', Math.max(1, bw - 2)); r.setAttribute('height', h); const c = Math.round(255 * (snr / 60)); const active = act.has(s.prn); r.setAttribute('fill', active ? `rgb(0,0,${c})` : `rgb(${c},0,0)`); r.setAttribute('opacity', '0.9'); bars.appendChild(r); const t = text(margin.l + i * bw + bw * 0.5, H - 6, String(s.prn ?? '')); t.setAttribute('fill', '#9aa4b2'); t.setAttribute('font-size', '10'); t.setAttribute('text-anchor', 'middle'); labels.appendChild(t); }); } // ---------- Gauge (speed + heading) ---------- (function initGauge() { const svg = $('gauge'); const cx = 90, cy = 90, R = 72; const startDeg = 270, endDeg = -10; // same semantics as original const maxSpeed = +(new URLSearchParams(location.search).get('maxSpeed') || 45); // scale ticks const path1 = []; const path2 = []; for (let i = 0; i <= maxSpeed; i++) { const deg = speedAngle(i, maxSpeed, startDeg, endDeg); const a = (endDeg + startDeg - deg) * Math.PI / 180; const long = i % 5 === 0; const r = long ? 7 : 4; const x1 = cx + Math.cos(a) * (r + 70); const y1 = cy - Math.sin(a) * (r + 70); const x2 = cx + Math.cos(a) * 70; const y2 = cy - Math.sin(a) * 70; (long ? path1 : path2).push(`M${x1},${y1}L${x2},${y2}`); if (long) { const tx = cx + Math.cos(a) * 60 - 5; const ty = cy - Math.sin(a) * 60 + 5; const label = text(tx, ty, String(maxSpeed - i)); label.setAttribute('font-size', '10'); label.setAttribute('font-weight', '600'); label.setAttribute('fill', '#9aa4b2'); svg.appendChild(label); } } const ticks1 = path(path1.join('')); ticks1.setAttribute('stroke', '#ffffff'); ticks1.setAttribute('stroke-opacity', '.6'); ticks1.setAttribute('fill', 'none'); ticks1.setAttribute('stroke-width', '2'); const ticks2 = path(path2.join('')); ticks2.setAttribute('stroke', '#ffffff'); ticks2.setAttribute('stroke-opacity', '.6'); ticks2.setAttribute('fill', 'none'); ticks2.setAttribute('stroke-width', '1'); svg.appendChild(ticks1); svg.appendChild(ticks2); // arc highlight const arc = path(); arc.setAttribute('fill', 'none'); arc.setAttribute('stroke', '#4cc3ff'); arc.setAttribute('stroke-width', '3'); arc.setAttribute('stroke-opacity', '0'); svg.appendChild(arc); // arrow for heading const arrow = path(`M ${-5},0 L ${-15},-15 L 0,-20 L 15,-15 Z`); arrow.setAttribute('fill', '#ef4444'); arrow.setAttribute('stroke', 'none'); arrow.setAttribute('transform', `translate(${cx},${cy}) rotate(0)`); svg.appendChild(arrow); // speed dot const dot = circle(cx, cy, 4); dot.setAttribute('fill', '#fff'); dot.setAttribute('stroke', '#000'); dot.setAttribute('stroke-width', '2'); svg.appendChild(dot); // labels N/E/S/W const labs = [['W', -30, 4], ['E', 30, 4], ['N', -4, -30], ['S', -4, 38]]; labs.forEach(([t, x, y]) => { const l = text(cx + x, cy + y, t); l.setAttribute('font-weight', '600'); l.setAttribute('fill', '#9aa4b2'); svg.appendChild(l); }); // speed big number const spd = text(90, 140, '0'); spd.setAttribute('font-size', '30'); spd.setAttribute('font-weight', '700'); spd.setAttribute('text-anchor', 'middle'); svg.appendChild(spd); const unit = text(90, 156, 'km/h'); unit.setAttribute('font-size', '12'); unit.setAttribute('fill', '#9aa4b2'); unit.setAttribute('text-anchor', 'middle'); svg.appendChild(unit); window.__gauge__ = { svg, cx, cy, R, startDeg, endDeg, maxSpeed, arc, arrow, dot, spd }; })(); function speedAngle(v, max, startDeg, endDeg) { // original mapping: end..start over range [0..max], clamped const s = clamp(v, 0, max); return endDeg + (s - max) / (0 - max) * (startDeg - endDeg); } function arcPath(cx, cy, r, a1, a2) { const toXY = (deg) => [cx + r * Math.cos((90 - deg) * Math.PI / 180), cy - r * Math.sin((90 - deg) * Math.PI / 180)]; const [x1, y1] = toXY(a1), [x2, y2] = toXY(a2); const large = (Math.abs(a2 - a1) % 360) > 180 ? 1 : 0; return `M${x1},${y1} A ${r},${r} 0 ${large} ${a2 > a1 ? 0 : 1} ${x2},${y2}`; } function updateGauge(state) { const { cx, cy, R, startDeg, endDeg, maxSpeed, arc, arrow, dot, spd } = window.__gauge__; const speed = +state.speed || 0; const bearing = +state.bearing || 0; spd.textContent = fmt0(speed); const ang = speedAngle(speed, maxSpeed, startDeg, endDeg); const px = cx + Math.cos(ang * Math.PI / 180) * R; const py = cy - Math.sin(ang * Math.PI / 180) * R; dot.setAttribute('cx', px); dot.setAttribute('cy', py); // glow proportional to speed const alpha = clamp(speed / maxSpeed, 0, 1); arc.setAttribute('d', arcPath(cx, cy, R, startDeg, ang)); arc.setAttribute('stroke-opacity', String(alpha)); arrow.setAttribute('transform', `translate(${cx},${cy}) rotate(${bearing})`); } // ---------- State table ---------- function updateStateTable(s) { $('v-time').textContent = s.time ? new Date(s.time).toISOString() : '—'; $('v-lat').textContent = fmt(s.lat, 6); $('v-lon').textContent = fmt(s.lon, 6); $('v-alt').textContent = s.alt != null ? `${fmt1(s.alt)} m` : '—'; $('v-speed').textContent = s.speed != null ? `${fmt1(s.speed)} km/h` : '—'; $('v-track').textContent = s.track != null ? `${fmt0(s.track)}°` : '—'; $('v-bearing').textContent = s.bearing != null ? `${fmt0(s.bearing)}°` : '—'; $('v-fix').textContent = s.fix || '—'; $('v-pdop').textContent = s.pdop != null ? fmt1(s.pdop) : '—'; $('v-vdop').textContent = s.vdop != null ? fmt1(s.vdop) : '—'; $('v-hdop').textContent = s.hdop != null ? fmt1(s.hdop) : '—'; const inUse = Array.isArray(s.satsActive) ? s.satsActive.length : (s.satsUsed ?? 0); const inView = Array.isArray(s.satsVisible) ? s.satsVisible.length : 0; $('v-active').textContent = inUse; $('v-view').textContent = inView; $('counts').textContent = `in use: ${inUse} • in view: ${inView}`; } // ---------- Frame-coalesced updates ---------- let pending = null; function queueUpdate(state) { pending = state; if (!window.__rafScheduled) { window.__rafScheduled = true; requestAnimationFrame(() => { window.__rafScheduled = false; if (!pending) return; updateSky(pending); updateSNR(pending); updateGauge(pending); updateStateTable(pending); pending = null; }); } } </script> </body> </html>