|
|
@@ -1,5 +1,8 @@
|
|
|
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
|
|
+import { AccelerometerController } from '../sensor/accelerometerController'
|
|
|
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
|
|
+import { DeviceMotionController } from '../sensor/deviceMotionController'
|
|
|
+import { GyroscopeController } from '../sensor/gyroscopeController'
|
|
|
import { type HeartRateDiscoveredDevice } from '../sensor/heartRateController'
|
|
|
import { HeartRateInputController } from '../sensor/heartRateInputController'
|
|
|
import { LocationController } from '../sensor/locationController'
|
|
|
@@ -98,6 +101,12 @@ export interface MapEngineViewState {
|
|
|
orientationMode: OrientationMode
|
|
|
orientationModeText: string
|
|
|
sensorHeadingText: string
|
|
|
+ deviceHeadingText: string
|
|
|
+ devicePoseText: string
|
|
|
+ headingConfidenceText: string
|
|
|
+ accelerometerText: string
|
|
|
+ gyroscopeText: string
|
|
|
+ deviceMotionText: string
|
|
|
compassDeclinationText: string
|
|
|
northReferenceButtonText: string
|
|
|
autoRotateSourceText: string
|
|
|
@@ -231,6 +240,12 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|
|
'orientationMode',
|
|
|
'orientationModeText',
|
|
|
'sensorHeadingText',
|
|
|
+ 'deviceHeadingText',
|
|
|
+ 'devicePoseText',
|
|
|
+ 'headingConfidenceText',
|
|
|
+ 'accelerometerText',
|
|
|
+ 'gyroscopeText',
|
|
|
+ 'deviceMotionText',
|
|
|
'compassDeclinationText',
|
|
|
'northReferenceButtonText',
|
|
|
'autoRotateSourceText',
|
|
|
@@ -386,6 +401,61 @@ function formatHeadingText(headingDeg: number | null): string {
|
|
|
return `${Math.round(normalizeRotationDeg(headingDeg))}掳`
|
|
|
}
|
|
|
|
|
|
+function formatDevicePoseText(pose: 'upright' | 'tilted' | 'flat'): string {
|
|
|
+ if (pose === 'flat') {
|
|
|
+ return '平放'
|
|
|
+ }
|
|
|
+
|
|
|
+ if (pose === 'tilted') {
|
|
|
+ return '倾斜'
|
|
|
+ }
|
|
|
+
|
|
|
+ return '竖持'
|
|
|
+}
|
|
|
+
|
|
|
+function formatHeadingConfidenceText(confidence: 'low' | 'medium' | 'high'): string {
|
|
|
+ if (confidence === 'high') {
|
|
|
+ return '高'
|
|
|
+ }
|
|
|
+
|
|
|
+ if (confidence === 'medium') {
|
|
|
+ return '中'
|
|
|
+ }
|
|
|
+
|
|
|
+ return '低'
|
|
|
+}
|
|
|
+
|
|
|
+function formatClockTime(timestamp: number | null): string {
|
|
|
+ if (!timestamp || !Number.isFinite(timestamp)) {
|
|
|
+ return '--:--:--'
|
|
|
+ }
|
|
|
+
|
|
|
+ const date = new Date(timestamp)
|
|
|
+ const hh = String(date.getHours()).padStart(2, '0')
|
|
|
+ const mm = String(date.getMinutes()).padStart(2, '0')
|
|
|
+ const ss = String(date.getSeconds()).padStart(2, '0')
|
|
|
+ return `${hh}:${mm}:${ss}`
|
|
|
+}
|
|
|
+
|
|
|
+function formatGyroscopeText(gyroscope: { x: number; y: number; z: number } | null): string {
|
|
|
+ if (!gyroscope) {
|
|
|
+ return '--'
|
|
|
+ }
|
|
|
+
|
|
|
+ return `x:${gyroscope.x.toFixed(2)} y:${gyroscope.y.toFixed(2)} z:${gyroscope.z.toFixed(2)}`
|
|
|
+}
|
|
|
+
|
|
|
+function formatDeviceMotionText(motion: { alpha: number | null; beta: number | null; gamma: number | null } | null): string {
|
|
|
+ if (!motion) {
|
|
|
+ return '--'
|
|
|
+ }
|
|
|
+
|
|
|
+ const alphaDeg = motion.alpha === null ? '--' : Math.round(normalizeRotationDeg(360 - motion.alpha * 180 / Math.PI))
|
|
|
+ const betaDeg = motion.beta === null ? '--' : Math.round(motion.beta * 180 / Math.PI)
|
|
|
+ const gammaDeg = motion.gamma === null ? '--' : Math.round(motion.gamma * 180 / Math.PI)
|
|
|
+ return `a:${alphaDeg} b:${betaDeg} g:${gammaDeg}`
|
|
|
+}
|
|
|
+
|
|
|
function formatOrientationModeText(mode: OrientationMode): string {
|
|
|
if (mode === 'north-up') {
|
|
|
return 'North Up'
|
|
|
@@ -589,12 +659,16 @@ function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
|
|
|
export class MapEngine {
|
|
|
buildVersion: string
|
|
|
renderer: WebGLMapRenderer
|
|
|
+ accelerometerController: AccelerometerController
|
|
|
compassController: CompassHeadingController
|
|
|
+ gyroscopeController: GyroscopeController
|
|
|
+ deviceMotionController: DeviceMotionController
|
|
|
locationController: LocationController
|
|
|
heartRateController: HeartRateInputController
|
|
|
feedbackDirector: FeedbackDirector
|
|
|
onData: (patch: Partial<MapEngineViewState>) => void
|
|
|
state: MapEngineViewState
|
|
|
+ accelerometerErrorText: string | null
|
|
|
previewScale: number
|
|
|
previewOriginX: number
|
|
|
previewOriginY: number
|
|
|
@@ -669,6 +743,7 @@ export class MapEngine {
|
|
|
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
|
|
this.buildVersion = buildVersion
|
|
|
this.onData = callbacks.onData
|
|
|
+ this.accelerometerErrorText = null
|
|
|
this.renderer = new WebGLMapRenderer(
|
|
|
(stats) => {
|
|
|
this.applyStats(stats)
|
|
|
@@ -679,6 +754,26 @@ export class MapEngine {
|
|
|
})
|
|
|
},
|
|
|
)
|
|
|
+ this.accelerometerController = new AccelerometerController({
|
|
|
+ onSample: (x, y, z) => {
|
|
|
+ this.accelerometerErrorText = null
|
|
|
+ this.telemetryRuntime.dispatch({
|
|
|
+ type: 'accelerometer_updated',
|
|
|
+ at: Date.now(),
|
|
|
+ x,
|
|
|
+ y,
|
|
|
+ z,
|
|
|
+ })
|
|
|
+ this.setState(this.getTelemetrySensorViewPatch(), true)
|
|
|
+ },
|
|
|
+ onError: (message) => {
|
|
|
+ this.accelerometerErrorText = `不可用: ${message}`
|
|
|
+ this.setState({
|
|
|
+ ...this.getTelemetrySensorViewPatch(),
|
|
|
+ statusText: `加速度计启动失败 (${this.buildVersion})`,
|
|
|
+ }, true)
|
|
|
+ },
|
|
|
+ })
|
|
|
this.compassController = new CompassHeadingController({
|
|
|
onHeading: (headingDeg) => {
|
|
|
this.handleCompassHeading(headingDeg)
|
|
|
@@ -687,6 +782,43 @@ export class MapEngine {
|
|
|
this.handleCompassError(message)
|
|
|
},
|
|
|
})
|
|
|
+ this.gyroscopeController = new GyroscopeController({
|
|
|
+ onSample: (x, y, z) => {
|
|
|
+ this.telemetryRuntime.dispatch({
|
|
|
+ type: 'gyroscope_updated',
|
|
|
+ at: Date.now(),
|
|
|
+ x,
|
|
|
+ y,
|
|
|
+ z,
|
|
|
+ })
|
|
|
+ this.setState(this.getTelemetrySensorViewPatch(), true)
|
|
|
+ },
|
|
|
+ onError: () => {
|
|
|
+ this.setState(this.getTelemetrySensorViewPatch(), true)
|
|
|
+ },
|
|
|
+ })
|
|
|
+ this.deviceMotionController = new DeviceMotionController({
|
|
|
+ onSample: (alpha, beta, gamma) => {
|
|
|
+ this.telemetryRuntime.dispatch({
|
|
|
+ type: 'device_motion_updated',
|
|
|
+ at: Date.now(),
|
|
|
+ alpha,
|
|
|
+ beta,
|
|
|
+ gamma,
|
|
|
+ })
|
|
|
+ this.setState({
|
|
|
+ ...this.getTelemetrySensorViewPatch(),
|
|
|
+ autoRotateSourceText: this.getAutoRotateSourceText(),
|
|
|
+ }, true)
|
|
|
+
|
|
|
+ if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
|
|
+ this.scheduleAutoRotate()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onError: () => {
|
|
|
+ this.setState(this.getTelemetrySensorViewPatch(), true)
|
|
|
+ },
|
|
|
+ })
|
|
|
this.locationController = new LocationController({
|
|
|
onLocation: (update) => {
|
|
|
this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
|
|
|
@@ -851,6 +983,12 @@ export class MapEngine {
|
|
|
orientationMode: 'manual',
|
|
|
orientationModeText: formatOrientationModeText('manual'),
|
|
|
sensorHeadingText: '--',
|
|
|
+ deviceHeadingText: '--',
|
|
|
+ devicePoseText: '竖持',
|
|
|
+ headingConfidenceText: '低',
|
|
|
+ accelerometerText: '未启用',
|
|
|
+ gyroscopeText: '--',
|
|
|
+ deviceMotionText: '--',
|
|
|
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
|
|
|
northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
|
|
|
autoRotateSourceText: formatAutoRotateSourceText('smart', false),
|
|
|
@@ -1019,6 +1157,9 @@ export class MapEngine {
|
|
|
{ label: '定位源', value: this.state.locationSourceText || '--' },
|
|
|
{ label: '当前位置', value: this.state.gpsCoordText || '--' },
|
|
|
{ label: 'GPS精度', value: telemetryState.lastGpsAccuracyMeters == null ? '--' : `${telemetryState.lastGpsAccuracyMeters.toFixed(1)}m` },
|
|
|
+ { label: '设备朝向', value: this.state.deviceHeadingText || '--' },
|
|
|
+ { label: '设备姿态', value: this.state.devicePoseText || '--' },
|
|
|
+ { label: '朝向可信度', value: this.state.headingConfidenceText || '--' },
|
|
|
{ label: '目标距离', value: `${telemetryPresentation.distanceToTargetValueText}${telemetryPresentation.distanceToTargetUnitText}` || '--' },
|
|
|
{ label: '当前速度', value: `${telemetryPresentation.speedText} km/h` },
|
|
|
{ label: '心率源', value: this.state.heartRateSourceText || '--' },
|
|
|
@@ -1056,7 +1197,10 @@ export class MapEngine {
|
|
|
this.clearMapPulseTimer()
|
|
|
this.clearStageFxTimer()
|
|
|
this.clearSessionTimerInterval()
|
|
|
+ this.accelerometerController.destroy()
|
|
|
this.compassController.destroy()
|
|
|
+ this.gyroscopeController.destroy()
|
|
|
+ this.deviceMotionController.destroy()
|
|
|
this.locationController.destroy()
|
|
|
this.heartRateController.destroy()
|
|
|
this.feedbackDirector.destroy()
|
|
|
@@ -1172,6 +1316,24 @@ export class MapEngine {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ getTelemetrySensorViewPatch(): Partial<MapEngineViewState> {
|
|
|
+ const telemetryState = this.telemetryRuntime.state
|
|
|
+ return {
|
|
|
+ deviceHeadingText: formatHeadingText(
|
|
|
+ telemetryState.deviceHeadingDeg === null
|
|
|
+ ? null
|
|
|
+ : getCompassReferenceHeadingDeg(this.northReferenceMode, telemetryState.deviceHeadingDeg),
|
|
|
+ ),
|
|
|
+ devicePoseText: formatDevicePoseText(telemetryState.devicePose),
|
|
|
+ headingConfidenceText: formatHeadingConfidenceText(telemetryState.headingConfidence),
|
|
|
+ accelerometerText: telemetryState.accelerometer
|
|
|
+ ? `#${telemetryState.accelerometerSampleCount} ${formatClockTime(telemetryState.accelerometerUpdatedAt)} x:${telemetryState.accelerometer.x.toFixed(3)} y:${telemetryState.accelerometer.y.toFixed(3)} z:${telemetryState.accelerometer.z.toFixed(3)}`
|
|
|
+ : '未启用',
|
|
|
+ gyroscopeText: formatGyroscopeText(telemetryState.gyroscope),
|
|
|
+ deviceMotionText: formatDeviceMotionText(telemetryState.deviceMotion),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
getGameModeText(): string {
|
|
|
return this.gameMode === 'score-o' ? '积分赛' : '顺序赛'
|
|
|
}
|
|
|
@@ -1930,6 +2092,10 @@ export class MapEngine {
|
|
|
}
|
|
|
|
|
|
attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
|
|
|
+ if (this.mounted) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
this.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode)
|
|
|
this.mounted = true
|
|
|
this.state.mapReady = true
|
|
|
@@ -1940,7 +2106,10 @@ export class MapEngine {
|
|
|
statusText: `单 WebGL 管线已完成,可切换手动或自动朝向 (${this.buildVersion})`,
|
|
|
})
|
|
|
this.syncRenderer()
|
|
|
+ this.accelerometerErrorText = null
|
|
|
this.compassController.start()
|
|
|
+ this.gyroscopeController.start()
|
|
|
+ this.deviceMotionController.start()
|
|
|
}
|
|
|
|
|
|
applyRemoteMapConfig(config: RemoteMapConfig): void {
|
|
|
@@ -2507,6 +2676,7 @@ export class MapEngine {
|
|
|
|
|
|
this.setState({
|
|
|
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
|
|
+ ...this.getTelemetrySensorViewPatch(),
|
|
|
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
|
|
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
|
|
autoRotateSourceText: this.getAutoRotateSourceText(),
|
|
|
@@ -2554,6 +2724,7 @@ export class MapEngine {
|
|
|
rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
|
|
|
northReferenceText: formatNorthReferenceText(nextMode),
|
|
|
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
|
|
+ ...this.getTelemetrySensorViewPatch(),
|
|
|
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
|
|
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
|
|
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
|
|
|
@@ -2572,6 +2743,7 @@ export class MapEngine {
|
|
|
this.setState({
|
|
|
northReferenceText: formatNorthReferenceText(nextMode),
|
|
|
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
|
|
+ ...this.getTelemetrySensorViewPatch(),
|
|
|
compassDeclinationText: formatCompassDeclinationText(nextMode),
|
|
|
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
|
|
|
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
|
|
|
@@ -2624,10 +2796,14 @@ export class MapEngine {
|
|
|
return null
|
|
|
}
|
|
|
|
|
|
- getSmartAutoRotateHeadingDeg(): number | null {
|
|
|
- const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
|
|
|
+ getPreferredSensorHeadingDeg(): number | null {
|
|
|
+ return this.smoothedSensorHeadingDeg === null
|
|
|
? null
|
|
|
: getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
|
|
+ }
|
|
|
+
|
|
|
+ getSmartAutoRotateHeadingDeg(): number | null {
|
|
|
+ const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
|
|
|
const movementHeadingDeg = this.getMovementHeadingDeg()
|
|
|
const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
|
|
|
const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null)
|
|
|
@@ -2661,9 +2837,7 @@ export class MapEngine {
|
|
|
return this.getSmartAutoRotateHeadingDeg()
|
|
|
}
|
|
|
|
|
|
- const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
|
|
|
- ? null
|
|
|
- : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
|
|
+ const sensorHeadingDeg = this.getPreferredSensorHeadingDeg()
|
|
|
const courseHeadingDeg = this.courseHeadingDeg === null
|
|
|
? null
|
|
|
: getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)
|