OpenLayers + Vue2 离线地图实现笔记

lishihuan大约 17 分钟

OpenLayers + Vue2 离线地图实现笔记

api文档地址open in new window

https://download.csdn.net/blog/column/11055250/123442218open in new window

https://blog.csdn.net/m0_45127388/article/details/129529260open in new window

项目概述

基于 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

  • 每条线路数据放在一个 VectorSourceVectorLayer 里。
  • 给每个图层设置一个唯一 idname
  • 控件点击时,通过 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 带一个 idtype 属性。
  • 控制显示/隐藏时,通过 过滤器/样式函数来动态决定要不要画。

示例:

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('---')
    })

规避重叠/相邻覆盖物处理

处理方法

通过坐标对非运维区域添加矩形框

image-20250916101508902
image-20250916101508902

场景描述:线路中有段区域非运维区域,需要用矩形框圈出来

非运维区域汇总功能实现指南

实现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)
      },
	
	}
}

图片中添加文本

image-20250928100925719
image-20250928100925719
// 运检站使用特殊的背景图片样式
              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+拖拽旋转❌ 禁用-已禁
鼠标滚轮缩放✅ 可用-保留
拖拽平移✅ 可用✅ 可用保留
双击放大✅ 可用✅ 可用保留