sl-webui eff69b2037 feat(hud): add sensor feeds (GPS, LIDAR, RealSense) (Issue #413)
Add tabbed sensor feed interface to the HUD center viewport:

GPS Map Panel:
- Fetches location data from /gps HTTP endpoint on port 8888
- Renders OpenStreetMap with real-time location marker
- Displays: coordinates, altitude, accuracy

LIDAR Point Cloud Visualization:
- Subscribes to /scan topic via rosbridge WebSocket
- 2D polar plot with grid, cardinal directions, forward indicator
- Real-time point cloud rendering with range statistics
- Displays: point count, max range (0-30m)

RealSense Dual Stream:
- Subscribes to /camera/color/image_raw/compressed (RGB)
- Subscribes to /camera/depth/image_rect_raw/compressed (Depth)
- Side-by-side canvas rendering with independent scaling
- FPS counter and resolution display

Tab System:
- 4-way view switching: 3D Model ↔ GPS ↔ LIDAR ↔ RealSense
- Persistent tab state, lazy initialization on demand
- Dark theme with cyan/orange accent colors
- Status indicators for each sensor (loading/error/ready)

Architecture:
- Browser native canvas for LIDAR visualization
- WebSocket rosbridge integration for sensor subscriptions
- Fetch API for HTTP GPS data (localhost:8888)
- Leaflet.js for OSM map rendering (CDN)
- 2s polling interval for GPS updates

Rosbridge Endpoints (assumes localhost:9090):
- /scan (sensor_msgs/LaserScan) — 1Hz LIDAR
- /camera/color/image_raw/compressed — RGB stream
- /camera/depth/image_rect_raw/compressed — Depth stream

HTTP Endpoints (assumes localhost:8888):
- GET /gps → { lat, lon, alt, accuracy, timestamp }

Integration:
- Preserves existing 3D HUD viewport and controls
- Left/right sidebars remain unchanged
- Bottom PID control bar operational
- Tab switching preserves center panel size/aspect ratio

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-04 22:57:10 -05:00

1177 lines
51 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SaltyLab HUD</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', Courier, monospace; background: #050510; }
input[type=range] { cursor: pointer; }
#console-log { scrollbar-width: thin; scrollbar-color: #1a3a4a #020208; }
</style>
</head>
<body class="h-screen overflow-hidden flex flex-col text-gray-300" style="font-size:12px">
<!-- ── TITLE BAR ── -->
<div class="flex items-center justify-between px-4 py-1.5 bg-[#070712] border-b border-cyan-950 shrink-0">
<div class="flex items-center gap-3">
<span class="text-orange-500 font-bold tracking-widest" style="font-size:14px">⚡ SALTYLAB</span>
<span id="state-badge" class="px-2 py-0.5 rounded font-bold border bg-gray-800 border-gray-600 text-gray-400" style="font-size:10px">DISARMED</span>
</div>
<span id="status" class="text-gray-600" style="font-size:10px">WebSerial ready</span>
<div class="flex gap-1.5">
<button id="connect-btn" onclick="toggleSerial()" class="px-3 py-1 rounded font-bold bg-red-900 hover:bg-red-800 text-white border border-red-700" style="font-size:10px">CONNECT USB</button>
<button id="arm-btn" onclick="toggleArm()" class="px-3 py-1 rounded font-bold bg-amber-800 hover:bg-amber-700 text-white border border-amber-600 hidden" style="font-size:10px">ARM</button>
<button id="gyrocal-btn" onclick="gyroRecal()" class="px-3 py-1 rounded font-bold bg-cyan-950 hover:bg-cyan-900 text-cyan-300 border border-cyan-800 hidden" style="font-size:10px">GYRO CAL</button>
<button id="yaw-btn" onclick="resetYaw()" class="px-3 py-1 rounded font-bold bg-green-950 hover:bg-green-900 text-green-400 border border-green-800 hidden" style="font-size:10px">YAW RESET</button>
<button id="dfu-btn" onclick="enterDFU()" class="px-3 py-1 rounded font-bold bg-gray-800 hover:bg-gray-700 text-gray-500 border border-gray-700 hidden" style="font-size:10px">DFU</button>
</div>
</div>
<!-- ── MAIN 3-COLUMN ── -->
<div class="flex flex-1 min-h-0 overflow-hidden">
<!-- LEFT: Telemetry Gauges (w-56 = 224px, inner = 200px after p-3) -->
<div class="w-56 shrink-0 flex flex-col gap-2 p-3 bg-[#070712] border-r border-cyan-950 overflow-y-auto">
<div class="text-cyan-700 font-bold tracking-widest" style="font-size:9px">ATTITUDE</div>
<!-- Artificial Horizon: 200×100 -->
<canvas id="c-horizon" width="200" height="100" class="rounded border border-cyan-950 block"></canvas>
<!-- Pitch / Roll / Yaw -->
<div class="grid grid-cols-3 gap-1">
<div class="bg-gray-950 rounded p-1 text-center">
<div class="text-gray-600" style="font-size:9px">PITCH</div>
<div id="v-pitch" class="text-cyan-400 font-bold">--</div>
<div class="text-gray-700" style="font-size:9px">°</div>
</div>
<div class="bg-gray-950 rounded p-1 text-center">
<div class="text-gray-600" style="font-size:9px">ROLL</div>
<div id="v-roll" class="text-cyan-400 font-bold">--</div>
<div class="text-gray-700" style="font-size:9px">°</div>
</div>
<div class="bg-gray-950 rounded p-1 text-center">
<div class="text-gray-600" style="font-size:9px">YAW</div>
<div id="v-yaw" class="text-cyan-400 font-bold">--</div>
<div class="text-gray-700" style="font-size:9px">°</div>
</div>
</div>
<div class="text-cyan-700 font-bold tracking-widest" style="font-size:9px">HEADING</div>
<!-- Compass tape: 200×70 -->
<canvas id="c-compass" width="200" height="70" class="rounded border border-cyan-950 block"></canvas>
<div id="v-hdg-row" class="text-center hidden">
<span class="text-gray-600" style="font-size:9px">MAG</span>
<span id="v-hdg" class="text-cyan-400 font-bold ml-1">--</span>
<span class="text-gray-600" style="font-size:9px">°</span>
</div>
<div class="text-cyan-700 font-bold tracking-widest" style="font-size:9px">MOTOR</div>
<div class="flex items-center gap-2">
<span id="v-motor" class="text-orange-400 font-bold w-10 text-right">--</span>
<div class="flex-1 h-2.5 bg-gray-950 rounded relative overflow-hidden border border-gray-800">
<div class="absolute inset-y-0 left-1/2 w-px bg-gray-700"></div>
<div id="motor-bar" class="absolute inset-y-0 transition-all duration-75 rounded" style="left:50%;width:0;background:#f97316"></div>
</div>
</div>
<div class="text-cyan-700 font-bold tracking-widest" style="font-size:9px">BATTERY</div>
<div class="flex items-center gap-2">
<span id="v-bat" class="text-green-400 font-bold w-14">--</span>
<div class="flex-1 h-2 bg-gray-950 rounded overflow-hidden border border-gray-800">
<div id="bat-bar" class="h-full transition-all duration-1000" style="width:0%;background:#22c55e"></div>
</div>
</div>
<!-- BME280 (shown only when data arrives) -->
<div id="bme-section" class="hidden">
<div class="text-cyan-700 font-bold tracking-widest mb-1" style="font-size:9px">ENVIRONMENT</div>
<div class="grid grid-cols-2 gap-1">
<div id="row-temp" class="hidden bg-gray-950 rounded p-1 text-center">
<div class="text-gray-600" style="font-size:9px">TEMP</div>
<div id="v-temp" class="text-cyan-400 font-bold">--</div>
<div class="text-gray-700" style="font-size:9px">°C</div>
</div>
<div id="row-hum" class="hidden bg-gray-950 rounded p-1 text-center">
<div class="text-gray-600" style="font-size:9px">HUMIDITY</div>
<div id="v-hum" class="text-cyan-400 font-bold">--</div>
<div class="text-gray-700" style="font-size:9px">%RH</div>
</div>
<div id="row-pres" class="hidden bg-gray-950 rounded p-1 text-center">
<div class="text-gray-600" style="font-size:9px">PRESSURE</div>
<div id="v-pres" class="text-cyan-400 font-bold">--</div>
<div class="text-gray-700" style="font-size:9px">hPa</div>
</div>
<div id="row-alt" class="hidden bg-gray-950 rounded p-1 text-center">
<div class="text-gray-600" style="font-size:9px">ALTITUDE</div>
<div id="v-alt" class="text-cyan-400 font-bold">--</div>
<div class="text-gray-700" style="font-size:9px">m</div>
</div>
</div>
</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 -->
<!-- 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">
<!-- USB Stats -->
<div>
<div class="text-cyan-700 font-bold tracking-widest mb-1" style="font-size:9px">USB STATS</div>
<div class="grid grid-cols-2 gap-1">
<div class="bg-gray-950 rounded p-1 text-center">
<div class="text-gray-600" style="font-size:9px">TX PKT</div>
<div id="v-txc" class="text-cyan-400 font-bold">--</div>
</div>
<div class="bg-gray-950 rounded p-1 text-center">
<div class="text-gray-600" style="font-size:9px">RX PKT</div>
<div id="v-rxc" class="text-cyan-400 font-bold">--</div>
</div>
</div>
</div>
<!-- Mode -->
<div>
<div class="text-cyan-700 font-bold tracking-widest mb-1" style="font-size:9px">MODE</div>
<div id="mode-badge" class="text-center py-1 rounded border font-bold bg-gray-900 border-gray-700 text-gray-400" style="font-size:11px">MANUAL</div>
</div>
<!-- CRSF / RC -->
<div>
<div class="text-cyan-700 font-bold tracking-widest mb-1" style="font-size:9px">RC / CRSF-ELRS</div>
<div class="grid grid-cols-2 gap-1 mb-2">
<div class="bg-gray-950 rounded p-1 text-center">
<div class="text-gray-600" style="font-size:9px">RSSI</div>
<div id="v-rssi" class="text-cyan-400 font-bold">--</div>
<div class="text-gray-700" style="font-size:9px">dBm</div>
</div>
<div class="bg-gray-950 rounded p-1 text-center">
<div class="text-gray-600" style="font-size:9px">LINK Q</div>
<div id="v-lq" class="text-cyan-400 font-bold">--</div>
<div class="text-gray-700" style="font-size:9px">%</div>
</div>
</div>
<!-- RC Sticks: CH1·CH2 (left), CH3·CH4 (right) -->
<div class="text-gray-600 mb-1" style="font-size:9px">RC STICKS (µs)</div>
<div class="flex justify-around mb-2">
<div class="flex flex-col items-center gap-1">
<canvas id="c-stick-l" width="64" height="64" class="border border-cyan-950 rounded block"></canvas>
<span class="text-gray-700" style="font-size:9px">CH1·CH2</span>
</div>
<div class="flex flex-col items-center gap-1">
<canvas id="c-stick-r" width="64" height="64" class="border border-cyan-950 rounded block"></canvas>
<span class="text-gray-700" style="font-size:9px">CH3·CH4</span>
</div>
</div>
<!-- CH raw values -->
<div class="grid grid-cols-4 gap-1">
<div class="bg-gray-950 rounded py-1 text-center">
<div class="text-gray-700" style="font-size:9px">CH1</div>
<div id="v-ch1" class="text-cyan-500" style="font-size:10px">--</div>
</div>
<div class="bg-gray-950 rounded py-1 text-center">
<div class="text-gray-700" style="font-size:9px">CH2</div>
<div id="v-ch2" class="text-cyan-500" style="font-size:10px">--</div>
</div>
<div class="bg-gray-950 rounded py-1 text-center">
<div class="text-gray-700" style="font-size:9px">CH3</div>
<div id="v-ch3" class="text-cyan-500" style="font-size:10px">--</div>
</div>
<div class="bg-gray-950 rounded py-1 text-center">
<div class="text-gray-700" style="font-size:9px">CH4</div>
<div id="v-ch4" class="text-cyan-500" style="font-size:10px">--</div>
</div>
</div>
</div>
<!-- Jetson -->
<div>
<div class="text-cyan-700 font-bold tracking-widest mb-1" style="font-size:9px">JETSON ORIN</div>
<div class="flex items-center gap-2 bg-gray-950 rounded p-2">
<div id="jetson-dot" class="w-2.5 h-2.5 rounded-full shrink-0" style="background:#374151"></div>
<span id="jetson-status" class="text-gray-500" style="font-size:10px">NO SIGNAL</span>
</div>
</div>
</div><!-- end RIGHT -->
</div><!-- end MAIN -->
<!-- ── BOTTOM: Controls bar ── -->
<div class="bg-[#070712] border-t border-cyan-950 shrink-0">
<div class="flex flex-wrap items-center gap-3 px-3 py-1.5">
<div class="flex items-center gap-1" style="font-size:10px">
<span class="text-gray-500 font-bold w-5">KP</span>
<input type="range" id="sl-kp" min="0" max="500000" step="100" value="0" class="w-16 accent-cyan-400" style="height:3px"
oninput="document.getElementById('lbl-kp').textContent=(this.value/1000).toFixed(1)">
<span id="lbl-kp" class="text-cyan-400 w-8 text-right">0.0</span>
</div>
<div class="flex items-center gap-1" style="font-size:10px">
<span class="text-gray-500 font-bold w-4">KI</span>
<input type="range" id="sl-ki" min="0" max="50000" step="10" value="0" class="w-16 accent-cyan-400" style="height:3px"
oninput="document.getElementById('lbl-ki').textContent=(this.value/1000).toFixed(3)">
<span id="lbl-ki" class="text-cyan-400 w-12 text-right">0.000</span>
</div>
<div class="flex items-center gap-1" style="font-size:10px">
<span class="text-gray-500 font-bold w-4">KD</span>
<input type="range" id="sl-kd" min="0" max="50000" step="10" value="0" class="w-16 accent-cyan-400" style="height:3px"
oninput="document.getElementById('lbl-kd').textContent=(this.value/1000).toFixed(3)">
<span id="lbl-kd" class="text-cyan-400 w-12 text-right">0.000</span>
</div>
<div class="flex items-center gap-1" style="font-size:10px">
<span class="text-gray-500 font-bold w-6">SP°</span>
<input type="range" id="sl-sp" min="-200" max="200" step="1" value="0" class="w-16 accent-orange-400" style="height:3px"
oninput="document.getElementById('lbl-sp').textContent=(this.value/10).toFixed(1)+'°'">
<span id="lbl-sp" class="text-orange-400 w-9 text-right">0.0°</span>
</div>
<div class="flex items-center gap-1" style="font-size:10px">
<span class="text-gray-500 font-bold w-8">MAX</span>
<input type="range" id="sl-ms" min="0" max="1000" step="10" value="200" class="w-16 accent-orange-400" style="height:3px"
oninput="document.getElementById('lbl-ms').textContent=this.value">
<span id="lbl-ms" class="text-orange-400 w-8 text-right">200</span>
</div>
<button onclick="sendPID()" class="px-2 py-1 rounded bg-cyan-950 hover:bg-cyan-900 text-cyan-300 border border-cyan-800" style="font-size:10px">APPLY</button>
<button onclick="queryPID()" class="px-2 py-1 rounded bg-gray-900 hover:bg-gray-800 text-gray-400 border border-gray-700" style="font-size:10px">QUERY</button>
<div class="ml-auto">
<button onclick="toggleConsole()" id="console-toggle" class="px-2 py-1 rounded bg-gray-900 hover:bg-gray-800 text-gray-500 border border-gray-800" style="font-size:10px">▲ LOG</button>
</div>
</div>
<!-- Collapsible console -->
<div id="console-panel" class="hidden px-3 pb-2">
<div id="console-log" class="h-24 overflow-y-auto rounded p-2 text-green-500 border border-gray-900" style="background:#020208;font-size:10px"></div>
</div>
</div><!-- end BOTTOM -->
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from 'three';
// ── Three.js setup ────────────────────────────────────────────────────────────
const container = document.getElementById('three-container');
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 100);
camera.position.set(0, 1.2, 5);
camera.lookAt(0, 0.8, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setClearColor(0x050510);
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.domElement.style.display = 'block';
container.appendChild(renderer.domElement);
// Lighting
scene.add(new THREE.AmbientLight(0x404060, 2));
const dirLight = new THREE.DirectionalLight(0xffffff, 3);
dirLight.position.set(5, 10, 7);
scene.add(dirLight);
const ptLight = new THREE.PointLight(0x0066ff, 1.5, 20);
ptLight.position.set(-3, 3, 3);
scene.add(ptLight);
// Ground grid + plane
scene.add(new THREE.GridHelper(12, 24, 0x001a2a, 0x000a14));
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(12, 12),
new THREE.MeshBasicMaterial({ color: 0x020208, transparent: true, opacity: 0.85 }));
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.005;
scene.add(ground);
// ── SaltyBot model ────────────────────────────────────────────────────────────
const boardGroup = new THREE.Group();
// Body (1.2m tall, pivot at wheel axle y=0)
const body = new THREE.Mesh(
new THREE.BoxGeometry(0.7, 1.2, 0.3),
new THREE.MeshPhongMaterial({ color: 0x1a2a4a, specular: 0x334466 }));
body.position.set(0, 0.7, 0);
boardGroup.add(body);
// Wheels: each in a pivot group for rolling animation.
// Pivot has rotation.z=PI/2 (lays cylinder flat → axis along X).
// Inner mesh spins around its local Y axis = rolling around the axle.
const wheelGeo = new THREE.CylinderGeometry(0.4, 0.4, 0.14, 20);
const wheelMat = new THREE.MeshPhongMaterial({ color: 0x1a1a1a, specular: 0x333333 });
const lPivot = new THREE.Group();
lPivot.position.set(-0.47, 0, 0);
lPivot.rotation.z = Math.PI / 2;
boardGroup.add(lPivot);
const lWheel = new THREE.Mesh(wheelGeo, wheelMat);
lPivot.add(lWheel);
const rPivot = new THREE.Group();
rPivot.position.set(0.47, 0, 0);
rPivot.rotation.z = Math.PI / 2;
boardGroup.add(rPivot);
const rWheel = new THREE.Mesh(wheelGeo, wheelMat);
rPivot.add(rWheel);
// Wheel rims (blue accent)
const rimGeo = new THREE.TorusGeometry(0.37, 0.025, 8, 20);
const rimMat = new THREE.MeshPhongMaterial({ color: 0x0055cc, specular: 0x0088ff });
[[-0.54], [0.54]].forEach(([x]) => {
const rim = new THREE.Mesh(rimGeo, rimMat);
rim.rotation.z = Math.PI / 2;
rim.position.set(x, 0, 0);
boardGroup.add(rim);
});
// Display panel
const disp = new THREE.Mesh(
new THREE.BoxGeometry(0.48, 0.36, 0.02),
new THREE.MeshPhongMaterial({ color: 0x001133, specular: 0x003366, emissive: 0x000a1a }));
disp.position.set(0, 0.75, 0.16);
boardGroup.add(disp);
// Status LED
const ledMat = new THREE.MeshBasicMaterial({ color: 0xff0000 });
const led = new THREE.Mesh(new THREE.SphereGeometry(0.04, 8, 8), ledMat);
led.position.set(0.22, 1.1, 0.16);
boardGroup.add(led);
// Sensor stem + head
const stem = new THREE.Mesh(
new THREE.BoxGeometry(0.1, 0.28, 0.1),
new THREE.MeshPhongMaterial({ color: 0x2a2a2a }));
stem.position.set(0, 1.44, 0);
boardGroup.add(stem);
const sensHead = new THREE.Mesh(
new THREE.BoxGeometry(0.28, 0.16, 0.28),
new THREE.MeshPhongMaterial({ color: 0x111122, specular: 0x333355 }));
sensHead.position.set(0, 1.66, 0);
boardGroup.add(sensHead);
scene.add(boardGroup);
// ── State ─────────────────────────────────────────────────────────────────────
let targetPitch = 0, targetRoll = 0, targetYaw = 0;
let curPitch = 0, curRoll = 0, curYaw = 0;
let yawOffset = 0;
let wheelAngle = 0;
let lastMotorCmd = 0;
const stateNames = ['DISARMED', 'ARMED', 'TILT FAULT'];
const stateCls = [
'bg-gray-800 border-gray-600 text-gray-400',
'bg-amber-900 border-amber-600 text-amber-300',
'bg-red-900 border-red-600 text-red-300'
];
const modeNames = ['MANUAL', 'ASSISTED', 'AUTO'];
const modeCls = [
'bg-gray-900 border-gray-700 text-gray-400',
'bg-blue-950 border-blue-700 text-blue-300',
'bg-purple-950 border-purple-700 text-purple-300'
];
// ── 2D canvas drawing ─────────────────────────────────────────────────────────
const cH = document.getElementById('c-horizon').getContext('2d');
const cC = document.getElementById('c-compass').getContext('2d');
const cSL = document.getElementById('c-stick-l').getContext('2d');
const cSR = document.getElementById('c-stick-r').getContext('2d');
function drawHorizon(pitch, roll) {
const W = 200, H = 100, cx = W / 2, cy = H / 2;
const rollRad = roll * Math.PI / 180;
const pitchPx = pitch * (H / 60); // ±60° spans full canvas height
cH.clearRect(0, 0, W, H);
// Sky background
cH.fillStyle = '#051a30';
cH.fillRect(0, 0, W, H);
// Horizon: translate to center, rotate by roll, shift by pitch
cH.save();
cH.translate(cx, cy);
cH.rotate(-rollRad);
// Ground fill (below horizon line)
cH.fillStyle = '#1a0f00';
cH.fillRect(-W, pitchPx, W * 2, H * 2);
// Horizon line
cH.strokeStyle = '#00ffff';
cH.lineWidth = 1.5;
cH.beginPath();
cH.moveTo(-W, pitchPx);
cH.lineTo(W, pitchPx);
cH.stroke();
// Pitch ladder every 10°
for (let d = -30; d <= 30; d += 10) {
if (d === 0) continue;
const y = pitchPx + d * (H / 60);
const lw = (Math.abs(d) % 20 === 0) ? 22 : 14;
cH.strokeStyle = 'rgba(0,210,210,0.4)';
cH.lineWidth = 0.7;
cH.beginPath();
cH.moveTo(-lw, y); cH.lineTo(lw, y);
cH.stroke();
cH.fillStyle = 'rgba(0,210,210,0.5)';
cH.font = '7px monospace';
cH.textAlign = 'left';
cH.fillText((-d).toString(), lw + 2, y + 3);
}
cH.restore();
// Fixed center reticle (orange — not affected by roll)
cH.strokeStyle = '#f97316';
cH.lineWidth = 1.5;
cH.beginPath();
cH.moveTo(cx - 28, cy); cH.lineTo(cx - 8, cy);
cH.moveTo(cx + 8, cy); cH.lineTo(cx + 28, cy);
cH.moveTo(cx, cy - 4); cH.lineTo(cx, cy + 4);
cH.stroke();
// Roll arc + pointer at top
cH.save();
cH.translate(cx, 14);
cH.strokeStyle = 'rgba(0,255,255,0.35)';
cH.lineWidth = 1;
cH.beginPath();
cH.arc(0, 0, 11, Math.PI, 0);
cH.stroke();
cH.save();
cH.rotate(-rollRad);
cH.fillStyle = '#f97316';
cH.beginPath();
cH.moveTo(0, -12); cH.lineTo(-3, -7); cH.lineTo(3, -7);
cH.closePath(); cH.fill();
cH.restore();
cH.restore();
}
function drawCompass(yaw) {
const W = 200, H = 70, cx = W / 2;
cC.clearRect(0, 0, W, H);
cC.fillStyle = '#050510';
cC.fillRect(0, 0, W, H);
cC.fillStyle = 'rgba(0,255,255,0.04)';
cC.fillRect(0, 0, W, H);
const degPerPx = W / 70; // 70° visible window
const cardinals = { 0:'N', 45:'NE', 90:'E', 135:'SE', 180:'S', 225:'SW', 270:'W', 315:'NW' };
for (let i = -35; i <= 35; i++) {
const deg = ((Math.round(yaw) + i) % 360 + 360) % 360;
const x = cx + i * degPerPx;
const isMaj = deg % 45 === 0;
const isMed = deg % 15 === 0;
if (!isMed && !isMaj) continue;
cC.strokeStyle = isMaj ? '#00cccc' : 'rgba(0,200,200,0.3)';
cC.lineWidth = isMaj ? 1.5 : 0.5;
const tH = isMaj ? 18 : 8;
cC.beginPath();
cC.moveTo(x, 0); cC.lineTo(x, tH);
cC.stroke();
if (isMaj && cardinals[deg] !== undefined) {
cC.fillStyle = deg === 0 ? '#ff4444' : '#00cccc';
cC.font = 'bold 9px monospace';
cC.textAlign = 'center';
cC.fillText(cardinals[deg], x, 30);
}
}
// Heading value
const hdg = ((Math.round(yaw) % 360) + 360) % 360;
cC.fillStyle = '#00ffff';
cC.font = 'bold 13px monospace';
cC.textAlign = 'center';
cC.fillText(hdg + '°', cx, H - 6);
// Center pointer (orange)
cC.strokeStyle = '#f97316';
cC.lineWidth = 2;
cC.beginPath();
cC.moveTo(cx, 0); cC.lineTo(cx, 12);
cC.stroke();
cC.fillStyle = '#f97316';
cC.beginPath();
cC.moveTo(cx, 0); cC.lineTo(cx - 4, 7); cC.lineTo(cx + 4, 7);
cC.closePath(); cC.fill();
}
function drawStick(ctx, xVal, yVal) {
// xVal, yVal: channel value in µs (10002000, center 1500)
const W = 64, H = 64, cx = W / 2, cy = H / 2, r = 28;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#070712';
ctx.fillRect(0, 0, W, H);
// Grid crosshair
ctx.strokeStyle = 'rgba(0,255,255,0.08)';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(cx, 2); ctx.lineTo(cx, H - 2);
ctx.moveTo(2, cy); ctx.lineTo(W - 2, cy);
ctx.stroke();
// Boundary circle
ctx.strokeStyle = 'rgba(0,255,255,0.15)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// Dot position (-1..+1 normalized)
const nx = (xVal - 1500) / 500;
const ny = -(yVal - 1500) / 500;
const dotX = cx + nx * r * 0.9;
const dotY = cy + ny * r * 0.9;
ctx.fillStyle = '#00ffff';
ctx.shadowBlur = 6;
ctx.shadowColor = '#00ffff';
ctx.beginPath();
ctx.arc(dotX, dotY, 3, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
}
// Initialize canvases
drawHorizon(0, 0);
drawCompass(0);
drawStick(cSL, 1500, 1500);
drawStick(cSR, 1500, 1500);
// ── IMU + telemetry update ────────────────────────────────────────────────────
window.updateIMU = function(data) {
const pitch = data.p !== undefined ? data.p / 10.0 : 0;
const roll = data.r !== undefined ? data.r / 10.0 : 0;
const yawRaw = data.y !== undefined ? data.y / 10.0 : 0;
const yaw = yawRaw - yawOffset;
const motorCmd = data.m !== undefined ? data.m : 0;
const state = data.s !== undefined ? data.s : 0;
const mode = data.md !== undefined ? data.md : 0;
// 3D rotation targets
targetPitch = pitch * Math.PI / 180;
targetRoll = -roll * Math.PI / 180; // negate: Three +z = left bank
targetYaw = yaw * Math.PI / 180;
lastMotorCmd = motorCmd;
// Left panel text
document.getElementById('v-pitch').textContent = pitch.toFixed(1) + '°';
document.getElementById('v-roll').textContent = roll.toFixed(1) + '°';
document.getElementById('v-yaw').textContent = yaw.toFixed(1) + '°';
document.getElementById('v-motor').textContent = motorCmd;
// Motor bar: bidirectional from center
const pct = motorCmd / 1000 * 50; // -50..+50%
const bar = document.getElementById('motor-bar');
bar.style.left = pct >= 0 ? '50%' : (50 + pct) + '%';
bar.style.width = Math.abs(pct) + '%';
bar.style.background = pct >= 0 ? '#f97316' : '#3b82f6';
// Battery (data.bat = V×100, 4S LiPo: 12.016.8V)
if (data.bat !== undefined && data.bat > 0) {
const v = data.bat / 100.0;
const p = Math.max(0, Math.min(100, (v - 12.0) / (16.8 - 12.0) * 100));
document.getElementById('v-bat').textContent = v.toFixed(2) + 'V';
const bb = document.getElementById('bat-bar');
bb.style.width = p + '%';
bb.style.background = p > 50 ? '#22c55e' : p > 20 ? '#f59e0b' : '#ef4444';
}
// State badge
const badge = document.getElementById('state-badge');
badge.textContent = stateNames[state] || 'UNKNOWN';
badge.className = 'px-2 py-0.5 rounded font-bold border ' + (stateCls[state] || stateCls[0]);
badge.style.fontSize = '10px';
// Arm button color
const armBtn = document.getElementById('arm-btn');
armBtn.textContent = state === 1 ? 'DISARM' : 'ARM';
armBtn.style.background = state === 1 ? '#7f1d1d' : '';
// Mode badge
const mb = document.getElementById('mode-badge');
mb.textContent = modeNames[mode] || 'MANUAL';
mb.className = 'text-center py-1 rounded border font-bold ' + (modeCls[mode] || modeCls[0]);
mb.style.fontSize = '11px';
// LED color by state
if (state === 1) {
ledMat.color.setRGB(0, 1, 0);
} else if (state === 2) {
const b = Math.sin(Date.now() / 80) > 0 ? 1 : 0;
ledMat.color.setRGB(b, 0, 0);
} else {
const v = 0.35 + 0.35 * Math.sin(Date.now() / 700);
ledMat.color.setRGB(v, 0, 0);
}
// Optional: heading
if (data.hd !== undefined && data.hd >= 0) {
document.getElementById('v-hdg-row').classList.remove('hidden');
document.getElementById('v-hdg').textContent = (data.hd / 10.0).toFixed(1);
}
// Optional: BME280 environment
const bme = document.getElementById('bme-section');
if (data.alt !== undefined) { bme.classList.remove('hidden'); document.getElementById('row-alt').classList.remove('hidden'); document.getElementById('v-alt').textContent = (data.alt / 100.0).toFixed(1); }
if (data.t !== undefined) { bme.classList.remove('hidden'); document.getElementById('row-temp').classList.remove('hidden'); document.getElementById('v-temp').textContent = (data.t / 10.0).toFixed(1); }
if (data.h !== undefined) { bme.classList.remove('hidden'); document.getElementById('row-hum').classList.remove('hidden'); document.getElementById('v-hum').textContent = (data.h / 10.0).toFixed(1); }
if (data.pa !== undefined) { bme.classList.remove('hidden'); document.getElementById('row-pres').classList.remove('hidden'); document.getElementById('v-pres').textContent = (data.pa / 10.0).toFixed(1); }
// CRSF link stats
if (data.rssi !== undefined) {
document.getElementById('v-rssi').textContent = data.rssi;
document.getElementById('v-lq').textContent = data.lq !== undefined ? data.lq : '--';
}
// RC channels (µs values from firmware)
if (data.ch1 !== undefined) {
const ch1 = data.ch1, ch2 = data.ch2 || 1500;
const ch3 = data.ch3 || 1500, ch4 = data.ch4 || 1500;
document.getElementById('v-ch1').textContent = ch1;
document.getElementById('v-ch2').textContent = ch2;
document.getElementById('v-ch3').textContent = ch3;
document.getElementById('v-ch4').textContent = ch4;
drawStick(cSL, ch1, ch2);
drawStick(cSR, ch3, ch4);
}
// USB telemetry counters
if (data.txc !== undefined) document.getElementById('v-txc').textContent = data.txc;
if (data.rxc !== undefined) document.getElementById('v-rxc').textContent = data.rxc;
// Jetson active
if (data.ja !== undefined) {
const alive = data.ja === 1;
document.getElementById('jetson-dot').style.background = alive ? '#22c55e' : '#374151';
document.getElementById('jetson-status').textContent = alive ? 'ACTIVE' : 'NO SIGNAL';
document.getElementById('jetson-status').style.color = alive ? '#4ade80' : '#6b7280';
}
// Redraw 2D gauges
drawHorizon(pitch, roll);
drawCompass(yaw);
};
// PID query response handler
window.updatePID = function(data) {
if (data.kp !== undefined) { document.getElementById('sl-kp').value = data.kp; document.getElementById('lbl-kp').textContent = (data.kp / 1000).toFixed(1); }
if (data.ki !== undefined) { document.getElementById('sl-ki').value = data.ki; document.getElementById('lbl-ki').textContent = (data.ki / 1000).toFixed(3); }
if (data.kd !== undefined) { document.getElementById('sl-kd').value = data.kd; document.getElementById('lbl-kd').textContent = (data.kd / 1000).toFixed(3); }
if (data.sp !== undefined) { document.getElementById('sl-sp').value = data.sp; document.getElementById('lbl-sp').textContent = (data.sp / 10).toFixed(1) + '°'; }
if (data.ms !== undefined) { document.getElementById('sl-ms').value = data.ms; document.getElementById('lbl-ms').textContent = data.ms; }
};
// ── Animation loop ────────────────────────────────────────────────────────────
function animate() {
requestAnimationFrame(animate);
// Smooth rotation interpolation
curPitch += (targetPitch - curPitch) * 0.15;
curRoll += (targetRoll - curRoll) * 0.15;
curYaw += (targetYaw - curYaw) * 0.15;
boardGroup.rotation.x = curPitch;
boardGroup.rotation.z = curRoll;
boardGroup.rotation.y = curYaw;
// Wheel rolling: spin inner mesh around local Y = axle axis
wheelAngle += (lastMotorCmd / 1000) * 0.05;
lWheel.rotation.y = wheelAngle;
rWheel.rotation.y = wheelAngle;
renderer.render(scene, camera);
}
animate();
// ── Yaw reset ─────────────────────────────────────────────────────────────────
window.resetYaw = function() {
// currentFirmwareYaw = yawOffset + (targetYaw in degrees)
const firmwareYaw = yawOffset + targetYaw * 180 / Math.PI;
yawOffset = firmwareYaw;
targetYaw = 0;
};
// ── Resize observer ───────────────────────────────────────────────────────────
new ResizeObserver(() => {
const w = container.clientWidth, h = container.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
}).observe(container);
// ── WebSerial ─────────────────────────────────────────────────────────────────
let port = null, reader = null, opening = false;
let lineBuffer = '', msgCount = 0, lastHzTime = Date.now();
let currentState = 0;
function logConsole(msg) {
const el = document.getElementById('console-log');
const d = document.createElement('div');
d.textContent = new Date().toLocaleTimeString() + ' ' + msg;
el.appendChild(d);
if (el.children.length > 300) el.removeChild(el.firstChild);
el.scrollTop = el.scrollHeight;
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
async function sendCmd(cmd) {
if (!port?.writable) return;
try {
const w = port.writable.getWriter();
await w.write(new TextEncoder().encode(cmd));
w.releaseLock();
} catch(e) { logConsole('TX err: ' + e.message); }
}
window.toggleSerial = async function() {
if (opening) return;
if (port) {
try { await sendCmd('S'); } catch(e) {}
try { reader?.cancel(); } catch(e) {}
try { await port.close(); } catch(e) {}
port = null; reader = null;
const btn = document.getElementById('connect-btn');
btn.textContent = 'CONNECT USB';
btn.className = btn.className.replace('bg-green-900', 'bg-red-900').replace('border-green-700', 'border-red-700');
['arm-btn', 'dfu-btn', 'yaw-btn', 'gyrocal-btn'].forEach(id =>
document.getElementById(id).classList.add('hidden'));
setStatus('Disconnected');
return;
}
try {
opening = true;
port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
opening = false;
const btn = document.getElementById('connect-btn');
btn.textContent = 'DISCONNECT';
btn.className = btn.className.replace('bg-red-900', 'bg-green-900').replace('border-red-700', 'border-green-700');
['arm-btn', 'dfu-btn', 'yaw-btn', 'gyrocal-btn'].forEach(id =>
document.getElementById(id).classList.remove('hidden'));
setStatus('Connected — streaming');
readLoop();
} catch(e) {
opening = false; port = null;
setStatus('Error: ' + e.message);
}
};
window.toggleArm = async function() { await sendCmd(currentState === 1 ? 'D' : 'A'); };
window.enterDFU = async function() {
if (!confirm('Reboot to DFU? Connection will be lost.')) return;
await sendCmd('R');
setStatus('Rebooting to DFU...');
};
window.gyroRecal = async function() {
await sendCmd('G');
setStatus('Gyro calibrating — hold still ~1s...');
};
window.sendPID = async function() {
const kp = (document.getElementById('sl-kp').value / 1000).toFixed(3);
const ki = (document.getElementById('sl-ki').value / 1000).toFixed(3);
const kd = (document.getElementById('sl-kd').value / 1000).toFixed(3);
const sp = (document.getElementById('sl-sp').value / 10).toFixed(1);
const ms = document.getElementById('sl-ms').value;
await sendCmd(`P${kp}`);
await sendCmd(`I${ki}`);
await sendCmd(`D${kd}`);
await sendCmd(`T${sp}`);
await sendCmd(`M${ms}`);
setStatus('PID applied');
};
window.queryPID = async function() { await sendCmd('?'); };
window.toggleConsole = function() {
const p = document.getElementById('console-panel');
const b = document.getElementById('console-toggle');
p.classList.toggle('hidden');
b.textContent = p.classList.contains('hidden') ? '▲ LOG' : '▼ LOG';
};
async function readLoop() {
const decoder = new TextDecoderStream();
port.readable.pipeTo(decoder.writable);
reader = decoder.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
lineBuffer += value;
const lines = lineBuffer.split('\n');
lineBuffer = lines.pop();
for (const line of lines) {
const t = line.trim();
if (!t) continue;
logConsole(t);
try {
const d = JSON.parse(t);
// PID query reply: has kp/ki/kd but not p/r/y
if (d.kp !== undefined && d.p === undefined) { window.updatePID(d); continue; }
if (d.err !== undefined) { setStatus('IMU err: ' + d.err); continue; }
currentState = d.s || 0;
window.updateIMU(d);
msgCount++;
const now = Date.now();
if (now - lastHzTime >= 1000) {
document.getElementById('v-hz').textContent = msgCount;
msgCount = 0;
lastHzTime = now;
}
} catch(e) {}
}
}
} 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>