tileStore.ts 15 KB

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