Gets the embedded HTML for the visualization page.
1077{
1078 return R"RAW_HTML(
1079<!DOCTYPE html>
1080<html>
1081<head>
1082 <title>MRDT 3D Visualizer</title>
1083 <style>
1084 body { margin: 0; overflow: hidden; background: #222; font-family: sans-serif; }
1085 #ui-layer {
1086 position: absolute;
1087 top: 10px;
1088 left: 10px;
1089 color: #0f0;
1090 background: rgba(0,0,0,0.5);
1091 padding: 10px;
1092 border-radius: 5px;
1093 pointer-events: none;
1094 min-width: 250px;
1095 z-index: 10;
1096 }
1097 #eta-box {
1098 position: absolute;
1099 top: 10px;
1100 left: 50%;
1101 transform: translateX(-50%);
1102 color: #0f0;
1103 background: rgba(0,0,0,0.5);
1104 padding: 10px;
1105 border-radius: 5px;
1106 font-size: 16px;
1107 font-weight: bold;
1108 pointer-events: none;
1109 z-index: 10;
1110 }
1111 #marker-layer {
1112 position: absolute;
1113 top: 0; left: 0; width: 100%; height: 100%;
1114 pointer-events: none;
1115 overflow: hidden;
1116 }
1117 .hud-marker {
1118 position: absolute;
1119 padding: 4px 8px;
1120 background: rgba(0, 0, 0, 0.7);
1121 color: white;
1122 border: 2px solid white;
1123 border-radius: 4px;
1124 font-size: 14px;
1125 font-weight: bold;
1126 white-space: nowrap;
1127 transform: translate(-50%, -50%);
1128 transition: opacity 0.2s;
1129 }
1130 .hud-marker::after {
1131 content: '';
1132 position: absolute;
1133 top: 50%; left: 50%;
1134 width: 0; height: 0;
1135 }
1136 #legend-layer {
1137 position: absolute;
1138 bottom: 20px;
1139 left: 10px;
1140 color: #fff;
1141 background: rgba(0,0,0,0.7);
1142 padding: 10px;
1143 border-radius: 5px;
1144 pointer-events: none;
1145 min-width: 150px;
1146 z-index: 10;
1147 }
1148 .legend-section { margin-bottom: 10px; border-bottom: 1px solid #555; padding-bottom: 5px; }
1149 .legend-item { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; font-size: 12px; }
1150 .color-box { width: 15px; height: 15px; border: 1px solid #aaa; }
1151 .circle-box { width: 12px; height: 12px; border-radius: 50%; }
1152 .control-group { margin-bottom: 10px; pointer-events: auto; }
1153 label { display: block; font-size: 12px; color: #aaa; }
1154 input[type=range] { width: 100%; }
1155 .val-disp { float: right; color: #fff; }
1156 .btn-group { position: absolute; bottom: 20px; right: 20px; display: flex; gap: 10px; z-index: 10; }
1157 .hud-btn { padding: 10px 20px; background: #444; color: white; border: 2px solid #666; cursor: pointer; font-size: 16px; z-index: 999; }
1158 .hud-btn.active { background: #00aa00; border-color: #00ff00; }
1159 .key { color: #fff; font-weight: bold; border: 1px solid #666; padding: 2px 5px; border-radius: 3px; background: #333; }
1160 h3 { margin-top: 0; border-bottom: 1px solid #555; padding-bottom: 5px; }
1161
1162 #detection-panel { position: absolute; top: 10px; right: 10px; width: auto; background: rgba(0,0,0,0.7); padding: 10px; border-radius: 5px; z-index: 10; transition: width 0.2s; }
1163 #detection-panel h3 { color: #0f0; margin-top: 0; }
1164 .gallery-item { cursor: pointer; }
1165 .gallery-item img { width: 100%; border: 2px solid #666; border-radius: 4px; transition: border-color 0.2s; }
1166 .gallery-item img:hover { border-color: #0f0; }
1167
1168 #detection-modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.9); align-items: center; justify-content: center; }
1169 #detection-modal img { max-width: 90%; max-height: 90%; border: 3px solid #0f0; }
1170 #modal-caption { position: absolute; bottom: 80px; color: #0f0; font-size: 18px; text-align: center; width: 100%; }
1171 #modal-open-btn { position: absolute; bottom: 30px; left: 50%; transform: translateX(-50%); padding: 10px 30px; background: #444; color: white; border: 2px solid #0f0; cursor: pointer; font-size: 16px; border-radius: 5px; }
1172 #modal-open-btn:hover { background: #00aa00; }
1173
1174 .modal-close { position: absolute; top: 20px; right: 40px; color: #fff; font-size: 40px; font-weight: bold; cursor: pointer; }
1175 .modal-close:hover { color: #0f0; }
1176
1177 .modal-nav { position: absolute; top: 50%; transform: translateY(-50%); color: #fff; font-size: 60px; font-weight: bold; cursor: pointer; padding: 20px; user-select: none; z-index: 1001; transition: color 0.2s; }
1178 .modal-nav:hover { color: #0f0; }
1179 .modal-nav.left { left: 20px; }
1180 .modal-nav.right { right: 20px; }
1181 </style>
1182 <script type="importmap">
1183 {
1184 "imports": {
1185 "three": "/lib/three.js",
1186 "three/addons/controls/OrbitControls.js": "/lib/orbit.js"
1187 }
1188 }
1189 </script>
1190</head>
1191<body>
1192 <div id="ui-layer">
1193 <h3 id="settings-toggle" style="cursor: pointer; pointer-events: auto; margin: 0; border: none; padding: 0; user-select: none;">Settings ▶</h3>
1194 <div id="settings-content" style="display: none; margin-top: 10px; border-top: 1px solid #555; padding-top: 10px;">
1195 <div class="control-group">
1196 <label style="color: #ccc; cursor: pointer; display: flex; align-items: center; gap: 5px;">
1197 <input type="checkbox" id="cb-ground" checked> Lock Rover to Terrain Height
1198 </label>
1199 </div>
1200 <div class="control-group">
1201 <label>Load Radius (m) <span id="val-rad" class="val-disp">50</span></label>
1202 <input type="range" id="sl-rad" min="10" max="200" value="50" step="10">
1203 </div>
1204 <div class="control-group">
1205 <label>Border Tol. (m) <span id="val-tol" class="val-disp">10</span></label>
1206 <input type="range" id="sl-tol" min="5" max="50" value="10" step="1">
1207 </div>
1208 <div class="control-group">
1209 <label>Min Score <span id="val-score" class="val-disp">0.0</span></label>
1210 <input type="range" id="sl-score" min="0.0" max="1.0" value="0.0" step="0.05">
1211 </div>
1212 <div id="status" style="margin-top:10px; color: #fff;">Status: Free Cam</div>
1213 <div id="stats" style="margin-top:5px; color: #aaa; font-size:12px;">Points: 0</div>
1214 </div>
1215 </div>
1216
1217 <div id="eta-box">ETA: Calculating...</div>
1218
1219 <div id="legend-layer">
1220 <div class="legend-section" id="det-legend">
1221 <strong>Detections</strong>
1222 </div>
1223 <div class="legend-section" id="state-legend">
1224 <strong>State Key</strong>
1225 </div>
1226 </div>
1227
1228 <div id="detection-panel">
1229 <h3>Latest Detection</h3>
1230 <div id="detection-gallery-items"></div>
1231 </div>
1232
1233 <div id="detection-modal" onclick="closeDetectionModal()">
1234 <span class="modal-close" onclick="closeDetectionModal()">×</span>
1235 <div class="modal-nav left" onclick="prevDetection(event)">❮</div>
1236 <img id="modal-image" src="" alt="Detection" onclick="event.stopPropagation()">
1237 <div class="modal-nav right" onclick="nextDetection(event)">❯</div>
1238 <div id="modal-caption"></div>
1239 <button id="modal-open-btn" onclick="event.stopPropagation()">Open in New Tab</button>
1240 </div>
1241
1242 <div id="marker-layer"></div>
1243
1244 <div class="btn-group">
1245 <button id="snap-btn" class="hud-btn" onclick="snapToRover()">Snap (Space)</button>
1246 <button id="follow-btn" class="hud-btn" onclick="toggleFollow()">Follow (F)</button>
1247 </div>
1248
1249<script type="module">
1250 import * as THREE from 'three';
1251 import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
1252
1253 let camera, scene, renderer, controls, roverMesh, pathLine, plannedPathLine, currentPoints;
1254 let waypointGroup, detectionGroup;
1255 let markerLayer;
1256 let activeWaypoints = [];
1257 let leftArrow, rightArrow;
1258 let beaconGeo, detectionTex;
1259
1260 let mapCenter = { x: 0, y: 0 };
1261 let cfgRadius = 50;
1262 let cfgTolerance = 10;
1263 let cfgMinScore = 0.0;
1264 let cfgLockGround = true;
1265 let isFetchingMap = false;
1266 let isFollowing = false;
1267 let targetPos = new THREE.Vector3();
1268 let targetHeading = 0.0;
1269 let prevRoverPos = new THREE.Vector3();
1270 const keys = { w:false, a:false, s:false, d:false, q:false, e:false, shift:false };
1271 let lastTime = performance.now();
1272
1273 // ETA Variables
1274 let lastTelemetryTime = 0;
1275 let lastTelemetryPos = new THREE.Vector3();
1276 let avgSpeed = 0.0;
1277 let pathDistance = 0.0;
1278 const speedHistory = [];
1279 let lastPathPoint = null;
1280
1281 // Detection Gallery Tracking
1282 let detectionFilenames = [];
1283 let currentModalIndex = 0;
1284
1285 const typeColors = {};
1286 const typeNames = {};
1287
1288 // State Colors
1289 const stateColors = {
1290 0: { name: "Idle", color: "#888888" },
1291 1: { name: "Navigating", color: "#00ffff" },
1292 2: { name: "Search Pattern", color: "#0000ff" },
1293 3: { name: "Approach Marker", color: "#ffffff" },
1294 4: { name: "Approach Object", color: "#ffaa00" },
1295 5: { name: "Verify Pos", color: "#00550e" },
1296 6: { name: "Verify Marker", color: "#06ac00" },
1297 7: { name: "Verify Object", color: "#78ff66" },
1298 8: { name: "Reversing", color: "#ff0000" },
1299 9: { name: "Stuck", color: "#330000" }
1300 };
1301
1302 // Detection Colors
1303 const detectColors = {
1304 10: { name: "Tag (Aruco)", color: "#aa00ff" }, // Purple
1305 11: { name: "Mallet", color: "#ffa500" }, // Orange
1306 12: { name: "Bottle", color: "#0088ff" }, // Blue
1307 13: { name: "Pick", color: "#ffee00" } // Yellow
1308 };
1309
1310 // Terrain Height Sampler
1311 function getTerrainHeight(rx, rz, defaultY) {
1312 if (!currentPoints || !currentPoints.geometry || !currentPoints.geometry.attributes.position) return defaultY;
1313 const positions = currentPoints.geometry.attributes.position.array;
1314 let sumY = 0;
1315 let count = 0;
1316 const radiusSq = 2.25; // 1.5m radius for averaging terrain
1317
1318 for (let i = 0; i < positions.length; i += 3) {
1319 const dx = positions[i] - rx;
1320 const dz = positions[i+2] - rz;
1321 if (dx*dx + dz*dz < radiusSq) {
1322 sumY += positions[i+1];
1323 count++;
1324 }
1325 }
1326 return count > 0 ? (sumY / count) : defaultY;
1327 }
1328
1329 // Thick Line / InstancedMesh Renderer (Replaces Firefox-broken LineBasicMaterial)
1330 function createThickPath(vertices, colors, radius, defaultColorHex) {
1331 if (vertices.length < 6) return null;
1332
1333 const numSegments = (vertices.length / 3) - 1;
1334 const cylinderGeo = new THREE.CylinderGeometry(radius, radius, 1, 8, 1, false);
1335 cylinderGeo.translate(0, 0.5, 0);
1336 cylinderGeo.rotateX(Math.PI / 2);
1337
1338 const mat = new THREE.MeshBasicMaterial();
1339 if (!colors) mat.color.setHex(defaultColorHex);
1340
1341 const mesh = new THREE.InstancedMesh(cylinderGeo, mat, numSegments);
1342 const p1 = new THREE.Vector3();
1343 const p2 = new THREE.Vector3();
1344 const dummy = new THREE.Object3D();
1345 const col = new THREE.Color();
1346
1347 for (let i = 0; i < numSegments; i++) {
1348 const idx = i * 3;
1349 p1.set(vertices[idx], vertices[idx+1], vertices[idx+2]);
1350 p2.set(vertices[idx+3], vertices[idx+4], vertices[idx+5]);
1351
1352 const dist = p1.distanceTo(p2);
1353 if (dist < 0.001) {
1354 dummy.scale.set(0, 0, 0);
1355 dummy.updateMatrix();
1356 mesh.setMatrixAt(i, dummy.matrix);
1357 continue;
1358 }
1359
1360 dummy.position.copy(p1);
1361 dummy.lookAt(p2);
1362 dummy.scale.set(1, 1, dist);
1363 dummy.updateMatrix();
1364 mesh.setMatrixAt(i, dummy.matrix);
1365
1366 if (colors) {
1367 col.setRGB(colors[idx], colors[idx+1], colors[idx+2]);
1368 mesh.setColorAt(i, col);
1369 }
1370 }
1371
1372 mesh.instanceMatrix.needsUpdate = true;
1373 if (colors) mesh.instanceColor.needsUpdate = true;
1374 return mesh;
1375 }
1376
1377 init();
1378 animate();
1379
1380 function init() {
1381 markerLayer = document.getElementById('marker-layer');
1382 scene = new THREE.Scene();
1383 scene.background = new THREE.Color(0x111111);
1384 scene.add(new THREE.GridHelper(100, 100));
1385 scene.add(new THREE.AxesHelper(2));
1386
1387 camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 10000);
1388 camera.position.set(0, 10, -10);
1389
1390 renderer = new THREE.WebGLRenderer({ antialias: true });
1391 renderer.setSize(window.innerWidth, window.innerHeight);
1392 document.body.appendChild(renderer.domElement);
1393
1394 controls = new OrbitControls(camera, renderer.domElement);
1395 controls.enableDamping = true;
1396 controls.maxDistance = 5000;
1397
1398 const geometry = new THREE.BoxGeometry(1, 0.5, 1.5);
1399 geometry.translate(0, 0.25, 0);
1400 const material = new THREE.MeshBasicMaterial({ color: 0xff00ff, wireframe: true });
1401 roverMesh = new THREE.Mesh(geometry, material);
1402 scene.add(roverMesh);
1403
1404 // Drive Vectors (Arrows)
1405 const arrowDir = new THREE.Vector3(0, 0, -1);
1406 const arrowOrigin = new THREE.Vector3(0, 0, 0);
1407 const arrowLen = 1;
1408 const arrowCol = 0xffff00;
1409 leftArrow = new THREE.ArrowHelper(arrowDir, arrowOrigin, arrowLen, arrowCol);
1410 rightArrow = new THREE.ArrowHelper(arrowDir, arrowOrigin, arrowLen, arrowCol);
1411 roverMesh.add(leftArrow);
1412 roverMesh.add(rightArrow);
1413 leftArrow.position.set(-0.6, 0, 0);
1414 rightArrow.position.set(0.6, 0, 0);
1415
1416 beaconGeo = new THREE.BoxGeometry(0.5, 10000, 0.5);
1417 const canvas = document.createElement('canvas');
1418 canvas.width = 32; canvas.height = 32;
1419 const ctx = canvas.getContext('2d');
1420 ctx.beginPath();
1421 ctx.arc(16,16,14,0,2*Math.PI);
1422 ctx.fillStyle = 'white';
1423 ctx.fill();
1424 detectionTex = new THREE.CanvasTexture(canvas);
1425
1426 waypointGroup = new THREE.Group();
1427 scene.add(waypointGroup);
1428
1429 detectionGroup = new THREE.Group();
1430 scene.add(detectionGroup);
1431
1432 const config = [
1433 { id: 0, name: "NAV", color: "#00ffff" },
1434 { id: 1, name: "TAG", color: "#ffffff" },
1435 { id: 2, name: "MALLET", color: "#ffa500" },
1436 { id: 3, name: "BOTTLE", color: "#0088ff" },
1437 { id: 4, name: "PICK", color: "#ffee00" },
1438 { id: 5, name: "OBJ", color: "#aaaaaa" },
1439 { id: 6, name: "OBSTACLE", color: "#ff0000" },
1440 { id: 7, name: "UNKNOWN", color: "#000000" },
1441 { id: 8, name: "GOAL REACHED", color: "#00ff00" }
1442 ];
1443
1444 config.forEach(c => {
1445 typeNames[c.id] = c.name;
1446 typeColors[c.id] = new THREE.Color(c.color);
1447 });
1448
1449 // Legend: Detections
1450 const detLegendDiv = document.getElementById('det-legend');
1451 for (const [id, data] of Object.entries(detectColors)) {
1452 const item = document.createElement('div');
1453 item.className = 'legend-item';
1454 item.innerHTML = `<div class="circle-box" style="background:${data.color}"></div><span>${data.name}</span>`;
1455 detLegendDiv.appendChild(item);
1456 }
1457
1458 // Legend: States
1459 const stateLegendDiv = document.getElementById('state-legend');
1460 for (const [id, data] of Object.entries(stateColors)) {
1461 const item = document.createElement('div');
1462 item.className = 'legend-item';
1463 item.innerHTML = `<div class="color-box" style="background:${data.color}"></div><span>${data.name}</span>`;
1464 stateLegendDiv.appendChild(item);
1465 }
1466
1467 // Toggles & Inputs
1468 const cbGround = document.getElementById('cb-ground');
1469 if (cbGround) {
1470 cbGround.addEventListener('change', (e) => {
1471 cfgLockGround = e.target.checked;
1472 if (cfgLockGround) {
1473 targetPos.y = getTerrainHeight(targetPos.x, targetPos.z, targetPos.y);
1474 }
1475 });
1476 }
1477 document.getElementById('sl-rad').oninput = (e) => {
1478 cfgRadius = parseInt(e.target.value);
1479 document.getElementById('val-rad').innerText = cfgRadius;
1480 checkBoundary(true);
1481 };
1482 document.getElementById('sl-tol').oninput = (e) => {
1483 cfgTolerance = parseInt(e.target.value);
1484 document.getElementById('val-tol').innerText = cfgTolerance;
1485 };
1486 document.getElementById('sl-score').oninput = (e) => {
1487 cfgMinScore = parseFloat(e.target.value);
1488 document.getElementById('val-score').innerText = cfgMinScore.toFixed(2);
1489 checkBoundary(true);
1490 };
1491
1492 // UI Layer Collapsible Toggle
1493 const settingsToggle = document.getElementById('settings-toggle');
1494 const settingsContent = document.getElementById('settings-content');
1495 settingsToggle.addEventListener('click', () => {
1496 if (settingsContent.style.display === 'none') {
1497 settingsContent.style.display = 'block';
1498 settingsToggle.innerHTML = 'Settings ▼';
1499 } else {
1500 settingsContent.style.display = 'none';
1501 settingsToggle.innerHTML = 'Settings ▶';
1502 }
1503 });
1504
1505 window.addEventListener('keydown', (e) => onKey(e, true));
1506 window.addEventListener('keyup', (e) => onKey(e, false));
1507 window.addEventListener('resize', onWindowResize);
1508
1509 window.toggleFollow = () => {
1510 isFollowing = !isFollowing;
1511 document.getElementById('follow-btn').classList.toggle('active', isFollowing);
1512 document.getElementById('status').innerText = isFollowing ? "Status: Locked" : "Status: Free Cam";
1513 if(isFollowing) controls.target.copy(roverMesh.position);
1514 };
1515 window.snapToRover = () => {
1516 camera.position.copy(roverMesh.position).add(new THREE.Vector3(0,10,-10));
1517 controls.target.copy(roverMesh.position);
1518 };
1519
1520 requestTelemetryLoop();
1521 setInterval(fetchPlannedPath, 2000);
1522 setInterval(fetchWaypoints, 2000);
1523 setInterval(fetchDetections, 1000);
1524 setInterval(fetchDetectionsList, 5000);
1525 fetchMapSquare(0, 0);
1526 }
1527
1528 // --- RECURSIVE LOOP ---
1529 async function requestTelemetryLoop() {
1530 if (!document.hidden) await fetchTelemetry();
1531 setTimeout(requestTelemetryLoop, 50);
1532 }
1533
1534 async function fetchTelemetry() {
1535 try {
1536 const response = await fetch('/api/telemetry');
1537 if (!response.ok) return;
1538 const buffer = await response.arrayBuffer();
1539 updateTelemetry(buffer);
1540 } catch (e) { }
1541 }
1542
1543 async function fetchPlannedPath() {
1544 try {
1545 const response = await fetch('/api/planned_path');
1546 const buffer = await response.arrayBuffer();
1547 updatePlannedPath(buffer);
1548 } catch(e) {}
1549 }
1550
1551 async function fetchWaypoints() {
1552 try {
1553 const response = await fetch('/api/waypoints');
1554 const buffer = await response.arrayBuffer();
1555 updateWaypoints(buffer);
1556 } catch(e) {}
1557 }
1558
1559 async function fetchDetections() {
1560 try {
1561 const response = await fetch('/api/detections');
1562 const buffer = await response.arrayBuffer();
1563 updateDetections(buffer);
1564 } catch(e) {}
1565 }
1566
1567 async function fetchDetectionsList() {
1568 try {
1569 const response = await fetch('/api/detection_list');
1570 const filenames = await response.json();
1571 updateDetectionGallery(filenames);
1572 } catch(e) {
1573 console.error('Failed to fetch detection list:', e);
1574 }
1575 }
1576
1577 function updateDetectionGallery(filenames) {
1578 detectionFilenames = filenames;
1579 const panel = document.getElementById('detection-panel');
1580 const gallery = document.getElementById('detection-gallery-items');
1581 const header = panel ? panel.querySelector('h3') : null;
1582 if (!gallery) return;
1583
1584 gallery.innerHTML = '';
1585
1586 if (filenames.length === 0) {
1587 if (panel) panel.style.width = 'auto';
1588 if (header) header.style.display = 'none';
1589 gallery.innerHTML = '<div style="color:#aaa; font-size:12px; text-align:center;">No detections</div>';
1590 return;
1591 }
1592
1593 if (panel) panel.style.width = '250px';
1594 if (header) header.style.display = 'block';
1595
1596 const latestIndex = filenames.length - 1;
1597 const filename = filenames[latestIndex];
1598
1599 const item = document.createElement('div');
1600 item.className = 'gallery-item';
1601
1602 const img = document.createElement('img');
1603 img.src = `/detections/${filename}`;
1604 img.alt = filename;
1605 img.title = "Click to view full gallery";
1606 img.addEventListener('click', () => showDetectionModal(latestIndex));
1607
1608 const info = document.createElement('div');
1609 info.style.color = '#fff';
1610 info.style.fontSize = '14px';
1611 info.style.textAlign = 'center';
1612 info.style.marginTop = '8px';
1613 info.innerText = `View all ${filenames.length} images`;
1614
1615 item.appendChild(img);
1616 item.appendChild(info);
1617 gallery.appendChild(item);
1618 }
1619
1620 window.showDetectionModal = function(index) {
1621 const modal = document.getElementById('detection-modal');
1622 const img = document.getElementById('modal-image');
1623 const caption = document.getElementById('modal-caption');
1624 const openBtn = document.getElementById('modal-open-btn');
1625
1626 if (modal && img && caption && detectionFilenames.length > 0) {
1627 currentModalIndex = index;
1628 const filename = detectionFilenames[currentModalIndex];
1629 const src = `/detections/${filename}`;
1630
1631 img.src = src;
1632 caption.innerText = `${filename} (${currentModalIndex + 1} of ${detectionFilenames.length})`;
1633
1634 if (openBtn) {
1635 openBtn.onclick = () => window.open(src, '_blank');
1636 }
1637 modal.style.display = 'flex';
1638 }
1639 }
1640
1641 window.closeDetectionModal = function() {
1642 const modal = document.getElementById('detection-modal');
1643 if (modal) modal.style.display = 'none';
1644 }
1645
1646 window.nextDetection = function(e) {
1647 e.stopPropagation();
1648 if (detectionFilenames.length === 0) return;
1649 let newIdx = currentModalIndex + 1;
1650 if (newIdx >= detectionFilenames.length) newIdx = 0;
1651 showDetectionModal(newIdx);
1652 }
1653
1654 window.prevDetection = function(e) {
1655 e.stopPropagation();
1656 if (detectionFilenames.length === 0) return;
1657 let newIdx = currentModalIndex - 1;
1658 if (newIdx < 0) newIdx = detectionFilenames.length - 1;
1659 showDetectionModal(newIdx);
1660 }
1661
1662 function updateArrow(arrow, power) {
1663 const absPwr = Math.abs(power);
1664 const dir = power >= 0 ? new THREE.Vector3(0, 0, -1) : new THREE.Vector3(0, 0, 1);
1665 arrow.setDirection(dir);
1666 arrow.setLength(Math.max(absPwr * 2.0, 0.001), 0.2, 0.1);
1667 const col = power >= 0 ? 0x00ff00 : 0xff0000;
1668 arrow.setColor(col);
1669 }
1670
1671 function updateTelemetry(buffer) {
1672 const view = new DataView(buffer);
1673 const rx = view.getFloat32(0, true);
1674 const ry = view.getFloat32(4, true);
1675 const rz = view.getFloat32(8, true);
1676 const rh = view.getFloat32(12, true);
1677
1678 // Calculate Speed using actual un-flattened 3D position
1679 const now = performance.now();
1680 const newPos = new THREE.Vector3(rx, ry, -rz);
1681 if (lastTelemetryTime > 0) {
1682 const dt = (now - lastTelemetryTime) / 1000.0;
1683 if (dt > 0.1) {
1684 const dist = newPos.distanceTo(lastTelemetryPos);
1685 const instSpeed = dist / dt;
1686 speedHistory.push(instSpeed);
1687 if (speedHistory.length > 20) speedHistory.shift();
1688 avgSpeed = speedHistory.reduce((a,b)=>a+b, 0) / speedHistory.length;
1689 }
1690 }
1691 lastTelemetryPos.copy(newPos);
1692 lastTelemetryTime = now;
1693
1694 // Drive Powers
1695 const leftPwr = view.getFloat32(16, true);
1696 const rightPwr = view.getFloat32(20, true);
1697 updateArrow(leftArrow, leftPwr);
1698 updateArrow(rightArrow, rightPwr);
1699
1700 let targetY = ry;
1701 if (cfgLockGround) {
1702 targetY = getTerrainHeight(rx, -rz, ry);
1703 }
1704 targetPos.set(rx, targetY, -rz);
1705 targetHeading = -rh * (Math.PI / 180.0);
1706
1707 checkBoundary(false);
1708
1709 const pathCount = view.getUint32(24, true); // Offset 24
1710 if (pathCount > 0) {
1711 if (pathLine) {
1712 scene.remove(pathLine);
1713 if (pathLine.geometry) pathLine.geometry.dispose();
1714 if (pathLine.material) pathLine.material.dispose();
1715 }
1716 const floats = new Float32Array(buffer, 28, pathCount * 4); // Offset 28
1717 const vertices = [];
1718 const colors = [];
1719 const c = new THREE.Color();
1720
1721 for(let i=0; i<floats.length; i+=4) {
1722 vertices.push(floats[i], floats[i+1], -floats[i+2]);
1723 const state = Math.floor(floats[i+3]);
1724 const hex = stateColors[state] ? stateColors[state].color : "#ffffff";
1725 c.set(hex);
1726 colors.push(c.r, c.g, c.b);
1727 }
1728
1729 pathLine = createThickPath(vertices, colors, 0.1, 0xffffff);
1730 if (pathLine) scene.add(pathLine);
1731 }
1732 }
1733
1734 function updatePlannedPath(buffer) {
1735 const view = new DataView(buffer);
1736 const count = view.getUint32(0, true);
1737 if (plannedPathLine) {
1738 scene.remove(plannedPathLine);
1739 if (plannedPathLine.geometry) plannedPathLine.geometry.dispose();
1740 if (plannedPathLine.material) plannedPathLine.material.dispose();
1741 plannedPathLine = null;
1742 }
1743
1744 pathDistance = 0.0;
1745 lastPathPoint = null;
1746
1747 if (count > 0) {
1748 const floats = new Float32Array(buffer, 4, count * 3);
1749 const vertices = [];
1750 for(let i=0; i<floats.length; i+=3) {
1751 vertices.push(floats[i], floats[i+1], -floats[i+2]);
1752 }
1753
1754 // Calculate total path distance (sum of segments)
1755 // Add distance from rover to first point
1756 if(vertices.length >= 3) {
1757 const firstPt = new THREE.Vector3(vertices[0], vertices[1], vertices[2]);
1758 pathDistance += targetPos.distanceTo(firstPt);
1759 // Store Last Point
1760 const lastIdx = vertices.length - 3;
1761 lastPathPoint = new THREE.Vector3(vertices[lastIdx], vertices[lastIdx+1], vertices[lastIdx+2]);
1762 }
1763 // Add segments
1764 for(let i=0; i<vertices.length-3; i+=3) {
1765 const p1 = new THREE.Vector3(vertices[i], vertices[i+1], vertices[i+2]);
1766 const p2 = new THREE.Vector3(vertices[i+3], vertices[i+4], vertices[i+5]);
1767 pathDistance += p1.distanceTo(p2);
1768 }
1769
1770 plannedPathLine = createThickPath(vertices, null, 0.1, 0xeeff00);
1771 if (plannedPathLine) scene.add(plannedPathLine);
1772 }
1773 }
1774
1775 function updateWaypoints(buffer) {
1776 while(waypointGroup.children.length > 0){
1777 const child = waypointGroup.children[0];
1778 waypointGroup.remove(child);
1779 if (child.material) child.material.dispose();
1780 }
1781 markerLayer.innerHTML = '';
1782 activeWaypoints = [];
1783
1784 const view = new DataView(buffer);
1785 const count = view.getUint32(0, true);
1786 if(count === 0) return;
1787
1788 let offset = 4;
1789
1790 for(let i=0; i<count; i++) {
1791 const x = view.getFloat32(offset, true);
1792 const y = view.getFloat32(offset+4, true);
1793 const z = view.getFloat32(offset+8, true);
1794 const type = view.getInt32(offset+12, true);
1795 offset += 16;
1796
1797 const col = typeColors[type] || typeColors[7];
1798 const beaconMat = new THREE.MeshBasicMaterial({
1799 color: col,
1800 transparent: true,
1801 opacity: 0.3,
1802 depthTest: false
1803 });
1804 const beacon = new THREE.Mesh(beaconGeo, beaconMat);
1805 beacon.position.set(x, 0, -z);
1806 waypointGroup.add(beacon);
1807
1808 const div = document.createElement('div');
1809 div.className = 'hud-marker';
1810 div.innerText = typeNames[type] || "UNK";
1811 div.style.borderColor = "#" + col.getHexString();
1812 markerLayer.appendChild(div);
1813
1814 activeWaypoints.push({
1815 div: div,
1816 pos: new THREE.Vector3(x, 0, -z)
1817 });
1818 }
1819 }
1820
1821 function updateDetections(buffer) {
1822 while(detectionGroup.children.length > 0){
1823 const child = detectionGroup.children[0];
1824 detectionGroup.remove(child);
1825 if (child.geometry) child.geometry.dispose();
1826 if (child.material) child.material.dispose();
1827 }
1828
1829 const view = new DataView(buffer);
1830 const count = view.getUint32(0, true);
1831 if(count === 0) return;
1832
1833 let offset = 4;
1834
1835 for(let i=0; i<count; i++) {
1836 const x = view.getFloat32(offset, true);
1837 const y = view.getFloat32(offset+4, true);
1838 const z = view.getFloat32(offset+8, true);
1839 const type = view.getInt32(offset+12, true);
1840 offset += 16;
1841
1842 let col = "#ffffff";
1843 if(detectColors[type]) col = detectColors[type].color;
1844
1845 const mat = new THREE.PointsMaterial({
1846 color: col,
1847 map: detectionTex,
1848 size: 2.0, // Large persistent dot
1849 sizeAttenuation: true,
1850 alphaTest: 0.5,
1851 transparent: true
1852 });
1853 const geo = new THREE.BufferGeometry();
1854 geo.setAttribute('position', new THREE.Float32BufferAttribute([x, y, -z], 3));
1855
1856 const pt = new THREE.Points(geo, mat);
1857 detectionGroup.add(pt);
1858 }
1859 }
1860
1861 function updateHUD() {
1862 const width = window.innerWidth;
1863 const height = window.innerHeight;
1864 const pad = 30;
1865
1866 // Update ETA Box
1867 const etaBox = document.getElementById('eta-box');
1868
1869 // Reached End Logic
1870 let bReached = false;
1871 if (lastPathPoint && roverMesh.position.distanceTo(lastPathPoint) < 2.0) {
1872 bReached = true;
1873 }
1874
1875 if (bReached) {
1876 etaBox.innerText = "Status: Reached End of Path";
1877 etaBox.style.color = "#00ff00"; // Green
1878 } else {
1879 etaBox.style.color = "#0f0"; // Default Green
1880 if (avgSpeed < 0.05) {
1881 etaBox.innerText = "ETA: Stopped";
1882 } else {
1883 const timeSec = pathDistance / avgSpeed;
1884 if (!isFinite(timeSec) || timeSec < 0) {
1885 etaBox.innerText = "ETA: --:--";
1886 } else {
1887 const min = Math.floor(timeSec / 60);
1888 const sec = Math.floor(timeSec % 60);
1889 etaBox.innerText = `ETA: ${min}m ${sec}s (${avgSpeed.toFixed(2)} m/s)`;
1890 }
1891 }
1892 }
1893
1894 activeWaypoints.forEach(wp => {
1895 const target = wp.pos.clone();
1896 target.y = roverMesh.position.y + 3.0;
1897 target.project(camera);
1898
1899 let x = (target.x * .5 + .5) * width;
1900 let y = (target.y * -.5 + .5) * height;
1901
1902 const isBehind = target.z > 1;
1903
1904 if (isBehind) {
1905 x = width - x;
1906 y = height - y;
1907 }
1908
1909 const cx = width / 2;
1910 const cy = height / 2;
1911 const dx = x - cx;
1912 const dy = y - cy;
1913
1914 if (!isBehind && x >= pad && x <= width - pad && y >= pad && y <= height - pad) {
1915 y -= 40;
1916 wp.div.style.opacity = "0.9";
1917 } else {
1918 let t = Infinity;
1919 if (dx > 0) t = Math.min(t, (width - pad - cx) / dx);
1920 if (dx < 0) t = Math.min(t, (pad - cx) / dx);
1921 if (dy > 0) t = Math.min(t, (height - pad - cy) / dy);
1922 if (dy < 0) t = Math.min(t, (pad - cy) / dy);
1923
1924 x = cx + dx * t;
1925 y = cy + dy * t;
1926 wp.div.style.opacity = "0.6";
1927 }
1928
1929 wp.div.style.left = x + 'px';
1930 wp.div.style.top = y + 'px';
1931
1932 // Use 2D horizontal distance for distance display.
1933 const dxPos = roverMesh.position.x - wp.pos.x;
1934 const dzPos = roverMesh.position.z - wp.pos.z;
1935 const dist = Math.sqrt(dxPos*dxPos + dzPos*dzPos);
1936
1937 wp.div.innerText = `${wp.div.innerText.split(' ')[0]} ${Math.round(dist)}m`;
1938 });
1939 }
1940
1941 function checkBoundary(force) {
1942 if(isFetchingMap && !force) return;
1943 const roverX = targetPos.x;
1944 const roverN = -targetPos.z;
1945 const distE = Math.abs(roverX - mapCenter.x);
1946 const distN = Math.abs(roverN - mapCenter.y);
1947 const limit = cfgRadius - cfgTolerance;
1948
1949 if (force || distE > limit || distN > limit) {
1950 fetchMapSquare(roverX, roverN);
1951 }
1952 }
1953
1954 async function fetchMapSquare(x, y) {
1955 if(isFetchingMap) return;
1956 isFetchingMap = true;
1957 try {
1958 const url = `/api/map?x=${x.toFixed(2)}&y=${y.toFixed(2)}&r=${cfgRadius}&s=${cfgMinScore}`;
1959 const response = await fetch(url);
1960 const buffer = await response.arrayBuffer();
1961 loadMapPoints(buffer);
1962 mapCenter = { x: x, y: y };
1963 } catch(e) { console.error(e); }
1964 isFetchingMap = false;
1965 }
1966
1967 function loadMapPoints(buffer) {
1968 const view = new DataView(buffer);
1969 const count = view.getUint32(0, true);
1970
1971 if(currentPoints) {
1972 scene.remove(currentPoints);
1973 currentPoints.geometry.dispose();
1974 currentPoints.material.dispose();
1975 currentPoints = null;
1976 }
1977
1978 if(count === 0) {
1979 document.getElementById('stats').innerText = "Points: 0";
1980 return;
1981 }
1982
1983 const pts = [];
1984 const colors = [];
1985 const c = new THREE.Color();
1986 const floats = new Float32Array(buffer, 4, count * 4);
1987
1988 for(let i=0; i<floats.length; i+=4) {
1989 pts.push(floats[i], floats[i+1], -floats[i+2]);
1990 c.setHSL(floats[i+3] * 0.33, 1.0, 0.5);
1991 colors.push(c.r, c.g, c.b);
1992 }
1993
1994 const geo = new THREE.BufferGeometry();
1995 geo.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
1996 geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
1997 const mat = new THREE.PointsMaterial({ size: 0.5, vertexColors: true });
1998
1999 currentPoints = new THREE.Points(geo, mat);
2000 scene.add(currentPoints);
2001 document.getElementById('stats').innerText = "Points: " + count;
2002 }
2003
2004 function onKey(e, p) {
2005 if(keys.hasOwnProperty(e.key.toLowerCase())) keys[e.key.toLowerCase()] = p;
2006 if(e.key === 'Shift') keys.shift = p;
2007 if(p && e.key==='f') window.toggleFollow();
2008 if(p && e.key===' ') window.snapToRover();
2009 }
2010 function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }
2011
2012 function animate() {
2013 requestAnimationFrame(animate);
2014 const now = performance.now();
2015 const dt = Math.min((now - lastTime) / 1000.0, 0.1);
2016 lastTime = now;
2017
2018 prevRoverPos.copy(roverMesh.position);
2019 const lerpFactor = 5.0 * dt;
2020 roverMesh.position.lerp(targetPos, lerpFactor);
2021 const dRot = targetHeading - roverMesh.rotation.y;
2022 roverMesh.rotation.y += Math.atan2(Math.sin(dRot), Math.cos(dRot)) * lerpFactor;
2023
2024 if(isFollowing) {
2025 camera.position.add(new THREE.Vector3().subVectors(roverMesh.position, prevRoverPos));
2026 controls.target.copy(roverMesh.position);
2027 } else {
2028 const spd = (keys.shift?15:5)*dt;
2029 const fwd = new THREE.Vector3(); camera.getWorldDirection(fwd); fwd.y=0; fwd.normalize();
2030 const rgt = new THREE.Vector3().crossVectors(fwd, camera.up).normalize();
2031 if(keys.w) camera.position.addScaledVector(fwd, spd);
2032 if(keys.s) camera.position.addScaledVector(fwd, -spd);
2033 if(keys.d) camera.position.addScaledVector(rgt, spd);
2034 if(keys.a) camera.position.addScaledVector(rgt, -spd);
2035 if(keys.q) camera.position.y += spd;
2036 if(keys.e) camera.position.y -= spd;
2037 controls.target.add(new THREE.Vector3(0,0,0).addScaledVector(fwd, (keys.w-keys.s)*spd).addScaledVector(rgt, (keys.d-keys.a)*spd));
2038 }
2039 controls.update();
2040
2041 updateHUD();
2042
2043 renderer.render(scene, camera);
2044 }
2045</script>
2046</body>
2047</html>
2048 )RAW_HTML";
2049}