Files
cmr-mini/miniprogram/engine/map/mapEngine.ts

1587 lines
49 KiB
TypeScript

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 MAGNETIC_DECLINATION_DEG = -6.91
const MAGNETIC_DECLINATION_TEXT = '6.91° W'
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 = 8
const AUTO_ROTATE_EASE = 0.34
const AUTO_ROTATE_SNAP_DEG = 0.1
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 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'
type NorthReferenceMode = 'magnetic' | 'true'
const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic'
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
compassDeclinationText: string
northReferenceButtonText: 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<MapEngineViewState>) => void
}
const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
'buildVersion',
'renderMode',
'projectionMode',
'mapReady',
'mapReadyText',
'mapName',
'zoom',
'rotationDeg',
'rotationText',
'rotationMode',
'rotationModeText',
'rotationToggleText',
'orientationMode',
'orientationModeText',
'sensorHeadingText',
'compassDeclinationText',
'northReferenceButtonText',
'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 getTrueHeadingDeg(magneticHeadingDeg: number): number {
return normalizeRotationDeg(magneticHeadingDeg + MAGNETIC_DECLINATION_DEG)
}
function getMagneticHeadingDeg(trueHeadingDeg: number): number {
return normalizeRotationDeg(trueHeadingDeg - MAGNETIC_DECLINATION_DEG)
}
function getMapNorthOffsetDeg(_mode: NorthReferenceMode): number {
return MAP_NORTH_OFFSET_DEG
}
function getCompassReferenceHeadingDeg(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
if (mode === 'true') {
return getTrueHeadingDeg(magneticHeadingDeg)
}
return normalizeRotationDeg(magneticHeadingDeg)
}
function getMapReferenceHeadingDegFromSensor(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
if (mode === 'magnetic') {
return normalizeRotationDeg(magneticHeadingDeg)
}
return getTrueHeadingDeg(magneticHeadingDeg)
}
function getMapReferenceHeadingDegFromCourse(mode: NorthReferenceMode, trueHeadingDeg: number): number {
if (mode === 'magnetic') {
return getMagneticHeadingDeg(trueHeadingDeg)
}
return normalizeRotationDeg(trueHeadingDeg)
}
function formatNorthReferenceText(mode: NorthReferenceMode): string {
if (mode === 'magnetic') {
return `Compass Magnetic / Heading-Up Magnetic (${MAGNETIC_DECLINATION_TEXT})`
}
return `Compass True / Heading-Up True (${MAGNETIC_DECLINATION_TEXT})`
}
function formatCompassDeclinationText(mode: NorthReferenceMode): string {
if (mode === 'true') {
return MAGNETIC_DECLINATION_TEXT
}
return ''
}
function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
return mode === 'magnetic' ? '北参考:磁北' : '北参考:真北'
}
function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
if (mode === 'magnetic') {
return '已切到磁北模式'
}
return '已切到真北模式'
}
function getNextNorthReferenceMode(mode: NorthReferenceMode): NorthReferenceMode {
return mode === 'magnetic' ? 'true' : 'magnetic'
}
function formatCompassNeedleDegForMode(mode: NorthReferenceMode, magneticHeadingDeg: number | null): number {
if (magneticHeadingDeg === null) {
return 0
}
const referenceHeadingDeg = mode === 'true'
? getTrueHeadingDeg(magneticHeadingDeg)
: normalizeRotationDeg(magneticHeadingDeg)
return normalizeRotationDeg(360 - referenceHeadingDeg)
}
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<MapEngineViewState>) => 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<MapEngineViewState>
mounted: boolean
northReferenceMode: NorthReferenceMode
sensorHeadingDeg: number | null
smoothedSensorHeadingDeg: number | null
compassDisplayHeadingDeg: 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: '--',
compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
autoRotateSourceText: formatAutoRotateSourceText('fusion', false),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
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.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
this.sensorHeadingDeg = null
this.smoothedSensorHeadingDeg = null
this.compassDisplayHeadingDeg = null
this.autoRotateHeadingDeg = null
this.courseHeadingDeg = null
this.targetAutoRotationDeg = null
this.autoRotateSourceMode = 'fusion'
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
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
}
const targetRotationDeg = MAP_NORTH_OFFSET_DEG
if (Math.abs(normalizeAngleDeltaDeg(this.state.rotationDeg - targetRotationDeg)) <= 0.01) {
return
}
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, targetRotationDeg)
this.clearInertiaTimer()
this.clearPreviewResetTimer()
this.panVelocityX = 0
this.panVelocityY = 0
this.renderer.setAnimationPaused(false)
this.commitViewport(
{
...resolvedViewport,
rotationDeg: targetRotationDeg,
rotationText: formatRotationText(targetRotationDeg),
},
`旋转角度已回到真北参考 (${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()
}
handleCycleNorthReferenceMode(): void {
this.cycleNorthReferenceMode()
}
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 mapNorthOffsetDeg = MAP_NORTH_OFFSET_DEG
this.autoRotateCalibrationOffsetDeg = mapNorthOffsetDeg
const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, mapNorthOffsetDeg)
this.commitViewport(
{
...resolvedViewport,
rotationDeg: mapNorthOffsetDeg,
rotationText: formatRotationText(mapNorthOffsetDeg),
rotationMode: 'manual',
rotationModeText: formatRotationModeText('north-up'),
rotationToggleText: formatRotationToggleText('north-up'),
orientationMode: 'north-up',
orientationModeText: formatOrientationModeText('north-up'),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
},
`地图已固定为真北朝上 (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
this.syncRenderer()
},
)
}
setHeadingUpMode(): void {
this.autoRotateCalibrationPending = false
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(this.northReferenceMode)
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),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
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)
const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null
? compassHeadingDeg
: interpolateAngleDeg(this.compassDisplayHeadingDeg, compassHeadingDeg, COMPASS_NEEDLE_SMOOTHING)
this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
this.setState({
sensorHeadingText: formatHeadingText(compassHeadingDeg),
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
})
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)
}
cycleNorthReferenceMode(): void {
const nextMode = getNextNorthReferenceMode(this.northReferenceMode)
const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
? null
: getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg)
this.northReferenceMode = nextMode
this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg
this.compassDisplayHeadingDeg = compassHeadingDeg
if (this.state.orientationMode === 'north-up') {
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),
northReferenceText: formatNorthReferenceText(nextMode),
sensorHeadingText: formatHeadingText(compassHeadingDeg),
compassDeclinationText: formatCompassDeclinationText(nextMode),
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
},
`${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
true,
() => {
this.resetPreviewState()
this.syncRenderer()
},
)
return
}
this.setState({
northReferenceText: formatNorthReferenceText(nextMode),
sensorHeadingText: formatHeadingText(compassHeadingDeg),
compassDeclinationText: formatCompassDeclinationText(nextMode),
northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
}, true)
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
this.scheduleAutoRotate()
}
}
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 === null
? null
: getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
const courseHeadingDeg = this.courseHeadingDeg === null
? null
: getMapReferenceHeadingDegFromCourse(this.northReferenceMode, 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<MapEngineViewState>, 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<MapEngineViewState>,
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<MapEngineViewState>): Partial<MapEngineViewState> {
const viewPatch = {} as Partial<MapEngineViewState>
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
}
}