OpenLayers + Vue2 离线地图实现笔记
OpenLayers + Vue2 离线地图实现笔记
https://download.csdn.net/blog/column/11055250/123442218
https://blog.csdn.net/m0_45127388/article/details/129529260
项目概述
基于 Vue2 + Webpack + OpenLayers 的离线地图应用,支持安徽省范围的 WMTS 瓦片服务,包含点/线/面绘制功能。
技术栈
- 前端框架: Vue 2.7.16
- 构建工具: Webpack 5 + Babel
- 地图引擎: OpenLayers 9.2.4
- 瓦片服务: GeoServer WMTS (EPSG:3857_ah16)
第一步:创建项目骨架
1.1 项目结构
ol-vue2-webpack/
├── package.json # 依赖配置
├── webpack.config.js # Webpack 配置
├── .babelrc # Babel 配置
├── public/
│ └── index.html # 入口 HTML
└── src/
├── main.js # Vue 入口
├── App.vue # 根组件
└── components/
└── OlMap.vue # 地图组件
1.2 关键依赖
{
"dependencies": {
"ol": "^9.2.4",
"vue": "^2.7.16"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"babel-loader": "^9.1.3",
"css-loader": "^6.10.0",
"html-webpack-plugin": "^5.6.0",
"style-loader": "^3.3.4",
"vue-loader": "^15.11.1",
"vue-style-loader": "^4.1.3",
"vue-template-compiler": "^2.7.16",
"vue-loader-plugin": "^1.0.0",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
}
}
1.3 Webpack 配置要点
// webpack.config.js
const { VueLoaderPlugin } = require('vue-loader');
module.exports = {
// Vue2 + Webpack5 兼容配置
module: {
rules: [
{ test: /\.vue$/, loader: 'vue-loader' },
{ test: /\.js$/, exclude: /node_modules/, use: { loader: 'babel-loader' } },
{ test: /\.css$/, use: ['vue-style-loader', 'css-loader'] }
]
},
plugins: [
new VueLoaderPlugin(), // 必须添加
new HtmlWebpackPlugin({...})
],
devServer: {
proxy: {
'/wmts': {
target: 'http://localhost:18080',
changeOrigin: true,
pathRewrite: { '^/wmts': '/geoserver/gwc/service/wmts' }
}
}
}
};
第二步:引入底图瓦片服务
2.1 WMTS 服务配置
// 服务参数
const projection = 'EPSG:3857'
const matrixSet = 'EPSG:3857_ah16'
const layer = 'ah16'
const format = 'image/png'
// 服务 URL(通过代理)
const wmtsUrl = '/wmts'
2.2 瓦片网格配置
// 缩放级别范围
const minZoom = 8
const maxZoom = 18
const tileSize = 256
const origin = [-20037508.342789244, 20037508.342789244]
// 生成分辨率和矩阵ID
const resolutions = []
const matrixIds = []
for (let z = minZoom; z <= maxZoom; z++) {
resolutions[z] = (156543.03392804097 / Math.pow(2, z))
matrixIds[z] = `EPSG:3857_ah16:${z}`
}
2.3 创建 WMTS 源
import WMTS from 'ol/source/WMTS'
import WMTSTileGrid from 'ol/tilegrid/WMTS'
import TileLayer from 'ol/layer/Tile'
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: tileSize
}),
wrapX: false, // 禁用横向重复,避免破图
requestEncoding: 'KVP'
})
const wmtsLayer = new TileLayer({ source: wmtsSource })
第三步:控制缩放级别
3.1 设置缩放范围
// 在 View 中限制缩放级别
const view = new View({
center: fromLonLat([117.27, 31.88]), // 安徽省中心
zoom: 10,
projection: 'EPSG:3857',
minZoom: 8, // 最小缩放级别
maxZoom: 18 // 最大缩放级别
})
3.2 监听缩放完成事件
// 监听地图操作完成(缩放/平移)
this.map.on('moveend', () => {
const view = this.map.getView()
const zoom = view.getZoom()
const center = view.getCenter()
const centerLonLat = fromLonLat(center, 'EPSG:4326')
console.log('地图操作完成:')
console.log('- 缩放级别:', zoom)
console.log('- 中心点坐标 (3857):', center)
console.log('- 中心点坐标 (4326):', centerLonLat)
})
第四步:控制区域范围
4.1 定义地理范围
// 安徽省大致范围 (经纬度)
const anhuiBounds = [
[114.9, 29.4], // 西南角
[119.3, 35.1] // 东北角
]
// 转换为 Web Mercator 投影
const anhuiBounds3857 = [
fromLonLat(anhuiBounds[0]), // 西南角
fromLonLat(anhuiBounds[1]) // 东北角
]
4.2 限制地图范围
// 在 View 中设置范围限制
const view = new View({
center: fromLonLat([117.27, 31.88]),
zoom: 10,
projection: 'EPSG:3857',
minZoom: 8,
maxZoom: 18,
extent: anhuiBounds3857.flat() // 限制地图范围在安徽省内
})
4.3 动态范围检查
// 监听地图中心变化,确保不超出范围
this.map.getView().on('change:center', () => {
const view = this.map.getView()
const center = view.getCenter()
const extent = anhuiBounds3857.flat()
// 检查是否超出范围
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('地图已自动回到安徽省范围内')
}
})
第五步:添加缺省背景图层
5.1 创建缺省背景
// 创建 SVG 缺省图
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>
`
// 使用 encodeURIComponent 处理中文编码
const encodedSvg = encodeURIComponent(svgString)
// 创建背景图层
const backgroundLayer = new TileLayer({
source: new XYZ({
url: `data:image/svg+xml;charset=utf-8,${encodedSvg}`,
tilePixelRatio: 1
}),
opacity: 0.3
})
5.2 图层顺序
// 背景层在最底层,确保瓦片加载失败时有视觉反馈
this.map = new Map({
target: this.$refs.mapEl,
layers: [backgroundLayer, wmtsLayer, this.vectorLayer],
view: view
})
第六步:错误处理与优化
6.1 瓦片加载错误处理
// 监听瓦片加载错误
wmtsSource.on('tileloaderror', (event) => {
console.warn('瓦片加载失败:', event)
})
6.2 破图问题解决
// 关键配置
const wmtsSource = new WMTS({
// ... 其他配置
wrapX: false, // 禁用横向重复,避免破图
requestEncoding: 'KVP'
})
6.3 编码问题处理
// 使用 encodeURIComponent 而不是 btoa 处理中文
const encodedSvg = encodeURIComponent(svgString)
const url = `data:image/svg+xml;charset=utf-8,${encodedSvg}`
第七步:绘制功能实现
7.1 矢量图层配置
// 创建矢量源和图层
const vectorSource = new VectorSource()
const vectorLayer = new VectorLayer({
source: vectorSource,
style: new Style({
stroke: new Stroke({ color: '#0078ff', width: 2 }),
fill: new Fill({ color: 'rgba(0,120,255,0.15)' }),
image: new CircleStyle({ radius: 5, fill: new Fill({ color: '#ff5722' }) })
})
})
7.2 绘制交互
// 绘制模式切换
setMode(type) {
if (!this.map) return
if (this.draw) {
this.map.removeInteraction(this.draw)
this.draw = null
}
if (!type) return
this.draw = new Draw({ source: this.vectorSource, type })
this.map.addInteraction(this.draw)
}
// 清空功能
clearFeatures() {
this.vectorSource.clear()
}
8. 蒙层
地图外层有蒙层,安徽省镂空透出底图
import { borderline1 } from './js/anhui_areas_wgs84.js'
8.1 地图外层有蒙层,安徽省镂空透出底图
- 方式1:
// 1) 外环(全球,收缩到±85,避免极区异常)→ 转3857
const outer3857 = [[-180,85],[-180,-85],[180,-85],[180,85],[-180,85]].map(p => fromLonLat(p))
// 2) 内环(安徽边界,WGS84→3857),并反向作为“洞”(可选:确保闭合)
const inner3857 = (function () {
const ring = parseCoordinateString(borderline1) // 你已有的函数:内部 fromLonLat
const first = ring[0], last = ring[ring.length - 1]
if (first[0] !== last[0] || first[1] !== last[1]) ring.push(first)
return ring.reverse() // 反向以明确“洞”
})()
// 3) 生成“打洞”面并作为图层(省外着色、省内透明)
const maskFeature = new Feature(new Polygon([outer3857, inner3857]))
const maskLayer = new VectorLayer({
source: new VectorSource({ features: [maskFeature] }),
style: new Style({
fill: new Fill({ color: '#deeafb' }), // 省外蒙层(与你天地图写法的颜色一致)
stroke: new Stroke({ color: '#13dfee', width: 1 }) // 外轮廓线
}),
zIndex: 100
})
map.addLayer(maskLayer)
方式二
保证与 WMTS/3857 完全一致(坐标统一、wrapX=false)。
内环方向、闭合处理、±85 外环,规避坑点。
额外还有缺省背景、范围限制(用户体验、安全边界)等增强项。
8.2 给安徽省加蒙层【正常都是给指定区域外加蒙层】-- 参考
loadAnhuiBoundaryData(source) {
// 解析坐标字符串
const coordinates = this.parseCoordinateString(borderline1)
// 创建多边形几何体
const polygon = new Polygon([coordinates])
// 创建要素
const feature = new Feature({
geometry: polygon,
name: '安徽省边界'
})
// 添加到矢量源
source.addFeature(feature)
console.log('安徽省边界数据加载完成')
},
<template>
<div class="container">
<div class="toolbar">
<button @click="setMode('Point')">点</button>
<button @click="setMode('LineString')">线</button>
<button @click="setMode('Polygon')">面</button>
<button @click="setMode(null)">漫游</button>
<button @click="clearFeatures">清空</button>
</div>
<div ref="mapEl" class="map"></div>
</div>
</template>
<script>
import Map from 'ol/Map'
import View from 'ol/View'
import VectorSource from 'ol/source/Vector'
import VectorLayer from 'ol/layer/Vector'
import { Draw } from 'ol/interaction'
import { Style, Stroke, Fill, Circle as CircleStyle } from 'ol/style'
import { fromLonLat } from 'ol/proj'
import WMTS from 'ol/source/WMTS'
import WMTSTileGrid from 'ol/tilegrid/WMTS'
import XYZ from 'ol/source/XYZ'
import TileLayer from 'ol/layer/Tile'
import Polygon from 'ol/geom/Polygon'
import Feature from 'ol/Feature'
import { borderline1 } from './js/anhui_areas_wgs84.js'
export default {
name: 'OlMap',
data() {
return {
// ===== 地图核心对象 =====
map: null, // OpenLayers Map 实例
draw: null, // 绘制交互对象
vectorSource: new VectorSource(), // 矢量数据源
vectorLayer: null, // 矢量图层
// ===== 地图配置参数 =====
projection: 'EPSG:3857', // 地图投影坐标系
minZoom: 6, // 最小缩放级别
maxZoom: 18, // 最大缩放级别
tileSize: 256, // 瓦片大小
// ===== WMTS 服务配置 =====
wmtsConfig: {
url: '/wmts', // WMTS 服务地址
layer: 'ah16', // 图层名称 (可替换为 ahwx16 等)
matrixSet: 'EPSG:3857_ah16', // 矩阵集名称
format: 'image/png', // 图片格式
style: '' // 样式名称
},
// ===== 地图中心点和范围配置 =====
mapCenter: [117.27, 31.88], // 地图中心点 [经度, 纬度] (可修改为青阳等)
mapExtent: [ // 地图显示范围 [西南角, 东北角]
[110.0, 25.0], // 西南角 [经度, 纬度]
[125.0, 40.0] // 东北角 [经度, 纬度]
],
// ===== 图层对象 =====
wmtsLayer: null, // WMTS 底图图层
backgroundLayer: null, // 缺省背景图层 (瓦片加载失败时显示)
provinceMaskLayer: null, // 省份蒙层 (安徽省外区域遮罩)
boundaryLayer: null, // 省份边界图层 (安徽省轮廓线)
// ===== 安徽省原始边界数据 =====
anhuiBoundsLonLat: [ // 安徽省原始边界范围
[114.9, 29.4], // 西南角
[119.3, 35.1] // 东北角
]
}
},
async mounted() {
// 初始化地图
this.initMap()
},
beforeDestroy() {
if (this.draw && this.map) this.map.removeInteraction(this.draw)
this.map = null
},
methods: {
// 初始化地图
initMap() {
this.vectorLayer = this.buildVectorEditingLayer()
const { resolutions, matrixIds, origin } = this.buildTileGridMeta()
this.wmtsLayer = this.buildWmtsLayer(resolutions, matrixIds, origin)
this.backgroundLayer = this.buildBackgroundLayer()
this.boundaryLayer = this.buildBoundaryLayer()
this.provinceMaskLayer = this.buildProvinceMaskLayer()
const view = this.buildView()
this.map = new Map({
target: this.$refs.mapEl,
layers: [this.backgroundLayer, this.wmtsLayer, this.provinceMaskLayer, this.boundaryLayer, this.vectorLayer],
view
})
this.bindEvents()
},
buildVectorEditingLayer() {
return new VectorLayer({
source: this.vectorSource,
style: new Style({
stroke: new Stroke({ color: '#0078ff', width: 2 }),
fill: new Fill({ color: 'rgba(0,120,255,0.15)' }),
image: new CircleStyle({ radius: 5, fill: new Fill({ color: '#ff5722' }) })
})
})
},
/**
* 构建 WMTS 瓦片网格元数据
* 生成所有缩放级别的分辨率和矩阵ID,确保低级别缩放正常
* @returns {Object} 包含 resolutions、matrixIds、origin 的对象
*/
buildTileGridMeta() {
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] = `${this.wmtsConfig.matrixSet}:${z}`
}
return { resolutions, matrixIds, origin }
},
/**
* 构建 WMTS 底图图层
* 使用配置的 WMTS 服务创建底图图层,支持瓦片加载错误处理
* @param {Array} resolutions - 分辨率数组
* @param {Array} matrixIds - 矩阵ID数组
* @param {Array} origin - 瓦片网格原点
* @returns {TileLayer} WMTS 底图图层
*/
buildWmtsLayer(resolutions, matrixIds, origin) {
const wmtsSource = new WMTS({
url: this.wmtsConfig.url,
layer: this.wmtsConfig.layer,
matrixSet: this.wmtsConfig.matrixSet,
format: this.wmtsConfig.format,
style: this.wmtsConfig.style,
tileGrid: new WMTSTileGrid({
origin,
resolutions,
matrixIds,
tileSize: this.tileSize
}),
wrapX: false, // 禁用横向重复,避免破图
requestEncoding: 'KVP' // 使用 KVP 请求编码
})
// 监听瓦片加载错误
wmtsSource.on('tileloaderror', (event) => {
console.warn('WMTS 瓦片加载失败:', event)
})
return new TileLayer({
source: wmtsSource,
opacity: 1
})
},
/**
* 构建缺省背景图层
* 当 WMTS 瓦片加载失败时显示的备用背景,使用 SVG 数据 URI
* @returns {TileLayer} 缺省背景图层
*/
buildBackgroundLayer() {
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 // 半透明,作为背景
})
},
buildView() {
// 使用更大的范围,避免缩放时被限制
// 安徽省范围 + 周边缓冲区域
const expandedBounds = [
[110.0, 25.0], // 西南角,扩大范围
[125.0, 40.0] // 东北角,扩大范围
]
const extent3857 = [fromLonLat(expandedBounds[0]), fromLonLat(expandedBounds[1])].flat()
return new View({
center: fromLonLat([117.27, 31.88]),
zoom: 10,
projection: this.projection,
minZoom: this.minZoom,
maxZoom: this.maxZoom,
extent: extent3857
})
},
// —— 蒙层与边界封装 ——
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(new Polygon([outer3857, inner3857]))
return new VectorLayer({
source: new VectorSource({ features: [maskFeature] }),
zIndex: 100,
style: new Style({
fill: new Fill({ color: '#deeafb' }),
stroke: new Stroke({ color: '#13dfee', width: 1 })
})
})
},
buildBoundaryLayer() {
const ring3857 = this.toRing3857FromString(borderline1, true, false)
const feature = new Feature(new Polygon([ring3857]))
return new VectorLayer({
source: new VectorSource({ features: [feature] }),
zIndex: 200,
style: new Style({
stroke: new Stroke({ color: '#13dfee', width: 1 }),
fill: new Fill({ color: 'rgba(0,0,0,0)' })
})
})
},
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
},
// —— 交互与工具 ——
bindEvents() {
this.map.on('moveend', () => {
const view = this.map.getView()
const zoom = view.getZoom()
const center = view.getCenter()
const centerLonLat = fromLonLat(center, 'EPSG:4326')
console.log('地图操作完成:')
console.log('- 缩放级别:', zoom)
console.log('- 中心点坐标 (3857):', center)
console.log('- 中心点坐标 (4326):', centerLonLat)
console.log('---')
})
this.map.getView().on('change:center', () => {
const view = this.map.getView()
const center = view.getCenter()
const extent = [fromLonLat(this.anhuiBoundsLonLat[0]), fromLonLat(this.anhuiBoundsLonLat[1])].flat()
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('地图已自动回到安徽省范围内')
}
})
},
setMode(type) {
if (!this.map) return
if (this.draw) {
this.map.removeInteraction(this.draw)
this.draw = null
}
if (!type) return
this.draw = new Draw({ source: this.vectorSource, type })
this.map.addInteraction(this.draw)
},
clearFeatures() {
this.vectorSource.clear()
}
}
}
</script>
<style scoped>
.container { display: flex; flex-direction: column; height: 100vh; }
.toolbar { padding: 8px 12px; border-bottom: 1px solid #e0e0e0; }
.toolbar button { margin-right: 8px; }
.map { flex: 1; }
</style>
9. 创建Legend 图例组件
10. 添加线
需求线路分组管理 + 图层显隐
✅ 推荐做法 1:为每条线路单独建一个 VectorLayer
- 每条线路数据放在一个
VectorSource→VectorLayer里。 - 给每个图层设置一个唯一
id或name。 - 控件点击时,通过
layer.setVisible(true/false)控制显示/隐藏。
示例代码:
import Map from 'ol/Map'
import View from 'ol/View'
import { LineString } from 'ol/geom'
import { Vector as VectorSource } from 'ol/source'
import { Vector as VectorLayer } from 'ol/layer'
import Feature from 'ol/Feature'
import { Stroke, Style } from 'ol/style'
import { fromLonLat } from 'ol/proj'
function buildLineLayer(id, coords, color) {
const feature = new Feature({
geometry: new LineString(coords.map(c => fromLonLat(c)))
})
return new VectorLayer({
source: new VectorSource({ features: [feature] }),
style: new Style({
stroke: new Stroke({ color, width: 3 })
}),
properties: { id }
})
}
// 初始化地图
const line1 = buildLineLayer('line1', [[117, 31], [118, 32]], 'red')
const line2 = buildLineLayer('line2', [[116.5, 31.5], [118.5, 33]], 'blue')
const map = new Map({
target: 'map',
layers: [line1, line2],
view: new View({
center: fromLonLat([117.2, 31.8]),
zoom: 8
})
})
// 控制显隐
function toggleLine(id, visible) {
const layer = map.getLayers().getArray().find(l => l.get('id') === id)
if (layer) layer.setVisible(visible)
}
这样,你在前端 UI(比如 checkbox 列表)里勾选线路时,直接调用 toggleLine('line1', false) 就能隐藏对应线路。
✅ 做法 2:所有线路放在同一个 VectorLayer,用属性过滤
- 所有线路放在一个
VectorSource。 - 每个 feature 带一个
id或type属性。 - 控制显示/隐藏时,通过 过滤器/样式函数来动态决定要不要画。
示例:
const source = new VectorSource()
source.addFeature(new Feature({
id: 'line1',
geometry: new LineString([...])
}))
source.addFeature(new Feature({
id: 'line2',
geometry: new LineString([...])
}))
const layer = new VectorLayer({
source,
style: (feature) => {
const hiddenIds = ['line2'] // 假设 line2 隐藏
if (hiddenIds.includes(feature.get('id'))) {
return null // 不渲染
}
return new Style({
stroke: new Stroke({ color: 'red', width: 2 })
})
}
})
这种方式更适合线路很多(成百上千条),不想生成很多图层时。
📌 总结
- 线路少(几十条以内) → 每条线路一个
VectorLayer,直接用setVisible,逻辑清晰。 - 线路多(上百/上千条) → 全放一个图层里,用属性 + 样式函数控制显示。
案例
“从接口加载 → 组装 → 渲染 → 事件/高亮/选中”的链路收敛为两个清晰入口:
- assembleGoeJSONFormt(res):把接口数据转为标准 GeoJSON FeatureCollection
- renderLinesFeatureCollection(fc):统一渲染入口(赋色/样式/extData/更新数据源/fit/推送图例)
initLine 只做请求与调用,不再夹杂渲染细节,点击事件/高亮/选中逻辑继续复用现有方法。
1. 使用入口(添加线路的标准写法)
async initLine() {
queryLinesGrouped({ teamId: this.teamId })
.then(res => {
if (res && res.code === 200 && res.data) {
const fc = this.assembleGoeJSONFormt(res) // 1) 组装 GeoJSON
this.renderLinesFeatureCollection(fc) // 2) 统一渲染
}
})
.catch(e => console.error('[Lines] fetch error:', e))
}
2. 组装 GeoJSON(保持最小/明晰)
assembleGoeJSONFormt(res) {
const data = Array.isArray(res.data) ? res.data : []
const features = data.map(item => {
const coords = Array.isArray(item.coordsMulti) ? item.coordsMulti : []
if (!coords.length) return null
return { type:'Feature', properties:{
group_id: String(item.id||''), name: b64ToUtf8(item.name||''),
line_ids: Array.isArray(item.lineIds)?item.lineIds.map(String):[],
line_names: (item.lineNames||[]).map(n=>b64ToUtf8(n||'')) },
geometry:{ type:'MultiLineString', coordinates: coords } }
}).filter(Boolean)
return { type:'FeatureCollection', features }
}
3. 统一渲染(赋色/样式/extData/fit/图例事件)
renderLinesFeatureCollection(fc) {
const format = new GeoJSON()
const feats = format.readFeatures(fc, { dataProjection:'EPSG:4326', featureProjection:this.projection })
const legendItemArr = [], palette = this.LINE_PALETTE?.length ? this.LINE_PALETTE : ['#1890ff']
feats.forEach((f,i) => {
const gid = String(f.get('group_id') || i+1), color = palette[i%palette.length], name = f.get('name') || `线路${i+1}`
f.setId(gid); f.set('name', name); f.set('color', color); f.set('hidden', false); this.lineIdToFeature.set(gid, f)
const baseStyle = new Style({ stroke: new Stroke({ color, width: 3 }) }); f.set('baseStyle', baseStyle); f.setStyle(baseStyle)
if (this.showLineLabels) this.applyLabelStyleIfNeeded(f)
f.set('extData', this.buildLineExtData(f)); legendItemArr.push({ id: gid, name, color })
})
this.linesSource.clear(); this.linesSource.addFeatures(feats)
if (feats.length > 0) this.focusExtent(this.linesSource.getExtent())
this.$emit('lines-change', legendItemArr)
}
4. 点击事件 + 高亮/选中(记录标记)
- 对外 emit:line-click,携带 extData
- 选中记录:lastCheckFeature
- 高亮/闪烁:统一在 handleLineClick 内处理
// 处理线路点击(emit + 高亮 + 闪烁 + 定位)
handleLineClick(feature) {
const payload = { id: String(feature.getId()||''), name: feature.get('name')||'',
color: feature.get('color')||'#1890ff', extData: feature.get('extData')||null }
this.$emit('line-click', payload)
if (this.lastCheckFeature === feature) return this.resetLastHighlight()
this.resetLastHighlight()
const hl = this.buildStyle(feature.get('color')||'#1890ff', this.highlightWidth, this.showLineLabels ? feature.get('name') : null, this.getLabelColor())
feature.setStyle(hl); feature.set('currentStyle', hl); this.lastCheckFeature = feature
this.markerFlashing(feature); this.focusFeature(feature)
}
通过id查询线路,并高亮
/**
* 程序化选中线路(等同于点击交互)
* @param {string|number} groupId 线路分组ID(feature id)
* @returns {boolean} 是否选中成功
*/
selectLine(groupId) {
try {
const gid = String(groupId)
let feature = null
if (this.lineIdToFeature && this.lineIdToFeature.get) {
feature = this.lineIdToFeature.get(gid) || null
}
if (!feature && this.linesSource) {
const all = this.linesSource.getFeatures() || []
feature = all.find(f => String(f.getId ? f.getId() : '') === gid) || null
}
if (!feature) return false
// 若隐藏则先显示
if (feature.get && feature.get('hidden')) {
feature.set('hidden', false)
this.applyLabelStyleIfNeeded(feature)
}
this.handleLineClick(feature)
return true
} catch (e) {
console.error('[Lines] selectLine error:', e)
return false
}
}
5. 统一 extData(可外部记录标记)
buildLineExtData(feature) {
const gid = String(feature?.getId?.() || feature?.get?.('group_id') || '')
return { id: gid, name: feature.get('name')||'', color: feature.get('color')||'#1890ff',
type: 'line', layer: 'lines', geometryType: 'polyline', groupId: gid,
lineIds: feature.get('line_ids') || [], position: this.getFeaturePosition(feature) }
}
6. queryLinesGrouped 接口返回值示例
满足当前组装逻辑的一个参考示例(数组,每个元素是“线路组”):
[
{
"id": 101,
"name": "5p2o5YyX6KGo", // base64("安庆一号线")
"coordsMulti": [
[ [117.10, 31.80], [117.20, 31.85], [117.35, 31.90] ],
[ [117.40, 31.92], [117.55, 31.95] ]
],
"lineIds": [ "L101A", "L101B" ],
"lineNames": [ "5p2o5YyX6KGoQQ==", "5p2o5YyX6KGoQg==" ] // base64 子线名称(可选)
},
{
"id": 102,
"name": "5rW36YeR5Lit", // base64("滁州二号线")
"coordsMulti": [
[ [118.00, 32.20], [118.20, 32.25] ]
],
"lineIds": [ "L102" ],
"lineNames": [ "5LqM5L2c" ] // 可为空
}
]
要点:
- coordsMulti 使用 EPSG:4326 经纬度坐标,MultiLineString 的二维数组
- lineIds/lineNames 可选;存在时会写入 extData.lineIds
11. 添加点
实现源码-主要展示的是点线面的添加: map.vue
12. 添加区域描边
9. 事件监听
9.1 监听图层zoom
// 监听地图移动结束事件(包括缩放和平移完成)
this.map.on('moveend', () => {
const view = this.map.getView()
const zoom = view.getZoom()
const center = view.getCenter()
const centerLonLat = fromLonLat(center, 'EPSG:4326')
console.log('地图操作完成:')
console.log('- 缩放级别:', zoom)
console.log('- 中心点坐标 (3857):', center)
console.log('- 中心点坐标 (4326):', centerLonLat)
console.log('---')
})
- 过程监听
// 监听缩放变化
this.map.getView().on('change:resolution', () => {
const view = this.map.getView()
const zoom = view.getZoom()
const center = view.getCenter()
const centerLonLat = fromLonLat(center, 'EPSG:4326')
console.log('地图缩放变化:')
console.log('- 缩放级别:', zoom)
console.log('- 中心点坐标 (3857):', center)
console.log('- 中心点坐标 (4326):', centerLonLat)
console.log('---')
})
规避重叠/相邻覆盖物处理
通过坐标对非运维区域添加矩形框

场景描述:线路中有段区域非运维区域,需要用矩形框圈出来
实现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)
},
}
}
图片中添加文本

// 运检站使用特殊的背景图片样式
const yjzName = feature.get('name')
// 背景图片样式(122*40像素)
const bgStyle = new Style({
image: new Icon({
src: self.getIcon(typeCode, showChecked),
anchor: [0.5, 0.5], // 中心锚点
anchorXUnits: 'fraction',
anchorYUnits: 'fraction',
scale: 1.0, // 保持原始尺寸122*40
crossOrigin: 'anonymous'
}),
geometry: new Point(offsetCoord),
zIndex: 436
})
styles.push(bgStyle)
// 在背景图片中心添加文本(固定大小,不适配)
const textStyle = new Style({
text: new OlText({
text: yjzName,
font: 'bold 14px Arial', // 固定字体大小,不进行适配
fill: new Fill({ color: '#000' }), // 黑色文字
// stroke: new Stroke({ color: '#fff', width: 1 }), // 白色描边
textAlign: 'center',
textBaseline: 'middle',
offsetX: 10, // 固定偏移10像素,不进行适配
offsetY: 0 // 垂直居中,不进行适配
}),
geometry: new Point(offsetCoord),
zIndex: 437 // 文字在背景图片之上
})
styles.push(textStyle)
其他属性使用记录
1. 禁用地图旋转功能,只保留缩放
禁用地图旋转功能,只保留缩放。在 OpenLayers 中,需要配置 interactions 来控制这个行为。
通过两个层面进行了配置:
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+拖拽旋转 | ❌ 禁用 | - | 已禁 |
| 鼠标滚轮缩放 | ✅ 可用 | - | 保留 |
| 拖拽平移 | ✅ 可用 | ✅ 可用 | 保留 |
| 双击放大 | ✅ 可用 | ✅ 可用 | 保留 |