OpenLayers 实战指南

lishihuan大约 55 分钟

OpenLayers 实战指南

当前是结合 OpenLayers离线地图OpenLayers_整理 2个笔记汇总的结果

基于 Vue2 + OpenLayers 9.2.4 的离线地图开发完整指南

本文档整合了 OpenLayers 基础知识与实战经验,面向业务地图开发

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

https://blog.csdn.net/tk08888/article/details/127053451open in new window


📚 目录


功能清单速查

本项目基于 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:展示人员/设备位置

场景 2:展示线路/管线

场景 3:展示区域范围

场景 4:海量数据展示

场景 5:轨迹回放

  • 轨迹动画 → 🔄 规划中(推荐使用定时器 + 更新坐标)
  • 路径模拟 → 🔄 规划中

场景 6:地图控制

按技术实现查找

图标相关

样式相关

交互相关

数据加载


🎓 学习路径建议

第 1 周:基础入门

第 2 周:交互进阶

第 3 周:业务实战

第 4 周:性能优化


💡 待开发功能清单

功能优先级预计工作量技术方案
轨迹动画2-3天定时器 + Feature 坐标更新 + requestAnimationFrame
分级设色专题图1-2天数值分段 + 样式函数 + 图例组件
3D 地图5-7天集成 Cesium 或 ol-cesium

🔗 外部资源链接


一、快速开始

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 支持多种瓦片服务接入方式,常用的有 WMTSXYZ 两种。

方式一: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'
    })
  })
}

方式对比

特性WMTSXYZ
标准化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 优化响应速度

❌ 避免做法

  • 同时监听 clicksingleclick(会冲突)
  • 不设置 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
实战决策表

核心原则

  1. 优先考虑一个图层(简单场景)
  2. 按业务逻辑分组(复杂场景)
  3. 避免过度分层(性能考虑)
场景控制需求数据量推荐方案理由
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)
何时必须分图层?

必须分图层的情况

  1. 渲染方式不同(最常见)

    // 普通点 vs 聚合点
    const normalLayer = new VectorLayer({ source: pointsSource })
    const clusterLayer = new VectorLayer({ 
      source: new Cluster({ source: pointsSource }) 
    })
    
  2. 有明确的业务分组,且需要整体控制

    // 例如:3-5个大类,每个大类需要整体显隐
    // 驿站图层、观冰站图层、青阳站图层
    
  3. 性能瓶颈(极少见)

    // 单图层 > 10000 要素,且样式函数复杂
    

不需要分图层的情况

  1. 只是颜色、样式不同

    // ❌ 不需要:10条线路,每条颜色不同
    // ✅ 用 Feature 的 color 属性控制即可
    
  2. 只是类型不同,但没有明确的业务分组

    // ❌ 不需要:50个小类,每个小类一个图层
    // ✅ 用 Feature 的 typeCode 属性控制即可
    
  3. 数据量少

    // ❌ 不需要: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个POI5000个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最好数据少,不重叠
Declutter100-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:非运维区域标注(矩形框)

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)
      },
	
	}
}
  • 下面的方式没尝试过,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:图片标注 + 文本叠加

image-20250928100925719
image-20250928100925719
// 场景:在图片图标上叠加文本(如设备名称)

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                 // 最大缩放级别
  })
}

六、参考资源


七、学习路径建议

第1周:基础地图 + 图层管理
第2周:点线面要素 + 样式
第3周:交互事件 + 高亮选中
第4周:聚合 + 热力图
第5周:行政区 + 专题图
第6周:性能优化 + 工程化封装

📌 本文档持续更新中...

最后更新:2025-02-09