import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera' import { CompassHeadingController } from '../sensor/compassHeadingController' import { WebGLMapRenderer } from '../renderer/webglMapRenderer' import { type MapRendererStats } from '../renderer/mapRenderer' import { worldTileToLonLat, type LonLatPoint } from '../../utils/projection' const RENDER_MODE = 'Single WebGL Pipeline' const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen' const MAP_NORTH_OFFSET_DEG = 0 const MIN_ZOOM = 15 const MAX_ZOOM = 20 const DEFAULT_ZOOM = 17 const DESIRED_VISIBLE_COLUMNS = 3 const OVERDRAW = 1 const DEFAULT_TOP_LEFT_TILE_X = 108132 const DEFAULT_TOP_LEFT_TILE_Y = 51199 const DEFAULT_CENTER_TILE_X = DEFAULT_TOP_LEFT_TILE_X + 1 const DEFAULT_CENTER_TILE_Y = DEFAULT_TOP_LEFT_TILE_Y + 1 const TILE_SOURCE = 'https://oss-mbh5.colormaprun.com/wxMap/lcx/{z}/{x}/{y}.png' const MIN_PREVIEW_SCALE = 0.55 const MAX_PREVIEW_SCALE = 1.85 const INERTIA_FRAME_MS = 16 const INERTIA_DECAY = 0.92 const INERTIA_MIN_SPEED = 0.02 const PREVIEW_RESET_DURATION_MS = 140 const UI_SYNC_INTERVAL_MS = 80 const ROTATE_STEP_DEG = 15 const AUTO_ROTATE_FRAME_MS = 10 const AUTO_ROTATE_EASE = 0.24 const AUTO_ROTATE_SNAP_DEG = 0.1 const AUTO_ROTATE_DEADZONE_DEG = 1.5 const AUTO_ROTATE_MAX_STEP_DEG = 0.9 const AUTO_ROTATE_HEADING_SMOOTHING = 0.32 const SAMPLE_TRACK_WGS84: LonLatPoint[] = [ worldTileToLonLat({ x: DEFAULT_CENTER_TILE_X - 0.72, y: DEFAULT_CENTER_TILE_Y + 0.44 }, DEFAULT_ZOOM), worldTileToLonLat({ x: DEFAULT_CENTER_TILE_X - 0.18, y: DEFAULT_CENTER_TILE_Y + 0.08 }, DEFAULT_ZOOM), worldTileToLonLat({ x: DEFAULT_CENTER_TILE_X + 0.22, y: DEFAULT_CENTER_TILE_Y - 0.16 }, DEFAULT_ZOOM), worldTileToLonLat({ x: DEFAULT_CENTER_TILE_X + 0.64, y: DEFAULT_CENTER_TILE_Y - 0.52 }, DEFAULT_ZOOM), ] const SAMPLE_GPS_WGS84: LonLatPoint = worldTileToLonLat( { x: DEFAULT_CENTER_TILE_X + 0.12, y: DEFAULT_CENTER_TILE_Y - 0.06 }, DEFAULT_ZOOM, ) 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' export interface MapEngineStageRect { width: number height: number left: number top: number } export interface MapEngineViewState { buildVersion: string renderMode: string projectionMode: string mapReady: boolean mapReadyText: string mapName: string zoom: number rotationDeg: number rotationText: string rotationMode: RotationMode rotationModeText: string rotationToggleText: string orientationMode: OrientationMode orientationModeText: string sensorHeadingText: string autoRotateSourceText: string autoRotateCalibrationText: string northReferenceText: string compassNeedleDeg: number centerTileX: number centerTileY: number centerText: string tileSource: string visibleColumnCount: number visibleTileCount: number readyTileCount: number memoryTileCount: number diskTileCount: number memoryHitCount: number diskHitCount: number networkFetchCount: number cacheHitRateText: string tileTranslateX: number tileTranslateY: number tileSizePx: number stageWidth: number stageHeight: number stageLeft: number stageTop: number statusText: string } export interface MapEngineCallbacks { onData: (patch: Partial) => void } const VIEW_SYNC_KEYS: Array = [ 'buildVersion', 'renderMode', 'projectionMode', 'mapReady', 'mapReadyText', 'mapName', 'zoom', 'rotationDeg', 'rotationText', 'rotationMode', 'rotationModeText', 'rotationToggleText', 'orientationMode', 'orientationModeText', 'sensorHeadingText', 'autoRotateSourceText', 'autoRotateCalibrationText', 'northReferenceText', 'compassNeedleDeg', 'centerText', 'tileSource', 'visibleTileCount', 'readyTileCount', 'memoryTileCount', 'diskTileCount', 'memoryHitCount', 'diskHitCount', 'networkFetchCount', 'cacheHitRateText', 'tileSizePx', 'statusText', ] function buildCenterText(zoom: number, x: number, y: number): string { return `z${zoom} / x${x} / y${y}` } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) } function normalizeRotationDeg(rotationDeg: number): number { const normalized = rotationDeg % 360 return normalized < 0 ? normalized + 360 : normalized } function normalizeAngleDeltaRad(angleDeltaRad: number): number { let normalized = angleDeltaRad while (normalized > Math.PI) { normalized -= Math.PI * 2 } while (normalized < -Math.PI) { normalized += Math.PI * 2 } return normalized } function normalizeAngleDeltaDeg(angleDeltaDeg: number): number { let normalized = angleDeltaDeg while (normalized > 180) { normalized -= 360 } while (normalized < -180) { normalized += 360 } return normalized } function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: number): number { return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor) } function formatRotationText(rotationDeg: number): string { return `${Math.round(normalizeRotationDeg(rotationDeg))}deg` } function formatHeadingText(headingDeg: number | null): string { if (headingDeg === null) { return '--' } return `${Math.round(normalizeRotationDeg(headingDeg))}deg` } function formatOrientationModeText(mode: OrientationMode): string { if (mode === 'north-up') { return 'North Up' } if (mode === 'heading-up') { return 'Heading Up' } return 'Manual Gesture' } function formatRotationModeText(mode: OrientationMode): string { return formatOrientationModeText(mode) } function formatRotationToggleText(mode: OrientationMode): string { if (mode === 'manual') { return '切到北朝上' } if (mode === 'north-up') { return '切到朝向朝上' } return '切到手动旋转' } function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string { if (mode === 'sensor') { return 'Sensor Only' } if (mode === 'course') { return hasCourseHeading ? 'Course Only' : 'Course Pending' } return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only' } function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string { if (pending) { return 'Pending' } if (offsetDeg === null) { return '--' } return `Offset ${Math.round(normalizeRotationDeg(offsetDeg))}deg` } function formatNorthReferenceText(): string { return 'Map North = 0deg (TFW aligned)' } function formatCompassNeedleDeg(headingDeg: number | null): number { if (headingDeg === null) { return 0 } return normalizeRotationDeg(360 - headingDeg) } function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networkFetchCount: number): string { const total = memoryHitCount + diskHitCount + networkFetchCount if (!total) { return '--' } const hitRate = ((memoryHitCount + diskHitCount) / total) * 100 return `${Math.round(hitRate)}%` } export class MapEngine { buildVersion: string renderer: WebGLMapRenderer compassController: CompassHeadingController onData: (patch: Partial) => void state: MapEngineViewState previewScale: number previewOriginX: number previewOriginY: number panLastX: number panLastY: number panLastTimestamp: number panVelocityX: number panVelocityY: number pinchStartDistance: number pinchStartScale: number pinchStartAngle: number pinchStartRotationDeg: number pinchAnchorWorldX: number pinchAnchorWorldY: number gestureMode: GestureMode inertiaTimer: number previewResetTimer: number viewSyncTimer: number autoRotateTimer: number pendingViewPatch: Partial mounted: boolean sensorHeadingDeg: number | null smoothedSensorHeadingDeg: number | null autoRotateHeadingDeg: number | null courseHeadingDeg: number | null targetAutoRotationDeg: number | null autoRotateSourceMode: AutoRotateSourceMode autoRotateCalibrationOffsetDeg: number | null autoRotateCalibrationPending: boolean constructor(buildVersion: string, callbacks: MapEngineCallbacks) { this.buildVersion = buildVersion this.onData = callbacks.onData this.renderer = new WebGLMapRenderer( (stats) => { this.applyStats(stats) }, (message) => { this.setState({ statusText: `${message} (${this.buildVersion})`, }) }, ) this.compassController = new CompassHeadingController({ onHeading: (headingDeg) => { this.handleCompassHeading(headingDeg) }, onError: (message) => { this.handleCompassError(message) }, }) this.state = { buildVersion: this.buildVersion, renderMode: RENDER_MODE, projectionMode: PROJECTION_MODE, mapReady: false, mapReadyText: 'BOOTING', mapName: 'LCX 测试地图', zoom: DEFAULT_ZOOM, rotationDeg: 0, rotationText: formatRotationText(0), rotationMode: 'manual', rotationModeText: formatRotationModeText('manual'), rotationToggleText: formatRotationToggleText('manual'), orientationMode: 'manual', orientationModeText: formatOrientationModeText('manual'), sensorHeadingText: '--', autoRotateSourceText: formatAutoRotateSourceText('fusion', false), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, MAP_NORTH_OFFSET_DEG), northReferenceText: formatNorthReferenceText(), compassNeedleDeg: 0, centerTileX: DEFAULT_CENTER_TILE_X, centerTileY: DEFAULT_CENTER_TILE_Y, centerText: buildCenterText(DEFAULT_ZOOM, DEFAULT_CENTER_TILE_X, DEFAULT_CENTER_TILE_Y), tileSource: TILE_SOURCE, visibleColumnCount: DESIRED_VISIBLE_COLUMNS, visibleTileCount: 0, readyTileCount: 0, memoryTileCount: 0, diskTileCount: 0, memoryHitCount: 0, diskHitCount: 0, networkFetchCount: 0, cacheHitRateText: '--', tileTranslateX: 0, tileTranslateY: 0, tileSizePx: 0, stageWidth: 0, stageHeight: 0, stageLeft: 0, stageTop: 0, statusText: `单 WebGL 管线已准备接入方向传感器 (${this.buildVersion})`, } this.previewScale = 1 this.previewOriginX = 0 this.previewOriginY = 0 this.panLastX = 0 this.panLastY = 0 this.panLastTimestamp = 0 this.panVelocityX = 0 this.panVelocityY = 0 this.pinchStartDistance = 0 this.pinchStartScale = 1 this.pinchStartAngle = 0 this.pinchStartRotationDeg = 0 this.pinchAnchorWorldX = 0 this.pinchAnchorWorldY = 0 this.gestureMode = 'idle' this.inertiaTimer = 0 this.previewResetTimer = 0 this.viewSyncTimer = 0 this.autoRotateTimer = 0 this.pendingViewPatch = {} this.mounted = false this.sensorHeadingDeg = null this.smoothedSensorHeadingDeg = null this.autoRotateHeadingDeg = null this.courseHeadingDeg = null this.targetAutoRotationDeg = null this.autoRotateSourceMode = 'fusion' this.autoRotateCalibrationOffsetDeg = MAP_NORTH_OFFSET_DEG this.autoRotateCalibrationPending = false } getInitialData(): MapEngineViewState { return { ...this.state } } destroy(): void { this.clearInertiaTimer() this.clearPreviewResetTimer() this.clearViewSyncTimer() this.clearAutoRotateTimer() this.compassController.destroy() this.renderer.destroy() this.mounted = false } setStage(rect: MapEngineStageRect): void { this.previewScale = 1 this.previewOriginX = rect.width / 2 this.previewOriginY = rect.height / 2 this.commitViewport( { stageWidth: rect.width, stageHeight: rect.height, stageLeft: rect.left, stageTop: rect.top, }, `地图视口与 WebGL 引擎已对齐 (${this.buildVersion})`, true, ) } attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void { this.renderer.attachCanvas(canvasNode, width, height, dpr) this.mounted = true this.state.mapReady = true this.state.mapReadyText = 'READY' this.onData({ mapReady: true, mapReadyText: 'READY', statusText: `单 WebGL 管线已就绪,可切换手动或自动朝向 (${this.buildVersion})`, }) this.syncRenderer() this.compassController.start() } handleTouchStart(event: WechatMiniprogram.TouchEvent): void { this.clearInertiaTimer() this.clearPreviewResetTimer() this.renderer.setAnimationPaused(true) this.panVelocityX = 0 this.panVelocityY = 0 if (event.touches.length >= 2) { const origin = 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) this.pinchAnchorWorldX = anchorWorld.x this.pinchAnchorWorldY = anchorWorld.y this.setPreviewState(this.pinchStartScale, origin.x, origin.y) this.syncRenderer() this.compassController.start() return } if (event.touches.length === 1) { this.gestureMode = 'pan' this.panLastX = event.touches[0].pageX this.panLastY = event.touches[0].pageY this.panLastTimestamp = event.timeStamp || Date.now() } } handleTouchMove(event: WechatMiniprogram.TouchEvent): void { if (event.touches.length >= 2) { const distance = this.getTouchDistance(event.touches) const angle = this.getTouchAngle(event.touches) const origin = 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) this.pinchAnchorWorldX = anchorWorld.x this.pinchAnchorWorldY = anchorWorld.y } this.gestureMode = 'pinch' const nextRotationDeg = this.state.orientationMode === 'heading-up' ? this.state.rotationDeg : normalizeRotationDeg(this.pinchStartRotationDeg + normalizeAngleDeltaRad(angle - this.pinchStartAngle) * 180 / Math.PI) const anchorOffset = this.getWorldOffsetFromScreen(origin.x, origin.y, nextRotationDeg) const resolvedViewport = this.resolveViewportForExactCenter( this.pinchAnchorWorldX - anchorOffset.x, this.pinchAnchorWorldY - anchorOffset.y, nextRotationDeg, ) this.setPreviewState( clamp(this.pinchStartScale * (distance / this.pinchStartDistance), MIN_PREVIEW_SCALE, MAX_PREVIEW_SCALE), origin.x, origin.y, ) this.commitViewport( { ...resolvedViewport, rotationDeg: nextRotationDeg, rotationText: formatRotationText(nextRotationDeg), }, this.state.orientationMode === 'heading-up' ? `双指缩放中,自动朝向保持开启 (${this.buildVersion})` : `双指缩放与旋转中 (${this.buildVersion})`, ) return } if (this.gestureMode !== 'pan' || event.touches.length !== 1) { return } const touch = event.touches[0] const deltaX = touch.pageX - this.panLastX const deltaY = touch.pageY - this.panLastY const nextTimestamp = event.timeStamp || Date.now() const elapsed = Math.max(nextTimestamp - this.panLastTimestamp, 16) const instantVelocityX = deltaX / elapsed const instantVelocityY = deltaY / elapsed this.panVelocityX = this.panVelocityX * 0.72 + instantVelocityX * 0.28 this.panVelocityY = this.panVelocityY * 0.72 + instantVelocityY * 0.28 this.panLastX = touch.pageX this.panLastY = touch.pageY this.panLastTimestamp = nextTimestamp this.normalizeTranslate( this.state.tileTranslateX + deltaX, this.state.tileTranslateY + deltaY, `已拖拽单 WebGL 地图引擎 (${this.buildVersion})`, ) } handleTouchEnd(event: WechatMiniprogram.TouchEvent): void { 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 if (zoomDelta) { const residualScale = gestureScale / Math.pow(2, zoomDelta) this.zoomAroundPoint(zoomDelta, originX, originY, residualScale) } else { this.animatePreviewToRest() } this.resetPinchState() this.panVelocityX = 0 this.panVelocityY = 0 if (event.touches.length === 1) { this.gestureMode = 'pan' this.panLastX = event.touches[0].pageX this.panLastY = event.touches[0].pageY this.panLastTimestamp = event.timeStamp || Date.now() return } this.gestureMode = 'idle' this.renderer.setAnimationPaused(false) this.scheduleAutoRotate() return } if (event.touches.length === 1) { this.gestureMode = 'pan' this.panLastX = event.touches[0].pageX this.panLastY = event.touches[0].pageY this.panLastTimestamp = event.timeStamp || Date.now() return } if (this.gestureMode === 'pan' && (Math.abs(this.panVelocityX) >= INERTIA_MIN_SPEED || Math.abs(this.panVelocityY) >= INERTIA_MIN_SPEED)) { this.startInertia() this.gestureMode = 'idle' this.resetPinchState() return } this.gestureMode = 'idle' this.resetPinchState() this.renderer.setAnimationPaused(false) this.scheduleAutoRotate() } handleTouchCancel(): void { this.gestureMode = 'idle' this.resetPinchState() this.panVelocityX = 0 this.panVelocityY = 0 this.clearInertiaTimer() this.animatePreviewToRest() this.renderer.setAnimationPaused(false) this.scheduleAutoRotate() } handleRecenter(): void { this.clearInertiaTimer() this.clearPreviewResetTimer() this.panVelocityX = 0 this.panVelocityY = 0 this.renderer.setAnimationPaused(false) this.commitViewport( { zoom: DEFAULT_ZOOM, centerTileX: DEFAULT_CENTER_TILE_X, centerTileY: DEFAULT_CENTER_TILE_Y, tileTranslateX: 0, tileTranslateY: 0, }, `已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() this.compassController.start() this.scheduleAutoRotate() }, ) } handleRotateStep(stepDeg = ROTATE_STEP_DEG): void { if (this.state.rotationMode === 'auto') { this.setState({ statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`, }, true) return } const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const nextRotationDeg = normalizeRotationDeg(this.state.rotationDeg + stepDeg) const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg) this.clearInertiaTimer() this.clearPreviewResetTimer() this.panVelocityX = 0 this.panVelocityY = 0 this.renderer.setAnimationPaused(false) this.commitViewport( { ...resolvedViewport, rotationDeg: nextRotationDeg, rotationText: formatRotationText(nextRotationDeg), }, `旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() this.compassController.start() }, ) } handleRotationReset(): void { if (this.state.rotationMode === 'auto') { this.setState({ statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`, }, true) return } if (!this.state.rotationDeg) { return } const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, 0) this.clearInertiaTimer() this.clearPreviewResetTimer() this.panVelocityX = 0 this.panVelocityY = 0 this.renderer.setAnimationPaused(false) this.commitViewport( { ...resolvedViewport, rotationDeg: 0, rotationText: formatRotationText(0), }, `旋转角度已归零 (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() this.compassController.start() }, ) } handleToggleRotationMode(): void { if (this.state.orientationMode === 'manual') { this.setNorthUpMode() return } if (this.state.orientationMode === 'north-up') { this.setHeadingUpMode() return } this.setManualMode() } handleSetManualMode(): void { this.setManualMode() } handleSetNorthUpMode(): void { this.setNorthUpMode() } handleSetHeadingUpMode(): void { this.setHeadingUpMode() } handleAutoRotateCalibrate(): void { if (this.state.orientationMode !== 'heading-up') { this.setState({ statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`, }, true) return } if (!this.calibrateAutoRotateToCurrentOrientation()) { this.setState({ statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`, }, true) return } this.setState({ statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`, }, true) this.scheduleAutoRotate() } setManualMode(): void { this.clearAutoRotateTimer() this.targetAutoRotationDeg = null this.autoRotateCalibrationPending = false this.setState({ rotationMode: 'manual', rotationModeText: formatRotationModeText('manual'), rotationToggleText: formatRotationToggleText('manual'), orientationMode: 'manual', orientationModeText: formatOrientationModeText('manual'), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), statusText: `已切回手动地图旋转 (${this.buildVersion})`, }, true) } setNorthUpMode(): void { this.clearAutoRotateTimer() this.targetAutoRotationDeg = null this.autoRotateCalibrationPending = false const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, MAP_NORTH_OFFSET_DEG) this.commitViewport( { ...resolvedViewport, rotationDeg: MAP_NORTH_OFFSET_DEG, rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG), rotationMode: 'manual', rotationModeText: formatRotationModeText('north-up'), rotationToggleText: formatRotationToggleText('north-up'), orientationMode: 'north-up', orientationModeText: formatOrientationModeText('north-up'), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), }, `地图已固定为北朝上 (${this.buildVersion})`, true, () => { this.resetPreviewState() this.syncRenderer() }, ) } setHeadingUpMode(): void { this.autoRotateCalibrationPending = false this.autoRotateCalibrationOffsetDeg = MAP_NORTH_OFFSET_DEG this.targetAutoRotationDeg = null this.setState({ rotationMode: 'auto', rotationModeText: formatRotationModeText('heading-up'), rotationToggleText: formatRotationToggleText('heading-up'), orientationMode: 'heading-up', orientationModeText: formatOrientationModeText('heading-up'), autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), statusText: `正在启用朝向朝上模式 (${this.buildVersion})`, }, true) if (this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() } } handleCompassHeading(headingDeg: number): void { this.sensorHeadingDeg = normalizeRotationDeg(headingDeg) this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null ? this.sensorHeadingDeg : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING) this.autoRotateHeadingDeg = this.smoothedSensorHeadingDeg this.setState({ sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg), autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null), compassNeedleDeg: formatCompassNeedleDeg(this.smoothedSensorHeadingDeg), }) if (!this.refreshAutoRotateTarget()) { return } if (this.state.orientationMode === 'heading-up') { this.scheduleAutoRotate() } } handleCompassError(message: string): void { this.clearAutoRotateTimer() this.targetAutoRotationDeg = null this.autoRotateCalibrationPending = false this.setState({ autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), statusText: `${message} (${this.buildVersion})`, }, true) } setCourseHeading(headingDeg: number | null): void { this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg) this.setState({ autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null), }) if (this.refreshAutoRotateTarget()) { this.scheduleAutoRotate() } } resolveAutoRotateInputHeadingDeg(): number | null { const sensorHeadingDeg = this.smoothedSensorHeadingDeg const courseHeadingDeg = this.courseHeadingDeg if (this.autoRotateSourceMode === 'sensor') { return sensorHeadingDeg } if (this.autoRotateSourceMode === 'course') { return courseHeadingDeg === null ? sensorHeadingDeg : courseHeadingDeg } if (sensorHeadingDeg !== null && courseHeadingDeg !== null) { return interpolateAngleDeg(sensorHeadingDeg, courseHeadingDeg, 0.35) } return sensorHeadingDeg === null ? courseHeadingDeg : sensorHeadingDeg } calibrateAutoRotateToCurrentOrientation(): boolean { const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg() if (inputHeadingDeg === null) { return false } this.autoRotateCalibrationOffsetDeg = normalizeRotationDeg(this.state.rotationDeg + inputHeadingDeg) this.autoRotateCalibrationPending = false this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg) this.setState({ autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), }) return true } refreshAutoRotateTarget(): boolean { const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg() if (inputHeadingDeg === null) { return false } if (this.autoRotateCalibrationPending || this.autoRotateCalibrationOffsetDeg === null) { if (!this.calibrateAutoRotateToCurrentOrientation()) { return false } return true } this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg) this.setState({ autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg), }) return true } scheduleAutoRotate(): void { if (this.autoRotateTimer || this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) { return } const step = () => { this.autoRotateTimer = 0 if (this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) { return } if (this.gestureMode !== 'idle' || this.inertiaTimer || this.previewResetTimer) { this.scheduleAutoRotate() return } const currentRotationDeg = this.state.rotationDeg const deltaDeg = normalizeAngleDeltaDeg(this.targetAutoRotationDeg - currentRotationDeg) if (Math.abs(deltaDeg) <= AUTO_ROTATE_SNAP_DEG) { if (Math.abs(deltaDeg) > 0.01) { this.applyAutoRotation(this.targetAutoRotationDeg) } this.scheduleAutoRotate() return } if (Math.abs(deltaDeg) <= AUTO_ROTATE_DEADZONE_DEG) { this.scheduleAutoRotate() return } const easedStepDeg = clamp(deltaDeg * AUTO_ROTATE_EASE, -AUTO_ROTATE_MAX_STEP_DEG, AUTO_ROTATE_MAX_STEP_DEG) this.applyAutoRotation(normalizeRotationDeg(currentRotationDeg + easedStepDeg)) this.scheduleAutoRotate() } this.autoRotateTimer = setTimeout(step, AUTO_ROTATE_FRAME_MS) as unknown as number } applyAutoRotation(nextRotationDeg: number): void { const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY) const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg) this.state = { ...this.state, ...resolvedViewport, rotationDeg: nextRotationDeg, rotationText: formatRotationText(nextRotationDeg), centerText: buildCenterText(this.state.zoom, resolvedViewport.centerTileX, resolvedViewport.centerTileY), } this.syncRenderer() } applyStats(stats: MapRendererStats): void { this.setState({ visibleTileCount: stats.visibleTileCount, readyTileCount: stats.readyTileCount, memoryTileCount: stats.memoryTileCount, diskTileCount: stats.diskTileCount, memoryHitCount: stats.memoryHitCount, diskHitCount: stats.diskHitCount, networkFetchCount: stats.networkFetchCount, cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, stats.networkFetchCount), }) } setState(patch: Partial, immediateUi = false): void { this.state = { ...this.state, ...patch, } const viewPatch = this.pickViewPatch(patch) if (!Object.keys(viewPatch).length) { return } this.pendingViewPatch = { ...this.pendingViewPatch, ...viewPatch, } if (immediateUi) { this.flushViewPatch() return } if (this.viewSyncTimer) { return } this.viewSyncTimer = setTimeout(() => { this.viewSyncTimer = 0 this.flushViewPatch() }, UI_SYNC_INTERVAL_MS) as unknown as number } commitViewport( patch: Partial, statusText: string, immediateUi = false, afterUpdate?: () => void, ): void { const nextZoom = typeof patch.zoom === 'number' ? patch.zoom : this.state.zoom const nextCenterTileX = typeof patch.centerTileX === 'number' ? patch.centerTileX : this.state.centerTileX const nextCenterTileY = typeof patch.centerTileY === 'number' ? patch.centerTileY : this.state.centerTileY const nextStageWidth = typeof patch.stageWidth === 'number' ? patch.stageWidth : this.state.stageWidth const nextStageHeight = typeof patch.stageHeight === 'number' ? patch.stageHeight : this.state.stageHeight const tileSizePx = getTileSizePx({ centerWorldX: nextCenterTileX, centerWorldY: nextCenterTileY, viewportWidth: nextStageWidth, viewportHeight: nextStageHeight, visibleColumns: DESIRED_VISIBLE_COLUMNS, }) this.setState({ ...patch, tileSizePx, centerText: buildCenterText(nextZoom, nextCenterTileX, nextCenterTileY), statusText, }, immediateUi) this.syncRenderer() this.compassController.start() if (afterUpdate) { afterUpdate() } } buildScene() { return { tileSource: this.state.tileSource, zoom: this.state.zoom, centerTileX: this.state.centerTileX, centerTileY: this.state.centerTileY, viewportWidth: this.state.stageWidth, viewportHeight: this.state.stageHeight, visibleColumns: DESIRED_VISIBLE_COLUMNS, overdraw: OVERDRAW, translateX: this.state.tileTranslateX, translateY: this.state.tileTranslateY, rotationRad: this.getRotationRad(this.state.rotationDeg), previewScale: this.previewScale || 1, previewOriginX: this.previewOriginX || this.state.stageWidth / 2, previewOriginY: this.previewOriginY || this.state.stageHeight / 2, track: SAMPLE_TRACK_WGS84, gpsPoint: SAMPLE_GPS_WGS84, } } syncRenderer(): void { if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) { return } this.renderer.updateScene(this.buildScene()) } getCameraState(rotationDeg = this.state.rotationDeg): CameraState { return { centerWorldX: this.state.centerTileX, centerWorldY: this.state.centerTileY, viewportWidth: this.state.stageWidth, viewportHeight: this.state.stageHeight, visibleColumns: DESIRED_VISIBLE_COLUMNS, translateX: this.state.tileTranslateX, translateY: this.state.tileTranslateY, rotationRad: this.getRotationRad(rotationDeg), } } getRotationRad(rotationDeg = this.state.rotationDeg): number { return normalizeRotationDeg(rotationDeg) * Math.PI / 180 } getBaseCamera(centerWorldX = this.state.centerTileX, centerWorldY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState { return { centerWorldX, centerWorldY, viewportWidth: this.state.stageWidth, viewportHeight: this.state.stageHeight, visibleColumns: DESIRED_VISIBLE_COLUMNS, rotationRad: this.getRotationRad(rotationDeg), } } getWorldOffsetFromScreen(stageX: number, stageY: number, rotationDeg = this.state.rotationDeg): { x: number; y: number } { const baseCamera = { centerWorldX: 0, centerWorldY: 0, viewportWidth: this.state.stageWidth, viewportHeight: this.state.stageHeight, visibleColumns: DESIRED_VISIBLE_COLUMNS, rotationRad: this.getRotationRad(rotationDeg), } return screenToWorld(baseCamera, { x: stageX, y: stageY }, false) } getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } { if (!this.state.stageWidth || !this.state.stageHeight) { return { x: this.state.centerTileX, y: this.state.centerTileY, } } const screenCenterX = this.state.stageWidth / 2 const screenCenterY = this.state.stageHeight / 2 return screenToWorld(this.getBaseCamera(), { x: screenCenterX - translateX, y: screenCenterY - translateY, }, false) } resolveViewportForExactCenter(centerWorldX: number, centerWorldY: number, rotationDeg = this.state.rotationDeg): { centerTileX: number centerTileY: number tileTranslateX: number tileTranslateY: number } { const nextCenterTileX = Math.round(centerWorldX) const nextCenterTileY = Math.round(centerWorldY) if (!this.state.stageWidth || !this.state.stageHeight) { return { centerTileX: nextCenterTileX, centerTileY: nextCenterTileY, tileTranslateX: 0, tileTranslateY: 0, } } const roundedCamera = this.getBaseCamera(nextCenterTileX, nextCenterTileY, rotationDeg) const projectedCenter = worldToScreen(roundedCamera, { x: centerWorldX, y: centerWorldY }, false) return { centerTileX: nextCenterTileX, centerTileY: nextCenterTileY, tileTranslateX: this.state.stageWidth / 2 - projectedCenter.x, tileTranslateY: this.state.stageHeight / 2 - projectedCenter.y, } } setPreviewState(scale: number, originX: number, originY: number): void { this.previewScale = scale this.previewOriginX = originX this.previewOriginY = originY } resetPreviewState(): void { this.setPreviewState(1, this.state.stageWidth / 2, this.state.stageHeight / 2) } resetPinchState(): void { this.pinchStartDistance = 0 this.pinchStartScale = 1 this.pinchStartAngle = 0 this.pinchStartRotationDeg = this.state.rotationDeg this.pinchAnchorWorldX = 0 this.pinchAnchorWorldY = 0 } clearPreviewResetTimer(): void { if (this.previewResetTimer) { clearTimeout(this.previewResetTimer) this.previewResetTimer = 0 } } clearInertiaTimer(): void { if (this.inertiaTimer) { clearTimeout(this.inertiaTimer) this.inertiaTimer = 0 } } clearViewSyncTimer(): void { if (this.viewSyncTimer) { clearTimeout(this.viewSyncTimer) this.viewSyncTimer = 0 } } clearAutoRotateTimer(): void { if (this.autoRotateTimer) { clearTimeout(this.autoRotateTimer) this.autoRotateTimer = 0 } } pickViewPatch(patch: Partial): Partial { const viewPatch = {} as Partial for (const key of VIEW_SYNC_KEYS) { if (Object.prototype.hasOwnProperty.call(patch, key)) { ;(viewPatch as any)[key] = patch[key] } } return viewPatch } flushViewPatch(): void { if (!Object.keys(this.pendingViewPatch).length) { return } const patch = this.pendingViewPatch this.pendingViewPatch = {} this.onData(patch) } getTouchDistance(touches: TouchPoint[]): number { if (touches.length < 2) { return 0 } const first = touches[0] const second = touches[1] const deltaX = first.pageX - second.pageX const deltaY = first.pageY - second.pageY return Math.sqrt(deltaX * deltaX + deltaY * deltaY) } getTouchAngle(touches: TouchPoint[]): number { if (touches.length < 2) { return 0 } const first = touches[0] const second = touches[1] return Math.atan2(second.pageY - first.pageY, second.pageX - first.pageX) } getStagePoint(touches: TouchPoint[]): { x: number; y: number } { if (!touches.length) { return { x: this.state.stageWidth / 2, y: this.state.stageHeight / 2, } } let pageX = 0 let pageY = 0 for (const touch of touches) { pageX += touch.pageX pageY += touch.pageY } return { x: pageX / touches.length - this.state.stageLeft, y: pageY / touches.length - this.state.stageTop, } } animatePreviewToRest(): void { this.clearPreviewResetTimer() const startScale = this.previewScale || 1 const originX = this.previewOriginX || this.state.stageWidth / 2 const originY = this.previewOriginY || this.state.stageHeight / 2 if (Math.abs(startScale - 1) < 0.01) { this.resetPreviewState() this.syncRenderer() this.compassController.start() this.scheduleAutoRotate() return } const startAt = Date.now() const step = () => { const progress = Math.min(1, (Date.now() - startAt) / PREVIEW_RESET_DURATION_MS) const eased = 1 - Math.pow(1 - progress, 3) const nextScale = startScale + (1 - startScale) * eased this.setPreviewState(nextScale, originX, originY) this.syncRenderer() this.compassController.start() if (progress >= 1) { this.resetPreviewState() this.syncRenderer() this.compassController.start() this.previewResetTimer = 0 this.scheduleAutoRotate() return } this.previewResetTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number } step() } normalizeTranslate(translateX: number, translateY: number, statusText: string): void { if (!this.state.stageWidth) { this.setState({ tileTranslateX: translateX, tileTranslateY: translateY, }) this.syncRenderer() this.compassController.start() return } const exactCenter = this.getExactCenterFromTranslate(translateX, translateY) const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y) const centerChanged = resolvedViewport.centerTileX !== this.state.centerTileX || resolvedViewport.centerTileY !== this.state.centerTileY if (centerChanged) { this.commitViewport(resolvedViewport, statusText) return } this.setState({ tileTranslateX: resolvedViewport.tileTranslateX, tileTranslateY: resolvedViewport.tileTranslateY, }) this.syncRenderer() this.compassController.start() } zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void { const nextZoom = clamp(this.state.zoom + zoomDelta, MIN_ZOOM, MAX_ZOOM) const appliedDelta = nextZoom - this.state.zoom if (!appliedDelta) { this.animatePreviewToRest() return } if (!this.state.stageWidth || !this.state.stageHeight) { this.commitViewport( { zoom: nextZoom, centerTileX: appliedDelta > 0 ? this.state.centerTileX * 2 : Math.floor(this.state.centerTileX / 2), centerTileY: appliedDelta > 0 ? this.state.centerTileY * 2 : Math.floor(this.state.centerTileY / 2), tileTranslateX: 0, tileTranslateY: 0, }, `缩放级别调整到 ${nextZoom}`, true, () => { this.setPreviewState(residualScale, stageX, stageY) this.syncRenderer() this.compassController.start() this.animatePreviewToRest() }, ) return } const camera = this.getCameraState() const world = screenToWorld(camera, { x: stageX, y: stageY }, true) const zoomFactor = Math.pow(2, appliedDelta) const nextWorldX = world.x * zoomFactor const nextWorldY = world.y * zoomFactor const anchorOffset = this.getWorldOffsetFromScreen(stageX, stageY) const exactCenterX = nextWorldX - anchorOffset.x const exactCenterY = nextWorldY - anchorOffset.y const resolvedViewport = this.resolveViewportForExactCenter(exactCenterX, exactCenterY) this.commitViewport( { zoom: nextZoom, ...resolvedViewport, }, `缩放级别调整到 ${nextZoom}`, true, () => { this.setPreviewState(residualScale, stageX, stageY) this.syncRenderer() this.compassController.start() this.animatePreviewToRest() }, ) } startInertia(): void { this.clearInertiaTimer() const step = () => { this.panVelocityX *= INERTIA_DECAY this.panVelocityY *= INERTIA_DECAY if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) { this.setState({ statusText: `惯性滑动结束 (${this.buildVersion})`, }) this.renderer.setAnimationPaused(false) this.inertiaTimer = 0 this.scheduleAutoRotate() return } this.normalizeTranslate( this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS, this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS, `惯性滑动中 (${this.buildVersion})`, ) this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number } this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number } }