import { buildTileUrl, type TileItem } from '../../utils/tile' import { getTilePersistentCache, type TilePersistentCache } from '../renderer/tilePersistentCache' const MAX_PARENT_FALLBACK_DEPTH = 2 const MAX_CHILD_FALLBACK_DEPTH = 1 const MAX_CONCURRENT_DOWNLOADS = 6 const MAX_MEMORY_CACHE_SIZE = 240 const ERROR_RETRY_DELAY_MS = 4000 export type TileStatus = 'idle' | 'loading' | 'ready' | 'error' export interface TileStoreEntry { image: any status: TileStatus sourcePath: string downloadTask: WechatMiniprogram.DownloadTask | null priority: number lastUsedAt: number lastAttemptAt: number lastVisibleKey: string } export interface TileStoreStats { visibleTileCount: number readyTileCount: number memoryTileCount: number diskTileCount: number memoryHitCount: number diskHitCount: number networkFetchCount: number } export interface ParentFallbackTileSource { entry: TileStoreEntry zoom: number } export interface ChildFallbackTileSource { division: number children: Array<{ entry: TileStoreEntry offsetX: number offsetY: number }> } export interface TileStoreScene { tileSource: string zoom: number viewportWidth: number viewportHeight: number translateX: number translateY: number } export interface TileStoreCallbacks { onTileReady?: () => void onTileError?: (message: string) => void } function positiveModulo(value: number, divisor: number): number { return ((value % divisor) + divisor) % divisor } function getTilePriority(tile: TileItem, scene: TileStoreScene): number { const viewportCenterX = scene.viewportWidth / 2 + scene.translateX const viewportCenterY = scene.viewportHeight / 2 + scene.translateY const tileCenterX = tile.leftPx + tile.sizePx / 2 const tileCenterY = tile.topPx + tile.sizePx / 2 const deltaX = tileCenterX - viewportCenterX const deltaY = tileCenterY - viewportCenterY return deltaX * deltaX + deltaY * deltaY } function bindImageLoad( image: any, src: string, onReady: () => void, onError: () => void, ): void { image.onload = onReady image.onerror = onError image.src = src } export class TileStore { canvas: any diskCache: TilePersistentCache tileCache: Map pendingUrls: string[] pendingSet: Set pendingQueueDirty: boolean activeDownloadCount: number destroyed: boolean memoryHitCount: number diskHitCount: number networkFetchCount: number onTileReady?: () => void onTileError?: (message: string) => void constructor(callbacks?: TileStoreCallbacks) { this.canvas = null this.diskCache = getTilePersistentCache() this.tileCache = new Map() this.pendingUrls = [] this.pendingSet = new Set() this.pendingQueueDirty = false this.activeDownloadCount = 0 this.destroyed = false this.memoryHitCount = 0 this.diskHitCount = 0 this.networkFetchCount = 0 this.onTileReady = callbacks && callbacks.onTileReady ? callbacks.onTileReady : undefined this.onTileError = callbacks && callbacks.onTileError ? callbacks.onTileError : undefined } attachCanvas(canvas: any): void { this.canvas = canvas } destroy(): void { this.destroyed = true this.tileCache.forEach((entry) => { if (entry.downloadTask) { entry.downloadTask.abort() } }) this.pendingUrls = [] this.pendingSet.clear() this.pendingQueueDirty = false this.activeDownloadCount = 0 this.tileCache.clear() this.canvas = null } getReadyMemoryTileCount(): number { let count = 0 this.tileCache.forEach((entry) => { if (entry.status === 'ready' && entry.image) { count += 1 } }) return count } getStats(visibleTileCount: number, readyTileCount: number): TileStoreStats { return { visibleTileCount, readyTileCount, memoryTileCount: this.getReadyMemoryTileCount(), diskTileCount: this.diskCache.getCount(), memoryHitCount: this.memoryHitCount, diskHitCount: this.diskHitCount, networkFetchCount: this.networkFetchCount, } } getEntry(url: string): TileStoreEntry | undefined { return this.tileCache.get(url) } touchTile(url: string, priority: number, usedAt: number): TileStoreEntry { let entry = this.tileCache.get(url) if (!entry) { entry = { image: null, status: 'idle', sourcePath: '', downloadTask: null, priority, lastUsedAt: usedAt, lastAttemptAt: 0, lastVisibleKey: '', } this.tileCache.set(url, entry) return entry } if (entry.priority !== priority && this.pendingSet.has(url)) { this.pendingQueueDirty = true } entry.priority = priority entry.lastUsedAt = usedAt return entry } trimPendingQueue(protectedUrls: Set): void { const nextPendingUrls: string[] = [] for (const url of this.pendingUrls) { if (!protectedUrls.has(url)) { continue } nextPendingUrls.push(url) } this.pendingUrls = nextPendingUrls this.pendingSet = new Set(nextPendingUrls) this.pendingQueueDirty = true } queueTile(url: string): void { if (this.pendingSet.has(url)) { return } const entry = this.tileCache.get(url) if (!entry || entry.status === 'loading' || entry.status === 'ready') { return } this.pendingSet.add(url) this.pendingUrls.push(url) this.pendingQueueDirty = true } queueVisibleTiles(tiles: TileItem[], scene: TileStoreScene, visibleKey: string): void { const usedAt = Date.now() const protectedUrls = new Set() const parentPriorityMap = new Map() const countedMemoryHits = new Set() for (const tile of tiles) { const priority = getTilePriority(tile, scene) const entry = this.touchTile(tile.url, priority, usedAt) protectedUrls.add(tile.url) if (entry.status === 'ready' && entry.lastVisibleKey !== visibleKey && !countedMemoryHits.has(tile.url)) { this.memoryHitCount += 1 entry.lastVisibleKey = visibleKey countedMemoryHits.add(tile.url) } for (let depth = 1; depth <= MAX_PARENT_FALLBACK_DEPTH; depth += 1) { const fallbackZoom = scene.zoom - depth if (fallbackZoom < 0) { break } const scale = Math.pow(2, depth) const fallbackX = Math.floor(tile.x / scale) const fallbackY = Math.floor(tile.y / scale) const fallbackUrl = buildTileUrl(scene.tileSource, fallbackZoom, fallbackX, fallbackY) const fallbackPriority = priority / (depth + 1) const existingPriority = parentPriorityMap.get(fallbackUrl) if (typeof existingPriority !== 'number' || fallbackPriority < existingPriority) { parentPriorityMap.set(fallbackUrl, fallbackPriority) } } } parentPriorityMap.forEach((priority, url) => { const entry = this.touchTile(url, priority, usedAt) protectedUrls.add(url) if (entry.status === 'ready' && entry.lastVisibleKey !== visibleKey && !countedMemoryHits.has(url)) { this.memoryHitCount += 1 entry.lastVisibleKey = visibleKey countedMemoryHits.add(url) } }) this.trimPendingQueue(protectedUrls) parentPriorityMap.forEach((_priority, url) => { const entry = this.tileCache.get(url) if (!entry) { return } if (entry.status === 'idle' || (entry.status === 'error' && usedAt - entry.lastAttemptAt > ERROR_RETRY_DELAY_MS)) { if (entry.status === 'error') { entry.status = 'idle' } this.queueTile(url) } }) for (const tile of tiles) { const entry = this.tileCache.get(tile.url) if (!entry) { continue } if (entry.status === 'idle' || (entry.status === 'error' && usedAt - entry.lastAttemptAt > ERROR_RETRY_DELAY_MS)) { if (entry.status === 'error') { entry.status = 'idle' } this.queueTile(tile.url) } } this.pruneMemoryCache(protectedUrls) this.pumpTileQueue() } pumpTileQueue(): void { if (this.destroyed || !this.canvas) { return } if (this.pendingQueueDirty && this.pendingUrls.length > 1) { this.pendingUrls.sort((leftUrl, rightUrl) => { const leftEntry = this.tileCache.get(leftUrl) const rightEntry = this.tileCache.get(rightUrl) const leftPriority = leftEntry ? leftEntry.priority : Number.MAX_SAFE_INTEGER const rightPriority = rightEntry ? rightEntry.priority : Number.MAX_SAFE_INTEGER return leftPriority - rightPriority }) this.pendingQueueDirty = false } while (this.activeDownloadCount < MAX_CONCURRENT_DOWNLOADS && this.pendingUrls.length) { const url = this.pendingUrls.shift() as string this.pendingSet.delete(url) const entry = this.tileCache.get(url) if (!entry || entry.status === 'loading' || entry.status === 'ready') { continue } this.startTileDownload(url, entry) } } startTileDownload(url: string, entry: TileStoreEntry): void { if (this.destroyed || !this.canvas) { return } entry.status = 'loading' entry.lastAttemptAt = Date.now() this.activeDownloadCount += 1 let finished = false const finish = () => { if (finished) { return } finished = true entry.downloadTask = null this.activeDownloadCount = Math.max(0, this.activeDownloadCount - 1) this.pumpTileQueue() } const markReady = () => { entry.status = 'ready' finish() if (this.onTileReady) { this.onTileReady() } } const markError = (message: string) => { entry.status = 'error' finish() if (this.onTileError) { this.onTileError(`${message}: ${url}`) } } const loadLocalImage = (localPath: string, fromPersistentCache: boolean) => { if (this.destroyed || !this.canvas) { finish() return } const localImage = this.canvas.createImage() entry.image = localImage entry.sourcePath = localPath bindImageLoad( localImage, localPath, () => { this.diskCache.markReady(url, localPath) markReady() }, () => { this.diskCache.remove(url) if (fromPersistentCache) { downloadToPersistentPath() return } markError('瓦片本地载入失败') }, ) } const tryRemoteImage = () => { if (this.destroyed || !this.canvas) { finish() return } const remoteImage = this.canvas.createImage() entry.image = remoteImage entry.sourcePath = url bindImageLoad( remoteImage, url, markReady, () => markError('瓦片远程载入失败'), ) } const downloadToPersistentPath = () => { this.networkFetchCount += 1 const filePath = this.diskCache.getTargetPath(url) const task = wx.downloadFile({ url, filePath, success: (res) => { if (this.destroyed) { finish() return } const resolvedPath = res.filePath || filePath || res.tempFilePath if (res.statusCode !== 200 || !resolvedPath) { tryRemoteImage() return } loadLocalImage(resolvedPath, false) }, fail: () => { tryRemoteImage() }, }) entry.downloadTask = task } const cachedPath = this.diskCache.getCachedPath(url) if (cachedPath) { this.diskHitCount += 1 loadLocalImage(cachedPath, true) return } downloadToPersistentPath() } pruneMemoryCache(protectedUrls: Set): void { if (this.tileCache.size <= MAX_MEMORY_CACHE_SIZE) { return } const removableEntries: Array<{ url: string; lastUsedAt: number; priority: number }> = [] this.tileCache.forEach((entry, url) => { if (protectedUrls.has(url) || this.pendingSet.has(url) || entry.status === 'loading') { return } removableEntries.push({ url, lastUsedAt: entry.lastUsedAt, priority: entry.priority, }) }) removableEntries.sort((leftEntry, rightEntry) => { if (leftEntry.lastUsedAt !== rightEntry.lastUsedAt) { return leftEntry.lastUsedAt - rightEntry.lastUsedAt } return rightEntry.priority - leftEntry.priority }) while (this.tileCache.size > MAX_MEMORY_CACHE_SIZE && removableEntries.length) { const nextEntry = removableEntries.shift() as { url: string } this.tileCache.delete(nextEntry.url) } } findParentFallback(tile: TileItem, scene: TileStoreScene): ParentFallbackTileSource | null { for (let depth = 1; depth <= MAX_PARENT_FALLBACK_DEPTH; depth += 1) { const fallbackZoom = scene.zoom - depth if (fallbackZoom < 0) { return null } const scale = Math.pow(2, depth) const fallbackX = Math.floor(tile.x / scale) const fallbackY = Math.floor(tile.y / scale) const fallbackUrl = buildTileUrl(scene.tileSource, fallbackZoom, fallbackX, fallbackY) const fallbackEntry = this.tileCache.get(fallbackUrl) if (fallbackEntry && fallbackEntry.status === 'ready' && fallbackEntry.image) { return { entry: fallbackEntry, zoom: fallbackZoom, } } } return null } getParentFallbackSlice(tile: TileItem, scene: TileStoreScene): { entry: TileStoreEntry sourceX: number sourceY: number sourceWidth: number sourceHeight: number } | null { const fallback = this.findParentFallback(tile, scene) if (!fallback) { return null } const zoomDelta = scene.zoom - fallback.zoom const division = Math.pow(2, zoomDelta) const imageWidth = fallback.entry.image.width || 256 const imageHeight = fallback.entry.image.height || 256 const sourceWidth = imageWidth / division const sourceHeight = imageHeight / division const offsetX = positiveModulo(tile.x, division) const offsetY = positiveModulo(tile.y, division) return { entry: fallback.entry, sourceX: offsetX * sourceWidth, sourceY: offsetY * sourceHeight, sourceWidth, sourceHeight, } } getChildFallback(tile: TileItem, scene: TileStoreScene): ChildFallbackTileSource | null { for (let depth = 1; depth <= MAX_CHILD_FALLBACK_DEPTH; depth += 1) { const childZoom = scene.zoom + depth const division = Math.pow(2, depth) const children: ChildFallbackTileSource['children'] = [] for (let offsetY = 0; offsetY < division; offsetY += 1) { for (let offsetX = 0; offsetX < division; offsetX += 1) { const childX = tile.x * division + offsetX const childY = tile.y * division + offsetY const childUrl = buildTileUrl(scene.tileSource, childZoom, childX, childY) const childEntry = this.tileCache.get(childUrl) if (!childEntry || childEntry.status !== 'ready' || !childEntry.image) { continue } children.push({ entry: childEntry, offsetX, offsetY, }) } } if (children.length) { return { division, children, } } } return null } }