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 @@ + +
+
🛰️
+
+
GPS MAP
+
#709
+
+
+
+
Live robot position · breadcrumb trail · speed · heading compass · fix quality
+
+ saltybot/gps/fix + saltybot/gps/vel +
+ +
+
📡