saltylab-firmware/ui/map_panel.js
sl-webui 5dac6337e6 feat: WebUI map view (Issue #587)
Standalone 3-file 2D map panel (ui/map_panel.{html,js,css}).
No build step. Open directly or serve ui/ directory.

Canvas layers (drawn every animation frame):
  - Grid lines (1m spacing) + world-origin axis cross + 1m scale bar
  - RPLIDAR scan dots (/scan, green, cbor-compressed, 100ms throttle)
  - Safety zone rings: danger 0.30m (red dashed) + warn 1.00m (amber dashed)
  - 100-position breadcrumb trail with fading cyan polyline + dots every 5 pts
  - UWB anchor markers (amber diamond + label, user-configured)
  - Robot marker: circle + forward arrow, red when e-stopped

Interactions:
  - Mouse wheel zoom (zooms around cursor)
  - Click+drag pan
  - Pinch-to-zoom (touch, two-finger)
  - Auto-center toggle (robot stays centered when on)
  - Zoom +/- buttons, Reset view button
  - Clear trail button
  - Mouse hover shows world coords (m) in bottom-left HUD

ROS topics:
  SUB /saltybot/pose/fused        geometry_msgs/PoseStamped   50ms throttle
  SUB /scan                       sensor_msgs/LaserScan       100ms + cbor
  SUB /saltybot/safety_zone/status std_msgs/String (JSON)     200ms throttle

Sidebar:
  - Robot position (x, y m) + heading (°)
  - Safety zone: forward zone (CLEAR/WARN/DANGER), closest obstacle (m), e-stop
  - UWB anchor manager: add/remove anchors with x/y/label, persisted localStorage
  - Topic reference

E-stop banner: pulsing red overlay when /saltybot/safety_zone/status estop_active=true

Mobile-responsive: sidebar hidden on <700px, canvas fills viewport.
WS URL persisted in localStorage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 12:20:04 -04:00

608 lines
20 KiB
JavaScript

/**
* map_panel.js — Saltybot 2D Map View (Issue #587)
*
* Canvas-based 2D map with:
* - Robot position from /saltybot/pose/fused (geometry_msgs/PoseStamped)
* - RPLIDAR scan overlay from /scan (sensor_msgs/LaserScan)
* - Safety zone rings at danger (0.30m) and warn (1.00m)
* - /saltybot/safety_zone/status JSON — e-stop + closest obstacle
* - UWB anchor markers (user-configured or via /saltybot/uwb/anchors)
* - 100-position breadcrumb trail
* - Zoom (wheel) and pan (drag) with touch support
* - Auto-center toggle
*
* World → canvas: cx = origin.x + x * ppm
* cy = origin.y - y * ppm (Y flipped)
*/
'use strict';
// ── Config ────────────────────────────────────────────────────────────────────
const DANGER_R = 0.30; // m — matches safety_zone_params.yaml default
const WARN_R = 1.00; // m
const TRAIL_MAX = 100;
const SCAN_THROTTLE = 100; // ms — don't render every scan packet
const GRID_SPACING_M = 1.0; // world metres per grid square
const PIXELS_PER_M = 80; // initial scale
const MIN_PPM = 10;
const MAX_PPM = 600;
// ── State ─────────────────────────────────────────────────────────────────────
let ros = null, poseSub = null, scanSub = null, statusSub = null;
const state = {
// Robot
robot: { x: 0, y: 0, theta: 0 }, // world metres + radians
trail: [], // [{x,y}]
// Scan
scan: null, // { angle_min, angle_increment, ranges[] }
scanTs: 0,
// Safety status
estop: false,
fwdZone: 'CLEAR',
closestM: null,
dangerN: 0,
warnN: 0,
// UWB anchors [{x,y,label}]
anchors: [],
// View
autoCenter: true,
ppm: PIXELS_PER_M, // pixels per metre
originX: 0, // canvas px where world (0,0) is
originY: 0,
};
// ── Canvas ────────────────────────────────────────────────────────────────────
const canvas = document.getElementById('map-canvas');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('map-wrap');
function resize() {
canvas.width = wrap.clientWidth || 600;
canvas.height = wrap.clientHeight || 400;
if (state.autoCenter) centerOnRobot();
draw();
}
window.addEventListener('resize', resize);
// ── World ↔ canvas coordinate helpers ────────────────────────────────────────
function w2cx(wx) { return state.originX + wx * state.ppm; }
function w2cy(wy) { return state.originY - wy * state.ppm; }
function c2wx(cx) { return (cx - state.originX) / state.ppm; }
function c2wy(cy) { return -(cy - state.originY) / state.ppm; }
function yawFromQuat(qx, qy, qz, qw) {
return Math.atan2(2*(qw*qz + qx*qy), 1 - 2*(qy*qy + qz*qz));
}
// ── Draw ──────────────────────────────────────────────────────────────────────
function draw() {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = '#010108';
ctx.fillRect(0, 0, W, H);
drawGrid(W, H);
drawScan();
drawSafetyZones();
drawTrail();
drawAnchors();
drawRobot();
updateHUD();
}
// Grid lines
function drawGrid(W, H) {
const ppm = state.ppm;
const step = GRID_SPACING_M * ppm;
const ox = ((state.originX % step) + step) % step;
const oy = ((state.originY % step) + step) % step;
ctx.strokeStyle = '#0d1a2a';
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = ox; x < W; x += step) { ctx.moveTo(x,0); ctx.lineTo(x,H); }
for (let y = oy; y < H; y += step) { ctx.moveTo(0,y); ctx.lineTo(W,y); }
ctx.stroke();
// Axis cross at world origin
const ox0 = state.originX, oy0 = state.originY;
if (ox0 > 0 && ox0 < W) {
ctx.strokeStyle = '#1e3a5f'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(ox0, 0); ctx.lineTo(ox0, H); ctx.stroke();
}
if (oy0 > 0 && oy0 < H) {
ctx.strokeStyle = '#1e3a5f'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0, oy0); ctx.lineTo(W, oy0); ctx.stroke();
}
// Scale bar (bottom right)
const barM = 1.0;
const barPx = barM * ppm;
const bx = W - 20, by = H - 12;
ctx.strokeStyle = '#374151'; ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(bx - barPx, by); ctx.lineTo(bx, by);
ctx.moveTo(bx - barPx, by - 4); ctx.lineTo(bx - barPx, by + 4);
ctx.moveTo(bx, by - 4); ctx.lineTo(bx, by + 4);
ctx.stroke();
ctx.fillStyle = '#6b7280'; ctx.font = '9px Courier New'; ctx.textAlign = 'right';
ctx.fillText(`${barM}m`, bx, by - 6);
ctx.textAlign = 'left';
}
// LIDAR scan
function drawScan() {
if (!state.scan) return;
const { angle_min, angle_increment, ranges, range_max } = state.scan;
const rx = state.robot.x, ry = state.robot.y, th = state.robot.theta;
const maxR = range_max || 12;
ctx.fillStyle = 'rgba(34,197,94,0.75)';
for (let i = 0; i < ranges.length; i++) {
const r = ranges[i];
if (!isFinite(r) || r <= 0.02 || r >= maxR) continue;
const a = th + angle_min + i * angle_increment;
const wx = rx + r * Math.cos(a);
const wy = ry + r * Math.sin(a);
const cx_ = w2cx(wx), cy_ = w2cy(wy);
ctx.beginPath();
ctx.arc(cx_, cy_, 1.5, 0, Math.PI * 2);
ctx.fill();
}
}
// Safety zone rings (drawn around robot)
function drawSafetyZones() {
const cx_ = w2cx(state.robot.x), cy_ = w2cy(state.robot.y);
const ppm = state.ppm;
// WARN ring (amber)
const warnEstop = state.estop;
ctx.strokeStyle = warnEstop ? 'rgba(239,68,68,0.35)' : 'rgba(245,158,11,0.35)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(cx_, cy_, WARN_R * ppm, 0, Math.PI * 2);
ctx.stroke();
// DANGER ring (red)
ctx.strokeStyle = 'rgba(239,68,68,0.55)';
ctx.lineWidth = 1.5;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.arc(cx_, cy_, DANGER_R * ppm, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
// Breadcrumb trail
function drawTrail() {
const trail = state.trail;
if (trail.length < 2) return;
ctx.lineWidth = 1.5;
for (let i = 1; i < trail.length; i++) {
const alpha = i / trail.length;
ctx.strokeStyle = `rgba(6,182,212,${alpha * 0.6})`;
ctx.beginPath();
ctx.moveTo(w2cx(trail[i-1].x), w2cy(trail[i-1].y));
ctx.lineTo(w2cx(trail[i].x), w2cy(trail[i].y));
ctx.stroke();
}
// Trail dots at every 5th point
ctx.fillStyle = 'rgba(6,182,212,0.4)';
for (let i = 0; i < trail.length; i += 5) {
ctx.beginPath();
ctx.arc(w2cx(trail[i].x), w2cy(trail[i].y), 2, 0, Math.PI * 2);
ctx.fill();
}
}
// UWB anchor markers
function drawAnchors() {
for (const a of state.anchors) {
const cx_ = w2cx(a.x), cy_ = w2cy(a.y);
const r = 7;
// Diamond shape
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(cx_, cy_ - r);
ctx.lineTo(cx_ + r, cy_);
ctx.lineTo(cx_, cy_ + r);
ctx.lineTo(cx_ - r, cy_);
ctx.closePath();
ctx.stroke();
ctx.fillStyle = 'rgba(245,158,11,0.15)';
ctx.fill();
// Label
ctx.fillStyle = '#fcd34d';
ctx.font = 'bold 9px Courier New';
ctx.textAlign = 'center';
ctx.fillText(a.label || '⚓', cx_, cy_ - r - 3);
ctx.textAlign = 'left';
}
}
// Robot marker
function drawRobot() {
const cx_ = w2cx(state.robot.x), cy_ = w2cy(state.robot.y);
const th = state.robot.theta;
const r = 10;
ctx.save();
ctx.translate(cx_, cy_);
ctx.rotate(-th); // canvas Y is flipped so negate
// Body circle
const isEstop = state.estop;
ctx.strokeStyle = isEstop ? '#ef4444' : '#22d3ee';
ctx.fillStyle = isEstop ? 'rgba(239,68,68,0.2)' : 'rgba(34,211,238,0.15)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Forward arrow
ctx.strokeStyle = isEstop ? '#f87171' : '#67e8f9';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(r + 4, 0);
ctx.stroke();
// Arrowhead
ctx.fillStyle = isEstop ? '#f87171' : '#67e8f9';
ctx.beginPath();
ctx.moveTo(r + 4, 0);
ctx.lineTo(r + 1, -3);
ctx.lineTo(r + 1, 3);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// ── HUD ───────────────────────────────────────────────────────────────────────
function updateHUD() {
document.getElementById('zoom-display').textContent =
(state.ppm / PIXELS_PER_M).toFixed(2) + 'x';
updateSidebar();
}
function updateSidebar() {
setText('sb-pos', `${state.robot.x.toFixed(2)}, ${state.robot.y.toFixed(2)} m`);
setText('sb-hdg', (state.robot.theta * 180 / Math.PI).toFixed(1) + '°');
setText('sb-trail', state.trail.length + ' pts');
setText('sb-closest',
state.closestM != null ? state.closestM.toFixed(2) + ' m' : '—');
setText('sb-fwd', state.fwdZone);
// Zone status dot
const dot = document.getElementById('sb-zone-dot');
if (dot) {
dot.className = 'sdot ' + (
state.estop ? 'red' :
state.fwdZone === 'DANGER' ? 'red' :
state.fwdZone === 'WARN' ? 'amber' : 'green'
);
}
// E-stop overlay
const ov = document.getElementById('estop-overlay');
if (ov) ov.classList.toggle('visible', state.estop);
// ESTOP badge in sidebar
setText('sb-estop', state.estop ? 'ACTIVE' : 'clear');
const estopEl = document.getElementById('sb-estop');
if (estopEl) estopEl.style.color = state.estop ? '#ef4444' : '#22c55e';
}
function setText(id, val) {
const el = document.getElementById(id);
if (el) el.textContent = val ?? '—';
}
// Coords HUD on mouse move
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const cx_ = (e.clientX - rect.left) * scaleX;
const cy_ = (e.clientY - rect.top) * scaleY;
const wx = c2wx(cx_).toFixed(2);
const wy = c2wy(cy_).toFixed(2);
document.getElementById('coords-hud').textContent = `(${wx}, ${wy}) m`;
});
// ── Zoom & pan ────────────────────────────────────────────────────────────────
let dragging = false, dragStart = { cx: 0, cy: 0, ox: 0, oy: 0 };
let pinchDist = null;
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (canvas.width / rect.width);
const my = (e.clientY - rect.top) * (canvas.height / rect.height);
const factor = e.deltaY < 0 ? 1.12 : 0.89;
const newPpm = Math.max(MIN_PPM, Math.min(MAX_PPM, state.ppm * factor));
// Zoom around cursor position
state.originX = mx - (mx - state.originX) * (newPpm / state.ppm);
state.originY = my - (my - state.originY) * (newPpm / state.ppm);
state.ppm = newPpm;
state.autoCenter = false;
document.getElementById('btn-center').classList.remove('on');
draw();
}, { passive: false });
canvas.addEventListener('pointerdown', (e) => {
if (e.pointerType === 'touch') return;
dragging = true;
canvas.classList.add('dragging');
canvas.setPointerCapture(e.pointerId);
dragStart = { cx: e.clientX, cy: e.clientY, ox: state.originX, oy: state.originY };
});
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
const dx = e.clientX - dragStart.cx;
const dy = e.clientY - dragStart.cy;
state.originX = dragStart.ox + dx;
state.originY = dragStart.oy + dy;
state.autoCenter = false;
document.getElementById('btn-center').classList.remove('on');
draw();
});
canvas.addEventListener('pointerup', () => {
dragging = false;
canvas.classList.remove('dragging');
});
canvas.addEventListener('pointercancel', () => {
dragging = false;
canvas.classList.remove('dragging');
});
// Touch pinch-to-zoom
canvas.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
pinchDist = touchDist(e.touches);
}
}, { passive: true });
canvas.addEventListener('touchmove', (e) => {
if (e.touches.length === 2 && pinchDist != null) {
const d = touchDist(e.touches);
const factor = d / pinchDist;
pinchDist = d;
const newPpm = Math.max(MIN_PPM, Math.min(MAX_PPM, state.ppm * factor));
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const rect = canvas.getBoundingClientRect();
const cx_ = (mx - rect.left) * (canvas.width / rect.width);
const cy_ = (my - rect.top) * (canvas.height / rect.height);
state.originX = cx_ - (cx_ - state.originX) * (newPpm / state.ppm);
state.originY = cy_ - (cy_ - state.originY) * (newPpm / state.ppm);
state.ppm = newPpm;
draw();
}
}, { passive: true });
canvas.addEventListener('touchend', () => { pinchDist = null; }, { passive: true });
function touchDist(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx*dx + dy*dy);
}
// ── Auto-center ───────────────────────────────────────────────────────────────
function centerOnRobot() {
state.originX = canvas.width / 2 - state.robot.x * state.ppm;
state.originY = canvas.height / 2 + state.robot.y * state.ppm;
}
document.getElementById('btn-center').addEventListener('click', () => {
state.autoCenter = !state.autoCenter;
document.getElementById('btn-center').classList.toggle('on', state.autoCenter);
if (state.autoCenter) { centerOnRobot(); draw(); }
});
document.getElementById('btn-zoom-in').addEventListener('click', () => {
state.ppm = Math.min(MAX_PPM, state.ppm * 1.4);
if (state.autoCenter) centerOnRobot();
draw();
});
document.getElementById('btn-zoom-out').addEventListener('click', () => {
state.ppm = Math.max(MIN_PPM, state.ppm / 1.4);
if (state.autoCenter) centerOnRobot();
draw();
});
document.getElementById('btn-reset').addEventListener('click', () => {
state.ppm = PIXELS_PER_M;
state.autoCenter = true;
document.getElementById('btn-center').classList.add('on');
centerOnRobot(); draw();
});
// ── ROS connection ────────────────────────────────────────────────────────────
function connect() {
const url = document.getElementById('ws-input').value.trim();
if (!url) return;
if (ros) ros.close();
ros = new ROSLIB.Ros({ url });
ros.on('connection', () => {
document.getElementById('conn-dot').className = 'connected';
document.getElementById('conn-label').textContent = url;
document.getElementById('btn-connect').textContent = 'RECONNECT';
document.getElementById('no-signal').classList.add('hidden');
setupSubs();
});
ros.on('error', (err) => {
document.getElementById('conn-dot').className = 'error';
document.getElementById('conn-label').textContent = 'ERR: ' + (err?.message || err);
teardown();
});
ros.on('close', () => {
document.getElementById('conn-dot').className = '';
document.getElementById('conn-label').textContent = 'Disconnected';
teardown();
});
}
function setupSubs() {
// Fused pose
poseSub = new ROSLIB.Topic({
ros, name: '/saltybot/pose/fused',
messageType: 'geometry_msgs/PoseStamped',
throttle_rate: 50,
});
poseSub.subscribe((msg) => {
const p = msg.pose.position;
const q = msg.pose.orientation;
const th = yawFromQuat(q.x, q.y, q.z, q.w);
state.robot = { x: p.x, y: p.y, theta: th };
state.trail.push({ x: p.x, y: p.y });
if (state.trail.length > TRAIL_MAX) state.trail.shift();
if (state.autoCenter) centerOnRobot();
requestDraw();
});
// RPLIDAR scan
scanSub = new ROSLIB.Topic({
ros, name: '/scan',
messageType: 'sensor_msgs/LaserScan',
throttle_rate: SCAN_THROTTLE,
compression: 'cbor',
});
scanSub.subscribe((msg) => {
state.scan = {
angle_min: msg.angle_min,
angle_increment: msg.angle_increment,
ranges: msg.ranges,
range_max: msg.range_max,
};
requestDraw();
});
// Safety zone status
statusSub = new ROSLIB.Topic({
ros, name: '/saltybot/safety_zone/status',
messageType: 'std_msgs/String',
throttle_rate: 200,
});
statusSub.subscribe((msg) => {
try {
const d = JSON.parse(msg.data);
state.estop = d.estop_active ?? false;
state.fwdZone = d.forward_zone ?? 'CLEAR';
state.closestM = d.closest_obstacle_m ?? null;
state.dangerN = d.danger_sector_count ?? 0;
state.warnN = d.warn_sector_count ?? 0;
} catch (_) {}
requestDraw();
});
}
function teardown() {
if (poseSub) { poseSub.unsubscribe(); poseSub = null; }
if (scanSub) { scanSub.unsubscribe(); scanSub = null; }
if (statusSub) { statusSub.unsubscribe(); statusSub = null; }
document.getElementById('no-signal').classList.remove('hidden');
}
// Batch draw calls
let drawPending = false;
function requestDraw() {
if (drawPending) return;
drawPending = true;
requestAnimationFrame(() => { drawPending = false; draw(); });
}
// ── UWB Anchors ───────────────────────────────────────────────────────────────
function renderAnchorList() {
const list = document.getElementById('anchor-list');
if (!list) return;
list.innerHTML = state.anchors.map((a, i) =>
`<div class="anchor-row">
<span style="color:#f59e0b">◆</span>
<span style="flex:1">${a.label} (${a.x.toFixed(1)}, ${a.y.toFixed(1)})</span>
<button onclick="removeAnchor(${i})" style="background:none;border:none;color:#6b7280;cursor:pointer;font-size:10px">✕</button>
</div>`
).join('') || '<div style="color:#374151;font-size:10px;text-align:center;padding:4px">No anchors</div>';
}
window.removeAnchor = function(i) {
state.anchors.splice(i, 1);
saveAnchors();
renderAnchorList();
draw();
};
document.getElementById('btn-add-anchor').addEventListener('click', () => {
const x = parseFloat(document.getElementById('anc-x').value);
const y = parseFloat(document.getElementById('anc-y').value);
const lbl = document.getElementById('anc-lbl').value.trim() || `A${state.anchors.length}`;
if (isNaN(x) || isNaN(y)) return;
state.anchors.push({ x, y, label: lbl });
document.getElementById('anc-x').value = '';
document.getElementById('anc-y').value = '';
document.getElementById('anc-lbl').value = '';
saveAnchors();
renderAnchorList();
draw();
});
function saveAnchors() {
localStorage.setItem('map_anchors', JSON.stringify(state.anchors));
}
function loadAnchors() {
try {
const saved = JSON.parse(localStorage.getItem('map_anchors') || '[]');
state.anchors = saved.filter(a => typeof a.x === 'number' && typeof a.y === 'number');
} catch (_) { state.anchors = []; }
renderAnchorList();
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.getElementById('btn-connect').addEventListener('click', connect);
document.getElementById('ws-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') connect();
});
const stored = localStorage.getItem('map_ws_url');
if (stored) document.getElementById('ws-input').value = stored;
document.getElementById('ws-input').addEventListener('change', (e) => {
localStorage.setItem('map_ws_url', e.target.value);
});
// Clear trail button
document.getElementById('btn-clear-trail').addEventListener('click', () => {
state.trail.length = 0; draw();
});
// Init: center at origin, set btn state
state.autoCenter = true;
document.getElementById('btn-center').classList.add('on');
loadAnchors();
resize(); // sets canvas size and draws initial blank map