Files
cmr-mini/miniprogram/engine/tile/tileStore.ts

581 lines
16 KiB
TypeScript

import { buildTileUrl, type TileItem } from '../../utils/tile'
import { isTileWithinBounds, type TileZoomBounds } from '../../utils/remoteMapConfig'
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
tileBoundsByZoom: Record<number, TileZoomBounds> | null
}
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)
if (!isTileWithinBounds(scene.tileBoundsByZoom, fallbackZoom, fallbackX, fallbackY)) {
continue
}
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)
if (!isTileWithinBounds(scene.tileBoundsByZoom, fallbackZoom, fallbackX, fallbackY)) {
continue
}
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
if (!isTileWithinBounds(scene.tileBoundsByZoom, childZoom, childX, childY)) {
continue
}
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
}
}