第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 |