Merge pull request 'feat: Robot GPS live map panel (Issue #709 companion)' (#711) from sl-webui/robot-gps-map into main

This commit is contained in:
sl-jetson 2026-04-03 22:43:56 -04:00
commit 7db6158ada
5 changed files with 761 additions and 0 deletions

View File

@ -22,6 +22,7 @@ const PANELS = [
{ id: 'settings', watchTopic: null, msgType: null }, // service-based { id: 'settings', watchTopic: null, msgType: null }, // service-based
{ id: 'gimbal', watchTopic: '/gimbal/state', msgType: 'geometry_msgs/Vector3' }, { id: 'gimbal', watchTopic: '/gimbal/state', msgType: 'geometry_msgs/Vector3' },
{ id: 'can', watchTopic: '/vesc/left/state', msgType: 'std_msgs/String' }, { id: 'can', watchTopic: '/vesc/left/state', msgType: 'std_msgs/String' },
{ id: 'gps', watchTopic: 'saltybot/gps/fix', msgType: 'std_msgs/String' },
]; ];
// ── State ────────────────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────────
@ -188,6 +189,13 @@ function setupTopics() {
}); });
vescWatch.subscribe(() => { markPanelLive('can'); }); vescWatch.subscribe(() => { markPanelLive('can'); });
// ── GPS fix (for GPS map card liveness) ──
const gpsWatch = new ROSLIB.Topic({
ros, name: 'saltybot/gps/fix',
messageType: 'std_msgs/String', throttle_rate: 2000,
});
gpsWatch.subscribe(() => { markPanelLive('gps'); });
// ── cmd_vel monitor (for gamepad card liveness) ── // ── cmd_vel monitor (for gamepad card liveness) ──
const cmdVelWatch = new ROSLIB.Topic({ const cmdVelWatch = new ROSLIB.Topic({
ros, name: '/cmd_vel', ros, name: '/cmd_vel',

165
ui/gps_map_panel.css Normal file
View File

@ -0,0 +1,165 @@
/* gps_map_panel.css — Robot GPS Live Map (Issue #709 companion) */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg0: #050510;
--bg1: #070712;
--bg2: #0a0a1a;
--border: #0c2a3a;
--border2: #1e3a5f;
--text-dim: #374151;
--text-mid: #6b7280;
--text-base: #9ca3af;
--cyan: #06b6d4;
--cyan-dim: #0e4f69;
--green: #22c55e;
--amber: #f59e0b;
--red: #ef4444;
--orange: #f97316;
}
html, body {
height: 100%;
overflow: hidden;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
background: var(--bg0);
color: var(--text-base);
display: flex;
flex-direction: column;
}
/* ── Header ── */
#header {
display: flex; align-items: center; gap: 10px;
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; }
.logo a { color: inherit; text-decoration: none; }
.logo a:hover { text-decoration: underline; }
#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(--text-dim); flex-shrink: 0; transition: background .3s;
}
#conn-dot.connected { background: var(--green); }
#conn-dot.error { background: var(--red); animation: blink 1s infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.4} }
#ws-input {
flex: 1; min-width: 160px; max-width: 240px;
background: var(--bg2); border: 1px solid var(--border2); border-radius: 4px;
color: #67e8f9; padding: 2px 8px; font-family: monospace; font-size: 11px;
}
#ws-input:focus { outline: none; border-color: var(--cyan); }
.hdr-btn {
padding: 3px 10px; border-radius: 4px; border: 1px solid var(--border2);
background: var(--bg2); color: #67e8f9; font-family: monospace;
font-size: 10px; font-weight: bold; cursor: pointer;
}
.hdr-btn:hover { background: var(--cyan-dim); }
#conn-label { font-size: 10px; color: var(--text-dim); }
/* ── Status bar ── */
#status-bar {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
padding: 4px 14px; background: var(--bg1);
border-bottom: 1px solid var(--border); font-size: 10px;
}
.sys-badge {
padding: 2px 8px; border-radius: 3px; font-weight: bold;
border: 1px solid; letter-spacing: .05em;
}
.badge-ok { background: #052e16; border-color: #166534; color: #4ade80; }
.badge-warn { background: #451a03; border-color: #92400e; color: #fcd34d; }
.badge-error { background: #450a0a; border-color: #991b1b; color: #f87171; animation: blink 1s infinite; }
.badge-stale { background: #111827; border-color: #374151; color: #6b7280; }
#last-update { color: var(--text-mid); margin-left: auto; }
/* ── Main layout ── */
#main {
flex: 1; display: flex; min-height: 0;
}
/* ── Map ── */
#map-wrap { flex: 1; position: relative; min-width: 0; }
#map { width: 100%; height: 100%; }
/* dark tile filter */
.leaflet-tile { filter: brightness(.8) invert(1) hue-rotate(200deg) saturate(.65); }
.leaflet-container { background: #090915; }
/* ── Map controls ── */
#map-controls {
position: absolute; top: 10px; right: 10px; z-index: 1000;
display: flex; flex-direction: column; gap: 4px;
}
.map-btn {
padding: 4px 9px; border-radius: 4px; border: 1px solid var(--border);
background: rgba(7,7,18,.88); color: var(--cyan);
font-family: monospace; font-size: 10px; font-weight: bold;
cursor: pointer; backdrop-filter: blur(4px);
}
.map-btn:hover { background: rgba(14,79,105,.88); }
.map-btn.active { background: rgba(6,182,212,.22); border-color: var(--cyan); }
/* ── Sidebar ── */
#sidebar {
width: 210px; 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: 4px 0; }
.big-val { font-size: 36px; font-weight: bold; font-family: monospace; line-height: 1; }
.big-unit { font-size: 11px; color: var(--text-mid); margin-top: 2px; }
.big-lbl { font-size: 8px; color: var(--text-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: 5px 7px; }
.kv-lbl { font-size: 8px; color: var(--text-dim); margin-bottom: 2px; }
.kv-val { font-size: 13px; font-weight: bold; font-family: monospace; }
.coord-row { font-size: 10px; color: var(--text-mid); margin-bottom: 2px; }
.coord-val { color: var(--cyan); font-family: monospace; }
/* accuracy bar */
.acc-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; }
/* compass */
#compass-wrap { display: flex; justify-content: center; padding: 6px 0; }
/* trail log */
#trail-log {
flex: 1; overflow-y: auto; padding: 6px 12px;
display: flex; flex-direction: column; gap: 3px;
}
.trail-entry { font-size: 9px; color: var(--text-dim); border-bottom: 1px solid #0b1a28; padding-bottom: 3px; }
.te-coord { color: var(--cyan); }
.te-spd { color: var(--orange); }
/* bottom bar */
#bottombar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 4px 14px; background: var(--bg1);
border-top: 1px solid var(--border); font-size: 10px;
flex-shrink: 0;
}
.bb-lbl { color: var(--text-dim); }
#last-msg { margin-left: auto; color: var(--text-dim); }
@media (max-width: 600px) { #sidebar { display: none; } }

138
ui/gps_map_panel.html Normal file
View File

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>Saltybot — GPS Map</title>
<link rel="stylesheet" href="gps_map_panel.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://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js"></script>
</head>
<body>
<!-- ── Header ── -->
<div id="header">
<div class="logo"><a href="index.html">SALTYBOT</a> — GPS MAP</div>
<div id="conn-bar">
<div id="conn-dot"></div>
<input id="ws-input" type="text" value="ws://localhost:9090" placeholder="ws://robot-ip:9090" />
<button class="hdr-btn" id="btn-connect">CONNECT</button>
<span id="conn-label">Not connected</span>
</div>
</div>
<!-- ── Status bar ── -->
<div id="status-bar">
<span style="color:var(--text-dim);font-size:10px">ROBOT FIX</span>
<span class="sys-badge badge-stale" id="badge-fix">STALE</span>
<span style="color:var(--text-dim)"></span>
<span style="color:var(--text-dim);font-size:10px">VELOCITY</span>
<span class="sys-badge badge-stale" id="badge-vel">STALE</span>
<span style="color:var(--text-dim)"></span>
<span style="color:var(--text-dim);font-size:10px">PHONE</span>
<span class="sys-badge badge-stale" id="badge-phone">NO DATA</span>
<span id="last-update">Awaiting data…</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 -->
<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">HDG °</div>
<div class="kv-val" id="val-hdg"></div>
</div>
</div>
<div id="compass-wrap">
<canvas id="compass-canvas" width="110" height="110"></canvas>
</div>
</div>
<!-- Fix quality -->
<div class="stat-section">
<div class="section-title">Fix Quality</div>
<div class="kv-grid">
<div class="kv-cell">
<div class="kv-lbl">STATUS</div>
<div class="kv-val" id="val-stat"></div>
</div>
<div class="kv-cell">
<div class="kv-lbl">FIXES</div>
<div class="kv-val" id="val-fixes">0</div>
</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>
<!-- Phone position -->
<div class="stat-section">
<div class="section-title" style="color:#3b82f6">📱 Phone / User</div>
<div style="margin-bottom:6px">
<div class="coord-row">LAT <span class="coord-val" id="phone-lat" style="color:#60a5fa"></span></div>
<div class="coord-row">LON <span class="coord-val" id="phone-lon" style="color:#60a5fa"></span></div>
</div>
<div class="kv-grid">
<div class="kv-cell">
<div class="kv-lbl">SPEED</div>
<div class="kv-val" id="phone-spd" style="color:#60a5fa"></div>
</div>
<div class="kv-cell">
<div class="kv-lbl">ACCURACY</div>
<div class="kv-val" id="phone-acc"></div>
</div>
</div>
<div class="coord-row" style="margin-top:6px">SRC <span id="phone-provider" style="color:#6b7280;font-family:monospace"></span></div>
</div>
<!-- Trail log -->
<div class="section-title" style="padding:8px 12px 2px">Trail log</div>
<div id="trail-log"></div>
</div>
</div>
<!-- ── Bottom bar ── -->
<div id="bottombar">
<span class="bb-lbl">FIXES</span><span id="bb-fixes">0</span>
<span class="bb-lbl">TRAIL PTS</span><span id="bb-trail">0</span>
<span class="bb-lbl">ROBOT MSGS</span><span id="bb-msgs">0</span>
<span class="bb-lbl">PHONE MSGS</span><span id="bb-phone">0</span>
<span id="last-msg">No data</span>
</div>
<script src="gps_map_panel.js"></script>
</body>
</html>

430
ui/gps_map_panel.js Normal file
View File

@ -0,0 +1,430 @@
/**
* gps_map_panel.js Robot GPS Live Map (Issue #709 companion)
*
* Subscribes via rosbridge to:
* saltybot/gps/fix std_msgs/String JSON {lat, lon, alt, stat, t}
* saltybot/gps/vel std_msgs/String JSON {spd, hdg, t}
* saltybot/phone/gps std_msgs/String JSON {ts, lat, lon, alt_m, accuracy_m, speed_ms, bearing_deg, provider}
*
* Renders a Leaflet map with:
* - Live robot position marker (cyan/orange) with heading arrow
* - Live phone/user position marker (blue) with bearing arrow
* - Orange robot trail + blue phone trail (up to MAX_TRAIL points each)
* - Sidebar: speed, altitude, heading compass, fix status, coordinates, phone position
* - FOLLOW mode (auto-pan) toggled by button or manual drag
* - Stale detection at 5 s
*/
'use strict';
// ── Constants ─────────────────────────────────────────────────────────────────
const MAX_TRAIL = 2000;
const MAX_LOG = 80;
const STALE_MS = 5000;
const RECONNECT_BASE = 2000;
const RECONNECT_MAX = 30000;
const RECONNECT_MUL = 1.5;
// ── State ─────────────────────────────────────────────────────────────────────
let ros = null;
const gps = {
lat: null, lon: null, alt: null, stat: null,
spd: null, hdg: null,
fixes: 0, msgs: 0,
tsfix: 0, tsvel: 0,
};
const phone = {
lat: null, lon: null, alt: null,
spd: null, bearing: null,
accuracy: null, provider: null,
msgs: 0, ts: 0,
};
let trail = []; // [{lat, lon}]
let phoneTrail = []; // [{lat, lon}]
let logEntries = [];
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);
// Heading-aware SVG marker icon (rotated via CSS transform on the div)
function makeIcon(hdg) {
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">
<!-- body circle -->
<circle cx="14" cy="20" r="10" fill="#06b6d4" fill-opacity=".3" stroke="#06b6d4" stroke-width="2"/>
<circle cx="14" cy="20" r="4" fill="#06b6d4"/>
<!-- heading arrow (points up = north when hdg=0) -->
<polygon points="14,2 10,14 18,14" fill="#f97316" opacity=".9"/>
</svg>`,
iconSize: [28, 36],
iconAnchor: [14, 20],
});
}
// Robot marker (cyan body, orange heading arrow)
let marker = null;
let polyline = L.polyline([], {
color: '#f97316', weight: 2.5, opacity: .8, lineJoin: 'round',
}).addTo(map);
// Phone/user marker (blue body, blue bearing arrow)
function makePhoneIcon(bearing) {
const rot = (bearing != null && bearing >= 0) ? bearing : 0;
return L.divIcon({
className: '',
html: `<svg width="24" height="32" viewBox="0 0 24 32" xmlns="http://www.w3.org/2000/svg"
style="transform:rotate(${rot}deg);transform-origin:12px 17px">
<!-- body circle -->
<circle cx="12" cy="17" r="8" fill="#3b82f6" fill-opacity=".35" stroke="#3b82f6" stroke-width="2"/>
<circle cx="12" cy="17" r="3.5" fill="#3b82f6"/>
<!-- bearing arrow (points up = north when bearing=0) -->
<polygon points="12,2 9,12 15,12" fill="#60a5fa" opacity=".9"/>
</svg>`,
iconSize: [24, 32],
iconAnchor: [12, 17],
});
}
let phoneMarker = null;
let phonePolyline = L.polyline([], {
color: '#3b82f6', weight: 2, opacity: .7, 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, cx = W / 2, cy = H / 2;
const r = cx - 4;
ctx.clearRect(0, 0, W, H);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = '#070712'; ctx.fill();
ctx.strokeStyle = '#1e3a5f'; ctx.lineWidth = 1.5; ctx.stroke();
const cards = ['N', 'E', 'S', 'W'];
ctx.font = 'bold 8px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
cards.forEach((c, i) => {
const a = i * Math.PI / 2;
const lx = cx + Math.sin(a) * (r - 9);
const ly = cy - Math.cos(a) * (r - 9);
ctx.fillStyle = c === 'N' ? '#ef4444' : '#4b5563';
ctx.fillText(c, lx, ly);
});
if (hdg == null || hdg < 0) {
ctx.fillStyle = '#374151'; ctx.font = '9px monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText('—', cx, cy);
return;
}
const rad = (hdg - 90) * Math.PI / 180;
const tipLen = r - 14;
const tailLen = 9;
ctx.save(); ctx.translate(cx, cy);
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();
ctx.beginPath(); ctx.arc(0, 0, 3, 0, Math.PI * 2);
ctx.fillStyle = '#9ca3af'; ctx.fill();
ctx.restore();
}
// ── Render ────────────────────────────────────────────────────────────────────
function setSysBadge(id, level, text) {
const el = $(id); if (!el) return;
el.className = `sys-badge badge-${level}`;
if (text !== undefined) el.textContent = text;
}
function render() {
const now = Date.now();
// Stale badges
const fixStale = now - gps.tsfix > STALE_MS;
const velStale = now - gps.tsvel > STALE_MS;
const phoneStale = now - phone.ts > STALE_MS;
setSysBadge('badge-fix', fixStale ? 'stale' : 'ok', fixStale ? 'STALE' : 'LIVE');
setSysBadge('badge-vel', velStale ? 'stale' : 'ok', velStale ? 'STALE' : 'LIVE');
setSysBadge('badge-phone', phone.ts === 0 ? 'stale' : phoneStale ? 'stale' : 'ok',
phone.ts === 0 ? 'NO DATA' : phoneStale ? 'STALE' : 'LIVE');
// Speed
const spdKmh = gps.spd != null ? gps.spd : null;
const spdTxt = spdKmh != null ? spdKmh.toFixed(1) : '—';
const spdColor = spdKmh == null ? '#6b7280'
: spdKmh < 2 ? '#22c55e'
: spdKmh < 30 ? '#f97316'
: '#ef4444';
$('val-speed').textContent = spdTxt;
$('val-speed').style.color = spdColor;
// Altitude
$('val-alt').textContent = gps.alt != null ? gps.alt.toFixed(1) : '—';
// Heading
const hdg = gps.hdg != null && gps.hdg >= 0 ? Math.round(gps.hdg) : null;
$('val-hdg').textContent = hdg != null ? hdg + '°' : '—';
drawCompass(gps.hdg);
// Fix status
const statMap = { 0: 'NO FIX', 1: 'GPS', 2: 'DGPS', 3: 'PPS', 4: 'RTK', 5: 'RTK-F', 6: 'EST' };
$('val-stat').textContent = gps.stat != null ? (statMap[gps.stat] || gps.stat) : '—';
$('val-stat').style.color = gps.stat ? '#22c55e' : '#ef4444';
$('val-fixes').textContent = gps.fixes;
$('bb-fixes').textContent = gps.fixes;
$('bb-msgs').textContent = gps.msgs;
$('bb-trail').textContent = trail.length;
// Coordinates
$('val-lat').textContent = gps.lat != null ? gps.lat.toFixed(6) : '—';
$('val-lon').textContent = gps.lon != null ? gps.lon.toFixed(6) : '—';
// Phone position
$('phone-lat').textContent = phone.lat != null ? phone.lat.toFixed(6) : '—';
$('phone-lon').textContent = phone.lon != null ? phone.lon.toFixed(6) : '—';
const phoneSpdMs = phone.spd;
$('phone-spd').textContent = phoneSpdMs != null ? (phoneSpdMs * 3.6).toFixed(1) + ' km/h' : '—';
$('phone-acc').textContent = phone.accuracy != null ? phone.accuracy.toFixed(1) + ' m' : '—';
$('phone-provider').textContent = phone.provider || '—';
$('bb-phone').textContent = phone.msgs;
}
function updateMap(lat, lon, hdg) {
const icon = makeIcon(hdg);
if (!marker) {
marker = L.marker([lat, lon], { icon }).addTo(map);
if (followMode) map.setView([lat, lon], 16);
} else {
marker.setIcon(icon);
marker.setLatLng([lat, lon]);
if (followMode) map.panTo([lat, lon]);
}
trail.push({ lat, lon });
if (trail.length > MAX_TRAIL) trail.shift();
polyline.setLatLngs(trail.map(p => [p.lat, p.lon]));
}
function updatePhoneMap(lat, lon, bearing) {
const icon = makePhoneIcon(bearing);
if (!phoneMarker) {
phoneMarker = L.marker([lat, lon], { icon }).addTo(map);
} else {
phoneMarker.setIcon(icon);
phoneMarker.setLatLng([lat, lon]);
}
phoneTrail.push({ lat, lon });
if (phoneTrail.length > MAX_TRAIL) phoneTrail.shift();
phonePolyline.setLatLngs(phoneTrail.map(p => [p.lat, p.lon]));
}
function addLog(lat, lon, spd, t) {
const time = new Date((t || Date.now() / 1000) * 1000)
.toLocaleTimeString('en-US', { hour12: false });
const spdTxt = spd != null ? spd.toFixed(1) + ' km/h' : '?';
logEntries.unshift({ time, lat, lon, spdTxt });
if (logEntries.length > MAX_LOG) logEntries.pop();
$('trail-log').innerHTML = logEntries.map(e =>
`<div class="trail-entry">${e.time} <span class="te-coord">${e.lat.toFixed(5)},${e.lon.toFixed(5)}</span> <span class="te-spd">${e.spdTxt}</span></div>`
).join('');
}
// ── ROS / auto-reconnect ──────────────────────────────────────────────────────
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…`;
reconnTick = setInterval(() => {
secs = Math.max(0, secs - 1);
if (secs > 0) $('conn-label').textContent = `Retry in ${secs}s…`;
}, 1000);
reconnTimer = setTimeout(() => { reconnTimer = null; connect(url, true); }, reconnDelay);
reconnDelay = Math.min(reconnDelay * RECONNECT_MUL, RECONNECT_MAX);
}
function connect(url, isRetry) {
cancelReconn();
if (!isRetry) reconnDelay = RECONNECT_BASE;
if (ros) { try { ros.close(); } catch(_) {} }
ros = new ROSLIB.Ros({ url });
ros.on('connection', () => {
reconnDelay = RECONNECT_BASE;
cancelReconn();
$('conn-dot').className = 'connected';
$('conn-label').style.color = '#22c55e';
$('conn-label').textContent = 'Connected';
$('ws-input').value = url;
localStorage.setItem('gps_map_ws_url', url);
setupTopics();
});
ros.on('error', () => {
$('conn-dot').className = 'error';
$('conn-label').style.color = '#ef4444';
$('conn-label').textContent = 'Error';
});
ros.on('close', () => {
$('conn-dot').className = '';
$('conn-label').style.color = '#6b7280';
scheduleReconn($('ws-input').value.trim());
});
}
function setupTopics() {
// 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);
gps.lat = d.lat ?? gps.lat;
gps.lon = d.lon ?? gps.lon;
gps.alt = d.alt ?? gps.alt;
gps.stat = d.stat ?? gps.stat;
gps.fixes++;
gps.msgs++;
gps.tsfix = Date.now();
if (gps.lat != null && gps.lon != null) {
updateMap(gps.lat, gps.lon, gps.hdg);
addLog(gps.lat, gps.lon, gps.spd, d.t);
}
$('last-update').textContent = 'Last: ' + new Date().toLocaleTimeString('en-US', { hour12: false });
$('last-msg').textContent = 'fix @ ' + 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);
gps.spd = d.spd ?? gps.spd;
gps.hdg = d.hdg ?? gps.hdg;
gps.msgs++;
gps.tsvel = Date.now();
// Update marker heading if we have a position
if (marker && gps.lat != null) {
marker.setIcon(makeIcon(gps.hdg));
}
render();
} catch(_) {}
});
// saltybot/phone/gps — {ts, lat, lon, alt_m, accuracy_m, speed_ms, bearing_deg, provider}
new ROSLIB.Topic({
ros, name: 'saltybot/phone/gps',
messageType: 'std_msgs/String', throttle_rate: 500,
}).subscribe(msg => {
try {
const d = JSON.parse(msg.data);
phone.lat = d.lat ?? phone.lat;
phone.lon = d.lon ?? phone.lon;
phone.alt = d.alt_m ?? phone.alt;
phone.accuracy = d.accuracy_m ?? phone.accuracy;
phone.spd = d.speed_ms ?? phone.spd;
phone.bearing = d.bearing_deg ?? phone.bearing;
phone.provider = d.provider ?? phone.provider;
phone.msgs++;
phone.ts = Date.now();
if (phone.lat != null && phone.lon != null) {
updatePhoneMap(phone.lat, phone.lon, phone.bearing);
}
render();
} catch(_) {}
});
}
// ── Stale checker (1 Hz) ──────────────────────────────────────────────────────
setInterval(render, 1000);
// ── Controls ──────────────────────────────────────────────────────────────────
$('btn-connect').addEventListener('click', () => connect($('ws-input').value.trim(), false));
$('ws-input').addEventListener('keydown', e => {
if (e.key === 'Enter') connect($('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 = []; phoneTrail = []; logEntries = []; gps.fixes = 0;
if (marker) { marker.remove(); marker = null; }
if (phoneMarker) { phoneMarker.remove(); phoneMarker = null; }
polyline.setLatLngs([]);
phonePolyline.setLatLngs([]);
$('trail-log').innerHTML = '';
render();
});
map.on('dragstart', () => {
if (followMode) {
followMode = false;
$('btn-follow').classList.remove('active');
$('btn-follow').textContent = '⊙ FREE';
}
});
// ── Init ──────────────────────────────────────────────────────────────────────
(function init() {
const saved = localStorage.getItem('gps_map_ws_url') || 'ws://localhost:9090';
$('ws-input').value = saved;
drawCompass(null);
connect(saved, false);
})();

View File

@ -193,6 +193,26 @@
</div> </div>
</a> </a>
<a class="panel-card" href="gps_map_panel.html" data-panel="gps">
<div class="card-header">
<div class="card-icon" style="color:#22c55e">🛰️</div>
<div>
<div class="card-title">GPS MAP</div>
<div class="card-sub">#709</div>
</div>
<div class="card-dot" id="dot-gps"></div>
</div>
<div class="card-desc">Live robot position · breadcrumb trail · speed · heading compass · fix quality</div>
<div class="card-topics">
<code>saltybot/gps/fix</code>
<code>saltybot/gps/vel</code>
</div>
<div class="card-footer">
<span class="card-status" id="status-gps">OFFLINE</span>
<span class="card-msg" id="msg-gps">No data</span>
</div>
</a>
<a class="panel-card" href="can_monitor_panel.html" data-panel="can"> <a class="panel-card" href="can_monitor_panel.html" data-panel="can">
<div class="card-header"> <div class="card-header">
<div class="card-icon" style="color:#06b6d4">📡</div> <div class="card-icon" style="color:#06b6d4">📡</div>