feat: Sul-Tee GPS live tracking dashboard (Issue #709) #710

Merged
sl-jetson merged 3 commits from sl-webui/issue-709-gps-tracker into main 2026-04-03 22:43:55 -04:00
Showing only changes of commit bb354336c3 - Show all commits

View File

@ -8,6 +8,8 @@
<!-- Leaflet.js --> <!-- Leaflet.js -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> <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> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- ROSLIB for robot GPS via rosbridge -->
<script src="https://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js"></script>
<style> <style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
@ -18,11 +20,12 @@
--bg2: #0d0d20; --bg2: #0d0d20;
--border: #0c2a3a; --border: #0c2a3a;
--cyan: #06b6d4; --cyan: #06b6d4;
--blue: #3b82f6;
--blue-hi:#60a5fa;
--green: #22c55e; --green: #22c55e;
--amber: #f59e0b; --amber: #f59e0b;
--red: #ef4444; --red: #ef4444;
--orange: #f97316; --orange: #f97316;
--purple: #a78bfa;
--text: #9ca3af; --text: #9ca3af;
--text-hi:#d1d5db; --text-hi:#d1d5db;
--dim: #374151; --dim: #374151;
@ -43,8 +46,8 @@ body {
#header { #header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 10px;
padding: 6px 14px; padding: 5px 14px;
background: var(--bg1); background: var(--bg1);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
@ -58,10 +61,16 @@ body {
} }
#conn-dot.connected { background: var(--green); } #conn-dot.connected { background: var(--green); }
#conn-dot.error { background: var(--red); animation: blink 1s infinite; } #conn-dot.error { background: var(--red); animation: blink 1s infinite; }
#ros-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--dim); flex-shrink: 0; transition: background .3s;
}
#ros-dot.connected { background: var(--orange); }
#ros-dot.error { background: var(--red); animation: blink 1s infinite; }
#ws-input { #ws-input {
flex: 1; background: var(--bg2); border: 1px solid #1e3a5f; flex: 1; background: var(--bg2); border: 1px solid #1e3a5f;
border-radius: 4px; color: #67e8f9; padding: 2px 8px; border-radius: 4px; color: #67e8f9; padding: 2px 8px;
font-family: monospace; font-size: 11px; font-family: monospace; font-size: 11px; min-width: 120px; max-width: 200px;
} }
#ws-input:focus { outline: none; border-color: var(--cyan); } #ws-input:focus { outline: none; border-color: var(--cyan); }
.hbtn { .hbtn {
@ -71,32 +80,39 @@ body {
} }
.hbtn:hover { background: #0e4f69; } .hbtn:hover { background: #0e4f69; }
#conn-label { font-size: 10px; color: var(--dim); white-space: nowrap; } #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; } .hdr-badge {
font-size: 9px; padding: 2px 7px; border-radius: 3px; border: 1px solid;
font-weight: bold; white-space: nowrap;
}
.badge-phone { background: #0f172a; border-color: #1d4ed8; color: var(--blue-hi); }
.badge-robot { background: #1a0f00; border-color: #9a3412; color: var(--orange); }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.4} } @keyframes blink { 0%,100%{opacity:1} 50%{opacity:.4} }
/* ── Main layout ─────────────────────────────────────────────── */ /* ── Status bar ─────────────────────────────────────────────── */
#main { #status-bar {
flex: 1; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
display: flex; padding: 3px 14px; background: var(--bg1);
min-height: 0; border-bottom: 1px solid var(--border); font-size: 10px;
gap: 0;
} }
.sys-badge {
padding: 1px 7px; border-radius: 3px; font-weight: bold;
border: 1px solid; letter-spacing: .05em; font-size: 9px;
}
.badge-ok { background: #052e16; border-color: #166534; color: #4ade80; }
.badge-stale { background: #111827; border-color: var(--dim); color: #6b7280; }
/* ── Main layout ─────────────────────────────────────────────── */
#main { flex: 1; display: flex; min-height: 0; }
/* ── Map ─────────────────────────────────────────────────────── */ /* ── Map ─────────────────────────────────────────────────────── */
#map-wrap { #map-wrap { flex: 1; position: relative; min-width: 0; }
flex: 1;
position: relative;
min-width: 0;
}
#map { width: 100%; height: 100%; } #map { width: 100%; height: 100%; }
/* Leaflet dark-mode override */ /* Leaflet dark-mode override */
.leaflet-tile { filter: brightness(.85) invert(1) hue-rotate(200deg) saturate(.7); } .leaflet-tile { filter: brightness(.85) invert(1) hue-rotate(200deg) saturate(.7); }
.leaflet-container { background: #090915; } .leaflet-container { background: #090915; }
/* trail colour patch (applied via JS options) */
/* ── Stats sidebar ───────────────────────────────────────────── */ /* ── Stats sidebar ───────────────────────────────────────────── */
#sidebar { #sidebar {
width: 220px; width: 220px;
@ -109,44 +125,42 @@ body {
} }
.stat-section { .stat-section {
padding: 10px 12px; padding: 9px 12px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.section-title { .section-title {
font-size: 8px; font-weight: bold; letter-spacing: .15em; font-size: 8px; font-weight: bold; letter-spacing: .15em;
color: #0891b2; text-transform: uppercase; margin-bottom: 8px; color: #0891b2; text-transform: uppercase; margin-bottom: 7px;
} }
.section-title.phone-title { color: #2563eb; }
.section-title.robot-title { color: #c2410c; }
.big-stat { .big-stat {
display: flex; flex-direction: column; align-items: center; display: flex; flex-direction: column; align-items: center;
padding: 6px 0; padding: 4px 0;
} }
.big-val { font-size: 38px; font-weight: bold; font-family: monospace; line-height: 1; } .big-val { font-size: 36px; font-weight: bold; font-family: monospace; line-height: 1; }
.big-unit { font-size: 11px; color: var(--text); margin-top: 2px; } .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; } .big-lbl { font-size: 8px; color: var(--dim); margin-top: 4px; letter-spacing: .1em; }
.kv-grid { .kv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; }
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
}
.kv-cell { .kv-cell {
background: var(--bg2); background: var(--bg2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 5px; border-radius: 5px;
padding: 6px 7px; padding: 5px 7px;
} }
.kv-lbl { font-size: 8px; color: var(--dim); margin-bottom: 2px; } .kv-lbl { font-size: 8px; color: var(--dim); margin-bottom: 2px; }
.kv-val { font-size: 13px; font-weight: bold; font-family: monospace; } .kv-val { font-size: 13px; font-weight: bold; font-family: monospace; }
.coord-row { font-size: 10px; color: var(--text); margin-bottom: 2px; } .coord-row { font-size: 10px; color: var(--text); margin-bottom: 2px; }
.coord-val { color: var(--cyan); font-family: monospace; } .coord-val { font-family: monospace; }
/* accuracy bar */ /* accuracy bar */
.acc-bar-wrap { height: 4px; background: var(--bg2); border-radius: 2px; margin-top: 6px; overflow: hidden; } .acc-bar-wrap { height: 4px; background: var(--bg2); border-radius: 2px; margin-top: 5px; overflow: hidden; }
.acc-bar { height: 100%; border-radius: 2px; transition: width .4s, background .4s; } .acc-bar { height: 100%; border-radius: 2px; transition: width .4s, background .4s; }
/* heading compass rose */ /* compass */
#compass-wrap { display: flex; justify-content: center; padding: 6px 0; } #compass-wrap { display: flex; justify-content: center; padding: 6px 0; }
#compass-canvas { border-radius: 50%; border: 1px solid var(--border); } #compass-canvas { border-radius: 50%; border: 1px solid var(--border); }
@ -154,7 +168,7 @@ body {
#trail-log { #trail-log {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 8px 12px; padding: 6px 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 3px; gap: 3px;
@ -165,19 +179,19 @@ body {
border-bottom: 1px solid #0c1a2a; border-bottom: 1px solid #0c1a2a;
padding-bottom: 3px; padding-bottom: 3px;
} }
.trail-entry .te-lat { color: var(--cyan); } .trail-entry .te-lat { color: var(--blue-hi); }
.trail-entry .te-spd { color: var(--orange); } .trail-entry .te-spd { color: var(--orange); }
/* ── Bottom bar ──────────────────────────────────────────────── */ /* ── Bottom bar ──────────────────────────────────────────────── */
#bottombar { #bottombar {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 10px;
padding: 4px 14px; padding: 4px 14px;
background: var(--bg1); background: var(--bg1);
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
font-size: 10px; color: var(--text); font-size: 10px; color: var(--text);
flex-shrink: 0; flex-wrap: wrap; flex-shrink: 0; flex-wrap: wrap;
} }
.bb-item { display: flex; align-items: center; gap: 5px; } .bb-item { display: flex; align-items: center; gap: 4px; }
.bb-lbl { color: var(--dim); } .bb-lbl { color: var(--dim); }
#last-msg { margin-left: auto; color: var(--dim); } #last-msg { margin-left: auto; color: var(--dim); }
@ -196,6 +210,17 @@ body {
.map-btn:hover { background: rgba(14,79,105,.85); } .map-btn:hover { background: rgba(14,79,105,.85); }
.map-btn.active { background: rgba(6,182,212,.25); border-color: var(--cyan); } .map-btn.active { background: rgba(6,182,212,.25); border-color: var(--cyan); }
/* Legend overlay */
#map-legend {
position: absolute; bottom: 26px; left: 10px; z-index: 1000;
background: rgba(7,7,18,.88); border: 1px solid var(--border);
border-radius: 5px; padding: 5px 9px;
display: flex; flex-direction: column; gap: 4px;
font-size: 10px; backdrop-filter: blur(4px);
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
@media (max-width: 600px) { @media (max-width: 600px) {
#sidebar { display: none; } #sidebar { display: none; }
} }
@ -207,12 +232,26 @@ body {
<div id="header"> <div id="header">
<div class="logo">⚡ SUL-TEE TRACKER</div> <div class="logo">⚡ SUL-TEE TRACKER</div>
<div id="conn-bar"> <div id="conn-bar">
<div id="conn-dot"></div> <div id="conn-dot" title="Phone WS"></div>
<div id="ros-dot" title="Robot ROS"></div>
<input id="ws-input" type="text" value="ws://100.64.0.2:9090" placeholder="ws://host:port" /> <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> <button class="hbtn" id="btn-connect">CONNECT</button>
<span id="conn-label">Not connected</span> <span id="conn-label">Not connected</span>
</div> </div>
<span id="fix-count">0 fixes</span> <span class="hdr-badge badge-phone" id="hdr-phone">📱 0 fixes</span>
<span class="hdr-badge badge-robot" id="hdr-robot">🤖 0 fixes</span>
</div>
<!-- ── Status bar ── -->
<div id="status-bar">
<span style="color:var(--dim);font-size:9px">PHONE GPS</span>
<span class="sys-badge badge-stale" id="badge-phone">NO DATA</span>
<span style="color:var(--dim)"></span>
<span style="color:var(--dim);font-size:9px">ROBOT FIX</span>
<span class="sys-badge badge-stale" id="badge-robot-fix">NO DATA</span>
<span style="color:var(--dim);font-size:9px">ROBOT VEL</span>
<span class="sys-badge badge-stale" id="badge-robot-vel">NO DATA</span>
<span id="status-last" style="color:var(--dim);margin-left:auto">Awaiting data…</span>
</div> </div>
<!-- ── Main ── --> <!-- ── Main ── -->
@ -221,28 +260,35 @@ body {
<!-- Map --> <!-- Map -->
<div id="map-wrap"> <div id="map-wrap">
<div id="map"></div> <div id="map"></div>
<div id="map-controls"> <div id="map-controls">
<button class="map-btn active" id="btn-follow">⊙ FOLLOW</button> <button class="map-btn active" id="btn-follow">⊙ FOLLOW</button>
<button class="map-btn" id="btn-fit">⊞ FIT ALL</button>
<button class="map-btn" id="btn-clear">✕ CLEAR</button> <button class="map-btn" id="btn-clear">✕ CLEAR</button>
</div> </div>
<div id="map-legend">
<div class="legend-item"><div class="legend-dot" style="background:#3b82f6;border:2px solid #60a5fa"></div><span style="color:#60a5fa">Phone / User</span></div>
<div class="legend-item"><div class="legend-dot" style="background:#f97316;border:2px solid #fb923c"></div><span style="color:#f97316">Robot (SAUL-TEE)</span></div>
</div>
</div> </div>
<!-- Sidebar --> <!-- Sidebar -->
<div id="sidebar"> <div id="sidebar">
<!-- Speed (big) --> <!-- Phone speed (big) -->
<div class="stat-section"> <div class="stat-section">
<div class="section-title">Speed</div> <div class="section-title phone-title">📱 Phone Speed</div>
<div class="big-stat"> <div class="big-stat">
<div class="big-val" id="val-speed" style="color:#f97316"></div> <div class="big-val" id="val-speed" style="color:#3b82f6"></div>
<div class="big-unit">km/h</div> <div class="big-unit">km/h</div>
<div class="big-lbl">GROUND SPEED</div> <div class="big-lbl">GROUND SPEED</div>
</div> </div>
</div> </div>
<!-- Altitude + heading --> <!-- Phone altitude + heading -->
<div class="stat-section"> <div class="stat-section">
<div class="section-title">Altitude &amp; Heading</div> <div class="section-title phone-title">Altitude &amp; Heading</div>
<div class="kv-grid"> <div class="kv-grid">
<div class="kv-cell"> <div class="kv-cell">
<div class="kv-lbl">ALT m</div> <div class="kv-lbl">ALT m</div>
@ -253,14 +299,14 @@ body {
<div class="kv-val" id="val-hdg"></div> <div class="kv-val" id="val-hdg"></div>
</div> </div>
</div> </div>
<div id="compass-wrap" style="margin-top:8px"> <div id="compass-wrap" style="margin-top:6px">
<canvas id="compass-canvas" width="120" height="120"></canvas> <canvas id="compass-canvas" width="110" height="110"></canvas>
</div> </div>
</div> </div>
<!-- Accuracy + coords --> <!-- Phone fix quality -->
<div class="stat-section"> <div class="stat-section">
<div class="section-title">Fix Quality</div> <div class="section-title phone-title">Phone Fix Quality</div>
<div class="kv-grid"> <div class="kv-grid">
<div class="kv-cell"> <div class="kv-cell">
<div class="kv-lbl">H-ACC m</div> <div class="kv-lbl">H-ACC m</div>
@ -271,17 +317,39 @@ body {
<div class="kv-val" id="val-fixes">0</div> <div class="kv-val" id="val-fixes">0</div>
</div> </div>
</div> </div>
<div class="acc-bar-wrap" style="margin-top:6px"> <div class="acc-bar-wrap">
<div class="acc-bar" id="acc-bar"></div> <div class="acc-bar" id="acc-bar"></div>
</div> </div>
<div style="margin-top:8px"> <div style="margin-top:6px">
<div class="coord-row">LAT <span class="coord-val" id="val-lat"></span></div> <div class="coord-row">LAT <span class="coord-val" id="val-lat" style="color:#60a5fa"></span></div>
<div class="coord-row">LON <span class="coord-val" id="val-lon"></span></div> <div class="coord-row">LON <span class="coord-val" id="val-lon" style="color:#60a5fa"></span></div>
</div> </div>
</div> </div>
<!-- Trail log --> <!-- Robot position -->
<div class="section-title" style="padding:8px 12px 4px">Trail log</div> <div class="stat-section">
<div class="section-title robot-title">🤖 Robot (SAUL-TEE)</div>
<div class="kv-grid">
<div class="kv-cell">
<div class="kv-lbl">SPEED km/h</div>
<div class="kv-val" id="robot-spd" style="color:#f97316"></div>
</div>
<div class="kv-cell">
<div class="kv-lbl">FIXES</div>
<div class="kv-val" id="robot-fixes">0</div>
</div>
</div>
<div style="margin-top:6px">
<div class="coord-row">LAT <span class="coord-val" id="robot-lat" style="color:#fb923c"></span></div>
<div class="coord-row">LON <span class="coord-val" id="robot-lon" style="color:#fb923c"></span></div>
</div>
<div class="coord-row" style="margin-top:4px">
DIST <span id="robot-dist" style="color:var(--amber);font-family:monospace"></span>
</div>
</div>
<!-- Trail log (phone) -->
<div class="section-title" style="padding:7px 12px 2px;color:#2563eb">Trail log (phone)</div>
<div id="trail-log"></div> <div id="trail-log"></div>
</div> </div>
@ -289,9 +357,9 @@ body {
<!-- ── Bottom bar ── --> <!-- ── Bottom bar ── -->
<div id="bottombar"> <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" style="color:#3b82f6"></span><span class="bb-lbl">PHONE</span><span id="bb-phone">0</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" style="color:#f97316"></span><span class="bb-lbl">ROBOT</span><span id="bb-robot">0</span></div>
<div class="bb-item"><span class="bb-lbl">TRAIL PTS</span><span id="bb-trail">0</span></div> <div class="bb-item"><span class="bb-lbl">TRAIL</span><span id="bb-trail">0</span></div>
<span id="last-msg">No data yet</span> <span id="last-msg">No data yet</span>
</div> </div>
@ -300,26 +368,39 @@ body {
// ── Config ──────────────────────────────────────────────────────────────────── // ── Config ────────────────────────────────────────────────────────────────────
const MAX_TRAIL = 2000; // max polyline points const MAX_TRAIL = 2000;
const MAX_LOG = 60; // trail log entries const MAX_LOG = 60;
const STALE_MS = 5000;
const RECONNECT_BASE = 2000; const RECONNECT_BASE = 2000;
const RECONNECT_MAX = 30000; const RECONNECT_MAX = 30000;
const RECONNECT_MUL = 1.5; const RECONNECT_MUL = 1.5;
// ── State ───────────────────────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────────────────────
const gps = { const phone = {
lat: null, lon: null, speed: null, altitude: null, lat: null, lon: null, speed: null, altitude: null,
course: null, accuracy: null, fixes: 0, course: null, accuracy: null, fixes: 0, ts: 0,
}; };
let trail = []; // [{lat,lon}]
const robot = {
lat: null, lon: null, alt: null, stat: null,
spd: null, hdg: null, fixes: 0,
tsfix: 0, tsvel: 0,
};
let phoneTrail = []; // [{lat,lon}]
let robotTrail = [];
let logEntries = []; let logEntries = [];
let msgCount = 0;
let ws = null;
let followMode = true; let followMode = true;
let reconnDelay = RECONNECT_BASE;
let reconnTimer = null; // WS (phone)
let reconnTick = null; let ws = null;
let wsDelay = RECONNECT_BASE;
let wsTimer = null;
let wsTick = null;
// ROS (robot)
let ros = null;
// ── DOM ─────────────────────────────────────────────────────────────────────── // ── DOM ───────────────────────────────────────────────────────────────────────
@ -327,31 +408,54 @@ const $ = id => document.getElementById(id);
// ── Leaflet map ─────────────────────────────────────────────────────────────── // ── Leaflet map ───────────────────────────────────────────────────────────────
const map = L.map('map', { const map = L.map('map', { center: [43.45, -79.76], zoom: 15, zoomControl: true });
center: [43.45, -79.76],
zoom: 15,
zoomControl: true,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors', attribution: '© OpenStreetMap contributors',
maxZoom: 19, maxZoom: 19,
}).addTo(map); }).addTo(map);
// Marker with custom cyan icon // Phone marker — blue, bearing arrow
const markerIcon = L.divIcon({ function makePhoneIcon(course) {
className: '', const rot = (course != null && course >= 0) ? course : 0;
html: `<svg width="22" height="22" viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg"> return L.divIcon({
<circle cx="11" cy="11" r="9" fill="#06b6d4" fill-opacity=".25" stroke="#06b6d4" stroke-width="2"/> className: '',
<circle cx="11" cy="11" r="4" fill="#06b6d4"/> html: `<svg width="24" height="32" viewBox="0 0 24 32" xmlns="http://www.w3.org/2000/svg"
</svg>`, style="transform:rotate(${rot}deg);transform-origin:12px 17px">
iconSize: [22, 22], <circle cx="12" cy="17" r="8" fill="#3b82f6" fill-opacity=".3" stroke="#3b82f6" stroke-width="2"/>
iconAnchor: [11, 11], <circle cx="12" cy="17" r="3.5" fill="#3b82f6"/>
}); <polygon points="12,2 9,12 15,12" fill="#60a5fa" opacity=".9"/>
</svg>`,
iconSize: [24, 32],
iconAnchor: [12, 17],
});
}
let marker = null; // Robot marker — orange, heading arrow
let polyline = L.polyline([], { function makeRobotIcon(hdg) {
color: '#f97316', weight: 3, opacity: .85, lineJoin: 'round', const rot = (hdg != null && hdg >= 0) ? hdg : 0;
return L.divIcon({
className: '',
html: `<svg width="28" height="36" viewBox="0 0 28 36" xmlns="http://www.w3.org/2000/svg"
style="transform:rotate(${rot}deg);transform-origin:14px 20px">
<circle cx="14" cy="20" r="10" fill="#f97316" fill-opacity=".25" stroke="#f97316" stroke-width="2"/>
<circle cx="14" cy="20" r="4" fill="#f97316"/>
<polygon points="14,2 10,14 18,14" fill="#fb923c" opacity=".9"/>
</svg>`,
iconSize: [28, 36],
iconAnchor: [14, 20],
});
}
let phoneMarker = null;
let robotMarker = null;
let phonePolyline = L.polyline([], {
color: '#3b82f6', weight: 2.5, opacity: .8, lineJoin: 'round',
}).addTo(map);
let robotPolyline = L.polyline([], {
color: '#f97316', weight: 2.5, opacity: .8, lineJoin: 'round',
}).addTo(map); }).addTo(map);
// ── Canvas compass ──────────────────────────────────────────────────────────── // ── Canvas compass ────────────────────────────────────────────────────────────
@ -363,15 +467,12 @@ function drawCompass(hdg) {
const cx = W / 2, cy = H / 2, r = W / 2 - 4; const cx = W / 2, cy = H / 2, r = W / 2 - 4;
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
// Ring
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = '#1e3a5f'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.strokeStyle = '#1e3a5f'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.fillStyle = '#070712'; ctx.fill(); ctx.fillStyle = '#070712'; ctx.fill();
// Cardinal labels
const cards = ['N','E','S','W']; const cards = ['N','E','S','W'];
ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = 'bold 8px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
cards.forEach((c, i) => { cards.forEach((c, i) => {
const a = i * Math.PI / 2; const a = i * Math.PI / 2;
const lx = cx + Math.sin(a) * (r - 10); const lx = cx + Math.sin(a) * (r - 10);
@ -382,90 +483,146 @@ function drawCompass(hdg) {
if (hdg == null || hdg < 0) { if (hdg == null || hdg < 0) {
ctx.fillStyle = '#374151'; ctx.font = '9px monospace'; ctx.fillStyle = '#374151'; ctx.font = '9px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('—', cx, cy); ctx.fillText('—', cx, cy);
return; return;
} }
// Needle
const rad = (hdg - 90) * Math.PI / 180; const rad = (hdg - 90) * Math.PI / 180;
const tipLen = r - 16, tailLen = 10; const tipLen = r - 14, tailLen = 9;
ctx.save(); ctx.save(); ctx.translate(cx, cy);
ctx.translate(cx, cy);
// North tip (orange)
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(Math.cos(rad) * tipLen, Math.sin(rad) * tipLen); 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.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(); ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2.5; ctx.lineCap = 'round'; ctx.stroke();
// Center dot
ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2); ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2);
ctx.fillStyle = '#9ca3af'; ctx.fill(); ctx.fillStyle = '#9ca3af'; ctx.fill();
ctx.restore(); ctx.restore();
} }
// ── Render GPS data ─────────────────────────────────────────────────────────── // ── Haversine distance (km) ───────────────────────────────────────────────────
function haversine(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 +
Math.cos(lat1 * Math.PI/180) * Math.cos(lat2 * Math.PI/180) * Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// ── Stale badge helper ────────────────────────────────────────────────────────
function setSysBadge(id, ok, text) {
const el = $(id); if (!el) return;
el.className = `sys-badge badge-${ok ? 'ok' : 'stale'}`;
el.textContent = text;
}
// ── Render ────────────────────────────────────────────────────────────────────
function render() { function render() {
const g = gps; const now = Date.now();
// ─ Phone badges & stats ─
const phoneStale = phone.ts === 0 || (now - phone.ts > STALE_MS);
setSysBadge('badge-phone', !phoneStale,
phone.ts === 0 ? 'NO DATA' : phoneStale ? 'STALE' : 'LIVE');
$('hdr-phone').textContent = `📱 ${phone.fixes} fix${phone.fixes !== 1 ? 'es' : ''}`;
// Speed // Speed
const spd = g.speed != null ? (g.speed * 3.6).toFixed(1) : '—'; const spdKmh = phone.speed != null ? phone.speed * 3.6 : null;
const spdNum = g.speed != null ? g.speed * 3.6 : null; $('val-speed').textContent = spdKmh != null ? spdKmh.toFixed(1) : '—';
const spdColor = spdNum == null ? '#6b7280' $('val-speed').style.color = spdKmh == null ? '#6b7280'
: spdNum < 5 ? '#22c55e' : spdKmh < 5 ? '#22c55e' : spdKmh < 30 ? '#3b82f6' : '#ef4444';
: spdNum < 30 ? '#f97316'
: '#ef4444';
$('val-speed').textContent = spd;
$('val-speed').style.color = spdColor;
// Altitude // Altitude
$('val-alt').textContent = g.altitude != null ? g.altitude.toFixed(1) : '—'; $('val-alt').textContent = phone.altitude != null ? phone.altitude.toFixed(1) : '—';
// Heading // Heading
const hdg = g.course != null && g.course >= 0 ? Math.round(g.course) : null; const hdg = phone.course != null && phone.course >= 0 ? Math.round(phone.course) : null;
$('val-hdg').textContent = hdg != null ? hdg + '°' : '—'; $('val-hdg').textContent = hdg != null ? hdg + '°' : '—';
drawCompass(g.course); drawCompass(phone.course);
// Accuracy // Accuracy
const acc = g.accuracy; const acc = phone.accuracy;
$('val-acc').textContent = acc != null ? acc.toFixed(1) : '—'; $('val-acc').textContent = acc != null ? acc.toFixed(1) : '—';
const accNorm = acc != null ? Math.min(1, acc / 50) : 0; const accNorm = acc != null ? Math.min(1, acc / 50) : 0;
const accColor = acc == null ? '#374151' : acc < 5 ? '#22c55e' : acc < 15 ? '#f59e0b' : '#ef4444'; const accColor = acc == null ? '#374151' : acc < 5 ? '#22c55e' : acc < 15 ? '#f59e0b' : '#ef4444';
$('acc-bar').style.width = (accNorm * 100) + '%'; $('acc-bar').style.width = (accNorm * 100) + '%';
$('acc-bar').style.background = accColor; $('acc-bar').style.background = accColor;
$('val-acc').style.color = accColor; $('val-acc').style.color = accColor;
// Fixes
$('val-fixes').textContent = g.fixes;
$('fix-count').textContent = g.fixes + ' fix' + (g.fixes !== 1 ? 'es' : '');
// Coords // Coords
$('val-lat').textContent = g.lat != null ? g.lat.toFixed(6) : '—'; $('val-fixes').textContent = phone.fixes;
$('val-lon').textContent = g.lon != null ? g.lon.toFixed(6) : '—'; $('val-lat').textContent = phone.lat != null ? phone.lat.toFixed(6) : '—';
$('val-lon').textContent = phone.lon != null ? phone.lon.toFixed(6) : '—';
// Bottom bar // ─ Robot badges & stats ─
$('bb-msgs').textContent = msgCount; const robotFixStale = robot.tsfix === 0 || (now - robot.tsfix > STALE_MS);
$('bb-trail').textContent = trail.length; const robotVelStale = robot.tsvel === 0 || (now - robot.tsvel > STALE_MS);
} setSysBadge('badge-robot-fix', !robotFixStale,
robot.tsfix === 0 ? 'NO DATA' : robotFixStale ? 'STALE' : 'LIVE');
setSysBadge('badge-robot-vel', !robotVelStale,
robot.tsvel === 0 ? 'NO DATA' : robotVelStale ? 'STALE' : 'LIVE');
$('hdr-robot').textContent = `🤖 ${robot.fixes} fix${robot.fixes !== 1 ? 'es' : ''}`;
function updateMap(lat, lon) { const robotSpdKmh = robot.spd != null ? robot.spd : null;
// Marker $('robot-spd').textContent = robotSpdKmh != null ? robotSpdKmh.toFixed(1) : '—';
if (!marker) { $('robot-fixes').textContent = robot.fixes;
marker = L.marker([lat, lon], { icon: markerIcon }).addTo(map); $('robot-lat').textContent = robot.lat != null ? robot.lat.toFixed(6) : '—';
if (followMode) map.setView([lat, lon], 16); $('robot-lon').textContent = robot.lon != null ? robot.lon.toFixed(6) : '—';
// Distance between phone and robot
if (phone.lat != null && robot.lat != null) {
const km = haversine(phone.lat, phone.lon, robot.lat, robot.lon);
$('robot-dist').textContent = km < 1 ? (km * 1000).toFixed(0) + ' m' : km.toFixed(2) + ' km';
} else { } else {
marker.setLatLng([lat, lon]); $('robot-dist').textContent = '—';
if (followMode) map.panTo([lat, lon]);
} }
// Trail // ─ Bottom bar ─
trail.push({ lat, lon }); $('bb-phone').textContent = phone.fixes;
if (trail.length > MAX_TRAIL) trail.shift(); $('bb-robot').textContent = robot.fixes;
polyline.setLatLngs(trail.map(p => [p.lat, p.lon])); $('bb-trail').textContent = phoneTrail.length;
$('bb-trail').textContent = trail.length; }
// ── Map update functions ──────────────────────────────────────────────────────
function updatePhoneMap(lat, lon, course) {
const icon = makePhoneIcon(course);
if (!phoneMarker) {
phoneMarker = L.marker([lat, lon], { icon }).addTo(map);
if (followMode) map.setView([lat, lon], 16);
} else {
phoneMarker.setIcon(icon);
phoneMarker.setLatLng([lat, lon]);
if (followMode) map.panTo([lat, lon]);
}
phoneTrail.push({ lat, lon });
if (phoneTrail.length > MAX_TRAIL) phoneTrail.shift();
phonePolyline.setLatLngs(phoneTrail.map(p => [p.lat, p.lon]));
}
function updateRobotMap(lat, lon, hdg) {
const icon = makeRobotIcon(hdg);
if (!robotMarker) {
robotMarker = L.marker([lat, lon], { icon }).addTo(map);
} else {
robotMarker.setIcon(icon);
robotMarker.setLatLng([lat, lon]);
}
robotTrail.push({ lat, lon });
if (robotTrail.length > MAX_TRAIL) robotTrail.shift();
robotPolyline.setLatLngs(robotTrail.map(p => [p.lat, p.lon]));
}
function fitAll() {
const pts = [];
if (phone.lat != null) pts.push([phone.lat, phone.lon]);
if (robot.lat != null) pts.push([robot.lat, robot.lon]);
if (pts.length > 0) map.fitBounds(pts, { padding: [40, 40], maxZoom: 17 });
} }
// ── Trail log ───────────────────────────────────────────────────────────────── // ── Trail log ─────────────────────────────────────────────────────────────────
@ -476,129 +633,171 @@ function addLogEntry(d, ts) {
logEntries.unshift({ time, lat: d.latitude, lon: d.longitude, spd }); logEntries.unshift({ time, lat: d.latitude, lon: d.longitude, spd });
if (logEntries.length > MAX_LOG) logEntries.pop(); if (logEntries.length > MAX_LOG) logEntries.pop();
const log = $('trail-log'); $('trail-log').innerHTML = logEntries.map(e =>
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>`
`<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(''); ).join('');
} }
// ── WebSocket ───────────────────────────────────────────────────────────────── // ── WebSocket (phone GPS) ─────────────────────────────────────────────────────
function cancelReconn() { function cancelWsReconn() {
if (reconnTimer) { clearTimeout(reconnTimer); reconnTimer = null; } if (wsTimer) { clearTimeout(wsTimer); wsTimer = null; }
if (reconnTick) { clearInterval(reconnTick); reconnTick = null; } if (wsTick) { clearInterval(wsTick); wsTick = null; }
} }
function scheduleReconn(url) { function scheduleWsReconn(url) {
cancelReconn(); cancelWsReconn();
let secs = Math.round(reconnDelay / 1000); let secs = Math.round(wsDelay / 1000);
$('conn-label').textContent = `Retry in ${secs}s…`; $('conn-label').textContent = `Retry in ${secs}s…`;
$('conn-label').style.color = '#6b7280'; $('conn-label').style.color = '#6b7280';
wsTick = setInterval(() => { secs = Math.max(0, secs-1); if (secs>0) $('conn-label').textContent=`Retry in ${secs}s…`; }, 1000);
reconnTick = setInterval(() => { wsTimer = setTimeout(() => { wsTimer = null; doConnect(url, true); }, wsDelay);
secs = Math.max(0, secs - 1); wsDelay = Math.min(wsDelay * RECONNECT_MUL, RECONNECT_MAX);
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) { function doConnect(url, isRetry) {
cancelReconn(); cancelWsReconn();
if (!isRetry) reconnDelay = RECONNECT_BASE; if (!isRetry) wsDelay = RECONNECT_BASE;
if (ws) { try { ws.close(); } catch(_) {} ws = null; } if (ws) { try { ws.close(); } catch(_) {} ws = null; }
$('conn-label').textContent = 'Connecting…'; $('conn-label').textContent = 'Connecting…';
$('conn-label').style.color = '#6b7280'; $('conn-label').style.color = '#6b7280';
$('conn-dot').className = ''; $('conn-dot').className = '';
$('bb-ws').textContent = url;
try { try { ws = new WebSocket(url); }
ws = new WebSocket(url); catch(e) { $('conn-label').textContent = 'Bad URL'; $('conn-dot').className = 'error'; return; }
} catch(e) {
$('conn-label').textContent = 'Bad URL';
$('conn-dot').className = 'error';
return;
}
ws.onopen = () => { ws.onopen = () => {
reconnDelay = RECONNECT_BASE; wsDelay = RECONNECT_BASE; cancelWsReconn();
cancelReconn();
$('conn-dot').className = 'connected'; $('conn-dot').className = 'connected';
$('conn-label').textContent = 'Connected'; $('conn-label').textContent = 'WS connected';
$('conn-label').style.color = '#22c55e'; $('conn-label').style.color = '#22c55e';
localStorage.setItem('sultee_ws_url', url); localStorage.setItem('sultee_ws_url', url);
}; };
ws.onclose = () => { $('conn-dot').className = ''; ws = null; scheduleWsReconn(url); };
ws.onclose = () => { ws.onerror = () => { $('conn-dot').className = 'error'; $('conn-label').style.color='#ef4444'; $('conn-label').textContent='WS error'; };
$('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 => { ws.onmessage = evt => {
msgCount++;
let msg; let msg;
try { msg = JSON.parse(evt.data); } catch(_) { return; } try { msg = JSON.parse(evt.data); } catch(_) { return; }
if (msg.type !== 'gps' || !msg.data) return; if (msg.type !== 'gps' || !msg.data) return;
const d = msg.data;
const d = msg.data;
const ts = msg.timestamp || Math.floor(Date.now() / 1000); const ts = msg.timestamp || Math.floor(Date.now() / 1000);
// Update state phone.lat = d.latitude ?? phone.lat;
gps.lat = d.latitude ?? gps.lat; phone.lon = d.longitude ?? phone.lon;
gps.lon = d.longitude ?? gps.lon; phone.speed = d.speed ?? phone.speed;
gps.speed = d.speed ?? gps.speed; phone.altitude = d.altitude ?? phone.altitude;
gps.altitude = d.altitude ?? gps.altitude; phone.course = d.course ?? phone.course;
gps.course = d.course ?? gps.course; phone.accuracy = d.horizontalAccuracy ?? phone.accuracy;
gps.accuracy = d.horizontalAccuracy ?? gps.accuracy; phone.fixes++;
gps.fixes++; phone.ts = Date.now();
render(); if (phone.lat != null && phone.lon != null) {
updatePhoneMap(phone.lat, phone.lon, phone.course);
if (gps.lat != null && gps.lon != null) {
updateMap(gps.lat, gps.lon);
addLogEntry(d, ts); addLogEntry(d, ts);
} }
$('last-msg').textContent = 'Last: ' + new Date(ts * 1000).toLocaleTimeString('en-US', { hour12: false }); $('status-last').textContent = 'Phone: ' + new Date(ts * 1000).toLocaleTimeString('en-US', { hour12: false });
$('last-msg').textContent = 'Phone: ' + new Date(ts * 1000).toLocaleTimeString('en-US', { hour12: false });
render();
}; };
} }
// ── ROSLIB (robot GPS) ────────────────────────────────────────────────────────
function connectRos(url) {
if (ros) { try { ros.close(); } catch(_) {} }
ros = new ROSLIB.Ros({ url });
ros.on('connection', () => {
$('ros-dot').className = 'connected';
setupRobotTopics();
});
ros.on('error', () => { $('ros-dot').className = 'error'; });
ros.on('close', () => {
$('ros-dot').className = '';
setTimeout(() => connectRos(url), 5000);
});
}
function setupRobotTopics() {
// saltybot/gps/fix — {lat, lon, alt, stat, t}
new ROSLIB.Topic({
ros, name: 'saltybot/gps/fix',
messageType: 'std_msgs/String', throttle_rate: 500,
}).subscribe(msg => {
try {
const d = JSON.parse(msg.data);
robot.lat = d.lat ?? robot.lat;
robot.lon = d.lon ?? robot.lon;
robot.alt = d.alt ?? robot.alt;
robot.stat = d.stat ?? robot.stat;
robot.fixes++;
robot.tsfix = Date.now();
if (robot.lat != null && robot.lon != null) {
updateRobotMap(robot.lat, robot.lon, robot.hdg);
}
$('last-msg').textContent = 'Robot: ' + new Date().toLocaleTimeString('en-US', { hour12: false });
render();
} catch(_) {}
});
// saltybot/gps/vel — {spd, hdg, t}
new ROSLIB.Topic({
ros, name: 'saltybot/gps/vel',
messageType: 'std_msgs/String', throttle_rate: 500,
}).subscribe(msg => {
try {
const d = JSON.parse(msg.data);
robot.spd = d.spd ?? robot.spd;
robot.hdg = d.hdg ?? robot.hdg;
robot.tsvel = Date.now();
if (robotMarker && robot.lat != null) {
robotMarker.setIcon(makeRobotIcon(robot.hdg));
}
render();
} catch(_) {}
});
}
// ── Stale refresh ─────────────────────────────────────────────────────────────
setInterval(render, 1000);
// ── Controls ────────────────────────────────────────────────────────────────── // ── Controls ──────────────────────────────────────────────────────────────────
$('btn-connect').addEventListener('click', () => { $('btn-connect').addEventListener('click', () => {
doConnect($('ws-input').value.trim(), false); const url = $('ws-input').value.trim();
doConnect(url, false);
connectRos(url);
}); });
$('ws-input').addEventListener('keydown', e => { $('ws-input').addEventListener('keydown', e => {
if (e.key === 'Enter') doConnect($('ws-input').value.trim(), false); if (e.key === 'Enter') { const url = $('ws-input').value.trim(); doConnect(url, false); connectRos(url); }
}); });
$('btn-follow').addEventListener('click', () => { $('btn-follow').addEventListener('click', () => {
followMode = !followMode; followMode = !followMode;
$('btn-follow').classList.toggle('active', followMode); $('btn-follow').classList.toggle('active', followMode);
$('btn-follow').textContent = followMode ? '⊙ FOLLOW' : '⊙ FREE'; $('btn-follow').textContent = followMode ? '⊙ FOLLOW' : '⊙ FREE';
if (followMode && gps.lat != null) map.panTo([gps.lat, gps.lon]); if (followMode && phone.lat != null) map.panTo([phone.lat, phone.lon]);
}); });
$('btn-fit').addEventListener('click', fitAll);
$('btn-clear').addEventListener('click', () => { $('btn-clear').addEventListener('click', () => {
trail = []; phoneTrail = []; robotTrail = [];
logEntries = []; logEntries = []; phone.fixes = 0; robot.fixes = 0;
gps.fixes = 0; if (phoneMarker) { phoneMarker.remove(); phoneMarker = null; }
if (marker) { marker.remove(); marker = null; } if (robotMarker) { robotMarker.remove(); robotMarker = null; }
polyline.setLatLngs([]); phonePolyline.setLatLngs([]);
robotPolyline.setLatLngs([]);
$('trail-log').innerHTML = ''; $('trail-log').innerHTML = '';
render(); render();
}); });
// Disable follow on manual pan
map.on('dragstart', () => { map.on('dragstart', () => {
if (followMode) { if (followMode) {
followMode = false; followMode = false;
@ -614,6 +813,7 @@ map.on('dragstart', () => {
$('ws-input').value = saved; $('ws-input').value = saved;
drawCompass(null); drawCompass(null);
doConnect(saved, false); doConnect(saved, false);
connectRos(saved);
})(); })();
</script> </script>
</body> </body>