文章目录
- 摘要
- 一、存在问题
-
- [1.1 TerriaJS 对 MVT 的符号化支持边界](#1.1 TerriaJS 对 MVT 的符号化支持边界)
- [1.2 源码级根因一:`json_style` 里没有任何 icon 处理分支](#1.2 源码级根因一:
json_style里没有任何 icon 处理分支) - [1.3 源码级根因二:SimpleStyle 接口只有点线面,没有符号位](#1.3 源码级根因二:SimpleStyle 接口只有点线面,没有符号位)
- [1.4 问题归纳](#1.4 问题归纳)
- [1.5 为什么不用现成的两条路](#1.5 为什么不用现成的两条路)
- 二、解决方法
-
- [2.1 总体思路](#2.1 总体思路)
- [2.2 改造点一:`json_style` 新增 `"type": "icon"` 分支](#2.2 改造点一:
json_style新增"type": "icon"分支) - [2.3 改造点二:`IconSymbolizer` 绘制偏移修正](#2.3 改造点二:
IconSymbolizer绘制偏移修正) - [2.4 改造点三:TerriaJS 新增 `iconImage` / `iconSheet` 两个 trait](#2.4 改造点三:TerriaJS 新增
iconImage/iconSheet两个 trait) - [2.5 改造点四:`MapboxVectorTileCatalogItem` 补齐 `labelRules`](#2.5 改造点四:
MapboxVectorTileCatalogItem补齐labelRules) - [2.6 改造点五:`json_style.d.ts` 新增属性 `IconSymbolizer`](#2.6 改造点五:
json_style.d.ts新增属性IconSymbolizer) - [2.7 关键技术点:`Sheet` 雪碧图与图标映射](#2.7 关键技术点:
Sheet雪碧图与图标映射) - [2.8 关键技术点:Sprite 文件的组织形式](#2.8 关键技术点:Sprite 文件的组织形式)
- [2.9 数据流分析(多图层)](#2.9 数据流分析(多图层))
- [2.10 数据流分析(单图层)](#2.10 数据流分析(单图层))
- [2.11 改造清单](#2.11 改造清单)
- [2.12 编译与运行](#2.12 编译与运行)
- 三、使用方式
-
- [3.1 方式一:单图层矢量瓦片(向后兼容接口 + 根级 icon 参数)](#3.1 方式一:单图层矢量瓦片(向后兼容接口 + 根级 icon 参数))
- [3.2 方式二:多图层矢量瓦片(style 中新增 `"type": "icon"` 图层)](#3.2 方式二:多图层矢量瓦片(style 中新增
"type": "icon"图层)) - [3.3 雪碧图(Sprite)文件组织建议](#3.3 雪碧图(Sprite)文件组织建议)
- [3.4 常见问题排查](#3.4 常见问题排查)
- 四、经验总结
-
- [4.1 通用经验](#4.1 通用经验)
- [附录 :相关规范与文档](#附录 :相关规范与文档)
摘要
上一篇《TerriaMap 开发实战03:geojson自定义svg符号》解决了 GeoJSON 数据源的自定义符号问题。但 MVT(Mapbox Vector Tile)矢量瓦片走的是完全不同的渲染管线------它不经过 Cesium 图元,而是由 protomaps.js 在 Canvas 上逐瓦片重绘。
本文要解决的核心问题是 :TerriaJS 的 MVT 符号化能力只覆盖了 Mapbox 样式规范 中 fill / line / circle / symbol 四种基础图层,不支持 symbol 图层的自定义图标(icon-image) 。在 style 中写 "type": "symbol" 只能出文字,引用图片则前端完全不显示。
解决方案 :不改 Cesium、不引入 Mapbox GL JS,而是**复用 protomaps.js 中已存在但未被 json_style 接入的 IconSymbolizer ,为 MVT 增加一个 "type": "icon" 的扩展图层类型,并用 Sprite 雪碧图(Sheet) 建立"图标名 → 图形"的映射关系。
全文按「存在问题 → 解决方法 → 使用方式」组织,关键源码位置与调用链均已核对到 protomaps.js 底层实现。
一、存在问题
1.1 TerriaJS 对 MVT 的符号化支持边界
TerriaJS 通过 MapboxVectorTileCatalogItem("type": "mvt")接入矢量瓦片,其样式能力完全由 style trait 驱动,并交由 protomaps.js 的 json_style() 解析。
在 protomaps.js 的 json_style 中,能够识别的 layer.type 只有五种:
JavaScript
// node_modules/protomaps/dist/protomaps.module.js → json_style()
if (layer.type == "fill") { ... }
else if (layer.type == "fill-extrusion") { ... }
else if (layer.type == "line") { ... }
else if (layer.type == "symbol") { ... } // ← 只处理文字
else if (layer.type == "circle") { ... }
对应到 Mapbox 样式规范:
| Mapbox 图层类型 | 规范能力 | protomaps 是否支持 |
|---|---|---|
| fill | 面填充 + 描边 | ✅ PolygonSymbolizer |
| line | 线样式 + 虚线 | ✅ LineSymbolizer |
| circle | 点圆形 | ✅ CircleSymbolizer |
| symbol | 文字标注 | ✅ CenteredTextSymbolizer / LineLabelSymbolizer |
| symbol + icon-image | 自定义图标 | ❌ 不支持 |
| fill-extrusion | 3D 拉伸 | ✅ PolygonSymbolizer(降级为 2D) |
symbol 图层的 icon-image(自定义图标)分支是缺失的------这正是本文要补齐的部分。
1.2 源码级根因一:json_style 里没有任何 icon 处理分支
MVT 的样式入口在 TerriaJS 侧:
TypeScript
// node_modules/terriajs/lib/Models/Catalog/CatalogItems/MapboxVectorTileCatalogItem.ts
@computed
get parsedJsonStyle() {
if (this.style) {
return json_style(this.style, new Map());
}
}

json_style 的返回值结构中,paint_rules 承载面/线/圆,label_rules 承载文字:
TypeScript
// node_modules/protomaps/dist/compat/json_style.d.ts
export declare function json_style(obj: any, fontsubmap: Map<string, FontSub>): {
paint_rules: ({ ... symbolizer: PolygonSymbolizer | LineSymbolizer | CircleSymbolizer })[];
label_rules: ({ ... symbolizer: LineLabelSymbolizer | CenteredTextSymbolizer })[];
tasks: never[];
};

注意 label_rules 的联合类型里没有 IconSymbolizer------类型层面就把图标挡在了门外。
而在 symbol 分支中,protomaps 只读取与文字相关的属性:

JavaScript
// node_modules/protomaps/dist/protomaps.module.js → json_style()
} else if (layer.type == "symbol") {
if (layer.layout["symbol-placement"] == "line") {
label_rules.push({
dataLayer: layer["source-layer"],
filter,
symbolizer: new LineLabelSymbolizer({
font: getFont(layer.layout, fontsubmap),
fill: layer.paint["text-color"],
width: layer.paint["text-halo-width"],
stroke: layer.paint["text-halo-color"],
textTransform: layer.layout["text-transform"],
label_props: layer.layout["text-field"] ? [layer.layout["text-field"]] : void 0
})
});
} else {
label_rules.push({
dataLayer: layer["source-layer"],
filter,
symbolizer: new CenteredTextSymbolizer({
font: getFont(layer.layout, fontsubmap),
fill: layer.paint["text-color"],
stroke: layer.paint["text-halo-color"],
width: layer.paint["text-halo-width"],
textTransform: layer.layout["text-transform"],
label_props: layer.layout["text-field"] ? [layer.layout["text-field"]] : void 0
})
});
}
}
layer.layout["icon-image"] 从头到尾没有被读取过。所以:
Plain
❌ "type": "symbol" + "layout": { "icon-image": "..." }
└─> json_style 走 CenteredTextSymbolizer 分支
└─> 只取 text-* 属性,icon-image 被丢弃
└─> 文字标注正常,图标完全不出现
1.3 源码级根因二:SimpleStyle 接口只有点线面,没有符号位
TerriaJS 中另一条 MVT 渲染路径(旧版 MapboxVectorTileImageryProvider)的样式接口定义如下:
TypeScript
// node_modules/terriajs/lib/Map/ImageryProvider/MapboxVectorTileImageryProvider.ts
interface SimpleStyle {
fillStyle: string;
strokeStyle: string;
lineWidth: number;
lineJoin: CanvasLineJoin;
}
只有填充色、描边色、线宽、线连接方式四个字段 ------这是典型 simplestyle-spec 的能力集,没有任何"符号/图标"的表达位。这就是"通过相对路径添加图片,前端无法展示"的直接原因:数据与配置中根本没有一个字段能承载"这个要素用哪个图形"。
1.4 问题归纳
| 编号 | 问题 | 根因 |
|---|---|---|
| P1 | MVT 无法渲染自定义图标 | protomaps json_style 只处理 fill/line/symbol/circle,无 icon 分支 |
| P2 | symbol 图层的 icon-image 无效 | 即使写 "type": "symbol",也只走文字分支,icon-image 从未被读取 |
| P3 | 相对路径图片无法加载 | SimpleStyle 接口无符号字段;MVT 走 Canvas 逐瓦片重绘,不是 Cesium 的 URL 加载器 |
| P4 | 单图层模式无图标入口 | layer + fillColor/lineColor 的向后兼容接口只覆盖面/线 |
| P5 | 多图层模式类型不完整 | json_style 的 label_rules 联合类型缺少 IconSymbolizer |
1.5 为什么不用现成的两条路
调研中评估过两条替代方案,均不适用:
| 方案 | 结论 |
|---|---|
| 引入 Mapbox GL JS / Mapbox API | 需要替换整个 MVT 渲染管线,TerriaJS 的 ProtomapsImageryProvider、要素拾取(pickFeatures)、高亮机制全部要重写,工作量与风险都不可控 |
| 引入控件(svg-sprite-loader 方案,见 TerriaJS/TerriaMap#223) | TerriaJS 自身的 svg-sprite-loader 只处理 wwwroot/images/icons 下的 UI 图标打包,不参与矢量瓦片的 Canvas 绘制过程,对 MVT 图层无效 |
而 protomaps.js 本身就已经实现了 IconSymbolizer 和 Sheet(雪碧图加载器) ------只是 json_style 没有把它们接入。这是最小改动、最贴合现有架构的路径。
二、解决方法
改造目标:①让 MVT 支持自定义图标符号;②兼容 Mapbox 样式规范的书写习惯;③单图层与多图层两种模式都能用。
2.1 总体思路
protomaps.js 的符号体系分为两类,正好对应两种绘制时机:
Plain
paint_rules → painter() 直接调用 symbolizer.draw(ctx, geom, z, feature) 画在瓦片底图上
label_rules → Labeler 布局后调用 symbolizer.place(layout, geom, feature) 画在瓦片上层(带避让)
IconSymbolizer 实现的是 place() 接口,属于 label_rules:
TypeScript
// node_modules/protomaps/dist/symbolizer.d.ts
export declare class IconSymbolizer implements LabelSymbolizer {
name: string;
sheet: Sheet;
dpr: number;
constructor(options: IconSymbolizerOptions);
place(layout: Layout, geom: Point[][], feature: Feature): {
anchor: Point;
bboxes: { minX: number; minY: number; maxX: number; maxY: number; }[];
draw: (ctx: CanvasRenderingContext2D) => void;
}[];
}

关键发现:IconSymbolizer 继承 LabelSymbolizer ,而 LabelSymbolizer 的 place() 与 LabelRule 是同一套接口 ------意味着只要把 IconSymbolizer 塞进 label_rules,整套标注避让、碰撞检测、跨瓦片失效重绘机制全部免费复用,无需任何额外改造。
因此本次改造分三步:
Plain
① protomaps 侧:json_style 新增 "type": "icon" 分支
读取 layout["icon-sheet"] / layout["icon-image"]
→ new Sheet(...) → new IconSymbolizer({ name, sheet })
→ push 进 label_rules
② protomaps 侧:IconSymbolizer 绘制偏移修正
修正原实现的图标左上角偏移,使其锚定在要素中心
③ TerriaJS 侧:新增 iconImage / iconSheet 两个 trait
单图层模式下由 MapboxVectorTileCatalogItem 直接构造 label rule
多图层模式下由 json_style 自动处理(无需改动)
2.2 改造点一:json_style 新增 "type": "icon" 分支
位置 :node_modules/protomaps/dist/protomaps.module.js → json_style() 函数,在 layer.type == "circle" 分支之后追加。
改造前 (json_style 到 circle 分支结束):
JavaScript
} else if (layer.type == "circle") {
paint_rules.push({
dataLayer: layer["source-layer"],
filter,
symbolizer: new CircleSymbolizer({
radius: layer.paint["circle-radius"],
fill: layer.paint["circle-color"],
stroke: layer.paint["circle-stroke-color"],
width: layer.paint["circle-stroke-width"]
})
});
}
改造后:
JavaScript
} else if (layer.type == "circle") {
paint_rules.push({ /* ... 原有逻辑不变 ... */ });
}
//feat:新增mvt多图层icon,添加icon-sheet/icon-image到style
else if (layer.type == "icon") {
const sheet = new Sheet(layer.layout["icon-sheet"]);
sheet.load();
label_rules.push({
dataLayer: layer["source-layer"],
filter,
symbolizer: new IconSymbolizer({
name: layer.layout["icon-image"],
sheet: sheet,
})
});
}
label_rules.reverse();
return { paint_rules, label_rules, tasks: [] };

2.3 改造点二:IconSymbolizer 绘制偏移修正
位置 :node_modules/protomaps/dist/protomaps.module.js → IconSymbolizer.place() 内的 draw 闭包。
改造前(protomaps 原始实现,图标偏左上角):
JavaScript
var IconSymbolizer = class {
constructor(options) {
this.name = options.name;
this.sheet = options.sheet;
this.dpr = window.devicePixelRatio;
}
place(layout, geom, feature) {
let pt = geom[0];
let a2 = new import_point_geometry6.default(geom[0][0].x, geom[0][0].y);
let loc = this.sheet.get(this.name);
let width = loc.w / this.dpr;
let height = loc.h / this.dpr;
let bbox = {
minX: a2.x - width / 2,
minY: a2.y - height / 2,
maxX: a2.x + width / 2,
maxY: a2.y + height / 2
};
let draw = (ctx) => {
ctx.globalAlpha = 1;
ctx.drawImage(this.sheet.canvas, loc.x, loc.y, loc.w, loc.h, -loc.w / 2 / this.dpr, -loc.h / 2 / this.dpr, loc.w / 2, loc.h / 2);
};
return [{ anchor: a2, bboxes: [bbox], draw }];
}
};
改造后:
JavaScript
var IconSymbolizer = class {
constructor(options) {
this.name = options.name;
this.sheet = options.sheet;
this.dpr = window.devicePixelRatio;
}
place(layout, geom, feature) {
let pt = geom[0];
let a2 = new import_point_geometry6.default(geom[0][0].x, geom[0][0].y);
let loc = this.sheet.get(this.name);
let width = loc.w / this.dpr;
let height = loc.h / this.dpr;
let bbox = {
minX: a2.x - width / 2,
minY: a2.y - height / 2,
maxX: a2.x + width / 2,
maxY: a2.y + height / 2
};
let draw = (ctx) => {
ctx.globalAlpha = 1;
// ctx.drawImage(this.sheet.canvas, loc.x, loc.y, loc.w, loc.h, -loc.w / 2 / this.dpr, -loc.h / 2 / this.dpr, loc.w / 2, loc.h / 2);
//feat:修改icon图标偏移量为0 //源代码如上,图标左上角偏移
ctx.drawImage(
this.sheet.canvas,
loc.x, loc.y, loc.w, loc.h,
-loc.w / 4 / this.dpr, -loc.h / 4 / this.dpr,
loc.w / 2, loc.h / 2
);
};
return [{ anchor: a2, bboxes: [bbox], draw }];
}
};
偏移修正的数学依据:
drawImage 的 9 参数形式中,后 4 个是"目标矩形",painter 在绘制前已经把画布原点平移到标签锚点:
JavaScript
// node_modules/protomaps/dist/painter.js
if (label_data) {
let matches = label_data.searchBbox(bbox, Infinity);
for (var label of matches) {
ctx.save();
ctx.translate(label.anchor.x - origin.x, label.anchor.y - origin.y);
label.draw(ctx);
ctx.restore();
}
}
此时画布原点即要素锚点,目标矩形应为 (-w/2, -h/2, w, h) 才是居中。原始实现写的是 (-loc.w/2/dpr, -loc.h/2/dpr, loc.w/2, loc.h/2)------目标宽高被除以了 2,导致图标被缩成一半并锚在左上角。
修正后:目标宽高为 loc.w/2(即按 dpr 折算后的 CSS 像素尺寸的一半......此处 protomaps 原始实现的 loc.w 已经是 img.width * scale,即已含 dpr),而起点偏移由 -loc.w/2/dpr 调整为 -loc.w/4/dpr------偏移量与目标尺寸匹配,图标回到要素中心。
2.4 改造点三:TerriaJS 新增 iconImage / iconSheet 两个 trait
位置 :node_modules/terriajs/lib/Traits/TraitsClasses/MapboxVectorTileCatalogItemTraits.ts
改造前:
TypeScript
@primitiveTrait({
type: "string",
name: "layer",
description:
"This property exists for backward compatibility. It can be used to only show a particular layer in the tileset."
})
layer?: string;
改造后:
TypeScript
//feat:新增mvt单图层icon,添加iconImage/iconSheet参数到根
@primitiveTrait({
type: "string",
name: "iconImage",
description:
"This property exists for backward compatibility. It can be used to load a custom icon image for the features"
})
iconImage?: string;
@primitiveTrait({
type: "string",
name: "iconSheet",
description:
"This property exists for backward compatibility. It can be used to load a custom icon image for the features"
})
iconSheet?: string;

为什么需要新增这两个 trait?
TerriaJS 的 MVT 有两种使用模式:
Plain
① 单图层模式:layer + fillColor/lineColor + styleUrl
→ 一个目录项只渲染一个 source-layer,样式参数直接放在根上
② 多图层模式:style.layers[]
→ 一个目录项渲染多个 source-layer,每个图层有自己的类型与样式
多图层模式不需要改 TerriaJS ------json_style 会自动把 "type": "icon" 的图层转成 label_rules。
单图层模式则完全绕过了 ****json_style :它的 paintRules / labelRules 都是手写构造 的,parsedJsonStyle 只在设置了 style 时才有值。因此单图层要支持图标,必须由 MapboxVectorTileCatalogItem 自己构造一条 labelRule。
2.5 改造点四:MapboxVectorTileCatalogItem 补齐 labelRules
位置 :node_modules/terriajs/lib/Models/Catalog/CatalogItems/MapboxVectorTileCatalogItem.ts
改造前 ------labelRules 只是一个空壳,只做 parsedJsonStyle 的透传:
TypeScript
@computed
get labelRules(): LabelRule[] {
if (this.parsedJsonStyle) {
rules.push(
...(this.parsedJsonStyle.label_rules as unknown as LabelRule[])
);
}
return [];
}
改造后:
TypeScript
@computed
get labelRules(): LabelRule[] {
//feat:新增mvt单图层icon,添加iconImage/iconSheet参数到根
const rules: LabelRule[] = [];
if (this.layer) {
if (this.iconImage && this.iconSheet) {
const sheet = new Sheet(this.iconSheet);
sheet.load();
rules.push({
dataLayer: this.layer,
symbolizer: new IconSymbolizer({
name: this.iconImage,
sheet: sheet,
})
});
}
}
if (this.parsedJsonStyle) {
rules.push(
...(this.parsedJsonStyle.label_rules as unknown as LabelRule[])
);
}
return rules;
}

同时需要补上 protomaps 的两个导入:
TypeScript
import {
GeomType,
json_style,
LabelRule,
LineSymbolizer,
IconSymbolizer, // ← 新增
Sheet, // ← 新增
PolygonSymbolizer,
Rule as PaintRule
} from "protomaps";
2.6 改造点五:json_style.d.ts 新增属性 IconSymbolizer
json_style 的返回值结构中补全新增属性IconSymbolizer。为 json_style 里添加 icon 类型的处理分支。
javascript
export declare function json_style(obj: any, fontsubmap: Map<string, FontSub>): {
paint_rules: ({
dataLayer: any;
filter: Filter | undefined;
symbolizer: PolygonSymbolizer;
} | {
dataLayer: any;
filter: Filter | undefined;
symbolizer: LineSymbolizer;
} | {
dataLayer: any;
filter: Filter | undefined;
symbolizer: CircleSymbolizer;
})[];
label_rules: ({
dataLayer: any;
filter: Filter | undefined;
symbolizer: LineLabelSymbolizer;
} | {
dataLayer: any;
filter: Filter | undefined;
symbolizer: CenteredTextSymbolizer;
}
//feat:label_rules中新增IconSymbolizer
| {
dataLayer: any;
filter: Filter | undefined;
symbolizer: IconSymbolizer;
}
)[];
tasks: never[];
};

2.7 关键技术点:Sheet 雪碧图与图标映射
Sheet 是 protomaps 提供的雪碧图(Sprite)加载器,定义在 node_modules\protomaps\dist\task.d.ts:
TypeScript
// node_modules/protomaps/dist/task.d.ts
interface Sprite {
x: number;
y: number;
w: number;
h: number;
}
export declare class Sheet {
src: string;
canvas: HTMLCanvasElement;
mapping: Map<string, Sprite>;
missingBox: Sprite;
constructor(src: string);
load(): Promise<this>;
get(name: string): Sprite;
}
- sprite:精灵图结构
- src:sheet路径,图标表源数据(HTML 字符串或 URL),包含<svg>
- canvas: 用于绘制图标的 HTML 画布元素
- mapping: 存储图标名称到 Sprite 位置映射的 Map
- missingBox: 当找不到指定图标时使用的默认占位符
- constructor(src: string): 构造函数,接收源数据
- load(): Promise: 异步加载图标表,返回加载完成的 Sheet 实例
- get(name: string): Sprite: 根据图标名称获取对应的 Sprite 信息
Sheet.load() 的工作流程(task.js 实现):
JavaScript
export class Sheet {
constructor(src) {
this.src = src;
this.canvas = document.createElement("canvas");
this.mapping = new Map();
this.missingBox = { x: 0, y: 0, w: 0, h: 0 };
}
load() {
let src = this.src;
let scale = window.devicePixelRatio;
if (src.endsWith(".html")) {
let c = await fetch(src); // ① 若源是 .html,先取其文本内容
src = await c.text();
}
let tree = new window.DOMParser().parseFromString(src, "text/html"); // ② 解析为 DOM
let icons = Array.from(tree.body.children); // ③ 取 body 下的子元素
let missingImg = await mkimg("data:image/svg+xml;base64," + btoa(MISSING));
let boxes = [{ w: missingImg.width * scale, h: missingImg.height * scale, img: missingImg, id: "" }];
let serializer = new XMLSerializer();
for (let ps of icons) { // ④ 逐个 SVG 元素转 base64 → Image
var svg64 = btoa(serializer.serializeToString(ps));
var image64 = "data:image/svg+xml;base64," + svg64;
let img = await mkimg(image64);
boxes.push({ w: img.width * scale, h: img.height * scale, img, id: ps.id }); // ⑤ id = SVG 的 id 属性
}
let packresult = potpack(boxes); // ⑥ potpack 自动装箱,拼成一张大图
this.canvas.width = packresult.w;
this.canvas.height = packresult.h;
let ctx = this.canvas.getContext("2d");
if (ctx) {
for (let box of boxes) {
if (box.x !== void 0 && box.y !== void 0) {
ctx.drawImage(box.img, box.x, box.y, box.w, box.h); // ⑦ 绘制到雪碧图
if (box.id)
this.mapping.set(box.id, { x: box.x, y: box.y, w: box.w, h: box.h }); // ⑧ 建立映射
else
this.missingBox = { x: box.x, y: box.y, w: box.w, h: box.h };
}
}
}
return this;
}
get(name: string) {
let result = this.mapping.get(name);
if (!result) result = this.missingBox; // ⑨ 找不到则返回占位框
return result;
}
}
这就是"符号映射"的机制:
Plain
sprite.html 中 <svg id="icon-pacman">...</svg>
│
│ ⑤ id = ps.id = "icon-pacman"
▼
Sheet.mapping = Map {
"icon-home2" → { x: 0, y: 0, w: 32, h: 32 },
"icon-droplet" → { x: 0, y: 42, w: 32, h: 32 },
"icon-dice" → { x: 0, y: 84, w: 32, h: 32 },
"icon-pacman" → { x: 0, y: 126, w: 32, h: 32 }
}
│
│ IconSymbolizer.place() → this.sheet.get(this.name)
▼
ctx.drawImage(sheet.canvas, loc.x, loc.y, loc.w, loc.h, ...)
关键约束:图标名必须等于 SVG 元素的 id 属性。 因为 Sheet.get(name) 找不到时会静默返回 missingBox(灰色占位图标),不会报错------这是排查时最容易踩的坑。
2.8 关键技术点:Sprite 文件的组织形式

Sheet.load() 支持两种源:
| 源 | 处理方式 |
|---|---|
| *.html | 先 fetch 取文本,再当作 HTML 解析,取 body 下的子元素 |
| 其他(*.svg 等) | 直接当作 HTML 文本解析 |

TerriaJS 中使用 .html 形式(因为一个 sprite 表需要包含多个 SVG),示例 wwwroot/data/sprite.html:
HTML
<html>
<body>
<svg id="icon-home2" width="32" height="32" viewBox="0 0 32 32" x="0" y="0">
<path d="M16 1l-16 16 3 3 3-3v13h8v-6h4v6h8v-13l3 3 3-3-16-16zM16 14c-1.105 0-2-0.895-2-2s0.895-2 2-2c1.105 0 2 0.895 2 2s-0.895 2-2 2z"></path>
</svg>
<svg id="icon-droplet" width="32" height="32" viewBox="0 0 32 32" x="0" y="42">
<path d="M27.020 14.786c-2.055-5.732-6.41-10.88-11.020-14.786-4.61 3.907-8.965 9.054-11.020 14.786-1.271 3.545-1.396 7.393 0.393 10.794 2.058 3.911 6.207 6.42 10.626 6.42s8.569-2.509 10.626-6.42c1.79-3.401 1.664-7.249 0.393-10.794zM23.086 23.717c-1.369 2.602-4.15 4.283-7.086 4.283-1.723 0-3.391-0.579-4.753-1.583 0.414 0.054 0.832 0.083 1.254 0.083 3.67 0 7.146-2.1 8.856-5.351 1.402-2.665 1.281-5.433 0.746-7.636 0.455 0.88 0.841 1.756 1.151 2.623 0.706 1.971 1.251 4.886-0.168 7.581z"></path>
</svg>
<svg id="icon-dice" width="32" height="32" viewBox="0 0 32 32" x="0" y="84">
<path d="M27 6h-16c-2.75 0-5 2.25-5 5v16c0 2.75 2.25 5 5 5h16c2.75 0 5-2.25 5-5v-16c0-2.75-2.25-5-5-5zM13 28c-1.657 0-3-1.343-3-3s1.343-3 3-3 3 1.343 3 3-1.343 3-3 3zM13 16c-1.657 0-3-1.343-3-3s1.343-3 3-3 3 1.343 3 3-1.343 3-3 3zM19 22c-1.657 0-3-1.343-3-3s1.343-3 3-3 3 1.343 3 3-1.343 3-3 3zM25 28c-1.657 0-3-1.343-3-3s1.343-3 3-3 3 1.343 3 3-1.343 3-3 3zM25 16c-1.657 0-3-1.343-3-3s1.343-3 3-3 3 1.343 3 3-1.343 3-3 3zM25.899 4c-0.467-2.275-2.491-4-4.899-4h-16c-2.75 0-5 2.25-5 5v16c0 2.408 1.725 4.432 4 4.899v-19.899c0-1.1 0.9-2 2-2h19.899z"></path>
</svg>
<svg id="icon-pacman" width="32" height="32" viewBox="0 0 32 32" x="0" y="126">
<path d="M30.148 5.588c-2.934-3.42-7.288-5.588-12.148-5.588-8.837 0-16 7.163-16 16s7.163 16 16 16c4.86 0 9.213-2.167 12.148-5.588l-10.148-10.412 10.148-10.412zM22 3.769c1.232 0 2.231 0.999 2.231 2.231s-0.999 2.231-2.231 2.231-2.231-0.999-2.231-2.231c0-1.232 0.999-2.231 2.231-2.231z"></path>
</svg>
</body>
</html>
重要提示:
Sheet完全使用 SVG 元素的id作为图标名,x/y属性会被potpack装箱算法覆盖,因此无需手工维护坐标 ------只要id写对就行。
2.9 数据流分析(多图层)
多图层走 style trait,由 json_style 统一处理:
Plain
simple.json 的 style.layers[]
│
│ ① MapboxVectorTileCatalogItem.parsedJsonStyle
│ → json_style(this.style, new Map())
▼
{ paint_rules: [...], label_rules: [...] }
│
│ ② json_style 遇到 layer.type == "icon"
│ → new Sheet(layout["icon-sheet"]) → new IconSymbolizer({name, sheet})
│ → push 进 label_rules
▼
label_rules = [{ dataLayer, filter, symbolizer: IconSymbolizer }]
│
│ ③ MapboxVectorTileCatalogItem.labelRules
│ → rules.push(...parsedJsonStyle.label_rules)
▼
labelRules → ProtomapsImageryProvider({ labelRules })
│
│ ④ ProtomapsImageryProvider → new Labelers(ctx, labelRules, 16, cb)
│ → 每次渲染瓦片时 labelers.add(z, tileMap)
▼
Labeler.layout() → rule.symbolizer.place(layout, transformed, feature)
│
│ ⑤ IconSymbolizer.place() → sheet.get(name) → ctx.drawImage(...)
▼
自定义 SVG 图标渲染到地图
2.10 数据流分析(单图层)
单图层不走 json_style,由 MapboxVectorTileCatalogItem 手写构造:
Plain
simple.json 的 layer / iconImage / iconSheet
│
│ ① MapboxVectorTileCatalogItem.labelRules
│ → new Sheet(this.iconSheet) → sheet.load()
│ → rules.push({ dataLayer: this.layer, symbolizer: new IconSymbolizer({name: this.iconImage, sheet}) })
▼
labelRules = [{ dataLayer: "motorway_junction", symbolizer: IconSymbolizer }]
│
│ ② ProtomapsImageryProvider → Labelers
▼
IconSymbolizer.place() → sheet.get(name) → ctx.drawImage(...)
│
▼
自定义 SVG 图标渲染到地图
2.11 改造清单
| 文件 | 位置 | 改动 |
|---|---|---|
| terriajs/lib/Traits/TraitsClasses/MapboxVectorTileCatalogItemTraits.ts | 新增 iconImage / iconSheet 两个 primitiveTrait | 单图层模式的图标入口 |
| terriajs/lib/Models/Catalog/CatalogItems/MapboxVectorTileCatalogItem.ts | import 增加 IconSymbolizer、Sheet | 引入 protomaps 的图标符号化能力 |
| terriajs/lib/Models/Catalog/CatalogItems/MapboxVectorTileCatalogItem.ts | labelRules getter | 新增单图层图标 rule 构造 |
| node_modules/protomaps/dist/protomaps.module.js | json_style() | 新增 layer.type == "icon" 分支 |
| node_modules/protomaps/dist/protomaps.module.js | IconSymbolizer.place() | 修正绘制偏移量 |
| node_modules/protomaps/dist/compat/json_style.d.ts | label_rules 联合类型 | 新增 IconSymbolizer 分支 |
| node_modules/protomaps/dist/compat/json_style.js | import 语句 | 引入 IconSymbolizer |
| node_modules/protomaps/dist/labeler.d.ts | import 语句 | 引入 IconSymbolizer 类型 |
2.12 编译与运行
TerriaJS 侧 (terriajs 工程)改造的是源码,在 TerriaMap 工程下位于 node_modules/terriajs/lib/...。修改完成后执行:
JSON
yarn gulp # 重新编译源码
yarn start # 启动服务
若单独用 git 维护了一个 terriajs 工程,可在 package.json 中替换 terriajs 的指向路径:
Shell
"devDependencies": {
// ......
"terriajs": "git+https://github.com/yourgit/terriajs.git#main",
"terriajs-cesium": "8.0.1",
// ......
},
修改完成后执行:
JSON
yarn install #自动下载修改后的terriajs仓库
yarn gulp # 重新编译源码
yarn start # 启动服务
protomaps 侧 是 node_modules 下的第三方包,直接改会被 yarn install 覆盖。因此本次改造同时通过 patch-package 固化:
JSON
// TerriaMap/package.json
"dependencies": {
"patch-package": "^8.0.0"
},
"scripts": {
"postinstall": "patch-package"
}
生成补丁文件:
Shell
npx patch-package protomaps
生成的 patches/protomaps+1.19.0.patch 已包含全部 protomaps 改动,yarn install 时会自动应用:
Plain
patches/protomaps+1.19.0.patch
├─ node_modules/protomaps/dist/compat/json_style.d.ts ← 类型声明补 IconSymbolizer
├─ node_modules/protomaps/dist/compat/json_style.js ← import 补 IconSymbolizer
├─ node_modules/protomaps/dist/labeler.d.ts ← import 补 IconSymbolizer
├─ node_modules/protomaps/dist/protomaps.module.js ← json_style 新增 icon 分支 + 偏移修正
└─ node_modules/protomaps/dist/task.d.ts
注意 :webpack 打包时按
package.json的module字段优先解析,即dist/protomaps.module.js(ESM 版本)。必须确保icon分支加在protomaps.module.js上 ,dist/protomaps.js(UMD 版本)虽同源但非打包入口。
参考文档:【问题解决】使用patch-package修改node-models中的源码
三、使用方式
改造后共支持两种配置方式,按场景选用。
3.1 方式一:单图层矢量瓦片(向后兼容接口 + 根级 icon 参数)
MVT 是一种矢量瓦片格式。与静态瓦片不同,MVT 瓦片传输的是几何图形(如点、线、多边形)及其属性信息,矢量数据在浏览器中实时渲染。
这里用了mapbox.mapbox-streets-v8的数据库来演示,文档:https://docs.mapbox.com/data/tilesets/reference/mapbox-streets-v8/#admin
wwwroot/init/simple.json 配置表中的使用方式:
JSON
"catalog": [
//.....
{
"name": "MVT - mapbox - 单图层",
"type": "mvt",
"url": "https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.mvt?access_token=yourtoken",
"layer": "motorway_junction", // 交通站点-点图层
//新增两个参数:
"iconImage": "icon-pacman", // 图标名,必须与 sprite.html 中 <svg id="..."> 一致
"iconSheet": "data/sprite.html" // 雪碧图路径
},
//.....
]
| 字段 | 必选 | 说明 |
|---|---|---|
| layer | ✅ | 要渲染的 source-layer 名 |
| iconImage | ✅ | 图标名,对应 sprite 中 <svg id="..."> 的 id |
| iconSheet | ✅ | 雪碧图(Sprite)文件路径 |
- type: "mvt"指明数据类型为 MVT(矢量瓦片),表示此图层的数据以矢量格式存储,适合动态渲染和样式调整。
- url:
- {z}、{x} 和 {y},分别代表缩放级别、瓦片的 x 坐标和 y 坐标。用户可以根据当前视图请求不同的瓦片。
- access_token: 是 Mapbox 用于验证用户请求的凭证,确保只有授权的用户才能访问相应的数据。
- layer: 指明要渲染的具体图层,这里的数据类型可以https://docs.mapbox.com/data/tilesets/reference/mapbox-streets-v8/#admin里找到,这里使用motorway_junction图层展示自定义符号。
- "iconSheet":指定图标文件(spirite表)的路径,html格式
- "iconImage":指定图标名称,位于spirite表中
iconImage与iconSheet必须同时配置 ,缺一则不生效(代码中为if (this.iconImage && this.iconSheet)的双条件判断)

3.2 方式二:多图层矢量瓦片(style 中新增 "type": "icon" 图层)
包含多个图层的数据,可以同时显示不同类型的地理特征。根据不同缩放层级展示不同数据。
wwwroot/init/simple.json 配置表中的使用方式:
JSON
"catalog": [
//.....
{
"name": "MVT - mapbox - 多图层",
"type": "mvt",
"url": "https://api.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.mvt?access_token=yourtoken",
"style": {
"layers": [
{
"type": "line",
"source-layer": "road",
"paint": {
"line-color": "red",
"line-width": 2
}
},
{
"id": "airport_label",
"source": "mapbox-streets",
"source-layer": "airport_label",
"type": "symbol",
"layout": {
"text-size": 30,
"text-font": "sans-serif"
},
"paint": {
"text-color": "yellow"
}
},
{
"id": "motorway_junction",
"source": "mapbox-streets",
"source-layer": "motorway_junction",
"type": "icon", // ← 新增的扩展图层类型
"layout": {
"icon-image": "icon-dice", // 图标名
"icon-sheet": "data/sprite.html" // 雪碧图路径
}
},
{
"id": "motorway_junction",
"source": "mapbox-streets",
"source-layer": "motorway_junction",
"type": "circle",
"paint": {
"circle-radius": 10,
"circle-color": "rgba(238,78,139, 0.4)",
"circle-stroke-color": "rgba(238,78,139, 1)",
"circle-stroke-width": 1
}
},
{
"id": "landuse_overlay",
"source": "mapbox-streets",
"source-layer": "landuse_overlay",
"type": "fill",
"paint": {
"fill-color": "rgba(66,100,251, 1)",
"fill-outline-color": "rgba(66,100,251, 1)"
}
}
]
}
},
//.....
]
各图层类型对照:
| 图层 type | 规范能力 | 实际效果 |
|---|---|---|
| "line" | 道路 - 线条 - 红色 | 红色道路线 |
| "symbol" | 机场 - 标签字符 - 黄色 | 黄色文字标注 |
| "icon" | 交通站点 - 自定义符号 | 雪碧图中的自定义 SVG 图标 |
| "circle" | 交通站点 - 圆形 - 粉色 | 粉色半透明圆 |
| "fill" | 土地利用 - 面填充 - 蓝色 | 蓝色地块 |
"type": "icon"是本文新增的扩展类型,遵循 Mapbox 样式规范的书写结构(layout存布局属性),但icon-sheet是 protomaps 特有的属性------它告诉渲染器去哪里找雪碧图。




3.3 雪碧图(Sprite)文件组织建议
Plain
wwwroot/data/
├─ sprite.html ← 雪碧图:一个文件装多个 SVG
└─ ...
编写规范:
-
每个图标一个
<svg>元素,id即图标名,与iconImage/icon-image严格一致; -
统一
width/height/viewBox(建议0 0 32 32),避免不同图标视觉大小不一致; -
x/y属性可省略,potpack会自动装箱; -
用 SVG 而非 PNG------矢量无损缩放,且
Sheet会按devicePixelRatio放大后重新装箱,高分屏不糊; -
图标用
currentColor/ 单色路径,便于统一着色。

3.4 常见问题排查
| 现象 | 原因 | 排查方法 |
|---|---|---|
| 图标不显示,只有一个灰色方块 | sheet.get(name) 找不到 → 返回 missingBox | 核对 iconImage 与 <svg id="..."> 是否完全一致(区分大小写) |
| 图标显示但位置偏移 | 绘制偏移问题 | 确认 protomaps.module.js 中 IconSymbolizer.place() 的偏移已修正为 -loc.w / 4 / this.dpr |
| 图标只在部分瓦片出现 | Sheet.load() 是异步的,首帧未就绪 | 属正常现象,sprite 加载完成后会重绘;若持续异常,检查 iconSheet 路径是否可访问 |
| 控制台报 sheet.get is not a function | Sheet 未正确导入 | 确认 protomaps 的 index.d.ts 导出了 Sheet(export * from "./task") |
| 多图层配置不生效 | webpack 解析到了 UMD 版本 | 确认改动加在 dist/protomaps.module.js(package.json 的 module 字段) |
| yarn install 后改动丢失 | node_modules 被覆盖 | 用 patch-package 固化补丁 |
四、经验总结
4.1 通用经验
-
先读源码,再定方案 。本次的关键突破在于发现 protomaps.js 里已经存在
IconSymbolizer和Sheet两个类,只是json_style没接入。在动手写代码前先把node_modules里的目标库翻一遍,往往能省掉大量重复实现。 -
区分"同名不同物" 。Mapbox 规范里的
symbol图层(含icon-image)与 protomaps 的label_rules是两个概念;paint_rules与label_rules的绘制时机、接口形态、层叠顺序 也完全不同------把IconSymbolizer放错数组会直接抛异常。 -
注意
label_rules的reverse()约定 。protomaps 在json_style末尾对label_rules做了一次反转,这是它"后加入者优先"的优先级机制。新增分支必须加在reverse()之前,否则会破坏顺序语义。 -
第三方包的改动必须固化 。直接改
node_modules会在yarn install后全部丢失。用patch-package生成补丁并提交,是这类改造的标准做法。 -
注意打包入口 。同一个包的
dist/protomaps.js(UMD)与dist/protomaps.module.js(ESM)内容同源但不是同一份文件 。webpack 按package.json的module字段优先,改错文件 = 改动不生效。
附录 :相关规范与文档
-
Layers | Mapbox Style Spec ------ 图层类型与
icon-image的规范定义 -
Sprite | Mapbox Style Spec ------ 雪碧图与图标映射的规范定义
-
protomaps.js ------
json_style/symbolizer/task的实现参考 -
TerriaJS/TerriaMap#223 ------ svg-sprite-loader 方案讨论(对 MVT 图层不适用)
-
MapboxVectorTileCatalogItem - TerriaJS ------ 上游源码