# 前端面试题：养老场景电子围栏 — 绘制 + 越界检测 + 声光报警

## 题目描述

在 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` 只触发一次，刚好够。"边界穿越"在拖拽中段发生还是在终点发生，对报警结果无影响——报警只需要知道"最终位置是否在围栏外"。
