Add mock GPS simulator and configurable location sources
This commit is contained in:
75
tools/mock-gps-sim/README.md
Normal file
75
tools/mock-gps-sim/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Mock GPS Simulator
|
||||
|
||||
## 启动
|
||||
|
||||
在仓库根目录运行:
|
||||
|
||||
```bash
|
||||
npm run mock-gps-sim
|
||||
```
|
||||
|
||||
启动后:
|
||||
|
||||
- 控制台页面: `http://127.0.0.1:17865/`
|
||||
- 小程序接收地址: `ws://127.0.0.1:17865/mock-gps`
|
||||
- 资源代理: `http://127.0.0.1:17865/proxy?url=<remote-url>`
|
||||
|
||||
## 当前能力
|
||||
|
||||
- 直接载入 `game.json`
|
||||
- 自动解析 `map / mapmeta / course`
|
||||
- 自动切换自定义瓦片
|
||||
- 自动渲染 KML 控制点
|
||||
- 一键跳到开始点 / 结束点 / 任意检查点
|
||||
- 地图点击跳点
|
||||
- 实时连续发送 `mock_gps`
|
||||
- 路径编辑
|
||||
- 上传轨迹文件回放(GPX / KML / GeoJSON)
|
||||
- 路径回放
|
||||
- 速度、频率、精度调节
|
||||
|
||||
## 加载自己的地图
|
||||
|
||||
推荐方式:
|
||||
|
||||
1. 启动模拟器后,打开 `http://127.0.0.1:17865/`
|
||||
2. 在“资源加载”里填自己的 `game.json` 地址
|
||||
3. 点“载入配置”
|
||||
|
||||
模拟器会自动:
|
||||
|
||||
- 读取 `map` 和 `mapmeta`
|
||||
- 切换到你的瓦片底图
|
||||
- 读取 `course`
|
||||
- 渲染开始点、检查点、结束点
|
||||
|
||||
如果你不想走整套配置,也可以:
|
||||
|
||||
- 直接填“瓦片模板”,例如 `https://host/tiles/{z}/{x}/{y}.webp`
|
||||
- 直接填 `KML URL`
|
||||
|
||||
路径回放也支持直接导入轨迹文件:
|
||||
|
||||
- `GPX`
|
||||
- `KML`
|
||||
- `GeoJSON / JSON`
|
||||
|
||||
说明:
|
||||
|
||||
- 配置和 KML 是通过本地代理拉取的,所以浏览器跨域问题会少很多
|
||||
- 如果你的资源需要鉴权,第一版代理还没有加认证头透传
|
||||
|
||||
## 真机调试注意
|
||||
|
||||
如果小程序跑在手机上,不要用 `127.0.0.1`。
|
||||
把小程序里的 mock bridge 地址改成你电脑在局域网里的 IP,例如:
|
||||
|
||||
```text
|
||||
ws://192.168.1.23:17865/mock-gps
|
||||
```
|
||||
|
||||
同理,浏览器里的模拟器页面也建议用电脑局域网地址打开,例如:
|
||||
|
||||
```text
|
||||
http://192.168.1.23:17865/
|
||||
```
|
||||
125
tools/mock-gps-sim/public/index.html
Normal file
125
tools/mock-gps-sim/public/index.html
Normal file
@@ -0,0 +1,125 @@
|
||||
<!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</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 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="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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="./simulator.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1157
tools/mock-gps-sim/public/simulator.js
Normal file
1157
tools/mock-gps-sim/public/simulator.js
Normal file
File diff suppressed because it is too large
Load Diff
278
tools/mock-gps-sim/public/style.css
Normal file
278
tools/mock-gps-sim/public/style.css
Normal file
@@ -0,0 +1,278 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "PingFang SC", sans-serif;
|
||||
background: #edf3ea;
|
||||
color: #163126;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 400px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 20px;
|
||||
background: rgba(250, 252, 248, 0.96);
|
||||
border-right: 1px solid rgba(22, 49, 38, 0.08);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel__header h1 {
|
||||
margin: 8px 0 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.panel__eyebrow {
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 12px;
|
||||
color: #557266;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.badge--muted {
|
||||
background: #e5ece5;
|
||||
color: #4f6458;
|
||||
}
|
||||
|
||||
.badge--ok {
|
||||
background: #d8f7e3;
|
||||
color: #0a7a3d;
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-top: 18px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 30px rgba(34, 63, 49, 0.07);
|
||||
}
|
||||
|
||||
.group__title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
color: #5d786c;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.group__status {
|
||||
min-height: 18px;
|
||||
margin: -4px 0 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #5e786d;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: #ebf0ea;
|
||||
color: #193226;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, background 120ms ease, color 120ms ease, opacity 120ms ease;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
color: #557266;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
min-height: 38px;
|
||||
border: 1px solid rgba(22, 49, 38, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.field--check {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: #678276;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint--ok {
|
||||
color: #0a7a3d;
|
||||
}
|
||||
|
||||
.hint--warn {
|
||||
color: #8d4b08;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 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: 140px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #f3f7f1;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #486257;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.map-shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
152
tools/mock-gps-sim/server.js
Normal file
152
tools/mock-gps-sim/server.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const http = require('http')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const { WebSocketServer } = require('ws')
|
||||
|
||||
const HOST = '0.0.0.0'
|
||||
const PORT = 17865
|
||||
const WS_PATH = '/mock-gps'
|
||||
const PROXY_PATH = '/proxy'
|
||||
const PUBLIC_DIR = path.join(__dirname, 'public')
|
||||
|
||||
function getContentType(filePath) {
|
||||
const ext = path.extname(filePath).toLowerCase()
|
||||
if (ext === '.html') {
|
||||
return 'text/html; charset=utf-8'
|
||||
}
|
||||
if (ext === '.css') {
|
||||
return 'text/css; charset=utf-8'
|
||||
}
|
||||
if (ext === '.js') {
|
||||
return 'application/javascript; charset=utf-8'
|
||||
}
|
||||
if (ext === '.json') {
|
||||
return 'application/json; charset=utf-8'
|
||||
}
|
||||
if (ext === '.svg') {
|
||||
return 'image/svg+xml'
|
||||
}
|
||||
return 'text/plain; charset=utf-8'
|
||||
}
|
||||
|
||||
function serveStatic(requestPath, response) {
|
||||
const safePath = requestPath === '/' ? '/index.html' : requestPath
|
||||
const resolvedPath = path.normalize(path.join(PUBLIC_DIR, safePath))
|
||||
if (!resolvedPath.startsWith(PUBLIC_DIR)) {
|
||||
response.writeHead(403)
|
||||
response.end('Forbidden')
|
||||
return
|
||||
}
|
||||
|
||||
fs.readFile(resolvedPath, (error, content) => {
|
||||
if (error) {
|
||||
response.writeHead(404)
|
||||
response.end('Not Found')
|
||||
return
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
'Content-Type': getContentType(resolvedPath),
|
||||
'Cache-Control': 'no-store',
|
||||
})
|
||||
response.end(content)
|
||||
})
|
||||
}
|
||||
|
||||
function isMockGpsPayload(payload) {
|
||||
return payload
|
||||
&& payload.type === 'mock_gps'
|
||||
&& Number.isFinite(payload.lat)
|
||||
&& Number.isFinite(payload.lon)
|
||||
}
|
||||
|
||||
async function handleProxyRequest(request, response) {
|
||||
const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`)
|
||||
const targetUrl = requestUrl.searchParams.get('url')
|
||||
if (!targetUrl) {
|
||||
response.writeHead(400, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
})
|
||||
response.end('Missing url')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(targetUrl)
|
||||
const body = Buffer.from(await upstream.arrayBuffer())
|
||||
response.writeHead(upstream.status, {
|
||||
'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream',
|
||||
'Cache-Control': 'no-store',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
})
|
||||
response.end(body)
|
||||
} catch (error) {
|
||||
response.writeHead(502, {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
})
|
||||
response.end(error && error.message ? error.message : 'Proxy request failed')
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
if ((request.url || '').startsWith(PROXY_PATH)) {
|
||||
handleProxyRequest(request, response)
|
||||
return
|
||||
}
|
||||
|
||||
serveStatic(request.url || '/', response)
|
||||
})
|
||||
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
|
||||
wss.on('connection', (socket) => {
|
||||
socket.on('message', (rawMessage) => {
|
||||
const text = String(rawMessage)
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch (_error) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isMockGpsPayload(parsed)) {
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify({
|
||||
type: 'mock_gps',
|
||||
timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
|
||||
lat: Number(parsed.lat),
|
||||
lon: Number(parsed.lon),
|
||||
accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
|
||||
speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0,
|
||||
headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0,
|
||||
})
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === client.OPEN) {
|
||||
client.send(serialized)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
if (!request.url || !request.url.startsWith(WS_PATH)) {
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, request)
|
||||
})
|
||||
})
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Mock GPS simulator running:`)
|
||||
console.log(` UI: http://127.0.0.1:${PORT}/`)
|
||||
console.log(` WS: ws://127.0.0.1:${PORT}${WS_PATH}`)
|
||||
console.log(` Proxy: http://127.0.0.1:${PORT}${PROXY_PATH}?url=<remote-url>`)
|
||||
})
|
||||
Reference in New Issue
Block a user