|
|
@@ -54,6 +54,10 @@ const AUTO_ROTATE_DEADZONE_DEG = 4
|
|
|
const AUTO_ROTATE_MAX_STEP_DEG = 0.75
|
|
|
const AUTO_ROTATE_HEADING_SMOOTHING = 0.32
|
|
|
const COMPASS_NEEDLE_SMOOTHING = 0.12
|
|
|
+const SMART_HEADING_BLEND_START_SPEED_KMH = 1.2
|
|
|
+const SMART_HEADING_MOVEMENT_SPEED_KMH = 3.0
|
|
|
+const SMART_HEADING_MIN_DISTANCE_METERS = 8
|
|
|
+const SMART_HEADING_MAX_ACCURACY_METERS = 25
|
|
|
const GPS_TRACK_MAX_POINTS = 200
|
|
|
const GPS_TRACK_MIN_STEP_METERS = 3
|
|
|
const MAP_TAP_MOVE_THRESHOLD_PX = 14
|
|
|
@@ -64,7 +68,8 @@ type TouchPoint = WechatMiniprogram.TouchDetail
|
|
|
type GestureMode = 'idle' | 'pan' | 'pinch'
|
|
|
type RotationMode = 'manual' | 'auto'
|
|
|
type OrientationMode = 'manual' | 'north-up' | 'heading-up'
|
|
|
-type AutoRotateSourceMode = 'sensor' | 'course' | 'fusion'
|
|
|
+type AutoRotateSourceMode = 'sensor' | 'course' | 'fusion' | 'smart'
|
|
|
+type SmartHeadingSource = 'sensor' | 'blended' | 'movement'
|
|
|
type NorthReferenceMode = 'magnetic' | 'true'
|
|
|
|
|
|
const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic'
|
|
|
@@ -122,6 +127,8 @@ export interface MapEngineViewState {
|
|
|
statusText: string
|
|
|
gpsTracking: boolean
|
|
|
gpsTrackingText: string
|
|
|
+ gpsLockEnabled: boolean
|
|
|
+ gpsLockAvailable: boolean
|
|
|
locationSourceMode: 'real' | 'mock'
|
|
|
locationSourceText: string
|
|
|
mockBridgeConnected: boolean
|
|
|
@@ -244,6 +251,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
|
|
'statusText',
|
|
|
'gpsTracking',
|
|
|
'gpsTrackingText',
|
|
|
+ 'gpsLockEnabled',
|
|
|
+ 'gpsLockAvailable',
|
|
|
'locationSourceMode',
|
|
|
'locationSourceText',
|
|
|
'mockBridgeConnected',
|
|
|
@@ -406,6 +415,10 @@ function formatRotationToggleText(mode: OrientationMode): string {
|
|
|
}
|
|
|
|
|
|
function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
|
|
|
+ if (mode === 'smart') {
|
|
|
+ return 'Smart / 手机朝向'
|
|
|
+ }
|
|
|
+
|
|
|
if (mode === 'sensor') {
|
|
|
return 'Sensor Only'
|
|
|
}
|
|
|
@@ -417,6 +430,18 @@ function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading
|
|
|
return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
|
|
|
}
|
|
|
|
|
|
+function formatSmartHeadingSourceText(source: SmartHeadingSource): string {
|
|
|
+ if (source === 'movement') {
|
|
|
+ return 'Smart / 前进方向'
|
|
|
+ }
|
|
|
+
|
|
|
+ if (source === 'blended') {
|
|
|
+ return 'Smart / 融合'
|
|
|
+ }
|
|
|
+
|
|
|
+ return 'Smart / 手机朝向'
|
|
|
+}
|
|
|
+
|
|
|
function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string {
|
|
|
if (pending) {
|
|
|
return 'Pending'
|
|
|
@@ -539,6 +564,18 @@ function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
|
|
|
return Math.sqrt(dx * dx + dy * dy)
|
|
|
}
|
|
|
|
|
|
+function resolveSmartHeadingSource(speedKmh: number | null, movementReliable: boolean): SmartHeadingSource {
|
|
|
+ if (!movementReliable || speedKmh === null || !Number.isFinite(speedKmh) || speedKmh <= SMART_HEADING_BLEND_START_SPEED_KMH) {
|
|
|
+ return 'sensor'
|
|
|
+ }
|
|
|
+
|
|
|
+ if (speedKmh >= SMART_HEADING_MOVEMENT_SPEED_KMH) {
|
|
|
+ return 'movement'
|
|
|
+ }
|
|
|
+
|
|
|
+ return 'blended'
|
|
|
+}
|
|
|
+
|
|
|
function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
|
|
|
const fromLatRad = from.lat * Math.PI / 180
|
|
|
const toLatRad = to.lat * Math.PI / 180
|
|
|
@@ -601,6 +638,7 @@ export class MapEngine {
|
|
|
currentGpsPoint: LonLatPoint | null
|
|
|
currentGpsTrack: LonLatPoint[]
|
|
|
currentGpsAccuracyMeters: number | null
|
|
|
+ currentGpsInsideMap: boolean
|
|
|
courseData: OrienteeringCourseData | null
|
|
|
courseOverlayVisible: boolean
|
|
|
cpRadiusMeters: number
|
|
|
@@ -626,6 +664,7 @@ export class MapEngine {
|
|
|
stageFxTimer: number
|
|
|
sessionTimerInterval: number
|
|
|
hasGpsCenteredOnce: boolean
|
|
|
+ gpsLockEnabled: boolean
|
|
|
|
|
|
constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
|
|
|
this.buildVersion = buildVersion
|
|
|
@@ -767,6 +806,7 @@ export class MapEngine {
|
|
|
this.currentGpsPoint = null
|
|
|
this.currentGpsTrack = []
|
|
|
this.currentGpsAccuracyMeters = null
|
|
|
+ this.currentGpsInsideMap = false
|
|
|
this.courseData = null
|
|
|
this.courseOverlayVisible = false
|
|
|
this.cpRadiusMeters = 5
|
|
|
@@ -787,6 +827,7 @@ export class MapEngine {
|
|
|
this.skipRadiusMeters = 30
|
|
|
this.skipRequiresConfirm = true
|
|
|
this.autoFinishOnLastControl = true
|
|
|
+ this.gpsLockEnabled = false
|
|
|
this.punchFeedbackTimer = 0
|
|
|
this.contentCardTimer = 0
|
|
|
this.mapPulseTimer = 0
|
|
|
@@ -812,7 +853,7 @@ export class MapEngine {
|
|
|
sensorHeadingText: '--',
|
|
|
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
|
|
|
northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
|
|
|
- autoRotateSourceText: formatAutoRotateSourceText('sensor', false),
|
|
|
+ autoRotateSourceText: formatAutoRotateSourceText('smart', false),
|
|
|
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
|
|
|
northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
|
|
|
compassNeedleDeg: 0,
|
|
|
@@ -839,6 +880,8 @@ export class MapEngine {
|
|
|
statusText: `单 WebGL 管线已就绪,等待传感器接入 (${this.buildVersion})`,
|
|
|
gpsTracking: false,
|
|
|
gpsTrackingText: '持续定位待启动',
|
|
|
+ gpsLockEnabled: false,
|
|
|
+ gpsLockAvailable: false,
|
|
|
locationSourceMode: 'real',
|
|
|
locationSourceText: '真实定位',
|
|
|
mockBridgeConnected: false,
|
|
|
@@ -932,7 +975,7 @@ export class MapEngine {
|
|
|
this.autoRotateHeadingDeg = null
|
|
|
this.courseHeadingDeg = null
|
|
|
this.targetAutoRotationDeg = null
|
|
|
- this.autoRotateSourceMode = 'sensor'
|
|
|
+ this.autoRotateSourceMode = 'smart'
|
|
|
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
|
|
|
this.autoRotateCalibrationPending = false
|
|
|
}
|
|
|
@@ -1052,6 +1095,7 @@ export class MapEngine {
|
|
|
this.currentGpsPoint = null
|
|
|
this.currentGpsTrack = []
|
|
|
this.currentGpsAccuracyMeters = null
|
|
|
+ this.currentGpsInsideMap = false
|
|
|
this.courseOverlayVisible = false
|
|
|
this.setCourseHeading(null)
|
|
|
}
|
|
|
@@ -1104,6 +1148,8 @@ export class MapEngine {
|
|
|
const debugState = this.locationController.getDebugState()
|
|
|
return {
|
|
|
gpsTracking: debugState.listening,
|
|
|
+ gpsLockEnabled: this.gpsLockEnabled,
|
|
|
+ gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
|
|
|
locationSourceMode: debugState.sourceMode,
|
|
|
locationSourceText: debugState.sourceModeText,
|
|
|
mockBridgeConnected: debugState.mockBridgeConnected,
|
|
|
@@ -1224,6 +1270,8 @@ export class MapEngine {
|
|
|
punchButtonEnabled: this.gamePresentation.hud.punchButtonEnabled,
|
|
|
skipButtonEnabled: this.isSkipAvailable(),
|
|
|
punchHintText: this.gamePresentation.hud.punchHintText,
|
|
|
+ gpsLockEnabled: this.gpsLockEnabled,
|
|
|
+ gpsLockAvailable: !!this.currentGpsPoint && this.currentGpsInsideMap,
|
|
|
}
|
|
|
|
|
|
if (statusText) {
|
|
|
@@ -1510,15 +1558,21 @@ export class MapEngine {
|
|
|
}
|
|
|
|
|
|
const startedAt = Date.now()
|
|
|
- let gameResult = this.gameRuntime.startSession(startedAt)
|
|
|
+ const startResult = this.gameRuntime.startSession(startedAt)
|
|
|
+ let gameResult = startResult
|
|
|
if (this.currentGpsPoint) {
|
|
|
- gameResult = this.gameRuntime.dispatch({
|
|
|
+ const gpsResult = this.gameRuntime.dispatch({
|
|
|
type: 'gps_updated',
|
|
|
at: Date.now(),
|
|
|
lon: this.currentGpsPoint.lon,
|
|
|
lat: this.currentGpsPoint.lat,
|
|
|
accuracyMeters: this.currentGpsAccuracyMeters,
|
|
|
})
|
|
|
+ gameResult = {
|
|
|
+ nextState: gpsResult.nextState,
|
|
|
+ presentation: gpsResult.presentation,
|
|
|
+ effects: [...startResult.effects, ...gpsResult.effects],
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
this.courseOverlayVisible = true
|
|
|
@@ -1534,6 +1588,7 @@ export class MapEngine {
|
|
|
if (!this.courseData) {
|
|
|
this.clearGameRuntime()
|
|
|
this.resetTransientGameUiState()
|
|
|
+ this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
|
|
|
this.setState({
|
|
|
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
|
|
|
}, true)
|
|
|
@@ -1543,6 +1598,7 @@ export class MapEngine {
|
|
|
|
|
|
this.loadGameDefinitionFromCourse()
|
|
|
this.resetTransientGameUiState()
|
|
|
+ this.feedbackDirector.handleEffects([{ type: 'session_cancelled' }])
|
|
|
this.setState({
|
|
|
...this.getGameViewPatch(`已退出当前对局 (${this.buildVersion})`),
|
|
|
}, true)
|
|
|
@@ -1572,8 +1628,14 @@ export class MapEngine {
|
|
|
const gpsTileX = Math.floor(gpsWorldPoint.x)
|
|
|
const gpsTileY = Math.floor(gpsWorldPoint.y)
|
|
|
const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
|
|
|
+ this.currentGpsInsideMap = gpsInsideMap
|
|
|
let gameStatusText: string | null = null
|
|
|
|
|
|
+ if (!gpsInsideMap && this.gpsLockEnabled) {
|
|
|
+ this.gpsLockEnabled = false
|
|
|
+ gameStatusText = `GPS已超出地图范围,锁定已关闭 (${this.buildVersion})`
|
|
|
+ }
|
|
|
+
|
|
|
if (this.courseData) {
|
|
|
const eventAt = Date.now()
|
|
|
const gameResult = this.gameRuntime.dispatch({
|
|
|
@@ -1594,18 +1656,23 @@ export class MapEngine {
|
|
|
gameStatusText = this.resolveAppliedGameStatusText(gameResult)
|
|
|
}
|
|
|
|
|
|
- if (gpsInsideMap && !this.hasGpsCenteredOnce) {
|
|
|
+ if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
|
|
+ this.scheduleAutoRotate()
|
|
|
+ }
|
|
|
+
|
|
|
+ if (gpsInsideMap && (this.gpsLockEnabled || !this.hasGpsCenteredOnce)) {
|
|
|
this.hasGpsCenteredOnce = true
|
|
|
+ const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
|
|
|
this.commitViewport({
|
|
|
- centerTileX: gpsWorldPoint.x,
|
|
|
- centerTileY: gpsWorldPoint.y,
|
|
|
- tileTranslateX: 0,
|
|
|
- tileTranslateY: 0,
|
|
|
+ ...lockedViewport,
|
|
|
gpsTracking: true,
|
|
|
gpsTrackingText: '持续定位进行中',
|
|
|
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
|
|
+ autoRotateSourceText: this.getAutoRotateSourceText(),
|
|
|
+ gpsLockEnabled: this.gpsLockEnabled,
|
|
|
+ gpsLockAvailable: true,
|
|
|
...this.getGameViewPatch(),
|
|
|
- }, gameStatusText || `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true)
|
|
|
+ }, gameStatusText || (this.gpsLockEnabled ? `GPS锁定跟随中 (${this.buildVersion})` : `GPS定位成功,已定位到当前位置 (${this.buildVersion})`), true)
|
|
|
return
|
|
|
}
|
|
|
|
|
|
@@ -1613,11 +1680,62 @@ export class MapEngine {
|
|
|
gpsTracking: true,
|
|
|
gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
|
|
|
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
|
|
+ autoRotateSourceText: this.getAutoRotateSourceText(),
|
|
|
+ gpsLockEnabled: this.gpsLockEnabled,
|
|
|
+ gpsLockAvailable: gpsInsideMap,
|
|
|
...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
|
|
|
}, true)
|
|
|
this.syncRenderer()
|
|
|
}
|
|
|
|
|
|
+ handleToggleGpsLock(): void {
|
|
|
+ if (!this.currentGpsPoint || !this.currentGpsInsideMap) {
|
|
|
+ this.setState({
|
|
|
+ gpsLockEnabled: false,
|
|
|
+ gpsLockAvailable: false,
|
|
|
+ statusText: this.currentGpsPoint
|
|
|
+ ? `当前位置不在地图范围内,无法锁定 (${this.buildVersion})`
|
|
|
+ : `当前还没有可锁定的GPS位置 (${this.buildVersion})`,
|
|
|
+ }, true)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const nextEnabled = !this.gpsLockEnabled
|
|
|
+ this.gpsLockEnabled = nextEnabled
|
|
|
+
|
|
|
+ if (nextEnabled) {
|
|
|
+ const gpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
|
|
|
+ const gpsTileX = Math.floor(gpsWorldPoint.x)
|
|
|
+ const gpsTileY = Math.floor(gpsWorldPoint.y)
|
|
|
+ const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
|
|
|
+ if (gpsInsideMap) {
|
|
|
+ this.hasGpsCenteredOnce = true
|
|
|
+ const lockedViewport = this.resolveViewportForExactCenter(gpsWorldPoint.x, gpsWorldPoint.y)
|
|
|
+ this.commitViewport({
|
|
|
+ ...lockedViewport,
|
|
|
+ gpsLockEnabled: true,
|
|
|
+ gpsLockAvailable: true,
|
|
|
+ }, `GPS已锁定在屏幕中央 (${this.buildVersion})`, true)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ this.setState({
|
|
|
+ gpsLockEnabled: true,
|
|
|
+ gpsLockAvailable: true,
|
|
|
+ statusText: `GPS锁定已开启,等待进入地图范围 (${this.buildVersion})`,
|
|
|
+ }, true)
|
|
|
+ this.syncRenderer()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ this.setState({
|
|
|
+ gpsLockEnabled: false,
|
|
|
+ gpsLockAvailable: true,
|
|
|
+ statusText: `GPS锁定已关闭 (${this.buildVersion})`,
|
|
|
+ }, true)
|
|
|
+ this.syncRenderer()
|
|
|
+ }
|
|
|
+
|
|
|
handleToggleOsmReference(): void {
|
|
|
const nextEnabled = !this.state.osmReferenceEnabled
|
|
|
this.setState({
|
|
|
@@ -1906,13 +2024,17 @@ export class MapEngine {
|
|
|
this.panVelocityY = 0
|
|
|
|
|
|
if (event.touches.length >= 2) {
|
|
|
- const origin = this.getStagePoint(event.touches)
|
|
|
+ const origin = this.gpsLockEnabled
|
|
|
+ ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
|
|
|
+ : this.getStagePoint(event.touches)
|
|
|
this.gestureMode = 'pinch'
|
|
|
this.pinchStartDistance = this.getTouchDistance(event.touches)
|
|
|
this.pinchStartScale = this.previewScale || 1
|
|
|
this.pinchStartAngle = this.getTouchAngle(event.touches)
|
|
|
this.pinchStartRotationDeg = this.state.rotationDeg
|
|
|
- const anchorWorld = screenToWorld(this.getCameraState(), origin, true)
|
|
|
+ const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
|
|
|
+ ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
|
|
|
+ : screenToWorld(this.getCameraState(), origin, true)
|
|
|
this.pinchAnchorWorldX = anchorWorld.x
|
|
|
this.pinchAnchorWorldY = anchorWorld.y
|
|
|
this.setPreviewState(this.pinchStartScale, origin.x, origin.y)
|
|
|
@@ -1936,13 +2058,17 @@ export class MapEngine {
|
|
|
if (event.touches.length >= 2) {
|
|
|
const distance = this.getTouchDistance(event.touches)
|
|
|
const angle = this.getTouchAngle(event.touches)
|
|
|
- const origin = this.getStagePoint(event.touches)
|
|
|
+ const origin = this.gpsLockEnabled
|
|
|
+ ? { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2 }
|
|
|
+ : this.getStagePoint(event.touches)
|
|
|
if (!this.pinchStartDistance) {
|
|
|
this.pinchStartDistance = distance
|
|
|
this.pinchStartScale = this.previewScale || 1
|
|
|
this.pinchStartAngle = angle
|
|
|
this.pinchStartRotationDeg = this.state.rotationDeg
|
|
|
- const anchorWorld = screenToWorld(this.getCameraState(), origin, true)
|
|
|
+ const anchorWorld = this.gpsLockEnabled && this.currentGpsPoint
|
|
|
+ ? lonLatToWorldTile(this.currentGpsPoint, this.state.zoom)
|
|
|
+ : screenToWorld(this.getCameraState(), origin, true)
|
|
|
this.pinchAnchorWorldX = anchorWorld.x
|
|
|
this.pinchAnchorWorldY = anchorWorld.y
|
|
|
}
|
|
|
@@ -1992,6 +2118,12 @@ export class MapEngine {
|
|
|
this.panLastY = touch.pageY
|
|
|
this.panLastTimestamp = nextTimestamp
|
|
|
|
|
|
+ if (this.gpsLockEnabled) {
|
|
|
+ this.panVelocityX = 0
|
|
|
+ this.panVelocityY = 0
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
this.normalizeTranslate(
|
|
|
this.state.tileTranslateX + deltaX,
|
|
|
this.state.tileTranslateY + deltaY,
|
|
|
@@ -2011,8 +2143,8 @@ export class MapEngine {
|
|
|
if (this.gestureMode === 'pinch' && event.touches.length < 2) {
|
|
|
const gestureScale = this.previewScale || 1
|
|
|
const zoomDelta = Math.round(Math.log2(gestureScale))
|
|
|
- const originX = this.previewOriginX || this.state.stageWidth / 2
|
|
|
- const originY = this.previewOriginY || this.state.stageHeight / 2
|
|
|
+ const originX = this.gpsLockEnabled ? this.state.stageWidth / 2 : (this.previewOriginX || this.state.stageWidth / 2)
|
|
|
+ const originY = this.gpsLockEnabled ? this.state.stageHeight / 2 : (this.previewOriginY || this.state.stageHeight / 2)
|
|
|
|
|
|
if (zoomDelta) {
|
|
|
const residualScale = gestureScale / Math.pow(2, zoomDelta)
|
|
|
@@ -2350,6 +2482,7 @@ export class MapEngine {
|
|
|
rotationToggleText: formatRotationToggleText('heading-up'),
|
|
|
orientationMode: 'heading-up',
|
|
|
orientationModeText: formatOrientationModeText('heading-up'),
|
|
|
+ autoRotateSourceText: this.getAutoRotateSourceText(),
|
|
|
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
|
|
|
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
|
|
statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
|
|
|
@@ -2376,7 +2509,7 @@ export class MapEngine {
|
|
|
sensorHeadingText: formatHeadingText(compassHeadingDeg),
|
|
|
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
|
|
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
|
|
- autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
|
|
|
+ autoRotateSourceText: this.getAutoRotateSourceText(),
|
|
|
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
|
|
|
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
|
|
})
|
|
|
@@ -2454,7 +2587,7 @@ export class MapEngine {
|
|
|
setCourseHeading(headingDeg: number | null): void {
|
|
|
this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
|
|
|
this.setState({
|
|
|
- autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
|
|
|
+ autoRotateSourceText: this.getAutoRotateSourceText(),
|
|
|
})
|
|
|
|
|
|
if (this.refreshAutoRotateTarget()) {
|
|
|
@@ -2462,7 +2595,72 @@ export class MapEngine {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ getMovementHeadingDeg(): number | null {
|
|
|
+ if (!this.currentGpsInsideMap) {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.currentGpsAccuracyMeters !== null && this.currentGpsAccuracyMeters > SMART_HEADING_MAX_ACCURACY_METERS) {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.currentGpsTrack.length < 2) {
|
|
|
+ return null
|
|
|
+ }
|
|
|
+
|
|
|
+ const lastPoint = this.currentGpsTrack[this.currentGpsTrack.length - 1]
|
|
|
+ let accumulatedDistanceMeters = 0
|
|
|
+
|
|
|
+ for (let index = this.currentGpsTrack.length - 2; index >= 0; index -= 1) {
|
|
|
+ const nextPoint = this.currentGpsTrack[index + 1]
|
|
|
+ const point = this.currentGpsTrack[index]
|
|
|
+ accumulatedDistanceMeters += getApproxDistanceMeters(point, nextPoint)
|
|
|
+
|
|
|
+ if (accumulatedDistanceMeters >= SMART_HEADING_MIN_DISTANCE_METERS) {
|
|
|
+ return getInitialBearingDeg(point, lastPoint)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return null
|
|
|
+ }
|
|
|
+
|
|
|
+ getSmartAutoRotateHeadingDeg(): number | null {
|
|
|
+ const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
|
|
|
+ ? null
|
|
|
+ : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
|
|
+ const movementHeadingDeg = this.getMovementHeadingDeg()
|
|
|
+ const speedKmh = this.telemetryRuntime.state.currentSpeedKmh
|
|
|
+ const smartSource = resolveSmartHeadingSource(speedKmh, movementHeadingDeg !== null)
|
|
|
+
|
|
|
+ if (smartSource === 'movement') {
|
|
|
+ return movementHeadingDeg === null ? sensorHeadingDeg : movementHeadingDeg
|
|
|
+ }
|
|
|
+
|
|
|
+ if (smartSource === 'blended' && sensorHeadingDeg !== null && movementHeadingDeg !== null && speedKmh !== null) {
|
|
|
+ const blend = Math.max(0, Math.min(1, (speedKmh - SMART_HEADING_BLEND_START_SPEED_KMH) / (SMART_HEADING_MOVEMENT_SPEED_KMH - SMART_HEADING_BLEND_START_SPEED_KMH)))
|
|
|
+ return interpolateAngleDeg(sensorHeadingDeg, movementHeadingDeg, blend)
|
|
|
+ }
|
|
|
+
|
|
|
+ return sensorHeadingDeg === null ? movementHeadingDeg : sensorHeadingDeg
|
|
|
+ }
|
|
|
+
|
|
|
+ getAutoRotateSourceText(): string {
|
|
|
+ if (this.autoRotateSourceMode !== 'smart') {
|
|
|
+ return formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null)
|
|
|
+ }
|
|
|
+
|
|
|
+ const smartSource = resolveSmartHeadingSource(
|
|
|
+ this.telemetryRuntime.state.currentSpeedKmh,
|
|
|
+ this.getMovementHeadingDeg() !== null,
|
|
|
+ )
|
|
|
+ return formatSmartHeadingSourceText(smartSource)
|
|
|
+ }
|
|
|
+
|
|
|
resolveAutoRotateInputHeadingDeg(): number | null {
|
|
|
+ if (this.autoRotateSourceMode === 'smart') {
|
|
|
+ return this.getSmartAutoRotateHeadingDeg()
|
|
|
+ }
|
|
|
+
|
|
|
const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
|
|
|
? null
|
|
|
: getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
|
|
|
@@ -2976,6 +3174,26 @@ export class MapEngine {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
+ if (this.gpsLockEnabled && this.currentGpsPoint) {
|
|
|
+ const nextGpsWorldPoint = lonLatToWorldTile(this.currentGpsPoint, nextZoom)
|
|
|
+ const resolvedViewport = this.resolveViewportForExactCenter(nextGpsWorldPoint.x, nextGpsWorldPoint.y)
|
|
|
+ this.commitViewport(
|
|
|
+ {
|
|
|
+ zoom: nextZoom,
|
|
|
+ ...resolvedViewport,
|
|
|
+ },
|
|
|
+ `缩放级别调整到 ${nextZoom}`,
|
|
|
+ true,
|
|
|
+ () => {
|
|
|
+ this.setPreviewState(residualScale, this.state.stageWidth / 2, this.state.stageHeight / 2)
|
|
|
+ this.syncRenderer()
|
|
|
+ this.compassController.start()
|
|
|
+ this.animatePreviewToRest()
|
|
|
+ },
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
if (!this.state.stageWidth || !this.state.stageHeight) {
|
|
|
this.commitViewport(
|
|
|
{
|