diff --git a/docs/AMap面试题.md b/docs/AMap面试题.md index 9aadbc8..26fd3bd 100644 --- a/docs/AMap面试题.md +++ b/docs/AMap面试题.md @@ -329,6 +329,165 @@ startAnimation() → setInterval 40ms → setPosition → 到达 > **模式开关管理点选(用完即解绑)→ `AMap.Driving` + 回调转 Promise 规划路线 → 降采样到 250 点后 `setInterval` + `setPosition` 驱动动画,状态变量全程控制 UI 互斥。** +--- +## Q6:如何在高德地图上实现电子围栏的交互绘制与越界报警? + +--- + +## 题目 + +在一个养老看护应用中,你需要实现以下功能: +1. **多边形围栏绘制**:用户在地图上逐点点击添加顶点,支持预览虚线、首顶点高亮提示闭合、右键取消 +2. **圆形围栏绘制**:第一次点击定圆心,鼠标移动实时预览圆形,第二次点击定半径 +3. **老人越界报警**:老人标记(👴)可通过方向键或拖拽移动,每次移动自动检测是否在围栏内,越界时触发声光报警并记录历史 + +请描述关键实现思路,重点关注**绘制过程的交互设计**和**越界检测算法**。 + +--- + +## 参考答案 + +### 一、多边形围栏绘制 + +#### 1. 整体状态机 + +绘制过程由 `isDrawing` + `fenceType` 两个状态变量驱动,按钮禁用/启用、提示条显示、光标样式全部由它们控制: + +``` +空闲 ──[点击「绘制围栏」]──▶ 绘制中(polygon) + │ ├─ click → 添加顶点(编号圆点 + 虚线连接) + │ ├─ click 首顶点(30m内) → 闭环完成 + │ └─ 右键 → 取消 + ◀──────────────────────────────┘ +``` + +#### 2. 关键设计决策 + +| 决策 | 做法 | 原因 | +|------|------|------| +| **事件绑定** | `map.on('click', handler)` 临时绑定,绘制完成/取消时 `off` | 用完即解绑,避免全局监听干扰其他功能 | +| **双击冲突** | 绘制开始时 `map.setStatus({ doubleClickZoom: false })` | 双击 = click+click+dblclick,不关闭则 dblclick 被 AMap 消费导致触发意外缩放 | +| **光标提示** | `map.setDefaultCursor('crosshair')` | 用户明确知道"正在绘制中" | +| **右键取消** | `map.on('rightclick', handler)` + `e.domEvent.preventDefault()` | 阻止浏览器右键菜单弹出 | + +#### 3. 闭环判断:Haversine 距离 + +当已有 ≥3 个顶点时,每次点击计算点击位置与首顶点的地理距离。距离 < 30 米 → 自动闭环: + +```ts +// Haversine 公式 — 球面两点最短距离 +function haversineDistance(a, b): + R = 6371000 // 地球半径(米) + dLat, dLng 转为弧度 + a = sin²(dLat/2) + cos(lat1) * cos(lat2) * sin²(dLng/2) + return R * 2 * atan2(√a, √(1-a)) +``` + +**为什么不用像素距离?** 缩放级别变化时像素距离不等价于地理距离——zoom=10 时 30 米 = 2px,zoom=18 时 30 米 = 80px。用地理距离保证所有缩放级别行为一致。 + +#### 4. 首顶点高亮渐变 + +≥3 个顶点后,第一个顶点标记从普通绿色圆点(`geofence-vertex-dot`)替换为红色脉动大圆(`geofence-first-vertex`),CSS 动画 `geofence-vertex-pulse` 缩放+阴影闪烁——给用户强烈暗示"点击这里闭合"。 + +### 二、圆形围栏绘制 + +#### 两阶段 Click 设计 + +``` +阶段 1:第一次 click + ├─ 记录圆心坐标 + ├─ map.off('click', onClick) ← 解绑第一阶段 + ├─ 添加红色圆心标记 + ├─ map.on('mousemove', onMouseMove) ← 动态预览圆形 + ├─ map.on('click', onSecondClick) ← 第二阶段 + └─ map.on('rightclick', onCancel) + +onMouseMove: + └─ 计算 Haversine(圆心, 鼠标) → 更新预览 Circle 的半径 + +阶段 2:第二次 click + ├─ 计算最终半径 + ├─ 清理预览 Circle + 圆心标记 + ├─ 创建正式 Circle 覆盖物 + └─ 恢复 doubleClickZoom + 默认光标 +``` + +**关键**:两个阶段通过解绑/重新绑定不同的事件处理器实现,而非在同一个 handler 中用 `if/else` 分支——职责更清晰。 + +### 三、越界检测 + +#### 1. 检测时机 + +每次老人位置变化都触发 `checkBoundary()`: + +- **方向键移动** (`movePerson`):`setPosition` → `checkBoundary()` +- **拖拽标记** (`dragend` 事件):更新 `personPosition` → `checkBoundary()` +- **首次添加** (`addPerson`):创建后立即检测一次 + +#### 2. 判断点是否在围栏内 + +| 围栏类型 | 方法 | 说明 | +|---------|------|------| +| **圆形** | `AMap.Circle.contains(point)` | AMap 内置方法,直接判断 | +| **多边形** | `AMap.GeometryUtil.isPointInRing()` | AMap 工具方法,优先使用 | +| 多边形兜底 | 自实现射线法 `isPointInPolygon()` | try-catch 兜底,防止 GeometryUtil 不可用 | + +**射线法原理**:从待测点向右发射水平射线,统计与多边形各边的交点数——奇数=在内,偶数=在外。 + +#### 3. 状态转换 + +```ts +checkBoundary(): + 计算 inside(当前是否在围栏内) + + if (!inside && wasInside): // 刚刚越界 + → triggerAlarm(position) + + if (inside && !wasInside): // 刚刚回栏 + → resetAlarm() +``` + +**核心**:用 `wasInside`(上一次检测结果)与当前 `inside` 比较,只做**状态转换检测**,不做持续检测。越界后只要不回围栏,不会重复触发报警。 + +### 四、报警表现(多通道) + +报警通过三个通道同时表现,每个通道独立控制: + +| 通道 | 实现 | 说明 | +|------|------|------| +| **视觉-浮层** | `alarm-overlay` 绝对定位覆盖地图,box-shake + bg-pulse 动画,点击任意处关闭 | `alarmActive` 控制 `v-if` | +| **视觉-标签** | `fence-status` 位置标签:红底"老人已越界" / 绿底"安全区域内" | 由 `alarmActive` 驱动 class 切换 | +| **视觉-围栏** | `updateFenceStyle(true)` → 填充红色半透明 + 红色虚线描边 + 加粗 | `setOptions` 动态切换样式 | +| **听觉** | Web Audio API:880Hz 方波,每 800ms 响 300ms,增益从 0.2 指数衰减到 0 | `OscillatorNode` + `setInterval` | +| **记录** | `alarmHistory` 追加 `{ time, position }`,右侧面板显示 | 累计 `alarmCount` 在浮层中展示 | + +### 五、架构分工 + +``` +MapView.vue (胶水层) + │ + ├─ useAmap() → mapInstance(地图实例,注入下游) + │ + ├─ useGeofence(mapInstance) + │ ├─ 围栏绘制(Polygon/Circle) + │ ├─ 老人标记(添加/移动/拖拽) + │ └─ 越界检测 + 报警 + │ + └─ useRouteTrack(mapInstance) + └─ 路线规划 + 护理员动画(共享 mapInstance,互不干扰) + +生命周期:onUnmounted → destroyGeofence() + destroyRouteTrack() +``` + +两个 Composable 共享同一个 `mapInstance`,通过各自的状态变量(`isDrawing`、`isPlanning`、`isAnimating`)在模板层实现按钮互斥——绘制围栏时禁用路线操作,规划路线时禁用围栏操作。 + +--- + +## 总结一句话 + +> **多边形用"点击地图→Haversine 30m 首顶点闭合 + 手动按钮兜底",圆形用"两阶段 Click + mousemove 实时预览",越界检测用 GeometryUtil/射线法 + 状态转换去重,报警通过视觉(浮层/标签/围栏变色)+ 听觉(Web Audio 蜂鸣)+ 记录(历史面板)三通道实现。** + + --- ## Q5:大卡车每10s发一个GPS点,连续3天(~25920点),前端如何绘制轨迹?会卡顿吗?如何处理? @@ -501,3 +660,316 @@ GET /api/track/123?precision=high → 原始数据(zoom > 14) ## 总结一句话 > **数据层 DP 抽稀去冗余(脱水)→ 渲染层 Loca/CustomLayer 换引擎(重构,量大时)→ 交互层 zoom 自适应 + 视口裁剪 + 防抖(减负),三层组合按量级分档选配,2.6 万点 DP + zoom + 防抖足矣。** + + +--- +## Q7:如何在高德地图上叠加自定义图片(ImageLayer)? + +--- + +## 题目 + +你有一张本地图片(如建筑平面图、园区示意图、地质图层),需要将其叠加到高德地图的指定地理范围内。请回答: + +1. **高德地图 JSAPI v2 中用什么类实现图片叠加?`GroundOverlay`(v1) 和 `ImageLayer`(v2) 有什么区别?** +2. **如何确定图片的地理范围(Bounds)?** +3. **如何管理图片图层的生命周期(加载/显示/隐藏/销毁)?** +4. **Vite 项目中 `import xxx from '@/assets/xxx.png'` 拿到的到底是什么?** + +--- + +## 参考答案 + +### 1. ImageLayer vs GroundOverlay + +| 维度 | `GroundOverlay` (v1.x) | `ImageLayer` (v2.0) | +|------|------------------------|---------------------| +| **所属版本** | JSAPI v1.x | JSAPI v2.0 | +| **构造函数** | `new AMap.GroundOverlay(bounds, { image: url })` | `new AMap.ImageLayer({ url, bounds })` | +| **添加方式** | `map.add(groundOverlay)` | `imageLayer.setMap(map)` | +| **所需插件** | 无(v1 核心) | 无(v2 核心,继承自 `BuildLayer`) | +| **扩展性** | 基本属性(透明度、层级) | 更丰富的图层管理 API(setZooms, setOpacity, setzIndex 等) | + +本项目使用 JSAPI v2.0,所以用 `AMap.ImageLayer`。 + +### 2. Bounds — 地理边界 + +`AMap.Bounds` 用**西南角 + 东北角**两个经纬度坐标定义一个矩形区域: + +```ts +const bounds = new AMap.Bounds( + [116.385, 39.902], // 西南角 (southWest) + [116.410, 39.917], // 东北角 (northEast) +) + +const imageLayer = new AMap.ImageLayer({ + url: '/path/to/image.png', + bounds: bounds, // 也可以是 [lng1, lat1, lng2, lat2] 四元组 + zooms: [3, 20], // 缩放级别范围(小于 3 或大于 20 时自动隐藏) + opacity: 0.8, // 透明度 0-1 +}) +imageLayer.setMap(map) +``` + +**如何确定 bounds?** 有三种常见方式: + +| 方式 | 说明 | 适用场景 | +|------|------|---------| +| **手动指定** | 已知图片覆盖的实际地理位置范围 | 有明确坐标的建筑平面图、园区图 | +| **从地图视口取** | `map.getBounds()` 获取当前可视区域,直接用于 bounds | "把图片铺满当前屏幕"的快速开发 | +| **从图片元数据取** | 图片自带地理参考信息(如 GeoTIFF) | 专业 GIS 图层 | + +### 3. 生命周期管理 + +图片图层的完整生命周期分为 5 个阶段: + +``` +① 创建 → ② 加载 → ③ 显示 → ④ 更新 → ⑤ 销毁 +``` + +```ts +// ① 创建 +const layer = new AMap.ImageLayer({ url, bounds, zooms, opacity }) + +// ② 加载(异步,监听 complete 事件) +layer.on('complete', () => console.log('图片加载完成')) +layer.setMap(map) // 此时开始网络请求 + +// ③ 显示/隐藏 +layer.show() // 显示(默认) +layer.hide() // 隐藏,不销毁 + +// ④ 更新属性 +layer.setOpacity(0.5) +layer.setBounds(newBounds) +layer.setZooms([10, 18]) + +// ⑤ 销毁 +layer.setMap(null) // 从地图移除 +layer.destroy() // 释放资源 +``` + +**关键细节**: + +- `show()`/`hide()` 只控制可见性,不触发网络请求(图片已缓存),适合频繁切换场景 +- `setMap(null)` 从地图移除但不释放内存,`destroy()` 彻底释放 +- `zooms` 参数让图片在特定缩放级别自动显隐 —— 比手动监听 `zoomchange` 更优雅 +- 组件卸载时必须调用 `destroy()`,否则图片资源泄漏 + +### 4. Vite 中的图片导入 + +```ts +import desktopPng from '@/assets/desktop.png' +``` + +这里 `desktopPng` 是 **字符串(URL 地址)**,不是文件对象或 base64。 + +Vite 对小于 `assetsInlineLimit`(默认 4KB)的图片自动内联为 base64,大于阈值的(如这张 desktop.png 是 1.6MB)则复制到 `dist/assets/` 并返回带 hash 的路径,如 `/assets/desktop-D4jkcXaU.png`。 + +这意味着 `AMap.ImageLayer` 接收的 `url` 就是一个普通的 HTTP URL,AMap 内部通过 `` 标签加载,完全兼容 Vite 的构建产物。 + +### 实际落地示例 + +```vue + + + +``` + +--- + +## 总结一句话 + +> **JSAPI v2 用 `AMap.ImageLayer({ url, bounds, zooms })` 替代 v1 的 `GroundOverlay`;Bounds 由西南/东北两个经纬度定义;生命周期通过 `setMap`/`show`/`hide`/`destroy` 管理;Vite 的图片 import 返回构建后的 URL 字符串,直接传给 `url` 即可。** + + +--- +## Q8:如何在 ImageLayer 上叠加 Marker?遇到"地图文字盖住图片"怎么办? + +--- + +## 题目 + +你通过 `AMap.ImageLayer` 在地图上叠加了一张园区平面图。现在需要在图片上放置若干标记点(如设备位置、巡检点),但发现两个问题: + +1. **Marker 放在图片上能被看到吗?会不会被图片挡住?** +2. **地图自带的 POI 标注和路名盖在了图片上面,怎么处理?** + +请回答这两个问题的原因和解决方案。 + +--- + +## 参考答案 + +### 1. Marker 与 ImageLayer 的层级关系 + +高德地图的渲染从下到上分为多个图层: + +``` +┌───────────────────────┐ +│ ⑥ 地图文字标注 │ ← 独立渲染层,永远最上层! +│ ⑤ Marker 标记 │ ← 默认 zIndex 100+ +│ ④ Polygon / Polyline │ +│ ③ ImageLayer │ ← 默认 zIndex 6 +│ ② 道路 / 建筑 │ +│ ① 底图瓦片 │ +└───────────────────────┘ +``` + +**默认情况下 Marker 就在 ImageLayer 之上**,不需要特殊处理就能看到。但建议显式设置 zIndex 确保万无一失: + +```ts +// ImageLayer: zIndex 200 +const layer = new AMap.ImageLayer({ url, bounds, zIndex: 200 }) + +// Marker: zIndex 500(明确比 ImageLayer 高) +const marker = new AMap.Marker({ position, zIndex: 500 }) +``` + +如果 Marker 数量多(几百个),建议用同一个 zIndex 即可,AMap 内部按添加顺序渲染同层 Marker。 + +### 2. 为什么地图文字会盖住图片? + +地图的 **POI 标注和路名** 渲染在 AMap 引擎的**独立文字图层**上,这个图层位于所有用户叠加层(ImageLayer、Marker、Polygon 等)之上。所以无论 ImageLayer 的 zIndex 设多高,文字始终在上面。 + +这**不是 bug**——高德的设计假设用户叠加图片后仍需要看到地名参考。但图片叠加场景通常不需要。 + +**解决方案**:通过 `map.setFeatures()` 控制底图要素的显示,去掉 `'point'`(POI 标注): + +```ts +// 隐藏 POI 标注和路名 +map.setFeatures(['bg', 'road', 'building']) // 去掉 'point' + +// 恢复 +map.setFeatures(['bg', 'road', 'building', 'point']) +``` + +| 参数 | 含义 | +|------|------| +| `'bg'` | 背景地图 | +| `'road'` | 道路线 | +| `'building'` | 建筑 3D | +| **`'point'`** | **POI 标注 + 路名(就是盖在图片上的文字)** | + +**注意事项**: + +- **页面切换时需恢复**:在 `onUnmounted` 中将 features 恢复为全量 `['bg', 'road', 'building', 'point']`,否则离开页面后其他地图页面也看不到标注 +- 如果只需要隐藏路名但保留 POI,可以用 `map.setShowLabel(false)`(不推荐的旧 API),或者接受"要么全留要么全去" +- `setFeatures` 是全局操作,影响整个地图实例。如果同一地图上有多个 ImageLayer 需要不同策略,需要自己管理状态 + +### 3. 完整示例:图片 + Marker + 无文字标注 + +```ts +// ① 地图就绪后先叠加图片 +const imageLayer = new AMap.ImageLayer({ + url: desktopPng, + bounds: new AMap.Bounds([116.38, 39.90], [116.41, 39.92]), + zIndex: 200, +}) +imageLayer.setMap(map) + +// ② 隐藏 POI 标注(可选:提供按钮让用户自行切换) +map.setFeatures(['bg', 'road', 'building']) + +// ③ 在图片上放置 Marker +function placeMarker(lng: number, lat: number) { + const marker = new AMap.Marker({ + position: [lng, lat], + content: '
📍
', + offset: new AMap.Pixel(-13, -13), + zIndex: 500, // 明确高于 ImageLayer + draggable: true, + }) + marker.setMap(map) + return marker +} + +// ④ 离开页面时清理 +onUnmounted(() => { + imageLayer.destroy() + map.setFeatures(['bg', 'road', 'building', 'point']) // 恢复文字层 +}) +``` + +### 4. Marker 交互设计:模式开关 + +在图片上放置 Marker 需要"点击地图"交互。推荐使用**模式开关**而非全局 `map.on('click')`: + +``` +用户点击「📍 添加标记」→ map.on('click', handler) + 光标变 crosshair +用户点击地图 → 创建 Marker + 可能自动退出模式 +用户再次点击按钮 → map.off('click', handler) + 光标还原 +``` + +**为什么不用始终开启的全局 click?** 全局 click 无法区分用户的意图——是在拖动地图还是在放置标记。模式开关让职责单一,用完即解绑,事件不堆积。 + +--- + +## 总结一句话 + +> **Marker 默认层级就在 ImageLayer 之上,设 zIndex:500 保底;地图文字标注通过 `map.setFeatures(['bg','road','building'])` 去掉 `'point'` 来隐藏,离开页面时恢复;放置 Marker 用模式开关管理 map click 事件,用完即解绑。** + + +--- +## Q9:ImageLayer 上的 Marker 为什么移除图片后还在?它们是什么关系? + +--- + +## 题目 + +你在 `ImageLayer` 上放了一些 Marker,视觉上 Marker 在图片上面。但当你调用 `removeImageLayer()` 移除图片后,Marker 依然留在地图上。为什么? + +--- + +## 参考答案 + +**因为它们不是父子关系,而是平级兄弟。** + +ImageLayer 和 Marker 都直接挂在地图实例上,是**两个独立的覆盖物**: + +``` + mapInstance + / \ + ImageLayer Marker①, Marker②, Marker③... +``` + +zIndex 只决定视觉上的前后遮挡,不产生任何数据上的从属关系。移除 ImageLayer 只销毁自己,Marker 不受影响。 + +**类比**:桌上铺一块布(ImageLayer),上面放几颗棋子(Marker)。抽走布,棋子还在桌上——它们从没粘在布上。 + +**如果想让 Marker 跟着图片走**,可以: +- 图片隐藏时调 `marker.hide()`,显示时调 `marker.show()` +- 或者更彻底:移除图片时 `clearMarkers()`,重新叠加时再重建 Marker + +--- + +## 总结一句话 + +> **高德地图中所有覆盖物(ImageLayer、Marker、Polygon 等)都是 `mapInstance` 的平级子节点,视觉上的"谁在谁上面"由 zIndex 控制,数据上互不隶属。** diff --git a/src/App.vue b/src/App.vue index b6f61ca..b0c9621 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,6 +7,7 @@ 关于 地图 切换演示 + 图片叠加
diff --git a/src/assets/desktop.png b/src/assets/desktop.png new file mode 100644 index 0000000..aa01e5e Binary files /dev/null and b/src/assets/desktop.png differ diff --git a/src/router/index.ts b/src/router/index.ts index 1425073..630a0b4 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -20,6 +20,11 @@ const routes = [ path: '/map-switch-demo', name: 'map-switch-demo', component: () => import('@/views/MapSwitchDemo.vue') + }, + { + path: '/image-overlay', + name: 'image-overlay', + component: () => import('@/views/ImageOverlayDemo.vue') } ] diff --git a/src/views/ImageOverlayDemo.vue b/src/views/ImageOverlayDemo.vue new file mode 100644 index 0000000..218b003 --- /dev/null +++ b/src/views/ImageOverlayDemo.vue @@ -0,0 +1,539 @@ + + + + + + + + diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 927ade3..22ab7ca 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -48,6 +48,9 @@ declare namespace AMap { getDefaultCursor(): string getCenter(): { lng: number; lat: number } lngLatToContainer(lnglat: { lng: number; lat: number }): { x: number; y: number } + /** 设置底图要素显示:'bg'=背景, 'road'=道路, 'building'=建筑, 'point'=POI标注 */ + setFeatures(features: string[]): void + getFeatures(): string[] } class Geocoder { @@ -252,6 +255,47 @@ declare namespace AMap { setContent(content: string): void } + // ── 图片图层(ImageLayer)── + interface ImageLayerOptions { + /** 图片 URL */ + url: string + /** 图片覆盖的地理范围(西南角 + 东北角) */ + bounds: Bounds | [number, number, number, number] + /** 显示的缩放级别范围 [min, max] */ + zooms?: [number, number] + /** 透明度 0-1,默认 1 */ + opacity?: number + /** 图层叠加顺序,默认 6 */ + zIndex?: number + [key: string]: any + } + + class ImageLayer { + constructor(opts: ImageLayerOptions) + setMap(map: Map | null): void + setBounds(bounds: Bounds | [number, number, number, number]): void + setZooms(zooms: [number, number]): void + setOpacity(opacity: number): void + setzIndex(zIndex: number): void + getBounds(): Bounds + getOpacity(): number + getzIndex(): number + show(): void + hide(): void + destroy(): void + } + + // ── 地理范围(Bounds)── + class Bounds { + constructor(southWest: [number, number], northEast: [number, number]) + /** 返回西南角坐标 */ + getSouthWest(): { lng: number; lat: number } + /** 返回东北角坐标 */ + getNorthEast(): { lng: number; lat: number } + /** 判断某点是否在范围内 */ + contains(point: [number, number] | { lng: number; lat: number }): boolean + } + // ── 驾车路线规划 ── interface DrivingOptions { map?: Map