diff --git a/ui/gps_map_panel.html b/ui/gps_map_panel.html
index d9b65af..36b5d43 100644
--- a/ui/gps_map_panel.html
+++ b/ui/gps_map_panel.html
@@ -24,11 +24,14 @@
- GPS FIX
+ ROBOT FIX
STALE
│
VELOCITY
STALE
+ │
+ PHONE
+ NO DATA
Awaiting data…
@@ -94,6 +97,26 @@
+
+
+
📱 Phone / User
+
+
+
SRC —
+
+
Trail log
@@ -105,7 +128,8 @@
FIXES0
TRAIL PTS0
- MSGS0
+ ROBOT MSGS0
+ PHONE MSGS0
No data
diff --git a/ui/gps_map_panel.js b/ui/gps_map_panel.js
index 89eea4c..be42988 100644
--- a/ui/gps_map_panel.js
+++ b/ui/gps_map_panel.js
@@ -2,13 +2,15 @@
* 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/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 with heading arrow
- * - Orange breadcrumb trail polyline (up to MAX_TRAIL points)
- * - Sidebar: speed, altitude, heading compass, fix status, coordinates
+ * - 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
*/
@@ -35,7 +37,15 @@ const gps = {
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;
@@ -74,11 +84,35 @@ function makeIcon(hdg) {
});
}
+// 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) {
@@ -135,10 +169,13 @@ function render() {
const now = Date.now();
// Stale badges
- const fixStale = now - gps.tsfix > STALE_MS;
- const velStale = now - gps.tsvel > STALE_MS;
- setSysBadge('badge-fix', fixStale ? 'stale' : 'ok', fixStale ? 'STALE' : 'LIVE');
- setSysBadge('badge-vel', velStale ? 'stale' : 'ok', velStale ? 'STALE' : 'LIVE');
+ 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;
@@ -171,6 +208,15 @@ function render() {
// 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) {
@@ -190,6 +236,21 @@ function updateMap(lat, lon, hdg) {
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 });
@@ -297,6 +358,30 @@ function setupTopics() {
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) ──────────────────────────────────────────────────────
@@ -318,9 +403,11 @@ $('btn-follow').addEventListener('click', () => {
});
$('btn-clear').addEventListener('click', () => {
- trail = []; logEntries = []; gps.fixes = 0;
- if (marker) { marker.remove(); marker = null; }
+ 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();
});