feat: add camera preview toggle to visualization
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user