# 前端面试题：地图路线规划 + 轨迹动画系统

## 题目描述

在 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()                       ← 完整清理
```

---

## 起始点路径规划 — 完整实现链路

整个"画起始点 → 规划路线"功能由 **5 个阶段** 串联而成，每个阶段由不同的函数负责，状态通过 Vue 响应式变量驱动 UI。

### 阶段 1：进入标记模式 — `enterSetMode('origin' | 'dest')`

```
用户点击 [📍标记起点]
  → enterSetMode('origin')
  → exitSetMode()           ← 先清理上一次的状态（互斥）
  → settingMode.value = 'origin'
  → map.setDefaultCursor('crosshair')
  → map.on('click', onClick) ← 注册一次性点击监听
```

**关键代码：**

```ts
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)
}
```

**三个设计细节：**

| 细节 | 代码位置 | 理由 |
|------|----------|------|
| `exitSetMode()` 前置调用 | `enterSetMode` 第 3 行 | 如果用户先点了"标记起点"，又点"标记终点"，旧点击事件会被清理，不会同时触发两个 handler |
| `_rtClick` 强引用保存 | `(map as any)._rtClick = onClick` | `map.off('click', handler)` 需要同一个函数引用才能解绑；闭包内的 `onClick` 每次调用都是新函数，`_rtClick` 保存它以便 `exitSetMode` 精确解绑 |
| 单击后自动退出 | `exitSetMode()` 在回调末尾 | 避免用户误放多个标记；每次设点都是"进入模式 → 点一次 → 退出"的原子操作 |

### 阶段 2：安全提取经纬度 — `eventLngLat()`

```ts
function eventLngLat(e: any): [number, number] | null {
  const lng: number = e.lnglat?.lng ?? e.lnglat?.getLng?.()  // 兼容两种 API 格式
  const lat: number = e.lnglat?.lat ?? e.lnglat?.getLat?.()
  if (isNaN(lng) || isNaN(lat)) return null                   // NaN 防护
  return [lng, lat]
}
```

高德地图点击事件的 `lnglat` 对象在不同版本中可能是**属性**（`e.lnglat.lng`）也可能是**方法**（`e.lnglat.getLng()`）。`??` 链式回退 + `isNaN` 兜底确保不会因为 API 差异导致后续逻辑拿到 `NaN` 坐标。

### 阶段 3：创建/更新标记 — `applyOrigin()` & `applyDestination()`

```ts
function applyOrigin(point: [number, number], AMap: ..., map: ...): void {
  originPoint.value = point       // ① 反应式状态：驱动 canPlanRoute 计算属性

  if (originMarker.value) {
    originMarker.value.setPosition(point)  // ② 已存在：复用 + 移动位置
  } else {
    const m = new AMap.Marker({            // ③ 不存在：创建新标记
      position: point,
      content: ORIGIN_HTML,                //    '📍' emoji 作为标记内容
      offset: new AMap.Pixel(-10, -10),    //    居中对齐
      zIndex: 205,                         //    层级：起点(205) < 护理员(210) < 终点(215)
    })
    map.add(m)
    originMarker.value = m
  }
}
```

**状态双轨制：**

| 轨道 | 变量 | 类型 | 用途 |
|------|------|------|------|
| 坐标层 | `originPoint` / `destPoint` | `Ref<[number,number] \| null>` | 响应式数据，驱动 `canPlanRoute`、传给 Driving API |
| 标记层 | `originMarker` / `destMarker` | `ShallowRef<AMap.Marker \| null>` | AMap 实例，用于地图渲染；用 `shallowRef` 避免深度响应式开销 |

`applyDestination` 逻辑完全对称，区别在于 `content`（🏁）、`offset`（-12, -32）和 `zIndex`（215，高于护理员和起点）。

### 阶段 4：路线规划 — `planRoute()`

这是整个功能的核心，流程如下：

```
planRoute()
  ├─ 前置检查：originPoint && destPoint && map 都存在
  ├─ clearRoute()               ← 清除旧路线
  ├─ isPlanning = true          ← 按钮 loading 状态
  ├─ loadAMap()                 ← 动态加载
  ├─ 检查 AMap.Driving 可用      ← 防御性编程
  ├─ new AMap.Driving({ map, policy: 0 })
  │    policy: 0 = 速度优先（LEAST_TIME）
  │    map: 必须传入！Driving 依赖 map 做投影计算
  ├─ driving.search(originLngLat, destLngLat, callback)
  └─ 回调处理 3 种 status
```

**路线数据提取（核心）：**

```ts
if (status === 'complete' && result.routes?.length > 0) {
  const route = result.routes[0]
  const fullPath: [number, number][] = []

  // Driving 返回的路径是分层结构：Route → Step → Path
  for (const step of route.steps) {
    for (const pt of step.path) fullPath.push([pt.lng, pt.lat])
  }

  routePath.value = fullPath                        // 原始路径（5000+ 点）
  animPath.value  = downsamplePath(fullPath, 250)   // 动画路径（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])
}
```

**为什么要 `new AMap.LngLat(lng, lat)` 而不是直接传数组？**

```ts
const originLngLat = new AMap.LngLat(orgLng, orgLat)  // ✅ 推荐
const destLngLat   = new AMap.LngLat(destLng, destLat)
// 某些版本的 AMap.Driving.search() 不接受 [lng, lat] 数组
```

**三种 status 的完整处理：**

| status | 含义 | 处理策略 |
|--------|------|----------|
| `'complete'` | 规划成功 | 提取路径 → 下采样 → 绘折线 → `resolve()` |
| `'error'` | API 调用失败（密钥/权限/网络） | 提取 `result.info` 或 `result.message` → `reject()` |
| `'no_data'` | 起终点间无可通行道路 | 提示用户缩短距离或换位置 → `reject()` |
| 其他 | 未知状态（不应发生） | 防御性兜底 → `reject()` |

**关键：`finally` 块保证状态恢复**

```ts
try {
  // ... Driving 路线规划 ...
} catch (err: any) {
  alert(`路线规划失败: ${err.message}`)
  clearRoute()           // 失败时清理半成品
} finally {
  isPlanning.value = false  // ← 无论如何都要关掉 loading，否则按钮永久禁用
}
```

### 阶段 5：级联清理 — 删除起点/终点自动清除路线

```ts
function removeOrigin(): void {
  // ...移除 marker 和坐标...
  clearRoute()     // ← 级联：没了起点，路线也无意义
}

function removeDest(): void {
  // ...移除 marker 和坐标...
  clearRoute()     // ← 同理
}

function clearRoute(): void {
  stopAnimation()                          // 停动画
  if (routePolyline.value) {
    map.remove(routePolyline.value)        // 移除折线
    routePolyline.value = null
  }
  routePath.value = []                     // 清空路径数据
  animPath.value = []
  routeInfo.value = null
  animProgress.value = 0                   // 重置进度
  animStep.value = 0
}
```

**级联关系图：**

```
removeOrigin() / removeDest()
  └─ clearRoute()
       ├─ stopAnimation()     ← 先停动画，避免 setInterval 操作已删除的对象
       ├─ map.remove(polyline)
       └─ 重置所有相关 ref

clearAll()
  ├─ exitSetMode()        ← 退出标记模式
  ├─ stopAnimation()
  ├─ clearRoute()
  ├─ removeCaregiver()
  ├─ removeOrigin()
  └─ removeDest()
```

清理顺序很重要：**先停动画 → 再删路线 → 最后删标记**。如果反过来，`stopAnimation` 中的 `setPosition` 可能操作已删除的 marker。

### 完整数据流图

```
用户操作                    状态变化                      地图效果
───────                    ────────                      ────────
点击 [📍标记起点]
  → enterSetMode('origin')  settingMode='origin'        光标变十字
  → 点击地图                 originPoint=[lng,lat]       📍 出现在点击位置
  → exitSetMode()           settingMode=null             光标恢复默认

点击 [🏁标记终点]
  → enterSetMode('dest')    settingMode='dest'           光标变十字
  → 点击地图                 destPoint=[lng,lat]         🏁 出现在点击位置
  → exitSetMode()           settingMode=null             光标恢复默认
                             canPlanRoute=true           [🚗规划路线] 按钮可点击

点击 [🚗规划路线]
  → planRoute()             isPlanning=true              按钮 loading
    → Driving.search()                                   
    → 提取 path              routePath=[5000+点]          
    → downsamplePath(250)   animPath=[250点]             
    → new Polyline          routePolyline=实例           蓝色折线出现
    → setFitView                                         视野自动缩放
                             isPlanning=false            按钮恢复

点击 [▶出发]
  → startAnimation()        isAnimating=true             
    → setInterval(40ms)     animStep 0→249               👩‍⚕️ 沿路线移动
                             animProgress 0→1            进度条推进
    → stopAnimation()       isAnimating=false            👩‍⚕️ 停在终点
```

---

## 核心技术点

### 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 清理 + 事件解绑 | ⭐⭐ |
| **级联清理** | 删除起点/终点时自动清除路线，避免僵尸数据 | ⭐⭐ |
| **状态双轨制** | 坐标层（ref）驱动逻辑 vs 标记层（shallowRef）驱动渲染 | ⭐⭐⭐ |
| **调试** | 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 }
// 或动态计算，进度条相应调整
```

### 追问 5：为什么 `originPoint` 用 `ref` 而 `originMarker` 用 `shallowRef`？

**期望回答：**
- `originPoint` 是纯数据 `[number, number]`，需要响应式以驱动 `canPlanRoute` 计算属性和 Driving API 调用
- `originMarker` 是 AMap 实例对象，内部结构庞大且不应被 Vue 深度代理
- `shallowRef` 只监听 `.value` 的替换（`m = new AMap.Marker(...)` 或 `m = null`），不追踪内部属性变化
- 标记的位置更新通过 `marker.setPosition()` 直接操作 AMap 原生 API，不需要 Vue 参与

### 追问 6：`clearRoute()` 里为什么要先 `stopAnimation()` 再删折线？

**期望回答：** `stopAnimation` 中会调用 `caregiverMarker.value?.setPosition()`。清理顺序必须是：
1. `stopAnimation()` — 先停 `setInterval`，确保不再操作任何地图对象
2. `map.remove(routePolyline)` — 再安全删除路线折线
3. 重置所有 ref — 最后清理响应式状态

这个顺序保证了"先停用，再删除，最后置空"，避免 `setInterval` 回调在对象删除后仍然触发 `setPosition`。
