(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, pathEditMode: false, playbackRunning: false, playbackTimer: 0, streamTimer: 0, lastSentText: '--', lastResourceDetailText: '尚未载入资源', lastTrackSourceText: '路径待命', currentLatLng: L.latLng(DEFAULT_CENTER[0], DEFAULT_CENTER[1]), headingDeg: 0, currentSegmentIndex: 0, currentSegmentProgress: 0, lastPlaybackAt: 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'), 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.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.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.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 setSocketBadge(false) updateUiState() log('桥接已断开') }) socket.addEventListener('error', () => { state.connected = false state.socketConnecting = false 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(/([\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(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") .replace(/&/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(//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', '
', [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', `
${item.sequence}
`, [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', '
', [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 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 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 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(' 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: '© 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.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() })()