tileStore.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590
  1. import { buildTileUrl, type TileItem } from '../../utils/tile'
  2. import { isTileWithinBounds, type TileZoomBounds } from '../../utils/remoteMapConfig'
  3. import { getTilePersistentCache, type TilePersistentCache } from '../renderer/tilePersistentCache'
  4. const MAX_PARENT_FALLBACK_DEPTH = 2
  5. const MAX_CHILD_FALLBACK_DEPTH = 1
  6. const MAX_CONCURRENT_DOWNLOADS = 6
  7. const MAX_MEMORY_CACHE_SIZE = 240
  8. const ERROR_RETRY_DELAY_MS = 4000
  9. export type TileStatus = 'idle' | 'loading' | 'ready' | 'error'
  10. export interface TileStoreEntry {
  11. image: any
  12. status: TileStatus
  13. sourcePath: string
  14. downloadTask: WechatMiniprogram.DownloadTask | null
  15. priority: number
  16. lastUsedAt: number
  17. lastAttemptAt: number
  18. lastVisibleKey: string
  19. retryable: boolean
  20. }
  21. export interface TileStoreStats {
  22. visibleTileCount: number
  23. readyTileCount: number
  24. memoryTileCount: number
  25. diskTileCount: number
  26. memoryHitCount: number
  27. diskHitCount: number
  28. networkFetchCount: number
  29. }
  30. export interface ParentFallbackTileSource {
  31. entry: TileStoreEntry
  32. zoom: number
  33. }
  34. export interface ChildFallbackTileSource {
  35. division: number
  36. children: Array<{
  37. entry: TileStoreEntry
  38. offsetX: number
  39. offsetY: number
  40. }>
  41. }
  42. export interface TileStoreScene {
  43. tileSource: string
  44. zoom: number
  45. viewportWidth: number
  46. viewportHeight: number
  47. translateX: number
  48. translateY: number
  49. tileBoundsByZoom: Record<number, TileZoomBounds> | null
  50. }
  51. export interface TileStoreCallbacks {
  52. onTileReady?: () => void
  53. onTileError?: (message: string) => void
  54. }
  55. function positiveModulo(value: number, divisor: number): number {
  56. return ((value % divisor) + divisor) % divisor
  57. }
  58. function getTilePriority(tile: TileItem, scene: TileStoreScene): number {
  59. const viewportCenterX = scene.viewportWidth / 2 + scene.translateX
  60. const viewportCenterY = scene.viewportHeight / 2 + scene.translateY
  61. const tileCenterX = tile.leftPx + tile.sizePx / 2
  62. const tileCenterY = tile.topPx + tile.sizePx / 2
  63. const deltaX = tileCenterX - viewportCenterX
  64. const deltaY = tileCenterY - viewportCenterY
  65. return deltaX * deltaX + deltaY * deltaY
  66. }
  67. function bindImageLoad(
  68. image: any,
  69. src: string,
  70. onReady: () => void,
  71. onError: () => void,
  72. ): void {
  73. image.onload = onReady
  74. image.onerror = onError
  75. image.src = src
  76. }
  77. export class TileStore {
  78. canvas: any
  79. diskCache: TilePersistentCache
  80. tileCache: Map<string, TileStoreEntry>
  81. pendingUrls: string[]
  82. pendingSet: Set<string>
  83. pendingQueueDirty: boolean
  84. activeDownloadCount: number
  85. destroyed: boolean
  86. memoryHitCount: number
  87. diskHitCount: number
  88. networkFetchCount: number
  89. onTileReady?: () => void
  90. onTileError?: (message: string) => void
  91. constructor(callbacks?: TileStoreCallbacks) {
  92. this.canvas = null
  93. this.diskCache = getTilePersistentCache()
  94. this.tileCache = new Map<string, TileStoreEntry>()
  95. this.pendingUrls = []
  96. this.pendingSet = new Set<string>()
  97. this.pendingQueueDirty = false
  98. this.activeDownloadCount = 0
  99. this.destroyed = false
  100. this.memoryHitCount = 0
  101. this.diskHitCount = 0
  102. this.networkFetchCount = 0
  103. this.onTileReady = callbacks && callbacks.onTileReady ? callbacks.onTileReady : undefined
  104. this.onTileError = callbacks && callbacks.onTileError ? callbacks.onTileError : undefined
  105. }
  106. attachCanvas(canvas: any): void {
  107. this.canvas = canvas
  108. }
  109. destroy(): void {
  110. this.destroyed = true
  111. this.tileCache.forEach((entry) => {
  112. if (entry.downloadTask) {
  113. entry.downloadTask.abort()
  114. }
  115. })
  116. this.pendingUrls = []
  117. this.pendingSet.clear()
  118. this.pendingQueueDirty = false
  119. this.activeDownloadCount = 0
  120. this.tileCache.clear()
  121. this.canvas = null
  122. }
  123. getReadyMemoryTileCount(): number {
  124. let count = 0
  125. this.tileCache.forEach((entry) => {
  126. if (entry.status === 'ready' && entry.image) {
  127. count += 1
  128. }
  129. })
  130. return count
  131. }
  132. getStats(visibleTileCount: number, readyTileCount: number): TileStoreStats {
  133. return {
  134. visibleTileCount,
  135. readyTileCount,
  136. memoryTileCount: this.getReadyMemoryTileCount(),
  137. diskTileCount: this.diskCache.getCount(),
  138. memoryHitCount: this.memoryHitCount,
  139. diskHitCount: this.diskHitCount,
  140. networkFetchCount: this.networkFetchCount,
  141. }
  142. }
  143. getEntry(url: string): TileStoreEntry | undefined {
  144. return this.tileCache.get(url)
  145. }
  146. touchTile(url: string, priority: number, usedAt: number): TileStoreEntry {
  147. let entry = this.tileCache.get(url)
  148. if (!entry) {
  149. entry = {
  150. image: null,
  151. status: 'idle',
  152. sourcePath: '',
  153. downloadTask: null,
  154. priority,
  155. lastUsedAt: usedAt,
  156. lastAttemptAt: 0,
  157. lastVisibleKey: '',
  158. retryable: true,
  159. }
  160. this.tileCache.set(url, entry)
  161. return entry
  162. }
  163. if (entry.priority !== priority && this.pendingSet.has(url)) {
  164. this.pendingQueueDirty = true
  165. }
  166. entry.priority = priority
  167. entry.lastUsedAt = usedAt
  168. return entry
  169. }
  170. trimPendingQueue(protectedUrls: Set<string>): void {
  171. const nextPendingUrls: string[] = []
  172. for (const url of this.pendingUrls) {
  173. if (!protectedUrls.has(url)) {
  174. continue
  175. }
  176. nextPendingUrls.push(url)
  177. }
  178. this.pendingUrls = nextPendingUrls
  179. this.pendingSet = new Set<string>(nextPendingUrls)
  180. this.pendingQueueDirty = true
  181. }
  182. queueTile(url: string): void {
  183. if (this.pendingSet.has(url)) {
  184. return
  185. }
  186. const entry = this.tileCache.get(url)
  187. if (!entry || entry.status === 'loading' || entry.status === 'ready') {
  188. return
  189. }
  190. this.pendingSet.add(url)
  191. this.pendingUrls.push(url)
  192. this.pendingQueueDirty = true
  193. }
  194. queueVisibleTiles(tiles: TileItem[], scene: TileStoreScene, visibleKey: string): void {
  195. const usedAt = Date.now()
  196. const protectedUrls = new Set<string>()
  197. const parentPriorityMap = new Map<string, number>()
  198. const countedMemoryHits = new Set<string>()
  199. for (const tile of tiles) {
  200. const priority = getTilePriority(tile, scene)
  201. const entry = this.touchTile(tile.url, priority, usedAt)
  202. protectedUrls.add(tile.url)
  203. if (entry.status === 'ready' && entry.lastVisibleKey !== visibleKey && !countedMemoryHits.has(tile.url)) {
  204. this.memoryHitCount += 1
  205. entry.lastVisibleKey = visibleKey
  206. countedMemoryHits.add(tile.url)
  207. }
  208. for (let depth = 1; depth <= MAX_PARENT_FALLBACK_DEPTH; depth += 1) {
  209. const fallbackZoom = scene.zoom - depth
  210. if (fallbackZoom < 0) {
  211. break
  212. }
  213. const scale = Math.pow(2, depth)
  214. const fallbackX = Math.floor(tile.x / scale)
  215. const fallbackY = Math.floor(tile.y / scale)
  216. if (!isTileWithinBounds(scene.tileBoundsByZoom, fallbackZoom, fallbackX, fallbackY)) {
  217. continue
  218. }
  219. const fallbackUrl = buildTileUrl(scene.tileSource, fallbackZoom, fallbackX, fallbackY)
  220. const fallbackPriority = priority / (depth + 1)
  221. const existingPriority = parentPriorityMap.get(fallbackUrl)
  222. if (typeof existingPriority !== 'number' || fallbackPriority < existingPriority) {
  223. parentPriorityMap.set(fallbackUrl, fallbackPriority)
  224. }
  225. }
  226. }
  227. parentPriorityMap.forEach((priority, url) => {
  228. const entry = this.touchTile(url, priority, usedAt)
  229. protectedUrls.add(url)
  230. if (entry.status === 'ready' && entry.lastVisibleKey !== visibleKey && !countedMemoryHits.has(url)) {
  231. this.memoryHitCount += 1
  232. entry.lastVisibleKey = visibleKey
  233. countedMemoryHits.add(url)
  234. }
  235. })
  236. this.trimPendingQueue(protectedUrls)
  237. parentPriorityMap.forEach((_priority, url) => {
  238. const entry = this.tileCache.get(url)
  239. if (!entry) {
  240. return
  241. }
  242. if (entry.status === 'idle' || (entry.status === 'error' && entry.retryable && usedAt - entry.lastAttemptAt > ERROR_RETRY_DELAY_MS)) {
  243. if (entry.status === 'error') {
  244. entry.status = 'idle'
  245. entry.retryable = true
  246. }
  247. this.queueTile(url)
  248. }
  249. })
  250. for (const tile of tiles) {
  251. const entry = this.tileCache.get(tile.url)
  252. if (!entry) {
  253. continue
  254. }
  255. if (entry.status === 'idle' || (entry.status === 'error' && entry.retryable && usedAt - entry.lastAttemptAt > ERROR_RETRY_DELAY_MS)) {
  256. if (entry.status === 'error') {
  257. entry.status = 'idle'
  258. entry.retryable = true
  259. }
  260. this.queueTile(tile.url)
  261. }
  262. }
  263. this.pruneMemoryCache(protectedUrls)
  264. this.pumpTileQueue()
  265. }
  266. pumpTileQueue(): void {
  267. if (this.destroyed || !this.canvas) {
  268. return
  269. }
  270. if (this.pendingQueueDirty && this.pendingUrls.length > 1) {
  271. this.pendingUrls.sort((leftUrl, rightUrl) => {
  272. const leftEntry = this.tileCache.get(leftUrl)
  273. const rightEntry = this.tileCache.get(rightUrl)
  274. const leftPriority = leftEntry ? leftEntry.priority : Number.MAX_SAFE_INTEGER
  275. const rightPriority = rightEntry ? rightEntry.priority : Number.MAX_SAFE_INTEGER
  276. return leftPriority - rightPriority
  277. })
  278. this.pendingQueueDirty = false
  279. }
  280. while (this.activeDownloadCount < MAX_CONCURRENT_DOWNLOADS && this.pendingUrls.length) {
  281. const url = this.pendingUrls.shift() as string
  282. this.pendingSet.delete(url)
  283. const entry = this.tileCache.get(url)
  284. if (!entry || entry.status === 'loading' || entry.status === 'ready') {
  285. continue
  286. }
  287. this.startTileDownload(url, entry)
  288. }
  289. }
  290. startTileDownload(url: string, entry: TileStoreEntry): void {
  291. if (this.destroyed || !this.canvas) {
  292. return
  293. }
  294. entry.status = 'loading'
  295. entry.lastAttemptAt = Date.now()
  296. this.activeDownloadCount += 1
  297. let finished = false
  298. const finish = () => {
  299. if (finished) {
  300. return
  301. }
  302. finished = true
  303. entry.downloadTask = null
  304. this.activeDownloadCount = Math.max(0, this.activeDownloadCount - 1)
  305. this.pumpTileQueue()
  306. }
  307. const markReady = () => {
  308. entry.status = 'ready'
  309. finish()
  310. if (this.onTileReady) {
  311. this.onTileReady()
  312. }
  313. }
  314. const markError = (message: string, retryable = true) => {
  315. entry.status = 'error'
  316. entry.retryable = retryable
  317. finish()
  318. if (this.onTileError) {
  319. this.onTileError(`${message}: ${url}`)
  320. }
  321. }
  322. const loadLocalImage = (localPath: string, fromPersistentCache: boolean) => {
  323. if (this.destroyed || !this.canvas) {
  324. finish()
  325. return
  326. }
  327. const localImage = this.canvas.createImage()
  328. entry.image = localImage
  329. entry.sourcePath = localPath
  330. bindImageLoad(
  331. localImage,
  332. localPath,
  333. () => {
  334. this.diskCache.markReady(url, localPath)
  335. markReady()
  336. },
  337. () => {
  338. this.diskCache.remove(url)
  339. if (fromPersistentCache) {
  340. downloadToPersistentPath()
  341. return
  342. }
  343. markError('瓦片本地载入失败')
  344. },
  345. )
  346. }
  347. const tryRemoteImage = () => {
  348. if (this.destroyed || !this.canvas) {
  349. finish()
  350. return
  351. }
  352. const remoteImage = this.canvas.createImage()
  353. entry.image = remoteImage
  354. entry.sourcePath = url
  355. bindImageLoad(
  356. remoteImage,
  357. url,
  358. markReady,
  359. () => markError('瓦片远程载入失败'),
  360. )
  361. }
  362. const downloadToPersistentPath = () => {
  363. this.networkFetchCount += 1
  364. const filePath = this.diskCache.getTargetPath(url)
  365. const task = wx.downloadFile({
  366. url,
  367. filePath,
  368. success: (res) => {
  369. if (this.destroyed) {
  370. finish()
  371. return
  372. }
  373. const resolvedPath = res.filePath || filePath || res.tempFilePath
  374. if (res.statusCode >= 400 && res.statusCode < 500) {
  375. markError(`瓦片资源不存在(${res.statusCode})`, false)
  376. return
  377. }
  378. if (res.statusCode !== 200 || !resolvedPath) {
  379. tryRemoteImage()
  380. return
  381. }
  382. loadLocalImage(resolvedPath, false)
  383. },
  384. fail: () => {
  385. tryRemoteImage()
  386. },
  387. })
  388. entry.downloadTask = task
  389. }
  390. const cachedPath = this.diskCache.getCachedPath(url)
  391. if (cachedPath) {
  392. this.diskHitCount += 1
  393. loadLocalImage(cachedPath, true)
  394. return
  395. }
  396. downloadToPersistentPath()
  397. }
  398. pruneMemoryCache(protectedUrls: Set<string>): void {
  399. if (this.tileCache.size <= MAX_MEMORY_CACHE_SIZE) {
  400. return
  401. }
  402. const removableEntries: Array<{ url: string; lastUsedAt: number; priority: number }> = []
  403. this.tileCache.forEach((entry, url) => {
  404. if (protectedUrls.has(url) || this.pendingSet.has(url) || entry.status === 'loading') {
  405. return
  406. }
  407. removableEntries.push({
  408. url,
  409. lastUsedAt: entry.lastUsedAt,
  410. priority: entry.priority,
  411. })
  412. })
  413. removableEntries.sort((leftEntry, rightEntry) => {
  414. if (leftEntry.lastUsedAt !== rightEntry.lastUsedAt) {
  415. return leftEntry.lastUsedAt - rightEntry.lastUsedAt
  416. }
  417. return rightEntry.priority - leftEntry.priority
  418. })
  419. while (this.tileCache.size > MAX_MEMORY_CACHE_SIZE && removableEntries.length) {
  420. const nextEntry = removableEntries.shift() as { url: string }
  421. this.tileCache.delete(nextEntry.url)
  422. }
  423. }
  424. findParentFallback(tile: TileItem, scene: TileStoreScene): ParentFallbackTileSource | null {
  425. for (let depth = 1; depth <= MAX_PARENT_FALLBACK_DEPTH; depth += 1) {
  426. const fallbackZoom = scene.zoom - depth
  427. if (fallbackZoom < 0) {
  428. return null
  429. }
  430. const scale = Math.pow(2, depth)
  431. const fallbackX = Math.floor(tile.x / scale)
  432. const fallbackY = Math.floor(tile.y / scale)
  433. if (!isTileWithinBounds(scene.tileBoundsByZoom, fallbackZoom, fallbackX, fallbackY)) {
  434. continue
  435. }
  436. const fallbackUrl = buildTileUrl(scene.tileSource, fallbackZoom, fallbackX, fallbackY)
  437. const fallbackEntry = this.tileCache.get(fallbackUrl)
  438. if (fallbackEntry && fallbackEntry.status === 'ready' && fallbackEntry.image) {
  439. return {
  440. entry: fallbackEntry,
  441. zoom: fallbackZoom,
  442. }
  443. }
  444. }
  445. return null
  446. }
  447. getParentFallbackSlice(tile: TileItem, scene: TileStoreScene): {
  448. entry: TileStoreEntry
  449. sourceX: number
  450. sourceY: number
  451. sourceWidth: number
  452. sourceHeight: number
  453. } | null {
  454. const fallback = this.findParentFallback(tile, scene)
  455. if (!fallback) {
  456. return null
  457. }
  458. const zoomDelta = scene.zoom - fallback.zoom
  459. const division = Math.pow(2, zoomDelta)
  460. const imageWidth = fallback.entry.image.width || 256
  461. const imageHeight = fallback.entry.image.height || 256
  462. const sourceWidth = imageWidth / division
  463. const sourceHeight = imageHeight / division
  464. const offsetX = positiveModulo(tile.x, division)
  465. const offsetY = positiveModulo(tile.y, division)
  466. return {
  467. entry: fallback.entry,
  468. sourceX: offsetX * sourceWidth,
  469. sourceY: offsetY * sourceHeight,
  470. sourceWidth,
  471. sourceHeight,
  472. }
  473. }
  474. getChildFallback(tile: TileItem, scene: TileStoreScene): ChildFallbackTileSource | null {
  475. for (let depth = 1; depth <= MAX_CHILD_FALLBACK_DEPTH; depth += 1) {
  476. const childZoom = scene.zoom + depth
  477. const division = Math.pow(2, depth)
  478. const children: ChildFallbackTileSource['children'] = []
  479. for (let offsetY = 0; offsetY < division; offsetY += 1) {
  480. for (let offsetX = 0; offsetX < division; offsetX += 1) {
  481. const childX = tile.x * division + offsetX
  482. const childY = tile.y * division + offsetY
  483. if (!isTileWithinBounds(scene.tileBoundsByZoom, childZoom, childX, childY)) {
  484. continue
  485. }
  486. const childUrl = buildTileUrl(scene.tileSource, childZoom, childX, childY)
  487. const childEntry = this.tileCache.get(childUrl)
  488. if (!childEntry || childEntry.status !== 'ready' || !childEntry.image) {
  489. continue
  490. }
  491. children.push({
  492. entry: childEntry,
  493. offsetX,
  494. offsetY,
  495. })
  496. }
  497. }
  498. if (children.length) {
  499. return {
  500. division,
  501. children,
  502. }
  503. }
  504. }
  505. return null
  506. }
  507. }