diff --git a/doc/debug/模拟器控制面板重构方案.md b/doc/debug/模拟器控制面板重构方案.md new file mode 100644 index 0000000..9e650da --- /dev/null +++ b/doc/debug/模拟器控制面板重构方案.md @@ -0,0 +1,108 @@ +# 模拟器控制面板重构方案 + +## 目标 + +在不破坏现有老版面板的前提下,新增一套新版控制面板,用于承接更复杂的开发调试工作流。 + +重构目标: + +- 保留老版入口,确保已有使用习惯不受影响 +- 新增工作台式面板,提升连接、控制、观察、排障效率 +- 继续复用现有模拟器脚本和 websocket 协议,避免维护两套逻辑 + +## 设计原则 + +1. 新旧并行 + - 新版入口使用 `/` + - 旧版入口保留在 `/v1/` +2. 逻辑复用 + - 两个页面共用 `simulator.js` + - 只通过不同 HTML 布局和 CSS 风格区分 +3. 面向调试流程 + - 连接优先 + - 控制第二 + - 观察第三 + - 日志独立 + +## 新版布局 + +新版面板采用工作台布局: + +- 顶部:连接状态条 +- 左侧:控制区 +- 中间:地图与路径预览 +- 右侧:状态摘要与快捷观察 +- 右下:调试日志浮层 + +## 功能分区 + +### 1. 顶部连接条 + +包含: + +- 定位模拟连接状态 +- 心率模拟连接状态 +- 调试日志连接状态 +- 一键连接开发调试源 +- 新旧面板切换入口 + +### 2. 左侧控制区 + +包含: + +- 资源加载 +- 定位实时发送 +- 路径回放 +- 心率模拟 +- 新网关桥接 + +采用折叠分组,默认展开高频项。 + +### 3. 中间地图区 + +保留现有 Leaflet 地图和轨迹预览能力,作为核心观察区。 + +### 4. 右侧状态摘要 + +包含: + +- 当前经纬度 +- 当前航向 +- 当前路径点数 +- 最近发送状态 +- 最近心率发送状态 +- 资源加载摘要 +- 网关桥接摘要 + +### 5. 日志区 + +日志继续做成浮层: + +- 默认悬浮在地图右下 +- 可清空 +- 面积更大 +- 便于边看地图边看日志 + +## 与旧版的关系 + +旧版和新版应同时可用: + +- 新版作为默认工作台 +- 旧版继续作为稳定基线 +- 问题排查时可快速回退旧版 + +## 实施顺序 + +1. 根路径切换到新版工作台 +2. 新增新版样式 `workbench.css` +3. 复用现有 `simulator.js` +4. 旧版页面迁移到 `/v1/` +5. 在旧版和新版之间互相添加跳转入口 +6. 更新 README 和调试文档索引 + +## 验收标准 + +- 老版页面继续正常工作 +- 新版页面可完整使用现有 GPS、心率、日志、路径、网关能力 +- 两个页面共用同一套 websocket 协议和数据逻辑 +- 用户可以在两个版本之间切换 diff --git a/doc/debug/调试文档索引.md b/doc/debug/调试文档索引.md index ba2724f..f77af8d 100644 --- a/doc/debug/调试文档索引.md +++ b/doc/debug/调试文档索引.md @@ -9,6 +9,8 @@ ## 当前主文档 +- [模拟器控制面板重构方案](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md) + 用于说明新版模拟器工作台布局、新旧并行策略和重构目标。 - [平台能力说明](/D:/dev/cmr-mini/doc/debug/平台能力说明.md) 用于记录主体能力、`web-view`、传感器等平台边界。 - [模拟器调试日志方案](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md) @@ -21,9 +23,10 @@ ## 推荐阅读顺序 1. [platform-capability-notes.md](/D:/dev/cmr-mini/doc/debug/平台能力说明.md) -2. [sensor-current-summary.md](/D:/dev/cmr-mini/doc/debug/传感器现状总结.md) -3. [mock-simulator-debug-log-proposal.md](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md) -4. [compass-debugging-notes.md](/D:/dev/cmr-mini/doc/debug/罗盘排障记录.md) +2. [mock-simulator-control-panel-proposal.md](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md) +3. [sensor-current-summary.md](/D:/dev/cmr-mini/doc/debug/传感器现状总结.md) +4. [mock-simulator-debug-log-proposal.md](/D:/dev/cmr-mini/doc/debug/模拟器调试日志方案.md) +5. [compass-debugging-notes.md](/D:/dev/cmr-mini/doc/debug/罗盘排障记录.md) ## 使用建议 @@ -31,4 +34,3 @@ - 看“现在系统是什么状态”,优先看传感器现状总结。 - 看“以后日志怎么打”,优先看模拟器日志方案。 - 看“为什么罗盘以前坏过”,再去看罗盘问题记录。 - diff --git a/doc/文档索引.md b/doc/文档索引.md index e37f17c..61b84da 100644 --- a/doc/文档索引.md +++ b/doc/文档索引.md @@ -26,6 +26,7 @@ ## 调试 - [调试文档索引](/D:/dev/cmr-mini/doc/debug/调试文档索引.md) +- [模拟器控制面板重构方案](/D:/dev/cmr-mini/doc/debug/模拟器控制面板重构方案.md) ## 网关 @@ -40,4 +41,3 @@ - 长期保留的少量工作便签见 [notes](/D:/dev/cmr-mini/doc/notes)。 - 历史方案稿和阶段性讨论稿已移到 [archive](/D:/dev/cmr-mini/doc/archive/归档索引.md)。 - 正式阅读建议优先从本页和配置索引进入,不再直接平铺浏览全部文档。 - diff --git a/tools/mock-gps-sim/README.md b/tools/mock-gps-sim/README.md index f28b4ce..156f851 100644 --- a/tools/mock-gps-sim/README.md +++ b/tools/mock-gps-sim/README.md @@ -10,7 +10,8 @@ npm run mock-gps-sim 启动后: -- 控制台页面: `http://127.0.0.1:17865/` +- 新版工作台: `http://127.0.0.1:17865/` +- 旧版面板: `http://127.0.0.1:17865/v1/` - 小程序定位模拟地址: `ws://127.0.0.1:17865/mock-gps` - 小程序心率模拟地址: `ws://127.0.0.1:17865/mock-hr` - 小程序调试日志地址: `ws://127.0.0.1:17865/debug-log` @@ -88,6 +89,12 @@ ws://127.0.0.1:17865/debug-log http://127.0.0.1:17865/ ``` +如果需要旧版稳定界面,打开: + +```text +http://127.0.0.1:17865/v1/ +``` + 在“新网关桥接”区域可以直接配置: - 是否启用桥接 diff --git a/tools/mock-gps-sim/public/index.html b/tools/mock-gps-sim/public/index.html index 7ff2a23..452a1b5 100644 --- a/tools/mock-gps-sim/public/index.html +++ b/tools/mock-gps-sim/public/index.html @@ -3,226 +3,284 @@ - Mock GPS Simulator + Mock GPS Simulator Workbench - + -
- - -
-
-
-
-
调试日志
- -
-
-
-
+
diff --git a/tools/mock-gps-sim/public/simulator.js b/tools/mock-gps-sim/public/simulator.js index 6b77698..c991272 100644 --- a/tools/mock-gps-sim/public/simulator.js +++ b/tools/mock-gps-sim/public/simulator.js @@ -44,6 +44,7 @@ debugSocket: null, connected: false, heartRateConnected: false, + debugConnected: false, socketConnecting: false, heartRateSocketConnecting: false, debugSocketConnecting: false, @@ -74,6 +75,9 @@ bridgeLastStatusText: '--', bridgeConfigSaving: false, bridgePresets: [], + debugLogEntries: [], + debugLogScopeFilter: 'all', + debugLogPanelMinimized: false, } const elements = { @@ -143,7 +147,20 @@ pathCountText: document.getElementById('pathCountText'), log: document.getElementById('log'), debugLog: document.getElementById('debugLog'), + debugLogMeta: document.getElementById('debugLogMeta'), clearDebugLogBtn: document.getElementById('clearDebugLogBtn'), + debugLogScopeFilter: document.getElementById('debugLogScopeFilter'), + floatingDebugLogPanel: document.getElementById('floatingDebugLogPanel'), + toggleDebugLogPanelBtn: document.getElementById('toggleDebugLogPanelBtn'), + topGpsStatus: document.getElementById('topGpsStatus'), + topHrStatus: document.getElementById('topHrStatus'), + topLoggerStatus: document.getElementById('topLoggerStatus'), + topGatewayStatus: document.getElementById('topGatewayStatus'), + summaryResourceText: document.getElementById('summaryResourceText'), + summaryGpsSendText: document.getElementById('summaryGpsSendText'), + summaryHrSendText: document.getElementById('summaryHrSendText'), + summaryPathText: document.getElementById('summaryPathText'), + summaryGatewayText: document.getElementById('summaryGatewayText'), } elements.configUrlInput.value = DEFAULT_CONFIG_URL @@ -165,22 +182,79 @@ return } - const time = new Date(entry.timestamp || Date.now()).toLocaleTimeString() - const scope = String(entry.scope || 'app') - const level = String(entry.level || 'info').toUpperCase() - const message = String(entry.message || '') - const payloadText = entry.payload ? ` ${JSON.stringify(entry.payload)}` : '' - const nextText = `[${time}] [${scope}] [${level}] ${message}${payloadText}\n${elements.debugLog.textContent || ''}` - elements.debugLog.textContent = nextText - .split('\n') - .slice(0, MAX_DEBUG_LOG_LINES) - .join('\n') + const normalized = { + timestamp: entry.timestamp || Date.now(), + scope: String(entry.scope || 'app'), + level: String(entry.level || 'info'), + message: String(entry.message || ''), + payload: entry.payload && typeof entry.payload === 'object' ? entry.payload : null, + } + state.debugLogEntries.unshift(normalized) + if (state.debugLogEntries.length > MAX_DEBUG_LOG_LINES) { + state.debugLogEntries = state.debugLogEntries.slice(0, MAX_DEBUG_LOG_LINES) + } + renderDebugScopeOptions() + renderDebugLog() } function clearDebugLog() { - if (elements.debugLog) { - elements.debugLog.textContent = '' + state.debugLogEntries = [] + renderDebugScopeOptions() + renderDebugLog() + } + + function renderDebugScopeOptions() { + if (!elements.debugLogScopeFilter) { + return } + + const staticOptions = ['all', 'logger', 'gps-logo', 'gps', 'heart-rate', 'track', 'compass', 'h5', 'content-card', 'gateway'] + const seenScopes = new Set(staticOptions) + state.debugLogEntries.forEach((entry) => { + if (entry.scope) { + seenScopes.add(entry.scope) + } + }) + + const options = Array.from(seenScopes) + const currentValue = options.includes(state.debugLogScopeFilter) ? state.debugLogScopeFilter : 'all' + elements.debugLogScopeFilter.innerHTML = options + .map((scope) => ``) + .join('') + elements.debugLogScopeFilter.value = currentValue + state.debugLogScopeFilter = currentValue + } + + function renderDebugLog() { + if (!elements.debugLog) { + return + } + + const filteredEntries = state.debugLogEntries.filter((entry) => { + return state.debugLogScopeFilter === 'all' || entry.scope === state.debugLogScopeFilter + }) + + if (elements.debugLogMeta) { + const scopeLabel = state.debugLogScopeFilter === 'all' ? '全部' : state.debugLogScopeFilter + elements.debugLogMeta.textContent = `${scopeLabel} · ${filteredEntries.length} 条` + } + + elements.debugLog.textContent = filteredEntries + .map((entry) => { + const time = new Date(entry.timestamp || Date.now()).toLocaleTimeString() + const level = String(entry.level || 'info').toUpperCase() + const payloadText = entry.payload ? ` ${JSON.stringify(entry.payload)}` : '' + return `[${time}] [${entry.scope}] [${level}] ${entry.message}${payloadText}` + }) + .join('\n') + } + + function updateDebugLogPanelState() { + if (!elements.floatingDebugLogPanel || !elements.toggleDebugLogPanelBtn) { + return + } + elements.floatingDebugLogPanel.classList.toggle('is-minimized', state.debugLogPanelMinimized) + elements.toggleDebugLogPanelBtn.textContent = state.debugLogPanelMinimized ? '展开' : '缩小' } function setResourceStatus(message, tone) { @@ -206,6 +280,19 @@ elements.socketStatus.className = connected ? 'badge badge--ok' : 'badge badge--muted' } + function setConnectionValue(element, text, tone) { + if (!element) { + return + } + element.textContent = text + element.classList.remove('is-ok', 'is-warn') + if (tone === 'ok') { + element.classList.add('is-ok') + } else if (tone === 'warn') { + element.classList.add('is-warn') + } + } + function formatClockTime(timestamp) { if (!timestamp) { return '--' @@ -304,6 +391,43 @@ } else { elements.playbackStatus.textContent = '路径待命' } + + setConnectionValue( + elements.topGpsStatus, + state.connected ? (state.streaming ? '发送中' : '已连接') : state.socketConnecting ? '连接中' : '未连接', + state.connected ? 'ok' : state.socketConnecting ? 'warn' : null + ) + setConnectionValue( + elements.topHrStatus, + state.heartRateConnected ? (state.heartRateStreaming ? '发送中' : '已连接') : state.heartRateSocketConnecting ? '连接中' : '未连接', + state.heartRateConnected ? 'ok' : state.heartRateSocketConnecting ? 'warn' : null + ) + setConnectionValue( + elements.topLoggerStatus, + state.debugConnected ? '已连接' : state.debugSocketConnecting ? '连接中' : '未连接', + state.debugConnected ? 'ok' : state.debugSocketConnecting ? 'warn' : null + ) + setConnectionValue( + elements.topGatewayStatus, + !state.bridgeEnabled ? '未启用' : state.bridgeConnected && state.bridgeAuthenticated ? '已认证' : state.bridgeConnected ? '待认证' : '未连接', + state.bridgeConnected && state.bridgeAuthenticated ? 'ok' : state.bridgeEnabled ? 'warn' : null + ) + + if (elements.summaryResourceText) { + elements.summaryResourceText.textContent = state.resourceLoading ? '载入中' : state.loadedCourse ? '已载入' : '未载入' + } + if (elements.summaryGpsSendText) { + elements.summaryGpsSendText.textContent = state.connected ? (state.streaming ? `${elements.hzSelect.value} Hz` : '待命') : '未连接' + } + if (elements.summaryHrSendText) { + elements.summaryHrSendText.textContent = state.heartRateConnected ? (state.heartRateStreaming ? `${elements.heartRateHzSelect.value} Hz` : '待命') : '未连接' + } + if (elements.summaryPathText) { + elements.summaryPathText.textContent = state.playbackRunning ? '回放中' : state.pathEditMode ? '编辑中' : pathPoints.length >= 2 ? `${pathPoints.length} 点` : '待命' + } + if (elements.summaryGatewayText) { + elements.summaryGatewayText.textContent = !state.bridgeEnabled ? '未启用' : state.bridgeConnected && state.bridgeAuthenticated ? '已认证' : state.bridgeConnected ? '待认证' : '未连接' + } } function bridgeConfigFromServerPayload(payload) { @@ -587,6 +711,7 @@ const socket = new WebSocket(DEBUG_LOG_WS_URL) state.debugSocket = socket + state.debugConnected = false state.debugSocketConnecting = true log(`连接日志通道 ${DEBUG_LOG_WS_URL}`) @@ -605,20 +730,27 @@ socket.addEventListener('open', () => { state.debugSocketConnecting = false + state.debugSocket = socket + state.debugConnected = true log('日志通道已连接') + updateUiState() }) socket.addEventListener('close', () => { state.debugSocketConnecting = false state.debugSocket = null + state.debugConnected = false log('日志通道已断开') + updateUiState() window.setTimeout(connectDebugSocket, 1500) }) socket.addEventListener('error', () => { state.debugSocketConnecting = false state.debugSocket = null + state.debugConnected = false log('日志通道连接失败') + updateUiState() }) } @@ -1803,9 +1935,24 @@ if (elements.clearDebugLogBtn) { elements.clearDebugLogBtn.addEventListener('click', clearDebugLog) } + if (elements.toggleDebugLogPanelBtn) { + elements.toggleDebugLogPanelBtn.addEventListener('click', () => { + state.debugLogPanelMinimized = !state.debugLogPanelMinimized + updateDebugLogPanelState() + }) + } + if (elements.debugLogScopeFilter) { + elements.debugLogScopeFilter.addEventListener('change', () => { + state.debugLogScopeFilter = elements.debugLogScopeFilter.value || 'all' + renderDebugLog() + }) + } updateReadout() setSocketBadge(false) + renderDebugScopeOptions() + renderDebugLog() + updateDebugLogPanelState() setResourceStatus('支持直接载入 game.json,也支持单独填瓦片模板和 KML 地址。', null) state.bridgePresets = loadBridgePresets() renderBridgePresetOptions('') diff --git a/tools/mock-gps-sim/public/style.css b/tools/mock-gps-sim/public/style.css index 8517969..0fc89dd 100644 --- a/tools/mock-gps-sim/public/style.css +++ b/tools/mock-gps-sim/public/style.css @@ -33,6 +33,17 @@ body { font-size: 28px; } +.panel__links { + margin: 0 0 10px; +} + +.panel__links a { + color: #24523e; + text-decoration: none; + font-size: 13px; + font-weight: 700; +} + .panel__eyebrow { font-weight: 800; letter-spacing: 0.18em; diff --git a/tools/mock-gps-sim/public/v1/index.html b/tools/mock-gps-sim/public/v1/index.html new file mode 100644 index 0000000..1f91132 --- /dev/null +++ b/tools/mock-gps-sim/public/v1/index.html @@ -0,0 +1,231 @@ + + + + + + Mock GPS Simulator v1 + + + + +
+ + +
+
+
+
+
调试日志
+ +
+
+
+
+
+ + + + + diff --git a/tools/mock-gps-sim/public/workbench.css b/tools/mock-gps-sim/public/workbench.css new file mode 100644 index 0000000..3c827d9 --- /dev/null +++ b/tools/mock-gps-sim/public/workbench.css @@ -0,0 +1,555 @@ +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; + font-family: "Segoe UI", "PingFang SC", sans-serif; + background: + radial-gradient(circle at top left, rgba(68, 161, 124, 0.16), transparent 34%), + linear-gradient(160deg, #edf5ef 0%, #dbe8df 100%); + color: #163126; + overflow: hidden; +} + +.wb-shell { + height: 100vh; + display: grid; + grid-template-rows: auto 1fr; +} + +.wb-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 18px 24px; + background: rgba(248, 251, 247, 0.92); + border-bottom: 1px solid rgba(22, 49, 38, 0.08); + backdrop-filter: blur(14px); +} + +.wb-topbar__eyebrow { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.24em; + color: #6a8778; +} + +.wb-topbar h1 { + margin: 6px 0 8px; + font-size: 30px; + line-height: 1.1; +} + +.wb-topbar__links { + display: flex; + flex-wrap: wrap; + gap: 14px; +} + +.wb-topbar__links a { + color: #24523e; + text-decoration: none; + font-size: 13px; + font-weight: 700; +} + +.wb-topbar__status { + display: flex; + align-items: center; + gap: 16px; +} + +.wb-connection-bar { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + +.wb-connection-pill { + min-width: 112px; + padding: 10px 14px; + border-radius: 16px; + background: rgba(235, 242, 236, 0.95); + box-shadow: inset 0 0 0 1px rgba(22, 49, 38, 0.06); +} + +.wb-connection-pill__label { + display: block; + margin-bottom: 4px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + color: #6f8a7d; +} + +.wb-connection-pill__value { + display: block; + font-size: 13px; + color: #2b4338; +} + +.wb-connection-pill__value.is-ok { + color: #0a7a3d; +} + +.wb-connection-pill__value.is-warn { + color: #9a5b10; +} + +.wb-layout { + min-height: 0; + display: grid; + grid-template-columns: 380px 1fr; + gap: 18px; + padding: 18px; +} + +.wb-sidebar { + min-height: 0; + overflow-y: auto; + padding-right: 4px; +} + +.wb-stage { + position: relative; + min-height: 0; + overflow: hidden; + border-radius: 28px; + box-shadow: 0 28px 60px rgba(20, 41, 31, 0.18); +} + +.wb-bottom-strip { + padding: 0 18px 18px; +} + +.wb-card--bottom .log { + max-height: 180px; +} + +#map { + width: 100%; + height: 100%; +} + +.wb-section, +.wb-card { + margin-bottom: 14px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(255, 255, 255, 0.7); + box-shadow: 0 16px 40px rgba(25, 44, 34, 0.08); + overflow: hidden; +} + +.wb-section summary, +.wb-card__title { + list-style: none; + cursor: pointer; + padding: 18px 18px 16px; + font-size: 14px; + font-weight: 800; + letter-spacing: 0.08em; + color: #537062; +} + +.wb-section summary::-webkit-details-marker { + display: none; +} + +.wb-section[open] summary { + border-bottom: 1px solid rgba(22, 49, 38, 0.06); +} + +.wb-section__body, +.wb-card { + padding: 0 18px 18px; +} + +.wb-card__title { + padding: 18px 18px 12px; + margin: 0 -18px 6px; +} + +.badge { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 14px; + border-radius: 999px; + font-size: 13px; + font-weight: 700; +} + +.badge--muted { + background: #e5ece5; + color: #4f6458; +} + +.badge--ok { + background: #d8f7e3; + color: #0a7a3d; +} + +.group__status, +.hint { + min-height: 18px; + margin: 0 0 12px; + font-size: 12px; + line-height: 1.5; + color: #5e786d; +} + +.row { + display: flex; + gap: 10px; + margin-bottom: 10px; +} + +.btn { + flex: 1; + min-height: 42px; + border: 0; + border-radius: 14px; + background: #ebf0ea; + color: #193226; + font-weight: 700; + cursor: pointer; + transition: transform 120ms ease, background 120ms ease, color 120ms ease, opacity 120ms ease; +} + +.btn:hover { + transform: translateY(-1px); +} + +.btn--primary { + background: #103f2f; + color: #fff; +} + +.btn--accent { + background: #0ea5a4; + color: #fff; +} + +.btn.is-active { + outline: 2px solid #ffb300; +} + +.btn:disabled { + opacity: 0.56; + cursor: not-allowed; + transform: none; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; + font-size: 13px; + color: #557266; +} + +.field input, +.field select { + min-height: 40px; + border: 1px solid rgba(22, 49, 38, 0.12); + border-radius: 12px; + padding: 0 12px; + font: inherit; + background: rgba(249, 252, 249, 0.96); +} + +.field--check { + flex-direction: row; + align-items: center; +} + +.file-input-hidden { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.stat { + display: flex; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid rgba(22, 49, 38, 0.06); +} + +.stat:last-child { + border-bottom: 0; +} + +.stat span { + color: #668073; + font-size: 13px; +} + +.stat strong { + font-size: 14px; +} + +.log { + min-height: 180px; + max-height: 300px; + overflow-y: auto; + padding: 12px 14px; + border-radius: 16px; + background: #f3f7f1; + font-size: 12px; + line-height: 1.6; + color: #486257; + white-space: pre-wrap; +} + +.log--debug { + max-height: 320px; + background: #111917; + color: #d6f3df; + font-family: Consolas, "SFMono-Regular", monospace; +} + +.log--floating { + min-height: 340px; + max-height: min(54vh, 560px); + font-size: 13px; +} + +.jump-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.jump-chip { + min-height: 32px; + padding: 0 12px; + border: 0; + border-radius: 999px; + background: #eef6ea; + color: #244132; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.jump-chip--start { + background: #fff0c9; +} + +.jump-chip--finish { + background: #ffe2b8; +} + +.floating-debug-log { + position: absolute; + right: 18px; + bottom: 18px; + z-index: 600; + width: min(620px, calc(100vw - 500px)); + min-width: 440px; + max-width: 720px; + padding: 14px; + border-radius: 22px; + background: rgba(255, 255, 255, 0.94); + border: 1px solid rgba(255, 255, 255, 0.52); + box-shadow: 0 22px 60px rgba(17, 33, 26, 0.22); + backdrop-filter: blur(16px); + transition: width 160ms ease, min-width 160ms ease, max-width 160ms ease, transform 160ms ease, box-shadow 160ms ease; +} + +.floating-debug-log.is-minimized { + width: 236px; + min-width: 236px; + max-width: 236px; + padding: 12px 14px; + border-radius: 18px; + box-shadow: 0 16px 36px rgba(17, 33, 26, 0.18); + transform: translateY(4px); +} + +.floating-debug-log.is-minimized .log--floating, +.floating-debug-log.is-minimized .floating-debug-log__filter, +.floating-debug-log.is-minimized .floating-debug-log__clear { + display: none; +} + +.floating-debug-log.is-minimized .floating-debug-log__header { + margin-bottom: 0; +} + +.floating-debug-log.is-minimized .floating-debug-log__actions { + gap: 0; +} + +.floating-debug-log__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.floating-debug-log__title-wrap { + display: flex; + flex-direction: column; + gap: 4px; +} + +.floating-debug-log__title { + font-size: 14px; + font-weight: 800; + letter-spacing: 0.08em; + color: #4a6a5e; +} + +.floating-debug-log__meta { + font-size: 12px; + color: #6a8278; +} + +.floating-debug-log__clear { + min-height: 30px; + padding: 0 12px; + border: 0; + border-radius: 999px; + background: rgba(17, 33, 26, 0.1); + color: #244132; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.floating-debug-log__toggle { + min-height: 30px; + padding: 0 12px; + border: 0; + border-radius: 999px; + background: rgba(17, 33, 26, 0.1); + color: #244132; + font-size: 12px; + font-weight: 700; + cursor: pointer; +} + +.floating-debug-log__actions { + display: flex; + align-items: center; + gap: 10px; +} + +.floating-debug-log__filter { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #557266; +} + +.floating-debug-log__filter select { + min-height: 30px; + border: 1px solid rgba(22, 49, 38, 0.12); + border-radius: 10px; + padding: 0 10px; + background: rgba(249, 252, 249, 0.96); + font: inherit; +} + +.leaflet-container { + background: #dfeadb; +} + +.course-marker { + display: flex; + align-items: center; + justify-content: center; +} + +.course-marker__control { + width: 34px; + height: 34px; + border-radius: 999px; + border: 3px solid #cc0077; + color: #cc0077; + background: rgba(255, 255, 255, 0.9); + font-size: 16px; + font-weight: 800; + line-height: 1; +} + +.course-marker__start { + width: 0; + height: 0; + border-left: 16px solid transparent; + border-right: 16px solid transparent; + border-bottom: 28px solid #cc0077; + filter: drop-shadow(0 3px 10px rgba(22, 49, 38, 0.22)); +} + +.course-marker__finish { + position: relative; + width: 36px; + height: 36px; + border-radius: 999px; + border: 4px solid #cc0077; + background: rgba(255, 255, 255, 0.76); +} + +.course-marker__finish::after { + content: ""; + position: absolute; + inset: 6px; + border-radius: 999px; + border: 3px solid #cc0077; +} + +@media (max-width: 1380px) { + .wb-layout { + grid-template-columns: 340px 1fr; + } + + .floating-debug-log { + width: min(520px, calc(100vw - 440px)); + } +} + +@media (max-width: 1120px) { + .wb-layout { + grid-template-columns: 1fr; + grid-template-rows: auto minmax(420px, 1fr); + } + + .wb-sidebar { + max-height: 32vh; + } + + .floating-debug-log { + width: min(420px, calc(100vw - 48px)); + min-width: 0; + } + + .wb-topbar { + flex-direction: column; + align-items: flex-start; + } + + .wb-topbar__status { + width: 100%; + flex-direction: column; + align-items: flex-start; + } + + .wb-bottom-strip { + padding-top: 18px; + } +} diff --git a/tools/mock-gps-sim/server.js b/tools/mock-gps-sim/server.js index e6d5f0e..e45c2ba 100644 --- a/tools/mock-gps-sim/server.js +++ b/tools/mock-gps-sim/server.js @@ -57,7 +57,12 @@ function respondJson(response, statusCode, payload) { } function serveStatic(requestPath, response) { - const safePath = requestPath === '/' ? '/index.html' : requestPath + let safePath = requestPath === '/' ? '/index.html' : requestPath + if (safePath.endsWith('/')) { + safePath = `${safePath}index.html` + } else if (!path.extname(safePath)) { + safePath = `${safePath}/index.html` + } const resolvedPath = path.normalize(path.join(PUBLIC_DIR, safePath)) if (!resolvedPath.startsWith(PUBLIC_DIR)) { response.writeHead(403)