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

两个细节值得记住:

  1. 同一角落的堆叠顺序不一致。 top-* 用 appendChild(后加的离角落更远),bottom-* 用 insertBefore(firstChild)(后加的离边缘更近)。想让两个控件保持固定顺序,别依赖添加先后,而应自己写一个”容器控件”把多个元素包进同一个 div 再 addControl。
  2. 构造时不校验 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'),非法控件只发事件不抛异常