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:
@@ -0,0 +1,132 @@
|
||||
import { startStatusSocket } from "/js/status-panel.js";
|
||||
import { buildCameraGrid, setPreviewActive } from "/js/camera-preview.js";
|
||||
import { buildCameraTabs } from "/js/camera-settings.js";
|
||||
import { initRecordingPanel } from "/js/recording-panel.js";
|
||||
import { initLogPanel } from "/js/log-panel.js";
|
||||
import { initPointcloudView } from "/js/pointcloud-view.js";
|
||||
|
||||
async function getJSON(url, opts) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`${url}: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const OVERLAY_IDS = ["overlay-settings", "overlay-recording", "overlay-log"];
|
||||
let pointcloudView = null;
|
||||
|
||||
function anyOverlayOpen() {
|
||||
return OVERLAY_IDS.some((id) => !document.getElementById(id).classList.contains("hidden"));
|
||||
}
|
||||
// The point-cloud render loop keeps running (cheap-ish) but there's no
|
||||
// reason to keep painting frames the operator can't see behind a full-screen
|
||||
// overlay — pause on open, resume once every overlay is closed.
|
||||
function syncPointcloudPaused() {
|
||||
if (pointcloudView) pointcloudView.setPaused(anyOverlayOpen());
|
||||
}
|
||||
|
||||
function openOverlay(id) {
|
||||
document.getElementById(id).classList.remove("hidden");
|
||||
syncPointcloudPaused();
|
||||
}
|
||||
function closeOverlay(id) {
|
||||
document.getElementById(id).classList.add("hidden");
|
||||
syncPointcloudPaused();
|
||||
}
|
||||
|
||||
// Camera MJPEG only needs to stream while its tab is actually visible inside
|
||||
// the open Settings overlay — otherwise it's wasted phone battery/bandwidth
|
||||
// behind a closed overlay (same reasoning M3's point-cloud stream will need
|
||||
// for the scan view itself).
|
||||
function syncPreviewActive() {
|
||||
const overlaySettings = document.getElementById("overlay-settings");
|
||||
const previewPane = document.getElementById("pane-camera-preview");
|
||||
const active = !overlaySettings.classList.contains("hidden") && previewPane.classList.contains("active");
|
||||
setPreviewActive(active);
|
||||
}
|
||||
|
||||
function setupOverlays() {
|
||||
document.querySelectorAll("[data-close]").forEach((el) => {
|
||||
el.onclick = () => {
|
||||
closeOverlay(el.dataset.close);
|
||||
syncPreviewActive();
|
||||
};
|
||||
});
|
||||
|
||||
document.getElementById("btn-settings").onclick = () => {
|
||||
openOverlay("overlay-settings");
|
||||
syncPreviewActive();
|
||||
};
|
||||
document.getElementById("btn-rec-open").onclick = () => openOverlay("overlay-recording");
|
||||
document.getElementById("btn-open-log").onclick = () => openOverlay("overlay-log");
|
||||
}
|
||||
|
||||
function setupPaneTabs() {
|
||||
const btns = document.querySelectorAll(".pane-tab-btn[data-pane]");
|
||||
btns.forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
btns.forEach((b) => b.classList.remove("active"));
|
||||
document.querySelectorAll(".pane-tab").forEach((p) => p.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
document.getElementById(btn.dataset.pane).classList.add("active");
|
||||
syncPreviewActive();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
setupOverlays();
|
||||
setupPaneTabs();
|
||||
pointcloudView = initPointcloudView();
|
||||
|
||||
const status = await getJSON("/api/status");
|
||||
await buildCameraGrid(status.cameras);
|
||||
await buildCameraTabs(status.cameras);
|
||||
|
||||
initRecordingPanel(status.default_save_dir);
|
||||
initLogPanel();
|
||||
|
||||
document.getElementById("btn-lidar-start").onclick = async () => {
|
||||
await getJSON("/api/lidar/start", { method: "POST" });
|
||||
};
|
||||
document.getElementById("btn-lidar-stop").onclick = async () => {
|
||||
await getJSON("/api/lidar/stop", { method: "POST" });
|
||||
};
|
||||
document.getElementById("btn-estop").onclick = async () => {
|
||||
if (!confirm("모든 프로세스(LiDAR/카메라/녹화)를 즉시 정지합니다. 계속할까요?")) return;
|
||||
await getJSON("/api/system/estop", { method: "POST" });
|
||||
};
|
||||
document.getElementById("btn-fastlio-test-start").onclick = async () => {
|
||||
try {
|
||||
await getJSON("/api/fastlio/start", { method: "POST" });
|
||||
} catch (e) {
|
||||
alert(String(e));
|
||||
}
|
||||
};
|
||||
document.getElementById("btn-fastlio-test-stop").onclick = async () => {
|
||||
try {
|
||||
await getJSON("/api/fastlio/stop", { method: "POST" });
|
||||
} catch (e) {
|
||||
alert(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
const btnViewOrbit = document.getElementById("btn-view-orbit");
|
||||
const btnViewLidar = document.getElementById("btn-view-lidar");
|
||||
btnViewOrbit.onclick = () => {
|
||||
pointcloudView.setViewMode("orbit");
|
||||
btnViewOrbit.className = "btn btn-blue";
|
||||
btnViewLidar.className = "btn btn-gray";
|
||||
};
|
||||
btnViewLidar.onclick = () => {
|
||||
pointcloudView.setViewMode("lidar");
|
||||
btnViewLidar.className = "btn btn-blue";
|
||||
btnViewOrbit.className = "btn btn-gray";
|
||||
};
|
||||
|
||||
startStatusSocket();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
alert("초기화 실패: " + e);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// Builds the MJPEG camera preview grid. One <img> per camera, no client JS
|
||||
// decoding needed — the browser natively renders multipart MJPEG streams.
|
||||
|
||||
export function buildCameraGrid(cameras) {
|
||||
const grid = document.getElementById("camera-grid");
|
||||
grid.innerHTML = "";
|
||||
for (const cam of cameras) {
|
||||
const pane = document.createElement("div");
|
||||
pane.className = "camera-pane";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "cam-title";
|
||||
title.textContent = `${cam.id} (${cam.topic})`;
|
||||
pane.appendChild(title);
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.dataset.camId = cam.id;
|
||||
img.alt = cam.id;
|
||||
pane.appendChild(img);
|
||||
|
||||
grid.appendChild(pane);
|
||||
}
|
||||
}
|
||||
|
||||
export function setPreviewActive(active) {
|
||||
document.querySelectorAll("#camera-grid img").forEach((img) => {
|
||||
if (active) {
|
||||
if (!img.src) img.src = `/api/camera/${img.dataset.camId}/stream.mjpg?t=${Date.now()}`;
|
||||
} else {
|
||||
img.removeAttribute("src");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Per-camera exposure/gain settings tabs — mirrors the QTabWidget layout in
|
||||
// scan_gui_triple.py (auto-exposure toggle, brightness/exp_min/exp_max vs.
|
||||
// exp_time mutually exclusive groups, gain; Apply = live ros2 param set,
|
||||
// Save = persist into the base YAML).
|
||||
|
||||
async function getJSON(url, opts) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`${url}: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function field(labelText, id, type, step) {
|
||||
const wrap = document.createDocumentFragment();
|
||||
const label = document.createElement("label");
|
||||
label.textContent = labelText;
|
||||
label.htmlFor = id;
|
||||
const input = document.createElement("input");
|
||||
input.type = type;
|
||||
input.id = id;
|
||||
if (step) input.step = step;
|
||||
wrap.appendChild(label);
|
||||
wrap.appendChild(input);
|
||||
return { wrap, input };
|
||||
}
|
||||
|
||||
export async function buildCameraTabs(cameras) {
|
||||
const tabBar = document.getElementById("camera-tabs");
|
||||
const panelsRoot = document.getElementById("camera-tab-panels");
|
||||
tabBar.innerHTML = "";
|
||||
panelsRoot.innerHTML = "";
|
||||
|
||||
for (let i = 0; i < cameras.length; i++) {
|
||||
const cam = cameras[i];
|
||||
const tabBtn = document.createElement("button");
|
||||
tabBtn.className = "tab-btn" + (i === 0 ? " active" : "");
|
||||
tabBtn.textContent = cam.id;
|
||||
tabBtn.onclick = () => {
|
||||
document.querySelectorAll(".tab-btn").forEach((b) => b.classList.remove("active"));
|
||||
document.querySelectorAll(".tab-panel").forEach((p) => p.classList.remove("active"));
|
||||
tabBtn.classList.add("active");
|
||||
document.getElementById(`cam-panel-${cam.id}`).classList.add("active");
|
||||
};
|
||||
tabBar.appendChild(tabBtn);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "tab-panel" + (i === 0 ? " active" : "");
|
||||
panel.id = `cam-panel-${cam.id}`;
|
||||
panelsRoot.appendChild(panel);
|
||||
|
||||
await buildCameraPanel(panel, cam.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildCameraPanel(panel, camId) {
|
||||
const values = await getJSON(`/api/camera/${camId}/params`);
|
||||
|
||||
const autoLabel = document.createElement("label");
|
||||
const autoChk = document.createElement("input");
|
||||
autoChk.type = "checkbox";
|
||||
autoChk.checked = values.exposure_auto;
|
||||
autoLabel.appendChild(autoChk);
|
||||
autoLabel.appendChild(document.createTextNode(" 자동 노출"));
|
||||
panel.appendChild(autoLabel);
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "cam-field-grid";
|
||||
panel.appendChild(grid);
|
||||
|
||||
const fBrightness = field("목표 밝기:", `${camId}-brightness`, "number");
|
||||
fBrightness.input.min = 0; fBrightness.input.max = 255; fBrightness.input.value = values.exposure_auto_target_brightness;
|
||||
const fExpMin = field("노출 하한 (us):", `${camId}-exp-min`, "number");
|
||||
fExpMin.input.min = 10; fExpMin.input.max = 1000000; fExpMin.input.step = 100; fExpMin.input.value = values.exposure_auto_min;
|
||||
const fExpMax = field("노출 상한 (us):", `${camId}-exp-max`, "number");
|
||||
fExpMax.input.min = 10; fExpMax.input.max = 1000000; fExpMax.input.step = 1000; fExpMax.input.value = values.exposure_auto_max;
|
||||
const fExpTime = field("노출 시간 (us):", `${camId}-exp-time`, "number");
|
||||
fExpTime.input.min = 10; fExpTime.input.max = 1000000; fExpTime.input.value = values.exposure_time;
|
||||
const fGain = field("게인 (dB):", `${camId}-gain`, "number");
|
||||
fGain.input.min = 0; fGain.input.max = 16.9; fGain.input.step = 0.5; fGain.input.value = values.gain;
|
||||
|
||||
for (const f of [fBrightness, fExpMin, fExpMax, fExpTime, fGain]) grid.appendChild(f.wrap);
|
||||
|
||||
const autoWidgets = [fBrightness.input, fExpMin.input, fExpMax.input];
|
||||
const manualWidgets = [fExpTime.input];
|
||||
|
||||
function applyToggleState(checked) {
|
||||
autoWidgets.forEach((w) => (w.disabled = !checked));
|
||||
manualWidgets.forEach((w) => (w.disabled = checked));
|
||||
}
|
||||
autoChk.onchange = () => applyToggleState(autoChk.checked);
|
||||
applyToggleState(values.exposure_auto);
|
||||
|
||||
const btnRow = document.createElement("div");
|
||||
btnRow.className = "btn-row";
|
||||
const btnApply = document.createElement("button");
|
||||
btnApply.className = "btn btn-blue";
|
||||
btnApply.textContent = "적용";
|
||||
const btnSave = document.createElement("button");
|
||||
btnSave.className = "btn btn-green";
|
||||
btnSave.textContent = "저장";
|
||||
btnRow.appendChild(btnApply);
|
||||
btnRow.appendChild(btnSave);
|
||||
panel.appendChild(btnRow);
|
||||
|
||||
const result = document.createElement("div");
|
||||
result.className = "cam-result";
|
||||
panel.appendChild(result);
|
||||
|
||||
function collect() {
|
||||
return {
|
||||
exposure_auto: autoChk.checked,
|
||||
exposure_auto_target_brightness: parseInt(fBrightness.input.value, 10),
|
||||
exposure_auto_min: parseFloat(fExpMin.input.value),
|
||||
exposure_auto_max: parseFloat(fExpMax.input.value),
|
||||
exposure_time: parseInt(fExpTime.input.value, 10),
|
||||
gain: parseFloat(fGain.input.value),
|
||||
};
|
||||
}
|
||||
|
||||
btnApply.onclick = async () => {
|
||||
result.textContent = "적용 중…";
|
||||
try {
|
||||
await getJSON(`/api/camera/${camId}/params`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect()),
|
||||
});
|
||||
const r = await getJSON(`/api/camera/${camId}/params/apply`, { method: "POST" });
|
||||
result.textContent = r.ok ? "적용 완료" : r.results.filter(x => !x.ok).map(x => `${x.name}: ${x.message}`).join(" / ");
|
||||
result.style.color = r.ok ? "var(--color-success)" : "var(--color-danger)";
|
||||
} catch (e) {
|
||||
result.textContent = String(e);
|
||||
result.style.color = "var(--color-danger)";
|
||||
}
|
||||
};
|
||||
|
||||
btnSave.onclick = async () => {
|
||||
result.textContent = "저장 중…";
|
||||
try {
|
||||
await getJSON(`/api/camera/${camId}/params`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(collect()),
|
||||
});
|
||||
const r = await getJSON(`/api/camera/${camId}/params/save`, { method: "POST" });
|
||||
result.textContent = `저장됨 → ${r.path}`;
|
||||
result.style.color = "var(--color-success)";
|
||||
} catch (e) {
|
||||
result.textContent = String(e);
|
||||
result.style.color = "var(--color-danger)";
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function formatBytes(bytes) {
|
||||
if (bytes == null) return "";
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function formatElapsed(seconds) {
|
||||
if (seconds == null) return "";
|
||||
const s = Math.floor(seconds);
|
||||
const m = Math.floor(s / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return h > 0 ? `${h}:${pad(m % 60)}:${pad(s % 60)}` : `${pad(m)}:${pad(s % 60)}`;
|
||||
}
|
||||
|
||||
export function formatDuration(seconds) {
|
||||
if (seconds == null) return "—";
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`;
|
||||
return formatElapsed(seconds);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Unified [tag] log scrollback — consumes /ws/logs (backlog once, then new
|
||||
// lines as they're appended to lidar.log/camera.log/recording.log).
|
||||
|
||||
const MAX_RENDERED_LINES = 2000;
|
||||
|
||||
export function initLogPanel() {
|
||||
const body = document.getElementById("log-body");
|
||||
const autoscrollChk = document.getElementById("log-autoscroll");
|
||||
const clearBtn = document.getElementById("btn-log-clear");
|
||||
|
||||
function appendLine({ tag, line }) {
|
||||
const row = document.createElement("div");
|
||||
row.className = `log-line tag-${tag}`;
|
||||
const tagSpan = document.createElement("span");
|
||||
tagSpan.className = "log-tag";
|
||||
tagSpan.textContent = `[${tag}]`;
|
||||
const textSpan = document.createElement("span");
|
||||
textSpan.className = "log-text";
|
||||
textSpan.textContent = line;
|
||||
row.appendChild(tagSpan);
|
||||
row.appendChild(textSpan);
|
||||
body.appendChild(row);
|
||||
while (body.childElementCount > MAX_RENDERED_LINES) {
|
||||
body.removeChild(body.firstChild);
|
||||
}
|
||||
if (autoscrollChk.checked) {
|
||||
body.scrollTop = body.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
clearBtn.onclick = () => { body.innerHTML = ""; };
|
||||
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
function connect() {
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/logs`);
|
||||
ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.backlog) msg.backlog.forEach(appendLine);
|
||||
if (msg.lines) msg.lines.forEach(appendLine);
|
||||
};
|
||||
ws.onclose = () => setTimeout(connect, 1500);
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
connect();
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// M3 live scan view — consumes /ws/pointcloud (see backend/pointcloud_codec.py
|
||||
// for the exact binary layout) and renders into #pointcloud-canvas with
|
||||
// three.js. One reused BufferGeometry with pre-allocated typed arrays,
|
||||
// overwritten in place per frame (needsUpdate=true) to avoid GC churn at
|
||||
// the stream's 3-5Hz.
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "/vendor/three/OrbitControls.js";
|
||||
|
||||
const MAGIC = "PCF1";
|
||||
|
||||
// Must match backend config.py POINTCLOUD_PROFILES max_points — sizes the
|
||||
// pre-allocated buffers, so it can't just read the count off the wire.
|
||||
const PROFILE_MAX_POINTS = { desktop: 20000, phone: 7000 };
|
||||
|
||||
function pickProfile() {
|
||||
return window.innerWidth < 768 ? "phone" : "desktop";
|
||||
}
|
||||
|
||||
export function initPointcloudView() {
|
||||
const canvas = document.getElementById("pointcloud-canvas");
|
||||
const emptyState = document.getElementById("scan-empty");
|
||||
const profile = pickProfile();
|
||||
const maxPoints = PROFILE_MAX_POINTS[profile];
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0e14);
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, 1, 0.05, 500);
|
||||
// FAST-LIO's "camera_init" world frame is z-up (LiDAR convention), not
|
||||
// three.js's default y-up — set the camera's up vector to match so
|
||||
// OrbitControls orbits around the right axis.
|
||||
camera.up.set(0, 0, 1);
|
||||
camera.position.set(0, -8, 5);
|
||||
|
||||
const controls = new OrbitControls(camera, canvas);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.08;
|
||||
|
||||
const grid = new THREE.GridHelper(40, 40, 0x2a3040, 0x1a1f28);
|
||||
grid.rotation.x = Math.PI / 2; // GridHelper defaults to the XZ plane (y-up); rotate into XY (z-up)
|
||||
scene.add(grid);
|
||||
|
||||
const positions = new Float32Array(maxPoints * 3);
|
||||
const colors = new Float32Array(maxPoints * 3);
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
geometry.setDrawRange(0, 0);
|
||||
const material = new THREE.PointsMaterial({ size: 0.035, vertexColors: true, sizeAttenuation: true });
|
||||
scene.add(new THREE.Points(geometry, material));
|
||||
|
||||
// Small marker at the current sensor pose (position + heading).
|
||||
const poseGizmo = new THREE.Mesh(
|
||||
new THREE.ConeGeometry(0.15, 0.4, 12),
|
||||
new THREE.MeshBasicMaterial({ color: 0x5b8cff }),
|
||||
);
|
||||
poseGizmo.rotation.x = Math.PI / 2; // ConeGeometry points +y by default; point it along +x (forward) instead
|
||||
poseGizmo.visible = false;
|
||||
scene.add(poseGizmo);
|
||||
|
||||
let hasCentered = false;
|
||||
let running = true;
|
||||
let paused = false;
|
||||
let viewMode = "orbit"; // or "lidar"
|
||||
const latestPose = { position: new THREE.Vector3(), quaternion: new THREE.Quaternion(), valid: false };
|
||||
const lookTarget = new THREE.Vector3();
|
||||
const FORWARD = new THREE.Vector3(1, 0, 0); // body-frame +x — matches the poseGizmo's forward convention below
|
||||
|
||||
function resize() {
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
if (w === 0 || h === 0) return;
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
new ResizeObserver(resize).observe(canvas);
|
||||
resize();
|
||||
|
||||
function updateCameraForLidarView() {
|
||||
if (!latestPose.valid) return;
|
||||
camera.position.copy(latestPose.position);
|
||||
lookTarget.copy(FORWARD).applyQuaternion(latestPose.quaternion).add(latestPose.position);
|
||||
camera.up.set(0, 0, 1);
|
||||
camera.lookAt(lookTarget);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
if (!running) return;
|
||||
requestAnimationFrame(animate);
|
||||
if (paused) return;
|
||||
if (viewMode === "lidar") {
|
||||
updateCameraForLidarView();
|
||||
} else {
|
||||
controls.update();
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
|
||||
function applyFrame(buf) {
|
||||
const view = new DataView(buf);
|
||||
if (String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)) !== MAGIC) return;
|
||||
let off = 4;
|
||||
off += 4; // frame_seq — unused client-side for now
|
||||
off += 8; // timestamp
|
||||
const poseFlag = view.getUint32(off, true); off += 4;
|
||||
if (poseFlag) {
|
||||
const x = view.getFloat32(off, true); const y = view.getFloat32(off + 4, true); const z = view.getFloat32(off + 8, true);
|
||||
const qx = view.getFloat32(off + 12, true), qy = view.getFloat32(off + 16, true),
|
||||
qz = view.getFloat32(off + 20, true), qw = view.getFloat32(off + 24, true);
|
||||
off += 28;
|
||||
poseGizmo.position.set(x, y, z);
|
||||
poseGizmo.quaternion.set(qx, qy, qz, qw);
|
||||
poseGizmo.visible = true;
|
||||
latestPose.position.set(x, y, z);
|
||||
latestPose.quaternion.set(qx, qy, qz, qw);
|
||||
latestPose.valid = true;
|
||||
if (!hasCentered) {
|
||||
controls.target.set(x, y, z);
|
||||
camera.position.set(x, y - 8, z + 5);
|
||||
hasCentered = true;
|
||||
}
|
||||
}
|
||||
const n = view.getUint32(off, true); off += 4;
|
||||
|
||||
const count = Math.min(n, maxPoints);
|
||||
positions.set(new Float32Array(buf, off, count * 3));
|
||||
const intensity = new Uint8Array(buf, off + n * 3 * 4, count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = intensity[i] / 255;
|
||||
// Cool-to-warm ramp by intensity — no camera colour to draw from
|
||||
// (LiDAR-only), so intensity is the only per-point signal worth encoding.
|
||||
colors[i * 3] = 0.15 + 0.65 * t;
|
||||
colors[i * 3 + 1] = 0.35 + 0.35 * (1 - Math.abs(t - 0.5) * 2);
|
||||
colors[i * 3 + 2] = 0.75 - 0.55 * t;
|
||||
}
|
||||
geometry.attributes.position.needsUpdate = true;
|
||||
geometry.attributes.color.needsUpdate = true;
|
||||
geometry.setDrawRange(0, count);
|
||||
|
||||
emptyState.classList.toggle("hidden", count > 0);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(`${proto}//${location.host}/ws/pointcloud?profile=${profile}`);
|
||||
ws.binaryType = "arraybuffer";
|
||||
ws.onmessage = (ev) => applyFrame(ev.data);
|
||||
ws.onclose = () => { if (running) setTimeout(connect, 1500); };
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
connect();
|
||||
|
||||
return {
|
||||
setPaused(p) { paused = p; },
|
||||
// "orbit" — free OrbitControls camera (default). "lidar" — camera locked
|
||||
// to the sensor's live position/heading, first-person. Disabling
|
||||
// OrbitControls in lidar mode (not just skipping controls.update()) stops
|
||||
// it from silently accumulating drag input it never gets to apply, which
|
||||
// would otherwise jump the view on switching back to orbit.
|
||||
setViewMode(mode) {
|
||||
viewMode = mode;
|
||||
controls.enabled = mode === "orbit";
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { formatBytes, formatDuration } from "/js/format.js";
|
||||
|
||||
async function getJSON(url, opts) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`${url}: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function initRecordingPanel(defaultSaveDir) {
|
||||
const saveDirInput = document.getElementById("rec-save-dir");
|
||||
saveDirInput.value = defaultSaveDir;
|
||||
|
||||
const browseBtn = document.getElementById("btn-browse");
|
||||
const browsePanel = document.getElementById("browse-panel");
|
||||
const filenameInput = document.getElementById("rec-filename");
|
||||
const confirmBtn = document.getElementById("btn-rec-confirm");
|
||||
const stopBtn = document.getElementById("btn-rec-stop");
|
||||
const bagList = document.getElementById("bag-list");
|
||||
|
||||
browseBtn.onclick = async () => {
|
||||
if (!browsePanel.classList.contains("hidden")) {
|
||||
browsePanel.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
await renderBrowse(saveDirInput.value || defaultSaveDir);
|
||||
browsePanel.classList.remove("hidden");
|
||||
};
|
||||
|
||||
async function renderBrowse(path) {
|
||||
const data = await getJSON(`/api/fs/browse?path=${encodeURIComponent(path)}`);
|
||||
browsePanel.innerHTML = "";
|
||||
const cur = document.createElement("div");
|
||||
cur.className = "dir-entry";
|
||||
cur.style.fontWeight = "bold";
|
||||
cur.textContent = `✓ 선택: ${data.path}`;
|
||||
cur.onclick = () => {
|
||||
saveDirInput.value = data.path;
|
||||
browsePanel.classList.add("hidden");
|
||||
refreshBags();
|
||||
};
|
||||
browsePanel.appendChild(cur);
|
||||
if (data.parent) {
|
||||
const up = document.createElement("div");
|
||||
up.className = "dir-entry";
|
||||
up.textContent = "..";
|
||||
up.onclick = () => renderBrowse(data.parent);
|
||||
browsePanel.appendChild(up);
|
||||
}
|
||||
for (const d of data.dirs) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "dir-entry";
|
||||
el.textContent = d.name;
|
||||
el.onclick = () => renderBrowse(d.path);
|
||||
browsePanel.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
saveDirInput.addEventListener("change", refreshBags);
|
||||
|
||||
confirmBtn.onclick = async () => {
|
||||
try {
|
||||
await getJSON("/api/recording/start", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename: filenameInput.value, save_dir: saveDirInput.value }),
|
||||
});
|
||||
refreshBags();
|
||||
document.getElementById("overlay-recording").classList.add("hidden");
|
||||
} catch (e) {
|
||||
alert(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
stopBtn.onclick = async () => {
|
||||
await getJSON("/api/recording/stop", { method: "POST" });
|
||||
// rosbag2 flushes metadata.yaml on SIGINT — give it a moment before
|
||||
// reading the bag back, otherwise it still shows as "incomplete".
|
||||
setTimeout(refreshBags, 1200);
|
||||
};
|
||||
|
||||
function renderBagItem(bag) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "bag-item";
|
||||
|
||||
const nameRow = document.createElement("div");
|
||||
nameRow.className = "bag-item-name";
|
||||
nameRow.textContent = bag.name;
|
||||
if (!bag.complete) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "bag-item-incomplete";
|
||||
badge.textContent = "(녹화 중 / 미완료)";
|
||||
nameRow.appendChild(badge);
|
||||
}
|
||||
li.appendChild(nameRow);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "bag-item-meta";
|
||||
const parts = [formatDuration(bag.duration_s), formatBytes(bag.size_bytes)];
|
||||
if (bag.message_count != null) parts.push(`msg ${bag.message_count.toLocaleString()}`);
|
||||
if (bag.topics && bag.topics.length) parts.push(`topic ${bag.topics.length}개`);
|
||||
meta.textContent = parts.filter(Boolean).join(" · ");
|
||||
li.appendChild(meta);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
// Disk-free is owned entirely by status-panel.js now (HUD strip, driven by
|
||||
// /ws/status) — this panel only needs the bag list.
|
||||
async function refreshBags() {
|
||||
const dir = saveDirInput.value || defaultSaveDir;
|
||||
try {
|
||||
const bagsData = await getJSON(`/api/bags?save_dir=${encodeURIComponent(dir)}`);
|
||||
bagList.innerHTML = "";
|
||||
for (const bag of bagsData.bags) {
|
||||
bagList.appendChild(renderBagItem(bag));
|
||||
}
|
||||
} catch (e) {
|
||||
// Directory may not exist yet (new save path) — not an error worth alarming over.
|
||||
bagList.innerHTML = "";
|
||||
}
|
||||
}
|
||||
refreshBags();
|
||||
setInterval(refreshBags, 15000);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user