第07章 - 矢量图层与路径绘制
矢量图层是 Leaflet 里唯一”用代码画出来”的图层。它的类层次是:
Path(抽象基类,定义样式与事件)
├── Polyline(折线)→ Polygon(多边形)→ Rectangle(矩形)
└── CircleMarker(像素半径圆)→ Circle(米半径圆)
Path 通过 Renderer 绘制:SVG(默认)或 Canvas
这条链上有两个容易混淆的分界:样式在 Path 上定义,所以折线、多边形、圆共享同一套 color / weight / fill 选项;几何语义在子类上定义,Polyline 的坐标是”一串点”,Polygon 是”外环 + 若干内环”,Circle 的半径单位是米而 CircleMarker 是像素。
7.1 Path:共享的样式系统
7.1.1 默认值
src/layer/vector/Path.js 的默认选项:
| 选项 | 默认值 | 说明 |
|---|---|---|
stroke |
true |
是否描边 |
color |
'#3388ff' |
描边颜色 |
weight |
3 |
描边宽度(像素) |
opacity |
1 |
描边透明度 |
lineCap |
'round' |
线端样式:butt / round / square |
lineJoin |
'round' |
拐角样式:miter / round / bevel |
dashArray |
null |
虚线样式,如 '5,10' 或 [5, 10] |
dashOffset |
null |
虚线起始偏移 |
fill |
false |
是否填充;Polygon / Circle 覆盖为 true(CircleMarker 也是 true) |
fillColor |
null |
填充色;为 null 时取 color |
fillOpacity |
0.2 |
填充透明度 |
fillRule |
'evenodd' |
内环判定规则 |
interactive |
true |
是否响应指针事件 |
bubblingPointerEvents |
true |
事件是否冒泡到地图(与 Marker 的 false 相反) |
renderer |
undefined |
指定渲染器;设置后会覆盖 pane |
fillColor 的”取 color“行为在源码里是渲染时判断的,不是构造时赋值。所以运行时改 color 后如果没显式设过 fillColor,填充色会跟着变。
7.1.2 样式设置的三个方法
import {LeafletMap, TileLayer, Polyline, Polygon, Circle} from 'leaflet';
const map = new LeafletMap('map').setView([30.27, 120.15], 12);
new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom: 19}).addTo(map);
const line = new Polyline([[30.20, 120.10], [30.27, 120.15], [30.32, 120.22]], {
color: '#e6194b',
weight: 4
}).addTo(map);
line.setStyle({dashArray: '6,8', opacity: 0.8}); // 运行时改样式
line.bringToFront(); // 提到同 pane 的最上层
line.redraw(); // 坐标变了之后手动重绘
三个方法的边界:setStyle 改样式但不会改几何;redraw 重算几何但不动样式;bringToFront / bringToBack 只在同一 Renderer 内调整顺序。
setStyle 有一个例外需要注意,源码注释写明:className is an exception: it is applied only when the path element is created。也就是说 setStyle({className: 'highlight'}) 只会更新 this.options,不会改已存在的 SVG 元素的 class。要动态加类,直接操作 DOM:
line.getElement()?.classList.add('highlight');
getElement() 在 SVG 渲染器下返回 SVGPathElement,在 Canvas 渲染器下返回 undefined(因为没有独立元素)。
7.1.3 事件
矢量图层继承了 Interactive layer 的完整事件集:click、dblclick、pointerdown、pointerup、pointerover、pointerout、contextmenu,以及 preclick(在 click 之前触发,用于”先做某件事再让 click 处理”)。
line.on({
pointerover: e => e.target.setStyle({weight: 6}),
pointerout: e => line.setStyle({weight: 4}),
click: e => console.log('点击位置', e.latlng)
});
注意 pointerover 的 e.target 就是图层本身,所以可以直接 e.target.setStyle(...)。
7.2 Polyline:折线与多段线
7.2.1 数据形态
Polyline 接受”点数组”,支持三种嵌套深度:
// 单条线:LatLng 数组
new Polyline([[30.20, 120.10], [30.27, 120.15]]);
// 多条线(MultiPolyline):数组的数组
new Polyline([
[[30.20, 120.10], [30.27, 120.15]],
[[30.30, 120.20], [30.35, 120.25]]
]);
// 点可以是数组、对象或 LatLng 实例
new Polyline([{lat: 30.20, lng: 120.10}, new LatLng(30.27, 120.15)]);
判断嵌套深度的规则在源码里:第一项是数组且第一项的第一项是数字 → 扁平结构。
7.2.2 化简:smoothFactor
Polyline 的默认选项只有两个:
| 选项 | 默认值 | 说明 |
|---|---|---|
smoothFactor |
0.5 |
每级缩放时按 Douglas–Peucker 化简的容差(像素) |
noClip |
false |
关闭视口裁剪 |
smoothFactor 在 1.x 默认是 1,2.0 降到 0.5,更新日志给的理由是”减少相邻图形之间的缝隙”。这个值越大,线越”简”,渲染越快但形状失真越明显。
实用判断:GPS 轨迹这类点密度极高的线用默认值甚至调到 1 都行;行政边界这类形状敏感的数据,如果发现细节被吃掉,调到 0 完全关闭化简——代价是点数多时缩放会明显变慢。
7.2.3 常用方法
line.addLatLng([30.35, 120.30]); // 追加一个点
line.setLatLngs(newCoords); // 整体替换
line.getLatLngs(); // 读回(嵌套结构与传入一致)
line.getBounds(); // 包围盒
line.isEmpty(); // 是否没有点
line.getCenter(); // 质心;未加入地图时抛错
line.closestLayerPoint(point); // 距某像素点最近的线上点
line.toGeoJSON(6); // 转 GeoJSON,精度 6 位小数
getCenter() 的报错信息是 Must add layer to map before using getCenter()——因为它需要投影后的像素坐标才能算。同理 Polygon.getCenter()。
7.3 Polygon:多边形与洞
7.3.1 三种数据形态
// 简单多边形
new Polygon([[30.20, 120.10], [30.30, 120.10], [30.30, 120.20], [30.20, 120.20]]);
// 带洞:第一个环是外环,后续是内环
new Polygon([
[[30.20, 120.10], [30.30, 120.10], [30.30, 120.20], [30.20, 120.20]], // 外环
[[30.23, 120.13], [30.27, 120.13], [30.27, 120.17], [30.23, 120.17]] // 洞
]);
// 多个多边形(MultiPolygon):数组的数组的数组
new Polygon([
[[[30.20, 120.10], [30.30, 120.10], [30.30, 120.20]]],
[[[30.32, 120.22], [30.35, 120.22], [30.35, 120.25]]]
]);
官方文档给了一条明确建议:不要在末尾重复第一个点来闭合环。Polygon 的 _convertLatLngs 会自动删掉重复的闭合点:
// 源码:len >= 2 且首尾相等时 pop 掉最后一个
if (len >= 2 && LineUtil.isFlat(result) && result[0].equals(result[len - 1])) {
result.pop();
}
传了也能跑,但会让 getLatLngs() 的返回值与直觉不符,且 GeoJSON 导出时容易多出一个点。
7.3.2 填充规则
fillRule 默认 'evenodd'。它与内环的绕向配合决定”哪些区域是实心”:
evenodd:奇偶规则,不管绕向,任意两条边界之间的区域交替填充与镂空;nonzero:非零环绕规则,内环与外环同向时会被”填满”而不是挖洞。
如果从 GIS 数据(Shapefile / GeoJSON)导入的多边形洞显示不出来,先查 fillRule 与环的绕向是否匹配。GeoJSON 规范要求外环逆时针、内环顺时针,而 GPS 采集的数据经常是反的。
7.3.3 批量样式
FeatureGroup.setStyle 会遍历成员调 setStyle,这是给一批多边形统一上色的标准做法:
import {FeatureGroup} from 'leaflet';
const zones = new FeatureGroup([
new Polygon([[30.2, 120.1], [30.3, 120.1], [30.3, 120.2]]),
new Polygon([[30.32, 120.22], [30.35, 120.22], [30.35, 120.25]])
]).addTo(map);
zones.setStyle({color: '#2ca02c', fillOpacity: 0.3});
7.4 Rectangle:用包围盒画矩形
Rectangle 继承 Polygon,构造参数是 LatLngBounds 而不是点数组:
import {Rectangle} from 'leaflet';
const rect = new Rectangle([[30.20, 120.10], [30.30, 120.20]], {
color: '#ff7800',
weight: 1
}).addTo(map);
rect.setBounds([[30.22, 120.12], [30.28, 120.18]]); // 改范围
它内部把 bounds 展开成 [southWest, northWest, northEast, southEast] 四个角点再交给 Polygon。用途很明确:框选区域、地图分幅、行政区外包框。要旋转的矩形不能用它,得自己算四个角点建 Polygon。
7.5 Circle 与 CircleMarker:两种”圆”
这两个类的区别是半径单位,不是大小:
CircleMarker |
Circle |
|
|---|---|---|
| 半径单位 | CSS 像素 | 米 |
| 默认半径 | 10 |
无默认,必须传 radius |
| 缩放时 | 屏幕上大小不变 | 随地图缩放变化(表示真实地理范围) |
| 适合 | 点位符号、状态灯 | 覆盖范围、缓冲区、服务半径 |
| 构造 | new CircleMarker(latlng, {radius: 8}) |
new Circle(latlng, {radius: 500}) |
// Circle 的 radius 在 2.0 是选项,不是第二个参数
const cov = new Circle([30.27, 120.15], {
radius: 1500, // 米
color: '#1f77b4',
fillOpacity: 0.15,
weight: 2
}).addTo(map);
const dot = new CircleMarker([30.30, 120.20], {
radius: 8, // 像素
color: '#fff',
fillColor: '#d62728',
fillOpacity: 1,
weight: 2
}).addTo(map);
2.0 的破坏性变更:new Circle(latlng, 200) 会直接抛错,错误信息是 Circle: the radius is an option in Leaflet 2. Use new Circle(latlng, {radius}).。1.x 里半径是第二个参数,这是从 1.x 迁移时最容易漏改的一处——因为它在源码里被特意做成了抛错而不是静默兼容。
Circle 的”圆”是投影后的近似:在球面墨卡托下,半径用像素近似换算,getBounds() 给出的是外包矩形。官方注释写明 It's an approximation and starts to diverge from a real circle closer to poles。高纬度地区(如 60° 以上)不要用它做精度要求高的缓冲区可视化。
7.6 渲染器:SVG 与 Canvas
7.6.1 选择渲染器
import {LeafletMap, Canvas, Polyline} from 'leaflet';
// 全图默认用 Canvas
const map = new LeafletMap('map', {center: [30.27, 120.15], zoom: 12, renderer: new Canvas()});
// 或只给某些图层指定渲染器
const canvasRenderer = new Canvas({padding: 0.5});
const line = new Polyline(coords, {renderer: canvasRenderer}).addTo(map);
1.x 的 preferCanvas: true 在 2.0 已删除,传它会打印 Map: preferCanvas option has been removed. Use renderer: new Canvas() instead.。
7.6.2 渲染器选项
两个渲染器共享 BlanketOverlay 的选项:
| 选项 | 默认值 | 说明 |
|---|---|---|
padding |
0.1 |
渲染范围相对视口外扩的比例,0.1 即上下左右各扩 10% |
tolerance(仅 Canvas) |
0 |
点击命中判定的额外容差像素 |
padding 越大,平移时越不容易出现”新区域还没画出来”的空白,代价是每次重绘的路径更多。0.5 是地图类应用里常见的调优值。
7.6.3 2.0 的 Canvas 事件变化
这条必须记住:2.0 里 Canvas 的 <canvas> 元素不再接收指针事件(CSS 设为 pointer-events: none),改由地图自己做命中检测,再分发给对应的 Path。
带来的影响:
- 给 canvas 元素直接
addEventListener('click')的代码不再触发; - 监听 Canvas 下层图层(SVG path、marker)的点击,现在能正常收到;
- 命中检测顺序由 pane 的 z-index 与 DOM 顺序决定,Canvas 渲染器按
zIndex排序后逐个询问。
所以自定义命中逻辑要覆盖 Canvas 的 _layerAt(e, click) 方法,而不是绑 DOM 事件。迁移文档把旧的 _onClick、_onPointerMove 等方法列为已删除。
7.6.4 SVG 与 Canvas 的取舍
| SVG | Canvas | |
|---|---|---|
| 适合要素数 | 几百 ~ 几千 | 几千 ~ 几万 |
| 单要素可加 CSS class / 过渡 | 是 | 否 |
| 事件命中 | 浏览器原生 | Leaflet 几何判断(可用 tolerance 放宽) |
| 导出图片 | 取 <svg> 序列化 |
canvas.toDataURL() |
| 高分屏 | 矢量清晰 | 内部按 devicePixelRatio 放大画布 |
判断标准是”单个要素是否需要有独立行为”:需要 hover 变色、点击单独响应、CSS 动画 → SVG;只需要整体渲染出来看 → Canvas。
7.7 动态更新与性能
7.7.1 更新坐标的正确方式
// 好:改坐标后 redraw 一次
line.setLatLngs(newCoords); // 内部已调用 redraw
// 差:循环里逐个 setLatLngs
for (const coords of allCoords) {
line.setLatLngs(coords); // 每次都触发重绘
}
setLatLngs 与 addLatLng 内部都会调 redraw()。批量追加时用一次 setLatLngs(bigArray) 而不是 N 次 addLatLng。
7.7.2 数量级参考
| 要素数 | SVG | Canvas |
|---|---|---|
| 500 | 流畅 | 流畅 |
| 5,000 | 缩放开始卡顿 | 流畅 |
| 50,000 | 明显卡顿,DOM 节点过多 | 需要分段渲染或改用 WebGL 方案 |
超过 5 万要素时 Leaflet 的 SVG 与 Canvas 都会吃力,此时应考虑:服务端出矢量瓦片(换 MapLibre GL JS)、或在服务端聚合后再传 GeoJSON、或用 leaflet.heat 这类专门插件。
7.8 排错清单
| 现象 | 原因 | 修法 |
|---|---|---|
Circle\: the radius is an option in Leaflet 2 |
用了 1.x 的 new Circle(latlng, 200) |
改成 new Circle(latlng, {radius: 200}) |
| 多边形洞不显示 | fillRule 与环绕向不匹配 |
试 fillRule: 'nonzero',或规范化环的绕向 |
| 线在视野边缘被截断 | 默认开启了视口裁剪 | 设 noClip: true(仅限要素少时) |
getCenter() 抛 Must add layer to map |
图层未加入地图,没有投影上下文 | 先 addTo(map) 再取 |
setStyle({className}) 不生效 |
class 只在元素创建时应用 | 用 getElement().classList.add()(仅 SVG) |
| Canvas 上收不到点击事件 | 2.0 改由地图命中检测;canvas 元素本身不接事件 | 监听图层的 click,不要绑 canvas DOM |
| 图形边缘有 1px 锯齿/缝隙 | 坐标被取整,或相邻图形共享边被各自裁剪 | 2.0 起矢量按亚像素绘制;仍存在则检查 smoothFactor |
| 大量要素时缩放卡死 | SVG DOM 节点过多 | 换 renderer: new Canvas(),或抽稀数据 |
7.9 生产建议
样式与数据分离。 把 setStyle 的调用集中在渲染函数里,而不是散落在各处。业务上常见需求是”按属性分级设色”,这时用 GeoJSON 的 style 函数(第 13 章)比手动遍历更清晰。
不要为每个要素建一个 Path 再单独 addTo。 用 FeatureGroup 或 GeoJSON 一次性加入,事件也绑在组上(FeatureGroup 支持事件冒泡)。
地理范围用 Circle,符号用 CircleMarker。 用错单位会导致”缩放时圈不变”或”圈大到覆盖整屏”这类离谱表现。
高要素量先算再画。 前端用 turf 算缓冲区/等值线的代价很高。能在 PostGIS 里 ST_Buffer 出结果的,就不要在浏览器里做。
7.10 本章小结
| 要点 | 结论 |
|---|---|
| 类层次 | Path → Polyline → Polygon → Rectangle;Path → CircleMarker → Circle |
| 共享样式 | stroke / color: '#3388ff' / weight: 3 / fillOpacity: 0.2 / fillRule: 'evenodd' |
| 化简 | smoothFactor 默认 0.5(1.x 是 1),调大更快更粗,0 关闭 |
| 多边形 | 首个环是外环,后续是洞;不要重复首点闭合 |
| 圆 | Circle 半径单位米、必须传选项;CircleMarker 单位像素、默认 10 |
| 渲染器 | 默认 SVG;preferCanvas 已删,改 renderer: new Canvas();Canvas 不再直接接指针事件 |
| 性能分界 | SVG 数千要素、Canvas 数万要素;再往上要换方案 |