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

lishihuan大约 11 分钟

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

1. 规避重叠

POI 图层里开启了 declutter: true,所以同一图层内相互碰撞的图标会被自动“避让”,部分被暂时不渲染,缩放更大(下钻)或位置稍有变化时才显示。

this.poiLayer = new VectorLayer({
  source: this.poiSource,
  zIndex: 435,
  declutter: true  // 当前为开启状态
})

2. 针对相邻的图标如何调整

  • 保持规避,但尽可能少“吞点”
    • 降低图标 scale(变小,碰撞盒更小)
    • 设置不同类别/重要程度的样式 zIndex,让更重要的优先显示(如 risk > other)
  • 更友好的聚合方案
    • 低级别使用 cluster 聚合(显示数量气泡),放大自动散开,常见于大量点位场景

3. 同一坐标点多种覆盖物 的处理方案【不采用聚合版】

在这种情况下,常规的 declutter/聚合/缩放控制都很难“同时显示且不遮挡”。更适合的是以下几种专门面向“同点多类型”的方案,我推荐优先考虑第 1 或第 2 种,或做一个混合方案。

方案 A:点击/悬停时“扇形展开”

  • 常态:点位显示为“合并标识”(比如叠层/角标/计数)
  • 交互:鼠标悬停或点击后,在该点周围以扇形或环形展开各类型图标(像气泡菜单),每个可点击
  • 优点:常态非常简洁,不丢信息;需要时一次性看到所有类型且不重叠
  • 适配较多类型(>8)也可用,超出时多圈/分页展开
  • 技术实现:展开态用临时 overlay/feature,按像素位移布局,无需改底层数据结构

方案 B:常态“微偏移布局”(固定像素位移)

  • 对同坐标的各类型图标,按照固定像素位移(非地图单位)分布在中心周围(十字/网格/环形),必要时加细引线
  • 优点:无需交互就能同时看到所有类型;直观、无隐藏
  • 注意:如果类型很多,画面会比较密;建议配合图标缩小和最多显示 N 个、其余用“+n”角标
  • 技术实现:为每个 feature 设一个 offsetPx=[dx,dy],样式改用 style function 按 resolution 把像素位移换算成地图坐标;不依赖 declutter

方案 C:组合图标(动态合成多子图标为一张)

  • 把该点的多类型小图标按网格/叠层绘制到离屏 canvas,生成一张合成 PNG 作单一图标显示;数量多时显示前 N 个 + “+n”角标
  • 点击后弹出详情列表或再切换到展开态(与方案 A 结合)
  • 优点:绘制开销低、不会被 declutter 吞掉、视觉统一;对数量多的点很友好
  • 技术实现:按“类型集合 + 高亮状态 + 当前可见性”做缓存 key,复用合成结果;高亮时把被选类型换成 *_check 版本或加描边/光环

推荐的落地策略(混合)

  • 常态使用“组合图标”(方案 C),简洁表达“这里有多种类型”
  • 当数量不大(≤4)时可以直接用“微偏移布局”(方案 B)常显,便于直观点选
  • 点击或“+n”角标再触发“扇形展开”(方案 A),完整查看和操作所有类型
  • 这样既能在不下钻时看到所有信息,又避免 declutter 吞点;并兼容你现有的类型开关与高亮闪烁(高亮时对组合图标里对应类型绘制 *_check 或外圈发光)

4. 实现

4.1 以“主点”为中心,多个覆盖物环绕四周,并用引线指向

OlMap.vue

目前已杆塔为主,其他的覆盖物围绕再杆塔,并且用引线指向

image-20250912162925627
image-20250912162925627

4.1.1 实现思路

以下是与具体项目解耦的通用做法,适用于任意地图/可视化引擎(OpenLayers、Mapbox GL、Leaflet、Canvas/WebGL 引擎等),核心思路是“主点为中心,覆盖物按环绕规则布局,并绘制指向线”。

1) 数据建模
  • 主点实体字段
    • id、name
    • 坐标 coord(通常经纬度)
    • 占位尺寸 footprint(近似宽高或半径,用于避让)
    • 附属类型列表 types(如 ['sgd','pfw', ...])
  • 覆盖物类型配置
    • 图标映射:type → iconUrl、size、anchor
    • UI 开关映射(如一个 Map 或状态管理器,控制某类型是否显示)

要点:

  • types 应去重
  • 类型显示开关单独维护,不与图标/样式逻辑耦合
2) 图层与渲染边界
  • 使用两类渲染单元:
    • 主点图层:只负责主点图标与名称
    • 附属覆盖物图层:负责环绕小图标与引线
  • 低缩放时,建议隐藏“独立的 POI 图层”,避免和“环绕覆盖物”重复;高缩放时再显示 POI 自己的点

要点:

  • 主点层 zIndex 高于附属层,避免被盖住
  • 不直接在 Feature 上 setStyle(或等价 API);尽量用图层样式函数/渲染回调统一控制
3) 槽位与稳定布局
  • 采用均匀等分的槽位系统
    • 将圆周按 N 等分(推荐 12 或 16)
    • 槽位编号 i 的角度:theta = 90° - i * (360° / N),0 号位为正上
  • 槽位分配策略(稳定且可复用)
    • 优先使用上一次的分配结果(slotMap[type])
    • 若无上次结果,先尝试“固定优先槽位”(如 {sgd: 0, pfw: 3})
    • 再按从 0 开始递增找空位
  • 多圈外扩
    • 槽位索引 idx → ring = floor(idx / N)
    • 每增加一圈,基准半径增加 ringExpand = baseR * expandFactor(如 0.75)

要点:

  • 将 slotMap 挂在主点实例/Feature 上,切换开关/缩放后仍保持稳定位置
  • 为关键类型配置固定槽位,提升认知稳定性
4) 避让主点占位(关键)
  • 根据主点图标近似 footprint 来计算“最小避让半径”
    • 输入:icon 近似宽高(像素)与缩放 scale
    • 计算:沿方向向量 (ux, uy) 取横向与纵向避让距离
      • 横向:abs(ux) * (iconWidth/2 + extraMargin)
      • 纵向(上方):if uy < 0 → (iconHeight + extraMargin) * (-uy)
      • 纵向(下方):if uy > 0 → bottomMargin * uy(下方给更小裕量)
    • clearance = max(横向, 纵向)
    • radius = baseR + clearance + ringExpand
  • 偏移(像素空间):
    • dx = round(ux * radius), dy = round(uy * radius)

要点:

  • 先将主点坐标转屏幕像素,再做偏移,最后转回坐标;始终用“像素空间”做环绕与连线,避免不同缩放下偏移不一致
5) 绘制引线与覆盖物图标
  • 从主点像素 centerPx 到覆盖物像素 iconPx = centerPx + [dx, dy]
  • 引线终点向回缩 stopShortPx(建议 4–8 像素),避免线被覆盖物图标盖住
  • 将像素坐标分别转换为地图坐标,绘制
    • 引线:LineString([center, toCoord])
    • 图标:Point(iconCoord) + Icon(symbol)
  • zIndex 建议
    • 引线 < 覆盖物图标 < 主点图标

要点:

  • 统一在样式/渲染函数里完成,避免在外部对单个点反复 setStyle
6) 交互与选中态
  • 点击小图标:只改变 feature 属性(如 selected、blink),不要在点击中直接改样式
  • 图层样式函数读取属性决定使用“选中态图标”或“基础图标”
  • 闪烁效果:
    • 定时器交替设置 blink true/false
    • 每次变更后触发图层重绘(changed/triggerRender)
    • 结束后保持选中态 true
7) 与类型开关联动
  • 通过一个 Map 维护 type → visible 布尔值
  • 样式/渲染函数里只绘制 visible 的类型
  • 切换开关时:
    • 不直接改 feature 样式
    • 更新可见性 Map 后触发主点图层与覆盖物图层重绘即可
8) 性能与边界
  • 低缩放隐藏独立 POI;仅绘制“主点 + 环绕覆盖物”
  • 数量极多时,可限制每个主点显示的覆盖物数(如 6),并在下一槽位绘制“+n”角标表示余量
  • 避免在每帧大量 new 对象;可做样式对象缓存(按 type/selected 缓存 Icon 实例)
9) 可配置参数建议
  • baseR(基础半径):24–36 像素,视图标大小调
  • slotsCount(等分数):12 或 16
  • expandFactor(多圈外扩系数):0.6–0.9
  • stopShortPx(引线回缩):4–8 像素
  • footprint:依据主点图标近似宽高与 scale 设置;上方 extraMargin 大一些、下方 bottomMargin 小一些更自然
10) 伪代码骨架(与引擎无关)
for each mainPoint:
  center = mainPoint.coord
  types = unique(filterByToggle(mainPoint.types))

  slotMap = mainPoint.slotMap || {}
  preferred = { sgd: 0, pfw: 3 }  // 可配置
  assignSlots(types, slotMap, preferred, slotsCount)
  mainPoint.slotMap = slotMap

  for each t in take(types, maxPerPoint):
    idx = slotMap[t]
    ring = floor(idx / slotsCount)
    theta = 90deg - (idx % slotsCount) * (360deg / slotsCount)
    ux, uy = cos(theta), sin(theta)

    clearance = calcClearance(ux, uy, footprint, extraMargin, bottomMargin)
    radius = baseR + clearance + ring * (baseR * expandFactor)

    centerPx = toPixel(center)
    iconPx = centerPx + [ux*radius, uy*radius]
    lineEndPx = iconPx - normalize([ux,uy]) * stopShortPx

    drawLine(from: toCoord(centerPx), to: toCoord(lineEndPx))
    drawIcon(at: toCoord(iconPx), icon: iconOf(t, selected?))

  if remaining > 0:
    drawBadge("+n", at next available slot)

4.1.2 实现demo

分别适配 OpenLayers、Mapbox GL JS、Leaflet。它们都实现相同目标:以主点为中心,多个覆盖物环绕四周,并用引线准确指向覆盖物图标边缘。你可按项目替换图标与坐标转换 API。

说明:

  • 统一思路:中心点 → 转屏幕像素 → 计算偏移 → 转回坐标 → 画线和图标
  • 关键参数:slotsCount、baseR、expandFactor、stopShortPx、footprint、preferredSlots
  • 为简洁,示例省略了类型开关、闪烁与缓存,聚焦“环绕+引线”

OpenLayers 6+ 模板(StyleFunction 版)

// inputs
const preferredSlots = { sgd: 0, pfw: 3 }
const slotsCount = 12, baseR = 28, expandFactor = 0.75, stopShortPx = 6
const footprint = { width: 28, height: 42, scale: 0.7, extraMargin: 6, bottomMargin: 8 }
// iconResolver(type) => { src, scale }

function assignSlots(types, prev = {}) {
  const used = new Set(), map = {}
  types.forEach(t => {
    let idx = Number.isFinite(prev[t]) ? prev[t] : null
    if (idx === null || used.has(idx)) {
      const pref = Number.isFinite(preferredSlots[t]) ? preferredSlots[t] : null
      idx = (pref !== null && !used.has(pref)) ? pref : 0
      while (used.has(idx)) idx++
    }
    map[t] = idx; used.add(idx)
  })
  return map
}

function clearanceForDir(ux, uy) {
  const w = footprint.width * footprint.scale
  const h = footprint.height * footprint.scale
  const horiz = Math.abs(ux) * (w / 2 + footprint.extraMargin)
  const vert = uy < 0 ? (h + footprint.extraMargin) * (-uy) : footprint.bottomMargin * uy
  return Math.max(horiz, vert)
}

function buildOverlayStyles(olMap, center, types, prevSlotMap) {
  const styles = []
  const view = olMap.getView()
  if (!center || !view) return styles
  const centerPx = olMap.getPixelFromCoordinate(center)
  const slotMap = assignSlots(types, prevSlotMap)
  const toCoord = p => olMap.getCoordinateFromPixel(p)

  types.forEach(type => {
    const idx = slotMap[type]
    const ring = Math.floor(idx / slotsCount)
    const i = idx % slotsCount
    const theta = Math.PI/2 - i * (2*Math.PI/slotsCount)
    const ux = Math.cos(theta), uy = Math.sin(theta)
    const radius = baseR + clearanceForDir(ux, uy) + ring * (baseR * expandFactor)
    const dx = Math.round(ux * radius), dy = Math.round(uy * radius)

    // line end (shrink a bit)
    const len = Math.hypot(dx, dy) || 1
    const ex = dx / len, ey = dy / len
    const endPx = [centerPx[0] + dx - ex * stopShortPx, centerPx[1] + dy - ey * stopShortPx]
    const end = toCoord(endPx)

    // icon position
    const iconPx = [centerPx[0] + dx, centerPx[1] + dy]
    const iconCoord = toCoord(iconPx)
    const { src, scale = 0.65 } = iconResolver(type)

    styles.push(
      new ol.style.Style({ geometry: new ol.geom.LineString([center, end]),
        stroke: new ol.style.Stroke({ color: '#999', width: 1 }), zIndex: 390 }),
      new ol.style.Style({ geometry: new ol.geom.Point(iconCoord),
        image: new ol.style.Icon({ src, scale, anchor: [0.5,0.5] }), zIndex: 400 })
    )
  })
  return { styles, slotMap }
}

// 在 towerFeature 的 styleFunction 中调用:
// const { styles, slotMap } = buildOverlayStyles(map, feat.getGeometry().getCoordinates(), types, feat.get('slotMap'))
// feat.set('slotMap', slotMap); return [towerStyle, ...styles]

Mapbox GL JS 模板(自定义层 custom layer 或 render pass 驱动)

// 思路:使用 CustomLayerInterface 在 render 中用 map.project/unproject 计算像素偏移,
// 用一个 GeoJSON source 动态更新两类要素:LineString(引线)与 Point(覆盖物图标)。
// 覆盖物图标可使用 symbol layer(icon-image: type -> sprite)。

// 初始化
map.addSource('overlays', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({ id: 'overlay-lines', type: 'line', source: 'overlays',
  paint: { 'line-color': '#999', 'line-width': 1 } })
map.addLayer({ id: 'overlay-icons', type: 'symbol', source: 'overlays',
  layout: { 'icon-image': ['get','icon'], 'icon-size': 0.65, 'icon-anchor': 'center' } })

function iconResolver(type) { return { icon: type } } // 需提前把各 type 注册到 sprite

function rebuildOverlays(map, mainPoints) {
  const features = []
  mainPoints.forEach(mp => {
    const centerLngLat = mp.coord   // [lng, lat]
    const centerPx = map.project(centerLngLat)
    const types = mp.typesUniqueVisible // 已过滤去重

    const slotMap = assignSlots(types, mp.slotMap)
    mp.slotMap = slotMap

    types.forEach(type => {
      const idx = slotMap[type]
      const ring = Math.floor(idx / slotsCount)
      const i = idx % slotsCount
      const theta = Math.PI/2 - i * (2*Math.PI/slotsCount)
      const ux = Math.cos(theta), uy = Math.sin(theta)
      const radius = baseR + clearanceForDir(ux, uy) + ring * (baseR * expandFactor)
      const dx = ux * radius, dy = uy * radius

      const iconPx = { x: centerPx.x + dx, y: centerPx.y + dy }
      const endPx  = { x: iconPx.x - (dx/Math.hypot(dx,dy))*stopShortPx,
                       y: iconPx.y - (dy/Math.hypot(dx,dy))*stopShortPx }

      const endLngLat  = map.unproject([endPx.x, endPx.y])
      const iconLngLat = map.unproject([iconPx.x, iconPx.y])
      const { icon } = iconResolver(type)

      features.push(
        { type:'Feature', geometry:{ type:'LineString', coordinates:[centerLngLat, [endLngLat.lng,endLngLat.lat]] },
          properties: { kind:'line' } },
        { type:'Feature', geometry:{ type:'Point', coordinates:[iconLngLat.lng, iconLngLat.lat] },
          properties: { kind:'icon', icon } }
      )
    })
  })
  map.getSource('overlays').setData({ type:'FeatureCollection', features })
}

// 注意:在 move/zoom 事件或数据变化时调用 rebuildOverlays(map, data)

Leaflet 模板(用 project/unproject 计算像素偏移)

// 初始化图层
const overlayLayer = L.layerGroup().addTo(map)

function iconResolver(type) {
  return L.icon({ iconUrl: `/icons/${type}.png`, iconSize: [20, 20], iconAnchor: [10, 10] })
}

function rebuildOverlays(map, mainPoints) {
  overlayLayer.clearLayers()
  mainPoints.forEach(mp => {
    const centerLatLng = L.latLng(mp.lat, mp.lng)
    const centerPx = map.project(centerLatLng, map.getZoom())
    const types = mp.typesUniqueVisible

    const slotMap = assignSlots(types, mp.slotMap)
    mp.slotMap = slotMap

    types.forEach(type => {
      const idx = slotMap[type]
      const ring = Math.floor(idx / slotsCount)
      const i = idx % slotsCount
      const theta = Math.PI/2 - i * (2*Math.PI/slotsCount)
      const ux = Math.cos(theta), uy = Math.sin(theta)

      const radius = baseR + clearanceForDir(ux, uy) + ring * (baseR * expandFactor)
      const dx = ux * radius, dy = uy * radius

      const iconPx = L.point(centerPx.x + dx, centerPx.y + dy)
      const endPx  = L.point(
        iconPx.x - (dx/Math.hypot(dx,dy))*stopShortPx,
        iconPx.y - (dy/Math.hypot(dx,dy))*stopShortPx
      )

      const iconLatLng = map.unproject(iconPx, map.getZoom())
      const endLatLng  = map.unproject(endPx,  map.getZoom())

      // 引线
      L.polyline([centerLatLng, endLatLng], { color:'#999', weight:1 }).addTo(overlayLayer)

      // 覆盖物图标
      L.marker(iconLatLng, { icon: iconResolver(type), interactive: true }).addTo(overlayLayer)
    })
  })
}

// 在 map 的 move/zoomend 事件与数据更新时调用 rebuildOverlays(map, data)

通用工具函数(伪代码)

const preferredSlots = { sgd: 0, pfw: 3 }
const slotsCount = 12, baseR = 28, expandFactor = 0.75, stopShortPx = 6
const footprint = { width: 28, height: 42, scale: 0.7, extraMargin: 6, bottomMargin: 8 }

function assignSlots(types, prev = {}) {
  const used = new Set(), map = {}
  types.forEach(t => {
    let idx = Number.isFinite(prev[t]) ? prev[t] : null
    if (idx === null || used.has(idx)) {
      const pref = Number.isFinite(preferredSlots[t]) ? preferredSlots[t] : null
      idx = (pref !== null && !used.has(pref)) ? pref : 0
      while (used.has(idx)) idx++
    }
    map[t] = idx; used.add(idx)
  })
  return map
}
function clearanceForDir(ux, uy) {
  const w = footprint.width * footprint.scale
  const h = footprint.height * footprint.scale
  const horiz = Math.abs(ux) * (w/2 + footprint.extraMargin)
  const vert  = uy < 0 ? (h + footprint.extraMargin) * (-uy) : footprint.bottomMargin * uy
  return Math.max(horiz, vert)
}

使用提示

  • 将“坐标⇄像素”的 API 换成对应引擎的 project/unproject
  • 在缩放/移动时重建或重绘(OpenLayers 用层样式函数自动生效;Mapbox/Leaflet 在事件里刷新)
  • 覆盖物点击:直接挂在 symbol/marker 上;或用 feature-state 处理选中态
  • 若要在高缩放显示原始 POI,请在低缩放隐藏 POI,避免与环绕重复

需要我把这些模板打成三个最小可运行的 demo(含 HTML)吗?我可以再补齐资源与初始化代码,方便你一键试跑。