/** * 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} * * 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 * - 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, }; let trail = []; // [{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], }); } let marker = null; let polyline = L.polyline([], { color: '#f97316', weight: 2.5, opacity: .8, 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; setSysBadge('badge-fix', fixStale ? 'stale' : 'ok', fixStale ? 'STALE' : 'LIVE'); setSysBadge('badge-vel', velStale ? 'stale' : 'ok', velStale ? '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) : '—'; } 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 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 => `