OpenLayers 实战指南
OpenLayers 实战指南
当前是结合 OpenLayers离线地图 和 OpenLayers_整理 2个笔记汇总的结果
基于 Vue2 + OpenLayers 9.2.4 的离线地图开发完整指南
本文档整合了 OpenLayers 基础知识与实战经验,面向业务地图开发
https://download.csdn.net/blog/column/11055250/123442218
https://blog.csdn.net/m0_45127388/article/details/129529260
https://blog.csdn.net/tk08888/article/details/127053451
📚 目录
功能清单速查
本项目基于 OpenLayers 9.2.4 实现的 WebGIS 功能全景图
🎯 一级功能分类
1️⃣ 基础要素绘制与展示
| 功能模块 | 支持能力 | 实现状态 | 文档位置 |
|---|---|---|---|
| 点要素 | 自定义图标(PNG/SVG/GIF)、点击交互、悬停效果、高亮选中 | ✅ 已实现 | M02 点要素模块 |
| 线要素 | 普通线/虚线/箭头线、分组管理、显隐控制、高亮选中 | ✅ 已实现 | M03 线要素模块 |
| 面要素 | 多边形绘制、填充/描边、透明度控制、打洞效果 | ✅ 已实现 | M04 面要素模块 |
2️⃣ 高级可视化
| 功能模块 | 支持能力 | 实现状态 | 文档位置 |
|---|---|---|---|
| 海量点标记 | 千/万/十万级点展示、Canvas 渲染、样式函数 | ✅ 已实现 | 4.1 海量点渲染优化 |
| 点聚合 | 自动聚合、聚合数量显示、点击展开、自定义样式 | ✅ 已实现 | M05 聚合模块 |
| 热力图 | 热力半径、权重配置、渐变色、动态更新 | ✅ 已实现 | M06 热力图模块 |
| 分级设色专题图 | 数值分段、颜色映射、图例生成 | 🔄 规划中 | - |
3️⃣ 地图交互与控制
| 功能模块 | 支持能力 | 实现状态 | 文档位置 |
|---|---|---|---|
| 图层管理 | 多图层切换、显隐控制、图层分组、zIndex 管理 | ✅ 已实现 | M01 图层管理模块 |
| 缩放控制 | 缩放级别限制、动态缩放、缩放监听 | ✅ 已实现 | 1.4 地图视图控制 |
| 范围限制 | 区域范围限制、自动回弹、边界检查 | ✅ 已实现 | 1.4 地图视图控制 |
| 旋转控制 | 禁用旋转、锁定方向 | ✅ 已实现 | 1.4 地图视图控制 |
4️⃣ 业务专题功能
| 功能模块 | 支持能力 | 实现状态 | 文档位置 |
|---|---|---|---|
| 行政区划 | 省/市/区边界、描边高亮、蒙层效果(镂空) | ✅ 已实现 | 案例3:行政区蒙层效果 |
| 搜索定位 | 地址搜索、业务对象搜索、定位到要素、缩放到目标 | ✅ 已实现 | Q5: 如何定位到要素? |
| 轨迹动画 | 人员/车辆轨迹、播放/暂停/倍速、行进动画、路径模拟 | 🔄 规划中 | - |
| 非运维区域标注 | 矩形框标注、虚线样式、相邻区域合并 | ✅ 已实现 | 案例2:非运维区域标注 |
5️⃣ 2D/3D 与高级特效
| 功能模块 | 支持能力 | 实现状态 | 文档位置 |
|---|---|---|---|
| 2D 地图 | 平面地图、倾斜视角、高程模拟 | ✅ 已实现 | 当前文档 |
| 3D 地图 | 真 3D 场景、地形、建筑物 | 🔄 规划中 | 推荐使用 Cesium |
| GIF 动画图标 | 动态图标、闪烁效果、自定义动画 | ✅ 已实现 | M02 点要素模块 |
| 图片 + 文本叠加 | 图标上叠加文字、自定义偏移、样式配置 | ✅ 已实现 | 案例4:图片标注 + 文本叠加 |
6️⃣ 底图与数据源
| 功能模块 | 支持能力 | 实现状态 | 文档位置 |
|---|---|---|---|
| WMTS 服务 | GeoServer、ArcGIS Server、离线瓦片 | ✅ 已实现 | 1.3 底图瓦片服务接入 |
| XYZ 瓦片 | 天地图、高德、百度、自定义瓦片 | ✅ 已实现 | 1.3 底图瓦片服务接入 |
| 离线地图 | 本地瓦片、缺省背景、错误处理 | ✅ 已实现 | 1.3 底图瓦片服务接入 |
7️⃣ 性能优化
| 功能模块 | 支持能力 | 实现状态 | 文档位置 |
|---|---|---|---|
| 海量数据渲染 | Canvas 渲染、declutter、分批加载 | ✅ 已实现 | 四、性能优化 |
| 图层懒加载 | 按需加载、缩放级别控制 | ✅ 已实现 | 4.2 图层懒加载 |
| 触屏优化 | 快速响应、延迟优化、容差配置 | ✅ 已实现 | 4. 触屏优化 |
🔍 功能快速查找索引
按业务场景查找
场景 1:展示人员/设备位置
- 基础展示 → M02 点要素模块
- 自定义图标 → M02 点要素模块
- GIF 动画图标 → M02 点要素模块
- 点击查看详情 → 3. 要素点击事件
- 高亮选中效果 → Q4: 如何实现要素高亮?
场景 2:展示线路/管线
- 基础线路绘制 → M03 线要素模块
- 线路分组管理 → 案例1:线路分组管理 + 图例联动
- 虚线/箭头线 → M03 线要素模块
- 非运维区域标注 → 案例2:非运维区域标注
场景 3:展示区域范围
- 基础面绘制 → M04 面要素模块
- 行政区划边界 → 案例3:行政区蒙层效果
- 省外蒙层效果 → 案例3:行政区蒙层效果
场景 4:海量数据展示
- 海量点渲染 → 4.1 海量点渲染优化
- 点聚合 → M05 聚合模块
- 热力图 → M06 热力图模块
场景 5:轨迹回放
- 轨迹动画 → 🔄 规划中(推荐使用定时器 + 更新坐标)
- 路径模拟 → 🔄 规划中
场景 6:地图控制
- 图层切换 → M01 图层管理模块
- 缩放控制 → 1.4 地图视图控制
- 范围限制 → 1.4 地图视图控制
- 搜索定位 → Q5: 如何定位到要素?
按技术实现查找
图标相关
- 静态图标(PNG/SVG) → M02 点要素模块
- 动态图标(GIF) → M02 点要素模块
- 图标 + 文本 → 案例4:图片标注 + 文本叠加
样式相关
- 点样式 → M02 点要素模块
- 线样式(实线/虚线/箭头) → M03 线要素模块
- 面样式(填充/描边) → M04 面要素模块
- 高亮样式 → Q4: 如何实现要素高亮?
交互相关
数据加载
- GeoJSON 加载 → 案例1:线路分组管理
- 分批加载 → 4.3 要素分批加载
- 懒加载 → 4.2 图层懒加载
🎓 学习路径建议
第 1 周:基础入门
- ✅ 地图初始化 → 1.5 完整初始化示例
- ✅ 底图接入 → 1.3 底图瓦片服务接入
- ✅ 点线面绘制 → M02 / M03 / M04
第 2 周:交互进阶
- ✅ 事件监听 → 1.4 地图事件监听
- ✅ 要素高亮 → Q4: 如何实现要素高亮?
- ✅ 图层管理 → M01 图层管理模块
第 3 周:业务实战
- ✅ 线路管理 → 案例1:线路分组管理
- ✅ 行政区蒙层 → 案例3:行政区蒙层效果
- ✅ 非运维区域 → 案例2:非运维区域标注
第 4 周:性能优化
- ✅ 海量点渲染 → 4.1 海量点渲染优化
- ✅ 点聚合 → M05 聚合模块
- ✅ 触屏优化 → 4. 触屏优化
💡 待开发功能清单
| 功能 | 优先级 | 预计工作量 | 技术方案 |
|---|---|---|---|
| 轨迹动画 | 高 | 2-3天 | 定时器 + Feature 坐标更新 + requestAnimationFrame |
| 分级设色专题图 | 中 | 1-2天 | 数值分段 + 样式函数 + 图例组件 |
| 3D 地图 | 低 | 5-7天 | 集成 Cesium 或 ol-cesium |
🔗 外部资源链接
- 官方文档: https://openlayers.org/en/latest/apidoc/
- 示例库: https://openlayers.org/en/latest/examples/
- GeoJSON 规范: https://geojson.org/
- 坐标转换工具: https://epsg.io/
- 水经注离线地图: 地图开发整理#水经注离线地图下载
一、快速开始
1.1 技术栈
{
"dependencies": {
"ol": "^9.2.4",
"vue": "^2.7.16"
},
"devDependencies": {
"webpack": "^5.91.0",
"vue-loader": "^15.11.1",
"babel-loader": "^9.1.3"
}
}
1.2 项目结构
project/
├── src/
│ ├── components/
│ │ └── OlMap.vue # 地图组件
│ ├── map/
│ │ ├── core/ # 地图初始化
│ │ ├── layers/ # 图层管理
│ │ ├── features/ # 点线面要素
│ │ ├── business/ # 业务模块(行政区、专题图)
│ │ ├── interaction/ # 交互模块
│ │ └── utils/ # 工具函数
│ └── main.js
└── public/
└── index.html
1.3 底图瓦片服务接入
OpenLayers 支持多种瓦片服务接入方式,常用的有 WMTS 和 XYZ 两种。
方式一:WMTS(推荐用于标准 OGC 服务)
适用场景:
- GeoServer、ArcGIS Server 等标准 WMTS 服务
- 需要多坐标系支持
- 企业级离线地图服务
完整示例:
import Map from 'ol/Map'
import View from 'ol/View'
import TileLayer from 'ol/layer/Tile'
import WMTS from 'ol/source/WMTS'
import WMTSTileGrid from 'ol/tilegrid/WMTS'
import { fromLonLat } from 'ol/proj'
export default {
data() {
return {
map: null,
projection: 'EPSG:3857',
minZoom: 8,
maxZoom: 18
}
},
mounted() {
this.initMap()
},
methods: {
initMap() {
// 1. 创建 WMTS 底图
const wmtsLayer = this.createWMTSLayer()
// 2. 创建缺省背景(瓦片加载失败时显示)
const backgroundLayer = this.createBackgroundLayer()
// 3. 创建视图
const view = this.createView()
// 4. 创建地图
this.map = new Map({
target: this.$refs.mapEl,
layers: [backgroundLayer, wmtsLayer],
view: view
})
// 5. 绑定事件
this.bindMapEvents()
},
/**
* 创建 WMTS 图层
* 关键点:
* 1. 瓦片网格配置(resolutions、matrixIds)
* 2. wrapX: false 避免破图
* 3. 从 0 级开始定义分辨率,确保低级别缩放正常
*/
createWMTSLayer() {
// 生成所有缩放级别的分辨率和矩阵ID
const resolutions = []
const matrixIds = []
const origin = [-20037508.342789244, 20037508.342789244]
// ⚠️ 从 0 开始,确保低级别缩放正常
for (let z = 0; z <= this.maxZoom; z++) {
resolutions[z] = 156543.03392804097 / Math.pow(2, z)
matrixIds[z] = `EPSG:3857_ah16:${z}` // 根据实际服务调整
}
const wmtsSource = new WMTS({
url: '/wmts', // 代理到 GeoServer
layer: 'ah16', // 图层名称
matrixSet: 'EPSG:3857_ah16', // 矩阵集名称
format: 'image/png',
style: '', // 默认样式
tileGrid: new WMTSTileGrid({
origin: origin,
resolutions: resolutions,
matrixIds: matrixIds,
tileSize: 256
}),
wrapX: false, // ⚠️ 禁用横向重复,避免破图
requestEncoding: 'KVP' // 请求编码方式
})
// 监听瓦片加载错误
wmtsSource.on('tileloaderror', (event) => {
console.warn('WMTS 瓦片加载失败:', event)
})
return new TileLayer({
source: wmtsSource,
opacity: 1
})
},
/**
* 创建缺省背景图层
* 当 WMTS 瓦片加载失败时显示
*/
createBackgroundLayer() {
const svgString = `
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" fill="#f0f0f0"/>
<text x="128" y="128" text-anchor="middle" fill="#999"
font-family="Arial" font-size="14">离线地图</text>
</svg>
`
const encodedSvg = encodeURIComponent(svgString)
return new TileLayer({
source: new XYZ({
url: `data:image/svg+xml;charset=utf-8,${encodedSvg}`,
tilePixelRatio: 1
}),
opacity: 0.3
})
},
createView() {
return new View({
center: fromLonLat([117.27, 31.88]), // 安徽省中心
zoom: 10,
projection: this.projection,
minZoom: this.minZoom,
maxZoom: this.maxZoom
})
},
bindMapEvents() {
// 监听地图操作完成
this.map.on('moveend', () => {
const view = this.map.getView()
const zoom = view.getZoom()
const center = view.getCenter()
console.log('缩放级别:', zoom, '中心点:', center)
})
}
}
}
Webpack 代理配置:
// webpack.config.js
module.exports = {
devServer: {
proxy: {
'/wmts': {
target: 'http://localhost:18080',
changeOrigin: true,
pathRewrite: { '^/wmts': '/geoserver/gwc/service/wmts' }
}
}
}
}
方式二:XYZ(推荐用于简单瓦片服务)
适用场景:
- 标准 XYZ 瓦片结构({z}/{x}/{y}.png)
- 天地图、高德、百度等在线地图
- 自定义离线瓦片
示例 1:在线地图(天地图)
import XYZ from 'ol/source/XYZ'
import TileLayer from 'ol/layer/Tile'
// 天地图影像底图
function createTiandituLayer() {
const tk = 'your_tianditu_token' // 天地图密钥
return new TileLayer({
source: new XYZ({
url: `http://t{0-7}.tianditu.gov.cn/DataServer?T=img_w&x={x}&y={y}&l={z}&tk=${tk}`,
crossOrigin: 'anonymous'
})
})
}
// 天地图注记图层
function createTiandituAnnotationLayer() {
const tk = 'your_tianditu_token'
return new TileLayer({
source: new XYZ({
url: `http://t{0-7}.tianditu.gov.cn/DataServer?T=cia_w&x={x}&y={y}&l={z}&tk=${tk}`,
crossOrigin: 'anonymous'
})
})
}
示例 2:离线瓦片 【通过 水经注离线地图下载】
// 本地离线瓦片(假设瓦片存放在 /tiles 目录)
function createOfflineXYZLayer() {
return new TileLayer({
source: new XYZ({
url: '/tiles/{z}/{x}/{y}.png',
tilePixelRatio: 1,
maxZoom: 18,
minZoom: 8
})
})
}
示例 3:高德地图(需坐标转换)
// ⚠️ 注意:高德地图使用 GCJ-02 坐标系,需要转换
function createGaodeLayer() {
return new TileLayer({
source: new XYZ({
url: 'http://wprd0{1-4}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&style=7&x={x}&y={y}&z={z}',
crossOrigin: 'anonymous'
})
})
}
方式对比
| 特性 | WMTS | XYZ |
|---|---|---|
| 标准化 | OGC 标准 | 事实标准 |
| 配置复杂度 | 较高(需配置矩阵集) | 简单 |
| 适用场景 | 企业级服务 | 在线地图/简单瓦片 |
| 坐标系支持 | 多坐标系 | 通常 EPSG:3857 |
| 性能 | 相同 | 相同 |
| 推荐使用 | GeoServer、ArcGIS | 天地图、离线瓦片 |
1.4 地图视图控制
控制缩放级别
// 方式1:在 View 中限制
const view = new View({
center: fromLonLat([117.27, 31.88]),
zoom: 10,
minZoom: 8, // 最小缩放级别(省级)
maxZoom: 18, // 最大缩放级别(街道级)
projection: 'EPSG:3857'
})
// 方式2:动态设置缩放
this.map.getView().setZoom(12)
// 方式3:缩放到指定级别(带动画)
this.map.getView().animate({
zoom: 14,
duration: 500
})
// 监听缩放变化
this.map.getView().on('change:resolution', () => {
const zoom = this.map.getView().getZoom()
console.log('当前缩放级别:', zoom)
// 根据缩放级别控制图层显示
if (zoom > 12) {
this.detailLayer.setVisible(true)
} else {
this.detailLayer.setVisible(false)
}
})
控制区域范围
// 方式1:限制地图可视范围(硬限制)
const anhuiBounds = [
[114.9, 29.4], // 西南角 [经度, 纬度]
[119.3, 35.1] // 东北角
]
const extent3857 = [
...fromLonLat(anhuiBounds[0]),
...fromLonLat(anhuiBounds[1])
]
const view = new View({
center: fromLonLat([117.27, 31.88]),
zoom: 10,
extent: extent3857, // 限制范围
projection: 'EPSG:3857'
})
// 方式2:动态范围检查(软限制,自动回弹)
this.map.getView().on('change:center', () => {
const view = this.map.getView()
const center = view.getCenter()
const extent = extent3857
// 检查是否超出范围
if (center[0] < extent[0] || center[0] > extent[2] ||
center[1] < extent[1] || center[1] > extent[3]) {
// 自动回到中心
view.setCenter(fromLonLat([117.27, 31.88]))
console.log('地图已自动回到安徽省范围内')
}
})
// 方式3:定位到指定范围(带动画)
function fitToExtent(extent) {
this.map.getView().fit(extent, {
padding: [50, 50, 50, 50], // 边距
duration: 500, // 动画时长
maxZoom: 16 // 最大缩放级别
})
}
禁用地图旋转
import { defaults as defaultInteractions } from 'ol/interaction'
const map = new Map({
target: 'map',
interactions: defaultInteractions({
altShiftDragRotate: false, // 禁用 PC 端 Alt+Shift+拖拽旋转
pinchRotate: false, // 禁用触屏双指旋转
// 保留其他交互
doubleClickZoom: true, // 双击放大
dragPan: true, // 拖拽平移
mouseWheelZoom: true, // 鼠标滚轮缩放
pinchZoom: true, // 触屏双指缩放
keyboard: true, // 键盘操作
shiftDragZoom: true // Shift+拖拽框选放大
}),
view: new View({
center: fromLonLat([117.27, 31.88]),
zoom: 10,
rotation: 0, // 固定旋转角度为 0(正北向上)
enableRotation: false, // 禁用旋转
constrainRotation: false // 不约束旋转步进
})
})
地图事件监听(完整实战版)
OpenLayers 提供了丰富的事件系统,以下是生产环境中常用的事件处理方案。
1. 核心事件类型
| 事件名 | 触发时机 | 常用场景 |
|---|---|---|
singleclick | 单击(推荐) | 要素选择、信息查询 |
click | 点击(有延迟) | 不推荐,与 singleclick 冲突 |
dblclick | 双击 | 快速缩放 |
pointermove | 鼠标/触摸移动 | 悬停效果、鼠标样式 |
pointerdown | 按下 | 触摸开始检测 |
pointerup | 抬起 | 触摸结束检测 |
moveend | 移动/缩放结束 | 数据加载、状态更新 |
change:resolution | 缩放变化 | 图层显隐控制 |
change:center | 中心点变化 | 范围检查 |
2. 基础事件监听
// 1. 监听地图移动结束(缩放/平移完成)
this.map.on('moveend', () => {
const view = this.map.getView()
const zoom = view.getZoom()
const center = view.getCenter()
const centerLonLat = toLonLat(center)
console.log('地图操作完成:')
console.log('- 缩放级别:', zoom)
console.log('- 中心点坐标 (3857):', center)
console.log('- 中心点坐标 (4326):', centerLonLat)
})
// 2. 监听缩放变化(实时)
this.map.getView().on('change:resolution', () => {
const zoom = this.map.getView().getZoom()
console.log('缩放级别变化:', zoom)
// 根据缩放级别控制图层显示
if (zoom > 12) {
this.detailLayer.setVisible(true)
} else {
this.detailLayer.setVisible(false)
}
})
// 3. 监听地图单击(推荐使用 singleclick)
this.map.on('singleclick', (event) => {
const coordinate = event.coordinate
const lonLat = toLonLat(coordinate)
console.log('点击坐标:', lonLat)
// 获取点击位置的要素
const feature = this.map.forEachFeatureAtPixel(event.pixel, (f) => f)
if (feature) {
console.log('点击了要素:', feature.get('name'))
}
})
// 4. 监听鼠标移动(悬停效果)
this.map.on('pointermove', (event) => {
// 检查是否悬停在要素上
const hit = this.map.hasFeatureAtPixel(event.pixel)
const target = this.map.getTargetElement()
if (target) {
target.style.cursor = hit ? 'pointer' : ''
}
})
3. 要素点击事件(多图层优先级处理)
在实际项目中,地图上通常有多个图层(点、线、面),需要设置点击优先级。
/**
* 要素点击事件处理(生产级)
* 优先级:点要素 > 面要素 > 线要素 > 空白
*/
bindClickEvents() {
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const hitTolerance = isTouchDevice ? 15 : 6 // 触屏容差更大
this.map.on('singleclick', (event) => {
// 1. 最高优先级:检查点要素(杆塔、POI等)
const pointFeature = this.map.forEachFeatureAtPixel(event.pixel, (feature, layer) => {
if (!feature || !feature.get) return null
const module = feature.get('module')
// 只检测点要素
if (module === 'tower' || module === 'poi') {
return feature
}
return null
}, {
hitTolerance: hitTolerance,
layerFilter: (layer) => layer !== this.polygonLayer // 排除面图层
})
if (pointFeature && !pointFeature.get('hidden')) {
console.log('✅ 点击了点要素:', pointFeature.get('name'))
this.handlePointClick(pointFeature)
return
}
// 2. 第二优先级:检查面要素
const polygonFeature = this.map.forEachFeatureAtPixel(event.pixel, (feature, layer) => {
if (layer === this.polygonLayer) {
return feature
}
}, {
hitTolerance: isTouchDevice ? 20 : 10, // 面要素容差更大
layerFilter: (layer) => layer === this.polygonLayer
})
if (polygonFeature) {
console.log('✅ 点击了面要素:', polygonFeature.get('name'))
this.handlePolygonClick(polygonFeature)
return
}
// 3. 最低优先级:检查线要素
const lineFeature = this.map.forEachFeatureAtPixel(event.pixel, (feature) => {
if (!feature || !feature.get) return null
const module = feature.get('module')
if (module === 'line') {
return feature
}
return null
}, {
hitTolerance: isTouchDevice ? 12 : 6,
layerFilter: (layer) => layer !== this.polygonLayer
})
if (lineFeature && !lineFeature.get('hidden')) {
console.log('✅ 点击了线要素:', lineFeature.get('name'))
this.handleLineClick(lineFeature)
return
}
// 4. 点击空白处
console.log('❌ 点击了空白处')
this.resetHighlight()
this.hideTooltip()
})
}
4. 触屏优化(解决移动端延迟问题)
移动端使用 singleclick 有 300ms 延迟,可以通过 pointerdown + pointerup 实现快速响应。
/**
* 触屏快速响应优化
* 解决 singleclick 在移动端的 300ms 延迟问题
*/
bindTouchOptimizedEvents() {
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
if (!isTouchDevice) return
let touchStartTime = 0
let touchStartPixel = null
let lastTouchHandled = 0
// 记录触摸开始
this.map.on('pointerdown', (event) => {
if (event.originalEvent.pointerType === 'touch') {
touchStartTime = Date.now()
touchStartPixel = event.pixel
}
})
// 触摸结束时快速响应
this.map.on('pointerup', (event) => {
if (event.originalEvent.pointerType === 'touch' && touchStartPixel) {
const touchDuration = Date.now() - touchStartTime
const dx = Math.abs(event.pixel[0] - touchStartPixel[0])
const dy = Math.abs(event.pixel[1] - touchStartPixel[1])
const moveDistance = Math.sqrt(dx * dx + dy * dy)
// 判断是否为快速点击:时间 < 200ms,移动 < 10px
if (touchDuration < 200 && moveDistance < 10) {
console.log('🚀 触屏快速响应触发')
// 检查要素
const feature = this.map.forEachFeatureAtPixel(event.pixel, (f) => f, {
hitTolerance: 15
})
if (feature) {
lastTouchHandled = Date.now()
this.handleFeatureClick(feature)
} else {
lastTouchHandled = Date.now()
this.resetHighlight()
}
touchStartPixel = null
return
}
touchStartPixel = null
}
})
// 在 singleclick 中避免重复触发
this.map.on('singleclick', (event) => {
if (Date.now() - lastTouchHandled < 500) {
return // 跳过,已由快速响应处理
}
// 正常处理逻辑...
})
}
5. 悬停效果(鼠标样式 + Tooltip)
/**
* 悬停效果处理
*/
bindHoverEvents() {
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
this.map.on('pointermove', (event) => {
if (!this.map) return
// 检查是否悬停在面数据上(使用更大的容差)
const hitPolygon = this.map.hasFeatureAtPixel(event.pixel, {
layerFilter: (layer) => layer === this.polygonLayer,
hitTolerance: isTouchDevice ? 20 : 10
})
if (hitPolygon) {
const target = this.map.getTargetElement()
if (target) target.style.cursor = 'pointer'
return
}
// 检查其他图层
const hit = this.map.hasFeatureAtPixel(event.pixel, {
hitTolerance: isTouchDevice ? 12 : 6,
layerFilter: (layer) => layer !== this.polygonLayer
})
const target = this.map.getTargetElement()
if (target) target.style.cursor = hit ? 'pointer' : ''
// 显示 Tooltip(可选)
if (hit) {
const feature = this.map.forEachFeatureAtPixel(event.pixel, (f) => f)
if (feature) {
this.showTooltip(feature, event.coordinate)
}
} else {
this.hideTooltip()
}
})
}
6. 缩放级别控制图层显示
/**
* 根据缩放级别动态显示/隐藏图层
*/
bindZoomControlEvents() {
const view = this.map.getView()
view.on('change:resolution', () => {
const zoom = view.getZoom()
// 示例:缩放级别 > 12 时显示详细图层
if (zoom > 12) {
this.detailLayer.setVisible(true)
this.labelLayer.setVisible(true)
} else {
this.detailLayer.setVisible(false)
this.labelLayer.setVisible(false)
}
// 示例:根据缩放级别调整样式
this.updateFeatureStyles(zoom)
})
}
7. 完整的 bindEvents 方法(生产级)
/**
* 绑定地图事件(完整版)
*/
bindEvents() {
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const hitTolerance = isTouchDevice ? 15 : 6
// 1. 缩放监听:控制图层显示
const view = this.map.getView()
view.on('change:resolution', () => {
const zoom = view.getZoom()
this.syncLayersVisibilityByZoom(zoom)
})
// 2. 触屏快速响应(移动端优化)
if (isTouchDevice) {
this.bindTouchOptimizedEvents()
}
// 3. 要素点击事件(多图层优先级)
this.map.on('singleclick', (event) => {
// 触屏设备跳过(已由快速响应处理)
if (isTouchDevice && Date.now() - this.lastTouchHandled < 500) {
return
}
// 优先级:点 > 面 > 线 > 空白
const pointFeature = this.getPointFeatureAtPixel(event.pixel, hitTolerance)
if (pointFeature) {
this.handlePointClick(pointFeature)
return
}
const polygonFeature = this.getPolygonFeatureAtPixel(event.pixel, hitTolerance)
if (polygonFeature) {
this.handlePolygonClick(polygonFeature)
return
}
const lineFeature = this.getLineFeatureAtPixel(event.pixel, hitTolerance)
if (lineFeature) {
this.handleLineClick(lineFeature)
return
}
// 空白处理
this.resetHighlight()
this.hideTooltip()
})
// 4. 悬停效果
this.map.on('pointermove', (event) => {
const hit = this.map.hasFeatureAtPixel(event.pixel, { hitTolerance })
const target = this.map.getTargetElement()
if (target) target.style.cursor = hit ? 'pointer' : ''
})
// 5. 地图操作完成
this.map.on('moveend', () => {
const zoom = this.map.getView().getZoom()
// 高缩放级别时清除选中效果(可选)
if (zoom <= this.hidePoiCheckMaxZoom && !this.isFocusingFeature) {
this.resetHighlight()
}
})
}
8. 事件处理最佳实践
✅ 推荐做法:
- 使用
singleclick而不是click(避免双击冲突) - 移动端增加
hitTolerance(15px 以上) - 多图层时设置优先级(点 > 面 > 线)
- 使用
layerFilter过滤不需要的图层 - 触屏设备使用
pointerdown/up优化响应速度
❌ 避免做法:
- 同时监听
click和singleclick(会冲突) - 不设置
hitTolerance(移动端难以点击) - 不使用
layerFilter(性能差,逻辑混乱) - 在
pointermove中执行重计算(性能问题)
⚠️ 注意事项:
singleclick在移动端有 300ms 延迟,需要优化forEachFeatureAtPixel会遍历所有图层,注意性能- 事件监听要在组件销毁时清理(避免内存泄漏)
1.5 完整初始化示例
将上述所有配置整合到一起:
<template>
<div class="map-container">
<div ref="mapEl" class="map"></div>
</div>
</template>
<script>
import Map from 'ol/Map'
import View from 'ol/View'
import TileLayer from 'ol/layer/Tile'
import WMTS from 'ol/source/WMTS'
import WMTSTileGrid from 'ol/tilegrid/WMTS'
import XYZ from 'ol/source/XYZ'
import { fromLonLat, toLonLat } from 'ol/proj'
import { defaults as defaultInteractions } from 'ol/interaction'
export default {
name: 'OlMap',
data() {
return {
map: null,
projection: 'EPSG:3857',
minZoom: 8,
maxZoom: 18,
mapCenter: [117.27, 31.88], // 安徽省中心
anhuiBounds: [
[114.9, 29.4], // 西南角
[119.3, 35.1] // 东北角
]
}
},
mounted() {
this.initMap()
},
beforeDestroy() {
if (this.map) {
this.map.setTarget(null)
this.map = null
}
},
methods: {
initMap() {
// 1. 创建图层
const backgroundLayer = this.createBackgroundLayer()
const wmtsLayer = this.createWMTSLayer()
// 2. 创建视图
const view = this.createView()
// 3. 创建地图
this.map = new Map({
target: this.$refs.mapEl,
layers: [backgroundLayer, wmtsLayer],
view: view,
interactions: defaultInteractions({
altShiftDragRotate: false,
pinchRotate: false
})
})
// 4. 绑定事件
this.bindMapEvents()
},
createWMTSLayer() {
const resolutions = []
const matrixIds = []
const origin = [-20037508.342789244, 20037508.342789244]
for (let z = 0; z <= this.maxZoom; z++) {
resolutions[z] = 156543.03392804097 / Math.pow(2, z)
matrixIds[z] = `EPSG:3857_ah16:${z}`
}
const wmtsSource = new WMTS({
url: '/wmts',
layer: 'ah16',
matrixSet: 'EPSG:3857_ah16',
format: 'image/png',
style: '',
tileGrid: new WMTSTileGrid({
origin: origin,
resolutions: resolutions,
matrixIds: matrixIds,
tileSize: 256
}),
wrapX: false,
requestEncoding: 'KVP'
})
wmtsSource.on('tileloaderror', (event) => {
console.warn('WMTS 瓦片加载失败:', event)
})
return new TileLayer({ source: wmtsSource, opacity: 1 })
},
createBackgroundLayer() {
const svgString = `
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" fill="#f0f0f0"/>
<text x="128" y="128" text-anchor="middle" fill="#999"
font-family="Arial" font-size="14">离线地图</text>
</svg>
`
const encodedSvg = encodeURIComponent(svgString)
return new TileLayer({
source: new XYZ({
url: `data:image/svg+xml;charset=utf-8,${encodedSvg}`,
tilePixelRatio: 1
}),
opacity: 0.3
})
},
createView() {
const extent3857 = [
...fromLonLat(this.anhuiBounds[0]),
...fromLonLat(this.anhuiBounds[1])
]
return new View({
center: fromLonLat(this.mapCenter),
zoom: 10,
projection: this.projection,
minZoom: this.minZoom,
maxZoom: this.maxZoom,
extent: extent3857,
rotation: 0,
enableRotation: false
})
},
bindMapEvents() {
// 监听地图操作完成
this.map.on('moveend', () => {
const view = this.map.getView()
const zoom = view.getZoom()
const center = view.getCenter()
const centerLonLat = toLonLat(center)
console.log('地图操作完成:')
console.log('- 缩放级别:', zoom)
console.log('- 中心点 (经纬度):', centerLonLat)
})
// 监听中心点变化(范围检查)
this.map.getView().on('change:center', () => {
const view = this.map.getView()
const center = view.getCenter()
const extent = [
...fromLonLat(this.anhuiBounds[0]),
...fromLonLat(this.anhuiBounds[1])
]
if (center[0] < extent[0] || center[0] > extent[2] ||
center[1] < extent[1] || center[1] > extent[3]) {
view.setCenter(fromLonLat(this.mapCenter))
console.log('地图已自动回到安徽省范围内')
}
})
}
}
}
</script>
<style scoped>
.map-container {
width: 100%;
height: 100vh;
}
.map {
width: 100%;
height: 100%;
}
</style>
二、核心能力模块
M01 | 图层管理模块
核心概念:Layer vs Source
在 OpenLayers 中,图层管理的核心是理解 Layer(图层) 和 Source(数据源) 的关系:
Map(地图)
└─ Layer(图层)- 负责渲染和显示控制
└─ Source(数据源)- 负责数据存储
└─ Feature(要素)- 具体的点/线/面数据
类比 ArcGIS JS API:
- OpenLayers 的
VectorLayer + VectorSource≈ ArcGIS 的FeatureLayer - OpenLayers 的
Feature≈ ArcGIS 的Graphic
图层设计决策指南
核心问题:数据如何组织?
在 OpenLayers 中,数据组织的核心是:如何分组才能高效地检索和控制显隐。
本质思考:
- Layer 是控制单元:一个 Layer 是一个独立的显隐控制单元
- Source 是数据容器:一个 Source 可以包含多个 Feature
- Feature 是数据个体:每个 Feature 可以有自己的属性和样式
决策模型:按控制粒度分层
graph TD
B["第一步:确定控制粒度"]
%% 第一步:控制粒度判断
B --> C["按【整体】控制显隐?"]
C -->|是| D["所有数据放一个图层<br/>示例:所有线路作为一个整体开关"]
B --> E["按【分组】控制显隐?"]
E -->|是| F["缺陷隐患<br/>示例:缺陷按等级分组控制"]
B --> G["按【个体】控制显隐?"]
G --> H["数据量 < 50?"]
H -->|是| I["每个个体一个图层<br/>示例:10条线路,每条独立控制"]
H -->|否| J["合并图层 + 样式函数<br/>示例:100条线路用样式控制显隐"]
%% 第二步:渲染方式校验
D & F & I & J --> K["第二步:考虑渲染方式"]
K --> L["需要不同渲染方式?"]
L -->|是| M["必须分图层<br/>示例:普通点 vs 聚合/热力图层"]
L -->|否| N["可合并到一个图层"]
%% 第三步:性能兜底校验
M & N --> O["第三步:性能校验"]
O --> P["图层数量 > 20?"]
P -->|是| Q["性能下降,建议合并"]
P -->|否| R["单图层要素 > 5000?"]
R -->|是| S["考虑分图层/使用聚合"]
R -->|否| T["当前方案可行"]
%% 极简样式(仅区分类型,不花哨)
classDef step fill:#e6f7ff,stroke:#1890ff,stroke-width:2px
classDef judge fill:#fff7e6,stroke:#faad14,stroke-width:1px
classDef res fill:#f6ffed,stroke:#52c41a,stroke-width:1px
class B,K,O step
class C,E,G,H,L,P,R judge
class D,F,I,J,M,N,Q,S,T res
实战决策表
核心原则:
- 优先考虑一个图层(简单场景)
- 按业务逻辑分组(复杂场景)
- 避免过度分层(性能考虑)
| 场景 | 控制需求 | 数据量 | 推荐方案 | 理由 |
|---|---|---|---|---|
| 10条线路 | 每条独立控制 | 10 | ✅ 一个图层 + 样式函数 | 数据少,用属性控制即可 |
| 100条线路 | 每条独立控制 | 100 | ✅ 一个图层 + 样式函数 + 索引 | 建立索引提高检索效率 |
| 线路按单位分组 | 按单位控制 | 10个单位 | ✅ 一个图层 + 样式函数 | 通过 unit 属性控制即可 |
| POI(3-5个大类) | 按大类控制 | 3-5个大类 | ✅ 按大类分图层 | 大类少,分图层便于管理 |
| POI(10+个大类) | 按大类控制 | 10+个大类 | ✅ 一个图层 + 样式函数 | 大类多,合并图层更好 |
| POI(50个小类) | 按小类控制 | 50个小类 | ✅ 按大类分图层 + 样式函数控制小类 | 分层管理,兼顾性能和可维护性 |
| 1000个POI | 整体控制 | 1000 | ✅ 一个图层 + declutter | 启用防重叠 |
| 5000个POI | 整体控制 | 5000 | ✅ 一个图层 + Cluster | 使用聚合 |
| 普通点 vs 聚合点 | 切换渲染方式 | 任意 | ❌ 必须分两个图层 | 渲染方式不同 |
| 普通点 vs 热力图 | 切换渲染方式 | 任意 | ❌ 必须分两个图层 | 渲染方式不同 |
推荐的分层策略
策略一:简单场景(数据少,控制简单)
一个图层 + Feature 属性控制
适用:10条线路、20个POI等
策略二:中等复杂度(有明确的业务分组)
按业务大类分图层(3-5个图层)+ 样式函数控制小类
适用:POI分为驿站、观冰站、青阳站等大类
策略三:高复杂度(数据多,分类复杂)
按业务大类分图层 + Map索引 + 样式函数
适用:成百上千的POI,分多个大类和小类
实战案例:POI 分层方案
场景:POI 分为 3 个大类(驿站、观冰站、青阳站),每个大类下有多个小类
推荐方案:按大类分 3 个图层 + 样式函数控制小类
/**
* POI 分层管理(推荐方案)
* 策略:按大类分图层(便于整体控制),小类用样式函数(灵活控制)
*/
class POILayerManager {
constructor(map) {
this.map = map
this.categoryLayers = new Map() // 大类图层
this.categorySources = new Map() // 大类数据源
this.featureIndexes = new Map() // 每个大类的要素索引
this.hiddenTypes = new Set() // 隐藏的小类
}
/**
* 初始化大类图层
*/
initCategories(categories) {
// categories: [
// { id: 'yz', name: '驿站', zIndex: 300 },
// { id: 'gbz', name: '观冰站', zIndex: 301 },
// { id: 'qyz', name: '青阳站', zIndex: 302 }
// ]
categories.forEach(category => {
const source = new VectorSource()
const featureIndex = new Map()
const layer = new VectorLayer({
source: source,
style: (feature) => this.getStyle(feature),
zIndex: category.zIndex,
declutter: true, // 启用防重叠
properties: {
id: category.id,
name: category.name,
type: 'poi'
}
})
this.map.addLayer(layer)
this.categoryLayers.set(category.id, layer)
this.categorySources.set(category.id, source)
this.featureIndexes.set(category.id, featureIndex)
})
}
/**
* 样式函数
*/
getStyle(feature) {
const typeCode = feature.get('typeCode')
// 如果小类被隐藏,不渲染
if (this.hiddenTypes.has(typeCode)) {
return null
}
const iconUrl = feature.get('iconUrl')
const isHighlight = feature.get('highlight') || false
const priority = feature.get('priority') || 0
return new Style({
image: new Icon({
src: iconUrl,
scale: isHighlight ? 1.2 : 1.0,
anchor: [0.5, 1]
}),
zIndex: isHighlight ? 1000 : (100 + priority * 10)
})
}
/**
* 添加 POI
*/
addPOI(categoryId, poiData) {
const source = this.categorySources.get(categoryId)
const index = this.featureIndexes.get(categoryId)
if (!source || !index) {
console.warn(`Category ${categoryId} not found`)
return null
}
const feature = new Feature({
geometry: new Point(fromLonLat([poiData.lon, poiData.lat])),
id: poiData.id,
name: poiData.name,
typeCode: poiData.typeCode,
categoryId: categoryId,
iconUrl: poiData.iconUrl,
priority: poiData.priority || 0
})
feature.setId(poiData.id)
source.addFeature(feature)
index.set(poiData.id, feature) // 建立索引
return feature
}
/**
* 控制大类显隐(整个图层)
*/
toggleCategory(categoryId, visible) {
const layer = this.categoryLayers.get(categoryId)
if (layer) {
layer.setVisible(visible)
}
}
/**
* 控制小类显隐(样式函数)
*/
toggleType(typeCode, visible) {
if (visible) {
this.hiddenTypes.delete(typeCode)
} else {
this.hiddenTypes.add(typeCode)
}
// 刷新所有图层
this.categoryLayers.forEach(layer => {
layer.getSource().changed()
})
}
/**
* 按ID查找并控制(O(1) 复杂度)
*/
toggleById(id, visible) {
// 遍历所有大类的索引
for (const [categoryId, index] of this.featureIndexes) {
const feature = index.get(id)
if (feature) {
feature.set('hidden', !visible)
feature.changed()
return true
}
}
return false
}
/**
* 高亮
*/
highlight(id, highlight = true) {
for (const [categoryId, index] of this.featureIndexes) {
const feature = index.get(id)
if (feature) {
feature.set('highlight', highlight)
feature.changed()
return true
}
}
return false
}
/**
* 获取统计信息
*/
getStatistics() {
const stats = []
this.categoryLayers.forEach((layer, categoryId) => {
const source = layer.getSource()
const features = source.getFeatures()
stats.push({
categoryId,
categoryName: layer.get('name'),
total: features.length,
visible: layer.getVisible()
})
})
return stats
}
}
// 使用示例
const poiManager = new POILayerManager(this.map)
// 1. 初始化大类图层(3个图层)
poiManager.initCategories([
{ id: 'yz', name: '驿站', zIndex: 300 },
{ id: 'gbz', name: '观冰站', zIndex: 301 },
{ id: 'qyz', name: '青阳站', zIndex: 302 }
])
// 2. 添加 POI(自动归类到对应图层)
poiManager.addPOI('yz', {
id: 'yz001',
name: '驿站1',
typeCode: 'yz_type1', // 小类
lon: 117.27,
lat: 31.88,
iconUrl: '/icons/yz_type1.png',
priority: 5
})
// 3. 控制显隐
poiManager.toggleCategory('yz', false) // 隐藏整个驿站大类
poiManager.toggleType('yz_type1', false) // 隐藏驿站的某个小类
poiManager.toggleById('yz001', false) // 隐藏某个具体的POI
// 4. 高亮
poiManager.highlight('yz001', true)
何时必须分图层?
必须分图层的情况:
渲染方式不同(最常见)
// 普通点 vs 聚合点 const normalLayer = new VectorLayer({ source: pointsSource }) const clusterLayer = new VectorLayer({ source: new Cluster({ source: pointsSource }) })有明确的业务分组,且需要整体控制
// 例如:3-5个大类,每个大类需要整体显隐 // 驿站图层、观冰站图层、青阳站图层性能瓶颈(极少见)
// 单图层 > 10000 要素,且样式函数复杂
不需要分图层的情况:
只是颜色、样式不同
// ❌ 不需要:10条线路,每条颜色不同 // ✅ 用 Feature 的 color 属性控制即可只是类型不同,但没有明确的业务分组
// ❌ 不需要:50个小类,每个小类一个图层 // ✅ 用 Feature 的 typeCode 属性控制即可数据量少
// ❌ 不需要:10条线路按单位分5个图层 // ✅ 一个图层 + unit 属性控制即可
分层决策流程图
是否需要不同的渲染方式?
├─ 是 → 必须分图层
│ └─ 例如:普通点 vs 聚合点
│
└─ 否 → 是否有明确的业务分组(3-5个)?
├─ 是 → 按业务分组分图层
│ └─ 例如:驿站、观冰站、青阳站
│
└─ 否 → 一个图层 + Feature 属性控制
└─ 例如:10条线路、50个小类
推荐的通用方案
方案:一个图层 + Feature 属性 + 样式函数 + Map 索引
/**
* 通用图层管理方案(适用于大部分场景)
*/
class UniversalLayerManager {
constructor(map, layerConfig) {
this.map = map
this.source = new VectorSource()
this.featureMap = new Map() // 索引:快速检索
this.hiddenIds = new Set() // 隐藏的要素ID
this.hiddenTypes = new Set() // 隐藏的类型
// 创建图层(使用样式函数)
this.layer = new VectorLayer({
source: this.source,
style: (feature) => this.getStyle(feature),
zIndex: layerConfig.zIndex || 100,
declutter: layerConfig.declutter || false
})
this.map.addLayer(this.layer)
}
/**
* 样式函数(统一处理显隐、类型、状态)
*/
getStyle(feature) {
const id = feature.getId()
const typeCode = feature.get('typeCode')
// 1. 检查是否隐藏(按ID)
if (this.hiddenIds.has(id)) {
return null
}
// 2. 检查是否隐藏(按类型)
if (this.hiddenTypes.has(typeCode)) {
return null
}
// 3. 根据要素属性返回样式
const geometryType = feature.getGeometry().getType()
if (geometryType === 'Point') {
return this.getPointStyle(feature)
} else if (geometryType === 'LineString' || geometryType === 'MultiLineString') {
return this.getLineStyle(feature)
} else if (geometryType === 'Polygon' || geometryType === 'MultiPolygon') {
return this.getPolygonStyle(feature)
}
}
/**
* 点样式
*/
getPointStyle(feature) {
const iconUrl = feature.get('iconUrl')
const isHighlight = feature.get('highlight') || false
return new Style({
image: new Icon({
src: iconUrl,
scale: isHighlight ? 1.2 : 1.0,
anchor: [0.5, 1]
})
})
}
/**
* 线样式
*/
getLineStyle(feature) {
const color = feature.get('color') || '#1890ff'
const width = feature.get('width') || 3
const isHighlight = feature.get('highlight') || false
return new Style({
stroke: new Stroke({
color: isHighlight ? '#ff0000' : color,
width: isHighlight ? width + 2 : width
})
})
}
/**
* 面样式
*/
getPolygonStyle(feature) {
const fillColor = feature.get('fillColor') || 'rgba(0, 120, 255, 0.2)'
const strokeColor = feature.get('strokeColor') || '#0078ff'
return new Style({
fill: new Fill({ color: fillColor }),
stroke: new Stroke({ color: strokeColor, width: 2 })
})
}
/**
* 添加要素(自动建立索引)
*/
addFeature(featureData) {
const { id, geometry, properties } = featureData
const feature = new Feature({
geometry: geometry,
...properties
})
feature.setId(id)
this.source.addFeature(feature)
this.featureMap.set(id, feature) // 建立索引
return feature
}
/**
* 按ID控制显隐(O(1) 复杂度)
*/
toggleById(id, visible) {
if (visible) {
this.hiddenIds.delete(id)
} else {
this.hiddenIds.add(id)
}
const feature = this.featureMap.get(id)
if (feature) {
feature.changed() // 触发重新渲染
}
}
/**
* 按类型控制显隐
*/
toggleByType(typeCode, visible) {
if (visible) {
this.hiddenTypes.delete(typeCode)
} else {
this.hiddenTypes.add(typeCode)
}
// 刷新图层
this.source.changed()
}
/**
* 按属性批量控制
*/
toggleByProperty(propertyName, propertyValue, visible) {
const features = this.source.getFeatures()
features.forEach(feature => {
if (feature.get(propertyName) === propertyValue) {
const id = feature.getId()
if (visible) {
this.hiddenIds.delete(id)
} else {
this.hiddenIds.add(id)
}
feature.changed()
}
})
}
/**
* 高亮要素
*/
highlight(id, highlight = true) {
const feature = this.featureMap.get(id)
if (feature) {
feature.set('highlight', highlight)
feature.changed()
}
}
}
// 使用示例
const layerManager = new UniversalLayerManager(this.map, {
zIndex: 200,
declutter: false
})
// 添加10条线路(都在一个图层中)
for (let i = 1; i <= 10; i++) {
layerManager.addFeature({
id: `line${i}`,
geometry: new LineString([...]),
properties: {
name: `线路${i}`,
color: `hsl(${i * 36}, 70%, 50%)`,
width: 3,
typeCode: 'line',
unit: `单位${Math.ceil(i / 3)}` // 按单位分组
}
})
}
// 控制显隐
layerManager.toggleById('line1', false) // 隐藏线路1
layerManager.toggleByType('line', false) // 隐藏所有线路
layerManager.toggleByProperty('unit', '单位1', false) // 隐藏单位1的所有线路
// 高亮
layerManager.highlight('line2', true)
性能对比(实测数据参考)
| 方案 | 10条线路 | 100条线路 | 1000个POI | 5000个POI |
|---|---|---|---|---|
| 分图层 | 正常 | 性能下降 | 性能差 | 卡顿 |
| 一个图层 | 正常 | 正常 | 正常 | 正常 |
| 一个图层 + 索引 | 正常 | 正常 | 正常 | 正常 |
| 一个图层 + Cluster | - | - | - | 流畅 |
结论:
- ✅ 默认使用一个图层 + Feature 属性控制
- ✅ 建立 Map 索引提高检索效率(O(1))
- ✅ 数据量大时(> 1000)启用 declutter 或 Cluster
- ❌ 不要轻易分图层,除非渲染方式不同
方案对比:分图层 vs 合并图层
核心区别:控制方式不同
| 维度 | 分图层方案 | 合并图层方案 |
|---|---|---|
| 控制方式 | layer.setVisible(true/false) | 样式函数返回 null 或修改属性 |
| 检索方式 | 遍历图层数组 | 遍历 Source 中的 Feature |
| 适用场景 | 控制粒度 = 图层粒度 | 控制粒度 < 图层粒度 |
| 性能(图层少) | 好 | 好 |
| 性能(图层多) | 差(> 20 个图层) | 好 |
| 代码复杂度 | 简单(直接控制图层) | 中等(需要样式函数或属性管理) |
| 内存占用 | 每个图层独立开销 | 共享图层开销 |
| 典型案例 | 10条线路,每条独立控制 | 100条线路,每条独立控制 |
检索效率对比
场景:需要根据 ID 查找并控制某个要素的显隐
方案一:分图层
// 检索:O(n) - n 为图层数量
const layer = this.map.getLayers().getArray()
.find(l => l.get('id') === targetId)
// 控制:O(1)
if (layer) layer.setVisible(false)
// 总复杂度:O(n)
// 适用:图层数量少(< 20)
方案二:合并图层
// 检索:O(m) - m 为要素数量
const feature = this.source.getFeatureById(targetId)
// 控制:O(1)
if (feature) {
feature.set('hidden', true)
feature.changed() // 触发重新渲染
}
// 总复杂度:O(m)
// 适用:要素数量多,但检索频率低
方案三:合并图层 + 索引
// 建立索引:O(1)
this.featureMap.set(targetId, feature)
// 检索:O(1)
const feature = this.featureMap.get(targetId)
// 控制:O(1)
if (feature) {
feature.set('hidden', true)
feature.changed()
}
// 总复杂度:O(1)
// 适用:要素数量多,检索频率高(推荐)
结论:
- 分图层:图层少时检索快
- 合并图层 + 索引:要素多时检索快(推荐)
实战案例:线路图层设计
场景:10 条线路,需要独立控制每条线路的显示/隐藏
方案一:每条线路一个图层(推荐:线路少时)
/**
* 方案一:每条线路独立图层
* 优点:显隐控制简单、逻辑清晰
* 缺点:图层多时性能略差
* 适用:线路数量 < 20 条
*/
class LineLayerManager {
constructor(map) {
this.map = map
this.lineLayers = new Map() // 存储线路图层
}
/**
* 添加线路
* @param {string} lineId - 线路ID
* @param {Array} coordinates - 坐标数组 [[lon,lat],...]
* @param {Object} options - 配置项
*/
addLine(lineId, coordinates, options = {}) {
const { name, color = '#1890ff', width = 3, zIndex = 200 } = options
// 创建要素
const feature = new Feature({
geometry: new LineString(coordinates.map(c => fromLonLat(c))),
id: lineId,
name: name
})
// 创建独立图层
const layer = new VectorLayer({
source: new VectorSource({ features: [feature] }),
style: new Style({
stroke: new Stroke({ color, width })
}),
zIndex: zIndex,
properties: { id: lineId, type: 'line', name }
})
this.map.addLayer(layer)
this.lineLayers.set(lineId, layer)
return layer
}
/**
* 显示/隐藏线路
*/
toggleLine(lineId, visible) {
const layer = this.lineLayers.get(lineId)
if (layer) {
layer.setVisible(visible)
}
}
/**
* 移除线路
*/
removeLine(lineId) {
const layer = this.lineLayers.get(lineId)
if (layer) {
this.map.removeLayer(layer)
this.lineLayers.delete(lineId)
}
}
/**
* 获取所有线路
*/
getAllLines() {
return Array.from(this.lineLayers.entries()).map(([id, layer]) => ({
id,
name: layer.get('name'),
visible: layer.getVisible()
}))
}
}
// 使用示例
const lineManager = new LineLayerManager(this.map)
// 添加线路
lineManager.addLine('line1', [[117.1, 31.8], [117.2, 31.9]], {
name: '安庆一号线',
color: '#1890ff',
width: 3
})
lineManager.addLine('line2', [[117.3, 32.0], [117.4, 32.1]], {
name: '滁州二号线',
color: '#52c41a',
width: 3
})
// 控制显隐
lineManager.toggleLine('line1', false) // 隐藏线路1
lineManager.toggleLine('line1', true) // 显示线路1
方案二:所有线路一个图层(推荐:线路多时)
/**
* 方案二:所有线路合并到一个图层
* 优点:性能好、内存占用少
* 缺点:显隐控制需要样式函数
* 适用:线路数量 > 20 条
*/
class UnifiedLineLayerManager {
constructor(map) {
this.map = map
this.linesSource = new VectorSource()
this.hiddenLineIds = new Set() // 隐藏的线路ID集合
this.lineFeatures = new Map() // 存储线路要素
// 创建统一图层(使用样式函数)
this.linesLayer = new VectorLayer({
source: this.linesSource,
style: (feature) => this.getLineStyle(feature), // 动态样式函数
zIndex: 200
})
this.map.addLayer(this.linesLayer)
}
/**
* 动态样式函数
* 根据要素状态返回不同样式
*/
getLineStyle(feature) {
const lineId = feature.get('id')
// 如果线路被隐藏,返回 null(不渲染)
if (this.hiddenLineIds.has(lineId)) {
return null
}
// 返回正常样式
const color = feature.get('color') || '#1890ff'
const width = feature.get('width') || 3
const isHighlight = feature.get('highlight') || false
return new Style({
stroke: new Stroke({
color: isHighlight ? '#ff0000' : color,
width: isHighlight ? width + 2 : width
})
})
}
/**
* 添加线路
*/
addLine(lineId, coordinates, options = {}) {
const { name, color = '#1890ff', width = 3 } = options
const feature = new Feature({
geometry: new LineString(coordinates.map(c => fromLonLat(c))),
id: lineId,
name: name,
color: color,
width: width,
highlight: false
})
feature.setId(lineId) // 设置要素ID,便于查找
this.linesSource.addFeature(feature)
this.lineFeatures.set(lineId, feature)
return feature
}
/**
* 显示/隐藏线路
* 通过修改隐藏集合 + 刷新样式实现
*/
toggleLine(lineId, visible) {
if (visible) {
this.hiddenLineIds.delete(lineId)
} else {
this.hiddenLineIds.add(lineId)
}
// 触发样式重新计算
const feature = this.lineFeatures.get(lineId)
if (feature) {
feature.changed() // 通知图层重新渲染该要素
}
}
/**
* 高亮线路
*/
highlightLine(lineId, highlight = true) {
const feature = this.lineFeatures.get(lineId)
if (feature) {
feature.set('highlight', highlight)
feature.changed()
}
}
/**
* 移除线路
*/
removeLine(lineId) {
const feature = this.lineFeatures.get(lineId)
if (feature) {
this.linesSource.removeFeature(feature)
this.lineFeatures.delete(lineId)
this.hiddenLineIds.delete(lineId)
}
}
/**
* 获取所有线路
*/
getAllLines() {
return Array.from(this.lineFeatures.entries()).map(([id, feature]) => ({
id,
name: feature.get('name'),
visible: !this.hiddenLineIds.has(id)
}))
}
}
// 使用示例
const lineManager = new UnifiedLineLayerManager(this.map)
// 添加多条线路
for (let i = 1; i <= 10; i++) {
lineManager.addLine(`line${i}`, [
[117 + i * 0.1, 31.8],
[117 + i * 0.1, 32.0]
], {
name: `线路${i}`,
color: `hsl(${i * 36}, 70%, 50%)`,
width: 3
})
}
// 控制显隐
lineManager.toggleLine('line1', false) // 隐藏
lineManager.toggleLine('line1', true) // 显示
// 高亮
lineManager.highlightLine('line2', true)
实战案例:POI 图层设计
场景:POI 分为大类(驿站、观冰站、青阳站)和小类(多种类型),数量可能上百个
推荐方案:按大类分图层 + 样式函数控制小类
/**
* POI 图层管理器
* 策略:按大类分图层,小类用样式函数控制
*/
class POILayerManager {
constructor(map) {
this.map = map
this.poiLayers = new Map() // 大类图层
this.poiSources = new Map() // 大类数据源
this.hiddenTypes = new Set() // 隐藏的小类类型
}
/**
* 初始化大类图层
* @param {Array} categories - 大类配置 [{id, name, zIndex}]
*/
initCategories(categories) {
categories.forEach(category => {
const source = new VectorSource()
const layer = new VectorLayer({
source: source,
style: (feature) => this.getPOIStyle(feature),
zIndex: category.zIndex || 300,
properties: {
id: category.id,
type: 'poi',
category: category.name
}
})
this.map.addLayer(layer)
this.poiLayers.set(category.id, layer)
this.poiSources.set(category.id, source)
})
}
/**
* 动态样式函数
*/
getPOIStyle(feature) {
const typeCode = feature.get('typeCode')
// 如果小类被隐藏,不渲染
if (this.hiddenTypes.has(typeCode)) {
return null
}
const iconUrl = feature.get('iconUrl')
const isHighlight = feature.get('highlight') || false
const scale = isHighlight ? 1.2 : 1.0
return new Style({
image: new Icon({
src: iconUrl,
scale: scale,
anchor: [0.5, 1],
anchorXUnits: 'fraction',
anchorYUnits: 'fraction'
})
})
}
/**
* 添加 POI
* @param {string} categoryId - 大类ID
* @param {Object} poiData - POI数据
*/
addPOI(categoryId, poiData) {
const source = this.poiSources.get(categoryId)
if (!source) {
console.warn(`Category ${categoryId} not found`)
return null
}
const feature = new Feature({
geometry: new Point(fromLonLat([poiData.lon, poiData.lat])),
id: poiData.id,
name: poiData.name,
typeCode: poiData.typeCode,
categoryId: categoryId,
iconUrl: poiData.iconUrl,
highlight: false
})
feature.setId(poiData.id)
source.addFeature(feature)
return feature
}
/**
* 控制大类显隐
*/
toggleCategory(categoryId, visible) {
const layer = this.poiLayers.get(categoryId)
if (layer) {
layer.setVisible(visible)
}
}
/**
* 控制小类显隐
*/
toggleType(typeCode, visible) {
if (visible) {
this.hiddenTypes.delete(typeCode)
} else {
this.hiddenTypes.add(typeCode)
}
// 刷新所有图层
this.poiLayers.forEach(layer => {
layer.getSource().changed()
})
}
/**
* 获取图层统计
*/
getStatistics() {
const stats = []
this.poiLayers.forEach((layer, categoryId) => {
const source = layer.getSource()
const features = source.getFeatures()
stats.push({
categoryId,
categoryName: layer.get('category'),
total: features.length,
visible: layer.getVisible()
})
})
return stats
}
}
// 使用示例
const poiManager = new POILayerManager(this.map)
// 1. 初始化大类图层
poiManager.initCategories([
{ id: 'yz', name: '驿站', zIndex: 300 },
{ id: 'qyz', name: '青阳站', zIndex: 301 },
{ id: 'gbz', name: '观冰站', zIndex: 302 }
])
// 2. 添加 POI
poiManager.addPOI('yz', {
id: 'yz001',
name: '驿站1',
typeCode: 'yz_type1',
lon: 117.27,
lat: 31.88,
iconUrl: '/icons/yz.png'
})
// 3. 控制显隐
poiManager.toggleCategory('yz', false) // 隐藏整个驿站大类
poiManager.toggleType('yz_type1', false) // 隐藏驿站的某个小类
图层设计最佳实践
1. 按业务功能分层
// 推荐的图层结构
const layerStructure = {
// 底图层(zIndex: 0-99)
baseLayer: 0,
backgroundLayer: 10,
// 业务数据层(zIndex: 100-299)
maskLayer: 100, // 蒙层
boundaryLayer: 110, // 边界
polygonLayer: 120, // 面数据
// 线路层(zIndex: 200-299)
linesLayer: 200, // 线路
nonOperationalLayer: 210, // 非运维区域
// POI 层(zIndex: 300-399)
poiLayer: 300, // POI 点
towerLayer: 310, // 杆塔
// 交互层(zIndex: 400-499)
highlightLayer: 400, // 高亮
tooltipLayer: 410, // 提示
// 控件层(zIndex: 500+)
controlLayer: 500 // 控件
}
2. 图层命名规范
// 推荐的命名规范
const layerNaming = {
// 格式:{业务模块}_{数据类型}_{具体名称}
'line_main_ah01': '线路_主线_安徽01',
'poi_yz_station': 'POI_驿站_站点',
'polygon_shmc_area': '面_山火茅草_区域',
'tower_risk_high': '杆塔_风险_高风险'
}
3. 图层管理工具类
/**
* 统一图层管理器
*/
class LayerManager {
constructor(map) {
this.map = map
this.layers = new Map()
}
/**
* 注册图层
*/
register(id, layer, metadata = {}) {
this.layers.set(id, {
layer,
metadata: {
name: metadata.name || id,
category: metadata.category || 'default',
zIndex: layer.getZIndex(),
...metadata
}
})
}
/**
* 获取图层
*/
get(id) {
return this.layers.get(id)?.layer
}
/**
* 按分类获取图层
*/
getByCategory(category) {
return Array.from(this.layers.entries())
.filter(([_, data]) => data.metadata.category === category)
.map(([id, data]) => ({ id, ...data }))
}
/**
* 显示/隐藏图层
*/
toggle(id, visible) {
const layer = this.get(id)
if (layer) layer.setVisible(visible)
}
/**
* 批量控制
*/
toggleCategory(category, visible) {
this.getByCategory(category).forEach(({ layer }) => {
layer.setVisible(visible)
})
}
}
// 使用示例
const layerManager = new LayerManager(this.map)
// 注册图层
layerManager.register('lines', linesLayer, {
name: '线路图层',
category: 'business',
description: '所有线路数据'
})
// 控制
layerManager.toggle('lines', false)
layerManager.toggleCategory('business', true)
多图层管理
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
// 创建业务图层
const pointsLayer = new VectorLayer({
source: new VectorSource(),
zIndex: 100,
properties: { id: 'points', name: '点位图层' }
})
const linesLayer = new VectorLayer({
source: new VectorSource(),
zIndex: 200,
properties: { id: 'lines', name: '线路图层' }
})
// 添加到地图
this.map.addLayer(pointsLayer)
this.map.addLayer(linesLayer)
// 图层显隐控制
function toggleLayer(layerId, visible) {
const layer = this.map.getLayers().getArray()
.find(l => l.get('id') === layerId)
if (layer) layer.setVisible(visible)
}
M02 | 点要素模块
样式设置:静态样式 vs 动态样式函数
在 OpenLayers 中,要素样式有两种设置方式:
1. 静态样式(Static Style)
特点:
- 样式固定,不会根据状态变化
- 性能好,适合样式不变的场景
- 设置在 Feature 或 Layer 上
使用场景:
- 样式固定的图标
- 不需要交互的要素
- 数据量少的场景
// 方式1:在 Feature 上设置静态样式
const feature = new Feature({
geometry: new Point(fromLonLat([117.27, 31.88])),
name: '固定样式点'
})
feature.setStyle(new Style({
image: new Icon({
src: '/icons/marker.png',
scale: 1.0
})
}))
// 方式2:在 Layer 上设置统一静态样式
const layer = new VectorLayer({
source: new VectorSource({ features: [feature] }),
style: new Style({
image: new Icon({
src: '/icons/marker.png',
scale: 1.0
})
})
})
2. 动态样式函数(Style Function)
特点:
- 样式根据要素属性或状态动态计算
- 灵活,支持复杂逻辑
- 每次渲染都会调用,性能略差
使用场景:
- 需要根据属性显示不同样式
- 需要显示/隐藏控制
- 需要高亮/选中效果
- 需要根据缩放级别调整样式
// 在 Layer 上设置动态样式函数
const layer = new VectorLayer({
source: source,
style: (feature, resolution) => {
// 1. 根据隐藏状态控制显示
if (feature.get('hidden')) {
return null // 返回 null 表示不渲染
}
// 2. 根据类型显示不同图标
const typeCode = feature.get('typeCode')
const iconUrl = this.getIconByType(typeCode)
// 3. 根据状态显示不同样式
const isHighlight = feature.get('highlight')
const scale = isHighlight ? 1.2 : 1.0
// 4. 根据缩放级别调整样式
const zoom = this.map.getView().getZoom()
const showLabel = zoom > 12
return new Style({
image: new Icon({
src: iconUrl,
scale: scale,
anchor: [0.5, 1]
}),
text: showLabel ? new Text({
text: feature.get('name'),
offsetY: -30,
font: '12px Arial',
fill: new Fill({ color: '#000' })
}) : undefined
})
}
})
3. 样式函数高级用法
/**
* 完整的样式函数示例
* 支持:显隐控制、类型区分、高亮、缩放适配
*/
class StyleManager {
constructor(map) {
this.map = map
this.hiddenTypes = new Set() // 隐藏的类型
}
/**
* 创建样式函数
*/
createStyleFunction() {
return (feature, resolution) => {
// 1. 检查是否隐藏
const typeCode = feature.get('typeCode')
if (this.hiddenTypes.has(typeCode)) {
return null
}
// 2. 获取基础属性
const isHighlight = feature.get('highlight') || false
const isSelected = feature.get('selected') || false
// 3. 计算缩放级别
const zoom = this.map.getView().getZoom()
// 4. 根据状态计算样式
const iconUrl = this.getIconUrl(typeCode, isSelected)
const scale = this.getScale(zoom, isHighlight, isSelected)
const showLabel = zoom > 12
// 5. 构建样式
const styles = []
// 主图标
styles.push(new Style({
image: new Icon({
src: iconUrl,
scale: scale,
anchor: [0.5, 1],
anchorXUnits: 'fraction',
anchorYUnits: 'fraction'
}),
zIndex: isSelected ? 1000 : 100
}))
// 文本标注(高缩放级别显示)
if (showLabel) {
styles.push(new Style({
text: new Text({
text: feature.get('name'),
offsetY: -30,
font: 'bold 12px Arial',
fill: new Fill({ color: '#000' }),
stroke: new Stroke({ color: '#fff', width: 2 })
}),
zIndex: isSelected ? 1001 : 101
}))
}
return styles
}
}
/**
* 获取图标URL
*/
getIconUrl(typeCode, isSelected) {
const baseUrl = `/icons/${typeCode}.png`
return isSelected ? `/icons/${typeCode}_selected.png` : baseUrl
}
/**
* 计算缩放比例
*/
getScale(zoom, isHighlight, isSelected) {
let baseScale = 1.0
// 根据缩放级别调整
if (zoom < 10) baseScale = 0.8
else if (zoom > 14) baseScale = 1.2
// 高亮放大
if (isHighlight) baseScale *= 1.2
// 选中放大
if (isSelected) baseScale *= 1.3
return baseScale
}
/**
* 隐藏类型
*/
hideType(typeCode) {
this.hiddenTypes.add(typeCode)
}
/**
* 显示类型
*/
showType(typeCode) {
this.hiddenTypes.delete(typeCode)
}
}
// 使用示例
const styleManager = new StyleManager(this.map)
const layer = new VectorLayer({
source: source,
style: styleManager.createStyleFunction()
})
// 控制显隐
styleManager.hideType('type1')
layer.getSource().changed() // 触发重新渲染
4. 性能优化:样式缓存
/**
* 样式缓存优化
* 避免重复创建相同的样式对象
*/
class CachedStyleManager {
constructor() {
this.styleCache = new Map()
}
/**
* 创建带缓存的样式函数
*/
createStyleFunction() {
return (feature) => {
// 生成缓存键
const typeCode = feature.get('typeCode')
const isHighlight = feature.get('highlight') || false
const isSelected = feature.get('selected') || false
const cacheKey = `${typeCode}_${isHighlight}_${isSelected}`
// 检查缓存
if (this.styleCache.has(cacheKey)) {
return this.styleCache.get(cacheKey)
}
// 创建新样式
const style = new Style({
image: new Icon({
src: `/icons/${typeCode}.png`,
scale: isSelected ? 1.3 : (isHighlight ? 1.2 : 1.0)
})
})
// 存入缓存
this.styleCache.set(cacheKey, style)
return style
}
}
/**
* 清空缓存
*/
clearCache() {
this.styleCache.clear()
}
}
添加点位(支持自定义图标)
import Feature from 'ol/Feature'
import Point from 'ol/geom/Point'
import { Style, Icon } from 'ol/style'
import { fromLonLat } from 'ol/proj'
function addPoint(lon, lat, iconUrl, name) {
const feature = new Feature({
geometry: new Point(fromLonLat([lon, lat])),
name: name,
type: 'point'
})
feature.setStyle(new Style({
image: new Icon({
src: iconUrl,
scale: 1.0,
anchor: [0.5, 1], // 底部中心为锚点
anchorXUnits: 'fraction',
anchorYUnits: 'fraction'
})
}))
this.pointsSource.addFeature(feature)
return feature
}
GIF 动画图标支持
OpenLayers 原生支持 GIF 动画图标,无需特殊处理:
/**
* 添加 GIF 动画图标
*/
function addAnimatedPoint(lon, lat, gifUrl, name) {
const feature = new Feature({
geometry: new Point(fromLonLat([lon, lat])),
name: name,
type: 'animated'
})
feature.setStyle(new Style({
image: new Icon({
src: gifUrl, // 直接使用 GIF 路径
scale: 1.0,
anchor: [0.5, 0.5]
})
}))
return feature
}
// 使用示例
const blinkingMarker = addAnimatedPoint(
117.27, 31.88,
'/icons/marker-blink.gif',
'闪烁标记'
)
注意事项:
- GIF 动画会自动播放
- 性能消耗比静态图标大
- 建议控制 GIF 数量(< 50 个)
- 可以用 CSS 动画替代(性能更好)
点击事件处理
this.map.on('singleclick', (evt) => {
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f)
if (feature) {
const name = feature.get('name')
const type = feature.get('type')
console.log('点击了:', name, type)
// 高亮选中
this.highlightFeature(feature)
}
})
M03 | 线要素模块
添加线路(支持分组管理)
import LineString from 'ol/geom/LineString'
import { Stroke, Style } from 'ol/style'
function addLine(coordinates, color, name, groupId) {
// coordinates: [[lon1, lat1], [lon2, lat2], ...]
const coords3857 = coordinates.map(c => fromLonLat(c))
const feature = new Feature({
geometry: new LineString(coords3857),
name: name,
groupId: groupId,
color: color,
type: 'line'
})
feature.setStyle(new Style({
stroke: new Stroke({
color: color,
width: 3
})
}))
this.linesSource.addFeature(feature)
return feature
}
线路显隐控制
// 方式1:通过图层控制(推荐:线路少时)
function toggleLine(groupId, visible) {
const layer = this.map.getLayers().getArray()
.find(l => l.get('groupId') === groupId)
if (layer) layer.setVisible(visible)
}
// 方式2:通过样式函数控制(推荐:线路多时)
const linesLayer = new VectorLayer({
source: linesSource,
style: (feature) => {
const hiddenIds = this.hiddenLineIds // 隐藏的线路ID数组
if (hiddenIds.includes(feature.get('groupId'))) {
return null // 不渲染
}
return new Style({
stroke: new Stroke({
color: feature.get('color'),
width: 3
})
})
}
})
M04 | 面要素模块
行政区划边界(带蒙层效果)
import Polygon from 'ol/geom/Polygon'
import { Fill } from 'ol/style'
function createProvinceMask(boundaryCoords) {
// 外环(全球范围)
const outer3857 = [
[-180, 85], [-180, -85], [180, -85], [180, 85], [-180, 85]
].map(p => fromLonLat(p))
// 内环(省份边界,反向作为洞)
const inner3857 = boundaryCoords.map(c => fromLonLat(c)).reverse()
// 创建打洞面
const maskFeature = new Feature({
geometry: new Polygon([outer3857, inner3857])
})
const maskLayer = new VectorLayer({
source: new VectorSource({ features: [maskFeature] }),
style: new Style({
fill: new Fill({ color: 'rgba(222, 234, 251, 0.6)' }), // 省外蒙层
stroke: new Stroke({ color: '#13dfee', width: 2 })
}),
zIndex: 100
})
return maskLayer
}
M05 | 聚合模块(海量点优化)
点聚合(Cluster)
适用场景:
- 海量点数据(> 1000 个)
- 需要按距离自动聚合
- 点击聚合后展开查看详情
import Cluster from 'ol/source/Cluster'
import { Circle as CircleStyle, Text } from 'ol/style'
function createClusterLayer(pointsSource) {
const clusterSource = new Cluster({
distance: 40, // 聚合距离(像素)
minDistance: 20, // 最小聚合距离
source: pointsSource
})
return new VectorLayer({
source: clusterSource,
style: (feature) => {
const size = feature.get('features').length
if (size === 1) {
// 单个点,使用原始样式
return feature.get('features')[0].getStyle()
}
// 聚合点样式
return new Style({
image: new CircleStyle({
radius: 15 + Math.min(size / 10, 10),
fill: new Fill({ color: 'rgba(255, 153, 0, 0.8)' }),
stroke: new Stroke({ color: '#fff', width: 2 })
}),
text: new Text({
text: size.toString(),
fill: new Fill({ color: '#fff' }),
font: 'bold 12px Arial'
})
})
}
})
}
// 点击聚合点展开
map.on('singleclick', (event) => {
const feature = map.forEachFeatureAtPixel(event.pixel, (f) => f)
if (feature) {
const features = feature.get('features')
if (features && features.length > 1) {
// 聚合点,缩放到包含所有点的范围
const extent = createEmpty()
features.forEach(f => {
extend(extent, f.getGeometry().getExtent())
})
map.getView().fit(extent, { duration: 500, padding: [50, 50, 50, 50] })
}
}
})
覆盖物防重叠(Declutter)
核心概念:
declutter是 OpenLayers 的自动防重叠机制- 当多个图标/文本重叠时,自动隐藏部分要素
- 适用于密集的点位或文本标注
适用场景:
- 密集的 POI 点位
- 大量文本标注
- 不需要聚合,但需要避免重叠
1. 基础用法
/**
* 启用 declutter(防重叠)
*/
const layer = new VectorLayer({
source: pointsSource,
declutter: true, // 启用防重叠
style: (feature) => {
return new Style({
image: new Icon({
src: feature.get('iconUrl'),
scale: 1.0
}),
text: new Text({
text: feature.get('name'),
offsetY: -20,
font: '12px Arial',
fill: new Fill({ color: '#000' }),
stroke: new Stroke({ color: '#fff', width: 2 })
})
})
}
})
效果:
- 当图标或文本重叠时,OpenLayers 会自动隐藏部分要素
- 缩放地图时,隐藏的要素可能重新显示
- 优先显示先添加的要素
2. Declutter vs Cluster 对比
| 特性 | Declutter(防重叠) | Cluster(聚合) |
|---|---|---|
| 原理 | 自动隐藏重叠要素 | 合并为聚合点 |
| 数据变化 | 不改变数据 | 生成新的聚合要素 |
| 适用场景 | 密集标注、文本 | 海量点数据 |
| 性能 | 较好 | 好 |
| 交互 | 点击原始要素 | 点击聚合点 |
| 缩放行为 | 自动显示/隐藏 | 自动聚合/展开 |
| 推荐使用 | 100-1000 个点 | > 1000 个点 |
3. 高级配置:优先级控制
/**
* 通过 zIndex 控制 declutter 优先级
* zIndex 越大,越不容易被隐藏
*/
const layer = new VectorLayer({
source: pointsSource,
declutter: true,
style: (feature) => {
const isImportant = feature.get('important') || false
const zIndex = isImportant ? 1000 : 100 // 重要的点优先显示
return new Style({
image: new Icon({
src: feature.get('iconUrl'),
scale: 1.0
}),
text: new Text({
text: feature.get('name'),
offsetY: -20,
font: '12px Arial',
fill: new Fill({ color: '#000' })
}),
zIndex: zIndex // 设置优先级
})
}
})
4. 实战案例:POI 防重叠
/**
* POI 图层 + 防重叠 + 优先级
*/
class POILayerWithDeclutter {
constructor(map) {
this.map = map
this.poiSource = new VectorSource()
// 创建图层,启用 declutter
this.poiLayer = new VectorLayer({
source: this.poiSource,
declutter: true, // 启用防重叠
style: (feature) => this.getStyle(feature),
zIndex: 300
})
this.map.addLayer(this.poiLayer)
}
/**
* 样式函数(带优先级)
*/
getStyle(feature) {
const typeCode = feature.get('typeCode')
const isSelected = feature.get('selected') || false
const priority = feature.get('priority') || 0 // 优先级:0-10
// 计算 zIndex(优先级越高,越不容易被隐藏)
const zIndex = isSelected ? 1000 : (100 + priority * 10)
return new Style({
image: new Icon({
src: this.getIconUrl(typeCode, isSelected),
scale: isSelected ? 1.2 : 1.0,
anchor: [0.5, 1]
}),
text: new Text({
text: feature.get('name'),
offsetY: -25,
font: 'bold 12px Arial',
fill: new Fill({ color: '#000' }),
stroke: new Stroke({ color: '#fff', width: 2 })
}),
zIndex: zIndex
})
}
/**
* 添加 POI(带优先级)
*/
addPOI(data) {
const feature = new Feature({
geometry: new Point(fromLonLat([data.lon, data.lat])),
id: data.id,
name: data.name,
typeCode: data.typeCode,
priority: data.priority || 0, // 优先级
selected: false
})
feature.setId(data.id)
this.poiSource.addFeature(feature)
return feature
}
getIconUrl(typeCode, isSelected) {
return isSelected
? `/icons/${typeCode}_selected.png`
: `/icons/${typeCode}.png`
}
}
// 使用示例
const poiLayer = new POILayerWithDeclutter(this.map)
// 添加 POI(设置优先级)
poiLayer.addPOI({
id: 'poi1',
name: '重要站点',
typeCode: 'yz',
lon: 117.27,
lat: 31.88,
priority: 10 // 高优先级,不容易被隐藏
})
poiLayer.addPOI({
id: 'poi2',
name: '普通站点',
typeCode: 'yz',
lon: 117.28,
lat: 31.88,
priority: 5 // 中等优先级
})
5. Declutter 最佳实践
✅ 推荐做法:
- 数量 100-1000 个点时使用 declutter
- 重要的要素设置更高的 zIndex
- 文本标注建议启用 declutter
- 配合缩放级别控制显示
❌ 避免做法:
- 数量 > 5000 个点时使用 declutter(性能差,建议用 Cluster)
- 所有要素使用相同 zIndex(无法控制优先级)
- 在移动端使用过多 declutter(性能问题)
⚠️ 注意事项:
- declutter 只在视觉上隐藏,数据仍然存在
- 隐藏的要素仍然可以被点击(需要额外处理)
- 缩放地图时,declutter 会重新计算
6. 性能对比
| 方案 | 数据量 | 性能 | 适用场景 |
|---|---|---|---|
| 无优化 | < 100 | 最好 | 数据少,不重叠 |
| Declutter | 100-1000 | 好 | 密集但不海量 |
| Cluster | > 1000 | 好 | 海量数据 |
| Declutter + Cluster | > 5000 | 最好 | 超大数据量 |
7. 组合使用:Declutter + Cluster
/**
* 先聚合,再防重叠
* 适用于超大数据量(> 5000 个点)
*/
const clusterSource = new Cluster({
distance: 50,
source: pointsSource
})
const layer = new VectorLayer({
source: clusterSource,
declutter: true, // 聚合后的点也启用防重叠
style: (feature) => {
const features = feature.get('features')
const size = features.length
if (size === 1) {
// 单个点
return features[0].getStyle()
}
// 聚合点
return new Style({
image: new CircleStyle({
radius: 15 + Math.min(size / 10, 10),
fill: new Fill({ color: 'rgba(255, 153, 0, 0.8)' })
}),
text: new Text({
text: size.toString(),
fill: new Fill({ color: '#fff' })
})
})
}
})
M06 | 热力图模块
import Heatmap from 'ol/layer/Heatmap'
function createHeatmapLayer(pointsSource) {
return new Heatmap({
source: pointsSource,
blur: 15, // 模糊半径
radius: 8, // 热力半径
weight: (feature) => {
// 根据要素属性设置权重
return feature.get('value') || 1
},
gradient: ['#00f', '#0ff', '#0f0', '#ff0', '#f00'] // 渐变色
})
}
三、实战案例
案例1:线路分组管理 + 图例联动
// 1. 从接口加载线路数据
async initLines() {
const res = await queryLinesGrouped({ teamId: this.teamId })
if (res && res.code === 200) {
const fc = this.assembleGeoJSON(res.data)
this.renderLines(fc)
}
}
// 2. 组装 GeoJSON
assembleGeoJSON(data) {
const features = data.map(item => ({
type: 'Feature',
properties: {
group_id: String(item.id),
name: item.name,
line_ids: item.lineIds
},
geometry: {
type: 'MultiLineString',
coordinates: item.coordsMulti // [[[lon,lat],...],...]
}
}))
return { type: 'FeatureCollection', features }
}
// 3. 渲染线路
renderLines(featureCollection) {
const format = new GeoJSON()
const features = format.readFeatures(featureCollection, {
dataProjection: 'EPSG:4326',
featureProjection: 'EPSG:3857'
})
const legendItems = []
const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d']
features.forEach((f, i) => {
const gid = f.get('group_id')
const name = f.get('name')
const color = colors[i % colors.length]
f.setId(gid)
f.set('color', color)
f.setStyle(new Style({
stroke: new Stroke({ color, width: 3 })
}))
legendItems.push({ id: gid, name, color })
})
this.linesSource.clear()
this.linesSource.addFeatures(features)
// 发送图例数据给父组件
this.$emit('lines-change', legendItems)
}
// 4. 图例控制显隐
toggleLineVisibility(groupId, visible) {
const feature = this.linesSource.getFeatureById(groupId)
if (feature) {
feature.set('hidden', !visible)
feature.setStyle(visible ? feature.get('baseStyle') : null)
}
}
案例2:非运维区域标注(矩形框)

场景描述:线路中有段区域非运维区域,需要用矩形框圈出来
实现demo
NonOperationalRegionProcessor.js
import {NonOperationalRegionProcessor} from "@/views/map/components/js/NonOperationalRegionProcessor";
export default {
name: 'OlMap',
props: {
teamId: {type: Number, default: null}
},
data() {
return {
// ===== 地图核心对象 =====
map: null, // OpenLayers Map 实例
// 非运维区域处理器
nonOperationalProcessor: new NonOperationalRegionProcessor({
epsilonKm: 0.05, // 相邻阈值(约50米)
regionMergeKm: 0.1, // 区域合并阈值(约100米)
batchSize: 500, // 批处理大小
strokeColor: '#9e9e9e', // 虚线颜色
strokeWidth: 2, // 虚线宽度
lineDash: [8, 6], // 虚线样式
fillColor: 'rgba(158,158,158,0.08)' // 填充色
})
}
},
methods: {
/**
* 非运维区段:返回所有相关线路的相邻点组成的线段集合
* 形如:
* const lineSegments = [
* [[117.8,30.6],[118.0,30.7]],
* [[118.01,30.71],[118.2,30.8]],
* [[118.9,31.2],[119.0,31.3]]
* ]
*/
initNotYwLinePoints(){
selectNotYwLinePoints({ teamId: this.teamId })
.then(res => {
if (res && res.code === 200 && res.data) {
const boxFeatures = this.buildHighlightBoxes(res.data)
// 创建矢量图层
const vectorLayer = new VectorLayer({
source: new VectorSource({
features: boxFeatures
})
});
// 添加到地图
this.map.addLayer(vectorLayer);
}
})
.catch(e => {
console.error('[Lines] fetch error:', e)
})
},
/*相邻“非运维区段”合并后再画虚线矩形框*
* 现在会自动把相邻段合并,一组只画一个虚线矩形。
* 相邻阈值 epsilonKm 默认 0.05(约 50 米),可按需要调大/调小以影响合并的“敏感度”。
* @param lineSegments
* @param projection
* @returns {any[]}
*/
buildHighlightBoxes(lineSegments, projection = 'EPSG:3857') {
if (!this.nonOperationalProcessor) {
console.warn('NonOperationalRegionProcessor未初始化')
return []
}
return this.nonOperationalProcessor.buildHighlightBoxes(lineSegments, projection)
},
}
}
- 下面的方式没尝试过,AI生成
// 场景:线路中某些区段为非运维区域,需要用虚线矩形框标注
function buildNonOperationalBoxes(lineSegments) {
// lineSegments: [[[lon1,lat1],[lon2,lat2]], ...]
const features = lineSegments.map(segment => {
const coords3857 = segment.map(c => fromLonLat(c))
// 计算包围盒
const xs = coords3857.map(c => c[0])
const ys = coords3857.map(c => c[1])
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
// 扩展边界(增加缓冲区)
const buffer = 50 // 米
const box = [
[minX - buffer, minY - buffer],
[maxX + buffer, minY - buffer],
[maxX + buffer, maxY + buffer],
[minX - buffer, maxY + buffer],
[minX - buffer, minY - buffer]
]
return new Feature({
geometry: new Polygon([box]),
type: 'non-operational'
})
})
// 创建虚线矩形图层
const boxLayer = new VectorLayer({
source: new VectorSource({ features }),
style: new Style({
stroke: new Stroke({
color: '#9e9e9e',
width: 2,
lineDash: [8, 6] // 虚线
}),
fill: new Fill({
color: 'rgba(158, 158, 158, 0.08)'
})
}),
zIndex: 50
})
return boxLayer
}
案例3:行政区蒙层效果(省外遮罩 + 省内镂空)
应用场景:地图外层添加半透明蒙层,指定区域(如安徽省)镂空透出底图,突出显示关注区域。
3.1 核心原理
使用 Polygon 的"打洞"特性:
- 外环:全球范围(作为蒙层)
- 内环:省份边界(反向坐标,作为"洞")
3.2 完整实现(方式一:推荐)
import Polygon from 'ol/geom/Polygon'
import Feature from 'ol/Feature'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import { Style, Fill, Stroke } from 'ol/style'
import { fromLonLat } from 'ol/proj'
/**
* 创建省份蒙层(省外遮罩 + 省内镂空)
* @param {string} boundaryCoordString - 边界坐标字符串(WGS84格式)
* @returns {VectorLayer} 蒙层图层
*/
function buildProvinceMaskLayer(boundaryCoordString) {
// 1. 外环:全球范围(收缩到 ±85°,避免极区异常)
const outer3857 = [
[-180, 85], [-180, -85], [180, -85], [180, 85], [-180, 85]
].map(p => fromLonLat(p))
// 2. 内环:省份边界(WGS84 → 3857,反向作为"洞")
const inner3857 = parseAndConvertBoundary(boundaryCoordString, true, true)
// 3. 创建打洞面要素
const maskFeature = new Feature({
geometry: new Polygon([outer3857, inner3857]),
name: '省份蒙层'
})
// 4. 创建蒙层图层
return new VectorLayer({
source: new VectorSource({ features: [maskFeature] }),
style: new Style({
fill: new Fill({ color: 'rgba(222, 234, 251, 0.6)' }), // 省外半透明蓝色
stroke: new Stroke({ color: '#13dfee', width: 1 }) // 边界线
}),
zIndex: 100 // 确保在底图之上
})
}
/**
* 解析边界坐标字符串并转换为 3857 坐标
* @param {string} coordString - 坐标字符串(格式:'lon1,lat1 lon2,lat2 ...')
* @param {boolean} ensureClosed - 是否确保闭合
* @param {boolean} reverseRing - 是否反向(用于打洞)
* @returns {Array} 3857 坐标数组
*/
function parseAndConvertBoundary(coordString, ensureClosed = true, reverseRing = false) {
// 解析坐标对
const pairs = coordString.trim().split(' ')
const ring = pairs.map(pair => {
const [lng, lat] = pair.split(',').map(Number)
return fromLonLat([lng, lat])
})
// 确保闭合
if (ensureClosed && ring.length > 0) {
const first = ring[0]
const last = ring[ring.length - 1]
if (first[0] !== last[0] || first[1] !== last[1]) {
ring.push([...first]) // 添加首点作为终点
}
}
// 反向(用于打洞)
if (reverseRing) {
ring.reverse()
}
return ring
}
3.3 边界数据格式
// 安徽省边界坐标(WGS84,简化版示例)
const anhuiBoundary = `
117.2,31.8 117.3,31.9 117.4,32.0 117.5,32.1
117.6,32.0 117.7,31.9 117.8,31.8 117.2,31.8
`.trim()
// 使用
const maskLayer = buildProvinceMaskLayer(anhuiBoundary)
this.map.addLayer(maskLayer)
3.4 同时添加边界线图层
/**
* 创建省份边界线图层(不带蒙层,仅描边)
* @param {string} boundaryCoordString - 边界坐标字符串
* @returns {VectorLayer} 边界线图层
*/
function buildBoundaryLayer(boundaryCoordString) {
const ring3857 = parseAndConvertBoundary(boundaryCoordString, true, false)
const feature = new Feature({
geometry: new Polygon([ring3857]),
name: '省份边界'
})
return new VectorLayer({
source: new VectorSource({ features: [feature] }),
style: new Style({
stroke: new Stroke({ color: '#13dfee', width: 2 }), // 边界线
fill: new Fill({ color: 'rgba(0, 0, 0, 0)' }) // 透明填充
}),
zIndex: 200 // 在蒙层之上
})
}
3.5 完整 Vue 组件示例
<template>
<div class="map-container">
<div ref="mapEl" class="map"></div>
</div>
</template>
<script>
import Map from 'ol/Map'
import View from 'ol/View'
import TileLayer from 'ol/layer/Tile'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import WMTS from 'ol/source/WMTS'
import WMTSTileGrid from 'ol/tilegrid/WMTS'
import Polygon from 'ol/geom/Polygon'
import Feature from 'ol/Feature'
import { Style, Fill, Stroke } from 'ol/style'
import { fromLonLat } from 'ol/proj'
import { borderline1 } from './js/anhui_areas_wgs84.js' // 边界数据
export default {
name: 'OlMapWithMask',
data() {
return {
map: null,
projection: 'EPSG:3857',
minZoom: 8,
maxZoom: 18
}
},
mounted() {
this.initMap()
},
methods: {
initMap() {
// 1. 创建底图
const wmtsLayer = this.createWMTSLayer()
// 2. 创建蒙层
const maskLayer = this.buildProvinceMaskLayer()
// 3. 创建边界线
const boundaryLayer = this.buildBoundaryLayer()
// 4. 创建地图
this.map = new Map({
target: this.$refs.mapEl,
layers: [wmtsLayer, maskLayer, boundaryLayer],
view: new View({
center: fromLonLat([117.27, 31.88]),
zoom: 10,
projection: this.projection,
minZoom: this.minZoom,
maxZoom: this.maxZoom
})
})
},
createWMTSLayer() {
// WMTS 图层创建逻辑(参考前面章节)
// ...
},
buildProvinceMaskLayer() {
// 外环(全球)
const outer3857 = [
[-180, 85], [-180, -85], [180, -85], [180, 85], [-180, 85]
].map(p => fromLonLat(p))
// 内环(安徽省边界,反向)
const inner3857 = this.toRing3857FromString(borderline1, true, true)
// 创建打洞面
const maskFeature = new Feature({
geometry: new Polygon([outer3857, inner3857])
})
return new VectorLayer({
source: new VectorSource({ features: [maskFeature] }),
style: new Style({
fill: new Fill({ color: 'rgba(222, 234, 251, 0.6)' }),
stroke: new Stroke({ color: '#13dfee', width: 1 })
}),
zIndex: 100
})
},
buildBoundaryLayer() {
const ring3857 = this.toRing3857FromString(borderline1, true, false)
const feature = new Feature({
geometry: new Polygon([ring3857])
})
return new VectorLayer({
source: new VectorSource({ features: [feature] }),
style: new Style({
stroke: new Stroke({ color: '#13dfee', width: 2 }),
fill: new Fill({ color: 'rgba(0, 0, 0, 0)' })
}),
zIndex: 200
})
},
/**
* 坐标字符串转 3857 环
* @param {string} coordString - 坐标字符串
* @param {boolean} ensureClosed - 确保闭合
* @param {boolean} reverseRing - 反向(用于打洞)
*/
toRing3857FromString(coordString, ensureClosed = true, reverseRing = false) {
const pairs = coordString.trim().split(' ')
const ring = pairs.map(pair => {
const [lng, lat] = pair.split(',').map(Number)
return fromLonLat([lng, lat])
})
// 确保闭合
if (ensureClosed && ring.length > 0) {
const first = ring[0]
const last = ring[ring.length - 1]
if (first[0] !== last[0] || first[1] !== last[1]) {
ring.push([...first])
}
}
// 反向
if (reverseRing) {
ring.reverse()
}
return ring
}
}
}
</script>
<style scoped>
.map-container {
width: 100%;
height: 100vh;
}
.map {
width: 100%;
height: 100%;
}
</style>
3.6 方式二:给指定区域加蒙层(不推荐)
如果需要给省内加蒙层(而不是省外),可以直接创建面要素:
/**
* 给指定区域加蒙层(省内遮罩)
* ⚠️ 注意:这种方式会遮挡省内内容,通常不推荐
*/
function buildRegionMaskLayer(boundaryCoordString) {
const ring3857 = parseAndConvertBoundary(boundaryCoordString, true, false)
const feature = new Feature({
geometry: new Polygon([ring3857]),
name: '区域蒙层'
})
return new VectorLayer({
source: new VectorSource({ features: [feature] }),
style: new Style({
fill: new Fill({ color: 'rgba(0, 0, 0, 0.3)' }), // 半透明黑色
stroke: new Stroke({ color: '#ff0000', width: 2 })
}),
zIndex: 100
})
}
3.7 关键要点总结
✅ 推荐做法:
- 使用"打洞"方式实现省外蒙层(外环 + 反向内环)
- 外环使用 ±85° 避免极区异常
- 确保内环闭合(首尾坐标一致)
- 内环反向(
reverse())以形成"洞" - 蒙层 zIndex 在底图之上、业务图层之下
⚠️ 注意事项:
- 坐标系统一:边界数据(WGS84)→ 转换为 3857
- 与 WMTS 配置保持一致(
wrapX: false) - 蒙层颜色建议半透明(alpha 0.3-0.6)
- 边界数据需要足够精细(避免锯齿)
🎨 样式建议:
// 浅色主题
fill: new Fill({ color: 'rgba(222, 234, 251, 0.6)' }) // 浅蓝色
// 深色主题
fill: new Fill({ color: 'rgba(0, 0, 0, 0.5)' }) // 半透明黑色
// 强调主题
fill: new Fill({ color: 'rgba(255, 255, 255, 0.8)' }) // 半透明白色
案例4:图片标注 + 文本叠加

// 场景:在图片图标上叠加文本(如设备名称)
function createIconWithText(lon, lat, iconUrl, text) {
const feature = new Feature({
geometry: new Point(fromLonLat([lon, lat])),
name: text
})
const styles = []
// 1. 背景图片
styles.push(new Style({
image: new Icon({
src: iconUrl,
scale: 1.0,
anchor: [0.5, 0.5]
}),
zIndex: 100
}))
// 2. 文本(叠加在图片上)
styles.push(new Style({
text: new Text({
text: text,
font: 'bold 14px Arial',
fill: new Fill({ color: '#000' }),
offsetX: 10, // 文本偏移
offsetY: 0,
textAlign: 'center',
textBaseline: 'middle'
}),
zIndex: 101
}))
feature.setStyle(styles)
return feature
}
四、性能优化
4.1 海量点渲染优化
// 使用 Canvas 渲染 + declutter(防重叠)
const vectorLayer = new VectorLayer({
source: pointsSource,
declutter: true, // 自动避免标注重叠
renderMode: 'image', // 使用 Canvas 渲染
updateWhileAnimating: true,
updateWhileInteracting: true
})
4.2 图层懒加载
// 根据缩放级别动态加载图层
this.map.getView().on('change:resolution', () => {
const zoom = this.map.getView().getZoom()
if (zoom > 12) {
// 加载详细图层
this.loadDetailLayer()
} else {
// 卸载详细图层
this.unloadDetailLayer()
}
})
4.3 要素分批加载
function addFeaturesInBatches(features, batchSize = 500) {
let index = 0
const addBatch = () => {
const batch = features.slice(index, index + batchSize)
this.vectorSource.addFeatures(batch)
index += batchSize
if (index < features.length) {
requestAnimationFrame(addBatch)
}
}
addBatch()
}
五、常见问题
Q1: 地图旋转如何禁用?
1️⃣ 导入交互控制模块
import { defaults as defaultInteractions } from 'ol/interaction'
2️⃣ 配置 Map 交互(禁用旋转交互)
this.map = new Map({
// ...其他配置
interactions: defaultInteractions({
// ❌ 禁用旋转相关的交互
altShiftDragRotate: false, // PC端 Alt+Shift+拖拽旋转
pinchRotate: false, // 触屏双指旋转
// ✅ 保留其他交互
doubleClickZoom: true, // 双击放大
dragPan: true, // 拖拽平移
mouseWheelZoom: true, // 鼠标滚轮缩放
pinchZoom: true, // 触屏双指缩放 ✅
keyboard: true, // 键盘操作
shiftDragZoom: true // Shift+拖拽框选放大
})
})
3️⃣ 配置 View(锁定旋转角度)
return new View({
// ...其他配置
rotation: 0, // 固定旋转角度为0(正北向上)
enableRotation: false, // 禁用旋转
constrainRotation: false // 不约束旋转步进
})
✅ 效果
| 操作 | PC端 | 触屏端 | 状态 |
|---|---|---|---|
| 双指缩放 | - | ✅ 可用 | 保留 |
| 双指旋转 | - | ❌ 禁用 | 已禁 |
| Alt+Shift+拖拽旋转 | ❌ 禁用 | - | 已禁 |
| 鼠标滚轮缩放 | ✅ 可用 | - | 保留 |
| 拖拽平移 | ✅ 可用 | ✅ 可用 | 保留 |
| 双击放大 | ✅ 可用 | ✅ 可用 | 保留 |
Q2: 如何限制地图范围?
const view = new View({
center: fromLonLat([117.27, 31.88]),
zoom: 10,
extent: [
...fromLonLat([114.9, 29.4]), // 西南角
...fromLonLat([119.3, 35.1]) // 东北角
]
})
Q3: WMTS 瓦片加载失败怎么办?
// 1. 添加缺省背景
const backgroundLayer = new TileLayer({
source: new XYZ({
url: 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(`
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
<rect width="256" height="256" fill="#f0f0f0"/>
<text x="128" y="128" text-anchor="middle" fill="#999">离线地图</text>
</svg>
`)
}),
opacity: 0.3
})
// 2. 监听加载错误
wmtsSource.on('tileloaderror', (event) => {
console.warn('瓦片加载失败:', event)
})
Q4: 如何实现要素高亮?
let lastHighlightFeature = null
function highlightFeature(feature) {
// 恢复上一个高亮
if (lastHighlightFeature) {
lastHighlightFeature.setStyle(lastHighlightFeature.get('baseStyle'))
}
// 设置新高亮
const highlightStyle = new Style({
stroke: new Stroke({
color: '#ff0000',
width: 5
})
})
feature.setStyle(highlightStyle)
lastHighlightFeature = feature
}
Q5: 如何定位到要素?
function focusFeature(feature) {
const geometry = feature.getGeometry()
const extent = geometry.getExtent()
this.map.getView().fit(extent, {
padding: [50, 50, 50, 50], // 边距
duration: 500, // 动画时长
maxZoom: 16 // 最大缩放级别
})
}
六、参考资源
- 官方文档: https://openlayers.org/en/latest/apidoc/
- 示例库: https://openlayers.org/en/latest/examples/
- GeoJSON 规范: https://geojson.org/
- 坐标转换工具: https://epsg.io/
七、学习路径建议
第1周:基础地图 + 图层管理
第2周:点线面要素 + 样式
第3周:交互事件 + 高亮选中
第4周:聚合 + 热力图
第5周:行政区 + 专题图
第6周:性能优化 + 工程化封装
📌 本文档持续更新中...
最后更新:2025-02-09