| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- 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<string, TileStoreEntry>
- pendingUrls: string[]
- pendingSet: Set<string>
- 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<string, TileStoreEntry>()
- this.pendingUrls = []
- this.pendingSet = new Set<string>()
- 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<string>): 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<string>(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<string>()
- const parentPriorityMap = new Map<string, number>()
- const countedMemoryHits = new Set<string>()
- 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<string>): 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
- }
- }
|