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
+25
View File
@@ -0,0 +1,25 @@
/* Self-hosted (vendored, not CDN) — the field PC/phone may have no internet.
Both files are variable fonts; one @font-face per family covers every
weight the design uses via the weight axis. */
@font-face {
font-family: "Geist";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/vendor/fonts/Geist-Variable.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
@font-face {
font-family: "Geist Mono";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("/vendor/fonts/GeistMono-Variable.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215,
U+FEFF, U+FFFD;
}
+699
View File
@@ -0,0 +1,699 @@
/* Hallmark · pre-emit critique: P5 H4 E4 S4 R5 V4
* genre: modern-minimal · theme: custom (Tally-referenced) · redesign
* scope: app (single-screen phone HUD over a live scan view) — no marketing
* macrostructure/nav/footer archetypes apply
* anchor hue: cool-cobalt 256 · outdoor-legibility constraint: status never colour-only
*/
html, body { overflow: hidden; height: 100%; margin: 0; }
* { box-sizing: border-box; }
body {
background: var(--color-paper);
color: var(--color-ink-2);
font-family: var(--font-body);
font-weight: 400;
font-size: var(--text-base);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 650;
font-style: normal;
letter-spacing: -0.015em;
color: var(--color-ink);
margin: 0;
}
.hidden { display: none !important; }
a { color: var(--color-accent); }
/* ── Focus — instant, never animated, always visible ────────────────────── */
:focus { outline: none; }
:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
/* ── App shell — one fixed viewport, no page scroll ever. The scan view is
the permanent base layer; the HUD floats fixed on top of it; Settings/
Recording/Log are overlays drawn above both, never a navigation away. ── */
#app {
position: fixed;
inset: 0;
overflow: hidden;
}
.scan-view {
position: absolute;
inset: 0;
overflow: hidden;
background: var(--color-ink);
}
#pointcloud-canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none; /* OrbitControls owns touch gestures here, not the browser (no pull-to-refresh/pinch-zoom-page fighting it) */
}
.scan-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-ink-4);
font-family: var(--font-mono);
font-size: var(--text-sm);
text-align: center;
padding: var(--space-2xl);
}
/* ── HUD — fixed, pointer-events pass through the gaps so the scan view
underneath stays interactive (pan/zoom, once M3 adds that). Top/bottom
scrims guarantee legible light text regardless of what's rendered in the
scan view beneath (placeholder today, a point cloud later). ── */
.hud {
position: absolute;
inset: 0;
z-index: var(--z-sticky);
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.hud-top {
pointer-events: auto;
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-2xs);
padding: max(var(--space-xs), env(safe-area-inset-top)) max(var(--space-sm), env(safe-area-inset-right))
var(--space-lg) max(var(--space-sm), env(safe-area-inset-left));
background: linear-gradient(to bottom, oklch(20% 0.02 258 / 0.65), transparent);
}
.hud-bottom {
pointer-events: auto;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-lg) max(var(--space-sm), env(safe-area-inset-right))
max(var(--space-xs), env(safe-area-inset-bottom)) max(var(--space-sm), env(safe-area-inset-left));
background: linear-gradient(to top, oklch(20% 0.02 258 / 0.65), transparent);
}
.hud-startup, .hud-rec, .hud-actions, .hud-top-right {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.status-badges {
display: flex;
gap: var(--space-2xs);
flex-wrap: wrap;
}
/* ── Status badges — icon + text, never colour alone (outdoor/colour-blind safe) ── */
.badge {
display: inline-flex;
align-items: center;
gap: 0.3em;
padding: 0.3em 0.7em;
border-radius: var(--radius-pill);
font-family: var(--font-mono);
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1.4;
white-space: nowrap;
}
/* Sensor badges (LiDAR/IMU/camN) get a hard fixed width, not shrink-to-fit
— icon glyphs (✓/!/✕) can render at slightly different widths than
monospace text depending on font fallback, so relying on content width
alone still let the box size drift. A fixed width removes that variable
entirely: label sits left, Hz sits right (space-between), and the box
itself never resizes as health/Hz change. Sized generously for the
longest realistic content ("✓ LiDAR STALE 199.9Hz"); flex-shrink:0 stops
the row from squeezing it under width pressure. */
.badge-sensor {
width: 15em;
box-sizing: border-box;
flex-shrink: 0;
justify-content: space-between;
}
.badge-ok { background: var(--color-success); color: var(--color-success-ink); }
.badge-stale { background: var(--color-warning); color: var(--color-warning-ink); }
.badge-down { background: var(--color-danger); color: var(--color-danger-ink); }
.badge-rec { background: var(--color-danger); color: var(--color-danger-ink); }
.badge-ok::before { content: "✓"; }
.badge-stale::before { content: "!"; }
.badge-down::before { content: "✕"; }
.badge-rec::before {
content: "";
width: 7px; height: 7px;
border-radius: 50%;
background: currentColor;
animation: badge-rec-pulse 1.4s var(--ease-in-out) infinite;
}
@keyframes badge-rec-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
@media (prefers-reduced-motion: reduce) {
.badge-rec::before { animation: none; }
}
/* Badge Hz suffix — a measured number, not just a traffic-light state.
status-panel.js NBSP-pads the health word and Hz digits to a fixed
character count so badge width never changes between updates (that's
the real fix); min-width here is just a safety margin against font
metric rounding. */
.badge .badge-hz {
display: inline-block;
min-width: 7ch;
text-align: right;
opacity: 0.85;
font-weight: 500;
}
.hint {
color: var(--color-ink-3);
font-size: var(--text-xs);
margin: var(--space-2xs) 0;
word-break: break-all;
font-family: var(--font-mono);
}
/* ── Buttons — pill, semantic fills, full state coverage ─────────────────
default · hover · focus-visible · active · disabled all styled below. */
.btn-row { display: flex; gap: var(--space-xs); margin: var(--space-sm) 0; }
.btn {
flex: 1;
min-height: 44px; /* touch target floor */
padding: 0 var(--space-md);
border: 1px solid transparent;
border-radius: var(--radius-pill);
font-family: var(--font-body);
font-weight: 600;
font-size: var(--text-sm);
white-space: nowrap;
cursor: pointer;
transition: background-color var(--dur-mid) var(--ease-out),
border-color var(--dur-mid) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
}
.btn:active { transform: translateY(1px); }
.btn:disabled {
background: var(--color-paper-2) !important;
color: var(--color-ink-4) !important;
border-color: var(--color-rule-soft) !important;
cursor: not-allowed;
transform: none;
}
.btn-green {
background: var(--color-success); color: var(--color-success-ink);
}
.btn-green:hover:not(:disabled) { background: oklch(from var(--color-success) calc(l - 0.05) c h); }
.btn-red {
background: var(--color-danger); color: var(--color-danger-ink);
}
.btn-red:hover:not(:disabled) { background: oklch(from var(--color-danger) calc(l - 0.05) c h); }
.btn-blue {
background: var(--color-accent); color: var(--color-accent-ink);
}
.btn-blue:hover:not(:disabled) { background: oklch(from var(--color-accent) calc(l - 0.05) c h); }
.btn-gray {
background: var(--color-paper-2); color: var(--color-ink-2);
border-color: var(--color-rule);
}
.btn-gray:hover:not(:disabled) { background: var(--color-paper-3); }
.btn-small {
flex: 0 0 auto;
width: 44px;
min-height: 44px;
border-radius: var(--radius-sm);
background: var(--color-paper-2);
color: var(--color-ink-2);
border-color: var(--color-rule);
}
.btn-small:hover { background: var(--color-paper-3); }
/* HUD buttons sit inline in a floating strip, not a stacked full-width
.btn-row, so they must not stretch (flex:1) — size to content instead. */
.btn-hud {
flex: none;
min-width: 64px;
box-shadow: var(--shadow-whisper);
}
.btn-icon {
min-width: 44px;
width: 44px;
padding: 0;
font-size: var(--text-md);
}
.btn-estop {
min-height: 52px;
background: var(--color-paper-1);
color: var(--color-danger);
border: 1.5px solid var(--color-danger);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
cursor: pointer;
transition: background-color var(--dur-mid) var(--ease-out), color var(--dur-mid) var(--ease-out);
}
.btn-estop:hover {
background: var(--color-danger);
color: var(--color-danger-ink);
}
.btn-estop:active { transform: translateY(1px); }
.btn-estop-hud {
min-height: 44px;
padding: 0 var(--space-md);
font-size: var(--text-xs);
background: oklch(20% 0.02 258 / 0.55);
box-shadow: var(--shadow-whisper);
}
/* ── State lines (● + label — carried over from the field-proven PyQt5 idiom) ──
Default colours assume a light panel; the HUD versions below override for
legibility over the dark top/bottom scrims. */
.state-line {
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: 600;
margin-top: var(--space-2xs);
}
.state-line.on { color: var(--color-success); }
.state-line.off { color: var(--color-ink-4); }
.state-line.rec { color: var(--color-danger); }
.hud .state-line {
font-size: var(--text-xs);
text-shadow: 0 1px 3px oklch(0% 0 0 / 0.5);
}
.hud .state-line.off { color: var(--color-paper-3); }
/* ── Disk free space — safety info, not decoration ───────────────────── */
.disk-free {
display: flex;
align-items: center;
gap: 0.4em;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
}
.disk-free.warning { color: var(--color-warning); font-weight: 600; }
.disk-free.danger { color: var(--color-danger); font-weight: 600; }
/* Small always-on chip in the HUD top strip — same idea as the sensor
badges, just neutral (free space isn't a health traffic light). */
.hud-disk-free {
padding: 0.3em 0.7em;
border-radius: var(--radius-pill);
background: oklch(20% 0.02 258 / 0.55);
color: var(--color-paper-1);
font-weight: 600;
}
.hud-disk-free.warning { color: oklch(78% 0.16 68); }
.hud-disk-free.danger { color: oklch(78% 0.18 25); }
/* ── Live recording meter (elapsed + size) ───────────────────────────── */
.rec-meter {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
font-variant-numeric: tabular-nums;
}
.hud .rec-meter {
color: var(--color-paper-1);
text-shadow: 0 1px 3px oklch(0% 0 0 / 0.5);
}
/* ── Bag list — richer entries (duration / size / topic count) ──────── */
.bag-item { padding: var(--space-xs) 0; border-bottom: 1px solid var(--color-rule-soft); }
.bag-item:last-child { border-bottom: none; }
.bag-item-name {
color: var(--color-ink-2);
font-weight: 600;
display: flex;
align-items: center;
gap: 0.4em;
}
.bag-item-meta {
color: var(--color-ink-4);
font-size: var(--text-xs);
margin-top: 2px;
}
.bag-item-incomplete {
color: var(--color-warning);
font-weight: 600;
}
/* ── Overlays — Settings / Recording / Log all float as a centred sheet
above the scan view + HUD, never a page navigation. Later overlays in
DOM order (log, opened from inside Settings) stack visually above
earlier ones at the same z-index. ── */
.overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-md);
}
.overlay-backdrop {
position: absolute;
inset: 0;
background: oklch(15% 0.02 258 / 0.55);
}
.overlay-sheet {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
max-width: 480px;
max-height: min(85dvh, 720px);
background: var(--color-paper-1);
border: 1px solid var(--color-rule);
border-radius: var(--radius-lg);
box-shadow: 0 8px 32px oklch(15% 0.02 258 / 0.28);
overflow: hidden;
}
.overlay-sheet-small { max-width: 380px; }
.overlay-header {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--color-rule-soft);
}
.overlay-header h2 { font-size: var(--text-md); }
.btn-close {
flex: none;
width: 36px;
height: 36px;
border: none;
border-radius: var(--radius-sm);
background: var(--color-paper-2);
color: var(--color-ink-2);
font-size: var(--text-sm);
cursor: pointer;
}
.btn-close:hover { background: var(--color-paper-3); }
.overlay-tabs {
flex: none;
display: flex;
gap: var(--space-lg);
padding: 0 var(--space-lg);
border-bottom: 1px solid var(--color-rule);
overflow-x: auto;
}
.overlay-body {
flex: 1;
overflow-y: auto;
padding: var(--space-lg);
}
/* Outer overlay-level tabs (카메라 설정 / 미리보기 / 최근 녹화 / 로그).
Deliberately a different class from .tab-btn/.tab-panel below — those are
reset via a global querySelectorAll by camera-settings.js for the
per-camera tabs, which would otherwise clobber these too. */
.pane-tab-btn {
padding: var(--space-xs) 0;
min-height: 40px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--color-ink-3);
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: color var(--dur-mid) var(--ease-out), border-color var(--dur-mid) var(--ease-out);
}
.pane-tab-btn:hover { color: var(--color-ink); }
.pane-tab-btn.active { color: var(--color-ink); border-bottom-color: var(--color-accent); font-weight: 600; }
.pane-tab-btn-log { margin-left: auto; color: var(--color-accent); }
.pane-tab { display: none; }
.pane-tab.active { display: block; }
/* ── Log overlay — the unified [tag] scrollback the PyQt tools had ──────── */
.log-header-actions { display: flex; align-items: center; gap: var(--space-md); }
.log-autoscroll {
display: flex; align-items: center; gap: var(--space-2xs);
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
cursor: pointer;
}
.log-autoscroll input { accent-color: var(--color-accent); cursor: pointer; }
.log-body {
flex: 1;
min-height: 0;
background: var(--color-ink);
padding: var(--space-sm) var(--space-md);
overflow-y: auto;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
.log-line { display: flex; gap: 0.6em; }
.log-line .log-tag {
flex: 0 0 auto;
color: var(--color-ink-4);
text-transform: uppercase;
font-weight: 600;
}
.log-line.tag-lidar .log-tag { color: oklch(70% 0.14 256); }
.log-line.tag-camera .log-tag { color: oklch(72% 0.14 149); }
.log-line.tag-recording .log-tag { color: oklch(72% 0.16 25); }
.log-line.tag-fastlio .log-tag { color: oklch(72% 0.15 320); }
.log-line .log-text { color: var(--color-paper-1); }
/* ── Form fields ──────────────────────────────────────────────────────── */
.field-row { display: flex; align-items: center; gap: var(--space-xs); margin: var(--space-sm) 0; }
.field-row label {
width: 72px; flex: 0 0 72px;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
}
.field-row input[type=text] {
flex: 1;
min-height: 40px;
padding: 0 var(--space-sm);
background: var(--color-paper-2);
border: 1px solid var(--color-rule);
border-radius: var(--radius-sm);
color: var(--color-ink);
font-family: var(--font-body);
font-size: var(--text-sm);
transition: border-color var(--dur-mid) var(--ease-out), background-color var(--dur-mid) var(--ease-out);
}
.field-row input[type=text]:hover { border-color: var(--color-ink-4); }
.field-row input[type=text]:focus-visible {
background: var(--color-paper-1);
border-color: var(--color-accent);
outline: 2px solid var(--color-focus);
outline-offset: 1px;
}
.field-row input[type=text]::placeholder { color: var(--color-ink-4); }
.browse-panel {
border: 1px solid var(--color-rule);
border-radius: var(--radius-sm);
background: var(--color-paper-2);
max-height: 180px;
overflow-y: auto;
padding: var(--space-2xs);
margin-bottom: var(--space-sm);
font-family: var(--font-mono);
font-size: var(--text-xs);
}
.browse-panel .dir-entry {
padding: var(--space-xs);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease-out);
}
.browse-panel .dir-entry:hover { background: var(--color-paper-3); }
.bag-list {
list-style: none;
padding: 0;
margin: 0;
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-2);
}
.bag-list li {
padding: var(--space-xs) 0;
border-bottom: 1px solid var(--color-rule-soft);
}
.bag-list li:last-child { border-bottom: none; }
/* ── Tabs — underline indicator, crossfade content (never slide).
Per-camera tabs inside the "카메라 설정" pane. ── */
.tabs { display: flex; gap: var(--space-lg); border-bottom: 1px solid var(--color-rule); margin-bottom: var(--space-md); }
.tab-btn {
padding: var(--space-xs) 0;
min-height: 40px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--color-ink-3);
font-family: var(--font-mono);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: color var(--dur-mid) var(--ease-out), border-color var(--dur-mid) var(--ease-out);
}
.tab-btn:hover { color: var(--color-ink); }
.tab-btn.active { color: var(--color-ink); border-bottom-color: var(--color-accent); font-weight: 600; }
.tab-panel { display: none; animation: tab-crossfade var(--dur-mid) var(--ease-out); }
.tab-panel.active { display: block; }
@keyframes tab-crossfade { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
.tab-panel { animation: none; }
}
.tab-panel > label:first-child {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-sm);
color: var(--color-ink-2);
cursor: pointer;
}
.tab-panel input[type=checkbox] {
width: 18px; height: 18px;
accent-color: var(--color-accent);
cursor: pointer;
}
.cam-field-grid {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: var(--space-xs) var(--space-sm);
align-items: center;
margin: var(--space-sm) 0;
}
.cam-field-grid label {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
}
.cam-field-grid input[type=number] {
min-height: 36px;
padding: 0 var(--space-xs);
border: 1px solid var(--color-rule);
border-radius: var(--radius-sm);
background: var(--color-paper-2);
color: var(--color-ink);
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: var(--text-sm);
width: 100%;
transition: border-color var(--dur-mid) var(--ease-out);
}
.cam-field-grid input[type=number]:hover { border-color: var(--color-ink-4); }
.cam-field-grid input[type=number]:focus-visible {
border-color: var(--color-accent);
outline: 2px solid var(--color-focus);
outline-offset: 1px;
}
.cam-field-grid input:disabled {
color: var(--color-ink-4);
background: var(--color-paper);
border-color: var(--color-rule-soft);
}
.cam-result {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
min-height: 1.4em;
margin-top: var(--space-2xs);
}
/* ── Camera preview grid ─────────────────────────────────────────────── */
.camera-grid { display: flex; gap: var(--space-md); flex-wrap: wrap; }
.camera-pane { flex: 1; min-width: 260px; }
.camera-pane .cam-title {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--color-ink-3);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: var(--space-2xs);
}
.camera-pane img {
width: 100%;
aspect-ratio: 4 / 3;
object-fit: contain;
background: var(--color-ink);
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
display: block;
}
/* ── Mobile / small overlay widths — verified at 320 / 375 / 414 / 768 px
(Hallmark floor). No horizontal scroll, no two-line button labels, tap
targets >= 44px. The app shell itself has no scroll at all (see #app). ── */
@media (max-width: 480px) {
.field-row { flex-wrap: wrap; }
.field-row label { width: 100%; flex-basis: 100%; }
.cam-field-grid { grid-template-columns: 1fr; }
.cam-field-grid label { margin-top: var(--space-2xs); }
}
+96
View File
@@ -0,0 +1,96 @@
/* Hallmark · genre: modern-minimal · theme: custom (Tally-referenced cool-indigo,
* instrument-panel discipline borrowed from Cobalt) · redesign · designed-as-app
* paper-band: light · display-style: grotesk-sans (Geist) · accent-hue: cool-cobalt (256)
*/
:root {
color-scheme: light;
/* ── Paper / ink — cool engineered near-white, never pure #fff/#000 ── */
--color-paper: oklch(97.8% 0.005 255); /* page ground */
--color-paper-1: oklch(99.3% 0.003 255); /* panel / card surface */
--color-paper-2: oklch(95.0% 0.008 255); /* recessed surface (inputs, browse tree) */
--color-paper-3: oklch(91.5% 0.010 255); /* hover/active recessed surface */
--color-ink: oklch(22.0% 0.020 258); /* headings, primary text */
--color-ink-2: oklch(38.0% 0.016 257); /* body text */
--color-ink-3: oklch(48.0% 0.013 257); /* secondary / hint text */
--color-ink-4: oklch(66.0% 0.010 257); /* placeholder / disabled text */
--color-rule: oklch(85.0% 0.010 255); /* visible hairline border */
--color-rule-soft: oklch(90.5% 0.008 255); /* quiet hairline */
/* ── Accent — one cobalt signal, used for focus / links / active state only ── */
--color-accent: oklch(52.0% 0.185 256);
--color-accent-ink: oklch(98.5% 0.004 255);
--color-accent-tint: oklch(93.5% 0.030 256);
--color-focus: oklch(48.0% 0.200 256);
/* ── Functional state colours — status/health semantics, not brand accent ── */
--color-success: oklch(52.0% 0.145 149);
--color-success-ink: oklch(98.5% 0.004 149);
--color-warning: oklch(64.0% 0.165 68);
--color-warning-ink: oklch(20.0% 0.030 68);
--color-danger: oklch(53.0% 0.195 25);
--color-danger-ink: oklch(98.5% 0.004 25);
/* ── Typography — Geist family throughout (single-family discipline is the
modern-minimal signature), Geist Mono as the one outlier for status/data.
Geist covers Latin only; "Noto Sans KR" carries the Korean UI copy —
the browser falls back to it per-glyph automatically. */
--font-display: "Geist", "Noto Sans CJK KR", "Noto Sans KR", ui-sans-serif, system-ui, sans-serif;
--font-body: "Geist", "Noto Sans CJK KR", "Noto Sans KR", ui-sans-serif, system-ui, sans-serif;
--font-mono: "Geist Mono", "Noto Sans CJK KR", "Noto Sans KR", ui-monospace, "SF Mono", Menlo, monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.8125rem; /* 13px */
--text-base: 0.9375rem; /* 15px */
--text-md: 1.0625rem; /* 17px */
--text-lg: 1.3125rem; /* 21px */
--text-xl: 1.75rem; /* 28px */
--text-2xl: 2.25rem; /* 36px */
/* ── Spacing — 4pt scale ── */
--space-3xs: 0.25rem; /* 4px */
--space-2xs: 0.375rem; /* 6px */
--space-xs: 0.5rem; /* 8px */
--space-sm: 0.75rem; /* 12px */
--space-md: 1rem; /* 16px */
--space-lg: 1.5rem; /* 24px */
--space-xl: 2rem; /* 32px */
--space-2xl: 3rem; /* 48px */
/* ── Radius — tight/technical for surfaces, pill for actions & chips ── */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--radius-pill: 999px;
/* ── Elevation — hairline borders do the work; shadow is a whisper, one only ── */
--shadow-whisper: 0 1px 2px oklch(20% 0.02 258 / 0.06);
/* ── Motion — three named eases, no bounce/overshoot ── */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in: cubic-bezier(0.7, 0, 0.84, 0);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--dur-fast: 100ms;
--dur-mid: 180ms;
--dur-slow: 280ms;
/* ── Z-index — named scale ── */
--z-base: 1;
--z-raised: 10;
--z-dropdown: 100;
--z-sticky: 200;
--z-modal: 400;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: var(--dur-fast) !important;
scroll-behavior: auto !important;
}
}
+154
View File
@@ -0,0 +1,154 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover">
<title>scan_web</title>
<link rel="stylesheet" href="/css/fonts.css">
<link rel="stylesheet" href="/css/tokens.css">
<link rel="stylesheet" href="/css/style.css">
<script type="importmap">
{
"imports": {
"three": "/vendor/three/three.module.min.js"
}
}
</script>
</head>
<body>
<div id="app">
<!-- Base layer, always full-screen: the live FAST-LIO point cloud. Empty
state shown until the first accumulated points arrive. -->
<section id="view-scan" class="scan-view">
<canvas id="pointcloud-canvas"></canvas>
<div id="scan-empty" class="scan-placeholder">
<p>포인트 없음 — 시동 후 녹화를 시작하면 지도가 쌓입니다.</p>
</div>
</section>
<!-- Fixed HUD — never scrolls, always on top of the scan view. -->
<div class="hud">
<div class="hud-top">
<div id="status-badges" class="status-badges"></div>
<div class="hud-top-right">
<div id="map-points" class="disk-free hud-disk-free"></div>
<div id="disk-free" class="disk-free hud-disk-free"></div>
</div>
</div>
<div class="hud-bottom">
<div class="hud-startup">
<button id="btn-lidar-start" class="btn btn-green btn-hud">시동</button>
<button id="btn-lidar-stop" class="btn btn-red btn-hud">정지</button>
<div id="lidar-state" class="state-line">● 정지</div>
</div>
<div class="hud-rec">
<button id="btn-rec-open" class="btn btn-red btn-hud">● 녹화</button>
<button id="btn-rec-stop" class="btn btn-gray btn-hud hidden">■ 정지</button>
<div id="rec-meter" class="rec-meter hidden"></div>
</div>
<div class="hud-actions">
<button id="btn-settings" class="btn btn-gray btn-hud btn-icon" aria-label="설정"></button>
<button id="btn-estop" class="btn-estop btn-estop-hud">E-STOP</button>
</div>
</div>
</div>
<!-- Settings overlay — camera settings / preview / recent bags, plus the
entry point into the log overlay. Floats over the scan view, closed by
default. -->
<div id="overlay-settings" class="overlay hidden">
<div class="overlay-backdrop" data-close="overlay-settings"></div>
<div class="overlay-sheet">
<div class="overlay-header">
<h2>설정</h2>
<button class="btn-close" data-close="overlay-settings"></button>
</div>
<div class="overlay-tabs">
<button class="pane-tab-btn active" data-pane="pane-camera-settings">카메라 설정</button>
<button class="pane-tab-btn" data-pane="pane-camera-preview">미리보기</button>
<button class="pane-tab-btn" data-pane="pane-lidar-test">라이다 테스트</button>
<button class="pane-tab-btn" data-pane="pane-recent-bags">최근 녹화</button>
<button id="btn-open-log" class="pane-tab-btn pane-tab-btn-log">로그</button>
</div>
<div class="overlay-body">
<div class="pane-tab active" id="pane-camera-settings">
<div class="tabs" id="camera-tabs"></div>
<div id="camera-tab-panels"></div>
</div>
<div class="pane-tab" id="pane-camera-preview">
<div id="camera-grid" class="camera-grid"></div>
</div>
<div class="pane-tab" id="pane-lidar-test">
<p class="hint">녹화 없이 FAST-LIO만 켜서 라이다 포인트 품질을 미리 확인합니다.
시작 후 몇 초간 흔들지 말고 가만히 두세요 — FAST-LIO가 정지 상태의 IMU로 중력 방향을 자동 정렬합니다.</p>
<div class="btn-row">
<button id="btn-fastlio-test-start" class="btn btn-blue">테스트 시작</button>
<button id="btn-fastlio-test-stop" class="btn btn-gray">테스트 정지</button>
</div>
<div id="fastlio-test-state" class="state-line">● 정지</div>
<h3>시점</h3>
<div class="btn-row">
<button id="btn-view-orbit" class="btn btn-blue">자유 시점</button>
<button id="btn-view-lidar" class="btn btn-gray">라이다 시점</button>
</div>
</div>
<div class="pane-tab" id="pane-recent-bags">
<ul id="bag-list" class="bag-list"></ul>
</div>
</div>
</div>
</div>
<!-- Recording modal — just enough to see/set the save path and filename
before confirming; floats over the scan view. -->
<div id="overlay-recording" class="overlay hidden">
<div class="overlay-backdrop" data-close="overlay-recording"></div>
<div class="overlay-sheet overlay-sheet-small">
<div class="overlay-header">
<h2>녹화 시작</h2>
<button class="btn-close" data-close="overlay-recording"></button>
</div>
<div class="overlay-body">
<div class="field-row">
<label>저장 경로</label>
<input type="text" id="rec-save-dir" />
<button id="btn-browse" class="btn btn-small"></button>
</div>
<div id="browse-panel" class="browse-panel hidden"></div>
<div class="field-row">
<label>파일 이름</label>
<input type="text" id="rec-filename" placeholder="scan_001" />
</div>
<div class="btn-row">
<button id="btn-rec-confirm" class="btn btn-red">● 녹화 시작</button>
</div>
<div id="rec-path" class="hint"></div>
</div>
</div>
</div>
<!-- Log overlay — opened from inside Settings, stacks above it. -->
<div id="overlay-log" class="overlay hidden">
<div class="overlay-backdrop" data-close="overlay-log"></div>
<div class="overlay-sheet">
<div class="overlay-header">
<h2>로그</h2>
<div class="log-header-actions">
<label class="log-autoscroll"><input type="checkbox" id="log-autoscroll" checked /> 자동 스크롤</label>
<button id="btn-log-clear" class="btn btn-small btn-gray">지우기</button>
<button class="btn-close" data-close="overlay-log"></button>
</div>
</div>
<div id="log-body" class="log-body"></div>
</div>
</div>
</div>
<script type="module" src="/js/app.js"></script>
</body>
</html>
+132
View File
@@ -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);
});
+33
View File
@@ -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");
}
});
}
+148
View File
@@ -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)";
}
};
}
+21
View File
@@ -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);
}
+45
View File
@@ -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();
}
+169
View File
@@ -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";
},
};
}
+123
View File
@@ -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);
}
+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;
}
}
Binary file not shown.
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2024 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long