Gets the embedded HTML for the visualization page.
968{
969 return R"RAW_HTML(
970<!DOCTYPE html>
971<html>
972<head>
973 <title>MRDT 3D Visualizer</title>
974 <style>
975 body { margin: 0; overflow: hidden; background: #222; font-family: sans-serif; }
976 #ui-layer {
977 position: absolute;
978 top: 10px;
979 left: 10px;
980 color: #0f0;
981 background: rgba(0,0,0,0.5);
982 padding: 10px;
983 border-radius: 5px;
984 pointer-events: none;
985 min-width: 250px;
986 z-index: 10;
987 }
988 #eta-box {
989 position: absolute;
990 top: 10px;
991 right: 10px;
992 color: #0f0;
993 background: rgba(0,0,0,0.5);
994 padding: 10px;
995 border-radius: 5px;
996 font-size: 16px;
997 font-weight: bold;
998 pointer-events: none;
999 z-index: 10;
1000 }
1001 #marker-layer {
1002 position: absolute;
1003 top: 0; left: 0; width: 100%; height: 100%;
1004 pointer-events: none;
1005 overflow: hidden;
1006 }
1007 .hud-marker {
1008 position: absolute;
1009 padding: 4px 8px;
1010 background: rgba(0, 0, 0, 0.7);
1011 color: white;
1012 border: 2px solid white;
1013 border-radius: 4px;
1014 font-size: 14px;
1015 font-weight: bold;
1016 white-space: nowrap;
1017 transform: translate(-50%, -50%);
1018 transition: opacity 0.2s;
1019 }
1020 .hud-marker::after {
1021 content: '';
1022 position: absolute;
1023 top: 50%; left: 50%;
1024 width: 0; height: 0;
1025 }
1026 #legend-layer {
1027 position: absolute;
1028 bottom: 20px;
1029 left: 10px;
1030 color: #fff;
1031 background: rgba(0,0,0,0.7);
1032 padding: 10px;
1033 border-radius: 5px;
1034 pointer-events: none;
1035 min-width: 150px;
1036 z-index: 10;
1037 }
1038 .legend-section { margin-bottom: 10px; border-bottom: 1px solid #555; padding-bottom: 5px; }
1039 .legend-item { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; font-size: 12px; }
1040 .color-box { width: 15px; height: 15px; border: 1px solid #aaa; }
1041 .circle-box { width: 12px; height: 12px; border-radius: 50%; }
1042 .control-group { margin-bottom: 10px; pointer-events: auto; }
1043 label { display: block; font-size: 12px; color: #aaa; }
1044 input[type=range] { width: 100%; }
1045 .val-disp { float: right; color: #fff; }
1046 .btn-group { position: absolute; bottom: 20px; right: 20px; display: flex; gap: 10px; z-index: 10; }
1047 .hud-btn { padding: 10px 20px; background: #444; color: white; border: 2px solid #666; cursor: pointer; font-size: 16px; z-index: 999; }
1048 .hud-btn.active { background: #00aa00; border-color: #00ff00; }
1049 .key { color: #fff; font-weight: bold; border: 1px solid #666; padding: 2px 5px; border-radius: 3px; background: #333; }
1050 h3 { margin-top: 0; border-bottom: 1px solid #555; padding-bottom: 5px; }
1051 </style>
1052 <script type="importmap">
1053 {
1054 "imports": {
1055 "three": "/lib/three.js",
1056 "three/addons/controls/OrbitControls.js": "/lib/orbit.js"
1057 }
1058 }
1059 </script>
1060</head>
1061<body>
1062 <div id="ui-layer">
1063 <h3>Settings</h3>
1064 <div class="control-group">
1065 <label>Load Radius (m) <span id="val-rad" class="val-disp">50</span></label>
1066 <input type="range" id="sl-rad" min="10" max="200" value="50" step="10">
1067 </div>
1068 <div class="control-group">
1069 <label>Border Tol. (m) <span id="val-tol" class="val-disp">10</span></label>
1070 <input type="range" id="sl-tol" min="5" max="50" value="10" step="1">
1071 </div>
1072 <div class="control-group">
1073 <label>Min Score <span id="val-score" class="val-disp">0.0</span></label>
1074 <input type="range" id="sl-score" min="0.0" max="1.0" value="0.0" step="0.05">
1075 </div>
1076 <div id="status" style="margin-top:10px; color: #fff;">Status: Free Cam</div>
1077 <div id="stats" style="margin-top:5px; color: #aaa; font-size:12px;">Points: 0</div>
1078 </div>
1079
1080 <div id="eta-box">ETA: Calculating...</div>
1081
1082 <div id="legend-layer">
1083 <div class="legend-section" id="det-legend">
1084 <strong>Detections</strong>
1085 </div>
1086 <div class="legend-section" id="state-legend">
1087 <strong>State Key</strong>
1088 </div>
1089 </div>
1090
1091 <div id="marker-layer"></div>
1092
1093 <div class="btn-group">
1094 <button id="snap-btn" class="hud-btn" onclick="snapToRover()">Snap (Space)</button>
1095 <button id="follow-btn" class="hud-btn" onclick="toggleFollow()">Follow (F)</button>
1096 </div>
1097
1098<script type="module">
1099 import * as THREE from 'three';
1100 import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
1101
1102 let camera, scene, renderer, controls, roverMesh, pathLine, plannedPathLine, currentPoints;
1103 let waypointGroup, detectionGroup;
1104 let markerLayer;
1105 let activeWaypoints = [];
1106 let leftArrow, rightArrow;
1107
1108 let mapCenter = { x: 0, y: 0 };
1109 let cfgRadius = 50;
1110 let cfgTolerance = 10;
1111 let cfgMinScore = 0.0;
1112 let isFetchingMap = false;
1113 let isFollowing = false;
1114 let targetPos = new THREE.Vector3();
1115 let targetHeading = 0.0;
1116 let prevRoverPos = new THREE.Vector3();
1117 const keys = { w:false, a:false, s:false, d:false, q:false, e:false, shift:false };
1118 let lastTime = performance.now();
1119
1120 // ETA Variables
1121 let lastTelemetryTime = 0;
1122 let lastTelemetryPos = new THREE.Vector3();
1123 let avgSpeed = 0.0;
1124 let pathDistance = 0.0;
1125 const speedHistory = [];
1126 let lastPathPoint = null;
1127
1128 const typeColors = {};
1129 const typeNames = {};
1130
1131 // State Colors
1132 const stateColors = {
1133 0: { name: "Idle", color: "#888888" },
1134 1: { name: "Navigating", color: "#00ffff" },
1135 2: { name: "Search Pattern", color: "#0000ff" },
1136 3: { name: "Approach Marker", color: "#ffffff" },
1137 4: { name: "Approach Object", color: "#ffaa00" },
1138 5: { name: "Verify Pos", color: "#00550e" },
1139 6: { name: "Verify Marker", color: "#06ac00" },
1140 7: { name: "Verify Object", color: "#78ff66" },
1141 8: { name: "Reversing", color: "#ff0000" },
1142 9: { name: "Stuck", color: "#330000" }
1143 };
1144
1145 // Detection Colors
1146 const detectColors = {
1147 10: { name: "Tag (Aruco)", color: "#aa00ff" }, // Purple
1148 11: { name: "Mallet", color: "#ffa500" }, // Orange
1149 12: { name: "Bottle", color: "#0088ff" }, // Blue
1150 13: { name: "Pick", color: "#ffee00" } // Yellow
1151 };
1152
1153 init();
1154 animate();
1155
1156 function init() {
1157 markerLayer = document.getElementById('marker-layer');
1158 scene = new THREE.Scene();
1159 scene.background = new THREE.Color(0x111111);
1160 scene.add(new THREE.GridHelper(100, 100));
1161 scene.add(new THREE.AxesHelper(2));
1162
1163 camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 10000);
1164 camera.position.set(0, 10, -10);
1165
1166 renderer = new THREE.WebGLRenderer({ antialias: true });
1167 renderer.setSize(window.innerWidth, window.innerHeight);
1168 document.body.appendChild(renderer.domElement);
1169
1170 controls = new OrbitControls(camera, renderer.domElement);
1171 controls.enableDamping = true;
1172
1173 const geometry = new THREE.BoxGeometry(1, 0.5, 1.5);
1174 const material = new THREE.MeshBasicMaterial({ color: 0xff00ff, wireframe: true });
1175 roverMesh = new THREE.Mesh(geometry, material);
1176 scene.add(roverMesh);
1177
1178 // Drive Vectors (Arrows)
1179 const arrowDir = new THREE.Vector3(0, 0, -1);
1180 const arrowOrigin = new THREE.Vector3(0, 0, 0);
1181 const arrowLen = 1;
1182 const arrowCol = 0xffff00;
1183 leftArrow = new THREE.ArrowHelper(arrowDir, arrowOrigin, arrowLen, arrowCol);
1184 rightArrow = new THREE.ArrowHelper(arrowDir, arrowOrigin, arrowLen, arrowCol);
1185 roverMesh.add(leftArrow);
1186 roverMesh.add(rightArrow);
1187 leftArrow.position.set(-0.6, 0, 0);
1188 rightArrow.position.set(0.6, 0, 0);
1189
1190 waypointGroup = new THREE.Group();
1191 scene.add(waypointGroup);
1192
1193 detectionGroup = new THREE.Group(); // New Group for detections
1194 scene.add(detectionGroup);
1195
1196 const config = [
1197 { id: 0, name: "NAV", color: "#00ffff" },
1198 { id: 1, name: "TAG", color: "#ffffff" },
1199 { id: 2, name: "MALLET", color: "#ffa500" },
1200 { id: 3, name: "BOTTLE", color: "#0088ff" },
1201 { id: 4, name: "PICK", color: "#ffee00" },
1202 { id: 5, name: "OBJ", color: "#aaaaaa" },
1203 { id: 6, name: "OBSTACLE", color: "#ff0000" },
1204 { id: 7, name: "UNKNOWN", color: "#000000" },
1205 { id: 8, name: "GOAL REACHED", color: "#00ff00" }
1206 ];
1207
1208 config.forEach(c => {
1209 typeNames[c.id] = c.name;
1210 typeColors[c.id] = new THREE.Color(c.color);
1211 });
1212
1213 // Legend: Detections
1214 const detLegendDiv = document.getElementById('det-legend');
1215 for (const [id, data] of Object.entries(detectColors)) {
1216 const item = document.createElement('div');
1217 item.className = 'legend-item';
1218 item.innerHTML = `<div class="circle-box" style="background:${data.color}"></div><span>${data.name}</span>`;
1219 detLegendDiv.appendChild(item);
1220 }
1221
1222 // Legend: States
1223 const stateLegendDiv = document.getElementById('state-legend');
1224 for (const [id, data] of Object.entries(stateColors)) {
1225 const item = document.createElement('div');
1226 item.className = 'legend-item';
1227 item.innerHTML = `<div class="color-box" style="background:${data.color}"></div><span>${data.name}</span>`;
1228 stateLegendDiv.appendChild(item);
1229 }
1230
1231 document.getElementById('sl-rad').oninput = (e) => {
1232 cfgRadius = parseInt(e.target.value);
1233 document.getElementById('val-rad').innerText = cfgRadius;
1234 checkBoundary(true);
1235 };
1236 document.getElementById('sl-tol').oninput = (e) => {
1237 cfgTolerance = parseInt(e.target.value);
1238 document.getElementById('val-tol').innerText = cfgTolerance;
1239 };
1240 document.getElementById('sl-score').oninput = (e) => {
1241 cfgMinScore = parseFloat(e.target.value);
1242 document.getElementById('val-score').innerText = cfgMinScore.toFixed(2);
1243 checkBoundary(true);
1244 };
1245
1246 window.addEventListener('keydown', (e) => onKey(e, true));
1247 window.addEventListener('keyup', (e) => onKey(e, false));
1248 window.addEventListener('resize', onWindowResize);
1249
1250 window.toggleFollow = () => {
1251 isFollowing = !isFollowing;
1252 document.getElementById('follow-btn').classList.toggle('active', isFollowing);
1253 document.getElementById('status').innerText = isFollowing ? "Status: Locked" : "Status: Free Cam";
1254 if(isFollowing) controls.target.copy(roverMesh.position);
1255 };
1256 window.snapToRover = () => {
1257 camera.position.copy(roverMesh.position).add(new THREE.Vector3(0,10,-10));
1258 controls.target.copy(roverMesh.position);
1259 };
1260
1261 requestTelemetryLoop();
1262 setInterval(fetchPlannedPath, 2000);
1263 setInterval(fetchWaypoints, 2000);
1264 setInterval(fetchDetections, 1000); // Poll detections every second
1265 fetchMapSquare(0, 0);
1266 }
1267
1268 // --- RECURSIVE LOOP ---
1269 async function requestTelemetryLoop() {
1270 if (!document.hidden) await fetchTelemetry();
1271 setTimeout(requestTelemetryLoop, 50);
1272 }
1273
1274 async function fetchTelemetry() {
1275 try {
1276 const response = await fetch('/api/telemetry');
1277 if (!response.ok) return;
1278 const buffer = await response.arrayBuffer();
1279 updateTelemetry(buffer);
1280 } catch (e) { }
1281 }
1282
1283 async function fetchPlannedPath() {
1284 try {
1285 const response = await fetch('/api/planned_path');
1286 const buffer = await response.arrayBuffer();
1287 updatePlannedPath(buffer);
1288 } catch(e) {}
1289 }
1290
1291 async function fetchWaypoints() {
1292 try {
1293 const response = await fetch('/api/waypoints');
1294 const buffer = await response.arrayBuffer();
1295 updateWaypoints(buffer);
1296 } catch(e) {}
1297 }
1298
1299 async function fetchDetections() {
1300 try {
1301 const response = await fetch('/api/detections');
1302 const buffer = await response.arrayBuffer();
1303 updateDetections(buffer);
1304 } catch(e) {}
1305 }
1306
1307 function updateArrow(arrow, power) {
1308 const absPwr = Math.abs(power);
1309 const dir = power >= 0 ? new THREE.Vector3(0, 0, -1) : new THREE.Vector3(0, 0, 1);
1310 arrow.setDirection(dir);
1311 arrow.setLength(Math.max(absPwr * 2.0, 0.001), 0.2, 0.1);
1312 const col = power >= 0 ? 0x00ff00 : 0xff0000;
1313 arrow.setColor(col);
1314 }
1315
1316 function updateTelemetry(buffer) {
1317 const view = new DataView(buffer);
1318 const rx = view.getFloat32(0, true);
1319 const ry = view.getFloat32(4, true);
1320 const rz = view.getFloat32(8, true);
1321 const rh = view.getFloat32(12, true);
1322
1323 // Calculate Speed
1324 const now = performance.now();
1325 const newPos = new THREE.Vector3(rx, ry, -rz);
1326 if (lastTelemetryTime > 0) {
1327 const dt = (now - lastTelemetryTime) / 1000.0;
1328 if (dt > 0.1) {
1329 const dist = newPos.distanceTo(lastTelemetryPos);
1330 const instSpeed = dist / dt;
1331 speedHistory.push(instSpeed);
1332 if (speedHistory.length > 20) speedHistory.shift();
1333 avgSpeed = speedHistory.reduce((a,b)=>a+b, 0) / speedHistory.length;
1334 }
1335 }
1336 lastTelemetryPos.copy(newPos);
1337 lastTelemetryTime = now;
1338
1339 // Drive Powers
1340 const leftPwr = view.getFloat32(16, true);
1341 const rightPwr = view.getFloat32(20, true);
1342 updateArrow(leftArrow, leftPwr);
1343 updateArrow(rightArrow, rightPwr);
1344
1345 targetPos.set(rx, ry, -rz);
1346 targetHeading = -rh * (Math.PI / 180.0);
1347
1348 checkBoundary(false);
1349
1350 const pathCount = view.getUint32(24, true); // Offset 24
1351 if (pathCount > 0) {
1352 if (pathLine) scene.remove(pathLine);
1353 const floats = new Float32Array(buffer, 28, pathCount * 4); // Offset 28
1354 const vertices = [];
1355 const colors = [];
1356 const c = new THREE.Color();
1357
1358 for(let i=0; i<floats.length; i+=4) {
1359 vertices.push(floats[i], floats[i+1], -floats[i+2]);
1360 const state = Math.floor(floats[i+3]);
1361 const hex = stateColors[state] ? stateColors[state].color : "#ffffff";
1362 c.set(hex);
1363 colors.push(c.r, c.g, c.b);
1364 }
1365
1366 const pathGeo = new THREE.BufferGeometry();
1367 pathGeo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
1368 pathGeo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
1369 const mat = new THREE.LineBasicMaterial({ vertexColors: true, linewidth: 3 });
1370 pathLine = new THREE.Line(pathGeo, mat);
1371 scene.add(pathLine);
1372 }
1373 }
1374
1375 function updatePlannedPath(buffer) {
1376 const view = new DataView(buffer);
1377 const count = view.getUint32(0, true);
1378 if (plannedPathLine) {
1379 scene.remove(plannedPathLine);
1380 plannedPathLine.geometry.dispose();
1381 plannedPathLine = null;
1382 }
1383
1384 pathDistance = 0.0;
1385 lastPathPoint = null;
1386
1387 if (count > 0) {
1388 const floats = new Float32Array(buffer, 4, count * 3);
1389 const vertices = [];
1390 for(let i=0; i<floats.length; i+=3) {
1391 vertices.push(floats[i], floats[i+1], -floats[i+2]);
1392 }
1393
1394 // Calculate total path distance (sum of segments)
1395 // Add distance from rover to first point
1396 if(vertices.length >= 3) {
1397 const firstPt = new THREE.Vector3(vertices[0], vertices[1], vertices[2]);
1398 pathDistance += targetPos.distanceTo(firstPt);
1399 // Store Last Point
1400 const lastIdx = vertices.length - 3;
1401 lastPathPoint = new THREE.Vector3(vertices[lastIdx], vertices[lastIdx+1], vertices[lastIdx+2]);
1402 }
1403 // Add segments
1404 for(let i=0; i<vertices.length-3; i+=3) {
1405 const p1 = new THREE.Vector3(vertices[i], vertices[i+1], vertices[i+2]);
1406 const p2 = new THREE.Vector3(vertices[i+3], vertices[i+4], vertices[i+5]);
1407 pathDistance += p1.distanceTo(p2);
1408 }
1409
1410 const geo = new THREE.BufferGeometry();
1411 geo.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
1412 plannedPathLine = new THREE.Line(geo, new THREE.LineBasicMaterial({ color: 0xeeff00, linewidth: 4 }));
1413 scene.add(plannedPathLine);
1414 }
1415 }
1416
1417 function updateWaypoints(buffer) {
1418 while(waypointGroup.children.length > 0){
1419 waypointGroup.remove(waypointGroup.children[0]);
1420 }
1421 markerLayer.innerHTML = '';
1422 activeWaypoints = [];
1423
1424 const view = new DataView(buffer);
1425 const count = view.getUint32(0, true);
1426 if(count === 0) return;
1427
1428 let offset = 4;
1429 const beaconGeo = new THREE.BoxGeometry(0.5, 10000, 0.5);
1430
1431 for(let i=0; i<count; i++) {
1432 const x = view.getFloat32(offset, true);
1433 const y = view.getFloat32(offset+4, true);
1434 const z = view.getFloat32(offset+8, true);
1435 const type = view.getInt32(offset+12, true);
1436 offset += 16;
1437
1438 const col = typeColors[type] || typeColors[7];
1439 const beaconMat = new THREE.MeshBasicMaterial({
1440 color: col,
1441 transparent: true,
1442 opacity: 0.3,
1443 depthTest: false
1444 });
1445 const beacon = new THREE.Mesh(beaconGeo, beaconMat);
1446 beacon.position.set(x, 0, -z);
1447 waypointGroup.add(beacon);
1448
1449 const div = document.createElement('div');
1450 div.className = 'hud-marker';
1451 div.innerText = typeNames[type] || "UNK";
1452 div.style.borderColor = "#" + col.getHexString();
1453 markerLayer.appendChild(div);
1454
1455 activeWaypoints.push({
1456 div: div,
1457 pos: new THREE.Vector3(x, 0, -z)
1458 });
1459 }
1460 }
1461
1462 function updateDetections(buffer) {
1463 while(detectionGroup.children.length > 0){
1464 detectionGroup.remove(detectionGroup.children[0]);
1465 }
1466
1467 const view = new DataView(buffer);
1468 const count = view.getUint32(0, true);
1469 if(count === 0) return;
1470
1471 let offset = 4;
1472 // Use a simple circle texture
1473 const canvas = document.createElement('canvas');
1474 canvas.width = 32; canvas.height = 32;
1475 const ctx = canvas.getContext('2d');
1476 ctx.beginPath();
1477 ctx.arc(16,16,14,0,2*Math.PI);
1478 ctx.fillStyle = 'white';
1479 ctx.fill();
1480 const tex = new THREE.CanvasTexture(canvas);
1481
1482 for(let i=0; i<count; i++) {
1483 const x = view.getFloat32(offset, true);
1484 const y = view.getFloat32(offset+4, true);
1485 const z = view.getFloat32(offset+8, true);
1486 const type = view.getInt32(offset+12, true);
1487 offset += 16;
1488
1489 let col = "#ffffff";
1490 if(detectColors[type]) col = detectColors[type].color;
1491
1492 const mat = new THREE.PointsMaterial({
1493 color: col,
1494 map: tex,
1495 size: 2.0, // Large persistent dot
1496 sizeAttenuation: true,
1497 alphaTest: 0.5,
1498 transparent: true
1499 });
1500 const geo = new THREE.BufferGeometry();
1501 geo.setAttribute('position', new THREE.Float32BufferAttribute([x, y, -z], 3));
1502
1503 const pt = new THREE.Points(geo, mat);
1504 detectionGroup.add(pt);
1505 }
1506 }
1507
1508 function updateHUD() {
1509 const width = window.innerWidth;
1510 const height = window.innerHeight;
1511 const pad = 30;
1512
1513 // Update ETA Box
1514 const etaBox = document.getElementById('eta-box');
1515
1516 // Reached End Logic
1517 let bReached = false;
1518 if (lastPathPoint && roverMesh.position.distanceTo(lastPathPoint) < 2.0) {
1519 bReached = true;
1520 }
1521
1522 if (bReached) {
1523 etaBox.innerText = "Status: Reached End of Path";
1524 etaBox.style.color = "#00ff00"; // Green
1525 } else {
1526 etaBox.style.color = "#0f0"; // Default Green
1527 if (avgSpeed < 0.05) {
1528 etaBox.innerText = "ETA: Stopped";
1529 } else {
1530 const timeSec = pathDistance / avgSpeed;
1531 if (!isFinite(timeSec) || timeSec < 0) {
1532 etaBox.innerText = "ETA: --:--";
1533 } else {
1534 const min = Math.floor(timeSec / 60);
1535 const sec = Math.floor(timeSec % 60);
1536 etaBox.innerText = `ETA: ${min}m ${sec}s (${avgSpeed.toFixed(2)} m/s)`;
1537 }
1538 }
1539 }
1540
1541 activeWaypoints.forEach(wp => {
1542 const target = wp.pos.clone();
1543 target.y = roverMesh.position.y + 3.0;
1544 target.project(camera);
1545
1546 let x = (target.x * .5 + .5) * width;
1547 let y = (target.y * -.5 + .5) * height;
1548
1549 const isBehind = target.z > 1;
1550
1551 if (isBehind) {
1552 x = width - x;
1553 y = height - y;
1554 }
1555
1556 const cx = width / 2;
1557 const cy = height / 2;
1558 const dx = x - cx;
1559 const dy = y - cy;
1560
1561 if (!isBehind && x >= pad && x <= width - pad && y >= pad && y <= height - pad) {
1562 y -= 40;
1563 wp.div.style.opacity = "0.9";
1564 } else {
1565 let t = Infinity;
1566 if (dx > 0) t = Math.min(t, (width - pad - cx) / dx);
1567 if (dx < 0) t = Math.min(t, (pad - cx) / dx);
1568 if (dy > 0) t = Math.min(t, (height - pad - cy) / dy);
1569 if (dy < 0) t = Math.min(t, (pad - cy) / dy);
1570
1571 x = cx + dx * t;
1572 y = cy + dy * t;
1573 wp.div.style.opacity = "0.6";
1574 }
1575
1576 wp.div.style.left = x + 'px';
1577 wp.div.style.top = y + 'px';
1578
1579 // Use 2D horizontal distance for distance display.
1580 const dxPos = roverMesh.position.x - wp.pos.x;
1581 const dzPos = roverMesh.position.z - wp.pos.z;
1582 const dist = Math.sqrt(dxPos*dxPos + dzPos*dzPos);
1583
1584 wp.div.innerText = `${wp.div.innerText.split(' ')[0]} ${Math.round(dist)}m`;
1585 });
1586 }
1587
1588 function checkBoundary(force) {
1589 if(isFetchingMap && !force) return;
1590 const roverX = targetPos.x;
1591 const roverN = -targetPos.z;
1592 const distE = Math.abs(roverX - mapCenter.x);
1593 const distN = Math.abs(roverN - mapCenter.y);
1594 const limit = cfgRadius - cfgTolerance;
1595
1596 if (force || distE > limit || distN > limit) {
1597 fetchMapSquare(roverX, roverN);
1598 }
1599 }
1600
1601 async function fetchMapSquare(x, y) {
1602 if(isFetchingMap) return;
1603 isFetchingMap = true;
1604 try {
1605 const url = `/api/map?x=${x.toFixed(2)}&y=${y.toFixed(2)}&r=${cfgRadius}&s=${cfgMinScore}`;
1606 const response = await fetch(url);
1607 const buffer = await response.arrayBuffer();
1608 loadMapPoints(buffer);
1609 mapCenter = { x: x, y: y };
1610 } catch(e) { console.error(e); }
1611 isFetchingMap = false;
1612 }
1613
1614 function loadMapPoints(buffer) {
1615 const view = new DataView(buffer);
1616 const count = view.getUint32(0, true);
1617
1618 if(currentPoints) {
1619 scene.remove(currentPoints);
1620 currentPoints.geometry.dispose();
1621 currentPoints.material.dispose();
1622 currentPoints = null;
1623 }
1624
1625 if(count === 0) {
1626 document.getElementById('stats').innerText = "Points: 0";
1627 return;
1628 }
1629
1630 const pts = [];
1631 const colors = [];
1632 const c = new THREE.Color();
1633 const floats = new Float32Array(buffer, 4, count * 4);
1634
1635 for(let i=0; i<floats.length; i+=4) {
1636 pts.push(floats[i], floats[i+1], -floats[i+2]);
1637 c.setHSL(floats[i+3] * 0.33, 1.0, 0.5);
1638 colors.push(c.r, c.g, c.b);
1639 }
1640
1641 const geo = new THREE.BufferGeometry();
1642 geo.setAttribute('position', new THREE.Float32BufferAttribute(pts, 3));
1643 geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
1644 const mat = new THREE.PointsMaterial({ size: 0.5, vertexColors: true });
1645
1646 currentPoints = new THREE.Points(geo, mat);
1647 scene.add(currentPoints);
1648 document.getElementById('stats').innerText = "Points: " + count;
1649 }
1650
1651 function onKey(e, p) {
1652 if(keys.hasOwnProperty(e.key.toLowerCase())) keys[e.key.toLowerCase()] = p;
1653 if(e.key === 'Shift') keys.shift = p;
1654 if(p && e.key==='f') window.toggleFollow();
1655 if(p && e.key===' ') window.snapToRover();
1656 }
1657 function onWindowResize() { camera.aspect = window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }
1658
1659 function animate() {
1660 requestAnimationFrame(animate);
1661 const now = performance.now();
1662 const dt = (now - lastTime)/1000;
1663 lastTime = now;
1664
1665 prevRoverPos.copy(roverMesh.position);
1666 const lerpFactor = 5.0 * dt;
1667 roverMesh.position.lerp(targetPos, lerpFactor);
1668 const dRot = targetHeading - roverMesh.rotation.y;
1669 roverMesh.rotation.y += Math.atan2(Math.sin(dRot), Math.cos(dRot)) * lerpFactor;
1670
1671 if(isFollowing) {
1672 camera.position.add(new THREE.Vector3().subVectors(roverMesh.position, prevRoverPos));
1673 controls.target.copy(roverMesh.position);
1674 } else {
1675 const spd = (keys.shift?15:5)*dt;
1676 const fwd = new THREE.Vector3(); camera.getWorldDirection(fwd); fwd.y=0; fwd.normalize();
1677 const rgt = new THREE.Vector3().crossVectors(fwd, camera.up).normalize();
1678 if(keys.w) camera.position.addScaledVector(fwd, spd);
1679 if(keys.s) camera.position.addScaledVector(fwd, -spd);
1680 if(keys.d) camera.position.addScaledVector(rgt, spd);
1681 if(keys.a) camera.position.addScaledVector(rgt, -spd);
1682 if(keys.q) camera.position.y += spd;
1683 if(keys.e) camera.position.y -= spd;
1684 controls.target.add(new THREE.Vector3(0,0,0).addScaledVector(fwd, (keys.w-keys.s)*spd).addScaledVector(rgt, (keys.d-keys.a)*spd));
1685 }
1686 controls.update();
1687
1688 updateHUD();
1689
1690 renderer.render(scene, camera);
1691 }
1692</script>
1693</body>
1694</html>
1695 )RAW_HTML";
1696}