高德地图添加轨迹规划和重放功能
This commit is contained in:
231
docs/interview-question-amap-loader.md
Normal file
231
docs/interview-question-amap-loader.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# 前端面试题:高德地图 JSAPI 加载机制与安全密钥
|
||||
|
||||
## 题目描述
|
||||
|
||||
在 Vue 3 + Vite 项目中,使用 `@amap/amap-jsapi-loader` 动态加载高德地图 JSAPI 2.0。要求:
|
||||
|
||||
1. **全局只加载一次** — 多个组件同时调用不会重复下载 ~600KB 脚本
|
||||
2. **加载失败可重试** — 不缓存失败的 Promise
|
||||
3. **安全密钥按需配置** — 2021/12/02 后申请的 Key 必须配置 `securityJsCode`
|
||||
4. **安全密钥必须在脚本加载之前设置** — 否则 Driving/Geocoder 等服务报 `INVALID_USER_SCODE`
|
||||
|
||||
---
|
||||
|
||||
## 完整源码与解析
|
||||
|
||||
### 1. 配置文件
|
||||
|
||||
```ts
|
||||
// config/amap.ts
|
||||
export const AMAP_JSAPI_KEY = import.meta.env.VITE_AMAP_JSAPI_KEY as string
|
||||
export const AMAP_WEB_KEY = import.meta.env.VITE_AMAP_WEB_KEY as string
|
||||
export const AMAP_SECURITY_CODE = import.meta.env.VITE_AMAP_SECURITY_CODE as string
|
||||
export const AMAP_VERSION = '2.0'
|
||||
|
||||
export const AMAP_PLUGINS = [
|
||||
'AMap.Geocoder', // 地理编码
|
||||
'AMap.AutoComplete', // 输入提示
|
||||
'AMap.PlaceSearch', // 搜索
|
||||
'AMap.Geolocation', // 定位
|
||||
'AMap.MarkerClusterer', // 点聚合
|
||||
'AMap.Driving', // 驾车路线规划
|
||||
] as const
|
||||
```
|
||||
|
||||
### 2. 加载器 — 单例模式
|
||||
|
||||
```ts
|
||||
// composables/useAmap.ts
|
||||
import AMapLoader from '@amap/amap-jsapi-loader'
|
||||
import { AMAP_JSAPI_KEY, AMAP_SECURITY_CODE, AMAP_VERSION, AMAP_PLUGINS } from '@/config/amap'
|
||||
|
||||
/** 全局 Promise 缓存 — 多次调用共享同一个下载 */
|
||||
let amapPromise: Promise<typeof AMap> | null = null
|
||||
|
||||
/** 全局对象缓存 — 成功后直接返回,避免再次 await */
|
||||
let AMapGlobal: typeof AMap | null = null
|
||||
|
||||
export async function loadAMap(): Promise<typeof AMap> {
|
||||
// ① 已加载过 → 直接返回
|
||||
if (AMapGlobal) return AMapGlobal
|
||||
|
||||
if (!amapPromise) {
|
||||
// ② ⚠️ 必须在 JSAPI 脚本加载之前设置安全密钥
|
||||
if (AMAP_SECURITY_CODE && !(window as any)._AMapSecurityConfig) {
|
||||
;(window as any)._AMapSecurityConfig = {
|
||||
securityJsCode: AMAP_SECURITY_CODE,
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 发起加载
|
||||
amapPromise = AMapLoader.load({
|
||||
key: AMAP_JSAPI_KEY,
|
||||
version: AMAP_VERSION,
|
||||
plugins: [...AMAP_PLUGINS],
|
||||
})
|
||||
.then((amap) => {
|
||||
AMapGlobal = amap // ④ 成功后缓存,后续调用瞬间返回
|
||||
return amap
|
||||
})
|
||||
.catch((err) => {
|
||||
amapPromise = null // ⑤ 失败后清空 → 允许重试
|
||||
throw new Error(`高德地图 JSAPI 加载失败: ${err.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
return amapPromise
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心知识点
|
||||
|
||||
### 知识点 ①:单例模式 — 避免重复加载
|
||||
|
||||
```
|
||||
组件 A 组件 B 组件 C
|
||||
│ │ │
|
||||
├─ loadAMap() ├─ loadAMap() ├─ loadAMap()
|
||||
│ amapPromise │ amapPromise │ amapPromise
|
||||
│ = load() │ !== null │ !== null
|
||||
│ (开始下载) │ (等待同一Promise)│ (等待同一Promise)
|
||||
│ ↓ │ ↓ │ ↓
|
||||
└─── await ──────┴─── await ──────┴─── await
|
||||
│
|
||||
AMapGlobal = amap (缓存)
|
||||
|
||||
后续任何调用:if (AMapGlobal) return AMapGlobal → 瞬间返回
|
||||
```
|
||||
|
||||
**两层缓存的作用:**
|
||||
|
||||
| 缓存层 | 类型 | 作用 |
|
||||
|--------|------|------|
|
||||
| `amapPromise` | `Promise<AMap> \| null` | 并发调用去重 — A、B、C 同时调用时,共享同一个下载 Promise |
|
||||
| `AMapGlobal` | `typeof AMap \| null` | 跨时间缓存 — 下载完成后,任意时刻再调用直接返回对象 |
|
||||
|
||||
### 知识点 ②:失败重试
|
||||
|
||||
```ts
|
||||
.catch((err) => {
|
||||
amapPromise = null // ← 清空 Promise,下次调用重新下载
|
||||
throw new Error(...)
|
||||
})
|
||||
```
|
||||
|
||||
如果不置 `null`,失败的 Promise 会被缓存,后续调用拿到的是同一个 rejected Promise,永远无法恢复。
|
||||
|
||||
### 知识点 ③:安全密钥的时序约束
|
||||
|
||||
```
|
||||
✅ 正确顺序:
|
||||
window._AMapSecurityConfig = { securityJsCode: 'xxx' }
|
||||
↓
|
||||
<script src="https://webapi.amap.com/maps?v=2.0&key=..."></script>
|
||||
↓
|
||||
AMap.Driving 等服务可用
|
||||
|
||||
❌ 错误顺序(写在 AMapLoader.load 之后):
|
||||
<script src="..."></script> ← JSAPI 已加载,安全配置窗口关闭
|
||||
↓
|
||||
window._AMapSecurityConfig = { ... } ← 无效!Driving 等服务报 INVALID_USER_SCODE
|
||||
```
|
||||
|
||||
**为什么必须在此之前?** 高德 JSAPI 脚本加载时读取 `_AMapSecurityConfig` 并完成内部初始化。脚本加载后该配置窗口关闭,之后再设置不会生效。
|
||||
|
||||
### 知识点 ④:`as const` 插件列表
|
||||
|
||||
```ts
|
||||
export const AMAP_PLUGINS = [
|
||||
'AMap.Geocoder',
|
||||
// ...
|
||||
] as const
|
||||
```
|
||||
|
||||
`as const` 将数组类型收窄为 `readonly` 字面量元组,提供:
|
||||
- 类型安全:`[...AMAP_PLUGINS]` 展开时保留精确字面量类型
|
||||
- 防止意外修改:插件列表不应在运行时被 push/pop
|
||||
- Tree-shaking 友好:Vite 可以更好地优化
|
||||
|
||||
### 知识点 ⑤:环境变量前缀 `VITE_`
|
||||
|
||||
```
|
||||
.env 文件中:
|
||||
VITE_AMAP_JSAPI_KEY=xxx ← ✅ Vite 会暴露给客户端
|
||||
AMAP_JSAPI_KEY=xxx ← ❌ 客户端 import.meta.env 读不到
|
||||
```
|
||||
|
||||
Vite 只暴露 `VITE_` 前缀的环境变量,防止敏感信息泄漏。这是 Vite 的安全设计。
|
||||
|
||||
---
|
||||
|
||||
## INVALID_USER_SCODE 排查流程
|
||||
|
||||
```
|
||||
[护理员] ❌ Driving 返回错误: 未知错误 INVALID_USER_SCODE
|
||||
```
|
||||
|
||||
**排查清单:**
|
||||
|
||||
| 检查项 | 怎么做 |
|
||||
|--------|--------|
|
||||
| ① Key 申请时间 | 2021/12/02 之后申请的 Key 必须配置安全密钥 |
|
||||
| ② `_AMapSecurityConfig` 时机 | 必须在 `AMapLoader.load()` **之前**设置 |
|
||||
| ③ JSAPI Key vs 安全密钥 | 是两个不同的值,都在 AMap 控制台查看 |
|
||||
| ④ 密钥拼写 | `securityJsCode`(注意大小写)不能有空格 |
|
||||
| ⑤ 环境变量 | `.env` 中 `VITE_AMAP_SECURITY_CODE=xxx`,重启 dev server |
|
||||
| ⑥ 条件加载 | 如果没有安全密钥,不设置 config(老 Key 不需要) |
|
||||
|
||||
---
|
||||
|
||||
## 三种 AMap 密钥的区别
|
||||
|
||||
| | JSAPI Key | Web Service Key | 安全密钥 |
|
||||
|---|---|---|---|
|
||||
| 环境变量 | `VITE_AMAP_JSAPI_KEY` | `VITE_AMAP_WEB_KEY` | `VITE_AMAP_SECURITY_CODE` |
|
||||
| 用途 | 地图展示、标记、折线 | 服务端 API(地理编码等) | 前端安全验证 |
|
||||
| 使用方式 | `AMapLoader.load({ key })` | 后端 fetch 请求 | `window._AMapSecurityConfig` |
|
||||
| 暴露风险 | 前端可见(无法避免) | **应仅在后端使用** | 前端可见(建议代理) |
|
||||
| 影响范围 | 无此 Key 地图无法加载 | 无此 Key 后端 API 不可用 | 2021/12 后新 Key 不配则 Driving/Geocoder 报错 |
|
||||
|
||||
---
|
||||
|
||||
## 考察点汇总
|
||||
|
||||
| 层级 | 考察点 | 难度 |
|
||||
|------|--------|------|
|
||||
| **设计模式** | 单例模式 + 两层缓存(Promise + 对象) | ⭐⭐⭐ |
|
||||
| **错误处理** | 失败清空 Promise 允许重试 | ⭐⭐ |
|
||||
| **时序约束** | `_AMapSecurityConfig` 必须在脚本加载之前 | ⭐⭐⭐⭐ |
|
||||
| **Vite** | `VITE_` 前缀的环境变量暴露机制 | ⭐⭐ |
|
||||
| **TypeScript** | `as const` 字面量类型收窄 | ⭐⭐ |
|
||||
| **调试** | INVALID_USER_SCODE 的系统化排查 | ⭐⭐⭐⭐ |
|
||||
|
||||
## 追问方向
|
||||
|
||||
### 追问 1:为什么不直接在 `index.html` 中加 `<script>` 标签加载 AMap?
|
||||
|
||||
**期望回答:**
|
||||
- 按需加载:仅在访问地图页面时才下载 600KB 脚本,首页不需要
|
||||
- 插件管理:通过 AMapLoader 统一管理插件版本
|
||||
- 微前端兼容:子应用独立控制加载时机,避免主应用 `<script>` 影响
|
||||
- 安全密钥时序:`AMapLoader.load()` 之前可以执行任意 JS 设置 `_AMapSecurityConfig`
|
||||
|
||||
### 追问 2:如果多个子应用都需要 AMap,`AMapGlobal` 会冲突吗?
|
||||
|
||||
**期望回答:** 如果多个子应用共用同一个域名,`AMapGlobal` 是模块级变量,在同一 JS 上下文中共享。如果子应用用 iframe 沙箱隔离(本项目的 `vue3-app` 配置了 `iframe: true`),则每个 iframe 有独立的 JS 上下文,各自加载自己的 AMap,不会冲突。
|
||||
|
||||
### 追问 3:`window._AMapSecurityConfig` 中还有一个 `serviceHost` 字段是干什么的?
|
||||
|
||||
**期望回答:** 用于代理模式。生产环境中不应把安全密钥写在前端代码里,可以通过 Nginx 反向代理转发:
|
||||
```nginx
|
||||
location /_AMapService/ {
|
||||
set $args "$args&jscode=真实密钥";
|
||||
proxy_pass https://restapi.amap.com/;
|
||||
}
|
||||
```
|
||||
前端只需配置代理地址而不暴露密钥:
|
||||
```ts
|
||||
window._AMapSecurityConfig = { serviceHost: '/_AMapService' }
|
||||
```
|
||||
396
docs/interview-question-geofence.md
Normal file
396
docs/interview-question-geofence.md
Normal file
@@ -0,0 +1,396 @@
|
||||
# 前端面试题:养老场景电子围栏 — 绘制 + 越界检测 + 声光报警
|
||||
|
||||
## 题目描述
|
||||
|
||||
在 Vue 3 + 高德地图的微前端子应用中,实现一个**养老院/社区电子围栏**系统:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ sub-app-header │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ 🚨 电子围栏报警!(越界时全屏覆盖) │
|
||||
│ ✅ 老人在安全区域内 (状态标签) │
|
||||
│ │
|
||||
│ 🗺️ 高德地图 │
|
||||
│ [绿色多边形围栏] + [👴 可拖拽老人] │
|
||||
│ │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ 🗺️ 电子围栏 [🔷绘制围栏][⭕圆形围栏][🗑️清除] │
|
||||
│ 🎯 绘制提示条(顶点计数 + 闭合提示) │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ 👴 老人控制 [➕添加][↑←→↓方向键][🗑️移除] │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ 📋 报警记录(右侧浮层) │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**核心要求:**
|
||||
|
||||
1. 支持**多边形**和**圆形**两种围栏绘制
|
||||
2. 老人标记可拖拽 + 方向键移动,每次移动**即时检测**越界
|
||||
3. 越界时触发**声光报警**:围栏变红、全屏浮层、蜂鸣音、报警记录
|
||||
4. 老人回到围栏内**自动解除**报警
|
||||
5. 组件卸载时**完整清理**所有地图覆盖物和事件监听
|
||||
|
||||
---
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 文件分层
|
||||
|
||||
```
|
||||
microapp-vue3/src/
|
||||
├── composables/
|
||||
│ ├── useAmap.ts ← 地图实例管理(单例加载 JSAPI、生命周期)
|
||||
│ └── useGeofence.ts ← 电子围栏核心逻辑(围栏、标记、检测、报警)
|
||||
├── views/
|
||||
│ └── MapView.vue ← 视图层(布局、按钮、报警 UI)
|
||||
├── config/amap.ts ← 密钥、版本、插件列表
|
||||
└── vite-env.d.ts ← AMap 类型声明(Polygon/Circle/Marker/GeometryUtil...)
|
||||
```
|
||||
|
||||
**关键设计决策:逻辑与视图分离。** `useGeofence` composable 封装所有地图操作,`MapView.vue` 只负责 UI 渲染和事件绑定。好处:
|
||||
|
||||
- 围栏逻辑可在其他页面复用(如多围栏管理页)
|
||||
- 单元测试只需测 composable,无需挂载 Vue 组件
|
||||
- 视图层更换 UI 框架(如 Element Plus 面板)不影响核心逻辑
|
||||
|
||||
---
|
||||
|
||||
## 核心技术点拆解
|
||||
|
||||
### 1. 围栏绘制 — 交互设计与踩坑
|
||||
|
||||
#### 多边形绘制流程
|
||||
|
||||
```
|
||||
点击「🔷 绘制围栏」
|
||||
→ map.setStatus({ doubleClickZoom: false }) ← 关键!禁用双击缩放
|
||||
→ 注册 map.on('click', onClick)
|
||||
→ 光标变 crosshair
|
||||
|
||||
每次单击:
|
||||
├─ 地理距离 < 30m 且 已有点 ≥ 3? → 闭环完成 ✅(点击首顶点)
|
||||
├─ 否则 → 添加顶点
|
||||
│ ├─ new AMap.Marker(编号圆点)
|
||||
│ ├─ new AMap.Polyline(虚线预览)
|
||||
│ └─ 点数 ≥ 3?→ 销毁首顶点 → 重建为红色脉冲高亮标记
|
||||
└─ 右键 → cancelDraw()
|
||||
|
||||
闭环(三种方式):
|
||||
├─ 点击红色脉冲的首顶点(Haversine < 30m)
|
||||
├─ 点击「✅ 闭合围栏(N点)」按钮
|
||||
└─ 创建 AMap.Polygon → 清理绘制中间状态 → 恢复 doubleClickZoom
|
||||
```
|
||||
|
||||
#### 圆形绘制流程
|
||||
|
||||
```
|
||||
点击「⭕ 圆形围栏」
|
||||
→ 第 1 次点击:确定圆心(红色标记)
|
||||
→ map.on('mousemove'):动态预览圆形(Haversine 实时计算半径)
|
||||
→ 第 2 次点击:确定半径
|
||||
→ 清理预览 Circle → 创建正式 Circle 围栏
|
||||
→ 右键:取消
|
||||
```
|
||||
|
||||
#### ⚠️ 踩坑记录(面试中最有区分度)
|
||||
|
||||
| 踩坑 | 现象 | 根因 | 修复 |
|
||||
|------|------|------|------|
|
||||
| **双击闭合** | 双击后地图缩放 + 多了 2 个废顶点 | AMap 默认 `doubleClickZoom: true` 拦截双击;双击 = click+click+dblclick | `map.setStatus({ doubleClickZoom: false })` + 改用点击首顶点闭合 |
|
||||
| **`lngLatToContainer({lng,lat})`** | 第 4 次点击报 `Pixel(NaN, NaN)` | AMap v2 不接受普通对象,只接受 `[lng,lat]` 或 `LngLat` 实例 | 用 **Haversine 地理距离**替换像素距离判断,不再调用此方法 |
|
||||
| **`marker.setContent()`** | 更新标记内容后内部状态损坏 → NaN | AMap v2 的 `setContent` 触发重新布局,新旧内容尺寸不同时偏移计算异常 | 改为 **destroy + new Marker** 重建,确保内部状态干净 |
|
||||
| **`e.pixel` 不可靠** | 偶发 NaN | 某些事件回调中 `e.pixel` 可能未被填充 | 一律使用 `e.lnglat` + Haversine,不依赖像素坐标 |
|
||||
| **scoped CSS 无效** | 自定义 Marker 样式不生效 | AMap 注入的 DOM 不带 Vue 的 `data-v-xxx` 属性 | 新增**非 scoped** 的 `<style>` 块,用全局 class 选择器 |
|
||||
|
||||
---
|
||||
|
||||
### 2. 老人标记管理
|
||||
|
||||
```ts
|
||||
// 创建:HTML content + draggable + dragend 自动检测
|
||||
const marker = new AMap.Marker({
|
||||
position,
|
||||
content: '<div class="geofence-person-marker">👴</div>', // ← CSS class,非 inline
|
||||
offset: new AMap.Pixel(-17, -17), // 34×34 居中
|
||||
draggable: true, // 允许拖拽
|
||||
zIndex: 200, // 高于围栏(100)和顶点(120)
|
||||
})
|
||||
|
||||
marker.on('dragend', () => {
|
||||
// 每次拖拽结束 → 更新位置 → 立即检测越界
|
||||
const pos = marker.getPosition()
|
||||
personPosition.value = [pos.lng, pos.lat]
|
||||
checkBoundary()
|
||||
})
|
||||
```
|
||||
|
||||
**两种移动方式:**
|
||||
|
||||
| 方式 | 实现 | 步长 | 适用场景 |
|
||||
|------|------|------|----------|
|
||||
| 拖拽 | `draggable: true` + `dragend` 事件 | 任意 | 手动测试、演示 |
|
||||
| 方向键 | `movePerson(direction)` ± 0.0005° | ~30-50m | 精确步进测试 |
|
||||
|
||||
**为什么步长用 0.0005° 而非固定米数?** 经纬度步长简单可靠,不依赖地图投影。对于养老场景的社区围栏(通常 100-500m 范围),像素级精度足够。
|
||||
|
||||
---
|
||||
|
||||
### 3. 越界检测 — 双算法兜底
|
||||
|
||||
```ts
|
||||
function checkBoundary(): void {
|
||||
const pp = personPosition.value
|
||||
if (!pp || !fenceOverlay.value) return
|
||||
|
||||
let inside: boolean
|
||||
|
||||
if (fenceType.value === 'circle') {
|
||||
// 圆形:AMap 内置 contains() 方法
|
||||
inside = (fenceOverlay.value as AMap.Circle).contains(pp)
|
||||
} else {
|
||||
// 多边形:优先 AMap.GeometryUtil,兜底射线法
|
||||
try {
|
||||
inside = AMap.GeometryUtil.isPointInRing(
|
||||
{ lng: pp[0], lat: pp[1] },
|
||||
fenceVertices.value
|
||||
)
|
||||
} catch {
|
||||
inside = isPointInPolygon(pp, fenceVertices.value) // 纯 JS 兜底
|
||||
}
|
||||
}
|
||||
|
||||
// 状态变迁检测:只在"进入→出去"时报警,"出去→进入"时解除
|
||||
const wasInside = isInside.value
|
||||
isInside.value = inside
|
||||
|
||||
if (!inside && wasInside) triggerAlarm(pp) // 刚刚越界
|
||||
if (inside && !wasInside) resetAlarm() // 刚刚回归
|
||||
}
|
||||
```
|
||||
|
||||
**为什么需要兜底?** `AMap.GeometryUtil` 在某些加载顺序下可能未就绪(JSAPI 异步加载)。射线法作为纯数学算法始终可用:
|
||||
|
||||
```
|
||||
射线法(Ray Casting):从目标点向右发射一条水平线
|
||||
→ 统计与多边形各边的交点数
|
||||
→ 奇数 = 内部,偶数 = 外部
|
||||
```
|
||||
|
||||
**状态变迁检测的设计意图:** 不直接用 `if (!inside) alert()`,而是追踪 `wasInside → isInside` 的变化。这样:
|
||||
- 持续在围栏外 → 不重复报警
|
||||
- 出去再进来再出去 → 每次跨越边界都报警(累计次数 + 历史记录)
|
||||
|
||||
---
|
||||
|
||||
### 4. 报警系统 — 五通道并行的设计模式
|
||||
|
||||
越界时**同时触发 5 个通道**,每个通道独立可关闭:
|
||||
|
||||
```
|
||||
triggerAlarm()
|
||||
├─ 通道 1:视觉-围栏
|
||||
│ updateFenceStyle(true)
|
||||
│ 绿色实线 → 红色虚线(fillColor/strokeColor 切换)
|
||||
│
|
||||
├─ 通道 2:视觉-全屏浮层
|
||||
│ alarmActive = true → v-if 渲染
|
||||
│ 全屏红色脉冲背景 + 震动弹窗 + 脉冲图标
|
||||
│ 点击任意处 → dismissAlarm()
|
||||
│
|
||||
├─ 通道 3:视觉-状态标签
|
||||
│ fence-status--safe → fence-status--alarm
|
||||
│ "✅ 安全" → "🚨 已越界!"
|
||||
│
|
||||
├─ 通道 4:听觉-蜂鸣
|
||||
│ startAlarmSound()
|
||||
│ Web Audio API → OscillatorNode 方波 880Hz
|
||||
│ 每 800ms 蜂鸣一次(setInterval)
|
||||
│ 0.3 秒 duration + exponentialRampToValueAtTime 淡出
|
||||
│
|
||||
└─ 通道 5:记录-历史
|
||||
alarmHistory.push({ time, position })
|
||||
右侧浮层实时展示
|
||||
```
|
||||
|
||||
**`updateFenceStyle` 的兼容处理:**
|
||||
|
||||
```ts
|
||||
// AMap 不同版本可能没有 setOptions,兜底用 Object.assign
|
||||
poly.setOptions?.(style) || Object.assign(poly as any, style)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 响应式设计 — `shallowRef` 的正确用法
|
||||
|
||||
```ts
|
||||
// ✅ 正确:shallowRef — 只在引用替换时触发更新
|
||||
const fenceOverlay = shallowRef<AMap.Polygon | AMap.Circle | null>(null)
|
||||
const personMarker = shallowRef<AMap.Marker | null>(null)
|
||||
|
||||
// ❌ 错误:ref — 会深度追踪 AMap 实例的数千个内部属性
|
||||
// const fenceOverlay = ref<AMap.Polygon | null>(null)
|
||||
// → setCenter/setZoom 等操作触发深度 diff → 性能灾难
|
||||
```
|
||||
|
||||
| 存储方式 | 适用场景 | 代价 |
|
||||
|----------|----------|------|
|
||||
| `shallowRef` | 地图实例、覆盖物(内部状态由 AMap 自行管理) | 无性能损耗 |
|
||||
| `ref` | 简单值(坐标数组、布尔、计数) | 需要响应式追踪 |
|
||||
| `computed` | 派生状态(`hasFence`、`hasPerson`) | 自动缓存 |
|
||||
|
||||
---
|
||||
|
||||
### 6. 生命周期清理 — 避免内存泄漏的完整链条
|
||||
|
||||
```ts
|
||||
onUnmounted(() => {
|
||||
destroyGeofence()
|
||||
├─ stopAlarmSound() // 清理 setInterval + Oscillator
|
||||
├─ clearFence() // map.remove(polygon/circle) + 重置状态
|
||||
├─ removePerson() // map.remove(marker) + 停止报警
|
||||
└─ cancelDraw() // 清理预览线/顶点/事件监听 + 恢复 doubleClickZoom
|
||||
})
|
||||
```
|
||||
|
||||
**事件监听的清理机制:**
|
||||
|
||||
```ts
|
||||
// 绑定:将 handler 引用存储到 map 实例的自定义属性上
|
||||
;(map as any)._geofenceClick = onClick
|
||||
map.on('click', onClick)
|
||||
|
||||
// 清理:通过引用精确 off,避免误删其他监听器
|
||||
const m = map as any
|
||||
if (m._geofenceClick) {
|
||||
map.off('click', m._geofenceClick)
|
||||
delete m._geofenceClick
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整调用链路图
|
||||
|
||||
```
|
||||
用户操作 → 电子围栏响应
|
||||
═══════════════════════════════════════════
|
||||
|
||||
┌─ 绘制 ─────────────────────────────────────┐
|
||||
│ [🔷绘制围栏] → startDrawPolygon() │
|
||||
│ → map.setStatus({ doubleClickZoom:false }) │
|
||||
│ → 逐一点击 → onClick(e) │
|
||||
│ → 每点:new Marker + new Polyline │
|
||||
│ → ≥3点:首顶点变红脉冲 │
|
||||
│ → 点击首顶点 / [✅闭合] → finishDraw() │
|
||||
│ → finishPolygonDraw() │
|
||||
│ → new AMap.Polygon(path, FENCE_STYLE) │
|
||||
│ → cleanupDrawing(map) │
|
||||
│ → map.setStatus({ doubleClickZoom:true})│
|
||||
└────────────────────────────────────────────┘
|
||||
|
||||
┌─ 老人 ─────────────────────────────────────┐
|
||||
│ [➕添加老人] → addPerson() │
|
||||
│ → new AMap.Marker({draggable:true}) │
|
||||
│ → marker.on('dragend', checkBoundary) │
|
||||
│ → checkBoundary() // 初始检测 │
|
||||
│ │
|
||||
│ [↑↓←→] / 拖拽 → movePerson() │
|
||||
│ → marker.setPosition([lng, lat]) │
|
||||
│ → checkBoundary() // 每次移动立即检测 │
|
||||
└────────────────────────────────────────────┘
|
||||
|
||||
┌─ 检测 ─────────────────────────────────────┐
|
||||
│ checkBoundary() │
|
||||
│ ├─ 无围栏 → isInside = true (skip) │
|
||||
│ ├─ 圆形 → Circle.contains(point) │
|
||||
│ └─ 多边形 → GeometryUtil.isPointInRing() │
|
||||
│ └─ 失败 → isPointInPolygon() │
|
||||
│ └─ wasInside && !inside → triggerAlarm() │
|
||||
│ └─ !wasInside && inside → resetAlarm() │
|
||||
└────────────────────────────────────────────┘
|
||||
|
||||
┌─ 报警 ─────────────────────────────────────┐
|
||||
│ triggerAlarm(position) │
|
||||
│ ├─ alarmCount++ │
|
||||
│ ├─ alarmHistory.push({time, position}) │
|
||||
│ ├─ updateFenceStyle(true) // 围栏变红虚线 │
|
||||
│ ├─ alarmActive = true // 浮层出现 │
|
||||
│ └─ startAlarmSound() // 蜂鸣开始 │
|
||||
│ │
|
||||
│ dismissAlarm() / resetAlarm() │
|
||||
│ ├─ alarmActive = false │
|
||||
│ ├─ updateFenceStyle(false) // 恢复绿色 │
|
||||
│ └─ stopAlarmSound() // 蜂鸣停止 │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 考察点汇总
|
||||
|
||||
| 层级 | 考察点 | 难度 |
|
||||
|------|--------|------|
|
||||
| **架构设计** | Composable 模式:逻辑/视图分离、单一职责 | ⭐⭐⭐ |
|
||||
| **响应式** | `shallowRef` vs `ref` vs `computed` 的选择理由 | ⭐⭐ |
|
||||
| **几何算法** | 射线法(Ray Casting)原理 + Haversine 公式 | ⭐⭐⭐⭐ |
|
||||
| **地图 SDK 踩坑** | `doubleClickZoom`、`lngLatToContainer` 参数格式、`setContent` 副作用 | ⭐⭐⭐⭐⭐ |
|
||||
| **交互设计** | 三种闭环方式(点击首顶点/按钮/右键取消)的 UX 考量 | ⭐⭐⭐ |
|
||||
| **事件管理** | 动态事件绑定/解绑、闭包捕获、内存泄漏防范 | ⭐⭐⭐ |
|
||||
| **Web Audio API** | OscillatorNode + GainNode + exponentialRampToValueAtTime | ⭐⭐⭐ |
|
||||
| **生命周期** | `onUnmounted` 中 4 步清理链(音效→围栏→标记→绘制态) | ⭐⭐ |
|
||||
| **CSS 隔离** | scoped vs 全局样式在 AMap 自定义标记中的应用 | ⭐⭐⭐ |
|
||||
| **状态机** | wasInside/inside 变迁检测避免重复报警 | ⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 加分项
|
||||
|
||||
- **能解释为什么不用 `AMap.MouseTool` 而手动处理 click 事件** — 更细粒度的交互控制(首顶点高亮、实时顶点计数、三通道闭环)
|
||||
- **能指出 `setContent` 的替代方案** — destroy + new Marker 重建,或使用 `setOptions`(如果 SDK 支持)
|
||||
- **能说明 `lngLatToContainer` 的参数格式陷阱** — AMap v2 只接受 `[lng, lat]` 数组或 `LngLat` 实例,不接受 `{lng, lat}` 普通对象
|
||||
- **能提出改进方向**:
|
||||
- 支持多个围栏 + 多个老人(一对多/多对多关系)
|
||||
- 使用 `watchPosition` 替代 `getCurrentPosition` 实现实时 GPS 追踪
|
||||
- 报警信息通过 WebSocket 推送到护工端
|
||||
- 围栏数据持久化(localStorage/后端)+ 编辑已有围栏的顶点
|
||||
- 使用 AMap 的 `PolygonEditor` 插件支持围栏二次编辑
|
||||
- **能分析 `movePerson` 的步长选择** — 0.0005° 约 30-50 米的经纬度换算
|
||||
|
||||
---
|
||||
|
||||
## 追问方向
|
||||
|
||||
### 追问 1:如果围栏有 100 个顶点,`isPointInPolygon` 的性能如何?
|
||||
|
||||
**期望回答:** 射线法时间复杂度 O(n),n 为顶点数。100 个顶点约 0.01ms。但养老场景围栏通常 4-20 个顶点,性能完全不是瓶颈。如果真有数千顶点,可以先用 bounding box 做粗筛。
|
||||
|
||||
### 追问 2:圆形围栏的 `contains()` 精度取决于什么?
|
||||
|
||||
**期望回答:** `AMap.Circle.contains()` 基于球面几何计算,精度受限于:
|
||||
1. 地球半径常量(AMap 使用 6378137m)
|
||||
2. 高纬度地区 Mercator 投影变形
|
||||
3. 极小的圆(<1m)可能受浮点精度影响
|
||||
对于养老场景(通常 100-1000m 半径),精度完全够用。
|
||||
|
||||
### 追问 3:`alarmActive` 为什么用 `ref` 而不用 `shallowRef`?
|
||||
|
||||
**期望回答:** `alarmActive` 是布尔值,不是对象。`ref(true)` 和 `shallowRef(true)` 对原始值行为完全相同——Vue 对原始类型不会深度追踪。但用 `ref` 语义更清晰("这是一个需要响应式的值")。
|
||||
|
||||
### 追问 4:如果老人恰好在围栏边界线上,`isPointInRing` 返回什么?
|
||||
|
||||
**期望回答:** 取决于浮点精度和算法实现。射线法在边界线上行为不确定(可能 true 也可能 false)。生产环境应该在边界 ±2m 加一个模糊区间(hysteresis),避免在边界反复触发报警/解除。
|
||||
|
||||
### 追问 5:`startAlarmSound` 中为什么要 try-catch?
|
||||
|
||||
**期望回答:**
|
||||
1. 部分浏览器(如 iOS Safari)要求 AudioContext 必须在用户手势中创建
|
||||
2. 某些企业环境可能禁用 Web Audio API
|
||||
3. setInterval 可能被浏览器节流(后台标签页降至 1s)
|
||||
4. 静默失败比页面崩溃更优雅——视觉报警仍然工作
|
||||
|
||||
### 追问 6:为什么不在 `drag` 事件中检测,而是 `dragend`?
|
||||
|
||||
**期望回答:** `drag` 事件每像素触发一次,拖拽 100px 就触发 100 次 `checkBoundary`,其中 99 次是浪费。`dragend` 只触发一次,刚好够。"边界穿越"在拖拽中段发生还是在终点发生,对报警结果无影响——报警只需要知道"最终位置是否在围栏外"。
|
||||
242
docs/interview-question-map-layout.md
Normal file
242
docs/interview-question-map-layout.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 前端面试题:全屏三区布局(header + 地图 + 操作栏)且无滚动条
|
||||
|
||||
## 题目描述
|
||||
|
||||
你需要实现一个**微前端子应用**的地图页面,布局要求如下:
|
||||
|
||||
```
|
||||
┌────────────────────────────────┐
|
||||
│ sub-app-header │ ← 高 48px,始终可见
|
||||
│ 🟢 Vue3 子应用 [首页][关于][地图] │
|
||||
├────────────────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ 🗺️ 高德地图 │ ← 占满剩余空间
|
||||
│ │
|
||||
│ │
|
||||
├────────────────────────────────┤
|
||||
│ 🗺️ 高德地图 [📍回到默认] [🎯我的位置] │ ← 高 52px,始终可见
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
|
||||
**核心要求:**
|
||||
|
||||
1. 页面**不能出现滚动条**,三个区域必须同时完整显示在视口内
|
||||
2. 地图区域需正确渲染(高德地图 JSAPI 要求容器有明确的像素尺寸)
|
||||
3. 该应用既要能在**微前端环境**中嵌入运行,也要能**独立运行**
|
||||
|
||||
---
|
||||
|
||||
## 初始代码(有问题)
|
||||
|
||||
```vue
|
||||
<!-- App.vue -->
|
||||
<template>
|
||||
<div id="microapp-vue3-container">
|
||||
<header class="sub-app-header">
|
||||
<h1>🟢 Vue3 子应用</h1>
|
||||
<nav>
|
||||
<router-link to="/">首页</router-link>
|
||||
<router-link to="/about">关于</router-link>
|
||||
<router-link to="/map">地图</router-link>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="sub-app-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#microapp-vue3-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.sub-app-header {
|
||||
height: 48px;
|
||||
flex-shrink: 0;
|
||||
/* ...其他样式省略 */
|
||||
}
|
||||
|
||||
.sub-app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- MapView.vue — 地图页面 -->
|
||||
<template>
|
||||
<div class="map-page">
|
||||
<div v-if="loading" class="map-status">⏳ 地图加载中...</div>
|
||||
<div v-else-if="error" class="map-status map-error">❌ {{ error }}</div>
|
||||
|
||||
<div ref="containerRef" class="map-container" />
|
||||
|
||||
<div class="map-controls">
|
||||
<span>🗺️ 高德地图</span>
|
||||
<div>
|
||||
<button @click="resetView">📍 回到默认位置</button>
|
||||
<button @click="getCurrentPosition">🎯 我的位置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
flex-shrink: 0;
|
||||
/* ... */
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**问题:** 页面出现了垂直滚动条,地图也没有正确渲染。
|
||||
|
||||
---
|
||||
|
||||
## 考察知识点
|
||||
|
||||
| 层级 | 知识点 | 具体体现 |
|
||||
|------|--------|----------|
|
||||
| **CSS 盒模型** | `height: 100%` 的继承链 | 百分比高度必须逐级传递,任意一环缺失都会断裂 |
|
||||
| **Flexbox** | `flex: 1` 与 `min-height: 0` | flex 子项默认 `min-height: auto`,可能导致溢出 |
|
||||
| **溢出控制** | `overflow` 的层级管理 | 在哪一层阻止溢出、哪一层允许滚动需要精心设计 |
|
||||
| **第三方库适配** | 地图 SDK 的容器约束 | AMap 要求容器有明确像素高度,flex 分配的隐式高度可能不生效 |
|
||||
| **微前端** | 子应用的挂载点与视口 | `100vh` vs `100%` 在不同环境下的差异 |
|
||||
|
||||
---
|
||||
|
||||
## 期望答案要点
|
||||
|
||||
### 1. 修复百分比高度继承链(关键)
|
||||
|
||||
```
|
||||
初始:html → body → #root(无高度)→ #container(100vh) ❌ 链断裂
|
||||
修复:html → body → #root → #container(全部 100%) ✅ 完整链路
|
||||
```
|
||||
|
||||
```css
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden; /* 根级截断,杜绝页面级滚动条 */
|
||||
}
|
||||
|
||||
#microapp-vue3-root {
|
||||
height: 100%; /* 关键:挂载点也需撑满 */
|
||||
}
|
||||
|
||||
#microapp-vue3-container {
|
||||
height: 100%; /* 用 % 而非 vh,适配微前端环境 */
|
||||
overflow: hidden;
|
||||
}
|
||||
```
|
||||
|
||||
**为什么 `100vh` 不够好?**
|
||||
- 移动端浏览器地址栏收起/展开会改变 `100vh` 的实际值
|
||||
- 微前端环境中,子应用容器不一定等于视口高度,`100%` 跟随父容器更稳健
|
||||
|
||||
### 2. flex 布局的溢出收缩
|
||||
|
||||
```css
|
||||
.sub-app-main {
|
||||
flex: 1;
|
||||
min-height: 0; /* 允许收缩到内容以下 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-page {
|
||||
flex: 1;
|
||||
min-height: 0; /* 同上 */
|
||||
overflow: hidden; /* 阻止地图页面自身溢出 */
|
||||
}
|
||||
```
|
||||
|
||||
**关键原理:** flex item 默认 `min-height: auto`,不允许收缩到内容高度以下;设为 `0` 后才真正服从 flex 分配。
|
||||
|
||||
### 3. AMap 容器必须有明确的尺寸
|
||||
|
||||
```css
|
||||
.map-container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 避免:display: none 导致容器尺寸为 0 */
|
||||
.map-hidden {
|
||||
visibility: hidden; /* ✅ 保留占位,地图可正常初始化 */
|
||||
/* display: none; */ /* ❌ 尺寸归零,地图无法渲染 */
|
||||
}
|
||||
```
|
||||
|
||||
配合 JS 端延迟 `resize`:
|
||||
```js
|
||||
onMounted(async () => {
|
||||
const map = await initMap()
|
||||
if (map) {
|
||||
nextTick(() => map.resize()) // 修正 flex 布局下的初始尺寸
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 4. 不同页面的滚动策略分离
|
||||
|
||||
地图页不需要滚动,但首页/关于页可能需要:
|
||||
|
||||
```css
|
||||
.sub-app-main {
|
||||
overflow-y: auto; /* 允许滚动 */
|
||||
}
|
||||
|
||||
.map-page {
|
||||
overflow: hidden; /* 地图页自己阻止溢出 */
|
||||
/* flex: 1 意味着它恰好填满父容器,不会触发父级的 overflow-y */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 加分项
|
||||
|
||||
- 能说出 `display: none` vs `visibility: hidden` 对第三方库 DOM 测量的影响
|
||||
- 能解释为什么在 `onMounted` 中调用 `map.resize()`,而不是 `onBeforeMount`
|
||||
- 能分析 `resizeEnable: true` 的 AMap 配置与手动 `resize()` 的配合关系
|
||||
- 能指出微前端场景下 `body { margin: 0 }` 不生效的边界情况(宿主页面的 body 不由子应用控制)
|
||||
|
||||
---
|
||||
|
||||
## 追问方向
|
||||
|
||||
1. **如果不用 flex,还有什么方案?** → grid、absolute + calc、vh 运算
|
||||
2. **如何保证在其他页面(首页/关于页)的滚动正常?** → 仅在父级 `.sub-app-main` 留 `overflow-y: auto`,子页面不设 `overflow: hidden`
|
||||
3. **`min-height: 0` 为什么是必须的?** → flex item 的隐含 `min-height: auto` 阻止收缩
|
||||
4. **如果地图加载失败,当前布局会崩吗?** → 状态提示用绝对定位浮层,不占 flex 空间,不影响布局
|
||||
469
docs/interview-question-my-location.md
Normal file
469
docs/interview-question-my-location.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# 前端面试题:高德地图"我的位置"功能完整实现
|
||||
|
||||
## 题目描述
|
||||
|
||||
在一个 Vue 3 + 高德地图的微前端子应用中,实现**"我的位置"**功能:用户点击按钮后,地图自动定位到当前设备所在地理位置,并将缩放级别调到 15(街道级)。
|
||||
|
||||
```
|
||||
┌────────────────────────────────┐
|
||||
│ sub-app-header │
|
||||
│ 🟢 Vue3 子应用 [首页][关于][地图] │
|
||||
├────────────────────────────────┤
|
||||
│ │
|
||||
│ 🗺️ 高德地图 │
|
||||
│ (当前在北京天安门) │
|
||||
│ │
|
||||
├────────────────────────────────┤
|
||||
│ 🗺️ 高德地图 [📍回到默认] [🎯 我的位置] │ ← 点击这个
|
||||
└────────────────────────────────┘
|
||||
|
||||
点击 🎯 我的位置 →
|
||||
① 检测浏览器是否支持定位
|
||||
② 调用浏览器 Geolocation API
|
||||
③ 提取经纬度
|
||||
④ 移动地图中心 → 用户所在位置,zoom = 15
|
||||
```
|
||||
|
||||
**要求:**
|
||||
|
||||
1. 在地图实例创建完成后,按钮才可点击(未就绪时 `disabled`)
|
||||
2. 必须处理:浏览器不支持定位、用户拒绝授权、定位超时等异常
|
||||
3. 地图实例通过 composable 管理,不能直接在组件中 `new AMap.Map()`
|
||||
4. 地图需要支持微前端环境(iframe 沙箱),注意权限策略差异
|
||||
|
||||
---
|
||||
|
||||
## 完整源码解读
|
||||
|
||||
### 1. 项目文件结构
|
||||
|
||||
```
|
||||
microapp-vue3/src/
|
||||
├── config/
|
||||
│ └── amap.ts ← JSAPI 密钥、版本、插件列表
|
||||
├── composables/
|
||||
│ └── useAmap.ts ← 地图实例管理(单例加载、生命周期)
|
||||
├── views/
|
||||
│ └── MapView.vue ← 地图页面 + "我的位置" 功能
|
||||
├── vite-env.d.ts ← AMap 全局类型声明
|
||||
├── App.vue ← 子应用根组件(header + router-view)
|
||||
├── main.ts ← 微前端生命周期(mount/unmount)
|
||||
└── router/index.ts ← 路由定义
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 配置文件 — 密钥与插件
|
||||
|
||||
```ts
|
||||
// src/config/amap.ts
|
||||
export const AMAP_JSAPI_KEY = import.meta.env.VITE_AMAP_JSAPI_KEY as string
|
||||
export const AMAP_VERSION = '2.0'
|
||||
|
||||
export const AMAP_PLUGINS = [
|
||||
'AMap.Geocoder', // 地理编码 / 逆地理编码
|
||||
'AMap.AutoComplete', // 输入提示
|
||||
'AMap.PlaceSearch', // 搜索服务
|
||||
'AMap.Geolocation', // ← 定位插件(已加载但当前未使用)
|
||||
'AMap.MarkerClusterer', // 点聚合
|
||||
] as const
|
||||
```
|
||||
|
||||
**关键细节:** `AMap.Geolocation` 是高德自己的定位插件(基于 IP + 基站),精度低于浏览器原生 GPS。**当前实现选择的是浏览器原生 `navigator.geolocation`,而非高德插件**——这是一个常见的架构决策点(见后文追问)。
|
||||
|
||||
---
|
||||
|
||||
### 3. 地图 Composable — 实例管理与单例加载
|
||||
|
||||
```ts
|
||||
// src/composables/useAmap.ts
|
||||
|
||||
// ① 全局单例 — 避免多次加载 JSAPI 脚本(~600KB)
|
||||
let amapPromise: Promise<typeof AMap> | null = null
|
||||
let AMapGlobal: typeof AMap | null = null
|
||||
|
||||
export async function loadAMap(): Promise<typeof AMap> {
|
||||
if (AMapGlobal) return AMapGlobal // 缓存命中
|
||||
|
||||
if (!amapPromise) {
|
||||
amapPromise = AMapLoader.load({
|
||||
key: AMAP_JSAPI_KEY,
|
||||
version: AMAP_VERSION,
|
||||
plugins: [...AMAP_PLUGINS],
|
||||
})
|
||||
.then((amap) => {
|
||||
AMapGlobal = amap
|
||||
return amap
|
||||
})
|
||||
.catch((err) => {
|
||||
amapPromise = null // ② 失败后可重试
|
||||
throw new Error(`高德地图 JSAPI 加载失败: ${err.message}`)
|
||||
})
|
||||
}
|
||||
return amapPromise
|
||||
}
|
||||
|
||||
export function useAmap(options: AMap.MapOptions = {}) {
|
||||
const containerRef = ref<HTMLDivElement | null>(null)
|
||||
const mapInstance = shallowRef<AMap.Map | null>(null) // ③ 浅响应式
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function initMap(): Promise<AMap.Map | null> {
|
||||
if (!containerRef.value) {
|
||||
error.value = '地图容器不存在'
|
||||
return null
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const AMap = await loadAMap()
|
||||
|
||||
if (mapInstance.value) {
|
||||
mapInstance.value.destroy() // ④ 先销毁旧实例
|
||||
}
|
||||
|
||||
const defaultOptions: AMap.MapOptions = {
|
||||
center: [116.397428, 39.90923], // 默认:北京天安门
|
||||
zoom: 11,
|
||||
viewMode: '3D',
|
||||
resizeEnable: true, // ⑤ 窗口 resize 时自动重绘
|
||||
}
|
||||
|
||||
mapInstance.value = new AMap.Map(containerRef.value, {
|
||||
...defaultOptions,
|
||||
...options, // ⑥ 调用方可覆盖默认值
|
||||
})
|
||||
|
||||
return mapInstance.value
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '地图初始化失败'
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function destroyMap(): void {
|
||||
if (mapInstance.value) {
|
||||
mapInstance.value.destroy()
|
||||
mapInstance.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => { destroyMap() }) // ⑦ 组件卸载自动清理
|
||||
|
||||
return { containerRef, mapInstance, loading, error, initMap, destroyMap }
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点:**
|
||||
|
||||
| 编号 | 技术点 | 说明 |
|
||||
|------|--------|------|
|
||||
| ① | **模块级单例** | `loadAMap()` 的 Promise 缓存在模块作用域,多次调用 `useAmap()` 不会重复下载 JSAPI |
|
||||
| ② | **失败重试** | catch 中将 `amapPromise` 置 `null`,下一次调用会重新加载 |
|
||||
| ③ | **`shallowRef`** | 地图实例是复杂第三方对象,不需要深度响应式追踪,`shallowRef` 只在引用变化时触发更新,节省性能 |
|
||||
| ④ | **销毁旧实例** | 如果多次调用 `initMap()`,先 `destroy()` 再 `new`,避免内存泄漏 |
|
||||
| ⑤ | **`resizeEnable`** | 容器尺寸变化时自动调用 `resize()`,在 flex 布局中尤其重要 |
|
||||
| ⑥ | **默认值合并** | 调用方传入的 options 覆盖默认值,支持自定义初始位置和缩放 |
|
||||
| ⑦ | **生命周期绑定** | `onUnmounted` 确保组件销毁时释放地图资源 |
|
||||
|
||||
---
|
||||
|
||||
### 4. "我的位置" — 核心实现
|
||||
|
||||
```vue
|
||||
<!-- src/views/MapView.vue -->
|
||||
<template>
|
||||
<div class="map-page">
|
||||
<div ref="containerRef" class="map-container"
|
||||
:class="{ 'map-hidden': loading || error }" />
|
||||
|
||||
<div class="map-controls">
|
||||
<span class="map-title">🗺️ 高德地图</span>
|
||||
<div class="map-actions">
|
||||
<button @click="resetView" :disabled="!mapInstance">📍 回到默认位置</button>
|
||||
<button @click="getCurrentPosition" :disabled="!mapInstance">🎯 我的位置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, nextTick } from 'vue'
|
||||
import { useAmap } from '@/composables/useAmap'
|
||||
|
||||
const { containerRef, mapInstance, loading, error, initMap } = useAmap({
|
||||
center: [116.397428, 39.90923],
|
||||
zoom: 12,
|
||||
pitch: 30,
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 地图初始化
|
||||
// ─────────────────────────────────────────
|
||||
async function bootstrapMap() {
|
||||
const map = await initMap()
|
||||
if (map) {
|
||||
nextTick(() => { map.resize() }) // flex 布局下修正地图尺寸
|
||||
}
|
||||
}
|
||||
onMounted(() => { bootstrapMap() })
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 📍 回到默认视图
|
||||
// ─────────────────────────────────────────
|
||||
function resetView(): void {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
map.setCenter([116.397428, 39.90923])
|
||||
map.setZoom(12)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────
|
||||
// 🎯 我的位置 — 核心功能
|
||||
// ─────────────────────────────────────────
|
||||
function getCurrentPosition(): void {
|
||||
// ① 能力检测 — 浏览器是否支持 Geolocation API
|
||||
if (navigator.geolocation) {
|
||||
// ② 调用原生 API
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
// ③ 成功回调 — 提取经纬度并移动地图
|
||||
(pos) => {
|
||||
const { longitude, latitude } = pos.coords
|
||||
mapInstance.value?.setCenter([longitude, latitude])
|
||||
mapInstance.value?.setZoom(15)
|
||||
},
|
||||
// ④ 失败回调 — 用户拒绝 / 超时 / 无法获取
|
||||
(err) => {
|
||||
alert(`定位失败: ${err.message}`)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// ⑤ 完全不支持 — HTTP 环境或老旧浏览器
|
||||
alert('浏览器不支持定位')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心知识点拆解
|
||||
|
||||
### 知识点 ①:`navigator.geolocation` vs `AMap.Geolocation`
|
||||
|
||||
| 维度 | `navigator.geolocation`(浏览器原生) | `AMap.Geolocation`(高德插件) |
|
||||
|------|--------------------------------------|-------------------------------|
|
||||
| **定位来源** | GPS + Wi-Fi + 基站 + IP | IP 定位 + 基站(无 GPS) |
|
||||
| **精度** | 高(室外可达 5-10 米) | 低(通常 100-500 米) |
|
||||
| **授权弹窗** | 浏览器原生弹窗(信任度高) | 无弹窗(静默获取) |
|
||||
| **HTTPS 要求** | **必须 HTTPS** 或 localhost | 无要求 |
|
||||
| **加载方式** | 浏览器内置,无需加载 | 需加载高德 JSAPI + Geolocation 插件 |
|
||||
| **使用场景** | "我的位置"精确导航 | 城市级粗略定位、IP 统计 |
|
||||
|
||||
**当前代码的架构决策:选择浏览器原生 API**。因为按钮文案是"我的位置",用户预期是精确的 GPS 定位,而非粗粒度的 IP 定位。
|
||||
|
||||
### 知识点 ②:`getCurrentPosition` 的参数与安全策略
|
||||
|
||||
```js
|
||||
navigator.geolocation.getCurrentPosition(success, error, options)
|
||||
```
|
||||
|
||||
**可选的第三个参数 `options`(当前未使用):**
|
||||
|
||||
```js
|
||||
navigator.geolocation.getCurrentPosition(success, error, {
|
||||
timeout: 10000, // 超时时间(ms),默认 Infinity
|
||||
maximumAge: 60000, // 缓存有效期(ms),0 = 强制重新获取
|
||||
enableHighAccuracy: true, // 高精度模式(启用 GPS),默认 false
|
||||
})
|
||||
```
|
||||
|
||||
**如果面试者能指出缺失的 `options`,说明有实际落地经验:**
|
||||
|
||||
- 不设 `timeout` → 用户在室内可能永远等不到回调
|
||||
- 不设 `enableHighAccuracy: true` → 浏览器可能只返回 IP 定位,精度很差
|
||||
- 不设 `maximumAge` → 每次点击都重新定位,没有利用缓存
|
||||
|
||||
### 知识点 ③:微前端环境下的 Geolocation 权限
|
||||
|
||||
```
|
||||
主应用 (microapp-main)
|
||||
└─ <micro-app name="vue3-app" iframe>
|
||||
└─ iframe → 子应用 (microapp-vue3)
|
||||
```
|
||||
|
||||
当子应用运行在 **iframe 沙箱**中时(`subApps.ts` 中 `iframe: true`),`navigator.geolocation` 的行为:
|
||||
|
||||
- **Chrome / Edge**:iframe 中的 `getCurrentPosition` 需要 iframe 的 `allow="geolocation"` 属性,否则直接触发错误回调
|
||||
- **Firefox**:会向上询问用户授权,相对宽松
|
||||
- **Safari**:iframe 中基本无法获取位置
|
||||
|
||||
**主应用需要额外配置 iframe 的 Permissions Policy:**
|
||||
|
||||
```html
|
||||
<!-- 主应用中嵌入子应用的 iframe 需要声明 -->
|
||||
<iframe allow="geolocation" src="..."></iframe>
|
||||
|
||||
<!-- 或通过 HTTP 响应头 -->
|
||||
Permissions-Policy: geolocation=(self "http://localhost:3001")
|
||||
```
|
||||
|
||||
**当前代码缺少这个配置**——这是一个真实的技术债,也是面试中考察微前端经验的好切入点。
|
||||
|
||||
### 知识点 ④:`shallowRef` 的地图实例存储
|
||||
|
||||
```ts
|
||||
const mapInstance = shallowRef<AMap.Map | null>(null)
|
||||
```
|
||||
|
||||
| 对比 | `ref()` | `shallowRef()` |
|
||||
|------|---------|-----------------|
|
||||
| 深度追踪 | ✅ 递归追踪内部属性 | ❌ 仅追踪 `.value` 的引用替换 |
|
||||
| `map.setCenter()` 后 | 触发不必要的重渲染 | **不触发**(引用没变) |
|
||||
| `map.setZoom()` 后 | 触发不必要的重渲染 | **不触发** |
|
||||
| 性能 | 差(每次地图操作都 diff) | 好(只有销毁/重建才触发) |
|
||||
|
||||
**关键原理:** 地图实例内部状态的变更(平移、缩放、标记点增删)都不应该触发 Vue 的响应式更新——地图自己管理自己的 DOM。`shallowRef` 正是为此场景设计。
|
||||
|
||||
### 知识点 ⑤:按钮的 `disabled` 守卫
|
||||
|
||||
```html
|
||||
<button @click="getCurrentPosition" :disabled="!mapInstance">🎯 我的位置</button>
|
||||
```
|
||||
|
||||
**防护链:**
|
||||
|
||||
```
|
||||
mapInstance 为 null(地图未就绪)
|
||||
→ button disabled → 无法点击 → getCurrentPosition 不会执行
|
||||
→ 同时函数内部仍有 mapInstance.value?.setCenter() 的 ?. 守卫
|
||||
```
|
||||
|
||||
这是**双重防护**:
|
||||
1. **UI 层守卫**:`disabled` 阻止用户操作
|
||||
2. **逻辑层守卫**:`?.` 可选链防止异步竞态(例如在定位回调返回前地图被销毁)
|
||||
|
||||
### 知识点 ⑥:异步初始化窗口 — 竞态条件
|
||||
|
||||
```
|
||||
用户操作时间线:
|
||||
t=0 页面加载,onMounted → initMap() 开始
|
||||
t=50 initMap 还在加载 JSAPI...
|
||||
t=100 用户疯狂点击 "我的位置" → 按钮 disabled(mapInstance 为 null) ✅ 被拦截
|
||||
t=500 initMap 完成,mapInstance 赋值
|
||||
t=600 用户点击 "我的位置" → getCurrentPosition 执行 ✅ 正常工作
|
||||
```
|
||||
|
||||
**如果没有 `:disabled="!mapInstance"`:**
|
||||
|
||||
```
|
||||
t=100 getCurrentPosition() 中的 mapInstance.value?.setCenter()
|
||||
→ undefined?.setCenter() 不报错但什么也不做
|
||||
→ 地图不动,用户困惑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整调用链路图
|
||||
|
||||
```
|
||||
用户点击 🎯 我的位置
|
||||
│
|
||||
├─ button :disabled="!mapInstance"
|
||||
│ └─ mapInstance.value 是否为 null?
|
||||
│ ├─ null → 按钮灰色,事件不触发 【终止】
|
||||
│ └─ 有值 → 继续
|
||||
│
|
||||
├─ getCurrentPosition()
|
||||
│ │
|
||||
│ ├─ ① navigator.geolocation 是否存在?
|
||||
│ │ ├─ 不存在 → alert('浏览器不支持定位') 【终止】
|
||||
│ │ └─ 存在 → 继续
|
||||
│ │
|
||||
│ ├─ ② navigator.geolocation.getCurrentPosition(success, error)
|
||||
│ │ │
|
||||
│ │ ├─ 浏览器弹出授权弹窗:"localhost 想要获取您的位置"
|
||||
│ │ │ ├─ 用户拒绝 → error({ code: 1, message: "User denied" })
|
||||
│ │ │ │ └─ alert('定位失败: User denied Geolocation') 【终止】
|
||||
│ │ │ └─ 用户允许 → 浏览器开始获取位置
|
||||
│ │ │ ├─ 超时/信号弱 → error({ code: 3, message: "Timeout" })
|
||||
│ │ │ │ └─ alert('定位失败: Timeout expired') 【终止】
|
||||
│ │ │ └─ 成功 → success(pos)
|
||||
│ │ │ │
|
||||
│ │ │ ├─ ③ const { longitude, latitude } = pos.coords
|
||||
│ │ │ │ └─ pos.coords 还包含:
|
||||
│ │ │ │ · accuracy(精度,米)
|
||||
│ │ │ │ · altitude(海拔)
|
||||
│ │ │ │ · heading(方向角)
|
||||
│ │ │ │ · speed(速度,m/s)
|
||||
│ │ │ │
|
||||
│ │ │ ├─ ④ mapInstance.value?.setCenter([lng, lat])
|
||||
│ │ │ │ └─ 地图中心平移到用户位置
|
||||
│ │ │ │
|
||||
│ │ │ └─ ⑤ mapInstance.value?.setZoom(15)
|
||||
│ │ │ └─ 缩放到街道级(默认 zoom=12 是城市级)
|
||||
│ │ │
|
||||
│ │ └─ 注意:当前实现没有传 options 参数!
|
||||
│ │ 建议补充 { timeout, enableHighAccuracy, maximumAge }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 考察点汇总
|
||||
|
||||
| 层级 | 考察点 | 难度 |
|
||||
|------|--------|------|
|
||||
| **API 选型** | `navigator.geolocation` vs `AMap.Geolocation` 的取舍 | ⭐⭐⭐ |
|
||||
| **响应式** | 为何用 `shallowRef` 而非 `ref` 存储地图实例 | ⭐⭐ |
|
||||
| **异步安全** | `disabled` + `?.` 双重守卫防止竞态 | ⭐⭐ |
|
||||
| **单例模式** | JSAPI 加载的模块级缓存与失败重试 | ⭐⭐⭐ |
|
||||
| **微前端** | iframe 沙箱中 geolocation 的 Permissions Policy | ⭐⭐⭐⭐ |
|
||||
| **安全策略** | HTTPS 要求、授权弹窗、用户拒绝处理 | ⭐⭐ |
|
||||
| **生命周期** | `onUnmounted` 销毁地图、`onMounted` 初始化 | ⭐ |
|
||||
| **错误处理** | 三层 fallback:浏览器不支持 → 授权拒绝 → 超时 | ⭐⭐ |
|
||||
| **用户体验** | 缺少 loading 状态、缺少定位失败后的降级 UI | ⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 加分项
|
||||
|
||||
- **能说出 `getCurrentPosition` 的 `options` 三个参数及其合理默认值**
|
||||
- **能指出 iframe 沙箱下 geolocation 需要主应用配合配置 `Permissions-Policy`**
|
||||
- **能分析为何 `AMap.Geolocation` 已加载却未使用** — 精度不够,不适合"我的位置"场景
|
||||
- **能提出改进方案**:
|
||||
- 优先用 `navigator.geolocation`(高精度),fallback 到 `AMap.Geolocation`(低精度但可用)
|
||||
- 定位过程中按钮显示 loading 态(如 spinner + "定位中...")
|
||||
- 定位成功后在地图上添加用户位置标记点(Marker)
|
||||
- 用 `watchPosition` 替代 `getCurrentPosition` 实现实时追踪
|
||||
- **能指出 `pos.coords.accuracy` 可用于在地图上绘制精度圈**
|
||||
|
||||
---
|
||||
|
||||
## 追问方向
|
||||
|
||||
### 追问 1:如果定位失败,如何优雅降级?
|
||||
|
||||
**期望回答:**
|
||||
```
|
||||
navigator.geolocation(GPS,高精度)
|
||||
↓ 失败/不支持
|
||||
AMap.Geolocation(IP 定位,低精度,但一定能拿到城市级位置)
|
||||
↓ 也失败
|
||||
默认位置(北京天安门) + Toast 提示
|
||||
```
|
||||
|
||||
### 追问 2:为什么当前代码没用 `AMap.Geolocation` 却加载了它的插件?
|
||||
|
||||
**期望回答:** 这是预留能力。插件列表是静态配置,加载后该功能即可用。如果未来需要定位功能(如 POI 搜索中的"附近"功能),不需要改配置重新加载 JSAPI。代价是首屏多加载 ~15KB 的插件代码——一个有意为之的权衡。
|
||||
|
||||
### 追问 3:如果在 Vue 3 的 `watchEffect` 中调用 `map.setCenter()`,会发生什么?
|
||||
|
||||
**期望回答:** 如果 `mapInstance` 用 `ref()` 存储,`setCenter` 会改变地图内部状态,Vue 的响应式系统会追踪到这些变化并触发 `watchEffect` 重新执行,形成死循环。`shallowRef` 避免了这个问题。
|
||||
|
||||
### 追问 4:微前端 iframe 沙箱中 `navigator.geolocation` 失效的根本原因?
|
||||
|
||||
**期望回答:** 浏览器的 Permissions Policy(原 Feature Policy)要求跨域 iframe 必须显式声明 `allow="geolocation"` 属性。`@micro-zoe/micro-app` 的 iframe 沙箱创建的是同源 iframe,但浏览器的权限模型仍然将其视为独立上下文,需要主应用在 `<micro-app>` 标签或框架配置中透传该 permission。
|
||||
200
docs/interview-question-route-track.md
Normal file
200
docs/interview-question-route-track.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# 前端面试题:地图路线规划 + 轨迹动画系统
|
||||
|
||||
## 题目描述
|
||||
|
||||
在 Vue 3 + 高德地图的养老/医疗微前端应用中,实现一个**护理员轨迹系统**:
|
||||
|
||||
```
|
||||
操作流程:
|
||||
[📍标记起点] → 点击地图放 📍
|
||||
[🏁标记终点] → 点击地图放 🏁
|
||||
[➕添加护理员] → 在起点创建 👩⚕️
|
||||
[🚗规划路线] → AMap.Driving 计算驾车路线 → 蓝色折线
|
||||
[▶出发] → 护理员沿路线动画移动
|
||||
|
||||
不管路径 500km 还是 2000km,动画始终 ~10 秒丝滑播完
|
||||
```
|
||||
|
||||
**核心要求:**
|
||||
|
||||
1. 起点和终点通过**点击地图手动标记**(独立 `AMap.Marker`)
|
||||
2. 驾车路线使用 `AMap.Driving` 规划(真实道路路径,非直线)
|
||||
3. 护理员标记沿路线**动画移动**,无论路径多长都流畅无卡顿
|
||||
4. 支持暂停、回到起点、全部清除
|
||||
5. 护理员不存在时点击"出发"要**自动创建**(容错 UX)
|
||||
|
||||
---
|
||||
|
||||
## 架构设计
|
||||
|
||||
```
|
||||
useRouteTrack(mapInstance)
|
||||
├─ enterSetMode('origin'|'dest') ← 点击地图放标记
|
||||
├─ addCaregiver() ← 在起点创建护理员
|
||||
├─ planRoute() ← AMap.Driving 路线规划
|
||||
│ └─ downsamplePath(full, 250) ← ★ 路径下采样
|
||||
├─ startAnimation() ← setInterval 帧动画
|
||||
└─ clearAll() ← 完整清理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心技术点
|
||||
|
||||
### 1. 路径下采样 — 丝滑动画的秘密
|
||||
|
||||
```
|
||||
AMap.Driving 返回原始路径:5000+ 个点(每几米一个)
|
||||
↓ downsamplePath(fullPath, 250)
|
||||
动画路径:250 个点(均匀间隔)
|
||||
↓ setInterval(40ms) → 25fps
|
||||
总时长:250 × 40ms = 10 秒(恒定)
|
||||
```
|
||||
|
||||
**为什么需要下采样?**
|
||||
|
||||
| 不用下采样 | 用下采样 |
|
||||
|------------|----------|
|
||||
| 5000 点 × 40ms = **200 秒**(太慢) | 250 点 × 40ms = **10 秒**(刚好) |
|
||||
| 短距离只有 200 点 = 8 秒(不一致) | 始终 250 点 = **恒定 10 秒** |
|
||||
| 每个点间隔 ~1m,动画抖动 | 均匀间隔,**视觉丝滑** |
|
||||
|
||||
**下采样算法:**
|
||||
|
||||
```ts
|
||||
function downsamplePath(path: [number, number][], target: number): [number, number][] {
|
||||
if (path.length <= target) return [...path]
|
||||
const result: [number, number][] = []
|
||||
const step = (path.length - 1) / (target - 1) // 5000/249 ≈ 20.08
|
||||
for (let i = 0; i < target; i++) {
|
||||
result.push(path[Math.min(Math.round(i * step), path.length - 1)])
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
**为什么选 250?**
|
||||
- 250 × 40ms = 10 秒,不短不长,刚好展示完整轨迹
|
||||
- 25fps 是人眼感知流畅的最低帧率,再低会卡顿
|
||||
- 250 个 `setPosition` 调用对 DOM 压力极小
|
||||
|
||||
### 2. 帧动画引擎
|
||||
|
||||
```ts
|
||||
async function startAnimation(): Promise<void> {
|
||||
// 🔧 容错:护理员不存在 → 自动在起点创建
|
||||
if (!caregiverMarker.value) {
|
||||
if (!originPoint.value) { alert('请先标记起点'); return }
|
||||
await addCaregiver()
|
||||
}
|
||||
|
||||
const total = animPath.value.length - 1 // 249 步
|
||||
|
||||
animTimer = setInterval(() => {
|
||||
if (animStep.value >= total) {
|
||||
stopAnimation() // 到达终点自动停
|
||||
animProgress.value = 1
|
||||
return
|
||||
}
|
||||
animStep.value++
|
||||
animProgress.value = animStep.value / total // 0→1 驱动进度条
|
||||
caregiverMarker.value?.setPosition(animPath.value[animStep.value])
|
||||
}, 40) // 25fps
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 帧间隔 | 40ms | 25fps,人眼流畅感知阈值 |
|
||||
| 总步数 | 249 | 250 个路径点,首点即起始位置 |
|
||||
| 总时长 | ~10s | 恒定,不受实际距离影响 |
|
||||
| 进度计算 | `step/total` | 0→1,驱动 CSS 进度条宽度 |
|
||||
|
||||
### 3. 三种 status 的完整错误处理
|
||||
|
||||
```ts
|
||||
driving.search(originLngLat, destLngLat, (status, result) => {
|
||||
if (status === 'complete' && result.routes?.length > 0) {
|
||||
// ✅ 成功:提取路径 → 下采样 → 绘制折线
|
||||
} else if (status === 'error') {
|
||||
// ❌ 参数/权限问题:INVALID_USER_SCODE(缺安全密钥)等
|
||||
reject(new Error(`路线规划失败: ${result.info || result.message}`))
|
||||
} else if (status === 'no_data') {
|
||||
// ⚠️ 起终点之间确实无路(如海岛 → 内陆无桥梁)
|
||||
reject(new Error('起终点之间无可用路线'))
|
||||
} else {
|
||||
// ⁉️ 未知状态,不应发生
|
||||
reject(new Error(`路线规划失败 (${status})`))
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 4. 点击地图放标记 — 模式切换设计
|
||||
|
||||
```
|
||||
enterSetMode('origin' | 'dest')
|
||||
→ exitSetMode() // 先退出当前模式(互斥)
|
||||
→ settingMode = mode // origin 或 dest
|
||||
→ map.setDefaultCursor('crosshair')
|
||||
→ map.on('click', handler)
|
||||
→ 点击地图:
|
||||
→ 获取经纬度
|
||||
→ mode === 'origin' ? applyOrigin() : applyDestination()
|
||||
→ exitSetMode() // 单次点击后自动退出
|
||||
```
|
||||
|
||||
**三个关键设计决策:**
|
||||
|
||||
| 决策 | 理由 |
|
||||
|------|------|
|
||||
| 单次点击后自动退出 | 避免用户误放多个标记 |
|
||||
| origin 和 dest 互斥 | 同一时间只能设一种点,防止状态混乱 |
|
||||
| 退出时恢复光标 + 解绑事件 | `exitSetMode()` 统一清理,防止事件泄漏 |
|
||||
|
||||
---
|
||||
|
||||
## 考察点汇总
|
||||
|
||||
| 层级 | 考察点 | 难度 |
|
||||
|------|--------|------|
|
||||
| **算法** | 路径下采样(均匀间隔重采样) | ⭐⭐⭐ |
|
||||
| **动画** | setInterval 帧动画 + 进度归一化 | ⭐⭐ |
|
||||
| **API 集成** | AMap.Driving 三种 status 的完整处理 | ⭐⭐⭐ |
|
||||
| **交互设计** | 模式切换 + 点击地图放标记 | ⭐⭐ |
|
||||
| **容错设计** | 护理员缺失时自动创建 | ⭐ |
|
||||
| **资源管理** | setInterval 清理 + 事件解绑 | ⭐⭐ |
|
||||
| **调试** | INVALID_USER_SCODE 问题排查 | ⭐⭐⭐⭐ |
|
||||
|
||||
## 加分项
|
||||
|
||||
- **能解释为什么 250 个点** — 250×40ms=10s 恒定,与距离无关
|
||||
- **能提出变速方案** — 起步加速/到站减速(easeInOutCubic),用动态 interval 替代固定 40ms
|
||||
- **能指出 `new AMap.LngLat(lng, lat)` 的必要性** — 数组 `[lng, lat]` 在某些版本不被 Driving 接受
|
||||
- **能说明 `map: undefined` 会导致 Driving 失败** — Driving 依赖 map 做投影计算
|
||||
- **能分析不同距离下的优化策略** — 短距离提高 target 点数(更细腻),长距离降低(防卡顿)
|
||||
|
||||
## 追问方向
|
||||
|
||||
### 追问 1:如果路线有 10 万个点,250 的下采样会丢失多少细节?
|
||||
|
||||
**期望回答:** 10 万 → 250,每 400 个点取 1 个,城市街道级别的拐弯会被抹平。但视觉上因为标记移动只显示一个点,250 个关键帧足以模拟沿途运动。如果需要更高精度,可以动态调整 target:短距离(<50km)用 500 点,长距离(>500km)用 150 点。
|
||||
|
||||
### 追问 2:为什么用 `setInterval` 而不用 `requestAnimationFrame`?
|
||||
|
||||
**期望回答:**
|
||||
- `rAF` 60fps → 250 点只需 4 秒(太快)
|
||||
- `rAF` 帧率不稳定(后台标签页降到 1fps)→ 动画时间不可预测
|
||||
- `setInterval(40)` 稳定 25fps,后台自动暂停(浏览器节流),回来时从当前位置继续
|
||||
- 需要配合进度条时需要可知的总时长,`setInterval` 更可控
|
||||
|
||||
### 追问 3:`startAnimation` 中为什么要 `await addCaregiver()`?
|
||||
|
||||
**期望回答:** `addCaregiver()` 内部调用 `loadAMap()` 是异步的。如果护理员不存在,需要等标记创建完成再开始动画。`await` 确保 `caregiverMarker.value` 已被赋值,后续 `setInterval` 中才能正常调用 `setPosition`。
|
||||
|
||||
### 追问 4:如何让动画支持「可调速」?
|
||||
|
||||
**期望回答:** 将 40ms 提取为变量 `speed`,暴露给外部。速度加倍 = interval 减半:
|
||||
```ts
|
||||
const speedMap = { '1x': 40, '2x': 20, '4x': 10 }
|
||||
// 或动态计算,进度条相应调整
|
||||
```
|
||||
789
src/composables/useGeofence.ts
Normal file
789
src/composables/useGeofence.ts
Normal file
@@ -0,0 +1,789 @@
|
||||
// ============================================================
|
||||
// 电子围栏 Composable — 养老/医疗场景
|
||||
//
|
||||
// 功能:
|
||||
// 1. 绘制围栏(多边形 / 圆形)
|
||||
// 2. 添加老人标记(👴 可拖拽 + 方向键移动)
|
||||
// 3. 越界检测(围栏 vs 老人位置)
|
||||
// 4. 声光报警(越界时触发)
|
||||
// ============================================================
|
||||
|
||||
import { ref, shallowRef, computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { loadAMap } from './useAmap'
|
||||
|
||||
// ─── 常量 ────────────────────────────────────────────
|
||||
|
||||
/** 围栏默认样式 */
|
||||
const FENCE_STYLE = {
|
||||
fillColor: 'rgba(66, 184, 131, 0.18)',
|
||||
strokeColor: '#42b883',
|
||||
strokeWeight: 2,
|
||||
strokeOpacity: 0.9,
|
||||
strokeStyle: 'solid' as const,
|
||||
}
|
||||
|
||||
/** 围栏告警样式 */
|
||||
const FENCE_ALARM_STYLE = {
|
||||
fillColor: 'rgba(255, 77, 79, 0.25)',
|
||||
strokeColor: '#FF4D4F',
|
||||
strokeWeight: 3,
|
||||
strokeOpacity: 1,
|
||||
strokeStyle: 'dashed' as const,
|
||||
}
|
||||
|
||||
/** 绘制时预览线样式 */
|
||||
const DRAW_LINE_STYLE = {
|
||||
strokeColor: '#42b883',
|
||||
strokeWeight: 2,
|
||||
strokeOpacity: 0.6,
|
||||
strokeStyle: 'dashed' as const,
|
||||
}
|
||||
|
||||
/** 老人标记 HTML(使用全局 CSS 类) */
|
||||
const PERSON_MARKER_HTML = '<div class="geofence-person-marker">👴</div>'
|
||||
|
||||
/** 普通顶点标记(使用全局 CSS 类) */
|
||||
function vertexMarkerHTML(index: number): string {
|
||||
return `<div class="geofence-vertex-dot">${index}</div>`
|
||||
}
|
||||
|
||||
// ─── 工具函数 ────────────────────────────────────────
|
||||
|
||||
/** 射线法判断点是否在多边形内(兜底方案,AMap 不内置此方法) */
|
||||
function isPointInPolygon(
|
||||
point: [number, number],
|
||||
polygon: [number, number][]
|
||||
): boolean {
|
||||
const [x, y] = point
|
||||
let inside = false
|
||||
const n = polygon.length
|
||||
for (let i = 0, j = n - 1; i < n; j = i++) {
|
||||
const [xi, yi] = polygon[i]
|
||||
const [xj, yj] = polygon[j]
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
// ─── 类型 ────────────────────────────────────────────
|
||||
|
||||
export interface AlarmRecord {
|
||||
time: string
|
||||
position: [number, number]
|
||||
}
|
||||
|
||||
// ─── Composable ──────────────────────────────────────
|
||||
|
||||
export function useGeofence(mapInstance: Ref<AMap.Map | null>) {
|
||||
// ========== 状态 ==========
|
||||
|
||||
/** 当前围栏覆盖物 */
|
||||
const fenceOverlay = shallowRef<AMap.Polygon | AMap.Circle | null>(null)
|
||||
/** 围栏类型 */
|
||||
const fenceType = ref<'polygon' | 'circle' | null>(null)
|
||||
/** 圆形围栏参数 */
|
||||
const circleCenter = ref<[number, number] | null>(null)
|
||||
const circleRadius = ref(0)
|
||||
/** 多边形顶点(用于出入判断) */
|
||||
const fenceVertices = ref<[number, number][]>([])
|
||||
|
||||
/** 是否正在绘制 */
|
||||
const isDrawing = ref(false)
|
||||
/** 绘制时的预览线 */
|
||||
const drawLine = shallowRef<AMap.Polyline | null>(null)
|
||||
/** 绘制时的顶点标记列表 */
|
||||
const vertexMarkers = shallowRef<AMap.Marker[]>([])
|
||||
/** 当前已绘制的顶点数(用于模板判断是否 >= 3 个) */
|
||||
const drawVertexCount = ref(0)
|
||||
|
||||
/** 老人标记 */
|
||||
const personMarker = shallowRef<AMap.Marker | null>(null)
|
||||
/** 老人当前位置 */
|
||||
const personPosition = ref<[number, number] | null>(null)
|
||||
|
||||
/** 老人是否在围栏内 */
|
||||
const isInside = ref(true)
|
||||
/** 报警是否激活 */
|
||||
const alarmActive = ref(false)
|
||||
/** 越界次数 */
|
||||
const alarmCount = ref(0)
|
||||
/** 报警历史 */
|
||||
const alarmHistory = ref<AlarmRecord[]>([])
|
||||
/** 报警音效振荡器引用 */
|
||||
let alarmOscillator: OscillatorNode | null = null
|
||||
let alarmGain: GainNode | null = null
|
||||
let alarmInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 是否已有围栏(可用于模板判断) */
|
||||
const hasFence = computed(() => fenceOverlay.value !== null)
|
||||
/** 是否已有老人标记 */
|
||||
const hasPerson = computed(() => personMarker.value !== null)
|
||||
|
||||
// ========== 围栏绘制 ==========
|
||||
|
||||
/** 绘制时的暂存顶点(闭包内引用,供 finishDraw 访问) */
|
||||
let _drawPoints: [number, number][] = []
|
||||
|
||||
/**
|
||||
* 启动多边形围栏绘制
|
||||
*
|
||||
* 交互:
|
||||
* - 在地图上逐一点击添加顶点(绿色编号圆点 + 虚线连线)
|
||||
* - 点击首顶点附近(±15 像素范围)→ 闭环完成 ✅
|
||||
* - 点击「闭合围栏」按钮 → 闭环完成 ✅
|
||||
* - 右键或 Esc → 取消 🗑️
|
||||
*
|
||||
* 解决双击冲突:禁用 AMap 的双击缩放(doubleClickZoom = false),
|
||||
* 避免双击 = click+click+dblclick 的竞态。
|
||||
*/
|
||||
async function startDrawPolygon(): Promise<void> {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
|
||||
// 清理上一个绘制状态
|
||||
cancelDraw()
|
||||
|
||||
const AMap = await loadAMap()
|
||||
|
||||
const points: [number, number][] = []
|
||||
_drawPoints = points
|
||||
isDrawing.value = true
|
||||
fenceType.value = 'polygon'
|
||||
drawVertexCount.value = 0
|
||||
|
||||
// 关键:禁用双击缩放,否则 dblclick 被 AMap 消耗
|
||||
map.setStatus({ doubleClickZoom: false })
|
||||
|
||||
// ── 点击事件:添加顶点 或 闭环 ──
|
||||
function onClick(e: any): void {
|
||||
// 安全获取经纬度(兼容 LngLat 实例 / 普通对象 / getter 多种形式)
|
||||
const lnglat = e.lnglat
|
||||
const lng: number = typeof lnglat.lng === 'number'
|
||||
? lnglat.lng
|
||||
: lnglat.getLng?.() ?? NaN
|
||||
const lat: number = typeof lnglat.lat === 'number'
|
||||
? lnglat.lat
|
||||
: lnglat.getLat?.() ?? NaN
|
||||
|
||||
if (isNaN(lng) || isNaN(lat)) return
|
||||
|
||||
const clicked: [number, number] = [lng, lat]
|
||||
|
||||
// 已有 >= 3 个顶点 + 点击位置接近首顶点(地理距离 < 30 米) → 闭环
|
||||
if (points.length >= 3) {
|
||||
const distToFirst = haversineDistance(clicked, points[0])
|
||||
if (distToFirst < 30) {
|
||||
finishPolygonDraw(points, map!, AMap)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 普通点击:添加顶点
|
||||
points.push(clicked)
|
||||
drawVertexCount.value = points.length
|
||||
|
||||
// 添加编号顶点标记
|
||||
const vm = new AMap.Marker({
|
||||
position: clicked,
|
||||
content: vertexMarkerHTML(points.length),
|
||||
offset: new AMap.Pixel(-6, -6),
|
||||
zIndex: 120,
|
||||
})
|
||||
map!.add(vm)
|
||||
vertexMarkers.value = [...vertexMarkers.value, vm]
|
||||
|
||||
// 更新预览线
|
||||
if (drawLine.value) {
|
||||
drawLine.value.setPath(points)
|
||||
} else {
|
||||
drawLine.value = new AMap.Polyline({
|
||||
path: points,
|
||||
...DRAW_LINE_STYLE,
|
||||
zIndex: 110,
|
||||
})
|
||||
map!.add(drawLine.value)
|
||||
}
|
||||
|
||||
// 更新首顶点样式:销毁并重建为首顶点专用高亮标记
|
||||
if (points.length >= 3 && vertexMarkers.value[0]) {
|
||||
const oldFirst = vertexMarkers.value[0]
|
||||
const firstPos = oldFirst.getPosition()
|
||||
map!.remove(oldFirst)
|
||||
|
||||
const highlightedFirst = new AMap.Marker({
|
||||
position: [firstPos.lng, firstPos.lat],
|
||||
content: `<div class="geofence-first-vertex">1</div>`,
|
||||
offset: new AMap.Pixel(-10, -10),
|
||||
zIndex: 125, // 高于其他顶点
|
||||
})
|
||||
map!.add(highlightedFirst)
|
||||
|
||||
// 替换数组中的第一个
|
||||
const rest = vertexMarkers.value.slice(1)
|
||||
vertexMarkers.value = [highlightedFirst, ...rest]
|
||||
}
|
||||
}
|
||||
|
||||
// ── 右键:取消绘制 ──
|
||||
function onRightClick(e: any): void {
|
||||
// 阻止浏览器默认右键菜单
|
||||
e.domEvent?.preventDefault?.()
|
||||
cancelDraw()
|
||||
}
|
||||
|
||||
// 绑定事件
|
||||
;(map as any)._geofenceClick = onClick
|
||||
;(map as any)._geofenceRightClick = onRightClick
|
||||
|
||||
map.on('click', onClick)
|
||||
map.on('rightclick', onRightClick)
|
||||
|
||||
// 切换光标为十字
|
||||
map.setDefaultCursor('crosshair')
|
||||
}
|
||||
|
||||
/** 手动闭合多边形(供 UI 按钮调用) */
|
||||
async function finishDraw(): Promise<void> {
|
||||
const map = mapInstance.value
|
||||
if (!map || !isDrawing.value) return
|
||||
|
||||
const AMap = await loadAMap()
|
||||
|
||||
if (fenceType.value === 'polygon') {
|
||||
finishPolygonDraw(_drawPoints, map, AMap)
|
||||
}
|
||||
// 圆形暂不支持手动完成(只能点击第二次完成)
|
||||
}
|
||||
|
||||
/** 完成多边形绘制,创建最终围栏 */
|
||||
function finishPolygonDraw(
|
||||
points: [number, number][],
|
||||
map: AMap.Map,
|
||||
AMap: typeof import('@amap/amap-jsapi-loader')
|
||||
): void {
|
||||
if (points.length < 3) {
|
||||
cancelDraw()
|
||||
alert('至少需要 3 个顶点才能形成电子围栏。请继续点击添加更多顶点。')
|
||||
return
|
||||
}
|
||||
|
||||
// 清理绘制中间状态(预览线、顶点标记、事件监听)
|
||||
cleanupDrawing(map)
|
||||
isDrawing.value = false
|
||||
drawVertexCount.value = 0
|
||||
_drawPoints = []
|
||||
|
||||
// 恢复双击缩放
|
||||
map.setStatus({ doubleClickZoom: true })
|
||||
|
||||
// 创建围栏多边形
|
||||
const polygon = new AMap.Polygon({
|
||||
path: points,
|
||||
...FENCE_STYLE,
|
||||
zIndex: 100,
|
||||
})
|
||||
map.add(polygon)
|
||||
fenceOverlay.value = polygon
|
||||
fenceVertices.value = [...points]
|
||||
|
||||
map.setDefaultCursor('default')
|
||||
|
||||
console.log(`[电子围栏] ✅ 多边形围栏已创建,顶点数: ${points.length}`)
|
||||
}
|
||||
|
||||
/** 启动圆形围栏绘制 */
|
||||
async function startDrawCircle(): Promise<void> {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
|
||||
cancelDraw()
|
||||
|
||||
const AMap = await loadAMap()
|
||||
isDrawing.value = true
|
||||
fenceType.value = 'circle'
|
||||
drawVertexCount.value = 0
|
||||
|
||||
// 禁用双击缩放
|
||||
map.setStatus({ doubleClickZoom: false })
|
||||
|
||||
let centerPoint: [number, number] | null = null
|
||||
let previewCircle: AMap.Circle | null = null
|
||||
|
||||
// ── 第一次点击:确定圆心 ──
|
||||
function onClick(e: any): void {
|
||||
const lng = e.lnglat.getLng()
|
||||
const lat = e.lnglat.getLat()
|
||||
|
||||
if (!centerPoint) {
|
||||
// 设置圆心
|
||||
centerPoint = [lng, lat]
|
||||
map!.off('click', onClick)
|
||||
|
||||
// 添加圆心标记
|
||||
const cm = new AMap.Marker({
|
||||
position: [lng, lat],
|
||||
content: `<div style="width:16px;height:16px;border-radius:50%;background:#FF4D4F;border:2px solid #fff;box-shadow:0 1px 6px rgba(0,0,0,0.3);"></div>`,
|
||||
offset: new AMap.Pixel(-8, -8),
|
||||
zIndex: 120,
|
||||
})
|
||||
map!.add(cm)
|
||||
vertexMarkers.value = [cm]
|
||||
|
||||
// 监听鼠标移动,动态预览圆形
|
||||
function onMouseMove(me: any): void {
|
||||
const mlng = me.lnglat.getLng()
|
||||
const mlat = me.lnglat.getLat()
|
||||
const r = haversineDistance(centerPoint!, [mlng, mlat])
|
||||
|
||||
if (previewCircle) {
|
||||
previewCircle.setCenter(centerPoint!)
|
||||
previewCircle.setRadius(r)
|
||||
} else {
|
||||
previewCircle = new AMap.Circle({
|
||||
center: centerPoint!,
|
||||
radius: r,
|
||||
fillColor: 'rgba(66, 184, 131, 0.12)',
|
||||
fillOpacity: 0.3,
|
||||
strokeColor: '#42b883',
|
||||
strokeWeight: 2,
|
||||
strokeOpacity: 0.6,
|
||||
strokeStyle: 'dashed',
|
||||
zIndex: 110,
|
||||
})
|
||||
map!.add(previewCircle)
|
||||
}
|
||||
}
|
||||
|
||||
function onSecondClick(se: any): void {
|
||||
const slng = se.lnglat.getLng()
|
||||
const slat = se.lnglat.getLat()
|
||||
const r = haversineDistance(centerPoint!, [slng, slat])
|
||||
|
||||
map!.off('mousemove', onMouseMove)
|
||||
map!.off('click', onSecondClick)
|
||||
|
||||
// 清理预览
|
||||
if (previewCircle) {
|
||||
map!.remove(previewCircle)
|
||||
previewCircle = null
|
||||
}
|
||||
cleanupDrawing(map!)
|
||||
isDrawing.value = false
|
||||
drawVertexCount.value = 0
|
||||
|
||||
// 创建正式围栏
|
||||
const circle = new AMap.Circle({
|
||||
center: centerPoint!,
|
||||
radius: r,
|
||||
...FENCE_STYLE,
|
||||
zIndex: 100,
|
||||
})
|
||||
map!.add(circle)
|
||||
fenceOverlay.value = circle
|
||||
circleCenter.value = centerPoint
|
||||
circleRadius.value = r
|
||||
fenceVertices.value = [centerPoint!]
|
||||
|
||||
// 恢复双击缩放
|
||||
map!.setStatus({ doubleClickZoom: true })
|
||||
map!.setDefaultCursor('default')
|
||||
console.log(`[电子围栏] ✅ 圆形围栏已创建,半径: ${Math.round(r)}米`)
|
||||
}
|
||||
|
||||
// ── 右键取消 ──
|
||||
function onCircleRightClick(e: any): void {
|
||||
e.domEvent?.preventDefault?.()
|
||||
map!.off('mousemove', onMouseMove)
|
||||
map!.off('click', onSecondClick)
|
||||
cancelDraw()
|
||||
}
|
||||
|
||||
;(map as any)._geofenceSecondClick = onSecondClick
|
||||
;(map as any)._geofenceMouseMove = onMouseMove
|
||||
;(map as any)._geofenceCircleRightClick = onCircleRightClick
|
||||
map!.on('click', onSecondClick)
|
||||
map!.on('mousemove', onMouseMove)
|
||||
map!.on('rightclick', onCircleRightClick)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 绘制阶段的右键取消 ──
|
||||
function onCancelRightClick(e: any): void {
|
||||
e.domEvent?.preventDefault?.()
|
||||
cancelDraw()
|
||||
}
|
||||
|
||||
;(map as any)._geofenceClick = onClick
|
||||
;(map as any)._geofenceCircleCancelRight = onCancelRightClick
|
||||
map.on('click', onClick)
|
||||
map.on('rightclick', onCancelRightClick)
|
||||
map.setDefaultCursor('crosshair')
|
||||
}
|
||||
|
||||
/** 取消当前绘制 */
|
||||
function cancelDraw(): void {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
|
||||
cleanupDrawing(map)
|
||||
isDrawing.value = false
|
||||
drawVertexCount.value = 0
|
||||
_drawPoints = []
|
||||
|
||||
// 恢复双击缩放
|
||||
map.setStatus({ doubleClickZoom: true })
|
||||
map.setDefaultCursor('default')
|
||||
}
|
||||
|
||||
/** 清理绘制中的中间元素 */
|
||||
function cleanupDrawing(map: AMap.Map): void {
|
||||
// 移除预览线
|
||||
if (drawLine.value) {
|
||||
map.remove(drawLine.value)
|
||||
drawLine.value = null
|
||||
}
|
||||
// 移除顶点标记
|
||||
vertexMarkers.value.forEach((m) => map.remove(m))
|
||||
vertexMarkers.value = []
|
||||
|
||||
// 移除事件监听
|
||||
const m = map as any
|
||||
if (m._geofenceClick) { map.off('click', m._geofenceClick); delete m._geofenceClick }
|
||||
if (m._geofenceRightClick) { map.off('rightclick', m._geofenceRightClick); delete m._geofenceRightClick }
|
||||
if (m._geofenceSecondClick) { map.off('click', m._geofenceSecondClick); delete m._geofenceSecondClick }
|
||||
if (m._geofenceMouseMove) { map.off('mousemove', m._geofenceMouseMove); delete m._geofenceMouseMove }
|
||||
if (m._geofenceCircleRightClick) { map.off('rightclick', m._geofenceCircleRightClick); delete m._geofenceCircleRightClick }
|
||||
if (m._geofenceCircleCancelRight) { map.off('rightclick', m._geofenceCircleCancelRight); delete m._geofenceCircleCancelRight }
|
||||
|
||||
// 恢复双击缩放
|
||||
map.setStatus({ doubleClickZoom: true })
|
||||
}
|
||||
|
||||
/** 删除当前围栏 */
|
||||
function clearFence(): void {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
|
||||
if (fenceOverlay.value) {
|
||||
map.remove(fenceOverlay.value)
|
||||
fenceOverlay.value = null
|
||||
}
|
||||
fenceType.value = null
|
||||
fenceVertices.value = []
|
||||
circleCenter.value = null
|
||||
circleRadius.value = 0
|
||||
isInside.value = true
|
||||
alarmActive.value = false
|
||||
stopAlarmSound()
|
||||
console.log('[电子围栏] 🗑️ 围栏已清除')
|
||||
}
|
||||
|
||||
// ========== 老人标记 ==========
|
||||
|
||||
/** 在地图中心添加老人标记 */
|
||||
async function addPerson(): Promise<void> {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
|
||||
// 先移除旧标记
|
||||
removePerson()
|
||||
|
||||
const AMap = await loadAMap()
|
||||
|
||||
// 默认放在地图当前中心
|
||||
const center = map.getCenter()
|
||||
const position: [number, number] = [center.lng, center.lat]
|
||||
|
||||
// 如果围栏存在,放在围栏内部
|
||||
if (fenceOverlay.value && fenceType.value === 'polygon') {
|
||||
// 尝试放在第一个顶点的附近(围栏内部)
|
||||
const v0 = fenceVertices.value[0]
|
||||
if (v0) {
|
||||
position[0] = v0[0] + 0.001 // 稍偏移
|
||||
position[1] = v0[1] + 0.001
|
||||
}
|
||||
}
|
||||
|
||||
const marker = new AMap.Marker({
|
||||
position,
|
||||
content: PERSON_MARKER_HTML,
|
||||
offset: new AMap.Pixel(-17, -17),
|
||||
draggable: true,
|
||||
zIndex: 200,
|
||||
})
|
||||
|
||||
// 拖拽结束 → 检测越界
|
||||
marker.on('dragend', () => {
|
||||
const pos = marker.getPosition()
|
||||
personPosition.value = [pos.lng, pos.lat]
|
||||
checkBoundary()
|
||||
})
|
||||
|
||||
map.add(marker)
|
||||
personMarker.value = marker
|
||||
personPosition.value = position
|
||||
|
||||
// 创建后立刻检测位置
|
||||
checkBoundary()
|
||||
|
||||
console.log(`[电子围栏] 👴 老人已添加,位置: [${position[0].toFixed(5)}, ${position[1].toFixed(5)}]`)
|
||||
}
|
||||
|
||||
/** 移除老人标记 */
|
||||
function removePerson(): void {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
|
||||
if (personMarker.value) {
|
||||
map.remove(personMarker.value)
|
||||
personMarker.value = null
|
||||
}
|
||||
personPosition.value = null
|
||||
isInside.value = true
|
||||
alarmActive.value = false
|
||||
stopAlarmSound()
|
||||
}
|
||||
|
||||
// ========== 移动控制 ==========
|
||||
|
||||
/** 步长(经纬度偏移),约 20-50 米 */
|
||||
const MOVE_STEP = 0.0005
|
||||
|
||||
/** 方向移动老人 */
|
||||
function movePerson(direction: 'up' | 'down' | 'left' | 'right'): void {
|
||||
const marker = personMarker.value
|
||||
if (!marker) return
|
||||
|
||||
const pos = marker.getPosition()
|
||||
let { lng, lat } = pos
|
||||
|
||||
switch (direction) {
|
||||
case 'up': lat += MOVE_STEP; break
|
||||
case 'down': lat -= MOVE_STEP; break
|
||||
case 'left': lng -= MOVE_STEP; break
|
||||
case 'right': lng += MOVE_STEP; break
|
||||
}
|
||||
|
||||
const newPosition: [number, number] = [lng, lat]
|
||||
marker.setPosition(newPosition)
|
||||
personPosition.value = newPosition
|
||||
|
||||
// 每次移动后检测越界
|
||||
checkBoundary()
|
||||
}
|
||||
|
||||
// ========== 越界检测 ==========
|
||||
|
||||
/**
|
||||
* 检测老人是否在围栏内
|
||||
* 更新 isInside / alarmActive,必要时触发报警
|
||||
*/
|
||||
function checkBoundary(): void {
|
||||
const pp = personPosition.value
|
||||
if (!pp) return
|
||||
|
||||
const fence = fenceOverlay.value
|
||||
if (!fence) {
|
||||
// 没有围栏,不判断
|
||||
isInside.value = true
|
||||
return
|
||||
}
|
||||
|
||||
let inside: boolean
|
||||
|
||||
if (fenceType.value === 'circle') {
|
||||
// 圆形:使用 AMap.Circle.contains()
|
||||
inside = (fence as AMap.Circle).contains(pp)
|
||||
} else {
|
||||
// 多边形:先用 GeometryUtil,兜底用射线法
|
||||
const AMap = (window as any).AMap
|
||||
try {
|
||||
inside = AMap.GeometryUtil.isPointInRing(
|
||||
{ lng: pp[0], lat: pp[1] },
|
||||
fenceVertices.value
|
||||
)
|
||||
} catch {
|
||||
inside = isPointInPolygon(pp, fenceVertices.value)
|
||||
}
|
||||
}
|
||||
|
||||
const wasInside = isInside.value
|
||||
isInside.value = inside
|
||||
|
||||
if (!inside && wasInside) {
|
||||
// ── 刚刚越界!触发报警 ──
|
||||
triggerAlarm(pp)
|
||||
}
|
||||
|
||||
if (inside && !wasInside) {
|
||||
// ── 回到围栏内 ──
|
||||
resetAlarm()
|
||||
updateFenceStyle(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 报警 ==========
|
||||
|
||||
function triggerAlarm(position: [number, number]): void {
|
||||
if (alarmActive.value) return // 已激活,不重复
|
||||
|
||||
alarmActive.value = true
|
||||
alarmCount.value++
|
||||
alarmHistory.value.push({
|
||||
time: new Date().toLocaleTimeString('zh-CN'),
|
||||
position,
|
||||
})
|
||||
|
||||
updateFenceStyle(true)
|
||||
startAlarmSound()
|
||||
|
||||
console.warn(`[电子围栏] 🚨 第 ${alarmCount.value} 次越界报警!位置: [${position[0].toFixed(5)}, ${position[1].toFixed(5)}]`)
|
||||
}
|
||||
|
||||
function resetAlarm(): void {
|
||||
alarmActive.value = false
|
||||
stopAlarmSound()
|
||||
updateFenceStyle(false)
|
||||
console.log('[电子围栏] ✅ 老人已回到围栏内')
|
||||
}
|
||||
|
||||
/** 手动解除报警 */
|
||||
function dismissAlarm(): void {
|
||||
alarmActive.value = false
|
||||
stopAlarmSound()
|
||||
updateFenceStyle(false)
|
||||
}
|
||||
|
||||
/** 围栏样式切换 */
|
||||
function updateFenceStyle(alarm: boolean): void {
|
||||
const fence = fenceOverlay.value
|
||||
if (!fence) return
|
||||
|
||||
const style = alarm ? FENCE_ALARM_STYLE : FENCE_STYLE
|
||||
if (fenceType.value === 'polygon') {
|
||||
const poly = fence as AMap.Polygon
|
||||
poly.setOptions?.(style) || Object.assign(poly as any, style)
|
||||
} else {
|
||||
const circle = fence as AMap.Circle
|
||||
circle.setOptions?.(style) || Object.assign(circle as any, style)
|
||||
}
|
||||
}
|
||||
|
||||
/** 播放报警音(Web Audio API — 蜂鸣声) */
|
||||
function startAlarmSound(): void {
|
||||
stopAlarmSound()
|
||||
|
||||
try {
|
||||
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext
|
||||
if (!AudioCtx) return
|
||||
|
||||
const ctx = new AudioCtx()
|
||||
|
||||
function beep(): void {
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
|
||||
osc.type = 'square'
|
||||
osc.frequency.value = 880
|
||||
gain.gain.value = 0.2
|
||||
|
||||
osc.start(ctx.currentTime)
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3)
|
||||
osc.stop(ctx.currentTime + 0.3)
|
||||
|
||||
alarmOscillator = osc
|
||||
alarmGain = gain
|
||||
}
|
||||
|
||||
// 每 800ms 蜂鸣一次
|
||||
beep()
|
||||
alarmInterval = setInterval(() => {
|
||||
beep()
|
||||
}, 800)
|
||||
} catch {
|
||||
// 静默失败(部分环境不支持 Web Audio)
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止报警音 */
|
||||
function stopAlarmSound(): void {
|
||||
if (alarmInterval) {
|
||||
clearInterval(alarmInterval)
|
||||
alarmInterval = null
|
||||
}
|
||||
try {
|
||||
alarmOscillator?.stop?.()
|
||||
alarmGain?.disconnect?.()
|
||||
} catch { /* ignore */ }
|
||||
alarmOscillator = null
|
||||
alarmGain = null
|
||||
}
|
||||
|
||||
// ========== 生命周期清理 ==========
|
||||
|
||||
/** 销毁所有围栏相关资源 */
|
||||
function destroyGeofence(): void {
|
||||
stopAlarmSound()
|
||||
clearFence()
|
||||
removePerson()
|
||||
cancelDraw()
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
fenceOverlay,
|
||||
fenceType,
|
||||
fenceVertices,
|
||||
isDrawing,
|
||||
isInside,
|
||||
alarmActive,
|
||||
alarmCount,
|
||||
alarmHistory,
|
||||
hasFence,
|
||||
hasPerson,
|
||||
personMarker,
|
||||
personPosition,
|
||||
|
||||
// 围栏操作
|
||||
startDrawPolygon,
|
||||
startDrawCircle,
|
||||
finishDraw,
|
||||
cancelDraw,
|
||||
clearFence,
|
||||
drawVertexCount,
|
||||
|
||||
// 老人标记操作
|
||||
addPerson,
|
||||
removePerson,
|
||||
movePerson,
|
||||
|
||||
// 检测 & 报警
|
||||
checkBoundary,
|
||||
dismissAlarm,
|
||||
destroyGeofence,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 工具函数 ────────────────────────────────────────
|
||||
|
||||
/** Haversine 公式计算两点间距离(米) */
|
||||
function haversineDistance(
|
||||
[lng1, lat1]: [number, number],
|
||||
[lng2, lat2]: [number, number]
|
||||
): number {
|
||||
const R = 6371000 // 地球半径(米)
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180
|
||||
const dLng = ((lng2 - lng1) * Math.PI) / 180
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLng / 2) ** 2
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
return R * c
|
||||
}
|
||||
413
src/composables/useRouteTrack.ts
Normal file
413
src/composables/useRouteTrack.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
// ============================================================
|
||||
// 护理员轨迹 Composable — 手动标记起点/终点 + 路线 + 动画
|
||||
//
|
||||
// 交互模型:
|
||||
// 1. [📍标记起点] → 点击地图 → 放 📍 marker
|
||||
// 2. [🏁标记终点] → 点击地图 → 放 🏁 marker
|
||||
// 3. [👩⚕️添加护理员] → 放在起点位置(可拖拽)
|
||||
// 4. [🚗规划路线] → AMap.Driving → 蓝色折线
|
||||
// 5. [▶出发] → 护理员沿路线动画移动
|
||||
// ============================================================
|
||||
|
||||
import { ref, shallowRef, computed } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import { loadAMap } from './useAmap'
|
||||
|
||||
// ─── 标记 HTML ─────────────────────────────────────
|
||||
|
||||
const CAREGIVER_HTML = '<div class="geofence-caregiver-marker">👩⚕️</div>'
|
||||
const ORIGIN_HTML = '<div class="geofence-origin-marker">📍</div>'
|
||||
const DEST_HTML = '<div class="geofence-dest-marker">🏁</div>'
|
||||
|
||||
// ─── 类型 ──────────────────────────────────────────
|
||||
|
||||
export interface RouteInfo {
|
||||
distance: number
|
||||
duration: number
|
||||
path: [number, number][]
|
||||
}
|
||||
|
||||
// ─── 工具 ──────────────────────────────────────────
|
||||
|
||||
function downsamplePath(path: [number, number][], target: number): [number, number][] {
|
||||
if (path.length <= target) return [...path]
|
||||
const result: [number, number][] = []
|
||||
const step = (path.length - 1) / (target - 1)
|
||||
for (let i = 0; i < target; i++) {
|
||||
result.push(path[Math.min(Math.round(i * step), path.length - 1)])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 安全获取事件经纬度 */
|
||||
function eventLngLat(e: any): [number, number] | null {
|
||||
const lng: number = e.lnglat?.lng ?? e.lnglat?.getLng?.()
|
||||
const lat: number = e.lnglat?.lat ?? e.lnglat?.getLat?.()
|
||||
if (isNaN(lng) || isNaN(lat)) return null
|
||||
return [lng, lat]
|
||||
}
|
||||
|
||||
// ─── Composable ────────────────────────────────────
|
||||
|
||||
export function useRouteTrack(mapInstance: Ref<AMap.Map | null>) {
|
||||
// ===== 状态 =====
|
||||
|
||||
const caregiverMarker = shallowRef<AMap.Marker | null>(null)
|
||||
const originMarker = shallowRef<AMap.Marker | null>(null)
|
||||
const destMarker = shallowRef<AMap.Marker | null>(null)
|
||||
const routePolyline = shallowRef<AMap.Polyline | null>(null)
|
||||
|
||||
const routePath = ref<[number, number][]>([])
|
||||
const animPath = ref<[number, number][]>([])
|
||||
const routeInfo = ref<RouteInfo | null>(null)
|
||||
const isPlanning = ref(false)
|
||||
const isAnimating = ref(false)
|
||||
const animProgress = ref(0)
|
||||
const animStep = ref(0)
|
||||
|
||||
/** 起点坐标 */
|
||||
const originPoint = ref<[number, number] | null>(null)
|
||||
/** 终点坐标 */
|
||||
const destPoint = ref<[number, number] | null>(null)
|
||||
|
||||
/** 当前模式:'origin' | 'dest' | null */
|
||||
const settingMode = ref<'origin' | 'dest' | null>(null)
|
||||
|
||||
let animTimer: ReturnType<typeof setInterval> | null = null
|
||||
let _clickHandler: ((e: any) => void) | null = null
|
||||
|
||||
// ── 派生 ──
|
||||
|
||||
const hasCaregiver = () => caregiverMarker.value !== null
|
||||
const hasRoute = () => routePolyline.value !== null
|
||||
const hasOrigin = () => originPoint.value !== null
|
||||
const hasDest = () => destPoint.value !== null
|
||||
const canPlanRoute = computed(() =>
|
||||
hasOrigin() && hasDest() && !isPlanning.value
|
||||
)
|
||||
|
||||
// ===== 点击地图通用处理 =====
|
||||
|
||||
async function enterSetMode(mode: 'origin' | 'dest'): Promise<void> {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
exitSetMode()
|
||||
|
||||
settingMode.value = mode
|
||||
map.setDefaultCursor('crosshair')
|
||||
|
||||
const AMap = await loadAMap()
|
||||
|
||||
function onClick(e: any): void {
|
||||
const pt = eventLngLat(e)
|
||||
if (!pt) return
|
||||
|
||||
if (mode === 'origin') {
|
||||
applyOrigin(pt, AMap, map!)
|
||||
} else {
|
||||
applyDestination(pt)
|
||||
}
|
||||
exitSetMode()
|
||||
}
|
||||
|
||||
_clickHandler = onClick
|
||||
;(map as any)._rtClick = onClick
|
||||
map.on('click', onClick)
|
||||
}
|
||||
|
||||
function exitSetMode(): void {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
settingMode.value = null
|
||||
map.setDefaultCursor('default')
|
||||
if (_clickHandler && (map as any)._rtClick) {
|
||||
map.off('click', _clickHandler)
|
||||
delete (map as any)._rtClick
|
||||
_clickHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 起点标记 =====
|
||||
|
||||
function applyOrigin(
|
||||
point: [number, number],
|
||||
AMap: typeof import('@amap/amap-jsapi-loader'),
|
||||
map: AMap.Map
|
||||
): void {
|
||||
originPoint.value = point
|
||||
|
||||
if (originMarker.value) {
|
||||
originMarker.value.setPosition(point)
|
||||
} else {
|
||||
const m = new AMap.Marker({
|
||||
position: point,
|
||||
content: ORIGIN_HTML,
|
||||
offset: new AMap.Pixel(-10, -10),
|
||||
zIndex: 205,
|
||||
})
|
||||
map.add(m)
|
||||
originMarker.value = m
|
||||
}
|
||||
|
||||
console.log(`[护理员] 📍 起点: [${point[0].toFixed(4)}, ${point[1].toFixed(4)}]`)
|
||||
}
|
||||
|
||||
function removeOrigin(): void {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
if (originMarker.value) { map.remove(originMarker.value); originMarker.value = null }
|
||||
originPoint.value = null
|
||||
// 清除起点也清除路线
|
||||
clearRoute()
|
||||
}
|
||||
|
||||
// ===== 终点标记 =====
|
||||
|
||||
function applyDestination(point: [number, number]): void {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
destPoint.value = point
|
||||
|
||||
if (destMarker.value) {
|
||||
destMarker.value.setPosition(point)
|
||||
} else {
|
||||
const m = new AMap.Marker({
|
||||
position: point,
|
||||
content: DEST_HTML,
|
||||
offset: new AMap.Pixel(-12, -32),
|
||||
zIndex: 215,
|
||||
})
|
||||
map.add(m)
|
||||
destMarker.value = m
|
||||
}
|
||||
console.log(`[护理员] 🏁 终点: [${point[0].toFixed(4)}, ${point[1].toFixed(4)}]`)
|
||||
}
|
||||
|
||||
function removeDest(): void {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
if (destMarker.value) { map.remove(destMarker.value); destMarker.value = null }
|
||||
destPoint.value = null
|
||||
clearRoute()
|
||||
}
|
||||
|
||||
// ===== 护理员标记 =====
|
||||
|
||||
/** 添加护理员:放在起点位置,没有起点则放地图中心 */
|
||||
async function addCaregiver(): Promise<void> {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
|
||||
const pos = originPoint.value
|
||||
? [...originPoint.value]
|
||||
: (() => { const c = map.getCenter(); return [c.lng, c.lat] as [number, number] })()
|
||||
|
||||
removeCaregiver()
|
||||
|
||||
const AMap = await loadAMap()
|
||||
|
||||
const marker = new AMap.Marker({
|
||||
position: pos,
|
||||
content: CAREGIVER_HTML,
|
||||
offset: new AMap.Pixel(-17, -17),
|
||||
draggable: true,
|
||||
zIndex: 210,
|
||||
})
|
||||
map.add(marker)
|
||||
caregiverMarker.value = marker
|
||||
|
||||
// 如果没起点,护理员的位置同时作为起点
|
||||
if (!originPoint.value) {
|
||||
applyOrigin(pos, AMap, map)
|
||||
}
|
||||
|
||||
if (!originPoint.value && !destPoint.value) {
|
||||
map.setZoomAndCenter(5, pos)
|
||||
}
|
||||
|
||||
console.log(`[护理员] 👩⚕️ 护理员已添加: [${pos[0].toFixed(4)}, ${pos[1].toFixed(4)}]`)
|
||||
}
|
||||
|
||||
function removeCaregiver(): void {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
stopAnimation()
|
||||
if (caregiverMarker.value) { map.remove(caregiverMarker.value); caregiverMarker.value = null }
|
||||
}
|
||||
|
||||
// ===== 全部清除 =====
|
||||
|
||||
function clearAll(): void {
|
||||
exitSetMode()
|
||||
stopAnimation()
|
||||
clearRoute()
|
||||
removeCaregiver()
|
||||
removeOrigin()
|
||||
removeDest()
|
||||
}
|
||||
|
||||
// ===== 路线规划 =====
|
||||
|
||||
async function planRoute(): Promise<void> {
|
||||
const map = mapInstance.value
|
||||
if (!map || !originPoint.value || !destPoint.value) return
|
||||
|
||||
clearRoute()
|
||||
isPlanning.value = true
|
||||
|
||||
try {
|
||||
const AMap = await loadAMap()
|
||||
|
||||
// 检查 Driving 是否可用
|
||||
if (!AMap.Driving) {
|
||||
throw new Error('AMap.Driving 插件未加载,请检查 config/amap.ts 中的 AMAP_PLUGINS 是否包含 "AMap.Driving"')
|
||||
}
|
||||
|
||||
const [orgLng, orgLat] = originPoint.value
|
||||
const [destLng, destLat] = destPoint.value
|
||||
|
||||
console.log(`[护理员] 🚗 规划路线: [${orgLng.toFixed(4)}, ${orgLat.toFixed(4)}] → [${destLng.toFixed(4)}, ${destLat.toFixed(4)}]`)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
// 必须传入 map 实例,否则 Driving 无法正确初始化
|
||||
const driving = new AMap.Driving({
|
||||
map: map as any,
|
||||
policy: 0, // 0 = 速度优先
|
||||
showTraffic: false,
|
||||
})
|
||||
|
||||
// 参数使用 AMap.LngLat 实例(更可靠)
|
||||
const originLngLat = new AMap.LngLat(orgLng, orgLat)
|
||||
const destLngLat = new AMap.LngLat(destLng, destLat)
|
||||
|
||||
driving.search(originLngLat, destLngLat, (status: string, result: any) => {
|
||||
console.log(`[护理员] Driving 回调: status="${status}", routes=${result?.routes?.length ?? 0}`)
|
||||
|
||||
if (status === 'complete' && result.routes?.length > 0) {
|
||||
const route = result.routes[0]
|
||||
const fullPath: [number, number][] = []
|
||||
for (const step of route.steps) {
|
||||
for (const pt of step.path) fullPath.push([pt.lng, pt.lat])
|
||||
}
|
||||
if (fullPath.length < 2) { reject(new Error('路线点数不足')); return }
|
||||
|
||||
routePath.value = fullPath
|
||||
animPath.value = downsamplePath(fullPath, 250)
|
||||
routeInfo.value = { distance: route.distance, duration: route.time, path: fullPath }
|
||||
|
||||
const polyline = new AMap.Polyline({
|
||||
path: fullPath,
|
||||
strokeColor: '#4A90D9', strokeWeight: 4, strokeOpacity: 0.75,
|
||||
lineJoin: 'round', lineCap: 'round', zIndex: 85,
|
||||
})
|
||||
map!.add(polyline)
|
||||
routePolyline.value = polyline
|
||||
map!.setFitView([polyline], false, [60, 60, 60, 60])
|
||||
|
||||
console.log(`[护理员] 🚗 路线完成 — ${(route.distance/1000).toFixed(1)}km,${animPath.value.length}动画点`)
|
||||
resolve()
|
||||
} else if (status === 'error') {
|
||||
const msg = result?.info || result?.message || '未知错误'
|
||||
console.error(`[护理员] ❌ Driving 返回错误: ${msg}`, result)
|
||||
reject(new Error(`路线规划失败: ${msg}`))
|
||||
} else if (status === 'no_data') {
|
||||
reject(new Error('起终点之间无可用路线,请尝试缩短距离或调整位置'))
|
||||
} else {
|
||||
console.error(`[护理员] ❌ 未知状态: ${status}`, result)
|
||||
reject(new Error(`路线规划失败 (${status}),请重试`))
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (err: any) {
|
||||
alert(`路线规划失败: ${err.message}`)
|
||||
clearRoute()
|
||||
} finally {
|
||||
isPlanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearRoute(): void {
|
||||
const map = mapInstance.value
|
||||
if (!map) return
|
||||
stopAnimation()
|
||||
if (routePolyline.value) { map.remove(routePolyline.value); routePolyline.value = null }
|
||||
routePath.value = []
|
||||
animPath.value = []
|
||||
routeInfo.value = null
|
||||
animProgress.value = 0
|
||||
animStep.value = 0
|
||||
}
|
||||
|
||||
// ===== 动画 =====
|
||||
|
||||
async function startAnimation(): Promise<void> {
|
||||
// 如果护理员不存在,自动在起点创建
|
||||
if (!caregiverMarker.value) {
|
||||
if (!originPoint.value) {
|
||||
alert('请先标记起点和终点,再规划路线')
|
||||
return
|
||||
}
|
||||
await addCaregiver()
|
||||
}
|
||||
|
||||
if (animPath.value.length < 2) {
|
||||
alert('路线数据为空,请先规划路线')
|
||||
return
|
||||
}
|
||||
|
||||
if (isAnimating.value) return
|
||||
|
||||
isAnimating.value = true
|
||||
animStep.value = 0
|
||||
animProgress.value = 0
|
||||
const total = animPath.value.length - 1
|
||||
|
||||
animTimer = setInterval(() => {
|
||||
if (animStep.value >= total) {
|
||||
stopAnimation()
|
||||
animProgress.value = 1
|
||||
return
|
||||
}
|
||||
animStep.value++
|
||||
animProgress.value = animStep.value / total
|
||||
caregiverMarker.value?.setPosition(animPath.value[animStep.value])
|
||||
}, 40)
|
||||
|
||||
console.log(`[护理员] ▶ 出发,${total}步 ~${(total*40/1000).toFixed(1)}秒`)
|
||||
}
|
||||
|
||||
function stopAnimation(): void {
|
||||
if (animTimer) { clearInterval(animTimer); animTimer = null }
|
||||
isAnimating.value = false
|
||||
}
|
||||
|
||||
function resetToOrigin(): void {
|
||||
stopAnimation()
|
||||
if (caregiverMarker.value && originPoint.value) {
|
||||
caregiverMarker.value.setPosition(originPoint.value)
|
||||
}
|
||||
animProgress.value = 0
|
||||
animStep.value = 0
|
||||
}
|
||||
|
||||
// ===== 清理 =====
|
||||
|
||||
function destroyRouteTrack(): void {
|
||||
clearAll()
|
||||
}
|
||||
|
||||
return {
|
||||
caregiverMarker, routePolyline, routePath, routeInfo,
|
||||
isPlanning, isAnimating, animProgress,
|
||||
originPoint, destPoint, settingMode,
|
||||
hasCaregiver, hasRoute, hasOrigin, hasDest, canPlanRoute,
|
||||
|
||||
enterSetMode, exitSetMode,
|
||||
addCaregiver, removeCaregiver,
|
||||
removeOrigin, removeDest,
|
||||
planRoute, clearRoute,
|
||||
startAnimation, stopAnimation, resetToOrigin,
|
||||
clearAll,
|
||||
destroyRouteTrack,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user