openlayers+天地图实现gis点位、自定义点位、点聚合点位图层显示,以标出管网位置,点击展示详细信息、监控信息

41 阅读9分钟
  • 大屏基础界面:

image.png

  • 抠出江阴市,其他范围添加遮罩,默认打开其中个别开关 image.png 遮罩代码:
// 江阴边界填充图层
const boundarySource = new VectorSource({
  features: new GeoJSON().readFeatures(jiangyinArea.jiangyinArea, {
    featureProjection: 'EPSG:3857'
  })
})

const boundaryLayer = new VectorLayer({
  source: boundarySource,
  style: createBoundaryStyle,
  zIndex: 1 // 确保在底图之上,WMS图层之下
})

// 创建遮罩层
const maskLayer = createMaskLayer()
function createMaskLayer() {
  // 世界范围坐标 (EPSG:3857)
  const worldExtent = [-20037508.34, -20037508.34, 20037508.34, 20037508.34]

  // 创建世界范围多边形
  const worldPolygon = new Polygon([[
    [worldExtent[0], worldExtent[1]],
    [worldExtent[2], worldExtent[1]],
    [worldExtent[2], worldExtent[3]],
    [worldExtent[0], worldExtent[3]],
    [worldExtent[0], worldExtent[1]]
  ]])

  // 解析江阴区域GeoJSON
  const jiangyinFeatures = new GeoJSON().readFeatures(jiangyinArea.jiangyinBoundery, {
    featureProjection: 'EPSG:3857'
  })

  // 获取江阴区域坐标(只取第一个多边形)
  const jiangyinCoords = jiangyinFeatures[0].getGeometry().getCoordinates()

  // 创建遮罩Feature:在世界多边形中"挖空"江阴区域
  const maskFeature = new Feature({
    geometry: new Polygon([
      worldPolygon.getCoordinates()[0], // 外环:世界范围
      ...jiangyinCoords.map(ring => ring.slice().reverse()) // 内环:江阴区域(坐标需反转)
    ])
  })

  // 创建矢量图层
  return new VectorLayer({
    source: new VectorSource({ features: [maskFeature] }),
    style: new Style({
      fill: new Fill({
        color: 'rgba(6, 18, 50, 0.3)' // 半透明黑色遮罩
      }),
      stroke: new Stroke({
        color: 'transparent' // 隐藏边界线
      })
    }),
    zIndex: 1 // 确保在底图之上
  })
}
  • 共有三种图层,分别为gis server直接请求加载的瓦片、接口得到经纬度生成点位图层;点聚合图层。将不同类型放入不同图层,之后新加类型只需复制粘贴即可。聚合效果使用leaflet,聚合或散开会有动画效果,点击聚合点位也会有动画效果

image.png 核心代码及节流函数:

//点聚合样式
import * as L from 'leaflet'
import 'leaflet.markercluster'
import '@/assets/styles/MarkerCluster.css'
import AnimatedCluster from 'ol-ext/layer/AnimatedCluster'   // 图层
import ClusterSource from 'ol/source/Cluster'               // 真正的聚类源import * as olEasing from 'ol/easing'
import * as olEasing from 'ol/easing'
//点击聚合点后散开
import { extend as extendExtent, createEmpty } from 'ol/extent';
// 点聚合相关
//点聚合距离
const clusterDistance = ref(100);
//自定义聚合数据(接口获取)
let autoClusterMarkerList = {
  '报装':[],
  '排水户':[],
  '营销':[],
}
//自定义聚合源
let clusterSourceList = {
  '报装':null,
  '排水户':null,
  '营销':null,
}
//自定义聚合源图层
let clusterSourceLayerList = {
  '报装':null,
  '排水户':null,
  '营销':null,
}

//添加点标记到聚合图层
function generateClusPointsToLayer() {
  ['报装', '排水户', '营销'].forEach(key => {
    const source = clusterSourceList[key]          // 这就是 VectorSource
    if (!source) return

    source.clear()                                 // 直接清
    const arr = autoClusterMarkerList[key]
    if (!arr || !arr.length) return

    arr.forEach(item => {
      if (!item.latLng || !item.latLng.includes(',')) return
      const [lng, lat] = item.latLng.split(',').map(Number)
      const feature = new Feature({
        geometry: new Point(fromLonLat([lng, lat]))
      })
      feature.set('data', item)
      source.addFeature(feature)
    })
  })
}
//初始化地图
function initMap() {
  // 天地图key轮换 zq
  const tdtKeys = globalConfigs.getTdtKeys();
  // 计数器用localStorage
  let tdtKeyIndex = parseInt(localStorage.getItem('tdtKeyIndex'), 10);
  if (isNaN(tdtKeyIndex)) {
    tdtKeyIndex = 0;
  } else {
    tdtKeyIndex = (tdtKeyIndex + 1) % tdtKeys.length;
  }
  localStorage.setItem('tdtKeyIndex', tdtKeyIndex);
  const tdtKey = tdtKeys[tdtKeyIndex];
  // 天地图矢量底图服务URL,随机选择天地图二级域名 t0-t7
  const tdtSubdomain = Math.floor(Math.random() * 8)
  const tdtUrl = `https://t${tdtSubdomain}.tianditu.gov.cn/DataServer?T=img_w&x={x}&y={y}&l={z}&tk=${tdtKey}`
  const tdtLayer = new TileLayer({
    source: new XYZ({
      url: tdtUrl,
      minZoom: MIN_ZOOM,
      maxZoom: MAX_ZOOM,
      crossOrigin: 'anonymous', // 避免跨域导致瓦片加载异常
    }),
    cacheSize: 512 // 缓存512个瓦片
  })

  // 江阴边界填充图层
  const boundarySource = new VectorSource({
    features: new GeoJSON().readFeatures(jiangyinArea.jiangyinArea, {
      featureProjection: 'EPSG:3857'
    })
  })

  const boundaryLayer = new VectorLayer({
    source: boundarySource,
    style: createBoundaryStyle,
    zIndex: 1 // 确保在底图之上,WMS图层之下
  })

  // 创建遮罩层
  const maskLayer = createMaskLayer()
  function createMaskLayer() {
    // 世界范围坐标 (EPSG:3857)
    const worldExtent = [-20037508.34, -20037508.34, 20037508.34, 20037508.34]

    // 创建世界范围多边形
    const worldPolygon = new Polygon([[
      [worldExtent[0], worldExtent[1]],
      [worldExtent[2], worldExtent[1]],
      [worldExtent[2], worldExtent[3]],
      [worldExtent[0], worldExtent[3]],
      [worldExtent[0], worldExtent[1]]
    ]])

    // 解析江阴区域GeoJSON
    const jiangyinFeatures = new GeoJSON().readFeatures(jiangyinArea.jiangyinBoundery, {
      featureProjection: 'EPSG:3857'
    })

    // 获取江阴区域坐标(只取第一个多边形)
    const jiangyinCoords = jiangyinFeatures[0].getGeometry().getCoordinates()

    // 创建遮罩Feature:在世界多边形中"挖空"江阴区域
    const maskFeature = new Feature({
      geometry: new Polygon([
        worldPolygon.getCoordinates()[0], // 外环:世界范围
        ...jiangyinCoords.map(ring => ring.slice().reverse()) // 内环:江阴区域(坐标需反转)
      ])
    })

    // 创建矢量图层
    return new VectorLayer({
      source: new VectorSource({ features: [maskFeature] }),
      style: new Style({
        fill: new Fill({
          color: 'rgba(6, 18, 50, 0.3)' // 半透明黑色遮罩
        }),
        stroke: new Stroke({
          color: 'transparent' // 隐藏边界线
        })
      }),
      zIndex: 1 // 确保在底图之上
    })
  }

  // GeoServer WMS 图层 ---供水管网
  let curheight = 300;
  const wmsSource = new TileWMS({
    url: '/geoserver/work1/wms', // 通过代理转发
    params: {
      'SERVICE': 'WMS',
      'VERSION': '1.1.0',
      'REQUEST': 'GetMap',
      'LAYERS': 'work1:pipe_network',
      'STYLES': 'line_green',
      'FORMAT': 'image/png', // 必须用image/png或image/jpeg
      // 'FORMAT': 'application/json;type=utfgrid',
      'SRS': 'EPSG:3857',    // 1.1.1用SRS,1.3.0用CRS
      'TILED': false, // 必须为false,否则GeoServer可能返回空图
      'TRANSPARENT': true,
      // 'CQL_FILTER' 动态设置,初始值为300
      'CQL_FILTER': `height > ${curheight}`,
      // 'maxFeatures': 5000, //控制显示密度,随机过滤,而不是密度过滤
      // 'format_options': 'cluster:true;cluster_distance:60;cluster_max:5',  //仅适用于 application/vnd.mapbox-vector-tile 等矢量格式,对 PNG/JPG 栅格格式无效
      // 'CQL_FILTER': 'ST_Area(geom) > 10', // 过滤小面积要素,小短线,小面积图形
      // 'CQL_FILTER': 'Length(geom) >= 110', // 过滤小短线
      // BBOX参数由OpenLayers自动生成并拼接,无需手动设置
    },

    serverType: 'geoserver',
    tileGrid: new TileGrid({
      extent: [13356624.394745126, 3722943.2809553347, 13424618.821758036, 3757885.6374882166],  // EPSG:3857 全球范围
      resolutions: [
        611.49622628141,     // 级别 8
        305.748113140705,    // 级别 9
        152.8740565703525,   // 级别 10
        76.43702828517625,   // 级别 11
        38.21851414258813,   // 级别 12
        19.109257071294063,  // 级别 13
        9.554628535647032,    // 级别 14
        4.777314267823516,    // 级别 15
        2.388657133911758,    // 级别 16
        1.194328566955879,    // 级别 17
        0.5971642834779395,   // 级别 18
        0.29858214173896974,  // 级别 19
        0.14929107086948487,  // 级别 20
        0.0746455354243517,   // 级别 21
        0.0373227677121758,  // 级别 22
        0.0186613838560879, // 级别 23
      ]
    }),
    crossOrigin: 'anonymous'
    // projection: 'EPSG:3857', // OpenLayers 6+不需要此项,view的projection决定
  })
  // GeoServer WMS 图层 ---污水管网
  const wmsSource2 = new TileWMS({
    url: '/geoserver/work1/wms', // 通过代理转发
    params: {
      'SERVICE': 'WMS',
      'VERSION': '1.1.0',
      'REQUEST': 'GetMap',
      'LAYERS': 'work1:pipe_dirty',
      'STYLES': 'line_orange',
      'FORMAT': 'image/png', // 必须用image/png或image/jpeg
      // 'FORMAT': 'application/json;type=utfgrid',
      'SRS': 'EPSG:3857',    // 1.1.1用SRS,1.3.0用CRS
      'TILED': false, // 必须为false,否则GeoServer可能返回空图
      'TRANSPARENT': true,
      // 'CQL_FILTER' 动态设置,初始值为300
      'CQL_FILTER': `height > ${curheight} AND pipe_type = '污水管网'`,
      // 'maxFeatures': 5000, //控制显示密度,随机过滤,而不是密度过滤
      // 'format_options': 'cluster:true;cluster_distance:60;cluster_max:5',  //仅适用于 application/vnd.mapbox-vector-tile 等矢量格式,对 PNG/JPG 栅格格式无效
      // 'CQL_FILTER': 'ST_Area(geom) > 10', // 过滤小面积要素,小短线,小面积图形
      // 'CQL_FILTER': 'Length(geom) >= 110', // 过滤小短线
      // BBOX参数由OpenLayers自动生成并拼接,无需手动设置
    },

    serverType: 'geoserver',
    tileGrid: new TileGrid({
      extent: [13356624.394745126, 3722943.2809553347, 13424618.821758036, 3757885.6374882166],  // EPSG:3857 全球范围
      resolutions: [
        611.49622628141,     // 级别 8
        305.748113140705,    // 级别 9
        152.8740565703525,   // 级别 10
        76.43702828517625,   // 级别 11
        38.21851414258813,   // 级别 12
        19.109257071294063,  // 级别 13
        9.554628535647032,    // 级别 14
        4.777314267823516,    // 级别 15
        2.388657133911758,    // 级别 16
        1.194328566955879,    // 级别 17
        0.5971642834779395,   // 级别 18
        0.29858214173896974,  // 级别 19
        0.14929107086948487,  // 级别 20
        0.0746455354243517,   // 级别 21
        0.0373227677121758,  // 级别 22
        0.0186613838560879, // 级别 23
      ]
    }),
    crossOrigin: 'anonymous'
    // projection: 'EPSG:3857', // OpenLayers 6+不需要此项,view的projection决定
  })

  // GeoServer WMS 图层 ---污水管网
  const wmsSource3 = new TileWMS({
    url: '/geoserver/work1/wms', // 通过代理转发
    params: {
      'SERVICE': 'WMS',
      'VERSION': '1.1.0',
      'REQUEST': 'GetMap',
      'LAYERS': 'work1:pipe_dirty',
      'STYLES': 'line_blue',
      'FORMAT': 'image/png', // 必须用image/png或image/jpeg
      // 'FORMAT': 'application/json;type=utfgrid',
      'SRS': 'EPSG:3857',    // 1.1.1用SRS,1.3.0用CRS
      'TILED': false, // 必须为false,否则GeoServer可能返回空图
      'TRANSPARENT': true,
      // 'CQL_FILTER' 动态设置,初始值为300
      'CQL_FILTER': `height > ${curheight} AND pipe_type = '雨水管网'`,
      // 'maxFeatures': 5000, //控制显示密度,随机过滤,而不是密度过滤
      // 'format_options': 'cluster:true;cluster_distance:60;cluster_max:5',  //仅适用于 application/vnd.mapbox-vector-tile 等矢量格式,对 PNG/JPG 栅格格式无效
      // 'CQL_FILTER': 'ST_Area(geom) > 10', // 过滤小面积要素,小短线,小面积图形
      // 'CQL_FILTER': 'Length(geom) >= 110', // 过滤小短线
      // BBOX参数由OpenLayers自动生成并拼接,无需手动设置
    },

    serverType: 'geoserver',
    tileGrid: new TileGrid({
      extent: [13356624.394745126, 3722943.2809553347, 13424618.821758036, 3757885.6374882166],  // EPSG:3857 全球范围
      resolutions: [
        611.49622628141,     // 级别 8
        305.748113140705,    // 级别 9
        152.8740565703525,   // 级别 10
        76.43702828517625,   // 级别 11
        38.21851414258813,   // 级别 12
        19.109257071294063,  // 级别 13
        9.554628535647032,    // 级别 14
        4.777314267823516,    // 级别 15
        2.388657133911758,    // 级别 16
        1.194328566955879,    // 级别 17
        0.5971642834779395,   // 级别 18
        0.29858214173896974,  // 级别 19
        0.14929107086948487,  // 级别 20
        0.0746455354243517,   // 级别 21
        0.0373227677121758,  // 级别 22
        0.0186613838560879, // 级别 23
      ]
    }),
    crossOrigin: 'anonymous'
    // projection: 'EPSG:3857', // OpenLayers 6+不需要此项,view的projection决定
  })


  wmsLayer = new TileLayer({
    source: wmsSource,
    opacity: 1,
    cacheSize: 256, // 缓存256个WMS瓦片
    visible: false // 初始状态显示
  })
  wmsLayer.set('layerName', 'layerName01')

  wmsLayer2 = new TileLayer({
    source: wmsSource2,
    opacity: 1,
    cacheSize: 256, // 缓存256个WMS瓦片
    visible: false // 初始状态显示
  })
  wmsLayer2.set('layerName', 'layerName02')

  wmsLayer3 = new TileLayer({
    source: wmsSource3,
    opacity: 1,
    cacheSize: 256, // 缓存256个WMS瓦片
    visible: false // 初始状态显示
  })
  wmsLayer3.set('layerName', 'layerName03')

  //点位
  let markerSourceList = {
    '雨水井':null,
    '污水井':null,
    '阀门':null,
    '消防栓':null,
    '污水泵站':null,
    '污水厂范围':null,
    '污水厂实体':null,
  }

  function getMarkerSource(CQL_FILTER,STYLES,LAYERS){
    let params = {
      'SERVICE': 'WMS',
      'VERSION': '1.1.0',
      'REQUEST': 'GetMap',
      'FORMAT': 'image/png', // 必须用image/png或image/jpeg
      // 'FORMAT': 'application/json;type=utfgrid',
      'SRS': 'EPSG:3857',    // 1.1.1用SRS,1.3.0用CRS
      'TILED': false, // 必须为false,否则GeoServer可能返回空图
      'TRANSPARENT': true,
      // 'CQL_FILTER' 动态设置,初始值为300
      // 'maxFeatures': 5000, //控制显示密度,随机过滤,而不是密度过滤
      // 'format_options': 'cluster:true;cluster_distance:60;cluster_max:5',  //仅适用于 application/vnd.mapbox-vector-tile 等矢量格式,对 PNG/JPG 栅格格式无效
      // 'CQL_FILTER': 'ST_Area(geom) > 10', // 过滤小面积要素,小短线,小面积图形
      // 'CQL_FILTER': 'Length(geom) >= 110', // 过滤小短线
      // BBOX参数由OpenLayers自动生成并拼接,无需手动设置
    }
    if(CQL_FILTER){
      params.CQL_FILTER = CQL_FILTER
    }
    if(STYLES){
      params.STYLES = STYLES
    }
    if(LAYERS){
      params.LAYERS = LAYERS
    }
    return new TileWMS({
      url: '/geoserver/work1/wms', // 通过代理转发
      params: params,

      serverType: 'geoserver',
      tileGrid: new TileGrid({
        extent: [13356624.394745126, 3722943.2809553347, 13424618.821758036, 3757885.6374882166],  // EPSG:3857 全球范围
        resolutions: [
          611.49622628141,     // 级别 8
          305.748113140705,    // 级别 9
          152.8740565703525,   // 级别 10
          76.43702828517625,   // 级别 11
          38.21851414258813,   // 级别 12
          19.109257071294063,  // 级别 13
          9.554628535647032,    // 级别 14
          4.777314267823516,    // 级别 15
          2.388657133911758,    // 级别 16
          1.194328566955879,    // 级别 17
          0.5971642834779395,   // 级别 18
          0.29858214173896974,  // 级别 19
          0.14929107086948487,  // 级别 20
          0.0746455354243517,   // 级别 21
          0.0373227677121758,  // 级别 22
          0.0186613838560879, // 级别 23
        ]
      }),
      crossOrigin: 'anonymous'
      // projection: 'EPSG:3857', // OpenLayers 6+不需要此项,view的projection决定
    })
  }

  markerSourceList['雨水井'] =getMarkerSource('dno=10','point_rain','work1:dirty_point')
  markerSourceList['污水井'] =getMarkerSource('dno=13','point_sewage_well','work1:dirty_point')
  markerSourceList['阀门'] =getMarkerSource('','point_valve','work1:control')
  markerSourceList['消防栓'] =getMarkerSource('','point_fire_hyd','work1:fire_hyd')
  markerSourceList['污水泵站'] =getMarkerSource('SUBTYPE=2','point_dirty_pump','work1:ps_pump')
  markerSourceList['污水厂范围'] =getMarkerSource('','','work1:wsc_area')
  markerSourceList['污水厂实体'] =getMarkerSource('','','work1:wsc')


  function getMarkerLayer(source) {
    return new TileLayer({
      source: source,
      opacity: 1,
      cacheSize: 256, // 缓存256个WMS瓦片
      visible: false // 初始状态显示
    })
  }

  markerList['雨水井'] =getMarkerLayer(markerSourceList['雨水井'])
  markerList['污水井'] =getMarkerLayer(markerSourceList['污水井'])
  markerList['阀门'] =getMarkerLayer(markerSourceList['阀门'])
  markerList['消防栓'] =getMarkerLayer(markerSourceList['消防栓'])
  markerList['污水泵站'] =getMarkerLayer(markerSourceList['污水泵站'])
  markerList['污水厂范围'] =getMarkerLayer(markerSourceList['污水厂范围'])
  markerList['污水厂实体'] =getMarkerLayer(markerSourceList['污水厂实体'])


  markerList['雨水井'].set('layerName', '雨水井')
  markerList['污水井'].set('layerName', '污水井')
  markerList['阀门'].set('layerName', '阀门')
  markerList['消防栓'].set('layerName', '消防栓')
  markerList['污水泵站'].set('layerName', '污水泵站')
  markerList['污水厂范围'].set('layerName', '污水厂范围')
  markerList['污水厂实体'].set('layerName', '污水厂实体')

  autoMarkerLayerList['电导率'] = new VectorLayer({
    source: new VectorSource(),
  })
  autoMarkerLayerList['水厂'] = new VectorLayer({
    source: new VectorSource(),
  })
  autoMarkerLayerList['水源地'] = new VectorLayer({
    source: new VectorSource(),
  })
  autoMarkerLayerList['加压站'] = new VectorLayer({
    source: new VectorSource(),
  })
  autoMarkerLayerList['二供工单'] = new VectorLayer({
    source: new VectorSource(),
  })
  autoMarkerLayerList['高品质水'] = new VectorLayer({
    source: new VectorSource(),
  })
  autoMarkerLayerList['直饮水机'] = new VectorLayer({
    source: new VectorSource(),
  })
  autoMarkerLayerList['消防压力'] = new VectorLayer({
    source: new VectorSource(),
  })
  autoMarkerLayerList['工程项目'] = new VectorLayer({
    source: new VectorSource(),
  })

  //点聚合图层
  clusterSourceList['报装'] = new VectorSource();
  clusterSourceList['排水户'] = new VectorSource();
  clusterSourceList['营销'] = new VectorSource();

  // 创建聚合数据源
  const animOptions = {
    animationDuration: 600,
    easing: olEasing.easeOut,
    spread: 36
  }
  // 创建聚合图层样式
  const clusterStyle = (feature) => {
    const features = feature.get('features') // AnimatedCluster 里一定存在
    const size = features.length
    const item = features[0].get('data')
    const markerImg = item.markerImg

    // 单点:直接返回图标,但保留爆炸能力
    if (size === 1) {
      return new Style({
        image: new Icon({
          anchor: [0.5, 0.5],
          src: markerImg,
          scale: 0.4,
          zIndex: 9999
        })
      })
    }

    // 聚合圈
    const radius = Math.min(20 + Math.sqrt(size) * 5, 40)
    const color =
        size > 50 ? '#d32f2f' : size > 20 ? '#f57c00' : size > 5 ? '#1976d2' : '#388e3c'

    return new Style({
      image: new Circle({
        radius,
        fill: new Fill({ color: color + '80' }),
        stroke: new Stroke({ color: '#fff', width: 3 })
      }),
      text: new Text({
        text: size.toString(),
        fill: new Fill({ color: '#fff' }),
        font: 'bold 16px sans-serif'
      })
    })
  }
  /* 3. 直接创建 ol-ext 的 AnimatedCluster 图层(它内部会自己包 ClusterSource) */
  const clusterLayerList = {
    报装: new AnimatedCluster({
      name: '报装',
      source: new ClusterSource({
        source: clusterSourceList['报装'],
        distance: clusterDistance.value
      }),
      ...animOptions,
      style: clusterStyle
    }),
    排水户: new AnimatedCluster({
      name: '排水户',
      source: new ClusterSource({
        source: clusterSourceList['排水户'],
        distance: clusterDistance.value
      }),
      ...animOptions,
      style: clusterStyle
    }),
    营销: new AnimatedCluster({
      name: '营销',
      source: new ClusterSource({
        source: clusterSourceList['营销'],
        distance: clusterDistance.value
      }),
      ...animOptions,
      style: clusterStyle
    })
  }




  //坐标系转换
  const extent4326 = [119.75487990670948, 31.606645090235276, 120.86752346270671, 32.08937971257144];
  const extent3857 = transformExtent(extent4326, 'EPSG:4326', 'EPSG:3857');

  //创建地图
  map = new Map({
    target: 'ol-map',
    layers: [
      tdtLayer,
      boundaryLayer, // 添加边界图层
      maskLayer,//江阴以外的遮罩
      wmsLayer,
      wmsLayer2,
      wmsLayer3,
      markerList['雨水井'],
      markerList['污水井'],
      markerList['阀门'],
      markerList['消防栓'],
      markerList['污水泵站'],
      markerList['污水厂范围'],
      markerList['污水厂实体'],
      autoMarkerLayerList['电导率'],
      autoMarkerLayerList['水厂'],
      autoMarkerLayerList['水源地'],
      autoMarkerLayerList['加压站'],
      autoMarkerLayerList['二供工单'],
      autoMarkerLayerList['高品质水'],
      autoMarkerLayerList['直饮水机'],
      autoMarkerLayerList['消防压力'],
      autoMarkerLayerList['工程项目'],
      clusterLayerList['报装'],
      clusterLayerList['排水户'],
      clusterLayerList['营销']
    ],
    view: new View({
      center: fromLonLat(lnglatCenter), // fromLonLat 默认转换为 EPSG:3857
      zoom: MIN_ZOOM,
      minZoom: MIN_ZOOM,
      maxZoom: MAX_ZOOM,
      projection: 'EPSG:3857',
      // 添加以下属性限制拖动范围
      extent: extent3857,
      constrainOnlyCenter: true, // 只限制中心点,不限制整个视图
      constrainResolution: false, // 可选:限制分辨率不能超出范围
      smoothExtentConstraint: true // 平滑约束
    }),
    interactions: defaultInteractions({
      // 启用以鼠标位置为中心的缩放
      mouseWheelZoom: {
        duration: 250,
        constrainResolution: false, // 允许中间缩放级别
        onFocusOnly: false // 确保不需要聚焦也能缩放
      }
    }),
    controls: defaultControls().extend([new FullScreen(), new ScaleLine()]),
  })

  // 节流处理的WMS参数更新函数
  const updateWmsParams = throttle((zoom) => {
    try {
      let curheight = 300;
      if (zoom >= 12 && zoom < 13) curheight = 250;
      else if (zoom >= 13 && zoom < 14) curheight = 200;
      else if (zoom >= 14 && zoom < 15) curheight = 150;
      else if (zoom >= 15 && zoom < 16) curheight = 100;
      else if (zoom >= 16 && zoom < 17) curheight = 50;
      else if (zoom >= 17 && zoom <= 18) curheight = 0;
      else if (zoom < 12) curheight = 300;

      // 确保WMS源存在且有效
      if (wmsSource && typeof wmsSource.updateParams === 'function') {
        wmsSource.updateParams({
          'CQL_FILTER': `height > ${curheight}`
        });
      }
      if (wmsSource2 && typeof wmsSource2.updateParams === 'function') {
        wmsSource2.updateParams({
          'CQL_FILTER': `height > ${curheight} AND pipe_type = '污水管网'`
        });
      }
      if (wmsSource3 && typeof wmsSource3.updateParams === 'function') {
        wmsSource3.updateParams({
          'CQL_FILTER': `height > ${curheight} AND pipe_type = '雨水管网'`
        });
      }
      updateAutoMarkerLayerList();
    } catch (error) {
      console.warn('WMS参数更新失败:', error);
    }
  }, 50); // 50ms节流,确保及时更新

  // 合并的事件监听器:处理缩放限制和WMS参数更新,更新管线详情弹框位置
  map.getView().on('change:resolution', function () {
    try {
      const view = map.getView()
      const zoom = view.getZoom()

      // 节流更新WMS参数
      updateWmsParams(zoom)

      if (popupVisible.value && coordinate) {
        updatePopupPosition(coordinate);
      }
    } catch (error) {
      console.error('地图缩放事件处理失败:', error);
    }
  })

  // 地图图层实例点击事件
  const handleMapClick = throttle(async function (evt) {
    /* 0. 清理上一次弹窗状态 */
    currentFeature = null
    popupTypes.value = []
    isProjectPopup.value = false
    coordinate = evt.coordinate

    /* 1. 优先处理:是否点到了聚合簇 */
    let hitCluster = false
    map.forEachFeatureAtPixel(evt.pixel, (f, l) => {
      const features = f.get('features')
      if (features && features.length > 1 && ['报装', '排水户', '营销'].includes(l?.get('name'))) {
        hitCluster = true
        f.set('select', true)
        const extent = features.reduce(
            (acc, ft) => {
              extendExtent(acc, ft.getGeometry().getExtent());
              return acc;
            },
            createEmpty()          // ← 初始值是一个“空范围”
        );
        /* 下一帧再执行缩放,避免阻塞当前事件循环 */
        requestAnimationFrame(() =>
            map.getView().fit(extent, { padding: [60, 60, 60, 60], maxZoom: 17 })
        )
      }
    })
    /* 只要用于打散,就直接 return,不往下走任何弹窗逻辑 */
    if (hitCluster) return

    /* 2. 下方保持你原来的单点 / WMS 弹窗逻辑,无任何改动 */
    if (!wmsLayer.getVisible() && !wmsLayer2.getVisible() && !wmsLayer3.getVisible() &&
        !activeButtons.value.includes('阀门') &&
        !activeButtons.value.includes('消防栓') &&
        !activeButtons.value.includes('二供工单') &&
        !activeButtons.value.includes('高品质水') &&
        !activeButtons.value.includes('直饮水机') &&
        !activeButtons.value.includes('报装') &&
        !activeButtons.value.includes('营销') &&
        !activeButtons.value.includes('消防压力') &&
        !activeButtons.value.includes('工程项目') &&
        !activeButtons.value.includes('加压站') &&
        !activeButtons.value.includes('水厂') &&
        !activeButtons.value.includes('水源地') &&
        !activeButtons.value.includes('雨水井') &&
        !activeButtons.value.includes('污水井') &&
        !activeButtons.value.includes('电导率') &&
        !activeButtons.value.includes('排水户') &&
        !activeButtons.value.includes('污水泵站') &&
        !activeButtons.value.includes('污水厂')) {
      return
    }

    const viewResolution = map.getView().getResolution()
    coordinate = evt.coordinate

    if (activeButtons.value.includes('加压站')) {
      const destfeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === autoMarkerLayerList['加压站']) return { feature, layer }
      })
      if (destfeatureInfo) {
        currentFeature = destfeatureInfo.feature
        const data = currentFeature.get('data')
        popupTypes.value = ['加压站']
        markerInfo.value = `
<div class="info-card">
  <div class="info-center">
    <span class="info-address-value">${data.waterworksName}</span>
  </div>
</div>`
        updatePopupPosition(coordinate)
        popupVisible.value = true
        return
      }
    }

    if (activeButtons.value.includes('水厂')) {
      const destfeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === autoMarkerLayerList['水厂']) return { feature, layer }
      })
      if (destfeatureInfo) {
        currentFeature = destfeatureInfo.feature
        const data = currentFeature.get('data')
        popupTypes.value = ['水厂']
        markerInfo.value = `
<div class="info-card">
  <div class="info-center">
    <span class="info-address-value">${data.waterworksName}</span>
  </div>
</div>`
        updatePopupPosition(coordinate)
        popupVisible.value = true
        return
      }
    }

    if (activeButtons.value.includes('水源地')) {
      const destfeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === autoMarkerLayerList['水源地']) return { feature, layer }
      })
      if (destfeatureInfo) {
        currentFeature = destfeatureInfo.feature
        const data = currentFeature.get('data')
        popupTypes.value = ['水源地']
        markerInfo.value = `
<div class="info-card">
  <div class="info-center">
    <span class="info-address-value">${data.waterworksName}</span>
  </div>
</div>`
        updatePopupPosition(coordinate)
        popupVisible.value = true
        return
      }
    }

    if (activeButtons.value.includes('二供工单')) {
      const destfeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === autoMarkerLayerList['二供工单']) return { feature, layer }
      })
      if (destfeatureInfo) {
        currentFeature = destfeatureInfo.feature
        const data = currentFeature.get('data')
        popupTypes.value = ['二供工单']
        markerInfo.value = `
<div class="info-card">
  <div class="info-center"><span class="info-center-name">状态:</span><span class="info-address-value">${data.status}</span></div>
  <div class="info-center"><span class="info-center-name">泵房:</span><span class="info-address-value">${data.executor}</span></div>
  <div class="info-address"><span class="info-center-name">类型:</span><span class="info-address-value">${data.processType}</span></div>
</div>`
        updatePopupPosition(coordinate)
        popupVisible.value = true
        return
      }
    }

    if (activeButtons.value.includes('高品质水')) {
      const destfeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === autoMarkerLayerList['高品质水']) return { feature, layer }
      })
      if (destfeatureInfo) {
        currentFeature = destfeatureInfo.feature
        const data = currentFeature.get('data')
        popupTypes.value = ['高品质水']
        markerInfo.value = `
<div class="info-card">
  <div class="info-center"><span class="info-center-name">设备名称:</span><span class="info-address-value">${data.equipmentName}</span></div>
  <div class="info-center"><span class="info-center-name">状态:</span><span class="info-address-value">${data.equipmentStatus_dict}</span></div>
  <div class="info-address"><span class="info-center-name">设备编码:</span><span class="info-address-value">${data.equipmentCode}</span></div>
  <div class="info-address"><span class="info-center-name">设备地址:</span><span class="info-address-value">${data.equipmentAddress}</span></div>
</div>`
        updatePopupPosition(coordinate)
        popupVisible.value = true
        return
      }
    }

    if (activeButtons.value.includes('直饮水机')) {
      const destfeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === autoMarkerLayerList['直饮水机']) return { feature, layer }
      })
      if (destfeatureInfo) {
        currentFeature = destfeatureInfo.feature
        const data = currentFeature.get('data')
        popupTypes.value = ['直饮水机']
        markerInfo.value = `
<div class="info-card">
  <div class="info-center"><span class="info-center-name">设备名称:</span><span class="info-address-value">${data.equipmentName}</span></div>
  <div class="info-center"><span class="info-center-name">状态:</span><span class="info-address-value">${data.equipmentStatus_dict}</span></div>
  <div class="info-address"><span class="info-center-name">设备编码:</span><span class="info-address-value">${data.equipmentCode}</span></div>
  <div class="info-address"><span class="info-center-name">设备地址:</span><span class="info-address-value">${data.equipmentAddress}</span></div>
</div>`
        updatePopupPosition(coordinate)
        popupVisible.value = true
        return
      }
    }

    if (activeButtons.value.includes('报装')) {
      const destFeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === clusterLayerList['报装']) return { feature, layer }
      })
      if (destFeatureInfo) {
        currentFeature = destFeatureInfo.feature
        const realFeatures = currentFeature.get('features')
        const data = realFeatures[0].get('data')
        popupTypes.value = ['报装']
        markerInfo.value = `
<div class="info-card">
  <div class="info-center"><span class="info-center-name">项目编号:</span><span class="info-address-value">${data.projno}</span></div>
  <div class="info-center"><span class="info-center-name">项目名称:</span><span class="info-address-value">${data.projname}</span></div>
  <div class="info-address"><span class="info-center-name">设备地址:</span><span class="info-address-value">${data.projaddress}</span></div>
</div>`
        updatePopupPosition(coordinate)
        popupVisible.value = true
        return
      }
    }

    if (activeButtons.value.includes('营销')) {
      const destFeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === clusterLayerList['营销']) return { feature, layer }
      })
      if (destFeatureInfo) {
        currentFeature = destFeatureInfo.feature
        const realFeatures = currentFeature.get('features')
        const data = realFeatures[0].get('data')
        popupTypes.value = ['营销']
        markerInfo.value = `
<div class="info-card">
  <div class="info-center"><span class="info-center-name">主键:</span><span class="info-address-value">${data.id}</span></div>
  <div class="info-center"><span class="info-center-name">反映地址:</span><span class="info-address-value">${data.reflectaddress}</span></div>
  <div class="info-address"><span class="info-center-name">反映详情:</span><span class="info-address-value">${data.reflectdetail}</span></div>
  <div class="info-center"><span class="info-center-name">反映用户:</span><span class="info-address-value">${data.reflectuser}</span></div>
  <div class="info-address"><span class="info-center-name">咨询主题:</span><span class="info-address-value">${data.serverLarge}</span></div>
  <div class="info-center"><span class="info-center-name">咨询类型:</span><span class="info-address-value">${data.serverSmall}</span></div>
  <div class="info-address"><span class="info-center-name">反映中心:</span><span class="info-address-value">${data.treatinst}</span></div>
  <div class="info-address"><span class="info-center-name">条目备注:</span><span class="info-address-value">${data.entryremark}</span></div>
</div>`
        updatePopupPosition(coordinate)
        popupVisible.value = true
        return
      }
    }

    if (activeButtons.value.includes('排水户')) {
      const destFeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === clusterLayerList['排水户']) return { feature, layer }
      })
      if (destFeatureInfo) {
        currentFeature = destFeatureInfo.feature
        const realFeatures = currentFeature.get('features')
        const data = realFeatures[0].get('data')
        popupTypes.value = ['排水户']
        markerInfo.value = `
<div class="info-card">
  <div class="info-center"><span class="info-center-name">排水户名称:</span><span class="info-address-value">${data.name}</span></div>
  <div class="info-center"><span class="info-center-name">所属区域:</span><span class="info-address-value">${data.geom}</span></div>
  <div class="info-address"><span class="info-center-name">详细地址:</span><span class="info-address-value">${data.address}</span></div>
  <div class="info-address"><span class="info-center-name">排水性质:</span><span class="info-address-value">${data.nature}</span></div>
  <div class="info-address"><span class="info-center-name">是否重点排水户:</span><span class="info-address-value">${data.isSewUser}</span></div>
  <div class="info-address"><span class="info-center-name">行业类型:</span><span class="info-address-value">${data.type}</span></div>
  <div class="info-address"><span class="info-center-name">证书:</span><span class="info-address-value">${data.issue}</span></div>
  <div class="info-address"><span class="info-center-name">证书总数:</span><span class="info-address-value">${data.issueTotal}</span></div>
  <div class="info-center"><span class="info-center-name">工业废水排放量:</span><span class="info-address-value">${data.industryAmount}</span></div>
  <div class="info-center"><span class="info-center-name">污水排放总量:</span><span class="info-address-value">${data.dirtyAmount}</span></div>
  <div class="info-address"><span class="info-center-name">消防用水量:</span><span class="info-address-value">${data.xiaofangAmount}</span></div>
  <div class="info-address"><span class="info-center-name">生产用水量:</span><span class="info-address-value">${data.produceAmount}</span></div>
  <div class="info-address"><span class="info-center-name">生活污水排放量:</span><span class="info-address-value">${data.lifeDirtyAmount}</span></div>
  <div class="info-address"><span class="info-center-name">生活用水量:</span><span class="info-address-value">${data.lifeAmount}</span></div>
  <div class="info-address"><span class="info-center-name">申请表用水量:</span><span class="info-address-value">${data.applyAmount}</span></div>
  <div class="info-address"><span class="info-center-name">备注:</span><span class="info-address-value">${data.remark}</span></div>
</div>`
        updatePopupPosition(coordinate)
        popupVisible.value = true
        return
      }
    }

    if (activeButtons.value.includes('消防压力')) {
      const destfeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === autoMarkerLayerList['消防压力']) return { feature, layer }
      })
      if (destfeatureInfo) {
        currentFeature = destfeatureInfo.feature
        const data = currentFeature.get('data')
        if (xiaofangDialog.value) xiaofangDialog.value.show(data)
        return
      }
    }

    if (activeButtons.value.includes('工程项目')) {
      const destfeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === autoMarkerLayerList['工程项目']) return { feature, layer }
      })
      if (destfeatureInfo) {
        currentFeature = destfeatureInfo.feature
        const data = currentFeature.get('data')
        popupTypes.value = ['工程项目']
        popupComponent.value = markRaw(ProjectPopup)
        popupProps.value = { data }
        updatePopupPosition(coordinate)
        popupVisible.value = true
        return
      }
    }

    if (activeButtons.value.includes('电导率')) {
      const destfeatureInfo = map.forEachFeatureAtPixel(evt.pixel, (feature, layer) => {
        if (layer === autoMarkerLayerList['电导率']) return { feature, layer }
      })
      if (destfeatureInfo) {
        currentFeature = destfeatureInfo.feature
        const data = currentFeature.get('data')
        if (conductivityDialog.value) conductivityDialog.value.show(data)
        return
      }
    }

    /* -------- 以下 WMS GetFeatureInfo 部分也完全保持原样 -------- */
    function getUrl(wmsSourceType, QUERY_LAYERS) {
      return wmsSourceType.getFeatureInfoUrl(
          coordinate,
          viewResolution,
          'EPSG:3857',
          {
            REQUEST: 'GetFeatureInfo',
            INFO_FORMAT: 'application/json',
            QUERY_LAYERS,
            FEATURE_COUNT: 1
          }
      )
    }

    const urls = []
    const markerList = []

    if (wmsLayer.getVisible()) {
      urls.push({ url: getUrl(wmsSource, 'work1:pipe_network'), type: 'type1' })
    }
    if (wmsLayer2.getVisible()) {
      urls.push({ url: getUrl(wmsSource2, 'work1:pipe_dirty'), type: 'type2' })
    }
    if (wmsLayer3.getVisible()) {
      urls.push({ url: getUrl(wmsSource3, 'work1:pipe_dirty'), type: 'type3' })
    }
    if (activeButtons.value.includes('阀门')) {
      markerList.push({ url: getUrl(markerSourceList['阀门'], 'work1:control'), type: '阀门' })
    }
    if (activeButtons.value.includes('消防栓')) {
      markerList.push({ url: getUrl(markerSourceList['消防栓'], 'work1:fire_hyd'), type: '消防栓' })
    }
    if (activeButtons.value.includes('雨水井')) {
      markerList.push({ url: getUrl(markerSourceList['雨水井'], 'work1:dirty_point'), type: '雨水井' })
    }
    if (activeButtons.value.includes('污水井')) {
      markerList.push({ url: getUrl(markerSourceList['污水井'], 'work1:dirty_point'), type: '污水井' })
    }
    if (activeButtons.value.includes('污水泵站')) {
      markerList.push({ url: getUrl(markerSourceList['污水泵站'], 'work1:ps_pump'), type: '污水泵站' })
    }
    if (activeButtons.value.includes('污水厂')) {
      markerList.push({ url: getUrl(markerSourceList['污水厂范围'], 'work1:wsc_area'), type: '污水厂范围' })
      markerList.push({ url: getUrl(markerSourceList['污水厂实体'], 'work1:wsc'), type: '污水厂实体' })
    }

    handlerUrl(urls, markerList)
  }, 50)
  const handlerUrl = async (urls,markerList) => {
    //字典
    const getLabel = (value) => {
      const item = subtype.value.find(opt => opt.value == value);
      return item ? item.label : value; // 找不到时显示原值
    };
    try {
      // 遍历所有管线
      for (let i=0;i<urls.length;i++) {
        if (!urls[i].url) continue; // 跳过无效URL
        const response = await fetch(urls[i].url);
        const data = await response.json();
        // 检查是否有要素信息
        if (data && data.features && data.features.length > 0) {
          const feature = data.features[0];
          let height
          let material
          let geo_length
          let assetstrin
          if(urls[i].type == 'type1'){
            height = feature.properties?.height ?? '--'
            material = feature.properties?.material ?? '--'
            geo_length = feature.properties?.geo_length ?? '--'
            assetstrin = feature.properties?.assetstrin ?? '--'
          }else if(urls[i].type == 'type2'){
            height = feature.properties?.height ?? '--'
            material = feature.properties['材质'] || '--'
            geo_length = feature.properties['管长'] || '--'
            assetstrin = feature.properties['所在道'] || '--'
          }else if(urls[i].type == 'type3'){
            height = feature.properties?.height ?? '--'
            material = feature.properties['材质'] || '--'
            geo_length = feature.properties['管长'] || '--'
            assetstrin = feature.properties['所在道'] || '--'
          }
          // 显示弹窗(使用对应图层的属性)
          markerInfo.value = `
<div class="info-card">
  <!-- 管径 -->
  <div class="info-center">
    <span class="info-center-name">管径:</span>
    <span class="info-center-value">
      ${height}<span style="font-size: 12px; color: #b8d4ff; margin-left: 4px;">mm</span>
    </span>
  </div>

  <!-- 材质 -->
  <div class="info-center">
    <span class="info-center-name">材质:</span>
    <span class="info-center-value">${material}</span>
  </div>

  <!-- 长度 -->
  <div class="info-center">
    <span class="info-center-name">长度:</span>
    <span class="info-center-value">
      ${geo_length}<span style="font-size: 12px; color: #b8d4ff; margin-left: 4px;">m</span>
    </span>
  </div>

  <!-- 所在地 -->
  <div class="info-address">
    <span class="info-center-name">所在地:</span>
    <span class="info-address-value">${assetstrin}</span>
  </div>
</div>
`;
          updatePopupPosition(coordinate); // 更新弹窗位置
          popupVisible.value = true;
          return
        }
      }
      //遍历标记
      for (const marker of markerList) {
        if (!marker.url) continue; // 跳过无效URL
        const response = await fetch(marker.url);
        const data = await response.json();
        // 检查是否有要素信息
        if (data && data.features && data.features.length > 0) {
          const feature = data.features[0];
          if(marker.type === '阀门'){
            markerInfo.value = `
<div class="info-card">
  <!-- 所在地 -->
  <div class="info-address">
    <span class="info-center-name">所在地:</span>
    <span class="info-address-value">${feature.properties?.ADDRESS ?? '--'}</span>
  </div>

  <!-- 阀门类型 -->
  <div class="info-center">
    <span class="info-center-name">阀门类型:</span>
    <span class="info-center-value">
      ${getLabel(feature.properties.SUBTYPE)}
    </span>
  </div>

  <!-- 符号角度 -->
  <div class="info-angle">
    <span class="info-center-name">符号角度:</span>
    <span class="angle-value">
      ${feature.properties?.ANGLE ?? '--'}
    </span>
  </div>
</div>
`;
          }
          if(marker.type === '消防栓'){
            markerInfo.value = `
<div class="info-card">
  <div class="info-address">
    <span class="info-center-name">所在地:</span>
    <span class="info-address-value">${feature.properties?.ADDRESS ?? '--'}</span>
  </div>

  <div class="info-center">
    <span class="info-center-name">工程名称:</span>
    <span class="info-center-value">
      ${getLabel(feature.properties.PROJECTNAM)}
    </span>
  </div>

  <div class="info-center">
    <span class="info-center-name">消防栓类型:</span>
    <span class="info-center-value">
      ${xiaofangshuanDict(feature.properties.SUBTYPE)}
    </span>
  </div>

  <!-- 符号角度 -->
  <div class="info-angle">
    <span class="info-center-name">符号角度:</span>
    <span class="angle-value">
      ${feature.properties?.ANGLE ?? '--'}
    </span>
  </div>
</div>
`;
          }

          if(marker.type === '雨水井' || marker.type === '污水井'){
            markerInfo.value = `
<div class="info-card">
  <div class="info-address">
    <span class="info-center-name">所在道:</span>
    <span class="info-address-value">${feature.properties['所在道'] || ''}</span>
  </div>

  <div class="info-center">
    <span class="info-center-name">权属单:</span>
    <span class="info-center-value">
      ${feature.properties['权属单'] || ''}
    </span>
  </div>

  <div class="info-center">
    <span class="info-center-name">测绘单:</span>
    <span class="info-center-value">
      ${feature.properties['测绘单'] || ''}
    </span>
  </div>

  <!-- 符号角度 -->
  <div class="info-angle">
    <span class="info-center-name">符号角度:</span>
    <span class="angle-value">
      ${feature.properties?.angle ?? '--'}
    </span>
  </div>
</div>
`;
          }

          if(marker.type === '污水泵站'){
            markerInfo.value = `
<div class="info-card">
  <div class="info-address">
    <span class="info-center-name">泵站名称:</span>
    <span class="info-address-value">${feature.properties['NAME'] || ''}</span>
  </div>

  <div class="info-address">
    <span class="info-center-name">探测单位:</span>
    <span class="info-address-value">${feature.properties['REPORTDEPT'] || ''}</span>
  </div>

   <div class="info-angle">
    <span class="info-center-name">符号角度:</span>
    <span class="angle-value">
      ${feature.properties?.ANGLE ?? '--'}
    </span>
  </div>
</div>
`;
          }

          if(marker.type === '污水厂范围'){
            markerInfo.value = `
<div class="info-card">
  <div class="info-address">
    <span class="info-center-name">污水厂名称:</span>
    <span class="info-address-value">${feature.properties['NAME'] || ''}</span>
  </div>
</div>
`;
          }

          updatePopupPosition(coordinate); // 更新弹窗位置
          popupVisible.value = true;
          return
        }
      }
      // 所有图层都无结果时关闭弹窗
      popupVisible.value = false;
    } catch (error) {
      popupVisible.value = false;
    }
  }

  //绑定事件
  map.un('singleclick', handleMapClick);
  map.on('singleclick', handleMapClick);
  // 点击地图输出经纬度坐标
  map.on('singleclick', function (evt) {
    // evt.coordinate 是投影坐标(EPSG:3857),需要转换为经纬度
    const lonLat = toLonLat(evt.coordinate);
    console.log('点击位置经纬度:', lonLat);
  });

// 在点击地图其他地方关闭弹窗时也清除高亮
  map.on('pointerdown', function (evt) {
    // 如果不是单击(即不是singleclick事件),关闭弹窗并清除高亮
    if (!evt.originalEvent || evt.originalEvent.type !== 'click') {
      popupVisible.value = false;
    }
  });
}
  • 自定义组件,可嵌入监控

image.png

//  装载组件
popupComponent.value = markRaw(ProjectPopup)

子组件代码:

<template>
  <div class="info-card" style="max-width:400px">
    <div class="info-center">
      <span class="info-center-name">项目名称:</span>
      <span class="info-address-value">{{ data.projectName }}</span>
    </div>

    <div class="info-center">
      <span class="info-center-name">项目编号:</span>
      <span class="info-address-value">{{ data.projectCode }}</span>
    </div>

    <div class="info-address">
      <span class="info-center-name">项目类型:</span>
      <span class="info-address-value">{{ data.projectType }}</span>
    </div>

    <div class="info-address">
      <span class="info-center-name">工单类型:</span>
      <span class="info-address-value">{{ data.flowId }}</span>
    </div>

    <div class="info-address">
      <span class="info-center-name">项目阶段:</span>
      <span class="info-address-value">{{ projectPeriodLabel }}</span>
    </div>

    <div class="info-address">
      <span class="info-center-name">项目负责人:</span>
      <span class="info-address-value">{{ data.managerUser }}</span>
    </div>

    <div class="info-address">
      <span class="info-center-name">开始时间:</span>
      <span class="info-address-value">{{ data.planStartTime }}</span>
    </div>

    <div class="info-address">
      <span class="info-center-name">结束时间:</span>
      <span class="info-address-value">{{ data.planEndTime }}</span>
    </div>

    <div class="info-address">
      <span class="info-center-name">联系电话:</span>
      <span class="info-address-value">{{ data.managerPhone }}</span>
    </div>

    <div class="info-address">
      <span class="info-center-name">经纬度:</span>
      <span class="info-address-value">{{ data.latLng }}</span>
    </div>

    <!-- 视频 -->
    <div class="video-overlay" @click="showVideo"></div>
    <iframe
        v-if="videoUrl"
        :src="videoUrl"
        style="width:320px;height:200px;overflow:hidden;border:0;margin-top:8px"
        loading="lazy"
        allowfullscreen
        @load="onIframeLoad"
    />
    <video-dialog ref="videoDialog" :title-size="'font-size:24px'"></video-dialog>
  </div>
</template>

<script setup>
import { ref, onMounted, computed } from 'vue'
import { getMonitorUrl } from '@/api/screen/map'
import { globalConfigs } from '@/utils/globalConfigs'
import VideoDialog from "./VideoDialog.vue";

// 字典
const { proxy } = getCurrentInstance();
const { pm_project_period } = proxy.useDict('pm_project_period')

const props = defineProps({
  data: { type: Object, required: true }
})

const videoDialog = ref()
const msg = ref('')
let iframeLoaded = false
//加载完成
function onIframeLoad(){
  iframeLoaded = true
}
//弹层
function showVideo() {
  if(!iframeLoaded) return
  setTimeout(()=>{
    let item = {
      msg:msg.value,
      monitorName:props.data.projectName
    }
    if(videoDialog.value) videoDialog.value.show(item)
  },100)
}

const videoUrl = ref('')
const ipUrl = globalConfigs.monitorUrl()
const monitorIpUrl = globalConfigs.monitorIpUrl()

const projectPeriodLabel = computed(() => {
  const val = pm_project_period.value.find(
      item => item.value === props.data.projectPeriod
  )
  return val?.label ?? props.data.projectPeriod
})

onMounted(async () => {
  try {
    msg.value = ''
    const res = await getMonitorUrl('d8051f4f9f72439f86cf04df15ed998d')
    msg.value = res.msg
    videoUrl.value = `${ipUrl}?url=${monitorIpUrl}${msg.value}`
  } catch (e) {
    console.error('获取监控地址失败', e)
  }
})
</script>

<style scoped>
.info-card::before {
  content: '';
  position: absolute;
  top: 7px;          /* 距离卡片顶部 14px,可微调 */
  left: -7px;         /* 向左伸出 7px,刚好盖住边框形成无缝三角 */
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 6px 7px 6px 0;   /* 上 右 下 左:形成一个朝左的三角形 */
  border-color: transparent rgba(20, 26, 35, 0.95) transparent transparent;
  /* 三角形颜色与卡片背景保持一致 */
}
.white-card::before {
  content: '';
  position: absolute;
  top: 6px;          /* 距离卡片顶部 14px,可微调 */
  left: -12px;         /* 向左伸出 7px,刚好盖住边框形成无缝三角 */
  width: 0;
  height: 0;
  border-style: solid;
  border-width: 12px 14px 12px 0;   /* 上 右 下 左:形成一个朝左的三角形 */
  border-color: transparent rgba(255, 255, 255, 1) transparent transparent;
  /* 三角形颜色与卡片背景保持一致 */
}
.info-card {
  background: rgba(20, 26, 35, 0.95);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 8px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
  padding: 14px 16px;
  min-width: 220px;
  max-width: 280px;
  font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  font-size: 13px;
  line-height: 1.4;
  color: #fff;
  backdrop-filter: blur(4px);
  position: relative;   /* 让箭头相对它定位 */
}
.white-card{
  background: white;
  border-radius: 10px 10px 10px 10px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.12);
  padding: 18px;
  min-width: 280px;
  font-family: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  border: 1px solid #eee;
  position: relative;   /* 让箭头相对它定位 */
}

.info-address{
  display: flex;
  margin-bottom: 8px;
  align-items: flex-start;
}

.info-address-value{
  flex:1;
  word-break:break-all;
}

.info-center{
  display: flex;
  margin-bottom: 8px;
  align-items: center;
}

.info-center-name{
  flex:0 0 80px;
  color:#8ab4f8;
  font-weight:600;
}

.info-center-value{
  flex:1;
  color:#1a73e8;
  font-weight:500;
  padding:4px 8px;
  background:rgba(26,115,232,0.15);
  border-radius:4px;
}

.info-angle{
  display: flex;
  align-items: center;
}

.angle-value{
  flex:1;
  color:#5f6368;
  font-family:monospace;
  font-size:14px;
}
.tiandi-map-container {
  width: 100%;
  height: 100%;
  position: relative;
}

.ol-map-full {
  width: 100%;
  height: 100%;
  position: relative;
}

.ol-popup {
  pointer-events: auto;
}

.map-topic-panel {
  position: absolute;
  top: 0px;
  right: 1100px;
  width: 338px;
  height: 320px;
  background: linear-gradient(90deg, rgba(10, 41, 88, 0.3) 0%, rgba(20, 49, 90, 0.8) 52%, rgba(14, 33, 61, 0.3) 100%);
  border-radius: 12px;
  overflow-y: auto;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
  z-index: 2000;
  padding: 16px 12px 12px 12px;
  display: flex;
  flex-direction: column;
  gap: 8px;
  transition: all 0.6s;
}

.map-topic-panel::-webkit-scrollbar {
  width: 4px;
  background: transparent;
}

.map-topic-panel::-webkit-scrollbar-thumb {
  background: rgba(25, 78, 108, 0.5);
  border-radius: 2px;
}

.topic-row {
  margin-bottom: 6px;
}

.topic-title {
  color: #fff;
  font-size: 15px;
  font-weight: bold;
  margin-bottom: 8px;
  letter-spacing: 1px;
}

.topic-btns-cols{
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.topic-btns {
  display: flex;
  flex-direction: row;
  gap: 8px;
}

.topic-btn {
  width: 72px;
  height: 74px;
  background: rgba(15, 53, 75, 0.3);
  border-radius: 7px;
  border: 1px solid rgba(25, 78, 108, 0.5);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  transition: box-shadow 0.2s;
}

.topic-btn:hover {
  box-shadow: 0 0 8px 2px rgba(20, 49, 90, 0.18);
}

.topic-btn-active {
  background: rgba(0, 50, 108, 0.8) !important;
  border-radius: 7px;
  border: 1px solid #00CEFF !important;
}

.topic-btn-icon {
  width: 28px;
  height: 28px;
  background: rgba(53, 155, 255, 0.5);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 6px;
}

.topic-btn img {
  width: 16px;
  height: 16px;
  display: block;
}

.topic-btn-label {
  color: #fff;
  font-size: 14px;
  text-align: center;
  line-height: 1.2;
  min-height: 28px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  /* 保证多行和单行按钮视觉对齐 */
}

.topic-btn-label span {
  display: block;
  margin: 0;
  padding: 0;
}

.video-overlay {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 10; /* 确保在iframe上层 */
  cursor: pointer;
  overflow: hidden
}
</style>

自定义弹层显示echarts

image.png 子组件代码:

<template>
  <el-dialog  class="video-dialog" title="电导率详情" v-model="showDialog" width="800" :close-on-click-modal="true"
             append-to-body @close="handlerClose">
    <div class="dialog-wrapper">
      <el-form ref="formRef" :model="form" label-width="100px" label-position="left">
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="选择日期">
              <date-picker ref="datePicker" v-model="dateRange" @change="search"></date-picker>
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <HandlerHistoryEcharts v-model:isshowData="showDialog" :tableList="tableList" :data="deviceData.status_dict" :tag="'conductivity'" />
      <div class="title-box">
        <div class="circle" style="background: #31E2FF;"></div>
        <div class="font">基本信息</div>
      </div>
      <div class="label-row">
        <div class="label-long">设备名:{{deviceData.deviceName}}</div>
        <div class="label-short">电池电压:{{deviceData.power}}</div>
        <div class="label-middle status-row">
          <div>状态:</div>
          <div class="circle" :style="'background:'+(deviceData.status_dict==='离线' ? '#D8D8D8' : (deviceData.status_dict==='在线' ? '#3DFF00' : (deviceData.status_dict==='维护' ? '#FFA500' : (deviceData.status_dict==='报警' ? '#FF2D2D' : ''))))"></div>
          <div>{{deviceData.status_dict}}</div>
        </div>
      </div>

      <div class="label-row">
        <div class="label-short">上限:{{deviceData.conductivityUpperLimit}}</div>
        <div class="label-short">下限:{{deviceData.conductivityLowerLimit}}</div>
        <div class="label-short">rssi:{{deviceData.rssi}}</div>
        <div class="label-middle">imei:{{deviceData.imei}}</div>
      </div>
    </div>
  </el-dialog>
</template>

<script setup>
import HandlerHistoryEcharts from "./HandlerHistoryEcharts";
import DatePicker from "./environment/datePicker.vue";
import dayjs from "dayjs";
import {getConductivityToken, setConductivityToken} from "../../../utils/auth";
import axios from "axios";
import {searchConductivityHistory} from "../../../api/screen/map";

const showDialog = ref(false)
const tableList = ref([])
const deviceData = ref({})
const data = ref({})

const dateRange = ref([dayjs().subtract(1, 'day').format('YYYY-MM-DD'),dayjs().format('YYYY-MM-DD')])
const form = ref({
})

function reset(){
  form.value = {}
  tableList.value = []
  deviceData.value = {}
  dateRange.value = [dayjs().subtract(1, 'day').format('YYYY-MM-DD'),dayjs().format('YYYY-MM-DD')]
}

function show(data){
  reset()
  showDialog.value = true
  deviceData.value = data
  search()
}
// 关闭弹窗
const handlerClose = () => {
  showDialog.value = false
  reset()
}
async function search() {
  //拿电导率缓存
  let token = ''
  if ([null, undefined, '', 'undefined', 'null'].includes(getConductivityToken())) {
    let uuid = dayjs().format('YYYYMMDD') + Math.floor(Math.random() * 100000000)
    let tokenRes = await axios.request({
      url: `http://kjgs.jsjnsw.com:17001/jwtauth/api/auth/getToken?uuid=${uuid}`
    })
    token = tokenRes.data.token
    setConductivityToken(token)
  } else {
    token = getConductivityToken()
  }
  let query = {
    token: token,
    imei: deviceData.value.imei,
    tag: 'conductivity',
    deviceType: '1',
    pkConductivity: '',
    //当前日期的00:00:00时间戳
    startTime: dayjs(dateRange.value[0]) + '',
    //当前日期的23:59:59
    endTime: dayjs(dateRange.value[1]) + '',
  }
  let response = await searchConductivityHistory(query)
  tableList.value = response.data
}

defineExpose({
  show
})
</script>

<style scoped lang='scss'>
.dialog-wrapper{
  width: 100%;
  height: 500px;
  background-image: url("@/assets/images/conductivityDialogBg.png");
  background-size: 100% 100%;
  box-sizing: border-box;
  padding: 74px 40px 36px 42px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}

.title-box{
  width: 100%;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: flex-start;
}

.status-row{
  display: flex;
  flex-direction: row;
  align-items: center;
}
.circle{
  width: 6px;
  height: 6px;
  border-radius: 6px;
  margin-right: 10px;
}

.font{
  font-weight: 500;
  font-size: 16px;
  color: #D8F0FF;
}

.label-row{
  margin-top: 10px;
  width: 100%;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
}

.label-long{
  width: 310px;
  height: 30px;
  background: rgba(11,78,140,0.5);
  box-sizing: border-box;
  padding-left: 10px;
  font-weight: 500;
  font-size: 14px;
  color: #FFFFFF;
  display: flex;
  align-items: center;
  justify-content: flex-start;
}

.label-middle{
  width: 238px;
  height: 30px;
  background: rgba(11,78,140,0.5);
  box-sizing: border-box;
  padding-left: 10px;
  font-weight: 500;
  font-size: 14px;
  color: #FFFFFF;
  display: flex;
  align-items: center;
  justify-content: flex-start;
}

.label-short{
  width: 150px;
  height: 30px;
  background: rgba(11,78,140,0.5);
  box-sizing: border-box;
  padding-left: 10px;
  font-weight: 500;
  font-size: 14px;
  color: #FFFFFF;
  display: flex;
  align-items: center;
  justify-content: flex-start;
}

:deep(.el-form-item__label) {
  color: #FFFFFF !important;
}
</style>