mapEngine.ts 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137
  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. import { GameRuntime } from '../../game/core/gameRuntime'
  10. import { type GameEffect } from '../../game/core/gameResult'
  11. import { buildGameDefinitionFromCourse } from '../../game/content/courseToGameDefinition'
  12. import { SoundDirector } from '../../game/audio/soundDirector'
  13. import { EMPTY_GAME_PRESENTATION_STATE, type GamePresentationState } from '../../game/presentation/presentationState'
  14. const RENDER_MODE = 'Single WebGL Pipeline'
  15. const PROJECTION_MODE = 'WGS84 -> WorldTile -> Camera -> Screen'
  16. const MAP_NORTH_OFFSET_DEG = 0
  17. let MAGNETIC_DECLINATION_DEG = -6.91
  18. let MAGNETIC_DECLINATION_TEXT = '6.91掳 W'
  19. const MIN_ZOOM = 15
  20. const MAX_ZOOM = 20
  21. const DEFAULT_ZOOM = 17
  22. const DESIRED_VISIBLE_COLUMNS = 3
  23. const OVERDRAW = 1
  24. const DEFAULT_TOP_LEFT_TILE_X = 108132
  25. const DEFAULT_TOP_LEFT_TILE_Y = 51199
  26. const DEFAULT_CENTER_TILE_X = DEFAULT_TOP_LEFT_TILE_X + 1
  27. const DEFAULT_CENTER_TILE_Y = DEFAULT_TOP_LEFT_TILE_Y + 1
  28. const TILE_SOURCE = 'https://oss-mbh5.colormaprun.com/wxMap/lcx/{z}/{x}/{y}.png'
  29. const OSM_TILE_SOURCE = 'https://tiles.mymarsgo.xyz/{z}/{x}/{y}.png'
  30. const MAP_OVERLAY_OPACITY = 0.72
  31. const GPS_MAP_CALIBRATION: MapCalibration = {
  32. offsetEastMeters: 0,
  33. offsetNorthMeters: 0,
  34. rotationDeg: 0,
  35. scale: 1,
  36. }
  37. const MIN_PREVIEW_SCALE = 0.55
  38. const MAX_PREVIEW_SCALE = 1.85
  39. const INERTIA_FRAME_MS = 16
  40. const INERTIA_DECAY = 0.92
  41. const INERTIA_MIN_SPEED = 0.02
  42. const PREVIEW_RESET_DURATION_MS = 140
  43. const UI_SYNC_INTERVAL_MS = 80
  44. const ROTATE_STEP_DEG = 15
  45. const AUTO_ROTATE_FRAME_MS = 8
  46. const AUTO_ROTATE_EASE = 0.34
  47. const AUTO_ROTATE_SNAP_DEG = 0.1
  48. const AUTO_ROTATE_DEADZONE_DEG = 4
  49. const AUTO_ROTATE_MAX_STEP_DEG = 0.75
  50. const AUTO_ROTATE_HEADING_SMOOTHING = 0.32
  51. const COMPASS_NEEDLE_SMOOTHING = 0.12
  52. const GPS_TRACK_MAX_POINTS = 200
  53. const GPS_TRACK_MIN_STEP_METERS = 3
  54. type TouchPoint = WechatMiniprogram.TouchDetail
  55. type GestureMode = 'idle' | 'pan' | 'pinch'
  56. type RotationMode = 'manual' | 'auto'
  57. type OrientationMode = 'manual' | 'north-up' | 'heading-up'
  58. type AutoRotateSourceMode = 'sensor' | 'course' | 'fusion'
  59. type NorthReferenceMode = 'magnetic' | 'true'
  60. const DEFAULT_NORTH_REFERENCE_MODE: NorthReferenceMode = 'magnetic'
  61. export interface MapEngineStageRect {
  62. width: number
  63. height: number
  64. left: number
  65. top: number
  66. }
  67. export interface MapEngineViewState {
  68. buildVersion: string
  69. renderMode: string
  70. projectionMode: string
  71. mapReady: boolean
  72. mapReadyText: string
  73. mapName: string
  74. configStatusText: string
  75. zoom: number
  76. rotationDeg: number
  77. rotationText: string
  78. rotationMode: RotationMode
  79. rotationModeText: string
  80. rotationToggleText: string
  81. orientationMode: OrientationMode
  82. orientationModeText: string
  83. sensorHeadingText: string
  84. compassDeclinationText: string
  85. northReferenceButtonText: string
  86. autoRotateSourceText: string
  87. autoRotateCalibrationText: string
  88. northReferenceText: string
  89. compassNeedleDeg: number
  90. centerTileX: number
  91. centerTileY: number
  92. centerText: string
  93. tileSource: string
  94. visibleColumnCount: number
  95. visibleTileCount: number
  96. readyTileCount: number
  97. memoryTileCount: number
  98. diskTileCount: number
  99. memoryHitCount: number
  100. diskHitCount: number
  101. networkFetchCount: number
  102. cacheHitRateText: string
  103. tileTranslateX: number
  104. tileTranslateY: number
  105. tileSizePx: number
  106. stageWidth: number
  107. stageHeight: number
  108. stageLeft: number
  109. stageTop: number
  110. statusText: string
  111. gpsTracking: boolean
  112. gpsTrackingText: string
  113. gpsCoordText: string
  114. gameSessionStatus: 'idle' | 'running' | 'finished' | 'failed'
  115. panelProgressText: string
  116. punchButtonText: string
  117. punchButtonEnabled: boolean
  118. punchHintText: string
  119. punchFeedbackVisible: boolean
  120. punchFeedbackText: string
  121. punchFeedbackTone: 'neutral' | 'success' | 'warning'
  122. contentCardVisible: boolean
  123. contentCardTitle: string
  124. contentCardBody: string
  125. osmReferenceEnabled: boolean
  126. osmReferenceText: string
  127. }
  128. export interface MapEngineCallbacks {
  129. onData: (patch: Partial<MapEngineViewState>) => void
  130. }
  131. const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
  132. 'buildVersion',
  133. 'renderMode',
  134. 'projectionMode',
  135. 'mapReady',
  136. 'mapReadyText',
  137. 'mapName',
  138. 'configStatusText',
  139. 'zoom',
  140. 'rotationDeg',
  141. 'rotationText',
  142. 'rotationMode',
  143. 'rotationModeText',
  144. 'rotationToggleText',
  145. 'orientationMode',
  146. 'orientationModeText',
  147. 'sensorHeadingText',
  148. 'compassDeclinationText',
  149. 'northReferenceButtonText',
  150. 'autoRotateSourceText',
  151. 'autoRotateCalibrationText',
  152. 'northReferenceText',
  153. 'compassNeedleDeg',
  154. 'centerText',
  155. 'tileSource',
  156. 'visibleTileCount',
  157. 'readyTileCount',
  158. 'memoryTileCount',
  159. 'diskTileCount',
  160. 'memoryHitCount',
  161. 'diskHitCount',
  162. 'networkFetchCount',
  163. 'cacheHitRateText',
  164. 'tileSizePx',
  165. 'statusText',
  166. 'gpsTracking',
  167. 'gpsTrackingText',
  168. 'gpsCoordText',
  169. 'gameSessionStatus',
  170. 'panelProgressText',
  171. 'punchButtonText',
  172. 'punchButtonEnabled',
  173. 'punchHintText',
  174. 'punchFeedbackVisible',
  175. 'punchFeedbackText',
  176. 'punchFeedbackTone',
  177. 'contentCardVisible',
  178. 'contentCardTitle',
  179. 'contentCardBody',
  180. 'osmReferenceEnabled',
  181. 'osmReferenceText',
  182. ]
  183. function buildCenterText(zoom: number, x: number, y: number): string {
  184. return `z${zoom} / x${x} / y${y}`
  185. }
  186. function clamp(value: number, min: number, max: number): number {
  187. return Math.max(min, Math.min(max, value))
  188. }
  189. function normalizeRotationDeg(rotationDeg: number): number {
  190. const normalized = rotationDeg % 360
  191. return normalized < 0 ? normalized + 360 : normalized
  192. }
  193. function normalizeAngleDeltaRad(angleDeltaRad: number): number {
  194. let normalized = angleDeltaRad
  195. while (normalized > Math.PI) {
  196. normalized -= Math.PI * 2
  197. }
  198. while (normalized < -Math.PI) {
  199. normalized += Math.PI * 2
  200. }
  201. return normalized
  202. }
  203. function normalizeAngleDeltaDeg(angleDeltaDeg: number): number {
  204. let normalized = angleDeltaDeg
  205. while (normalized > 180) {
  206. normalized -= 360
  207. }
  208. while (normalized < -180) {
  209. normalized += 360
  210. }
  211. return normalized
  212. }
  213. function interpolateAngleDeg(currentDeg: number, targetDeg: number, factor: number): number {
  214. return normalizeRotationDeg(currentDeg + normalizeAngleDeltaDeg(targetDeg - currentDeg) * factor)
  215. }
  216. function formatRotationText(rotationDeg: number): string {
  217. return `${Math.round(normalizeRotationDeg(rotationDeg))}deg`
  218. }
  219. function formatHeadingText(headingDeg: number | null): string {
  220. if (headingDeg === null) {
  221. return '--'
  222. }
  223. return `${Math.round(normalizeRotationDeg(headingDeg))}掳`
  224. }
  225. function formatOrientationModeText(mode: OrientationMode): string {
  226. if (mode === 'north-up') {
  227. return 'North Up'
  228. }
  229. if (mode === 'heading-up') {
  230. return 'Heading Up'
  231. }
  232. return 'Manual Gesture'
  233. }
  234. function formatRotationModeText(mode: OrientationMode): string {
  235. return formatOrientationModeText(mode)
  236. }
  237. function formatRotationToggleText(mode: OrientationMode): string {
  238. if (mode === 'manual') {
  239. return '切到北朝上'
  240. }
  241. if (mode === 'north-up') {
  242. return '切到朝向朝上'
  243. }
  244. return '鍒囧埌鎵嬪姩鏃嬭浆'
  245. }
  246. function formatAutoRotateSourceText(mode: AutoRotateSourceMode, hasCourseHeading: boolean): string {
  247. if (mode === 'sensor') {
  248. return 'Sensor Only'
  249. }
  250. if (mode === 'course') {
  251. return hasCourseHeading ? 'Course Only' : 'Course Pending'
  252. }
  253. return hasCourseHeading ? 'Sensor + Course' : 'Sensor Only'
  254. }
  255. function formatAutoRotateCalibrationText(pending: boolean, offsetDeg: number | null): string {
  256. if (pending) {
  257. return 'Pending'
  258. }
  259. if (offsetDeg === null) {
  260. return '--'
  261. }
  262. return `Offset ${Math.round(normalizeRotationDeg(offsetDeg))}deg`
  263. }
  264. function getTrueHeadingDeg(magneticHeadingDeg: number): number {
  265. return normalizeRotationDeg(magneticHeadingDeg + MAGNETIC_DECLINATION_DEG)
  266. }
  267. function getMagneticHeadingDeg(trueHeadingDeg: number): number {
  268. return normalizeRotationDeg(trueHeadingDeg - MAGNETIC_DECLINATION_DEG)
  269. }
  270. function getMapNorthOffsetDeg(_mode: NorthReferenceMode): number {
  271. return MAP_NORTH_OFFSET_DEG
  272. }
  273. function getCompassReferenceHeadingDeg(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  274. if (mode === 'true') {
  275. return getTrueHeadingDeg(magneticHeadingDeg)
  276. }
  277. return normalizeRotationDeg(magneticHeadingDeg)
  278. }
  279. function getMapReferenceHeadingDegFromSensor(mode: NorthReferenceMode, magneticHeadingDeg: number): number {
  280. if (mode === 'magnetic') {
  281. return normalizeRotationDeg(magneticHeadingDeg)
  282. }
  283. return getTrueHeadingDeg(magneticHeadingDeg)
  284. }
  285. function getMapReferenceHeadingDegFromCourse(mode: NorthReferenceMode, trueHeadingDeg: number): number {
  286. if (mode === 'magnetic') {
  287. return getMagneticHeadingDeg(trueHeadingDeg)
  288. }
  289. return normalizeRotationDeg(trueHeadingDeg)
  290. }
  291. function formatNorthReferenceText(mode: NorthReferenceMode): string {
  292. if (mode === 'magnetic') {
  293. return `Compass Magnetic / Heading-Up Magnetic (${MAGNETIC_DECLINATION_TEXT})`
  294. }
  295. return `Compass True / Heading-Up True (${MAGNETIC_DECLINATION_TEXT})`
  296. }
  297. function formatCompassDeclinationText(mode: NorthReferenceMode): string {
  298. if (mode === 'true') {
  299. return MAGNETIC_DECLINATION_TEXT
  300. }
  301. return ''
  302. }
  303. function formatNorthReferenceButtonText(mode: NorthReferenceMode): string {
  304. return mode === 'magnetic' ? '鍖楀弬鑰冿細纾佸寳' : '鍖楀弬鑰冿細鐪熷寳'
  305. }
  306. function formatNorthReferenceStatusText(mode: NorthReferenceMode): string {
  307. if (mode === 'magnetic') {
  308. return '已切到磁北模式'
  309. }
  310. return '已切到真北模式'
  311. }
  312. function getNextNorthReferenceMode(mode: NorthReferenceMode): NorthReferenceMode {
  313. return mode === 'magnetic' ? 'true' : 'magnetic'
  314. }
  315. function formatCompassNeedleDegForMode(mode: NorthReferenceMode, magneticHeadingDeg: number | null): number {
  316. if (magneticHeadingDeg === null) {
  317. return 0
  318. }
  319. const referenceHeadingDeg = mode === 'true'
  320. ? getTrueHeadingDeg(magneticHeadingDeg)
  321. : normalizeRotationDeg(magneticHeadingDeg)
  322. return normalizeRotationDeg(360 - referenceHeadingDeg)
  323. }
  324. function formatCacheHitRate(memoryHitCount: number, diskHitCount: number, networkFetchCount: number): string {
  325. const total = memoryHitCount + diskHitCount + networkFetchCount
  326. if (!total) {
  327. return '--'
  328. }
  329. const hitRate = ((memoryHitCount + diskHitCount) / total) * 100
  330. return `${Math.round(hitRate)}%`
  331. }
  332. function formatGpsCoordText(point: LonLatPoint | null, accuracyMeters: number | null): string {
  333. if (!point) {
  334. return '--'
  335. }
  336. const base = `${point.lat.toFixed(6)}, ${point.lon.toFixed(6)}`
  337. if (accuracyMeters === null || !Number.isFinite(accuracyMeters)) {
  338. return base
  339. }
  340. return `${base} / 卤${Math.round(accuracyMeters)}m`
  341. }
  342. function getApproxDistanceMeters(a: LonLatPoint, b: LonLatPoint): number {
  343. const avgLatRad = ((a.lat + b.lat) / 2) * Math.PI / 180
  344. const dx = (b.lon - a.lon) * 111320 * Math.cos(avgLatRad)
  345. const dy = (b.lat - a.lat) * 110540
  346. return Math.sqrt(dx * dx + dy * dy)
  347. }
  348. function getInitialBearingDeg(from: LonLatPoint, to: LonLatPoint): number {
  349. const fromLatRad = from.lat * Math.PI / 180
  350. const toLatRad = to.lat * Math.PI / 180
  351. const deltaLonRad = (to.lon - from.lon) * Math.PI / 180
  352. const y = Math.sin(deltaLonRad) * Math.cos(toLatRad)
  353. const x = Math.cos(fromLatRad) * Math.sin(toLatRad) - Math.sin(fromLatRad) * Math.cos(toLatRad) * Math.cos(deltaLonRad)
  354. const bearingDeg = Math.atan2(y, x) * 180 / Math.PI
  355. return normalizeRotationDeg(bearingDeg)
  356. }
  357. export class MapEngine {
  358. buildVersion: string
  359. renderer: WebGLMapRenderer
  360. compassController: CompassHeadingController
  361. locationController: LocationController
  362. soundDirector: SoundDirector
  363. onData: (patch: Partial<MapEngineViewState>) => void
  364. state: MapEngineViewState
  365. previewScale: number
  366. previewOriginX: number
  367. previewOriginY: number
  368. panLastX: number
  369. panLastY: number
  370. panLastTimestamp: number
  371. panVelocityX: number
  372. panVelocityY: number
  373. pinchStartDistance: number
  374. pinchStartScale: number
  375. pinchStartAngle: number
  376. pinchStartRotationDeg: number
  377. pinchAnchorWorldX: number
  378. pinchAnchorWorldY: number
  379. gestureMode: GestureMode
  380. inertiaTimer: number
  381. previewResetTimer: number
  382. viewSyncTimer: number
  383. autoRotateTimer: number
  384. pendingViewPatch: Partial<MapEngineViewState>
  385. mounted: boolean
  386. northReferenceMode: NorthReferenceMode
  387. sensorHeadingDeg: number | null
  388. smoothedSensorHeadingDeg: number | null
  389. compassDisplayHeadingDeg: number | null
  390. autoRotateHeadingDeg: number | null
  391. courseHeadingDeg: number | null
  392. targetAutoRotationDeg: number | null
  393. autoRotateSourceMode: AutoRotateSourceMode
  394. autoRotateCalibrationOffsetDeg: number | null
  395. autoRotateCalibrationPending: boolean
  396. minZoom: number
  397. maxZoom: number
  398. defaultZoom: number
  399. defaultCenterTileX: number
  400. defaultCenterTileY: number
  401. tileBoundsByZoom: Record<number, TileZoomBounds> | null
  402. currentGpsPoint: LonLatPoint | null
  403. currentGpsTrack: LonLatPoint[]
  404. currentGpsAccuracyMeters: number | null
  405. courseData: OrienteeringCourseData | null
  406. cpRadiusMeters: number
  407. gameRuntime: GameRuntime
  408. gamePresentation: GamePresentationState
  409. gameMode: 'classic-sequential'
  410. punchPolicy: 'enter' | 'enter-confirm'
  411. punchRadiusMeters: number
  412. autoFinishOnLastControl: boolean
  413. punchFeedbackTimer: number
  414. contentCardTimer: number
  415. hasGpsCenteredOnce: boolean
  416. constructor(buildVersion: string, callbacks: MapEngineCallbacks) {
  417. this.buildVersion = buildVersion
  418. this.onData = callbacks.onData
  419. this.renderer = new WebGLMapRenderer(
  420. (stats) => {
  421. this.applyStats(stats)
  422. },
  423. (message) => {
  424. this.setState({
  425. statusText: `${message} (${this.buildVersion})`,
  426. })
  427. },
  428. )
  429. this.compassController = new CompassHeadingController({
  430. onHeading: (headingDeg) => {
  431. this.handleCompassHeading(headingDeg)
  432. },
  433. onError: (message) => {
  434. this.handleCompassError(message)
  435. },
  436. })
  437. this.locationController = new LocationController({
  438. onLocation: (update) => {
  439. this.handleLocationUpdate(update.longitude, update.latitude, typeof update.accuracy === 'number' ? update.accuracy : null)
  440. },
  441. onStatus: (message) => {
  442. this.setState({
  443. gpsTracking: this.locationController.listening,
  444. gpsTrackingText: message,
  445. }, true)
  446. },
  447. onError: (message) => {
  448. this.setState({
  449. gpsTracking: false,
  450. gpsTrackingText: message,
  451. statusText: `${message} (${this.buildVersion})`,
  452. }, true)
  453. },
  454. })
  455. this.soundDirector = new SoundDirector()
  456. this.minZoom = MIN_ZOOM
  457. this.maxZoom = MAX_ZOOM
  458. this.defaultZoom = DEFAULT_ZOOM
  459. this.defaultCenterTileX = DEFAULT_CENTER_TILE_X
  460. this.defaultCenterTileY = DEFAULT_CENTER_TILE_Y
  461. this.tileBoundsByZoom = null
  462. this.currentGpsPoint = null
  463. this.currentGpsTrack = []
  464. this.currentGpsAccuracyMeters = null
  465. this.courseData = null
  466. this.cpRadiusMeters = 5
  467. this.gameRuntime = new GameRuntime()
  468. this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
  469. this.gameMode = 'classic-sequential'
  470. this.punchPolicy = 'enter-confirm'
  471. this.punchRadiusMeters = 5
  472. this.autoFinishOnLastControl = true
  473. this.punchFeedbackTimer = 0
  474. this.contentCardTimer = 0
  475. this.hasGpsCenteredOnce = false
  476. this.state = {
  477. buildVersion: this.buildVersion,
  478. renderMode: RENDER_MODE,
  479. projectionMode: PROJECTION_MODE,
  480. mapReady: false,
  481. mapReadyText: 'BOOTING',
  482. mapName: 'LCX 娴嬭瘯鍦板浘',
  483. configStatusText: '远程配置待加载',
  484. zoom: DEFAULT_ZOOM,
  485. rotationDeg: 0,
  486. rotationText: formatRotationText(0),
  487. rotationMode: 'manual',
  488. rotationModeText: formatRotationModeText('manual'),
  489. rotationToggleText: formatRotationToggleText('manual'),
  490. orientationMode: 'manual',
  491. orientationModeText: formatOrientationModeText('manual'),
  492. sensorHeadingText: '--',
  493. compassDeclinationText: formatCompassDeclinationText(DEFAULT_NORTH_REFERENCE_MODE),
  494. northReferenceButtonText: formatNorthReferenceButtonText(DEFAULT_NORTH_REFERENCE_MODE),
  495. autoRotateSourceText: formatAutoRotateSourceText('sensor', false),
  496. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)),
  497. northReferenceText: formatNorthReferenceText(DEFAULT_NORTH_REFERENCE_MODE),
  498. compassNeedleDeg: 0,
  499. centerTileX: DEFAULT_CENTER_TILE_X,
  500. centerTileY: DEFAULT_CENTER_TILE_Y,
  501. centerText: buildCenterText(DEFAULT_ZOOM, DEFAULT_CENTER_TILE_X, DEFAULT_CENTER_TILE_Y),
  502. tileSource: TILE_SOURCE,
  503. visibleColumnCount: DESIRED_VISIBLE_COLUMNS,
  504. visibleTileCount: 0,
  505. readyTileCount: 0,
  506. memoryTileCount: 0,
  507. diskTileCount: 0,
  508. memoryHitCount: 0,
  509. diskHitCount: 0,
  510. networkFetchCount: 0,
  511. cacheHitRateText: '--',
  512. tileTranslateX: 0,
  513. tileTranslateY: 0,
  514. tileSizePx: 0,
  515. stageWidth: 0,
  516. stageHeight: 0,
  517. stageLeft: 0,
  518. stageTop: 0,
  519. statusText: `鍗?WebGL 绠$嚎宸插噯澶囨帴鍏ユ柟鍚戜紶鎰熷櫒 (${this.buildVersion})`,
  520. gpsTracking: false,
  521. gpsTrackingText: '持续定位待启动',
  522. gpsCoordText: '--',
  523. panelProgressText: '0/0',
  524. punchButtonText: '鎵撶偣',
  525. gameSessionStatus: 'idle',
  526. punchButtonEnabled: false,
  527. punchHintText: '绛夊緟杩涘叆妫€鏌ョ偣鑼冨洿',
  528. punchFeedbackVisible: false,
  529. punchFeedbackText: '',
  530. punchFeedbackTone: 'neutral',
  531. contentCardVisible: false,
  532. contentCardTitle: '',
  533. contentCardBody: '',
  534. osmReferenceEnabled: false,
  535. osmReferenceText: 'OSM参考:关',
  536. }
  537. this.previewScale = 1
  538. this.previewOriginX = 0
  539. this.previewOriginY = 0
  540. this.panLastX = 0
  541. this.panLastY = 0
  542. this.panLastTimestamp = 0
  543. this.panVelocityX = 0
  544. this.panVelocityY = 0
  545. this.pinchStartDistance = 0
  546. this.pinchStartScale = 1
  547. this.pinchStartAngle = 0
  548. this.pinchStartRotationDeg = 0
  549. this.pinchAnchorWorldX = 0
  550. this.pinchAnchorWorldY = 0
  551. this.gestureMode = 'idle'
  552. this.inertiaTimer = 0
  553. this.previewResetTimer = 0
  554. this.viewSyncTimer = 0
  555. this.autoRotateTimer = 0
  556. this.pendingViewPatch = {}
  557. this.mounted = false
  558. this.northReferenceMode = DEFAULT_NORTH_REFERENCE_MODE
  559. this.sensorHeadingDeg = null
  560. this.smoothedSensorHeadingDeg = null
  561. this.compassDisplayHeadingDeg = null
  562. this.autoRotateHeadingDeg = null
  563. this.courseHeadingDeg = null
  564. this.targetAutoRotationDeg = null
  565. this.autoRotateSourceMode = 'sensor'
  566. this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(DEFAULT_NORTH_REFERENCE_MODE)
  567. this.autoRotateCalibrationPending = false
  568. }
  569. getInitialData(): MapEngineViewState {
  570. return { ...this.state }
  571. }
  572. destroy(): void {
  573. this.clearInertiaTimer()
  574. this.clearPreviewResetTimer()
  575. this.clearViewSyncTimer()
  576. this.clearAutoRotateTimer()
  577. this.clearPunchFeedbackTimer()
  578. this.clearContentCardTimer()
  579. this.compassController.destroy()
  580. this.locationController.destroy()
  581. this.soundDirector.destroy()
  582. this.renderer.destroy()
  583. this.mounted = false
  584. }
  585. clearGameRuntime(): void {
  586. this.gameRuntime.clear()
  587. this.gamePresentation = EMPTY_GAME_PRESENTATION_STATE
  588. this.setCourseHeading(null)
  589. }
  590. loadGameDefinitionFromCourse(): GameEffect[] {
  591. if (!this.courseData) {
  592. this.clearGameRuntime()
  593. return []
  594. }
  595. const definition = buildGameDefinitionFromCourse(
  596. this.courseData,
  597. this.cpRadiusMeters,
  598. this.gameMode,
  599. this.autoFinishOnLastControl,
  600. this.punchPolicy,
  601. this.punchRadiusMeters,
  602. )
  603. const result = this.gameRuntime.loadDefinition(definition)
  604. this.gamePresentation = result.presentation
  605. this.refreshCourseHeadingFromPresentation()
  606. return result.effects
  607. }
  608. refreshCourseHeadingFromPresentation(): void {
  609. if (!this.courseData || !this.gamePresentation.activeLegIndices.length) {
  610. this.setCourseHeading(null)
  611. return
  612. }
  613. const activeLegIndex = this.gamePresentation.activeLegIndices[0]
  614. const activeLeg = this.courseData.layers.legs[activeLegIndex]
  615. if (!activeLeg) {
  616. this.setCourseHeading(null)
  617. return
  618. }
  619. this.setCourseHeading(getInitialBearingDeg(activeLeg.fromPoint, activeLeg.toPoint))
  620. }
  621. resolveGameStatusText(effects: GameEffect[]): string | null {
  622. const lastEffect = effects.length ? effects[effects.length - 1] : null
  623. if (!lastEffect) {
  624. return null
  625. }
  626. if (lastEffect.type === 'control_completed') {
  627. const sequenceText = typeof lastEffect.sequence === 'number' ? String(lastEffect.sequence) : lastEffect.controlId
  628. return `宸插畬鎴愭鏌ョ偣 ${sequenceText} (${this.buildVersion})`
  629. }
  630. if (lastEffect.type === 'session_finished') {
  631. return `璺嚎宸插畬鎴?(${this.buildVersion})`
  632. }
  633. if (lastEffect.type === 'session_started') {
  634. return `椤哄簭鎵撶偣宸插紑濮?(${this.buildVersion})`
  635. }
  636. return null
  637. }
  638. getGameViewPatch(statusText?: string | null): Partial<MapEngineViewState> {
  639. const patch: Partial<MapEngineViewState> = {
  640. gameSessionStatus: this.gameRuntime.state ? this.gameRuntime.state.status : 'idle',
  641. panelProgressText: this.gamePresentation.progressText,
  642. punchButtonText: this.gamePresentation.punchButtonText,
  643. punchButtonEnabled: this.gamePresentation.punchButtonEnabled,
  644. punchHintText: this.gamePresentation.punchHintText,
  645. }
  646. if (statusText) {
  647. patch.statusText = statusText
  648. }
  649. return patch
  650. }
  651. clearPunchFeedbackTimer(): void {
  652. if (this.punchFeedbackTimer) {
  653. clearTimeout(this.punchFeedbackTimer)
  654. this.punchFeedbackTimer = 0
  655. }
  656. }
  657. clearContentCardTimer(): void {
  658. if (this.contentCardTimer) {
  659. clearTimeout(this.contentCardTimer)
  660. this.contentCardTimer = 0
  661. }
  662. }
  663. showPunchFeedback(text: string, tone: 'neutral' | 'success' | 'warning'): void {
  664. this.clearPunchFeedbackTimer()
  665. this.setState({
  666. punchFeedbackVisible: true,
  667. punchFeedbackText: text,
  668. punchFeedbackTone: tone,
  669. }, true)
  670. this.punchFeedbackTimer = setTimeout(() => {
  671. this.punchFeedbackTimer = 0
  672. this.setState({
  673. punchFeedbackVisible: false,
  674. }, true)
  675. }, 1400) as unknown as number
  676. }
  677. showContentCard(title: string, body: string): void {
  678. this.clearContentCardTimer()
  679. this.setState({
  680. contentCardVisible: true,
  681. contentCardTitle: title,
  682. contentCardBody: body,
  683. }, true)
  684. this.contentCardTimer = setTimeout(() => {
  685. this.contentCardTimer = 0
  686. this.setState({
  687. contentCardVisible: false,
  688. }, true)
  689. }, 2600) as unknown as number
  690. }
  691. closeContentCard(): void {
  692. this.clearContentCardTimer()
  693. this.setState({
  694. contentCardVisible: false,
  695. }, true)
  696. }
  697. applyGameEffects(effects: GameEffect[]): string | null {
  698. this.soundDirector.handleEffects(effects)
  699. const statusText = this.resolveGameStatusText(effects)
  700. for (const effect of effects) {
  701. if (effect.type === 'punch_feedback') {
  702. this.showPunchFeedback(effect.text, effect.tone)
  703. }
  704. if (effect.type === 'control_completed') {
  705. this.showPunchFeedback(`完成 ${typeof effect.sequence === 'number' ? effect.sequence : effect.label}`, 'success')
  706. this.showContentCard(effect.displayTitle, effect.displayBody)
  707. }
  708. if (effect.type === 'session_finished' && this.locationController.listening) {
  709. this.locationController.stop()
  710. }
  711. }
  712. return statusText
  713. }
  714. handleStartGame(): void {
  715. if (!this.gameRuntime.definition || !this.gameRuntime.state) {
  716. this.setState({
  717. statusText: `当前还没有可开始的路线 (${this.buildVersion})`,
  718. }, true)
  719. return
  720. }
  721. if (this.gameRuntime.state.status !== 'idle') {
  722. return
  723. }
  724. if (!this.locationController.listening) {
  725. this.locationController.start()
  726. }
  727. const startedAt = Date.now()
  728. let gameResult = this.gameRuntime.startSession(startedAt)
  729. if (this.currentGpsPoint) {
  730. gameResult = this.gameRuntime.dispatch({
  731. type: 'gps_updated',
  732. at: Date.now(),
  733. lon: this.currentGpsPoint.lon,
  734. lat: this.currentGpsPoint.lat,
  735. accuracyMeters: this.currentGpsAccuracyMeters,
  736. })
  737. }
  738. this.gamePresentation = this.gameRuntime.getPresentation()
  739. this.refreshCourseHeadingFromPresentation()
  740. const defaultStatusText = this.currentGpsPoint
  741. ? `顺序打点已开始 (${this.buildVersion})`
  742. : `顺序打点已开始,GPS定位启动中 (${this.buildVersion})`
  743. const gameStatusText = this.applyGameEffects(gameResult.effects) || defaultStatusText
  744. this.setState({
  745. ...this.getGameViewPatch(gameStatusText),
  746. }, true)
  747. this.syncRenderer()
  748. }
  749. handlePunchAction(): void {
  750. const gameResult = this.gameRuntime.dispatch({
  751. type: 'punch_requested',
  752. at: Date.now(),
  753. })
  754. this.gamePresentation = gameResult.presentation
  755. this.refreshCourseHeadingFromPresentation()
  756. const gameStatusText = this.applyGameEffects(gameResult.effects)
  757. this.setState({
  758. ...this.getGameViewPatch(gameStatusText),
  759. }, true)
  760. this.syncRenderer()
  761. }
  762. handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
  763. const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
  764. const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
  765. if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) {
  766. this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS)
  767. }
  768. this.currentGpsPoint = nextPoint
  769. this.currentGpsAccuracyMeters = accuracyMeters
  770. const gpsWorldPoint = lonLatToWorldTile(nextPoint, this.state.zoom)
  771. const gpsTileX = Math.floor(gpsWorldPoint.x)
  772. const gpsTileY = Math.floor(gpsWorldPoint.y)
  773. const gpsInsideMap = isTileWithinBounds(this.tileBoundsByZoom, this.state.zoom, gpsTileX, gpsTileY)
  774. let gameStatusText: string | null = null
  775. if (this.courseData) {
  776. const gameResult = this.gameRuntime.dispatch({
  777. type: 'gps_updated',
  778. at: Date.now(),
  779. lon: longitude,
  780. lat: latitude,
  781. accuracyMeters,
  782. })
  783. this.gamePresentation = gameResult.presentation
  784. this.refreshCourseHeadingFromPresentation()
  785. gameStatusText = this.applyGameEffects(gameResult.effects)
  786. }
  787. if (gpsInsideMap && !this.hasGpsCenteredOnce) {
  788. this.hasGpsCenteredOnce = true
  789. this.commitViewport({
  790. centerTileX: gpsWorldPoint.x,
  791. centerTileY: gpsWorldPoint.y,
  792. tileTranslateX: 0,
  793. tileTranslateY: 0,
  794. gpsTracking: true,
  795. gpsTrackingText: '持续定位进行中',
  796. gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
  797. ...this.getGameViewPatch(),
  798. }, gameStatusText || `GPS定位成功,已定位到当前位置 (${this.buildVersion})`, true)
  799. return
  800. }
  801. this.setState({
  802. gpsTracking: true,
  803. gpsTrackingText: gpsInsideMap ? '持续定位进行中' : 'GPS不在当前地图范围内',
  804. gpsCoordText: formatGpsCoordText(nextPoint, accuracyMeters),
  805. ...this.getGameViewPatch(gameStatusText || (gpsInsideMap ? `GPS位置已更新 (${this.buildVersion})` : `GPS位置超出当前地图范围 (${this.buildVersion})`)),
  806. }, true)
  807. this.syncRenderer()
  808. }
  809. handleToggleOsmReference(): void {
  810. const nextEnabled = !this.state.osmReferenceEnabled
  811. this.setState({
  812. osmReferenceEnabled: nextEnabled,
  813. osmReferenceText: nextEnabled ? 'OSM参考:开' : 'OSM参考:关',
  814. statusText: nextEnabled ? `OSM参考底图已开启 (${this.buildVersion})` : `OSM参考底图已关闭 (${this.buildVersion})`,
  815. }, true)
  816. this.syncRenderer()
  817. }
  818. handleToggleGpsTracking(): void {
  819. if (this.locationController.listening) {
  820. this.locationController.stop()
  821. return
  822. }
  823. this.locationController.start()
  824. }
  825. setStage(rect: MapEngineStageRect): void {
  826. this.previewScale = 1
  827. this.previewOriginX = rect.width / 2
  828. this.previewOriginY = rect.height / 2
  829. this.commitViewport(
  830. {
  831. stageWidth: rect.width,
  832. stageHeight: rect.height,
  833. stageLeft: rect.left,
  834. stageTop: rect.top,
  835. },
  836. `鍦板浘瑙嗗彛涓?WebGL 寮曟搸宸插榻?(${this.buildVersion})`,
  837. true,
  838. )
  839. }
  840. attachCanvas(canvasNode: any, width: number, height: number, dpr: number, labelCanvasNode?: any): void {
  841. this.renderer.attachCanvas(canvasNode, width, height, dpr, labelCanvasNode)
  842. this.mounted = true
  843. this.state.mapReady = true
  844. this.state.mapReadyText = 'READY'
  845. this.onData({
  846. mapReady: true,
  847. mapReadyText: 'READY',
  848. statusText: `鍗?WebGL 绠$嚎宸插氨缁紝鍙垏鎹㈡墜鍔ㄦ垨鑷姩鏈濆悜 (${this.buildVersion})`,
  849. })
  850. this.syncRenderer()
  851. this.compassController.start()
  852. }
  853. applyRemoteMapConfig(config: RemoteMapConfig): void {
  854. MAGNETIC_DECLINATION_DEG = config.magneticDeclinationDeg
  855. MAGNETIC_DECLINATION_TEXT = config.magneticDeclinationText
  856. this.minZoom = config.minZoom
  857. this.maxZoom = config.maxZoom
  858. this.defaultZoom = config.defaultZoom
  859. this.defaultCenterTileX = config.initialCenterTileX
  860. this.defaultCenterTileY = config.initialCenterTileY
  861. this.tileBoundsByZoom = config.tileBoundsByZoom
  862. this.courseData = config.course
  863. this.cpRadiusMeters = config.cpRadiusMeters
  864. this.gameMode = config.gameMode
  865. this.punchPolicy = config.punchPolicy
  866. this.punchRadiusMeters = config.punchRadiusMeters
  867. this.autoFinishOnLastControl = config.autoFinishOnLastControl
  868. const gameEffects = this.loadGameDefinitionFromCourse()
  869. const gameStatusText = this.applyGameEffects(gameEffects)
  870. const statePatch: Partial<MapEngineViewState> = {
  871. configStatusText: `杩滅▼閰嶇疆宸茶浇鍏?/ ${config.courseStatusText}`,
  872. projectionMode: config.projectionModeText,
  873. tileSource: config.tileSource,
  874. sensorHeadingText: formatHeadingText(this.smoothedSensorHeadingDeg === null ? null : getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)),
  875. compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
  876. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  877. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  878. compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
  879. ...this.getGameViewPatch(),
  880. }
  881. if (!this.state.stageWidth || !this.state.stageHeight) {
  882. this.setState({
  883. ...statePatch,
  884. zoom: this.defaultZoom,
  885. centerTileX: this.defaultCenterTileX,
  886. centerTileY: this.defaultCenterTileY,
  887. centerText: buildCenterText(this.defaultZoom, this.defaultCenterTileX, this.defaultCenterTileY),
  888. statusText: gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`,
  889. }, true)
  890. return
  891. }
  892. this.commitViewport({
  893. ...statePatch,
  894. zoom: this.defaultZoom,
  895. centerTileX: this.defaultCenterTileX,
  896. centerTileY: this.defaultCenterTileY,
  897. tileTranslateX: 0,
  898. tileTranslateY: 0,
  899. }, gameStatusText || `路线已载入,点击开始进入游戏 (${this.buildVersion})`, true, () => {
  900. this.resetPreviewState()
  901. this.syncRenderer()
  902. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  903. this.scheduleAutoRotate()
  904. }
  905. })
  906. }
  907. handleTouchStart(event: WechatMiniprogram.TouchEvent): void {
  908. this.clearInertiaTimer()
  909. this.clearPreviewResetTimer()
  910. this.panVelocityX = 0
  911. this.panVelocityY = 0
  912. if (event.touches.length >= 2) {
  913. const origin = this.getStagePoint(event.touches)
  914. this.gestureMode = 'pinch'
  915. this.pinchStartDistance = this.getTouchDistance(event.touches)
  916. this.pinchStartScale = this.previewScale || 1
  917. this.pinchStartAngle = this.getTouchAngle(event.touches)
  918. this.pinchStartRotationDeg = this.state.rotationDeg
  919. const anchorWorld = screenToWorld(this.getCameraState(), origin, true)
  920. this.pinchAnchorWorldX = anchorWorld.x
  921. this.pinchAnchorWorldY = anchorWorld.y
  922. this.setPreviewState(this.pinchStartScale, origin.x, origin.y)
  923. this.syncRenderer()
  924. this.compassController.start()
  925. return
  926. }
  927. if (event.touches.length === 1) {
  928. this.gestureMode = 'pan'
  929. this.panLastX = event.touches[0].pageX
  930. this.panLastY = event.touches[0].pageY
  931. this.panLastTimestamp = event.timeStamp || Date.now()
  932. }
  933. }
  934. handleTouchMove(event: WechatMiniprogram.TouchEvent): void {
  935. if (event.touches.length >= 2) {
  936. const distance = this.getTouchDistance(event.touches)
  937. const angle = this.getTouchAngle(event.touches)
  938. const origin = this.getStagePoint(event.touches)
  939. if (!this.pinchStartDistance) {
  940. this.pinchStartDistance = distance
  941. this.pinchStartScale = this.previewScale || 1
  942. this.pinchStartAngle = angle
  943. this.pinchStartRotationDeg = this.state.rotationDeg
  944. const anchorWorld = screenToWorld(this.getCameraState(), origin, true)
  945. this.pinchAnchorWorldX = anchorWorld.x
  946. this.pinchAnchorWorldY = anchorWorld.y
  947. }
  948. this.gestureMode = 'pinch'
  949. const nextRotationDeg = this.state.orientationMode === 'heading-up'
  950. ? this.state.rotationDeg
  951. : normalizeRotationDeg(this.pinchStartRotationDeg + normalizeAngleDeltaRad(angle - this.pinchStartAngle) * 180 / Math.PI)
  952. const anchorOffset = this.getWorldOffsetFromScreen(origin.x, origin.y, nextRotationDeg)
  953. const resolvedViewport = this.resolveViewportForExactCenter(
  954. this.pinchAnchorWorldX - anchorOffset.x,
  955. this.pinchAnchorWorldY - anchorOffset.y,
  956. nextRotationDeg,
  957. )
  958. this.setPreviewState(
  959. clamp(this.pinchStartScale * (distance / this.pinchStartDistance), MIN_PREVIEW_SCALE, MAX_PREVIEW_SCALE),
  960. origin.x,
  961. origin.y,
  962. )
  963. this.commitViewport(
  964. {
  965. ...resolvedViewport,
  966. rotationDeg: nextRotationDeg,
  967. rotationText: formatRotationText(nextRotationDeg),
  968. },
  969. this.state.orientationMode === 'heading-up'
  970. ? `鍙屾寚缂╂斁涓紝鑷姩鏈濆悜淇濇寔寮€鍚?(${this.buildVersion})`
  971. : `鍙屾寚缂╂斁涓庢棆杞腑 (${this.buildVersion})`,
  972. )
  973. return
  974. }
  975. if (this.gestureMode !== 'pan' || event.touches.length !== 1) {
  976. return
  977. }
  978. const touch = event.touches[0]
  979. const deltaX = touch.pageX - this.panLastX
  980. const deltaY = touch.pageY - this.panLastY
  981. const nextTimestamp = event.timeStamp || Date.now()
  982. const elapsed = Math.max(nextTimestamp - this.panLastTimestamp, 16)
  983. const instantVelocityX = deltaX / elapsed
  984. const instantVelocityY = deltaY / elapsed
  985. this.panVelocityX = this.panVelocityX * 0.72 + instantVelocityX * 0.28
  986. this.panVelocityY = this.panVelocityY * 0.72 + instantVelocityY * 0.28
  987. this.panLastX = touch.pageX
  988. this.panLastY = touch.pageY
  989. this.panLastTimestamp = nextTimestamp
  990. this.normalizeTranslate(
  991. this.state.tileTranslateX + deltaX,
  992. this.state.tileTranslateY + deltaY,
  993. `宸叉嫋鎷藉崟 WebGL 鍦板浘寮曟搸 (${this.buildVersion})`,
  994. )
  995. }
  996. handleTouchEnd(event: WechatMiniprogram.TouchEvent): void {
  997. if (this.gestureMode === 'pinch' && event.touches.length < 2) {
  998. const gestureScale = this.previewScale || 1
  999. const zoomDelta = Math.round(Math.log2(gestureScale))
  1000. const originX = this.previewOriginX || this.state.stageWidth / 2
  1001. const originY = this.previewOriginY || this.state.stageHeight / 2
  1002. if (zoomDelta) {
  1003. const residualScale = gestureScale / Math.pow(2, zoomDelta)
  1004. this.zoomAroundPoint(zoomDelta, originX, originY, residualScale)
  1005. } else {
  1006. this.animatePreviewToRest()
  1007. }
  1008. this.resetPinchState()
  1009. this.panVelocityX = 0
  1010. this.panVelocityY = 0
  1011. if (event.touches.length === 1) {
  1012. this.gestureMode = 'pan'
  1013. this.panLastX = event.touches[0].pageX
  1014. this.panLastY = event.touches[0].pageY
  1015. this.panLastTimestamp = event.timeStamp || Date.now()
  1016. return
  1017. }
  1018. this.gestureMode = 'idle'
  1019. this.renderer.setAnimationPaused(false)
  1020. this.scheduleAutoRotate()
  1021. return
  1022. }
  1023. if (event.touches.length === 1) {
  1024. this.gestureMode = 'pan'
  1025. this.panLastX = event.touches[0].pageX
  1026. this.panLastY = event.touches[0].pageY
  1027. this.panLastTimestamp = event.timeStamp || Date.now()
  1028. return
  1029. }
  1030. if (this.gestureMode === 'pan' && (Math.abs(this.panVelocityX) >= INERTIA_MIN_SPEED || Math.abs(this.panVelocityY) >= INERTIA_MIN_SPEED)) {
  1031. this.startInertia()
  1032. this.gestureMode = 'idle'
  1033. this.resetPinchState()
  1034. return
  1035. }
  1036. this.gestureMode = 'idle'
  1037. this.resetPinchState()
  1038. this.renderer.setAnimationPaused(false)
  1039. this.scheduleAutoRotate()
  1040. }
  1041. handleTouchCancel(): void {
  1042. this.gestureMode = 'idle'
  1043. this.resetPinchState()
  1044. this.panVelocityX = 0
  1045. this.panVelocityY = 0
  1046. this.clearInertiaTimer()
  1047. this.animatePreviewToRest()
  1048. this.renderer.setAnimationPaused(false)
  1049. this.scheduleAutoRotate()
  1050. }
  1051. handleRecenter(): void {
  1052. this.clearInertiaTimer()
  1053. this.clearPreviewResetTimer()
  1054. this.panVelocityX = 0
  1055. this.panVelocityY = 0
  1056. this.renderer.setAnimationPaused(false)
  1057. this.commitViewport(
  1058. {
  1059. zoom: this.defaultZoom,
  1060. centerTileX: this.defaultCenterTileX,
  1061. centerTileY: this.defaultCenterTileY,
  1062. tileTranslateX: 0,
  1063. tileTranslateY: 0,
  1064. },
  1065. `宸插洖鍒板崟 WebGL 寮曟搸榛樿棣栧睆 (${this.buildVersion})`,
  1066. true,
  1067. () => {
  1068. this.resetPreviewState()
  1069. this.syncRenderer()
  1070. this.compassController.start()
  1071. this.scheduleAutoRotate()
  1072. },
  1073. )
  1074. }
  1075. handleRotateStep(stepDeg = ROTATE_STEP_DEG): void {
  1076. if (this.state.rotationMode === 'auto') {
  1077. this.setState({
  1078. statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
  1079. }, true)
  1080. return
  1081. }
  1082. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1083. const nextRotationDeg = normalizeRotationDeg(this.state.rotationDeg + stepDeg)
  1084. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
  1085. this.clearInertiaTimer()
  1086. this.clearPreviewResetTimer()
  1087. this.panVelocityX = 0
  1088. this.panVelocityY = 0
  1089. this.renderer.setAnimationPaused(false)
  1090. this.commitViewport(
  1091. {
  1092. ...resolvedViewport,
  1093. rotationDeg: nextRotationDeg,
  1094. rotationText: formatRotationText(nextRotationDeg),
  1095. },
  1096. `鏃嬭浆瑙掑害璋冩暣鍒?${formatRotationText(nextRotationDeg)} (${this.buildVersion})`,
  1097. true,
  1098. () => {
  1099. this.resetPreviewState()
  1100. this.syncRenderer()
  1101. this.compassController.start()
  1102. },
  1103. )
  1104. }
  1105. handleRotationReset(): void {
  1106. if (this.state.rotationMode === 'auto') {
  1107. this.setState({
  1108. statusText: `褰撳墠涓嶆槸鎵嬪姩鏃嬭浆妯″紡锛岃鍏堝垏鍥炴墜鍔?(${this.buildVersion})`,
  1109. }, true)
  1110. return
  1111. }
  1112. const targetRotationDeg = MAP_NORTH_OFFSET_DEG
  1113. if (Math.abs(normalizeAngleDeltaDeg(this.state.rotationDeg - targetRotationDeg)) <= 0.01) {
  1114. return
  1115. }
  1116. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1117. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, targetRotationDeg)
  1118. this.clearInertiaTimer()
  1119. this.clearPreviewResetTimer()
  1120. this.panVelocityX = 0
  1121. this.panVelocityY = 0
  1122. this.renderer.setAnimationPaused(false)
  1123. this.commitViewport(
  1124. {
  1125. ...resolvedViewport,
  1126. rotationDeg: targetRotationDeg,
  1127. rotationText: formatRotationText(targetRotationDeg),
  1128. },
  1129. `鏃嬭浆瑙掑害宸插洖鍒扮湡鍖楀弬鑰?(${this.buildVersion})`,
  1130. true,
  1131. () => {
  1132. this.resetPreviewState()
  1133. this.syncRenderer()
  1134. this.compassController.start()
  1135. },
  1136. )
  1137. }
  1138. handleToggleRotationMode(): void {
  1139. if (this.state.orientationMode === 'manual') {
  1140. this.setNorthUpMode()
  1141. return
  1142. }
  1143. if (this.state.orientationMode === 'north-up') {
  1144. this.setHeadingUpMode()
  1145. return
  1146. }
  1147. this.setManualMode()
  1148. }
  1149. handleSetManualMode(): void {
  1150. this.setManualMode()
  1151. }
  1152. handleSetNorthUpMode(): void {
  1153. this.setNorthUpMode()
  1154. }
  1155. handleSetHeadingUpMode(): void {
  1156. this.setHeadingUpMode()
  1157. }
  1158. handleCycleNorthReferenceMode(): void {
  1159. this.cycleNorthReferenceMode()
  1160. }
  1161. handleAutoRotateCalibrate(): void {
  1162. if (this.state.orientationMode !== 'heading-up') {
  1163. this.setState({
  1164. statusText: `璇峰厛鍒囧埌鏈濆悜鏈濅笂妯″紡鍐嶆牎鍑?(${this.buildVersion})`,
  1165. }, true)
  1166. return
  1167. }
  1168. if (!this.calibrateAutoRotateToCurrentOrientation()) {
  1169. this.setState({
  1170. statusText: `褰撳墠杩樻病鏈変紶鎰熷櫒鏂瑰悜鏁版嵁锛屾殏鏃舵棤娉曟牎鍑?(${this.buildVersion})`,
  1171. }, true)
  1172. return
  1173. }
  1174. this.setState({
  1175. statusText: `宸叉寜褰撳墠鎸佹満鏂瑰悜瀹屾垚鏈濆悜鏍″噯 (${this.buildVersion})`,
  1176. }, true)
  1177. this.scheduleAutoRotate()
  1178. }
  1179. setManualMode(): void {
  1180. this.clearAutoRotateTimer()
  1181. this.targetAutoRotationDeg = null
  1182. this.autoRotateCalibrationPending = false
  1183. this.setState({
  1184. rotationMode: 'manual',
  1185. rotationModeText: formatRotationModeText('manual'),
  1186. rotationToggleText: formatRotationToggleText('manual'),
  1187. orientationMode: 'manual',
  1188. orientationModeText: formatOrientationModeText('manual'),
  1189. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  1190. statusText: `宸插垏鍥炴墜鍔ㄥ湴鍥炬棆杞?(${this.buildVersion})`,
  1191. }, true)
  1192. }
  1193. setNorthUpMode(): void {
  1194. this.clearAutoRotateTimer()
  1195. this.targetAutoRotationDeg = null
  1196. this.autoRotateCalibrationPending = false
  1197. const mapNorthOffsetDeg = MAP_NORTH_OFFSET_DEG
  1198. this.autoRotateCalibrationOffsetDeg = mapNorthOffsetDeg
  1199. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1200. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, mapNorthOffsetDeg)
  1201. this.commitViewport(
  1202. {
  1203. ...resolvedViewport,
  1204. rotationDeg: mapNorthOffsetDeg,
  1205. rotationText: formatRotationText(mapNorthOffsetDeg),
  1206. rotationMode: 'manual',
  1207. rotationModeText: formatRotationModeText('north-up'),
  1208. rotationToggleText: formatRotationToggleText('north-up'),
  1209. orientationMode: 'north-up',
  1210. orientationModeText: formatOrientationModeText('north-up'),
  1211. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, mapNorthOffsetDeg),
  1212. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  1213. },
  1214. `鍦板浘宸插浐瀹氫负鐪熷寳鏈濅笂 (${this.buildVersion})`,
  1215. true,
  1216. () => {
  1217. this.resetPreviewState()
  1218. this.syncRenderer()
  1219. },
  1220. )
  1221. }
  1222. setHeadingUpMode(): void {
  1223. this.autoRotateCalibrationPending = false
  1224. this.autoRotateCalibrationOffsetDeg = getMapNorthOffsetDeg(this.northReferenceMode)
  1225. this.targetAutoRotationDeg = null
  1226. this.setState({
  1227. rotationMode: 'auto',
  1228. rotationModeText: formatRotationModeText('heading-up'),
  1229. rotationToggleText: formatRotationToggleText('heading-up'),
  1230. orientationMode: 'heading-up',
  1231. orientationModeText: formatOrientationModeText('heading-up'),
  1232. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  1233. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  1234. statusText: `姝e湪鍚敤鏈濆悜鏈濅笂妯″紡 (${this.buildVersion})`,
  1235. }, true)
  1236. if (this.refreshAutoRotateTarget()) {
  1237. this.scheduleAutoRotate()
  1238. }
  1239. }
  1240. handleCompassHeading(headingDeg: number): void {
  1241. this.sensorHeadingDeg = normalizeRotationDeg(headingDeg)
  1242. this.smoothedSensorHeadingDeg = this.smoothedSensorHeadingDeg === null
  1243. ? this.sensorHeadingDeg
  1244. : interpolateAngleDeg(this.smoothedSensorHeadingDeg, this.sensorHeadingDeg, AUTO_ROTATE_HEADING_SMOOTHING)
  1245. const compassHeadingDeg = getCompassReferenceHeadingDeg(this.northReferenceMode, this.smoothedSensorHeadingDeg)
  1246. this.compassDisplayHeadingDeg = this.compassDisplayHeadingDeg === null
  1247. ? compassHeadingDeg
  1248. : interpolateAngleDeg(this.compassDisplayHeadingDeg, compassHeadingDeg, COMPASS_NEEDLE_SMOOTHING)
  1249. this.autoRotateHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  1250. this.setState({
  1251. sensorHeadingText: formatHeadingText(compassHeadingDeg),
  1252. compassDeclinationText: formatCompassDeclinationText(this.northReferenceMode),
  1253. northReferenceButtonText: formatNorthReferenceButtonText(this.northReferenceMode),
  1254. autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
  1255. compassNeedleDeg: formatCompassNeedleDegForMode(this.northReferenceMode, this.smoothedSensorHeadingDeg),
  1256. northReferenceText: formatNorthReferenceText(this.northReferenceMode),
  1257. })
  1258. if (!this.refreshAutoRotateTarget()) {
  1259. return
  1260. }
  1261. if (this.state.orientationMode === 'heading-up') {
  1262. this.scheduleAutoRotate()
  1263. }
  1264. }
  1265. handleCompassError(message: string): void {
  1266. this.clearAutoRotateTimer()
  1267. this.targetAutoRotationDeg = null
  1268. this.autoRotateCalibrationPending = false
  1269. this.setState({
  1270. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  1271. statusText: `${message} (${this.buildVersion})`,
  1272. }, true)
  1273. }
  1274. cycleNorthReferenceMode(): void {
  1275. const nextMode = getNextNorthReferenceMode(this.northReferenceMode)
  1276. const nextMapNorthOffsetDeg = getMapNorthOffsetDeg(nextMode)
  1277. const compassHeadingDeg = this.smoothedSensorHeadingDeg === null
  1278. ? null
  1279. : getCompassReferenceHeadingDeg(nextMode, this.smoothedSensorHeadingDeg)
  1280. this.northReferenceMode = nextMode
  1281. this.autoRotateCalibrationOffsetDeg = nextMapNorthOffsetDeg
  1282. this.compassDisplayHeadingDeg = compassHeadingDeg
  1283. if (this.state.orientationMode === 'north-up') {
  1284. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1285. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, MAP_NORTH_OFFSET_DEG)
  1286. this.commitViewport(
  1287. {
  1288. ...resolvedViewport,
  1289. rotationDeg: MAP_NORTH_OFFSET_DEG,
  1290. rotationText: formatRotationText(MAP_NORTH_OFFSET_DEG),
  1291. northReferenceText: formatNorthReferenceText(nextMode),
  1292. sensorHeadingText: formatHeadingText(compassHeadingDeg),
  1293. compassDeclinationText: formatCompassDeclinationText(nextMode),
  1294. northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
  1295. compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
  1296. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
  1297. },
  1298. `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
  1299. true,
  1300. () => {
  1301. this.resetPreviewState()
  1302. this.syncRenderer()
  1303. },
  1304. )
  1305. return
  1306. }
  1307. this.setState({
  1308. northReferenceText: formatNorthReferenceText(nextMode),
  1309. sensorHeadingText: formatHeadingText(compassHeadingDeg),
  1310. compassDeclinationText: formatCompassDeclinationText(nextMode),
  1311. northReferenceButtonText: formatNorthReferenceButtonText(nextMode),
  1312. compassNeedleDeg: formatCompassNeedleDegForMode(nextMode, this.smoothedSensorHeadingDeg),
  1313. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, nextMapNorthOffsetDeg),
  1314. statusText: `${formatNorthReferenceStatusText(nextMode)} (${this.buildVersion})`,
  1315. }, true)
  1316. if (this.state.orientationMode === 'heading-up' && this.refreshAutoRotateTarget()) {
  1317. this.scheduleAutoRotate()
  1318. }
  1319. }
  1320. setCourseHeading(headingDeg: number | null): void {
  1321. this.courseHeadingDeg = headingDeg === null ? null : normalizeRotationDeg(headingDeg)
  1322. this.setState({
  1323. autoRotateSourceText: formatAutoRotateSourceText(this.autoRotateSourceMode, this.courseHeadingDeg !== null),
  1324. })
  1325. if (this.refreshAutoRotateTarget()) {
  1326. this.scheduleAutoRotate()
  1327. }
  1328. }
  1329. resolveAutoRotateInputHeadingDeg(): number | null {
  1330. const sensorHeadingDeg = this.smoothedSensorHeadingDeg === null
  1331. ? null
  1332. : getMapReferenceHeadingDegFromSensor(this.northReferenceMode, this.smoothedSensorHeadingDeg)
  1333. const courseHeadingDeg = this.courseHeadingDeg === null
  1334. ? null
  1335. : getMapReferenceHeadingDegFromCourse(this.northReferenceMode, this.courseHeadingDeg)
  1336. if (this.autoRotateSourceMode === 'sensor') {
  1337. return sensorHeadingDeg
  1338. }
  1339. if (this.autoRotateSourceMode === 'course') {
  1340. return courseHeadingDeg === null ? sensorHeadingDeg : courseHeadingDeg
  1341. }
  1342. if (sensorHeadingDeg !== null && courseHeadingDeg !== null) {
  1343. return interpolateAngleDeg(sensorHeadingDeg, courseHeadingDeg, 0.35)
  1344. }
  1345. return sensorHeadingDeg === null ? courseHeadingDeg : sensorHeadingDeg
  1346. }
  1347. calibrateAutoRotateToCurrentOrientation(): boolean {
  1348. const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  1349. if (inputHeadingDeg === null) {
  1350. return false
  1351. }
  1352. this.autoRotateCalibrationOffsetDeg = normalizeRotationDeg(this.state.rotationDeg + inputHeadingDeg)
  1353. this.autoRotateCalibrationPending = false
  1354. this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
  1355. this.setState({
  1356. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  1357. })
  1358. return true
  1359. }
  1360. refreshAutoRotateTarget(): boolean {
  1361. const inputHeadingDeg = this.resolveAutoRotateInputHeadingDeg()
  1362. if (inputHeadingDeg === null) {
  1363. return false
  1364. }
  1365. if (this.autoRotateCalibrationPending || this.autoRotateCalibrationOffsetDeg === null) {
  1366. if (!this.calibrateAutoRotateToCurrentOrientation()) {
  1367. return false
  1368. }
  1369. return true
  1370. }
  1371. this.targetAutoRotationDeg = normalizeRotationDeg(this.autoRotateCalibrationOffsetDeg - inputHeadingDeg)
  1372. this.setState({
  1373. autoRotateCalibrationText: formatAutoRotateCalibrationText(false, this.autoRotateCalibrationOffsetDeg),
  1374. })
  1375. return true
  1376. }
  1377. scheduleAutoRotate(): void {
  1378. if (this.autoRotateTimer || this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
  1379. return
  1380. }
  1381. const step = () => {
  1382. this.autoRotateTimer = 0
  1383. if (this.state.rotationMode !== 'auto' || this.targetAutoRotationDeg === null) {
  1384. return
  1385. }
  1386. if (this.gestureMode !== 'idle' || this.inertiaTimer || this.previewResetTimer) {
  1387. this.scheduleAutoRotate()
  1388. return
  1389. }
  1390. const currentRotationDeg = this.state.rotationDeg
  1391. const deltaDeg = normalizeAngleDeltaDeg(this.targetAutoRotationDeg - currentRotationDeg)
  1392. if (Math.abs(deltaDeg) <= AUTO_ROTATE_SNAP_DEG) {
  1393. if (Math.abs(deltaDeg) > 0.01) {
  1394. this.applyAutoRotation(this.targetAutoRotationDeg)
  1395. }
  1396. this.scheduleAutoRotate()
  1397. return
  1398. }
  1399. if (Math.abs(deltaDeg) <= AUTO_ROTATE_DEADZONE_DEG) {
  1400. this.scheduleAutoRotate()
  1401. return
  1402. }
  1403. const easedStepDeg = clamp(deltaDeg * AUTO_ROTATE_EASE, -AUTO_ROTATE_MAX_STEP_DEG, AUTO_ROTATE_MAX_STEP_DEG)
  1404. this.applyAutoRotation(normalizeRotationDeg(currentRotationDeg + easedStepDeg))
  1405. this.scheduleAutoRotate()
  1406. }
  1407. this.autoRotateTimer = setTimeout(step, AUTO_ROTATE_FRAME_MS) as unknown as number
  1408. }
  1409. applyAutoRotation(nextRotationDeg: number): void {
  1410. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1411. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y, nextRotationDeg)
  1412. this.setState({
  1413. ...resolvedViewport,
  1414. rotationDeg: nextRotationDeg,
  1415. rotationText: formatRotationText(nextRotationDeg),
  1416. centerText: buildCenterText(this.state.zoom, resolvedViewport.centerTileX, resolvedViewport.centerTileY),
  1417. })
  1418. this.syncRenderer()
  1419. }
  1420. applyStats(stats: MapRendererStats): void {
  1421. this.setState({
  1422. visibleTileCount: stats.visibleTileCount,
  1423. readyTileCount: stats.readyTileCount,
  1424. memoryTileCount: stats.memoryTileCount,
  1425. diskTileCount: stats.diskTileCount,
  1426. memoryHitCount: stats.memoryHitCount,
  1427. diskHitCount: stats.diskHitCount,
  1428. networkFetchCount: stats.networkFetchCount,
  1429. cacheHitRateText: formatCacheHitRate(stats.memoryHitCount, stats.diskHitCount, stats.networkFetchCount),
  1430. })
  1431. }
  1432. setState(patch: Partial<MapEngineViewState>, immediateUi = false): void {
  1433. this.state = {
  1434. ...this.state,
  1435. ...patch,
  1436. }
  1437. const viewPatch = this.pickViewPatch(patch)
  1438. if (!Object.keys(viewPatch).length) {
  1439. return
  1440. }
  1441. this.pendingViewPatch = {
  1442. ...this.pendingViewPatch,
  1443. ...viewPatch,
  1444. }
  1445. if (immediateUi) {
  1446. this.flushViewPatch()
  1447. return
  1448. }
  1449. if (this.viewSyncTimer) {
  1450. return
  1451. }
  1452. this.viewSyncTimer = setTimeout(() => {
  1453. this.viewSyncTimer = 0
  1454. this.flushViewPatch()
  1455. }, UI_SYNC_INTERVAL_MS) as unknown as number
  1456. }
  1457. commitViewport(
  1458. patch: Partial<MapEngineViewState>,
  1459. statusText: string,
  1460. immediateUi = false,
  1461. afterUpdate?: () => void,
  1462. ): void {
  1463. const nextZoom = typeof patch.zoom === 'number' ? patch.zoom : this.state.zoom
  1464. const nextCenterTileX = typeof patch.centerTileX === 'number' ? patch.centerTileX : this.state.centerTileX
  1465. const nextCenterTileY = typeof patch.centerTileY === 'number' ? patch.centerTileY : this.state.centerTileY
  1466. const nextStageWidth = typeof patch.stageWidth === 'number' ? patch.stageWidth : this.state.stageWidth
  1467. const nextStageHeight = typeof patch.stageHeight === 'number' ? patch.stageHeight : this.state.stageHeight
  1468. const tileSizePx = getTileSizePx({
  1469. centerWorldX: nextCenterTileX,
  1470. centerWorldY: nextCenterTileY,
  1471. viewportWidth: nextStageWidth,
  1472. viewportHeight: nextStageHeight,
  1473. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1474. })
  1475. this.setState({
  1476. ...patch,
  1477. tileSizePx,
  1478. centerText: buildCenterText(nextZoom, nextCenterTileX, nextCenterTileY),
  1479. statusText,
  1480. }, immediateUi)
  1481. this.syncRenderer()
  1482. this.compassController.start()
  1483. if (afterUpdate) {
  1484. afterUpdate()
  1485. }
  1486. }
  1487. buildScene() {
  1488. const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
  1489. return {
  1490. tileSource: this.state.tileSource,
  1491. osmTileSource: OSM_TILE_SOURCE,
  1492. zoom: this.state.zoom,
  1493. centerTileX: this.state.centerTileX,
  1494. centerTileY: this.state.centerTileY,
  1495. exactCenterWorldX: exactCenter.x,
  1496. exactCenterWorldY: exactCenter.y,
  1497. tileBoundsByZoom: this.tileBoundsByZoom,
  1498. viewportWidth: this.state.stageWidth,
  1499. viewportHeight: this.state.stageHeight,
  1500. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1501. overdraw: OVERDRAW,
  1502. translateX: this.state.tileTranslateX,
  1503. translateY: this.state.tileTranslateY,
  1504. rotationRad: this.getRotationRad(this.state.rotationDeg),
  1505. previewScale: this.previewScale || 1,
  1506. previewOriginX: this.previewOriginX || this.state.stageWidth / 2,
  1507. previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
  1508. track: this.currentGpsTrack,
  1509. gpsPoint: this.currentGpsPoint,
  1510. gpsCalibration: GPS_MAP_CALIBRATION,
  1511. gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
  1512. course: this.courseData,
  1513. cpRadiusMeters: this.cpRadiusMeters,
  1514. activeControlSequences: this.gamePresentation.activeControlSequences,
  1515. activeStart: this.gamePresentation.activeStart,
  1516. completedStart: this.gamePresentation.completedStart,
  1517. activeFinish: this.gamePresentation.activeFinish,
  1518. completedFinish: this.gamePresentation.completedFinish,
  1519. revealFullCourse: this.gamePresentation.revealFullCourse,
  1520. activeLegIndices: this.gamePresentation.activeLegIndices,
  1521. completedLegIndices: this.gamePresentation.completedLegIndices,
  1522. completedControlSequences: this.gamePresentation.completedControlSequences,
  1523. osmReferenceEnabled: this.state.osmReferenceEnabled,
  1524. overlayOpacity: MAP_OVERLAY_OPACITY,
  1525. }
  1526. }
  1527. syncRenderer(): void {
  1528. if (!this.mounted || !this.state.stageWidth || !this.state.stageHeight) {
  1529. return
  1530. }
  1531. this.renderer.updateScene(this.buildScene())
  1532. }
  1533. getCameraState(rotationDeg = this.state.rotationDeg): CameraState {
  1534. return {
  1535. centerWorldX: this.state.centerTileX + 0.5,
  1536. centerWorldY: this.state.centerTileY + 0.5,
  1537. viewportWidth: this.state.stageWidth,
  1538. viewportHeight: this.state.stageHeight,
  1539. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1540. translateX: this.state.tileTranslateX,
  1541. translateY: this.state.tileTranslateY,
  1542. rotationRad: this.getRotationRad(rotationDeg),
  1543. }
  1544. }
  1545. getRotationRad(rotationDeg = this.state.rotationDeg): number {
  1546. return normalizeRotationDeg(rotationDeg) * Math.PI / 180
  1547. }
  1548. getBaseCamera(centerTileX = this.state.centerTileX, centerTileY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState {
  1549. return {
  1550. centerWorldX: centerTileX + 0.5,
  1551. centerWorldY: centerTileY + 0.5,
  1552. viewportWidth: this.state.stageWidth,
  1553. viewportHeight: this.state.stageHeight,
  1554. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1555. rotationRad: this.getRotationRad(rotationDeg),
  1556. }
  1557. }
  1558. getWorldOffsetFromScreen(stageX: number, stageY: number, rotationDeg = this.state.rotationDeg): { x: number; y: number } {
  1559. const baseCamera = {
  1560. centerWorldX: 0,
  1561. centerWorldY: 0,
  1562. viewportWidth: this.state.stageWidth,
  1563. viewportHeight: this.state.stageHeight,
  1564. visibleColumns: DESIRED_VISIBLE_COLUMNS,
  1565. rotationRad: this.getRotationRad(rotationDeg),
  1566. }
  1567. return screenToWorld(baseCamera, { x: stageX, y: stageY }, false)
  1568. }
  1569. getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } {
  1570. if (!this.state.stageWidth || !this.state.stageHeight) {
  1571. return {
  1572. x: this.state.centerTileX + 0.5,
  1573. y: this.state.centerTileY + 0.5,
  1574. }
  1575. }
  1576. const screenCenterX = this.state.stageWidth / 2
  1577. const screenCenterY = this.state.stageHeight / 2
  1578. return screenToWorld(this.getBaseCamera(), {
  1579. x: screenCenterX - translateX,
  1580. y: screenCenterY - translateY,
  1581. }, false)
  1582. }
  1583. resolveViewportForExactCenter(centerWorldX: number, centerWorldY: number, rotationDeg = this.state.rotationDeg): {
  1584. centerTileX: number
  1585. centerTileY: number
  1586. tileTranslateX: number
  1587. tileTranslateY: number
  1588. } {
  1589. const nextCenterTileX = Math.floor(centerWorldX)
  1590. const nextCenterTileY = Math.floor(centerWorldY)
  1591. if (!this.state.stageWidth || !this.state.stageHeight) {
  1592. return {
  1593. centerTileX: nextCenterTileX,
  1594. centerTileY: nextCenterTileY,
  1595. tileTranslateX: 0,
  1596. tileTranslateY: 0,
  1597. }
  1598. }
  1599. const roundedCamera = this.getBaseCamera(nextCenterTileX, nextCenterTileY, rotationDeg)
  1600. const projectedCenter = worldToScreen(roundedCamera, { x: centerWorldX, y: centerWorldY }, false)
  1601. return {
  1602. centerTileX: nextCenterTileX,
  1603. centerTileY: nextCenterTileY,
  1604. tileTranslateX: this.state.stageWidth / 2 - projectedCenter.x,
  1605. tileTranslateY: this.state.stageHeight / 2 - projectedCenter.y,
  1606. }
  1607. }
  1608. setPreviewState(scale: number, originX: number, originY: number): void {
  1609. this.previewScale = scale
  1610. this.previewOriginX = originX
  1611. this.previewOriginY = originY
  1612. }
  1613. resetPreviewState(): void {
  1614. this.setPreviewState(1, this.state.stageWidth / 2, this.state.stageHeight / 2)
  1615. }
  1616. resetPinchState(): void {
  1617. this.pinchStartDistance = 0
  1618. this.pinchStartScale = 1
  1619. this.pinchStartAngle = 0
  1620. this.pinchStartRotationDeg = this.state.rotationDeg
  1621. this.pinchAnchorWorldX = 0
  1622. this.pinchAnchorWorldY = 0
  1623. }
  1624. clearPreviewResetTimer(): void {
  1625. if (this.previewResetTimer) {
  1626. clearTimeout(this.previewResetTimer)
  1627. this.previewResetTimer = 0
  1628. }
  1629. }
  1630. clearInertiaTimer(): void {
  1631. if (this.inertiaTimer) {
  1632. clearTimeout(this.inertiaTimer)
  1633. this.inertiaTimer = 0
  1634. }
  1635. }
  1636. clearViewSyncTimer(): void {
  1637. if (this.viewSyncTimer) {
  1638. clearTimeout(this.viewSyncTimer)
  1639. this.viewSyncTimer = 0
  1640. }
  1641. }
  1642. clearAutoRotateTimer(): void {
  1643. if (this.autoRotateTimer) {
  1644. clearTimeout(this.autoRotateTimer)
  1645. this.autoRotateTimer = 0
  1646. }
  1647. }
  1648. pickViewPatch(patch: Partial<MapEngineViewState>): Partial<MapEngineViewState> {
  1649. const viewPatch = {} as Partial<MapEngineViewState>
  1650. for (const key of VIEW_SYNC_KEYS) {
  1651. if (Object.prototype.hasOwnProperty.call(patch, key)) {
  1652. ;(viewPatch as any)[key] = patch[key]
  1653. }
  1654. }
  1655. return viewPatch
  1656. }
  1657. flushViewPatch(): void {
  1658. if (!Object.keys(this.pendingViewPatch).length) {
  1659. return
  1660. }
  1661. const patch = this.pendingViewPatch
  1662. this.pendingViewPatch = {}
  1663. this.onData(patch)
  1664. }
  1665. getTouchDistance(touches: TouchPoint[]): number {
  1666. if (touches.length < 2) {
  1667. return 0
  1668. }
  1669. const first = touches[0]
  1670. const second = touches[1]
  1671. const deltaX = first.pageX - second.pageX
  1672. const deltaY = first.pageY - second.pageY
  1673. return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
  1674. }
  1675. getTouchAngle(touches: TouchPoint[]): number {
  1676. if (touches.length < 2) {
  1677. return 0
  1678. }
  1679. const first = touches[0]
  1680. const second = touches[1]
  1681. return Math.atan2(second.pageY - first.pageY, second.pageX - first.pageX)
  1682. }
  1683. getStagePoint(touches: TouchPoint[]): { x: number; y: number } {
  1684. if (!touches.length) {
  1685. return {
  1686. x: this.state.stageWidth / 2,
  1687. y: this.state.stageHeight / 2,
  1688. }
  1689. }
  1690. let pageX = 0
  1691. let pageY = 0
  1692. for (const touch of touches) {
  1693. pageX += touch.pageX
  1694. pageY += touch.pageY
  1695. }
  1696. return {
  1697. x: pageX / touches.length - this.state.stageLeft,
  1698. y: pageY / touches.length - this.state.stageTop,
  1699. }
  1700. }
  1701. animatePreviewToRest(): void {
  1702. this.clearPreviewResetTimer()
  1703. const startScale = this.previewScale || 1
  1704. const originX = this.previewOriginX || this.state.stageWidth / 2
  1705. const originY = this.previewOriginY || this.state.stageHeight / 2
  1706. if (Math.abs(startScale - 1) < 0.01) {
  1707. this.resetPreviewState()
  1708. this.syncRenderer()
  1709. this.compassController.start()
  1710. this.scheduleAutoRotate()
  1711. return
  1712. }
  1713. const startAt = Date.now()
  1714. const step = () => {
  1715. const progress = Math.min(1, (Date.now() - startAt) / PREVIEW_RESET_DURATION_MS)
  1716. const eased = 1 - Math.pow(1 - progress, 3)
  1717. const nextScale = startScale + (1 - startScale) * eased
  1718. this.setPreviewState(nextScale, originX, originY)
  1719. this.syncRenderer()
  1720. this.compassController.start()
  1721. if (progress >= 1) {
  1722. this.resetPreviewState()
  1723. this.syncRenderer()
  1724. this.compassController.start()
  1725. this.previewResetTimer = 0
  1726. this.scheduleAutoRotate()
  1727. return
  1728. }
  1729. this.previewResetTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  1730. }
  1731. step()
  1732. }
  1733. normalizeTranslate(translateX: number, translateY: number, statusText: string): void {
  1734. if (!this.state.stageWidth) {
  1735. this.setState({
  1736. tileTranslateX: translateX,
  1737. tileTranslateY: translateY,
  1738. })
  1739. this.syncRenderer()
  1740. this.compassController.start()
  1741. return
  1742. }
  1743. const exactCenter = this.getExactCenterFromTranslate(translateX, translateY)
  1744. const resolvedViewport = this.resolveViewportForExactCenter(exactCenter.x, exactCenter.y)
  1745. const centerChanged = resolvedViewport.centerTileX !== this.state.centerTileX || resolvedViewport.centerTileY !== this.state.centerTileY
  1746. if (centerChanged) {
  1747. this.commitViewport(resolvedViewport, statusText)
  1748. return
  1749. }
  1750. this.setState({
  1751. tileTranslateX: resolvedViewport.tileTranslateX,
  1752. tileTranslateY: resolvedViewport.tileTranslateY,
  1753. })
  1754. this.syncRenderer()
  1755. this.compassController.start()
  1756. }
  1757. zoomAroundPoint(zoomDelta: number, stageX: number, stageY: number, residualScale: number): void {
  1758. const nextZoom = clamp(this.state.zoom + zoomDelta, this.minZoom, this.maxZoom)
  1759. const appliedDelta = nextZoom - this.state.zoom
  1760. if (!appliedDelta) {
  1761. this.animatePreviewToRest()
  1762. return
  1763. }
  1764. if (!this.state.stageWidth || !this.state.stageHeight) {
  1765. this.commitViewport(
  1766. {
  1767. zoom: nextZoom,
  1768. centerTileX: appliedDelta > 0 ? this.state.centerTileX * 2 : Math.floor(this.state.centerTileX / 2),
  1769. centerTileY: appliedDelta > 0 ? this.state.centerTileY * 2 : Math.floor(this.state.centerTileY / 2),
  1770. tileTranslateX: 0,
  1771. tileTranslateY: 0,
  1772. },
  1773. `缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
  1774. true,
  1775. () => {
  1776. this.setPreviewState(residualScale, stageX, stageY)
  1777. this.syncRenderer()
  1778. this.compassController.start()
  1779. this.animatePreviewToRest()
  1780. },
  1781. )
  1782. return
  1783. }
  1784. const camera = this.getCameraState()
  1785. const world = screenToWorld(camera, { x: stageX, y: stageY }, true)
  1786. const zoomFactor = Math.pow(2, appliedDelta)
  1787. const nextWorldX = world.x * zoomFactor
  1788. const nextWorldY = world.y * zoomFactor
  1789. const anchorOffset = this.getWorldOffsetFromScreen(stageX, stageY)
  1790. const exactCenterX = nextWorldX - anchorOffset.x
  1791. const exactCenterY = nextWorldY - anchorOffset.y
  1792. const resolvedViewport = this.resolveViewportForExactCenter(exactCenterX, exactCenterY)
  1793. this.commitViewport(
  1794. {
  1795. zoom: nextZoom,
  1796. ...resolvedViewport,
  1797. },
  1798. `缂╂斁绾у埆璋冩暣鍒?${nextZoom}`,
  1799. true,
  1800. () => {
  1801. this.setPreviewState(residualScale, stageX, stageY)
  1802. this.syncRenderer()
  1803. this.compassController.start()
  1804. this.animatePreviewToRest()
  1805. },
  1806. )
  1807. }
  1808. startInertia(): void {
  1809. this.clearInertiaTimer()
  1810. const step = () => {
  1811. this.panVelocityX *= INERTIA_DECAY
  1812. this.panVelocityY *= INERTIA_DECAY
  1813. if (Math.abs(this.panVelocityX) < INERTIA_MIN_SPEED && Math.abs(this.panVelocityY) < INERTIA_MIN_SPEED) {
  1814. this.setState({
  1815. statusText: `鎯€ф粦鍔ㄧ粨鏉?(${this.buildVersion})`,
  1816. })
  1817. this.renderer.setAnimationPaused(false)
  1818. this.inertiaTimer = 0
  1819. this.scheduleAutoRotate()
  1820. return
  1821. }
  1822. this.normalizeTranslate(
  1823. this.state.tileTranslateX + this.panVelocityX * INERTIA_FRAME_MS,
  1824. this.state.tileTranslateY + this.panVelocityY * INERTIA_FRAME_MS,
  1825. `鎯€ф粦鍔ㄤ腑 (${this.buildVersion})`,
  1826. )
  1827. this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  1828. }
  1829. this.inertiaTimer = setTimeout(step, INERTIA_FRAME_MS) as unknown as number
  1830. }
  1831. }