Single-file vanilla JS dashboard at ui/sultee-tracker.html:
- Connects to ws://100.64.0.2:9090 (configurable, saved in localStorage)
- Parses {"type":"gps","data":{...},"timestamp":...} JSON frames from iPhone
- Leaflet.js + OpenStreetMap tiles with dark CSS filter
- Live position marker (cyan pulsing dot SVG icon)
- Orange polyline trail (up to 2000 points)
- Auto-centers on first GPS fix; FOLLOW/FREE toggle; drag disables follow
- Sidebar: speed (km/h, color-coded), altitude, heading, compass rose canvas,
h-accuracy bar (green/amber/red), coordinate display, fix count
- Scrollable trail log with timestamp + coords + speed per fix
- Exponential backoff auto-reconnect (2s→30s cap)
- CLEAR button resets trail, marker, log, fix count
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
621 lines
20 KiB
HTML
621 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Sul-Tee GPS Tracker</title>
|
|
|
|
<!-- Leaflet.js -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
:root {
|
|
--bg0: #050510;
|
|
--bg1: #070712;
|
|
--bg2: #0d0d20;
|
|
--border: #0c2a3a;
|
|
--cyan: #06b6d4;
|
|
--green: #22c55e;
|
|
--amber: #f59e0b;
|
|
--red: #ef4444;
|
|
--orange: #f97316;
|
|
--purple: #a78bfa;
|
|
--text: #9ca3af;
|
|
--text-hi:#d1d5db;
|
|
--dim: #374151;
|
|
}
|
|
|
|
html, body { height: 100%; overflow: hidden; }
|
|
|
|
body {
|
|
font-family: 'Courier New', Courier, monospace;
|
|
font-size: 12px;
|
|
background: var(--bg0);
|
|
color: var(--text);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* ── Header ──────────────────────────────────────────────────── */
|
|
#header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 6px 14px;
|
|
background: var(--bg1);
|
|
border-bottom: 1px solid var(--border);
|
|
flex-shrink: 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
.logo { color: var(--orange); font-weight: bold; letter-spacing: .15em; font-size: 13px; white-space: nowrap; }
|
|
#conn-bar { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 240px; }
|
|
#conn-dot {
|
|
width: 8px; height: 8px; border-radius: 50%;
|
|
background: var(--dim); flex-shrink: 0; transition: background .3s;
|
|
}
|
|
#conn-dot.connected { background: var(--green); }
|
|
#conn-dot.error { background: var(--red); animation: blink 1s infinite; }
|
|
#ws-input {
|
|
flex: 1; background: var(--bg2); border: 1px solid #1e3a5f;
|
|
border-radius: 4px; color: #67e8f9; padding: 2px 8px;
|
|
font-family: monospace; font-size: 11px;
|
|
}
|
|
#ws-input:focus { outline: none; border-color: var(--cyan); }
|
|
.hbtn {
|
|
padding: 3px 10px; border-radius: 4px; border: 1px solid #1e3a5f;
|
|
background: var(--bg2); color: #67e8f9; font-family: monospace;
|
|
font-size: 10px; font-weight: bold; cursor: pointer; white-space: nowrap;
|
|
}
|
|
.hbtn:hover { background: #0e4f69; }
|
|
#conn-label { font-size: 10px; color: var(--dim); white-space: nowrap; }
|
|
#fix-count { font-size: 10px; color: var(--text); margin-left: auto; white-space: nowrap; }
|
|
|
|
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.4} }
|
|
|
|
/* ── Main layout ─────────────────────────────────────────────── */
|
|
#main {
|
|
flex: 1;
|
|
display: flex;
|
|
min-height: 0;
|
|
gap: 0;
|
|
}
|
|
|
|
/* ── Map ─────────────────────────────────────────────────────── */
|
|
#map-wrap {
|
|
flex: 1;
|
|
position: relative;
|
|
min-width: 0;
|
|
}
|
|
#map { width: 100%; height: 100%; }
|
|
|
|
/* Leaflet dark-mode override */
|
|
.leaflet-tile { filter: brightness(.85) invert(1) hue-rotate(200deg) saturate(.7); }
|
|
.leaflet-container { background: #090915; }
|
|
|
|
/* trail colour patch (applied via JS options) */
|
|
|
|
/* ── Stats sidebar ───────────────────────────────────────────── */
|
|
#sidebar {
|
|
width: 220px;
|
|
flex-shrink: 0;
|
|
background: var(--bg1);
|
|
border-left: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.stat-section {
|
|
padding: 10px 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.section-title {
|
|
font-size: 8px; font-weight: bold; letter-spacing: .15em;
|
|
color: #0891b2; text-transform: uppercase; margin-bottom: 8px;
|
|
}
|
|
|
|
.big-stat {
|
|
display: flex; flex-direction: column; align-items: center;
|
|
padding: 6px 0;
|
|
}
|
|
.big-val { font-size: 38px; font-weight: bold; font-family: monospace; line-height: 1; }
|
|
.big-unit { font-size: 11px; color: var(--text); margin-top: 2px; }
|
|
.big-lbl { font-size: 8px; color: var(--dim); margin-top: 4px; letter-spacing: .1em; }
|
|
|
|
.kv-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 4px;
|
|
}
|
|
.kv-cell {
|
|
background: var(--bg2);
|
|
border: 1px solid var(--border);
|
|
border-radius: 5px;
|
|
padding: 6px 7px;
|
|
}
|
|
.kv-lbl { font-size: 8px; color: var(--dim); margin-bottom: 2px; }
|
|
.kv-val { font-size: 13px; font-weight: bold; font-family: monospace; }
|
|
|
|
.coord-row { font-size: 10px; color: var(--text); margin-bottom: 2px; }
|
|
.coord-val { color: var(--cyan); font-family: monospace; }
|
|
|
|
/* accuracy bar */
|
|
.acc-bar-wrap { height: 4px; background: var(--bg2); border-radius: 2px; margin-top: 6px; overflow: hidden; }
|
|
.acc-bar { height: 100%; border-radius: 2px; transition: width .4s, background .4s; }
|
|
|
|
/* heading compass rose */
|
|
#compass-wrap { display: flex; justify-content: center; padding: 6px 0; }
|
|
#compass-canvas { border-radius: 50%; border: 1px solid var(--border); }
|
|
|
|
/* trail log */
|
|
#trail-log {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
}
|
|
.trail-entry {
|
|
font-size: 9px;
|
|
color: var(--dim);
|
|
border-bottom: 1px solid #0c1a2a;
|
|
padding-bottom: 3px;
|
|
}
|
|
.trail-entry .te-lat { color: var(--cyan); }
|
|
.trail-entry .te-spd { color: var(--orange); }
|
|
|
|
/* ── Bottom bar ──────────────────────────────────────────────── */
|
|
#bottombar {
|
|
display: flex; align-items: center; gap: 12px;
|
|
padding: 4px 14px;
|
|
background: var(--bg1);
|
|
border-top: 1px solid var(--border);
|
|
font-size: 10px; color: var(--text);
|
|
flex-shrink: 0; flex-wrap: wrap;
|
|
}
|
|
.bb-item { display: flex; align-items: center; gap: 5px; }
|
|
.bb-lbl { color: var(--dim); }
|
|
#last-msg { margin-left: auto; color: var(--dim); }
|
|
|
|
/* ── Map controls overlay ────────────────────────────────────── */
|
|
#map-controls {
|
|
position: absolute; top: 10px; right: 10px; z-index: 1000;
|
|
display: flex; flex-direction: column; gap: 4px;
|
|
}
|
|
.map-btn {
|
|
padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border);
|
|
background: rgba(7,7,18,.85); color: var(--cyan);
|
|
font-family: monospace; font-size: 10px; font-weight: bold;
|
|
cursor: pointer; backdrop-filter: blur(4px);
|
|
white-space: nowrap;
|
|
}
|
|
.map-btn:hover { background: rgba(14,79,105,.85); }
|
|
.map-btn.active { background: rgba(6,182,212,.25); border-color: var(--cyan); }
|
|
|
|
@media (max-width: 600px) {
|
|
#sidebar { display: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- ── Header ── -->
|
|
<div id="header">
|
|
<div class="logo">⚡ SUL-TEE TRACKER</div>
|
|
<div id="conn-bar">
|
|
<div id="conn-dot"></div>
|
|
<input id="ws-input" type="text" value="ws://100.64.0.2:9090" placeholder="ws://host:port" />
|
|
<button class="hbtn" id="btn-connect">CONNECT</button>
|
|
<span id="conn-label">Not connected</span>
|
|
</div>
|
|
<span id="fix-count">0 fixes</span>
|
|
</div>
|
|
|
|
<!-- ── Main ── -->
|
|
<div id="main">
|
|
|
|
<!-- Map -->
|
|
<div id="map-wrap">
|
|
<div id="map"></div>
|
|
<div id="map-controls">
|
|
<button class="map-btn active" id="btn-follow">⊙ FOLLOW</button>
|
|
<button class="map-btn" id="btn-clear">✕ CLEAR</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sidebar -->
|
|
<div id="sidebar">
|
|
|
|
<!-- Speed (big) -->
|
|
<div class="stat-section">
|
|
<div class="section-title">Speed</div>
|
|
<div class="big-stat">
|
|
<div class="big-val" id="val-speed" style="color:#f97316">—</div>
|
|
<div class="big-unit">km/h</div>
|
|
<div class="big-lbl">GROUND SPEED</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Altitude + heading -->
|
|
<div class="stat-section">
|
|
<div class="section-title">Altitude & Heading</div>
|
|
<div class="kv-grid">
|
|
<div class="kv-cell">
|
|
<div class="kv-lbl">ALT m</div>
|
|
<div class="kv-val" id="val-alt">—</div>
|
|
</div>
|
|
<div class="kv-cell">
|
|
<div class="kv-lbl">HEADING °</div>
|
|
<div class="kv-val" id="val-hdg">—</div>
|
|
</div>
|
|
</div>
|
|
<div id="compass-wrap" style="margin-top:8px">
|
|
<canvas id="compass-canvas" width="120" height="120"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Accuracy + coords -->
|
|
<div class="stat-section">
|
|
<div class="section-title">Fix Quality</div>
|
|
<div class="kv-grid">
|
|
<div class="kv-cell">
|
|
<div class="kv-lbl">H-ACC m</div>
|
|
<div class="kv-val" id="val-acc">—</div>
|
|
</div>
|
|
<div class="kv-cell">
|
|
<div class="kv-lbl">FIXES</div>
|
|
<div class="kv-val" id="val-fixes">0</div>
|
|
</div>
|
|
</div>
|
|
<div class="acc-bar-wrap" style="margin-top:6px">
|
|
<div class="acc-bar" id="acc-bar"></div>
|
|
</div>
|
|
<div style="margin-top:8px">
|
|
<div class="coord-row">LAT <span class="coord-val" id="val-lat">—</span></div>
|
|
<div class="coord-row">LON <span class="coord-val" id="val-lon">—</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Trail log -->
|
|
<div class="section-title" style="padding:8px 12px 4px">Trail log</div>
|
|
<div id="trail-log"></div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Bottom bar ── -->
|
|
<div id="bottombar">
|
|
<div class="bb-item"><span class="bb-lbl">WS</span><span id="bb-ws">—</span></div>
|
|
<div class="bb-item"><span class="bb-lbl">MSGS</span><span id="bb-msgs">0</span></div>
|
|
<div class="bb-item"><span class="bb-lbl">TRAIL PTS</span><span id="bb-trail">0</span></div>
|
|
<span id="last-msg">No data yet</span>
|
|
</div>
|
|
|
|
<script>
|
|
'use strict';
|
|
|
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
|
|
const MAX_TRAIL = 2000; // max polyline points
|
|
const MAX_LOG = 60; // trail log entries
|
|
const RECONNECT_BASE = 2000;
|
|
const RECONNECT_MAX = 30000;
|
|
const RECONNECT_MUL = 1.5;
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
|
|
const gps = {
|
|
lat: null, lon: null, speed: null, altitude: null,
|
|
course: null, accuracy: null, fixes: 0,
|
|
};
|
|
let trail = []; // [{lat,lon}]
|
|
let logEntries = [];
|
|
let msgCount = 0;
|
|
let ws = null;
|
|
let followMode = true;
|
|
let reconnDelay = RECONNECT_BASE;
|
|
let reconnTimer = null;
|
|
let reconnTick = null;
|
|
|
|
// ── DOM ───────────────────────────────────────────────────────────────────────
|
|
|
|
const $ = id => document.getElementById(id);
|
|
|
|
// ── Leaflet map ───────────────────────────────────────────────────────────────
|
|
|
|
const map = L.map('map', {
|
|
center: [43.45, -79.76],
|
|
zoom: 15,
|
|
zoomControl: true,
|
|
});
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 19,
|
|
}).addTo(map);
|
|
|
|
// Marker with custom cyan icon
|
|
const markerIcon = L.divIcon({
|
|
className: '',
|
|
html: `<svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg">
|
|
<circle cx="11" cy="11" r="9" fill="#06b6d4" fill-opacity=".25" stroke="#06b6d4" stroke-width="2"/>
|
|
<circle cx="11" cy="11" r="4" fill="#06b6d4"/>
|
|
</svg>`,
|
|
iconSize: [22, 22],
|
|
iconAnchor: [11, 11],
|
|
});
|
|
|
|
let marker = null;
|
|
let polyline = L.polyline([], {
|
|
color: '#f97316', weight: 3, opacity: .85, lineJoin: 'round',
|
|
}).addTo(map);
|
|
|
|
// ── Canvas compass ────────────────────────────────────────────────────────────
|
|
|
|
function drawCompass(hdg) {
|
|
const canvas = $('compass-canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const W = canvas.width, H = canvas.height;
|
|
const cx = W / 2, cy = H / 2, r = W / 2 - 4;
|
|
|
|
ctx.clearRect(0, 0, W, H);
|
|
|
|
// Ring
|
|
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
ctx.strokeStyle = '#1e3a5f'; ctx.lineWidth = 1.5; ctx.stroke();
|
|
ctx.fillStyle = '#070712'; ctx.fill();
|
|
|
|
// Cardinal labels
|
|
const cards = ['N','E','S','W'];
|
|
ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
cards.forEach((c, i) => {
|
|
const a = i * Math.PI / 2;
|
|
const lx = cx + Math.sin(a) * (r - 10);
|
|
const ly = cy - Math.cos(a) * (r - 10);
|
|
ctx.fillStyle = c === 'N' ? '#ef4444' : '#4b5563';
|
|
ctx.fillText(c, lx, ly);
|
|
});
|
|
|
|
if (hdg == null || hdg < 0) {
|
|
ctx.fillStyle = '#374151'; ctx.font = '9px monospace';
|
|
ctx.fillText('—', cx, cy);
|
|
return;
|
|
}
|
|
|
|
// Needle
|
|
const rad = (hdg - 90) * Math.PI / 180;
|
|
const tipLen = r - 16, tailLen = 10;
|
|
|
|
ctx.save();
|
|
ctx.translate(cx, cy);
|
|
|
|
// North tip (orange)
|
|
ctx.beginPath();
|
|
ctx.moveTo(Math.cos(rad) * tipLen, Math.sin(rad) * tipLen);
|
|
ctx.lineTo(Math.cos(rad + Math.PI) * tailLen, Math.sin(rad + Math.PI) * tailLen);
|
|
ctx.strokeStyle = '#f97316'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.stroke();
|
|
|
|
// Center dot
|
|
ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#9ca3af'; ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
// ── Render GPS data ───────────────────────────────────────────────────────────
|
|
|
|
function render() {
|
|
const g = gps;
|
|
|
|
// Speed
|
|
const spd = g.speed != null ? (g.speed * 3.6).toFixed(1) : '—';
|
|
const spdNum = g.speed != null ? g.speed * 3.6 : null;
|
|
const spdColor = spdNum == null ? '#6b7280'
|
|
: spdNum < 5 ? '#22c55e'
|
|
: spdNum < 30 ? '#f97316'
|
|
: '#ef4444';
|
|
$('val-speed').textContent = spd;
|
|
$('val-speed').style.color = spdColor;
|
|
|
|
// Altitude
|
|
$('val-alt').textContent = g.altitude != null ? g.altitude.toFixed(1) : '—';
|
|
|
|
// Heading
|
|
const hdg = g.course != null && g.course >= 0 ? Math.round(g.course) : null;
|
|
$('val-hdg').textContent = hdg != null ? hdg + '°' : '—';
|
|
drawCompass(g.course);
|
|
|
|
// Accuracy
|
|
const acc = g.accuracy;
|
|
$('val-acc').textContent = acc != null ? acc.toFixed(1) : '—';
|
|
const accNorm = acc != null ? Math.min(1, acc / 50) : 0;
|
|
const accColor = acc == null ? '#374151' : acc < 5 ? '#22c55e' : acc < 15 ? '#f59e0b' : '#ef4444';
|
|
$('acc-bar').style.width = (accNorm * 100) + '%';
|
|
$('acc-bar').style.background = accColor;
|
|
$('val-acc').style.color = accColor;
|
|
|
|
// Fixes
|
|
$('val-fixes').textContent = g.fixes;
|
|
$('fix-count').textContent = g.fixes + ' fix' + (g.fixes !== 1 ? 'es' : '');
|
|
|
|
// Coords
|
|
$('val-lat').textContent = g.lat != null ? g.lat.toFixed(6) : '—';
|
|
$('val-lon').textContent = g.lon != null ? g.lon.toFixed(6) : '—';
|
|
|
|
// Bottom bar
|
|
$('bb-msgs').textContent = msgCount;
|
|
$('bb-trail').textContent = trail.length;
|
|
}
|
|
|
|
function updateMap(lat, lon) {
|
|
// Marker
|
|
if (!marker) {
|
|
marker = L.marker([lat, lon], { icon: markerIcon }).addTo(map);
|
|
if (followMode) map.setView([lat, lon], 16);
|
|
} else {
|
|
marker.setLatLng([lat, lon]);
|
|
if (followMode) map.panTo([lat, lon]);
|
|
}
|
|
|
|
// Trail
|
|
trail.push({ lat, lon });
|
|
if (trail.length > MAX_TRAIL) trail.shift();
|
|
polyline.setLatLngs(trail.map(p => [p.lat, p.lon]));
|
|
$('bb-trail').textContent = trail.length;
|
|
}
|
|
|
|
// ── Trail log ─────────────────────────────────────────────────────────────────
|
|
|
|
function addLogEntry(d, ts) {
|
|
const time = new Date(ts * 1000).toLocaleTimeString('en-US', { hour12: false });
|
|
const spd = d.speed != null ? (d.speed * 3.6).toFixed(1) : '?';
|
|
logEntries.unshift({ time, lat: d.latitude, lon: d.longitude, spd });
|
|
if (logEntries.length > MAX_LOG) logEntries.pop();
|
|
|
|
const log = $('trail-log');
|
|
log.innerHTML = logEntries.map(e =>
|
|
`<div class="trail-entry">${e.time} <span class="te-lat">${e.lat.toFixed(5)}, ${e.lon.toFixed(5)}</span> <span class="te-spd">${e.spd} km/h</span></div>`
|
|
).join('');
|
|
}
|
|
|
|
// ── WebSocket ─────────────────────────────────────────────────────────────────
|
|
|
|
function cancelReconn() {
|
|
if (reconnTimer) { clearTimeout(reconnTimer); reconnTimer = null; }
|
|
if (reconnTick) { clearInterval(reconnTick); reconnTick = null; }
|
|
}
|
|
|
|
function scheduleReconn(url) {
|
|
cancelReconn();
|
|
let secs = Math.round(reconnDelay / 1000);
|
|
$('conn-label').textContent = `Retry in ${secs}s…`;
|
|
$('conn-label').style.color = '#6b7280';
|
|
|
|
reconnTick = setInterval(() => {
|
|
secs = Math.max(0, secs - 1);
|
|
if (secs > 0) $('conn-label').textContent = `Retry in ${secs}s…`;
|
|
}, 1000);
|
|
|
|
reconnTimer = setTimeout(() => { reconnTimer = null; doConnect(url, true); }, reconnDelay);
|
|
reconnDelay = Math.min(reconnDelay * RECONNECT_MUL, RECONNECT_MAX);
|
|
}
|
|
|
|
function doConnect(url, isRetry) {
|
|
cancelReconn();
|
|
if (!isRetry) reconnDelay = RECONNECT_BASE;
|
|
if (ws) { try { ws.close(); } catch(_) {} ws = null; }
|
|
|
|
$('conn-label').textContent = 'Connecting…';
|
|
$('conn-label').style.color = '#6b7280';
|
|
$('conn-dot').className = '';
|
|
$('bb-ws').textContent = url;
|
|
|
|
try {
|
|
ws = new WebSocket(url);
|
|
} catch(e) {
|
|
$('conn-label').textContent = 'Bad URL';
|
|
$('conn-dot').className = 'error';
|
|
return;
|
|
}
|
|
|
|
ws.onopen = () => {
|
|
reconnDelay = RECONNECT_BASE;
|
|
cancelReconn();
|
|
$('conn-dot').className = 'connected';
|
|
$('conn-label').textContent = 'Connected';
|
|
$('conn-label').style.color = '#22c55e';
|
|
localStorage.setItem('sultee_ws_url', url);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
$('conn-dot').className = '';
|
|
ws = null;
|
|
scheduleReconn(url);
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
$('conn-dot').className = 'error';
|
|
$('conn-label').style.color = '#ef4444';
|
|
$('conn-label').textContent = 'Error';
|
|
};
|
|
|
|
ws.onmessage = evt => {
|
|
msgCount++;
|
|
let msg;
|
|
try { msg = JSON.parse(evt.data); } catch(_) { return; }
|
|
|
|
if (msg.type !== 'gps' || !msg.data) return;
|
|
const d = msg.data;
|
|
const ts = msg.timestamp || Math.floor(Date.now() / 1000);
|
|
|
|
// Update state
|
|
gps.lat = d.latitude ?? gps.lat;
|
|
gps.lon = d.longitude ?? gps.lon;
|
|
gps.speed = d.speed ?? gps.speed;
|
|
gps.altitude = d.altitude ?? gps.altitude;
|
|
gps.course = d.course ?? gps.course;
|
|
gps.accuracy = d.horizontalAccuracy ?? gps.accuracy;
|
|
gps.fixes++;
|
|
|
|
render();
|
|
|
|
if (gps.lat != null && gps.lon != null) {
|
|
updateMap(gps.lat, gps.lon);
|
|
addLogEntry(d, ts);
|
|
}
|
|
|
|
$('last-msg').textContent = 'Last: ' + new Date(ts * 1000).toLocaleTimeString('en-US', { hour12: false });
|
|
};
|
|
}
|
|
|
|
// ── Controls ──────────────────────────────────────────────────────────────────
|
|
|
|
$('btn-connect').addEventListener('click', () => {
|
|
doConnect($('ws-input').value.trim(), false);
|
|
});
|
|
$('ws-input').addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') doConnect($('ws-input').value.trim(), false);
|
|
});
|
|
|
|
$('btn-follow').addEventListener('click', () => {
|
|
followMode = !followMode;
|
|
$('btn-follow').classList.toggle('active', followMode);
|
|
$('btn-follow').textContent = followMode ? '⊙ FOLLOW' : '⊙ FREE';
|
|
if (followMode && gps.lat != null) map.panTo([gps.lat, gps.lon]);
|
|
});
|
|
|
|
$('btn-clear').addEventListener('click', () => {
|
|
trail = [];
|
|
logEntries = [];
|
|
gps.fixes = 0;
|
|
if (marker) { marker.remove(); marker = null; }
|
|
polyline.setLatLngs([]);
|
|
$('trail-log').innerHTML = '';
|
|
render();
|
|
});
|
|
|
|
// Disable follow on manual pan
|
|
map.on('dragstart', () => {
|
|
if (followMode) {
|
|
followMode = false;
|
|
$('btn-follow').classList.remove('active');
|
|
$('btn-follow').textContent = '⊙ FREE';
|
|
}
|
|
});
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
|
|
(function init() {
|
|
const saved = localStorage.getItem('sultee_ws_url') || 'ws://100.64.0.2:9090';
|
|
$('ws-input').value = saved;
|
|
drawCompass(null);
|
|
doConnect(saved, false);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|