feat: add Korean-English localization and light theme

Localize studio, curriculum, visualization and loading interfaces. Add persistent language and theme settings, refine module loading states, and normalize remote shell locales.
This commit is contained in:
2026-09-29 13:27:11 +09:00
parent 4125f71792
commit d44180db67
74 changed files with 2519 additions and 998 deletions
+32 -29
View File
@@ -377,50 +377,50 @@
</head>
<body>
<div id="topLeftControls">
<button id="obstacleToggle" title="벽 배치" aria-label="벽 배치" aria-pressed="false">Wall</button>
<button id="obstacleToggle" title="벽 배치" data-i18n-title="벽 배치" aria-label="벽 배치" data-i18n-aria-label="벽 배치" aria-pressed="false">Wall</button>
</div>
<div id="topRightControls">
<div id="downloadWrap">
<button id="downloadMap" title="맵 저장" aria-label="맵 저장" disabled><span>SAVE</span></button>
<button id="downloadMap" title="맵 저장" data-i18n-title="맵 저장" aria-label="맵 저장" data-i18n-aria-label="맵 저장" disabled><span>SAVE</span></button>
</div>
<button id="pathToggle" title="로봇 경로 표시" aria-label="로봇 경로 표시">Path</button>
<button id="sensorToggle" title="센서 표시" aria-label="센서 표시" aria-pressed="false">Sensor</button>
<button id="poseToggle" title="초기 위치 지정" aria-label="초기 위치 지정">Pose</button>
<button id="goalToggle" title="목표 위치 지정" aria-label="목표 위치 지정">Goal</button>
<select id="cameraPresetSelect" title="카메라 시점" aria-label="카메라 시점">
<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="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="카메라 시점">
<option value="default">Default</option>
<option value="top">Top</option>
<option value="follow">Follow</option>
</select>
</div>
<canvas id="miniMap" width="260" height="220"></canvas>
<button id="miniMapToggle" title="미니맵 접기/펼치기" aria-label="미니맵 접기">▾</button>
<button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button>
<div id="cameraPreviewWrap">
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
<img id="cameraPreview" alt="실시간 카메라 화면" />
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
<img id="cameraPreview" alt="실시간 카메라 화면" data-i18n-alt="실시간 카메라 화면" />
</div>
<div id="keyGuide">
<div>i 전진 · , 후진 · j/l 회전 · k 정지</div>
<div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div>
</div>
<div id="rosTopicStatusWrap">
<button id="rosTopicStatusToggle" type="button" title="ROS 상태 접기/펼치기" aria-label="ROS 상태 접기" aria-expanded="true">
<button id="rosTopicStatusToggle" type="button" title="ROS 상태 접기/펼치기" data-i18n-title="ROS 상태 접기/펼치기" aria-label="ROS 상태 접기" data-i18n-aria-label="ROS 상태 접기" aria-expanded="true">
<span id="rosTopicStatusChevron" aria-hidden="true">▾</span>
</button>
<aside id="rosTopicStatus" aria-label="ROS 상태">
<div id="rosTopicStatusTitle">ROS 상태</div>
<aside id="rosTopicStatus" aria-label="ROS 상태" data-i18n-aria-label="ROS 상태">
<div id="rosTopicStatusTitle" data-i18n="ROS 상태">ROS 상태</div>
<div id="rosTopicStatusList"></div>
</aside>
</div>
<div id="responseWaitOverlay" role="status" aria-live="polite">
<div class="response-wait-dialog">
<span class="response-wait-spinner" aria-hidden="true"></span>
<span>응답을 기다리는 중입니다.</span>
<span data-i18n="응답을 기다리는 중입니다.">응답을 기다리는 중입니다.</span>
</div>
</div>
<div id="mapSaveOverlay" role="status" aria-live="polite" aria-hidden="true">
<div class="response-wait-dialog">
<span class="response-wait-spinner" aria-hidden="true"></span>
<span>맵을 저장하고 있습니다.</span>
<span data-i18n="맵을 저장하고 있습니다.">맵을 저장하고 있습니다.</span>
</div>
</div>
<script type="importmap">
@@ -431,7 +431,10 @@
}
}
</script>
<script type="module" src="./locale.js"></script>
<script type="module">
import { t, setText } from './locale.js';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
@@ -501,7 +504,7 @@ const rosTopicStatusList = document.getElementById('rosTopicStatusList');
if (REAL_TELEOP && !EDITOR_MODE) {
document.body.classList.add('real-teleop');
if (REAL_MKMINI_TELEOP) {
document.querySelector('#keyGuide div').textContent = 'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지';
setText(document.querySelector('#keyGuide div'), 'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지');
}
}
if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
@@ -554,7 +557,7 @@ function updateRosTopicStatus(topicStatus = {}) {
rosTopicStatusToggle?.addEventListener('click', () => {
const collapsed = rosTopicStatusWrap?.classList.toggle('is-collapsed') ?? false;
rosTopicStatusToggle.setAttribute('aria-expanded', String(!collapsed));
rosTopicStatusToggle.setAttribute('aria-label', collapsed ? 'ROS 상태 펼치기' : 'ROS 상태 접기');
rosTopicStatusToggle.setAttribute('aria-label', collapsed ? t("ROS 상태 펼치기") : t("ROS 상태 접기"));
});
updateRosTopicStatus();
@@ -858,7 +861,7 @@ window.addEventListener('message', (event) => {
pendingSimulatorPoseRequests.delete(payload.requestId);
window.clearTimeout(request.timeout);
if (payload.ok) request.resolve();
else request.reject(new Error(payload.message || '가제보 로봇 위치를 적용하지 못했습니다.'));
else request.reject(new Error(payload.message || t("가제보 로봇 위치를 적용하지 못했습니다.")));
});
let robotModel = null;
@@ -965,8 +968,8 @@ function syncGoalToggleAvailability() {
goalToggleButton.disabled = goalDisabled;
poseToggleButton.disabled = poseDisabled;
poseToggleButton.title = poseDisabled
? '주행 시뮬레이션에서는 초기 위치를 한 번만 설정할 수 있습니다.'
: '로봇 초기 위치 설정';
? t("주행 시뮬레이션에서는 초기 위치를 한 번만 설정할 수 있습니다.")
: t("로봇 초기 위치 설정");
if (goalDisabled) {
navigationGoalMode = false;
goalToggleButton.classList.remove('active');
@@ -2264,8 +2267,8 @@ function drawMiniMapNow(state) {
miniCtx.textBaseline = 'middle';
miniCtx.fillText(
SHOW_POINT_CLOUD && state?.point_cloud
? '3D 포인트클라우드를 표시 중입니다.'
: '모듈을 연결해주세요.',
? t("3D 포인트클라우드를 표시 중입니다.")
: t("모듈을 연결해주세요."),
w / 2,
h / 2,
);
@@ -2592,10 +2595,10 @@ downloadMapButton.addEventListener('click', async () => {
body: JSON.stringify({})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || payload.message || '맵 저장에 실패했습니다.');
if (!response.ok) throw new Error(payload.detail || payload.message || t("맵 저장에 실패했습니다."));
window.parent.postMessage({ source: 'autonomy-slam-viewer', type: 'map-saved', map: payload.map }, '*');
} catch (error) {
window.parent.postMessage({ source: 'autonomy-slam-viewer', type: 'map-save-failed', message: error instanceof Error ? error.message : '맵 저장에 실패했습니다.' }, '*');
window.parent.postMessage({ source: 'autonomy-slam-viewer', type: 'map-save-failed', message: error instanceof Error ? error.message : t("맵 저장에 실패했습니다.") }, '*');
} finally {
setMapSavePending(false);
downloadMapButton.disabled = !latestMiniMapGrid?.data?.length;
@@ -2616,7 +2619,7 @@ sensorToggleButton.addEventListener('click', () => {
cameraPreviewToggle?.addEventListener('click', () => {
const collapsed = cameraPreviewWrap?.classList.toggle('is-collapsed') ?? false;
cameraPreviewToggle.setAttribute('aria-expanded', String(!collapsed));
cameraPreviewToggle.setAttribute('aria-label', collapsed ? '카메라 화면 펼치기' : '카메라 화면 접기');
cameraPreviewToggle.setAttribute('aria-label', collapsed ? t("카메라 화면 펼치기") : t("카메라 화면 접기"));
});
goalToggleButton.addEventListener('click', () => {
@@ -2677,7 +2680,7 @@ obstacleToggleButton.addEventListener('click', () => {
miniMapToggle.addEventListener('click', () => {
const hidden = miniMap.classList.toggle('hidden');
miniMapToggle.textContent = hidden ? '▴' : '▾';
miniMapToggle.setAttribute('aria-label', hidden ? '미니맵 펼치기' : '미니맵 접기');
miniMapToggle.setAttribute('aria-label', hidden ? t("미니맵 펼치기") : t("미니맵 접기"));
focusViewer();
});
@@ -2911,7 +2914,7 @@ async function sendNavigationGoal(goal) {
const cancelResponse = await fetch(`${BACKEND_URL}/cancel_navigation`, { method:'POST' });
if (!cancelResponse.ok) {
const message = await cancelResponse.text().catch(() => '');
throw new Error(message || '기존 목표를 취소하지 못했습니다.');
throw new Error(message || t("기존 목표를 취소하지 못했습니다."));
}
navigationGoalId = null;
placeNavigationGoalMarker(goal.x, goal.y, goal.yaw);
@@ -2934,7 +2937,7 @@ function moveLocalSimulatorToInitialPose(pose) {
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
pendingSimulatorPoseRequests.delete(requestId);
reject(new Error('가제보 로봇 위치 적용 시간이 초과되었습니다.'));
reject(new Error(t("가제보 로봇 위치 적용 시간이 초과되었습니다.")));
}, 12_000);
pendingSimulatorPoseRequests.set(requestId, { resolve, reject, timeout });
window.parent.postMessage({