第07章 - 图层 Layer 体系详解
7.1 图层是排序的”绘制单元”
地图上的每一块内容都属于某个图层。图层有三个作用:决定画什么(source + source-layer + filter)、决定怎么画(type + paint + layout)、决定画在哪一层(在 layers 数组里的位置)。
后一条最容易被忽视。样式里 layers 的数组顺序就是绘制顺序,后来的在上。加图层时如果没有指定插入位置,它就加在最顶层,遮住已有的标注:
// 错误:新图层被追加到最上面,把 label 图层盖住了
map.addLayer({id: 'highlight', type: 'fill', source: 'districts', paint: {'fill-color': '#ff0'}});
// 正确:插到某个已有图层之前
map.addLayer(
{id: 'highlight', type: 'fill', source: 'districts', paint: {'fill-color': '#ff0'}},
'label-city' // before 参数:插到该图层之前
);
// 不写 before 时,也可以用 moveLayer 事后调整
map.moveLayer('highlight', 'label-city');
before 必须是当前样式里真实存在的图层 id,否则会报 There is no layer with ID=...。想在”所有标注之下、所有面之上”插入,最稳的做法是先记下目标图层的 id,再传 before。
7.2 各类型的属性范围
图层的类型决定了它能用哪些属性。把每类的 layout 与 paint 列出来,比翻文档快:
| 类型 | layout 属性 | paint 属性(关键项) |
|---|---|---|
background |
仅 visibility |
background-color、background-opacity、background-pattern |
fill |
fill-sort-key、visibility |
fill-antialias、fill-opacity、fill-color、fill-outline-color、fill-translate、fill-pattern |
line |
line-cap、line-join、line-miter-limit、line-round-limit、line-sort-key、visibility |
line-opacity、line-color、line-translate、line-width、line-gap-width、line-offset、line-blur、line-dasharray、line-pattern、line-gradient |
symbol |
30 余项,见 7.3 | 14 项:icon-* 与 text-* 各一组 |
circle |
circle-sort-key、visibility |
circle-radius、circle-color、circle-blur、circle-opacity、circle-translate、circle-pitch-scale、circle-pitch-alignment、circle-stroke-* |
heatmap |
仅 visibility |
heatmap-radius、heatmap-weight、heatmap-intensity、heatmap-color、heatmap-opacity |
fill-extrusion |
fill-extrusion-rounded-corner-distance、visibility |
fill-extrusion-height、-base、-color、-opacity、-pattern、-translate、-vertical-gradient |
raster |
仅 visibility |
raster-opacity、raster-fade-duration、raster-resampling、raster-contrast 等 |
hillshade |
仅 visibility |
hillshade-illumination-direction(默认 335)、-altitude、-anchor、-exaggeration(0.5)、-method、resampling |
color-relief |
仅 visibility |
color-relief-color、color-relief-opacity、resampling |
注意 raster、hillshade、color-relief、heatmap 四类的 layout 只有 visibility——它们没有”几何生成”这一步,所有属性都在 paint 侧。
7.2.1 fill 的描边陷阱
fill-outline-color 不是”给多边形的边描边”,它只在填充的边缘画 1 像素线,而且依赖 fill-antialias: true。要可控线宽的边,叠一个 line 图层:
// 面
map.addLayer({
id: 'district-fill', type: 'fill', source: 'districts',
paint: {'fill-color': '#9ecae1', 'fill-opacity': 0.6}
});
// 边(用同一个 source,几何会被复用)
map.addLayer({
id: 'district-outline', type: 'line', source: 'districts',
paint: {'line-color': '#3182bd', 'line-width': 1.5}
});
7.2.2 line 的双线技巧
line-gap-width 可以画出”外描边”效果,不用叠两个图层:主线宽 w,设置 line-gap-width: w 后,线被渲染成两条相距 w 的边。配合 line-offset,可以做出道路的立体感。
map.addLayer({
id: 'road-outline', type: 'line', source: 'roads', 'source-layer': 'transportation',
layout: {'line-join': 'round', 'line-cap': 'round'},
paint: {'line-color': '#5b5b5b', 'line-width': 7}
});
map.addLayer({
id: 'road-casing', type: 'line', source: 'roads', 'source-layer': 'transportation',
layout: {'line-join': 'round', 'line-cap': 'round'},
paint: {'line-color': '#ffffff', 'line-width': 5}
});
这两个图层的 layout 完全一致,因此会共享同一个 Bucket(见 3.5),几何只算一遍。把 line-cap 从 round 改成 butt,就会立刻多出一份完整几何。
7.3 symbol:唯一需要碰撞计算的类型
symbol 的属性最多,因为图标和文字各有自己的一整套几何与锚点。规范里 layout_symbol 共 47 个字段,常用分组如下:
| 分组 | 属性 | 说明 |
|---|---|---|
| 通用 | symbol-placement |
point / line / line-center,决定标注贴在点上还是沿线上 |
| 通用 | symbol-spacing |
line 模式下相邻标注的最小间距(像素) |
| 通用 | symbol-sort-key |
碰撞优先级,越小越优先保留 |
| 通用 | symbol-z-order |
auto / viewport-y / source,控制叠放 |
| 图标 | icon-image |
sprite 中的图片名 |
| 图标 | icon-size / icon-rotate / icon-offset / icon-anchor |
尺寸、旋转、偏移、锚点 |
| 图标 | icon-allow-overlap / icon-ignore-placement |
是否允许重叠、是否参与碰撞 |
| 文字 | text-field |
文本内容,支持 {name} token 或表达式 |
| 文字 | text-font / text-size / text-max-width |
字体栈、字号、换行宽度(em) |
| 文字 | text-transform / text-letter-spacing / text-line-height |
大小写、字距、行高 |
| 文字 | text-anchor / text-variable-anchor / text-offset |
锚点与偏移,variable-anchor 会在候选位置间自动择优 |
| 文字 | text-allow-overlap / text-ignore-placement / text-padding |
碰撞相关 |
| 文字 | text-writing-mode |
竖排文字 |
| 倾斜相关 | icon-pitch-alignment / text-pitch-alignment / icon-rotation-alignment / text-rotation-alignment |
选择 map 时贴着地图旋转,viewport 时保持屏幕朝向 |
text-variable-anchor 是提升标注可读性的利器:给它一组候选锚点(如 ["top", "bottom", "left", "right"]),碰撞时会在这些位置里找空位。
map.addLayer({
id: 'poi-label',
type: 'symbol',
source: 'poi',
layout: {
'text-field': ['get', 'name'],
'text-font': ['Noto Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 11, 16, 15],
'text-max-width': 8,
'text-variable-anchor': ['top', 'bottom', 'left', 'right'],
'text-radial-offset': 0.6,
'icon-image': ['get', 'icon'],
'icon-size': 0.8,
'symbol-sort-key': ['get', 'rank']
},
paint: {
'text-color': '#222',
'text-halo-color': '#fff',
'text-halo-width': 1.4,
'icon-opacity': 0.95
}
});
text-halo-width 是保证可读性的关键参数。它只在 text-halo-color 与 text-halo-width 同时存在时生效,常见组合是白色描边 1–2 px。
字体缺失是最常见的”标注不显示”原因。 text-font 里的每个字体名都必须在 glyphs 服务里存在。写了一个服务端没有的字体,这个图层的文字会整体不出现,控制台可能只有一条不显眼的 404。
7.4 图层的动态控制
7.4.1 可见性
visibility 是最便宜的开关。设为 none 后该图层不参与绘制,但样式与 source 都保留,切回来是瞬时的。
map.setLayoutProperty('poi-label', 'visibility', 'none');
map.setLayoutProperty('poi-label', 'visibility', 'visible');
相比之下 removeLayer 会销毁图层对象及其 Bucket 引用,再加回来要重新解析。
7.4.2 zoom 范围
setLayerZoomRange(id, minzoom, maxzoom) 等价于改 layout 之外的图层公共字段,比手动改样式对象安全:
// 只在 8 到 14 层显示
map.setLayerZoomRange('district-fill', 8, 14);
// 只设下限
map.setLayerZoomRange('district-fill', 8, null);
注意 minzoom/maxzoom 的语义:zoom < minzoom 或 zoom >= maxzoom 时隐藏,上限是开区间。
7.4.3 filter 与 feature-state
setFilter 适合”按属性筛要素”。要做”选中某个要素并高亮”,用 setFeatureState 更省:
// 一次注册,之后只改状态
map.addLayer({
id: 'road-line',
type: 'line',
source: 'roads',
'source-layer': 'transportation',
paint: {
'line-color': [
'case',
['boolean', ['feature-state', 'selected'], false],
'#ff6b35', // 选中
'#7a7a7a' // 默认
],
'line-width': [
'case',
['boolean', ['feature-state', 'selected'], false],
6,
2
]
}
});
// 选中
map.setFeatureState({source: 'roads', id: roadId}, {selected: true});
// 取消
map.removeFeatureState({source: 'roads', id: roadId}, 'selected');
前提是 source 有稳定的 feature id:矢量瓦片要配 promoteId,GeoJSON 可以用 generateId 或 promoteId。没有 id,setFeatureState 无处落地。
7.5 查询图层要素
三类查询各有用途,别混用:
| 方法 | 查询范围 | 是否受样式影响 | 典型用途 |
|---|---|---|---|
queryRenderedFeatures |
当前屏幕上已渲染的要素 | 是(filter、zoom、碰撞都算) | 点击拾取、悬停高亮 |
querySourceFeatures |
某个 source 已加载的所有瓦片要素 | 否(但需要瓦片已加载) | 统计、导出当前视图数据 |
queryTerrainElevation |
某个经纬度的高程 | — | 地形上的位置计算 |
// 点击拾取:限定图层与半径,性能最好
map.on('click', (e) => {
const hits = map.queryRenderedFeatures(
[[e.point.x - 5, e.point.y - 5], [e.point.x + 5, e.point.y + 5]],
{layers: ['road-line', 'poi-label']}
);
console.log(hits.map((f) => [f.layer.id, f.properties.name]));
});
// 统计当前已加载的要素(不受 filter 影响)
const all = map.querySourceFeatures('roads', {sourceLayer: 'transportation'});
console.log('已加载要素数:', all.length);
queryRenderedFeatures 返回的 feature 带 layer、source、properties、geometry,但 geometry 是屏幕坐标系下的简化几何,不是原始地理坐标。要做空间计算,用 f.geometry 前先确认坐标系,或改用 querySourceFeatures 拿瓦片坐标再转换。
7.6 图层事件
图层级事件是 map.on(type, layerId, listener) 的简写,内部会先做一次 queryRenderedFeatures:
const layerEvents = [
'click', 'dblclick', 'mousemove',
'mouseenter', 'mouseleave',
'mouseover', 'mouseout', 'contextmenu'
];
for (const type of layerEvents) {
map.on(type, 'poi-label', (e) => {
// 只有点的位置落在该图层要素上才会进来
console.log(type, e.features[0].properties);
});
}
几个限制:图层必须已经存在,否则注册成功但永不触发;mouseenter/mouseleave 是按要素去重后的结果,快速移动时可能漏事件;移动端没有鼠标语义,mouseenter 不会触发,要用 click。
7.7 三维图层
fill-extrusion 是从面挤出体块的图层,常用于建筑。两个高度属性决定立体形状:
map.addLayer({
id: 'building-3d',
type: 'fill-extrusion',
source: 'buildings',
'source-layer': 'building',
minzoom: 14,
paint: {
'fill-extrusion-color': [
'interpolate', ['linear'], ['get', 'height'],
0, '#d9d9d9',
50, '#bdbdbd',
200, '#969696'
],
'fill-extrusion-height': ['get', 'height'],
'fill-extrusion-base': ['get', 'min_height'],
'fill-extrusion-opacity': 0.85,
// 侧面明暗渐变,增强体积感(默认 true)
'fill-extrusion-vertical-gradient': true
}
});
fill-extrusion-height 默认 0,所以只写 type 不写高度时看到的是平的。base 用于”建筑从某个高度起算”,例如高架桥面;两个都用 ["get", ...] 时才是数据驱动。
倾斜视角需要 pitch > 0;maxPitch 默认 60,够用。若要让相机深入楼群,需要配合 centerClampedToGround 与地形。
7.8 排错清单
图层加了但不显示。 按顺序查:source 是否存在、source-layer 是否匹配、minzoom/maxzoom 是否排除当前层级、filter 是否筛空、layout.visibility 是否为 none、图层是否被更上层的图层完全遮住。
There is no layer with ID=xxx。 before 参数指向了不存在的图层,或 removeLayer 了一个不存在的 id。
改属性没反应。 检查是不是把 paint 属性写进了 layout(或反之)。写错位置时校验会报错,但如果关了 validateStyle,可能静默忽略。
标注互相压盖。 三个手段:调 symbol-sort-key 给重要要素更高优先级、加 text-padding 让碰撞框更保守、用 text-variable-anchor 增加候选位置。
文字不显示但图标正常。 字体名在 glyphs 服务里不存在,或 glyphs URL 模板错误。用浏览器网络面板搜 .pbf 请求。
line-dasharray 不生效。 line-dasharray 与 line-pattern 互斥;另外它要求线宽与虚线长度比例合理,过小的 dash 值在某些渲染后端上会被忽略。
feature-state 高亮在缩放后丢失。 promoteId 没配,或 source 被 setData 替换后 feature id 变了。跨缩放保持一致的前提是 id 稳定。
7.9 生产建议
图层数量控制在几十个以内。 每多一个图层就多一轮绘制与状态切换。把同色同宽的要素合并到同一图层,用表达式区分样式,比拆成十个图层快。
把”开关类”图层合并。 例如”道路、铁路、水系”三类都可能有可见性开关,用 visibility 控制比动态增删图层稳定,也便于做 URL 状态同步。
用 setPaintProperty 而不是重建样式。 主题切换、颜色高亮都只该改属性。重建样式会触发 sprite/glyphs 检查,可能引发全量更新。
给数据驱动属性设上限。 ["get", "height"] 这类表达式为每个顶点写数据。要素超过十万时,考虑在数据侧预计算分档,用 step 减少取值精度。
图层顺序写进配置而不是硬编码。 用常量定义 z-order(底图 → 面 → 线 → 点 → 标注 → 交互高亮),所有 addLayer 都按这份顺序计算 before,避免不同模块互相插队。
7.10 本章小结
| 主题 | 结论 |
|---|---|
| 顺序 | layers 数组顺序即绘制顺序,后加的在上;用 before 或 moveLayer 定位 |
| 类型 | 10 种规范类型;layout 只影响几何,paint 影响像素 |
| symbol | 47 个 layout 属性;text-halo-width + text-halo-color 是可读性关键;字体必须存在于 glyphs |
| line | 同 layout 的图层共享 Bucket;line-gap-width 可做双线 |
| 动态控制 | visibility 最便宜;setFilter 筛要素;setFeatureState 做选中态(需稳定 id) |
| 查询 | 点击用 queryRenderedFeatures(受样式影响),统计用 querySourceFeatures |
| 3D | fill-extrusion-height 默认 0,必须显式给值 |
| 常见坑 | before 图层不存在、字体缺失、filter 筛空、feature id 不稳定 |