saltylab-firmware/ui/sultee-tracker.html
sl-webui 811a2ccc5c fix(sultee-tracker): subscribe to proper ROS GPS topics for robot marker
Switch robot GPS subscription from custom saltybot/gps/* std_msgs/String
topics to the canonical /gps/fix (sensor_msgs/NavSatFix) and /gps/vel
(geometry_msgs/TwistStamped) published by the SIM7600X GPS driver node.

- /gps/fix: read msg.latitude/longitude/altitude/status.status directly
- /gps/vel: compute speed (sqrt(vx²+vy²) * 3.6 km/h) and heading
  (angular.z radians → degrees) from ENU velocity components

Closes #709

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:41:47 -04:00

819 lines
31 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sul-Tee GPS Tracker</title>
<!-- Leaflet.js -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- ROSLIB for robot GPS via rosbridge -->
<script src="https://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg0: #050510;
--bg1: #070712;
--bg2: #0d0d20;
--border: #0c2a3a;
--cyan: #06b6d4;
--blue: #3b82f6;
--blue-hi:#60a5fa;
--green: #22c55e;
--amber: #f59e0b;
--red: #ef4444;
--orange: #f97316;
--text: #9ca3af;
--text-hi:#d1d5db;
--dim: #374151;
}
html, body { height: 100%; overflow: hidden; }
body {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
background: var(--bg0);
color: var(--text);
display: flex;
flex-direction: column;
}
/* ── Header ──────────────────────────────────────────────────── */
#header {
display: flex;
align-items: center;
gap: 10px;
padding: 5px 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; white-space: nowrap; }
#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(--dim); flex-shrink: 0; transition: background .3s;
}
#conn-dot.connected { background: var(--green); }
#conn-dot.error { background: var(--red); animation: blink 1s infinite; }
#ros-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--dim); flex-shrink: 0; transition: background .3s;
}
#ros-dot.connected { background: var(--orange); }
#ros-dot.error { background: var(--red); animation: blink 1s infinite; }
#ws-input {
flex: 1; background: var(--bg2); border: 1px solid #1e3a5f;
border-radius: 4px; color: #67e8f9; padding: 2px 8px;
font-family: monospace; font-size: 11px; min-width: 120px; max-width: 200px;
}
#ws-input:focus { outline: none; border-color: var(--cyan); }
.hbtn {
padding: 3px 10px; border-radius: 4px; border: 1px solid #1e3a5f;
background: var(--bg2); color: #67e8f9; font-family: monospace;
font-size: 10px; font-weight: bold; cursor: pointer; white-space: nowrap;
}
.hbtn:hover { background: #0e4f69; }
#conn-label { font-size: 10px; color: var(--dim); white-space: nowrap; }
.hdr-badge {
font-size: 9px; padding: 2px 7px; border-radius: 3px; border: 1px solid;
font-weight: bold; white-space: nowrap;
}
.badge-phone { background: #0f172a; border-color: #1d4ed8; color: var(--blue-hi); }
.badge-robot { background: #1a0f00; border-color: #9a3412; color: var(--orange); }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.4} }
/* ── Status bar ─────────────────────────────────────────────── */
#status-bar {
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
padding: 3px 14px; background: var(--bg1);
border-bottom: 1px solid var(--border); font-size: 10px;
}
.sys-badge {
padding: 1px 7px; border-radius: 3px; font-weight: bold;
border: 1px solid; letter-spacing: .05em; font-size: 9px;
}
.badge-ok { background: #052e16; border-color: #166534; color: #4ade80; }
.badge-stale { background: #111827; border-color: var(--dim); color: #6b7280; }
/* ── 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%; }
/* Leaflet dark-mode override */
.leaflet-tile { filter: brightness(.85) invert(1) hue-rotate(200deg) saturate(.7); }
.leaflet-container { background: #090915; }
/* ── Stats sidebar ───────────────────────────────────────────── */
#sidebar {
width: 220px;
flex-shrink: 0;
background: var(--bg1);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.stat-section {
padding: 9px 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: 7px;
}
.section-title.phone-title { color: #2563eb; }
.section-title.robot-title { color: #c2410c; }
.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); margin-top: 2px; }
.big-lbl { font-size: 8px; color: var(--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(--dim); margin-bottom: 2px; }
.kv-val { font-size: 13px; font-weight: bold; font-family: monospace; }
.coord-row { font-size: 10px; color: var(--text); margin-bottom: 2px; }
.coord-val { font-family: monospace; }
/* accuracy bar */
.acc-bar-wrap { height: 4px; background: var(--bg2); border-radius: 2px; margin-top: 5px; 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; }
#compass-canvas { border-radius: 50%; border: 1px solid var(--border); }
/* 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(--dim);
border-bottom: 1px solid #0c1a2a;
padding-bottom: 3px;
}
.trail-entry .te-lat { color: var(--blue-hi); }
.trail-entry .te-spd { color: var(--orange); }
/* ── Bottom bar ──────────────────────────────────────────────── */
#bottombar {
display: flex; align-items: center; gap: 10px;
padding: 4px 14px;
background: var(--bg1);
border-top: 1px solid var(--border);
font-size: 10px; color: var(--text);
flex-shrink: 0; flex-wrap: wrap;
}
.bb-item { display: flex; align-items: center; gap: 4px; }
.bb-lbl { color: var(--dim); }
#last-msg { margin-left: auto; color: var(--dim); }
/* ── Map controls overlay ────────────────────────────────────── */
#map-controls {
position: absolute; top: 10px; right: 10px; z-index: 1000;
display: flex; flex-direction: column; gap: 4px;
}
.map-btn {
padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border);
background: rgba(7,7,18,.85); color: var(--cyan);
font-family: monospace; font-size: 10px; font-weight: bold;
cursor: pointer; backdrop-filter: blur(4px);
white-space: nowrap;
}
.map-btn:hover { background: rgba(14,79,105,.85); }
.map-btn.active { background: rgba(6,182,212,.25); border-color: var(--cyan); }
/* Legend overlay */
#map-legend {
position: absolute; bottom: 26px; left: 10px; z-index: 1000;
background: rgba(7,7,18,.88); border: 1px solid var(--border);
border-radius: 5px; padding: 5px 9px;
display: flex; flex-direction: column; gap: 4px;
font-size: 10px; backdrop-filter: blur(4px);
}
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
@media (max-width: 600px) {
#sidebar { display: none; }
}
</style>
</head>
<body>
<!-- ── Header ── -->
<div id="header">
<div class="logo">⚡ SUL-TEE TRACKER</div>
<div id="conn-bar">
<div id="conn-dot" title="Phone WS"></div>
<div id="ros-dot" title="Robot ROS"></div>
<input id="ws-input" type="text" value="ws://100.64.0.2:9090" placeholder="ws://host:port" />
<button class="hbtn" id="btn-connect">CONNECT</button>
<span id="conn-label">Not connected</span>
</div>
<span class="hdr-badge badge-phone" id="hdr-phone">📱 0 fixes</span>
<span class="hdr-badge badge-robot" id="hdr-robot">🤖 0 fixes</span>
</div>
<!-- ── Status bar ── -->
<div id="status-bar">
<span style="color:var(--dim);font-size:9px">PHONE GPS</span>
<span class="sys-badge badge-stale" id="badge-phone">NO DATA</span>
<span style="color:var(--dim)"></span>
<span style="color:var(--dim);font-size:9px">ROBOT FIX</span>
<span class="sys-badge badge-stale" id="badge-robot-fix">NO DATA</span>
<span style="color:var(--dim);font-size:9px">ROBOT VEL</span>
<span class="sys-badge badge-stale" id="badge-robot-vel">NO DATA</span>
<span id="status-last" style="color:var(--dim);margin-left:auto">Awaiting data…</span>
</div>
<!-- ── Main ── -->
<div id="main">
<!-- Map -->
<div id="map-wrap">
<div id="map"></div>
<div id="map-controls">
<button class="map-btn active" id="btn-follow">⊙ FOLLOW</button>
<button class="map-btn" id="btn-fit">⊞ FIT ALL</button>
<button class="map-btn" id="btn-clear">✕ CLEAR</button>
</div>
<div id="map-legend">
<div class="legend-item"><div class="legend-dot" style="background:#3b82f6;border:2px solid #60a5fa"></div><span style="color:#60a5fa">Phone / User</span></div>
<div class="legend-item"><div class="legend-dot" style="background:#f97316;border:2px solid #fb923c"></div><span style="color:#f97316">Robot (SAUL-TEE)</span></div>
</div>
</div>
<!-- Sidebar -->
<div id="sidebar">
<!-- Phone speed (big) -->
<div class="stat-section">
<div class="section-title phone-title">📱 Phone Speed</div>
<div class="big-stat">
<div class="big-val" id="val-speed" style="color:#3b82f6"></div>
<div class="big-unit">km/h</div>
<div class="big-lbl">GROUND SPEED</div>
</div>
</div>
<!-- Phone altitude + heading -->
<div class="stat-section">
<div class="section-title phone-title">Altitude &amp; Heading</div>
<div class="kv-grid">
<div class="kv-cell">
<div class="kv-lbl">ALT m</div>
<div class="kv-val" id="val-alt"></div>
</div>
<div class="kv-cell">
<div class="kv-lbl">HEADING °</div>
<div class="kv-val" id="val-hdg"></div>
</div>
</div>
<div id="compass-wrap" style="margin-top:6px">
<canvas id="compass-canvas" width="110" height="110"></canvas>
</div>
</div>
<!-- Phone fix quality -->
<div class="stat-section">
<div class="section-title phone-title">Phone Fix Quality</div>
<div class="kv-grid">
<div class="kv-cell">
<div class="kv-lbl">H-ACC m</div>
<div class="kv-val" id="val-acc"></div>
</div>
<div class="kv-cell">
<div class="kv-lbl">FIXES</div>
<div class="kv-val" id="val-fixes">0</div>
</div>
</div>
<div class="acc-bar-wrap">
<div class="acc-bar" id="acc-bar"></div>
</div>
<div style="margin-top:6px">
<div class="coord-row">LAT <span class="coord-val" id="val-lat" style="color:#60a5fa"></span></div>
<div class="coord-row">LON <span class="coord-val" id="val-lon" style="color:#60a5fa"></span></div>
</div>
</div>
<!-- Robot position -->
<div class="stat-section">
<div class="section-title robot-title">🤖 Robot (SAUL-TEE)</div>
<div class="kv-grid">
<div class="kv-cell">
<div class="kv-lbl">SPEED km/h</div>
<div class="kv-val" id="robot-spd" style="color:#f97316"></div>
</div>
<div class="kv-cell">
<div class="kv-lbl">FIXES</div>
<div class="kv-val" id="robot-fixes">0</div>
</div>
</div>
<div style="margin-top:6px">
<div class="coord-row">LAT <span class="coord-val" id="robot-lat" style="color:#fb923c"></span></div>
<div class="coord-row">LON <span class="coord-val" id="robot-lon" style="color:#fb923c"></span></div>
</div>
<div class="coord-row" style="margin-top:4px">
DIST <span id="robot-dist" style="color:var(--amber);font-family:monospace"></span>
</div>
</div>
<!-- Trail log (phone) -->
<div class="section-title" style="padding:7px 12px 2px;color:#2563eb">Trail log (phone)</div>
<div id="trail-log"></div>
</div>
</div>
<!-- ── Bottom bar ── -->
<div id="bottombar">
<div class="bb-item"><span class="bb-lbl" style="color:#3b82f6"></span><span class="bb-lbl">PHONE</span><span id="bb-phone">0</span></div>
<div class="bb-item"><span class="bb-lbl" style="color:#f97316"></span><span class="bb-lbl">ROBOT</span><span id="bb-robot">0</span></div>
<div class="bb-item"><span class="bb-lbl">TRAIL</span><span id="bb-trail">0</span></div>
<span id="last-msg">No data yet</span>
</div>
<script>
'use strict';
// ── Config ────────────────────────────────────────────────────────────────────
const MAX_TRAIL = 2000;
const MAX_LOG = 60;
const STALE_MS = 5000;
const RECONNECT_BASE = 2000;
const RECONNECT_MAX = 30000;
const RECONNECT_MUL = 1.5;
// ── State ─────────────────────────────────────────────────────────────────────
const phone = {
lat: null, lon: null, speed: null, altitude: null,
course: null, accuracy: null, fixes: 0, ts: 0,
};
const robot = {
lat: null, lon: null, alt: null, stat: null,
spd: null, hdg: null, fixes: 0,
tsfix: 0, tsvel: 0,
};
let phoneTrail = []; // [{lat,lon}]
let robotTrail = [];
let logEntries = [];
let followMode = true;
// WS (phone)
let ws = null;
let wsDelay = RECONNECT_BASE;
let wsTimer = null;
let wsTick = null;
// ROS (robot)
let ros = 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);
// Phone marker — blue, bearing arrow
function makePhoneIcon(course) {
const rot = (course != null && course >= 0) ? course : 0;
return L.divIcon({
className: '',
html: `<svg width="24" height="32" viewBox="0 0 24 32" xmlns="http://www.w3.org/2000/svg"
style="transform:rotate(${rot}deg);transform-origin:12px 17px">
<circle cx="12" cy="17" r="8" fill="#3b82f6" fill-opacity=".3" stroke="#3b82f6" stroke-width="2"/>
<circle cx="12" cy="17" r="3.5" fill="#3b82f6"/>
<polygon points="12,2 9,12 15,12" fill="#60a5fa" opacity=".9"/>
</svg>`,
iconSize: [24, 32],
iconAnchor: [12, 17],
});
}
// Robot marker — orange, heading arrow
function makeRobotIcon(hdg) {
const rot = (hdg != null && hdg >= 0) ? hdg : 0;
return L.divIcon({
className: '',
html: `<svg width="28" height="36" viewBox="0 0 28 36" xmlns="http://www.w3.org/2000/svg"
style="transform:rotate(${rot}deg);transform-origin:14px 20px">
<circle cx="14" cy="20" r="10" fill="#f97316" fill-opacity=".25" stroke="#f97316" stroke-width="2"/>
<circle cx="14" cy="20" r="4" fill="#f97316"/>
<polygon points="14,2 10,14 18,14" fill="#fb923c" opacity=".9"/>
</svg>`,
iconSize: [28, 36],
iconAnchor: [14, 20],
});
}
let phoneMarker = null;
let robotMarker = null;
let phonePolyline = L.polyline([], {
color: '#3b82f6', weight: 2.5, opacity: .8, lineJoin: 'round',
}).addTo(map);
let robotPolyline = 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;
const cx = W / 2, cy = H / 2, r = W / 2 - 4;
ctx.clearRect(0, 0, W, H);
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.strokeStyle = '#1e3a5f'; ctx.lineWidth = 1.5; ctx.stroke();
ctx.fillStyle = '#070712'; ctx.fill();
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 - 10);
const ly = cy - Math.cos(a) * (r - 10);
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, 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 = '#3b82f6'; 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();
}
// ── Haversine distance (km) ───────────────────────────────────────────────────
function haversine(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 +
Math.cos(lat1 * Math.PI/180) * Math.cos(lat2 * Math.PI/180) * Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
// ── Stale badge helper ────────────────────────────────────────────────────────
function setSysBadge(id, ok, text) {
const el = $(id); if (!el) return;
el.className = `sys-badge badge-${ok ? 'ok' : 'stale'}`;
el.textContent = text;
}
// ── Render ────────────────────────────────────────────────────────────────────
function render() {
const now = Date.now();
// ─ Phone badges & stats ─
const phoneStale = phone.ts === 0 || (now - phone.ts > STALE_MS);
setSysBadge('badge-phone', !phoneStale,
phone.ts === 0 ? 'NO DATA' : phoneStale ? 'STALE' : 'LIVE');
$('hdr-phone').textContent = `📱 ${phone.fixes} fix${phone.fixes !== 1 ? 'es' : ''}`;
// Speed
const spdKmh = phone.speed != null ? phone.speed * 3.6 : null;
$('val-speed').textContent = spdKmh != null ? spdKmh.toFixed(1) : '—';
$('val-speed').style.color = spdKmh == null ? '#6b7280'
: spdKmh < 5 ? '#22c55e' : spdKmh < 30 ? '#3b82f6' : '#ef4444';
// Altitude
$('val-alt').textContent = phone.altitude != null ? phone.altitude.toFixed(1) : '—';
// Heading
const hdg = phone.course != null && phone.course >= 0 ? Math.round(phone.course) : null;
$('val-hdg').textContent = hdg != null ? hdg + '°' : '—';
drawCompass(phone.course);
// Accuracy
const acc = phone.accuracy;
$('val-acc').textContent = acc != null ? acc.toFixed(1) : '—';
const accNorm = acc != null ? Math.min(1, acc / 50) : 0;
const accColor = acc == null ? '#374151' : acc < 5 ? '#22c55e' : acc < 15 ? '#f59e0b' : '#ef4444';
$('acc-bar').style.width = (accNorm * 100) + '%';
$('acc-bar').style.background = accColor;
$('val-acc').style.color = accColor;
// Coords
$('val-fixes').textContent = phone.fixes;
$('val-lat').textContent = phone.lat != null ? phone.lat.toFixed(6) : '—';
$('val-lon').textContent = phone.lon != null ? phone.lon.toFixed(6) : '—';
// ─ Robot badges & stats ─
const robotFixStale = robot.tsfix === 0 || (now - robot.tsfix > STALE_MS);
const robotVelStale = robot.tsvel === 0 || (now - robot.tsvel > STALE_MS);
setSysBadge('badge-robot-fix', !robotFixStale,
robot.tsfix === 0 ? 'NO DATA' : robotFixStale ? 'STALE' : 'LIVE');
setSysBadge('badge-robot-vel', !robotVelStale,
robot.tsvel === 0 ? 'NO DATA' : robotVelStale ? 'STALE' : 'LIVE');
$('hdr-robot').textContent = `🤖 ${robot.fixes} fix${robot.fixes !== 1 ? 'es' : ''}`;
const robotSpdKmh = robot.spd != null ? robot.spd : null;
$('robot-spd').textContent = robotSpdKmh != null ? robotSpdKmh.toFixed(1) : '—';
$('robot-fixes').textContent = robot.fixes;
$('robot-lat').textContent = robot.lat != null ? robot.lat.toFixed(6) : '—';
$('robot-lon').textContent = robot.lon != null ? robot.lon.toFixed(6) : '—';
// Distance between phone and robot
if (phone.lat != null && robot.lat != null) {
const km = haversine(phone.lat, phone.lon, robot.lat, robot.lon);
$('robot-dist').textContent = km < 1 ? (km * 1000).toFixed(0) + ' m' : km.toFixed(2) + ' km';
} else {
$('robot-dist').textContent = '—';
}
// ─ Bottom bar ─
$('bb-phone').textContent = phone.fixes;
$('bb-robot').textContent = robot.fixes;
$('bb-trail').textContent = phoneTrail.length;
}
// ── Map update functions ──────────────────────────────────────────────────────
function updatePhoneMap(lat, lon, course) {
const icon = makePhoneIcon(course);
if (!phoneMarker) {
phoneMarker = L.marker([lat, lon], { icon }).addTo(map);
if (followMode) map.setView([lat, lon], 16);
} else {
phoneMarker.setIcon(icon);
phoneMarker.setLatLng([lat, lon]);
if (followMode) map.panTo([lat, lon]);
}
phoneTrail.push({ lat, lon });
if (phoneTrail.length > MAX_TRAIL) phoneTrail.shift();
phonePolyline.setLatLngs(phoneTrail.map(p => [p.lat, p.lon]));
}
function updateRobotMap(lat, lon, hdg) {
const icon = makeRobotIcon(hdg);
if (!robotMarker) {
robotMarker = L.marker([lat, lon], { icon }).addTo(map);
} else {
robotMarker.setIcon(icon);
robotMarker.setLatLng([lat, lon]);
}
robotTrail.push({ lat, lon });
if (robotTrail.length > MAX_TRAIL) robotTrail.shift();
robotPolyline.setLatLngs(robotTrail.map(p => [p.lat, p.lon]));
}
function fitAll() {
const pts = [];
if (phone.lat != null) pts.push([phone.lat, phone.lon]);
if (robot.lat != null) pts.push([robot.lat, robot.lon]);
if (pts.length > 0) map.fitBounds(pts, { padding: [40, 40], maxZoom: 17 });
}
// ── Trail log ─────────────────────────────────────────────────────────────────
function addLogEntry(d, ts) {
const time = new Date(ts * 1000).toLocaleTimeString('en-US', { hour12: false });
const spd = d.speed != null ? (d.speed * 3.6).toFixed(1) : '?';
logEntries.unshift({ time, lat: d.latitude, lon: d.longitude, spd });
if (logEntries.length > MAX_LOG) logEntries.pop();
$('trail-log').innerHTML = logEntries.map(e =>
`<div class="trail-entry">${e.time} <span class="te-lat">${e.lat.toFixed(5)},${e.lon.toFixed(5)}</span> <span class="te-spd">${e.spd} km/h</span></div>`
).join('');
}
// ── WebSocket (phone GPS) ─────────────────────────────────────────────────────
function cancelWsReconn() {
if (wsTimer) { clearTimeout(wsTimer); wsTimer = null; }
if (wsTick) { clearInterval(wsTick); wsTick = null; }
}
function scheduleWsReconn(url) {
cancelWsReconn();
let secs = Math.round(wsDelay / 1000);
$('conn-label').textContent = `Retry in ${secs}s…`;
$('conn-label').style.color = '#6b7280';
wsTick = setInterval(() => { secs = Math.max(0, secs-1); if (secs>0) $('conn-label').textContent=`Retry in ${secs}s…`; }, 1000);
wsTimer = setTimeout(() => { wsTimer = null; doConnect(url, true); }, wsDelay);
wsDelay = Math.min(wsDelay * RECONNECT_MUL, RECONNECT_MAX);
}
function doConnect(url, isRetry) {
cancelWsReconn();
if (!isRetry) wsDelay = RECONNECT_BASE;
if (ws) { try { ws.close(); } catch(_) {} ws = null; }
$('conn-label').textContent = 'Connecting…';
$('conn-label').style.color = '#6b7280';
$('conn-dot').className = '';
try { ws = new WebSocket(url); }
catch(e) { $('conn-label').textContent = 'Bad URL'; $('conn-dot').className = 'error'; return; }
ws.onopen = () => {
wsDelay = RECONNECT_BASE; cancelWsReconn();
$('conn-dot').className = 'connected';
$('conn-label').textContent = 'WS connected';
$('conn-label').style.color = '#22c55e';
localStorage.setItem('sultee_ws_url', url);
};
ws.onclose = () => { $('conn-dot').className = ''; ws = null; scheduleWsReconn(url); };
ws.onerror = () => { $('conn-dot').className = 'error'; $('conn-label').style.color='#ef4444'; $('conn-label').textContent='WS error'; };
ws.onmessage = evt => {
let msg;
try { msg = JSON.parse(evt.data); } catch(_) { return; }
if (msg.type !== 'gps' || !msg.data) return;
const d = msg.data;
const ts = msg.timestamp || Math.floor(Date.now() / 1000);
phone.lat = d.latitude ?? phone.lat;
phone.lon = d.longitude ?? phone.lon;
phone.speed = d.speed ?? phone.speed;
phone.altitude = d.altitude ?? phone.altitude;
phone.course = d.course ?? phone.course;
phone.accuracy = d.horizontalAccuracy ?? phone.accuracy;
phone.fixes++;
phone.ts = Date.now();
if (phone.lat != null && phone.lon != null) {
updatePhoneMap(phone.lat, phone.lon, phone.course);
addLogEntry(d, ts);
}
$('status-last').textContent = 'Phone: ' + new Date(ts * 1000).toLocaleTimeString('en-US', { hour12: false });
$('last-msg').textContent = 'Phone: ' + new Date(ts * 1000).toLocaleTimeString('en-US', { hour12: false });
render();
};
}
// ── ROSLIB (robot GPS) ────────────────────────────────────────────────────────
function connectRos(url) {
if (ros) { try { ros.close(); } catch(_) {} }
ros = new ROSLIB.Ros({ url });
ros.on('connection', () => {
$('ros-dot').className = 'connected';
setupRobotTopics();
});
ros.on('error', () => { $('ros-dot').className = 'error'; });
ros.on('close', () => {
$('ros-dot').className = '';
setTimeout(() => connectRos(url), 5000);
});
}
function setupRobotTopics() {
// /gps/fix — sensor_msgs/NavSatFix (SIM7600X / Pixel 5 GPS)
new ROSLIB.Topic({
ros, name: '/gps/fix',
messageType: 'sensor_msgs/NavSatFix', throttle_rate: 500,
}).subscribe(msg => {
robot.lat = msg.latitude ?? robot.lat;
robot.lon = msg.longitude ?? robot.lon;
robot.alt = msg.altitude ?? robot.alt;
robot.stat = msg.status ? msg.status.status : robot.stat;
robot.fixes++;
robot.tsfix = Date.now();
if (robot.lat != null && robot.lon != null) {
updateRobotMap(robot.lat, robot.lon, robot.hdg);
}
$('last-msg').textContent = 'Robot: ' + new Date().toLocaleTimeString('en-US', { hour12: false });
render();
});
// /gps/vel — geometry_msgs/TwistStamped (east/north m/s + heading in radians)
new ROSLIB.Topic({
ros, name: '/gps/vel',
messageType: 'geometry_msgs/TwistStamped', throttle_rate: 500,
}).subscribe(msg => {
const vx = msg.twist.linear.x; // east m/s
const vy = msg.twist.linear.y; // north m/s
const spd = Math.sqrt(vx * vx + vy * vy) * 3.6; // → km/h
const hdg = (msg.twist.angular.z * 180 / Math.PI + 360) % 360; // radians → degrees
robot.spd = spd;
robot.hdg = hdg;
robot.tsvel = Date.now();
if (robotMarker && robot.lat != null) {
robotMarker.setIcon(makeRobotIcon(robot.hdg));
}
render();
});
}
// ── Stale refresh ─────────────────────────────────────────────────────────────
setInterval(render, 1000);
// ── Controls ──────────────────────────────────────────────────────────────────
$('btn-connect').addEventListener('click', () => {
const url = $('ws-input').value.trim();
doConnect(url, false);
connectRos(url);
});
$('ws-input').addEventListener('keydown', e => {
if (e.key === 'Enter') { const url = $('ws-input').value.trim(); doConnect(url, false); connectRos(url); }
});
$('btn-follow').addEventListener('click', () => {
followMode = !followMode;
$('btn-follow').classList.toggle('active', followMode);
$('btn-follow').textContent = followMode ? '⊙ FOLLOW' : '⊙ FREE';
if (followMode && phone.lat != null) map.panTo([phone.lat, phone.lon]);
});
$('btn-fit').addEventListener('click', fitAll);
$('btn-clear').addEventListener('click', () => {
phoneTrail = []; robotTrail = [];
logEntries = []; phone.fixes = 0; robot.fixes = 0;
if (phoneMarker) { phoneMarker.remove(); phoneMarker = null; }
if (robotMarker) { robotMarker.remove(); robotMarker = null; }
phonePolyline.setLatLngs([]);
robotPolyline.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('sultee_ws_url') || 'ws://100.64.0.2:9090';
$('ws-input').value = saved;
drawCompass(null);
doConnect(saved, false);
connectRos(saved);
})();
</script>
</body>
</html>