第12章 - 标记弹窗与自定义覆盖物

12.1 三种叠加方式,三种坐标系

地图上的”点状标记”可以用三套完全不同的机制实现,代价与能力差别很大:

方式 载体 定位手段 数量级 能否做逐帧 GPU 绘制
Marker / Popup DOM 元素 map.project(lngLat) 得出屏幕像素,写 style.transform 几十到几百 不能
自定义 DOM 覆盖层 自己的 DOM 同上,自己监听 move/zoom 几十 不能
CustomLayerInterface WebGL 绘制 用 MapLibre 给的投影矩阵 十万级顶点 能

三者的分界在坐标系。DOM 覆盖物活在 CSS 像素坐标系里:map.project() 把经纬度换成 [0,0] 在左上角、往下往右递增的像素点,你把元素 translate 过去即可;它与渲染帧不同步,相机变化后要等浏览器下一次布局才对齐。Custom Layer 活在裁剪空间(clip space):MapLibre 每帧把 modelViewProjectionMatrix 传给你,投影、倾斜、地形、球面都在矩阵里,渲染与其他图层严格同帧。

判断标准很简单:需要跟随 3D 地形、需要十万级对象、需要自定义着色效果,走 Custom Layer;其余情况用 DOM,开发成本低一个数量级。

12.2 Marker:默认值里藏着坑

Marker 构造时(src/ui/marker.ts)的默认值:

选项 类型 默认值 说明
element HTMLElement 内置 SVG 图钉 传了自己的元素后,color/scale 失效
anchor PositionAnchor 'center' 哪个部位对准坐标点
offset PointLike 默认图钉 [0, -14];自定义 element [0, 0] 像素偏移,负值向左上
color string '#3FB1CE' 只影响默认图钉
scale number 1 基准尺寸 41×27 px
draggable boolean false 开启后可拖动,且默认可键盘聚焦
clickTolerance number 0 判定”点击 vs 拖动”的位移阈值
rotation number 0 顺时针角度
rotationAlignment 'map' \| 'viewport' \| 'auto' 'auto'(等价 viewport) 旋转是否跟随地图
pitchAlignment 'map' \| 'viewport' \| 'auto' 'auto'(取 rotationAlignment) 是否贴合倾斜地图平面
opacity string \| number 1 未遮挡时不透明度
opacityWhenCovered string \| number 0.2 被 3D 地形或球体背面遮挡时的不透明度
subpixelPositioning boolean false 关掉像素取整,移动更顺滑

默认图钉的 offset 是 [0, -14] 而不是 [0, 0],源码注释解释了推导:默认 SVG 高 41px,图钉尖在阴影椭圆中心,相对 SVG 顶边约 34.8px,与中心 20.5px 的差取整得 14。少一个负号,图钉尖就会落在坐标点下方 14px。

Marker 的 DOM 挂在 map.getCanvasContainer() 里,位置靠 transform: translate(x, y) 写像素坐标。addTo() 会订阅 move、moveend、terrain、projectiontransition、idle 五个事件来重算位置,注意是 move 而不是 render——静止状态下相机不变,Marker 不会重算。如果你手动改了元素内容导致尺寸变化,anchor 的对齐会偏,需要自己再调一次 setLngLat 触发更新。

12.2.1 遮挡处理

开了 3D 地形后,_updateOpacity() 会用 map._camera.transform.isLocationOccluded(lngLat) 判断该点是否被地形/球面挡住,命中时把 style.opacity 设成 opacityWhenCovered,并切换 maplibregl-marker-covered 类。想自己接管:

.maplibregl-marker-covered {
  pointer-events: none;
  cursor: default;
}

pointer-events: none 很关键:被遮挡的图钉还能点,用户会以为点在”看起来空白的位置”。Popup 也有对应能力,参数名是 locationOccludedOpacity,默认 undefined(不处理遮挡)。

12.2.2 完整示例:可拖动标记 + 联动弹窗

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <title>Marker 与 Popup</title>
  <link href="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.css" rel="stylesheet" />
  <style>
    body { margin: 0; }
    #map { height: 100vh; }
    .pin-warehouse { width: 18px; height: 18px; border-radius: 50%; background: #e8590c; border: 2px solid #fff; cursor: grab; }
    .maplibregl-marker-covered { opacity: 0.15; pointer-events: none; }
  </style>
</head>
<body>
  <div id="map"></div>
  <script src="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.js"></script>
  <script>
    const map = new maplibregl.Map({
      container: 'map',
      style: 'https://demotiles.maplibre.org/style.json',
      center: [116.39, 39.9],
      zoom: 10
    });

    map.on('load', () => {
      // 1) 自定义 element 的 Marker:offset 默认 [0,0],anchor 用 center
      const el = document.createElement('div');
      el.className = 'pin-warehouse';

      const marker = new maplibregl.Marker({ element: el, anchor: 'center', draggable: true })
        .setLngLat([116.39, 39.9])
        .setPopup(
          new maplibregl.Popup({ offset: 16, closeButton: true, maxWidth: '320px' })
            .setHTML('<b>仓库 A</b><br/>拖动图钉可调整位置')
        )
        .addTo(map);

      // 2) 拖动时实时回写坐标,dragend 才是提交时机
      marker.on('drag', () => {
        const { lng, lat } = marker.getLngLat();
        document.title = '拖动中 ' + lng.toFixed(5) + ', ' + lat.toFixed(5);
      });
      marker.on('dragend', () => {
        const { lng, lat } = marker.getLngLat();
        console.log('提交坐标', lng, lat);
      });

      // 3) 点击地图别处新增标记;注意 Popup 默认 closeOnClick: true
      map.on('click', (e) => {
        const p = document.createElement('div');
        p.className = 'pin-warehouse';
        new maplibregl.Marker({ element: p })
          .setLngLat(e.lngLat)
          .setPopup(new maplibregl.Popup().setText('+' + e.lngLat.lng.toFixed(4) + ', ' + e.lngLat.lat.toFixed(4)))
          .addTo(map)
          .togglePopup();
      });

      // 4) 显式控制 popup 打开/关闭
      map.on('contextmenu', () => marker.togglePopup());
    });
  </script>
</body>
</html>

注意第 3 步:在 map.on('click') 里新建 Marker 后立刻 togglePopup() 往往打不开——Popup 的 closeOnClick: true 会在同一次点击的事件传播中把它关掉。稳妥做法是构造 Popup 时传 closeOnClick: false,或者用 setTimeout(..., 0) 延后打开。

12.3 Popup:DOM 容器 + 定位算法

Popup 是纯 DOM 覆盖物,默认值如下(src/ui/popup.ts):

选项 默认值 说明
closeButton true 右上角关闭按钮
closeOnClick true 点击地图关闭
closeOnMove false 地图移动时关闭;会与”跟随移动”的用法冲突
focusAfterOpen true 打开后把焦点移到弹窗内第一个可聚焦元素
anchor 未设,动态计算 优先 'bottom',越界时自动换边
offset 未设,即 0 数字、PointLike,或按 anchor 逐项给的对象
maxWidth '240px' 设为 'none' 才能按内容自适应
className '' 附加类名,用于自定义外观
subpixelPositioning false 关掉像素取整
locationOccludedOpacity undefined 被球面遮挡时的不透明度

anchor 不传时并不是固定下来,而是每次更新都重算:默认优先把弹窗放在锚点上方(bottom 边贴住坐标),靠近地图边缘时自动翻到另一侧。这也是为什么给 offset 传单个数字时,它表示”远离锚点的距离”,而传对象时可以为每个 anchor 分别指定。

三种填内容的方式,安全性与灵活性成反比:

// 最安全:纯文本,内容里的 < > 会被当文本
popup.setText('<b>不会加粗</b>');

// 常用:允许 HTML,数据来自外部时务必先做白名单清洗
popup.setHTML('<b>仓库 A</b><br/>容量 1200 托');

// 最稳的结构化做法:传 DOM 节点,用 textContent 填值
const box = document.createElement('div');
const title = document.createElement('h4');
title.textContent = '仓库 A';           // 外部字符串走 textContent
box.appendChild(title);
popup.setDOMContent(box);

数据里的字段直接拼进 setHTML 是 XSS 入口。MapLibre 不做转义,字符串原样进 innerHTML。

12.3.1 trackPointer:跟随鼠标的提示框

trackPointer() 让弹窗贴着鼠标走,内部把事件从 move 换成 mousemove + drag:

const hover = new maplibregl.Popup({
  closeButton: false,
  closeOnClick: false,
  offset: 12,
  className: 'hover-tip'
}).trackPointer();
// .hover-tip 里 pointer-events: none,避免提示框自己挡住鼠标
map.on('mousemove', 'poi-layer', (e) => {
  hover.setLngLat(e.lngLat).setText(e.features[0].properties.name).addTo(map);
});
map.on('mouseleave', 'poi-layer', () => hover.remove());

trackPointer() 与 setLngLat() 互斥:调用 setLngLat 会把 _trackPointer 复位为 false。悬停提示框必须关掉 closeOnClick,否则点一下就消失。

12.4 自定义 DOM 覆盖层:自己算投影

Marker 之外的覆盖物(图例、动态标签、跟随相机的工具栏)不必继承什么类,只需要在 move 时重算位置。核心就两个方法:

const p = map.project([116.39, 39.9]);   // LngLat → Point{x, y}(CSS 像素,相对容器左上)
const ll = map.unproject([p.x, p.y]);    // Point → LngLat

一个完整可跑的例子,把 DOM 元素钉在坐标上,并处理”地图旋转后标签要不要跟着转”:

// 与 map 同尺寸的绝对定位层,pointer-events: none,内部需要交互的元素再单独打开
const overlay = document.createElement('div');
overlay.className = 'overlay-layer';
map.getCanvasContainer().appendChild(overlay);

const items = [
  { lngLat: [116.39, 39.9], text: '总部' },
  { lngLat: [116.48, 39.92], text: '分拨中心' },
  { lngLat: [116.30, 39.86], text: '前置仓' }
];

const nodes = items.map((item) => {
  const node = document.createElement('div');
  node.className = 'overlay-label';
  node.textContent = item.text;
  overlay.appendChild(node);
  return { ...item, node };
});

function update() {
  const bearing = map.getBearing();
  for (const item of nodes) {
    // project 在特殊情况下可能返回 null(点位超出世界边界),必须判空
    const p = map.project(item.lngLat);
    if (!p) { item.node.hidden = true; continue; }
    item.node.hidden = false;
    item.node.style.transform =
      'translate(-50%, -50%) translate(' + p.x + 'px, ' + p.y + 'px) rotate(' + (-bearing) + 'deg)';
  }
}

map.on('move', update);
map.on('zoom', update);
update();

配套 CSS 至少要三条:position: absolute、left: 0; top: 0、容器 pointer-events: none(要点击则给子元素单独开 auto)。transform 里先 translate(-50%, -50%) 再按像素平移,是为了以元素中心对齐坐标点;顺序不能反,反了偏移量会被当成像素值。

几十个节点直接在 move 里写 transform 没问题;上百个就合并到 requestAnimationFrame,否则高刷屏上一帧写多次、触发多次样式重算。

12.5 自定义图层:CustomLayerInterface

需要逐帧 GPU 绘制时用 CustomLayerInterface(src/style/style_layer/custom_style_layer.ts)。它不是类,是一个接口,你提供普通对象或类实例,通过 map.addLayer() 加入:

成员 必需 说明
id 是 图层唯一标识
type 是 必须是 'custom'
renderingMode 否 '2d'(默认,无深度)或 '3d'(共享深度缓冲)
render(gl, options) 是 每帧调用,往主帧缓冲画
prerender(gl, options) 否 离屏渲染;有它时 MapLibre 会先跑一次 offscreen pass
onAdd(map, gl) 否 初始化 shader、buffer
onRemove(map, gl) 否 释放 GL 资源
renderToTerrainTile 否 实验性,把内容画进地形瓦片
terrainTileRevision 否 实验性,配合上一项触发重绘

render 收到的 options 是 CustomRenderMethodInput,字段很多,真正要先弄明白的是这几个:

type CustomRenderMethodInput = {
  farZ: number;                            // 远裁剪面距离
  nearZ: number;                           // 近裁剪面距离
  fov: number;                             // 垂直视场角(弧度)
  modelViewProjectionMatrix: mat4;         // 世界 → 裁剪空间
  projectionMatrix: mat4;                  // 视图 → 裁剪空间
  shaderData: {
    variantName: string;                   // 投影变体名,可作为 shader 缓存键
    vertexShaderPrelude: string;           // MapLibre 提供的投影函数前导代码
    define: string;                        // 宏定义(含 GLOBE 等)
  };
  defaultProjectionData: CustomLayerProjectionData; // mainMatrix 等
  getProjectionData: (params) => RendererProjectionData;
  renderTerrainHeightMap?: (target) => void;        // 实验性
};

关键事实:mixed 状态不可假设。文档写得很明确——混合函数被设成 gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA),即颜色要按预乘 alpha 给;深度状态由 renderingMode 决定,2d 模式不参与深度测试,3d 模式共享深度缓冲。你可以在 render 里改 GL 状态,但改完要还原,否则后续图层会画错。

12.5.1 最小可运行的自定义图层

下面这个例子在裁剪空间画一个跟随地图中心的红点,用来验证管线通不通:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <title>Custom Layer 最小示例</title>
  <link href="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.css" rel="stylesheet" />
  <style>body { margin: 0; } #map { height: 100vh; }</style>
</head>
<body>
  <div id="map"></div>
  <script src="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.js"></script>
  <script>
    const VERT = `#version 300 es
      uniform mat4 u_matrix;
      void main() {
        gl_Position = u_matrix * vec4(0.0, 0.0, 0.0, 1.0);
        gl_PointSize = 24.0;
      }`;
    const FRAG = `#version 300 es
      precision highp float;
      out vec4 fragColor;
      void main() { fragColor = vec4(1.0, 0.0, 0.0, 1.0); }`;

    function compile(gl, type, src) {
      const s = gl.createShader(type);
      gl.shaderSource(s, src);
      gl.compileShader(s);
      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
      return s;
    }

    const customLayer = {
      id: 'center-dot',
      type: 'custom',
      renderingMode: '2d',
      onAdd(map, gl) {
        this.program = gl.createProgram();
        gl.attachShader(this.program, compile(gl, gl.VERTEX_SHADER, VERT));
        gl.attachShader(this.program, compile(gl, gl.FRAGMENT_SHADER, FRAG));
        gl.linkProgram(this.program);
        if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
          throw new Error(gl.getProgramInfoLog(this.program));
        }
        this.uMatrix = gl.getUniformLocation(this.program, 'u_matrix');
      },
      render(gl, options) {
        gl.useProgram(this.program);
        // modelViewProjectionMatrix 是 Float32Array;上传时 uniformMatrix4fv 要传 false
        gl.uniformMatrix4fv(this.uMatrix, false, options.modelViewProjectionMatrix);
        gl.drawArrays(gl.POINTS, 0, 1);
      },
      onRemove(map, gl) {
        gl.deleteProgram(this.program);
      }
    };

    const map = new maplibregl.Map({
      container: 'map',
      style: 'https://demotiles.maplibre.org/style.json',
      center: [116.39, 39.9],
      zoom: 6
    });
    map.on('load', () => map.addLayer(customLayer));
  </script>
</body>
</html>

这个点钉在墨卡托世界原点经矩阵投影后的位置,不是地图中心。要画”某个经纬度上的点”,正确做法是先把 LngLat 转成 MercatorCoordinate 传进顶点属性,或用 shaderData.vertexShaderPrelude 提供的 projectTile。

12.5.2 什么时候必须调 triggerRepaint

MapLibre 是按需渲染:没有事件、没有动画、没有数据变化时不会重绘,render 也就不会被调用。状态变了却看不到画面更新,先检查这一点:

// 数据驱动的动画必须每帧主动请求重绘
let t0 = performance.now();
function tick(now) {
  const t = (now - t0) / 1000;
  customLayer.time = t;             // 渲染时读这个值
  map.triggerRepaint();             // 关键:下一帧再画一次
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

反过来,静止的自定义图层不需要每帧 triggerRepaint,否则等于把地图变成满帧运行的 WebGL 应用,风扇和电量都会抗议。

12.6 排错

现象 原因 修法
自定义图层完全看不见 忘了 map.triggerRepaint(),或 render 里没 useProgram 检查渲染循环;确认 shader 编译通过
图层位置随缩放漂移 用了 projectionMatrix 而不是 modelViewProjectionMatrix 世界坐标走 M-V-P
颜色发灰、边缘透 按非预乘 alpha 给了颜色 预乘 rgb *= a,或改混合函数并还原
setStyle(map.getStyle()) 后自定义图层消失 Style.serialize() 会跳过 type === 'custom' 的图层 换样式后重新 addLayer,或把图层注册逻辑收敛到 style.load
弹窗遮挡时不隐藏 没设 locationOccludedOpacity 显式传入,或监听 terrain 事件自己判断
弹窗内容高度变化后位置错 Popup 只在 setLngLat/move 时更新 内容变更后调用 popup.setLngLat(popup.getLngLat()) 强制刷新

第四条是从源码里确认的:Style._serializedAllLayers() 里有 if (layer.type !== 'custom') 判断,自定义图层不会出现在 getStyle() 的结果里。因此”先取样式再重新设上去”这种换肤写法,会把自定义图层悄悄清掉。

12.7 生产建议

数量分级。 Marker 超过约 200 个就应该换成 symbol 图层(circle 或用 icon-image 的 symbol),后者走 GPU、自带碰撞检测与聚合。几百个 DOM 节点在地图平移时会触发大量样式重算,中低端手机掉帧明显。

Popup 只保留一个实例。 常见写法是每次点击 new Popup(),忘了 remove,DOM 会越积越多。用单个复用的 Popup 实例,setLngLat + setDOMContent 替换内容。

自定义图层的资源生命周期绑定 onAdd/onRemove。 setStyle 会移除并重建全部图层,onRemove 里不删 program/buffer 就是 GPU 内存泄漏;同时要处理 webglcontextlost/webglcontextrestored,WebGL 上下文丢失后所有 GL 对象失效,必须重建。

12.8 本章小结

要点 事实
三分法 DOM 覆盖物(Marker/Popup/自绘)走 project,像素坐标;Custom Layer 走矩阵,裁剪空间
Marker 默认 offset 内置图钉 [0, -14],自定义 element [0, 0],混用会错位
遮挡 Marker 用 opacityWhenCovered: 0.2 + maplibregl-marker-covered 类;Popup 用 locationOccludedOpacity
Popup 默认 closeOnClick: true、closeButton: true、maxWidth: '240px'、anchor 动态计算
自定义图层 必须 type: 'custom',混合固定为预乘 alpha,静止时不会自动重绘,要 triggerRepaint()
序列化陷阱 getStyle() 不含 custom 图层,换样式后需重新添加
内容安全 setHTML 不做转义,外部数据用 setText 或 textContent