/** * 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 => `