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({
+72
View File
@@ -0,0 +1,72 @@
const storageKey = 'autonomy-studio.lms.language';
let language = 'ko';
try { language = localStorage.getItem(storageKey) === 'en' ? 'en' : 'ko'; } catch {}
const english = {
'벽 배치': 'Place walls',
'맵 저장': 'Save map',
'로봇 경로 표시': 'Show robot path',
'센서 표시': 'Show sensors',
'초기 위치 지정': 'Set initial pose',
'목표 위치 지정': 'Set goal',
'카메라 시점': 'Camera view',
'미니맵 접기/펼치기': 'Toggle minimap',
'미니맵 접기': 'Collapse minimap',
'미니맵 펼치기': 'Expand minimap',
'카메라 화면 접기/펼치기': 'Toggle camera preview',
'카메라 화면 접기': 'Collapse camera preview',
'카메라 화면 펼치기': 'Expand camera preview',
'실시간 카메라 화면': 'Live camera preview',
'i 전진 · , 후진 · j/l 회전 · k 정지': 'i Forward · , Reverse · j/l Turn · k Stop',
'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지': 'i Forward · , Reverse · j/l Steer while moving · k Stop',
'ROS 상태 접기/펼치기': 'Toggle ROS status',
'ROS 상태 접기': 'Collapse ROS status',
'ROS 상태 펼치기': 'Expand ROS status',
'ROS 상태': 'ROS status',
'응답을 기다리는 중입니다.': 'Waiting for a response.',
'맵을 저장하고 있습니다.': 'Saving the map.',
'가제보 로봇 위치를 적용하지 못했습니다.': 'Failed to set the Gazebo robot pose.',
'주행 시뮬레이션에서는 초기 위치를 한 번만 설정할 수 있습니다.': 'The initial pose can only be set once during navigation simulation.',
'로봇 초기 위치 설정': 'Set initial robot pose',
'3D 포인트클라우드를 표시 중입니다.': 'Displaying the 3D point cloud.',
'모듈을 연결해주세요.': 'Connect your modules.',
'맵 저장에 실패했습니다.': 'Failed to save the map.',
'기존 목표를 취소하지 못했습니다.': 'Failed to cancel the previous goal.',
'가제보 로봇 위치 적용 시간이 초과되었습니다.': 'Setting the Gazebo robot pose timed out.'
};
export function t(key) { return language === 'en' ? (english[key] ?? key) : key; }
// Only explicitly marked viewer UI is translated. Topic names and telemetry stay intact.
export function setText(element, key) {
element.dataset.i18n = key;
element.textContent = t(key);
}
function refresh() {
document.documentElement.lang = language;
document.querySelectorAll('[data-i18n]').forEach(element => {
element.textContent = t(element.dataset.i18n);
});
for (const attribute of ['title', 'aria-label', 'alt']) {
document.querySelectorAll(`[data-i18n-${attribute}]`).forEach(element => {
element.setAttribute(attribute, t(element.getAttribute(`data-i18n-${attribute}`)));
});
}
}
window.addEventListener('storage', event => {
if (event.key !== storageKey) return;
language = event.newValue === 'en' ? 'en' : 'ko';
refresh();
});
refresh();
// Embedded viewers may not share storage with the app (for example file URLs).
window.addEventListener('message', event => {
if (window.parent === window || event.source !== window.parent) return;
if (event.data?.type !== 'lms:language' || !['ko', 'en'].includes(event.data.language)) return;
language = event.data.language;
refresh();
});
if (window.parent !== window) window.parent.postMessage({ type: 'lms:language:ready' }, '*');
+13 -1
View File
@@ -78,11 +78,23 @@
document.getElementById('splash-image').src = './assets/splash.png'
const el = document.getElementById('loading-text')
const translations = {
'학습 환경 준비 중...': 'Preparing your learning environment...',
'로그인 세션 확인 중...': 'Checking your session...',
'강좌 정보 불러오는 중...': 'Loading courses...',
'커리큘럼 및 플랫폼 정보 가져오는 중...': 'Loading curriculum and platforms...'
}
let language = 'ko'
try {
language = localStorage.getItem('autonomy-studio.lms.language') === 'en' ? 'en' : 'ko'
} catch {}
document.documentElement.lang = language
window.setSplashStatus = (message) => {
if (typeof message === 'string' && message.trim()) {
el.textContent = message
el.textContent = language === 'en' ? (translations[message] ?? message) : message
}
}
window.setSplashStatus(el.textContent)
</script>
</body>
</html>