feat: initialize mini program map engine
This commit is contained in:
567
miniprogram/engine/tile/tileStore.ts
Normal file
567
miniprogram/engine/tile/tileStore.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user