第11章 - 控件与自定义 UI
11.1 控件在哪一层
MapLibre 的控件(Control)是纯 DOM,不属于 WebGL 渲染管线。你在页面上看到的缩放按钮、比例尺、版权条,都是插入到地图容器里的 div/button,不占 GPU 的任何一帧渲染时间;它们只在 move、zoom、resize 这类事件触发时才更新自己的文字或 transform。
这一点决定了控件的能力边界:控件可以覆盖在 canvas 之上、接收点击、用任意 CSS 布局,但它看不到图层内部数据,也拿不到逐帧渲染的钩子。需要逐帧自定义绘制时要用 Custom Layer(见第 12 章),需要响应相机变化用事件,两者不要混用。
Map 构造时会在地图容器里建一个控件容器,以及四个定点容器:
div.maplibregl-map
├── div.maplibregl-canvas-container (canvas,WebGL 绘制)
└── div.maplibregl-control-container
├── div.maplibregl-ctrl-top-left z-index: 2, pointer-events: none
├── div.maplibregl-ctrl-top-right
├── div.maplibregl-ctrl-bottom-left
└── div.maplibregl-ctrl-bottom-right
源码在 Map._setupContainer() 里用循环创建这四个容器(src/ui/map.ts):
const controlContainer = this._controlContainer = DOM.create('div', 'maplibregl-control-container', container);
const positions = this._controlPositions = {};
for (const positionName of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) {
positions[positionName] = DOM.create('div', `maplibregl-ctrl-${positionName} `, controlContainer);
}
配套 CSS(src/css/maplibre-gl.css)里,四个定点容器是 position: absolute; pointer-events: none; z-index: 2,控件自身带 pointer-events: auto。pointer-events 这一对设置很关键:容器不吞事件,鼠标可以穿过空白区域拖地图;控件本体接管事件,按钮才能点。
11.2 IControl 契约
自定义控件要实现 IControl(src/ui/control/control.ts),只有两条硬性要求,外加一个可选方法:
| 成员 | 类型 | 是否必需 | 说明 |
|---|---|---|---|
onAdd(map) |
(map: Map) => HTMLElement |
必需 | 返回控件根元素,由 map 负责插入 DOM,自己不要 appendChild 到地图容器 |
onRemove(map) |
(map: Map) => void |
必需 | 移除 DOM、解绑事件、释放资源 |
getDefaultPosition() |
() => ControlPosition |
可选 | 不给时 addControl 用 'top-right' |
addControl 的挂载逻辑(src/ui/map.ts):
addControl(control: IControl, position?: ControlPosition): this {
if (position === undefined) {
if (control.getDefaultPosition) {
position = control.getDefaultPosition();
} else {
position = 'top-right';
}
}
if (!control?.onAdd) {
return this.fire(new ErrorEvent(new Error(
'Invalid argument to map.addControl(). Argument must be a control with onAdd and onRemove methods.')));
}
const controlElement = control.onAdd(this);
this._controls.push(control);
const positionContainer = this._controlPositions[position];
if (position.includes('bottom')) {
positionContainer.insertBefore(controlElement, positionContainer.firstChild);
} else {
positionContainer.appendChild(controlElement);
}
return this;
}
两个细节值得记住:
- 同一角落的堆叠顺序不一致。
top-*用appendChild(后加的离角落更远),bottom-*用insertBefore(firstChild)(后加的离边缘更近)。想让两个控件保持固定顺序,别依赖添加先后,而应自己写一个”容器控件”把多个元素包进同一个div再addControl。 - 构造时不校验 onRemove。 只检查
onAdd就直接调用,onRemove要到移除时才用。写自定义控件时漏掉onRemove不会立刻报错,但removeControl会抛异常。addControl传入不合法的对象不会抛错,而是派发error事件——如果你的代码”按钮没出来也不报错”,先map.on('error', console.error)看有没有这个错误。
11.3 内置控件与默认值
v6.17.0 导出九个控件类加一个 Hash 类(src/index.ts)。默认只挂 AttributionControl;LogoControl 由 maplibreLogo: true 打开,其余都要自己 addControl。
| 控件 | 构造参数 | 关键默认值 | getDefaultPosition |
|---|---|---|---|
NavigationControl |
showCompass、showZoom、visualizePitch、visualizeRoll |
四个都开,visualizePitch: false、visualizeRoll: true |
无 → top-right |
ScaleControl |
maxWidth、unit |
maxWidth: 100(像素)、unit: 'metric' |
bottom-left |
AttributionControl |
compact、customAttribution |
compact: true;容器宽度 ≤ 640px 自动折叠 |
bottom-right |
FullscreenControl |
container、pseudo |
pseudo: false(走原生 Fullscreen API) |
无 → top-right |
GeolocateControl |
positionOptions、fitBoundsOptions、trackUserLocation、showAccuracyCircle、showUserLocation、zoomToUserAccuracy |
{enableHighAccuracy: false, maximumAge: 0, timeout: 6000}、fitBoundsOptions: {maxZoom: 15}、trackUserLocation: false |
无 → top-right |
LogoControl |
compact |
宽度 ≤ 640px 自动紧凑 | bottom-left |
GlobeControl |
无 | 在 mercator / globe 间切换 |
无 → top-right |
TerrainControl |
TerrainSpecification(必传 source) |
调用 setTerrain(options) / setTerrain(null) |
无 → top-right |
Hash |
hashName(默认 'map' 前缀) |
与 Map 的 hash: true 等价能力 |
无 → top-right |
只实现了 getDefaultPosition 的三个控件是 AttributionControl、LogoControl、ScaleControl——用 Select-String 在 src/ui/control/ 里搜一遍就能验证,其余的落到 top-right。
11.3.1 NavigationControl 的罗盘角度
visualizePitch 和 visualizeRoll 决定罗盘指针怎么转,代码里是一串 rotateX/rotateZ 组合:
_rotateCompassArrow = (): void => {
const pitch = this._map.getPitch();
const roll = this._map.getRoll();
const bearing = this._map.getBearing();
const pitchScale = 1 / Math.pow(Math.cos(degreesToRadians(pitch)), 0.5);
if (this.options.visualizePitch && this.options.visualizeRoll) {
this._compassIcon.style.transform = `scale(${pitchScale}) rotateZ(${-roll}deg) rotateX(${pitch}deg) rotateZ(${-bearing}deg)`;
return;
}
// …其余分支省略
};
visualizePitch: true 时会额外监听 pitch 事件;否则罗盘只跟 rotate(以及 roll)。代价是每次 pitch/roll/rotate 都要改一次内联 transform,在倾斜视角下拖动地图会持续触发。地图长时间保持斜视角且不需要指示俯仰时,关掉它更省。
11.3.2 ScaleControl 的量算方式
比例尺不是按公式硬算一个漂亮数字,而是取容器中点、向左右各推 maxWidth / 2 像素,反投影成两个 LngLat,再用球面余弦距离求实际米数,最后向下取整成”好看”的数:
function updateScale(map, container, options) {
const optWidth = options?.maxWidth || 100;
const {width: containerWidth, height: containerHeight} = map._camera.transform;
const y = containerHeight / 2;
const x = containerWidth / 2;
const left = map.unproject([x - optWidth / 2, y]);
const right = map.unproject([x + optWidth / 2, y]);
if (!left || !right) return;
const globeWidth = Math.round(map.project(right).x - map.project(left).x);
const maxWidth = Math.min(optWidth, globeWidth, containerWidth);
const maxMeters = left.distanceTo(right);
// …按 unit 分支取 km/m/mi/ft/nm 并 setScale
}
比例尺长度不会超过 maxWidth,也不会超过容器宽度:maxWidth: 100 时窄屏上它会变短而不是溢出。distanceTo 按球面距离算,mercator 下纬度 40° 附近约有 30% 的实地偏差,这是投影性质而非控件错误。
11.3.3 AttributionControl 的折叠
compact: true 时,容器宽度 ≤ 640px 才折叠成圆形 i 按钮,否则展开显示全部版权文字;用户拖动地图时还会自动收起已展开的面板。源码判断的是 map.getCanvasContainer().offsetWidth <= 640,不是 window.innerWidth——把它嵌进 500px 的侧边栏里,即使桌面浏览器也会折叠。想要永远展开,传 compact: false:
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [116.39, 39.9],
zoom: 5,
attributionControl: false // 先关掉默认的
});
map.addControl(new maplibregl.AttributionControl({
compact: false,
customAttribution: ['© 我的数据源', '<a href="https://example.com">机构名称</a>']
}), 'bottom-right');
customAttribution 接受字符串或字符串数组,其中的 HTML 会被插入 DOM。来源是外部数据时不要直接拼进去,先用白名单清洗。
11.4 从零写一个可用控件
下面是一个完整可运行页面:一个显示实时相机参数的控件,一个”回到初始视角”按钮,以及本地化配置。用 CDN 引入,无需构建。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>自定义控件示例</title>
<link href="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.css" rel="stylesheet" />
<style>
body { margin: 0; }
#map { height: 100vh; }
/* 自定义控件的外观全部交给 CSS 类,不在 JS 里写行内样式 */
.camera-panel {
background: rgba(255, 255, 255, 0.92);
border-radius: 4px;
padding: 6px 10px;
font: 12px/1.6 ui-monospace, Menlo, Consolas, monospace;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
min-width: 168px;
}
.camera-panel__row { display: flex; justify-content: space-between; gap: 12px; }
.camera-panel__key { color: #666; }
.camera-panel--pinned { border: 1px solid #3fb1ce; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/maplibre-gl@6.17.0/dist/maplibre-gl.js"></script>
<script>
class CameraReadoutControl {
constructor() {
this._container = null;
this._map = null;
this._render = () => { /* 由 onAdd 覆盖 */ };
}
onAdd(map) {
this._map = map;
// 根节点必须带 maplibregl-ctrl 类:它负责 pointer-events: auto
this._container = document.createElement('div');
this._container.className = 'maplibregl-ctrl camera-panel';
this._render = () => {
const c = map.getCenter();
this._container.innerHTML =
'<div class="camera-panel__row"><span class="camera-panel__key">lng</span><span>' + c.lng.toFixed(5) + '</span></div>' +
'<div class="camera-panel__row"><span class="camera-panel__key">lat</span><span>' + c.lat.toFixed(5) + '</span></div>' +
'<div class="camera-panel__row"><span class="camera-panel__key">zoom</span><span>' + map.getZoom().toFixed(2) + '</span></div>' +
'<div class="camera-panel__row"><span class="camera-panel__key">pitch</span><span>' + map.getPitch().toFixed(1) + '°</span></div>';
};
this._render();
// 相机每帧变化都会触发 move;高频渲染下用 requestAnimationFrame 合并更新
this._raf = null;
this._onMove = () => {
if (this._raf !== null) return;
this._raf = requestAnimationFrame(() => {
this._raf = null;
this._render();
});
};
map.on('move', this._onMove);
return this._container;
}
onRemove() {
if (this._raf !== null) cancelAnimationFrame(this._raf);
this._map.off('move', this._onMove);
this._container.remove();
this._map = null;
this._container = null;
}
getDefaultPosition() {
return 'top-left';
}
}
const map = new maplibregl.Map({
container: 'map',
style: 'https://demotiles.maplibre.org/style.json',
center: [116.39, 39.9],
zoom: 5,
locale: {
'NavigationControl.ZoomIn': '放大',
'NavigationControl.ZoomOut': '缩小',
'NavigationControl.ResetBearing': '拖动旋转,点击回正北',
'ScaleControl.Kilometers': '千米',
'AttributionControl.ToggleAttribution': '展开版权信息'
}
});
const camera = new CameraReadoutControl();
map.addControl(camera);
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right');
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
// 一个"回到初始视角"按钮:内部直接调用 map API,不需要独占控件容器
class HomeControl {
onAdd(m) {
const div = document.createElement('div');
div.className = 'maplibregl-ctrl maplibregl-ctrl-group';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'maplibregl-ctrl-home';
btn.title = '回到初始视角';
btn.setAttribute('aria-label', '回到初始视角');
btn.textContent = '⌂';
btn.addEventListener('click', () => m.easeTo({ center: [116.39, 39.9], zoom: 5, bearing: 0, pitch: 0, duration: 600 }));
div.appendChild(btn);
this._div = div;
return div;
}
onRemove() { this._div.remove(); }
getDefaultPosition() { return 'bottom-right'; }
}
map.addControl(new HomeControl());
</script>
</body>
</html>
这个页面能验证三件事:控件根节点带 maplibregl-ctrl 后鼠标事件不再穿透;locale 里的键名生效于工具提示(title 与 aria-label 同时被设置);easeTo 的 duration 单位是毫秒。
11.4.1 依赖 npm 的工程写法
npm install maplibre-gl@6.17.0
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
const map = new maplibregl.Map({ /* … */ });
map.addControl(new maplibregl.NavigationControl(), 'top-right');
// IControl 是"类型",运行时并不存在,需要 type 导入
/** @typedef {import('maplibre-gl').IControl} IControl */
IControl、ControlPosition、NavigationControlOptions 这些在 src/index.ts 里都是 export type。TypeScript 下要写 import type {IControl} from 'maplibre-gl',用普通 import 会编译报错或被 tree-shaking 删除。
11.5 定位、层级与样式覆盖
控件位置是 CSS 决定的,不是 JS 参数决定的。要让控件偏离标准的四角,有两条路:
路线 A:覆盖 .maplibregl-ctrl-* 容器的边距。 四个容器是 position: absolute 的零尺寸锚点,控件靠 margin 与 float 定位。改边距即可整体平移,适合给顶部工具栏让位:
/* 若页面有 56px 高的固定页头,压在地图上 */
.maplibregl-ctrl-top-right .maplibregl-ctrl { margin-top: 66px; }
路线 B:把控件挂到地图外的容器。 严格说 addControl 只能挂到四角,但可以把控件根元素的样式改成绝对定位到任意位置,或者干脆不用 addControl,自己维护一个 DOM 面板,用 map.on('move') 手动同步信息。地图外的面板更灵活,代价是要自己处理 map.remove() 时的清理。
层级方面,四个定点容器是 z-index: 2,Marker 与 Popup 是 position: absolute 且带 will-change: transform,由各自类名控制。Popup 的容器 pointer-events 为 none,可交互内容需要显式打开。控件和弹窗叠在一起时,先检查是不是同一 z-index 打架,不要一上来就加 !important。
11.6 排错:五个常见症状
| 症状 | 直接原因 | 修法 |
|---|---|---|
| 控件看得见、点不动 | 根节点没带 maplibregl-ctrl,继承了父容器的 pointer-events: none |
给根元素加上 maplibregl-ctrl 类 |
addControl 后毫无反应也不报错 |
对象缺少 onAdd,走了 fire(new ErrorEvent(...)) |
监听 map.on('error', ...),检查是否有 onAdd/onRemove |
removeControl 抛异常 |
实现里没有 onRemove |
补上 onRemove,在其中 this._container.remove() |
| 控件重复出现两份 | 同一个实例被 addControl 两次,DOM 被移走一次再挂一次 |
addControl 前用 map.hasControl(ctrl) 判断 |
| 地图销毁后仍报错 | onRemove 没解绑 move/resize 监听,或没清 requestAnimationFrame |
在 onRemove 里成对清理 |
第二种最隐蔽。addControl 对非法参数不抛异常:
const bad = { onAdd: undefined };
map.addControl(bad); // 不会 throw
map.on('error', (e) => console.error(e.error.message));
// Error: Invalid argument to map.addControl(). Argument must be a control with onAdd and onRemove methods.
hasControl 只比较对象引用(this._controls.includes(control)),两个内容相同但不同的实例都会被认为”没有”。想实现”同类型只允许一个”,自己记一个字段或在 onAdd 里做幂等判断。
另外,控件在 Map 构造完成、样式加载之前就能添加——它不依赖 style。但如果控件要读图层或数据源,必须等 load 或 style.load;在 setStyle 之后所有非默认控件都需要重新 addControl,因为换样式时 Map 会移除全部控件再重建。
11.7 生产建议
控件数量控制在 6 个以内。 每个控件至少绑定一到两个 map 事件,十个控件意味着每次 move 有十几次 DOM 写入。移动端把相机读数这类高频控件换成 requestAnimationFrame 合并更新,或只在 moveend 刷新。
自绘控件优先复用内置类名。 maplibregl-ctrl-group 已提供圆角、阴影、按钮分隔线与深色模式配色,maplibregl-ctrl-icon 负责 SVG 背景。自定义按钮套进这两层,视觉与官方一致,也自动获得 forced-colors 高对比适配。
本地化只覆盖用得到的键。 locale 会与内置表浅合并({...defaultLocale, ...options.locale}),不需要抄全表。键名写错不会警告,只是不生效——核对 src/ui/default_locale.ts 里的 26 个键。
别在控件里直接改地图样式的行内属性。 控件是 DOM,样式走 CSS 类;地图的 canvas 尺寸由 Map 自己管理,改 canvas 的宽高会导致相机与视口不一致,出现点击偏移。
销毁顺序:先 removeControl,再 map.remove()。 map.remove() 会移除控件容器,但不会调用每个控件的 onRemove,控件自己注册的外部监听和定时器需要显式清掉。
11.8 本章小结
| 要点 | 事实 |
|---|---|
| 控件层级 | 纯 DOM,在 div.maplibregl-control-container 下的四个定点容器里,z-index: 2,不参与 WebGL 渲染 |
| 契约 | 必须实现 onAdd/onRemove,getDefaultPosition 可选,缺省位置 top-right |
| 默认控件 | 只有 AttributionControl;LogoControl 由 maplibreLogo: true 打开 |
| 堆叠差异 | top-* 用 appendChild,bottom-* 用 insertBefore,同角落顺序不自洽 |
| 关键类名 | 根节点 maplibregl-ctrl(恢复 pointer-events)、成组控件 maplibregl-ctrl-group |
| 本地化 | locale 与内置表浅合并,键名取自 src/ui/default_locale.ts |
| 排错入口 | map.on('error'),非法控件只发事件不抛异常 |