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:
commit
a06821a8c8
337
ui/index.html
337
ui/index.html
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user