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,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;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
Vendored
+21
@@ -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.
|
||||
+1523
File diff suppressed because it is too large
Load Diff
+6
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user