@versatiles/svg-renderer 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -10
- package/dist/index.cjs +153 -32
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +153 -32
- package/dist/index.js.map +1 -1
- package/dist/maplibre.cjs +153 -32
- package/dist/maplibre.cjs.map +1 -1
- package/dist/maplibre.js +153 -32
- package/dist/maplibre.js.map +1 -1
- package/dist/maplibre.umd.js +153 -32
- package/dist/maplibre.umd.js.map +1 -1
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Renders vector maps as SVG.
|
|
|
12
12
|
|
|
13
13
|
[Download SVG](docs/demo.svg)
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Supported layer types: background, fill, line, circle, symbol, and raster.
|
|
16
16
|
|
|
17
17
|
## Installation
|
|
18
18
|
|
|
@@ -103,15 +103,25 @@ new SVGExportControl({
|
|
|
103
103
|
|
|
104
104
|
### `renderToSVG(options): Promise<string>`
|
|
105
105
|
|
|
106
|
-
| Option
|
|
107
|
-
|
|
|
108
|
-
| `style`
|
|
109
|
-
| `width`
|
|
110
|
-
| `height`
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
106
|
+
| Option | Type | Default | Description |
|
|
107
|
+
| -------------- | -------------------- | ------------ | ----------------------------------------- |
|
|
108
|
+
| `style` | `StyleSpecification` | *(required)* | MapLibre style specification |
|
|
109
|
+
| `width` | `number` | `1024` | Output width in pixels |
|
|
110
|
+
| `height` | `number` | `1024` | Output height in pixels |
|
|
111
|
+
| `lon` | `number` | `0` | Center longitude |
|
|
112
|
+
| `lat` | `number` | `0` | Center latitude |
|
|
113
|
+
| `zoom` | `number` | `2` | Zoom level |
|
|
114
|
+
| `renderLabels` | `boolean` | `false` | Enable rendering of text labels and icons |
|
|
115
|
+
|
|
116
|
+
### About `renderLabels`
|
|
117
|
+
|
|
118
|
+
When `renderLabels` is set to `true`, symbol layers are rendered, including text labels and sprite-based icons. By default, this option is disabled.
|
|
119
|
+
|
|
120
|
+
> [!WARNING]
|
|
121
|
+
> The rendering of labels and icons is experimental and may produce imperfect results. Since we cannot use the original layouting engine of MapLibre GL JS, there are known limitations:
|
|
122
|
+
>
|
|
123
|
+
> - **No collision detection:** Text labels are rendered without collision detection, so labels may overlap.
|
|
124
|
+
> - **Simplified text placement:** Labels can not be positioned along lines.
|
|
115
125
|
|
|
116
126
|
## E2E Visual Comparison
|
|
117
127
|
|
package/dist/index.cjs
CHANGED
|
@@ -9263,6 +9263,9 @@ class SVGRenderer {
|
|
|
9263
9263
|
height;
|
|
9264
9264
|
#svg;
|
|
9265
9265
|
#backgroundColor;
|
|
9266
|
+
#spriteSheetDefs = new Map();
|
|
9267
|
+
#spriteSymbolDefs = new Map();
|
|
9268
|
+
#sdfFilterDefs = new Map();
|
|
9266
9269
|
constructor(opt) {
|
|
9267
9270
|
this.width = opt.width;
|
|
9268
9271
|
this.height = opt.height;
|
|
@@ -9455,7 +9458,7 @@ class SVGRenderer {
|
|
|
9455
9458
|
drawIcons(id, features, spriteAtlas) {
|
|
9456
9459
|
if (features.length === 0)
|
|
9457
9460
|
return;
|
|
9458
|
-
|
|
9461
|
+
const elements = [];
|
|
9459
9462
|
for (const [feature, style] of features) {
|
|
9460
9463
|
if (style.opacity <= 0)
|
|
9461
9464
|
continue;
|
|
@@ -9472,27 +9475,102 @@ class SVGRenderer {
|
|
|
9472
9475
|
const [anchorDx, anchorDy] = mapIconAnchor(style.anchor, iconW, iconH);
|
|
9473
9476
|
const ox = style.offset[0] * style.size + anchorDx;
|
|
9474
9477
|
const oy = style.offset[1] * style.size + anchorDy;
|
|
9475
|
-
const
|
|
9476
|
-
|
|
9477
|
-
const
|
|
9478
|
-
const
|
|
9479
|
-
const
|
|
9480
|
-
|
|
9481
|
-
|
|
9482
|
-
|
|
9483
|
-
|
|
9484
|
-
|
|
9485
|
-
|
|
9486
|
-
|
|
9487
|
-
|
|
9478
|
+
const [iconXr, iconYr] = roundXY(point.x + ox, point.y + oy);
|
|
9479
|
+
// Register sprite sheet in global defs (once per unique data URI)
|
|
9480
|
+
const imgW = Math.round(sprite.sheetWidth * 10);
|
|
9481
|
+
const imgH = Math.round(sprite.sheetHeight * 10);
|
|
9482
|
+
const sheetKey = sprite.sheetDataUri;
|
|
9483
|
+
if (!this.#spriteSheetDefs.has(sheetKey)) {
|
|
9484
|
+
this.#spriteSheetDefs.set(sheetKey, {
|
|
9485
|
+
defId: `sprite-sheet-${String(this.#spriteSheetDefs.size)}`,
|
|
9486
|
+
width: imgW,
|
|
9487
|
+
height: imgH,
|
|
9488
|
+
href: sprite.sheetDataUri,
|
|
9489
|
+
});
|
|
9490
|
+
}
|
|
9491
|
+
const sheetDef = this.#spriteSheetDefs.get(sheetKey);
|
|
9492
|
+
// Register symbol for this sprite (once per sprite name + sheet)
|
|
9493
|
+
const sprX = Math.round(sprite.x * 10);
|
|
9494
|
+
const sprY = Math.round(sprite.y * 10);
|
|
9495
|
+
const sprW = Math.round(sprite.width * 10);
|
|
9496
|
+
const sprH = Math.round(sprite.height * 10);
|
|
9497
|
+
const symKey = `${style.image}\0${sheetKey}`;
|
|
9498
|
+
if (!this.#spriteSymbolDefs.has(symKey)) {
|
|
9499
|
+
this.#spriteSymbolDefs.set(symKey, {
|
|
9500
|
+
symbolId: `sprite-${escapeXml(style.image)}`,
|
|
9501
|
+
sheetDefId: sheetDef.defId,
|
|
9502
|
+
x: sprX,
|
|
9503
|
+
y: sprY,
|
|
9504
|
+
width: sprW,
|
|
9505
|
+
height: sprH,
|
|
9506
|
+
});
|
|
9507
|
+
}
|
|
9508
|
+
const symDef = this.#spriteSymbolDefs.get(symKey);
|
|
9509
|
+
// Build instance: translate to position, scale from native to desired size
|
|
9510
|
+
const scaleStr = scale === 1 ? '' : ` scale(${formatScale(scale)})`;
|
|
9511
|
+
const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
|
|
9512
|
+
// SDF filter for colorable icons
|
|
9513
|
+
let filterAttr = '';
|
|
9514
|
+
if (style.sdf) {
|
|
9515
|
+
const iconColor = new Color(style.color);
|
|
9516
|
+
const haloColor = new Color(style.haloColor);
|
|
9517
|
+
const hasHalo = style.haloWidth > 0 && haloColor.alpha > 0;
|
|
9518
|
+
const filterKey = hasHalo
|
|
9519
|
+
? `sdf\0${iconColor.hex}\0${haloColor.hex}\0${String(style.haloWidth)}`
|
|
9520
|
+
: `sdf\0${iconColor.hex}`;
|
|
9521
|
+
if (!this.#sdfFilterDefs.has(filterKey)) {
|
|
9522
|
+
const filterId = `sdf-${String(this.#sdfFilterDefs.size)}`;
|
|
9523
|
+
const iconFloodOpacity = iconColor.alpha < 255 ? ` flood-opacity="${iconColor.opacity.toFixed(3)}"` : '';
|
|
9524
|
+
let content;
|
|
9525
|
+
if (hasHalo) {
|
|
9526
|
+
const haloRadius = formatScale(style.haloWidth);
|
|
9527
|
+
const haloFloodOpacity = haloColor.alpha < 255 ? ` flood-opacity="${haloColor.opacity.toFixed(3)}"` : '';
|
|
9528
|
+
content =
|
|
9529
|
+
`<filter id="${filterId}" color-interpolation-filters="sRGB">` +
|
|
9530
|
+
// Threshold alpha at 0.75 (MapLibre SDF edge) to get sharp icon mask
|
|
9531
|
+
`<feComponentTransfer in="SourceGraphic" result="sharp"><feFuncA type="discrete" tableValues="0 0 0 1" /></feComponentTransfer>` +
|
|
9532
|
+
// Dilate sharp mask for halo
|
|
9533
|
+
`<feMorphology in="sharp" operator="dilate" radius="${haloRadius}" result="dilated" />` +
|
|
9534
|
+
`<feFlood flood-color="${haloColor.rgb}"${haloFloodOpacity} result="haloColor" />` +
|
|
9535
|
+
`<feComposite in="haloColor" in2="dilated" operator="in" result="halo" />` +
|
|
9536
|
+
// Color the sharp icon
|
|
9537
|
+
`<feFlood flood-color="${iconColor.rgb}"${iconFloodOpacity} result="iconColor" />` +
|
|
9538
|
+
`<feComposite in="iconColor" in2="sharp" operator="in" result="colored" />` +
|
|
9539
|
+
`<feComposite in="colored" in2="halo" operator="over" />` +
|
|
9540
|
+
`</filter>`;
|
|
9541
|
+
}
|
|
9542
|
+
else {
|
|
9543
|
+
content =
|
|
9544
|
+
`<filter id="${filterId}" x="0" y="0" width="1" height="1" color-interpolation-filters="sRGB">` +
|
|
9545
|
+
// Threshold alpha at 0.75 (MapLibre SDF edge) to get sharp mask
|
|
9546
|
+
`<feComponentTransfer in="SourceGraphic" result="sharp"><feFuncA type="discrete" tableValues="0 0 0 1" /></feComponentTransfer>` +
|
|
9547
|
+
// Replace color while keeping sharp alpha
|
|
9548
|
+
`<feFlood flood-color="${iconColor.rgb}"${iconFloodOpacity} result="color" />` +
|
|
9549
|
+
`<feComposite in="color" in2="sharp" operator="in" />` +
|
|
9550
|
+
`</filter>`;
|
|
9551
|
+
}
|
|
9552
|
+
this.#sdfFilterDefs.set(filterKey, { filterId, content });
|
|
9553
|
+
}
|
|
9554
|
+
const { filterId } = this.#sdfFilterDefs.get(filterKey);
|
|
9555
|
+
filterAttr = ` filter="url(#${filterId})"`;
|
|
9556
|
+
}
|
|
9488
9557
|
if (style.rotate !== 0) {
|
|
9489
9558
|
const [cx, cy] = roundXY(point.x + style.offset[0] * style.size, point.y + style.offset[1] * style.size);
|
|
9490
|
-
|
|
9559
|
+
elements.push(`<g transform="rotate(${String(style.rotate)},${formatNum(cx)},${formatNum(cy)})">` +
|
|
9560
|
+
`<g transform="translate(${formatNum(iconXr)},${formatNum(iconYr)})${scaleStr}"${opacityAttr}${filterAttr}>` +
|
|
9561
|
+
`<use href="#${escapeXml(symDef.symbolId)}" />` +
|
|
9562
|
+
`</g></g>`);
|
|
9563
|
+
}
|
|
9564
|
+
else {
|
|
9565
|
+
elements.push(`<g transform="translate(${formatNum(iconXr)},${formatNum(iconYr)})${scaleStr}"${opacityAttr}${filterAttr}>` +
|
|
9566
|
+
`<use href="#${escapeXml(symDef.symbolId)}" />` +
|
|
9567
|
+
`</g>`);
|
|
9491
9568
|
}
|
|
9492
|
-
this.#svg.push(`<svg ${attrs.join(' ')} viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg">` +
|
|
9493
|
-
`<image width="${String(sprite.sheetWidth)}" height="${String(sprite.sheetHeight)}" href="${sprite.sheetDataUri}" />` +
|
|
9494
|
-
`</svg>`);
|
|
9495
9569
|
}
|
|
9570
|
+
if (elements.length === 0)
|
|
9571
|
+
return;
|
|
9572
|
+
this.#svg.push(`<g id="${escapeXml(id)}">`);
|
|
9573
|
+
this.#svg.push(...elements);
|
|
9496
9574
|
this.#svg.push('</g>');
|
|
9497
9575
|
}
|
|
9498
9576
|
drawRasterTiles(id, tiles, style) {
|
|
@@ -9528,9 +9606,21 @@ class SVGRenderer {
|
|
|
9528
9606
|
getString() {
|
|
9529
9607
|
const w = this.width.toFixed(0);
|
|
9530
9608
|
const h = this.height.toFixed(0);
|
|
9609
|
+
// Build defs content
|
|
9610
|
+
const defsContent = [`<clipPath id="vb"><rect width="${w}" height="${h}"/></clipPath>`];
|
|
9611
|
+
for (const sheet of this.#spriteSheetDefs.values()) {
|
|
9612
|
+
defsContent.push(`<image id="${escapeXml(sheet.defId)}" width="${formatNum(sheet.width)}" height="${formatNum(sheet.height)}" href="${escapeXml(sheet.href)}" />`);
|
|
9613
|
+
}
|
|
9614
|
+
for (const sym of this.#spriteSymbolDefs.values()) {
|
|
9615
|
+
const clipId = `${sym.symbolId}-clip`;
|
|
9616
|
+
defsContent.push(`<clipPath id="${escapeXml(clipId)}"><rect width="${formatNum(sym.width)}" height="${formatNum(sym.height)}" /></clipPath>`, `<symbol id="${escapeXml(sym.symbolId)}"><g clip-path="url(#${escapeXml(clipId)})"><use href="#${escapeXml(sym.sheetDefId)}" x="${formatNum(-sym.x)}" y="${formatNum(-sym.y)}" /></g></symbol>`);
|
|
9617
|
+
}
|
|
9618
|
+
for (const { content } of this.#sdfFilterDefs.values()) {
|
|
9619
|
+
defsContent.push(content);
|
|
9620
|
+
}
|
|
9531
9621
|
const parts = [
|
|
9532
9622
|
`<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">`,
|
|
9533
|
-
`<defs
|
|
9623
|
+
`<defs>\n ${defsContent.join('\n ')}\n</defs>`,
|
|
9534
9624
|
`<g clip-path="url(#vb)">`,
|
|
9535
9625
|
];
|
|
9536
9626
|
if (this.#backgroundColor.alpha > 0) {
|
|
@@ -9555,6 +9645,9 @@ function strokeAttr(color, width) {
|
|
|
9555
9645
|
function formatScaled(v) {
|
|
9556
9646
|
return formatNum(Math.round(v * 10));
|
|
9557
9647
|
}
|
|
9648
|
+
function formatScale(v) {
|
|
9649
|
+
return (Math.round(v * 10000) / 10000).toString();
|
|
9650
|
+
}
|
|
9558
9651
|
function roundXY(x, y) {
|
|
9559
9652
|
return [Math.round(x * 10), Math.round(y * 10)];
|
|
9560
9653
|
}
|
|
@@ -16017,14 +16110,29 @@ async function loadVectorSource(source, job, layerFeatures) {
|
|
|
16017
16110
|
list = features.polygons;
|
|
16018
16111
|
break;
|
|
16019
16112
|
}
|
|
16020
|
-
|
|
16021
|
-
|
|
16022
|
-
geometry
|
|
16023
|
-
|
|
16024
|
-
|
|
16025
|
-
|
|
16026
|
-
|
|
16027
|
-
|
|
16113
|
+
// Split MultiPoint into individual Point features
|
|
16114
|
+
if (type === 'Point' && geometry.length > 1) {
|
|
16115
|
+
for (const ring of geometry) {
|
|
16116
|
+
const feature = new Feature({
|
|
16117
|
+
type,
|
|
16118
|
+
geometry: [ring],
|
|
16119
|
+
id: featureSrc.id,
|
|
16120
|
+
properties: featureSrc.properties,
|
|
16121
|
+
});
|
|
16122
|
+
if (feature.doesOverlap([0, 0, width, height]))
|
|
16123
|
+
list.push(feature);
|
|
16124
|
+
}
|
|
16125
|
+
}
|
|
16126
|
+
else {
|
|
16127
|
+
const feature = new Feature({
|
|
16128
|
+
type,
|
|
16129
|
+
geometry,
|
|
16130
|
+
id: featureSrc.id,
|
|
16131
|
+
properties: featureSrc.properties,
|
|
16132
|
+
});
|
|
16133
|
+
if (feature.doesOverlap([0, 0, width, height]))
|
|
16134
|
+
list.push(feature);
|
|
16135
|
+
}
|
|
16028
16136
|
}
|
|
16029
16137
|
}
|
|
16030
16138
|
}));
|
|
@@ -16161,6 +16269,14 @@ async function getRasterTiles(job, sourceName) {
|
|
|
16161
16269
|
return rasterTiles.filter((tile) => tile !== null);
|
|
16162
16270
|
}
|
|
16163
16271
|
|
|
16272
|
+
async function fetchSpritePair(url) {
|
|
16273
|
+
const [jsonResponse, imageResponse] = await Promise.all([
|
|
16274
|
+
fetch(`${url}.json`),
|
|
16275
|
+
fetch(`${url}.png`),
|
|
16276
|
+
]);
|
|
16277
|
+
if (jsonResponse.ok && imageResponse.ok)
|
|
16278
|
+
return { jsonResponse, imageResponse };
|
|
16279
|
+
}
|
|
16164
16280
|
async function loadSpriteAtlas(style) {
|
|
16165
16281
|
const atlas = new Map();
|
|
16166
16282
|
const sprite = style.sprite;
|
|
@@ -16180,12 +16296,11 @@ async function loadSpriteAtlas(style) {
|
|
|
16180
16296
|
}
|
|
16181
16297
|
await Promise.all(sources.map(async ({ id, url }) => {
|
|
16182
16298
|
try {
|
|
16183
|
-
|
|
16184
|
-
|
|
16185
|
-
|
|
16186
|
-
]);
|
|
16187
|
-
if (!jsonResponse.ok || !imageResponse.ok)
|
|
16299
|
+
// Try @2x retina sprites first, fall back to 1x
|
|
16300
|
+
const spritePair = (await fetchSpritePair(`${url}@2x`)) ?? (await fetchSpritePair(url));
|
|
16301
|
+
if (!spritePair)
|
|
16188
16302
|
return;
|
|
16303
|
+
const { jsonResponse, imageResponse } = spritePair;
|
|
16189
16304
|
const json = (await jsonResponse.json());
|
|
16190
16305
|
const imageBuffer = await imageResponse.arrayBuffer();
|
|
16191
16306
|
const base64 = typeof Buffer !== 'undefined'
|
|
@@ -16207,6 +16322,7 @@ async function loadSpriteAtlas(style) {
|
|
|
16207
16322
|
x: entry.x,
|
|
16208
16323
|
y: entry.y,
|
|
16209
16324
|
pixelRatio: entry.pixelRatio ?? 1,
|
|
16325
|
+
sdf: entry.sdf ?? false,
|
|
16210
16326
|
sheetDataUri,
|
|
16211
16327
|
sheetWidth,
|
|
16212
16328
|
sheetHeight,
|
|
@@ -16540,6 +16656,7 @@ async function render(job) {
|
|
|
16540
16656
|
: '';
|
|
16541
16657
|
if (!iconName || !spriteAtlas.has(iconName))
|
|
16542
16658
|
return [];
|
|
16659
|
+
const spriteEntry = spriteAtlas.get(iconName);
|
|
16543
16660
|
return [
|
|
16544
16661
|
[
|
|
16545
16662
|
feature,
|
|
@@ -16550,6 +16667,10 @@ async function render(job) {
|
|
|
16550
16667
|
offset: getLayout('icon-offset', feature),
|
|
16551
16668
|
rotate: getLayout('icon-rotate', feature),
|
|
16552
16669
|
opacity: getPaint('icon-opacity', feature),
|
|
16670
|
+
sdf: spriteEntry.sdf,
|
|
16671
|
+
color: getPaint('icon-color', feature),
|
|
16672
|
+
haloColor: getPaint('icon-halo-color', feature),
|
|
16673
|
+
haloWidth: getPaint('icon-halo-width', feature),
|
|
16553
16674
|
},
|
|
16554
16675
|
],
|
|
16555
16676
|
];
|