Merge pull request 'feat: sensor feeds in HUD (Issue #413)' (#419) from sl-webui/issue-413-sensor-hud into main

This commit is contained in:
sl-jetson 2026-03-04 23:21:34 -05:00
commit a06821a8c8

View File

@ -115,8 +115,63 @@ input[type=range] { cursor: pointer; }
<div class="mt-auto text-gray-700" style="font-size:10px">PKT/s: <span id="v-hz" class="text-gray-500">--</span></div>
</div><!-- end LEFT -->
<!-- CENTER: Three.js viewport -->
<div id="three-container" class="flex-1 relative overflow-hidden" style="background:#050510"></div>
<!-- CENTER: Sensor feeds + 3D viewport -->
<div id="center-panel" class="flex-1 relative overflow-hidden" style="background:#050510">
<!-- Sensor feed tabs -->
<div id="sensor-tabs" class="absolute top-0 left-0 right-0 flex gap-1 bg-[#070712] border-b border-cyan-950 px-2 py-1 z-10" style="font-size:10px">
<button onclick="switchSensorView('3d')" class="sensor-tab-btn active px-2 py-1 rounded bg-cyan-950 border border-cyan-800 text-cyan-300 font-bold">3D MODEL</button>
<button onclick="switchSensorView('gps')" class="sensor-tab-btn px-2 py-1 rounded bg-gray-900 border border-gray-700 text-gray-400 font-bold">GPS</button>
<button onclick="switchSensorView('lidar')" class="sensor-tab-btn px-2 py-1 rounded bg-gray-900 border border-gray-700 text-gray-400 font-bold">LIDAR</button>
<button onclick="switchSensorView('realsense')" class="sensor-tab-btn px-2 py-1 rounded bg-gray-900 border border-gray-700 text-gray-400 font-bold">REALSENSE</button>
</div>
<!-- 3D viewport -->
<div id="three-container" class="sensor-view active w-full h-full" style="background:#050510"></div>
<!-- GPS map -->
<div id="gps-view" class="sensor-view hidden w-full h-full flex flex-col">
<div class="flex-1 relative overflow-hidden bg-gray-950">
<iframe id="gps-map" src="" class="w-full h-full border-0" style="background:#050510"></iframe>
<div id="gps-loading" class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
<span class="text-cyan-400">Fetching GPS data from /gps...</span>
</div>
</div>
<div class="bg-[#070712] border-t border-cyan-950 px-2 py-1.5 flex gap-2">
<span id="gps-status" class="text-cyan-400 text-xs">--</span>
<span id="gps-coords" class="text-gray-500 text-xs">--</span>
<span id="gps-alt" class="text-gray-500 text-xs">--</span>
</div>
</div>
<!-- LIDAR point cloud -->
<div id="lidar-view" class="sensor-view hidden w-full h-full flex flex-col">
<canvas id="lidar-canvas" class="flex-1" style="background:#050510"></canvas>
<div class="bg-[#070712] border-t border-cyan-950 px-2 py-1.5 flex gap-2">
<span id="lidar-status" class="text-cyan-400 text-xs">Ready</span>
<span id="lidar-points" class="text-gray-500 text-xs">Points: --</span>
<span id="lidar-range" class="text-gray-500 text-xs">Range: --m</span>
</div>
</div>
<!-- RealSense (RGB + Depth side-by-side) -->
<div id="realsense-view" class="sensor-view hidden w-full h-full flex flex-col">
<div class="flex-1 flex gap-1 overflow-hidden">
<div class="flex-1 relative bg-gray-950">
<div id="rs-rgb-loading" class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 text-xs text-cyan-400">RGB...</div>
<canvas id="rs-rgb-canvas" class="w-full h-full"></canvas>
</div>
<div class="flex-1 relative bg-gray-950">
<div id="rs-depth-loading" class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 text-xs text-cyan-400">Depth...</div>
<canvas id="rs-depth-canvas" class="w-full h-full"></canvas>
</div>
</div>
<div class="bg-[#070712] border-t border-cyan-950 px-2 py-1.5 flex gap-2">
<span id="rs-status" class="text-cyan-400 text-xs">Ready</span>
<span id="rs-fps" class="text-gray-500 text-xs">FPS: --</span>
<span id="rs-res" class="text-gray-500 text-xs">-- px</span>
</div>
</div>
</div>
<!-- RIGHT: Comms Stats (w-56 = 224px) -->
<div class="w-56 shrink-0 flex flex-col gap-3 p-3 bg-[#070712] border-l border-cyan-950 overflow-y-auto">
@ -838,6 +893,284 @@ async function readLoop() {
}
} catch(e) { setStatus('Read err: ' + e.message); }
}
// ── Sensor feed tab switching ──────────────────────────────────────────────────
window.switchSensorView = function(view) {
// Hide all views
document.querySelectorAll('.sensor-view').forEach(el => el.classList.add('hidden'));
// Show selected view
document.getElementById(view + '-view').classList.remove('hidden');
// Update tab buttons
document.querySelectorAll('.sensor-tab-btn').forEach(btn => {
btn.classList.add('bg-gray-900', 'border-gray-700', 'text-gray-400');
btn.classList.remove('bg-cyan-950', 'border-cyan-800', 'text-cyan-300');
});
event.target.classList.remove('bg-gray-900', 'border-gray-700', 'text-gray-400');
event.target.classList.add('bg-cyan-950', 'border-cyan-800', 'text-cyan-300');
// Initialize sensor if needed
if (view === 'gps') initGPS();
else if (view === 'lidar') initLIDAR();
else if (view === 'realsense') initRealSense();
};
// ── GPS Map fetcher ───────────────────────────────────────────────────────────
let gpsInitialized = false;
function initGPS() {
if (gpsInitialized) return;
gpsInitialized = true;
async function fetchGPS() {
try {
const resp = await fetch('http://localhost:8888/gps', { mode: 'cors', cache: 'no-store' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
// data: { lat, lon, alt, accuracy, timestamp }
if (data.lat !== undefined && data.lon !== undefined) {
document.getElementById('gps-loading').classList.add('hidden');
document.getElementById('gps-status').textContent = 'LIVE';
document.getElementById('gps-coords').textContent = `${data.lat.toFixed(6)}, ${data.lon.toFixed(6)}`;
if (data.alt !== undefined) {
document.getElementById('gps-alt').textContent = `Alt: ${data.alt.toFixed(1)}m`;
}
// Render simple map iframe (OSM + marker)
const mapHtml = `
<!DOCTYPE html><html><head>
<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>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: monospace; background: #050510; }
#map { width: 100%; height: 100%; }
</style>
</head><body>
<div id="map"></div>
<script>
const map = L.map('map').setView([${data.lat}, ${data.lon}], 18);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OSM',
maxZoom: 19
}).addTo(map);
L.circleMarker([${data.lat}, ${data.lon}], {
radius: 8,
fillColor: '#00ff00',
color: '#fff',
weight: 2,
opacity: 0.9,
fillOpacity: 0.8
}).addTo(map);
<\/script>
</body></html>
`;
const blob = new Blob([mapHtml], { type: 'text/html' });
document.getElementById('gps-map').src = URL.createObjectURL(blob);
}
} catch (e) {
document.getElementById('gps-status').textContent = 'ERROR: ' + e.message;
}
}
// Fetch every 2 seconds
fetchGPS();
setInterval(fetchGPS, 2000);
}
// ── LIDAR Point Cloud (topic /scan, rosbridge) ─────────────────────────────
let lidarInitialized = false, lidarCanvas = null, lidarCtx = null;
function initLIDAR() {
if (lidarInitialized) return;
lidarInitialized = true;
lidarCanvas = document.getElementById('lidar-canvas');
lidarCtx = lidarCanvas.getContext('2d');
lidarCanvas.width = lidarCanvas.clientWidth;
lidarCanvas.height = lidarCanvas.clientHeight;
// Subscribe to /scan via rosbridge (assumes rosbridge running on localhost:9090)
let latestScan = null;
async function subscribeLIDAR() {
try {
const ws = new WebSocket('ws://localhost:9090');
ws.onopen = () => {
ws.send(JSON.stringify({
op: 'subscribe',
topic: '/scan',
type: 'sensor_msgs/LaserScan'
}));
};
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data.msg) {
latestScan = data.msg;
}
} catch (e) {}
};
ws.onerror = (e) => {
document.getElementById('lidar-status').textContent = 'WebSocket error: ' + e;
};
} catch (e) {
document.getElementById('lidar-status').textContent = 'Connection error: ' + e.message;
}
}
subscribeLIDAR();
// Draw loop
function drawLIDAR() {
const W = lidarCanvas.width;
const H = lidarCanvas.height;
const cx = W / 2, cy = H / 2;
const scale = Math.min(W, H) / 2 - 30;
lidarCtx.fillStyle = '#020208';
lidarCtx.fillRect(0, 0, W, H);
// Grid
lidarCtx.strokeStyle = 'rgba(0,255,255,0.08)';
lidarCtx.lineWidth = 0.5;
for (let d = 1; d <= 5; d++) {
const r = (d / 5) * scale;
lidarCtx.beginPath();
lidarCtx.arc(cx, cy, r, 0, 2 * Math.PI);
lidarCtx.stroke();
}
// Cardinal directions
lidarCtx.strokeStyle = 'rgba(0,255,255,0.15)';
lidarCtx.lineWidth = 1;
lidarCtx.beginPath();
lidarCtx.moveTo(cx, cy - scale);
lidarCtx.lineTo(cx, cy + scale);
lidarCtx.stroke();
lidarCtx.beginPath();
lidarCtx.moveTo(cx - scale, cy);
lidarCtx.lineTo(cx + scale, cy);
lidarCtx.stroke();
// Draw points
if (latestScan && latestScan.ranges) {
const ranges = latestScan.ranges;
const angleMin = latestScan.angle_min || -Math.PI;
const angleIncrement = latestScan.angle_increment || 0.01;
let pointCount = 0, maxRange = 0;
lidarCtx.fillStyle = '#06b6d4';
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
if (range === 0 || !isFinite(range) || range > 30) continue;
const angle = angleMin + i * angleIncrement;
const x = cx + Math.cos(angle) * range / 30 * scale;
const y = cy - Math.sin(angle) * range / 30 * scale;
if (x >= 0 && x <= W && y >= 0 && y <= H) {
lidarCtx.fillRect(x - 1, y - 1, 2, 2);
pointCount++;
maxRange = Math.max(maxRange, range);
}
}
document.getElementById('lidar-points').textContent = 'Points: ' + pointCount;
document.getElementById('lidar-range').textContent = 'Range: ' + maxRange.toFixed(1) + 'm';
}
// Forward indicator
lidarCtx.strokeStyle = '#f59e0b';
lidarCtx.lineWidth = 2;
lidarCtx.beginPath();
lidarCtx.moveTo(cx, cy);
lidarCtx.lineTo(cx, cy - scale * 0.3);
lidarCtx.stroke();
requestAnimationFrame(drawLIDAR);
}
drawLIDAR();
}
// ── RealSense Dual Stream (RGB + Depth) ────────────────────────────────────
let realsenseInitialized = false;
function initRealSense() {
if (realsenseInitialized) return;
realsenseInitialized = true;
const rgbCanvas = document.getElementById('rs-rgb-canvas');
const depthCanvas = document.getElementById('rs-depth-canvas');
const rgbCtx = rgbCanvas.getContext('2d');
const depthCtx = depthCanvas.getContext('2d');
rgbCanvas.width = rgbCanvas.clientWidth;
rgbCanvas.height = rgbCanvas.clientHeight;
depthCanvas.width = depthCanvas.clientWidth;
depthCanvas.height = depthCanvas.clientHeight;
let frameCount = 0, lastFpsTime = Date.now();
// Fetch RealSense streams from ROS bridge topics
async function subscribeRealSense() {
try {
const ws = new WebSocket('ws://localhost:9090');
ws.onopen = () => {
// Subscribe to RGB and depth images
ws.send(JSON.stringify({
op: 'subscribe',
topic: '/camera/color/image_raw/compressed',
type: 'sensor_msgs/CompressedImage'
}));
ws.send(JSON.stringify({
op: 'subscribe',
topic: '/camera/depth/image_rect_raw/compressed',
type: 'sensor_msgs/CompressedImage'
}));
};
ws.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data);
if (data.msg) {
const topic = data.topic;
const msg = data.msg;
if (msg.data && typeof msg.data === 'string') {
// Base64 JPEG data
const imgData = 'data:image/jpeg;base64,' + msg.data;
const img = new Image();
img.onload = () => {
if (topic.includes('color')) {
document.getElementById('rs-rgb-loading').classList.add('hidden');
rgbCtx.drawImage(img, 0, 0, rgbCanvas.width, rgbCanvas.height);
} else if (topic.includes('depth')) {
document.getElementById('rs-depth-loading').classList.add('hidden');
depthCtx.drawImage(img, 0, 0, depthCanvas.width, depthCanvas.height);
}
};
img.src = imgData;
frameCount++;
}
}
} catch (e) {}
};
ws.onerror = (e) => {
document.getElementById('rs-status').textContent = 'WebSocket error';
};
} catch (e) {
document.getElementById('rs-status').textContent = 'Connection error';
}
}
subscribeRealSense();
// FPS counter
setInterval(() => {
const now = Date.now();
const elapsed = (now - lastFpsTime) / 1000;
const fps = Math.round(frameCount / elapsed);
document.getElementById('rs-fps').textContent = 'FPS: ' + fps;
document.getElementById('rs-res').textContent = rgbCanvas.width + 'x' + rgbCanvas.height + ' px';
frameCount = 0;
lastFpsTime = now;
}, 1000);
}
</script>
</body>
</html>