瀏覽代碼

重构模拟器工作台与日志浮层

zhangyan 1 周之前
父節點
當前提交
1635a11780

+ 108 - 0
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 协议和数据逻辑
+- 用户可以在两个版本之间切换

+ 6 - 4
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 @@
 - 看“现在系统是什么状态”,优先看传感器现状总结。
 - 看“以后日志怎么打”,优先看模拟器日志方案。
 - 看“为什么罗盘以前坏过”,再去看罗盘问题记录。
-

+ 1 - 1
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)。
 - 正式阅读建议优先从本页和配置索引进入,不再直接平铺浏览全部文档。
-

+ 8 - 1
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/
+```
+
 在“新网关桥接”区域可以直接配置:
 
 - 是否启用桥接

+ 264 - 206
tools/mock-gps-sim/public/index.html

@@ -3,226 +3,284 @@
   <head>
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
-    <title>Mock GPS Simulator</title>
+    <title>Mock GPS Simulator Workbench</title>
     <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
-    <link rel="stylesheet" href="./style.css">
+    <link rel="stylesheet" href="./workbench.css">
   </head>
   <body>
-    <div class="layout">
-      <aside class="panel">
-        <div class="panel__header">
-          <div class="panel__eyebrow">MOCK GPS SIM</div>
-          <h1>外部模拟器</h1>
-          <div id="socketStatus" class="badge badge--muted">未连接</div>
-        </div>
-
-        <section class="group">
-          <div class="group__title">资源加载</div>
-          <label class="field">
-            <span>游戏配置 URL</span>
-            <input id="configUrlInput" type="text" value="https://oss-mbh5.colormaprun.com/wxmini/test/game.json">
-          </label>
-          <div class="row">
-            <button id="loadConfigBtn" class="btn btn--primary">载入配置</button>
-            <button id="fitCourseBtn" class="btn">适配视野</button>
-          </div>
-          <label class="field">
-            <span>瓦片模板</span>
-            <input id="tileUrlInput" type="text" placeholder="https://host/tiles/{z}/{x}/{y}.webp">
-          </label>
-          <div class="row">
-            <button id="applyTilesBtn" class="btn">应用瓦片</button>
-            <button id="resetTilesBtn" class="btn">恢复 OSM</button>
+    <div class="wb-shell">
+      <header class="wb-topbar">
+        <div class="wb-topbar__brand">
+          <div class="wb-topbar__eyebrow">MOCK GPS SIM</div>
+          <h1>模拟器工作台</h1>
+          <div class="wb-topbar__links">
+            <a href="/v1/">打开旧版面板</a>
           </div>
-          <label class="field">
-            <span>KML URL</span>
-            <input id="courseUrlInput" type="text" placeholder="https://host/course/c01.kml">
-          </label>
-          <div class="row">
-            <button id="loadCourseBtn" class="btn">载入控制点</button>
-            <button id="clearCourseBtn" class="btn">清空控制点</button>
+        </div>
+        <div class="wb-topbar__status">
+          <div class="wb-connection-bar">
+            <div class="wb-connection-pill">
+              <span class="wb-connection-pill__label">定位模拟</span>
+              <strong id="topGpsStatus" class="wb-connection-pill__value">未连接</strong>
+            </div>
+            <div class="wb-connection-pill">
+              <span class="wb-connection-pill__label">心率模拟</span>
+              <strong id="topHrStatus" class="wb-connection-pill__value">未连接</strong>
+            </div>
+            <div class="wb-connection-pill">
+              <span class="wb-connection-pill__label">调试日志</span>
+              <strong id="topLoggerStatus" class="wb-connection-pill__value">未连接</strong>
+            </div>
+            <div class="wb-connection-pill">
+              <span class="wb-connection-pill__label">网关桥接</span>
+              <strong id="topGatewayStatus" class="wb-connection-pill__value">未启用</strong>
+            </div>
           </div>
-          <div id="resourceStatus" class="hint">支持直接载入 game.json,也支持单独填瓦片模板和 KML 地址。</div>
-          <div id="resourceDetail" class="group__status">尚未载入资源</div>
-          <div id="courseJumpList" class="jump-list"></div>
-        </section>
+          <div id="socketStatus" class="badge badge--muted">未连接</div>
+        </div>
+      </header>
 
-        <section class="group">
-          <div class="group__title">实时发送</div>
-          <div id="realtimeStatus" class="group__status">桥接未连接</div>
-          <div id="lastSendStatus" class="group__status">最近发送: --</div>
-          <div class="row">
-            <button id="connectBtn" class="btn btn--primary">连接桥接</button>
-            <button id="sendOnceBtn" class="btn">发送一次</button>
-          </div>
-          <div class="row">
-            <button id="streamBtn" class="btn btn--accent">开始连续发送</button>
-            <button id="stopStreamBtn" class="btn">停止发送</button>
-          </div>
-          <label class="field">
-            <span>发送频率</span>
-            <select id="hzSelect">
-              <option value="2">2 Hz</option>
-              <option value="5" selected>5 Hz</option>
-              <option value="10">10 Hz</option>
-            </select>
-          </label>
-          <label class="field">
-            <span>精度 (m)</span>
-            <input id="accuracyInput" type="number" min="1" max="100" value="6">
-          </label>
-        </section>
+      <div class="wb-layout">
+        <aside class="wb-sidebar">
+          <section class="wb-card">
+            <div class="wb-card__title">运行摘要</div>
+            <div class="stat"><span>资源状态</span><strong id="summaryResourceText">未载入</strong></div>
+            <div class="stat"><span>定位发送</span><strong id="summaryGpsSendText">待命</strong></div>
+            <div class="stat"><span>心率发送</span><strong id="summaryHrSendText">待命</strong></div>
+            <div class="stat"><span>路径状态</span><strong id="summaryPathText">待命</strong></div>
+            <div class="stat"><span>网关桥接</span><strong id="summaryGatewayText">未启用</strong></div>
+          </section>
 
-        <section class="group">
-          <div class="group__title">新网关桥接</div>
-          <div id="gatewayBridgeStatus" class="group__status">未启用</div>
-          <div id="gatewayBridgeTarget" class="group__status">目标设备: --</div>
-          <div id="gatewayBridgeLast" class="group__status">最近状态: --</div>
-          <label class="field">
-            <span>桥接预设</span>
-            <select id="gatewayBridgePresetSelect">
-              <option value="">选择预设</option>
-            </select>
-          </label>
-          <label class="field">
-            <span>预设名称</span>
-            <input id="gatewayBridgePresetNameInput" type="text" placeholder="例如:家长端-A / 场控-B">
-          </label>
-          <div class="row">
-            <button id="applyGatewayBridgePresetBtn" class="btn">套用预设</button>
-            <button id="saveGatewayBridgePresetBtn" class="btn">保存预设</button>
-          </div>
-          <div class="row">
-            <button id="deleteGatewayBridgePresetBtn" class="btn">删除预设</button>
-          </div>
-          <label class="field field--check">
-            <input id="gatewayBridgeEnabledInput" type="checkbox">
-            <span>启用新网关桥接</span>
-          </label>
-            <label class="field">
-              <span>网关地址</span>
-              <input id="gatewayBridgeUrlInput" type="text" placeholder="ws://127.0.0.1:18080/ws">
-            </label>
-            <label class="field">
-              <span>Producer Token / Channel Token</span>
-              <input id="gatewayBridgeTokenInput" type="text" placeholder="producerToken 或 dev-producer-token">
-            </label>
-            <label class="field">
-              <span>Channel ID</span>
-              <input id="gatewayBridgeChannelIdInput" type="text" placeholder="ch-xxxx">
-            </label>
-            <label class="field">
-              <span>目标 Device ID</span>
-              <input id="gatewayBridgeDeviceIdInput" type="text" placeholder="child-001">
-            </label>
-          <label class="field">
-            <span>目标 Group ID</span>
-            <input id="gatewayBridgeGroupIdInput" type="text" placeholder="class-a">
-          </label>
-          <label class="field">
-            <span>Source ID</span>
-            <input id="gatewayBridgeSourceIdInput" type="text" placeholder="mock-gps-sim">
-          </label>
-          <label class="field">
-            <span>Source Mode</span>
-            <input id="gatewayBridgeSourceModeInput" type="text" placeholder="mock">
-          </label>
-          <div class="row">
-            <button id="applyGatewayBridgeConfigBtn" class="btn btn--primary">应用桥接配置</button>
-            <button id="reloadGatewayBridgeConfigBtn" class="btn">重新读取</button>
-          </div>
-        </section>
+          <section class="wb-card">
+            <div class="wb-card__title">当前位置</div>
+            <div class="stat"><span>纬度</span><strong id="latText">--</strong></div>
+            <div class="stat"><span>经度</span><strong id="lonText">--</strong></div>
+            <div class="stat"><span>航向</span><strong id="headingText">--</strong></div>
+            <div class="stat"><span>路径点</span><strong id="pathCountText">0</strong></div>
+          </section>
 
-        <section class="group">
-          <div class="group__title">心率模拟</div>
-          <div id="heartRateStatus" class="group__status">心率模拟待命</div>
-          <div id="lastHeartRateStatus" class="group__status">最近发送: --</div>
-          <div class="row">
-            <button id="sendHeartRateOnceBtn" class="btn">发送一次</button>
-            <button id="startHeartRateStreamBtn" class="btn btn--accent">开始连续发送</button>
-          </div>
-          <div class="row">
-            <button id="stopHeartRateStreamBtn" class="btn">停止发送</button>
-            <button id="applyHeartRatePresetBtn" class="btn">应用分区样本</button>
-          </div>
-          <div class="row">
-            <button id="toggleHeartRateSampleBtn" class="btn">模拟真实样本</button>
-          </div>
-          <label class="field">
-            <span>心率值 (bpm)</span>
-            <input id="heartRateInput" type="number" min="40" max="220" value="120">
-          </label>
-          <label class="field">
-            <span>发送频率</span>
-            <select id="heartRateHzSelect">
-              <option value="1" selected>1 Hz</option>
-              <option value="2">2 Hz</option>
-              <option value="4">4 Hz</option>
-            </select>
-          </label>
-          <label class="field">
-            <span>样本模板</span>
-            <select id="heartRateSampleTemplateSelect">
-              <option value="jog" selected>慢跑样本</option>
-              <option value="tempo">节奏跑样本</option>
-              <option value="interval">间歇跑样本</option>
-              <option value="recovery">恢复走样本</option>
-            </select>
-          </label>
-        </section>
+          <details class="wb-section" open>
+            <summary>资源加载</summary>
+            <div class="wb-section__body">
+              <label class="field">
+                <span>游戏配置 URL</span>
+                <input id="configUrlInput" type="text" value="https://oss-mbh5.colormaprun.com/wxmini/test/game.json">
+              </label>
+              <div class="row">
+                <button id="loadConfigBtn" class="btn btn--primary">载入配置</button>
+                <button id="fitCourseBtn" class="btn">适配视野</button>
+              </div>
+              <label class="field">
+                <span>瓦片模板</span>
+                <input id="tileUrlInput" type="text" placeholder="https://host/tiles/{z}/{x}/{y}.webp">
+              </label>
+              <div class="row">
+                <button id="applyTilesBtn" class="btn">应用瓦片</button>
+                <button id="resetTilesBtn" class="btn">恢复 OSM</button>
+              </div>
+              <label class="field">
+                <span>KML URL</span>
+                <input id="courseUrlInput" type="text" placeholder="https://host/course/c01.kml">
+              </label>
+              <div class="row">
+                <button id="loadCourseBtn" class="btn">载入控制点</button>
+                <button id="clearCourseBtn" class="btn">清空控制点</button>
+              </div>
+              <div id="resourceStatus" class="hint">支持直接载入 game.json,也支持单独填瓦片模板和 KML 地址。</div>
+              <div id="resourceDetail" class="group__status">尚未载入资源</div>
+              <div id="courseJumpList" class="jump-list"></div>
+            </div>
+          </details>
 
-        <section class="group">
-          <div class="group__title">路径回放</div>
-          <div id="playbackStatus" class="group__status">路径待命</div>
-          <input id="trackFileInput" class="file-input-hidden" type="file" accept=".gpx,.kml,.geojson,.json,application/json,application/gpx+xml,application/vnd.google-earth.kml+xml">
-          <div class="row">
-            <button id="importTrackBtn" class="btn">导入轨迹文件</button>
-            <button id="togglePathModeBtn" class="btn">开启路径编辑</button>
-          </div>
-          <div class="row">
-            <button id="clearPathBtn" class="btn">清空路径</button>
-            <button id="fitPathBtn" class="btn">适配路径</button>
-          </div>
-          <div class="row">
-            <button id="playPathBtn" class="btn btn--accent">开始回放</button>
-            <button id="pausePathBtn" class="btn">暂停回放</button>
-          </div>
-          <label class="field">
-            <span>移动速度 (km/h)</span>
-            <input id="speedInput" type="number" min="1" max="25" step="0.1" value="6">
-          </label>
-          <label class="field field--check">
-            <input id="loopPathInput" type="checkbox" checked>
-            <span>循环回放</span>
-          </label>
-          <div id="pathHint" class="hint">点击“开启路径编辑”后,在地图上逐点添加路径。</div>
-        </section>
+          <details class="wb-section" open>
+            <summary>定位发送</summary>
+            <div class="wb-section__body">
+              <div id="realtimeStatus" class="group__status">桥接未连接</div>
+              <div id="lastSendStatus" class="group__status">最近发送: --</div>
+              <div class="row">
+                <button id="connectBtn" class="btn btn--primary">连接桥接</button>
+                <button id="sendOnceBtn" class="btn">发送一次</button>
+              </div>
+              <div class="row">
+                <button id="streamBtn" class="btn btn--accent">开始连续发送</button>
+                <button id="stopStreamBtn" class="btn">停止发送</button>
+              </div>
+              <label class="field">
+                <span>发送频率</span>
+                <select id="hzSelect">
+                  <option value="2">2 Hz</option>
+                  <option value="5" selected>5 Hz</option>
+                  <option value="10">10 Hz</option>
+                </select>
+              </label>
+              <label class="field">
+                <span>精度 (m)</span>
+                <input id="accuracyInput" type="number" min="1" max="100" value="6">
+              </label>
+            </div>
+          </details>
 
-        <section class="group">
-          <div class="group__title">当前位置</div>
-          <div class="stat"><span>纬度</span><strong id="latText">--</strong></div>
-          <div class="stat"><span>经度</span><strong id="lonText">--</strong></div>
-          <div class="stat"><span>航向</span><strong id="headingText">--</strong></div>
-          <div class="stat"><span>路径点</span><strong id="pathCountText">0</strong></div>
-        </section>
+          <details class="wb-section" open>
+            <summary>路径回放</summary>
+            <div class="wb-section__body">
+              <div id="playbackStatus" class="group__status">路径待命</div>
+              <input id="trackFileInput" class="file-input-hidden" type="file" accept=".gpx,.kml,.geojson,.json,application/json,application/gpx+xml,application/vnd.google-earth.kml+xml">
+              <div class="row">
+                <button id="importTrackBtn" class="btn">导入轨迹文件</button>
+                <button id="togglePathModeBtn" class="btn">开启路径编辑</button>
+              </div>
+              <div class="row">
+                <button id="clearPathBtn" class="btn">清空路径</button>
+                <button id="fitPathBtn" class="btn">适配路径</button>
+              </div>
+              <div class="row">
+                <button id="playPathBtn" class="btn btn--accent">开始回放</button>
+                <button id="pausePathBtn" class="btn">暂停回放</button>
+              </div>
+              <label class="field">
+                <span>移动速度 (km/h)</span>
+                <input id="speedInput" type="number" min="1" max="25" step="0.1" value="6">
+              </label>
+              <label class="field field--check">
+                <input id="loopPathInput" type="checkbox" checked>
+                <span>循环回放</span>
+              </label>
+              <div id="pathHint" class="hint">点击“开启路径编辑”后,在地图上逐点添加路径。</div>
+            </div>
+          </details>
 
-        <section class="group">
-          <div class="group__title">日志</div>
-          <div id="log" class="log"></div>
-        </section>
+          <details class="wb-section">
+            <summary>心率模拟</summary>
+            <div class="wb-section__body">
+              <div id="heartRateStatus" class="group__status">心率模拟待命</div>
+              <div id="lastHeartRateStatus" class="group__status">最近发送: --</div>
+              <div class="row">
+                <button id="sendHeartRateOnceBtn" class="btn">发送一次</button>
+                <button id="startHeartRateStreamBtn" class="btn btn--accent">开始连续发送</button>
+              </div>
+              <div class="row">
+                <button id="stopHeartRateStreamBtn" class="btn">停止发送</button>
+                <button id="applyHeartRatePresetBtn" class="btn">应用分区样本</button>
+              </div>
+              <div class="row">
+                <button id="toggleHeartRateSampleBtn" class="btn">模拟真实样本</button>
+              </div>
+              <label class="field">
+                <span>心率值 (bpm)</span>
+                <input id="heartRateInput" type="number" min="40" max="220" value="120">
+              </label>
+              <label class="field">
+                <span>发送频率</span>
+                <select id="heartRateHzSelect">
+                  <option value="1" selected>1 Hz</option>
+                  <option value="2">2 Hz</option>
+                  <option value="4">4 Hz</option>
+                </select>
+              </label>
+              <label class="field">
+                <span>样本模板</span>
+                <select id="heartRateSampleTemplateSelect">
+                  <option value="jog" selected>慢跑样本</option>
+                  <option value="tempo">节奏跑样本</option>
+                  <option value="interval">间歇跑样本</option>
+                  <option value="recovery">恢复走样本</option>
+                </select>
+              </label>
+            </div>
+          </details>
 
-      </aside>
+          <details class="wb-section">
+            <summary>新网关桥接</summary>
+            <div class="wb-section__body">
+              <div id="gatewayBridgeStatus" class="group__status">未启用</div>
+              <div id="gatewayBridgeTarget" class="group__status">目标设备: --</div>
+              <div id="gatewayBridgeLast" class="group__status">最近状态: --</div>
+              <label class="field">
+                <span>桥接预设</span>
+                <select id="gatewayBridgePresetSelect">
+                  <option value="">选择预设</option>
+                </select>
+              </label>
+              <label class="field">
+                <span>预设名称</span>
+                <input id="gatewayBridgePresetNameInput" type="text" placeholder="例如:家长端-A / 场控-B">
+              </label>
+              <div class="row">
+                <button id="applyGatewayBridgePresetBtn" class="btn">套用预设</button>
+                <button id="saveGatewayBridgePresetBtn" class="btn">保存预设</button>
+              </div>
+              <div class="row">
+                <button id="deleteGatewayBridgePresetBtn" class="btn">删除预设</button>
+              </div>
+              <label class="field field--check">
+                <input id="gatewayBridgeEnabledInput" type="checkbox">
+                <span>启用新网关桥接</span>
+              </label>
+              <label class="field">
+                <span>网关地址</span>
+                <input id="gatewayBridgeUrlInput" type="text" placeholder="ws://127.0.0.1:18080/ws">
+              </label>
+              <label class="field">
+                <span>Producer Token / Channel Token</span>
+                <input id="gatewayBridgeTokenInput" type="text" placeholder="producerToken 或 dev-producer-token">
+              </label>
+              <label class="field">
+                <span>Channel ID</span>
+                <input id="gatewayBridgeChannelIdInput" type="text" placeholder="ch-xxxx">
+              </label>
+              <label class="field">
+                <span>目标 Device ID</span>
+                <input id="gatewayBridgeDeviceIdInput" type="text" placeholder="child-001">
+              </label>
+              <label class="field">
+                <span>目标 Group ID</span>
+                <input id="gatewayBridgeGroupIdInput" type="text" placeholder="class-a">
+              </label>
+              <label class="field">
+                <span>Source ID</span>
+                <input id="gatewayBridgeSourceIdInput" type="text" placeholder="mock-gps-sim">
+              </label>
+              <label class="field">
+                <span>Source Mode</span>
+                <input id="gatewayBridgeSourceModeInput" type="text" placeholder="mock">
+              </label>
+              <div class="row">
+                <button id="applyGatewayBridgeConfigBtn" class="btn btn--primary">应用桥接配置</button>
+                <button id="reloadGatewayBridgeConfigBtn" class="btn">重新读取</button>
+              </div>
+            </div>
+          </details>
+        </aside>
 
-      <main class="map-shell">
-        <div id="map"></div>
-        <section class="floating-debug-log">
-          <div class="floating-debug-log__header">
-            <div class="floating-debug-log__title">调试日志</div>
-            <button id="clearDebugLogBtn" class="floating-debug-log__clear" type="button">清空</button>
-          </div>
-          <div id="debugLog" class="log log--debug log--floating"></div>
+        <main class="wb-stage">
+          <div id="map"></div>
+            <section id="floatingDebugLogPanel" class="floating-debug-log">
+              <div class="floating-debug-log__header">
+                <div class="floating-debug-log__title-wrap">
+                  <div class="floating-debug-log__title">调试日志</div>
+                  <div id="debugLogMeta" class="floating-debug-log__meta">全部 · 0 条</div>
+                </div>
+                <div class="floating-debug-log__actions">
+                  <label class="floating-debug-log__filter">
+                    <span>范围</span>
+                  <select id="debugLogScopeFilter">
+                    <option value="all">全部</option>
+                  </select>
+                </label>
+                <button id="toggleDebugLogPanelBtn" class="floating-debug-log__toggle" type="button">缩小</button>
+                <button id="clearDebugLogBtn" class="floating-debug-log__clear" type="button">清空</button>
+              </div>
+            </div>
+            <div id="debugLog" class="log log--debug log--floating"></div>
+          </section>
+        </main>
+      </div>
+      <section class="wb-bottom-strip">
+        <section class="wb-card wb-card--bottom">
+          <div class="wb-card__title">最近事件</div>
+          <div id="log" class="log"></div>
         </section>
-      </main>
+      </section>
     </div>
 
     <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

+ 159 - 12
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) => `<option value="${scope}">${scope === 'all' ? '全部' : scope}</option>`)
+      .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('')

+ 11 - 0
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;

+ 231 - 0
tools/mock-gps-sim/public/v1/index.html

@@ -0,0 +1,231 @@
+<!doctype html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Mock GPS Simulator v1</title>
+    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
+    <link rel="stylesheet" href="../style.css">
+  </head>
+  <body>
+    <div class="layout">
+      <aside class="panel">
+        <div class="panel__header">
+          <div class="panel__eyebrow">MOCK GPS SIM</div>
+          <h1>外部模拟器</h1>
+          <div class="panel__links"><a href="/">打开新版工作台</a></div>
+          <div id="socketStatus" class="badge badge--muted">未连接</div>
+        </div>
+
+        <section class="group">
+          <div class="group__title">资源加载</div>
+          <label class="field">
+            <span>游戏配置 URL</span>
+            <input id="configUrlInput" type="text" value="https://oss-mbh5.colormaprun.com/wxmini/test/game.json">
+          </label>
+          <div class="row">
+            <button id="loadConfigBtn" class="btn btn--primary">载入配置</button>
+            <button id="fitCourseBtn" class="btn">适配视野</button>
+          </div>
+          <label class="field">
+            <span>瓦片模板</span>
+            <input id="tileUrlInput" type="text" placeholder="https://host/tiles/{z}/{x}/{y}.webp">
+          </label>
+          <div class="row">
+            <button id="applyTilesBtn" class="btn">应用瓦片</button>
+            <button id="resetTilesBtn" class="btn">恢复 OSM</button>
+          </div>
+          <label class="field">
+            <span>KML URL</span>
+            <input id="courseUrlInput" type="text" placeholder="https://host/course/c01.kml">
+          </label>
+          <div class="row">
+            <button id="loadCourseBtn" class="btn">载入控制点</button>
+            <button id="clearCourseBtn" class="btn">清空控制点</button>
+          </div>
+          <div id="resourceStatus" class="hint">支持直接载入 game.json,也支持单独填瓦片模板和 KML 地址。</div>
+          <div id="resourceDetail" class="group__status">尚未载入资源</div>
+          <div id="courseJumpList" class="jump-list"></div>
+        </section>
+
+        <section class="group">
+          <div class="group__title">实时发送</div>
+          <div id="realtimeStatus" class="group__status">桥接未连接</div>
+          <div id="lastSendStatus" class="group__status">最近发送: --</div>
+          <div class="row">
+            <button id="connectBtn" class="btn btn--primary">连接桥接</button>
+            <button id="sendOnceBtn" class="btn">发送一次</button>
+          </div>
+          <div class="row">
+            <button id="streamBtn" class="btn btn--accent">开始连续发送</button>
+            <button id="stopStreamBtn" class="btn">停止发送</button>
+          </div>
+          <label class="field">
+            <span>发送频率</span>
+            <select id="hzSelect">
+              <option value="2">2 Hz</option>
+              <option value="5" selected>5 Hz</option>
+              <option value="10">10 Hz</option>
+            </select>
+          </label>
+          <label class="field">
+            <span>精度 (m)</span>
+            <input id="accuracyInput" type="number" min="1" max="100" value="6">
+          </label>
+        </section>
+
+        <section class="group">
+          <div class="group__title">新网关桥接</div>
+          <div id="gatewayBridgeStatus" class="group__status">未启用</div>
+          <div id="gatewayBridgeTarget" class="group__status">目标设备: --</div>
+          <div id="gatewayBridgeLast" class="group__status">最近状态: --</div>
+          <label class="field">
+            <span>桥接预设</span>
+            <select id="gatewayBridgePresetSelect">
+              <option value="">选择预设</option>
+            </select>
+          </label>
+          <label class="field">
+            <span>预设名称</span>
+            <input id="gatewayBridgePresetNameInput" type="text" placeholder="例如:家长端-A / 场控-B">
+          </label>
+          <div class="row">
+            <button id="applyGatewayBridgePresetBtn" class="btn">套用预设</button>
+            <button id="saveGatewayBridgePresetBtn" class="btn">保存预设</button>
+          </div>
+          <div class="row">
+            <button id="deleteGatewayBridgePresetBtn" class="btn">删除预设</button>
+          </div>
+          <label class="field field--check">
+            <input id="gatewayBridgeEnabledInput" type="checkbox">
+            <span>启用新网关桥接</span>
+          </label>
+          <label class="field">
+            <span>网关地址</span>
+            <input id="gatewayBridgeUrlInput" type="text" placeholder="ws://127.0.0.1:18080/ws">
+          </label>
+          <label class="field">
+            <span>Producer Token / Channel Token</span>
+            <input id="gatewayBridgeTokenInput" type="text" placeholder="producerToken 或 dev-producer-token">
+          </label>
+          <label class="field">
+            <span>Channel ID</span>
+            <input id="gatewayBridgeChannelIdInput" type="text" placeholder="ch-xxxx">
+          </label>
+          <label class="field">
+            <span>目标 Device ID</span>
+            <input id="gatewayBridgeDeviceIdInput" type="text" placeholder="child-001">
+          </label>
+          <label class="field">
+            <span>目标 Group ID</span>
+            <input id="gatewayBridgeGroupIdInput" type="text" placeholder="class-a">
+          </label>
+          <label class="field">
+            <span>Source ID</span>
+            <input id="gatewayBridgeSourceIdInput" type="text" placeholder="mock-gps-sim">
+          </label>
+          <label class="field">
+            <span>Source Mode</span>
+            <input id="gatewayBridgeSourceModeInput" type="text" placeholder="mock">
+          </label>
+          <div class="row">
+            <button id="applyGatewayBridgeConfigBtn" class="btn btn--primary">应用桥接配置</button>
+            <button id="reloadGatewayBridgeConfigBtn" class="btn">重新读取</button>
+          </div>
+        </section>
+
+        <section class="group">
+          <div class="group__title">心率模拟</div>
+          <div id="heartRateStatus" class="group__status">心率模拟待命</div>
+          <div id="lastHeartRateStatus" class="group__status">最近发送: --</div>
+          <div class="row">
+            <button id="sendHeartRateOnceBtn" class="btn">发送一次</button>
+            <button id="startHeartRateStreamBtn" class="btn btn--accent">开始连续发送</button>
+          </div>
+          <div class="row">
+            <button id="stopHeartRateStreamBtn" class="btn">停止发送</button>
+            <button id="applyHeartRatePresetBtn" class="btn">应用分区样本</button>
+          </div>
+          <div class="row">
+            <button id="toggleHeartRateSampleBtn" class="btn">模拟真实样本</button>
+          </div>
+          <label class="field">
+            <span>心率值 (bpm)</span>
+            <input id="heartRateInput" type="number" min="40" max="220" value="120">
+          </label>
+          <label class="field">
+            <span>发送频率</span>
+            <select id="heartRateHzSelect">
+              <option value="1" selected>1 Hz</option>
+              <option value="2">2 Hz</option>
+              <option value="4">4 Hz</option>
+            </select>
+          </label>
+          <label class="field">
+            <span>样本模板</span>
+            <select id="heartRateSampleTemplateSelect">
+              <option value="jog" selected>慢跑样本</option>
+              <option value="tempo">节奏跑样本</option>
+              <option value="interval">间歇跑样本</option>
+              <option value="recovery">恢复走样本</option>
+            </select>
+          </label>
+        </section>
+
+        <section class="group">
+          <div class="group__title">路径回放</div>
+          <div id="playbackStatus" class="group__status">路径待命</div>
+          <input id="trackFileInput" class="file-input-hidden" type="file" accept=".gpx,.kml,.geojson,.json,application/json,application/gpx+xml,application/vnd.google-earth.kml+xml">
+          <div class="row">
+            <button id="importTrackBtn" class="btn">导入轨迹文件</button>
+            <button id="togglePathModeBtn" class="btn">开启路径编辑</button>
+          </div>
+          <div class="row">
+            <button id="clearPathBtn" class="btn">清空路径</button>
+            <button id="fitPathBtn" class="btn">适配路径</button>
+          </div>
+          <div class="row">
+            <button id="playPathBtn" class="btn btn--accent">开始回放</button>
+            <button id="pausePathBtn" class="btn">暂停回放</button>
+          </div>
+          <label class="field">
+            <span>移动速度 (km/h)</span>
+            <input id="speedInput" type="number" min="1" max="25" step="0.1" value="6">
+          </label>
+          <label class="field field--check">
+            <input id="loopPathInput" type="checkbox" checked>
+            <span>循环回放</span>
+          </label>
+          <div id="pathHint" class="hint">点击“开启路径编辑”后,在地图上逐点添加路径。</div>
+        </section>
+
+        <section class="group">
+          <div class="group__title">当前位置</div>
+          <div class="stat"><span>纬度</span><strong id="latText">--</strong></div>
+          <div class="stat"><span>经度</span><strong id="lonText">--</strong></div>
+          <div class="stat"><span>航向</span><strong id="headingText">--</strong></div>
+          <div class="stat"><span>路径点</span><strong id="pathCountText">0</strong></div>
+        </section>
+
+        <section class="group">
+          <div class="group__title">日志</div>
+          <div id="log" class="log"></div>
+        </section>
+      </aside>
+
+      <main class="map-shell">
+        <div id="map"></div>
+        <section class="floating-debug-log">
+          <div class="floating-debug-log__header">
+            <div class="floating-debug-log__title">调试日志</div>
+            <button id="clearDebugLogBtn" class="floating-debug-log__clear" type="button">清空</button>
+          </div>
+          <div id="debugLog" class="log log--debug log--floating"></div>
+        </section>
+      </main>
+    </div>
+
+    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+    <script src="../simulator.js"></script>
+  </body>
+  </html>

+ 555 - 0
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;
+  }
+}

+ 6 - 1
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)