1654 lines
52 KiB
TypeScript
1654 lines
52 KiB
TypeScript
import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
|
|
import { CompassHeadingController } from '../sensor/compassHeadingController'
|
|
import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
|
|
import { type MapRendererStats } from '../renderer/mapRenderer'
|
|
import { worldTileToLonLat, type LonLatPoint } from '../../utils/projection'
|
|
import { 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 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
|
|
}
|
|
|
|
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',
|
|
]
|
|
|
|
function buildCenterText(zoom: number, x: number, y: number): string {
|
|
return `z${zoom} / x${x} / y${y}`
|
|
}
|
|
|
|
function clamp(value: number, min: number, max: number): number {
|
|
return Math.max(min, Math.min(max, value))
|
|
}
|
|
|
|
function normalizeRotationDeg(rotationDeg: number): number {
|
|
const normalized = rotationDeg % 360
|
|
return normalized < 0 ? normalized + 360 : normalized
|
|
}
|
|
|
|
function normalizeAngleDeltaRad(angleDeltaRad: number): number {
|
|
let normalized = angleDeltaRad
|
|
|
|
while (normalized > Math.PI) {
|
|
normalized -= Math.PI * 2
|
|
}
|
|
|
|
while (normalized < -Math.PI) {
|
|
normalized += Math.PI * 2
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
function normalizeAngleDeltaDeg(angleDeltaDeg: number): number {
|
|
let normalized = angleDeltaDeg
|
|
|
|
while (normalized > 180) {
|
|
normalized -= 360
|
|
}
|
|
|
|
while (normalized < -180) {
|
|
normalized += 360
|
|
}
|
|
|
|
return normalized
|
|
}
|
|
|
|
function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: number): number {
|
|
return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
|
|
}
|
|
|
|
function formatRotationText(rotationDeg: number): string {
|
|
return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
|
|
}
|
|
|
|
function formatHeadingText(headingDeg: number | null): string {
|
|
if (headingDeg === null) {
|
|
return '--'
|
|
}
|
|
|
|
return `${Math.round(normalizeRotationDeg(headingDeg))}deg`
|
|
}
|
|
|
|
function formatOrientationModeText(mode: OrientationMode): string {
|
|
if (mode === 'north-up') {
|
|
return 'North Up'
|
|
}
|
|
|
|
if (mode === 'heading-up') {
|
|
return 'Heading Up'
|
|
}
|
|
|
|
return 'Manual Gesture'
|
|
}
|
|
|
|
function formatRotationModeText(mode: OrientationMode): string {
|
|
return formatOrientationModeText(mode)
|
|
}
|
|
|
|
function formatRotationToggleText(mode: OrientationMode): string {
|
|
if (mode === 'manual') {
|
|
return '切到北朝上'
|
|
}
|
|
|
|
if (mode === 'north-up') {
|
|
return '切到朝向朝上'
|
|
}
|
|
|
|
return '切到手动旋转'
|
|
}
|
|
|
|
function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
|
|
if (mode === 'sensor') {
|
|
return 'Sensor Only'
|
|
}
|
|
|
|
if (mode === 'course') {
|
|
return hasCourseHeading ? 'Course Only' : 'Course Pending'
|
|
}
|
|
|
|
return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
|
|
}
|
|
|
|
function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string {
|
|
if (pending) {
|
|
return 'Pending'
|
|
}
|
|
|
|
if (offsetDeg === null) {
|
|
return '--'
|
|
}
|
|
|
|
return `Offset ${Math.round(normalizeRotationDeg(offsetDeg))}deg`
|
|
}
|
|
|
|
function getTrueHeadingDeg(magneticHeadingDeg: number): number {
|
|
return normalizeRotationDeg(magneticHeadingDeg + MAGNETIC_DECLINATION_DEG)
|
|
}
|
|
|
|
function getMagneticHeadingDeg(trueHeadingDeg: number): number {
|
|
return normalizeRotationDeg(trueHeadingDeg - MAGNETIC_DECLINATION_DEG)
|
|
}
|
|
|
|
function getMapNorthOffsetDeg(_mode: NorthReferenceMode): number {
|
|
return MAP_NORTH_OFFSET_DEG
|
|
}
|
|
|
|
function getCompassReferenceHeadingDeg(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
|
|
if (mode === 'true') {
|
|
return getTrueHeadingDeg(magneticHeadingDeg)
|
|
}
|
|
|
|
return normalizeRotationDeg(magneticHeadingDeg)
|
|
}
|
|
|
|
function getMapReferenceHeadingDegFromSensor(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
|
|
if (mode === 'magnetic') {
|
|
return normalizeRotationDeg(magneticHeadingDeg)
|
|
}
|
|
|
|
return getTrueHeadingDeg(magneticHeadingDeg)
|
|
}
|
|
|
|
function getMapReferenceHeadingDegFromCourse(mode: NorthReferenceMode, trueHeadingDeg: number): number {
|
|
if (mode === 'magnetic') {
|
|
return getMagneticHeadingDeg(trueHeadingDeg)
|
|
}
|
|
|
|
return normalizeRotationDeg(trueHeadingDeg)
|
|
}
|
|
|
|
function formatNorthReferenceText(mode: NorthReferenceMode): string {
|
|
if (mode === 'magnetic') {
|
|
return `Compass Magnetic / Heading-Up Magnetic (${MAGNETIC_DECLINATION_TEXT})`
|
|
}
|
|
|
|
return `Compass True / Heading-Up True (${MAGNETIC_DECLINATION_TEXT})`
|
|
}
|
|
|
|
function formatCompassDeclinationText(mode: NorthReferenceMode): string {
|
|
if (mode === 'true') {
|
|
return MAGNETIC_DECLINATION_TEXT
|
|
}
|
|
|
|
return ''
|
|
}
|
|
|
|
function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
|
|
return mode === 'magnetic' ? '北参考:磁北' : '北参考:真北'
|
|
}
|
|
|
|
function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
|
|
if (mode === 'magnetic') {
|
|
return '已切到磁北模式'
|
|
}
|
|
|
|
return '已切到真北模式'
|
|
}
|
|
|
|
function getNextNorthReferenceMode(mode: NorthReferenceMode): NorthReferenceMode {
|
|
return mode === 'magnetic' ? 'true' : 'magnetic'
|
|
}
|
|
|
|
function formatCompassNeedleDegForMode(mode: NorthReferenceMode, magneticHeadingDeg: number | null): number {
|
|
if (magneticHeadingDeg === null) {
|
|
return 0
|
|
}
|
|
|
|
const referenceHeadingDeg = mode === 'true'
|
|
? getTrueHeadingDeg(magneticHeadingDeg)
|
|
: normalizeRotationDeg(magneticHeadingDeg)
|
|
|
|
return normalizeRotationDeg(360 - referenceHeadingDeg)
|
|
}
|
|
|
|
function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networkFetchCount: number): string {
|
|
const total = memoryHitCount + diskHitCount + networkFetchCount
|
|
if (!total) {
|
|
return '--'
|
|
}
|
|
|
|
const hitRate = ((memoryHitCount + diskHitCount) / total) * 100
|
|
return `${Math.round(hitRate)}%`
|
|
}
|
|
export class MapEngine {
|
|
buildVersion: string
|
|
renderer: WebGLMapRenderer
|
|
compassController: CompassHeadingController
|
|
onData: (patch: Partial<MapEngineViewState>) => void
|
|
state: MapEngineViewState
|
|
previewScale: number
|
|
previewOriginX: number
|
|
previewOriginY: number
|
|
panLastX: number
|
|
panLastY: number
|
|
panLastTimestamp: number
|
|
panVelocityX: number
|
|
panVelocityY: number
|
|
pinchStartDistance: number
|
|
pinchStartScale: number
|
|
pinchStartAngle: number
|
|
pinchStartRotationDeg: number
|
|
pinchAnchorWorldX: number
|
|
pinchAnchorWorldY: number
|
|
gestureMode: GestureMode
|
|
inertiaTimer: number
|
|
previewResetTimer: number
|
|
viewSyncTimer: number
|
|
autoRotateTimer: number
|
|
pendingViewPatch: Partial<MapEngineViewState>
|
|
mounted: boolean
|
|
northReferenceMode: NorthReferenceMode
|
|
sensorHeadingDeg: number | null
|
|
smoothedSensorHeadingDeg: number | null
|
|
compassDisplayHeadingDeg: number | null
|
|
autoRotateHeadingDeg: number | null
|
|
courseHeadingDeg: number | null
|
|
targetAutoRotationDeg: number | null
|
|
autoRotateSourceMode: AutoRotateSourceMode
|
|
autoRotateCalibrationOffsetDeg: number | null
|
|
autoRotateCalibrationPending: boolean
|
|
minZoom: number
|
|
maxZoom: number
|
|
defaultZoom: number
|
|
defaultCenterTileX: number
|
|
defaultCenterTileY: number
|
|
tileBoundsByZoom: Record<number, TileZoomBounds> | null
|
|
|
|
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.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.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})`,
|
|
}
|
|
this.previewScale = 1
|
|
this.previewOriginX = 0
|
|
this.previewOriginY = 0
|
|
this.panLastX = 0
|
|
this.panLastY = 0
|
|
this.panLastTimestamp = 0
|
|
this.panVelocityX = 0
|
|
this.panVelocityY = 0
|
|
this.pinchStartDistance = 0
|
|
this.pinchStartScale = 1
|
|
this.pinchStartAngle = 0
|
|
this.pinchStartRotationDeg = 0
|
|
this.pinchAnchorWorldX = 0
|
|
this.pinchAnchorWorldY = 0
|
|
this.gestureMode = 'idle'
|
|
this.inertiaTimer = 0
|
|
this.previewResetTimer = 0
|
|
this.viewSyncTimer = 0
|
|
this.autoRotateTimer = 0
|
|
this.pendingViewPatch = {}
|
|
this.mounted = false
|
|
this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
|
|
this.sensorHeadingDeg = null
|
|
this.smoothedSensorHeadingDeg = null
|
|
this.compassDisplayHeadingDeg = null
|
|
this.autoRotateHeadingDeg = null
|
|
this.courseHeadingDeg = null
|
|
this.targetAutoRotationDeg = null
|
|
this.autoRotateSourceMode = 'fusion'
|
|
this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
|
|
this.autoRotateCalibrationPending = false
|
|
}
|
|
|
|
getInitialData(): MapEngineViewState {
|
|
return { ...this.state }
|
|
}
|
|
|
|
destroy(): void {
|
|
this.clearInertiaTimer()
|
|
this.clearPreviewResetTimer()
|
|
this.clearViewSyncTimer()
|
|
this.clearAutoRotateTimer()
|
|
this.compassController.destroy()
|
|
this.renderer.destroy()
|
|
this.mounted = false
|
|
}
|
|
|
|
setStage(rect: MapEngineStageRect): void {
|
|
this.previewScale = 1
|
|
this.previewOriginX = rect.width / 2
|
|
this.previewOriginY = rect.height / 2
|
|
this.commitViewport(
|
|
{
|
|
stageWidth: rect.width,
|
|
stageHeight: rect.height,
|
|
stageLeft: rect.left,
|
|
stageTop: rect.top,
|
|
},
|
|
`地图视口与 WebGL 引擎已对齐 (${this.buildVersion})`,
|
|
true,
|
|
)
|
|
}
|
|
|
|
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
|
|
this.renderer.attachCanvas(canvasNode, width, height, dpr)
|
|
this.mounted = true
|
|
this.state.mapReady = true
|
|
this.state.mapReadyText = 'READY'
|
|
this.onData({
|
|
mapReady: true,
|
|
mapReadyText: 'READY',
|
|
statusText: `单 WebGL 管线已就绪,可切换手动或自动朝向 (${this.buildVersion})`,
|
|
})
|
|
this.syncRenderer()
|
|
this.compassController.start()
|
|
}
|
|
|
|
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: SAMPLE_TRACK_WGS84,
|
|
gpsPoint: SAMPLE_GPS_WGS84,
|
|
}
|
|
}
|
|
|
|
syncRenderer(): void {
|
|
if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) {
|
|
return
|
|
}
|
|
|
|
this.renderer.updateScene(this.buildScene())
|
|
}
|
|
|
|
getCameraState(rotationDeg = this.state.rotationDeg): CameraState {
|
|
return {
|
|
centerWorldX: this.state.centerTileX,
|
|
centerWorldY: this.state.centerTileY,
|
|
viewportWidth: this.state.stageWidth,
|
|
viewportHeight: this.state.stageHeight,
|
|
visibleColumns: DESIRED_VISIBLE_COLUMNS,
|
|
translateX: this.state.tileTranslateX,
|
|
translateY: this.state.tileTranslateY,
|
|
rotationRad: this.getRotationRad(rotationDeg),
|
|
}
|
|
}
|
|
|
|
getRotationRad(rotationDeg = this.state.rotationDeg): number {
|
|
return normalizeRotationDeg(rotationDeg) * Math.PI / 180
|
|
}
|
|
|
|
getBaseCamera(centerWorldX = this.state.centerTileX, centerWorldY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState {
|
|
return {
|
|
centerWorldX,
|
|
centerWorldY,
|
|
viewportWidth: this.state.stageWidth,
|
|
viewportHeight: this.state.stageHeight,
|
|
visibleColumns: DESIRED_VISIBLE_COLUMNS,
|
|
rotationRad: this.getRotationRad(rotationDeg),
|
|
}
|
|
}
|
|
|
|
getWorldOffsetFromScreen(stageX: number, stageY: number, rotationDeg = this.state.rotationDeg): { x: number; y: number } {
|
|
const baseCamera = {
|
|
centerWorldX: 0,
|
|
centerWorldY: 0,
|
|
viewportWidth: this.state.stageWidth,
|
|
viewportHeight: this.state.stageHeight,
|
|
visibleColumns: DESIRED_VISIBLE_COLUMNS,
|
|
rotationRad: this.getRotationRad(rotationDeg),
|
|
}
|
|
|
|
return screenToWorld(baseCamera, { x: stageX, y: stageY }, false)
|
|
}
|
|
|
|
getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } {
|
|
if (!this.state.stageWidth || !this.state.stageHeight) {
|
|
return {
|
|
x: this.state.centerTileX,
|
|
y: this.state.centerTileY,
|
|
}
|
|
}
|
|
|
|
const screenCenterX = this.state.stageWidth / 2
|
|
const screenCenterY = this.state.stageHeight / 2
|
|
return screenToWorld(this.getBaseCamera(), {
|
|
x: screenCenterX - translateX,
|
|
y: screenCenterY - translateY,
|
|
}, false)
|
|
}
|
|
|
|
resolveViewportForExactCenter(centerWorldX: number, centerWorldY: number, rotationDeg = this.state.rotationDeg): {
|
|
centerTileX: number
|
|
centerTileY: number
|
|
tileTranslateX: number
|
|
tileTranslateY: number
|
|
} {
|
|
const nextCenterTileX = Math.round(centerWorldX)
|
|
const nextCenterTileY = Math.round(centerWorldY)
|
|
|
|
if (!this.state.stageWidth || !this.state.stageHeight) {
|
|
return {
|
|
centerTileX: nextCenterTileX,
|
|
centerTileY: nextCenterTileY,
|
|
tileTranslateX: 0,
|
|
tileTranslateY: 0,
|
|
}
|
|
}
|
|
|
|
const roundedCamera = this.getBaseCamera(nextCenterTileX, nextCenterTileY, rotationDeg)
|
|
const projectedCenter = worldToScreen(roundedCamera, { x: centerWorldX, y: centerWorldY }, false)
|
|
|
|
return {
|
|
centerTileX: nextCenterTileX,
|
|
centerTileY: nextCenterTileY,
|
|
tileTranslateX: this.state.stageWidth / 2 - projectedCenter.x,
|
|
tileTranslateY: this.state.stageHeight / 2 - projectedCenter.y,
|
|
}
|
|
}
|
|
|
|
setPreviewState(scale: number, originX: number, originY: number): void {
|
|
this.previewScale = scale
|
|
this.previewOriginX = originX
|
|
this.previewOriginY = originY
|
|
}
|
|
|
|
resetPreviewState(): void {
|
|
this.setPreviewState(1, this.state.stageWidth / 2, this.state.stageHeight / 2)
|
|
}
|
|
|
|
resetPinchState(): void {
|
|
this.pinchStartDistance = 0
|
|
this.pinchStartScale = 1
|
|
this.pinchStartAngle = 0
|
|
this.pinchStartRotationDeg = this.state.rotationDeg
|
|
this.pinchAnchorWorldX = 0
|
|
this.pinchAnchorWorldY = 0
|
|
}
|
|
|
|
clearPreviewResetTimer(): void {
|
|
if (this.previewResetTimer) {
|
|
clearTimeout(this.previewResetTimer)
|
|
this.previewResetTimer = 0
|
|
}
|
|
}
|
|
|
|
clearInertiaTimer(): void {
|
|
if (this.inertiaTimer) {
|
|
clearTimeout(this.inertiaTimer)
|
|
this.inertiaTimer = 0
|
|
}
|
|
}
|
|
|
|
clearViewSyncTimer(): void {
|
|
if (this.viewSyncTimer) {
|
|
clearTimeout(this.viewSyncTimer)
|
|
this.viewSyncTimer = 0
|
|
}
|
|
}
|
|
|
|
clearAutoRotateTimer(): void {
|
|
if (this.autoRotateTimer) {
|
|
clearTimeout(this.autoRotateTimer)
|
|
this.autoRotateTimer = 0
|
|
}
|
|
}
|
|
pickViewPatch(patch: Partial<MapEngineViewState>): Partial<MapEngineViewState> {
|
|
const viewPatch = {} as Partial<MapEngineViewState>
|
|
for (const key of VIEW_SYNC_KEYS) {
|
|
if (Object.prototype.hasOwnProperty.call(patch, key)) {
|
|
;(viewPatch as any)[key] = patch[key]
|
|
}
|
|
}
|
|
return viewPatch
|
|
}
|
|
|
|
flushViewPatch(): void {
|
|
if (!Object.keys(this.pendingViewPatch).length) {
|
|
return
|
|
}
|
|
|
|
const patch = this.pendingViewPatch
|
|
this.pendingViewPatch = {}
|
|
this.onData(patch)
|
|
}
|
|
|
|
getTouchDistance(touches: TouchPoint[]): number {
|
|
if (touches.length < 2) {
|
|
return 0
|
|
}
|
|
|
|
const first = touches[0]
|
|
const second = touches[1]
|
|
const deltaX = first.pageX - second.pageX
|
|
const deltaY = first.pageY - second.pageY
|
|
return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
|
|
}
|
|
|
|
getTouchAngle(touches: TouchPoint[]): number {
|
|
if (touches.length < 2) {
|
|
return 0
|
|
}
|
|
|
|
const first = touches[0]
|
|
const second = touches[1]
|
|
return Math.atan2(second.pageY - first.pageY, second.pageX - first.pageX)
|
|
}
|
|
|
|
getStagePoint(touches: TouchPoint[]): { x: number; y: number } {
|
|
if (!touches.length) {
|
|
return {
|
|
x: this.state.stageWidth / 2,
|
|
y: this.state.stageHeight / 2,
|
|
}
|
|
}
|
|
|
|
let pageX = 0
|
|
let pageY = 0
|
|
for (const touch of touches) {
|
|
pageX += touch.pageX
|
|
pageY += touch.pageY
|
|
}
|
|
|
|
return {
|
|
x: pageX / touches.length - this.state.stageLeft,
|
|
y: pageY / touches.length - this.state.stageTop,
|
|
}
|
|
}
|
|
|
|
animatePreviewToRest(): void {
|
|
this.clearPreviewResetTimer()
|
|
|
|
const startScale = this.previewScale || 1
|
|
const originX = this.previewOriginX || this.state.stageWidth / 2
|
|
const originY = this.previewOriginY || this.state.stageHeight / 2
|
|
|
|
if (Math.abs(startScale - 1) < 0.01) {
|
|
this.resetPreviewState()
|
|
this.syncRenderer()
|
|
this.compassController.start()
|
|
this.scheduleAutoRotate()
|
|
return
|
|
}
|
|
|
|
const startAt = Date.now()
|
|
const step = () => {
|
|
const progress = Math.min(1, (Date.now() - startAt) / PREVIEW_RESET_DURATION_MS)
|
|
const eased = 1 - Math.pow(1 - progress, 3)
|
|
const nextScale = startScale + (1 - startScale) * eased
|
|
this.setPreviewState(nextScale, originX, originY)
|
|
this.syncRenderer()
|
|
this.compassController.start()
|
|
|
|
if (progress >= 1) {
|
|
this.resetPreviewState()
|
|
this.syncRenderer()
|
|
this.compassController.start()
|
|
this.previewResetTimer = 0
|
|
this.scheduleAutoRotate()
|
|
return
|
|
}
|
|
|
|
this.previewResetTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
|
|
}
|
|
|
|
step()
|
|
}
|
|
|
|
normalizeTranslate(translateX: number, translateY: number, statusText: string): void {
|
|
if (!this.state.stageWidth) {
|
|
this.setState({
|
|
tileTranslateX: translateX,
|
|
tileTranslateY: translateY,
|
|
})
|
|
this.syncRenderer()
|
|
this.compassController.start()
|
|
return
|
|
}
|
|
|
|
const exactCenter = this.getExactCenterFromTranslate(translateX, translateY)
|
|
const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y)
|
|
const centerChanged = resolvedViewport.centerTileX !== this.state.centerTileX || resolvedViewport.centerTileY !== this.state.centerTileY
|
|
|
|
if (centerChanged) {
|
|
this.commitViewport(resolvedViewport, statusText)
|
|
return
|
|
}
|
|
|
|
this.setState({
|
|
tileTranslateX: resolvedViewport.tileTranslateX,
|
|
tileTranslateY: resolvedViewport.tileTranslateY,
|
|
})
|
|
this.syncRenderer()
|
|
this.compassController.start()
|
|
}
|
|
|
|
zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void {
|
|
const nextZoom = clamp(this.state.zoom + zoomDelta, 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
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|