基于uniapp+天地图API,实现H5/IOS/Android/鸿蒙/微信小程序地图选点功能
基于 UniApp + 天地图 API,实现一套逻辑适配 H5 / APP(IOS、Android) / 微信小程序 / 鸿蒙四大平台的地图选点功能。
多平台地图选点功能实现教程
基于 UniApp + 天地图 API,实现一套逻辑适配 H5 / APP / 微信小程序 / 鸿蒙四大平台的地图选点功能。


目录
- 架构总览
- 坐标系基础
- 项目文件结构与职责
- 核心 Composable 设计
- H5 端实现
- APP 端实现(renderjs 桥接)
- 微信小程序端实现
- 鸿蒙端实现(花瓣地图原生组件)
- POI 搜索与逆地理编码
- 选点结果输出
- 踩坑记录与最佳实践
- 完整项目源码
- 使用手册
1. 架构总览
1.1 设计思路
地图选点的核心挑战在于:各平台地图组件不同、坐标系不同、通信机制不同。为避免在视图层堆砌大量平台判断逻辑,我们采用 逻辑-视图分层 架构:
┌─────────────────────────────────────────────────────┐
│ 视图层 (index.vue) │
│ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ │
│ │ H5 │ │ APP-PLUS│ │MP-WEIXIN │ │ 鸿蒙 │ │
│ │天地图JS │ │renderjs │ │原生map组件│ │花瓣地图 │ │
│ └────┬────┘ └────┬────┘ └────┬─────┘ └────┬────┘ │
│ │ │ │ │ │
│ └───── 坐标系转换 ──────┴─────────────┘ │
│ │ │
│ ┌─────────▼──────────┐ │
│ │ useMapPicker Hook │ │
│ │ (纯逻辑,不依赖 │ │
│ │ 任何地图组件) │ │
│ └─────────┬──────────┘ │
│ │ │
│ ┌────────────▼────────────┐ │
│ │ coordTransform.ts │ │
│ │ mapConfig.ts │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────────────────┘
1.2 核心原则
| 原则 | 说明 |
|---|---|
| 逻辑-视图分离 | useMapPicker 封装所有业务逻辑,视图层只负责地图组件交互和坐标系转换 |
| 坐标系统一约定 | Hook 内部始终使用 WGS-84,各平台在视图层自行完成坐标系适配 |
| 结果双坐标系输出 | 最终结果同时返回 WGS-84 和 GCJ-02,调用方按需取用 |
| 条件编译隔离 | 通过 #ifdef / #ifndef 实现平台代码物理隔离,各端互不干扰 |
2. 坐标系基础
这是本功能最核心的背景知识,忽略坐标系差异会导致 100~600 米的位置偏移。
2.1 三种坐标系对照
| 坐标系 | 全称 | 用途 | 本项目中的使用场景 |
|---|---|---|---|
| WGS-84 | World Geodetic System 1984 | GPS 原始坐标,国际通用标准 | uni.getLocation({ type: 'wgs84' })、天地图 API、后端存储 |
| GCJ-02 | 国测局坐标(“火星坐标”) | 中国法定加密偏移标准 | 微信小程序 map 组件、花瓣地图 |
| CGCS2000 | 中国大地坐标2000系 | 国家大地坐标系 | 天地图底图(与 WGS-84 偏差 <1m,可等价使用) |
2.2 坐标系转换需求
┌──────────────────────┐
│ 天地图 API (WGS-84) │
│ · 逆地理编码 │
│ · POI 搜索 │
└───────┬──────┬───────┘
│ │
wgs84ToGcj02 │ │ gcj02ToWgs84
│ │
┌─────────────────▼──┐ ┌─▼──────────────────┐
│ 微信小程序 map (GCJ-02)│ │ 鸿蒙花瓣地图 (GCJ-02) │
└────────────────────┘ └────────────────────┘
┌─────────────────────────────────────────────┐
│ H5 / APP-PLUS 天地图 JS (WGS-84) ← 无需转换 │
└─────────────────────────────────────────────┘
2.3 转换算法实现
参考文件:coordTransform.ts
算法基于 Krasovsky 1940 椭球参数,对经纬度施加非线性偏移:
// 椭球参数
const A = 6378245.0 // 长半轴(米)
const EE = 0.00669342162296594323 // 第一偏心率的平方
// WGS-84 → GCJ-02(正向偏移)
export function wgs84ToGcj02(wgsLng, wgsLat): { lng, lat }
// GCJ-02 → WGS-84(近似逆运算,精度 <0.5m)
export function gcj02ToWgs84(gcjLng, gcjLat): { lng, lat }
// 境外坐标自动跳过偏移(中国境外无需加密)
function outOfChina(lng, lat): boolean
关键要点:
- GCJ-02 → WGS-84 是近似逆运算,但对于选点场景精度足够
- 境外坐标直接返回原值,不施加偏移
- 转换函数为纯计算,无副作用,可放心在计算属性中使用
3. 项目文件结构与职责
src/
├── utils/
│ ├── coordTransform.ts # 坐标系转换工具(WGS-84 ↔ GCJ-02)
│ └── mapConfig.ts # 天地图 API 凭证和接口地址配置
│
├── hooks/
│ └── useMapPicker.ts # 核心 composable(定位/逆地理编码/POI搜索/选点状态)
│
├── pages-sub/map/
│ ├── index.vue # 地图选点主页面(四端条件编译)
│ ├── test.vue # 测试页面(选点结果展示 + 原生 API 对比)
│ └── components/
│ ├── SearchBar.vue # 搜索栏组件(含输入框 + 清除按钮)
│ └── PoiList.vue # POI 搜索结果列表组件
│
└── uni_modules/native-harmony-map/ # 鸿蒙花瓣地图 UTS 插件
└── utssdk/app-harmony/
├── map.ets # 华为 MapKit 原生组件封装
└── index.uts # 导出地图控制器
依赖关系图
index.vue
├── useMapPicker (hook)
│ ├── mapConfig.ts (API 密钥 + URL)
│ └── coordTransform.ts (WGS-84 ↔ GCJ-02)
├── SearchBar.vue
│ └── PoiList.vue
└── native-harmony-map (UTS 插件, #ifdef APP-HARMONY)
└── map.ets (花瓣地图组件)
4. 核心 Composable 设计
参考文件:useMapPicker.ts
4.1 设计目标
将业务逻辑与各平台视图层完全解耦——hook 不依赖任何特定地图组件,只负责:
- 定位:获取 WGS-84 坐标
- 逆地理编码:坐标 → 地址
- POI 搜索:关键词 → 地点列表
- 选中状态管理:当前位置/选中位置
- 结果输出:双坐标系 MapPickerResult
4.2 坐标语义约定
这是整个系统最关键的约定,违反此约定将导致位置偏移:
| 变量 | 坐标系 | 说明 |
|---|---|---|
centerLat / centerLng |
随平台变化 | H5/APP-PLUS → WGS-84;微信/鸿蒙 → GCJ-02 |
selectedLocation.latitude/longitude |
始终 WGS-84 | 所有平台调用 updateSelected() 前必须转为 WGS-84 |
| 天地图 API 输入输出 | 始终 WGS-84 | 逆地理编码和 POI 搜索均使用 WGS-84 |
4.3 导出接口一览
// 响应式状态
centerLat, centerLng // 地图中心坐标(显示坐标系,随平台不同)
located, locating // 定位状态
selectedLocation // 选中位置信息(WGS-84)
keyword // 搜索关键词
poiList // POI 搜索结果
searchLoading // 搜索加载态
showPoiList // 是否显示搜索结果
hasSelection // 是否已有有效选中
// 方法
initLocation() // 初始化定位(WGS-84)
getCurrentLocation() // 获取当前位置(WGS-84)
updateSelected(lng, lat) // 更新选中位置(必须 WGS-84)
reverseGeocode(lng, lat) // 逆地理编码(必须 WGS-84)
onSearchInput(value, mapBound?) // 搜索输入(带防抖)
onSearchConfirm(mapBound?) // 确认搜索
clearSearch() // 清空搜索
selectPoi(item) // 选中 POI 结果
getResult() // 获取双坐标系结果
4.4 类型定义
/** 位置信息(坐标为 WGS-84) */
interface LocationInfo {
name: string // 地点名称
address: string // 完整地址
latitude: number // 纬度(WGS-84)
longitude: number // 经度(WGS-84)
province?: string // 省
city?: string // 市
district?: string // 区/县
}
/** POI 搜索结果项(坐标为 WGS-84) */
interface PoiItem {
key: string // 唯一标识(城市统计项以 "city_" 开头)
name: string // 地点名称
address: string // 地址
latitude: number // 纬度(WGS-84)
longitude: number // 经度(WGS-84)
}
/** 选点最终结果(含双坐标系) */
interface MapPickerResult {
name: string
address: string
latitude: number // WGS-84 纬度
longitude: number // WGS-84 经度
gcj02Latitude: number // GCJ-02 纬度
gcj02Longitude: number // GCJ-02 经度
}
5. H5 端实现
参考文件:index.vue 中 #ifdef H5 分支
5.1 实现方案
H5 端直接在浏览器中加载天地图 JS API 4.0,操作 DOM 创建地图实例。
5.2 核心代码解析
<template>
<!-- 天地图渲染容器 -->
<view id="tianditu-container" class="map-container" />
<!-- 中心定位针(CSS 固定在视口中心,拖拽地图时保持不动) -->
<view class="center-pin">
<image class="pin-icon" src="https://api.tianditu.gov.cn/img/map/markerA.png" />
</view>
</template>
初始化流程:
// 1. 动态加载天地图 JS
function initH5Map(lat, lng) {
const TMap = (window as any).T
if (!TMap) {
const script = document.createElement('script')
script.src = TIANDITU_JS_API_URL // 含浏览器端 Token
script.onload = () => createH5Map(lat, lng)
document.head.appendChild(script)
} else {
createH5Map(lat, lng)
}
}
// 2. 创建地图并绑定事件
function createH5Map(lat, lng) {
h5Map = new TMap.Map('tianditu-container')
h5Map.centerAndZoom(new TMap.LngLat(lng, lat), 15)
// 拖拽结束 → 取中心坐标(WGS-84,无需转换)→ 逆地理编码
h5Map.addEventListener('moveend', () => {
const center = h5Map.getCenter()
updateSelected(center.getLng(), center.getLat())
})
// 点击 → 平移 + 逆地理编码
h5Map.addEventListener('click', (e) => {
h5Map.panTo(new TMap.LngLat(lng, lat))
updateSelected(e.lnglat.getLng(), e.lnglat.getLat())
})
}
5.3 要点
- H5 天地图使用 WGS-84 坐标系,与 hook 中
centerLat/centerLng语义一致,无需坐标系转换 - 使用"中心定位针"交互模式:地图拖动时针不动,取地图中心坐标即为选中点
- 可通过
h5GetMapBound()获取当前视野范围,传给搜索接口优化结果精准度
6. APP 端实现(renderjs 桥接)
参考文件:index.vue 中 #ifdef APP-PLUS 分支
6.1 为什么需要 renderjs
APP 端的 WebView 分为逻辑层(运行 Vue 逻辑)和视图层(运行 DOM 渲染)。
天地图 JS API 需要操作 DOM,只能在视图层运行。UniApp 的 renderjs 正是为此设计的——它在视图层运行,可以直接操作 DOM,并通过 callMethod 与逻辑层通信。
6.2 通信架构
┌─ 逻辑层 (<script setup>) ──────────┐ ┌─ 视图层 (renderjs) ────────┐
│ │ │ │
│ mapRenderProp ─── change:prop ───→ │ │ propChanged() 分发指令 │
│ { type: 'init', lat, lng, tk } │ │ ├── initMap() 加载JS API │
│ { type: 'moveTo', lat, lng } │ │ └── moveToLocation() │
│ │ │ │
│ ←── callMethod('handleMapMoveEnd') ─┤ │ map.addEventListener( │
│ ←── callMethod('handleMapReady') ───┤ │ 'moveend', callback) │
│ │ │ │
└──────────────────────────────────────┘ └─────────────────────────────┘
6.3 桥接变量方案
callMethod 只能调用选项式 methods,无法访问 <script setup> 闭包。解决方案:
// 模块级桥接变量
let __mapMoveEndBridge: ((data) => void) | null = null
let __mapReadyBridge: (() => void) | null = null
// 选项式 methods 做转发
export default {
methods: {
handleMapMoveEnd(data) { __mapMoveEndBridge?.(data) },
handleMapReady() { __mapReadyBridge?.() },
},
}
// setup 侧赋值真正的回调
__mapMoveEndBridge = (data) => {
updateSelected(data.lng, data.lat)
}
__mapReadyBridge = () => {
updateSelected(wgsLng, wgsLat)
__mapReadyBridge = null // 一次性回调,用完清除
}
6.4 ⚠️ 关键坑:首次请求 403
问题:APP-PLUS 端天地图浏览器端 Key 依赖 JS API 加载后建立的会话 Cookie 鉴权。如果在天地图 JS 加载完成前发起 REST 请求(如逆地理编码),会返回 403: 不支持的key类型。
解决方案:延迟首次逆地理编码到 handleMapReady 回调后执行:
// 等待 renderjs 中天地图 JS 加载完成
__mapReadyBridge = () => {
updateSelected(wgsLng, wgsLat) // 此时才安全发起 REST 请求
__mapReadyBridge = null
}
7. 微信小程序端实现
参考文件:index.vue 中 #ifdef MP-WEIXIN 分支
7.1 实现方案
使用微信小程序原生 <map> 组件,该组件坐标系为 GCJ-02。
7.2 坐标系转换
所有与 hook 的数据交互都需要双向转换:
hook → 微信:WGS-84 → GCJ-02
- 定位坐标 → map 组件的 latitude/longitude
- POI 坐标 → moveTo 目标坐标
- selectedLocation → markers 渲染坐标
微信 → hook:GCJ-02 → WGS-84
- regionchange 返回的中心坐标
- tap 返回的点击坐标
7.3 核心代码解析
<template>
<map
id="mp-map"
:latitude="centerLat" <!-- GCJ-02 纬度 -->
:longitude="centerLng" <!-- GCJ-02 经度 -->
:markers="mpMarkers" <!-- GCJ-02 markers -->
:show-location="true"
@regionchange="mpOnRegionChange"
@tap="mpOnMapTap"
/>
</template>
// Markers 计算:WGS-84 → GCJ-02
const mpMarkers = computed(() => {
if (!selectedLocation.value.latitude) return []
const gcj = wgs84ToGcj02(selectedLocation.value.longitude, selectedLocation.value.latitude)
return [{ id: 1, latitude: gcj.lat, longitude: gcj.lng, ... }]
})
// 拖拽结束:GCJ-02 → WGS-84 → 逆地理编码
function mpOnRegionChange(e) {
if (e.type === 'end' && e.causedBy === 'gesture') {
mpMapCtx.value?.getCenterLocation({
success: (res) => {
centerLat.value = res.latitude // GCJ-02
centerLng.value = res.longitude
const wgs = gcj02ToWgs84(res.longitude, res.latitude)
updateSelected(wgs.lng, wgs.lat) // WGS-84
},
})
}
}
// 程序化移动:WGS-84 → GCJ-02
function mpMoveTo(wgsLat, wgsLng) {
const gcj = wgs84ToGcj02(wgsLng, wgsLat)
centerLat.value = gcj.lat
centerLng.value = gcj.lng
mpMapCtx.value?.moveToLocation({ latitude: gcj.lat, longitude: gcj.lng })
}
7.4 要点
- 微信
getCenterLocation返回 GCJ-02,tap事件的e.detail也是 GCJ-02 - 必须在
nextTick中创建uni.createMapContext,确保组件已挂载 regionchange事件中e.type === 'end' && e.causedBy === 'gesture'过滤出用户拖拽结束
8. 鸿蒙端实现(花瓣地图原生组件)
参考文件:index.vue 中 #ifdef APP-HARMONY 分支 + map.ets
8.1 实现方案
鸿蒙端使用华为 MapKit(花瓣地图)原生组件,通过 UTS 插件以 <embed tag="map"> 方式嵌入 Vue 页面。
8.2 UTS 插件架构
┌─ Vue 层 ──────────────────────────┐
│ <embed tag="map" :options="..." │
│ @mapclick="..." │
│ @camerapositionchange="..." │
│ /> │
└──────────┬────────────────────────┘
│ defineNativeEmbed('map')
┌──────────▼────────────────────────┐
│ map.ets (UTS 插件) │
│ HuaweiMapComponent │
│ ├── MapComponent (华为 MapKit) │
│ ├── 事件监听 (mapClick等) │
│ └── 稳定性轮询 (300ms) │
└───────────────────────────────────┘
8.3 坐标系转换
花瓣地图使用 GCJ-02 坐标系,与微信小程序同理,需双向转换:
天地图 → 花瓣地图:WGS-84 → GCJ-02(移动地图、渲染标记)
花瓣地图 → 天地图:GCJ-02 → WGS-84(逆地理编码、存储坐标)
8.4 ⚠️ 关键坑:无 cameraPositionChangeEnd 事件
华为 MapKit API 不提供 cameraPositionChangeEnd 事件(即拖拽结束回调),只有持续的 cameraPositionChange。
解决方案:稳定性轮询——每 300ms 读取相机位置,连续两次不变则判定为拖拽结束:
// map.ets 中
this.pollingTimer = setInterval(() => {
let cameraPosition = this.mapController.getCameraPosition()
let lat = cameraPosition.target.latitude
let lng = cameraPosition.target.longitude
let latChanged = Math.abs(lat - this.lastCameraLat) > 0.000001
let lngChanged = Math.abs(lng - this.lastCameraLng) > 0.000001
if (latChanged || lngChanged) {
// 还在移动中
this.lastCameraLat = lat
this.lastCameraLng = lng
this.isCameraStable = false
} else if (!this.isCameraStable) {
// 连续两次不变 → 拖拽结束,通知 Vue 层
this.isCameraStable = true
this.onCameraPositionChange?.({ type: 'camerapositionchange', detail: { latitude: lat, longitude: lng } })
}
}, 300)
8.5 程序化移动(moveToTimestamp 模式)
UTS 组件属性变化不会自动触发行为,需通过 @Watch 装饰器手动监听:
// map.ets 中
@Prop @Watch('onMoveToTimestampChange') moveToTs: number = 0
onMoveToTimestampChange(): void {
if (this.moveToTs > 0 && this.mapController) {
this.isCameraStable = false // 防止轮询误判
let target = { latitude: this.moveToLat, longitude: this.moveToLng }
let cameraUpdate = map.newLatLng(target)
this.mapController.moveCamera(cameraUpdate)
}
}
Vue 侧通过更新 moveToTimestamp 触发移动:
function harmonyMoveTo(wgsLat, wgsLng) {
const gcj = wgs84ToGcj02(wgsLng, wgsLat)
centerLat.value = gcj.lat
centerLng.value = gcj.lng
lastGeocodeTs = Date.now() // 防止 moveCamera 触发重复逆地理编码
harmonyMoveToTs.value = Date.now() // 变化触发 @Watch → moveCamera
}
8.6 防抖处理
mapclick 和 camerapositionchange 可能在短时间内同时触发(点击地图会先触发 click,随后相机移动触发 camera change),需防抖:
let lastGeocodeTs = 0
const GEOCODE_DEBOUNCE = 400 // 400ms 防抖窗口
function harmonyUpdateSelected(gcjLng, gcjLat) {
const now = Date.now()
if (now - lastGeocodeTs < GEOCODE_DEBOUNCE) return
lastGeocodeTs = now
// GCJ-02 → WGS-84 → 逆地理编码
}
8.7 地图点击自动移动相机
花瓣地图的 mapClick 事件只返回坐标,不自动移动相机。在 .ets 层手动处理:
this.mapEventManager.on('mapClick', (latLng) => {
// 在 .ets 内部直接移动相机到点击位置
if (this.mapController) {
let target = { latitude: latLng.latitude, longitude: latLng.longitude }
let cameraUpdate = map.newLatLng(target)
this.mapController.moveCamera(cameraUpdate)
}
// 通知 Vue 侧
this.onMapClick?.({ type: 'mapclick', detail: { ...latLng } })
})
9. POI 搜索与逆地理编码
9.1 天地图搜索 API
本项目的 POI 搜索和逆地理编码均使用天地图 REST API,不依赖任何第三方地图 SDK。
逆地理编码(坐标 → 地址):
GET https://api.tianditu.gov.cn/geocoder
?postStr={"lon":116.404,"lat":39.915,"ver":1}
&type=geocode
&tk=<TIANDITU_KEY>
POI 搜索(关键词 → 地点列表):
GET https://api.tianditu.gov.cn/v2/search
?postStr={"keyWord":"天安门","level":"12","mapBound":"73.66,3.86,135.05,53.55","queryType":"1","start":"0","count":"20"}
&type=query
&tk=<TIANDITU_KEY>
9.2 搜索结果类型处理
天地图搜索 API 根据结果类型返回不同结构,需分别处理:
| resultType | 含义 | 数据位置 | 处理方式 |
|---|---|---|---|
| 1 | POI 列表 | pois[] 或 result.data[] |
直接展示 |
| 2 | 当前范围无结果,按城市统计 | statistics.priorityCitys[] |
显示城市 + 计数,点击重新搜索 |
| 3 | 行政区划 | area 对象 |
展示行政区信息 |
9.3 搜索策略
- 联想搜索(
suggestPoi):用户输入过程中触发,10 条结果,轻量快速 - 确认搜索(
searchPoi):用户按下搜索键时触发,20 条结果,更完整 - 搜索范围:默认中国全境(
73.66,3.86,135.05,53.55),H5 端可传入当前地图视野范围 - 城市统计项:当 key 以
city_开头时,点击会以该城市为中心(15km 半径)重新搜索
9.4 防抖机制
function onSearchInput(value, mapBound?) {
keyword.value = value
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
suggestPoi(value, mapBound) // 300ms 后发起联想搜索
}, debounceMs)
}
10. 选点结果输出
10.1 eventChannel 通信模式
地图选点页面通过 uni.navigateTo 的 events 机制与调用页面通信:
// 调用方(如 test.vue)
uni.navigateTo({
url: '/pages-sub/map/index',
events: {
selectLocation: (data: MapPickerResult) => {
// data 包含 name, address, latitude, longitude, gcj02Latitude, gcj02Longitude
console.log('选点结果:', data)
},
},
})
// 选点页面(index.vue)
function confirmLocation() {
const eventChannel = instance?.proxy?.getOpenerEventChannel?.()
if (eventChannel) {
eventChannel.emit('selectLocation', getResult())
}
uni.navigateBack()
}
10.2 结果数据结构
interface MapPickerResult {
name: string // "天安门"
address: string // "北京市东城区东长安街"
latitude: number // 39.915 (WGS-84)
longitude: number // 116.404 (WGS-84)
gcj02Latitude: number // 39.921 (GCJ-02)
gcj02Longitude: number // 116.411 (GCJ-02)
}
为什么输出双坐标系?
- WGS-84:通用存储格式,传给后端、天地图 API 等无偏移场景
- GCJ-02:传给微信小程序/鸿蒙地图渲染,无需再转换
11. 踩坑记录与最佳实践
11.1 APP-PLUS 天地图首次请求 403
现象:APP-PLUS 端首次逆地理编码请求返回 403,提示"不支持的key类型"。
原因:浏览器端 Key 依赖天地图 JS API 加载后建立的会话 Cookie 鉴权。在 renderjs 中加载天地图 JS 之前发起 REST 请求,缺少 Cookie 上下文。
解决:延迟首次逆地理编码到 handleMapReady 回调后执行。
11.2 鸿蒙花瓣地图无拖拽结束事件
现象:华为 MapKit 没有 cameraPositionChangeEnd 事件,只有持续的 cameraPositionChange。
解决:300ms 稳定性轮询,连续两次位置不变则判定拖拽结束。
11.3 renderjs callMethod 无法访问 setup 闭包
现象:$ownerInstance.callMethod() 只能调用选项式 methods,无法访问 <script setup> 中的函数。
解决:模块级桥接变量,setup 侧赋值回调,methods 侧转发调用。
11.4 鸿蒙 mapclick 和 camerapositionchange 重复触发
现象:点击地图时,mapclick 和 camerapositionchange 短时间内都触发,导致逆地理编码请求两次。
解决:400ms 防抖窗口,同一时间段内只取最后一次。
11.5 微信小程序 map 组件坐标系
现象:微信 map 组件使用 GCJ-02,但天地图 API 使用 WGS-84,混用导致偏移。
解决:严格遵循坐标语义约定,hook 内部统一 WGS-84,视图层负责双向转换。
11.6 最佳实践总结
| 实践 | 说明 |
|---|---|
| 统一坐标系约定 | hook 内部始终 WGS-84,视图层负责适配 |
| 逻辑-视图分层 | 业务逻辑在 hook,平台差异在视图层 |
| 双坐标系输出 | 结果同时返回 WGS-84 和 GCJ-02 |
| 防抖/节流 | 搜索输入 300ms 防抖,鸿蒙逆地理编码 400ms 防抖 |
| 条件编译物理隔离 | 各平台代码块互不干扰,减少运行时判断 |
| 兜底策略 | 定位失败回退北京坐标,逆地理编码失败显示坐标字符串 |
12. 完整项目源码
以下为地图选点功能涉及的全部源文件,天地图 API Token 已脱敏(
<YOUR_TIANDITU_KEY>),请前往 天地图开发者平台 申请后替换。
12.1 坐标系转换工具 — src/utils/coordTransform.ts
/**
* 坐标系转换工具
*
* 本模块实现 WGS-84 与 GCJ-02 两种坐标系的双向转换。
* 这是中国地图开发中最基础也最关键的适配层——
* 若忽略坐标系差异,位置偏移可达 100~600 米。
*
* ──────────────────────────────────────────────
* 坐标系速查表:
* ──────────────────────────────────────────────
* WGS-84 GPS 原始坐标,国际通用标准
* → 天地图 API、uni.getLocation({ type: 'wgs84' })、后端存储
*
* GCJ-02 国测局加密坐标(俗称"火星坐标"),中国法定偏移标准
* → 微信小程序 map 组件、高德地图、腾讯地图、华为花瓣地图
*
* CGCS2000 中国大地坐标2000系,与 WGS-84 偏差 <1m,可等价使用
* → 天地图底图和 API 实质使用此坐标系
* ──────────────────────────────────────────────
*
* 算法原理:
* 基于 Krasovsky 1940 椭球参数,对经纬度施加非线性偏移。
* GCJ-02 → WGS-02 为近似逆运算(误差 <0.5m),满足业务精度要求。
* 境外坐标不走偏移,直接原值返回。
*/
// Krasovsky 1940 椭球参数
const PI = Math.PI
const A = 6378245.0 // 长半轴(米)
const EE = 0.00669342162296594323 // 第一偏心率的平方
/**
* 判定坐标是否在中国境内
* 境外坐标无需加密偏移,直接使用原值
*/
function outOfChina(lng: number, lat: number): boolean {
return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271
}
/**
* 纬度偏移量计算
* 输入:经度 - 105°,纬度 - 35°(归一化基准点)
*/
function transformLat(lng: number, lat: number): number {
let ret =
-100.0 +
2.0 * lng +
3.0 * lat +
0.2 * lat * lat +
0.1 * lng * lat +
0.2 * Math.sqrt(Math.abs(lng))
ret += ((20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0) / 3.0
ret += ((20.0 * Math.sin(lat * PI) + 40.0 * Math.sin((lat / 3.0) * PI)) * 2.0) / 3.0
ret +=
((160.0 * Math.sin((lat / 12.0) * PI) + 320 * Math.sin((lat * PI) / 30.0)) * 2.0) / 3.0
return ret
}
/**
* 经度偏移量计算
* 输入:经度 - 105°,纬度 - 35°(归一化基准点)
*/
function transformLng(lng: number, lat: number): number {
let ret =
300.0 +
lng +
2.0 * lat +
0.1 * lng * lng +
0.1 * lng * lat +
0.1 * Math.sqrt(Math.abs(lng))
ret += ((20.0 * Math.sin(6.0 * lng * PI) + 20.0 * Math.sin(2.0 * lng * PI)) * 2.0) / 3.0
ret += ((20.0 * Math.sin(lng * PI) + 40.0 * Math.sin((lng / 3.0) * PI)) * 2.0) / 3.0
ret +=
((150.0 * Math.sin((lng / 12.0) * PI) + 300.0 * Math.sin((lng / 30.0) * PI)) * 2.0) /
3.0
return ret
}
/**
* WGS-84 → GCJ-02
*
* 将 GPS 原始坐标转换为国产地图所需的加密坐标。
* 典型调用场景:
* - 拿到 uni.getLocation({ type: 'wgs84' }) 的定位后,转为 GCJ-02 传入微信 map 组件
* - 拿到天地图 POI 的 WGS-84 坐标后,转为 GCJ-02 传入花瓣地图
*
* @param wgsLng WGS-84 经度
* @param wgsLat WGS-84 纬度
* @returns GCJ-02 坐标 { lng, lat }
*/
export function wgs84ToGcj02(wgsLng: number, wgsLat: number): { lng: number; lat: number } {
if (outOfChina(wgsLng, wgsLat)) {
return { lng: wgsLng, lat: wgsLat }
}
let dlat = transformLat(wgsLng - 105.0, wgsLat - 35.0)
let dlng = transformLng(wgsLng - 105.0, wgsLat - 35.0)
const radlat = (wgsLat / 180.0) * PI
let magic = Math.sin(radlat)
magic = 1 - EE * magic * magic
const sqrtmagic = Math.sqrt(magic)
dlat = (dlat * 180.0) / (((A * (1 - EE)) / (magic * sqrtmagic)) * PI)
dlng = (dlng * 180.0) / ((A / sqrtmagic) * Math.cos(radlat) * PI)
return { lng: wgsLng + dlng, lat: wgsLat + dlat }
}
/**
* GCJ-02 → WGS-84(近似逆运算)
*
* 将国产地图加密坐标还原为 GPS 标准坐标。
* 典型调用场景:
* - 微信 map 的 regionchange 回调返回 GCJ-02,转为 WGS-84 后调天地图逆地理编码
* - 花瓣地图的 camerapositionchange 回调返回 GCJ-02,同理需转为 WGS-84
*
* 精度说明:结果与精确迭代法偏差 <0.5m,满足业务场景需求。
*
* @param gcjLng GCJ-02 经度
* @param gcjLat GCJ-02 纬度
* @returns WGS-84 坐标 { lng, lat }
*/
export function gcj02ToWgs84(gcjLng: number, gcjLat: number): { lng: number; lat: number } {
if (outOfChina(gcjLng, gcjLat)) {
return { lng: gcjLng, lat: gcjLat }
}
let dlat = transformLat(gcjLng - 105.0, gcjLat - 35.0)
let dlng = transformLng(gcjLng - 105.0, gcjLat - 35.0)
const radlat = (gcjLat / 180.0) * PI
let magic = Math.sin(radlat)
magic = 1 - EE * magic * magic
const sqrtmagic = Math.sqrt(magic)
dlat = (dlat * 180.0) / (((A * (1 - EE)) / (magic * sqrtmagic)) * PI)
dlng = (dlng * 180.0) / ((A / sqrtmagic) * Math.cos(radlat) * PI)
return { lng: gcjLng - dlng, lat: gcjLat - dlat }
}
12.2 地图服务配置 — src/utils/mapConfig.ts
/**
* 地图服务配置
*
* 集中管理天地图(Tianditu)的 API 凭证和接口地址。
* 所有地图相关模块统一从此处导入,避免硬编码散落在各文件中。
*
* 天地图开发者平台:https://cloudcenter.tianditu.gov.cn/center/development/myApp
* - 浏览器端 Key:用于 JS API 加载和前端 REST 请求(依赖浏览器 Cookie 会话鉴权)
* - 服务端 Key:用于后端直接调用 REST API(无会话依赖,适合 Node/云函数)
*/
/** 天地图 API Token — 浏览器端(JS API + 前端 REST 请求) */
export const TIANDITU_KEY = '<YOUR_TIANDITU_BROWSER_KEY>'
/** 天地图 API Token — 服务端(后端 REST 请求,暂未使用,预留扩展) */
export const TIANDITU_SERVER_KEY = '<YOUR_TIANDITU_SERVER_KEY>'
/** 天地图逆地理编码接口 — 坐标 → 地址 */
export const TIANDITU_GEOCODER_URL = 'https://api.tianditu.gov.cn/geocoder'
/** 天地图搜索接口 — POI检索 / 建议搜索 / 周边搜索 */
export const TIANDITU_SEARCH_URL = 'https://api.tianditu.gov.cn/v2/search'
/** 天地图 JS API 4.0 加载地址(含浏览器端 Token) */
export const TIANDITU_JS_API_URL = `https://api.tianditu.gov.cn/api?v=4.0&tk=${TIANDITU_KEY}`
12.3 核心逻辑 Composable — src/hooks/useMapPicker.ts
/**
* 地图选点核心逻辑 composable
*
* ═══════════════════════════════════════════════════════════════
* 设计目标:将业务逻辑与各平台视图层完全解耦
* ═══════════════════════════════════════════════════════════════
* 本 hook 不依赖任何特定地图组件(天地图 JS / 微信 map / 花瓣地图),
* 只负责:定位、逆地理编码、POI 搜索、选中状态管理、结果输出。
* 各平台视图层(index.vue)通过导入本 hook 的函数并传入坐标来驱动交互。
*
* ──────────────────────────────────────────────
* 坐标语义约定(务必遵守):
* ──────────────────────────────────────────────
* centerLat / centerLng — 地图中心点的「显示坐标」
* ├── H5 / APP-PLUS → WGS-84(天地图 JS 直接渲染)
* ├── MP-WEIXIN → GCJ-02(微信 map 原生组件)
* └── APP-HARMONY → GCJ-02(花瓣地图原生组件)
*
* selectedLocation — 选中的位置信息,坐标字段始终为 WGS-84
* └── 所有平台调用 updateSelected() 前必须将坐标转为 WGS-84
*
* 天地图 API — 输入输出均为 WGS-84 ≈ CGCS2000
* ──────────────────────────────────────────────
*/
import { ref, computed } from 'vue'
import { TIANDITU_KEY, TIANDITU_GEOCODER_URL, TIANDITU_SEARCH_URL } from '@/utils/mapConfig'
import { wgs84ToGcj02 } from '@/utils/coordTransform'
// ══════════════════════════════════════════════════
// 类型定义
// ══════════════════════════════════════════════════
/** 选中的位置信息(坐标字段统一为 WGS-84) */
export interface LocationInfo {
/** 地点名称(如"天安门") */
name: string
/** 完整地址(如"北京市东城区东长安街") */
address: string
/** 纬度(WGS-84) */
latitude: number
/** 经度(WGS-84) */
longitude: number
/** 省 */
province?: string
/** 市 */
city?: string
/** 区/县 */
district?: string
}
/** POI 搜索结果项(天地图返回的坐标为 WGS-84) */
export interface PoiItem {
/** 唯一标识(用于列表渲染 key 和城市统计项判断前缀) */
key: string
/** 地点名称 */
name: string
/** 地址 */
address: string
/** 纬度(WGS-84) */
latitude: number
/** 经度(WGS-84) */
longitude: number
}
/** 地图选点最终返回结果(含双坐标系,方便调用方按需取用) */
export interface MapPickerResult {
/** 地点名称 */
name: string
/** 完整地址 */
address: string
/** WGS-84 纬度 — 天地图/后端存储/GPS 通用 */
latitude: number
/** WGS-84 经度 */
longitude: number
/** GCJ-02 纬度 — 供微信小程序/鸿蒙地图渲染用 */
gcj02Latitude: number
/** GCJ-02 经度 */
gcj02Longitude: number
}
// ══════════════════════════════════════════════════
// Composable 主函数
// ══════════════════════════════════════════════════
export function useMapPicker(debounceMs = 300) {
// ── 响应式状态 ──────────────────────────────
/** 地图中心纬度(显示坐标系,随平台不同) */
const centerLat = ref(0)
/** 地图中心经度(显示坐标系,随平台不同) */
const centerLng = ref(0)
/** 是否已完成首次定位 */
const located = ref(false)
/** 是否正在定位中 */
const locating = ref(false)
/** 当前选中的位置信息(坐标为 WGS-84) */
const selectedLocation = ref<LocationInfo>({
name: '',
address: '',
latitude: 0,
longitude: 0,
})
/** 搜索关键词 */
const keyword = ref('')
/** POI 搜索结果列表 */
const poiList = ref<PoiItem[]>([])
/** 搜索加载态 */
const searchLoading = ref(false)
/** 是否显示 POI 结果列表 */
const showPoiList = ref(false)
/** 搜索防抖定时器 */
let searchTimer: ReturnType<typeof setTimeout> | null = null
// ── 计算属性 ────────────────────────────────
/** 是否已有有效选中(以地址非空为判断依据) */
const hasSelection = computed(() => !!selectedLocation.value.address)
// ══════════════════════════════════════════════
// 定位模块
// ══════════════════════════════════════════════
/**
* 获取当前位置的 WGS-84 坐标
*
* 调用 uni.getLocation 并指定 type: 'wgs84',确保返回 GPS 原始坐标。
* 开启 isHighAccuracy 可融合 GPS + Wi-Fi + 基站,在开阔区域可达米级精度。
* highAccuracyExpireTime 设置 5 秒超时,避免弱信号下无限等待。
*/
function getCurrentLocation(): Promise<{ latitude: number; longitude: number }> {
return new Promise((resolve, reject) => {
uni.getLocation({
type: 'wgs84',
isHighAccuracy: true,
highAccuracyExpireTime: 5000,
success: (res) => {
resolve({ latitude: res.latitude, longitude: res.longitude })
},
fail: (err) => {
console.warn('[useMapPicker] 定位失败:', err.errMsg)
reject(err)
},
})
})
}
/**
* 初始化定位
*
* 尝试获取当前位置,成功后将 WGS-84 坐标存入 centerLat/centerLng。
* 各平台在 onLoad 中会按需将此 WGS-84 坐标转为对应的显示坐标系。
* 定位失败时回退到北京天安门坐标(39.915, 116.404)。
*/
async function initLocation() {
locating.value = true
try {
const pos = await getCurrentLocation()
centerLat.value = pos.latitude
centerLng.value = pos.longitude
located.value = true
} catch {
// 定位失败 → 默认北京天安门
centerLat.value = 39.915
centerLng.value = 116.404
located.value = false
} finally {
locating.value = false
}
}
// ══════════════════════════════════════════════
// 逆地理编码模块
// ══════════════════════════════════════════════
/**
* 根据经纬度获取地址信息(逆地理编码)
*
* 调用天地图 geocoder REST API,将 WGS-84 坐标转为结构化地址。
* 返回的 LocationInfo 中坐标字段原样保留输入值。
* 请求失败时返回坐标字符串作为兜底地址。
*
* @param lng WGS-84 经度
* @param lat WGS-84 纬度
*/
async function reverseGeocode(lng: number, lat: number): Promise<LocationInfo> {
try {
const res = await new Promise<any>((resolve, reject) => {
uni.request({
url: TIANDITU_GEOCODER_URL,
data: {
postStr: JSON.stringify({ lon: lng, lat, ver: 1 }),
type: 'geocode',
tk: TIANDITU_KEY,
},
success: (r) => resolve(r),
fail: (e) => reject(e),
})
})
// status === '0' 表示天地图接口调用成功
if (res.statusCode === 200 && res.data?.status === '0') {
const result = res.data.result
const comp = result.addressComponent || {}
return {
name: comp.poiName || result.formatted_address || '',
address: result.formatted_address || '',
latitude: lat,
longitude: lng,
province: comp.province || '',
city: comp.city || '',
district: comp.district || '',
}
}
} catch (e) {
console.warn('[useMapPicker] 逆地理编码失败:', e)
}
// 兜底:返回坐标字符串作为地址
return {
name: '',
address: `${lng.toFixed(6)}, ${lat.toFixed(6)}`,
latitude: lat,
longitude: lng,
}
}
/**
* 更新选中位置并触发逆地理编码
*
* 此函数是各平台视图层与业务逻辑层的关键桥梁。
* 调用方必须在传入坐标前完成坐标系转换,确保参数为 WGS-84。
*
* @param wgsLng WGS-84 经度
* @param wgsLat WGS-84 纬度
*/
async function updateSelected(wgsLng: number, wgsLat: number) {
// 先清空旧数据,让 UI 进入加载态
selectedLocation.value = { name: '', address: '', latitude: wgsLat, longitude: wgsLng }
const info = await reverseGeocode(wgsLng, wgsLat)
// 保留原始坐标,用逆地理编码结果填充名称和地址
selectedLocation.value = { ...info, latitude: wgsLat, longitude: wgsLng }
}
// ══════════════════════════════════════════════
// POI 搜索模块
// ══════════════════════════════════════════════
/**
* 解析天地图搜索 API 的响应数据
*
* 天地图搜索接口根据结果类型返回不同结构,需分别处理:
*
* | resultType | 含义 | 数据位置 |
* |------------|--------------------|-------------------------------|
* | 1 | POI 列表 | pois[] 或 result.data[] |
* | 2 | 当前范围无结果 | statistics.priorityCitys[] |
* | | → 按城市统计摘要 | statistics.allAdmins[] |
* | 3 | 行政区划 | area 对象 |
*
* 天地图 POI 坐标字段为 lonlat("lng,lat" 格式),坐标系为 WGS-84
*/
function parseSearchResult(resData: any): PoiItem[] {
if (resData?.status?.infocode !== 1000) return []
const resultType = resData.resultType
// resultType=1:POI 列表(常规搜索结果)
if (resultType === 1) {
const data = resData.pois || resData.result?.data || []
return data.map((item: any) => {
const [lng, lat] = (item.lonlat || '').split(',').map(Number)
return {
key: item.hotPointID || item.uid || item.lonlat || `${lng}_${lat}`,
name: item.name || '',
address: item.address || item.adminName || '',
latitude: lat || Number(item.lat) || 0,
longitude: lng || Number(item.lon) || 0,
}
})
}
// resultType=2:当前 mapBound 内无结果,返回按城市统计
// 用户点击某城市后,会以该城市为中心重新搜索
if (resultType === 2 && resData.statistics) {
const cities = resData.statistics.priorityCitys || resData.statistics.allAdmins || []
return cities.map((item: any) => {
const [lng, lat] = (item.lonlat || '').split(',').map(Number)
return {
key: `city_${item.adminCode || item.lonlat}`,
name: `${item.adminName}(${item.count}个结果)`,
address: `点击在${item.adminName}中搜索`,
latitude: lat,
longitude: lng,
}
})
}
// resultType=3:行政区划(如搜索"福州市"直接匹配到行政区)
if (resultType === 3 && resData.area) {
const area = resData.area
const [lng, lat] = (area.lonlat || '').split(',').map(Number)
if (lng && lat) {
return [
{
key: `area_${area.adminCode || area.lonlat}`,
name: area.name || '',
address: area.name || '',
latitude: lat,
longitude: lng,
},
]
}
}
return []
}
/**
* POI 关键词搜索(完整结果)
*
* 使用 queryType=1 普通检索,返回最多 20 条结果。
* 搜索范围默认覆盖中国全境(CHINA_BOUNDS),避免因当前视野过小搜不到远距离地点。
*
* @param kw 搜索关键词
* @param mapBound 搜索范围经纬度边界(可选,默认中国全境)
*/
async function searchPoi(kw: string, mapBound?: string) {
if (!kw.trim()) {
poiList.value = []
showPoiList.value = false
return
}
searchLoading.value = true
showPoiList.value = true
const bound = mapBound || CHINA_BOUNDS
try {
const res = await new Promise<any>((resolve, reject) => {
uni.request({
url: TIANDITU_SEARCH_URL,
data: {
postStr: JSON.stringify({
keyWord: kw,
level: '12',
mapBound: bound,
queryType: '1',
start: '0',
count: '20',
}),
type: 'query',
tk: TIANDITU_KEY,
},
success: (r) => resolve(r),
fail: (e) => reject(e),
})
})
if (res.statusCode === 200 && res.data?.status?.infocode === 1000) {
poiList.value = parseSearchResult(res.data)
} else {
poiList.value = []
}
} catch (e) {
console.warn('[useMapPicker] POI搜索失败:', e)
poiList.value = []
} finally {
searchLoading.value = false
}
}
/**
* POI 搜索建议(联想词)
*
* 与 searchPoi 共用同一接口,但仅请求 10 条,
* 用于用户输入过程中的即时联想,提供更轻量的响应。
*
* @param kw 搜索关键词
* @param mapBound 搜索范围经纬度边界(可选,默认中国全境)
*/
async function suggestPoi(kw: string, mapBound?: string) {
if (!kw.trim()) {
poiList.value = []
showPoiList.value = false
return
}
const bound = mapBound || CHINA_BOUNDS
try {
const res = await new Promise<any>((resolve, reject) => {
uni.request({
url: TIANDITU_SEARCH_URL,
data: {
postStr: JSON.stringify({
keyWord: kw,
level: '12',
mapBound: bound,
queryType: '1',
start: '0',
count: '10',
}),
type: 'query',
tk: TIANDITU_KEY,
},
success: (r) => resolve(r),
fail: (e) => reject(e),
})
})
if (res.statusCode === 200 && res.data?.status?.infocode === 1000) {
poiList.value = parseSearchResult(res.data)
showPoiList.value = poiList.value.length > 0
}
} catch (e) {
console.warn('[useMapPicker] 建议搜索失败:', e)
}
}
/**
* 带防抖的搜索输入处理
*
* 用户每输入一个字符都会触发,通过 debounceMs(默认 300ms)延迟发送请求,
* 避免高频调用搜索 API。
*
* @param value 当前输入值
* @param mapBound 搜索范围经纬度边界(可选,仅 H5 端传入当前视野范围)
*/
function onSearchInput(value: string, mapBound?: string) {
keyword.value = value
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
suggestPoi(value, mapBound)
}, debounceMs)
}
/**
* 回车确认搜索
*
* 用户按下键盘搜索键时触发,清除防抖等待,立即发起更精确的 20 条搜索。
*
* @param mapBound 搜索范围经纬度边界(可选,仅 H5 端传入当前视野范围)
*/
function onSearchConfirm(mapBound?: string) {
if (searchTimer) clearTimeout(searchTimer)
searchPoi(keyword.value, mapBound)
}
/** 清空搜索关键词和结果,恢复初始状态 */
function clearSearch() {
keyword.value = ''
poiList.value = []
showPoiList.value = false
}
/**
* 选中某条 POI 搜索结果
*
* 更新 selectedLocation 并移动地图中心。
* 特殊处理:如果 key 以 "city_" 开头(城市统计项),
* 则以该城市为中心重新搜索,展开该城市下的 POI 结果。
*
* @param item POI 搜索结果项(坐标为 WGS-84)
*/
async function selectPoi(item: PoiItem) {
// 先更新选中位置(WGS-84)
selectedLocation.value = {
name: item.name,
address: item.address,
latitude: item.latitude,
longitude: item.longitude,
}
// 临时写入 WGS-84 到中心坐标,
// 各平台 handleSelectPoi 会覆盖为对应显示坐标系
centerLat.value = item.latitude
centerLng.value = item.longitude
showPoiList.value = false
// 城市统计项 → 移动到该城市后自动重新搜索
if (item.key.startsWith('city_')) {
const cityBound = estimateMapBounds(item.longitude, item.latitude, 15)
searchPoi(keyword.value, cityBound)
return
}
// POI 地址为空时,通过逆地理编码补充
if (!item.address && item.latitude && item.longitude) {
const info = await reverseGeocode(item.longitude, item.latitude)
selectedLocation.value = { ...selectedLocation.value, ...info }
}
}
// ══════════════════════════════════════════════
// 结果输出模块
// ══════════════════════════════════════════════
/**
* 获取最终选点结果
*
* 将 selectedLocation(WGS-84)转换为双坐标系的 MapPickerResult。
* 调用方通过 eventChannel.emit('selectLocation', getResult()) 传出。
*/
function getResult(): MapPickerResult {
const gcj = wgs84ToGcj02(selectedLocation.value.longitude, selectedLocation.value.latitude)
return {
name: selectedLocation.value.name,
address: selectedLocation.value.address,
latitude: selectedLocation.value.latitude,
longitude: selectedLocation.value.longitude,
gcj02Latitude: gcj.lat,
gcj02Longitude: gcj.lng,
}
}
// ── 统一导出 ────────────────────────────────
return {
// 状态
centerLat,
centerLng,
located,
locating,
selectedLocation,
keyword,
poiList,
searchLoading,
showPoiList,
hasSelection,
// 方法
initLocation,
getCurrentLocation,
updateSelected,
reverseGeocode,
onSearchInput,
onSearchConfirm,
clearSearch,
selectPoi,
getResult,
}
}
// ══════════════════════════════════════════════════
// 常量与工具函数
// ══════════════════════════════════════════════════
/** 中国全境经纬度范围,用于 POI 搜索时不限制区域 */
const CHINA_BOUNDS = '73.66,3.86,135.05,53.55'
/**
* 根据中心点和半径估算地图边界
*
* 将经纬度视为平面近似计算,适用于 15km 以内的小范围搜索。
* 纬度方向 1° ≈ 111km,经度方向 1° ≈ 111km / cos(lat)。
*
* @param lng 中心经度
* @param lat 中心纬度
* @param radiusKm 半径(千米)
* @returns "左下经度,左下纬度,右上经度,右上纬度" 格式的边界字符串
*/
function estimateMapBounds(lng: number, lat: number, radiusKm: number): string {
const dLat = radiusKm / 111
const dLng = (radiusKm / 111) * (1 / Math.cos((lat * Math.PI) / 180))
return `${(lng - dLng).toFixed(6)},${(lat - dLat).toFixed(6)},${(lng + dLng).toFixed(6)},${(lat + dLat).toFixed(6)}`
}
12.4 地图选点主页面 — src/pages-sub/map/index.vue
<route lang="json5">
{
style: {
navigationStyle: 'custom',
navigationBarTitleText: '选择位置',
},
}
</route>
<template>
<m-page>
<m-header title="选择位置" :hasBack="true"></m-header>
<m-body padding="0">
<view
class="map-picker"
:style="{
width: '100%',
height: `calc(100vh - ${rpxToPx(88)}px - ${safeAreaInsets?.top || 0}px)`,
}"
>
<!-- 搜索栏(内含搜索结果列表) -->
<SearchBar
:model-value="keyword"
:loading="searchLoading"
:poi-list="poiList"
:show-poi-list="showPoiList"
@update:model-value="(v: string) => (keyword = v)"
@search="handleSearchInput"
@clear="clearSearch"
@select="handleSelectPoi"
/>
<!-- ═══════ H5 端:天地图 JS API 直引模式 ═══════ -->
<!-- #ifdef H5 -->
<view id="tianditu-container" class="map-container" />
<view class="center-pin">
<image class="pin-icon" src="https://api.tianditu.gov.cn/img/map/markerA.png" mode="widthFix" />
</view>
<!-- #endif -->
<!-- ═══════ APP 端:renderjs 天地图桥接模式 ═══════ -->
<!-- #ifdef APP-PLUS -->
<view
id="tianditu-container"
class="map-container"
:change:prop="mapRender.propChanged"
:prop="mapRenderProp"
/>
<!-- #endif -->
<!-- ═══════ 微信小程序端:原生 map 组件 ═══════ -->
<!-- #ifdef MP-WEIXIN -->
<map
id="mp-map"
class="map-container"
:latitude="centerLat"
:longitude="centerLng"
:markers="mpMarkers"
:show-location="true"
@regionchange="mpOnRegionChange"
@tap="mpOnMapTap"
/>
<!-- #endif -->
<!-- ═══════ 鸿蒙端:花瓣地图原生组件 ═══════ -->
<!-- #ifdef APP-HARMONY -->
<embed
v-if="centerLat > 0"
class="map-container"
tag="map"
:options="harmonyMapOptions"
@mapclick="onHarmonyMapClick"
@camerapositionchange="onHarmonyCameraPositionChange"
/>
<view v-if="centerLat > 0" class="center-pin">
<image class="pin-icon" src="https://api.tianditu.gov.cn/img/map/markerA.png" mode="widthFix" />
</view>
<!-- #endif -->
<!-- 底部信息栏:展示选中位置 + 确认按钮 -->
<view class="bottom-bar">
<view v-if="hasSelection" class="selected-info">
<text class="selected-name">{{ selectedLocation.name || '当前位置' }}</text>
<text class="selected-address">{{ selectedLocation.address }}</text>
</view>
<view v-else class="selected-info">
<text class="selected-name loading-text">正在获取位置信息...</text>
</view>
<view class="confirm-btn" :class="{ disabled: !hasSelection }" @tap="confirmLocation">
<text>确定选择</text>
</view>
</view>
</view>
</m-body>
</m-page>
</template>
<!-- ════════ renderjs 回调桥接(选项式 API) ════════ -->
<script lang="ts">
let __mapMoveEndBridge: ((data: { lat: number; lng: number }) => void) | null = null
let __mapReadyBridge: (() => void) | null = null
export default {
methods: {
/** 拖拽结束回调 — 转发给 setup 侧 */
handleMapMoveEnd(data: { lat: number; lng: number }) {
__mapMoveEndBridge?.(data)
},
/** 天地图 JS API 加载就绪回调 — 转发给 setup 侧 */
handleMapReady() {
__mapReadyBridge?.()
},
},
}
</script>
<script lang="ts" setup>
import { rpxToPx, getSystemInfoSyncCompat } from '@/utils/tools'
import { TIANDITU_JS_API_URL, TIANDITU_KEY } from '@/utils/mapConfig'
import { useMapPicker, type PoiItem } from '@/hooks/useMapPicker'
import { gcj02ToWgs84, wgs84ToGcj02 } from '@/utils/coordTransform'
import SearchBar from './components/SearchBar.vue'
// #ifdef APP-HARMONY
import '@/uni_modules/native-harmony-map'
// #endif
const { safeAreaInsets } = getSystemInfoSyncCompat()
const {
centerLat,
centerLng,
located,
locating,
selectedLocation,
keyword,
poiList,
searchLoading,
showPoiList,
hasSelection,
initLocation,
updateSelected,
onSearchInput,
onSearchConfirm,
clearSearch,
selectPoi,
getResult,
} = useMapPicker()
/** 传给 renderjs 的指令数据 */
const mapRenderProp = ref<Record<string, any>>({})
// ════════════════════════════════════════════
// H5 端:直接操作 DOM 加载天地图
// ════════════════════════════════════════════
// #ifdef H5
let h5Map: any = null
let h5Marker: any = null
function initH5Map(lat: number, lng: number) {
const TMap = (window as any).T
if (!TMap) {
const script = document.createElement('script')
script.src = TIANDITU_JS_API_URL
script.onload = () => createH5Map(lat, lng)
document.head.appendChild(script)
} else {
createH5Map(lat, lng)
}
}
function createH5Map(lat: number, lng: number) {
const TMap = (window as any).T
h5Map = new TMap.Map('tianditu-container')
h5Map.centerAndZoom(new TMap.LngLat(lng, lat), 15)
h5Marker = new TMap.Marker(new TMap.LngLat(lng, lat))
h5Map.addOverLay(h5Marker)
h5Map.addEventListener('moveend', () => {
const center = h5Map.getCenter()
updateSelected(center.getLng(), center.getLat())
})
h5Map.addEventListener('click', (e: any) => {
const lng = e.lnglat.getLng()
const lat = e.lnglat.getLat()
h5Map.panTo(new TMap.LngLat(lng, lat))
h5Marker.setLngLat(new TMap.LngLat(lng, lat))
updateSelected(lng, lat)
})
}
function h5MoveTo(lat: number, lng: number) {
if (!h5Map) return
const TMap = (window as any).T
h5Map.panTo(new TMap.LngLat(lng, lat))
if (h5Marker) h5Marker.setLngLat(new TMap.LngLat(lng, lat))
}
function h5GetMapBound(): string | undefined {
if (!h5Map) return undefined
const b = h5Map.getBounds()
const sw = b.getSouthWest()
const ne = b.getNorthEast()
return `${sw.getLng()},${sw.getLat()},${ne.getLng()},${ne.getLat()}`
}
// #endif
// ════════════════════════════════════════════
// 微信小程序端:原生 map 组件(GCJ-02 坐标系)
// ════════════════════════════════════════════
// #ifdef MP-WEIXIN
const mpMapCtx = ref<UniApp.MapContext | null>(null)
const mpMarkers = computed(() => {
if (!selectedLocation.value.latitude) return []
const gcj = wgs84ToGcj02(selectedLocation.value.longitude, selectedLocation.value.latitude)
return [
{
id: 1,
latitude: gcj.lat,
longitude: gcj.lng,
width: 32,
height: 42,
anchor: { x: 0.5, y: 1 },
},
]
})
function mpOnRegionChange(e: any) {
if (e.type === 'end' && e.causedBy === 'gesture') {
mpMapCtx.value?.getCenterLocation({
success: (res) => {
centerLat.value = res.latitude
centerLng.value = res.longitude
const wgs = gcj02ToWgs84(res.longitude, res.latitude)
updateSelected(wgs.lng, wgs.lat)
},
})
}
}
function mpOnMapTap(e: any) {
const gcjLng = e.detail.longitude
const gcjLat = e.detail.latitude
centerLat.value = gcjLat
centerLng.value = gcjLng
const wgs = gcj02ToWgs84(gcjLng, gcjLat)
updateSelected(wgs.lng, wgs.lat)
}
function mpMoveTo(wgsLat: number, wgsLng: number) {
const gcj = wgs84ToGcj02(wgsLng, wgsLat)
centerLat.value = gcj.lat
centerLng.value = gcj.lng
mpMapCtx.value?.moveToLocation({
latitude: gcj.lat,
longitude: gcj.lng,
})
}
// #endif
// ════════════════════════════════════════════
// 页面初始化
// ════════════════════════════════════════════
onLoad(async () => {
await initLocation()
const wgsLat = centerLat.value
const wgsLng = centerLng.value
// #ifdef H5
initH5Map(wgsLat, wgsLng)
updateSelected(wgsLng, wgsLat)
// #endif
// #ifdef APP-PLUS
mapRenderProp.value = {
type: 'init',
latitude: wgsLat,
longitude: wgsLng,
tk: TIANDITU_KEY,
timestamp: Date.now(),
}
__mapReadyBridge = () => {
updateSelected(wgsLng, wgsLat)
__mapReadyBridge = null
}
// #endif
// #ifndef APP-PLUS
// #ifdef MP-WEIXIN
nextTick(() => {
mpMapCtx.value = uni.createMapContext('mp-map', instance?.proxy)
})
const mpGcj = wgs84ToGcj02(wgsLng, wgsLat)
centerLat.value = mpGcj.lat
centerLng.value = mpGcj.lng
// #endif
// #ifdef APP-HARMONY
const hmGcj = wgs84ToGcj02(wgsLng, wgsLat)
centerLat.value = hmGcj.lat
centerLng.value = hmGcj.lng
// #endif
updateSelected(wgsLng, wgsLat)
// #endif
})
// ════════════════════════════════════════════
// 搜索事件处理
// ════════════════════════════════════════════
function handleSearchInput(value: string) {
// #ifdef H5
onSearchInput(value, h5GetMapBound())
// #endif
// #ifndef H5
onSearchInput(value)
// #endif
}
function handleSearchConfirm() {
// #ifdef H5
onSearchConfirm(h5GetMapBound())
// #endif
// #ifndef H5
onSearchConfirm()
// #endif
}
function handleSelectPoi(item: PoiItem) {
selectPoi(item)
// #ifdef H5
h5MoveTo(item.latitude, item.longitude)
// #endif
// #ifdef APP-PLUS
mapRenderProp.value = {
type: 'moveTo',
latitude: item.latitude,
longitude: item.longitude,
timestamp: Date.now(),
}
// #endif
// #ifdef MP-WEIXIN
mpMoveTo(item.latitude, item.longitude)
// #endif
// #ifdef APP-HARMONY
harmonyMoveTo(item.latitude, item.longitude)
// #endif
}
// ════════════════════════════════════════════
// 确认选择
// ════════════════════════════════════════════
const instance = getCurrentInstance()
function confirmLocation() {
const eventChannel = instance?.proxy?.getOpenerEventChannel?.()
if (eventChannel) {
eventChannel.emit('selectLocation', getResult())
}
uni.navigateBack()
}
// ════════════════════════════════════════════
// APP renderjs 通信回调
// ════════════════════════════════════════════
// #ifdef APP-PLUS
__mapMoveEndBridge = (data: { lat: number; lng: number }) => {
console.log('handleMapMoveEnd', data)
updateSelected(data.lng, data.lat)
}
// #endif
// ════════════════════════════════════════════
// 鸿蒙端:花瓣地图原生组件交互
// ════════════════════════════════════════════
// #ifdef APP-HARMONY
const harmonyMoveToTs = ref(0)
let lastGeocodeTs = 0
const GEOCODE_DEBOUNCE = 400
const harmonyMapOptions = computed(() => ({
latitude: centerLat.value,
longitude: centerLng.value,
scale: 15,
showCompass: false,
moveToLatitude: centerLat.value,
moveToLongitude: centerLng.value,
moveToTimestamp: harmonyMoveToTs.value,
}))
function harmonyUpdateSelected(gcjLng: number, gcjLat: number) {
const now = Date.now()
if (now - lastGeocodeTs < GEOCODE_DEBOUNCE) return
lastGeocodeTs = now
const wgs = gcj02ToWgs84(gcjLng, gcjLat)
centerLat.value = gcjLat
centerLng.value = gcjLng
updateSelected(wgs.lng, wgs.lat)
}
function onHarmonyMapClick(e: any) {
const detail = e.detail as { latitude: number; longitude: number }
harmonyUpdateSelected(detail.longitude, detail.latitude)
}
function onHarmonyCameraPositionChange(e: any) {
const detail = e.detail as { latitude: number; longitude: number }
harmonyUpdateSelected(detail.longitude, detail.latitude)
}
function harmonyMoveTo(wgsLat: number, wgsLng: number) {
const gcj = wgs84ToGcj02(wgsLng, wgsLat)
centerLat.value = gcj.lat
centerLng.value = gcj.lng
lastGeocodeTs = Date.now()
harmonyMoveToTs.value = Date.now()
}
// #endif
</script>
<!-- ════════ APP 端 renderjs 模块 ════════ -->
<!-- #ifdef APP-PLUS -->
<script module="mapRender" lang="renderjs">
export default {
data() {
return {
map: null,
}
},
methods: {
propChanged(newVal) {
if (!newVal || !newVal.type) return
if (newVal.type === 'init') {
this.initMap(newVal.latitude, newVal.longitude, newVal.tk)
} else if (newVal.type === 'moveTo') {
this.moveToLocation(newVal.latitude, newVal.longitude)
}
},
initMap(lat, lng, tk) {
var style = document.createElement('style')
style.textContent =
'.renderjs-center-pin{position:fixed;top:50%;left:50%;z-index:900;width:32px;height:42px;pointer-events:none;transform:translate(-50%,-100%)}.renderjs-center-pin img{width:100%;height:100%}'
document.head.appendChild(style)
var pin = document.createElement('div')
pin.className = 'renderjs-center-pin'
pin.innerHTML = '<img src="https://api.tianditu.gov.cn/img/map/markerA.png" />'
document.body.appendChild(pin)
if (typeof T === 'undefined') {
const script = document.createElement('script')
script.src = 'https://api.tianditu.gov.cn/api?v=4.0&tk=' + tk
script.onload = () => this.createMap(lat, lng)
document.head.appendChild(script)
} else {
this.createMap(lat, lng)
}
},
createMap(lat, lng) {
this.map = new T.Map('tianditu-container')
this.map.centerAndZoom(new T.LngLat(lng, lat), 15)
this.map.addEventListener('moveend', () => {
var center = this.map.getCenter()
console.log('map moveend', JSON.stringify(center))
this.$ownerInstance.callMethod('handleMapMoveEnd', {
lat: center.getLat(),
lng: center.getLng(),
})
})
this.$ownerInstance.callMethod('handleMapReady')
},
moveToLocation(lat, lng) {
if (!this.map) return
this.map.panTo(new T.LngLat(lng, lat))
},
},
}
</script>
<!-- #endif -->
<style lang="less" scoped>
.map-picker {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.map-container {
flex: 1;
width: 100%;
min-height: 0;
}
.center-pin {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -100%);
width: 32px;
height: 42px;
z-index: 10;
pointer-events: none;
.pin-icon {
width: 32px;
height: 42px;
}
}
.bottom-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.08);
z-index: 1000;
}
.selected-info {
margin-bottom: 20rpx;
.selected-name {
display: block;
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 6rpx;
}
.selected-address {
display: block;
font-size: 24rpx;
color: #999;
}
.loading-text {
color: #999;
font-weight: normal;
}
}
.confirm-btn {
width: 100%;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: var(--theme-color, #1890ff);
color: #fff;
font-size: 30rpx;
font-weight: bold;
border-radius: 40rpx;
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
</style>
12.5 搜索栏组件 — src/pages-sub/map/components/SearchBar.vue
<template>
<view class="search-bar">
<view class="search-inner">
<text class="iconfont icon-search search-icon" />
<input
class="search-input"
:value="modelValue"
placeholder="搜索地点"
confirm-type="search"
@confirm="onConfirm"
/>
<view v-if="loading" class="search-action">
<wd-loading :size="16" />
</view>
<view v-else-if="modelValue" class="search-action" @tap="onClear">
<text class="iconfont icon-close" />
</view>
</view>
<PoiList
v-show="showPoiList"
:list="poiList"
:loading="loading"
@select="(item: PoiItem) => emit('select', item)"
/>
</view>
</template>
<script lang="ts" setup>
import type { PoiItem } from '@/hooks/useMapPicker'
import PoiList from './PoiList.vue'
defineProps<{
modelValue: string
loading?: boolean
poiList?: PoiItem[]
showPoiList?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
search: [keyword: string]
clear: []
select: [item: PoiItem]
}>()
function onConfirm(e: any) {
const value = e.detail?.value ?? e.target?.value ?? ''
console.log('onConfirm', value)
emit('update:modelValue', value)
emit('search', value)
}
function onClear() {
emit('update:modelValue', '')
emit('clear')
}
</script>
<style lang="less" scoped>
.search-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 998;
padding: 16rpx 24rpx;
padding-top: calc(16rpx + var(--status-bar-height, 0px));
}
.search-inner {
display: flex;
align-items: center;
height: 72rpx;
padding: 0 20rpx;
background: #fff;
border-radius: 36rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.12);
}
.search-icon {
font-size: 28rpx;
color: #999;
margin-right: 12rpx;
}
.search-input {
flex: 1;
height: 72rpx;
font-size: 28rpx;
color: #333;
}
.search-action {
display: flex;
align-items: center;
justify-content: center;
width: 40rpx;
height: 40rpx;
.iconfont {
font-size: 24rpx;
color: #ccc;
}
}
</style>
12.6 POI 列表组件 — src/pages-sub/map/components/PoiList.vue
<script lang="ts" setup>
import type { PoiItem } from '@/hooks/useMapPicker'
defineProps<{
list: PoiItem[]
loading?: boolean
}>()
const emit = defineEmits<{
select: [item: PoiItem]
}>()
</script>
<template>
<scroll-view class="poi-list" scroll-y>
<view v-if="loading" class="poi-status">
<wd-loading :size="32" />
<text class="poi-status-text">搜索中...</text>
</view>
<view v-else-if="list.length === 0" class="poi-status">
<text class="poi-status-text">未找到相关地点</text>
</view>
<view v-for="item in list" :key="item.key" class="poi-item" @tap="emit('select', item)">
<view class="poi-info">
<text class="poi-name">{{ item.name }}</text>
<text v-if="item.address" class="poi-address">{{ item.address }}</text>
</view>
<text class="iconfont icon-down poi-arrow rotate-270" />
</view>
</scroll-view>
</template>
<style lang="less" scoped>
.poi-list {
position: relative;
margin-top: 12rpx;
max-height: 60vh;
background: #fff;
border-radius: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
overflow: hidden;
box-sizing: border-box;
}
.poi-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48rpx 0;
.poi-status-text {
font-size: 26rpx;
color: #999;
margin-top: 12rpx;
}
}
.poi-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 28rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&:active {
background: #f8f8f8;
}
}
.poi-info {
flex: 1;
min-width: 0;
overflow: hidden;
}
.poi-name {
display: block;
font-size: 28rpx;
color: #333;
font-weight: 500;
margin-bottom: 6rpx;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.poi-address {
display: block;
font-size: 22rpx;
color: #999;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.poi-arrow {
flex-shrink: 0;
font-size: 24rpx;
color: #ccc;
margin-left: 16rpx;
}
</style>
12.7 鸿蒙花瓣地图 UTS 插件 — src/uni_modules/native-harmony-map/utssdk/app-harmony/map.ets
import { map, mapCommon, MapComponent } from '@kit.MapKit';
import { AsyncCallback } from '@kit.BasicServicesKit';
import { defineNativeEmbed, NativeEmbedBuilderOptions } from '@dcloudio/uni-app-runtime';
import { NodeRenderType, } from '@ohos.arkui.node';
interface IMarkerItem {
id: number
latitude: number
longitude: number
iconPath?: string
rotate?: number
zIndex?: number
visible?: boolean
alpha?: number
}
interface ICircleItem {
latitude: number
longitude: number
color?: number
fillColor?: number
radius: number
strokeWidth?: number
}
interface MapBuilderOptions extends NativeEmbedBuilderOptions {
longitude: number
latitude: number
scale?: number
rotate?: number
skew?: number
minScale?: number
maxScale?: number
markers?: IMarkerItem[]
circles?: ICircleItem[]
showCompass?: boolean
enableZoom?: boolean
moveToLatitude?: number
moveToLongitude?: number
moveToTimestamp?: number
}
interface IMarkerTapDetailEvent {
markerId: string
}
interface IMarkerTapEvent {
type: string
detail: IMarkerTapDetailEvent
}
interface IMapClickDetailEvent {
latitude: number
longitude: number
}
interface IMapClickEvent {
type: string
detail: IMapClickDetailEvent
}
interface ICameraPositionChangeEvent {
type: string
detail: IMapClickDetailEvent
}
export let mapControllerExportd: map.MapComponentController | null = null
@Component
struct HuaweiMapComponent {
@Prop longitude: number
@Prop latitude: number
@Prop scaleVal: number
@Prop skew: number
@Prop rotateVal: number
@Prop minScale: number
@Prop maxScale: number
@Prop showCompass: boolean = false
@Prop markers: IMarkerItem[] = []
@Prop circles: ICircleItem[] = []
@Prop moveToLat: number = 0
@Prop moveToLng: number = 0
@Prop @Watch('onMoveToTimestampChange') moveToTs: number = 0
onMarkerTap?: Function
onMapClick?: Function
onCameraPositionChange?: Function
private TAG = "HuaweiMapDemo";
private mapOptions?: mapCommon.MapOptions;
private callback?: AsyncCallback<map.MapComponentController>;
private mapController?: map.MapComponentController;
private mapEventManager?: map.MapEventManager;
private pollingTimer: number = -1;
private lastCameraLat: number = 0;
private lastCameraLng: number = 0;
private isCameraStable: boolean = true;
async setMarker(): Promise<void> {
this.markers.forEach(async (marker) => {
let markerOptions: mapCommon.MarkerOptions = {
position: {
latitude: marker.latitude,
longitude: marker.longitude
},
icon: marker.iconPath,
rotation: marker.rotate,
alpha: marker.alpha ?? 1,
clickable: true
}
let markerBoy = await this.mapController!.addMarker(markerOptions);
})
}
async setCircle(): Promise<void> {
this.circles.forEach(async (circle) => {
let mapCircleOptions: mapCommon.MapCircleOptions = {
center: {
latitude: circle.latitude,
longitude: circle.longitude
},
radius: circle.radius,
strokeColor: circle.color,
fillColor: circle.fillColor,
strokeWidth: circle.strokeWidth,
visible: true,
zIndex: 15
}
let mapCircle: map.MapCircle = await this.mapController!.addCircle(mapCircleOptions);
})
}
aboutToAppear(): void {
this.mapOptions = {
mapType: mapCommon.MapType.STANDARD,
position: {
target: {
latitude: this.latitude,
longitude: this.longitude,
},
zoom: this.scaleVal,
tilt: this.skew,
bearing: this.rotateVal
},
minZoom: this.minScale,
maxZoom: this.maxScale,
};
this.callback = async (err, mapController) => {
if (!err) {
this.mapController = mapController;
mapControllerExportd = mapController;
this.mapEventManager = this.mapController.getEventManager();
let callback = () => {
console.info(this.TAG, `on-mapLoad`);
}
this.mapEventManager.on("mapLoad", callback);
await this.setMarker()
await this.setCircle()
this.mapEventManager.on("markerClick", (marker: map.Marker) => {
console.log('markerClick click', marker);
if (this.onMarkerTap) {
let res: IMarkerTapEvent = {
type: "markertap",
detail: {
markerId: marker.getId()
}
}
this.onMarkerTap(res)
}
})
// 地图点击 → 内部移动相机 + 通知 Vue
this.mapEventManager.on("mapClick", (latLng: mapCommon.LatLng) => {
if (this.mapController) {
let target: mapCommon.LatLng = { latitude: latLng.latitude, longitude: latLng.longitude }
let cameraUpdate = map.newLatLng(target)
this.mapController.moveCamera(cameraUpdate)
}
if (this.onMapClick) {
let res: IMapClickEvent = {
type: "mapclick",
detail: {
latitude: latLng.latitude,
longitude: latLng.longitude
}
}
this.onMapClick(res)
}
})
// 拖拽/缩放结束 → 稳定性轮询检测
this.lastCameraLat = this.latitude
this.lastCameraLng = this.longitude
this.pollingTimer = setInterval(() => {
if (!this.mapController) return
let cameraPosition = this.mapController.getCameraPosition()
let lat = cameraPosition.target.latitude
let lng = cameraPosition.target.longitude
let latChanged = Math.abs(lat - this.lastCameraLat) > 0.000001
let lngChanged = Math.abs(lng - this.lastCameraLng) > 0.000001
if (latChanged || lngChanged) {
this.lastCameraLat = lat
this.lastCameraLng = lng
this.isCameraStable = false
} else if (!this.isCameraStable) {
this.isCameraStable = true
if (this.onCameraPositionChange) {
let res: ICameraPositionChangeEvent = {
type: "camerapositionchange",
detail: {
latitude: lat,
longitude: lng
}
}
this.onCameraPositionChange(res)
}
}
}, 300)
}
};
}
onMoveToTimestampChange(): void {
if (this.moveToTs > 0 && this.mapController) {
this.isCameraStable = false
let target: mapCommon.LatLng = { latitude: this.moveToLat, longitude: this.moveToLng }
let cameraUpdate = map.newLatLng(target)
this.mapController.moveCamera(cameraUpdate)
}
}
aboutToDisappear(): void {
if (this.pollingTimer !== -1) {
clearInterval(this.pollingTimer)
this.pollingTimer = -1
}
}
build() {
Stack() {
MapComponent({
mapOptions: this.mapOptions,
mapCallback: this.callback
})
.width('100%')
.height('100%')
}
.height('100%')
}
}
@Builder
function MapBuilder(options: MapBuilderOptions) {
HuaweiMapComponent({
latitude: options.latitude,
longitude: options.longitude,
scaleVal: options.scale ?? 16,
rotateVal: options.rotate ?? 0,
skew: options.skew ?? 0,
minScale: options.minScale ?? 2,
maxScale: options.maxScale ?? 20,
showCompass: options.showCompass ?? false,
markers: options.markers ?? [],
circles: options.circles ?? [],
moveToLat: options.moveToLatitude ?? 0,
moveToLng: options.moveToLongitude ?? 0,
moveToTs: options.moveToTimestamp ?? 0,
onMarkerTap: options?.on?.get('markertap'),
onMapClick: options?.on?.get('mapclick'),
onCameraPositionChange: options?.on?.get('camerapositionchange'),
})
.width(options.width)
.height(options.height)
}
defineNativeEmbed('map', {
builder: MapBuilder,
})
12.8 鸿蒙插件导出 — src/uni_modules/native-harmony-map/utssdk/app-harmony/index.uts
export { mapControllerExportd } from './map.ets'
12.9 测试页面 — src/pages-sub/map/test.vue
<route lang="json5">
{
style: {
navigationStyle: 'custom',
navigationBarTitleText: '地图选点测试',
},
}
</route>
<template>
<m-page ref="pageRef">
<m-header title="地图选点测试" :hasBack="true"></m-header>
<m-body padding="32rpx">
<view class="section">
<text class="section-title">天地图选点 Demo</text>
<text class="section-desc">基于天地图 JS API 4.0 实现,兼容 H5 / APP / 鸿蒙 / 微信小程序</text>
<view class="action-card" @tap="openMapPicker">
<text class="action-icon">📍</text>
<view class="action-info">
<text class="action-name">打开地图选点</text>
<text class="action-desc">点击选择一个位置</text>
</view>
<text class="iconfont icon-down rotate-270 action-arrow" />
</view>
</view>
<view v-if="selectedResult" class="result-section">
<text class="section-title">选择结果</text>
<view class="result-card">
<view class="result-row">
<text class="result-label">名称</text>
<text class="result-value">{{ selectedResult.name || '(无)' }}</text>
</view>
<view class="result-row">
<text class="result-label">地址</text>
<text class="result-value">{{ selectedResult.address || '(无)' }}</text>
</view>
<view class="result-row">
<text class="result-label">WGS-84</text>
<text class="result-value">{{ selectedResult.latitude }}, {{ selectedResult.longitude }}</text>
</view>
<view class="result-row">
<text class="result-label">GCJ-02</text>
<text class="result-value">{{ selectedResult.gcj02Latitude }}, {{ selectedResult.gcj02Longitude }}</text>
</view>
</view>
</view>
<view class="section" style="margin-top: 40rpx">
<text class="section-title">原生 API 对比</text>
<text class="section-desc">uni.chooseLocation() 各平台内置选点</text>
<view class="action-card" @tap="openNativePicker">
<text class="action-icon">🗺️</text>
<view class="action-info">
<text class="action-name">原生 chooseLocation</text>
<text class="action-desc">使用系统/SDK内置选点页面</text>
</view>
<text class="iconfont icon-down rotate-270 action-arrow" />
</view>
</view>
<view v-if="nativeResult" class="result-section">
<text class="section-title">原生选择结果</text>
<view class="result-card">
<view class="result-row">
<text class="result-label">名称</text>
<text class="result-value">{{ nativeResult.name || '(无)' }}</text>
</view>
<view class="result-row">
<text class="result-label">地址</text>
<text class="result-value">{{ nativeResult.address || '(无)' }}</text>
</view>
<view class="result-row">
<text class="result-label">坐标</text>
<text class="result-value">{{ nativeResult.latitude }}, {{ nativeResult.longitude }}</text>
</view>
</view>
</view>
</m-body>
</m-page>
</template>
<script lang="ts" setup>
import type { MapPickerResult } from '@/hooks/useMapPicker'
const pageRef = ref(null)
const selectedResult = ref<MapPickerResult | null>(null)
const nativeResult = ref<{ name: string; address: string; latitude: number; longitude: number } | null>(null)
function openMapPicker() {
uni.navigateTo({
url: '/pages-sub/map/index',
events: {
selectLocation: (data: MapPickerResult) => {
selectedResult.value = data
console.log('[测试入口] 天地图选点结果:', data)
},
},
})
}
function openNativePicker() {
uni.chooseLocation({
success: (res) => {
nativeResult.value = {
name: res.name,
address: res.address,
latitude: res.latitude,
longitude: res.longitude,
}
console.log('[测试入口] 原生chooseLocation结果:', res)
},
fail: (err) => {
console.warn('[测试入口] 原生chooseLocation失败:', err)
uni.showToast({ title: '选择取消或失败', icon: 'none' })
},
})
}
</script>
<style lang="less" scoped>
.section { margin-bottom: 24rpx; }
.section-title {
display: block; font-size: 30rpx; font-weight: bold;
color: #333; margin-bottom: 12rpx;
}
.section-desc {
display: block; font-size: 24rpx; color: #999; margin-bottom: 20rpx;
}
.action-card {
display: flex; align-items: center; padding: 28rpx 24rpx;
background: #fff; border-radius: 16rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
&:active { background: #f8f8f8; }
}
.action-icon { font-size: 40rpx; margin-right: 20rpx; }
.action-info { flex: 1; min-width: 0; }
.action-name {
display: block; font-size: 28rpx; font-weight: 500;
color: #333; margin-bottom: 4rpx;
}
.action-desc { display: block; font-size: 22rpx; color: #999; }
.action-arrow { font-size: 24rpx; color: #ccc; margin-left: 12rpx; }
.result-section { margin-top: 24rpx; }
.result-card {
background: #fff; border-radius: 16rpx; padding: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
}
.result-row {
display: flex; align-items: flex-start; padding: 12rpx 0;
border-bottom: 1rpx solid #f5f5f5;
&:last-child { border-bottom: none; }
}
.result-label { width: 130rpx; min-width: 130rpx; font-size: 24rpx; color: #999; }
.result-value { flex: 1; font-size: 24rpx; color: #333; word-break: break-all; }
</style>
13. 使用手册
本手册面向使用地图选点功能的开发者,介绍如何调用、配置和扩展该功能。
13.1 功能概述
地图选点功能提供以下能力:
| 能力 | 说明 |
|---|---|
| 🗺️ 地图浏览 | 拖拽/缩放地图,实时获取中心点位置 |
| 📍 定位 | 自动获取当前位置并居中显示 |
| 🔍 POI 搜索 | 关键词搜索地点,支持联想和确认搜索 |
| 🏠 逆地理编码 | 地图中心点实时逆地理编码,显示地址信息 |
| ✅ 选点确认 | 确认选择后返回双坐标系结果(WGS-84 + GCJ-02) |
支持平台
| 平台 | 地图引擎 | 坐标系 | 交互方式 |
|---|---|---|---|
| H5 | 天地图 JS API 4.0 | WGS-84 | 拖拽 + 点击 |
| APP-Android/iOS | 天地图 JS API(renderjs) | WGS-84 | 拖拽 + 点击 |
| 微信小程序 | 原生 <map> 组件 |
GCJ-02 | 拖拽 + 点击 |
| 鸿蒙 (HarmonyOS) | 华为花瓣地图 (MapKit) | GCJ-02 | 拖拽 + 点击 |
13.2 快速开始
最简调用
// 在任意页面中,导航到地图选点页并接收结果
uni.navigateTo({
url: '/pages-sub/map/index',
events: {
selectLocation: (data) => {
console.log('选中地点:', data.name)
console.log('地址:', data.address)
console.log('WGS-84:', data.latitude, data.longitude)
console.log('GCJ-02:', data.gcj02Latitude, data.gcj02Longitude)
},
},
})
完整示例
参考测试页面:test.vue
<template>
<view>
<button @tap="openMapPicker">打开地图选点</button>
<view v-if="result">
<text>名称:{{ result.name }}</text>
<text>地址:{{ result.address }}</text>
<text>WGS-84:{{ result.latitude }}, {{ result.longitude }}</text>
<text>GCJ-02:{{ result.gcj02Latitude }}, {{ result.gcj02Longitude }}</text>
</view>
</view>
</template>
<script lang="ts" setup>
import type { MapPickerResult } from '@/hooks/useMapPicker'
const result = ref<MapPickerResult | null>(null)
function openMapPicker() {
uni.navigateTo({
url: '/pages-sub/map/index',
events: {
selectLocation: (data: MapPickerResult) => {
result.value = data
},
},
})
}
</script>
13.3 调用方式
页面导航 + eventChannel
这是推荐的调用方式,通过 UniApp 的页面事件通道传递选点结果。
uni.navigateTo({
url: '/pages-sub/map/index',
events: {
// 监听选点确认事件
selectLocation: (data: MapPickerResult) => {
// 处理选点结果
},
},
})
执行流程:
调用页面 地图选点页面
│ │
├── navigateTo ──────────→ │ 打开选点页
│ │ └─ initLocation() 自动定位
│ │ └─ 用户拖拽/搜索/点击
│ │ └─ 逆地理编码实时更新
│ │
│ ←── emit('selectLocation', result) ──┤ 点击"确定选择"
│ │
│ navigateBack() │ 页面关闭
传递初始位置(扩展)
当前版本自动获取用户当前位置作为初始中心。如需指定初始位置,可通过 URL 参数扩展:
// 扩展方式(需自行实现参数解析)
uni.navigateTo({
url: `/pages-sub/map/index?lat=39.915&lng=116.404`,
})
13.4 返回结果说明
数据结构
interface MapPickerResult {
/** 地点名称(如"天安门") */
name: string
/** 完整地址(如"北京市东城区东长安街") */
address: string
/** WGS-84 纬度 — 通用存储格式,传给后端/天地图 API */
latitude: number
/** WGS-84 经度 */
longitude: number
/** GCJ-02 纬度 — 供微信小程序/鸿蒙地图渲染使用 */
gcj02Latitude: number
/** GCJ-02 经度 */
gcj02Longitude: number
}
坐标系选择指南
| 使用场景 | 选用坐标系 | 对应字段 |
|---|---|---|
| 传给后端 API 存储 | WGS-84 | latitude, longitude |
| 调用天地图 API(逆地理编码等) | WGS-84 | latitude, longitude |
| 渲染到微信小程序 map 组件 | GCJ-02 | gcj02Latitude, gcj02Longitude |
| 渲染到鸿蒙花瓣地图 | GCJ-02 | gcj02Latitude, gcj02Longitude |
| 渲染到 H5/APP 天地图 | WGS-84 | latitude, longitude |
| GPS 导航 | WGS-84 | latitude, longitude |
结果示例
{
"name": "天安门",
"address": "北京市东城区东长安街",
"latitude": 39.9087,
"longitude": 116.3975,
"gcj02Latitude": 39.9152,
"gcj02Longitude": 116.4041
}
注意:同一地点的 WGS-84 和 GCJ-02 坐标大约偏移 100~600 米,这是中国国测局加密算法的正常现象。
13.5 配置项
天地图 API 凭证
配置文件:mapConfig.ts
/** 浏览器端 Token — 用于 JS API 加载和前端 REST 请求 */
export const TIANDITU_KEY = '<YOUR_TIANDITU_BROWSER_KEY>'
/** 服务端 Token — 用于后端 REST 请求(暂未使用,预留扩展) */
export const TIANDITU_SERVER_KEY = '<YOUR_TIANDITU_SERVER_KEY>'
如何更换 Token:
- 访问 天地图开发者平台 注册/登录
- 创建应用,获取浏览器端和服务端 Key
- 修改
mapConfig.ts中的TIANDITU_KEY和TIANDITU_SERVER_KEY
防抖时间
在调用 useMapPicker 时可传入防抖时间(默认 300ms):
// 默认 300ms
const { ... } = useMapPicker()
// 自定义 500ms
const { ... } = useMapPicker(500)
13.6 平台差异说明
交互差异
| 特性 | H5 | APP-PLUS | 微信小程序 | 鸿蒙 |
|---|---|---|---|---|
| 地图引擎 | 天地图 JS | 天地图 JS (renderjs) | 微信原生 map | 花瓣地图 (MapKit) |
| 拖拽结束检测 | moveend 事件 |
moveend 事件 |
regionchange (type=end) |
稳定性轮询 300ms |
| 中心定位针 | CSS 覆盖物 | renderjs 注入 DOM | markers 渲染 | CSS 覆盖物 |
| 搜索范围限定 | 支持当前视野范围 | 不支持 | 不支持 | 不支持 |
| 定位精度 | 取决于浏览器 | 高精度模式 | 高精度模式 | 高精度模式 |
坐标系差异
| 平台 | 地图中心坐标 (centerLat/Lng) | 天地图 API 交互 |
|---|---|---|
| H5 | WGS-84 | 无需转换 |
| APP-PLUS | WGS-84 | 无需转换 |
| 微信小程序 | GCJ-02 | 需双向转换 |
| 鸿蒙 | GCJ-02 | 需双向转换 |
首次加载行为
| 平台 | 首次逆地理编码时机 |
|---|---|
| H5 | 地图创建后立即执行 |
| APP-PLUS | 等待天地图 JS 加载完成后执行(避免 403) |
| 微信小程序 | 地图组件挂载后立即执行 |
| 鸿蒙 | 花瓣地图初始化后立即执行 |
13.7 权限配置
微信小程序
在 manifest.json → mp-weixin 中配置:
{
"mp-weixin": {
"requiredPrivateInfos": ["getLocation", "chooseLocation"],
"permission": {
"scope.userLocation": {
"desc": "你的位置信息将用于选择位置"
}
}
}
}
APP-iOS
在 manifest.json → app-plus → distribute → ios 中配置:
{
"ios": {
"privacyDescription": {
"NSLocationWhenInUseUsageDescription": "用于选择位置",
"NSLocationAlwaysUsageDescription": "用于选择位置",
"NSLocationAlwaysAndWhenInUseUsageDescription": "用于选择位置"
}
}
}
APP-Android
依赖 uni-app 运行时自动处理定位权限,无需额外配置。
鸿蒙
在 module.json5 中配置定位权限(由 UTS 插件自动处理)。
13.8 路由注册
地图选点页面路由在 pages.json 中注册:
{
"subPackages": [
{
"root": "pages-sub",
"pages": [
{
"path": "map/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "选择位置"
}
},
{
"path": "map/test",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "地图选点测试"
}
}
]
}
]
}
测试页面
/pages-sub/map/test仅用于开发调试,正式发布时可移除。
13.9 常见问题
Q1: 选点位置与实际位置有偏移
原因:坐标系混淆。WGS-84 坐标直接用于 GCJ-02 地图(或反之)会产生 100~600m 偏移。
解决:
- 后端存储使用
latitude/longitude(WGS-84) - 微信/鸿蒙地图渲染使用
gcj02Latitude/gcj02Longitude - 如需在其他场景使用,参考 coordTransform.ts 进行转换
Q2: APP 端首次打开逆地理编码失败
原因:浏览器端 Key 依赖天地图 JS API 加载后的 Cookie 会话鉴权。
解决:此问题已在代码中处理(延迟到 handleMapReady 回调后执行),如仍出现请检查 Token 是否正确。
Q3: 微信小程序定位被拒绝
解决:
- 确认
manifest.json中已配置requiredPrivateInfos和permission - 确认小程序后台已开通定位权限
- 用户需在设置中授权位置权限
Q4: 鸿蒙端拖拽结束后逆地理编码延迟
原因:花瓣地图无 cameraPositionChangeEnd 事件,采用 300ms 稳定性轮询检测。
解决:这是平台限制,最多 300ms 延迟,对用户体验影响可忽略。
Q5: 搜索结果不完整
原因:搜索范围默认覆盖中国全境,但天地图 API 每次最多返回 20 条。
解决:
- H5 端支持传入当前地图视野范围缩小搜索区域
- 可通过修改
searchPoi中的count参数增加返回数量
Q6: 定位失败
现象:地图默认显示北京天安门位置。
解决:
- 检查设备是否开启定位权限
- 检查网络连接
- 室内 GPS 信号弱,可移动到窗边
Q7: 如何自定义地图样式或交互
修改点:
- 底部信息栏样式:修改 index.vue 的
<style>部分 - 搜索栏样式:修改 SearchBar.vue
- POI 列表样式:修改 PoiList.vue
- 地图缩放级别:修改
centerAndZoom的第二个参数(默认 15)
13.10 API 参考
useMapPicker Hook
function useMapPicker(debounceMs?: number): {
// 响应式状态
centerLat: Ref<number> // 地图中心纬度(显示坐标系)
centerLng: Ref<number> // 地图中心经度(显示坐标系)
located: Ref<boolean> // 是否已定位
locating: Ref<boolean> // 是否正在定位
selectedLocation: Ref<LocationInfo> // 选中位置(WGS-84)
keyword: Ref<string> // 搜索关键词
poiList: Ref<PoiItem[]> // 搜索结果列表
searchLoading: Ref<boolean> // 搜索加载态
showPoiList: Ref<boolean> // 是否显示搜索结果
hasSelection: Ref<boolean> // 是否有有效选中
// 方法
initLocation(): Promise<void> // 初始化定位
getCurrentLocation(): Promise<{latitude, longitude}> // 获取当前位置
updateSelected(wgsLng, wgsLat): Promise<void> // 更新选中位置(WGS-84)
reverseGeocode(lng, lat): Promise<LocationInfo> // 逆地理编码(WGS-84)
onSearchInput(value, mapBound?): void // 搜索输入(防抖)
onSearchConfirm(mapBound?): void // 确认搜索
clearSearch(): void // 清空搜索
selectPoi(item: PoiItem): Promise<void> // 选中 POI
getResult(): MapPickerResult // 获取最终结果
}
坐标转换工具
参考文件:coordTransform.ts
// WGS-84 → GCJ-02
function wgs84ToGcj02(wgsLng: number, wgsLat: number): { lng: number; lat: number }
// GCJ-02 → WGS-84(近似,精度 <0.5m)
function gcj02ToWgs84(gcjLng: number, gcjLat: number): { lng: number; lat: number }
地图配置
参考文件:mapConfig.ts
const TIANDITU_KEY: string // 浏览器端 Token
const TIANDITU_SERVER_KEY: string // 服务端 Token
const TIANDITU_GEOCODER_URL: string // 逆地理编码接口
const TIANDITU_SEARCH_URL: string // 搜索接口
const TIANDITU_JS_API_URL: string // JS API 加载地址
类型定义
// 位置信息
interface LocationInfo {
name: string
address: string
latitude: number // WGS-84
longitude: number // WGS-84
province?: string
city?: string
district?: string
}
// POI 搜索结果
interface PoiItem {
key: string // 唯一标识(城市统计项以 "city_" 开头)
name: string
address: string
latitude: number // WGS-84
longitude: number // WGS-84
}
// 选点最终结果
interface MapPickerResult {
name: string
address: string
latitude: number // WGS-84 纬度
longitude: number // WGS-84 经度
gcj02Latitude: number // GCJ-02 纬度
gcj02Longitude: number // GCJ-02 经度
}
13.11 文件索引
| 文件 | 职责 |
|---|---|
| index.vue | 地图选点主页面,四端条件编译 |
| useMapPicker.ts | 核心 composable,定位/搜索/选点逻辑 |
| coordTransform.ts | 坐标系转换(WGS-84 ↔ GCJ-02) |
| mapConfig.ts | 天地图 API 凭证和接口地址 |
| SearchBar.vue | 搜索栏组件 |
| PoiList.vue | POI 搜索结果列表组件 |
| map.ets | 鸿蒙花瓣地图 UTS 插件 |
| test.vue | 测试页面 |
更多推荐


所有评论(0)