feat: add camera preview toggle to visualization
This commit is contained in:
@@ -49,7 +49,7 @@
|
|||||||
#cameraPreviewWrap {
|
#cameraPreviewWrap {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
left:16px;
|
left:16px;
|
||||||
top:16px;
|
top:60px;
|
||||||
z-index:10;
|
z-index:10;
|
||||||
display:none;
|
display:none;
|
||||||
width:min(260px, calc(100vw - 32px));
|
width:min(260px, calc(100vw - 32px));
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
box-shadow:0 10px 26px rgba(0,0,0,.16);
|
box-shadow:0 10px 26px rgba(0,0,0,.16);
|
||||||
}
|
}
|
||||||
#cameraPreviewWrap.is-visible { display:block; }
|
#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; }
|
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
|
||||||
#cameraPreview {
|
#cameraPreview {
|
||||||
display:block;
|
display:block;
|
||||||
@@ -96,6 +97,9 @@
|
|||||||
display:flex;
|
display:flex;
|
||||||
gap:7px;
|
gap:7px;
|
||||||
align-items:flex-start;
|
align-items:flex-start;
|
||||||
|
max-width:calc(100% - 32px);
|
||||||
|
flex-wrap:wrap;
|
||||||
|
justify-content:flex-end;
|
||||||
}
|
}
|
||||||
#topLeftControls {
|
#topLeftControls {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
@@ -241,6 +245,7 @@
|
|||||||
#downloadMap,
|
#downloadMap,
|
||||||
#pathToggle,
|
#pathToggle,
|
||||||
#sensorToggle,
|
#sensorToggle,
|
||||||
|
#cameraToggle,
|
||||||
#poseToggle,
|
#poseToggle,
|
||||||
#goalToggle,
|
#goalToggle,
|
||||||
#obstacleToggle,
|
#obstacleToggle,
|
||||||
@@ -282,6 +287,7 @@
|
|||||||
#downloadMap:hover,
|
#downloadMap:hover,
|
||||||
#pathToggle:hover,
|
#pathToggle:hover,
|
||||||
#sensorToggle:hover,
|
#sensorToggle:hover,
|
||||||
|
#cameraToggle:hover,
|
||||||
#poseToggle:hover,
|
#poseToggle:hover,
|
||||||
#goalToggle:hover,
|
#goalToggle:hover,
|
||||||
#obstacleToggle:hover,
|
#obstacleToggle:hover,
|
||||||
@@ -307,6 +313,7 @@
|
|||||||
border-color:rgba(147,197,253,.55);
|
border-color:rgba(147,197,253,.55);
|
||||||
color:#fff;
|
color:#fff;
|
||||||
}
|
}
|
||||||
|
#cameraToggle.active,
|
||||||
#sensorToggle.active {
|
#sensorToggle.active {
|
||||||
background:rgba(14,116,144,.62);
|
background:rgba(14,116,144,.62);
|
||||||
border-color:rgba(103,232,249,.55);
|
border-color:rgba(103,232,249,.55);
|
||||||
@@ -385,6 +392,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="pathToggle" title="로봇 경로 표시" data-i18n-title="로봇 경로 표시" aria-label="로봇 경로 표시" data-i18n-aria-label="로봇 경로 표시">Path</button>
|
<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="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="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>
|
<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="카메라 시점">
|
<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 cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
|
||||||
const cameraPreview = document.getElementById('cameraPreview');
|
const cameraPreview = document.getElementById('cameraPreview');
|
||||||
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
|
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 responseWaitOverlay = document.getElementById('responseWaitOverlay');
|
||||||
const mapSaveOverlay = document.getElementById('mapSaveOverlay');
|
const mapSaveOverlay = document.getElementById('mapSaveOverlay');
|
||||||
const downloadMapButton = document.getElementById('downloadMap');
|
const downloadMapButton = document.getElementById('downloadMap');
|
||||||
@@ -907,6 +923,8 @@ let cameraFrameRequestInFlight = false;
|
|||||||
let cameraFrameRetryAt = 0;
|
let cameraFrameRetryAt = 0;
|
||||||
let cameraFrameRefreshTimer = null;
|
let cameraFrameRefreshTimer = null;
|
||||||
let cameraFrameObjectUrl = null;
|
let cameraFrameObjectUrl = null;
|
||||||
|
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
|
||||||
|
let cameraFrameAbortController = null;
|
||||||
let pathVisible = false;
|
let pathVisible = false;
|
||||||
// SLAM always renders its live scan, including the forward beam. Navigation
|
// SLAM always renders its live scan, including the forward beam. Navigation
|
||||||
// keeps the sensor opt-in so a saved-map view starts cleanly.
|
// keeps the sensor opt-in so a saved-map view starts cleanly.
|
||||||
@@ -2052,6 +2070,7 @@ async function refreshSensorPointCloud() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setCameraPreviewAvailable(available) {
|
function setCameraPreviewAvailable(available) {
|
||||||
|
available = available && cameraPreviewEnabled;
|
||||||
cameraPreviewWrap?.classList.toggle('is-visible', available);
|
cameraPreviewWrap?.classList.toggle('is-visible', available);
|
||||||
if (available) return;
|
if (available) return;
|
||||||
if (cameraFrameObjectUrl) {
|
if (cameraFrameObjectUrl) {
|
||||||
@@ -2067,7 +2086,7 @@ function hasLiveCameraState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCameraFrame() {
|
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.
|
// Reuse websocket topic status instead of probing an absent camera with HTTP.
|
||||||
if (!hasLiveCameraState()) {
|
if (!hasLiveCameraState()) {
|
||||||
setCameraPreviewAvailable(false);
|
setCameraPreviewAvailable(false);
|
||||||
@@ -2075,15 +2094,18 @@ async function refreshCameraFrame() {
|
|||||||
}
|
}
|
||||||
if (Date.now() < cameraFrameRetryAt) return;
|
if (Date.now() < cameraFrameRetryAt) return;
|
||||||
cameraFrameRequestInFlight = true;
|
cameraFrameRequestInFlight = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
cameraFrameAbortController = controller;
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), 2000);
|
||||||
try {
|
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) {
|
if (!response.ok) {
|
||||||
cameraFrameRetryAt = Date.now() + 5000;
|
cameraFrameRetryAt = Date.now() + 5000;
|
||||||
setCameraPreviewAvailable(false);
|
setCameraPreviewAvailable(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const frame = await response.blob();
|
const frame = await response.blob();
|
||||||
if (!hasLiveCameraState() || document.hidden) {
|
if (controller.signal.aborted || !cameraPreviewEnabled || !hasLiveCameraState() || document.hidden) {
|
||||||
setCameraPreviewAvailable(false);
|
setCameraPreviewAvailable(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2094,13 +2116,35 @@ async function refreshCameraFrame() {
|
|||||||
if (cameraPreview) cameraPreview.src = nextUrl;
|
if (cameraPreview) cameraPreview.src = nextUrl;
|
||||||
if (previousUrl) URL.revokeObjectURL(previousUrl);
|
if (previousUrl) URL.revokeObjectURL(previousUrl);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
cameraFrameRetryAt = Date.now() + 5000;
|
if (!controller.signal.aborted) cameraFrameRetryAt = Date.now() + 5000;
|
||||||
setCameraPreviewAvailable(false);
|
setCameraPreviewAvailable(false);
|
||||||
} finally {
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
cameraFrameAbortController = null;
|
||||||
cameraFrameRequestInFlight = false;
|
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('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
|
||||||
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
|
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
|
||||||
|
|
||||||
@@ -3231,16 +3275,14 @@ if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) {
|
|||||||
void refreshSensorPointCloud();
|
void refreshSensorPointCloud();
|
||||||
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
|
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
|
||||||
}
|
}
|
||||||
if (ENABLE_CAMERA_PREVIEW) {
|
setCameraPreviewEnabled(ENABLE_CAMERA_PREVIEW);
|
||||||
void refreshCameraFrame();
|
|
||||||
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
|
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
|
||||||
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
|
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
|
||||||
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
|
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
|
||||||
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
|
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
|
||||||
|
cameraFrameAbortController?.abort();
|
||||||
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
|
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const english = {
|
|||||||
'맵 저장': 'Save map',
|
'맵 저장': 'Save map',
|
||||||
'로봇 경로 표시': 'Show robot path',
|
'로봇 경로 표시': 'Show robot path',
|
||||||
'센서 표시': 'Show sensors',
|
'센서 표시': 'Show sensors',
|
||||||
|
'카메라 영상 표시': 'Show camera preview',
|
||||||
'초기 위치 지정': 'Set initial pose',
|
'초기 위치 지정': 'Set initial pose',
|
||||||
'목표 위치 지정': 'Set goal',
|
'목표 위치 지정': 'Set goal',
|
||||||
'카메라 시점': 'Camera view',
|
'카메라 시점': 'Camera view',
|
||||||
|
|||||||
Reference in New Issue
Block a user