feat: initialize mini program map engine

This commit is contained in:
2026-03-19 15:58:48 +08:00
commit 03abe28d8c
49 changed files with 28584 additions and 0 deletions

View File

@@ -0,0 +1,247 @@
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))
}
}

View File

@@ -0,0 +1,67 @@
import { type MapLayer } from '../layer/mapLayer'
import { buildCamera, type MapScene } from './mapRenderer'
import { type TileStore } from '../tile/tileStore'
export class CanvasOverlayRenderer {
canvas: any
ctx: any
dpr: number
layers: MapLayer[]
constructor(layers: MapLayer[]) {
this.canvas = null
this.ctx = null
this.dpr = 1
this.layers = layers
}
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)
}
}
clear(): void {
this.canvas = null
this.ctx = null
}
render(scene: MapScene, tileStore: TileStore, pulseFrame: number): void {
if (!this.ctx) {
return
}
const camera = buildCamera(scene)
const ctx = this.ctx
const previewScale = scene.previewScale || 1
const previewOriginX = scene.previewOriginX || scene.viewportWidth / 2
const previewOriginY = scene.previewOriginY || scene.viewportHeight / 2
ctx.clearRect(0, 0, scene.viewportWidth, scene.viewportHeight)
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,
tileStore,
})
}
ctx.restore()
}
}

View File

@@ -0,0 +1,44 @@
import { type CameraState } from '../camera/camera'
import { type TileStoreStats } from '../tile/tileStore'
import { type LonLatPoint } from '../../utils/projection'
export interface MapScene {
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 MapRendererStats = TileStoreStats
export interface MapRenderer {
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void
updateScene(scene: MapScene): void
setAnimationPaused(paused: boolean): void
destroy(): void
}
export function buildCamera(scene: MapScene): CameraState {
return {
centerWorldX: scene.centerTileX,
centerWorldY: scene.centerTileY,
viewportWidth: scene.viewportWidth,
viewportHeight: scene.viewportHeight,
visibleColumns: scene.visibleColumns,
translateX: scene.translateX,
translateY: scene.translateY,
rotationRad: scene.rotationRad,
}
}

View File

@@ -0,0 +1,213 @@
const STORAGE_KEY = 'cmr-tile-disk-cache-index-v1'
const ROOT_DIR = `${wx.env.USER_DATA_PATH}/cmr-tile-cache`
const MAX_PERSISTED_TILES = 600
const PERSIST_DELAY_MS = 300
export interface TilePersistentCacheRecord {
filePath: string
lastAccessedAt: number
}
type TilePersistentCacheIndex = Record<string, TilePersistentCacheRecord>
let sharedTilePersistentCache: TilePersistentCache | null = null
function getFileExtension(url: string): string {
const matched = url.match(/\.(png|jpg|jpeg|webp)(?:$|\?)/i)
if (!matched) {
return '.tile'
}
return `.${matched[1].toLowerCase()}`
}
function hashUrl(url: string): string {
let hash = 2166136261
for (let index = 0; index < url.length; index += 1) {
hash ^= url.charCodeAt(index)
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)
}
return (hash >>> 0).toString(36)
}
function cloneIndex(rawIndex: any): TilePersistentCacheIndex {
const nextIndex: TilePersistentCacheIndex = {}
if (!rawIndex || typeof rawIndex !== 'object') {
return nextIndex
}
Object.keys(rawIndex).forEach((url) => {
const record = rawIndex[url]
if (!record || typeof record.filePath !== 'string') {
return
}
nextIndex[url] = {
filePath: record.filePath,
lastAccessedAt: typeof record.lastAccessedAt === 'number' ? record.lastAccessedAt : 0,
}
})
return nextIndex
}
export class TilePersistentCache {
fs: WechatMiniprogram.FileSystemManager
rootDir: string
index: TilePersistentCacheIndex
persistTimer: number
constructor() {
this.fs = wx.getFileSystemManager()
this.rootDir = ROOT_DIR
this.index = {}
this.persistTimer = 0
this.ensureRootDir()
this.loadIndex()
this.pruneMissingFiles()
this.pruneIfNeeded()
}
ensureRootDir(): void {
try {
this.fs.accessSync(this.rootDir)
} catch {
this.fs.mkdirSync(this.rootDir, true)
}
}
loadIndex(): void {
try {
this.index = cloneIndex(wx.getStorageSync(STORAGE_KEY))
} catch {
this.index = {}
}
}
getCount(): number {
return Object.keys(this.index).length
}
schedulePersist(): void {
if (this.persistTimer) {
return
}
this.persistTimer = setTimeout(() => {
this.persistTimer = 0
wx.setStorageSync(STORAGE_KEY, this.index)
}, PERSIST_DELAY_MS) as unknown as number
}
pruneMissingFiles(): void {
let changed = false
Object.keys(this.index).forEach((url) => {
const record = this.index[url]
try {
this.fs.accessSync(record.filePath)
} catch {
delete this.index[url]
changed = true
}
})
if (changed) {
this.schedulePersist()
}
}
getCachedPath(url: string): string | null {
const record = this.index[url]
if (!record) {
return null
}
try {
this.fs.accessSync(record.filePath)
record.lastAccessedAt = Date.now()
this.schedulePersist()
return record.filePath
} catch {
delete this.index[url]
this.schedulePersist()
return null
}
}
getTargetPath(url: string): string {
const existingRecord = this.index[url]
if (existingRecord) {
existingRecord.lastAccessedAt = Date.now()
this.schedulePersist()
return existingRecord.filePath
}
const filePath = `${this.rootDir}/${hashUrl(url)}${getFileExtension(url)}`
this.index[url] = {
filePath,
lastAccessedAt: Date.now(),
}
this.schedulePersist()
return filePath
}
markReady(url: string, filePath: string): void {
this.index[url] = {
filePath,
lastAccessedAt: Date.now(),
}
this.pruneIfNeeded()
this.schedulePersist()
}
remove(url: string): void {
const record = this.index[url]
if (!record) {
return
}
try {
this.fs.unlinkSync(record.filePath)
} catch {
// Ignore unlink errors for already-missing files.
}
delete this.index[url]
this.schedulePersist()
}
pruneIfNeeded(): void {
const urls = Object.keys(this.index)
if (urls.length <= MAX_PERSISTED_TILES) {
return
}
const removableUrls = urls.sort((leftUrl, rightUrl) => {
return this.index[leftUrl].lastAccessedAt - this.index[rightUrl].lastAccessedAt
})
while (removableUrls.length > MAX_PERSISTED_TILES) {
const nextUrl = removableUrls.shift() as string
const record = this.index[nextUrl]
if (record) {
try {
this.fs.unlinkSync(record.filePath)
} catch {
// Ignore unlink errors for already-missing files.
}
}
delete this.index[nextUrl]
}
}
}
export function getTilePersistentCache(): TilePersistentCache {
if (!sharedTilePersistentCache) {
sharedTilePersistentCache = new TilePersistentCache()
}
return sharedTilePersistentCache
}

View File

@@ -0,0 +1,161 @@
import { TrackLayer } from '../layer/trackLayer'
import { GpsLayer } from '../layer/gpsLayer'
import { TileLayer } from '../layer/tileLayer'
import { TileStore, type TileStoreCallbacks } from '../tile/tileStore'
import { type MapRenderer, type MapRendererStats, type MapScene } from './mapRenderer'
import { WebGLTileRenderer } from './webglTileRenderer'
import { WebGLVectorRenderer } from './webglVectorRenderer'
const RENDER_FRAME_MS = 16
const ANIMATION_FRAME_MS = 33
export class WebGLMapRenderer implements MapRenderer {
tileStore: TileStore
tileLayer: TileLayer
trackLayer: TrackLayer
gpsLayer: GpsLayer
tileRenderer: WebGLTileRenderer
vectorRenderer: WebGLVectorRenderer
scene: MapScene | null
renderTimer: number
animationTimer: number
destroyed: boolean
animationPaused: boolean
pulseFrame: number
lastStats: MapRendererStats
onStats?: (stats: MapRendererStats) => void
onTileError?: (message: string) => void
constructor(onStats?: (stats: MapRendererStats) => void, onTileError?: (message: string) => void) {
this.onStats = onStats
this.onTileError = onTileError
this.tileStore = new TileStore({
onTileReady: () => {
this.scheduleRender()
},
onTileError: (message) => {
if (this.onTileError) {
this.onTileError(message)
}
this.scheduleRender()
},
} satisfies TileStoreCallbacks)
this.tileLayer = new TileLayer()
this.trackLayer = new TrackLayer()
this.gpsLayer = new GpsLayer()
this.tileRenderer = new WebGLTileRenderer(this.tileLayer, this.tileStore)
this.vectorRenderer = new WebGLVectorRenderer(this.trackLayer, this.gpsLayer)
this.scene = null
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.tileRenderer.attachCanvas(canvasNode, width, height, dpr)
this.vectorRenderer.attachContext(this.tileRenderer.gl, canvasNode)
this.startAnimation()
this.scheduleRender()
}
updateScene(scene: MapScene): 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.vectorRenderer.destroy()
this.tileRenderer.destroy()
this.tileStore.destroy()
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, ANIMATION_FRAME_MS) as unknown as number
}
tick()
}
scheduleRender(): void {
if (this.renderTimer || !this.scene || this.destroyed) {
return
}
this.renderTimer = setTimeout(() => {
this.renderTimer = 0
this.renderFrame()
}, RENDER_FRAME_MS) as unknown as number
}
renderFrame(): void {
if (!this.scene) {
return
}
this.tileRenderer.render(this.scene)
this.vectorRenderer.render(this.scene, this.pulseFrame)
this.emitStats(this.tileStore.getStats(this.tileLayer.lastVisibleTileCount, this.tileLayer.lastReadyTileCount))
}
emitStats(stats: MapRendererStats): 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)
}
}
}

View File

@@ -0,0 +1,329 @@
import { rotateScreenPoint, type ScreenPoint } from '../camera/camera'
import { type TileStore, type TileStoreEntry } from '../tile/tileStore'
import { TileLayer } from '../layer/tileLayer'
import { buildCamera, type MapScene } from './mapRenderer'
interface TextureRecord {
key: string
texture: any
}
function createShader(gl: any, type: number, source: string): any {
const shader = gl.createShader(type)
if (!shader) {
throw new Error('WebGL shader 创建失败')
}
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const message = gl.getShaderInfoLog(shader) || 'unknown shader error'
gl.deleteShader(shader)
throw new Error(message)
}
return shader
}
function createProgram(gl: any, vertexSource: string, fragmentSource: string): any {
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource)
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
const program = gl.createProgram()
if (!program) {
throw new Error('WebGL program 创建失败')
}
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
gl.deleteShader(vertexShader)
gl.deleteShader(fragmentShader)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const message = gl.getProgramInfoLog(program) || 'unknown program error'
gl.deleteProgram(program)
throw new Error(message)
}
return program
}
export class WebGLTileRenderer {
canvas: any
gl: any
tileLayer: TileLayer
tileStore: TileStore
dpr: number
program: any
positionBuffer: any
texCoordBuffer: any
positionLocation: number
texCoordLocation: number
textureCache: Map<string, TextureRecord>
constructor(tileLayer: TileLayer, tileStore: TileStore) {
this.canvas = null
this.gl = null
this.tileLayer = tileLayer
this.tileStore = tileStore
this.dpr = 1
this.program = null
this.positionBuffer = null
this.texCoordBuffer = null
this.positionLocation = -1
this.texCoordLocation = -1
this.textureCache = new Map<string, TextureRecord>()
}
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
this.canvas = canvasNode
this.dpr = dpr || 1
canvasNode.width = Math.max(1, Math.floor(width * this.dpr))
canvasNode.height = Math.max(1, Math.floor(height * this.dpr))
const gl = canvasNode.getContext('webgl') || canvasNode.getContext('experimental-webgl')
if (!gl) {
throw new Error('当前环境不支持 WebGL')
}
this.gl = gl
this.program = createProgram(
gl,
'attribute vec2 a_position; attribute vec2 a_texCoord; varying vec2 v_texCoord; void main() { gl_Position = vec4(a_position, 0.0, 1.0); v_texCoord = a_texCoord; }',
'precision mediump float; varying vec2 v_texCoord; uniform sampler2D u_texture; void main() { gl_FragColor = texture2D(u_texture, v_texCoord); }',
)
this.positionBuffer = gl.createBuffer()
this.texCoordBuffer = gl.createBuffer()
this.positionLocation = gl.getAttribLocation(this.program, 'a_position')
this.texCoordLocation = gl.getAttribLocation(this.program, 'a_texCoord')
gl.viewport(0, 0, canvasNode.width, canvasNode.height)
gl.disable(gl.DEPTH_TEST)
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
this.tileStore.attachCanvas(canvasNode)
}
destroy(): void {
if (this.gl) {
this.textureCache.forEach((record) => {
this.gl && this.gl.deleteTexture(record.texture)
})
if (this.program) {
this.gl.deleteProgram(this.program)
}
if (this.positionBuffer) {
this.gl.deleteBuffer(this.positionBuffer)
}
if (this.texCoordBuffer) {
this.gl.deleteBuffer(this.texCoordBuffer)
}
}
this.textureCache.clear()
this.program = null
this.positionBuffer = null
this.texCoordBuffer = null
this.gl = null
this.canvas = null
}
render(scene: MapScene): void {
if (!this.gl || !this.program || !this.positionBuffer || !this.texCoordBuffer) {
return
}
const gl = this.gl
const camera = buildCamera(scene)
const tiles = this.tileLayer.prepareTiles(scene, camera, this.tileStore)
gl.viewport(0, 0, this.canvas.width, this.canvas.height)
gl.clearColor(0.8588, 0.9333, 0.8314, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(this.program)
for (const tile of tiles) {
const readyEntry = this.tileStore.getEntry(tile.url)
if (readyEntry && readyEntry.status === 'ready' && readyEntry.image) {
this.drawEntry(readyEntry, tile.url, 0, 0, readyEntry.image.width || 256, readyEntry.image.height || 256, tile.leftPx, tile.topPx, tile.sizePx, tile.sizePx, scene)
this.tileLayer.lastReadyTileCount += 1
continue
}
const parentFallback = this.tileStore.getParentFallbackSlice(tile, scene)
if (parentFallback) {
this.drawEntry(
parentFallback.entry,
tile.url + '|parent',
parentFallback.sourceX,
parentFallback.sourceY,
parentFallback.sourceWidth,
parentFallback.sourceHeight,
tile.leftPx,
tile.topPx,
tile.sizePx,
tile.sizePx,
scene,
)
}
const childFallback = this.tileStore.getChildFallback(tile, scene)
if (!childFallback) {
continue
}
const cellWidth = tile.sizePx / childFallback.division
const cellHeight = tile.sizePx / childFallback.division
for (const child of childFallback.children) {
this.drawEntry(
child.entry,
tile.url + '|child|' + child.offsetX + '|' + child.offsetY,
0,
0,
child.entry.image.width || 256,
child.entry.image.height || 256,
tile.leftPx + child.offsetX * cellWidth,
tile.topPx + child.offsetY * cellHeight,
cellWidth,
cellHeight,
scene,
)
}
}
}
drawEntry(
entry: TileStoreEntry,
cacheKey: string,
sourceX: number,
sourceY: number,
sourceWidth: number,
sourceHeight: number,
drawLeft: number,
drawTop: number,
drawWidth: number,
drawHeight: number,
scene: MapScene,
): void {
if (!this.gl || !entry.image) {
return
}
const texture = this.getTexture(cacheKey, entry)
if (!texture) {
return
}
const gl = this.gl
const imageWidth = entry.image.width || 256
const imageHeight = entry.image.height || 256
const texLeft = sourceX / imageWidth
const texTop = sourceY / imageHeight
const texRight = (sourceX + sourceWidth) / imageWidth
const texBottom = (sourceY + sourceHeight) / imageHeight
const topLeft = this.transformToClip(drawLeft, drawTop, scene)
const topRight = this.transformToClip(drawLeft + drawWidth, drawTop, scene)
const bottomLeft = this.transformToClip(drawLeft, drawTop + drawHeight, scene)
const bottomRight = this.transformToClip(drawLeft + drawWidth, drawTop + drawHeight, scene)
const positions = new Float32Array([
topLeft.x, topLeft.y,
topRight.x, topRight.y,
bottomLeft.x, bottomLeft.y,
bottomLeft.x, bottomLeft.y,
topRight.x, topRight.y,
bottomRight.x, bottomRight.y,
])
const texCoords = new Float32Array([
texLeft, texTop,
texRight, texTop,
texLeft, texBottom,
texLeft, texBottom,
texRight, texTop,
texRight, texBottom,
])
gl.bindTexture(gl.TEXTURE_2D, texture.texture)
gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)
gl.enableVertexAttribArray(this.positionLocation)
gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, this.texCoordBuffer)
gl.bufferData(gl.ARRAY_BUFFER, texCoords, gl.STREAM_DRAW)
gl.enableVertexAttribArray(this.texCoordLocation)
gl.vertexAttribPointer(this.texCoordLocation, 2, gl.FLOAT, false, 0, 0)
gl.drawArrays(gl.TRIANGLES, 0, 6)
}
getTexture(cacheKey: string, entry: TileStoreEntry): TextureRecord | null {
if (!this.gl || !entry.image) {
return null
}
const key = cacheKey + '|' + entry.sourcePath
const existing = this.textureCache.get(key)
if (existing) {
return existing
}
const texture = this.gl.createTexture()
if (!texture) {
return null
}
this.gl.bindTexture(this.gl.TEXTURE_2D, texture)
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_S, this.gl.CLAMP_TO_EDGE)
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_WRAP_T, this.gl.CLAMP_TO_EDGE)
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MIN_FILTER, this.gl.LINEAR)
this.gl.texParameteri(this.gl.TEXTURE_2D, this.gl.TEXTURE_MAG_FILTER, this.gl.LINEAR)
this.gl.pixelStorei(this.gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1)
this.gl.texImage2D(this.gl.TEXTURE_2D, 0, this.gl.RGBA, this.gl.RGBA, this.gl.UNSIGNED_BYTE, entry.image)
const record = { key, texture }
this.textureCache.set(key, record)
return record
}
transformToClip(x: number, y: number, scene: MapScene): ScreenPoint {
const rotated = rotateScreenPoint(
{ x, y },
scene.viewportWidth / 2,
scene.viewportHeight / 2,
scene.rotationRad || 0,
)
const translated = {
x: rotated.x + scene.translateX,
y: rotated.y + scene.translateY,
}
const previewed = this.applyPreview(translated.x, translated.y, scene)
return {
x: this.toClipX(previewed.x, scene.viewportWidth),
y: this.toClipY(previewed.y, scene.viewportHeight),
}
}
applyPreview(x: number, y: number, scene: MapScene): { x: number; y: number } {
const scale = scene.previewScale || 1
const originX = scene.previewOriginX || scene.viewportWidth / 2
const originY = scene.previewOriginY || scene.viewportHeight / 2
return {
x: originX + (x - originX) * scale,
y: originY + (y - originY) * scale,
}
}
toClipX(x: number, width: number): number {
return x / width * 2 - 1
}
toClipY(y: number, height: number): number {
return 1 - y / height * 2
}
}

View File

@@ -0,0 +1,234 @@
import { buildCamera, type MapScene } from './mapRenderer'
import { TrackLayer } from '../layer/trackLayer'
import { GpsLayer } from '../layer/gpsLayer'
function createShader(gl: any, type: number, source: string): any {
const shader = gl.createShader(type)
if (!shader) {
throw new Error('WebGL shader 创建失败')
}
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const message = gl.getShaderInfoLog(shader) || 'unknown shader error'
gl.deleteShader(shader)
throw new Error(message)
}
return shader
}
function createProgram(gl: any, vertexSource: string, fragmentSource: string): any {
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource)
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)
const program = gl.createProgram()
if (!program) {
throw new Error('WebGL program 创建失败')
}
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
gl.deleteShader(vertexShader)
gl.deleteShader(fragmentShader)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
const message = gl.getProgramInfoLog(program) || 'unknown program error'
gl.deleteProgram(program)
throw new Error(message)
}
return program
}
export class WebGLVectorRenderer {
canvas: any
gl: any
dpr: number
trackLayer: TrackLayer
gpsLayer: GpsLayer
program: any
positionBuffer: any
colorBuffer: any
positionLocation: number
colorLocation: number
constructor(trackLayer: TrackLayer, gpsLayer: GpsLayer) {
this.canvas = null
this.gl = null
this.dpr = 1
this.trackLayer = trackLayer
this.gpsLayer = gpsLayer
this.program = null
this.positionBuffer = null
this.colorBuffer = null
this.positionLocation = -1
this.colorLocation = -1
}
attachCanvas(canvasNode: any, width: number, height: number, dpr: number): void {
this.canvas = canvasNode
this.dpr = dpr || 1
canvasNode.width = Math.max(1, Math.floor(width * this.dpr))
canvasNode.height = Math.max(1, Math.floor(height * this.dpr))
this.attachContext(canvasNode.getContext('webgl') || canvasNode.getContext('experimental-webgl'), canvasNode)
}
attachContext(gl: any, canvasNode: any): void {
if (!gl) {
throw new Error('当前环境不支持 WebGL Vector Layer')
}
this.canvas = canvasNode
this.gl = gl
this.program = createProgram(
gl,
'attribute vec2 a_position; attribute vec4 a_color; varying vec4 v_color; void main() { gl_Position = vec4(a_position, 0.0, 1.0); v_color = a_color; }',
'precision mediump float; varying vec4 v_color; void main() { gl_FragColor = v_color; }',
)
this.positionBuffer = gl.createBuffer()
this.colorBuffer = gl.createBuffer()
this.positionLocation = gl.getAttribLocation(this.program, 'a_position')
this.colorLocation = gl.getAttribLocation(this.program, 'a_color')
gl.viewport(0, 0, canvasNode.width, canvasNode.height)
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
}
destroy(): void {
if (this.gl) {
if (this.program) {
this.gl.deleteProgram(this.program)
}
if (this.positionBuffer) {
this.gl.deleteBuffer(this.positionBuffer)
}
if (this.colorBuffer) {
this.gl.deleteBuffer(this.colorBuffer)
}
}
this.program = null
this.positionBuffer = null
this.colorBuffer = null
this.gl = null
this.canvas = null
}
render(scene: MapScene, pulseFrame: number): void {
if (!this.gl || !this.program || !this.positionBuffer || !this.colorBuffer || !this.canvas) {
return
}
const gl = this.gl
const camera = buildCamera(scene)
const trackPoints = this.trackLayer.projectPoints(scene, camera)
const gpsPoint = this.gpsLayer.projectPoint(scene, camera)
const positions: number[] = []
const colors: number[] = []
for (let index = 1; index < trackPoints.length; index += 1) {
this.pushSegment(positions, colors, trackPoints[index - 1], trackPoints[index], 6, [0.09, 0.43, 0.36, 0.96], scene)
}
for (const point of trackPoints) {
this.pushCircle(positions, colors, point.x, point.y, 10, [0.09, 0.43, 0.36, 1], scene)
this.pushCircle(positions, colors, point.x, point.y, 6.5, [0.97, 0.98, 0.95, 1], scene)
}
this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, this.gpsLayer.getPulseRadius(pulseFrame), [0.13, 0.62, 0.74, 0.22], scene)
this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, 13, [1, 1, 1, 0.95], scene)
this.pushCircle(positions, colors, gpsPoint.x, gpsPoint.y, 9, [0.13, 0.63, 0.74, 1], scene)
gl.viewport(0, 0, this.canvas.width, this.canvas.height)
gl.useProgram(this.program)
gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STREAM_DRAW)
gl.enableVertexAttribArray(this.positionLocation)
gl.vertexAttribPointer(this.positionLocation, 2, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(colors), gl.STREAM_DRAW)
gl.enableVertexAttribArray(this.colorLocation)
gl.vertexAttribPointer(this.colorLocation, 4, gl.FLOAT, false, 0, 0)
gl.drawArrays(gl.TRIANGLES, 0, positions.length / 2)
}
pushSegment(
positions: number[],
colors: number[],
start: { x: number; y: number },
end: { x: number; y: number },
width: number,
color: [number, number, number, number],
scene: MapScene,
): void {
const deltaX = end.x - start.x
const deltaY = end.y - start.y
const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (!length) {
return
}
const normalX = -deltaY / length * (width / 2)
const normalY = deltaX / length * (width / 2)
const topLeft = this.toClip(start.x + normalX, start.y + normalY, scene)
const topRight = this.toClip(end.x + normalX, end.y + normalY, scene)
const bottomLeft = this.toClip(start.x - normalX, start.y - normalY, scene)
const bottomRight = this.toClip(end.x - normalX, end.y - normalY, scene)
this.pushTriangle(positions, colors, topLeft, topRight, bottomLeft, color)
this.pushTriangle(positions, colors, bottomLeft, topRight, bottomRight, color)
}
pushCircle(
positions: number[],
colors: number[],
centerX: number,
centerY: number,
radius: number,
color: [number, number, number, number],
scene: MapScene,
): void {
const segments = 20
const center = this.toClip(centerX, centerY, scene)
for (let index = 0; index < segments; index += 1) {
const startAngle = index / segments * Math.PI * 2
const endAngle = (index + 1) / segments * Math.PI * 2
const start = this.toClip(centerX + Math.cos(startAngle) * radius, centerY + Math.sin(startAngle) * radius, scene)
const end = this.toClip(centerX + Math.cos(endAngle) * radius, centerY + Math.sin(endAngle) * radius, scene)
this.pushTriangle(positions, colors, center, start, end, color)
}
}
pushTriangle(
positions: number[],
colors: number[],
first: { x: number; y: number },
second: { x: number; y: number },
third: { x: number; y: number },
color: [number, number, number, number],
): void {
positions.push(first.x, first.y, second.x, second.y, third.x, third.y)
for (let index = 0; index < 3; index += 1) {
colors.push(color[0], color[1], color[2], color[3])
}
}
toClip(x: number, y: number, scene: MapScene): { x: number; y: number } {
const previewScale = scene.previewScale || 1
const originX = scene.previewOriginX || scene.viewportWidth / 2
const originY = scene.previewOriginY || scene.viewportHeight / 2
const scaledX = originX + (x - originX) * previewScale
const scaledY = originY + (y - originY) * previewScale
return {
x: scaledX / scene.viewportWidth * 2 - 1,
y: 1 - scaledY / scene.viewportHeight * 2,
}
}
}