feat: sensor feeds in HUD (Issue #413) #419
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 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 -->
|
</div><!-- end LEFT -->
|
||||||
|
|
||||||
<!-- CENTER: Three.js viewport -->
|
<!-- CENTER: Sensor feeds + 3D viewport -->
|
||||||
<div id="three-container" class="flex-1 relative overflow-hidden" style="background:#050510"></div>
|
<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) -->
|
<!-- 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">
|
<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); }
|
} 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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user