feat: 添加图片图层
This commit is contained in:
472
docs/AMap面试题.md
472
docs/AMap面试题.md
@@ -329,6 +329,165 @@ startAnimation() → setInterval 40ms → setPosition → 到达
|
|||||||
> **模式开关管理点选(用完即解绑)→ `AMap.Driving` + 回调转 Promise 规划路线 → 降采样到 250 点后 `setInterval` + `setPosition` 驱动动画,状态变量全程控制 UI 互斥。**
|
> **模式开关管理点选(用完即解绑)→ `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点),前端如何绘制轨迹?会卡顿吗?如何处理?
|
## 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 + 防抖足矣。**
|
> **数据层 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 内部通过 `<img>` 标签加载,完全兼容 Vite 的构建产物。
|
||||||
|
|
||||||
|
### 实际落地示例
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div ref="containerRef" class="map-container" />
|
||||||
|
<button @click="toggleLayer">切换图层</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAmap } from '@/composables/useAmap'
|
||||||
|
import desktopPng from '@/assets/desktop.png'
|
||||||
|
|
||||||
|
const { containerRef, mapInstance, initMap } = useAmap({ zoom: 14 })
|
||||||
|
const layer = shallowRef<AMap.ImageLayer | null>(null)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const map = await initMap()
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
layer.value = new AMap.ImageLayer({
|
||||||
|
url: desktopPng, // Vite 导入的 URL
|
||||||
|
bounds: new AMap.Bounds([116.38,39.90], [116.41,39.92]),
|
||||||
|
zooms: [3, 20],
|
||||||
|
})
|
||||||
|
layer.value.setMap(map)
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleLayer() {
|
||||||
|
layer.value?.[layerVisible ? 'hide' : 'show']()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => layer.value?.destroy())
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总结一句话
|
||||||
|
|
||||||
|
> **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: '<div class="my-marker">📍</div>',
|
||||||
|
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 控制,数据上互不隶属。**
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<router-link to="/about">关于</router-link>
|
<router-link to="/about">关于</router-link>
|
||||||
<router-link to="/map">地图</router-link>
|
<router-link to="/map">地图</router-link>
|
||||||
<router-link to="/map-switch-demo">切换演示</router-link>
|
<router-link to="/map-switch-demo">切换演示</router-link>
|
||||||
|
<router-link to="/image-overlay">图片叠加</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="sub-app-main">
|
<main class="sub-app-main">
|
||||||
|
|||||||
BIN
src/assets/desktop.png
Normal file
BIN
src/assets/desktop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -20,6 +20,11 @@ const routes = [
|
|||||||
path: '/map-switch-demo',
|
path: '/map-switch-demo',
|
||||||
name: 'map-switch-demo',
|
name: 'map-switch-demo',
|
||||||
component: () => import('@/views/MapSwitchDemo.vue')
|
component: () => import('@/views/MapSwitchDemo.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/image-overlay',
|
||||||
|
name: 'image-overlay',
|
||||||
|
component: () => import('@/views/ImageOverlayDemo.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
539
src/views/ImageOverlayDemo.vue
Normal file
539
src/views/ImageOverlayDemo.vue
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overlay-page">
|
||||||
|
<!-- ═══ 地图初始化状态 ═══ -->
|
||||||
|
<div v-if="loading" class="map-status">⏳ 地图加载中...</div>
|
||||||
|
<div v-else-if="error" class="map-status map-error">❌ {{ error }}</div>
|
||||||
|
|
||||||
|
<!-- ═══ 图片加载状态 ═══ -->
|
||||||
|
<div v-if="mapReady && imageLoading" class="map-status">🖼️ 图片加载中...</div>
|
||||||
|
|
||||||
|
<!-- ═══ 地图容器 ═══ -->
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="map-container"
|
||||||
|
:class="{ 'map-hidden': loading || error }"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ═══ 底部控制栏 ═══ -->
|
||||||
|
<div class="map-controls">
|
||||||
|
<div class="controls-row">
|
||||||
|
<span class="controls-title">🖼️ 图片叠加层</span>
|
||||||
|
<div class="controls-actions">
|
||||||
|
<!-- 显示/隐藏 -->
|
||||||
|
<button
|
||||||
|
:disabled="!imageLayer"
|
||||||
|
@click="toggleVisible"
|
||||||
|
>
|
||||||
|
{{ layerVisible ? '👁️ 隐藏图片' : '👁️🗨️ 显示图片' }}
|
||||||
|
</button>
|
||||||
|
<!-- 透明度 -->
|
||||||
|
<label class="opacity-label">
|
||||||
|
透明度:
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
:value="opacityPercent"
|
||||||
|
:disabled="!imageLayer"
|
||||||
|
@input="setOpacity"
|
||||||
|
/>
|
||||||
|
<span class="opacity-value">{{ opacityPercent }}%</span>
|
||||||
|
</label>
|
||||||
|
<!-- 隐藏/显示地图标注 -->
|
||||||
|
<button
|
||||||
|
:disabled="!mapReady"
|
||||||
|
@click="toggleLabels"
|
||||||
|
>
|
||||||
|
{{ labelsHidden ? '🏷️ 显示标注' : '🚫 隐藏标注' }}
|
||||||
|
</button>
|
||||||
|
<!-- 移除/重新叠加 -->
|
||||||
|
<button
|
||||||
|
v-if="imageLayer"
|
||||||
|
class="btn-danger"
|
||||||
|
@click="removeImageLayer"
|
||||||
|
>
|
||||||
|
🗑️ 移除叠加
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
:disabled="!mapReady"
|
||||||
|
@click="addImageLayer"
|
||||||
|
>
|
||||||
|
➕ 叠加图片
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ 第二行:标记操作 ═══ -->
|
||||||
|
<div class="controls-row">
|
||||||
|
<span class="controls-title">📍 标记点</span>
|
||||||
|
<div class="controls-actions">
|
||||||
|
<!-- 添加标记模式 -->
|
||||||
|
<button
|
||||||
|
v-if="!markerMode"
|
||||||
|
:disabled="!imageLayer"
|
||||||
|
@click="enterMarkerMode"
|
||||||
|
>
|
||||||
|
📍 添加标记
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn-finish"
|
||||||
|
@click="exitMarkerMode"
|
||||||
|
>
|
||||||
|
🎯 点击地图放置标记...
|
||||||
|
</button>
|
||||||
|
<!-- 清除所有标记 -->
|
||||||
|
<button
|
||||||
|
v-if="markers.length > 0"
|
||||||
|
class="btn-danger"
|
||||||
|
:disabled="markerMode"
|
||||||
|
@click="clearMarkers"
|
||||||
|
>
|
||||||
|
🗑️ 清除标记 ({{ markers.length }})
|
||||||
|
</button>
|
||||||
|
<!-- 删除最后添加的标记 -->
|
||||||
|
<button
|
||||||
|
v-if="markers.length > 0"
|
||||||
|
class="btn-reset"
|
||||||
|
:disabled="markerMode"
|
||||||
|
@click="removeLastMarker"
|
||||||
|
>
|
||||||
|
↩ 撤销上一个
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 标记模式提示条 ── -->
|
||||||
|
<div v-if="markerMode" class="controls-row draw-hint-row">
|
||||||
|
<span class="draw-hint">
|
||||||
|
🎯 <strong>点击地图</strong>放置标记 |
|
||||||
|
已放置 <strong>{{ markers.length }}</strong> 个标记 |
|
||||||
|
再次点击「🎯」按钮退出
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="imageLayer" class="controls-row controls-info">
|
||||||
|
<span class="info-text">
|
||||||
|
图片: desktop.png |
|
||||||
|
范围: [{{ boundsSW.join(', ') }}] → [{{ boundsNE.join(', ') }}]
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// ============================================================
|
||||||
|
// 图片叠加层演示页 — 将 desktop.png 叠加到高德地图上
|
||||||
|
// ============================================================
|
||||||
|
import { ref, computed, shallowRef, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { useAmap } from '@/composables/useAmap'
|
||||||
|
import desktopPng from '@/assets/desktop.png'
|
||||||
|
|
||||||
|
// ═══ 地图初始化 ═══
|
||||||
|
const { containerRef, mapInstance, loading, error, initMap } = useAmap({
|
||||||
|
center: [116.397428, 39.90923], // 北京天安门
|
||||||
|
zoom: 14,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapReady = ref(false)
|
||||||
|
|
||||||
|
async function bootstrapMap() {
|
||||||
|
const map = await initMap()
|
||||||
|
if (map) {
|
||||||
|
mapReady.value = true
|
||||||
|
nextTick(() => map.resize())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => bootstrapMap())
|
||||||
|
|
||||||
|
// ═══ 图片叠加层 ═══
|
||||||
|
const imageLayer = shallowRef<AMap.ImageLayer | null>(null)
|
||||||
|
const layerVisible = ref(true)
|
||||||
|
const opacityPercent = ref(100)
|
||||||
|
const imageLoading = ref(false)
|
||||||
|
const labelsHidden = ref(false)
|
||||||
|
|
||||||
|
/** 底图默认显示的要素 */
|
||||||
|
const DEFAULT_FEATURES = ['bg', 'road', 'building', 'point']
|
||||||
|
/** 隐藏标注后的要素(去掉 point,PIO 标注和路名都不显示) */
|
||||||
|
const NO_LABEL_FEATURES = ['bg', 'road', 'building']
|
||||||
|
|
||||||
|
// 叠加范围:以北京市中心为参照,生成一个约 2km × 1.5km 的矩形区域
|
||||||
|
const boundsSW = computed(() => [116.385, 39.902])
|
||||||
|
const boundsNE = computed(() => [116.410, 39.917])
|
||||||
|
|
||||||
|
/** 将图片叠加到地图上 */
|
||||||
|
function addImageLayer() {
|
||||||
|
const map = mapInstance.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
if (imageLayer.value) return // 已有叠加层,避免重复创建
|
||||||
|
|
||||||
|
imageLoading.value = true
|
||||||
|
|
||||||
|
// 创建图片图层
|
||||||
|
const layer = new AMap.ImageLayer({
|
||||||
|
url: desktopPng,
|
||||||
|
bounds: new AMap.Bounds(
|
||||||
|
boundsSW.value as [number, number],
|
||||||
|
boundsNE.value as [number, number],
|
||||||
|
),
|
||||||
|
zooms: [3, 20],
|
||||||
|
zIndex: 200,
|
||||||
|
opacity: opacityPercent.value / 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听图片加载完成
|
||||||
|
layer.on('complete', () => {
|
||||||
|
imageLoading.value = false
|
||||||
|
console.log('[ImageOverlay] 图片加载完成')
|
||||||
|
})
|
||||||
|
|
||||||
|
layer.setMap(map)
|
||||||
|
imageLayer.value = layer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除图片叠加层 */
|
||||||
|
function removeImageLayer() {
|
||||||
|
if (imageLayer.value) {
|
||||||
|
imageLayer.value.setMap(null)
|
||||||
|
imageLayer.value.destroy()
|
||||||
|
imageLayer.value = null
|
||||||
|
imageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换可见性 */
|
||||||
|
function toggleVisible() {
|
||||||
|
if (!imageLayer.value) return
|
||||||
|
layerVisible.value = !layerVisible.value
|
||||||
|
if (layerVisible.value) {
|
||||||
|
imageLayer.value.show()
|
||||||
|
} else {
|
||||||
|
imageLayer.value.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置透明度 */
|
||||||
|
function setOpacity(e: Event) {
|
||||||
|
const val = Number((e.target as HTMLInputElement).value)
|
||||||
|
opacityPercent.value = val
|
||||||
|
if (imageLayer.value) {
|
||||||
|
imageLayer.value.setOpacity(val / 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换地图标注显示/隐藏 */
|
||||||
|
function toggleLabels() {
|
||||||
|
const map = mapInstance.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
labelsHidden.value = !labelsHidden.value
|
||||||
|
const features = labelsHidden.value ? NO_LABEL_FEATURES : DEFAULT_FEATURES
|
||||||
|
map.setFeatures(features)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 标记(Marker)═══
|
||||||
|
const markerMode = ref(false)
|
||||||
|
const markers = shallowRef<AMap.Marker[]>([])
|
||||||
|
|
||||||
|
/** 点击地图放置标记的回调(在进入/退出模式时绑定/解绑) */
|
||||||
|
function onMapClick(e: any) {
|
||||||
|
const map = mapInstance.value
|
||||||
|
if (!map || !markerMode.value) return
|
||||||
|
|
||||||
|
// 从事件中提取经纬度
|
||||||
|
const lngLat = e.lnglat || e.lngLat
|
||||||
|
if (!lngLat) return
|
||||||
|
|
||||||
|
const pos: [number, number] = [lngLat.lng, lngLat.lat]
|
||||||
|
const idx = markers.value.length + 1
|
||||||
|
|
||||||
|
// 创建带编号的标记
|
||||||
|
const marker = new AMap.Marker({
|
||||||
|
position: pos,
|
||||||
|
content: `<div class="img-marker-dot">${idx}</div>`,
|
||||||
|
offset: new AMap.Pixel(-13, -13),
|
||||||
|
zIndex: 500, // 确保在 ImageLayer(zIndex=200)之上
|
||||||
|
draggable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 拖拽结束时更新位置信息
|
||||||
|
marker.on('dragend', function (this: AMap.Marker) {
|
||||||
|
const p = this.getPosition()
|
||||||
|
console.log(`[Marker ${idx}] 拖拽到: [${p.lng.toFixed(6)}, ${p.lat.toFixed(6)}]`)
|
||||||
|
})
|
||||||
|
|
||||||
|
marker.setMap(map)
|
||||||
|
markers.value = [...markers.value, marker]
|
||||||
|
|
||||||
|
console.log(`[Marker ${idx}] 放置于: [${pos[0].toFixed(6)}, ${pos[1].toFixed(6)}]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 进入标记模式 */
|
||||||
|
function enterMarkerMode() {
|
||||||
|
const map = mapInstance.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
markerMode.value = true
|
||||||
|
map.setDefaultCursor('crosshair')
|
||||||
|
map.on('click', onMapClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退出标记模式 */
|
||||||
|
function exitMarkerMode() {
|
||||||
|
const map = mapInstance.value
|
||||||
|
if (!map) return
|
||||||
|
|
||||||
|
markerMode.value = false
|
||||||
|
map.setDefaultCursor('default')
|
||||||
|
map.off('click', onMapClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除所有标记 */
|
||||||
|
function clearMarkers() {
|
||||||
|
const map = mapInstance.value
|
||||||
|
markers.value.forEach((m) => {
|
||||||
|
m.setMap(null)
|
||||||
|
m.destroy()
|
||||||
|
})
|
||||||
|
markers.value = []
|
||||||
|
// 如果正在标记模式中,退出
|
||||||
|
if (markerMode.value) {
|
||||||
|
exitMarkerMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 撤销最后一个标记 */
|
||||||
|
function removeLastMarker() {
|
||||||
|
const last = markers.value[markers.value.length - 1]
|
||||||
|
if (!last) return
|
||||||
|
last.setMap(null)
|
||||||
|
last.destroy()
|
||||||
|
markers.value = markers.value.slice(0, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══ 生命周期清理 ═══
|
||||||
|
onUnmounted(() => {
|
||||||
|
removeImageLayer()
|
||||||
|
clearMarkers()
|
||||||
|
// 恢复底图标注,避免影响其他页面
|
||||||
|
if (mapInstance.value && labelsHidden.value) {
|
||||||
|
mapInstance.value.setFeatures(DEFAULT_FEATURES)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
页面布局
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
.overlay-page {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 状态浮层 ── */
|
||||||
|
.map-status {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 10;
|
||||||
|
padding: 14px 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-error {
|
||||||
|
color: #e74c3c;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 地图容器 ── */
|
||||||
|
.map-container {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════
|
||||||
|
底部控制栏
|
||||||
|
═══════════════════════════════════════════ */
|
||||||
|
.map-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #e8e8e8;
|
||||||
|
box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.04);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-row + .controls-row {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 按钮 ── */
|
||||||
|
.controls-actions button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid #42b883;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #42b883;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-actions button:hover:not(:disabled) {
|
||||||
|
background: #42b883;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-actions button:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
border-color: #FF4D4F !important;
|
||||||
|
color: #FF4D4F !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover:not(:disabled) {
|
||||||
|
background: #FF4D4F !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 透明度滑条 ── */
|
||||||
|
.opacity-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-label input[type='range'] {
|
||||||
|
width: 100px;
|
||||||
|
accent-color: #42b883;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opacity-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
width: 32px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 信息条 ── */
|
||||||
|
.controls-info {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 标记模式提示 ── */
|
||||||
|
.draw-hint-row {
|
||||||
|
background: #f0faf5;
|
||||||
|
border-top: 1px solid #d0ede0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-hint strong {
|
||||||
|
color: #42b883;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 按钮变体 ── */
|
||||||
|
.btn-finish {
|
||||||
|
border-color: #42b883 !important;
|
||||||
|
background: #42b883 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
font-weight: 600;
|
||||||
|
animation: btn-finish-pulse 0.8s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes btn-finish-pulse {
|
||||||
|
from { box-shadow: 0 0 0 rgba(66, 184, 131, 0); }
|
||||||
|
to { box-shadow: 0 0 12px rgba(66, 184, 131, 0.5); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset {
|
||||||
|
border-color: #95a5a6 !important;
|
||||||
|
color: #95a5a6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset:hover:not(:disabled) {
|
||||||
|
background: #95a5a6 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Marker 自定义样式(不能 scoped,AMap 直接注入 DOM) -->
|
||||||
|
<style>
|
||||||
|
.img-marker-dot {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #FF6B6B, #FF4D4F);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 26px;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 77, 79, 0.4);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
44
src/vite-env.d.ts
vendored
44
src/vite-env.d.ts
vendored
@@ -48,6 +48,9 @@ declare namespace AMap {
|
|||||||
getDefaultCursor(): string
|
getDefaultCursor(): string
|
||||||
getCenter(): { lng: number; lat: number }
|
getCenter(): { lng: number; lat: number }
|
||||||
lngLatToContainer(lnglat: { lng: number; lat: number }): { x: number; y: number }
|
lngLatToContainer(lnglat: { lng: number; lat: number }): { x: number; y: number }
|
||||||
|
/** 设置底图要素显示:'bg'=背景, 'road'=道路, 'building'=建筑, 'point'=POI标注 */
|
||||||
|
setFeatures(features: string[]): void
|
||||||
|
getFeatures(): string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
class Geocoder {
|
class Geocoder {
|
||||||
@@ -252,6 +255,47 @@ declare namespace AMap {
|
|||||||
setContent(content: string): void
|
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 {
|
interface DrivingOptions {
|
||||||
map?: Map
|
map?: Map
|
||||||
|
|||||||
Reference in New Issue
Block a user