Files
gardentech f34817d5b6 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.
2026-08-07 14:26:00 +09:00

139 lines
5.6 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}