Initial commit: scan_web integrated recording UI

FastAPI backend + vanilla JS frontend for LiDAR/camera startup, camera
settings, and rosbag recording, unifying the previous scan_gui/scan_gui_dual/
scan_gui_triple desktop tools into one web app.
This commit is contained in:
gardentech
2026-08-07 14:26:00 +09:00
commit f34817d5b6
38 changed files with 5430 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
// Renders the top-bar health badges + startup/recording state lines from /ws/status.
import { formatBytes, formatElapsed } from "/js/format.js";
function badgeClass(health) {
if (health === "OK") return "badge badge-ok";
if (health === "STALE") return "badge badge-stale";
return "badge badge-down";
}
// NBSP padding, not CSS min-width: regular spaces collapse in rendered
// text, so a guessed ch-width can still drift. Padding to a fixed character
// count (monospace font) makes each badge's pixel width identical on every
// tick regardless of health word or Hz digit count — nothing to reflow the
// row on mobile.
function padHealth(health) {
return health.padEnd(5, " ");
}
function formatHz(hz) {
return hz.toFixed(1).padStart(5, " ");
}
function makeBadge(label, health, hz) {
const b = document.createElement("span");
// badge-sensor: fixed CSS width (see style.css) — the NBSP padding above
// keeps internal jitter down, but the box's own size being fixed is what
// actually guarantees the position never moves, regardless of icon glyph
// width or font-metric rounding.
b.className = `${badgeClass(health)} badge-sensor`;
b.textContent = `${label} ${padHealth(health)}`;
const hzSpan = document.createElement("span");
hzSpan.className = "badge-hz";
hzSpan.textContent = health !== "DOWN" && hz > 0
? `${formatHz(hz)}Hz`
: `${" ".repeat(5)}Hz`;
b.appendChild(hzSpan);
return b;
}
export function startStatusSocket(onStatus) {
const proto = location.protocol === "https:" ? "wss:" : "ws:";
let ws;
function connect() {
ws = new WebSocket(`${proto}//${location.host}/ws/status`);
ws.onmessage = (ev) => {
const status = JSON.parse(ev.data);
render(status);
if (onStatus) onStatus(status);
};
ws.onclose = () => setTimeout(connect, 1500);
ws.onerror = () => ws.close();
}
connect();
}
function render(status) {
const badges = document.getElementById("status-badges");
badges.innerHTML = "";
badges.appendChild(makeBadge("LiDAR", status.lidar.health, status.lidar.hz));
badges.appendChild(makeBadge("IMU", status.lidar.imu_health, status.lidar.imu_hz));
badges.appendChild(makeBadge("MAP", status.map.health, status.map.hz));
for (const cam of status.cameras) {
badges.appendChild(makeBadge(cam.id, cam.health, cam.hz));
}
if (status.recording.active) {
const b = document.createElement("span");
b.className = "badge badge-rec";
b.textContent = "REC";
badges.appendChild(b);
}
const lidarState = document.getElementById("lidar-state");
if (lidarState) {
lidarState.textContent = status.lidar.running ? "● 실행 중" : "● 정지";
lidarState.className = "state-line " + (status.lidar.running ? "on" : "off");
}
const recPath = document.getElementById("rec-path");
if (recPath) {
recPath.textContent = status.recording.path ? `저장: ${status.recording.path}` : "";
}
const recMeter = document.getElementById("rec-meter");
if (recMeter) {
if (status.recording.active) {
recMeter.textContent = `${formatElapsed(status.recording.elapsed_s)} · ${formatBytes(status.recording.size_bytes)}`;
recMeter.classList.remove("hidden");
} else {
recMeter.classList.add("hidden");
}
}
// Always-on compact readout in the HUD — the backend already resolves the
// right path (DEFAULT_SAVE_DIR when idle, the live recording target while
// active), so this can just mirror status.disk unconditionally.
const diskEl = document.getElementById("disk-free");
if (diskEl && status.disk) {
const freeGb = status.disk.free_gb;
diskEl.textContent = `여유 ${freeGb.toFixed(1)}GB`;
diskEl.classList.remove("warning", "danger");
if (freeGb < status.disk.low_danger_gb) diskEl.classList.add("danger");
else if (freeGb < status.disk.low_warning_gb) diskEl.classList.add("warning");
}
const mapPointsEl = document.getElementById("map-points");
if (mapPointsEl && status.map) {
mapPointsEl.textContent = status.map.points > 0 ? `${status.map.points.toLocaleString()}pt` : "";
mapPointsEl.classList.toggle("hidden", status.map.points === 0);
}
document.getElementById("btn-lidar-start").disabled = status.lidar.running;
document.getElementById("btn-lidar-stop").disabled = !status.lidar.running;
// The record button swaps for a stop button once recording is live,
// rather than just disabling both — HUD real estate is tight on a phone.
const recOpenBtn = document.getElementById("btn-rec-open");
const recStopBtn = document.getElementById("btn-rec-stop");
recOpenBtn.classList.toggle("hidden", status.recording.active);
recStopBtn.classList.toggle("hidden", !status.recording.active);
recOpenBtn.disabled = !status.lidar.running;
const fastlioTestState = document.getElementById("fastlio-test-state");
const fastlioTestStart = document.getElementById("btn-fastlio-test-start");
const fastlioTestStop = document.getElementById("btn-fastlio-test-stop");
if (fastlioTestState && status.map) {
const running = !!(status.process_status && status.process_status.fastlio && status.process_status.fastlio.running);
fastlioTestState.textContent = running
? `● 실행 중 (${status.map.health} · ${status.map.hz.toFixed(1)}Hz · ${status.map.points.toLocaleString()}pt)`
: "● 정지";
fastlioTestState.className = "state-line " + (running ? "on" : "off");
fastlioTestStart.disabled = !status.lidar.running || running;
// Stopping while a real recording owns fastlio is rejected server-side
// (409) — disable client-side too so the test button can't fire that.
fastlioTestStop.disabled = !running || status.recording.active;
}
}