Files
cmr-mini/tools/mock-gps-sim/public/simulator.js

1341 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
const DEFAULT_CENTER = [31.2304, 121.4737]
const DEFAULT_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
const DEFAULT_TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
const PROXY_BASE_URL = `${location.origin}/proxy?url=`
const WS_URL = `ws://${location.hostname}:17865/mock-gps`
const map = L.map('map').setView(DEFAULT_CENTER, 16)
let tileLayer = createTileLayer(DEFAULT_TILE_URL, {
maxZoom: 20,
attribution: '© OpenStreetMap',
}).addTo(map)
const liveMarker = L.circleMarker(DEFAULT_CENTER, {
radius: 11,
color: '#ffffff',
weight: 3,
fillColor: '#ff2f92',
fillOpacity: 0.94,
}).addTo(map)
const pathLine = L.polyline([], {
color: '#0ea5a4',
weight: 4,
opacity: 0.9,
}).addTo(map)
const courseLayer = L.layerGroup().addTo(map)
const pathMarkers = []
const pathPoints = []
const state = {
socket: null,
connected: false,
socketConnecting: false,
streaming: false,
heartRateStreaming: false,
heartRateSampleMode: false,
pathEditMode: false,
playbackRunning: false,
playbackTimer: 0,
streamTimer: 0,
heartRateStreamTimer: 0,
lastSentText: '--',
lastHeartRateSentText: '--',
lastResourceDetailText: '尚未载入资源',
lastTrackSourceText: '路径待命',
currentLatLng: L.latLng(DEFAULT_CENTER[0], DEFAULT_CENTER[1]),
headingDeg: 0,
currentSegmentIndex: 0,
currentSegmentProgress: 0,
lastPlaybackAt: 0,
heartRateSampleStartedAt: 0,
loadedCourse: null,
resourceLoading: false,
}
const elements = {
socketStatus: document.getElementById('socketStatus'),
configUrlInput: document.getElementById('configUrlInput'),
loadConfigBtn: document.getElementById('loadConfigBtn'),
fitCourseBtn: document.getElementById('fitCourseBtn'),
tileUrlInput: document.getElementById('tileUrlInput'),
applyTilesBtn: document.getElementById('applyTilesBtn'),
resetTilesBtn: document.getElementById('resetTilesBtn'),
courseUrlInput: document.getElementById('courseUrlInput'),
loadCourseBtn: document.getElementById('loadCourseBtn'),
clearCourseBtn: document.getElementById('clearCourseBtn'),
resourceStatus: document.getElementById('resourceStatus'),
resourceDetail: document.getElementById('resourceDetail'),
courseJumpList: document.getElementById('courseJumpList'),
realtimeStatus: document.getElementById('realtimeStatus'),
lastSendStatus: document.getElementById('lastSendStatus'),
playbackStatus: document.getElementById('playbackStatus'),
heartRateStatus: document.getElementById('heartRateStatus'),
lastHeartRateStatus: document.getElementById('lastHeartRateStatus'),
sendHeartRateOnceBtn: document.getElementById('sendHeartRateOnceBtn'),
startHeartRateStreamBtn: document.getElementById('startHeartRateStreamBtn'),
stopHeartRateStreamBtn: document.getElementById('stopHeartRateStreamBtn'),
applyHeartRatePresetBtn: document.getElementById('applyHeartRatePresetBtn'),
toggleHeartRateSampleBtn: document.getElementById('toggleHeartRateSampleBtn'),
heartRateInput: document.getElementById('heartRateInput'),
heartRateHzSelect: document.getElementById('heartRateHzSelect'),
heartRateSampleTemplateSelect: document.getElementById('heartRateSampleTemplateSelect'),
trackFileInput: document.getElementById('trackFileInput'),
importTrackBtn: document.getElementById('importTrackBtn'),
connectBtn: document.getElementById('connectBtn'),
sendOnceBtn: document.getElementById('sendOnceBtn'),
streamBtn: document.getElementById('streamBtn'),
stopStreamBtn: document.getElementById('stopStreamBtn'),
togglePathModeBtn: document.getElementById('togglePathModeBtn'),
clearPathBtn: document.getElementById('clearPathBtn'),
fitPathBtn: document.getElementById('fitPathBtn'),
playPathBtn: document.getElementById('playPathBtn'),
pausePathBtn: document.getElementById('pausePathBtn'),
hzSelect: document.getElementById('hzSelect'),
accuracyInput: document.getElementById('accuracyInput'),
speedInput: document.getElementById('speedInput'),
loopPathInput: document.getElementById('loopPathInput'),
pathHint: document.getElementById('pathHint'),
latText: document.getElementById('latText'),
lonText: document.getElementById('lonText'),
headingText: document.getElementById('headingText'),
pathCountText: document.getElementById('pathCountText'),
log: document.getElementById('log'),
}
elements.configUrlInput.value = DEFAULT_CONFIG_URL
function createTileLayer(urlTemplate, extraOptions) {
return L.tileLayer(urlTemplate, Object.assign({
maxZoom: 20,
attribution: 'Custom Map',
}, extraOptions || {}))
}
function log(message) {
const time = new Date().toLocaleTimeString()
elements.log.textContent = `[${time}] ${message}\n` + elements.log.textContent
}
function setResourceStatus(message, tone) {
elements.resourceStatus.textContent = message
elements.resourceStatus.className = 'hint'
if (tone === 'ok') {
elements.resourceStatus.classList.add('hint--ok')
} else if (tone === 'warn') {
elements.resourceStatus.classList.add('hint--warn')
}
}
function updateReadout() {
elements.latText.textContent = state.currentLatLng.lat.toFixed(6)
elements.lonText.textContent = state.currentLatLng.lng.toFixed(6)
elements.headingText.textContent = `${Math.round(state.headingDeg)}°`
elements.pathCountText.textContent = String(pathPoints.length)
liveMarker.setLatLng(state.currentLatLng)
}
function setSocketBadge(connected) {
elements.socketStatus.textContent = connected ? '已连接' : '未连接'
elements.socketStatus.className = connected ? 'badge badge--ok' : 'badge badge--muted'
}
function formatClockTime(timestamp) {
if (!timestamp) {
return '--'
}
return new Date(timestamp).toLocaleTimeString()
}
function updateUiState() {
elements.connectBtn.textContent = state.connected ? '桥接已连接' : state.socketConnecting ? '连接中...' : '连接桥接'
elements.connectBtn.classList.toggle('is-active', state.connected)
elements.connectBtn.disabled = state.connected || state.socketConnecting
elements.sendOnceBtn.disabled = !state.connected
elements.streamBtn.textContent = state.streaming ? '发送中' : '开始连续发送'
elements.streamBtn.classList.toggle('is-active', state.streaming)
elements.streamBtn.disabled = !state.connected || state.streaming
elements.stopStreamBtn.disabled = !state.streaming
elements.sendHeartRateOnceBtn.disabled = !state.connected
elements.startHeartRateStreamBtn.textContent = state.heartRateStreaming ? '发送中' : '开始连续发送'
elements.startHeartRateStreamBtn.classList.toggle('is-active', state.heartRateStreaming)
elements.startHeartRateStreamBtn.disabled = !state.connected || state.heartRateStreaming
elements.stopHeartRateStreamBtn.disabled = !state.heartRateStreaming
elements.toggleHeartRateSampleBtn.textContent = state.heartRateSampleMode ? '关闭真实样本' : '模拟真实样本'
elements.toggleHeartRateSampleBtn.classList.toggle('is-active', state.heartRateSampleMode)
elements.togglePathModeBtn.textContent = state.pathEditMode ? '关闭路径编辑' : '开启路径编辑'
elements.togglePathModeBtn.classList.toggle('is-active', state.pathEditMode)
elements.importTrackBtn.disabled = state.resourceLoading
elements.clearPathBtn.textContent = pathPoints.length ? `清空路径 (${pathPoints.length})` : '清空路径'
elements.clearPathBtn.disabled = pathPoints.length === 0
elements.fitPathBtn.disabled = pathPoints.length < 2
elements.playPathBtn.textContent = state.playbackRunning ? '回放中' : '开始回放'
elements.playPathBtn.classList.toggle('is-active', state.playbackRunning)
elements.playPathBtn.disabled = pathPoints.length < 2 || state.playbackRunning
elements.pausePathBtn.disabled = !state.playbackRunning
elements.fitCourseBtn.disabled = !state.loadedCourse
elements.clearCourseBtn.disabled = !state.loadedCourse
elements.loadConfigBtn.textContent = state.resourceLoading ? '载入中...' : '载入配置'
elements.loadConfigBtn.disabled = state.resourceLoading
elements.loadCourseBtn.textContent = state.resourceLoading ? '载入中...' : '载入控制点'
elements.loadCourseBtn.disabled = state.resourceLoading
elements.applyTilesBtn.disabled = state.resourceLoading
elements.resetTilesBtn.disabled = state.resourceLoading
elements.lastSendStatus.textContent = `最近发送: ${state.lastSentText}`
elements.lastHeartRateStatus.textContent = `最近发送: ${state.lastHeartRateSentText}`
elements.resourceDetail.textContent = state.lastResourceDetailText
if (state.connected && state.streaming) {
elements.realtimeStatus.textContent = `桥接已连接,正在以 ${elements.hzSelect.value} Hz 连续发送`
} else if (state.connected) {
elements.realtimeStatus.textContent = '桥接已连接,待命中'
} else if (state.socketConnecting) {
elements.realtimeStatus.textContent = '桥接连接中'
} else {
elements.realtimeStatus.textContent = '桥接未连接'
}
if (state.connected && state.heartRateStreaming) {
elements.heartRateStatus.textContent = state.heartRateSampleMode
? `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 发送真实心率样本`
: `桥接已连接,正在以 ${elements.heartRateHzSelect.value} Hz 连续发送心率`
} else if (state.connected) {
elements.heartRateStatus.textContent = state.heartRateSampleMode ? '真实心率样本待命' : '心率模拟待命'
} else if (state.socketConnecting) {
elements.heartRateStatus.textContent = '桥接连接中'
} else {
elements.heartRateStatus.textContent = '桥接未连接'
}
if (state.playbackRunning) {
elements.playbackStatus.textContent = `路径回放中,速度 ${elements.speedInput.value} km/h`
} else if (state.pathEditMode) {
elements.playbackStatus.textContent = '路径编辑中,点击地图追加路径点'
} else if (pathPoints.length >= 2) {
elements.playbackStatus.textContent = `${state.lastTrackSourceText},共 ${pathPoints.length} 个路径点`
} else {
elements.playbackStatus.textContent = '路径待命'
}
}
function connectSocket() {
if (state.socket && (state.socket.readyState === WebSocket.OPEN || state.socket.readyState === WebSocket.CONNECTING)) {
return
}
const socket = new WebSocket(WS_URL)
state.socket = socket
state.socketConnecting = true
setSocketBadge(false)
updateUiState()
log(`连接 ${WS_URL}`)
socket.addEventListener('open', () => {
state.connected = true
state.socketConnecting = false
setSocketBadge(true)
updateUiState()
log('桥接已连接')
})
socket.addEventListener('close', () => {
state.connected = false
state.socketConnecting = false
stopStream()
stopHeartRateStream()
setSocketBadge(false)
updateUiState()
log('桥接已断开')
})
socket.addEventListener('error', () => {
state.connected = false
state.socketConnecting = false
stopStream()
stopHeartRateStream()
setSocketBadge(false)
updateUiState()
log('桥接连接失败')
})
}
function proxyUrl(targetUrl) {
return `${PROXY_BASE_URL}${encodeURIComponent(targetUrl)}`
}
async function fetchJson(targetUrl) {
const response = await fetch(proxyUrl(targetUrl), { cache: 'no-store' })
if (!response.ok) {
throw new Error(`载入失败: ${response.status} ${targetUrl}`)
}
const text = await response.text()
return parseJsonWithFallback(text)
}
async function fetchText(targetUrl) {
const response = await fetch(proxyUrl(targetUrl), { cache: 'no-store' })
if (!response.ok) {
throw new Error(`载入失败: ${response.status} ${targetUrl}`)
}
return response.text()
}
function parseJsonWithFallback(text) {
try {
return JSON.parse(text)
} catch (_error) {
const sanitized = text
.replace(/,\s*"center"\s*:\s*\[[^\]]*\]\s*(?=[}\r\n])/g, '')
.replace(/"center"\s*:\s*\[[^\]]*\]\s*,/g, '')
.replace(/,\s*([}\]])/g, '$1')
return JSON.parse(sanitized)
}
}
function resolveUrl(baseUrl, relativePath) {
const trimmed = String(relativePath || '').trim()
if (!trimmed) {
return ''
}
if (/^https?:\/\//i.test(trimmed)) {
return trimmed
}
const url = new URL(baseUrl)
if (trimmed.startsWith('/')) {
return `${url.origin}${trimmed}`
}
const baseDir = baseUrl.slice(0, baseUrl.lastIndexOf('/') + 1)
return `${baseDir}${trimmed.replace(/^\.\//, '')}`
}
function joinUrl(rootUrl, relativePath) {
const normalizedRoot = String(rootUrl || '').replace(/\/+$/, '')
const normalizedPath = String(relativePath || '').replace(/^\/+/, '')
return `${normalizedRoot}/${normalizedPath}`
}
function webMercatorToLatLng(x, y) {
const lon = x / 20037508.34 * 180
let lat = y / 20037508.34 * 180
lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180)) - Math.PI / 2)
return L.latLng(lat, lon)
}
function applyTileTemplate(tileUrl, options) {
const trimmed = String(tileUrl || '').trim()
if (!trimmed) {
throw new Error('瓦片模板不能为空')
}
if (tileLayer) {
map.removeLayer(tileLayer)
}
tileLayer = createTileLayer(trimmed, options || {}).addTo(map)
elements.tileUrlInput.value = trimmed
}
function fitBoundsFromMercator(bounds) {
if (!Array.isArray(bounds) || bounds.length !== 4) {
return
}
const southWest = webMercatorToLatLng(Number(bounds[0]), Number(bounds[1]))
const northEast = webMercatorToLatLng(Number(bounds[2]), Number(bounds[3]))
map.fitBounds(L.latLngBounds(southWest, northEast), { padding: [24, 24] })
}
function parseCoordinateTuple(rawValue) {
const parts = rawValue.trim().split(',')
if (parts.length < 2) {
return null
}
const lon = Number(parts[0])
const lat = Number(parts[1])
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
return null
}
return { lat, lon }
}
function extractPointCoordinates(block) {
const pointMatch = block.match(/<Point\b[\s\S]*?<coordinates>([\s\S]*?)<\/coordinates>[\s\S]*?<\/Point>/i)
if (!pointMatch) {
return null
}
const coordinateMatch = pointMatch[1].trim().match(/-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?)?/)
return coordinateMatch ? parseCoordinateTuple(coordinateMatch[0]) : null
}
function decodeXmlEntities(text) {
return text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&amp;/g, '&')
}
function stripXml(text) {
return decodeXmlEntities(String(text || '').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim()
}
function extractTagText(block, tagName) {
const match = block.match(new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'))
return match ? stripXml(match[1]) : ''
}
function normalizeCourseLabel(label) {
return String(label || '').trim().replace(/\s+/g, ' ')
}
function inferExplicitKind(label, placemarkBlock) {
const normalized = normalizeCourseLabel(label).toUpperCase().replace(/[^A-Z0-9]/g, '')
const styleHint = String(placemarkBlock || '').toUpperCase()
if (
normalized === 'S'
|| normalized.startsWith('START')
|| /^S\d+$/.test(normalized)
|| styleHint.includes('START')
|| styleHint.includes('TRIANGLE')
) {
return 'start'
}
if (
normalized === 'F'
|| normalized === 'M'
|| normalized.startsWith('FINISH')
|| normalized.startsWith('GOAL')
|| /^F\d+$/.test(normalized)
|| styleHint.includes('FINISH')
|| styleHint.includes('GOAL')
) {
return 'finish'
}
return null
}
function extractPlacemarkPoints(kmlText) {
const placemarkBlocks = kmlText.match(/<Placemark\b[\s\S]*?<\/Placemark>/gi) || []
const points = []
placemarkBlocks.forEach((placemarkBlock) => {
const point = extractPointCoordinates(placemarkBlock)
if (!point) {
return
}
const label = normalizeCourseLabel(extractTagText(placemarkBlock, 'name'))
points.push({
label,
point,
explicitKind: inferExplicitKind(label, placemarkBlock),
})
})
return points
}
function classifyOrderedNodes(points) {
if (!points.length) {
return []
}
const startIndex = points.findIndex((point) => point.explicitKind === 'start')
let finishIndex = -1
for (let index = points.length - 1; index >= 0; index -= 1) {
if (points[index].explicitKind === 'finish') {
finishIndex = index
break
}
}
return points.map((point, index) => {
let kind = point.explicitKind
if (!kind) {
if (startIndex === -1 && index === 0) {
kind = 'start'
} else if (finishIndex === -1 && points.length > 1 && index === points.length - 1) {
kind = 'finish'
} else {
kind = 'control'
}
}
return {
label: point.label,
point: point.point,
kind,
}
})
}
function parseCourseKml(kmlText) {
const points = extractPlacemarkPoints(kmlText)
if (!points.length) {
throw new Error('KML 中没有可用的 Point 控制点')
}
const nodes = classifyOrderedNodes(points)
const starts = []
const controls = []
const finishes = []
let controlSequence = 1
nodes.forEach((node) => {
if (node.kind === 'start') {
starts.push({
label: node.label || 'Start',
point: node.point,
})
return
}
if (node.kind === 'finish') {
finishes.push({
label: node.label || 'Finish',
point: node.point,
})
return
}
controls.push({
label: node.label || String(controlSequence),
sequence: controlSequence,
point: node.point,
})
controlSequence += 1
})
return {
title: extractTagText(kmlText, 'name') || 'Orienteering Course',
starts,
controls,
finishes,
}
}
function buildDivIcon(className, html, size) {
return L.divIcon({
className,
html,
iconSize: size,
iconAnchor: [size[0] / 2, size[1] / 2],
})
}
function setCurrentPosition(lat, lon) {
state.currentLatLng = L.latLng(lat, lon)
updateReadout()
}
function jumpToPoint(lat, lon, zoom) {
setCurrentPosition(lat, lon)
map.flyTo([lat, lon], zoom || Math.max(map.getZoom(), 18), {
duration: 0.6,
})
}
function buildJumpChip(label, point, className) {
const button = document.createElement('button')
button.type = 'button'
button.className = `jump-chip ${className || ''}`.trim()
button.textContent = label
button.addEventListener('click', () => {
jumpToPoint(point.lat, point.lon, 19)
log(`跳转到 ${label}`)
})
return button
}
function refreshCourseJumpList(course) {
elements.courseJumpList.innerHTML = ''
if (!course) {
return
}
course.starts.forEach((item) => {
elements.courseJumpList.appendChild(buildJumpChip('开始点', item.point, 'jump-chip--start'))
})
course.controls.forEach((item) => {
elements.courseJumpList.appendChild(buildJumpChip(String(item.sequence), item.point, ''))
})
course.finishes.forEach((item) => {
elements.courseJumpList.appendChild(buildJumpChip('结束点', item.point, 'jump-chip--finish'))
})
}
function renderCourse(course) {
courseLayer.clearLayers()
state.loadedCourse = course
refreshCourseJumpList(course)
course.starts.forEach((item) => {
const marker = L.marker([item.point.lat, item.point.lon], {
icon: buildDivIcon('course-marker', '<div class="course-marker__start"></div>', [36, 36]),
})
marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
marker.addTo(courseLayer)
})
course.controls.forEach((item) => {
const marker = L.marker([item.point.lat, item.point.lon], {
icon: buildDivIcon(
'course-marker',
`<div class="course-marker__control">${item.sequence}</div>`,
[40, 40],
),
})
marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
marker.addTo(courseLayer)
})
course.finishes.forEach((item) => {
const marker = L.marker([item.point.lat, item.point.lon], {
icon: buildDivIcon('course-marker', '<div class="course-marker__finish"></div>', [40, 40]),
})
marker.on('click', () => jumpToPoint(item.point.lat, item.point.lon, 19))
marker.addTo(courseLayer)
})
fitCourseBounds()
updateUiState()
}
function clearCourse() {
state.loadedCourse = null
courseLayer.clearLayers()
refreshCourseJumpList(null)
setResourceStatus('已清空控制点', 'warn')
state.lastResourceDetailText = '已清空控制点'
updateUiState()
}
function fitCourseBounds() {
if (!state.loadedCourse) {
return
}
const latLngs = []
state.loadedCourse.starts.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
state.loadedCourse.controls.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
state.loadedCourse.finishes.forEach((item) => latLngs.push([item.point.lat, item.point.lon]))
if (!latLngs.length) {
return
}
map.fitBounds(L.latLngBounds(latLngs), { padding: [30, 30] })
}
async function loadCourseFromUrl(courseUrl, shouldFit) {
const trimmed = String(courseUrl || '').trim()
if (!trimmed) {
throw new Error('KML 地址不能为空')
}
const kmlText = await fetchText(trimmed)
const course = parseCourseKml(kmlText)
renderCourse(course)
elements.courseUrlInput.value = trimmed
if (shouldFit !== false) {
fitCourseBounds()
}
setResourceStatus(`已载入控制点: ${course.title}`, 'ok')
state.lastResourceDetailText = `最近资源: 控制点 ${course.title} (${formatClockTime(Date.now())})`
log(`已载入 KML: ${trimmed}`)
updateUiState()
}
async function loadConfigResources() {
const configUrl = String(elements.configUrlInput.value || '').trim()
if (!configUrl) {
setResourceStatus('请先填写 game.json 地址', 'warn')
return
}
state.resourceLoading = true
updateUiState()
setResourceStatus('正在载入配置...', null)
try {
const config = await fetchJson(configUrl)
let mapStatus = '未找到瓦片配置'
if (config.map && config.mapmeta) {
const mapRootUrl = resolveUrl(configUrl, config.map)
const mapMetaUrl = resolveUrl(configUrl, config.mapmeta)
const mapMeta = await fetchJson(mapMetaUrl)
const tilePathTemplate = mapMeta.tilePathTemplate || `{z}/{x}/{y}.${mapMeta.tileFormat || 'png'}`
const tileTemplateUrl = /^https?:\/\//i.test(tilePathTemplate)
? tilePathTemplate
: joinUrl(mapRootUrl, tilePathTemplate)
applyTileTemplate(tileTemplateUrl, {
minZoom: Number.isFinite(mapMeta.minZoom) ? mapMeta.minZoom : 16,
maxZoom: Number.isFinite(mapMeta.maxZoom) ? mapMeta.maxZoom : 20,
attribution: 'Custom Map',
})
mapStatus = '已载入瓦片'
if (Array.isArray(mapMeta.bounds) && mapMeta.bounds.length === 4) {
fitBoundsFromMercator(mapMeta.bounds)
}
}
let courseStatus = '未找到 KML 配置'
if (config.course) {
const courseUrl = resolveUrl(configUrl, config.course)
elements.courseUrlInput.value = courseUrl
await loadCourseFromUrl(courseUrl, false)
courseStatus = '已载入控制点'
}
setResourceStatus(`配置已载入: ${mapStatus} / ${courseStatus}`, 'ok')
state.lastResourceDetailText = `最近资源: 配置 ${formatClockTime(Date.now())}`
log(`已载入配置: ${configUrl}`)
} catch (error) {
const message = error && error.message ? error.message : '未知错误'
setResourceStatus(`配置载入失败: ${message}`, 'warn')
log(`配置载入失败: ${message}`)
} finally {
state.resourceLoading = false
updateUiState()
}
}
function getAccuracy() {
return Math.max(1, Number(elements.accuracyInput.value) || 6)
}
function getSpeedMps() {
return Math.max(0.2, (Number(elements.speedInput.value) || 6) / 3.6)
}
function getHeartRateBpm() {
return Math.max(40, Math.min(220, Math.round(Number(elements.heartRateInput.value) || 120)))
}
function getSampleHeartRateBpm() {
const now = Date.now()
if (!state.heartRateSampleStartedAt) {
state.heartRateSampleStartedAt = now
}
const elapsedSeconds = (now - state.heartRateSampleStartedAt) / 1000
const template = elements.heartRateSampleTemplateSelect.value || 'jog'
let cycleSeconds = 360
let bpm = 120
const jitter = Math.sin(elapsedSeconds * 1.7) * 1.8 + Math.sin(elapsedSeconds * 0.47) * 1.2
if (template === 'recovery') {
cycleSeconds = 300
const phase = elapsedSeconds % cycleSeconds
if (phase < 80) {
bpm = 82 + phase * 0.08
} else if (phase < 190) {
bpm = 89 + Math.sin((phase - 80) / 20) * 3
} else {
bpm = 90 - (phase - 190) * 0.06 + Math.sin((phase - 190) / 18) * 2
}
} else if (template === 'tempo') {
cycleSeconds = 320
const phase = elapsedSeconds % cycleSeconds
if (phase < 50) {
bpm = 102 + phase * 0.42
} else if (phase < 230) {
bpm = 124 + Math.sin((phase - 50) / 14) * 5 + Math.sin((phase - 50) / 36) * 3
} else {
bpm = 126 - (phase - 230) * 0.18 + Math.sin((phase - 230) / 12) * 3
}
} else if (template === 'interval') {
cycleSeconds = 260
const phase = elapsedSeconds % cycleSeconds
if (phase < 40) {
bpm = 100 + phase * 0.35
} else {
const wavePhase = phase - 40
const intervalCycle = wavePhase % 44
if (intervalCycle < 20) {
bpm = 140 + intervalCycle * 1.2
} else if (intervalCycle < 32) {
bpm = 164 - (intervalCycle - 20) * 0.45
} else {
bpm = 158 - (intervalCycle - 32) * 2.7
}
}
} else {
const phase = elapsedSeconds % cycleSeconds
if (phase < 60) {
bpm = 96 + phase * 0.35
} else if (phase < 150) {
bpm = 118 + Math.sin((phase - 60) / 18) * 6
} else if (phase < 240) {
bpm = 138 + Math.sin((phase - 150) / 10) * 9
} else if (phase < 300) {
bpm = 158 + Math.sin((phase - 240) / 7) * 8
} else {
bpm = 124 - (phase - 300) * 0.22 + Math.sin((phase - 300) / 15) * 4
}
}
const nextBpm = Math.max(72, Math.min(182, Math.round(bpm + jitter)))
elements.heartRateInput.value = String(nextBpm)
return nextBpm
}
function sendCurrentPoint() {
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
log('未连接桥接,无法发送')
return
}
const payload = {
type: 'mock_gps',
timestamp: Date.now(),
lat: Number(state.currentLatLng.lat.toFixed(6)),
lon: Number(state.currentLatLng.lng.toFixed(6)),
accuracyMeters: getAccuracy(),
speedMps: Number(getSpeedMps().toFixed(2)),
headingDeg: Number(state.headingDeg.toFixed(1)),
}
state.socket.send(JSON.stringify(payload))
state.lastSentText = `${formatClockTime(payload.timestamp)} @ ${payload.lat.toFixed(6)}, ${payload.lon.toFixed(6)}`
updateUiState()
}
function sendCurrentHeartRate() {
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
log('未连接桥接,无法发送心率')
return
}
const payload = {
type: 'mock_heart_rate',
timestamp: Date.now(),
bpm: state.heartRateSampleMode ? getSampleHeartRateBpm() : getHeartRateBpm(),
}
state.socket.send(JSON.stringify(payload))
state.lastHeartRateSentText = `${formatClockTime(payload.timestamp)} @ ${payload.bpm} bpm`
updateUiState()
}
function startStream() {
stopStream()
state.streaming = true
const intervalMs = Math.max(80, 1000 / (Number(elements.hzSelect.value) || 5))
sendCurrentPoint()
state.streamTimer = window.setInterval(sendCurrentPoint, intervalMs)
updateUiState()
log(`开始连续发送 (${Math.round(1000 / intervalMs)} Hz)`)
}
function stopStream() {
state.streaming = false
if (state.streamTimer) {
window.clearInterval(state.streamTimer)
state.streamTimer = 0
log('已停止连续发送')
}
updateUiState()
}
function startHeartRateStream() {
stopHeartRateStream()
state.heartRateStreaming = true
if (state.heartRateSampleMode && !state.heartRateSampleStartedAt) {
state.heartRateSampleStartedAt = Date.now()
}
const intervalMs = Math.max(150, 1000 / (Number(elements.heartRateHzSelect.value) || 1))
sendCurrentHeartRate()
state.heartRateStreamTimer = window.setInterval(sendCurrentHeartRate, intervalMs)
updateUiState()
log(`开始连续发送心率 (${Math.round(1000 / intervalMs)} Hz)`)
}
function stopHeartRateStream() {
state.heartRateStreaming = false
if (state.heartRateStreamTimer) {
window.clearInterval(state.heartRateStreamTimer)
state.heartRateStreamTimer = 0
log('已停止连续发送心率')
}
updateUiState()
}
function applyHeartRatePreset() {
const sampleBpm = [88, 102, 118, 136, 154, 170]
const current = getHeartRateBpm()
let nextIndex = sampleBpm.findIndex((value) => value > current)
if (nextIndex === -1) {
nextIndex = 0
}
elements.heartRateInput.value = String(sampleBpm[nextIndex])
log(`已应用心率分区样本: ${sampleBpm[nextIndex]} bpm`)
}
function toggleHeartRateSampleMode() {
state.heartRateSampleMode = !state.heartRateSampleMode
state.heartRateSampleStartedAt = state.heartRateSampleMode ? Date.now() : 0
if (state.heartRateSampleMode) {
const bpm = getSampleHeartRateBpm()
log(`已开启真实心率样本 (${elements.heartRateSampleTemplateSelect.value || 'jog'}): ${bpm} bpm`)
} else {
log('已关闭真实心率样本')
}
updateUiState()
}
function syncPathLine() {
pathLine.setLatLngs(pathPoints)
elements.pathCountText.textContent = String(pathPoints.length)
updateUiState()
}
function clearPathMarkers() {
while (pathMarkers.length) {
map.removeLayer(pathMarkers.pop())
}
}
function refreshPathMarkers() {
clearPathMarkers()
pathPoints.forEach((point, index) => {
const marker = L.circleMarker(point, {
radius: 5,
color: '#ffffff',
weight: 2,
fillColor: index === 0 ? '#0ea5a4' : '#0b625b',
fillOpacity: 0.95,
}).addTo(map)
pathMarkers.push(marker)
})
}
function addPathPoint(latlng) {
pathPoints.push(L.latLng(latlng.lat, latlng.lng))
state.lastTrackSourceText = '手工路径'
syncPathLine()
refreshPathMarkers()
}
function fitPathBounds() {
if (pathPoints.length < 2) {
return
}
map.fitBounds(L.latLngBounds(pathPoints), { padding: [30, 30] })
}
function replacePathPoints(nextPoints, sourceLabel) {
pathPoints.splice(0, pathPoints.length)
nextPoints.forEach((point) => {
pathPoints.push(L.latLng(point.lat, point.lng))
})
state.lastTrackSourceText = sourceLabel
stopPlayback()
syncPathLine()
refreshPathMarkers()
if (pathPoints.length) {
state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
updateReadout()
}
if (pathPoints.length >= 2) {
fitPathBounds()
}
}
function parseGeoJsonTrack(rawValue) {
const latLngs = []
function pushLngLat(coords) {
if (!Array.isArray(coords) || coords.length < 2) {
return
}
const lng = Number(coords[0])
const lat = Number(coords[1])
if (Number.isFinite(lat) && Number.isFinite(lng)) {
latLngs.push({ lat, lng })
}
}
function walk(node) {
if (!node || typeof node !== 'object') {
return
}
if (node.type === 'FeatureCollection' && Array.isArray(node.features)) {
node.features.forEach(walk)
return
}
if (node.type === 'Feature' && node.geometry) {
walk(node.geometry)
return
}
if (node.type === 'LineString' && Array.isArray(node.coordinates)) {
node.coordinates.forEach(pushLngLat)
return
}
if (node.type === 'MultiLineString' && Array.isArray(node.coordinates)) {
node.coordinates.forEach((line) => {
if (Array.isArray(line)) {
line.forEach(pushLngLat)
}
})
}
}
if (Array.isArray(rawValue)) {
rawValue.forEach((item) => {
if (Array.isArray(item)) {
pushLngLat(item)
return
}
if (item && typeof item === 'object') {
const lat = Number(item.lat)
const lng = Number(item.lng !== undefined ? item.lng : item.lon)
if (Number.isFinite(lat) && Number.isFinite(lng)) {
latLngs.push({ lat, lng })
}
}
})
return latLngs
}
walk(rawValue)
return latLngs
}
function parseGpxTrack(text) {
const xml = new DOMParser().parseFromString(text, 'application/xml')
const latLngs = []
const trackPoints = Array.from(xml.querySelectorAll('trkpt'))
const routePoints = trackPoints.length ? [] : Array.from(xml.querySelectorAll('rtept'))
const nodes = trackPoints.length ? trackPoints : routePoints
nodes.forEach((node) => {
const lat = Number(node.getAttribute('lat'))
const lng = Number(node.getAttribute('lon'))
if (Number.isFinite(lat) && Number.isFinite(lng)) {
latLngs.push({ lat, lng })
}
})
return latLngs
}
function parseKmlTrack(text) {
const xml = new DOMParser().parseFromString(text, 'application/xml')
const latLngs = []
const lineStrings = Array.from(xml.querySelectorAll('LineString coordinates'))
lineStrings.forEach((node) => {
String(node.textContent || '')
.trim()
.split(/\s+/)
.forEach((tuple) => {
const parsed = parseCoordinateTuple(tuple)
if (parsed) {
latLngs.push({ lat: parsed.lat, lng: parsed.lon })
}
})
})
return latLngs
}
function parseTrackFile(fileName, text) {
const lowerName = String(fileName || '').toLowerCase()
if (lowerName.endsWith('.gpx')) {
return parseGpxTrack(text)
}
if (lowerName.endsWith('.kml')) {
return parseKmlTrack(text)
}
if (lowerName.endsWith('.geojson') || lowerName.endsWith('.json')) {
return parseGeoJsonTrack(parseJsonWithFallback(text))
}
if (text.includes('<gpx')) {
return parseGpxTrack(text)
}
if (text.includes('<kml') || text.includes('<LineString')) {
return parseKmlTrack(text)
}
return parseGeoJsonTrack(parseJsonWithFallback(text))
}
async function handleTrackFileSelected(file) {
if (!file) {
return
}
try {
const text = await file.text()
const latLngs = parseTrackFile(file.name, text)
if (!latLngs || latLngs.length < 2) {
throw new Error('轨迹文件中没有可回放的路径点')
}
replacePathPoints(latLngs, `轨迹文件 ${file.name}`)
log(`已导入轨迹文件: ${file.name} (${latLngs.length} 点)`)
} catch (error) {
const message = error && error.message ? error.message : '轨迹文件导入失败'
log(message)
alert(message)
} finally {
elements.trackFileInput.value = ''
}
}
function toRad(value) {
return value * Math.PI / 180
}
function toDeg(value) {
return value * 180 / Math.PI
}
function getDistanceMeters(from, to) {
const earth = 6371000
const lat1 = toRad(from.lat)
const lat2 = toRad(to.lat)
const dLat = lat2 - lat1
const dLon = toRad(to.lng - from.lng)
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2)
return earth * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
}
function getHeadingDeg(from, to) {
const lat1 = toRad(from.lat)
const lat2 = toRad(to.lat)
const dLon = toRad(to.lng - from.lng)
const y = Math.sin(dLon) * Math.cos(lat2)
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon)
const bearing = (toDeg(Math.atan2(y, x)) + 360) % 360
return bearing
}
function interpolateLatLng(from, to, t) {
return L.latLng(
from.lat + (to.lat - from.lat) * t,
from.lng + (to.lng - from.lng) * t,
)
}
function tickPlayback() {
if (!state.playbackRunning || pathPoints.length < 2) {
return
}
const now = performance.now()
if (!state.lastPlaybackAt) {
state.lastPlaybackAt = now
}
const deltaSeconds = (now - state.lastPlaybackAt) / 1000
state.lastPlaybackAt = now
let remainingTravel = getSpeedMps() * deltaSeconds
while (remainingTravel > 0 && state.currentSegmentIndex < pathPoints.length - 1) {
const from = pathPoints[state.currentSegmentIndex]
const to = pathPoints[state.currentSegmentIndex + 1]
const segmentDistance = getDistanceMeters(from, to)
if (!segmentDistance) {
state.currentSegmentIndex += 1
state.currentSegmentProgress = 0
continue
}
const remainingSegment = segmentDistance * (1 - state.currentSegmentProgress)
if (remainingTravel >= remainingSegment) {
remainingTravel -= remainingSegment
state.currentSegmentIndex += 1
state.currentSegmentProgress = 0
state.currentLatLng = L.latLng(to.lat, to.lng)
state.headingDeg = getHeadingDeg(from, to)
} else {
state.currentSegmentProgress += remainingTravel / segmentDistance
state.currentLatLng = interpolateLatLng(from, to, state.currentSegmentProgress)
state.headingDeg = getHeadingDeg(from, to)
remainingTravel = 0
}
}
if (state.currentSegmentIndex >= pathPoints.length - 1) {
if (elements.loopPathInput.checked) {
state.currentSegmentIndex = 0
state.currentSegmentProgress = 0
state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
} else {
stopPlayback()
}
}
updateReadout()
if (state.streaming) {
sendCurrentPoint()
}
if (state.playbackRunning) {
state.playbackTimer = window.requestAnimationFrame(tickPlayback)
}
}
function startPlayback() {
if (pathPoints.length < 2) {
log('至少需要两个路径点')
return
}
stopPlayback()
state.playbackRunning = true
state.currentSegmentIndex = 0
state.currentSegmentProgress = 0
state.currentLatLng = L.latLng(pathPoints[0].lat, pathPoints[0].lng)
state.lastPlaybackAt = 0
updateReadout()
updateUiState()
log('开始路径回放')
state.playbackTimer = window.requestAnimationFrame(tickPlayback)
}
function stopPlayback() {
state.playbackRunning = false
state.lastPlaybackAt = 0
if (state.playbackTimer) {
window.cancelAnimationFrame(state.playbackTimer)
state.playbackTimer = 0
}
updateUiState()
}
map.on('click', (event) => {
if (state.pathEditMode) {
addPathPoint(event.latlng)
return
}
setCurrentPosition(event.latlng.lat, event.latlng.lng)
})
liveMarker.on('mousedown', () => {
map.dragging.disable()
})
map.on('mousemove', (event) => {
if (event.originalEvent.buttons !== 1) {
return
}
if (state.pathEditMode) {
return
}
setCurrentPosition(event.latlng.lat, event.latlng.lng)
})
map.on('mouseup', () => {
map.dragging.enable()
})
elements.connectBtn.addEventListener('click', connectSocket)
elements.importTrackBtn.addEventListener('click', () => {
elements.trackFileInput.click()
})
elements.trackFileInput.addEventListener('change', (event) => {
const input = event.target
const file = input && input.files && input.files[0] ? input.files[0] : null
handleTrackFileSelected(file)
})
elements.loadConfigBtn.addEventListener('click', loadConfigResources)
elements.fitCourseBtn.addEventListener('click', fitCourseBounds)
elements.applyTilesBtn.addEventListener('click', () => {
try {
applyTileTemplate(elements.tileUrlInput.value, { attribution: 'Custom Map' })
setResourceStatus('已应用自定义瓦片', 'ok')
state.lastResourceDetailText = `最近资源: 自定义瓦片 ${formatClockTime(Date.now())}`
updateUiState()
} catch (error) {
setResourceStatus(error && error.message ? error.message : '瓦片应用失败', 'warn')
}
})
elements.resetTilesBtn.addEventListener('click', () => {
applyTileTemplate(DEFAULT_TILE_URL, {
maxZoom: 20,
attribution: '&copy; OpenStreetMap',
})
setResourceStatus('已恢复 OSM 底图', 'ok')
state.lastResourceDetailText = `最近资源: OSM 底图 ${formatClockTime(Date.now())}`
updateUiState()
})
elements.loadCourseBtn.addEventListener('click', async () => {
try {
await loadCourseFromUrl(elements.courseUrlInput.value, true)
} catch (error) {
const message = error && error.message ? error.message : 'KML 载入失败'
setResourceStatus(message, 'warn')
log(message)
}
})
elements.clearCourseBtn.addEventListener('click', clearCourse)
elements.fitPathBtn.addEventListener('click', fitPathBounds)
elements.sendOnceBtn.addEventListener('click', () => {
sendCurrentPoint()
log('已发送当前位置')
})
elements.streamBtn.addEventListener('click', startStream)
elements.stopStreamBtn.addEventListener('click', stopStream)
elements.sendHeartRateOnceBtn.addEventListener('click', () => {
sendCurrentHeartRate()
log('已发送当前心率')
})
elements.startHeartRateStreamBtn.addEventListener('click', startHeartRateStream)
elements.stopHeartRateStreamBtn.addEventListener('click', stopHeartRateStream)
elements.applyHeartRatePresetBtn.addEventListener('click', applyHeartRatePreset)
elements.toggleHeartRateSampleBtn.addEventListener('click', toggleHeartRateSampleMode)
elements.togglePathModeBtn.addEventListener('click', () => {
state.pathEditMode = !state.pathEditMode
elements.pathHint.textContent = state.pathEditMode
? '地图点击将按顺序追加路径点。'
: '点击“开启路径编辑”后,在地图上逐点添加路径。'
updateUiState()
})
elements.clearPathBtn.addEventListener('click', () => {
pathPoints.splice(0, pathPoints.length)
state.lastTrackSourceText = '路径待命'
syncPathLine()
clearPathMarkers()
stopPlayback()
log('已清空路径')
})
elements.playPathBtn.addEventListener('click', startPlayback)
elements.pausePathBtn.addEventListener('click', () => {
stopPlayback()
log('已暂停回放')
})
updateReadout()
setSocketBadge(false)
setResourceStatus('支持直接载入 game.json也支持单独填瓦片模板和 KML 地址。', null)
updateUiState()
connectSocket()
})()