tileStore.ts 16 KB

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