Jelajahi Sumber

feat: fix gps map projection and update map config

zhangyan 2 minggu lalu
induk
melakukan
51740761f5

+ 19 - 12
miniprogram/engine/layer/gpsLayer.ts

@@ -1,18 +1,25 @@
-import { type CameraState } from '../camera/camera'
-import { lonLatToWorldTile } from '../../utils/projection'
-import { worldToScreen } from '../camera/camera'
+import { calibratedLonLatToWorldTile } from '../../utils/projection'
+import { worldToScreen, type CameraState } from '../camera/camera'
 import { type MapLayer, type LayerRenderContext } from './mapLayer'
 import { type MapScene } from '../renderer/mapRenderer'
 import { type ScreenPoint } from './trackLayer'
 
+function buildVectorCamera(scene: MapScene): CameraState {
+  return {
+    centerWorldX: scene.exactCenterWorldX,
+    centerWorldY: scene.exactCenterWorldY,
+    viewportWidth: scene.viewportWidth,
+    viewportHeight: scene.viewportHeight,
+    visibleColumns: scene.visibleColumns,
+    rotationRad: scene.rotationRad,
+  }
+}
+
 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,
-    }
+  projectPoint(scene: MapScene): ScreenPoint {
+    const camera = buildVectorCamera(scene)
+    const worldPoint = calibratedLonLatToWorldTile(scene.gpsPoint, scene.zoom, scene.gpsCalibration, scene.gpsCalibrationOrigin)
+    return worldToScreen(camera, worldPoint, false)
   }
 
   getPulseRadius(pulseFrame: number): number {
@@ -20,8 +27,8 @@ export class GpsLayer implements MapLayer {
   }
 
   draw(context: LayerRenderContext): void {
-    const { ctx, camera, scene, pulseFrame } = context
-    const gpsScreenPoint = this.projectPoint(scene, camera)
+    const { ctx, scene, pulseFrame } = context
+    const gpsScreenPoint = this.projectPoint(scene)
     const pulse = this.getPulseRadius(pulseFrame)
 
     ctx.save()

+ 18 - 10
miniprogram/engine/layer/trackLayer.ts

@@ -1,5 +1,5 @@
 import { type CameraState } from '../camera/camera'
-import { lonLatToWorldTile } from '../../utils/projection'
+import { calibratedLonLatToWorldTile } from '../../utils/projection'
 import { worldToScreen } from '../camera/camera'
 import { type MapLayer, type LayerRenderContext } from './mapLayer'
 import { type MapScene } from '../renderer/mapRenderer'
@@ -9,21 +9,29 @@ export interface ScreenPoint {
   y: number
 }
 
+function buildVectorCamera(scene: MapScene): CameraState {
+  return {
+    centerWorldX: scene.exactCenterWorldX,
+    centerWorldY: scene.exactCenterWorldY,
+    viewportWidth: scene.viewportWidth,
+    viewportHeight: scene.viewportHeight,
+    visibleColumns: scene.visibleColumns,
+    rotationRad: scene.rotationRad,
+  }
+}
+
 export class TrackLayer implements MapLayer {
-  projectPoints(scene: MapScene, camera: CameraState): ScreenPoint[] {
+  projectPoints(scene: MapScene): ScreenPoint[] {
+    const camera = buildVectorCamera(scene)
     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,
-      }
+      const worldPoint = calibratedLonLatToWorldTile(point, scene.zoom, scene.gpsCalibration, scene.gpsCalibrationOrigin)
+      return worldToScreen(camera, worldPoint, false)
     })
   }
 
   draw(context: LayerRenderContext): void {
-    const { ctx, camera, scene } = context
-    const points = this.projectPoints(scene, camera)
+    const { ctx, scene } = context
+    const points = this.projectPoints(scene)
 
     ctx.save()
     ctx.lineCap = 'round'

+ 50 - 11
miniprogram/engine/map/mapEngine.ts

@@ -3,7 +3,7 @@ import { CompassHeadingController } from '../sensor/compassHeadingController'
 import { LocationController } from '../sensor/locationController'
 import { WebGLMapRenderer } from '../renderer/webglMapRenderer'
 import { type MapRendererStats } from '../renderer/mapRenderer'
-import { gcj02ToWgs84, lonLatToWorldTile, worldTileToLonLat, type LonLatPoint } from '../../utils/projection'
+import { lonLatToWorldTile, worldTileToLonLat, type LonLatPoint, type MapCalibration } from '../../utils/projection'
 import { isTileWithinBounds, type RemoteMapConfig, type TileZoomBounds } from '../../utils/remoteMapConfig'
 
 const RENDER_MODE = 'Single WebGL Pipeline'
@@ -21,6 +21,14 @@ const DEFAULT_TOP_LEFT_TILE_Y = 51199
 const DEFAULT_CENTER_TILE_X = DEFAULT_TOP_LEFT_TILE_X + 1
 const DEFAULT_CENTER_TILE_Y = DEFAULT_TOP_LEFT_TILE_Y + 1
 const TILE_SOURCE = 'https://oss-mbh5.colormaprun.com/wxMap/lcx/{z}/{x}/{y}.png'
+const OSM_TILE_SOURCE = 'https://tiles.mymarsgo.xyz/{z}/{x}/{y}.png'
+const MAP_OVERLAY_OPACITY = 0.72
+const GPS_MAP_CALIBRATION: MapCalibration = {
+  offsetEastMeters: 0,
+  offsetNorthMeters: 0,
+  rotationDeg: 0,
+  scale: 1,
+}
 const MIN_PREVIEW_SCALE = 0.55
 const MAX_PREVIEW_SCALE = 1.85
 const INERTIA_FRAME_MS = 16
@@ -114,6 +122,8 @@ export interface MapEngineViewState {
   gpsTracking: boolean
   gpsTrackingText: string
   gpsCoordText: string
+  osmReferenceEnabled: boolean
+  osmReferenceText: string
 }
 
 export interface MapEngineCallbacks {
@@ -158,6 +168,8 @@ const VIEW_SYNC_KEYS: Array<keyof MapEngineViewState> = [
   'gpsTracking',
   'gpsTrackingText',
   'gpsCoordText',
+  'osmReferenceEnabled',
+  'osmReferenceText',
 ]
 
 function buildCenterText(zoom: number, x: number, y: number): string {
@@ -524,6 +536,8 @@ export class MapEngine {
       gpsTracking: false,
       gpsTrackingText: '持续定位待启动',
       gpsCoordText: '--',
+      osmReferenceEnabled: false,
+      osmReferenceText: 'OSM参考:关',
     }
     this.previewScale = 1
     this.previewOriginX = 0
@@ -575,7 +589,7 @@ export class MapEngine {
 
 
   handleLocationUpdate(longitude: number, latitude: number, accuracyMeters: number | null): void {
-    const nextPoint: LonLatPoint = gcj02ToWgs84({ lon: longitude, lat: latitude })
+    const nextPoint: LonLatPoint = { lon: longitude, lat: latitude }
     const lastTrackPoint = this.currentGpsTrack.length ? this.currentGpsTrack[this.currentGpsTrack.length - 1] : null
     if (!lastTrackPoint || getApproxDistanceMeters(lastTrackPoint, nextPoint) >= GPS_TRACK_MIN_STEP_METERS) {
       this.currentGpsTrack = [...this.currentGpsTrack, nextPoint].slice(-GPS_TRACK_MAX_POINTS)
@@ -612,6 +626,16 @@ export class MapEngine {
     this.syncRenderer()
   }
 
+  handleToggleOsmReference(): void {
+    const nextEnabled = !this.state.osmReferenceEnabled
+    this.setState({
+      osmReferenceEnabled: nextEnabled,
+      osmReferenceText: nextEnabled ? 'OSM参考:开' : 'OSM参考:关',
+      statusText: nextEnabled ? `OSM参考底图已开启 (${this.buildVersion})` : `OSM参考底图已关闭 (${this.buildVersion})`,
+    }, true)
+    this.syncRenderer()
+  }
+
   handleToggleGpsTracking(): void {
     if (this.locationController.listening) {
       this.locationController.stop()
@@ -1364,11 +1388,15 @@ export class MapEngine {
   }
 
   buildScene() {
+    const exactCenter = this.getExactCenterFromTranslate(this.state.tileTranslateX, this.state.tileTranslateY)
     return {
       tileSource: this.state.tileSource,
+      osmTileSource: OSM_TILE_SOURCE,
       zoom: this.state.zoom,
       centerTileX: this.state.centerTileX,
       centerTileY: this.state.centerTileY,
+      exactCenterWorldX: exactCenter.x,
+      exactCenterWorldY: exactCenter.y,
       tileBoundsByZoom: this.tileBoundsByZoom,
       viewportWidth: this.state.stageWidth,
       viewportHeight: this.state.stageHeight,
@@ -1382,6 +1410,10 @@ export class MapEngine {
       previewOriginY: this.previewOriginY || this.state.stageHeight / 2,
       track: this.currentGpsTrack.length ? this.currentGpsTrack : SAMPLE_TRACK_WGS84,
       gpsPoint: this.currentGpsPoint,
+      gpsCalibration: GPS_MAP_CALIBRATION,
+      gpsCalibrationOrigin: worldTileToLonLat({ x: this.defaultCenterTileX, y: this.defaultCenterTileY }, this.defaultZoom),
+      osmReferenceEnabled: this.state.osmReferenceEnabled,
+      overlayOpacity: MAP_OVERLAY_OPACITY,
     }
   }
 
@@ -1395,8 +1427,8 @@ export class MapEngine {
 
   getCameraState(rotationDeg = this.state.rotationDeg): CameraState {
     return {
-      centerWorldX: this.state.centerTileX,
-      centerWorldY: this.state.centerTileY,
+      centerWorldX: this.state.centerTileX + 0.5,
+      centerWorldY: this.state.centerTileY + 0.5,
       viewportWidth: this.state.stageWidth,
       viewportHeight: this.state.stageHeight,
       visibleColumns: DESIRED_VISIBLE_COLUMNS,
@@ -1410,10 +1442,10 @@ export class MapEngine {
     return normalizeRotationDeg(rotationDeg) * Math.PI / 180
   }
 
-  getBaseCamera(centerWorldX = this.state.centerTileX, centerWorldY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState {
+  getBaseCamera(centerTileX = this.state.centerTileX, centerTileY = this.state.centerTileY, rotationDeg = this.state.rotationDeg): CameraState {
     return {
-      centerWorldX,
-      centerWorldY,
+      centerWorldX: centerTileX + 0.5,
+      centerWorldY: centerTileY + 0.5,
       viewportWidth: this.state.stageWidth,
       viewportHeight: this.state.stageHeight,
       visibleColumns: DESIRED_VISIBLE_COLUMNS,
@@ -1437,8 +1469,8 @@ export class MapEngine {
   getExactCenterFromTranslate(translateX: number, translateY: number): { x: number; y: number } {
     if (!this.state.stageWidth || !this.state.stageHeight) {
       return {
-        x: this.state.centerTileX,
-        y: this.state.centerTileY,
+        x: this.state.centerTileX + 0.5,
+        y: this.state.centerTileY + 0.5,
       }
     }
 
@@ -1456,8 +1488,8 @@ export class MapEngine {
     tileTranslateX: number
     tileTranslateY: number
   } {
-    const nextCenterTileX = Math.round(centerWorldX)
-    const nextCenterTileY = Math.round(centerWorldY)
+    const nextCenterTileX = Math.floor(centerWorldX)
+    const nextCenterTileY = Math.floor(centerWorldY)
 
     if (!this.state.stageWidth || !this.state.stageHeight) {
       return {
@@ -1763,6 +1795,13 @@ export class MapEngine {
 
 
 
+
+
+
+
+
+
+
 
 
 

+ 10 - 5
miniprogram/engine/renderer/mapRenderer.ts

@@ -1,13 +1,16 @@
 import { type CameraState } from '../camera/camera'
 import { type TileStoreStats } from '../tile/tileStore'
-import { type LonLatPoint } from '../../utils/projection'
+import { type LonLatPoint, type MapCalibration } from '../../utils/projection'
 import { type TileZoomBounds } from '../../utils/remoteMapConfig'
 
 export interface MapScene {
   tileSource: string
+  osmTileSource: string
   zoom: number
   centerTileX: number
   centerTileY: number
+  exactCenterWorldX: number
+  exactCenterWorldY: number
   tileBoundsByZoom: Record<number, TileZoomBounds> | null
   viewportWidth: number
   viewportHeight: number
@@ -21,6 +24,10 @@ export interface MapScene {
   previewOriginY: number
   track: LonLatPoint[]
   gpsPoint: LonLatPoint
+  gpsCalibration: MapCalibration
+  gpsCalibrationOrigin: LonLatPoint
+  osmReferenceEnabled: boolean
+  overlayOpacity: number
 }
 
 export type MapRendererStats = TileStoreStats
@@ -34,8 +41,8 @@ export interface MapRenderer {
 
 export function buildCamera(scene: MapScene): CameraState {
   return {
-    centerWorldX: scene.centerTileX,
-    centerWorldY: scene.centerTileY,
+    centerWorldX: scene.centerTileX + 0.5,
+    centerWorldY: scene.centerTileY + 0.5,
     viewportWidth: scene.viewportWidth,
     viewportHeight: scene.viewportHeight,
     visibleColumns: scene.visibleColumns,
@@ -44,5 +51,3 @@ export function buildCamera(scene: MapScene): CameraState {
     rotationRad: scene.rotationRad,
   }
 }
-
-

+ 13 - 1
miniprogram/engine/renderer/webglMapRenderer.ts

@@ -11,7 +11,9 @@ const ANIMATION_FRAME_MS = 33
 
 export class WebGLMapRenderer implements MapRenderer {
   tileStore: TileStore
+  osmTileStore: TileStore
   tileLayer: TileLayer
+  osmTileLayer: TileLayer
   trackLayer: TrackLayer
   gpsLayer: GpsLayer
   tileRenderer: WebGLTileRenderer
@@ -40,10 +42,19 @@ export class WebGLMapRenderer implements MapRenderer {
         this.scheduleRender()
       },
     } satisfies TileStoreCallbacks)
+    this.osmTileStore = new TileStore({
+      onTileReady: () => {
+        this.scheduleRender()
+      },
+      onTileError: () => {
+        this.scheduleRender()
+      },
+    } satisfies TileStoreCallbacks)
     this.tileLayer = new TileLayer()
+    this.osmTileLayer = new TileLayer()
     this.trackLayer = new TrackLayer()
     this.gpsLayer = new GpsLayer()
-    this.tileRenderer = new WebGLTileRenderer(this.tileLayer, this.tileStore)
+    this.tileRenderer = new WebGLTileRenderer(this.tileLayer, this.tileStore, this.osmTileLayer, this.osmTileStore)
     this.vectorRenderer = new WebGLVectorRenderer(this.trackLayer, this.gpsLayer)
     this.scene = null
     this.renderTimer = 0
@@ -94,6 +105,7 @@ export class WebGLMapRenderer implements MapRenderer {
     this.vectorRenderer.destroy()
     this.tileRenderer.destroy()
     this.tileStore.destroy()
+    this.osmTileStore.destroy()
     this.scene = null
   }
 

+ 44 - 10
miniprogram/engine/renderer/webglTileRenderer.ts

@@ -53,25 +53,31 @@ export class WebGLTileRenderer {
   gl: any
   tileLayer: TileLayer
   tileStore: TileStore
+  osmTileLayer: TileLayer
+  osmTileStore: TileStore
   dpr: number
   program: any
   positionBuffer: any
   texCoordBuffer: any
   positionLocation: number
   texCoordLocation: number
+  opacityLocation: any
   textureCache: Map<string, TextureRecord>
 
-  constructor(tileLayer: TileLayer, tileStore: TileStore) {
+  constructor(tileLayer: TileLayer, tileStore: TileStore, osmTileLayer: TileLayer, osmTileStore: TileStore) {
     this.canvas = null
     this.gl = null
     this.tileLayer = tileLayer
     this.tileStore = tileStore
+    this.osmTileLayer = osmTileLayer
+    this.osmTileStore = osmTileStore
     this.dpr = 1
     this.program = null
     this.positionBuffer = null
     this.texCoordBuffer = null
     this.positionLocation = -1
     this.texCoordLocation = -1
+    this.opacityLocation = null
     this.textureCache = new Map<string, TextureRecord>()
   }
 
@@ -90,12 +96,13 @@ export class WebGLTileRenderer {
     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); }',
+      'precision mediump float; varying vec2 v_texCoord; uniform sampler2D u_texture; uniform float u_opacity; void main() { vec4 color = texture2D(u_texture, v_texCoord); gl_FragColor = vec4(color.rgb, color.a * u_opacity); }',
     )
     this.positionBuffer = gl.createBuffer()
     this.texCoordBuffer = gl.createBuffer()
     this.positionLocation = gl.getAttribLocation(this.program, 'a_position')
     this.texCoordLocation = gl.getAttribLocation(this.program, 'a_texCoord')
+    this.opacityLocation = gl.getUniformLocation(this.program, 'u_opacity')
 
     gl.viewport(0, 0, canvasNode.width, canvasNode.height)
     gl.disable(gl.DEPTH_TEST)
@@ -103,6 +110,7 @@ export class WebGLTileRenderer {
     gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
 
     this.tileStore.attachCanvas(canvasNode)
+    this.osmTileStore.attachCanvas(canvasNode)
   }
 
   destroy(): void {
@@ -135,24 +143,46 @@ export class WebGLTileRenderer {
     }
 
     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)
 
+    if (scene.osmReferenceEnabled) {
+      this.renderTilePass(
+        {
+          ...scene,
+          tileSource: scene.osmTileSource,
+          tileBoundsByZoom: null,
+        },
+        1,
+        this.osmTileLayer,
+        this.osmTileStore,
+      )
+    }
+
+    this.renderTilePass(
+      scene,
+      scene.osmReferenceEnabled ? scene.overlayOpacity : 1,
+      this.tileLayer,
+      this.tileStore,
+    )
+  }
+
+  renderTilePass(scene: MapScene, opacity: number, tileLayer: TileLayer, tileStore: TileStore): void {
+    const camera = buildCamera(scene)
+    const tiles = tileLayer.prepareTiles(scene, camera, tileStore)
+
     for (const tile of tiles) {
-      const readyEntry = this.tileStore.getEntry(tile.url)
+      const readyEntry = 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
+        this.drawEntry(readyEntry, tile.url, 0, 0, readyEntry.image.width || 256, readyEntry.image.height || 256, tile.leftPx, tile.topPx, tile.sizePx, tile.sizePx, scene, opacity)
+        tileLayer.lastReadyTileCount += 1
         continue
       }
 
-      const parentFallback = this.tileStore.getParentFallbackSlice(tile, scene)
+      const parentFallback = tileStore.getParentFallbackSlice(tile, scene)
       if (parentFallback) {
         this.drawEntry(
           parentFallback.entry,
@@ -166,10 +196,11 @@ export class WebGLTileRenderer {
           tile.sizePx,
           tile.sizePx,
           scene,
+          opacity,
         )
       }
 
-      const childFallback = this.tileStore.getChildFallback(tile, scene)
+      const childFallback = tileStore.getChildFallback(tile, scene)
       if (!childFallback) {
         continue
       }
@@ -189,6 +220,7 @@ export class WebGLTileRenderer {
           cellWidth,
           cellHeight,
           scene,
+          opacity,
         )
       }
     }
@@ -206,6 +238,7 @@ export class WebGLTileRenderer {
     drawWidth: number,
     drawHeight: number,
     scene: MapScene,
+    opacity: number,
   ): void {
     if (!this.gl || !entry.image) {
       return
@@ -246,6 +279,7 @@ export class WebGLTileRenderer {
       texRight, texBottom,
     ])
 
+    gl.uniform1f(this.opacityLocation, opacity)
     gl.bindTexture(gl.TEXTURE_2D, texture.texture)
     gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer)
     gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW)

+ 4 - 4
miniprogram/engine/renderer/webglVectorRenderer.ts

@@ -1,4 +1,4 @@
-import { buildCamera, type MapScene } from './mapRenderer'
+import { type MapScene } from './mapRenderer'
 import { TrackLayer } from '../layer/trackLayer'
 import { GpsLayer } from '../layer/gpsLayer'
 
@@ -123,9 +123,8 @@ export class WebGLVectorRenderer {
     }
 
     const gl = this.gl
-    const camera = buildCamera(scene)
-    const trackPoints = this.trackLayer.projectPoints(scene, camera)
-    const gpsPoint = this.gpsLayer.projectPoint(scene, camera)
+    const trackPoints = this.trackLayer.projectPoints(scene)
+    const gpsPoint = this.gpsLayer.projectPoint(scene)
     const positions: number[] = []
     const colors: number[] = []
 
@@ -232,3 +231,4 @@ export class WebGLVectorRenderer {
     }
   }
 }
+

+ 2 - 0
miniprogram/engine/sensor/locationController.ts

@@ -89,6 +89,7 @@ export class LocationController {
 
   startBackgroundLocation(): void {
     wx.startLocationUpdateBackground({
+      type: 'wgs84',
       success: () => {
         this.bindLocationListener()
         this.listening = true
@@ -149,3 +150,4 @@ export class LocationController {
     wx.onLocationChange(this.boundLocationHandler)
   }
 }
+

+ 9 - 2
miniprogram/pages/map/map.ts

@@ -5,8 +5,8 @@ type MapPageData = MapEngineViewState & {
   showDebugPanel: boolean
 }
 
-const INTERNAL_BUILD_VERSION = 'map-build-88'
-const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/qyds-001/game.json'
+const INTERNAL_BUILD_VERSION = 'map-build-99'
+const REMOTE_GAME_CONFIG_URL = 'https://oss-mbh5.colormaprun.com/wxmini/test/game.json'
 
 let mapEngine: MapEngine | null = null
 
@@ -201,6 +201,12 @@ Page({
     }
   },
 
+  handleToggleOsmReference() {
+    if (mapEngine) {
+      mapEngine.handleToggleOsmReference()
+    }
+  },
+
   handleToggleDebugPanel() {
     this.setData({
       showDebugPanel: !this.data.showDebugPanel,
@@ -240,5 +246,6 @@ Page({
 
 
 
+
 
 

+ 1 - 0
miniprogram/pages/map/map.wxml

@@ -158,6 +158,7 @@
     </view>
     <view class="control-row">
       <view class="control-chip {{gpsTracking ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleGpsTracking">{{gpsTracking ? '停止定位' : '开启定位'}}</view>
+      <view class="control-chip {{osmReferenceEnabled ? 'control-chip--active' : 'control-chip--secondary'}}" bindtap="handleToggleOsmReference">{{osmReferenceText}}</view>
     </view>
     <view class="control-row control-row--triple">
       <view class="control-chip {{orientationMode === 'manual' ? 'control-chip--active' : ''}}" bindtap="handleSetManualMode">手动</view>

+ 44 - 0
miniprogram/utils/projection.ts

@@ -13,6 +13,13 @@ export interface WorldTilePoint {
   y: number
 }
 
+export interface MapCalibration {
+  offsetEastMeters: number
+  offsetNorthMeters: number
+  rotationDeg: number
+  scale: number
+}
+
 const MAX_LATITUDE = 85.05112878
 const EARTH_RADIUS = 6378137
 
@@ -60,6 +67,43 @@ export function worldTileToLonLat(point: WorldTilePoint, zoom: number): LonLatPo
   }
 }
 
+export function applyMapCalibration(point: LonLatPoint, calibration: MapCalibration, origin: LonLatPoint): LonLatPoint {
+  const scale = calibration.scale || 1
+  const rotationDeg = calibration.rotationDeg || 0
+  const offsetEastMeters = calibration.offsetEastMeters || 0
+  const offsetNorthMeters = calibration.offsetNorthMeters || 0
+
+  if (
+    Math.abs(scale - 1) < 0.000001
+    && Math.abs(rotationDeg) < 0.000001
+    && Math.abs(offsetEastMeters) < 0.000001
+    && Math.abs(offsetNorthMeters) < 0.000001
+  ) {
+    return point
+  }
+
+  const originMercator = lonLatToWebMercator(origin)
+  const pointMercator = lonLatToWebMercator(point)
+  const deltaX = pointMercator.x - originMercator.x
+  const deltaY = pointMercator.y - originMercator.y
+  const scaledX = deltaX * scale
+  const scaledY = deltaY * scale
+  const rotationRad = rotationDeg * Math.PI / 180
+  const cos = Math.cos(rotationRad)
+  const sin = Math.sin(rotationRad)
+
+  const calibratedMercator: WebMercatorPoint = {
+    x: originMercator.x + scaledX * cos - scaledY * sin + offsetEastMeters,
+    y: originMercator.y + scaledX * sin + scaledY * cos + offsetNorthMeters,
+  }
+
+  return webMercatorToLonLat(calibratedMercator)
+}
+
+export function calibratedLonLatToWorldTile(point: LonLatPoint, zoom: number, calibration: MapCalibration, origin: LonLatPoint): WorldTilePoint {
+  return lonLatToWorldTile(applyMapCalibration(point, calibration, origin), zoom)
+}
+
 const CHINA_AXIS = 6378245
 const CHINA_EE = 0.00669342162296594323