@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/dist/maplibre.cjs CHANGED
@@ -9444,6 +9444,9 @@ class SVGRenderer {
9444
9444
  height;
9445
9445
  #svg;
9446
9446
  #backgroundColor;
9447
+ #spriteSheetDefs = new Map();
9448
+ #spriteSymbolDefs = new Map();
9449
+ #sdfFilterDefs = new Map();
9447
9450
  constructor(opt) {
9448
9451
  this.width = opt.width;
9449
9452
  this.height = opt.height;
@@ -9636,7 +9639,7 @@ class SVGRenderer {
9636
9639
  drawIcons(id, features, spriteAtlas) {
9637
9640
  if (features.length === 0)
9638
9641
  return;
9639
- this.#svg.push(`<g id="${escapeXml(id)}">`);
9642
+ const elements = [];
9640
9643
  for (const [feature, style] of features) {
9641
9644
  if (style.opacity <= 0)
9642
9645
  continue;
@@ -9653,27 +9656,102 @@ class SVGRenderer {
9653
9656
  const [anchorDx, anchorDy] = mapIconAnchor(style.anchor, iconW, iconH);
9654
9657
  const ox = style.offset[0] * style.size + anchorDx;
9655
9658
  const oy = style.offset[1] * style.size + anchorDy;
9656
- const x = point.x + ox;
9657
- const y = point.y + oy;
9658
- const [sx, sy] = roundXY(x, y);
9659
- const [sw, sh] = roundXY(iconW, iconH);
9660
- const viewBox = `${String(sprite.x)} ${String(sprite.y)} ${String(sprite.width)} ${String(sprite.height)}`;
9661
- const attrs = [
9662
- `x="${formatNum(sx)}"`,
9663
- `y="${formatNum(sy)}"`,
9664
- `width="${formatNum(sw)}"`,
9665
- `height="${formatNum(sh)}"`,
9666
- ];
9667
- if (style.opacity < 1)
9668
- attrs.push(`opacity="${style.opacity.toFixed(3)}"`);
9659
+ const [iconXr, iconYr] = roundXY(point.x + ox, point.y + oy);
9660
+ // Register sprite sheet in global defs (once per unique data URI)
9661
+ const imgW = Math.round(sprite.sheetWidth * 10);
9662
+ const imgH = Math.round(sprite.sheetHeight * 10);
9663
+ const sheetKey = sprite.sheetDataUri;
9664
+ if (!this.#spriteSheetDefs.has(sheetKey)) {
9665
+ this.#spriteSheetDefs.set(sheetKey, {
9666
+ defId: `sprite-sheet-${String(this.#spriteSheetDefs.size)}`,
9667
+ width: imgW,
9668
+ height: imgH,
9669
+ href: sprite.sheetDataUri,
9670
+ });
9671
+ }
9672
+ const sheetDef = this.#spriteSheetDefs.get(sheetKey);
9673
+ // Register symbol for this sprite (once per sprite name + sheet)
9674
+ const sprX = Math.round(sprite.x * 10);
9675
+ const sprY = Math.round(sprite.y * 10);
9676
+ const sprW = Math.round(sprite.width * 10);
9677
+ const sprH = Math.round(sprite.height * 10);
9678
+ const symKey = `${style.image}\0${sheetKey}`;
9679
+ if (!this.#spriteSymbolDefs.has(symKey)) {
9680
+ this.#spriteSymbolDefs.set(symKey, {
9681
+ symbolId: `sprite-${escapeXml(style.image)}`,
9682
+ sheetDefId: sheetDef.defId,
9683
+ x: sprX,
9684
+ y: sprY,
9685
+ width: sprW,
9686
+ height: sprH,
9687
+ });
9688
+ }
9689
+ const symDef = this.#spriteSymbolDefs.get(symKey);
9690
+ // Build instance: translate to position, scale from native to desired size
9691
+ const scaleStr = scale === 1 ? '' : ` scale(${formatScale(scale)})`;
9692
+ const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9693
+ // SDF filter for colorable icons
9694
+ let filterAttr = '';
9695
+ if (style.sdf) {
9696
+ const iconColor = new Color(style.color);
9697
+ const haloColor = new Color(style.haloColor);
9698
+ const hasHalo = style.haloWidth > 0 && haloColor.alpha > 0;
9699
+ const filterKey = hasHalo
9700
+ ? `sdf\0${iconColor.hex}\0${haloColor.hex}\0${String(style.haloWidth)}`
9701
+ : `sdf\0${iconColor.hex}`;
9702
+ if (!this.#sdfFilterDefs.has(filterKey)) {
9703
+ const filterId = `sdf-${String(this.#sdfFilterDefs.size)}`;
9704
+ const iconFloodOpacity = iconColor.alpha < 255 ? ` flood-opacity="${iconColor.opacity.toFixed(3)}"` : '';
9705
+ let content;
9706
+ if (hasHalo) {
9707
+ const haloRadius = formatScale(style.haloWidth);
9708
+ const haloFloodOpacity = haloColor.alpha < 255 ? ` flood-opacity="${haloColor.opacity.toFixed(3)}"` : '';
9709
+ content =
9710
+ `<filter id="${filterId}" color-interpolation-filters="sRGB">` +
9711
+ // Threshold alpha at 0.75 (MapLibre SDF edge) to get sharp icon mask
9712
+ `<feComponentTransfer in="SourceGraphic" result="sharp"><feFuncA type="discrete" tableValues="0 0 0 1" /></feComponentTransfer>` +
9713
+ // Dilate sharp mask for halo
9714
+ `<feMorphology in="sharp" operator="dilate" radius="${haloRadius}" result="dilated" />` +
9715
+ `<feFlood flood-color="${haloColor.rgb}"${haloFloodOpacity} result="haloColor" />` +
9716
+ `<feComposite in="haloColor" in2="dilated" operator="in" result="halo" />` +
9717
+ // Color the sharp icon
9718
+ `<feFlood flood-color="${iconColor.rgb}"${iconFloodOpacity} result="iconColor" />` +
9719
+ `<feComposite in="iconColor" in2="sharp" operator="in" result="colored" />` +
9720
+ `<feComposite in="colored" in2="halo" operator="over" />` +
9721
+ `</filter>`;
9722
+ }
9723
+ else {
9724
+ content =
9725
+ `<filter id="${filterId}" x="0" y="0" width="1" height="1" color-interpolation-filters="sRGB">` +
9726
+ // Threshold alpha at 0.75 (MapLibre SDF edge) to get sharp mask
9727
+ `<feComponentTransfer in="SourceGraphic" result="sharp"><feFuncA type="discrete" tableValues="0 0 0 1" /></feComponentTransfer>` +
9728
+ // Replace color while keeping sharp alpha
9729
+ `<feFlood flood-color="${iconColor.rgb}"${iconFloodOpacity} result="color" />` +
9730
+ `<feComposite in="color" in2="sharp" operator="in" />` +
9731
+ `</filter>`;
9732
+ }
9733
+ this.#sdfFilterDefs.set(filterKey, { filterId, content });
9734
+ }
9735
+ const { filterId } = this.#sdfFilterDefs.get(filterKey);
9736
+ filterAttr = ` filter="url(#${filterId})"`;
9737
+ }
9669
9738
  if (style.rotate !== 0) {
9670
9739
  const [cx, cy] = roundXY(point.x + style.offset[0] * style.size, point.y + style.offset[1] * style.size);
9671
- attrs.push(`transform="rotate(${String(style.rotate)},${formatNum(cx)},${formatNum(cy)})"`);
9740
+ elements.push(`<g transform="rotate(${String(style.rotate)},${formatNum(cx)},${formatNum(cy)})">` +
9741
+ `<g transform="translate(${formatNum(iconXr)},${formatNum(iconYr)})${scaleStr}"${opacityAttr}${filterAttr}>` +
9742
+ `<use href="#${escapeXml(symDef.symbolId)}" />` +
9743
+ `</g></g>`);
9744
+ }
9745
+ else {
9746
+ elements.push(`<g transform="translate(${formatNum(iconXr)},${formatNum(iconYr)})${scaleStr}"${opacityAttr}${filterAttr}>` +
9747
+ `<use href="#${escapeXml(symDef.symbolId)}" />` +
9748
+ `</g>`);
9672
9749
  }
9673
- this.#svg.push(`<svg ${attrs.join(' ')} viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg">` +
9674
- `<image width="${String(sprite.sheetWidth)}" height="${String(sprite.sheetHeight)}" href="${sprite.sheetDataUri}" />` +
9675
- `</svg>`);
9676
9750
  }
9751
+ if (elements.length === 0)
9752
+ return;
9753
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9754
+ this.#svg.push(...elements);
9677
9755
  this.#svg.push('</g>');
9678
9756
  }
9679
9757
  drawRasterTiles(id, tiles, style) {
@@ -9709,9 +9787,21 @@ class SVGRenderer {
9709
9787
  getString() {
9710
9788
  const w = this.width.toFixed(0);
9711
9789
  const h = this.height.toFixed(0);
9790
+ // Build defs content
9791
+ const defsContent = [`<clipPath id="vb"><rect width="${w}" height="${h}"/></clipPath>`];
9792
+ for (const sheet of this.#spriteSheetDefs.values()) {
9793
+ defsContent.push(`<image id="${escapeXml(sheet.defId)}" width="${formatNum(sheet.width)}" height="${formatNum(sheet.height)}" href="${escapeXml(sheet.href)}" />`);
9794
+ }
9795
+ for (const sym of this.#spriteSymbolDefs.values()) {
9796
+ const clipId = `${sym.symbolId}-clip`;
9797
+ 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>`);
9798
+ }
9799
+ for (const { content } of this.#sdfFilterDefs.values()) {
9800
+ defsContent.push(content);
9801
+ }
9712
9802
  const parts = [
9713
9803
  `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">`,
9714
- `<defs><clipPath id="vb"><rect width="${w}" height="${h}"/></clipPath></defs>`,
9804
+ `<defs>\n ${defsContent.join('\n ')}\n</defs>`,
9715
9805
  `<g clip-path="url(#vb)">`,
9716
9806
  ];
9717
9807
  if (this.#backgroundColor.alpha > 0) {
@@ -9736,6 +9826,9 @@ function strokeAttr(color, width) {
9736
9826
  function formatScaled(v) {
9737
9827
  return formatNum(Math.round(v * 10));
9738
9828
  }
9829
+ function formatScale(v) {
9830
+ return (Math.round(v * 10000) / 10000).toString();
9831
+ }
9739
9832
  function roundXY(x, y) {
9740
9833
  return [Math.round(x * 10), Math.round(y * 10)];
9741
9834
  }
@@ -16198,14 +16291,29 @@ async function loadVectorSource(source, job, layerFeatures) {
16198
16291
  list = features.polygons;
16199
16292
  break;
16200
16293
  }
16201
- const feature = new Feature({
16202
- type,
16203
- geometry,
16204
- id: featureSrc.id,
16205
- properties: featureSrc.properties,
16206
- });
16207
- if (feature.doesOverlap([0, 0, width, height]))
16208
- list.push(feature);
16294
+ // Split MultiPoint into individual Point features
16295
+ if (type === 'Point' && geometry.length > 1) {
16296
+ for (const ring of geometry) {
16297
+ const feature = new Feature({
16298
+ type,
16299
+ geometry: [ring],
16300
+ id: featureSrc.id,
16301
+ properties: featureSrc.properties,
16302
+ });
16303
+ if (feature.doesOverlap([0, 0, width, height]))
16304
+ list.push(feature);
16305
+ }
16306
+ }
16307
+ else {
16308
+ const feature = new Feature({
16309
+ type,
16310
+ geometry,
16311
+ id: featureSrc.id,
16312
+ properties: featureSrc.properties,
16313
+ });
16314
+ if (feature.doesOverlap([0, 0, width, height]))
16315
+ list.push(feature);
16316
+ }
16209
16317
  }
16210
16318
  }
16211
16319
  }));
@@ -16342,6 +16450,14 @@ async function getRasterTiles(job, sourceName) {
16342
16450
  return rasterTiles.filter((tile) => tile !== null);
16343
16451
  }
16344
16452
 
16453
+ async function fetchSpritePair(url) {
16454
+ const [jsonResponse, imageResponse] = await Promise.all([
16455
+ fetch(`${url}.json`),
16456
+ fetch(`${url}.png`),
16457
+ ]);
16458
+ if (jsonResponse.ok && imageResponse.ok)
16459
+ return { jsonResponse, imageResponse };
16460
+ }
16345
16461
  async function loadSpriteAtlas(style) {
16346
16462
  const atlas = new Map();
16347
16463
  const sprite = style.sprite;
@@ -16361,12 +16477,11 @@ async function loadSpriteAtlas(style) {
16361
16477
  }
16362
16478
  await Promise.all(sources.map(async ({ id, url }) => {
16363
16479
  try {
16364
- const [jsonResponse, imageResponse] = await Promise.all([
16365
- fetch(`${url}.json`),
16366
- fetch(`${url}.png`),
16367
- ]);
16368
- if (!jsonResponse.ok || !imageResponse.ok)
16480
+ // Try @2x retina sprites first, fall back to 1x
16481
+ const spritePair = (await fetchSpritePair(`${url}@2x`)) ?? (await fetchSpritePair(url));
16482
+ if (!spritePair)
16369
16483
  return;
16484
+ const { jsonResponse, imageResponse } = spritePair;
16370
16485
  const json = (await jsonResponse.json());
16371
16486
  const imageBuffer = await imageResponse.arrayBuffer();
16372
16487
  const base64 = typeof Buffer !== 'undefined'
@@ -16388,6 +16503,7 @@ async function loadSpriteAtlas(style) {
16388
16503
  x: entry.x,
16389
16504
  y: entry.y,
16390
16505
  pixelRatio: entry.pixelRatio ?? 1,
16506
+ sdf: entry.sdf ?? false,
16391
16507
  sheetDataUri,
16392
16508
  sheetWidth,
16393
16509
  sheetHeight,
@@ -16721,6 +16837,7 @@ async function render(job) {
16721
16837
  : '';
16722
16838
  if (!iconName || !spriteAtlas.has(iconName))
16723
16839
  return [];
16840
+ const spriteEntry = spriteAtlas.get(iconName);
16724
16841
  return [
16725
16842
  [
16726
16843
  feature,
@@ -16731,6 +16848,10 @@ async function render(job) {
16731
16848
  offset: getLayout('icon-offset', feature),
16732
16849
  rotate: getLayout('icon-rotate', feature),
16733
16850
  opacity: getPaint('icon-opacity', feature),
16851
+ sdf: spriteEntry.sdf,
16852
+ color: getPaint('icon-color', feature),
16853
+ haloColor: getPaint('icon-halo-color', feature),
16854
+ haloWidth: getPaint('icon-halo-width', feature),
16734
16855
  },
16735
16856
  ],
16736
16857
  ];