581 lines
16 KiB
TypeScript
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
|
|
}
|
|
}
|
|
|
|
|