高德地图添加轨迹规划和重放功能
This commit is contained in:
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` 只触发一次,刚好够。"边界穿越"在拖拽中段发生还是在终点发生,对报警结果无影响——报警只需要知道"最终位置是否在围栏外"。
|
||||
Reference in New Issue
Block a user