mapEngine.ts 57 KB

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