1774 lines
56 KiB
TypeScript
1774 lines
56 KiB
TypeScript
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
||
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
||
import { LocationController } from '../sensor/locationController'
|
||
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
||
import { type MapRendererStats } from '../renderer/mapRenderer'
|
||
import { gcj02ToWgs84, lonLatToWorldTile, worldTileToLonLat, type LonLatPoint } from '../../utils/projection'
|
||
import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
|
||
|
||
const RENDER_MODE = 'Single WebGL Pipeline'
|
||
const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
|
||
const MAP_NORTH_OFFSET_DEG = 0
|
||
let MAGNETIC_DECLINATION_DEG = -6.91
|
||
let 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 GPS_TRACK_MAX_POINTS = 200
|
||
const GPS_TRACK_MIN_STEP_METERS = 3
|
||
|
||
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
|
||
configStatusText: 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
|
||
gpsTracking: boolean
|
||
gpsTrackingText: string
|
||
gpsCoordText: string
|
||
}
|
||
|
||
export interface MapEngineCallbacks {
|
||
onData: (patch: Partial<MapEngineViewState>) => void
|
||
}
|
||
|
||
const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
|
||
'buildVersion',
|
||
'renderMode',
|
||
'projectionMode',
|
||
'mapReady',
|
||
'mapReadyText',
|
||
'mapName',
|
||
'configStatusText',
|
||
'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',
|
||
'gpsTracking',
|
||
'gpsTrackingText',
|
||
'gpsCoordText',
|
||
]
|
||
|
||
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)}%`
|
||
}
|
||
|
||
function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | null): string {
|
||
if (!point) {
|
||
return '--'
|
||
}
|
||
|
||
const base = `${point.lat.toFixed(6)}, ${point.lon.toFixed(6)}`
|
||
if (accuracyMeters === null || !Number.isFinite(accuracyMeters)) {
|
||
return base
|
||
}
|
||
|
||
return `${base} / ±${Math.round(accuracyMeters)}m`
|
||
}
|
||
|
||
function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
|
||
const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
|
||
const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
|
||
const dy = (b.lat - a.lat) * 110540
|
||
return Math.sqrt(dx * dx + dy * dy)
|
||
}
|
||
|
||
export class MapEngine {
|
||
buildVersion: string
|
||
renderer: WebGLMapRenderer
|
||
compassController: CompassHeadingController
|
||
locationController: LocationController
|
||
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
|
||
minZoom: number
|
||
maxZoom: number
|
||
defaultZoom: number
|
||
defaultCenterTileX: number
|
||
defaultCenterTileY: number
|
||
tileBoundsByZoom: Record<number, TileZoomBounds> | null
|
||
currentGpsPoint: LonLatPoint
|
||
currentGpsTrack: LonLatPoint[]
|
||
currentGpsAccuracyMeters: number | null
|
||
hasGpsCenteredOnce: 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.locationController = new LocationController({
|
||
onLocation: (update) => {
|
||
this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
|
||
},
|
||
onStatus: (message) => {
|
||
this.setState({
|
||
gpsTracking: this.locationController.listening,
|
||
gpsTrackingText: message,
|
||
}, true)
|
||
},
|
||
onError: (message) => {
|
||
this.setState({
|
||
gpsTracking: false,
|
||
gpsTrackingText: message,
|
||
statusText: `${message} (${this.buildVersion})`,
|
||
}, true)
|
||
},
|
||
})
|
||
this.minZoom = MIN_ZOOM
|
||
this.maxZoom = MAX_ZOOM
|
||
this.defaultZoom = DEFAULT_ZOOM
|
||
this.defaultCenterTileX = DEFAULT_CENTER_TILE_X
|
||
this.defaultCenterTileY = DEFAULT_CENTER_TILE_Y
|
||
this.tileBoundsByZoom = null
|
||
this.currentGpsPoint = SAMPLE_GPS_WGS84
|
||
this.currentGpsTrack = []
|
||
this.currentGpsAccuracyMeters = null
|
||
this.hasGpsCenteredOnce = false
|
||
this.state = {
|
||
buildVersion: this.buildVersion,
|
||
renderMode: RENDER_MODE,
|
||
projectionMode: PROJECTION_MODE,
|
||
mapReady: false,
|
||
mapReadyText: 'BOOTING',
|
||
mapName: 'LCX 测试地图',
|
||
configStatusText: '远程配置待加载',
|
||
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})`,
|
||
gpsTracking: false,
|
||
gpsTrackingText: '持续定位待启动',
|
||
gpsCoordText: '--',
|
||
}
|
||
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.locationController.destroy()
|
||
this.renderer.destroy()
|
||
this.mounted = false
|
||
}
|
||
|
||
|
||
handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
|
||
const nextPoint: LonLatPoint = gcj02ToWgs84({ lon: longitude, lat: latitude })
|
||
const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
|
||
if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) {
|
||
this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS)
|
||
}
|
||
|
||
this.currentGpsPoint = nextPoint
|
||
this.currentGpsAccuracyMeters = accuracyMeters
|
||
|
||
const gpsWorldPoint = lonLatToWorldTile(nextPoint, 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) {
|
||
this.hasGpsCenteredOnce = true
|
||
this.commitViewport({
|
||
centerTileX: gpsWorldPoint.x,
|
||
centerTileY: gpsWorldPoint.y,
|
||
tileTranslateX: 0,
|
||
tileTranslateY: 0,
|
||
gpsTracking: true,
|
||
gpsTrackingText: '持续定位进行中',
|
||
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
||
}, `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true)
|
||
return
|
||
}
|
||
|
||
this.setState({
|
||
gpsTracking: true,
|
||
gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
|
||
gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
|
||
statusText: gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`,
|
||
}, true)
|
||
this.syncRenderer()
|
||
}
|
||
|
||
handleToggleGpsTracking(): void {
|
||
if (this.locationController.listening) {
|
||
this.locationController.stop()
|
||
return
|
||
}
|
||
|
||
this.locationController.start()
|
||
}
|
||
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()
|
||
}
|
||
|
||
applyRemoteMapConfig(config: RemoteMapConfig): void {
|
||
MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg
|
||
MAGNETIC_DECLINATION_TEXT = config.magneticDeclinationText
|
||
this.minZoom = config.minZoom
|
||
this.maxZoom = config.maxZoom
|
||
this.defaultZoom = config.defaultZoom
|
||
this.defaultCenterTileX = config.initialCenterTileX
|
||
this.defaultCenterTileY = config.initialCenterTileY
|
||
this.tileBoundsByZoom = config.tileBoundsByZoom
|
||
|
||
const statePatch: Partial<MapEngineViewState> = {
|
||
configStatusText: '远程配置已载入',
|
||
projectionMode: config.projectionModeText,
|
||
tileSource: config.tileSource,
|
||
sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
|
||
compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
|
||
northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
|
||
northReferenceText: formatNorthReferenceText(this.northReferenceMode),
|
||
compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
|
||
}
|
||
|
||
if (!this.state.stageWidth || !this.state.stageHeight) {
|
||
this.setState({
|
||
...statePatch,
|
||
zoom: this.defaultZoom,
|
||
centerTileX: this.defaultCenterTileX,
|
||
centerTileY: this.defaultCenterTileY,
|
||
centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
|
||
statusText: `远程地图配置已载入 (${this.buildVersion})`,
|
||
}, true)
|
||
return
|
||
}
|
||
|
||
this.commitViewport({
|
||
...statePatch,
|
||
zoom: this.defaultZoom,
|
||
centerTileX: this.defaultCenterTileX,
|
||
centerTileY: this.defaultCenterTileY,
|
||
tileTranslateX: 0,
|
||
tileTranslateY: 0,
|
||
}, `远程地图配置已载入 (${this.buildVersion})`, true, () => {
|
||
this.resetPreviewState()
|
||
this.syncRenderer()
|
||
if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
|
||
this.scheduleAutoRotate()
|
||
}
|
||
})
|
||
}
|
||
|
||
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: this.defaultZoom,
|
||
centerTileX: this.defaultCenterTileX,
|
||
centerTileY: this.defaultCenterTileY,
|
||
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,
|
||
tileBoundsByZoom: this.tileBoundsByZoom,
|
||
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: this.currentGpsTrack.length ? this.currentGpsTrack : SAMPLE_TRACK_WGS84,
|
||
gpsPoint: this.currentGpsPoint,
|
||
}
|
||
}
|
||
|
||
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, this.minZoom, this.maxZoom)
|
||
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
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|