docs/WINDOWS_PORT.md 覆盖: - 逐文件可移植性审计(核心逻辑层约 90% 可直接复用) - 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC + ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道 - 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel) - 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico) - macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils 放进 excludes、AlreadyMountedError 当成功处理 - 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等) - 验收基线(复用现有测试套件)与建议的工作顺序
2386 lines
92 KiB
JavaScript
2386 lines
92 KiB
JavaScript
/*!
|
||
* app.js — 状态机与全部交互(严格按 docs/PROTOCOL.md)
|
||
*
|
||
* 内容:
|
||
* - 设备卡片 / 连接 / 断开 / 隧道状态 / 开发者模式提示
|
||
* - 模式、倍速、循环、路线概览、途经点列表(拖拽排序 / 双击改坐标 / 定位 / 删除)
|
||
* - 绘制工具条(选择 / 添加途经点 / 折线绘制 / 撤销 / 删除 / 全览)
|
||
* - 播放控制(播放 / 暂停 / 继续 / 停止 / seek / 倍速)与实时状态
|
||
* - GPX 导入导出、路线保存到 localStorage
|
||
* - 日志面板(最多 300 行)
|
||
*
|
||
* 所有坐标对外(后端 / localStorage / 日志)一律 WGS84。
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
var WS_URL = (function () {
|
||
var loc = window.location;
|
||
var proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
var host = loc.hostname || '127.0.0.1';
|
||
var port = loc.port || '8787';
|
||
return proto + '//' + host + ':' + port + '/ws';
|
||
})();
|
||
|
||
var LOG_MAX = 300;
|
||
var UNDO_MAX = 50;
|
||
var PREF_KEY = 'iosloc.prefs.v1';
|
||
var ROUTES_KEY = 'iosloc.routes.v1';
|
||
|
||
var LEVEL_CN = { info: '信息', warn: '警告', error: '错误', success: '成功' };
|
||
var MODE_CN = { driving: '驾车', cycling: '骑行', walking: '步行', flight: '飞行' };
|
||
var CONN_CN = { usb: 'USB', network: '网络', wifi: 'Wi-Fi', mock: '模拟', lan: '局域网' };
|
||
var STATE_CN = { connected: '已连接', unpaired: '未配对', unavailable: '不可用' };
|
||
var TUNNEL_CN = {
|
||
idle: '空闲', starting: '建立中', connected: '已连接',
|
||
error: '失败', unsupported: '不需要'
|
||
};
|
||
|
||
var $ = function (sel) { return document.querySelector(sel); };
|
||
var $$ = function (sel) { return Array.prototype.slice.call(document.querySelectorAll(sel)); };
|
||
|
||
/* ================================================================== */
|
||
/* 全局状态 */
|
||
/* ================================================================== */
|
||
|
||
var S = {
|
||
ws: null,
|
||
map: null,
|
||
health: null,
|
||
mock: false,
|
||
version: '--',
|
||
config: null,
|
||
modeDefaults: { driving: 13.9, cycling: 5.5, walking: 1.4, flight: 60.0 },
|
||
|
||
devices: [],
|
||
selected: null,
|
||
device: null,
|
||
tunnel: null,
|
||
|
||
mode: 'driving',
|
||
speedScale: 1,
|
||
loop: false,
|
||
routeName: '',
|
||
|
||
waypoints: [], // WGS84 [lng,lat][]
|
||
drawnPath: [], // 手绘折线(WGS84)
|
||
route: null, // 服务端返回的 Route 对象
|
||
polyline: [], // 正在绘制的折线
|
||
drawing: false,
|
||
selectedWp: -1,
|
||
drawMode: 'select',
|
||
|
||
playing: false,
|
||
paused: false,
|
||
status: null,
|
||
scrubbing: false,
|
||
progress: 0,
|
||
|
||
logs: [],
|
||
undo: [],
|
||
tileSources: [],
|
||
tileSource: '',
|
||
logCollapsed: true,
|
||
|
||
lastLocation: null,
|
||
|
||
// 真实感模拟(校园跑等打卡测试):GPS 漂移 + 速度浮动
|
||
driftEnabled: true,
|
||
driftM: 4,
|
||
jitterEnabled: true,
|
||
jitterPercent: 8,
|
||
timingJitter: true,
|
||
deriveSpeed: true,
|
||
grossError: false,
|
||
grossErrorPercent: 2,
|
||
config: null
|
||
};
|
||
|
||
/* ================================================================== */
|
||
/* 小工具 */
|
||
/* ================================================================== */
|
||
|
||
function toast(msg, kind, ms) {
|
||
var wrap = $('#toastWrap');
|
||
var t = document.createElement('div');
|
||
t.className = 'toast' + (kind ? ' is-' + kind : '');
|
||
t.textContent = msg;
|
||
wrap.appendChild(t);
|
||
setTimeout(function () {
|
||
t.classList.add('is-out');
|
||
setTimeout(function () { if (t.parentNode) t.parentNode.removeChild(t); }, 320);
|
||
}, ms || 3400);
|
||
}
|
||
|
||
var modalOnOk = null;
|
||
|
||
function openModal(opt) {
|
||
$('#modalTitle').textContent = opt.title || '提示';
|
||
$('#modalBody').innerHTML = opt.html || '';
|
||
$('#modalOk').textContent = opt.okText || '确定';
|
||
$('#modalCancel').textContent = opt.cancelText || '取消';
|
||
$('#modalOk').className = 'btn ' + (opt.danger ? 'btn-danger' : 'btn-primary');
|
||
modalOnOk = opt.onOk || null;
|
||
$('#modalMask').classList.remove('hidden');
|
||
var first = $('#modalBody').querySelector('input, select, textarea');
|
||
if (first) setTimeout(function () { first.focus(); first.select && first.select(); }, 30);
|
||
}
|
||
|
||
function closeModal() {
|
||
$('#modalMask').classList.add('hidden');
|
||
modalOnOk = null;
|
||
}
|
||
|
||
$('#modalOk').addEventListener('click', function () {
|
||
var fn = modalOnOk;
|
||
if (fn) {
|
||
var r = fn();
|
||
if (r === false) return;
|
||
}
|
||
closeModal();
|
||
});
|
||
$('#modalCancel').addEventListener('click', closeModal);
|
||
$('#modalMask').addEventListener('click', function (e) {
|
||
if (e.target === $('#modalMask')) closeModal();
|
||
});
|
||
|
||
function confirmModal(title, text, onOk, okText) {
|
||
openModal({
|
||
title: title,
|
||
html: '<div>' + text + '</div>',
|
||
okText: okText || '确定',
|
||
danger: true,
|
||
onOk: onOk
|
||
});
|
||
}
|
||
|
||
function setBusy(btn, busy, busyText) {
|
||
if (!btn) return;
|
||
if (busy) {
|
||
if (!btn.dataset.origText) btn.dataset.origText = btn.textContent;
|
||
btn.disabled = true;
|
||
if (busyText) btn.textContent = busyText;
|
||
} else {
|
||
btn.disabled = false;
|
||
if (btn.dataset.origText) { btn.textContent = btn.dataset.origText; delete btn.dataset.origText; }
|
||
}
|
||
}
|
||
|
||
/** JSON 接口;服务端 ok:false 一律抛出中文 Error */
|
||
function apiJson(path, opts) {
|
||
return fetch(path, opts).then(function (res) {
|
||
var ct = res.headers.get('content-type') || '';
|
||
if (ct.indexOf('application/json') >= 0) {
|
||
return res.json().then(function (data) {
|
||
if (data && data.ok === false) {
|
||
var e = new Error(data.error || ('请求失败:' + path));
|
||
e.code = data.code || 'api_error';
|
||
throw e;
|
||
}
|
||
return data;
|
||
});
|
||
}
|
||
if (!res.ok) {
|
||
var err = new Error('请求失败 HTTP ' + res.status + '(' + path + ')');
|
||
err.code = 'http_' + res.status;
|
||
throw err;
|
||
}
|
||
return res.text();
|
||
});
|
||
}
|
||
|
||
function postJson(path, body) {
|
||
return apiJson(path, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||
body: JSON.stringify(body || {})
|
||
});
|
||
}
|
||
|
||
/** 下载文本文件 */
|
||
function downloadText(text, filename, mime) {
|
||
var blob = new Blob([text], { type: mime || 'text/plain;charset=utf-8' });
|
||
var url = URL.createObjectURL(blob);
|
||
var a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
setTimeout(function () {
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}, 1200);
|
||
}
|
||
|
||
function debounce(fn, ms) {
|
||
var t = null;
|
||
return function () {
|
||
var args = arguments, self = this;
|
||
clearTimeout(t);
|
||
t = setTimeout(function () { fn.apply(self, args); }, ms);
|
||
};
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 日志 */
|
||
/* ================================================================== */
|
||
|
||
var lastLog = null;
|
||
|
||
/** 把数字(坐标、序号、时间)换成 # 作为“同一类消息”的指纹 */
|
||
function logSignature(msg) {
|
||
return String(msg).replace(/-?\d+(?:\.\d+)?/g, '#');
|
||
}
|
||
|
||
function pushLog(level, msg, ts) {
|
||
var l = { ts: ts == null ? Date.now() / 1000 : ts, level: LEVEL_CN[level] ? level : 'info', msg: String(msg) };
|
||
var sig = logSignature(l.msg);
|
||
// 短时间内同类型消息(例如 5Hz 的定位下发日志)合并成一行,避免刷屏
|
||
if (lastLog && lastLog.sig === sig && lastLog.level === l.level && Math.abs(l.ts - lastLog.ts) < 3) {
|
||
lastLog.count += 1;
|
||
lastLog.ts = l.ts;
|
||
var merged = lastLog.msg + ' ×' + lastLog.count;
|
||
var mEl = lastLog.row.querySelector('.log-msg');
|
||
if (mEl) mEl.textContent = merged;
|
||
if (S.logs.length) S.logs[S.logs.length - 1] = { ts: l.ts, level: l.level, msg: merged };
|
||
return;
|
||
}
|
||
S.logs.push(l);
|
||
var body = $('#logBody');
|
||
var atBottom = body.scrollTop + body.clientHeight >= body.scrollHeight - 24;
|
||
while (S.logs.length > LOG_MAX) {
|
||
S.logs.shift();
|
||
if (body.firstChild) body.removeChild(body.firstChild);
|
||
}
|
||
var row = document.createElement('div');
|
||
row.className = 'log-line level-' + l.level;
|
||
row.innerHTML = '<span class="log-ts"></span><span class="log-lvl"></span><span class="log-msg"></span>';
|
||
row.querySelector('.log-ts').textContent = Geo.fmtClock(l.ts);
|
||
row.querySelector('.log-lvl').textContent = LEVEL_CN[l.level];
|
||
row.querySelector('.log-msg').textContent = l.msg;
|
||
body.appendChild(row);
|
||
if (atBottom) body.scrollTop = body.scrollHeight;
|
||
lastLog = { sig: sig, count: 1, row: row, msg: l.msg, level: l.level, ts: l.ts };
|
||
$('#logCount').textContent = S.logs.length + ' / ' + LOG_MAX;
|
||
var label = $('#logCollapsedLabel');
|
||
if (label) label.textContent = '运行日志 (' + S.logs.length + ')';
|
||
// 出错时自动展开日志面板,避免错误被折叠面板藏住
|
||
if (l.level === 'error' && S.logCollapsed) setLogCollapsed(false, true);
|
||
}
|
||
|
||
function setLogs(lines) {
|
||
S.logs = [];
|
||
lastLog = null;
|
||
$('#logBody').innerHTML = '';
|
||
(lines || []).forEach(function (l) { pushLog(l.level, l.msg, l.ts); });
|
||
if (!lines || !lines.length) pushLog('info', '服务端暂无历史日志');
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 首选项 / 本地路线 */
|
||
/* ================================================================== */
|
||
|
||
function loadPrefs() {
|
||
try {
|
||
var p = JSON.parse(localStorage.getItem(PREF_KEY) || '{}');
|
||
if (p.mode && MODE_CN[p.mode]) S.mode = p.mode;
|
||
if (isFinite(p.speedScale)) S.speedScale = Geo.clamp(+p.speedScale, Geo.SPEED_MIN, Geo.SPEED_MAX);
|
||
if (typeof p.loop === 'boolean') S.loop = p.loop;
|
||
if (p.tileSource) S.tileSource = p.tileSource;
|
||
if (p.routeName) S.routeName = p.routeName;
|
||
if (typeof p.logCollapsed === 'boolean') S.logCollapsed = p.logCollapsed;
|
||
if (p.view && isFinite(p.view.lng)) S.savedView = p.view;
|
||
return p;
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
var savePrefs = debounce(function () {
|
||
try {
|
||
localStorage.setItem(PREF_KEY, JSON.stringify({
|
||
mode: S.mode,
|
||
speedScale: S.speedScale,
|
||
loop: S.loop,
|
||
tileSource: S.tileSource,
|
||
routeName: S.routeName,
|
||
logCollapsed: S.logCollapsed,
|
||
view: S.map ? { lng: S.map.getCenter().lng, lat: S.map.getCenter().lat, zoom: S.map.getZoom() } : null
|
||
}));
|
||
} catch (e) { /* localStorage 不可用时忽略 */ }
|
||
}, 350);
|
||
|
||
function readSavedRoutes() {
|
||
try {
|
||
var arr = JSON.parse(localStorage.getItem(ROUTES_KEY) || '[]');
|
||
return Array.isArray(arr) ? arr : [];
|
||
} catch (e) { return []; }
|
||
}
|
||
|
||
function writeSavedRoutes(arr) {
|
||
try {
|
||
localStorage.setItem(ROUTES_KEY, JSON.stringify(arr));
|
||
return true;
|
||
} catch (e) {
|
||
toast('保存失败:浏览器本地存储不可用', 'err');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 初始化 */
|
||
/* ================================================================== */
|
||
|
||
function init() {
|
||
loadPrefs();
|
||
|
||
S.map = new SlippyMap('#map', { zoom: 12, minZoom: 3 });
|
||
S.map.setView(116.397428, 39.90923, 12, true);
|
||
bindMapEvents();
|
||
bindUI();
|
||
|
||
var v = S.savedView;
|
||
if (v && isFinite(v.lng)) S.map.setView(v.lng, v.lat, v.zoom || 12, true);
|
||
S.map.setSource({ key: 'amap', name: '高德矢量', max_zoom: 18, attribution: '© 高德地图', datum: 'gcj02' });
|
||
|
||
applyPrefsToUI();
|
||
renderDevice();
|
||
renderWaypoints();
|
||
updateStats();
|
||
renderSavedRoutes();
|
||
updateTransport();
|
||
|
||
setLogCollapsed(S.logCollapsed, true);
|
||
pushLog('info', '前端已启动,正在连接本地服务 ' + WS_URL);
|
||
connectWs();
|
||
loadHealth();
|
||
loadConfig();
|
||
loadTileSources();
|
||
|
||
window.addEventListener('beforeunload', function () { savePrefs(); });
|
||
}
|
||
|
||
function applyPrefsToUI() {
|
||
$$('#modeGroup .seg-btn').forEach(function (b) {
|
||
b.classList.toggle('is-active', b.dataset.mode === S.mode);
|
||
});
|
||
$('#speedRange').value = Math.round(Geo.speedToSlider(S.speedScale) * 1000);
|
||
$('#speedValue').textContent = Geo.fmtSpeedScale(S.speedScale);
|
||
$('#liveScale').textContent = Geo.fmtSpeedScale(S.speedScale);
|
||
$('#loopToggle').checked = S.loop;
|
||
$('#routeName').value = S.routeName || '';
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* WebSocket */
|
||
/* ================================================================== */
|
||
|
||
function connectWs() {
|
||
S.ws = new WSClient({ url: WS_URL });
|
||
S.ws.onState(function (st) {
|
||
var pill = $('#connPill');
|
||
if (st === 'open') { pill.textContent = '服务已连接'; pill.className = 'pill pill-ok'; }
|
||
else if (st === 'connecting') { pill.textContent = '连接中…'; pill.className = 'pill pill-warn'; }
|
||
else { pill.textContent = '未连接'; pill.className = 'pill pill-err'; }
|
||
$('#appSubtitle').textContent = st === 'open'
|
||
? '本地服务 ' + WS_URL.replace('ws://', '').replace('/ws', '') + ' · 已连接'
|
||
: '本地服务未连接,正在自动重连…';
|
||
updateBanner();
|
||
updateButtons();
|
||
});
|
||
|
||
S.ws.on('open', function () {
|
||
pushLog('success', '已连接到本地服务');
|
||
});
|
||
|
||
S.ws.on('close', function (info) {
|
||
if (!info.manual) pushLog('warn', '与本地服务的连接已断开,正在自动重连…');
|
||
S.playing = false; S.paused = false;
|
||
updateTransport();
|
||
});
|
||
|
||
S.ws.on('protocol_fallback', function (info) {
|
||
pushLog('warn', '服务端握手返回了未请求的 WebSocket 子协议,已切换兼容模式(v1)重连' +
|
||
(info && info.reason ? ':' + info.reason : ''));
|
||
});
|
||
|
||
S.ws.on('reconnecting', function (info) {
|
||
$('#appSubtitle').textContent = '连接断开,' + Math.round(info.delay / 100) / 10 + ' 秒后重试…';
|
||
});
|
||
|
||
S.ws.on('welcome', applySessionInfo);
|
||
|
||
S.ws.on('pong', function () { /* 心跳正常,无需处理 */ });
|
||
|
||
S.ws.on('ack', function (msg) {
|
||
if (msg.ok === false) {
|
||
pushLog('error', '命令失败:' + (msg.error || msg.cmd || '未知错误'));
|
||
return;
|
||
}
|
||
// 服务端把 welcome 数据并在 hello 的 ack 里返回(welcome 字段不在独立消息里)
|
||
if (msg.cmd === 'hello') applySessionInfo(msg);
|
||
});
|
||
|
||
S.ws.on('error', function (msg) {
|
||
var text = msg && msg.error ? msg.error : '服务端返回未知错误';
|
||
pushLog('error', text);
|
||
toast(text, 'err');
|
||
});
|
||
|
||
S.ws.on('devices', function (msg) {
|
||
applyDevices(msg.devices, msg.selected);
|
||
});
|
||
|
||
S.ws.on('device', function (msg) {
|
||
S.device = msg.device || null;
|
||
if (S.device) S.selected = S.device.udid;
|
||
renderDevice();
|
||
updateBanner();
|
||
});
|
||
|
||
S.ws.on('tunnel', function (msg) {
|
||
S.tunnel = msg;
|
||
if (S.device) S.device.tunnel = msg.state || S.device.tunnel;
|
||
pushLog(msg.state === 'error' ? 'error' : 'info',
|
||
'隧道状态:' + (TUNNEL_CN[msg.state] || msg.state) + (msg.message ? ' — ' + msg.message : ''));
|
||
renderDevice();
|
||
updateBanner();
|
||
// tunnel 事件可能是瞬时中间态(starting):设备对象才是权威状态,直接回读对齐
|
||
// (tunnel 事件很稀少,不需要节流;节流会导致卡片停在「建立中」)
|
||
lastDeviceSync = 0;
|
||
syncDeviceFromServer(true);
|
||
scheduleTunnelWatchdog(msg.state);
|
||
});
|
||
|
||
S.ws.on('status', onStatus);
|
||
|
||
// 开发者镜像挂载结果
|
||
S.ws.on('mounted', function (m) {
|
||
var btn = $('#btnMountImage');
|
||
if (btn) btn.disabled = false;
|
||
if (m && m.mounted) {
|
||
pushLog('success', '开发者镜像挂载完成:' + (m.message || ''));
|
||
toast('开发者镜像已挂载,现在可以进行虚拟定位', 'ok', 4200);
|
||
} else {
|
||
pushLog('error', '开发者镜像挂载失败:' + ((m && m.message) || '未知原因'));
|
||
toast('挂载失败,详情见运行日志', 'err', 5000);
|
||
}
|
||
});
|
||
S.ws.on('location', onLocation);
|
||
|
||
S.ws.on('log', function (msg) { pushLog(msg.level, msg.msg, msg.ts); });
|
||
S.ws.on('logs', function (msg) {
|
||
if (msg.lines) setLogs(msg.lines);
|
||
});
|
||
|
||
S.ws.connect();
|
||
}
|
||
|
||
/** 应用会话握手信息(兼容 hello→ack 与 hello→welcome 两种服务端实现) */
|
||
function applySessionInfo(msg) {
|
||
if (!msg) return;
|
||
if (msg.version) S.version = msg.version;
|
||
if (msg.mock != null) S.mock = !!msg.mock;
|
||
if (msg.config) applyConfig(msg.config);
|
||
if (msg.tunnel) {
|
||
S.tunnel = msg.tunnel;
|
||
if (S.device) S.device.tunnel = msg.tunnel.state || S.device.tunnel;
|
||
}
|
||
if (Array.isArray(msg.devices)) applyDevices(msg.devices, msg.selected);
|
||
$('#serverInfo').textContent = '服务端 v' + S.version + (S.mock ? '·模拟' : '');
|
||
if (!S.sessionLogged) {
|
||
S.sessionLogged = true;
|
||
pushLog('success', '服务端就绪:版本 ' + S.version + (S.mock ? '(模拟模式)' : '') +
|
||
(msg.backend ? ',后端 ' + msg.backend : ''));
|
||
}
|
||
renderDevice();
|
||
updateBanner();
|
||
updateButtons();
|
||
loadTileSources();
|
||
req('devices.refresh', {}, 6000).catch(function () { /* 忽略,等待推送 */ });
|
||
req('log.list', { limit: 200 }, 6000).catch(function () { /* 忽略 */ });
|
||
}
|
||
|
||
var lastDeviceSync = 0;
|
||
var tunnelWatch = null;
|
||
|
||
/** 以服务端设备对象为准回读设备状态(tunnel/state 字段) */
|
||
function syncDeviceFromServer(force) {
|
||
var t = Date.now();
|
||
if (!force && t - lastDeviceSync < 1200) return;
|
||
lastDeviceSync = t;
|
||
if (S.ws && S.ws.isOpen()) {
|
||
req('devices.refresh', {}, 8000).then(function (msg) {
|
||
if (msg.devices) applyDevices(msg.devices, msg.selected);
|
||
}).catch(function () { /* 忽略 */ });
|
||
} else {
|
||
apiJson('/api/devices').then(function (d) {
|
||
applyDevices(d.devices, d.selected);
|
||
}).catch(function () { /* 忽略 */ });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 隧道停在「建立中」时的兜底:服务端不一定会为中间态推后续事件,
|
||
* 这里每 2 秒回读一次权威设备状态,最长 20 秒;仍无结果就给出可操作提示。
|
||
*/
|
||
function scheduleTunnelWatchdog(state) {
|
||
if (tunnelWatch) { clearInterval(tunnelWatch); tunnelWatch = null; }
|
||
if (state !== 'starting') { S.tunnelStuck = false; return; }
|
||
var tries = 0;
|
||
tunnelWatch = setInterval(function () {
|
||
tries += 1;
|
||
syncDeviceFromServer(true);
|
||
if (S.device && S.device.tunnel !== 'starting') {
|
||
clearInterval(tunnelWatch);
|
||
tunnelWatch = null;
|
||
S.tunnelStuck = false;
|
||
renderDevice();
|
||
return;
|
||
}
|
||
if (tries >= 10) {
|
||
clearInterval(tunnelWatch);
|
||
tunnelWatch = null;
|
||
S.tunnelStuck = true;
|
||
pushLog('warn', '隧道建立超过 20 秒仍未完成。iOS 17+ 通常需要管理员权限(sudo 运行服务);' +
|
||
'也可用「手动 RSD」直接填写地址端口。');
|
||
renderDevice();
|
||
updateBanner();
|
||
}
|
||
}, 2000);
|
||
}
|
||
|
||
function req(cmd, fields, timeout) {
|
||
if (!S.ws || !S.ws.isOpen()) {
|
||
var e = new Error('尚未连接到本地服务,请稍后重试');
|
||
e.code = 'ws_closed';
|
||
return Promise.reject(e);
|
||
}
|
||
return S.ws.request(cmd, fields, timeout);
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 配置 / 健康检查 / 瓦片源 */
|
||
/* ================================================================== */
|
||
|
||
function loadHealth() {
|
||
apiJson('/api/health').then(function (h) {
|
||
S.health = h;
|
||
S.version = h.version || S.version;
|
||
S.mock = !!h.mock;
|
||
$('#serverInfo').textContent = '服务端 v' + S.version + (S.mock ? '·模拟' : '');
|
||
updateBanner();
|
||
}).catch(function () {
|
||
$('#serverInfo').textContent = '服务端不可达';
|
||
});
|
||
}
|
||
|
||
function applyConfig(cfg) {
|
||
if (!cfg) return;
|
||
S.config = cfg;
|
||
if (cfg.mode_defaults) {
|
||
Object.keys(cfg.mode_defaults).forEach(function (k) {
|
||
if (isFinite(cfg.mode_defaults[k])) S.modeDefaults[k] = +cfg.mode_defaults[k];
|
||
});
|
||
}
|
||
if (isFinite(cfg.default_speed_scale) && S.speedScale === 1) {
|
||
S.speedScale = Geo.clamp(+cfg.default_speed_scale, Geo.SPEED_MIN, Geo.SPEED_MAX);
|
||
applyPrefsToUI();
|
||
}
|
||
if (!S.tileSource && cfg.tile_source) S.tileSource = cfg.tile_source;
|
||
// 真实感模拟参数(服务端为准,例如上次退出前调好的值)
|
||
if (typeof cfg.drift_enabled === 'boolean') S.driftEnabled = cfg.drift_enabled;
|
||
if (isFinite(cfg.drift_m)) S.driftM = Math.round(+cfg.drift_m);
|
||
if (typeof cfg.speed_jitter_enabled === 'boolean') S.jitterEnabled = cfg.speed_jitter_enabled;
|
||
if (isFinite(cfg.speed_jitter)) S.jitterPercent = Math.round(+cfg.speed_jitter * 100);
|
||
if (typeof cfg.timing_jitter_enabled === 'boolean') S.timingJitter = cfg.timing_jitter_enabled;
|
||
if (typeof cfg.derive_speed === 'boolean') S.deriveSpeed = cfg.derive_speed;
|
||
if (typeof cfg.gross_error_enabled === 'boolean') S.grossError = cfg.gross_error_enabled;
|
||
if (isFinite(cfg.gross_error_probability)) S.grossErrorPercent = Math.round(+cfg.gross_error_probability * 100);
|
||
if (typeof applyTuningUi === 'function') applyTuningUi();
|
||
}
|
||
|
||
function loadConfig() {
|
||
apiJson('/api/config').then(function (data) {
|
||
applyConfig(data.config);
|
||
if (!S.tileSources.length) buildTileSourcesFromConfig();
|
||
}).catch(function (e) {
|
||
pushLog('warn', '读取配置失败:' + e.message);
|
||
});
|
||
}
|
||
|
||
/** 兼容多种 /api/settings/tiles 返回结构 */
|
||
function normalizeTileSettings(data) {
|
||
var payload = (data && (data.settings || data)) || {};
|
||
var raw = payload.sources || payload.tile_sources || payload.tiles || payload.list || null;
|
||
var current = payload.current || payload.tile_source || payload.selected || null;
|
||
var list = [];
|
||
if (Array.isArray(raw)) {
|
||
raw.forEach(function (it) {
|
||
if (!it) return;
|
||
var key = it.key || it.id || it.name;
|
||
if (key) list.push(toTileSource(key, it));
|
||
});
|
||
} else if (raw && typeof raw === 'object') {
|
||
Object.keys(raw).forEach(function (k) { list.push(toTileSource(k, raw[k] || {})); });
|
||
}
|
||
return { list: list, current: current };
|
||
}
|
||
|
||
function toTileSource(key, o) {
|
||
o = o || {};
|
||
return {
|
||
key: String(key),
|
||
name: o.name || String(key),
|
||
max_zoom: +(o.max_zoom || o.maxZoom || 18),
|
||
attribution: o.attribution || '',
|
||
datum: Coord.datumOfSource(key, o)
|
||
};
|
||
}
|
||
|
||
function buildTileSourcesFromConfig() {
|
||
var cfg = S.config || {};
|
||
var list = [];
|
||
if (cfg.tile_sources) {
|
||
Object.keys(cfg.tile_sources).forEach(function (k) { list.push(toTileSource(k, cfg.tile_sources[k])); });
|
||
}
|
||
if (!list.length) {
|
||
list = [{ key: 'amap', name: '高德矢量', max_zoom: 18, attribution: '© 高德地图', datum: 'gcj02' }];
|
||
}
|
||
S.tileSources = list;
|
||
if (!S.tileSource) S.tileSource = cfg.tile_source || list[0].key;
|
||
renderTileSelect();
|
||
applyTileSource(S.tileSource, true);
|
||
}
|
||
|
||
function loadTileSources() {
|
||
apiJson('/api/settings/tiles').then(function (data) {
|
||
var n = normalizeTileSettings(data);
|
||
if (!n.list.length) { buildTileSourcesFromConfig(); return; }
|
||
S.tileSources = n.list;
|
||
S.tileSource = S.tileSource || n.current || n.list[0].key;
|
||
if (!S.tileSources.some(function (s) { return s.key === S.tileSource; })) {
|
||
S.tileSource = n.current && n.list.some(function (s) { return s.key === n.current; })
|
||
? n.current : n.list[0].key;
|
||
}
|
||
renderTileSelect();
|
||
applyTileSource(S.tileSource, true);
|
||
}).catch(function (e) {
|
||
pushLog('warn', '读取瓦片源列表失败:' + e.message + ',使用配置中的瓦片源');
|
||
buildTileSourcesFromConfig();
|
||
});
|
||
}
|
||
|
||
function renderTileSelect() {
|
||
var sel = $('#tileSourceSelect');
|
||
sel.innerHTML = '';
|
||
S.tileSources.forEach(function (s) {
|
||
var o = document.createElement('option');
|
||
o.value = s.key;
|
||
o.textContent = s.name + (s.datum === 'gcj02' ? '(GCJ-02)' : '(WGS84)');
|
||
sel.appendChild(o);
|
||
});
|
||
sel.value = S.tileSource;
|
||
}
|
||
|
||
/** 切换底图;persist=true 时同步到服务端配置 */
|
||
function applyTileSource(key, initial) {
|
||
var src = S.tileSources.filter(function (s) { return s.key === key; })[0];
|
||
if (!src) return;
|
||
var changed = S.map.getSource().key !== key;
|
||
S.tileSource = key;
|
||
S.map.setSource(src);
|
||
S.map.invalidate();
|
||
$('#tileSourceSelect').value = key;
|
||
if (!initial) {
|
||
pushLog('info', '底图已切换为「' + src.name + '」,坐标基准 ' + (src.datum === 'gcj02' ? 'GCJ-02' : 'WGS84'));
|
||
postJson('/api/config', { tile_source: key }).catch(function (e) {
|
||
pushLog('warn', '底图设置未能保存到服务端:' + e.message);
|
||
});
|
||
}
|
||
if (changed) savePrefs();
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 设备 */
|
||
/* ================================================================== */
|
||
|
||
function applyDevices(list, selected) {
|
||
S.devices = Array.isArray(list) ? list : [];
|
||
if (selected) S.selected = selected;
|
||
if (!S.selected || !S.devices.some(function (d) { return d.udid === S.selected; })) {
|
||
S.selected = S.devices.length ? S.devices[0].udid : null;
|
||
}
|
||
S.device = S.devices.filter(function (d) { return d.udid === S.selected; })[0] || null;
|
||
renderDevice();
|
||
updateBanner();
|
||
}
|
||
|
||
function renderDevice() {
|
||
var card = $('#deviceCard');
|
||
var d = S.device;
|
||
|
||
if (!d) {
|
||
card.innerHTML = '<div class="device-empty">未检测到 iOS 设备 — 请用数据线连接 iPhone,' +
|
||
'解锁屏幕后点击右上角「刷新设备」</div>';
|
||
} else {
|
||
var stateCls = d.state === 'connected' ? 'ok' : (d.state === 'unpaired' ? 'warn' : 'err');
|
||
var tunCls = d.tunnel === 'connected' ? 'ok' : (d.tunnel === 'error' ? 'err' :
|
||
(d.tunnel === 'starting' ? 'warn' : ''));
|
||
var html = '';
|
||
html += '<div class="dev-name"><b>' + esc(d.name || d.udid) + '</b><span>' + esc(d.udid || '') + '</span></div>';
|
||
html += '<div class="dev-grid">';
|
||
html += devRow('型号', d.product_type || '--');
|
||
html += devRow('系统', 'iOS ' + (d.ios_version || '--'));
|
||
html += devRow('连接', CONN_CN[d.connection] || d.connection || '--');
|
||
html += devRow('设备状态', STATE_CN[d.state] || d.state || '--', stateCls);
|
||
html += devRow('隧道', TUNNEL_CN[d.tunnel] || d.tunnel || '--', tunCls);
|
||
html += devRow('虚拟定位', d.simulating ? '模拟中' : '未模拟', d.simulating ? 'ok' : '');
|
||
html += devRow('开发者模式', d.developer_mode ? '已开启' : '未开启', d.developer_mode ? 'ok' : 'warn');
|
||
html += '</div>';
|
||
if (d.state === 'unpaired') {
|
||
html += '<div class="dev-hint">设备未配对:请在 iPhone 上点「信任此电脑」后重新连接</div>';
|
||
}
|
||
if (d.developer_mode === false) {
|
||
html += '<div class="dev-hint">未开启开发者模式:设置 → 隐私与安全性 → 开发者模式</div>';
|
||
}
|
||
if (d.tunnel === 'starting' && S.tunnelStuck) {
|
||
html += '<div class="dev-hint">隧道建立已超过 20 秒,请检查是否以管理员权限运行,或改用「手动 RSD」</div>';
|
||
}
|
||
if (d.tunnel === 'error' && d.tunnel_hint) {
|
||
html += '<div class="dev-hint">' + esc(d.tunnel_hint) + '</div>';
|
||
}
|
||
if (d.last_error) {
|
||
html += '<div class="dev-hint">' + esc(d.last_error) + '</div>';
|
||
}
|
||
card.innerHTML = html;
|
||
}
|
||
|
||
if (S.devices.length > 1) {
|
||
var sel = document.createElement('select');
|
||
sel.className = 'input select';
|
||
sel.style.maxWidth = '190px';
|
||
S.devices.forEach(function (dd) {
|
||
var o = document.createElement('option');
|
||
o.value = dd.udid;
|
||
o.textContent = (dd.name || dd.udid) + ' · iOS ' + (dd.ios_version || '?');
|
||
if (dd.udid === S.selected) o.selected = true;
|
||
sel.appendChild(o);
|
||
});
|
||
sel.addEventListener('change', function () { selectDevice(sel.value); });
|
||
card.appendChild(sel);
|
||
}
|
||
|
||
updateButtons();
|
||
updateBanner();
|
||
}
|
||
|
||
function devRow(k, v, cls) {
|
||
return '<div class="dev-row"><span class="dev-k">' + esc(k) + '</span>' +
|
||
'<span class="dev-v ' + (cls || '') + '">' + esc(v) + '</span></div>';
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||
});
|
||
}
|
||
|
||
function updateButtons() {
|
||
var open = S.ws && S.ws.isOpen();
|
||
var d = S.device;
|
||
$('#btnDevicesRefresh').disabled = !open;
|
||
$('#btnDeviceConnect').disabled = !open || !d || d.state !== 'connected';
|
||
$('#btnDeviceDisconnect').disabled = !open || !d;
|
||
$('#btnFetchLogs').disabled = !open;
|
||
updateTransport();
|
||
}
|
||
|
||
function updateBanner() {
|
||
var banner = $('#alertBanner');
|
||
var text = '', kind = '', action = null;
|
||
|
||
if (!S.ws || !S.ws.isOpen()) {
|
||
text = '本地服务未连接(' + WS_URL.replace('ws://', '') + ')。请确认已启动服务后点击「重试」。';
|
||
kind = 'is-error';
|
||
action = { label: '重试', fn: function () { S.ws.reconnectNow(); } };
|
||
} else if (S.device && S.device.state === 'unpaired') {
|
||
text = '「' + (S.device.name || S.device.udid) + '」尚未与此电脑配对,请在 iPhone 上点击「信任」后重新连接。';
|
||
} else if (S.device && S.device.developer_mode === false) {
|
||
text = '请在 iPhone 上开启「开发者模式」:设置 → 隐私与安全性 → 开发者模式,开启后需重启手机,否则无法模拟定位。';
|
||
} else if (S.device && S.device.tunnel === 'error') {
|
||
text = S.device.tunnel_hint || '隧道建立失败。iOS 17 及以上需要管理员权限建立隧道,请以 sudo 运行服务。';
|
||
kind = 'is-error';
|
||
action = { label: '建立隧道', fn: function () { $('#btnTunnelEnsure').click(); } };
|
||
} else if (!S.device && S.devices.length === 0) {
|
||
text = '未检测到 iOS 设备。请用数据线连接 iPhone 并解锁屏幕;首次连接需要在手机上信任此电脑。';
|
||
kind = 'is-info';
|
||
} else if (S.mock) {
|
||
text = '当前为模拟模式(--mock):不会真正下发给手机,可用于完整验证轨迹与播放流程。';
|
||
kind = 'is-info';
|
||
}
|
||
|
||
if (!text) { banner.classList.add('hidden'); return; }
|
||
banner.className = 'banner ' + kind;
|
||
$('#bannerText').textContent = text;
|
||
var btn = $('#bannerAction');
|
||
if (action) {
|
||
btn.classList.remove('hidden');
|
||
btn.textContent = action.label;
|
||
btn.onclick = action.fn;
|
||
} else {
|
||
btn.classList.add('hidden');
|
||
btn.onclick = null;
|
||
}
|
||
}
|
||
|
||
function refreshDevices() {
|
||
var btn = $('#btnDevicesRefresh');
|
||
setBusy(btn, true, '扫描中…');
|
||
var done = function () { setBusy(btn, false); };
|
||
if (S.ws && S.ws.isOpen()) {
|
||
req('devices.refresh', {}, 12000).then(function (msg) {
|
||
if (msg.devices) applyDevices(msg.devices, msg.selected);
|
||
pushLog('info', '设备列表已刷新,共 ' + S.devices.length + ' 台');
|
||
}).catch(function (e) {
|
||
pushLog('error', '刷新设备失败:' + e.message);
|
||
toast(e.message, 'err');
|
||
}).then(done);
|
||
} else {
|
||
apiJson('/api/devices').then(function (data) {
|
||
applyDevices(data.devices, data.selected);
|
||
}).catch(function (e) {
|
||
toast(e.message, 'err');
|
||
}).then(done);
|
||
}
|
||
}
|
||
|
||
function selectDevice(udid) {
|
||
if (!udid) return;
|
||
if (S.ws && S.ws.isOpen()) {
|
||
req('device.select', { udid: udid }, 10000).then(function (msg) {
|
||
if (msg.device) { S.device = msg.device; renderDevice(); }
|
||
pushLog('info', '已选择设备:' + ((msg.device && msg.device.name) || udid));
|
||
}).catch(function (e) { toast(e.message, 'err'); });
|
||
} else {
|
||
postJson('/api/devices/select', { udid: udid }).then(function (data) {
|
||
S.device = data.device;
|
||
renderDevice();
|
||
}).catch(function (e) { toast(e.message, 'err'); });
|
||
}
|
||
}
|
||
|
||
function deviceConnect() {
|
||
var btn = $('#btnDeviceConnect');
|
||
setBusy(btn, true, '连接中…');
|
||
pushLog('info', '正在建立隧道并连接 DVT 定位服务…');
|
||
var finish = function () { setBusy(btn, false); };
|
||
if (S.ws && S.ws.isOpen()) {
|
||
req('device.connect', {}, 90000).then(function (msg) {
|
||
if (msg.device) { S.device = msg.device; renderDevice(); }
|
||
if (msg.tunnel) { S.tunnel = msg.tunnel; }
|
||
toast('设备已连接', 'ok');
|
||
syncDeviceFromServer(true);
|
||
pushLog('success', '设备连接成功' + (msg.tunnel && msg.tunnel.rsd ? '(RSD ' + msg.tunnel.rsd + ')' : ''));
|
||
}).catch(function (e) {
|
||
pushLog('error', '连接设备失败:' + e.message);
|
||
toast(e.message, 'err', 5200);
|
||
}).then(finish);
|
||
} else {
|
||
postJson('/api/devices/connect', {}).then(function (data) {
|
||
S.device = data.device;
|
||
renderDevice();
|
||
toast('设备已连接', 'ok');
|
||
}).catch(function (e) {
|
||
toast(e.message, 'err', 5200);
|
||
}).then(finish);
|
||
}
|
||
}
|
||
|
||
function deviceDisconnect() {
|
||
confirmModal('断开设备', '断开后将释放 DVT 会话并让手机恢复真实定位,确定继续?', function () {
|
||
var btn = $('#btnDeviceDisconnect');
|
||
setBusy(btn, true, '断开中…');
|
||
var finish = function () { setBusy(btn, false); };
|
||
var after = function () {
|
||
stopLocalPlaybackState();
|
||
toast('已断开连接,手机已恢复真实定位', 'ok');
|
||
};
|
||
if (S.ws && S.ws.isOpen()) {
|
||
req('device.disconnect', {}, 20000).then(function (msg) {
|
||
if (msg.device) { S.device = msg.device; renderDevice(); }
|
||
after();
|
||
}).catch(function (e) { toast(e.message, 'err'); }).then(finish);
|
||
} else {
|
||
postJson('/api/devices/disconnect', {}).then(after)
|
||
.catch(function (e) { toast(e.message, 'err'); }).then(finish);
|
||
}
|
||
}, '断开');
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 地图事件 */
|
||
/* ================================================================== */
|
||
|
||
function bindMapEvents() {
|
||
var m = S.map;
|
||
|
||
m.on('click', function (e) {
|
||
if (S.drawMode === 'add') {
|
||
addWaypoint(e.lng, e.lat);
|
||
} else if (S.drawMode === 'polyline') {
|
||
if (!S.drawing) startPolyline();
|
||
S.polyline.push([Coord.round6(e.lng), Coord.round6(e.lat)]);
|
||
m.setPath(S.polyline, '#d29922');
|
||
$('#toolHint').textContent = '折线绘制中:已加 ' + S.polyline.length + ' 点,双击或 Esc 结束';
|
||
pushLog('info', '折线加点 ' + S.polyline.length + ':' + Geo.fmtLngLat(e.lng, e.lat, 6));
|
||
} else {
|
||
selectWaypoint(-1);
|
||
}
|
||
});
|
||
|
||
m.on('dblclick', function () {
|
||
if (S.drawMode === 'polyline' && S.drawing) finishPolyline();
|
||
});
|
||
|
||
m.on('waypointclick', function (e) { selectWaypoint(e.index); });
|
||
|
||
m.on('waypointdrag', function (e) {
|
||
if (S.waypoints[e.index]) {
|
||
S.waypoints[e.index][0] = Coord.round6(e.lng);
|
||
S.waypoints[e.index][1] = Coord.round6(e.lat);
|
||
}
|
||
var row = $('#wpList').children[e.index];
|
||
if (row) row.querySelector('.wp-coord').textContent = Geo.fmtLngLat(e.lng, e.lat, 6);
|
||
updateStats();
|
||
});
|
||
|
||
m.on('waypointdragend', function (e) {
|
||
if (!e.moved) return;
|
||
pushUndo();
|
||
invalidateRoute('途经点已移动');
|
||
renderWaypoints();
|
||
updateStats();
|
||
});
|
||
|
||
m.on('move', function () { savePrefs(); });
|
||
m.on('zoomend', function () { savePrefs(); });
|
||
}
|
||
|
||
function setDrawMode(mode) {
|
||
if (mode !== 'polyline' && S.drawing) finishPolyline();
|
||
S.drawMode = mode;
|
||
$$('#drawToolbar .tool-btn[data-tool]').forEach(function (b) {
|
||
b.classList.toggle('is-active', b.dataset.tool === mode);
|
||
});
|
||
S.map.setDrawMode(mode);
|
||
S.map.setDoubleClickZoom(mode !== 'polyline');
|
||
S.map.setWaypoints(S.waypoints, { draggable: mode === 'select' });
|
||
var hints = {
|
||
select: '拖拽平移 · 滚轮缩放 · 可拖动途经点',
|
||
add: '点击地图添加途经点',
|
||
polyline: '连续点击加点,双击或 Esc 结束'
|
||
};
|
||
$('#toolHint').textContent = hints[mode] || '';
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 途经点 / 折线 */
|
||
/* ================================================================== */
|
||
|
||
function snapshot() {
|
||
return JSON.stringify({ waypoints: S.waypoints, drawnPath: S.drawnPath, route: S.route });
|
||
}
|
||
|
||
function pushUndo() {
|
||
S.undo.push(snapshot());
|
||
if (S.undo.length > UNDO_MAX) S.undo.shift();
|
||
}
|
||
|
||
function undo() {
|
||
if (!S.undo.length) { toast('没有可撤销的操作', 'warn'); return; }
|
||
var st = JSON.parse(S.undo.pop());
|
||
S.waypoints = st.waypoints || [];
|
||
S.drawnPath = st.drawnPath || [];
|
||
S.route = st.route || null;
|
||
syncMapLayers();
|
||
renderWaypoints();
|
||
updateStats();
|
||
toast('已撤销上一步', 'info', 1600);
|
||
}
|
||
|
||
function invalidateRoute(reason) {
|
||
if (S.route) {
|
||
S.route = null;
|
||
pushLog('warn', (reason || '路线已变更') + ',需要重新点击「加载 OSRM 路线」');
|
||
}
|
||
syncMapLayers();
|
||
}
|
||
|
||
function addWaypoint(lng, lat) {
|
||
pushUndo();
|
||
S.waypoints.push([Coord.round6(lng), Coord.round6(lat)]);
|
||
S.route = null;
|
||
S.drawnPath = [];
|
||
syncMapLayers();
|
||
renderWaypoints();
|
||
updateStats();
|
||
var i = S.waypoints.length - 1;
|
||
selectWaypoint(i);
|
||
pushLog('info', '已添加途经点 ' + (i + 1) + ':' + Geo.fmtLngLat(lng, lat, 6));
|
||
}
|
||
|
||
function removeWaypoint(index) {
|
||
if (index < 0 || index >= S.waypoints.length) return;
|
||
pushUndo();
|
||
S.waypoints.splice(index, 1);
|
||
S.route = null;
|
||
if (S.selectedWp === index) S.selectedWp = -1;
|
||
else if (S.selectedWp > index) S.selectedWp -= 1;
|
||
syncMapLayers();
|
||
renderWaypoints();
|
||
updateStats();
|
||
}
|
||
|
||
function setWaypointCoords(index, lng, lat) {
|
||
if (index < 0 || index >= S.waypoints.length) return;
|
||
pushUndo();
|
||
S.waypoints[index] = [Coord.round6(lng), Coord.round6(lat)];
|
||
S.route = null;
|
||
syncMapLayers();
|
||
renderWaypoints();
|
||
updateStats();
|
||
}
|
||
|
||
function reorderWaypoints(from, to) {
|
||
if (from === to || from < 0 || to < 0 || from >= S.waypoints.length || to >= S.waypoints.length) return;
|
||
pushUndo();
|
||
var item = S.waypoints.splice(from, 1)[0];
|
||
S.waypoints.splice(to, 0, item);
|
||
S.route = null;
|
||
syncMapLayers();
|
||
renderWaypoints();
|
||
updateStats();
|
||
}
|
||
|
||
function selectWaypoint(index, center) {
|
||
S.selectedWp = index;
|
||
S.map.setSelected(index);
|
||
$$('#wpList .wp-item').forEach(function (row, i) {
|
||
row.classList.toggle('is-selected', i === index);
|
||
});
|
||
if (index >= 0 && center && S.waypoints[index]) {
|
||
S.map.setView(S.waypoints[index][0], S.waypoints[index][1], Math.max(S.map.getZoom(), 15));
|
||
}
|
||
}
|
||
|
||
function syncMapLayers() {
|
||
S.map.setWaypoints(S.waypoints, { draggable: S.drawMode === 'select' });
|
||
if (S.drawing) {
|
||
S.map.setPath(S.polyline, '#d29922');
|
||
} else if (S.route && S.route.path) {
|
||
S.map.setPath(S.route.path);
|
||
} else if (S.drawnPath.length >= 2) {
|
||
S.map.setPath(S.drawnPath, '#d29922');
|
||
} else if (S.waypoints.length >= 2) {
|
||
S.map.setPath(S.waypoints.slice(), '#8b98a8');
|
||
} else {
|
||
S.map.setPath([]);
|
||
}
|
||
S.map.setSelected(S.selectedWp);
|
||
}
|
||
|
||
function startPolyline() {
|
||
S.drawing = true;
|
||
S.polyline = [];
|
||
pushLog('info', '开始折线绘制:连续点击地图加点,双击或 Esc 结束');
|
||
$('#toolHint').textContent = '折线绘制中:点击加点,双击或 Esc 结束';
|
||
}
|
||
|
||
function finishPolyline() {
|
||
S.drawing = false;
|
||
var pts = S.polyline.slice();
|
||
S.polyline = [];
|
||
if (pts.length < 2) {
|
||
S.map.setPath([]);
|
||
syncMapLayers();
|
||
toast('折线至少需要 2 个点', 'warn');
|
||
return;
|
||
}
|
||
pushUndo();
|
||
S.drawnPath = pts;
|
||
S.route = null;
|
||
syncMapLayers();
|
||
updateStats();
|
||
S.map.fitBounds(pts, { padding: 60 });
|
||
pushLog('success', '折线绘制完成,共 ' + pts.length + ' 点,长度 ' + Geo.fmtDistance(Geo.pathLength(pts)));
|
||
toast('折线绘制完成,可直接播放或导出 GPX', 'ok');
|
||
setDrawMode('select');
|
||
}
|
||
|
||
function renderWaypoints() {
|
||
var list = $('#wpList');
|
||
list.innerHTML = '';
|
||
$('#wpEmpty').classList.toggle('hidden', S.waypoints.length > 0);
|
||
var n = S.waypoints.length;
|
||
|
||
S.waypoints.forEach(function (wp, i) {
|
||
var row = document.createElement('div');
|
||
row.className = 'wp-item' + (i === 0 ? ' is-start' : (i === n - 1 ? ' is-end' : '')) +
|
||
(i === S.selectedWp ? ' is-selected' : '');
|
||
row.draggable = true;
|
||
row.dataset.index = String(i);
|
||
row.innerHTML =
|
||
'<span class="wp-drag" title="拖拽排序">⋮⋮</span>' +
|
||
'<span class="wp-idx">' + (i === 0 ? '起' : (i === n - 1 ? '终' : (i + 1))) + '</span>' +
|
||
'<span class="wp-main">' +
|
||
'<span class="wp-coord" title="双击可修改坐标">' + Geo.fmtLngLat(wp[0], wp[1], 6) + '</span>' +
|
||
'<span class="wp-actions">' +
|
||
'<button type="button" class="wp-btn" data-act="center" title="地图定位到该点">定位到此处</button>' +
|
||
'<button type="button" class="wp-btn" data-act="single" title="让设备模拟定位到该点">模拟到此</button>' +
|
||
'<button type="button" class="wp-btn danger" data-act="del" title="删除该点">删除</button>' +
|
||
'</span></span>';
|
||
|
||
row.addEventListener('click', function (ev) {
|
||
var btn = ev.target.closest('button');
|
||
if (btn) {
|
||
ev.stopPropagation();
|
||
if (btn.dataset.act === 'del') removeWaypoint(i);
|
||
else if (btn.dataset.act === 'center') {
|
||
selectWaypoint(i);
|
||
S.map.setView(wp[0], wp[1], Math.max(S.map.getZoom(), 15));
|
||
} else if (btn.dataset.act === 'single') setSingleLocation(wp[0], wp[1], i);
|
||
return;
|
||
}
|
||
selectWaypoint(i);
|
||
S.map.setView(wp[0], wp[1], Math.max(S.map.getZoom(), 14));
|
||
});
|
||
|
||
row.addEventListener('dblclick', function () { editWaypointCoords(i); });
|
||
|
||
row.addEventListener('dragstart', function (ev) {
|
||
ev.dataTransfer.effectAllowed = 'move';
|
||
try { ev.dataTransfer.setData('text/plain', String(i)); } catch (err) { /* 忽略 */ }
|
||
row.classList.add('is-dragging');
|
||
});
|
||
row.addEventListener('dragend', function () { row.classList.remove('is-dragging'); });
|
||
row.addEventListener('dragover', function (ev) {
|
||
ev.preventDefault();
|
||
row.classList.add('drag-over');
|
||
});
|
||
row.addEventListener('dragleave', function () { row.classList.remove('drag-over'); });
|
||
row.addEventListener('drop', function (ev) {
|
||
ev.preventDefault();
|
||
row.classList.remove('drag-over');
|
||
var from = parseInt(ev.dataTransfer.getData('text/plain'), 10);
|
||
if (isFinite(from)) reorderWaypoints(from, i);
|
||
});
|
||
|
||
list.appendChild(row);
|
||
});
|
||
|
||
$('#statWaypoints').textContent = String(n);
|
||
$('#routeMeta').textContent = (S.routeName || '未命名轨迹');
|
||
updateButtons();
|
||
}
|
||
|
||
function editWaypointCoords(index) {
|
||
var wp = S.waypoints[index];
|
||
if (!wp) return;
|
||
openModal({
|
||
title: '修改途经点坐标(第 ' + (index + 1) + ' 点)',
|
||
okText: '保存',
|
||
html:
|
||
'<div class="field"><label>经度</label><input class="input" id="m_lng" value="' + wp[0].toFixed(6) + '"></div>' +
|
||
'<div class="field"><label>纬度</label><input class="input" id="m_lat" value="' + wp[1].toFixed(6) + '"></div>' +
|
||
'<div class="hint">坐标为 WGS84,单位度(例如 116.397428 / 39.909230)。高德底图显示时会自动转换为 GCJ-02。</div>',
|
||
onOk: function () {
|
||
var lng = parseFloat($('#m_lng').value);
|
||
var lat = parseFloat($('#m_lat').value);
|
||
if (!isFinite(lng) || !isFinite(lat) || Math.abs(lng) > 180 || Math.abs(lat) > 90) {
|
||
toast('坐标格式不正确', 'err');
|
||
return false;
|
||
}
|
||
setWaypointCoords(index, lng, lat);
|
||
return true;
|
||
}
|
||
});
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 路线构建 / 统计 */
|
||
/* ================================================================== */
|
||
|
||
function buildLocalRoute(points, engine) {
|
||
var speed = S.modeDefaults[S.mode] || 13.9;
|
||
var path = points.map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; });
|
||
var distance = Geo.pathLength(path);
|
||
var duration = speed > 0 ? distance / speed : 0;
|
||
return {
|
||
id: 'r-' + Date.now(),
|
||
name: S.routeName || '未命名轨迹',
|
||
mode: S.mode,
|
||
waypoints: (S.waypoints.length >= 2 ? S.waypoints : path).map(function (p) { return [p[0], p[1]]; }),
|
||
path: path,
|
||
speeds: path.map(function () { return speed; }),
|
||
distance: distance,
|
||
duration: duration,
|
||
engine: engine || 'straight',
|
||
loop: S.loop,
|
||
speed_scale: S.speedScale
|
||
};
|
||
}
|
||
|
||
/** 归一化服务端 Route,补齐可能缺失的字段 */
|
||
function normalizeRoute(r, engine) {
|
||
if (!r || !Array.isArray(r.path) || r.path.length < 2) {
|
||
throw new Error('服务端返回的路线数据不完整');
|
||
}
|
||
var path = r.path.map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; });
|
||
var speed = S.modeDefaults[S.mode] || 13.9;
|
||
var speeds = Array.isArray(r.speeds) && r.speeds.length === path.length
|
||
? r.speeds.map(Number) : path.map(function () { return speed; });
|
||
var distance = isFinite(r.distance) ? +r.distance : Geo.pathLength(path);
|
||
var duration = isFinite(r.duration) ? +r.duration : (speed > 0 ? distance / speed : 0);
|
||
return {
|
||
id: r.id || ('r-' + Date.now()),
|
||
name: r.name || S.routeName || '未命名轨迹',
|
||
mode: r.mode || S.mode,
|
||
waypoints: Array.isArray(r.waypoints) && r.waypoints.length
|
||
? r.waypoints.map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; })
|
||
: S.waypoints.map(function (p) { return [p[0], p[1]]; }),
|
||
path: path,
|
||
speeds: speeds,
|
||
distance: distance,
|
||
duration: duration,
|
||
engine: r.engine || engine || 'straight',
|
||
loop: !!S.loop,
|
||
speed_scale: S.speedScale
|
||
};
|
||
}
|
||
|
||
function buildRoute() {
|
||
if (S.waypoints.length < 2) {
|
||
toast('至少需要 2 个途经点才能规划路线', 'warn');
|
||
return;
|
||
}
|
||
var btn = $('#btnBuildRoute');
|
||
setBusy(btn, true, '规划中…');
|
||
pushLog('info', '正在规划路线(' + (MODE_CN[S.mode] || S.mode) + ',' + S.waypoints.length + ' 个途经点)…');
|
||
|
||
postJson('/api/route/build', { waypoints: S.waypoints, mode: S.mode }).then(function (data) {
|
||
S.route = normalizeRoute(data.route, data.engine);
|
||
S.drawnPath = [];
|
||
syncMapLayers();
|
||
S.map.fitBounds(S.route.path, { padding: 60 });
|
||
updateStats();
|
||
if (S.route.engine === 'osrm') {
|
||
pushLog('success', 'OSRM 路线已生成:' + Geo.fmtDistance(S.route.distance) + ',' + Geo.fmtDuration(S.route.duration));
|
||
toast('路线已生成(OSRM 路网)', 'ok');
|
||
} else {
|
||
pushLog('warn', '路线规划服务不可用,已按直线生成;播放与导出仍然可用');
|
||
toast('OSRM 不可用,已退化为直线路线', 'warn', 4600);
|
||
}
|
||
}).catch(function (e) {
|
||
pushLog('error', '路线规划失败:' + e.message + ',改用本地直线路线');
|
||
toast('路线服务不可用,已本地生成直线路线', 'warn', 4600);
|
||
if (S.waypoints.length >= 2) {
|
||
S.route = buildLocalRoute(S.waypoints, 'straight');
|
||
S.drawnPath = [];
|
||
syncMapLayers();
|
||
S.map.fitBounds(S.route.path, { padding: 60 });
|
||
updateStats();
|
||
}
|
||
}).then(function () { setBusy(btn, false); });
|
||
}
|
||
|
||
/** 播放/导出用的路线:优先服务端路线 → 手绘折线 → 途经点直线 */
|
||
function ensureRoute() {
|
||
if (S.route && S.route.path && S.route.path.length >= 2) return S.route;
|
||
if (S.drawnPath.length >= 2) return buildLocalRoute(S.drawnPath, 'drawn');
|
||
if (S.waypoints.length >= 2) return buildLocalRoute(S.waypoints, 'straight');
|
||
return null;
|
||
}
|
||
|
||
function currentPath() {
|
||
if (S.route && S.route.path) return S.route.path;
|
||
if (S.drawnPath.length >= 2) return S.drawnPath;
|
||
return S.waypoints;
|
||
}
|
||
|
||
function updateStats() {
|
||
var path = currentPath();
|
||
var route = S.route;
|
||
var distance = route && isFinite(route.distance) ? route.distance : Geo.pathLength(path);
|
||
var speed = S.modeDefaults[S.mode] || 13.9;
|
||
var duration = route && isFinite(route.duration) ? route.duration : (speed > 0 ? distance / speed : 0);
|
||
|
||
$('#statDistance').textContent = distance > 0 ? Geo.fmtDistance(distance) : '--';
|
||
$('#statDuration').textContent = duration > 0 ? Geo.fmtDurationCn(duration) : '--';
|
||
$('#statPlayDuration').textContent = duration > 0
|
||
? Geo.fmtDurationCn(duration / Math.max(0.01, S.speedScale)) : '--';
|
||
$('#statPoints').textContent = String(path ? path.length : 0);
|
||
$('#statWaypoints').textContent = String(S.waypoints.length);
|
||
|
||
var engine = '--';
|
||
if (S.drawing) engine = '绘制中';
|
||
else if (route) engine = route.engine === 'osrm' ? 'OSRM 路网' : (route.engine === 'drawn' ? '手绘折线' : '直线');
|
||
else if (S.drawnPath.length >= 2) engine = '手绘折线';
|
||
else if (S.waypoints.length >= 2) engine = '待规划';
|
||
$('#statEngine').textContent = engine;
|
||
|
||
$('#chipTrack').textContent = '轨迹点 ' + (S.map ? S.map.layers.track.length : 0);
|
||
$('#routeMeta').textContent = (S.routeName || '未命名轨迹') + (route ? '' : '(未规划)');
|
||
updateTransport();
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 播放控制 */
|
||
/* ================================================================== */
|
||
|
||
function routePayload(route) {
|
||
return {
|
||
id: route.id,
|
||
name: route.name || S.routeName || '未命名轨迹',
|
||
mode: route.mode || S.mode,
|
||
waypoints: (route.waypoints || S.waypoints).map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; }),
|
||
path: route.path.map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; }),
|
||
speeds: route.speeds,
|
||
distance: route.distance,
|
||
duration: route.duration,
|
||
engine: route.engine,
|
||
loop: !!S.loop,
|
||
speed_scale: S.speedScale
|
||
};
|
||
}
|
||
|
||
function startPlay() {
|
||
var route = ensureRoute();
|
||
if (!route) {
|
||
toast('请先添加至少 2 个途经点,或绘制折线、加载路线', 'warn', 4200);
|
||
return;
|
||
}
|
||
route.loop = !!S.loop;
|
||
route.speed_scale = S.speedScale;
|
||
S.map.clearTrack();
|
||
S.map.setPlayedIndex(-1);
|
||
updateStats();
|
||
pushLog('info', '开始播放:' + Geo.fmtDistance(route.distance) + ',倍速 ' + Geo.fmtSpeedScale(S.speedScale) + (S.loop ? ',循环' : ''));
|
||
req('play.start', {
|
||
route: routePayload(route),
|
||
speed_scale: S.speedScale,
|
||
loop: !!S.loop,
|
||
start_index: 0,
|
||
tuning: {
|
||
drift_enabled: !!S.driftEnabled,
|
||
drift_m: Number(S.driftM),
|
||
speed_jitter_enabled: !!S.jitterEnabled,
|
||
speed_jitter: Number(S.jitterPercent),
|
||
timing_jitter_enabled: !!S.timingJitter,
|
||
derive_speed: !!S.deriveSpeed,
|
||
gross_error_enabled: !!S.grossError,
|
||
gross_error_probability: Number(S.grossErrorPercent) / 100
|
||
}
|
||
}, 15000).then(function () {
|
||
S.playing = true;
|
||
S.paused = false;
|
||
S.stoppedAt = 0;
|
||
S.statusSeq = null;
|
||
updateTransport();
|
||
pushLog('success', '播放已开始');
|
||
}).catch(function (e) {
|
||
pushLog('error', '开始播放失败:' + e.message);
|
||
toast(e.message, 'err', 4600);
|
||
});
|
||
}
|
||
|
||
function pausePlay() {
|
||
req('play.pause', {}, 8000).then(function () {
|
||
S.paused = true;
|
||
updateTransport();
|
||
pushLog('info', '播放已暂停');
|
||
}).catch(function (e) { toast(e.message, 'err'); });
|
||
}
|
||
|
||
function resumePlay() {
|
||
req('play.resume', {}, 8000).then(function () {
|
||
S.paused = false;
|
||
S.playing = true;
|
||
updateTransport();
|
||
pushLog('info', '继续播放');
|
||
}).catch(function (e) { toast(e.message, 'err'); });
|
||
}
|
||
|
||
function stopPlay(silent) {
|
||
req('play.stop', {}, 10000).then(function () {
|
||
stopLocalPlaybackState();
|
||
pushLog('info', '播放已停止,设备已恢复真实定位');
|
||
if (!silent) toast('已停止播放,设备恢复真实定位', 'ok');
|
||
}).catch(function (e) {
|
||
stopLocalPlaybackState();
|
||
if (!silent) toast(e.message, 'err');
|
||
});
|
||
}
|
||
|
||
function stopLocalPlaybackState() {
|
||
S.playing = false;
|
||
S.paused = false;
|
||
S.status = null;
|
||
S.statusSeq = null;
|
||
S.stoppedAt = Date.now();
|
||
S.progress = 0;
|
||
S.distanceDone = 0;
|
||
$('#progressRange').value = 0;
|
||
$('#liveProgress').textContent = '0.0%';
|
||
$('#timeElapsed').textContent = '00:00';
|
||
$('#timeRemaining').textContent = '00:00';
|
||
$('#liveIndex').textContent = '0 / 0';
|
||
$('#liveSpeed').textContent = '--';
|
||
$('#chipSpeed').textContent = '速度 --';
|
||
updateProgressChip();
|
||
S.map.setPlayedIndex(-1);
|
||
updateTransport();
|
||
}
|
||
|
||
function seek(progress) {
|
||
progress = Geo.clamp(progress, 0, 1);
|
||
req('play.seek', { progress: progress }, 8000).then(function () {
|
||
S.progress = progress;
|
||
}).catch(function (e) {
|
||
toast(e.message, 'err', 3800);
|
||
});
|
||
}
|
||
|
||
function setSpeedScale(v, opts) {
|
||
S.speedScale = Geo.clamp(+v, Geo.SPEED_MIN, Geo.SPEED_MAX);
|
||
$('#speedValue').textContent = Geo.fmtSpeedScale(S.speedScale);
|
||
$('#liveScale').textContent = Geo.fmtSpeedScale(S.speedScale);
|
||
$('#speedRange').value = Math.round(Geo.speedToSlider(S.speedScale) * 1000);
|
||
if (S.route) S.route.speed_scale = S.speedScale;
|
||
updateStats();
|
||
savePrefs();
|
||
if (S.playing && !(opts && opts.silent)) {
|
||
req('play.speed', { speed_scale: S.speedScale }, 8000).catch(function (e) {
|
||
pushLog('warn', '设置播放倍速失败:' + e.message);
|
||
});
|
||
}
|
||
}
|
||
|
||
function toggleLoop() {
|
||
S.loop = $('#loopToggle').checked;
|
||
savePrefs();
|
||
if (S.playing || S.paused) {
|
||
// 契约没有单独的 loop 命令:带上当前进度重新开始播放以应用循环设置
|
||
var route = ensureRoute();
|
||
if (!route) return;
|
||
var idx = S.status && isFinite(S.status.index) ? S.status.index : 0;
|
||
route.loop = S.loop;
|
||
pushLog('info', '循环设置已变更,正在按当前位置重新开始播放…');
|
||
req('play.start', {
|
||
route: routePayload(route),
|
||
speed_scale: S.speedScale,
|
||
loop: S.loop,
|
||
start_index: idx
|
||
}, 15000).then(function () {
|
||
S.playing = true;
|
||
S.paused = false;
|
||
updateTransport();
|
||
}).catch(function (e) {
|
||
toast('应用循环设置失败:' + e.message, 'err');
|
||
});
|
||
} else {
|
||
pushLog('info', S.loop ? '已开启循环播放' : '已关闭循环播放');
|
||
}
|
||
}
|
||
|
||
function updateProgressChip() {
|
||
var pct = (Geo.clamp(S.progress, 0, 1) * 100).toFixed(1) + '%';
|
||
var d = (S.status && isFinite(S.status.distance_done)) ? S.status.distance_done : S.distanceDone;
|
||
$('#chipProgress').textContent = '进度 ' + pct + (isFinite(d) && d > 0 ? ' · 已走 ' + Geo.fmtDistance(d) : '');
|
||
}
|
||
|
||
function onStatus(st) {
|
||
// play.stop 之后服务端可能还会推一条最终状态(progress=1)。用户已点「停止」,
|
||
// 这里在 4 秒窗口内忽略非播放态的状态推送,避免进度条/定位点被“弹回终点”。
|
||
if (S.stoppedAt) {
|
||
if (st.playing) {
|
||
S.stoppedAt = 0;
|
||
} else if (Date.now() - S.stoppedAt < 4000) {
|
||
updateTransport();
|
||
return;
|
||
} else {
|
||
S.stoppedAt = 0;
|
||
}
|
||
}
|
||
// seq 自增:丢弃乱序/重复的旧状态
|
||
if (isFinite(st.seq)) {
|
||
if (S.statusSeq != null && st.seq <= S.statusSeq) return;
|
||
S.statusSeq = st.seq;
|
||
}
|
||
S.status = st;
|
||
var wasPlaying = S.playing;
|
||
S.playing = !!st.playing;
|
||
S.paused = !!st.paused;
|
||
// 服务端回报的真实感参数(可能有别的客户端改过),状态变化时同步到开关
|
||
if (st.tuning) {
|
||
var t = st.tuning;
|
||
var changed = (typeof t.drift_enabled === 'boolean' && t.drift_enabled !== S.driftEnabled) ||
|
||
(isFinite(t.drift_m) && Math.round(t.drift_m) !== S.driftM) ||
|
||
(typeof t.speed_jitter_enabled === 'boolean' && t.speed_jitter_enabled !== S.jitterEnabled) ||
|
||
(isFinite(t.speed_jitter_percent) && Math.round(t.speed_jitter_percent) !== S.jitterPercent) ||
|
||
(typeof t.timing_jitter_enabled === 'boolean' && t.timing_jitter_enabled !== S.timingJitter) ||
|
||
(typeof t.derive_speed === 'boolean' && t.derive_speed !== S.deriveSpeed) ||
|
||
(typeof t.gross_error_enabled === 'boolean' && t.gross_error_enabled !== S.grossError);
|
||
if (changed) {
|
||
if (typeof t.drift_enabled === 'boolean') S.driftEnabled = t.drift_enabled;
|
||
if (isFinite(t.drift_m)) S.driftM = Math.round(t.drift_m);
|
||
if (typeof t.speed_jitter_enabled === 'boolean') S.jitterEnabled = t.speed_jitter_enabled;
|
||
if (isFinite(t.speed_jitter_percent)) S.jitterPercent = Math.round(t.speed_jitter_percent);
|
||
if (typeof t.timing_jitter_enabled === 'boolean') S.timingJitter = t.timing_jitter_enabled;
|
||
if (typeof t.derive_speed === 'boolean') S.deriveSpeed = t.derive_speed;
|
||
if (typeof t.gross_error_enabled === 'boolean') S.grossError = t.gross_error_enabled;
|
||
if (typeof applyTuningUi === 'function') applyTuningUi();
|
||
}
|
||
}
|
||
if (isFinite(st.speed_scale) && Math.abs(st.speed_scale - S.speedScale) > 1e-6 && !S.speedDragging) {
|
||
// 服务端为准(例如其他客户端改过倍速)
|
||
S.speedScale = Geo.clamp(st.speed_scale, Geo.SPEED_MIN, Geo.SPEED_MAX);
|
||
applyPrefsToUI();
|
||
updateStats();
|
||
}
|
||
if (isFinite(st.progress) && !S.scrubbing) {
|
||
S.progress = st.progress;
|
||
$('#progressRange').value = Math.round(st.progress * 1000);
|
||
$('#liveProgress').textContent = (st.progress * 100).toFixed(1) + '%';
|
||
updateProgressChip();
|
||
}
|
||
if (isFinite(st.elapsed)) $('#timeElapsed').textContent = Geo.fmtDuration(st.elapsed);
|
||
if (isFinite(st.remaining)) $('#timeRemaining').textContent = Geo.fmtDuration(st.remaining);
|
||
if (isFinite(st.index)) {
|
||
var total = isFinite(st.total) ? st.total : (currentPath() || []).length;
|
||
$('#liveIndex').textContent = st.index + ' / ' + total;
|
||
S.map.setPlayedIndex(st.index);
|
||
}
|
||
if (isFinite(st.lng) && isFinite(st.lat)) {
|
||
S.map.setCurrent(st.lng, st.lat);
|
||
$('#liveCoord').textContent = Geo.fmtLngLat(st.lng, st.lat, 6);
|
||
$('#chipCoord').textContent = Geo.fmtLngLat(st.lng, st.lat, 6);
|
||
}
|
||
if (isFinite(st.distance_done)) S.distanceDone = st.distance_done;
|
||
updateProgressChip();
|
||
if (st.loop != null) {
|
||
S.loop = !!st.loop;
|
||
$('#loopToggle').checked = S.loop;
|
||
}
|
||
if (!wasPlaying && S.playing) pushLog('info', '轨迹播放中…');
|
||
if (wasPlaying && !S.playing && st.progress >= 1) {
|
||
pushLog('success', '轨迹播放完成' + (S.loop ? '(循环继续)' : ''));
|
||
}
|
||
updateTransport();
|
||
}
|
||
|
||
function onLocation(loc) {
|
||
S.lastLocation = loc;
|
||
// play.stop / location.clear 后服务端会推 source:"cleared" 且 lng/lat 为 null
|
||
if (loc.source === 'cleared' || loc.lng == null || loc.lat == null) {
|
||
if (loc.source === 'cleared') {
|
||
S.map.setCurrent(null);
|
||
$('#liveCoord').textContent = '--';
|
||
$('#chipCoord').textContent = '--';
|
||
$('#liveSpeed').textContent = '--';
|
||
$('#chipSpeed').textContent = '速度 --';
|
||
pushLog('info', '设备已恢复真实定位');
|
||
}
|
||
return;
|
||
}
|
||
if (isFinite(loc.lng) && isFinite(loc.lat)) {
|
||
S.map.setCurrent(loc.lng, loc.lat);
|
||
$('#liveCoord').textContent = Geo.fmtLngLat(loc.lng, loc.lat, 6);
|
||
$('#chipCoord').textContent = Geo.fmtLngLat(loc.lng, loc.lat, 6);
|
||
if (loc.source === 'play') {
|
||
S.map.pushTrack(loc.lng, loc.lat);
|
||
$('#chipTrack').textContent = '轨迹点 ' + S.map.layers.track.length;
|
||
}
|
||
}
|
||
if (isFinite(loc.speed)) {
|
||
$('#liveSpeed').textContent = Geo.fmtSpeed(loc.speed);
|
||
$('#chipSpeed').textContent = '速度 ' + Geo.fmtSpeed(loc.speed);
|
||
}
|
||
if (isFinite(loc.progress) && !S.scrubbing && !S.stoppedAt) {
|
||
S.progress = loc.progress;
|
||
$('#progressRange').value = Math.round(loc.progress * 1000);
|
||
$('#liveProgress').textContent = (loc.progress * 100).toFixed(1) + '%';
|
||
updateProgressChip();
|
||
}
|
||
if (loc.source === 'manual') {
|
||
pushLog('success', '单点定位已下发:' + Geo.fmtLngLat(loc.lng, loc.lat, 6));
|
||
}
|
||
}
|
||
|
||
function updateTransport() {
|
||
var hasRoute = !!(S.route || S.drawnPath.length >= 2 || S.waypoints.length >= 2);
|
||
var playing = S.playing && !S.paused;
|
||
$('#btnPlay').disabled = playing || !hasRoute;
|
||
$('#btnPause').disabled = !playing;
|
||
$('#btnResume').disabled = !(S.playing && S.paused);
|
||
$('#btnStop').disabled = !(S.playing || S.paused);
|
||
$('#progressRange').disabled = !(S.playing || S.paused);
|
||
|
||
var stateText = '未播放';
|
||
var cls = '';
|
||
if (S.playing && !S.paused) { stateText = '播放中'; cls = 'is-playing'; }
|
||
else if (S.playing && S.paused) { stateText = '已暂停'; cls = 'is-paused'; }
|
||
else if (S.progress >= 1 && hasRoute) { stateText = '已结束'; }
|
||
$('#playStateText').textContent = stateText;
|
||
$('#playStateText').className = 'play-state ' + cls;
|
||
$('#chipPlayState').textContent = stateText;
|
||
$('#chipPlayState').className = 'chip ' + cls;
|
||
}
|
||
|
||
function setSingleLocation(lng, lat, index) {
|
||
req('location.set', { lng: Coord.round6(lng), lat: Coord.round6(lat) }, 10000).then(function () {
|
||
S.map.setCurrent(lng, lat);
|
||
pushLog('success', '已让设备定位到第 ' + (index + 1) + ' 个途经点:' + Geo.fmtLngLat(lng, lat, 6));
|
||
toast('已下发单点定位', 'ok');
|
||
}).catch(function (e) {
|
||
pushLog('error', '单点定位失败:' + e.message);
|
||
toast(e.message, 'err', 4600);
|
||
});
|
||
}
|
||
|
||
function clearLocation() {
|
||
confirmModal('恢复真实定位', '将停止模拟并让手机恢复 GPS 真实定位,确定继续?', function () {
|
||
if (S.playing || S.paused) stopPlay(true);
|
||
req('location.clear', {}, 10000).then(function () {
|
||
pushLog('success', '已发送「恢复真实定位」');
|
||
toast('设备已恢复真实定位', 'ok');
|
||
}).catch(function (e) {
|
||
pushLog('warn', '恢复真实定位失败:' + e.message);
|
||
toast(e.message, 'err', 4600);
|
||
});
|
||
}, '恢复');
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* GPX 导入导出 */
|
||
/* ================================================================== */
|
||
|
||
function exportGpx() {
|
||
var route = ensureRoute();
|
||
if (!route) {
|
||
toast('没有可导出的路线,请先添加途经点或绘制折线', 'warn', 4200);
|
||
return;
|
||
}
|
||
var name = S.routeName || route.name || '未命名轨迹';
|
||
var btn = $('#btnExportGpx');
|
||
setBusy(btn, true, '导出中…');
|
||
pushLog('info', '正在导出 GPX:' + name + '(' + route.path.length + ' 点)');
|
||
fetch('/api/gpx/export', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||
body: JSON.stringify({ route: routePayload(route), name: name })
|
||
}).then(function (res) {
|
||
var ct = res.headers.get('content-type') || '';
|
||
if (ct.indexOf('json') >= 0) {
|
||
return res.json().then(function (j) {
|
||
throw new Error(j.error || '导出失败');
|
||
});
|
||
}
|
||
if (!res.ok) throw new Error('导出失败 HTTP ' + res.status);
|
||
return res.text().then(function (text) {
|
||
var filename = (name.replace(/[\\/:*?"<>|]/g, '_') || 'route') + '.gpx';
|
||
downloadText(text, filename, 'application/gpx+xml;charset=utf-8');
|
||
pushLog('success', 'GPX 已开始下载:' + filename + '(' + text.length + ' 字节)');
|
||
toast('GPX 已开始下载', 'ok');
|
||
});
|
||
}).catch(function (e) {
|
||
pushLog('error', '导出 GPX 失败:' + e.message);
|
||
toast(e.message, 'err', 4600);
|
||
}).then(function () { setBusy(btn, false); });
|
||
}
|
||
|
||
function importGpx() {
|
||
$('#gpxFile').value = '';
|
||
$('#gpxFile').click();
|
||
}
|
||
|
||
function onGpxFile(ev) {
|
||
var file = ev.target.files && ev.target.files[0];
|
||
if (!file) return;
|
||
pushLog('info', '正在导入 GPX:' + file.name + '(' + file.size + ' 字节)');
|
||
var reader = new FileReader();
|
||
reader.onload = function () {
|
||
var text = String(reader.result || '');
|
||
fetch('/api/gpx/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||
body: text
|
||
}).then(function (res) {
|
||
return res.json();
|
||
}).then(function (data) {
|
||
if (!data || data.ok === false) throw new Error((data && data.error) || '导入失败');
|
||
var wps = (data.waypoints || []).map(function (p) {
|
||
return [Coord.round6(p[0]), Coord.round6(p[1])];
|
||
});
|
||
var full = (data.path || data.track || data.points || []).map(function (p) {
|
||
return [Coord.round6(p[0]), Coord.round6(p[1])];
|
||
});
|
||
if (!wps.length && !full.length) throw new Error('GPX 中没有可用的坐标点');
|
||
pushUndo();
|
||
S.route = null;
|
||
var geom = full.length >= wps.length ? full : wps;
|
||
var MAX_WP = 200;
|
||
if (geom.length > MAX_WP) {
|
||
// 大轨迹(例如把导出的整条路线再导回来):完整几何用于显示/播放/导出,
|
||
// 途经点只保留起终点,否则列表会渲染上万行
|
||
S.waypoints = [geom[0], geom[geom.length - 1]];
|
||
S.drawnPath = geom;
|
||
} else {
|
||
S.waypoints = wps.length >= 2 ? wps : geom;
|
||
// 服务端只回起点/终点时,用完整轨迹点保证导入后的形状可播放、可导出
|
||
S.drawnPath = geom.length > S.waypoints.length ? geom : [];
|
||
}
|
||
S.selectedWp = -1;
|
||
if (data.name) {
|
||
S.routeName = data.name;
|
||
$('#routeName').value = data.name;
|
||
savePrefs();
|
||
}
|
||
syncMapLayers();
|
||
renderWaypoints();
|
||
updateStats();
|
||
S.map.fitBounds(wps, { padding: 60 });
|
||
var note = S.drawnPath.length ? '(完整几何 ' + S.drawnPath.length + ' 点,途经点保留 ' + S.waypoints.length + ' 个)' : '';
|
||
pushLog('success', 'GPX 导入成功:' + (data.count || wps.length) + ' 个点' + note +
|
||
(data.name ? ',名称「' + data.name + '」' : ''));
|
||
toast('已导入 ' + (data.count || wps.length) + ' 个点' + (note ? ',轨迹已按完整几何载入' : ''), 'ok', 4200);
|
||
if (wps.length >= 2) buildRoute();
|
||
}).catch(function (e) {
|
||
pushLog('error', 'GPX 导入失败:' + e.message);
|
||
toast(e.message, 'err', 4600);
|
||
});
|
||
};
|
||
reader.onerror = function () {
|
||
toast('读取文件失败', 'err');
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* 本地保存的路线 */
|
||
/* ================================================================== */
|
||
|
||
function renderSavedRoutes() {
|
||
var list = readSavedRoutes();
|
||
var sel = $('#savedRoutes');
|
||
sel.innerHTML = '';
|
||
if (!list.length) {
|
||
var o = document.createElement('option');
|
||
o.value = '';
|
||
o.textContent = '(暂无保存的路线)';
|
||
sel.appendChild(o);
|
||
sel.disabled = true;
|
||
} else {
|
||
sel.disabled = false;
|
||
list.forEach(function (it, i) {
|
||
var op = document.createElement('option');
|
||
op.value = String(i);
|
||
op.textContent = (it.name || ('路线 ' + (i + 1))) + ' · ' +
|
||
new Date(it.ts || Date.now()).toLocaleString('zh-CN', { hour12: false });
|
||
sel.appendChild(op);
|
||
});
|
||
}
|
||
$('#btnLoadRoute').disabled = !list.length;
|
||
$('#btnDeleteSaved').disabled = !list.length;
|
||
}
|
||
|
||
function saveRouteLocal() {
|
||
var route = ensureRoute();
|
||
if (!route && !S.waypoints.length) {
|
||
toast('没有可保存的内容', 'warn');
|
||
return;
|
||
}
|
||
var name = ($('#routeName').value || '').trim() || ('轨迹 ' + new Date().toLocaleString('zh-CN', { hour12: false }));
|
||
S.routeName = name;
|
||
var list = readSavedRoutes();
|
||
var payload = {
|
||
name: name,
|
||
ts: Date.now(),
|
||
mode: S.mode,
|
||
speed_scale: S.speedScale,
|
||
loop: S.loop,
|
||
waypoints: S.waypoints,
|
||
drawnPath: S.drawnPath,
|
||
route: route
|
||
};
|
||
var idx = -1;
|
||
list.forEach(function (it, i) { if (it.name === name) idx = i; });
|
||
if (idx >= 0) list[idx] = payload; else list.push(payload);
|
||
if (writeSavedRoutes(list)) {
|
||
renderSavedRoutes();
|
||
savePrefs();
|
||
pushLog('success', '路线已保存到浏览器本地:' + name);
|
||
toast('已保存到本地:' + name, 'ok');
|
||
}
|
||
}
|
||
|
||
function loadRouteLocal() {
|
||
var list = readSavedRoutes();
|
||
var i = parseInt($('#savedRoutes').value, 10);
|
||
if (!isFinite(i) || !list[i]) { toast('请先选择要载入的路线', 'warn'); return; }
|
||
var it = list[i];
|
||
pushUndo();
|
||
S.waypoints = (it.waypoints || []).map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; });
|
||
S.drawnPath = (it.drawnPath || []).map(function (p) { return [Coord.round6(p[0]), Coord.round6(p[1])]; });
|
||
S.route = it.route || null;
|
||
S.routeName = it.name || '';
|
||
$('#routeName').value = S.routeName;
|
||
if (it.mode && MODE_CN[it.mode]) {
|
||
S.mode = it.mode;
|
||
$$('#modeGroup .seg-btn').forEach(function (b) { b.classList.toggle('is-active', b.dataset.mode === S.mode); });
|
||
}
|
||
if (isFinite(it.speed_scale)) setSpeedScale(it.speed_scale, { silent: true });
|
||
if (typeof it.loop === 'boolean') { S.loop = it.loop; $('#loopToggle').checked = S.loop; }
|
||
syncMapLayers();
|
||
renderWaypoints();
|
||
updateStats();
|
||
var pts = currentPath();
|
||
if (pts && pts.length >= 2) S.map.fitBounds(pts, { padding: 60 });
|
||
pushLog('success', '已载入本地路线:' + S.routeName);
|
||
toast('已载入:' + S.routeName, 'ok');
|
||
}
|
||
|
||
function deleteSavedRoute() {
|
||
var list = readSavedRoutes();
|
||
var i = parseInt($('#savedRoutes').value, 10);
|
||
if (!isFinite(i) || !list[i]) { toast('请先选择要删除的路线', 'warn'); return; }
|
||
var name = list[i].name;
|
||
confirmModal('删除本地路线', '确定删除保存的路线「' + esc(name) + '」?此操作不可恢复。', function () {
|
||
list.splice(i, 1);
|
||
writeSavedRoutes(list);
|
||
renderSavedRoutes();
|
||
pushLog('warn', '已删除本地路线:' + name);
|
||
}, '删除');
|
||
}
|
||
|
||
/* ================================================================== */
|
||
/* UI 绑定 */
|
||
/* ================================================================== */
|
||
|
||
function bindUI() {
|
||
// 顶部
|
||
$('#btnDevicesRefresh').addEventListener('click', refreshDevices);
|
||
$('#btnDeviceConnect').addEventListener('click', deviceConnect);
|
||
$('#btnDeviceDisconnect').addEventListener('click', deviceDisconnect);
|
||
|
||
// 模式
|
||
$$('#modeGroup .seg-btn').forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
S.mode = b.dataset.mode;
|
||
$$('#modeGroup .seg-btn').forEach(function (x) { x.classList.toggle('is-active', x === b); });
|
||
savePrefs();
|
||
updateStats();
|
||
pushLog('info', '出行模式:' + (MODE_CN[S.mode] || S.mode));
|
||
if (S.route && S.route.engine === 'osrm') {
|
||
toast('已切换为' + MODE_CN[S.mode] + ',如需按新模式的路线请重新加载', 'info', 3600);
|
||
}
|
||
});
|
||
});
|
||
|
||
// 倍速
|
||
var speedRange = $('#speedRange');
|
||
speedRange.addEventListener('input', function () {
|
||
S.speedDragging = true;
|
||
var v = Geo.sliderToSpeed(speedRange.value / 1000);
|
||
S.speedScale = v;
|
||
$('#speedValue').textContent = Geo.fmtSpeedScale(v);
|
||
$('#liveScale').textContent = Geo.fmtSpeedScale(v);
|
||
updateStats();
|
||
});
|
||
speedRange.addEventListener('change', function () {
|
||
S.speedDragging = false;
|
||
setSpeedScale(Geo.sliderToSpeed(speedRange.value / 1000));
|
||
});
|
||
|
||
$('#loopToggle').addEventListener('change', toggleLoop);
|
||
|
||
// ---- 真实感模拟:定位漂移 / 速度浮动 ----
|
||
var driftToggle = $('#driftToggle');
|
||
var driftRange = $('#driftRange');
|
||
var jitterToggle = $('#jitterToggle');
|
||
var jitterRange = $('#jitterRange');
|
||
|
||
function applyTuningUi() {
|
||
driftToggle.checked = !!S.driftEnabled;
|
||
driftRange.value = S.driftM;
|
||
driftRange.disabled = !S.driftEnabled;
|
||
$('#driftValue').textContent = S.driftM + ' 米';
|
||
jitterToggle.checked = !!S.jitterEnabled;
|
||
jitterRange.value = S.jitterPercent;
|
||
jitterRange.disabled = !S.jitterEnabled;
|
||
$('#jitterValue').textContent = '±' + S.jitterPercent + '%';
|
||
var tj = $('#timingJitterToggle');
|
||
if (tj) tj.checked = !!S.timingJitter;
|
||
var ds = $('#deriveSpeedToggle');
|
||
if (ds) ds.checked = !!S.deriveSpeed;
|
||
var ge = $('#grossErrorToggle');
|
||
if (ge) ge.checked = !!S.grossError;
|
||
var gr = $('#grossErrorRange');
|
||
if (gr) { gr.value = S.grossErrorPercent; gr.disabled = !S.grossError; }
|
||
var gv = $('#grossErrorValue');
|
||
if (gv) gv.textContent = S.grossErrorPercent + '%';
|
||
}
|
||
|
||
function pushTuning(withLog) {
|
||
applyTuningUi();
|
||
savePrefs();
|
||
if (!S.ws || !S.ws.isOpen()) return;
|
||
req('play.tuning', {
|
||
drift_enabled: !!S.driftEnabled,
|
||
drift_m: Number(S.driftM),
|
||
speed_jitter_enabled: !!S.jitterEnabled,
|
||
speed_jitter: Number(S.jitterPercent),
|
||
timing_jitter_enabled: !!S.timingJitter,
|
||
derive_speed: !!S.deriveSpeed,
|
||
gross_error_enabled: !!S.grossError,
|
||
gross_error_probability: Number(S.grossErrorPercent) / 100,
|
||
log: !!withLog
|
||
}, 8000).catch(function (e) {
|
||
pushLog('error', '真实感设置失败:' + e.message);
|
||
});
|
||
}
|
||
|
||
driftToggle.addEventListener('change', function () {
|
||
S.driftEnabled = driftToggle.checked;
|
||
pushTuning(true);
|
||
toast(S.driftEnabled ? ('已开启定位漂移(' + S.driftM + ' 米)') : '已关闭定位漂移', 'info', 2600);
|
||
});
|
||
driftRange.addEventListener('input', function () {
|
||
S.driftM = Number(driftRange.value);
|
||
$('#driftValue').textContent = S.driftM + ' 米';
|
||
});
|
||
driftRange.addEventListener('change', function () { pushTuning(true); });
|
||
|
||
jitterToggle.addEventListener('change', function () {
|
||
S.jitterEnabled = jitterToggle.checked;
|
||
pushTuning(true);
|
||
toast(S.jitterEnabled ? ('已开启速度浮动(±' + S.jitterPercent + '%)') : '已关闭速度浮动', 'info', 2600);
|
||
});
|
||
jitterRange.addEventListener('input', function () {
|
||
S.jitterPercent = Number(jitterRange.value);
|
||
$('#jitterValue').textContent = '±' + S.jitterPercent + '%';
|
||
});
|
||
jitterRange.addEventListener('change', function () { pushTuning(true); });
|
||
|
||
var timingToggle = $('#timingJitterToggle');
|
||
if (timingToggle) {
|
||
timingToggle.addEventListener('change', function () {
|
||
S.timingJitter = timingToggle.checked;
|
||
pushTuning(true);
|
||
toast(S.timingJitter ? '已开启发送节奏抖动(贴近真实 GPS)' : '已关闭发送节奏抖动(精确等间隔)', 'info', 2600);
|
||
});
|
||
}
|
||
var deriveToggle = $('#deriveSpeedToggle');
|
||
if (deriveToggle) {
|
||
deriveToggle.addEventListener('change', function () {
|
||
S.deriveSpeed = deriveToggle.checked;
|
||
pushTuning(true);
|
||
toast(S.deriveSpeed ? '上报速度由位移推算(与 iOS 一致)' : '上报速度用基准速度×浮动系数', 'info', 3000);
|
||
});
|
||
}
|
||
var grossToggle = $('#grossErrorToggle');
|
||
var grossRange = $('#grossErrorRange');
|
||
if (grossToggle) {
|
||
grossToggle.addEventListener('change', function () {
|
||
S.grossError = grossToggle.checked;
|
||
pushTuning(true);
|
||
toast(S.grossError ? ('已开启粗差注入(' + S.grossErrorPercent + '% 概率)') : '已关闭粗差注入', 'info', 3000);
|
||
});
|
||
}
|
||
if (grossRange) {
|
||
grossRange.addEventListener('input', function () {
|
||
S.grossErrorPercent = Number(grossRange.value);
|
||
$('#grossErrorValue').textContent = S.grossErrorPercent + '%';
|
||
});
|
||
grossRange.addEventListener('change', function () { pushTuning(true); });
|
||
}
|
||
|
||
// ---- 校园跑 / 长跑预设:一键把参数调到适合运动类 App 的组合 ----
|
||
function applyPreset(name) {
|
||
if (name === 'campus') {
|
||
S.mode = 'walking';
|
||
S.driftEnabled = true; S.driftM = 4;
|
||
S.jitterEnabled = true; S.jitterPercent = 8;
|
||
} else {
|
||
S.mode = 'cycling';
|
||
S.driftEnabled = true; S.driftM = 6;
|
||
S.jitterEnabled = true; S.jitterPercent = 10;
|
||
}
|
||
S.loop = false;
|
||
$$('#modeGroup .seg-btn').forEach(function (x) {
|
||
x.classList.toggle('is-active', x.dataset.mode === S.mode);
|
||
});
|
||
$('#loopToggle').checked = false;
|
||
applyTuningUi();
|
||
// 关键:用 1Hz 下发,与真实手机 GPS 给 App 的回调频率一致。
|
||
// 发太快(5~10Hz)时每帧位移只有 0.14~0.28 米,运动类 App 会当成 GPS 抖动丢掉,
|
||
// 结果就是"地图上点在动,但公里数一直是 0"。1Hz 下步行每帧位移 1.4 米,
|
||
// 落在 App 里程过滤器的正常范围内。
|
||
req('config.update', {
|
||
config: {
|
||
send_hz: 1,
|
||
drift_enabled: true, drift_m: S.driftM,
|
||
speed_jitter_enabled: true, speed_jitter: S.jitterPercent / 100
|
||
}
|
||
}, 8000).then(function (res) {
|
||
if (res && res.config) applyConfig(res.config);
|
||
var label = name === 'campus' ? '校园跑预设' : '长跑预设';
|
||
toast(label + '已启用:' + MODE_CN[S.mode] + ',下发 1Hz,漂移 ' + S.driftM +
|
||
' 米,速度浮动 ±' + S.jitterPercent + '%', 'ok', 4400);
|
||
pushLog('info', '已应用' + label + '(' + MODE_CN[S.mode] + ',1Hz,漂移 ' +
|
||
S.driftM + 'm,浮动 ±' + S.jitterPercent + '%)');
|
||
}).catch(function (e) {
|
||
pushLog('error', '预设设置失败:' + e.message);
|
||
});
|
||
// 正在播放时让"真实感"参数立即生效
|
||
if (S.playing || S.paused) pushTuning(false);
|
||
savePrefs();
|
||
updateStats();
|
||
}
|
||
|
||
$('#btnCampusPreset').addEventListener('click', function () { applyPreset('campus'); });
|
||
$('#btnMarathonPreset').addEventListener('click', function () { applyPreset('run'); });
|
||
|
||
// ---- 快速定位到坐标(校园跑:先把地图移到学校范围)----
|
||
function parseCoord(text) {
|
||
if (!text) return null;
|
||
var parts = String(text).replace(/[,、]/g, ',').split(/[\s,]+/).filter(function (x) { return x !== ''; });
|
||
if (parts.length < 2) return null;
|
||
var lat = parseFloat(parts[0]), lng = parseFloat(parts[1]);
|
||
if (!isFinite(lat) || !isFinite(lng)) return null;
|
||
if (lat < -85 || lat > 85 || lng < -180 || lng > 180) return null;
|
||
return { lat: lat, lng: lng };
|
||
}
|
||
|
||
var PLACES_KEY = 'iosloc.places.v1';
|
||
|
||
function loadPlaces() {
|
||
try { return JSON.parse(localStorage.getItem(PLACES_KEY) || '[]') || []; } catch (e) { return []; }
|
||
}
|
||
|
||
function renderPlaces() {
|
||
var sel = $('#placeSelect');
|
||
if (!sel) return;
|
||
var list = loadPlaces();
|
||
sel.innerHTML = '';
|
||
if (!list.length) {
|
||
var opt = document.createElement('option');
|
||
opt.value = ''; opt.textContent = '(暂无记住的地点)';
|
||
sel.appendChild(opt);
|
||
return;
|
||
}
|
||
list.forEach(function (p, i) {
|
||
var item = document.createElement('option');
|
||
item.value = String(i);
|
||
item.textContent = p.name + '(' + p.lat.toFixed(5) + ', ' + p.lng.toFixed(5) + ')';
|
||
sel.appendChild(item);
|
||
});
|
||
}
|
||
|
||
$('#btnJumpCoord').addEventListener('click', function () {
|
||
var c = parseCoord($('#jumpCoord').value);
|
||
if (!c) { toast('坐标格式不对,请填「纬度, 经度」,例如 30.2741, 120.1551', 'err', 4200); return; }
|
||
S.map.setView(c.lng, c.lat, Math.max(S.map.getZoom(), 16), true);
|
||
toast('已跳转到 ' + c.lat.toFixed(5) + ', ' + c.lng.toFixed(5), 'ok', 2600);
|
||
pushLog('info', '地图已跳转到 ' + c.lat.toFixed(6) + ', ' + c.lng.toFixed(6));
|
||
});
|
||
|
||
$('#btnSavePlace').addEventListener('click', function () {
|
||
var c = parseCoord($('#jumpCoord').value);
|
||
if (!c) { toast('请先填一个有效坐标', 'err', 3200); return; }
|
||
var name = prompt('给这个地点起个名字', '学校');
|
||
if (name === null) return;
|
||
var list = loadPlaces().filter(function (p) { return p.name !== name; });
|
||
list.push({ name: name || '未命名', lat: c.lat, lng: c.lng });
|
||
try { localStorage.setItem(PLACES_KEY, JSON.stringify(list)); } catch (e) { /* 忽略 */ }
|
||
renderPlaces();
|
||
toast('已记住「' + (name || '未命名') + '」', 'ok', 2400);
|
||
});
|
||
|
||
$('#btnGoPlace').addEventListener('click', function () {
|
||
var idx = $('#placeSelect').value;
|
||
var list = loadPlaces();
|
||
if (idx === '' || !list[Number(idx)]) { toast('还没有记住的地点', 'warn', 3000); return; }
|
||
var p = list[Number(idx)];
|
||
S.map.setView(p.lng, p.lat, Math.max(S.map.getZoom(), 16), true);
|
||
toast('已跳转到「' + p.name + '」', 'ok', 2600);
|
||
});
|
||
|
||
renderPlaces();
|
||
|
||
// 轨迹名称
|
||
$('#routeName').addEventListener('input', function () {
|
||
S.routeName = $('#routeName').value;
|
||
$('#routeMeta').textContent = S.routeName || '未命名轨迹';
|
||
savePrefs();
|
||
});
|
||
|
||
// 路线按钮
|
||
$('#btnBuildRoute').addEventListener('click', buildRoute);
|
||
$('#btnClear').addEventListener('click', function () {
|
||
confirmModal('清空轨迹', '将清空途经点、路线、绘制内容与轨迹点,确定继续?', function () {
|
||
pushUndo();
|
||
S.waypoints = [];
|
||
S.route = null;
|
||
S.drawnPath = [];
|
||
S.polyline = [];
|
||
S.drawing = false;
|
||
S.selectedWp = -1;
|
||
S.map.clearTrack();
|
||
S.map.setCurrent(null);
|
||
syncMapLayers();
|
||
renderWaypoints();
|
||
updateStats();
|
||
setDrawMode('select');
|
||
pushLog('warn', '已清空当前轨迹');
|
||
}, '清空');
|
||
});
|
||
$('#btnExportGpx').addEventListener('click', exportGpx);
|
||
$('#btnImportGpx').addEventListener('click', importGpx);
|
||
$('#gpxFile').addEventListener('change', onGpxFile);
|
||
$('#btnClearLocation').addEventListener('click', clearLocation);
|
||
|
||
// 本地路线
|
||
$('#btnSaveRoute').addEventListener('click', saveRouteLocal);
|
||
$('#btnLoadRoute').addEventListener('click', loadRouteLocal);
|
||
$('#btnDeleteSaved').addEventListener('click', deleteSavedRoute);
|
||
$('#savedRoutes').addEventListener('dblclick', loadRouteLocal);
|
||
|
||
// 底图
|
||
$('#tileSourceSelect').addEventListener('change', function () {
|
||
applyTileSource($('#tileSourceSelect').value, false);
|
||
});
|
||
|
||
// 绘制工具条
|
||
$$('#drawToolbar .tool-btn[data-tool]').forEach(function (b) {
|
||
b.addEventListener('click', function () {
|
||
setDrawMode(b.dataset.tool);
|
||
if (b.dataset.tool === 'polyline') startPolyline();
|
||
});
|
||
});
|
||
$('#toolUndo').addEventListener('click', undo);
|
||
$('#toolDelete').addEventListener('click', function () {
|
||
if (S.selectedWp < 0) { toast('请先在地图或列表中选择一个途经点', 'warn'); return; }
|
||
removeWaypoint(S.selectedWp);
|
||
});
|
||
$('#toolFit').addEventListener('click', function () {
|
||
var pts = currentPath();
|
||
if (pts && pts.length >= 2) S.map.fitBounds(pts, { padding: 60 });
|
||
else toast('当前没有可全览的轨迹', 'warn');
|
||
});
|
||
|
||
// 播放条
|
||
$('#btnPlay').addEventListener('click', startPlay);
|
||
$('#btnPause').addEventListener('click', pausePlay);
|
||
$('#btnResume').addEventListener('click', resumePlay);
|
||
$('#btnStop').addEventListener('click', function () { stopPlay(false); });
|
||
|
||
var pr = $('#progressRange');
|
||
pr.addEventListener('input', function () {
|
||
S.scrubbing = true;
|
||
var p = pr.value / 1000;
|
||
$('#liveProgress').textContent = (p * 100).toFixed(1) + '%';
|
||
if (S.status && isFinite(S.status.elapsed) && isFinite(S.status.remaining)) {
|
||
var total = S.status.elapsed + S.status.remaining;
|
||
$('#timeElapsed').textContent = Geo.fmtDuration(total * p);
|
||
$('#timeRemaining').textContent = Geo.fmtDuration(total * (1 - p));
|
||
}
|
||
});
|
||
var commitSeek = function () {
|
||
if (!S.scrubbing) return;
|
||
S.scrubbing = false;
|
||
seek(pr.value / 1000);
|
||
};
|
||
pr.addEventListener('change', commitSeek);
|
||
pr.addEventListener('pointerup', commitSeek);
|
||
|
||
// 设备与缓存(契约外的补充命令)
|
||
$('#btnTunnelEnsure').addEventListener('click', tunnelEnsure);
|
||
$('#btnTunnelManual').addEventListener('click', tunnelManual);
|
||
// iOS 17 以下:挂载开发者镜像(后台执行,过程日志实时推送)
|
||
$('#btnMountImage').addEventListener('click', function () {
|
||
var btn = this;
|
||
var dev = S.device;
|
||
if (!dev) { toast('请先连接设备', 'err', 3000); return; }
|
||
if ((dev.ios_major || 0) < 17) {
|
||
toast('本工具只支持 iOS 17 及以上;你的设备是 iOS ' + dev.ios_version +
|
||
',其镜像需由 Apple 按构建号签发,请用 Xcode 连接一次自动准备', 'err', 6000);
|
||
return;
|
||
}
|
||
btn.disabled = true;
|
||
pushLog('info', '开始挂载开发者镜像:' + (dev.name || '') + '(iOS ' + (dev.ios_version || '?') + ')');
|
||
toast('正在挂载开发者镜像,过程见右侧日志…', 'info', 3200);
|
||
req('device.mount_image', {}, 20000).then(function () {
|
||
// 结果通过 mounted 事件 / 日志推送,这里只做超时兜底恢复
|
||
setTimeout(function () { btn.disabled = false; }, 3000);
|
||
}).catch(function (e) {
|
||
btn.disabled = false;
|
||
pushLog('error', '挂载请求失败:' + e.message);
|
||
toast(e.message, 'err', 4200);
|
||
});
|
||
});
|
||
|
||
$('#btnTilesClear').addEventListener('click', tilesClear);
|
||
|
||
// 日志面板
|
||
$('#btnClearLog').addEventListener('click', function () {
|
||
S.logs = [];
|
||
lastLog = null;
|
||
$('#logBody').innerHTML = '';
|
||
$('#logCount').textContent = '0 / ' + LOG_MAX;
|
||
});
|
||
$('#btnFetchLogs').addEventListener('click', function () {
|
||
req('log.list', { limit: 200 }, 8000).then(function (msg) {
|
||
if (msg.lines) setLogs(msg.lines);
|
||
}).catch(function (e) {
|
||
toast('拉取日志失败:' + e.message, 'err');
|
||
});
|
||
});
|
||
$('#btnToggleLog').addEventListener('click', toggleLogPanel);
|
||
$('#logPanel').addEventListener('click', function (ev) {
|
||
if (S.logCollapsed && !ev.target.closest('button')) toggleLogPanel();
|
||
});
|
||
|
||
// 键盘
|
||
document.addEventListener('keydown', onKeyDown);
|
||
}
|
||
|
||
function tunnelEnsure() {
|
||
var btn = $('#btnTunnelEnsure');
|
||
setBusy(btn, true, '建立中…');
|
||
pushLog('info', '正在请求建立隧道(iOS 17+ 需要管理员权限)…');
|
||
req('tunnel.ensure', {}, 120000).then(function (msg) {
|
||
if (msg.device) { S.device = msg.device; }
|
||
if (msg.tunnel) {
|
||
S.tunnel = msg.tunnel;
|
||
if (S.device) S.device.tunnel = msg.tunnel.state || S.device.tunnel;
|
||
}
|
||
// need_tunnel === false:mock 模式或 iOS<17 经典通道,无需等待隧道
|
||
if (msg.need_tunnel === false) {
|
||
if (tunnelWatch) { clearInterval(tunnelWatch); tunnelWatch = null; }
|
||
S.tunnelStuck = false;
|
||
pushLog('info', '服务端无需建立 RSD 隧道:' + ((msg.tunnel && msg.tunnel.message) || '当前不需要隧道'));
|
||
}
|
||
renderDevice();
|
||
updateBanner();
|
||
syncDeviceFromServer(true);
|
||
pushLog('success', '隧道指令已完成:' + ((msg.tunnel && (msg.tunnel.message || msg.tunnel.state)) || '已连接'));
|
||
toast((msg.tunnel && msg.tunnel.message) || '隧道已建立', 'ok', 4200);
|
||
}).catch(function (e) {
|
||
pushLog('error', '建立隧道失败:' + e.message);
|
||
toast(e.message, 'err', 5200);
|
||
}).then(function () { setBusy(btn, false); });
|
||
}
|
||
|
||
function tunnelManual() {
|
||
openModal({
|
||
title: '手动填写 RSD 地址',
|
||
okText: '连接',
|
||
html:
|
||
'<div class="field"><label>主机</label><input class="input" id="m_rsd_host" placeholder="例如 fd00::1 或 127.0.0.1"></div>' +
|
||
'<div class="field"><label>端口</label><input class="input" id="m_rsd_port" placeholder="例如 51234"></div>' +
|
||
'<div class="hint">iOS 17 及以上需要 RSD 隧道。可先用管理员权限执行 ' +
|
||
'<code>pymobiledevice3 lockdown start-tunnel</code>,把输出的地址与端口填到这里。</div>',
|
||
onOk: function () {
|
||
var host = ($('#m_rsd_host').value || '').trim();
|
||
var port = parseInt($('#m_rsd_port').value, 10);
|
||
if (!host || !isFinite(port) || port <= 0 || port > 65535) {
|
||
toast('请填写正确的主机与端口', 'err');
|
||
return false;
|
||
}
|
||
var btn = $('#btnTunnelManual');
|
||
setBusy(btn, true, '连接中…');
|
||
req('tunnel.manual', { host: host, port: port }, 60000).then(function (msg) {
|
||
if (msg.device) S.device = msg.device;
|
||
if (msg.tunnel) {
|
||
S.tunnel = msg.tunnel;
|
||
if (S.device) S.device.tunnel = msg.tunnel.state || S.device.tunnel;
|
||
}
|
||
renderDevice();
|
||
updateBanner();
|
||
pushLog('success', '已按手动 RSD 地址连接:' + host + ':' + port);
|
||
toast('已连接手动 RSD 地址', 'ok');
|
||
}).catch(function (e) {
|
||
pushLog('error', '手动 RSD 连接失败:' + e.message);
|
||
toast(e.message, 'err', 5200);
|
||
}).then(function () { setBusy(btn, false); });
|
||
return true;
|
||
}
|
||
});
|
||
}
|
||
|
||
function tilesClear() {
|
||
var btn = $('#btnTilesClear');
|
||
setBusy(btn, true, '清理中…');
|
||
req('tiles.clear', {}, 30000).then(function () {
|
||
S.map.retryFailedTiles();
|
||
pushLog('success', '服务端瓦片缓存已清理,正在重新拉取底图');
|
||
toast('瓦片缓存已清理', 'ok');
|
||
}).catch(function (e) {
|
||
pushLog('warn', '清理瓦片缓存失败:' + e.message);
|
||
toast(e.message, 'err', 4200);
|
||
}).then(function () { setBusy(btn, false); });
|
||
}
|
||
|
||
function toggleLogPanel() {
|
||
setLogCollapsed(!S.logCollapsed);
|
||
}
|
||
|
||
function setLogCollapsed(collapsed, silent) {
|
||
S.logCollapsed = !!collapsed;
|
||
$('#workspace').classList.toggle('log-collapsed', S.logCollapsed);
|
||
var btn = $('#btnToggleLog');
|
||
btn.textContent = S.logCollapsed ? '‹' : '折叠';
|
||
btn.title = S.logCollapsed ? '展开日志面板' : '折叠日志面板';
|
||
if (!S.logCollapsed) setTimeout(function () { $('#logBody').scrollTop = $('#logBody').scrollHeight; }, 30);
|
||
if (S.map) setTimeout(function () { S.map.resize(); }, 60);
|
||
if (!silent) savePrefs();
|
||
}
|
||
|
||
function onKeyDown(e) {
|
||
var tag = (e.target && e.target.tagName) || '';
|
||
var typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
||
if (e.key === 'Escape') {
|
||
if (!$('#modalMask').classList.contains('hidden')) { closeModal(); return; }
|
||
if (S.drawing) { finishPolyline(); return; }
|
||
if (typing) { e.target.blur(); return; }
|
||
setDrawMode('select');
|
||
return;
|
||
}
|
||
if (typing) return;
|
||
if (e.key === ' ' || e.code === 'Space') {
|
||
e.preventDefault();
|
||
if (S.playing && !S.paused) pausePlay();
|
||
else if (S.playing && S.paused) resumePlay();
|
||
else startPlay();
|
||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||
if (S.selectedWp >= 0) { e.preventDefault(); removeWaypoint(S.selectedWp); }
|
||
} else if (e.key === '+' || e.key === '=') {
|
||
S.map.setZoom(S.map.getZoom() + 1);
|
||
} else if (e.key === '-' || e.key === '_') {
|
||
S.map.setZoom(S.map.getZoom() - 1);
|
||
}
|
||
}
|
||
|
||
/* ================================================================== */
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
|
||
// 便于在控制台排查
|
||
window.__IOSLOC = { S: S, toast: toast, pushLog: pushLog };
|
||
})();
|