Add mock GPS simulator and configurable location sources
This commit is contained in:
@@ -121,6 +121,13 @@ export interface MapEngineViewState {
|
|||||||
statusText: string
|
statusText: string
|
||||||
gpsTracking: boolean
|
gpsTracking: boolean
|
||||||
gpsTrackingText: string
|
gpsTrackingText: string
|
||||||
|
locationSourceMode: 'real' | 'mock'
|
||||||
|
locationSourceText: string
|
||||||
|
mockBridgeConnected: boolean
|
||||||
|
mockBridgeStatusText: string
|
||||||
|
mockBridgeUrlText: string
|
||||||
|
mockCoordText: string
|
||||||
|
mockSpeedText: string
|
||||||
gpsCoordText: string
|
gpsCoordText: string
|
||||||
heartRateConnected: boolean
|
heartRateConnected: boolean
|
||||||
heartRateStatusText: string
|
heartRateStatusText: string
|
||||||
@@ -209,6 +216,13 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|||||||
'statusText',
|
'statusText',
|
||||||
'gpsTracking',
|
'gpsTracking',
|
||||||
'gpsTrackingText',
|
'gpsTrackingText',
|
||||||
|
'locationSourceMode',
|
||||||
|
'locationSourceText',
|
||||||
|
'mockBridgeConnected',
|
||||||
|
'mockBridgeStatusText',
|
||||||
|
'mockBridgeUrlText',
|
||||||
|
'mockCoordText',
|
||||||
|
'mockSpeedText',
|
||||||
'gpsCoordText',
|
'gpsCoordText',
|
||||||
'heartRateConnected',
|
'heartRateConnected',
|
||||||
'heartRateStatusText',
|
'heartRateStatusText',
|
||||||
@@ -582,15 +596,20 @@ export class MapEngine {
|
|||||||
this.setState({
|
this.setState({
|
||||||
gpsTracking: this.locationController.listening,
|
gpsTracking: this.locationController.listening,
|
||||||
gpsTrackingText: message,
|
gpsTrackingText: message,
|
||||||
|
...this.getLocationControllerViewPatch(),
|
||||||
}, true)
|
}, true)
|
||||||
},
|
},
|
||||||
onError: (message) => {
|
onError: (message) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
gpsTracking: false,
|
gpsTracking: this.locationController.listening,
|
||||||
gpsTrackingText: message,
|
gpsTrackingText: message,
|
||||||
|
...this.getLocationControllerViewPatch(),
|
||||||
statusText: `${message} (${this.buildVersion})`,
|
statusText: `${message} (${this.buildVersion})`,
|
||||||
}, true)
|
}, true)
|
||||||
},
|
},
|
||||||
|
onDebugStateChange: () => {
|
||||||
|
this.setState(this.getLocationControllerViewPatch(), true)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
this.heartRateController = new HeartRateController({
|
this.heartRateController = new HeartRateController({
|
||||||
onHeartRate: (bpm) => {
|
onHeartRate: (bpm) => {
|
||||||
@@ -716,6 +735,13 @@ export class MapEngine {
|
|||||||
statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
|
statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
|
||||||
gpsTracking: false,
|
gpsTracking: false,
|
||||||
gpsTrackingText: '持续定位待启动',
|
gpsTrackingText: '持续定位待启动',
|
||||||
|
locationSourceMode: 'real',
|
||||||
|
locationSourceText: '真实定位',
|
||||||
|
mockBridgeConnected: false,
|
||||||
|
mockBridgeStatusText: '未连接',
|
||||||
|
mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
|
mockCoordText: '--',
|
||||||
|
mockSpeedText: '--',
|
||||||
gpsCoordText: '--',
|
gpsCoordText: '--',
|
||||||
heartRateConnected: false,
|
heartRateConnected: false,
|
||||||
heartRateStatusText: '心率带未连接',
|
heartRateStatusText: '心率带未连接',
|
||||||
@@ -833,6 +859,20 @@ export class MapEngine {
|
|||||||
return this.gamePresentation.hud.hudTargetControlId
|
return this.gamePresentation.hud.hudTargetControlId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLocationControllerViewPatch(): Partial<MapEngineViewState> {
|
||||||
|
const debugState = this.locationController.getDebugState()
|
||||||
|
return {
|
||||||
|
gpsTracking: debugState.listening,
|
||||||
|
locationSourceMode: debugState.sourceMode,
|
||||||
|
locationSourceText: debugState.sourceModeText,
|
||||||
|
mockBridgeConnected: debugState.mockBridgeConnected,
|
||||||
|
mockBridgeStatusText: debugState.mockBridgeStatusText,
|
||||||
|
mockBridgeUrlText: debugState.mockBridgeUrlText,
|
||||||
|
mockCoordText: debugState.mockCoordText,
|
||||||
|
mockSpeedText: debugState.mockSpeedText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getGameModeText(): string {
|
getGameModeText(): string {
|
||||||
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
||||||
}
|
}
|
||||||
@@ -1272,6 +1312,26 @@ export class MapEngine {
|
|||||||
this.locationController.start()
|
this.locationController.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleSetRealLocationMode(): void {
|
||||||
|
this.locationController.setSourceMode('real')
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetMockLocationMode(): void {
|
||||||
|
this.locationController.setSourceMode('mock')
|
||||||
|
}
|
||||||
|
|
||||||
|
handleConnectMockLocationBridge(): void {
|
||||||
|
this.locationController.connectMockBridge()
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDisconnectMockLocationBridge(): void {
|
||||||
|
this.locationController.disconnectMockBridge()
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSetMockLocationBridgeUrl(url: string): void {
|
||||||
|
this.locationController.setMockBridgeUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void {
|
handleSetGameMode(nextMode: 'classic-sequential' | 'score-o'): void {
|
||||||
if (this.gameMode === nextMode) {
|
if (this.gameMode === nextMode) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,153 +1,227 @@
|
|||||||
export interface LocationUpdate {
|
import { DEFAULT_MOCK_LOCATION_BRIDGE_URL, MockLocationBridge } from './mockLocationBridge'
|
||||||
latitude: number
|
import { MockLocationSource } from './mockLocationSource'
|
||||||
longitude: number
|
import { RealLocationSource } from './realLocationSource'
|
||||||
accuracy?: number
|
import { type LocationSample, type LocationSourceCallbacks, type LocationSourceMode } from './locationSource'
|
||||||
speed?: number
|
|
||||||
|
export interface LocationUpdate extends LocationSample {}
|
||||||
|
|
||||||
|
export interface LocationControllerDebugState {
|
||||||
|
sourceMode: LocationSourceMode
|
||||||
|
sourceModeText: string
|
||||||
|
listening: boolean
|
||||||
|
mockBridgeConnected: boolean
|
||||||
|
mockBridgeStatusText: string
|
||||||
|
mockBridgeUrlText: string
|
||||||
|
mockCoordText: string
|
||||||
|
mockSpeedText: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LocationControllerCallbacks {
|
export interface LocationControllerCallbacks {
|
||||||
onLocation: (update: LocationUpdate) => void
|
onLocation: (update: LocationUpdate) => void
|
||||||
onStatus: (message: string) => void
|
onStatus: (message: string) => void
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
|
onDebugStateChange?: (state: LocationControllerDebugState) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
|
function formatSourceModeText(mode: LocationSourceMode): string {
|
||||||
const authSettings = settings as Record<string, boolean | undefined>
|
return mode === 'mock' ? '模拟定位' : '真实定位'
|
||||||
return !!authSettings['scope.userLocation']
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasBackgroundLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
|
function formatMockCoordText(sample: LocationSample | null): string {
|
||||||
const authSettings = settings as Record<string, boolean | undefined>
|
if (!sample) {
|
||||||
return !!authSettings['scope.userLocationBackground']
|
return '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${sample.latitude.toFixed(6)}, ${sample.longitude.toFixed(6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMockSpeedText(sample: LocationSample | null): string {
|
||||||
|
if (!sample || !Number.isFinite(sample.speed)) {
|
||||||
|
return '--'
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(Number(sample.speed) * 3.6).toFixed(1)} km/h`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMockBridgeUrl(rawUrl: string): string {
|
||||||
|
const trimmed = rawUrl.trim()
|
||||||
|
if (!trimmed) {
|
||||||
|
return DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = trimmed
|
||||||
|
if (!/^wss?:\/\//i.test(normalized)) {
|
||||||
|
normalized = `ws://${normalized.replace(/^\/+/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/\/mock-gps(?:\?.*)?$/i.test(normalized)) {
|
||||||
|
normalized = normalized.replace(/\/+$/, '')
|
||||||
|
normalized = `${normalized}/mock-gps`
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LocationController {
|
export class LocationController {
|
||||||
callbacks: LocationControllerCallbacks
|
callbacks: LocationControllerCallbacks
|
||||||
listening: boolean
|
realSource: RealLocationSource
|
||||||
boundLocationHandler: ((result: WechatMiniprogram.OnLocationChangeCallbackResult) => void) | null
|
mockSource: MockLocationSource
|
||||||
|
mockBridge: MockLocationBridge
|
||||||
|
sourceMode: LocationSourceMode
|
||||||
|
mockBridgeStatusText: string
|
||||||
|
mockBridgeUrl: string
|
||||||
|
|
||||||
constructor(callbacks: LocationControllerCallbacks) {
|
constructor(callbacks: LocationControllerCallbacks) {
|
||||||
this.callbacks = callbacks
|
this.callbacks = callbacks
|
||||||
this.listening = false
|
this.sourceMode = 'real'
|
||||||
this.boundLocationHandler = null
|
this.mockBridgeUrl = DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||||
|
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||||
|
|
||||||
|
const sourceCallbacks: LocationSourceCallbacks = {
|
||||||
|
onLocation: (sample) => {
|
||||||
|
this.callbacks.onLocation(sample)
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onStatus: (message) => {
|
||||||
|
this.callbacks.onStatus(message)
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
this.callbacks.onError(message)
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
this.realSource = new RealLocationSource(sourceCallbacks)
|
||||||
|
this.mockSource = new MockLocationSource(sourceCallbacks)
|
||||||
|
this.mockBridge = new MockLocationBridge({
|
||||||
|
onOpen: () => {
|
||||||
|
this.mockBridgeStatusText = `已连接 (${this.mockBridge.url})`
|
||||||
|
this.callbacks.onStatus('模拟定位源已连接')
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
|
||||||
|
this.callbacks.onStatus('模拟定位源已断开')
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onError: (message) => {
|
||||||
|
this.mockBridgeStatusText = `连接失败 (${this.mockBridge.url})`
|
||||||
|
this.callbacks.onError(`模拟定位源错误: ${message}`)
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
onSample: (sample) => {
|
||||||
|
this.mockSource.pushSample(sample)
|
||||||
|
this.emitDebugState()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get listening(): boolean {
|
||||||
|
return this.sourceMode === 'mock' ? this.mockSource.active : this.realSource.active
|
||||||
|
}
|
||||||
|
|
||||||
|
getDebugState(): LocationControllerDebugState {
|
||||||
|
return {
|
||||||
|
sourceMode: this.sourceMode,
|
||||||
|
sourceModeText: formatSourceModeText(this.sourceMode),
|
||||||
|
listening: this.listening,
|
||||||
|
mockBridgeConnected: this.mockBridge.connected,
|
||||||
|
mockBridgeStatusText: this.mockBridgeStatusText,
|
||||||
|
mockBridgeUrlText: this.mockBridgeUrl,
|
||||||
|
mockCoordText: formatMockCoordText(this.mockSource.lastSample),
|
||||||
|
mockSpeedText: formatMockSpeedText(this.mockSource.lastSample),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.listening) {
|
this.getActiveSource().start()
|
||||||
this.callbacks.onStatus('后台持续定位进行中')
|
this.emitDebugState()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
wx.getSetting({
|
|
||||||
success: (result) => {
|
|
||||||
const settings = result.authSetting || {}
|
|
||||||
if (hasBackgroundLocationPermission(settings)) {
|
|
||||||
this.startBackgroundLocation()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasLocationPermission(settings)) {
|
|
||||||
this.requestBackgroundPermissionInSettings()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
wx.authorize({
|
|
||||||
scope: 'scope.userLocation',
|
|
||||||
success: () => {
|
|
||||||
this.requestBackgroundPermissionInSettings()
|
|
||||||
},
|
|
||||||
fail: () => {
|
|
||||||
this.requestBackgroundPermissionInSettings()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
fail: (error) => {
|
|
||||||
const message = error && error.errMsg ? error.errMsg : 'getSetting 失败'
|
|
||||||
this.callbacks.onError(`GPS授权检查失败: ${message}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
requestBackgroundPermissionInSettings(): void {
|
|
||||||
this.callbacks.onStatus('请在授权面板开启后台定位')
|
|
||||||
wx.openSetting({
|
|
||||||
success: (result) => {
|
|
||||||
const settings = result.authSetting || {}
|
|
||||||
if (hasBackgroundLocationPermission(settings)) {
|
|
||||||
this.startBackgroundLocation()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.callbacks.onError('GPS启动失败: 未授予后台定位权限')
|
|
||||||
},
|
|
||||||
fail: (error) => {
|
|
||||||
const message = error && error.errMsg ? error.errMsg : 'openSetting 失败'
|
|
||||||
this.callbacks.onError(`GPS启动失败: ${message}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
startBackgroundLocation(): void {
|
|
||||||
wx.startLocationUpdateBackground({
|
|
||||||
type: 'wgs84',
|
|
||||||
success: () => {
|
|
||||||
this.bindLocationListener()
|
|
||||||
this.listening = true
|
|
||||||
this.callbacks.onStatus('后台持续定位已启动')
|
|
||||||
},
|
|
||||||
fail: (error) => {
|
|
||||||
const message = error && error.errMsg ? error.errMsg : 'startLocationUpdateBackground 失败'
|
|
||||||
this.callbacks.onError(`GPS启动失败: ${message}`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
if (!this.listening) {
|
this.getActiveSource().stop()
|
||||||
this.callbacks.onStatus('后台持续定位未启动')
|
this.emitDebugState()
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
|
|
||||||
wx.offLocationChange(this.boundLocationHandler)
|
|
||||||
}
|
|
||||||
this.boundLocationHandler = null
|
|
||||||
|
|
||||||
wx.stopLocationUpdate({
|
|
||||||
complete: () => {
|
|
||||||
this.listening = false
|
|
||||||
this.callbacks.onStatus('后台持续定位已停止')
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
|
this.realSource.destroy()
|
||||||
wx.offLocationChange(this.boundLocationHandler)
|
this.mockSource.destroy()
|
||||||
}
|
this.mockBridge.destroy()
|
||||||
this.boundLocationHandler = null
|
this.emitDebugState()
|
||||||
|
|
||||||
if (this.listening) {
|
|
||||||
wx.stopLocationUpdate({ complete: () => {} })
|
|
||||||
this.listening = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bindLocationListener(): void {
|
setSourceMode(mode: LocationSourceMode): void {
|
||||||
if (this.boundLocationHandler) {
|
if (this.sourceMode === mode) {
|
||||||
|
this.callbacks.onStatus(`${formatSourceModeText(mode)}已启用`)
|
||||||
|
this.emitDebugState()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
this.boundLocationHandler = (result) => {
|
const wasListening = this.listening
|
||||||
this.callbacks.onLocation({
|
if (wasListening) {
|
||||||
latitude: result.latitude,
|
this.getActiveSource().stop()
|
||||||
longitude: result.longitude,
|
}
|
||||||
accuracy: result.accuracy,
|
this.sourceMode = mode
|
||||||
speed: result.speed,
|
|
||||||
})
|
if (wasListening) {
|
||||||
|
this.getActiveSource().start()
|
||||||
|
} else {
|
||||||
|
this.callbacks.onStatus(`已切换到${formatSourceModeText(mode)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
wx.onLocationChange(this.boundLocationHandler)
|
this.emitDebugState()
|
||||||
|
}
|
||||||
|
|
||||||
|
setMockBridgeUrl(url: string): void {
|
||||||
|
this.mockBridgeUrl = normalizeMockBridgeUrl(url)
|
||||||
|
|
||||||
|
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||||
|
this.mockBridgeStatusText = `已设置新地址,重连生效 (${this.mockBridgeUrl})`
|
||||||
|
this.callbacks.onStatus('模拟定位源地址已更新,重连后生效')
|
||||||
|
} else {
|
||||||
|
this.mockBridgeStatusText = `未连接 (${this.mockBridgeUrl})`
|
||||||
|
this.callbacks.onStatus('模拟定位源地址已更新')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitDebugState()
|
||||||
|
}
|
||||||
|
|
||||||
|
connectMockBridge(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
|
||||||
|
if (this.mockBridge.connected || this.mockBridge.connecting) {
|
||||||
|
this.callbacks.onStatus('模拟定位源已连接')
|
||||||
|
this.emitDebugState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = normalizeMockBridgeUrl(url === DEFAULT_MOCK_LOCATION_BRIDGE_URL ? this.mockBridgeUrl : url)
|
||||||
|
this.mockBridgeUrl = targetUrl
|
||||||
|
this.mockBridgeStatusText = `连接中 (${targetUrl})`
|
||||||
|
this.emitDebugState()
|
||||||
|
this.callbacks.onStatus('模拟定位源连接中')
|
||||||
|
this.mockBridge.connect(targetUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectMockBridge(): void {
|
||||||
|
if (!this.mockBridge.connected && !this.mockBridge.connecting) {
|
||||||
|
this.callbacks.onStatus('模拟定位源未连接')
|
||||||
|
this.emitDebugState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mockBridge.disconnect()
|
||||||
|
this.mockBridgeStatusText = `未连接 (${this.mockBridge.url})`
|
||||||
|
this.callbacks.onStatus('模拟定位源已断开')
|
||||||
|
this.emitDebugState()
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveSource(): RealLocationSource | MockLocationSource {
|
||||||
|
return this.sourceMode === 'mock' ? this.mockSource : this.realSource
|
||||||
|
}
|
||||||
|
|
||||||
|
emitDebugState(): void {
|
||||||
|
if (this.callbacks.onDebugStateChange) {
|
||||||
|
this.callbacks.onDebugStateChange(this.getDebugState())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
miniprogram/engine/sensor/locationSource.ts
Normal file
25
miniprogram/engine/sensor/locationSource.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export type LocationSourceMode = 'real' | 'mock'
|
||||||
|
|
||||||
|
export interface LocationSample {
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
accuracy?: number
|
||||||
|
speed?: number | null
|
||||||
|
headingDeg?: number | null
|
||||||
|
timestamp: number
|
||||||
|
sourceMode: LocationSourceMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationSourceCallbacks {
|
||||||
|
onLocation: (sample: LocationSample) => void
|
||||||
|
onStatus: (message: string) => void
|
||||||
|
onError: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocationSource {
|
||||||
|
readonly mode: LocationSourceMode
|
||||||
|
readonly active: boolean
|
||||||
|
start(): void
|
||||||
|
stop(): void
|
||||||
|
destroy(): void
|
||||||
|
}
|
||||||
147
miniprogram/engine/sensor/mockLocationBridge.ts
Normal file
147
miniprogram/engine/sensor/mockLocationBridge.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { type LocationSample } from './locationSource'
|
||||||
|
|
||||||
|
export const DEFAULT_MOCK_LOCATION_BRIDGE_URL = 'wss://gs.gotomars.xyz/mock-gps'
|
||||||
|
|
||||||
|
export interface MockLocationBridgeCallbacks {
|
||||||
|
onOpen: () => void
|
||||||
|
onClose: (message: string) => void
|
||||||
|
onError: (message: string) => void
|
||||||
|
onSample: (sample: LocationSample) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawMockGpsMessage = {
|
||||||
|
type?: string
|
||||||
|
timestamp?: number
|
||||||
|
lat?: number
|
||||||
|
lon?: number
|
||||||
|
accuracyMeters?: number
|
||||||
|
speedMps?: number
|
||||||
|
headingDeg?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseMessage(data: string): RawMockGpsMessage | null {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data) as RawMockGpsMessage
|
||||||
|
} catch (_error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocationSample(message: RawMockGpsMessage): LocationSample | null {
|
||||||
|
if (message.type !== 'mock_gps') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(message.lat) || !Number.isFinite(message.lon)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
latitude: Number(message.lat),
|
||||||
|
longitude: Number(message.lon),
|
||||||
|
accuracy: Number.isFinite(message.accuracyMeters) ? Number(message.accuracyMeters) : undefined,
|
||||||
|
speed: Number.isFinite(message.speedMps) ? Number(message.speedMps) : null,
|
||||||
|
headingDeg: Number.isFinite(message.headingDeg) ? Number(message.headingDeg) : null,
|
||||||
|
timestamp: Number.isFinite(message.timestamp) ? Number(message.timestamp) : Date.now(),
|
||||||
|
sourceMode: 'mock',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MockLocationBridge {
|
||||||
|
callbacks: MockLocationBridgeCallbacks
|
||||||
|
socketTask: WechatMiniprogram.SocketTask | null
|
||||||
|
connected: boolean
|
||||||
|
connecting: boolean
|
||||||
|
url: string
|
||||||
|
|
||||||
|
constructor(callbacks: MockLocationBridgeCallbacks) {
|
||||||
|
this.callbacks = callbacks
|
||||||
|
this.socketTask = null
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this.url = DEFAULT_MOCK_LOCATION_BRIDGE_URL
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(url = DEFAULT_MOCK_LOCATION_BRIDGE_URL): void {
|
||||||
|
if (this.connected || this.connecting) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.url = url
|
||||||
|
this.connecting = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const socketTask = wx.connectSocket({
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
this.socketTask = socketTask
|
||||||
|
|
||||||
|
socketTask.onOpen(() => {
|
||||||
|
this.connected = true
|
||||||
|
this.connecting = false
|
||||||
|
this.callbacks.onOpen()
|
||||||
|
})
|
||||||
|
|
||||||
|
socketTask.onClose((result) => {
|
||||||
|
const reason = result && result.reason ? result.reason : '模拟源连接已关闭'
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this.socketTask = null
|
||||||
|
this.callbacks.onClose(reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
socketTask.onError((result) => {
|
||||||
|
const message = result && result.errMsg ? result.errMsg : '模拟源连接失败'
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this.socketTask = null
|
||||||
|
this.callbacks.onError(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
socketTask.onMessage((result) => {
|
||||||
|
if (!result || typeof result.data !== 'string') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = safeParseMessage(result.data)
|
||||||
|
if (!parsed) {
|
||||||
|
this.callbacks.onError('模拟源消息不是合法 JSON')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = toLocationSample(parsed)
|
||||||
|
if (!sample) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onSample(sample)
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
this.socketTask = null
|
||||||
|
const message = error && (error as Error).message ? (error as Error).message : '模拟源连接创建失败'
|
||||||
|
this.callbacks.onError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect(): void {
|
||||||
|
if (!this.socketTask) {
|
||||||
|
if (this.connected || this.connecting) {
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const socketTask = this.socketTask
|
||||||
|
this.socketTask = null
|
||||||
|
this.connected = false
|
||||||
|
this.connecting = false
|
||||||
|
socketTask.close({})
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
51
miniprogram/engine/sensor/mockLocationSource.ts
Normal file
51
miniprogram/engine/sensor/mockLocationSource.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { type LocationSample, type LocationSource, type LocationSourceCallbacks } from './locationSource'
|
||||||
|
|
||||||
|
export class MockLocationSource implements LocationSource {
|
||||||
|
callbacks: LocationSourceCallbacks
|
||||||
|
active: boolean
|
||||||
|
lastSample: LocationSample | null
|
||||||
|
|
||||||
|
constructor(callbacks: LocationSourceCallbacks) {
|
||||||
|
this.callbacks = callbacks
|
||||||
|
this.active = false
|
||||||
|
this.lastSample = null
|
||||||
|
}
|
||||||
|
|
||||||
|
get mode(): 'mock' {
|
||||||
|
return 'mock'
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.active) {
|
||||||
|
this.callbacks.onStatus('模拟定位进行中')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.active = true
|
||||||
|
this.callbacks.onStatus('模拟定位已启动,等待外部输入')
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this.active) {
|
||||||
|
this.callbacks.onStatus('模拟定位未启动')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.active = false
|
||||||
|
this.callbacks.onStatus('模拟定位已停止')
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.active = false
|
||||||
|
this.lastSample = null
|
||||||
|
}
|
||||||
|
|
||||||
|
pushSample(sample: LocationSample): void {
|
||||||
|
this.lastSample = sample
|
||||||
|
if (!this.active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onLocation(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
147
miniprogram/engine/sensor/realLocationSource.ts
Normal file
147
miniprogram/engine/sensor/realLocationSource.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { type LocationSource, type LocationSourceCallbacks } from './locationSource'
|
||||||
|
|
||||||
|
function hasLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
|
||||||
|
const authSettings = settings as Record<string, boolean | undefined>
|
||||||
|
return !!authSettings['scope.userLocation']
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasBackgroundLocationPermission(settings: WechatMiniprogram.AuthSetting): boolean {
|
||||||
|
const authSettings = settings as Record<string, boolean | undefined>
|
||||||
|
return !!authSettings['scope.userLocationBackground']
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RealLocationSource implements LocationSource {
|
||||||
|
callbacks: LocationSourceCallbacks
|
||||||
|
active: boolean
|
||||||
|
boundLocationHandler: ((result: WechatMiniprogram.OnLocationChangeCallbackResult) => void) | null
|
||||||
|
|
||||||
|
constructor(callbacks: LocationSourceCallbacks) {
|
||||||
|
this.callbacks = callbacks
|
||||||
|
this.active = false
|
||||||
|
this.boundLocationHandler = null
|
||||||
|
}
|
||||||
|
|
||||||
|
get mode(): 'real' {
|
||||||
|
return 'real'
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.active) {
|
||||||
|
this.callbacks.onStatus('后台持续定位进行中')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.getSetting({
|
||||||
|
success: (result) => {
|
||||||
|
const settings = result.authSetting || {}
|
||||||
|
if (hasBackgroundLocationPermission(settings)) {
|
||||||
|
this.startBackgroundLocation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasLocationPermission(settings)) {
|
||||||
|
this.requestBackgroundPermissionInSettings()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.authorize({
|
||||||
|
scope: 'scope.userLocation',
|
||||||
|
success: () => {
|
||||||
|
this.requestBackgroundPermissionInSettings()
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
this.requestBackgroundPermissionInSettings()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
const message = error && error.errMsg ? error.errMsg : 'getSetting 失败'
|
||||||
|
this.callbacks.onError(`GPS授权检查失败: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (!this.active) {
|
||||||
|
this.callbacks.onStatus('后台持续定位未启动')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
|
||||||
|
wx.offLocationChange(this.boundLocationHandler)
|
||||||
|
}
|
||||||
|
this.boundLocationHandler = null
|
||||||
|
|
||||||
|
wx.stopLocationUpdate({
|
||||||
|
complete: () => {
|
||||||
|
this.active = false
|
||||||
|
this.callbacks.onStatus('后台持续定位已停止')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (typeof wx.offLocationChange === 'function' && this.boundLocationHandler) {
|
||||||
|
wx.offLocationChange(this.boundLocationHandler)
|
||||||
|
}
|
||||||
|
this.boundLocationHandler = null
|
||||||
|
|
||||||
|
if (this.active) {
|
||||||
|
wx.stopLocationUpdate({ complete: () => {} })
|
||||||
|
this.active = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestBackgroundPermissionInSettings(): void {
|
||||||
|
this.callbacks.onStatus('请在授权面板开启后台定位')
|
||||||
|
wx.openSetting({
|
||||||
|
success: (result) => {
|
||||||
|
const settings = result.authSetting || {}
|
||||||
|
if (hasBackgroundLocationPermission(settings)) {
|
||||||
|
this.startBackgroundLocation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.callbacks.onError('GPS启动失败: 未授予后台定位权限')
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
const message = error && error.errMsg ? error.errMsg : 'openSetting 失败'
|
||||||
|
this.callbacks.onError(`GPS启动失败: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
startBackgroundLocation(): void {
|
||||||
|
wx.startLocationUpdateBackground({
|
||||||
|
type: 'wgs84',
|
||||||
|
success: () => {
|
||||||
|
this.bindLocationListener()
|
||||||
|
this.active = true
|
||||||
|
this.callbacks.onStatus('后台持续定位已启动')
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
const message = error && error.errMsg ? error.errMsg : 'startLocationUpdateBackground 失败'
|
||||||
|
this.callbacks.onError(`GPS启动失败: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
bindLocationListener(): void {
|
||||||
|
if (this.boundLocationHandler) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.boundLocationHandler = (result) => {
|
||||||
|
this.callbacks.onLocation({
|
||||||
|
latitude: result.latitude,
|
||||||
|
longitude: result.longitude,
|
||||||
|
accuracy: result.accuracy,
|
||||||
|
speed: result.speed,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
sourceMode: 'real',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wx.onLocationChange(this.boundLocationHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
statusBarHeight: number
|
statusBarHeight: number
|
||||||
topInsetHeight: number
|
topInsetHeight: number
|
||||||
hudPanelIndex: number
|
hudPanelIndex: number
|
||||||
|
mockBridgeUrlDraft: string
|
||||||
panelTimerText: string
|
panelTimerText: string
|
||||||
panelMileageText: string
|
panelMileageText: string
|
||||||
panelDistanceValueText: string
|
panelDistanceValueText: string
|
||||||
@@ -30,7 +31,7 @@ type MapPageData = MapEngineViewState & {
|
|||||||
showRightButtonGroups: boolean
|
showRightButtonGroups: boolean
|
||||||
showBottomDebugButton: boolean
|
showBottomDebugButton: boolean
|
||||||
}
|
}
|
||||||
const INTERNAL_BUILD_VERSION = 'map-build-166'
|
const INTERNAL_BUILD_VERSION = 'map-build-172'
|
||||||
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
|
||||||
let mapEngine: MapEngine | null = null
|
let mapEngine: MapEngine | null = null
|
||||||
function buildSideButtonVisibility(mode: SideButtonMode) {
|
function buildSideButtonVisibility(mode: SideButtonMode) {
|
||||||
@@ -105,6 +106,14 @@ Page({
|
|||||||
panelProgressText: '0/0',
|
panelProgressText: '0/0',
|
||||||
gameSessionStatus: 'idle',
|
gameSessionStatus: 'idle',
|
||||||
gameModeText: '顺序赛',
|
gameModeText: '顺序赛',
|
||||||
|
locationSourceMode: 'real',
|
||||||
|
locationSourceText: '真实定位',
|
||||||
|
mockBridgeConnected: false,
|
||||||
|
mockBridgeStatusText: '未连接',
|
||||||
|
mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
|
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
|
mockCoordText: '--',
|
||||||
|
mockSpeedText: '--',
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
panelTelemetryTone: 'blue',
|
panelTelemetryTone: 'blue',
|
||||||
panelHeartRateZoneNameText: '--',
|
panelHeartRateZoneNameText: '--',
|
||||||
@@ -151,7 +160,19 @@ Page({
|
|||||||
|
|
||||||
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
|
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
|
||||||
onData: (patch) => {
|
onData: (patch) => {
|
||||||
this.setData(patch)
|
const nextPatch = patch as Partial<MapPageData>
|
||||||
|
if (
|
||||||
|
typeof nextPatch.mockBridgeUrlText === 'string'
|
||||||
|
&& this.data.mockBridgeUrlDraft === this.data.mockBridgeUrlText
|
||||||
|
) {
|
||||||
|
this.setData({
|
||||||
|
...nextPatch,
|
||||||
|
mockBridgeUrlDraft: nextPatch.mockBridgeUrlText,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setData(nextPatch)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -170,6 +191,14 @@ Page({
|
|||||||
panelProgressText: '0/0',
|
panelProgressText: '0/0',
|
||||||
gameSessionStatus: 'idle',
|
gameSessionStatus: 'idle',
|
||||||
gameModeText: '顺序赛',
|
gameModeText: '顺序赛',
|
||||||
|
locationSourceMode: 'real',
|
||||||
|
locationSourceText: '真实定位',
|
||||||
|
mockBridgeConnected: false,
|
||||||
|
mockBridgeStatusText: '未连接',
|
||||||
|
mockBridgeUrlText: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
|
mockBridgeUrlDraft: 'wss://gs.gotomars.xyz/mock-gps',
|
||||||
|
mockCoordText: '--',
|
||||||
|
mockSpeedText: '--',
|
||||||
panelSpeedValueText: '0',
|
panelSpeedValueText: '0',
|
||||||
panelTelemetryTone: 'blue',
|
panelTelemetryTone: 'blue',
|
||||||
panelHeartRateZoneNameText: '--',
|
panelHeartRateZoneNameText: '--',
|
||||||
@@ -382,6 +411,42 @@ Page({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
handleSetRealLocationMode() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleSetRealLocationMode()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSetMockLocationMode() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleSetMockLocationMode()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleConnectMockLocationBridge() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleConnectMockLocationBridge()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleMockBridgeUrlInput(event: WechatMiniprogram.Input) {
|
||||||
|
this.setData({
|
||||||
|
mockBridgeUrlDraft: event.detail.value,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSaveMockBridgeUrl() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleSetMockLocationBridgeUrl(this.data.mockBridgeUrlDraft)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
handleDisconnectMockLocationBridge() {
|
||||||
|
if (mapEngine) {
|
||||||
|
mapEngine.handleDisconnectMockLocationBridge()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
handleConnectHeartRate() {
|
handleConnectHeartRate() {
|
||||||
if (mapEngine) {
|
if (mapEngine) {
|
||||||
mapEngine.handleConnectHeartRate()
|
mapEngine.handleConnectHeartRate()
|
||||||
|
|||||||
@@ -285,10 +285,42 @@
|
|||||||
<text class="info-panel__label">GPS</text>
|
<text class="info-panel__label">GPS</text>
|
||||||
<text class="info-panel__value">{{gpsTrackingText}}</text>
|
<text class="info-panel__value">{{gpsTrackingText}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Location Source</text>
|
||||||
|
<text class="info-panel__value">{{locationSourceText}}</text>
|
||||||
|
</view>
|
||||||
<view class="info-panel__row info-panel__row--stack">
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
<text class="info-panel__label">GPS Coord</text>
|
<text class="info-panel__label">GPS Coord</text>
|
||||||
<text class="info-panel__value">{{gpsCoordText}}</text>
|
<text class="info-panel__value">{{gpsCoordText}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">Mock Bridge</text>
|
||||||
|
<text class="info-panel__value">{{mockBridgeStatusText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">Mock URL</text>
|
||||||
|
<view class="debug-inline-stack">
|
||||||
|
<input
|
||||||
|
class="debug-input"
|
||||||
|
value="{{mockBridgeUrlDraft}}"
|
||||||
|
placeholder="ws://192.168.x.x:17865/mock-gps"
|
||||||
|
bindinput="handleMockBridgeUrlInput"
|
||||||
|
/>
|
||||||
|
<view class="control-row control-row--compact">
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleSaveMockBridgeUrl">保存地址</view>
|
||||||
|
<view class="control-chip {{mockBridgeConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectMockLocationBridge">连接模拟源</view>
|
||||||
|
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectMockLocationBridge">断开模拟源</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row info-panel__row--stack">
|
||||||
|
<text class="info-panel__label">Mock Coord</text>
|
||||||
|
<text class="info-panel__value">{{mockCoordText}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-panel__row">
|
||||||
|
<text class="info-panel__label">Mock Speed</text>
|
||||||
|
<text class="info-panel__value">{{mockSpeedText}}</text>
|
||||||
|
</view>
|
||||||
<view class="info-panel__row">
|
<view class="info-panel__row">
|
||||||
<text class="info-panel__label">Heart Rate</text>
|
<text class="info-panel__label">Heart Rate</text>
|
||||||
<text class="info-panel__value">{{heartRateStatusText}}</text>
|
<text class="info-panel__value">{{heartRateStatusText}}</text>
|
||||||
@@ -313,6 +345,10 @@
|
|||||||
<view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
|
<view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
|
||||||
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
|
<view class="control-chip {{heartRateConnected ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleConnectHeartRate">{{heartRateConnected ? '心率带已连接' : '连接心率带'}}</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="control-row">
|
||||||
|
<view class="control-chip {{locationSourceMode === 'real' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetRealLocationMode">真实定位</view>
|
||||||
|
<view class="control-chip {{locationSourceMode === 'mock' ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleSetMockLocationMode">模拟定位</view>
|
||||||
|
</view>
|
||||||
<view class="control-row">
|
<view class="control-row">
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleDisconnectHeartRate">断开心率带</view>
|
||||||
<view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</view>
|
<view class="control-chip control-chip--secondary" bindtap="handleCycleNorthReferenceMode">{{northReferenceButtonText}}</view>
|
||||||
|
|||||||
@@ -1175,12 +1175,45 @@
|
|||||||
color: #45624b;
|
color: #45624b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.debug-input {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 72rpx;
|
||||||
|
padding: 0 22rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: inset 0 0 0 2rpx rgba(22, 48, 32, 0.08);
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #244132;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-inline-stack {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.control-row {
|
.control-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14rpx;
|
gap: 14rpx;
|
||||||
margin-top: 18rpx;
|
margin-top: 18rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.control-row--compact {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row--compact .control-chip {
|
||||||
|
padding: 16rpx 10rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row--single .control-chip {
|
||||||
|
flex: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.debug-section .control-row:last-child {
|
.debug-section .control-row:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
24
package-lock.json
generated
24
package-lock.json
generated
@@ -7,6 +7,9 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "miniprogram-ts-quickstart",
|
"name": "miniprogram-ts-quickstart",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"miniprogram-api-typings": "^2.8.3-1",
|
"miniprogram-api-typings": "^2.8.3-1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
@@ -32,6 +35,27 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,15 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"typecheck:watch": "tsc --noEmit -p tsconfig.json --watch"
|
"typecheck:watch": "tsc --noEmit -p tsconfig.json --watch",
|
||||||
|
"mock-gps-sim": "node tools/mock-gps-sim/server.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "",
|
"license": "",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"miniprogram-api-typings": "^2.8.3-1",
|
"miniprogram-api-typings": "^2.8.3-1",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
75
tools/mock-gps-sim/README.md
Normal file
75
tools/mock-gps-sim/README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Mock GPS Simulator
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
在仓库根目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run mock-gps-sim
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后:
|
||||||
|
|
||||||
|
- 控制台页面: `http://127.0.0.1:17865/`
|
||||||
|
- 小程序接收地址: `ws://127.0.0.1:17865/mock-gps`
|
||||||
|
- 资源代理: `http://127.0.0.1:17865/proxy?url=<remote-url>`
|
||||||
|
|
||||||
|
## 当前能力
|
||||||
|
|
||||||
|
- 直接载入 `game.json`
|
||||||
|
- 自动解析 `map / mapmeta / course`
|
||||||
|
- 自动切换自定义瓦片
|
||||||
|
- 自动渲染 KML 控制点
|
||||||
|
- 一键跳到开始点 / 结束点 / 任意检查点
|
||||||
|
- 地图点击跳点
|
||||||
|
- 实时连续发送 `mock_gps`
|
||||||
|
- 路径编辑
|
||||||
|
- 上传轨迹文件回放(GPX / KML / GeoJSON)
|
||||||
|
- 路径回放
|
||||||
|
- 速度、频率、精度调节
|
||||||
|
|
||||||
|
## 加载自己的地图
|
||||||
|
|
||||||
|
推荐方式:
|
||||||
|
|
||||||
|
1. 启动模拟器后,打开 `http://127.0.0.1:17865/`
|
||||||
|
2. 在“资源加载”里填自己的 `game.json` 地址
|
||||||
|
3. 点“载入配置”
|
||||||
|
|
||||||
|
模拟器会自动:
|
||||||
|
|
||||||
|
- 读取 `map` 和 `mapmeta`
|
||||||
|
- 切换到你的瓦片底图
|
||||||
|
- 读取 `course`
|
||||||
|
- 渲染开始点、检查点、结束点
|
||||||
|
|
||||||
|
如果你不想走整套配置,也可以:
|
||||||
|
|
||||||
|
- 直接填“瓦片模板”,例如 `https://host/tiles/{z}/{x}/{y}.webp`
|
||||||
|
- 直接填 `KML URL`
|
||||||
|
|
||||||
|
路径回放也支持直接导入轨迹文件:
|
||||||
|
|
||||||
|
- `GPX`
|
||||||
|
- `KML`
|
||||||
|
- `GeoJSON / JSON`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- 配置和 KML 是通过本地代理拉取的,所以浏览器跨域问题会少很多
|
||||||
|
- 如果你的资源需要鉴权,第一版代理还没有加认证头透传
|
||||||
|
|
||||||
|
## 真机调试注意
|
||||||
|
|
||||||
|
如果小程序跑在手机上,不要用 `127.0.0.1`。
|
||||||
|
把小程序里的 mock bridge 地址改成你电脑在局域网里的 IP,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ws://192.168.1.23:17865/mock-gps
|
||||||
|
```
|
||||||
|
|
||||||
|
同理,浏览器里的模拟器页面也建议用电脑局域网地址打开,例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://192.168.1.23:17865/
|
||||||
|
```
|
||||||
125
tools/mock-gps-sim/public/index.html
Normal file
125
tools/mock-gps-sim/public/index.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Mock GPS Simulator</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||||
|
<link rel="stylesheet" href="./style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="panel">
|
||||||
|
<div class="panel__header">
|
||||||
|
<div class="panel__eyebrow">MOCK GPS SIM</div>
|
||||||
|
<h1>外部模拟器</h1>
|
||||||
|
<div id="socketStatus" class="badge badge--muted">未连接</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<div class="group__title">资源加载</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>游戏配置 URL</span>
|
||||||
|
<input id="configUrlInput" type="text" value="https://oss-mbh5.colormaprun.com/wxmini/test/game.json">
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<button id="loadConfigBtn" class="btn btn--primary">载入配置</button>
|
||||||
|
<button id="fitCourseBtn" class="btn">适配视野</button>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>瓦片模板</span>
|
||||||
|
<input id="tileUrlInput" type="text" placeholder="https://host/tiles/{z}/{x}/{y}.webp">
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<button id="applyTilesBtn" class="btn">应用瓦片</button>
|
||||||
|
<button id="resetTilesBtn" class="btn">恢复 OSM</button>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>KML URL</span>
|
||||||
|
<input id="courseUrlInput" type="text" placeholder="https://host/course/c01.kml">
|
||||||
|
</label>
|
||||||
|
<div class="row">
|
||||||
|
<button id="loadCourseBtn" class="btn">载入控制点</button>
|
||||||
|
<button id="clearCourseBtn" class="btn">清空控制点</button>
|
||||||
|
</div>
|
||||||
|
<div id="resourceStatus" class="hint">支持直接载入 game.json,也支持单独填瓦片模板和 KML 地址。</div>
|
||||||
|
<div id="resourceDetail" class="group__status">尚未载入资源</div>
|
||||||
|
<div id="courseJumpList" class="jump-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<div class="group__title">实时发送</div>
|
||||||
|
<div id="realtimeStatus" class="group__status">桥接未连接</div>
|
||||||
|
<div id="lastSendStatus" class="group__status">最近发送: --</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="connectBtn" class="btn btn--primary">连接桥接</button>
|
||||||
|
<button id="sendOnceBtn" class="btn">发送一次</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="streamBtn" class="btn btn--accent">开始连续发送</button>
|
||||||
|
<button id="stopStreamBtn" class="btn">停止发送</button>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>发送频率</span>
|
||||||
|
<select id="hzSelect">
|
||||||
|
<option value="2">2 Hz</option>
|
||||||
|
<option value="5" selected>5 Hz</option>
|
||||||
|
<option value="10">10 Hz</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>精度 (m)</span>
|
||||||
|
<input id="accuracyInput" type="number" min="1" max="100" value="6">
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<div class="group__title">路径回放</div>
|
||||||
|
<div id="playbackStatus" class="group__status">路径待命</div>
|
||||||
|
<input id="trackFileInput" class="file-input-hidden" type="file" accept=".gpx,.kml,.geojson,.json,application/json,application/gpx+xml,application/vnd.google-earth.kml+xml">
|
||||||
|
<div class="row">
|
||||||
|
<button id="importTrackBtn" class="btn">导入轨迹文件</button>
|
||||||
|
<button id="togglePathModeBtn" class="btn">开启路径编辑</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="clearPathBtn" class="btn">清空路径</button>
|
||||||
|
<button id="fitPathBtn" class="btn">适配路径</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button id="playPathBtn" class="btn btn--accent">开始回放</button>
|
||||||
|
<button id="pausePathBtn" class="btn">暂停回放</button>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>移动速度 (km/h)</span>
|
||||||
|
<input id="speedInput" type="number" min="1" max="25" step="0.1" value="6">
|
||||||
|
</label>
|
||||||
|
<label class="field field--check">
|
||||||
|
<input id="loopPathInput" type="checkbox" checked>
|
||||||
|
<span>循环回放</span>
|
||||||
|
</label>
|
||||||
|
<div id="pathHint" class="hint">点击“开启路径编辑”后,在地图上逐点添加路径。</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<div class="group__title">当前位置</div>
|
||||||
|
<div class="stat"><span>纬度</span><strong id="latText">--</strong></div>
|
||||||
|
<div class="stat"><span>经度</span><strong id="lonText">--</strong></div>
|
||||||
|
<div class="stat"><span>航向</span><strong id="headingText">--</strong></div>
|
||||||
|
<div class="stat"><span>路径点</span><strong id="pathCountText">0</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="group">
|
||||||
|
<div class="group__title">日志</div>
|
||||||
|
<div id="log" class="log"></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="map-shell">
|
||||||
|
<div id="map"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="./simulator.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1157
tools/mock-gps-sim/public/simulator.js
Normal file
1157
tools/mock-gps-sim/public/simulator.js
Normal file
File diff suppressed because it is too large
Load Diff
278
tools/mock-gps-sim/public/style.css
Normal file
278
tools/mock-gps-sim/public/style.css
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", "PingFang SC", sans-serif;
|
||||||
|
background: #edf3ea;
|
||||||
|
color: #163126;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 400px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(250, 252, 248, 0.96);
|
||||||
|
border-right: 1px solid rgba(22, 49, 38, 0.08);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__header h1 {
|
||||||
|
margin: 8px 0 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__eyebrow {
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #557266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--muted {
|
||||||
|
background: #e5ece5;
|
||||||
|
color: #4f6458;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--ok {
|
||||||
|
background: #d8f7e3;
|
||||||
|
color: #0a7a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 10px 30px rgba(34, 63, 49, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #5d786c;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group__status {
|
||||||
|
min-height: 18px;
|
||||||
|
margin: -4px 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #5e786d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ebf0ea;
|
||||||
|
color: #193226;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, background 120ms ease, color 120ms ease, opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: #103f2f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--accent {
|
||||||
|
background: #0ea5a4;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.is-active {
|
||||||
|
outline: 2px solid #ffb300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.56;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #557266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid rgba(22, 49, 38, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--check {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #678276;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint--ok {
|
||||||
|
color: #0a7a3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint--warn {
|
||||||
|
color: #8d4b08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(22, 49, 38, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat span {
|
||||||
|
color: #668073;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log {
|
||||||
|
min-height: 140px;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f3f7f1;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #486257;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-chip {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef6ea;
|
||||||
|
color: #244132;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-chip--start {
|
||||||
|
background: #fff0c9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-chip--finish {
|
||||||
|
background: #ffe2b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-container {
|
||||||
|
background: #dfeadb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-marker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-marker__control {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid #cc0077;
|
||||||
|
color: #cc0077;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-marker__start {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 16px solid transparent;
|
||||||
|
border-right: 16px solid transparent;
|
||||||
|
border-bottom: 28px solid #cc0077;
|
||||||
|
filter: drop-shadow(0 3px 10px rgba(22, 49, 38, 0.22));
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-marker__finish {
|
||||||
|
position: relative;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 4px solid #cc0077;
|
||||||
|
background: rgba(255, 255, 255, 0.76);
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-marker__finish::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 3px solid #cc0077;
|
||||||
|
}
|
||||||
152
tools/mock-gps-sim/server.js
Normal file
152
tools/mock-gps-sim/server.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
const http = require('http')
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const { WebSocketServer } = require('ws')
|
||||||
|
|
||||||
|
const HOST = '0.0.0.0'
|
||||||
|
const PORT = 17865
|
||||||
|
const WS_PATH = '/mock-gps'
|
||||||
|
const PROXY_PATH = '/proxy'
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, 'public')
|
||||||
|
|
||||||
|
function getContentType(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase()
|
||||||
|
if (ext === '.html') {
|
||||||
|
return 'text/html; charset=utf-8'
|
||||||
|
}
|
||||||
|
if (ext === '.css') {
|
||||||
|
return 'text/css; charset=utf-8'
|
||||||
|
}
|
||||||
|
if (ext === '.js') {
|
||||||
|
return 'application/javascript; charset=utf-8'
|
||||||
|
}
|
||||||
|
if (ext === '.json') {
|
||||||
|
return 'application/json; charset=utf-8'
|
||||||
|
}
|
||||||
|
if (ext === '.svg') {
|
||||||
|
return 'image/svg+xml'
|
||||||
|
}
|
||||||
|
return 'text/plain; charset=utf-8'
|
||||||
|
}
|
||||||
|
|
||||||
|
function serveStatic(requestPath, response) {
|
||||||
|
const safePath = requestPath === '/' ? '/index.html' : requestPath
|
||||||
|
const resolvedPath = path.normalize(path.join(PUBLIC_DIR, safePath))
|
||||||
|
if (!resolvedPath.startsWith(PUBLIC_DIR)) {
|
||||||
|
response.writeHead(403)
|
||||||
|
response.end('Forbidden')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.readFile(resolvedPath, (error, content) => {
|
||||||
|
if (error) {
|
||||||
|
response.writeHead(404)
|
||||||
|
response.end('Not Found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.writeHead(200, {
|
||||||
|
'Content-Type': getContentType(resolvedPath),
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
})
|
||||||
|
response.end(content)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMockGpsPayload(payload) {
|
||||||
|
return payload
|
||||||
|
&& payload.type === 'mock_gps'
|
||||||
|
&& Number.isFinite(payload.lat)
|
||||||
|
&& Number.isFinite(payload.lon)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProxyRequest(request, response) {
|
||||||
|
const requestUrl = new URL(request.url || '/', `http://127.0.0.1:${PORT}`)
|
||||||
|
const targetUrl = requestUrl.searchParams.get('url')
|
||||||
|
if (!targetUrl) {
|
||||||
|
response.writeHead(400, {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
})
|
||||||
|
response.end('Missing url')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const upstream = await fetch(targetUrl)
|
||||||
|
const body = Buffer.from(await upstream.arrayBuffer())
|
||||||
|
response.writeHead(upstream.status, {
|
||||||
|
'Content-Type': upstream.headers.get('content-type') || 'application/octet-stream',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
})
|
||||||
|
response.end(body)
|
||||||
|
} catch (error) {
|
||||||
|
response.writeHead(502, {
|
||||||
|
'Content-Type': 'text/plain; charset=utf-8',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
})
|
||||||
|
response.end(error && error.message ? error.message : 'Proxy request failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
if ((request.url || '').startsWith(PROXY_PATH)) {
|
||||||
|
handleProxyRequest(request, response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serveStatic(request.url || '/', response)
|
||||||
|
})
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ noServer: true })
|
||||||
|
|
||||||
|
wss.on('connection', (socket) => {
|
||||||
|
socket.on('message', (rawMessage) => {
|
||||||
|
const text = String(rawMessage)
|
||||||
|
let parsed
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text)
|
||||||
|
} catch (_error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isMockGpsPayload(parsed)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = JSON.stringify({
|
||||||
|
type: 'mock_gps',
|
||||||
|
timestamp: Number.isFinite(parsed.timestamp) ? parsed.timestamp : Date.now(),
|
||||||
|
lat: Number(parsed.lat),
|
||||||
|
lon: Number(parsed.lon),
|
||||||
|
accuracyMeters: Number.isFinite(parsed.accuracyMeters) ? Number(parsed.accuracyMeters) : 6,
|
||||||
|
speedMps: Number.isFinite(parsed.speedMps) ? Number(parsed.speedMps) : 0,
|
||||||
|
headingDeg: Number.isFinite(parsed.headingDeg) ? Number(parsed.headingDeg) : 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
wss.clients.forEach((client) => {
|
||||||
|
if (client.readyState === client.OPEN) {
|
||||||
|
client.send(serialized)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.on('upgrade', (request, socket, head) => {
|
||||||
|
if (!request.url || !request.url.startsWith(WS_PATH)) {
|
||||||
|
socket.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
wss.emit('connection', ws, request)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`Mock GPS simulator running:`)
|
||||||
|
console.log(` UI: http://127.0.0.1:${PORT}/`)
|
||||||
|
console.log(` WS: ws://127.0.0.1:${PORT}${WS_PATH}`)
|
||||||
|
console.log(` Proxy: http://127.0.0.1:${PORT}${PROXY_PATH}?url=<remote-url>`)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user