TerriaMap 开发实战04:MVT数据自定义符号化 - terriajs源码修改

文章目录

  • 摘要
  • 一、存在问题
    • [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)文件路径
  1. type: "mvt"指明数据类型为 MVT(矢量瓦片),表示此图层的数据以矢量格式存储,适合动态渲染和样式调整。
  2. url:
  • {z}、{x} 和 {y},分别代表缩放级别、瓦片的 x 坐标和 y 坐标。用户可以根据当前视图请求不同的瓦片。
  • access_token: 是 Mapbox 用于验证用户请求的凭证,确保只有授权的用户才能访问相应的数据。
  1. layer: 指明要渲染的具体图层,这里的数据类型可以https://docs.mapbox.com/data/tilesets/reference/mapbox-streets-v8/#admin里找到,这里使用motorway_junction图层展示自定义符号。
  2. "iconSheet":指定图标文件(spirite表)的路径,html格式
  3. "iconImage":指定图标名称,位于spirite表中
  4. 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 通用经验

  1. 先读源码,再定方案 。本次的关键突破在于发现 protomaps.js 里已经存在 IconSymbolizer 和 Sheet 两个类,只是 json_style 没接入。在动手写代码前先把 node_modules 里的目标库翻一遍,往往能省掉大量重复实现。

  2. 区分"同名不同物" 。Mapbox 规范里的 symbol 图层(含 icon-image)与 protomaps 的 label_rules 是两个概念;paint_rules 与 label_rules 的绘制时机、接口形态、层叠顺序 也完全不同------把 IconSymbolizer 放错数组会直接抛异常。

  3. 注意 label_rules 的 reverse() 约定 。protomaps 在 json_style 末尾对 label_rules 做了一次反转,这是它"后加入者优先"的优先级机制。新增分支必须加在 reverse() 之前,否则会破坏顺序语义。

  4. 第三方包的改动必须固化 。直接改 node_modules 会在 yarn install 后全部丢失。用 patch-package 生成补丁并提交,是这类改造的标准做法。

  5. 注意打包入口 。同一个包的 dist/protomaps.js(UMD)与 dist/protomaps.module.js(ESM)内容同源但不是同一份文件 。webpack 按 package.json 的 module 字段优先,改错文件 = 改动不生效。


附录 :相关规范与文档

相关推荐
云上工程笔记2 小时前
SRT字幕一键转手绘动画:srt-whiteboard-animation的复刻实录
大数据·开发语言·javascript·人工智能·机器学习
FungLeo3 小时前
成为全栈·Vue管理后台篇·<scriptsetup>:把页面拆成可读、可测的 Vue 组件
前端·javascript·vue.js·typescript·前端组件化·composition api·vue sfc
清晨Coding15 小时前
删了 Cookie 还能自动登录?Google系网站登录态自动恢复的原理分析
javascript
kyriewen16 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
程序员大阳17 小时前
HTML+CSS+JS对话式实战(9)-项目总结与问题分析
javascript·css·html·实战·项目总结
Hilaku18 小时前
MCP 协议会成为前端 AI 工程的标准接口吗?
前端·javascript·程序员
RobinDevNotes21 小时前
从 Electron 到 GPUI,9 个跨平台桌面方案该怎么选
前端·javascript·rust·electron·openresty·前端开发工具
程序员大阳21 小时前
HTML+CSS+JS对话式实战(6)-学生端查看活动功能开发
javascript·css·html·模块·查看学生
子兮曰2 天前
Chrome 权限元素实战:144 与 151 的渐进增强写法
前端·javascript·chrome