diff --git a/ui/dashboard.js b/ui/dashboard.js
index 4463543..fa40c7b 100644
--- a/ui/dashboard.js
+++ b/ui/dashboard.js
@@ -22,6 +22,7 @@ const PANELS = [
{ id: 'settings', watchTopic: null, msgType: null }, // service-based
{ id: 'gimbal', watchTopic: '/gimbal/state', msgType: 'geometry_msgs/Vector3' },
{ id: 'can', watchTopic: '/vesc/left/state', msgType: 'std_msgs/String' },
+ { id: 'gps', watchTopic: 'saltybot/gps/fix', msgType: 'std_msgs/String' },
];
// ── State ──────────────────────────────────────────────────────────────────
@@ -188,6 +189,13 @@ function setupTopics() {
});
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) ──
const cmdVelWatch = new ROSLIB.Topic({
ros, name: '/cmd_vel',
diff --git a/ui/gps_map_panel.css b/ui/gps_map_panel.css
new file mode 100644
index 0000000..7c47ad6
--- /dev/null
+++ b/ui/gps_map_panel.css
@@ -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; } }
diff --git a/ui/gps_map_panel.html b/ui/gps_map_panel.html
new file mode 100644
index 0000000..36b5d43
--- /dev/null
+++ b/ui/gps_map_panel.html
@@ -0,0 +1,138 @@
+
+
+
+
+
+Saltybot — GPS Map
+
+
+
+
+
+
+
+
+
+
+
+
+ ROBOT FIX
+ STALE
+ │
+ VELOCITY
+ STALE
+ │
+ PHONE
+ NO DATA
+ Awaiting data…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ FIXES0
+ TRAIL PTS0
+ ROBOT MSGS0
+ PHONE MSGS0
+ No data
+
+
+
+
+
diff --git a/ui/gps_map_panel.js b/ui/gps_map_panel.js
new file mode 100644
index 0000000..be42988
--- /dev/null
+++ b/ui/gps_map_panel.js
@@ -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: ``,
+ 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: ``,
+ 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 =>
+ `${e.time} ${e.lat.toFixed(5)},${e.lon.toFixed(5)} ${e.spdTxt}
`
+ ).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);
+})();
diff --git a/ui/index.html b/ui/index.html
index 7b783ca..a9e59cb 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -193,6 +193,26 @@
+
+
+ Live robot position · breadcrumb trail · speed · heading compass · fix quality
+
+ saltybot/gps/fix
+ saltybot/gps/vel
+
+
+
+