feat: add weekly curriculum cover image upload and display

This commit is contained in:
2026-09-23 16:03:33 +09:00
parent a7c9cdc16e
commit 656b1bf62b
23 changed files with 723 additions and 95 deletions
+69 -20
View File
@@ -49,7 +49,7 @@
#cameraPreviewWrap {
position:absolute;
left:16px;
top:48px;
top:16px;
z-index:10;
display:none;
width:min(260px, calc(100vw - 32px));
@@ -69,7 +69,7 @@
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; }
#cameraPreviewToggle {
position:absolute;
right:0;
left:0;
top:100%;
display:grid;
width:30px;
@@ -110,6 +110,8 @@
bottom:16px;
z-index:10;
padding:7px 9px;
max-width:calc(100% - 32px);
box-sizing:border-box;
border:1px solid rgba(255,255,255,.24);
background:rgba(17,24,39,.34);
color:rgba(255,255,255,.82);
@@ -369,8 +371,8 @@
.nav2-navigation #rosTopicStatusWrap { bottom:16px; }
.real-runtime #topLeftControls { display:none; }
.real-runtime #keyGuide { display:none; }
.real-yahboom-teleop:not(.editor-mode) #keyGuide { display:block; }
.real-yahboom-teleop #rosTopicStatusWrap { bottom:72px; }
.real-teleop:not(.editor-mode) #keyGuide { display:block; }
.real-teleop #rosTopicStatusWrap { bottom:50px; }
</style>
</head>
<body>
@@ -444,7 +446,8 @@ const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase();
// Keep the prior real-platform default when an older LMS URL has no platform.
const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME);
const USE_YAHBOOM_MODEL = ROBOT_PLATFORM === 'yahboom';
const REAL_YAHBOOM_TELEOP = REAL_RUNTIME && USE_YAHBOOM_MODEL;
const REAL_MKMINI_TELEOP = REAL_RUNTIME && USE_MKMINI_MODEL;
const REAL_TELEOP = REAL_RUNTIME && (USE_YAHBOOM_MODEL || USE_MKMINI_MODEL);
const SHOW_POINT_CLOUD = params.get('pointCloud') === '1';
const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD;
const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000;
@@ -495,8 +498,11 @@ const rosTopicStatusList = document.getElementById('rosTopicStatusList');
if (EDITOR_MODE) document.body.classList.add('editor-mode');
if (!EDITOR_MODE) document.body.classList.add(`nav2-${NAV2_VIEW_MODE}`);
if (REAL_RUNTIME) document.body.classList.add('real-runtime');
if (REAL_YAHBOOM_TELEOP && !EDITOR_MODE) {
document.body.classList.add('real-yahboom-teleop');
if (REAL_TELEOP && !EDITOR_MODE) {
document.body.classList.add('real-teleop');
if (REAL_MKMINI_TELEOP) {
document.querySelector('#keyGuide div').textContent = 'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지';
}
}
if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
@@ -895,6 +901,7 @@ let savedMapPointCloudLoaded = false;
let sensorPointCloudRequestInFlight = false;
let sensorPointCloudRefreshTimer = null;
let cameraFrameRequestInFlight = false;
let cameraFrameRetryAt = 0;
let cameraFrameRefreshTimer = null;
let cameraFrameObjectUrl = null;
let pathVisible = false;
@@ -2051,28 +2058,47 @@ function setCameraPreviewAvailable(available) {
if (cameraPreview) cameraPreview.removeAttribute('src');
}
function hasLiveCameraState() {
return latestSlamState?.topic_status?.camera_image?.available === true
&& Date.now() - latestSlamStateReceivedAt < 3000;
}
async function refreshCameraFrame() {
if (!ENABLE_CAMERA_PREVIEW || cameraFrameRequestInFlight || document.hidden) return;
// Reuse websocket topic status instead of probing an absent camera with HTTP.
if (!hasLiveCameraState()) {
setCameraPreviewAvailable(false);
return;
}
if (Date.now() < cameraFrameRetryAt) return;
cameraFrameRequestInFlight = true;
try {
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: AbortSignal.timeout(2000) });
if (!response.ok) {
cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
return;
}
const nextUrl = URL.createObjectURL(await response.blob());
const frame = await response.blob();
if (!hasLiveCameraState() || document.hidden) {
setCameraPreviewAvailable(false);
return;
}
cameraFrameRetryAt = 0;
const nextUrl = URL.createObjectURL(frame);
const previousUrl = cameraFrameObjectUrl;
cameraFrameObjectUrl = nextUrl;
if (cameraPreview) cameraPreview.src = nextUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl);
} catch (_) {
cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
} finally {
cameraFrameRequestInFlight = false;
}
}
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(true));
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
function drawMiniMap(state) {
@@ -3217,7 +3243,7 @@ function connectControlSocket() {
if (!CONTROL_WS) return;
controlSocket = new WebSocket(CONTROL_WS);
controlSocket.addEventListener('close', () => {
if (REAL_YAHBOOM_TELEOP) {
if (REAL_TELEOP) {
activeMoveKey = null;
stopRobot();
}
@@ -3228,8 +3254,16 @@ function connectControlSocket() {
});
}
let maxLinear = REAL_YAHBOOM_TELEOP ? 0.1 : 0.22;
let maxAngular = REAL_YAHBOOM_TELEOP ? 0.5 : 2.84;
let maxLinear = REAL_TELEOP ? 0.1 : 0.22;
let maxAngular = REAL_TELEOP ? 0.5 : 2.84;
const mkMiniHeldKeys = new Set();
const MKMINI_REAL_ANGULAR_SPEED = 0.08;
function teleopKey(event) {
const key = event.key.toLowerCase();
return REAL_MKMINI_TELEOP
? ({ arrowup: 'i', arrowdown: ',', arrowleft: 'j', arrowright: 'l', ' ': 'k' }[key] ?? key)
: key;
}
let activeMoveKey = null;
let teleopInterval = null;
let singleMoveStopTimer = null;
@@ -3253,7 +3287,7 @@ const speedBindings = {
};
function canUseTeleop(event) {
if (REAL_RUNTIME && (!REAL_YAHBOOM_TELEOP || !realTeleopReady())) return false;
if (EDITOR_MODE || (REAL_RUNTIME && (!REAL_TELEOP || !realTeleopReady()))) return false;
if (!document.hasFocus()) return false;
if (document.hidden || event.ctrlKey || event.altKey || event.metaKey || event.target?.isContentEditable) return false;
const tag = event.target?.tagName?.toLowerCase();
@@ -3292,7 +3326,7 @@ function flushQueuedCmdVel() {
function sendCmdVel(linear, angular) {
const payload = { type:'cmd_vel', linear, angular };
if (REAL_YAHBOOM_TELEOP) {
if (REAL_TELEOP) {
// Never queue physical motion for replay after a connection recovers.
if (!controlSocket || controlSocket.readyState !== WebSocket.OPEN) return;
if ((linear || angular) && !realTeleopReady()) return;
@@ -3346,6 +3380,7 @@ function setSimulatorMkMiniSteering(angle) {
}
function stopRobot() {
mkMiniHeldKeys.clear();
if (teleopInterval) {
window.clearInterval(teleopInterval);
teleopInterval = null;
@@ -3359,13 +3394,25 @@ function stopRobot() {
function sendActiveMoveCommand() {
if (!activeMoveKey) return;
if (REAL_YAHBOOM_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) {
if (REAL_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) {
activeMoveKey = null;
stopRobot();
return;
}
const [linearSign, angularSign] = moveBindings[activeMoveKey];
if (linearSign === 0 && angularSign === 0) return;
if (REAL_MKMINI_TELEOP) {
let drive = 0;
let steering = 0;
for (const key of mkMiniHeldKeys) {
drive += moveBindings[key][0];
steering += moveBindings[key][1];
}
// The TOD adapter uses abs(linear) for steering: keep wheel direction
// consistent in reverse, and never turn a steering-only key into motion.
sendCmdVel(Math.sign(drive) * maxLinear, drive ? Math.sign(steering) * MKMINI_REAL_ANGULAR_SPEED : 0);
return;
}
if (USE_MKMINI_MODEL && !REAL_RUNTIME) {
if (activeMoveKey === 'j' || activeMoveKey === 'l') {
@@ -3389,6 +3436,7 @@ function sendActiveMoveCommand() {
function startActiveMoveCommand() {
sendActiveMoveCommand();
if (!activeMoveKey) return;
if (teleopInterval) return;
teleopInterval = window.setInterval(sendActiveMoveCommand, TELEOP_COMMAND_INTERVAL_MS);
}
@@ -3406,7 +3454,7 @@ function sendSingleMoveCommand(key) {
window.addEventListener('keydown', (event) => {
if (!canUseTeleop(event)) return;
const key = event.key.toLowerCase();
const key = teleopKey(event);
if (event.repeat) {
if (moveBindings[key]) event.preventDefault();
@@ -3415,7 +3463,7 @@ window.addEventListener('keydown', (event) => {
if (speedBindings[key]) {
event.preventDefault();
if (REAL_YAHBOOM_TELEOP) return;
if (REAL_TELEOP) return;
const [linearScale, angularScale] = speedBindings[key];
maxLinear = Math.max(0.01, maxLinear * linearScale);
maxAngular = Math.max(0.1, maxAngular * angularScale);
@@ -3431,7 +3479,8 @@ window.addEventListener('keydown', (event) => {
stopRobot();
return;
}
if (REAL_YAHBOOM_TELEOP) {
if (REAL_TELEOP) {
if (REAL_MKMINI_TELEOP) mkMiniHeldKeys.add(key);
activeMoveKey = key;
startActiveMoveCommand();
return;
@@ -3451,7 +3500,7 @@ window.addEventListener('keydown', (event) => {
});
window.addEventListener('keyup', (event) => {
if (REAL_YAHBOOM_TELEOP && event.key.toLowerCase() === activeMoveKey) {
if (REAL_TELEOP && (teleopKey(event) === activeMoveKey || (REAL_MKMINI_TELEOP && mkMiniHeldKeys.has(teleopKey(event))))) {
activeMoveKey = null;
stopRobot();
return;
@@ -3468,7 +3517,7 @@ window.addEventListener('blur', () => {
});
function releaseRealTeleop() {
if (!REAL_YAHBOOM_TELEOP || !activeMoveKey) return;
if (!REAL_TELEOP || !activeMoveKey) return;
activeMoveKey = null;
stopRobot();
}