saltylab-firmware/ui/sultee-tracker.html
sl-webui 2560718b39 feat: Sul-Tee GPS live tracking dashboard (Issue #709)
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>
2026-04-03 18:12:37 -04:00

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 &amp; 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>