feat: add camera preview toggle to visualization

This commit is contained in:
2026-10-14 14:06:44 +09:00
parent c9cceb4c01
commit db9201fd05
2 changed files with 52 additions and 9 deletions
+51 -9
View File
@@ -49,7 +49,7 @@
#cameraPreviewWrap {
position:absolute;
left:16px;
top:16px;
top:60px;
z-index:10;
display:none;
width:min(260px, calc(100vw - 32px));
@@ -58,6 +58,7 @@
box-shadow:0 10px 26px rgba(0,0,0,.16);
}
#cameraPreviewWrap.is-visible { display:block; }
#cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; }
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
#cameraPreview {
display:block;
@@ -96,6 +97,9 @@
display:flex;
gap:7px;
align-items:flex-start;
max-width:calc(100% - 32px);
flex-wrap:wrap;
justify-content:flex-end;
}
#topLeftControls {
position:absolute;
@@ -241,6 +245,7 @@
#downloadMap,
#pathToggle,
#sensorToggle,
#cameraToggle,
#poseToggle,
#goalToggle,
#obstacleToggle,
@@ -282,6 +287,7 @@
#downloadMap:hover,
#pathToggle:hover,
#sensorToggle:hover,
#cameraToggle:hover,
#poseToggle:hover,
#goalToggle:hover,
#obstacleToggle:hover,
@@ -307,6 +313,7 @@
border-color:rgba(147,197,253,.55);
color:#fff;
}
#cameraToggle.active,
#sensorToggle.active {
background:rgba(14,116,144,.62);
border-color:rgba(103,232,249,.55);
@@ -385,6 +392,10 @@
</div>
<button id="pathToggle" title="로봇 경로 표시" data-i18n-title="로봇 경로 표시" aria-label="로봇 경로 표시" data-i18n-aria-label="로봇 경로 표시">Path</button>
<button id="sensorToggle" title="센서 표시" data-i18n-title="센서 표시" aria-label="센서 표시" data-i18n-aria-label="센서 표시" aria-pressed="false">Sensor</button>
<button id="cameraToggle" type="button" title="카메라 영상 표시" data-i18n-title="카메라 영상 표시" aria-label="카메라 영상 표시" data-i18n-aria-label="카메라 영상 표시" aria-pressed="true" aria-controls="cameraPreviewWrap">
<!-- Lucide Camera icon. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/></svg>
</button>
<button id="poseToggle" title="초기 위치 지정" data-i18n-title="초기 위치 지정" aria-label="초기 위치 지정" data-i18n-aria-label="초기 위치 지정">Pose</button>
<button id="goalToggle" title="목표 위치 지정" data-i18n-title="목표 위치 지정" aria-label="목표 위치 지정" data-i18n-aria-label="목표 위치 지정">Goal</button>
<select id="cameraPresetSelect" title="카메라 시점" data-i18n-title="카메라 시점" aria-label="카메라 시점" data-i18n-aria-label="카메라 시점">
@@ -475,6 +486,11 @@ const miniMapToggle = document.getElementById('miniMapToggle');
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
const cameraPreview = document.getElementById('cameraPreview');
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
const cameraToggleButton = document.getElementById('cameraToggle');
const topRightControls = document.getElementById('topRightControls');
new ResizeObserver(() => {
cameraPreviewWrap.style.top = `${topRightControls.offsetTop + topRightControls.offsetHeight + 12}px`;
}).observe(topRightControls);
const responseWaitOverlay = document.getElementById('responseWaitOverlay');
const mapSaveOverlay = document.getElementById('mapSaveOverlay');
const downloadMapButton = document.getElementById('downloadMap');
@@ -907,6 +923,8 @@ let cameraFrameRequestInFlight = false;
let cameraFrameRetryAt = 0;
let cameraFrameRefreshTimer = null;
let cameraFrameObjectUrl = null;
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
let cameraFrameAbortController = null;
let pathVisible = false;
// SLAM always renders its live scan, including the forward beam. Navigation
// keeps the sensor opt-in so a saved-map view starts cleanly.
@@ -2052,6 +2070,7 @@ async function refreshSensorPointCloud() {
}
function setCameraPreviewAvailable(available) {
available = available && cameraPreviewEnabled;
cameraPreviewWrap?.classList.toggle('is-visible', available);
if (available) return;
if (cameraFrameObjectUrl) {
@@ -2067,7 +2086,7 @@ function hasLiveCameraState() {
}
async function refreshCameraFrame() {
if (!ENABLE_CAMERA_PREVIEW || cameraFrameRequestInFlight || document.hidden) return;
if (!cameraPreviewEnabled || cameraFrameRequestInFlight || document.hidden) return;
// Reuse websocket topic status instead of probing an absent camera with HTTP.
if (!hasLiveCameraState()) {
setCameraPreviewAvailable(false);
@@ -2075,15 +2094,18 @@ async function refreshCameraFrame() {
}
if (Date.now() < cameraFrameRetryAt) return;
cameraFrameRequestInFlight = true;
const controller = new AbortController();
cameraFrameAbortController = controller;
const timeout = window.setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: AbortSignal.timeout(2000) });
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) {
cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
return;
}
const frame = await response.blob();
if (!hasLiveCameraState() || document.hidden) {
if (controller.signal.aborted || !cameraPreviewEnabled || !hasLiveCameraState() || document.hidden) {
setCameraPreviewAvailable(false);
return;
}
@@ -2094,13 +2116,35 @@ async function refreshCameraFrame() {
if (cameraPreview) cameraPreview.src = nextUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl);
} catch (_) {
cameraFrameRetryAt = Date.now() + 5000;
if (!controller.signal.aborted) cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
} finally {
window.clearTimeout(timeout);
cameraFrameAbortController = null;
cameraFrameRequestInFlight = false;
}
}
function setCameraPreviewEnabled(enabled) {
cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled;
cameraToggleButton.classList.toggle('active', cameraPreviewEnabled);
cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled));
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
cameraFrameRefreshTimer = null;
if (!cameraPreviewEnabled) {
cameraFrameAbortController?.abort();
setCameraPreviewAvailable(false);
return;
}
cameraFrameRetryAt = 0;
cameraPreviewWrap.classList.remove('is-collapsed');
cameraPreviewToggle.setAttribute('aria-expanded', 'true');
cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기'));
void refreshCameraFrame();
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
}
cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled));
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
@@ -3231,16 +3275,14 @@ if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) {
void refreshSensorPointCloud();
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
}
if (ENABLE_CAMERA_PREVIEW) {
void refreshCameraFrame();
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
}
setCameraPreviewEnabled(ENABLE_CAMERA_PREVIEW);
window.addEventListener('beforeunload', () => {
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
cameraFrameAbortController?.abort();
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
});
@@ -7,6 +7,7 @@ const english = {
'맵 저장': 'Save map',
'로봇 경로 표시': 'Show robot path',
'센서 표시': 'Show sensors',
'카메라 영상 표시': 'Show camera preview',
'초기 위치 지정': 'Set initial pose',
'목표 위치 지정': 'Set goal',
'카메라 시점': 'Camera view',