mapEngine.ts 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586
  1. import { getTileSizePx, screenToWorld, worldToScreen, type CameraState } from '../camera/camera'
  2. import { CompassHeadingController } from '../sensor/compassHeadingController'
  3. import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
  4. import { type MapRendererStats } from '../renderer/mapRenderer'
  5. import { worldTileToLonLat, type LonLatPoint } from '../../utils/projection'
  6. const RENDER_MODE = 'Single WebGL Pipeline'
  7. const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
  8. const MAP_NORTH_OFFSET_DEG = 0
  9. const MAGNETIC_DECLINATION_DEG = -6.91
  10. const MAGNETIC_DECLINATION_TEXT = '6.91° W'
  11. const MIN_ZOOM = 15
  12. const MAX_ZOOM = 20
  13. const DEFAULT_ZOOM = 17
  14. const DESIRED_VISIBLE_COLUMNS = 3
  15. const OVERDRAW = 1
  16. const DEFAULT_TOP_LEFT_TILE_X = 108132
  17. const DEFAULT_TOP_LEFT_TILE_Y = 51199
  18. const DEFAULT_CENTER_TILE_X = DEFAULT_TOP_LEFT_TILE_X + 1
  19. const DEFAULT_CENTER_TILE_Y = DEFAULT_TOP_LEFT_TILE_Y + 1
  20. const TILE_SOURCE = 'https://oss-mbh5.colormaprun.com/wxMap/lcx/{z}/{x}/{y}.png'
  21. const MIN_PREVIEW_SCALE = 0.55
  22. const MAX_PREVIEW_SCALE = 1.85
  23. const INERTIA_FRAME_MS = 16
  24. const INERTIA_DECAY = 0.92
  25. const INERTIA_MIN_SPEED = 0.02
  26. const PREVIEW_RESET_DURATION_MS = 140
  27. const UI_SYNC_INTERVAL_MS = 80
  28. const ROTATE_STEP_DEG = 15
  29. const AUTO_ROTATE_FRAME_MS = 8
  30. const AUTO_ROTATE_EASE = 0.34
  31. const AUTO_ROTATE_SNAP_DEG = 0.1
  32. const AUTO_ROTATE_DEADZONE_DEG = 4
  33. const AUTO_ROTATE_MAX_STEP_DEG = 0.75
  34. const AUTO_ROTATE_HEADING_SMOOTHING = 0.32
  35. const COMPASS_NEEDLE_SMOOTHING = 0.12
  36. const SAMPLE_TRACK_WGS84: LonLatPoint[] = [
  37. worldTileToLonLat({ x: DEFAULT_CENTER_TILE_X - 0.72, y: DEFAULT_CENTER_TILE_Y + 0.44 }, DEFAULT_ZOOM),
  38. worldTileToLonLat({ x: DEFAULT_CENTER_TILE_X - 0.18, y: DEFAULT_CENTER_TILE_Y + 0.08 }, DEFAULT_ZOOM),
  39. worldTileToLonLat({ x: DEFAULT_CENTER_TILE_X + 0.22, y: DEFAULT_CENTER_TILE_Y - 0.16 }, DEFAULT_ZOOM),
  40. worldTileToLonLat({ x: DEFAULT_CENTER_TILE_X + 0.64, y: DEFAULT_CENTER_TILE_Y - 0.52 }, DEFAULT_ZOOM),
  41. ]
  42. const SAMPLE_GPS_WGS84: LonLatPoint = worldTileToLonLat(
  43. { x: DEFAULT_CENTER_TILE_X + 0.12, y: DEFAULT_CENTER_TILE_Y - 0.06 },
  44. DEFAULT_ZOOM,
  45. )
  46. type TouchPoint = WechatMiniprogram.TouchDetail
  47. type GestureMode = 'idle' | 'pan' | 'pinch'
  48. type RotationMode = 'manual' | 'auto'
  49. type OrientationMode = 'manual' | 'north-up' | 'heading-up'
  50. type AutoRotateSourceMode = 'sensor' | 'course' | 'fusion'
  51. type NorthReferenceMode = 'magnetic' | 'true'
  52. const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic'
  53. export interface MapEngineStageRect {
  54. width: number
  55. height: number
  56. left: number
  57. top: number
  58. }
  59. export interface MapEngineViewState {
  60. buildVersion: string
  61. renderMode: string
  62. projectionMode: string
  63. mapReady: boolean
  64. mapReadyText: string
  65. mapName: string
  66. zoom: number
  67. rotationDeg: number
  68. rotationText: string
  69. rotationMode: RotationMode
  70. rotationModeText: string
  71. rotationToggleText: string
  72. orientationMode: OrientationMode
  73. orientationModeText: string
  74. sensorHeadingText: string
  75. compassDeclinationText: string
  76. northReferenceButtonText: string
  77. autoRotateSourceText: string
  78. autoRotateCalibrationText: string
  79. northReferenceText: string
  80. compassNeedleDeg: number
  81. centerTileX: number
  82. centerTileY: number
  83. centerText: string
  84. tileSource: string
  85. visibleColumnCount: number
  86. visibleTileCount: number
  87. readyTileCount: number
  88. memoryTileCount: number
  89. diskTileCount: number
  90. memoryHitCount: number
  91. diskHitCount: number
  92. networkFetchCount: number
  93. cacheHitRateText: string
  94. tileTranslateX: number
  95. tileTranslateY: number
  96. tileSizePx: number
  97. stageWidth: number
  98. stageHeight: number
  99. stageLeft: number
  100. stageTop: number
  101. statusText: string
  102. }
  103. export interface MapEngineCallbacks {
  104. onData: (patch: Partial<MapEngineViewState>) => void
  105. }
  106. const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
  107. 'buildVersion',
  108. 'renderMode',
  109. 'projectionMode',
  110. 'mapReady',
  111. 'mapReadyText',
  112. 'mapName',
  113. 'zoom',
  114. 'rotationDeg',
  115. 'rotationText',
  116. 'rotationMode',
  117. 'rotationModeText',
  118. 'rotationToggleText',
  119. 'orientationMode',
  120. 'orientationModeText',
  121. 'sensorHeadingText',
  122. 'compassDeclinationText',
  123. 'northReferenceButtonText',
  124. 'autoRotateSourceText',
  125. 'autoRotateCalibrationText',
  126. 'northReferenceText',
  127. 'compassNeedleDeg',
  128. 'centerText',
  129. 'tileSource',
  130. 'visibleTileCount',
  131. 'readyTileCount',
  132. 'memoryTileCount',
  133. 'diskTileCount',
  134. 'memoryHitCount',
  135. 'diskHitCount',
  136. 'networkFetchCount',
  137. 'cacheHitRateText',
  138. 'tileSizePx',
  139. 'statusText',
  140. ]
  141. function buildCenterText(zoom: number, x: number, y: number): string {
  142. return `z${zoom} / x${x} / y${y}`
  143. }
  144. function clamp(value: number, min: number, max: number): number {
  145. return Math.max(min, Math.min(max, value))
  146. }
  147. function normalizeRotationDeg(rotationDeg: number): number {
  148. const normalized = rotationDeg % 360
  149. return normalized < 0 ? normalized + 360 : normalized
  150. }
  151. function normalizeAngleDeltaRad(angleDeltaRad: number): number {
  152. let normalized = angleDeltaRad
  153. while (normalized > Math.PI) {
  154. normalized -= Math.PI * 2
  155. }
  156. while (normalized < -Math.PI) {
  157. normalized += Math.PI * 2
  158. }
  159. return normalized
  160. }
  161. function normalizeAngleDeltaDeg(angleDeltaDeg: number): number {
  162. let normalized = angleDeltaDeg
  163. while (normalized > 180) {
  164. normalized -= 360
  165. }
  166. while (normalized < -180) {
  167. normalized += 360
  168. }
  169. return normalized
  170. }
  171. function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: number): number {
  172. return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
  173. }
  174. function formatRotationText(rotationDeg: number): string {
  175. return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
  176. }
  177. function formatHeadingText(headingDeg: number | null): string {
  178. if (headingDeg === null) {
  179. return '--'
  180. }
  181. return `${Math.round(normalizeRotationDeg(headingDeg))}deg`
  182. }
  183. function formatOrientationModeText(mode: OrientationMode): string {
  184. if (mode === 'north-up') {
  185. return 'North Up'
  186. }
  187. if (mode === 'heading-up') {
  188. return 'Heading Up'
  189. }
  190. return 'Manual Gesture'
  191. }
  192. function formatRotationModeText(mode: OrientationMode): string {
  193. return formatOrientationModeText(mode)
  194. }
  195. function formatRotationToggleText(mode: OrientationMode): string {
  196. if (mode === 'manual') {
  197. return '切到北朝上'
  198. }
  199. if (mode === 'north-up') {
  200. return '切到朝向朝上'
  201. }
  202. return '切到手动旋转'
  203. }
  204. function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
  205. if (mode === 'sensor') {
  206. return 'Sensor Only'
  207. }
  208. if (mode === 'course') {
  209. return hasCourseHeading ? 'Course Only' : 'Course Pending'
  210. }
  211. return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
  212. }
  213. function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string {
  214. if (pending) {
  215. return 'Pending'
  216. }
  217. if (offsetDeg === null) {
  218. return '--'
  219. }
  220. return `Offset ${Math.round(normalizeRotationDeg(offsetDeg))}deg`
  221. }
  222. function getTrueHeadingDeg(magneticHeadingDeg: number): number {
  223. return normalizeRotationDeg(magneticHeadingDeg + MAGNETIC_DECLINATION_DEG)
  224. }
  225. function getMagneticHeadingDeg(trueHeadingDeg: number): number {
  226. return normalizeRotationDeg(trueHeadingDeg - MAGNETIC_DECLINATION_DEG)
  227. }
  228. function getMapNorthOffsetDeg(_mode: NorthReferenceMode): number {
  229. return MAP_NORTH_OFFSET_DEG
  230. }
  231. function getCompassReferenceHeadingDeg(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  232. if (mode === 'true') {
  233. return getTrueHeadingDeg(magneticHeadingDeg)
  234. }
  235. return normalizeRotationDeg(magneticHeadingDeg)
  236. }
  237. function getMapReferenceHeadingDegFromSensor(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  238. if (mode === 'magnetic') {
  239. return normalizeRotationDeg(magneticHeadingDeg)
  240. }
  241. return getTrueHeadingDeg(magneticHeadingDeg)
  242. }
  243. function getMapReferenceHeadingDegFromCourse(mode: NorthReferenceMode, trueHeadingDeg: number): number {
  244. if (mode === 'magnetic') {
  245. return getMagneticHeadingDeg(trueHeadingDeg)
  246. }
  247. return normalizeRotationDeg(trueHeadingDeg)
  248. }
  249. function formatNorthReferenceText(mode: NorthReferenceMode): string {
  250. if (mode === 'magnetic') {
  251. return `Compass Magnetic / Heading-Up Magnetic (${MAGNETIC_DECLINATION_TEXT})`
  252. }
  253. return `Compass True / Heading-Up True (${MAGNETIC_DECLINATION_TEXT})`
  254. }
  255. function formatCompassDeclinationText(mode: NorthReferenceMode): string {
  256. if (mode === 'true') {
  257. return MAGNETIC_DECLINATION_TEXT
  258. }
  259. return ''
  260. }
  261. function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
  262. return mode === 'magnetic' ? '北参考:磁北' : '北参考:真北'
  263. }
  264. function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
  265. if (mode === 'magnetic') {
  266. return '已切到磁北模式'
  267. }
  268. return '已切到真北模式'
  269. }
  270. function getNextNorthReferenceMode(mode: NorthReferenceMode): NorthReferenceMode {
  271. return mode === 'magnetic' ? 'true' : 'magnetic'
  272. }
  273. function formatCompassNeedleDegForMode(mode: NorthReferenceMode, magneticHeadingDeg: number | null): number {
  274. if (magneticHeadingDeg === null) {
  275. return 0
  276. }
  277. const referenceHeadingDeg = mode === 'true'
  278. ? getTrueHeadingDeg(magneticHeadingDeg)
  279. : normalizeRotationDeg(magneticHeadingDeg)
  280. return normalizeRotationDeg(360 - referenceHeadingDeg)
  281. }
  282. function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networkFetchCount: number): string {
  283. const total = memoryHitCount + diskHitCount + networkFetchCount
  284. if (!total) {
  285. return '--'
  286. }
  287. const hitRate = ((memoryHitCount + diskHitCount) / total) * 100
  288. return `${Math.round(hitRate)}%`
  289. }
  290. export class MapEngine {
  291. buildVersion: string
  292. renderer: WebGLMapRenderer
  293. compassController: CompassHeadingController
  294. onData: (patch: Partial<MapEngineViewState>) => void
  295. state: MapEngineViewState
  296. previewScale: number
  297. previewOriginX: number
  298. previewOriginY: number
  299. panLastX: number
  300. panLastY: number
  301. panLastTimestamp: number
  302. panVelocityX: number
  303. panVelocityY: number
  304. pinchStartDistance: number
  305. pinchStartScale: number
  306. pinchStartAngle: number
  307. pinchStartRotationDeg: number
  308. pinchAnchorWorldX: number
  309. pinchAnchorWorldY: number
  310. gestureMode: GestureMode
  311. inertiaTimer: number
  312. previewResetTimer: number
  313. viewSyncTimer: number
  314. autoRotateTimer: number
  315. pendingViewPatch: Partial<MapEngineViewState>
  316. mounted: boolean
  317. northReferenceMode: NorthReferenceMode
  318. sensorHeadingDeg: number | null
  319. smoothedSensorHeadingDeg: number | null
  320. compassDisplayHeadingDeg: number | null
  321. autoRotateHeadingDeg: number | null
  322. courseHeadingDeg: number | null
  323. targetAutoRotationDeg: number | null
  324. autoRotateSourceMode: AutoRotateSourceMode
  325. autoRotateCalibrationOffsetDeg: number | null
  326. autoRotateCalibrationPending: boolean
  327. constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
  328. this.buildVersion = buildVersion
  329. this.onData = callbacks.onData
  330. this.renderer = new WebGLMapRenderer(
  331. (stats) => {
  332. this.applyStats(stats)
  333. },
  334. (message) => {
  335. this.setState({
  336. statusText: `${message} (${this.buildVersion})`,
  337. })
  338. },
  339. )
  340. this.compassController = new CompassHeadingController({
  341. onHeading: (headingDeg) => {
  342. this.handleCompassHeading(headingDeg)
  343. },
  344. onError: (message) => {
  345. this.handleCompassError(message)
  346. },
  347. })
  348. this.state = {
  349. buildVersion: this.buildVersion,
  350. renderMode: RENDER_MODE,
  351. projectionMode: PROJECTION_MODE,
  352. mapReady: false,
  353. mapReadyText: 'BOOTING',
  354. mapName: 'LCX 测试地图',
  355. zoom: DEFAULT_ZOOM,
  356. rotationDeg: 0,
  357. rotationText: formatRotationText(0),
  358. rotationMode: 'manual',
  359. rotationModeText: formatRotationModeText('manual'),
  360. rotationToggleText: formatRotationToggleText('manual'),
  361. orientationMode: 'manual',
  362. orientationModeText: formatOrientationModeText('manual'),
  363. sensorHeadingText: '--',
  364. compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
  365. northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
  366. autoRotateSourceText: formatAutoRotateSourceText('fusion', false),
  367. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
  368. northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
  369. compassNeedleDeg: 0,
  370. centerTileX: DEFAULT_CENTER_TILE_X,
  371. centerTileY: DEFAULT_CENTER_TILE_Y,
  372. centerText: buildCenterText(DEFAULT_ZOOM, DEFAULT_CENTER_TILE_X, DEFAULT_CENTER_TILE_Y),
  373. tileSource: TILE_SOURCE,
  374. visibleColumnCount: DESIRED_VISIBLE_COLUMNS,
  375. visibleTileCount: 0,
  376. readyTileCount: 0,
  377. memoryTileCount: 0,
  378. diskTileCount: 0,
  379. memoryHitCount: 0,
  380. diskHitCount: 0,
  381. networkFetchCount: 0,
  382. cacheHitRateText: '--',
  383. tileTranslateX: 0,
  384. tileTranslateY: 0,
  385. tileSizePx: 0,
  386. stageWidth: 0,
  387. stageHeight: 0,
  388. stageLeft: 0,
  389. stageTop: 0,
  390. statusText: `单 WebGL 管线已准备接入方向传感器 (${this.buildVersion})`,
  391. }
  392. this.previewScale = 1
  393. this.previewOriginX = 0
  394. this.previewOriginY = 0
  395. this.panLastX = 0
  396. this.panLastY = 0
  397. this.panLastTimestamp = 0
  398. this.panVelocityX = 0
  399. this.panVelocityY = 0
  400. this.pinchStartDistance = 0
  401. this.pinchStartScale = 1
  402. this.pinchStartAngle = 0
  403. this.pinchStartRotationDeg = 0
  404. this.pinchAnchorWorldX = 0
  405. this.pinchAnchorWorldY = 0
  406. this.gestureMode = 'idle'
  407. this.inertiaTimer = 0
  408. this.previewResetTimer = 0
  409. this.viewSyncTimer = 0
  410. this.autoRotateTimer = 0
  411. this.pendingViewPatch = {}
  412. this.mounted = false
  413. this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
  414. this.sensorHeadingDeg = null
  415. this.smoothedSensorHeadingDeg = null
  416. this.compassDisplayHeadingDeg = null
  417. this.autoRotateHeadingDeg = null
  418. this.courseHeadingDeg = null
  419. this.targetAutoRotationDeg = null
  420. this.autoRotateSourceMode = 'fusion'
  421. this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
  422. this.autoRotateCalibrationPending = false
  423. }
  424. getInitialData(): MapEngineViewState {
  425. return { ...this.state }
  426. }
  427. destroy(): void {
  428. this.clearInertiaTimer()
  429. this.clearPreviewResetTimer()
  430. this.clearViewSyncTimer()
  431. this.clearAutoRotateTimer()
  432. this.compassController.destroy()
  433. this.renderer.destroy()
  434. this.mounted = false
  435. }
  436. setStage(rect: MapEngineStageRect): void {
  437. this.previewScale = 1
  438. this.previewOriginX = rect.width / 2
  439. this.previewOriginY = rect.height / 2
  440. this.commitViewport(
  441. {
  442. stageWidth: rect.width,
  443. stageHeight: rect.height,
  444. stageLeft: rect.left,
  445. stageTop: rect.top,
  446. },
  447. `地图视口与 WebGL 引擎已对齐 (${this.buildVersion})`,
  448. true,
  449. )
  450. }
  451. attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
  452. this.renderer.attachCanvas(canvasNode, width, height, dpr)
  453. this.mounted = true
  454. this.state.mapReady = true
  455. this.state.mapReadyText = 'READY'
  456. this.onData({
  457. mapReady: true,
  458. mapReadyText: 'READY',
  459. statusText: `单 WebGL 管线已就绪,可切换手动或自动朝向 (${this.buildVersion})`,
  460. })
  461. this.syncRenderer()
  462. this.compassController.start()
  463. }
  464. handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
  465. this.clearInertiaTimer()
  466. this.clearPreviewResetTimer()
  467. this.renderer.setAnimationPaused(true)
  468. this.panVelocityX = 0
  469. this.panVelocityY = 0
  470. if (event.touches.length >= 2) {
  471. const origin = this.getStagePoint(event.touches)
  472. this.gestureMode = 'pinch'
  473. this.pinchStartDistance = this.getTouchDistance(event.touches)
  474. this.pinchStartScale = this.previewScale || 1
  475. this.pinchStartAngle = this.getTouchAngle(event.touches)
  476. this.pinchStartRotationDeg = this.state.rotationDeg
  477. const anchorWorld = screenToWorld(this.getCameraState(), origin, true)
  478. this.pinchAnchorWorldX = anchorWorld.x
  479. this.pinchAnchorWorldY = anchorWorld.y
  480. this.setPreviewState(this.pinchStartScale, origin.x, origin.y)
  481. this.syncRenderer()
  482. this.compassController.start()
  483. return
  484. }
  485. if (event.touches.length === 1) {
  486. this.gestureMode = 'pan'
  487. this.panLastX = event.touches[0].pageX
  488. this.panLastY = event.touches[0].pageY
  489. this.panLastTimestamp = event.timeStamp || Date.now()
  490. }
  491. }
  492. handleTouchMove(event: WechatMiniprogram.TouchEvent): void {
  493. if (event.touches.length >= 2) {
  494. const distance = this.getTouchDistance(event.touches)
  495. const angle = this.getTouchAngle(event.touches)
  496. const origin = this.getStagePoint(event.touches)
  497. if (!this.pinchStartDistance) {
  498. this.pinchStartDistance = distance
  499. this.pinchStartScale = this.previewScale || 1
  500. this.pinchStartAngle = angle
  501. this.pinchStartRotationDeg = this.state.rotationDeg
  502. const anchorWorld = screenToWorld(this.getCameraState(), origin, true)
  503. this.pinchAnchorWorldX = anchorWorld.x
  504. this.pinchAnchorWorldY = anchorWorld.y
  505. }
  506. this.gestureMode = 'pinch'
  507. const nextRotationDeg = this.state.orientationMode === 'heading-up'
  508. ? this.state.rotationDeg
  509. : normalizeRotationDeg(this.pinchStartRotationDeg + normalizeAngleDeltaRad(angle - this.pinchStartAngle) * 180 / Math.PI)
  510. const anchorOffset = this.getWorldOffsetFromScreen(origin.x, origin.y, nextRotationDeg)
  511. const resolvedViewport = this.resolveViewportForExactCenter(
  512. this.pinchAnchorWorldX - anchorOffset.x,
  513. this.pinchAnchorWorldY - anchorOffset.y,
  514. nextRotationDeg,
  515. )
  516. this.setPreviewState(
  517. clamp(this.pinchStartScale * (distance / this.pinchStartDistance), MIN_PREVIEW_SCALE, MAX_PREVIEW_SCALE),
  518. origin.x,
  519. origin.y,
  520. )
  521. this.commitViewport(
  522. {
  523. ...resolvedViewport,
  524. rotationDeg: nextRotationDeg,
  525. rotationText: formatRotationText(nextRotationDeg),
  526. },
  527. this.state.orientationMode === 'heading-up'
  528. ? `双指缩放中,自动朝向保持开启 (${this.buildVersion})`
  529. : `双指缩放与旋转中 (${this.buildVersion})`,
  530. )
  531. return
  532. }
  533. if (this.gestureMode !== 'pan' || event.touches.length !== 1) {
  534. return
  535. }
  536. const touch = event.touches[0]
  537. const deltaX = touch.pageX - this.panLastX
  538. const deltaY = touch.pageY - this.panLastY
  539. const nextTimestamp = event.timeStamp || Date.now()
  540. const elapsed = Math.max(nextTimestamp - this.panLastTimestamp, 16)
  541. const instantVelocityX = deltaX / elapsed
  542. const instantVelocityY = deltaY / elapsed
  543. this.panVelocityX = this.panVelocityX * 0.72 + instantVelocityX * 0.28
  544. this.panVelocityY = this.panVelocityY * 0.72 + instantVelocityY * 0.28
  545. this.panLastX = touch.pageX
  546. this.panLastY = touch.pageY
  547. this.panLastTimestamp = nextTimestamp
  548. this.normalizeTranslate(
  549. this.state.tileTranslateX + deltaX,
  550. this.state.tileTranslateY + deltaY,
  551. `已拖拽单 WebGL 地图引擎 (${this.buildVersion})`,
  552. )
  553. }
  554. handleTouchEnd(event: WechatMiniprogram.TouchEvent): void {
  555. if (this.gestureMode === 'pinch' && event.touches.length < 2) {
  556. const gestureScale = this.previewScale || 1
  557. const zoomDelta = Math.round(Math.log2(gestureScale))
  558. const originX = this.previewOriginX || this.state.stageWidth / 2
  559. const originY = this.previewOriginY || this.state.stageHeight / 2
  560. if (zoomDelta) {
  561. const residualScale = gestureScale / Math.pow(2, zoomDelta)
  562. this.zoomAroundPoint(zoomDelta, originX, originY, residualScale)
  563. } else {
  564. this.animatePreviewToRest()
  565. }
  566. this.resetPinchState()
  567. this.panVelocityX = 0
  568. this.panVelocityY = 0
  569. if (event.touches.length === 1) {
  570. this.gestureMode = 'pan'
  571. this.panLastX = event.touches[0].pageX
  572. this.panLastY = event.touches[0].pageY
  573. this.panLastTimestamp = event.timeStamp || Date.now()
  574. return
  575. }
  576. this.gestureMode = 'idle'
  577. this.renderer.setAnimationPaused(false)
  578. this.scheduleAutoRotate()
  579. return
  580. }
  581. if (event.touches.length === 1) {
  582. this.gestureMode = 'pan'
  583. this.panLastX = event.touches[0].pageX
  584. this.panLastY = event.touches[0].pageY
  585. this.panLastTimestamp = event.timeStamp || Date.now()
  586. return
  587. }
  588. if (this.gestureMode === 'pan' && (Math.abs(this.panVelocityX) >= INERTIA_MIN_SPEED || Math.abs(this.panVelocityY) >= INERTIA_MIN_SPEED)) {
  589. this.startInertia()
  590. this.gestureMode = 'idle'
  591. this.resetPinchState()
  592. return
  593. }
  594. this.gestureMode = 'idle'
  595. this.resetPinchState()
  596. this.renderer.setAnimationPaused(false)
  597. this.scheduleAutoRotate()
  598. }
  599. handleTouchCancel(): void {
  600. this.gestureMode = 'idle'
  601. this.resetPinchState()
  602. this.panVelocityX = 0
  603. this.panVelocityY = 0
  604. this.clearInertiaTimer()
  605. this.animatePreviewToRest()
  606. this.renderer.setAnimationPaused(false)
  607. this.scheduleAutoRotate()
  608. }
  609. handleRecenter(): void {
  610. this.clearInertiaTimer()
  611. this.clearPreviewResetTimer()
  612. this.panVelocityX = 0
  613. this.panVelocityY = 0
  614. this.renderer.setAnimationPaused(false)
  615. this.commitViewport(
  616. {
  617. zoom: DEFAULT_ZOOM,
  618. centerTileX: DEFAULT_CENTER_TILE_X,
  619. centerTileY: DEFAULT_CENTER_TILE_Y,
  620. tileTranslateX: 0,
  621. tileTranslateY: 0,
  622. },
  623. `已回到单 WebGL 引擎默认首屏 (${this.buildVersion})`,
  624. true,
  625. () => {
  626. this.resetPreviewState()
  627. this.syncRenderer()
  628. this.compassController.start()
  629. this.scheduleAutoRotate()
  630. },
  631. )
  632. }
  633. handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
  634. if (this.state.rotationMode === 'auto') {
  635. this.setState({
  636. statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
  637. }, true)
  638. return
  639. }
  640. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  641. const nextRotationDeg = normalizeRotationDeg(this.state.rotationDeg + stepDeg)
  642. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
  643. this.clearInertiaTimer()
  644. this.clearPreviewResetTimer()
  645. this.panVelocityX = 0
  646. this.panVelocityY = 0
  647. this.renderer.setAnimationPaused(false)
  648. this.commitViewport(
  649. {
  650. ...resolvedViewport,
  651. rotationDeg: nextRotationDeg,
  652. rotationText: formatRotationText(nextRotationDeg),
  653. },
  654. `旋转角度调整到 ${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
  655. true,
  656. () => {
  657. this.resetPreviewState()
  658. this.syncRenderer()
  659. this.compassController.start()
  660. },
  661. )
  662. }
  663. handleRotationReset(): void {
  664. if (this.state.rotationMode === 'auto') {
  665. this.setState({
  666. statusText: `当前不是手动旋转模式,请先切回手动 (${this.buildVersion})`,
  667. }, true)
  668. return
  669. }
  670. const targetRotationDeg = MAP_NORTH_OFFSET_DEG
  671. if (Math.abs(normalizeAngleDeltaDeg(this.state.rotationDeg - targetRotationDeg)) <= 0.01) {
  672. return
  673. }
  674. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  675. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, targetRotationDeg)
  676. this.clearInertiaTimer()
  677. this.clearPreviewResetTimer()
  678. this.panVelocityX = 0
  679. this.panVelocityY = 0
  680. this.renderer.setAnimationPaused(false)
  681. this.commitViewport(
  682. {
  683. ...resolvedViewport,
  684. rotationDeg: targetRotationDeg,
  685. rotationText: formatRotationText(targetRotationDeg),
  686. },
  687. `旋转角度已回到真北参考 (${this.buildVersion})`,
  688. true,
  689. () => {
  690. this.resetPreviewState()
  691. this.syncRenderer()
  692. this.compassController.start()
  693. },
  694. )
  695. }
  696. handleToggleRotationMode(): void {
  697. if (this.state.orientationMode === 'manual') {
  698. this.setNorthUpMode()
  699. return
  700. }
  701. if (this.state.orientationMode === 'north-up') {
  702. this.setHeadingUpMode()
  703. return
  704. }
  705. this.setManualMode()
  706. }
  707. handleSetManualMode(): void {
  708. this.setManualMode()
  709. }
  710. handleSetNorthUpMode(): void {
  711. this.setNorthUpMode()
  712. }
  713. handleSetHeadingUpMode(): void {
  714. this.setHeadingUpMode()
  715. }
  716. handleCycleNorthReferenceMode(): void {
  717. this.cycleNorthReferenceMode()
  718. }
  719. handleAutoRotateCalibrate(): void {
  720. if (this.state.orientationMode !== 'heading-up') {
  721. this.setState({
  722. statusText: `请先切到朝向朝上模式再校准 (${this.buildVersion})`,
  723. }, true)
  724. return
  725. }
  726. if (!this.calibrateAutoRotateToCurrentOrientation()) {
  727. this.setState({
  728. statusText: `当前还没有传感器方向数据,暂时无法校准 (${this.buildVersion})`,
  729. }, true)
  730. return
  731. }
  732. this.setState({
  733. statusText: `已按当前持机方向完成朝向校准 (${this.buildVersion})`,
  734. }, true)
  735. this.scheduleAutoRotate()
  736. }
  737. setManualMode(): void {
  738. this.clearAutoRotateTimer()
  739. this.targetAutoRotationDeg = null
  740. this.autoRotateCalibrationPending = false
  741. this.setState({
  742. rotationMode: 'manual',
  743. rotationModeText: formatRotationModeText('manual'),
  744. rotationToggleText: formatRotationToggleText('manual'),
  745. orientationMode: 'manual',
  746. orientationModeText: formatOrientationModeText('manual'),
  747. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  748. statusText: `已切回手动地图旋转 (${this.buildVersion})`,
  749. }, true)
  750. }
  751. setNorthUpMode(): void {
  752. this.clearAutoRotateTimer()
  753. this.targetAutoRotationDeg = null
  754. this.autoRotateCalibrationPending = false
  755. const mapNorthOffsetDeg = MAP_NORTH_OFFSET_DEG
  756. this.autoRotateCalibrationOffsetDeg = mapNorthOffsetDeg
  757. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  758. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, mapNorthOffsetDeg)
  759. this.commitViewport(
  760. {
  761. ...resolvedViewport,
  762. rotationDeg: mapNorthOffsetDeg,
  763. rotationText: formatRotationText(mapNorthOffsetDeg),
  764. rotationMode: 'manual',
  765. rotationModeText: formatRotationModeText('north-up'),
  766. rotationToggleText: formatRotationToggleText('north-up'),
  767. orientationMode: 'north-up',
  768. orientationModeText: formatOrientationModeText('north-up'),
  769. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
  770. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  771. },
  772. `地图已固定为真北朝上 (${this.buildVersion})`,
  773. true,
  774. () => {
  775. this.resetPreviewState()
  776. this.syncRenderer()
  777. },
  778. )
  779. }
  780. setHeadingUpMode(): void {
  781. this.autoRotateCalibrationPending = false
  782. this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(this.northReferenceMode)
  783. this.targetAutoRotationDeg = null
  784. this.setState({
  785. rotationMode: 'auto',
  786. rotationModeText: formatRotationModeText('heading-up'),
  787. rotationToggleText: formatRotationToggleText('heading-up'),
  788. orientationMode: 'heading-up',
  789. orientationModeText: formatOrientationModeText('heading-up'),
  790. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  791. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  792. statusText: `正在启用朝向朝上模式 (${this.buildVersion})`,
  793. }, true)
  794. if (this.refreshAutoRotateTarget()) {
  795. this.scheduleAutoRotate()
  796. }
  797. }
  798. handleCompassHeading(headingDeg: number): void {
  799. this.sensorHeadingDeg = normalizeRotationDeg(headingDeg)
  800. this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null
  801. ? this.sensorHeadingDeg
  802. : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING)
  803. const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
  804. this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null
  805. ? compassHeadingDeg
  806. : interpolateAngleDeg(this.compassDisplayHeadingDeg, compassHeadingDeg, COMPASS_NEEDLE_SMOOTHING)
  807. this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  808. this.setState({
  809. sensorHeadingText: formatHeadingText(compassHeadingDeg),
  810. compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
  811. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  812. autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
  813. compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
  814. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  815. })
  816. if (!this.refreshAutoRotateTarget()) {
  817. return
  818. }
  819. if (this.state.orientationMode === 'heading-up') {
  820. this.scheduleAutoRotate()
  821. }
  822. }
  823. handleCompassError(message: string): void {
  824. this.clearAutoRotateTimer()
  825. this.targetAutoRotationDeg = null
  826. this.autoRotateCalibrationPending = false
  827. this.setState({
  828. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  829. statusText: `${message} (${this.buildVersion})`,
  830. }, true)
  831. }
  832. cycleNorthReferenceMode(): void {
  833. const nextMode = getNextNorthReferenceMode(this.northReferenceMode)
  834. const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
  835. const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
  836. ? null
  837. : getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg)
  838. this.northReferenceMode = nextMode
  839. this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg
  840. this.compassDisplayHeadingDeg = compassHeadingDeg
  841. if (this.state.orientationMode === 'north-up') {
  842. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  843. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, MAP_NORTH_OFFSET_DEG)
  844. this.commitViewport(
  845. {
  846. ...resolvedViewport,
  847. rotationDeg: MAP_NORTH_OFFSET_DEG,
  848. rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
  849. northReferenceText: formatNorthReferenceText(nextMode),
  850. sensorHeadingText: formatHeadingText(compassHeadingDeg),
  851. compassDeclinationText: formatCompassDeclinationText(nextMode),
  852. northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
  853. compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
  854. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
  855. },
  856. `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
  857. true,
  858. () => {
  859. this.resetPreviewState()
  860. this.syncRenderer()
  861. },
  862. )
  863. return
  864. }
  865. this.setState({
  866. northReferenceText: formatNorthReferenceText(nextMode),
  867. sensorHeadingText: formatHeadingText(compassHeadingDeg),
  868. compassDeclinationText: formatCompassDeclinationText(nextMode),
  869. northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
  870. compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
  871. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
  872. statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
  873. }, true)
  874. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  875. this.scheduleAutoRotate()
  876. }
  877. }
  878. setCourseHeading(headingDeg: number | null): void {
  879. this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
  880. this.setState({
  881. autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
  882. })
  883. if (this.refreshAutoRotateTarget()) {
  884. this.scheduleAutoRotate()
  885. }
  886. }
  887. resolveAutoRotateInputHeadingDeg(): number | null {
  888. const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
  889. ? null
  890. : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
  891. const courseHeadingDeg = this.courseHeadingDeg === null
  892. ? null
  893. : getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)
  894. if (this.autoRotateSourceMode === 'sensor') {
  895. return sensorHeadingDeg
  896. }
  897. if (this.autoRotateSourceMode === 'course') {
  898. return courseHeadingDeg === null ? sensorHeadingDeg : courseHeadingDeg
  899. }
  900. if (sensorHeadingDeg !== null && courseHeadingDeg !== null) {
  901. return interpolateAngleDeg(sensorHeadingDeg, courseHeadingDeg, 0.35)
  902. }
  903. return sensorHeadingDeg === null ? courseHeadingDeg : sensorHeadingDeg
  904. }
  905. calibrateAutoRotateToCurrentOrientation(): boolean {
  906. const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  907. if (inputHeadingDeg === null) {
  908. return false
  909. }
  910. this.autoRotateCalibrationOffsetDeg = normalizeRotationDeg(this.state.rotationDeg + inputHeadingDeg)
  911. this.autoRotateCalibrationPending = false
  912. this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
  913. this.setState({
  914. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  915. })
  916. return true
  917. }
  918. refreshAutoRotateTarget(): boolean {
  919. const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  920. if (inputHeadingDeg === null) {
  921. return false
  922. }
  923. if (this.autoRotateCalibrationPending || this.autoRotateCalibrationOffsetDeg === null) {
  924. if (!this.calibrateAutoRotateToCurrentOrientation()) {
  925. return false
  926. }
  927. return true
  928. }
  929. this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
  930. this.setState({
  931. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  932. })
  933. return true
  934. }
  935. scheduleAutoRotate(): void {
  936. if (this.autoRotateTimer || this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
  937. return
  938. }
  939. const step = () => {
  940. this.autoRotateTimer = 0
  941. if (this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
  942. return
  943. }
  944. if (this.gestureMode !== 'idle' || this.inertiaTimer || this.previewResetTimer) {
  945. this.scheduleAutoRotate()
  946. return
  947. }
  948. const currentRotationDeg = this.state.rotationDeg
  949. const deltaDeg = normalizeAngleDeltaDeg(this.targetAutoRotationDeg - currentRotationDeg)
  950. if (Math.abs(deltaDeg) <= AUTO_ROTATE_SNAP_DEG) {
  951. if (Math.abs(deltaDeg) > 0.01) {
  952. this.applyAutoRotation(this.targetAutoRotationDeg)
  953. }
  954. this.scheduleAutoRotate()
  955. return
  956. }
  957. if (Math.abs(deltaDeg) <= AUTO_ROTATE_DEADZONE_DEG) {
  958. this.scheduleAutoRotate()
  959. return
  960. }
  961. const easedStepDeg = clamp(deltaDeg * AUTO_ROTATE_EASE, -AUTO_ROTATE_MAX_STEP_DEG, AUTO_ROTATE_MAX_STEP_DEG)
  962. this.applyAutoRotation(normalizeRotationDeg(currentRotationDeg + easedStepDeg))
  963. this.scheduleAutoRotate()
  964. }
  965. this.autoRotateTimer = setTimeout(step, AUTO_ROTATE_FRAME_MS) as unknown as number
  966. }
  967. applyAutoRotation(nextRotationDeg: number): void {
  968. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  969. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
  970. this.state = {
  971. ...this.state,
  972. ...resolvedViewport,
  973. rotationDeg: nextRotationDeg,
  974. rotationText: formatRotationText(nextRotationDeg),
  975. centerText: buildCenterText(this.state.zoom, resolvedViewport.centerTileX, resolvedViewport.centerTileY),
  976. }
  977. this.syncRenderer()
  978. }
  979. applyStats(stats: MapRendererStats): void {
  980. this.setState({
  981. visibleTileCount: stats.visibleTileCount,
  982. readyTileCount: stats.readyTileCount,
  983. memoryTileCount: stats.memoryTileCount,
  984. diskTileCount: stats.diskTileCount,
  985. memoryHitCount: stats.memoryHitCount,
  986. diskHitCount: stats.diskHitCount,
  987. networkFetchCount: stats.networkFetchCount,
  988. cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, stats.networkFetchCount),
  989. })
  990. }
  991. setState(patch: Partial<MapEngineViewState>, immediateUi = false): void {
  992. this.state = {
  993. ...this.state,
  994. ...patch,
  995. }
  996. const viewPatch = this.pickViewPatch(patch)
  997. if (!Object.keys(viewPatch).length) {
  998. return
  999. }
  1000. this.pendingViewPatch = {
  1001. ...this.pendingViewPatch,
  1002. ...viewPatch,
  1003. }
  1004. if (immediateUi) {
  1005. this.flushViewPatch()
  1006. return
  1007. }
  1008. if (this.viewSyncTimer) {
  1009. return
  1010. }
  1011. this.viewSyncTimer = setTimeout(() => {
  1012. this.viewSyncTimer = 0
  1013. this.flushViewPatch()
  1014. }, UI_SYNC_INTERVAL_MS) as unknown as number
  1015. }
  1016. commitViewport(
  1017. patch: Partial<MapEngineViewState>,
  1018. statusText: string,
  1019. immediateUi = false,
  1020. afterUpdate?: () => void,
  1021. ): void {
  1022. const nextZoom = typeof patch.zoom === 'number' ? patch.zoom : this.state.zoom
  1023. const nextCenterTileX = typeof patch.centerTileX === 'number' ? patch.centerTileX : this.state.centerTileX
  1024. const nextCenterTileY = typeof patch.centerTileY === 'number' ? patch.centerTileY : this.state.centerTileY
  1025. const nextStageWidth = typeof patch.stageWidth === 'number' ? patch.stageWidth : this.state.stageWidth
  1026. const nextStageHeight = typeof patch.stageHeight === 'number' ? patch.stageHeight : this.state.stageHeight
  1027. const tileSizePx = getTileSizePx({
  1028. centerWorldX: nextCenterTileX,
  1029. centerWorldY: nextCenterTileY,
  1030. viewportWidth: nextStageWidth,
  1031. viewportHeight: nextStageHeight,
  1032. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1033. })
  1034. this.setState({
  1035. ...patch,
  1036. tileSizePx,
  1037. centerText: buildCenterText(nextZoom, nextCenterTileX, nextCenterTileY),
  1038. statusText,
  1039. }, immediateUi)
  1040. this.syncRenderer()
  1041. this.compassController.start()
  1042. if (afterUpdate) {
  1043. afterUpdate()
  1044. }
  1045. }
  1046. buildScene() {
  1047. return {
  1048. tileSource: this.state.tileSource,
  1049. zoom: this.state.zoom,
  1050. centerTileX: this.state.centerTileX,
  1051. centerTileY: this.state.centerTileY,
  1052. viewportWidth: this.state.stageWidth,
  1053. viewportHeight: this.state.stageHeight,
  1054. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1055. overdraw: OVERDRAW,
  1056. translateX: this.state.tileTranslateX,
  1057. translateY: this.state.tileTranslateY,
  1058. rotationRad: this.getRotationRad(this.state.rotationDeg),
  1059. previewScale: this.previewScale || 1,
  1060. previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
  1061. previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
  1062. track: SAMPLE_TRACK_WGS84,
  1063. gpsPoint: SAMPLE_GPS_WGS84,
  1064. }
  1065. }
  1066. syncRenderer(): void {
  1067. if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) {
  1068. return
  1069. }
  1070. this.renderer.updateScene(this.buildScene())
  1071. }
  1072. getCameraState(rotationDeg = this.state.rotationDeg): CameraState {
  1073. return {
  1074. centerWorldX: this.state.centerTileX,
  1075. centerWorldY: this.state.centerTileY,
  1076. viewportWidth: this.state.stageWidth,
  1077. viewportHeight: this.state.stageHeight,
  1078. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1079. translateX: this.state.tileTranslateX,
  1080. translateY: this.state.tileTranslateY,
  1081. rotationRad: this.getRotationRad(rotationDeg),
  1082. }
  1083. }
  1084. getRotationRad(rotationDeg = this.state.rotationDeg): number {
  1085. return normalizeRotationDeg(rotationDeg) * Math.PI / 180
  1086. }
  1087. getBaseCamera(centerWorldX = this.state.centerTileX, centerWorldY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState {
  1088. return {
  1089. centerWorldX,
  1090. centerWorldY,
  1091. viewportWidth: this.state.stageWidth,
  1092. viewportHeight: this.state.stageHeight,
  1093. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1094. rotationRad: this.getRotationRad(rotationDeg),
  1095. }
  1096. }
  1097. getWorldOffsetFromScreen(stageX: number, stageY: number, rotationDeg = this.state.rotationDeg): { x: number; y: number } {
  1098. const baseCamera = {
  1099. centerWorldX: 0,
  1100. centerWorldY: 0,
  1101. viewportWidth: this.state.stageWidth,
  1102. viewportHeight: this.state.stageHeight,
  1103. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1104. rotationRad: this.getRotationRad(rotationDeg),
  1105. }
  1106. return screenToWorld(baseCamera, { x: stageX, y: stageY }, false)
  1107. }
  1108. getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } {
  1109. if (!this.state.stageWidth || !this.state.stageHeight) {
  1110. return {
  1111. x: this.state.centerTileX,
  1112. y: this.state.centerTileY,
  1113. }
  1114. }
  1115. const screenCenterX = this.state.stageWidth / 2
  1116. const screenCenterY = this.state.stageHeight / 2
  1117. return screenToWorld(this.getBaseCamera(), {
  1118. x: screenCenterX - translateX,
  1119. y: screenCenterY - translateY,
  1120. }, false)
  1121. }
  1122. resolveViewportForExactCenter(centerWorldX: number, centerWorldY: number, rotationDeg = this.state.rotationDeg): {
  1123. centerTileX: number
  1124. centerTileY: number
  1125. tileTranslateX: number
  1126. tileTranslateY: number
  1127. } {
  1128. const nextCenterTileX = Math.round(centerWorldX)
  1129. const nextCenterTileY = Math.round(centerWorldY)
  1130. if (!this.state.stageWidth || !this.state.stageHeight) {
  1131. return {
  1132. centerTileX: nextCenterTileX,
  1133. centerTileY: nextCenterTileY,
  1134. tileTranslateX: 0,
  1135. tileTranslateY: 0,
  1136. }
  1137. }
  1138. const roundedCamera = this.getBaseCamera(nextCenterTileX, nextCenterTileY, rotationDeg)
  1139. const projectedCenter = worldToScreen(roundedCamera, { x: centerWorldX, y: centerWorldY }, false)
  1140. return {
  1141. centerTileX: nextCenterTileX,
  1142. centerTileY: nextCenterTileY,
  1143. tileTranslateX: this.state.stageWidth / 2 - projectedCenter.x,
  1144. tileTranslateY: this.state.stageHeight / 2 - projectedCenter.y,
  1145. }
  1146. }
  1147. setPreviewState(scale: number, originX: number, originY: number): void {
  1148. this.previewScale = scale
  1149. this.previewOriginX = originX
  1150. this.previewOriginY = originY
  1151. }
  1152. resetPreviewState(): void {
  1153. this.setPreviewState(1, this.state.stageWidth / 2, this.state.stageHeight / 2)
  1154. }
  1155. resetPinchState(): void {
  1156. this.pinchStartDistance = 0
  1157. this.pinchStartScale = 1
  1158. this.pinchStartAngle = 0
  1159. this.pinchStartRotationDeg = this.state.rotationDeg
  1160. this.pinchAnchorWorldX = 0
  1161. this.pinchAnchorWorldY = 0
  1162. }
  1163. clearPreviewResetTimer(): void {
  1164. if (this.previewResetTimer) {
  1165. clearTimeout(this.previewResetTimer)
  1166. this.previewResetTimer = 0
  1167. }
  1168. }
  1169. clearInertiaTimer(): void {
  1170. if (this.inertiaTimer) {
  1171. clearTimeout(this.inertiaTimer)
  1172. this.inertiaTimer = 0
  1173. }
  1174. }
  1175. clearViewSyncTimer(): void {
  1176. if (this.viewSyncTimer) {
  1177. clearTimeout(this.viewSyncTimer)
  1178. this.viewSyncTimer = 0
  1179. }
  1180. }
  1181. clearAutoRotateTimer(): void {
  1182. if (this.autoRotateTimer) {
  1183. clearTimeout(this.autoRotateTimer)
  1184. this.autoRotateTimer = 0
  1185. }
  1186. }
  1187. pickViewPatch(patch: Partial<MapEngineViewState>): Partial<MapEngineViewState> {
  1188. const viewPatch = {} as Partial<MapEngineViewState>
  1189. for (const key of VIEW_SYNC_KEYS) {
  1190. if (Object.prototype.hasOwnProperty.call(patch, key)) {
  1191. ;(viewPatch as any)[key] = patch[key]
  1192. }
  1193. }
  1194. return viewPatch
  1195. }
  1196. flushViewPatch(): void {
  1197. if (!Object.keys(this.pendingViewPatch).length) {
  1198. return
  1199. }
  1200. const patch = this.pendingViewPatch
  1201. this.pendingViewPatch = {}
  1202. this.onData(patch)
  1203. }
  1204. getTouchDistance(touches: TouchPoint[]): number {
  1205. if (touches.length < 2) {
  1206. return 0
  1207. }
  1208. const first = touches[0]
  1209. const second = touches[1]
  1210. const deltaX = first.pageX - second.pageX
  1211. const deltaY = first.pageY - second.pageY
  1212. return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
  1213. }
  1214. getTouchAngle(touches: TouchPoint[]): number {
  1215. if (touches.length < 2) {
  1216. return 0
  1217. }
  1218. const first = touches[0]
  1219. const second = touches[1]
  1220. return Math.atan2(second.pageY - first.pageY, second.pageX - first.pageX)
  1221. }
  1222. getStagePoint(touches: TouchPoint[]): { x: number; y: number } {
  1223. if (!touches.length) {
  1224. return {
  1225. x: this.state.stageWidth / 2,
  1226. y: this.state.stageHeight / 2,
  1227. }
  1228. }
  1229. let pageX = 0
  1230. let pageY = 0
  1231. for (const touch of touches) {
  1232. pageX += touch.pageX
  1233. pageY += touch.pageY
  1234. }
  1235. return {
  1236. x: pageX / touches.length - this.state.stageLeft,
  1237. y: pageY / touches.length - this.state.stageTop,
  1238. }
  1239. }
  1240. animatePreviewToRest(): void {
  1241. this.clearPreviewResetTimer()
  1242. const startScale = this.previewScale || 1
  1243. const originX = this.previewOriginX || this.state.stageWidth / 2
  1244. const originY = this.previewOriginY || this.state.stageHeight / 2
  1245. if (Math.abs(startScale - 1) < 0.01) {
  1246. this.resetPreviewState()
  1247. this.syncRenderer()
  1248. this.compassController.start()
  1249. this.scheduleAutoRotate()
  1250. return
  1251. }
  1252. const startAt = Date.now()
  1253. const step = () => {
  1254. const progress = Math.min(1, (Date.now() - startAt) / PREVIEW_RESET_DURATION_MS)
  1255. const eased = 1 - Math.pow(1 - progress, 3)
  1256. const nextScale = startScale + (1 - startScale) * eased
  1257. this.setPreviewState(nextScale, originX, originY)
  1258. this.syncRenderer()
  1259. this.compassController.start()
  1260. if (progress >= 1) {
  1261. this.resetPreviewState()
  1262. this.syncRenderer()
  1263. this.compassController.start()
  1264. this.previewResetTimer = 0
  1265. this.scheduleAutoRotate()
  1266. return
  1267. }
  1268. this.previewResetTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  1269. }
  1270. step()
  1271. }
  1272. normalizeTranslate(translateX: number, translateY: number, statusText: string): void {
  1273. if (!this.state.stageWidth) {
  1274. this.setState({
  1275. tileTranslateX: translateX,
  1276. tileTranslateY: translateY,
  1277. })
  1278. this.syncRenderer()
  1279. this.compassController.start()
  1280. return
  1281. }
  1282. const exactCenter = this.getExactCenterFromTranslate(translateX, translateY)
  1283. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y)
  1284. const centerChanged = resolvedViewport.centerTileX !== this.state.centerTileX || resolvedViewport.centerTileY !== this.state.centerTileY
  1285. if (centerChanged) {
  1286. this.commitViewport(resolvedViewport, statusText)
  1287. return
  1288. }
  1289. this.setState({
  1290. tileTranslateX: resolvedViewport.tileTranslateX,
  1291. tileTranslateY: resolvedViewport.tileTranslateY,
  1292. })
  1293. this.syncRenderer()
  1294. this.compassController.start()
  1295. }
  1296. zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void {
  1297. const nextZoom = clamp(this.state.zoom + zoomDelta, MIN_ZOOM, MAX_ZOOM)
  1298. const appliedDelta = nextZoom - this.state.zoom
  1299. if (!appliedDelta) {
  1300. this.animatePreviewToRest()
  1301. return
  1302. }
  1303. if (!this.state.stageWidth || !this.state.stageHeight) {
  1304. this.commitViewport(
  1305. {
  1306. zoom: nextZoom,
  1307. centerTileX: appliedDelta > 0 ? this.state.centerTileX * 2 : Math.floor(this.state.centerTileX / 2),
  1308. centerTileY: appliedDelta > 0 ? this.state.centerTileY * 2 : Math.floor(this.state.centerTileY / 2),
  1309. tileTranslateX: 0,
  1310. tileTranslateY: 0,
  1311. },
  1312. `缩放级别调整到 ${nextZoom}`,
  1313. true,
  1314. () => {
  1315. this.setPreviewState(residualScale, stageX, stageY)
  1316. this.syncRenderer()
  1317. this.compassController.start()
  1318. this.animatePreviewToRest()
  1319. },
  1320. )
  1321. return
  1322. }
  1323. const camera = this.getCameraState()
  1324. const world = screenToWorld(camera, { x: stageX, y: stageY }, true)
  1325. const zoomFactor = Math.pow(2, appliedDelta)
  1326. const nextWorldX = world.x * zoomFactor
  1327. const nextWorldY = world.y * zoomFactor
  1328. const anchorOffset = this.getWorldOffsetFromScreen(stageX, stageY)
  1329. const exactCenterX = nextWorldX - anchorOffset.x
  1330. const exactCenterY = nextWorldY - anchorOffset.y
  1331. const resolvedViewport = this.resolveViewportForExactCenter(exactCenterX, exactCenterY)
  1332. this.commitViewport(
  1333. {
  1334. zoom: nextZoom,
  1335. ...resolvedViewport,
  1336. },
  1337. `缩放级别调整到 ${nextZoom}`,
  1338. true,
  1339. () => {
  1340. this.setPreviewState(residualScale, stageX, stageY)
  1341. this.syncRenderer()
  1342. this.compassController.start()
  1343. this.animatePreviewToRest()
  1344. },
  1345. )
  1346. }
  1347. startInertia(): void {
  1348. this.clearInertiaTimer()
  1349. const step = () => {
  1350. this.panVelocityX *= INERTIA_DECAY
  1351. this.panVelocityY *= INERTIA_DECAY
  1352. if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
  1353. this.setState({
  1354. statusText: `惯性滑动结束 (${this.buildVersion})`,
  1355. })
  1356. this.renderer.setAnimationPaused(false)
  1357. this.inertiaTimer = 0
  1358. this.scheduleAutoRotate()
  1359. return
  1360. }
  1361. this.normalizeTranslate(
  1362. this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
  1363. this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS,
  1364. `惯性滑动中 (${this.buildVersion})`,
  1365. )
  1366. this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  1367. }
  1368. this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  1369. }
  1370. }