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

1774 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}