From 91afc9d81c3385859a0a0bf13cf152f19a571f5d Mon Sep 17 00:00:00 2001 From: cirry <812852553@qq.com> Date: Thu, 25 Jun 2026 10:30:45 +0800 Subject: [PATCH] =?UTF-8?q?=E9=AB=98=E5=BE=B7=E5=9C=B0=E5=9B=BE=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E8=BD=A8=E8=BF=B9=E8=A7=84=E5=88=92=E5=92=8C=E9=87=8D?= =?UTF-8?q?=E6=94=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/interview-question-amap-loader.md | 231 ++++++++ docs/interview-question-geofence.md | 396 +++++++++++++ docs/interview-question-map-layout.md | 242 ++++++++ docs/interview-question-my-location.md | 469 +++++++++++++++ docs/interview-question-route-track.md | 200 +++++++ src/composables/useGeofence.ts | 789 +++++++++++++++++++++++++ src/composables/useRouteTrack.ts | 413 +++++++++++++ 7 files changed, 2740 insertions(+) create mode 100644 docs/interview-question-amap-loader.md create mode 100644 docs/interview-question-geofence.md create mode 100644 docs/interview-question-map-layout.md create mode 100644 docs/interview-question-my-location.md create mode 100644 docs/interview-question-route-track.md create mode 100644 src/composables/useGeofence.ts create mode 100644 src/composables/useRouteTrack.ts diff --git a/docs/interview-question-amap-loader.md b/docs/interview-question-amap-loader.md new file mode 100644 index 0000000..3b28a63 --- /dev/null +++ b/docs/interview-question-amap-loader.md @@ -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 | null = null + +/** 全局对象缓存 — 成功后直接返回,避免再次 await */ +let AMapGlobal: typeof AMap | null = null + +export async function loadAMap(): Promise { + // ① 已加载过 → 直接返回 + 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 \| 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' } + ↓ + + ↓ + AMap.Driving 等服务可用 + +❌ 错误顺序(写在 AMapLoader.load 之后): + ← 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` 中加 ` +``` + +--- + +## 核心知识点拆解 + +### 知识点 ①:`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) + └─ + └─ iframe → 子应用 (microapp-vue3) +``` + +当子应用运行在 **iframe 沙箱**中时(`subApps.ts` 中 `iframe: true`),`navigator.geolocation` 的行为: + +- **Chrome / Edge**:iframe 中的 `getCurrentPosition` 需要 iframe 的 `allow="geolocation"` 属性,否则直接触发错误回调 +- **Firefox**:会向上询问用户授权,相对宽松 +- **Safari**:iframe 中基本无法获取位置 + +**主应用需要额外配置 iframe 的 Permissions Policy:** + +```html + + + + +Permissions-Policy: geolocation=(self "http://localhost:3001") +``` + +**当前代码缺少这个配置**——这是一个真实的技术债,也是面试中考察微前端经验的好切入点。 + +### 知识点 ④:`shallowRef` 的地图实例存储 + +```ts +const mapInstance = shallowRef(null) +``` + +| 对比 | `ref()` | `shallowRef()` | +|------|---------|-----------------| +| 深度追踪 | ✅ 递归追踪内部属性 | ❌ 仅追踪 `.value` 的引用替换 | +| `map.setCenter()` 后 | 触发不必要的重渲染 | **不触发**(引用没变) | +| `map.setZoom()` 后 | 触发不必要的重渲染 | **不触发** | +| 性能 | 差(每次地图操作都 diff) | 好(只有销毁/重建才触发) | + +**关键原理:** 地图实例内部状态的变更(平移、缩放、标记点增删)都不应该触发 Vue 的响应式更新——地图自己管理自己的 DOM。`shallowRef` 正是为此场景设计。 + +### 知识点 ⑤:按钮的 `disabled` 守卫 + +```html + +``` + +**防护链:** + +``` +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,但浏览器的权限模型仍然将其视为独立上下文,需要主应用在 `` 标签或框架配置中透传该 permission。 diff --git a/docs/interview-question-route-track.md b/docs/interview-question-route-track.md new file mode 100644 index 0000000..f8e2c26 --- /dev/null +++ b/docs/interview-question-route-track.md @@ -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 { + // 🔧 容错:护理员不存在 → 自动在起点创建 + 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 } +// 或动态计算,进度条相应调整 +``` diff --git a/src/composables/useGeofence.ts b/src/composables/useGeofence.ts new file mode 100644 index 0000000..12693e9 --- /dev/null +++ b/src/composables/useGeofence.ts @@ -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 = '
👴
' + +/** 普通顶点标记(使用全局 CSS 类) */ +function vertexMarkerHTML(index: number): string { + return `
${index}
` +} + +// ─── 工具函数 ──────────────────────────────────────── + +/** 射线法判断点是否在多边形内(兜底方案,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) { + // ========== 状态 ========== + + /** 当前围栏覆盖物 */ + const fenceOverlay = shallowRef(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(null) + /** 绘制时的顶点标记列表 */ + const vertexMarkers = shallowRef([]) + /** 当前已绘制的顶点数(用于模板判断是否 >= 3 个) */ + const drawVertexCount = ref(0) + + /** 老人标记 */ + const personMarker = shallowRef(null) + /** 老人当前位置 */ + const personPosition = ref<[number, number] | null>(null) + + /** 老人是否在围栏内 */ + const isInside = ref(true) + /** 报警是否激活 */ + const alarmActive = ref(false) + /** 越界次数 */ + const alarmCount = ref(0) + /** 报警历史 */ + const alarmHistory = ref([]) + /** 报警音效振荡器引用 */ + let alarmOscillator: OscillatorNode | null = null + let alarmGain: GainNode | null = null + let alarmInterval: ReturnType | 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 { + 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: `
1
`, + 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 { + 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 { + 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: `
`, + 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 { + 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 +} diff --git a/src/composables/useRouteTrack.ts b/src/composables/useRouteTrack.ts new file mode 100644 index 0000000..ce3ad22 --- /dev/null +++ b/src/composables/useRouteTrack.ts @@ -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 = '
👩‍⚕️
' +const ORIGIN_HTML = '
📍
' +const DEST_HTML = '
🏁
' + +// ─── 类型 ────────────────────────────────────────── + +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) { + // ===== 状态 ===== + + const caregiverMarker = shallowRef(null) + const originMarker = shallowRef(null) + const destMarker = shallowRef(null) + const routePolyline = shallowRef(null) + + const routePath = ref<[number, number][]>([]) + const animPath = ref<[number, number][]>([]) + const routeInfo = ref(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 | 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 { + 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 { + 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 { + 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((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 { + // 如果护理员不存在,自动在起点创建 + 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, + } +}