mapEngine.ts 57 KB

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