feat: initialize mini program map engine
This commit is contained in:
15
miniprogram/app.json
Normal file
15
miniprogram/app.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/map/map",
|
||||
"pages/index/index",
|
||||
"pages/logs/logs"
|
||||
],
|
||||
"window": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "CMR Mini",
|
||||
"navigationBarBackgroundColor": "#ffffff"
|
||||
},
|
||||
"style": "v2",
|
||||
"componentFramework": "glass-easel",
|
||||
"lazyCodeLoading": "requiredComponents"
|
||||
}
|
||||
18
miniprogram/app.ts
Normal file
18
miniprogram/app.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// app.ts
|
||||
App<IAppOption>({
|
||||
globalData: {},
|
||||
onLaunch() {
|
||||
// 展示本地存储能力
|
||||
const logs = wx.getStorageSync('logs') || []
|
||||
logs.unshift(Date.now())
|
||||
wx.setStorageSync('logs', logs)
|
||||
|
||||
// 登录
|
||||
wx.login({
|
||||
success: res => {
|
||||
console.log(res.code)
|
||||
// 发送 res.code 到后台换取 openId, sessionKey, unionId
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
10
miniprogram/app.wxss
Normal file
10
miniprogram/app.wxss
Normal file
@@ -0,0 +1,10 @@
|
||||
/**app.wxss**/
|
||||
.container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 200rpx 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
98
miniprogram/engine/camera/camera.ts
Normal file
98
miniprogram/engine/camera/camera.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
export interface CameraState {
|
||||
centerWorldX: number
|
||||
centerWorldY: number
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
visibleColumns: number
|
||||
translateX?: number
|
||||
translateY?: number
|
||||
rotationRad?: number
|
||||
}
|
||||
|
||||
export interface ScreenPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface WorldPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export function getTileSizePx(camera: CameraState): number {
|
||||
if (!camera.viewportWidth || !camera.visibleColumns) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return camera.viewportWidth / camera.visibleColumns
|
||||
}
|
||||
|
||||
export function rotateScreenPoint(point: ScreenPoint, centerX: number, centerY: number, rotationRad: number): ScreenPoint {
|
||||
if (!rotationRad) {
|
||||
return point
|
||||
}
|
||||
|
||||
const deltaX = point.x - centerX
|
||||
const deltaY = point.y - centerY
|
||||
const cos = Math.cos(rotationRad)
|
||||
const sin = Math.sin(rotationRad)
|
||||
|
||||
return {
|
||||
x: centerX + deltaX * cos - deltaY * sin,
|
||||
y: centerY + deltaX * sin + deltaY * cos,
|
||||
}
|
||||
}
|
||||
|
||||
export function worldToScreen(
|
||||
camera: CameraState,
|
||||
world: WorldPoint,
|
||||
includeTranslate = false,
|
||||
): ScreenPoint {
|
||||
const tileSize = getTileSizePx(camera)
|
||||
const translateX = includeTranslate ? (camera.translateX || 0) : 0
|
||||
const translateY = includeTranslate ? (camera.translateY || 0) : 0
|
||||
const centerX = camera.viewportWidth / 2
|
||||
const centerY = camera.viewportHeight / 2
|
||||
|
||||
const rotated = rotateScreenPoint(
|
||||
{
|
||||
x: centerX + (world.x - camera.centerWorldX) * tileSize,
|
||||
y: centerY + (world.y - camera.centerWorldY) * tileSize,
|
||||
},
|
||||
centerX,
|
||||
centerY,
|
||||
camera.rotationRad || 0,
|
||||
)
|
||||
|
||||
return {
|
||||
x: rotated.x + translateX,
|
||||
y: rotated.y + translateY,
|
||||
}
|
||||
}
|
||||
|
||||
export function screenToWorld(
|
||||
camera: CameraState,
|
||||
screen: ScreenPoint,
|
||||
includeTranslate = true,
|
||||
): WorldPoint {
|
||||
const tileSize = getTileSizePx(camera)
|
||||
const translateX = includeTranslate ? (camera.translateX || 0) : 0
|
||||
const translateY = includeTranslate ? (camera.translateY || 0) : 0
|
||||
const centerX = camera.viewportWidth / 2
|
||||
const centerY = camera.viewportHeight / 2
|
||||
|
||||
const unrotated = rotateScreenPoint(
|
||||
{
|
||||
x: screen.x - translateX,
|
||||
y: screen.y - translateY,
|
||||
},
|
||||
centerX,
|
||||
centerY,
|
||||
-(camera.rotationRad || 0),
|
||||
)
|
||||
|
||||
return {
|
||||
x: camera.centerWorldX + (unrotated.x - centerX) / tileSize,
|
||||
y: camera.centerWorldY + (unrotated.y - centerY) / tileSize,
|
||||
}
|
||||
}
|
||||
51
miniprogram/engine/layer/gpsLayer.ts
Normal file
51
miniprogram/engine/layer/gpsLayer.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { type CameraState } from '../camera/camera'
|
||||
import { lonLatToWorldTile } from '../../utils/projection'
|
||||
import { worldToScreen } from '../camera/camera'
|
||||
import { type MapLayer, type LayerRenderContext } from './mapLayer'
|
||||
import { type MapScene } from '../renderer/mapRenderer'
|
||||
import { type ScreenPoint } from './trackLayer'
|
||||
|
||||
export class GpsLayer implements MapLayer {
|
||||
projectPoint(scene: MapScene, camera: CameraState): ScreenPoint {
|
||||
const worldPoint = lonLatToWorldTile(scene.gpsPoint, scene.zoom)
|
||||
const screenPoint = worldToScreen(camera, worldPoint, false)
|
||||
return {
|
||||
x: screenPoint.x + scene.translateX,
|
||||
y: screenPoint.y + scene.translateY,
|
||||
}
|
||||
}
|
||||
|
||||
getPulseRadius(pulseFrame: number): number {
|
||||
return 18 + 6 * (0.5 + 0.5 * Math.sin(pulseFrame / 6))
|
||||
}
|
||||
|
||||
draw(context: LayerRenderContext): void {
|
||||
const { ctx, camera, scene, pulseFrame } = context
|
||||
const gpsScreenPoint = this.projectPoint(scene, camera)
|
||||
const pulse = this.getPulseRadius(pulseFrame)
|
||||
|
||||
ctx.save()
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = 'rgba(33, 158, 188, 0.22)'
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, pulse, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.fillStyle = '#21a1bc'
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, 9, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = '#ffffff'
|
||||
ctx.lineWidth = 3
|
||||
ctx.arc(gpsScreenPoint.x, gpsScreenPoint.y, 13, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#0b3d4a'
|
||||
ctx.font = 'bold 16px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'bottom'
|
||||
ctx.fillText('GPS', gpsScreenPoint.x + 14, gpsScreenPoint.y - 12)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
15
miniprogram/engine/layer/mapLayer.ts
Normal file
15
miniprogram/engine/layer/mapLayer.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { type CameraState } from '../camera/camera'
|
||||
import { type MapScene } from '../renderer/mapRenderer'
|
||||
import { type TileStore } from '../tile/tileStore'
|
||||
|
||||
export interface LayerRenderContext {
|
||||
ctx: any
|
||||
camera: CameraState
|
||||
scene: MapScene
|
||||
pulseFrame: number
|
||||
tileStore: TileStore
|
||||
}
|
||||
|
||||
export interface MapLayer {
|
||||
draw(context: LayerRenderContext): void
|
||||
}
|
||||
124
miniprogram/engine/layer/tileLayer.ts
Normal file
124
miniprogram/engine/layer/tileLayer.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createTileGrid, type TileItem } from '../../utils/tile'
|
||||
import { getTileSizePx, type CameraState } from '../camera/camera'
|
||||
import { type MapScene } from '../renderer/mapRenderer'
|
||||
import { type TileStore } from '../tile/tileStore'
|
||||
import { type MapLayer, type LayerRenderContext } from './mapLayer'
|
||||
|
||||
function buildGridKey(scene: MapScene, tileSize: number): string {
|
||||
return [
|
||||
scene.tileSource,
|
||||
scene.zoom,
|
||||
scene.centerTileX,
|
||||
scene.centerTileY,
|
||||
scene.viewportWidth,
|
||||
scene.viewportHeight,
|
||||
tileSize,
|
||||
scene.overdraw,
|
||||
].join('|')
|
||||
}
|
||||
|
||||
export class TileLayer implements MapLayer {
|
||||
lastVisibleTileCount: number
|
||||
lastReadyTileCount: number
|
||||
cachedGridKey: string
|
||||
cachedTiles: TileItem[]
|
||||
|
||||
constructor() {
|
||||
this.lastVisibleTileCount = 0
|
||||
this.lastReadyTileCount = 0
|
||||
this.cachedGridKey = ''
|
||||
this.cachedTiles = []
|
||||
}
|
||||
|
||||
prepareTiles(scene: MapScene, camera: CameraState, tileStore: TileStore): TileItem[] {
|
||||
const tileSize = getTileSizePx(camera)
|
||||
if (!tileSize) {
|
||||
this.lastVisibleTileCount = 0
|
||||
this.lastReadyTileCount = 0
|
||||
this.cachedGridKey = ''
|
||||
this.cachedTiles = []
|
||||
return []
|
||||
}
|
||||
|
||||
const gridKey = buildGridKey(scene, tileSize)
|
||||
if (gridKey !== this.cachedGridKey) {
|
||||
this.cachedGridKey = gridKey
|
||||
this.cachedTiles = createTileGrid({
|
||||
urlTemplate: scene.tileSource,
|
||||
zoom: scene.zoom,
|
||||
centerTileX: scene.centerTileX,
|
||||
centerTileY: scene.centerTileY,
|
||||
viewportWidth: scene.viewportWidth,
|
||||
viewportHeight: scene.viewportHeight,
|
||||
tileSize,
|
||||
overdraw: scene.overdraw,
|
||||
})
|
||||
}
|
||||
|
||||
tileStore.queueVisibleTiles(this.cachedTiles, scene, gridKey)
|
||||
this.lastVisibleTileCount = this.cachedTiles.length
|
||||
this.lastReadyTileCount = 0
|
||||
return this.cachedTiles
|
||||
}
|
||||
|
||||
draw(context: LayerRenderContext): void {
|
||||
const { ctx, scene, camera, tileStore } = context
|
||||
const tiles = this.prepareTiles(scene, camera, tileStore)
|
||||
|
||||
for (const tile of tiles) {
|
||||
const entry = tileStore.getEntry(tile.url)
|
||||
const drawLeft = tile.leftPx + scene.translateX
|
||||
const drawTop = tile.topPx + scene.translateY
|
||||
|
||||
if (entry && entry.status === 'ready' && entry.image) {
|
||||
ctx.drawImage(entry.image, drawLeft, drawTop, tile.sizePx, tile.sizePx)
|
||||
this.lastReadyTileCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const parentFallback = tileStore.getParentFallbackSlice(tile, scene)
|
||||
let drewFallback = false
|
||||
if (parentFallback) {
|
||||
ctx.drawImage(
|
||||
parentFallback.entry.image,
|
||||
parentFallback.sourceX,
|
||||
parentFallback.sourceY,
|
||||
parentFallback.sourceWidth,
|
||||
parentFallback.sourceHeight,
|
||||
drawLeft,
|
||||
drawTop,
|
||||
tile.sizePx,
|
||||
tile.sizePx,
|
||||
)
|
||||
drewFallback = true
|
||||
}
|
||||
|
||||
const childFallback = tileStore.getChildFallback(tile, scene)
|
||||
if (childFallback) {
|
||||
const cellWidth = tile.sizePx / childFallback.division
|
||||
const cellHeight = tile.sizePx / childFallback.division
|
||||
for (const child of childFallback.children) {
|
||||
const childImageWidth = child.entry.image.width || 256
|
||||
const childImageHeight = child.entry.image.height || 256
|
||||
ctx.drawImage(
|
||||
child.entry.image,
|
||||
0,
|
||||
0,
|
||||
childImageWidth,
|
||||
childImageHeight,
|
||||
drawLeft + child.offsetX * cellWidth,
|
||||
drawTop + child.offsetY * cellHeight,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
)
|
||||
}
|
||||
drewFallback = true
|
||||
}
|
||||
|
||||
if (!drewFallback) {
|
||||
ctx.fillStyle = entry && entry.status === 'error' ? '#d9b2b2' : '#dbeed4'
|
||||
ctx.fillRect(drawLeft, drawTop, tile.sizePx, tile.sizePx)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
miniprogram/engine/layer/trackLayer.ts
Normal file
61
miniprogram/engine/layer/trackLayer.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { type CameraState } from '../camera/camera'
|
||||
import { lonLatToWorldTile } from '../../utils/projection'
|
||||
import { worldToScreen } from '../camera/camera'
|
||||
import { type MapLayer, type LayerRenderContext } from './mapLayer'
|
||||
import { type MapScene } from '../renderer/mapRenderer'
|
||||
|
||||
export interface ScreenPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export class TrackLayer implements MapLayer {
|
||||
projectPoints(scene: MapScene, camera: CameraState): ScreenPoint[] {
|
||||
return scene.track.map((point) => {
|
||||
const worldPoint = lonLatToWorldTile(point, scene.zoom)
|
||||
const screenPoint = worldToScreen(camera, worldPoint, false)
|
||||
return {
|
||||
x: screenPoint.x + scene.translateX,
|
||||
y: screenPoint.y + scene.translateY,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
draw(context: LayerRenderContext): void {
|
||||
const { ctx, camera, scene } = context
|
||||
const points = this.projectPoints(scene, camera)
|
||||
|
||||
ctx.save()
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.strokeStyle = 'rgba(23, 109, 93, 0.96)'
|
||||
ctx.lineWidth = 6
|
||||
ctx.beginPath()
|
||||
|
||||
points.forEach((screenPoint, index) => {
|
||||
if (index === 0) {
|
||||
ctx.moveTo(screenPoint.x, screenPoint.y)
|
||||
return
|
||||
}
|
||||
ctx.lineTo(screenPoint.x, screenPoint.y)
|
||||
})
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#f7fbf2'
|
||||
ctx.strokeStyle = '#176d5d'
|
||||
ctx.lineWidth = 4
|
||||
points.forEach((screenPoint, index) => {
|
||||
ctx.beginPath()
|
||||
ctx.arc(screenPoint.x, screenPoint.y, 10, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = '#176d5d'
|
||||
ctx.font = 'bold 14px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(String(index + 1), screenPoint.x, screenPoint.y)
|
||||
ctx.fillStyle = '#f7fbf2'
|
||||
})
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
1429
miniprogram/engine/map/mapEngine.ts
Normal file
1429
miniprogram/engine/map/mapEngine.ts
Normal file
File diff suppressed because it is too large
Load Diff
247
miniprogram/engine/renderer/canvasMapRenderer.ts
Normal file
247
miniprogram/engine/renderer/canvasMapRenderer.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
67
miniprogram/engine/renderer/canvasOverlayRenderer.ts
Normal file
67
miniprogram/engine/renderer/canvasOverlayRenderer.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
44
miniprogram/engine/renderer/mapRenderer.ts
Normal file
44
miniprogram/engine/renderer/mapRenderer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
213
miniprogram/engine/renderer/tilePersistentCache.ts
Normal file
213
miniprogram/engine/renderer/tilePersistentCache.ts
Normal 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
|
||||
}
|
||||
161
miniprogram/engine/renderer/webglMapRenderer.ts
Normal file
161
miniprogram/engine/renderer/webglMapRenderer.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
329
miniprogram/engine/renderer/webglTileRenderer.ts
Normal file
329
miniprogram/engine/renderer/webglTileRenderer.ts
Normal 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
|
||||
}
|
||||
}
|
||||
234
miniprogram/engine/renderer/webglVectorRenderer.ts
Normal file
234
miniprogram/engine/renderer/webglVectorRenderer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
206
miniprogram/engine/sensor/compassHeadingController.ts
Normal file
206
miniprogram/engine/sensor/compassHeadingController.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
export interface CompassHeadingControllerCallbacks {
|
||||
onHeading: (headingDeg: number) => void
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
type SensorSource = 'compass' | 'motion' | null
|
||||
|
||||
const ABSOLUTE_HEADING_CORRECTION = 0.24
|
||||
|
||||
function normalizeHeadingDeg(headingDeg: number): number {
|
||||
const normalized = headingDeg % 360
|
||||
return normalized < 0 ? normalized + 360 : normalized
|
||||
}
|
||||
|
||||
function normalizeHeadingDeltaDeg(deltaDeg: number): number {
|
||||
let normalized = deltaDeg
|
||||
|
||||
while (normalized > 180) {
|
||||
normalized -= 360
|
||||
}
|
||||
|
||||
while (normalized < -180) {
|
||||
normalized += 360
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function interpolateHeadingDeg(currentDeg: number, targetDeg: number, factor: number): number {
|
||||
return normalizeHeadingDeg(currentDeg + normalizeHeadingDeltaDeg(targetDeg - currentDeg) * factor)
|
||||
}
|
||||
|
||||
export class CompassHeadingController {
|
||||
callbacks: CompassHeadingControllerCallbacks
|
||||
listening: boolean
|
||||
source: SensorSource
|
||||
compassCallback: ((result: WechatMiniprogram.OnCompassChangeCallbackResult) => void) | null
|
||||
motionCallback: ((result: WechatMiniprogram.OnDeviceMotionChangeCallbackResult) => void) | null
|
||||
absoluteHeadingDeg: number | null
|
||||
pitchDeg: number | null
|
||||
rollDeg: number | null
|
||||
motionReady: boolean
|
||||
compassReady: boolean
|
||||
|
||||
constructor(callbacks: CompassHeadingControllerCallbacks) {
|
||||
this.callbacks = callbacks
|
||||
this.listening = false
|
||||
this.source = null
|
||||
this.compassCallback = null
|
||||
this.motionCallback = null
|
||||
this.absoluteHeadingDeg = null
|
||||
this.pitchDeg = null
|
||||
this.rollDeg = null
|
||||
this.motionReady = false
|
||||
this.compassReady = false
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.listening) {
|
||||
return
|
||||
}
|
||||
|
||||
this.absoluteHeadingDeg = null
|
||||
this.pitchDeg = null
|
||||
this.rollDeg = null
|
||||
this.motionReady = false
|
||||
this.compassReady = false
|
||||
this.source = null
|
||||
|
||||
if (typeof wx.startCompass === 'function' && typeof wx.onCompassChange === 'function') {
|
||||
this.startCompassSource()
|
||||
return
|
||||
}
|
||||
|
||||
this.callbacks.onError('当前环境不支持罗盘方向监听')
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.detachCallbacks()
|
||||
|
||||
if (this.motionReady) {
|
||||
wx.stopDeviceMotionListening({ complete: () => {} })
|
||||
}
|
||||
|
||||
if (this.compassReady) {
|
||||
wx.stopCompass({ complete: () => {} })
|
||||
}
|
||||
|
||||
this.listening = false
|
||||
this.source = null
|
||||
this.absoluteHeadingDeg = null
|
||||
this.pitchDeg = null
|
||||
this.rollDeg = null
|
||||
this.motionReady = false
|
||||
this.compassReady = false
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stop()
|
||||
}
|
||||
|
||||
startMotionSource(previousMessage: string): void {
|
||||
if (typeof wx.startDeviceMotionListening !== 'function' || typeof wx.onDeviceMotionChange !== 'function') {
|
||||
this.callbacks.onError(previousMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const callback = (result: WechatMiniprogram.OnDeviceMotionChangeCallbackResult) => {
|
||||
if (typeof result.alpha !== 'number' || Number.isNaN(result.alpha)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pitchDeg = typeof result.beta === 'number' && !Number.isNaN(result.beta)
|
||||
? result.beta * 180 / Math.PI
|
||||
: null
|
||||
this.rollDeg = typeof result.gamma === 'number' && !Number.isNaN(result.gamma)
|
||||
? result.gamma * 180 / Math.PI
|
||||
: null
|
||||
|
||||
const alphaDeg = result.alpha * 180 / Math.PI
|
||||
this.applyAbsoluteHeading(normalizeHeadingDeg(360 - alphaDeg), 'motion')
|
||||
}
|
||||
|
||||
this.motionCallback = callback
|
||||
wx.onDeviceMotionChange(callback)
|
||||
wx.startDeviceMotionListening({
|
||||
interval: 'ui',
|
||||
success: () => {
|
||||
this.motionReady = true
|
||||
this.listening = true
|
||||
this.source = 'motion'
|
||||
},
|
||||
fail: (res) => {
|
||||
this.detachMotionCallback()
|
||||
const motionMessage = res && res.errMsg ? res.errMsg : 'startDeviceMotionListening failed'
|
||||
this.callbacks.onError(`${previousMessage};${motionMessage}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
startCompassSource(): void {
|
||||
const callback = (result: WechatMiniprogram.OnCompassChangeCallbackResult) => {
|
||||
if (typeof result.direction !== 'number' || Number.isNaN(result.direction)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.applyAbsoluteHeading(normalizeHeadingDeg(result.direction), 'compass')
|
||||
}
|
||||
|
||||
this.compassCallback = callback
|
||||
wx.onCompassChange(callback)
|
||||
wx.startCompass({
|
||||
success: () => {
|
||||
this.compassReady = true
|
||||
this.listening = true
|
||||
this.source = 'compass'
|
||||
},
|
||||
fail: (res) => {
|
||||
this.detachCompassCallback()
|
||||
this.callbacks.onError(res && res.errMsg ? res.errMsg : 'startCompass failed')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
applyAbsoluteHeading(headingDeg: number, source: 'compass' | 'motion'): void {
|
||||
if (this.absoluteHeadingDeg === null) {
|
||||
this.absoluteHeadingDeg = headingDeg
|
||||
} else {
|
||||
this.absoluteHeadingDeg = interpolateHeadingDeg(this.absoluteHeadingDeg, headingDeg, ABSOLUTE_HEADING_CORRECTION)
|
||||
}
|
||||
|
||||
this.source = source
|
||||
this.callbacks.onHeading(this.absoluteHeadingDeg)
|
||||
}
|
||||
|
||||
detachCallbacks(): void {
|
||||
this.detachMotionCallback()
|
||||
this.detachCompassCallback()
|
||||
}
|
||||
|
||||
detachMotionCallback(): void {
|
||||
if (!this.motionCallback) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof wx.offDeviceMotionChange === 'function') {
|
||||
wx.offDeviceMotionChange(this.motionCallback)
|
||||
}
|
||||
this.motionCallback = null
|
||||
}
|
||||
|
||||
detachCompassCallback(): void {
|
||||
if (!this.compassCallback) {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof wx.offCompassChange === 'function') {
|
||||
wx.offCompassChange(this.compassCallback)
|
||||
}
|
||||
this.compassCallback = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
4
miniprogram/pages/index/index.json
Normal file
4
miniprogram/pages/index/index.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
}
|
||||
}
|
||||
54
miniprogram/pages/index/index.ts
Normal file
54
miniprogram/pages/index/index.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// index.ts
|
||||
// 获取应用实例
|
||||
const app = getApp<IAppOption>()
|
||||
const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
|
||||
|
||||
Component({
|
||||
data: {
|
||||
motto: 'Hello World',
|
||||
userInfo: {
|
||||
avatarUrl: defaultAvatarUrl,
|
||||
nickName: '',
|
||||
},
|
||||
hasUserInfo: false,
|
||||
canIUseGetUserProfile: wx.canIUse('getUserProfile'),
|
||||
canIUseNicknameComp: wx.canIUse('input.type.nickname'),
|
||||
},
|
||||
methods: {
|
||||
// 事件处理函数
|
||||
bindViewTap() {
|
||||
wx.navigateTo({
|
||||
url: '../logs/logs',
|
||||
})
|
||||
},
|
||||
onChooseAvatar(e: any) {
|
||||
const { avatarUrl } = e.detail
|
||||
const { nickName } = this.data.userInfo
|
||||
this.setData({
|
||||
"userInfo.avatarUrl": avatarUrl,
|
||||
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
|
||||
})
|
||||
},
|
||||
onInputChange(e: any) {
|
||||
const nickName = e.detail.value
|
||||
const { avatarUrl } = this.data.userInfo
|
||||
this.setData({
|
||||
"userInfo.nickName": nickName,
|
||||
hasUserInfo: nickName && avatarUrl && avatarUrl !== defaultAvatarUrl,
|
||||
})
|
||||
},
|
||||
getUserProfile() {
|
||||
// 推荐使用wx.getUserProfile获取用户信息,开发者每次通过该接口获取用户个人信息均需用户确认,开发者妥善保管用户快速填写的头像昵称,避免重复弹窗
|
||||
wx.getUserProfile({
|
||||
desc: '展示用户信息', // 声明获取用户个人信息后的用途,后续会展示在弹窗中,请谨慎填写
|
||||
success: (res) => {
|
||||
console.log(res)
|
||||
this.setData({
|
||||
userInfo: res.userInfo,
|
||||
hasUserInfo: true
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
27
miniprogram/pages/index/index.wxml
Normal file
27
miniprogram/pages/index/index.wxml
Normal file
@@ -0,0 +1,27 @@
|
||||
<!--index.wxml-->
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<view class="container">
|
||||
<view class="userinfo">
|
||||
<block wx:if="{{canIUseNicknameComp && !hasUserInfo}}">
|
||||
<button class="avatar-wrapper" open-type="chooseAvatar" bind:chooseavatar="onChooseAvatar">
|
||||
<image class="avatar" src="{{userInfo.avatarUrl}}"></image>
|
||||
</button>
|
||||
<view class="nickname-wrapper">
|
||||
<text class="nickname-label">昵称</text>
|
||||
<input type="nickname" class="nickname-input" placeholder="请输入昵称" bind:change="onInputChange" />
|
||||
</view>
|
||||
</block>
|
||||
<block wx:elif="{{!hasUserInfo}}">
|
||||
<button wx:if="{{canIUseGetUserProfile}}" bindtap="getUserProfile"> 获取头像昵称 </button>
|
||||
<view wx:else> 请使用2.10.4及以上版本基础库 </view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<image bindtap="bindViewTap" class="userinfo-avatar" src="{{userInfo.avatarUrl}}" mode="cover"></image>
|
||||
<text class="userinfo-nickname">{{userInfo.nickName}}</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="usermotto">
|
||||
<text class="user-motto">{{motto}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
62
miniprogram/pages/index/index.wxss
Normal file
62
miniprogram/pages/index/index.wxss
Normal file
@@ -0,0 +1,62 @@
|
||||
/**index.wxss**/
|
||||
page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.scrollarea {
|
||||
flex: 1;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.userinfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #aaa;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.userinfo-avatar {
|
||||
overflow: hidden;
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
margin: 20rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.usermotto {
|
||||
margin-top: 200px;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
padding: 0;
|
||||
width: 56px !important;
|
||||
border-radius: 8px;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.nickname-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
border-top: .5px solid rgba(0, 0, 0, 0.1);
|
||||
border-bottom: .5px solid rgba(0, 0, 0, 0.1);
|
||||
color: black;
|
||||
}
|
||||
|
||||
.nickname-label {
|
||||
width: 105px;
|
||||
}
|
||||
|
||||
.nickname-input {
|
||||
flex: 1;
|
||||
}
|
||||
4
miniprogram/pages/logs/logs.json
Normal file
4
miniprogram/pages/logs/logs.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
}
|
||||
}
|
||||
21
miniprogram/pages/logs/logs.ts
Normal file
21
miniprogram/pages/logs/logs.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// logs.ts
|
||||
// const util = require('../../utils/util.js')
|
||||
import { formatTime } from '../../utils/util'
|
||||
|
||||
Component({
|
||||
data: {
|
||||
logs: [],
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
this.setData({
|
||||
logs: (wx.getStorageSync('logs') || []).map((log: string) => {
|
||||
return {
|
||||
date: formatTime(new Date(log)),
|
||||
timeStamp: log
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
6
miniprogram/pages/logs/logs.wxml
Normal file
6
miniprogram/pages/logs/logs.wxml
Normal file
@@ -0,0 +1,6 @@
|
||||
<!--logs.wxml-->
|
||||
<scroll-view class="scrollarea" scroll-y type="list">
|
||||
<block wx:for="{{logs}}" wx:key="timeStamp" wx:for-item="log">
|
||||
<view class="log-item">{{index + 1}}. {{log.date}}</view>
|
||||
</block>
|
||||
</scroll-view>
|
||||
16
miniprogram/pages/logs/logs.wxss
Normal file
16
miniprogram/pages/logs/logs.wxss
Normal file
@@ -0,0 +1,16 @@
|
||||
page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.scrollarea {
|
||||
flex: 1;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.log-item {
|
||||
margin-top: 20rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.log-item:last-child {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
4
miniprogram/pages/map/map.json
Normal file
4
miniprogram/pages/map/map.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "地图",
|
||||
"disableScroll": true
|
||||
}
|
||||
173
miniprogram/pages/map/map.ts
Normal file
173
miniprogram/pages/map/map.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { MapEngine, type MapEngineStageRect, type MapEngineViewState } from '../../engine/map/mapEngine'
|
||||
|
||||
const INTERNAL_BUILD_VERSION = 'map-build-58'
|
||||
|
||||
let mapEngine: MapEngine | null = null
|
||||
|
||||
function getFallbackStageRect(): MapEngineStageRect {
|
||||
const systemInfo = wx.getSystemInfoSync()
|
||||
const width = Math.max(320, systemInfo.windowWidth - 20)
|
||||
const height = Math.max(280, Math.floor(systemInfo.windowHeight * 0.66))
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
left: 10,
|
||||
top: 0,
|
||||
}
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {} as MapEngineViewState,
|
||||
|
||||
onLoad() {
|
||||
mapEngine = new MapEngine(INTERNAL_BUILD_VERSION, {
|
||||
onData: (patch) => {
|
||||
this.setData(patch)
|
||||
},
|
||||
})
|
||||
|
||||
this.setData(mapEngine.getInitialData())
|
||||
},
|
||||
|
||||
onReady() {
|
||||
this.measureStageAndCanvas()
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
if (mapEngine) {
|
||||
mapEngine.destroy()
|
||||
mapEngine = null
|
||||
}
|
||||
},
|
||||
|
||||
measureStageAndCanvas() {
|
||||
const page = this
|
||||
const applyStage = (rawRect?: Partial<WechatMiniprogram.BoundingClientRectCallbackResult>) => {
|
||||
const fallbackRect = getFallbackStageRect()
|
||||
const rect: MapEngineStageRect = {
|
||||
width: rawRect && typeof rawRect.width === 'number' ? rawRect.width : fallbackRect.width,
|
||||
height: rawRect && typeof rawRect.height === 'number' ? rawRect.height : fallbackRect.height,
|
||||
left: rawRect && typeof rawRect.left === 'number' ? rawRect.left : fallbackRect.left,
|
||||
top: rawRect && typeof rawRect.top === 'number' ? rawRect.top : fallbackRect.top,
|
||||
}
|
||||
|
||||
const currentEngine = mapEngine
|
||||
if (!currentEngine) {
|
||||
return
|
||||
}
|
||||
|
||||
currentEngine.setStage(rect)
|
||||
|
||||
const canvasQuery = wx.createSelectorQuery().in(page)
|
||||
canvasQuery.select('#mapCanvas').fields({ node: true, size: true })
|
||||
canvasQuery.exec((canvasRes) => {
|
||||
const canvasRef = canvasRes[0] as any
|
||||
if (!canvasRef || !canvasRef.node) {
|
||||
page.setData({
|
||||
statusText: `WebGL 引擎初始化失败 (${INTERNAL_BUILD_VERSION})`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const dpr = wx.getSystemInfoSync().pixelRatio || 1
|
||||
try {
|
||||
currentEngine.attachCanvas(canvasRef.node, rect.width, rect.height, dpr)
|
||||
} catch (error) {
|
||||
page.setData({
|
||||
statusText: `WebGL 初始化失败 (${INTERNAL_BUILD_VERSION})`,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const query = wx.createSelectorQuery().in(page)
|
||||
query.select('.map-stage').boundingClientRect()
|
||||
query.exec((res) => {
|
||||
const rect = res[0] as WechatMiniprogram.BoundingClientRectCallbackResult | undefined
|
||||
applyStage(rect)
|
||||
})
|
||||
},
|
||||
|
||||
handleTouchStart(event: WechatMiniprogram.TouchEvent) {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleTouchStart(event)
|
||||
}
|
||||
},
|
||||
|
||||
handleTouchMove(event: WechatMiniprogram.TouchEvent) {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleTouchMove(event)
|
||||
}
|
||||
},
|
||||
|
||||
handleTouchEnd(event: WechatMiniprogram.TouchEvent) {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleTouchEnd(event)
|
||||
}
|
||||
},
|
||||
|
||||
handleTouchCancel() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleTouchCancel()
|
||||
}
|
||||
},
|
||||
|
||||
handleRecenter() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleRecenter()
|
||||
}
|
||||
},
|
||||
|
||||
handleRotateStep() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleRotateStep()
|
||||
}
|
||||
},
|
||||
|
||||
handleRotationReset() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleRotationReset()
|
||||
}
|
||||
},
|
||||
|
||||
handleSetManualMode() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleSetManualMode()
|
||||
}
|
||||
},
|
||||
|
||||
handleSetNorthUpMode() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleSetNorthUpMode()
|
||||
}
|
||||
},
|
||||
|
||||
handleSetHeadingUpMode() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleSetHeadingUpMode()
|
||||
}
|
||||
},
|
||||
|
||||
handleAutoRotateCalibrate() {
|
||||
if (mapEngine) {
|
||||
mapEngine.handleAutoRotateCalibrate()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
152
miniprogram/pages/map/map.wxml
Normal file
152
miniprogram/pages/map/map.wxml
Normal file
@@ -0,0 +1,152 @@
|
||||
<view class="page">
|
||||
<view class="page__header">
|
||||
<view>
|
||||
<view class="page__eyebrow">CMR MINI PROGRAM</view>
|
||||
<view class="page__title">{{mapName}}</view>
|
||||
</view>
|
||||
<view class="page__badge">{{mapReadyText}}</view>
|
||||
</view>
|
||||
|
||||
<view class="map-stage-wrap">
|
||||
<view
|
||||
class="map-stage"
|
||||
catchtouchstart="handleTouchStart"
|
||||
catchtouchmove="handleTouchMove"
|
||||
catchtouchend="handleTouchEnd"
|
||||
catchtouchcancel="handleTouchCancel"
|
||||
>
|
||||
<view class="map-content">
|
||||
<canvas
|
||||
id="mapCanvas"
|
||||
type="webgl"
|
||||
canvas-id="mapCanvas"
|
||||
class="map-canvas map-canvas--base"
|
||||
></canvas>
|
||||
</view>
|
||||
|
||||
<view class="map-stage__crosshair"></view>
|
||||
|
||||
<view class="map-stage__overlay">
|
||||
<view class="overlay-card">
|
||||
<view class="overlay-card__label">WEBGL MAP ENGINE</view>
|
||||
<view class="overlay-card__title">North Up / Heading Up / Manual</view>
|
||||
<view class="overlay-card__desc">
|
||||
地图北已经固定为正上方。现在支持手动旋转、北朝上、朝向朝上三种模式,并提供指北针用于校验朝向。
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="compass-widget">
|
||||
<view class="compass-widget__ring">
|
||||
<view class="compass-widget__north">N</view>
|
||||
<view class="compass-widget__needle" style="transform: translateX(-50%) rotate({{compassNeedleDeg}}deg);"></view>
|
||||
<view class="compass-widget__center"></view>
|
||||
</view>
|
||||
<view class="compass-widget__label">{{sensorHeadingText}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view class="info-panel" scroll-y enhanced show-scrollbar="true">
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Build</text>
|
||||
<text class="info-panel__value">{{buildVersion}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Renderer</text>
|
||||
<text class="info-panel__value">{{renderMode}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">Projection</text>
|
||||
<text class="info-panel__value">{{projectionMode}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Heading Mode</text>
|
||||
<text class="info-panel__value">{{orientationModeText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Sensor Heading</text>
|
||||
<text class="info-panel__value">{{sensorHeadingText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">North Ref</text>
|
||||
<text class="info-panel__value">{{northReferenceText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Auto Source</text>
|
||||
<text class="info-panel__value">{{autoRotateSourceText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Calibration</text>
|
||||
<text class="info-panel__value">{{autoRotateCalibrationText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">Tile URL</text>
|
||||
<text class="info-panel__value">{{tileSource}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Zoom</text>
|
||||
<text class="info-panel__value">{{zoom}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Rotation</text>
|
||||
<text class="info-panel__value">{{rotationText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Center Tile</text>
|
||||
<text class="info-panel__value">{{centerText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Tile Size</text>
|
||||
<text class="info-panel__value">{{tileSizePx}}px</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Visible Tiles</text>
|
||||
<text class="info-panel__value">{{visibleTileCount}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Ready Tiles</text>
|
||||
<text class="info-panel__value">{{readyTileCount}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Memory Tiles</text>
|
||||
<text class="info-panel__value">{{memoryTileCount}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Disk Tiles</text>
|
||||
<text class="info-panel__value">{{diskTileCount}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Cache Hit</text>
|
||||
<text class="info-panel__value">{{cacheHitRateText}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Disk Hits</text>
|
||||
<text class="info-panel__value">{{diskHitCount}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row">
|
||||
<text class="info-panel__label">Net Fetches</text>
|
||||
<text class="info-panel__value">{{networkFetchCount}}</text>
|
||||
</view>
|
||||
<view class="info-panel__row info-panel__row--stack">
|
||||
<text class="info-panel__label">Status</text>
|
||||
<text class="info-panel__value">{{statusText}}</text>
|
||||
</view>
|
||||
|
||||
<view class="control-row">
|
||||
<view class="control-chip control-chip--primary" bindtap="handleRecenter">回到首屏</view>
|
||||
<view class="control-chip control-chip--secondary" bindtap="handleRotationReset">旋转归零</view>
|
||||
</view>
|
||||
<view class="control-row control-row--triple">
|
||||
<view class="control-chip {{orientationMode === 'manual' ? 'control-chip--active' : ''}}" bindtap="handleSetManualMode">手动</view>
|
||||
<view class="control-chip {{orientationMode === 'north-up' ? 'control-chip--active' : ''}}" bindtap="handleSetNorthUpMode">北朝上</view>
|
||||
<view class="control-chip {{orientationMode === 'heading-up' ? 'control-chip--active' : ''}}" bindtap="handleSetHeadingUpMode">朝向朝上</view>
|
||||
</view>
|
||||
<view class="control-row" wx:if="{{orientationMode === 'heading-up'}}">
|
||||
<view class="control-chip" bindtap="handleAutoRotateCalibrate">按当前方向校准</view>
|
||||
</view>
|
||||
<view class="control-row" wx:if="{{orientationMode === 'manual'}}">
|
||||
<view class="control-chip" bindtap="handleRotateStep">旋转 +15°</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
337
miniprogram/pages/map/map.wxss
Normal file
337
miniprogram/pages/map/map.wxss
Normal file
@@ -0,0 +1,337 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
padding: 20rpx 20rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top left, #d8f3dc 0%, rgba(216, 243, 220, 0) 32%),
|
||||
linear-gradient(180deg, #f7fbf2 0%, #eef6ea 100%);
|
||||
color: #163020;
|
||||
}
|
||||
|
||||
.page__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page__eyebrow {
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 4rpx;
|
||||
color: #5f7a65;
|
||||
}
|
||||
|
||||
.page__title {
|
||||
margin-top: 8rpx;
|
||||
font-size: 44rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page__badge {
|
||||
padding: 10rpx 18rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #163020;
|
||||
color: #f7fbf2;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.map-stage-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 66vh;
|
||||
min-height: 520rpx;
|
||||
max-height: 72vh;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.map-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border: 2rpx solid rgba(22, 48, 32, 0.08);
|
||||
border-radius: 32rpx;
|
||||
background: #dbeed4;
|
||||
box-shadow: 0 18rpx 40rpx rgba(22, 48, 32, 0.08);
|
||||
}
|
||||
|
||||
.map-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.map-canvas--base {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.map-stage__crosshair {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
transform: translate(-50%, -50%);
|
||||
border: 3rpx solid rgba(255, 255, 255, 0.95);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 4rpx rgba(22, 48, 32, 0.2);
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.map-stage__crosshair::before,
|
||||
.map-stage__crosshair::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
.map-stage__crosshair::before {
|
||||
left: 50%;
|
||||
top: -18rpx;
|
||||
width: 2rpx;
|
||||
height: 76rpx;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.map-stage__crosshair::after {
|
||||
left: -18rpx;
|
||||
top: 50%;
|
||||
width: 76rpx;
|
||||
height: 2rpx;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.map-stage__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.overlay-card {
|
||||
width: 68%;
|
||||
padding: 22rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(247, 251, 242, 0.92);
|
||||
box-shadow: 0 12rpx 30rpx rgba(22, 48, 32, 0.08);
|
||||
}
|
||||
|
||||
.overlay-card__label {
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 3rpx;
|
||||
color: #5f7a65;
|
||||
}
|
||||
|
||||
.overlay-card__title {
|
||||
margin-top: 10rpx;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.overlay-card__desc {
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: #45624b;
|
||||
}
|
||||
|
||||
.compass-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.compass-widget__ring {
|
||||
position: relative;
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(247, 251, 242, 0.94);
|
||||
border: 2rpx solid rgba(22, 48, 32, 0.12);
|
||||
box-shadow: 0 10rpx 24rpx rgba(22, 48, 32, 0.1);
|
||||
}
|
||||
|
||||
.compass-widget__north {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 10rpx;
|
||||
transform: translateX(-50%);
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
color: #d62828;
|
||||
}
|
||||
|
||||
.compass-widget__needle {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 18rpx;
|
||||
width: 4rpx;
|
||||
height: 72rpx;
|
||||
transform-origin: 50% 36rpx;
|
||||
background: linear-gradient(180deg, #d62828 0%, #163020 100%);
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
|
||||
.compass-widget__center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: #163020;
|
||||
}
|
||||
|
||||
.compass-widget__label {
|
||||
min-width: 92rpx;
|
||||
padding: 6rpx 10rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(247, 251, 242, 0.94);
|
||||
font-size: 20rpx;
|
||||
text-align: center;
|
||||
color: #163020;
|
||||
box-shadow: 0 8rpx 18rpx rgba(22, 48, 32, 0.08);
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 22rpx 20rpx 28rpx;
|
||||
box-sizing: border-box;
|
||||
border-radius: 28rpx;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 12rpx 32rpx rgba(22, 48, 32, 0.08);
|
||||
}
|
||||
|
||||
.info-panel__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16rpx;
|
||||
padding: 10rpx 0;
|
||||
border-bottom: 1rpx solid rgba(22, 48, 32, 0.08);
|
||||
}
|
||||
|
||||
.info-panel__row--stack {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.info-panel__row:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-panel__label {
|
||||
flex-shrink: 0;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 2rpx;
|
||||
color: #5f7a65;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-panel__value {
|
||||
font-size: 25rpx;
|
||||
color: #163020;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.info-panel__row--stack .info-panel__value {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
text-align: left;
|
||||
color: #45624b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-panel__actions {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.info-panel__actions--triple .info-panel__action {
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.info-panel__action {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: 999rpx;
|
||||
background: #d7e8da;
|
||||
color: #163020;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.info-panel__action--primary {
|
||||
background: #2d6a4f;
|
||||
color: #f7fbf2;
|
||||
}
|
||||
|
||||
.info-panel__action--secondary {
|
||||
background: #eef6ea;
|
||||
color: #45624b;
|
||||
}
|
||||
|
||||
.info-panel__action--active {
|
||||
background: #2d6a4f;
|
||||
color: #f7fbf2;
|
||||
}
|
||||
|
||||
.control-row {
|
||||
display: flex;
|
||||
gap: 14rpx;
|
||||
margin-top: 18rpx;
|
||||
}
|
||||
|
||||
.control-row--triple .control-chip {
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.control-chip {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 20rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #d7e8da;
|
||||
color: #163020;
|
||||
font-size: 26rpx;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.control-chip--primary {
|
||||
background: #2d6a4f;
|
||||
color: #f7fbf2;
|
||||
}
|
||||
|
||||
.control-chip--secondary {
|
||||
background: #eef6ea;
|
||||
color: #45624b;
|
||||
}
|
||||
|
||||
.control-chip--active {
|
||||
background: #2d6a4f;
|
||||
color: #f7fbf2;
|
||||
}
|
||||
|
||||
|
||||
61
miniprogram/utils/projection.ts
Normal file
61
miniprogram/utils/projection.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export interface LonLatPoint {
|
||||
lon: number
|
||||
lat: number
|
||||
}
|
||||
|
||||
export interface WebMercatorPoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface WorldTilePoint {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
const MAX_LATITUDE = 85.05112878
|
||||
const EARTH_RADIUS = 6378137
|
||||
|
||||
function clampLatitude(lat: number): number {
|
||||
return Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, lat))
|
||||
}
|
||||
|
||||
export function lonLatToWebMercator(point: LonLatPoint): WebMercatorPoint {
|
||||
const latitude = clampLatitude(point.lat)
|
||||
const lonRad = point.lon * Math.PI / 180
|
||||
const latRad = latitude * Math.PI / 180
|
||||
|
||||
return {
|
||||
x: EARTH_RADIUS * lonRad,
|
||||
y: EARTH_RADIUS * Math.log(Math.tan(Math.PI / 4 + latRad / 2)),
|
||||
}
|
||||
}
|
||||
|
||||
export function webMercatorToLonLat(point: WebMercatorPoint): LonLatPoint {
|
||||
return {
|
||||
lon: point.x / EARTH_RADIUS * 180 / Math.PI,
|
||||
lat: (2 * Math.atan(Math.exp(point.y / EARTH_RADIUS)) - Math.PI / 2) * 180 / Math.PI,
|
||||
}
|
||||
}
|
||||
|
||||
export function lonLatToWorldTile(point: LonLatPoint, zoom: number): WorldTilePoint {
|
||||
const latitude = clampLatitude(point.lat)
|
||||
const scale = Math.pow(2, zoom)
|
||||
const latRad = latitude * Math.PI / 180
|
||||
|
||||
return {
|
||||
x: (point.lon + 180) / 360 * scale,
|
||||
y: (1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * scale,
|
||||
}
|
||||
}
|
||||
|
||||
export function worldTileToLonLat(point: WorldTilePoint, zoom: number): LonLatPoint {
|
||||
const scale = Math.pow(2, zoom)
|
||||
const lon = point.x / scale * 360 - 180
|
||||
const latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * point.y / scale)))
|
||||
|
||||
return {
|
||||
lon,
|
||||
lat: latRad * 180 / Math.PI,
|
||||
}
|
||||
}
|
||||
61
miniprogram/utils/tile.ts
Normal file
61
miniprogram/utils/tile.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export interface TileItem {
|
||||
key: string
|
||||
url: string
|
||||
x: number
|
||||
y: number
|
||||
leftPx: number
|
||||
topPx: number
|
||||
sizePx: number
|
||||
isCenter: boolean
|
||||
}
|
||||
|
||||
const TILE_OVERLAP_PX = 2
|
||||
|
||||
export interface TileGridOptions {
|
||||
urlTemplate: string
|
||||
zoom: number
|
||||
centerTileX: number
|
||||
centerTileY: number
|
||||
viewportWidth: number
|
||||
viewportHeight: number
|
||||
tileSize: number
|
||||
overdraw: number
|
||||
}
|
||||
|
||||
export function buildTileUrl(template: string, z: number, x: number, y: number): string {
|
||||
return template
|
||||
.replace('{z}', String(z))
|
||||
.replace('{x}', String(x))
|
||||
.replace('{y}', String(y))
|
||||
}
|
||||
|
||||
export function createTileGrid(options: TileGridOptions): TileItem[] {
|
||||
const tiles: TileItem[] = []
|
||||
const halfWidth = options.viewportWidth / 2
|
||||
const halfHeight = options.viewportHeight / 2
|
||||
const horizontalRange = Math.ceil(halfWidth / options.tileSize) + options.overdraw
|
||||
const verticalRange = Math.ceil(halfHeight / options.tileSize) + options.overdraw
|
||||
|
||||
for (let dy = -verticalRange; dy <= verticalRange; dy += 1) {
|
||||
for (let dx = -horizontalRange; dx <= horizontalRange; dx += 1) {
|
||||
const x = options.centerTileX + dx
|
||||
const y = options.centerTileY + dy
|
||||
|
||||
const rawLeft = halfWidth + (dx - 0.5) * options.tileSize
|
||||
const rawTop = halfHeight + (dy - 0.5) * options.tileSize
|
||||
|
||||
tiles.push({
|
||||
key: `${options.zoom}-${x}-${y}`,
|
||||
url: buildTileUrl(options.urlTemplate, options.zoom, x, y),
|
||||
x,
|
||||
y,
|
||||
leftPx: Math.floor(rawLeft - TILE_OVERLAP_PX / 2),
|
||||
topPx: Math.floor(rawTop - TILE_OVERLAP_PX / 2),
|
||||
sizePx: Math.ceil(options.tileSize) + TILE_OVERLAP_PX,
|
||||
isCenter: dx === 0 && dy === 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tiles
|
||||
}
|
||||
19
miniprogram/utils/util.ts
Normal file
19
miniprogram/utils/util.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const formatTime = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hour = date.getHours()
|
||||
const minute = date.getMinutes()
|
||||
const second = date.getSeconds()
|
||||
|
||||
return (
|
||||
[year, month, day].map(formatNumber).join('/') +
|
||||
' ' +
|
||||
[hour, minute, second].map(formatNumber).join(':')
|
||||
)
|
||||
}
|
||||
|
||||
const formatNumber = (n: number) => {
|
||||
const s = n.toString()
|
||||
return s[1] ? s : '0' + s
|
||||
}
|
||||
Reference in New Issue
Block a user