import { getTileSizePx, type CameraState } from '../camera/camera' import { TileStore, type TileStoreCallbacks, type TileStoreStats, } from '../tile/tileStore' import { type LonLatPoint } from '../../utils/projection' import { type MapLayer } from '../layer/mapLayer' import { TileLayer } from '../layer/tileLayer' import { TrackLayer } from '../layer/trackLayer' import { GpsLayer } from '../layer/gpsLayer' const RENDER_FRAME_MS = 16 export interface CanvasMapScene { tileSource: string zoom: number centerTileX: number centerTileY: number viewportWidth: number viewportHeight: number visibleColumns: number overdraw: number translateX: number translateY: number rotationRad: number previewScale: number previewOriginX: number previewOriginY: number track: LonLatPoint[] gpsPoint: LonLatPoint } export type CanvasMapRendererStats = TileStoreStats function buildCamera(scene: CanvasMapScene): CameraState { return { centerWorldX: scene.centerTileX, centerWorldY: scene.centerTileY, viewportWidth: scene.viewportWidth, viewportHeight: scene.viewportHeight, visibleColumns: scene.visibleColumns, rotationRad: scene.rotationRad, } } export class CanvasMapRenderer { canvas: any ctx: any dpr: number scene: CanvasMapScene | null tileStore: TileStore tileLayer: TileLayer layers: MapLayer[] renderTimer: number animationTimer: number destroyed: boolean animationPaused: boolean pulseFrame: number lastStats: CanvasMapRendererStats onStats?: (stats: CanvasMapRendererStats) => void onTileError?: (message: string) => void constructor( onStats?: (stats: CanvasMapRendererStats) => void, onTileError?: (message: string) => void, ) { this.onStats = onStats this.onTileError = onTileError this.canvas = null this.ctx = null this.dpr = 1 this.scene = null this.tileStore = new TileStore({ onTileReady: () => { this.scheduleRender() }, onTileError: (message) => { if (this.onTileError) { this.onTileError(message) } this.scheduleRender() }, } satisfies TileStoreCallbacks) this.tileLayer = new TileLayer() this.layers = [ this.tileLayer, new TrackLayer(), new GpsLayer(), ] this.renderTimer = 0 this.animationTimer = 0 this.destroyed = false this.animationPaused = false this.pulseFrame = 0 this.lastStats = { visibleTileCount: 0, readyTileCount: 0, memoryTileCount: 0, diskTileCount: 0, memoryHitCount: 0, diskHitCount: 0, networkFetchCount: 0, } } attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void { this.canvas = canvasNode this.ctx = canvasNode.getContext('2d') this.dpr = dpr || 1 canvasNode.width = Math.max(1, Math.floor(width * this.dpr)) canvasNode.height = Math.max(1, Math.floor(height * this.dpr)) if (typeof this.ctx.setTransform === 'function') { this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0) } else { this.ctx.scale(this.dpr, this.dpr) } this.tileStore.attachCanvas(canvasNode) this.startAnimation() this.scheduleRender() } updateScene(scene: CanvasMapScene): void { this.scene = scene this.scheduleRender() } setAnimationPaused(paused: boolean): void { this.animationPaused = paused if (!paused) { this.scheduleRender() } } destroy(): void { this.destroyed = true if (this.renderTimer) { clearTimeout(this.renderTimer) this.renderTimer = 0 } if (this.animationTimer) { clearTimeout(this.animationTimer) this.animationTimer = 0 } this.tileStore.destroy() this.canvas = null this.ctx = null this.scene = null } startAnimation(): void { if (this.animationTimer) { return } const tick = () => { if (this.destroyed) { this.animationTimer = 0 return } if (!this.animationPaused) { this.pulseFrame = (this.pulseFrame + 1) % 360 this.scheduleRender() } this.animationTimer = setTimeout(tick, 33) as unknown as number } tick() } scheduleRender(): void { if (this.renderTimer || !this.ctx || !this.scene || this.destroyed) { return } this.renderTimer = setTimeout(() => { this.renderTimer = 0 this.renderFrame() }, RENDER_FRAME_MS) as unknown as number } emitStats(stats: CanvasMapRendererStats): void { if ( stats.visibleTileCount === this.lastStats.visibleTileCount && stats.readyTileCount === this.lastStats.readyTileCount && stats.memoryTileCount === this.lastStats.memoryTileCount && stats.diskTileCount === this.lastStats.diskTileCount && stats.memoryHitCount === this.lastStats.memoryHitCount && stats.diskHitCount === this.lastStats.diskHitCount && stats.networkFetchCount === this.lastStats.networkFetchCount ) { return } this.lastStats = stats if (this.onStats) { this.onStats(stats) } } renderFrame(): void { if (!this.ctx || !this.scene) { return } const scene = this.scene const ctx = this.ctx const camera = buildCamera(scene) const tileSize = getTileSizePx(camera) ctx.clearRect(0, 0, scene.viewportWidth, scene.viewportHeight) ctx.fillStyle = '#dbeed4' ctx.fillRect(0, 0, scene.viewportWidth, scene.viewportHeight) if (!tileSize) { this.emitStats(this.tileStore.getStats(0, 0)) return } const previewScale = scene.previewScale || 1 const previewOriginX = scene.previewOriginX || scene.viewportWidth / 2 const previewOriginY = scene.previewOriginY || scene.viewportHeight / 2 ctx.save() ctx.translate(previewOriginX, previewOriginY) ctx.scale(previewScale, previewScale) ctx.translate(-previewOriginX, -previewOriginY) for (const layer of this.layers) { layer.draw({ ctx, camera, scene, pulseFrame: this.pulseFrame, tileStore: this.tileStore, }) } ctx.restore() this.emitStats(this.tileStore.getStats(this.tileLayer.lastVisibleTileCount, this.tileLayer.lastReadyTileCount)) } }