@versatiles/svg-renderer 0.5.2 → 0.7.0

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/index.js CHANGED
@@ -2826,6 +2826,23 @@ var paint_raster = {
2826
2826
  },
2827
2827
  "property-type": "data-constant"
2828
2828
  },
2829
+ resampling: {
2830
+ type: "enum",
2831
+ values: {
2832
+ linear: {
2833
+ },
2834
+ nearest: {
2835
+ }
2836
+ },
2837
+ "default": "linear",
2838
+ expression: {
2839
+ interpolated: false,
2840
+ parameters: [
2841
+ "zoom"
2842
+ ]
2843
+ },
2844
+ "property-type": "data-constant"
2845
+ },
2829
2846
  "raster-resampling": {
2830
2847
  type: "enum",
2831
2848
  values: {
@@ -2976,6 +2993,23 @@ var paint_hillshade = {
2976
2993
  ]
2977
2994
  },
2978
2995
  "property-type": "data-constant"
2996
+ },
2997
+ resampling: {
2998
+ type: "enum",
2999
+ values: {
3000
+ linear: {
3001
+ },
3002
+ nearest: {
3003
+ }
3004
+ },
3005
+ "default": "linear",
3006
+ expression: {
3007
+ interpolated: false,
3008
+ parameters: [
3009
+ "zoom"
3010
+ ]
3011
+ },
3012
+ "property-type": "data-constant"
2979
3013
  }
2980
3014
  };
2981
3015
  var paint_background = {
@@ -3396,6 +3430,23 @@ var v8Spec = {
3396
3430
  ]
3397
3431
  },
3398
3432
  "property-type": "color-ramp"
3433
+ },
3434
+ resampling: {
3435
+ type: "enum",
3436
+ values: {
3437
+ linear: {
3438
+ },
3439
+ nearest: {
3440
+ }
3441
+ },
3442
+ "default": "linear",
3443
+ expression: {
3444
+ interpolated: false,
3445
+ parameters: [
3446
+ "zoom"
3447
+ ]
3448
+ },
3449
+ "property-type": "data-constant"
3399
3450
  }
3400
3451
  },
3401
3452
  paint_background: paint_background,
@@ -6240,11 +6291,12 @@ class CollatorExpression {
6240
6291
  }
6241
6292
 
6242
6293
  class NumberFormat {
6243
- constructor(number, locale, currency, minFractionDigits, maxFractionDigits) {
6294
+ constructor(number, locale, currency, unit, minFractionDigits, maxFractionDigits) {
6244
6295
  this.type = StringType;
6245
6296
  this.number = number;
6246
6297
  this.locale = locale;
6247
6298
  this.currency = currency;
6299
+ this.unit = unit;
6248
6300
  this.minFractionDigits = minFractionDigits;
6249
6301
  this.maxFractionDigits = maxFractionDigits;
6250
6302
  }
@@ -6269,6 +6321,15 @@ class NumberFormat {
6269
6321
  if (!currency)
6270
6322
  return null;
6271
6323
  }
6324
+ let unit = null;
6325
+ if (options['unit']) {
6326
+ unit = context.parse(options['unit'], 1, StringType);
6327
+ if (!unit)
6328
+ return null;
6329
+ }
6330
+ if (currency && unit) {
6331
+ return context.error('NumberFormat options `currency` and `unit` are mutually exclusive');
6332
+ }
6272
6333
  let minFractionDigits = null;
6273
6334
  if (options['min-fraction-digits']) {
6274
6335
  minFractionDigits = context.parse(options['min-fraction-digits'], 1, NumberType);
@@ -6281,12 +6342,13 @@ class NumberFormat {
6281
6342
  if (!maxFractionDigits)
6282
6343
  return null;
6283
6344
  }
6284
- return new NumberFormat(number, locale, currency, minFractionDigits, maxFractionDigits);
6345
+ return new NumberFormat(number, locale, currency, unit, minFractionDigits, maxFractionDigits);
6285
6346
  }
6286
6347
  evaluate(ctx) {
6287
6348
  return new Intl.NumberFormat(this.locale ? this.locale.evaluate(ctx) : [], {
6288
- style: this.currency ? 'currency' : 'decimal',
6349
+ style: this.currency ? 'currency' : this.unit ? 'unit' : 'decimal',
6289
6350
  currency: this.currency ? this.currency.evaluate(ctx) : undefined,
6351
+ unit: this.unit ? this.unit.evaluate(ctx) : undefined,
6290
6352
  minimumFractionDigits: this.minFractionDigits
6291
6353
  ? this.minFractionDigits.evaluate(ctx)
6292
6354
  : undefined,
@@ -6303,6 +6365,9 @@ class NumberFormat {
6303
6365
  if (this.currency) {
6304
6366
  fn(this.currency);
6305
6367
  }
6368
+ if (this.unit) {
6369
+ fn(this.unit);
6370
+ }
6306
6371
  if (this.minFractionDigits) {
6307
6372
  fn(this.minFractionDigits);
6308
6373
  }
@@ -8041,6 +8106,16 @@ CompoundExpression.register(expressions$1, {
8041
8106
  varargs(ValueType),
8042
8107
  (ctx, args) => args.map((arg) => valueToString(arg.evaluate(ctx))).join('')
8043
8108
  ],
8109
+ split: [
8110
+ array(StringType),
8111
+ [StringType, StringType],
8112
+ (ctx, [s, delim]) => s.evaluate(ctx).split(delim.evaluate(ctx))
8113
+ ],
8114
+ join: [
8115
+ StringType,
8116
+ [array(StringType), StringType],
8117
+ (ctx, [arr, delim]) => arr.value.join(delim.evaluate(ctx))
8118
+ ],
8044
8119
  'resolved-locale': [
8045
8120
  StringType,
8046
8121
  [CollatorType],
@@ -9186,6 +9261,9 @@ class SVGRenderer {
9186
9261
  height;
9187
9262
  #svg;
9188
9263
  #backgroundColor;
9264
+ #spriteSheetDefs = new Map();
9265
+ #spriteSymbolDefs = new Map();
9266
+ #sdfFilterDefs = new Map();
9189
9267
  constructor(opt) {
9190
9268
  this.width = opt.width;
9191
9269
  this.height = opt.height;
@@ -9197,7 +9275,7 @@ class SVGRenderer {
9197
9275
  color.alpha *= style.opacity;
9198
9276
  this.#backgroundColor = color;
9199
9277
  }
9200
- drawPolygons(features) {
9278
+ drawPolygons(id, features) {
9201
9279
  if (features.length === 0)
9202
9280
  return;
9203
9281
  const groups = new Map();
@@ -9221,12 +9299,14 @@ class SVGRenderer {
9221
9299
  group.segments.push(ring.map((p) => roundXY(p.x, p.y)));
9222
9300
  });
9223
9301
  });
9302
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9224
9303
  for (const { segments, attrs } of groups.values()) {
9225
9304
  const d = segmentsToPath(segments, true);
9226
9305
  this.#svg.push(`<path d="${d}" ${attrs} />`);
9227
9306
  }
9307
+ this.#svg.push('</g>');
9228
9308
  }
9229
- drawLineStrings(features) {
9309
+ drawLineStrings(id, features) {
9230
9310
  if (features.length === 0)
9231
9311
  return;
9232
9312
  const groups = new Map();
@@ -9275,13 +9355,15 @@ class SVGRenderer {
9275
9355
  group.segments.push(line.map((p) => roundXY(p.x, p.y)));
9276
9356
  });
9277
9357
  });
9358
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9278
9359
  for (const { segments, attrs } of groups.values()) {
9279
9360
  const chains = chainSegments(segments);
9280
9361
  const d = segmentsToPath(chains);
9281
9362
  this.#svg.push(`<path d="${d}" ${attrs} />`);
9282
9363
  }
9364
+ this.#svg.push('</g>');
9283
9365
  }
9284
- drawCircles(features) {
9366
+ drawCircles(id, features) {
9285
9367
  if (features.length === 0)
9286
9368
  return;
9287
9369
  const groups = new Map();
@@ -9313,13 +9395,183 @@ class SVGRenderer {
9313
9395
  group.points.push(roundXY(p.x, p.y));
9314
9396
  });
9315
9397
  });
9398
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9316
9399
  for (const { points, attrs } of groups.values()) {
9317
9400
  for (const [x, y] of points) {
9318
9401
  this.#svg.push(`<circle cx="${formatNum(x)}" cy="${formatNum(y)}" ${attrs} />`);
9319
9402
  }
9320
9403
  }
9404
+ this.#svg.push('</g>');
9405
+ }
9406
+ drawLabels(id, features) {
9407
+ if (features.length === 0)
9408
+ return;
9409
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9410
+ for (const [feature, style] of features) {
9411
+ if (style.opacity <= 0 || !style.text)
9412
+ continue;
9413
+ const color = new Color(style.color);
9414
+ if (color.alpha <= 0)
9415
+ continue;
9416
+ const ring = feature.geometry[0];
9417
+ if (!ring || ring.length === 0)
9418
+ continue;
9419
+ const point = ring[Math.floor(ring.length / 2)];
9420
+ const [px, py] = roundXY(point.x, point.y);
9421
+ const fontSize = formatScaled(style.size);
9422
+ const fontFamily = style.font.join(', ') + ', Helvetica, Arial, sans-serif';
9423
+ const [svgAnchor, baseline] = mapTextAnchor(style.anchor);
9424
+ const offsetX = style.offset[0] * style.size;
9425
+ const offsetY = style.offset[1] * style.size;
9426
+ const [dx, dy] = roundXY(offsetX, offsetY);
9427
+ const attrs = [
9428
+ `x="${formatNum(px)}"`,
9429
+ `y="${formatNum(py)}"`,
9430
+ `font-family="${escapeXml(fontFamily)}"`,
9431
+ `font-size="${fontSize}"`,
9432
+ `text-anchor="${svgAnchor}"`,
9433
+ `dominant-baseline="${baseline}"`,
9434
+ ];
9435
+ if (dx !== 0)
9436
+ attrs.push(`dx="${formatNum(dx)}"`);
9437
+ if (dy !== 0)
9438
+ attrs.push(`dy="${formatNum(dy)}"`);
9439
+ if (style.rotate !== 0) {
9440
+ attrs.push(`transform="rotate(${String(style.rotate)},${formatNum(px)},${formatNum(py)})"`);
9441
+ }
9442
+ const haloColor = new Color(style.haloColor);
9443
+ if (style.haloWidth > 0 && haloColor.alpha > 0) {
9444
+ const haloWidth = formatScaled(style.haloWidth);
9445
+ attrs.push('paint-order="stroke fill"', `stroke="${haloColor.rgb}"`, `stroke-width="${haloWidth}"`, 'stroke-linejoin="round"');
9446
+ if (haloColor.alpha < 255)
9447
+ attrs.push(`stroke-opacity="${haloColor.opacity.toFixed(3)}"`);
9448
+ }
9449
+ attrs.push(fillAttr(color));
9450
+ if (style.opacity < 1)
9451
+ attrs.push(`opacity="${style.opacity.toFixed(3)}"`);
9452
+ this.#svg.push(`<text ${attrs.join(' ')}>${escapeXml(style.text)}</text>`);
9453
+ }
9454
+ this.#svg.push('</g>');
9321
9455
  }
9322
- drawRasterTiles(tiles, style) {
9456
+ drawIcons(id, features, spriteAtlas) {
9457
+ if (features.length === 0)
9458
+ return;
9459
+ const elements = [];
9460
+ for (const [feature, style] of features) {
9461
+ if (style.opacity <= 0)
9462
+ continue;
9463
+ const sprite = spriteAtlas.get(style.image);
9464
+ if (!sprite)
9465
+ continue;
9466
+ const ring = feature.geometry[0];
9467
+ if (!ring || ring.length === 0)
9468
+ continue;
9469
+ const point = ring[Math.floor(ring.length / 2)];
9470
+ const scale = style.size / sprite.pixelRatio;
9471
+ const iconW = sprite.width * scale;
9472
+ const iconH = sprite.height * scale;
9473
+ const [anchorDx, anchorDy] = mapIconAnchor(style.anchor, iconW, iconH);
9474
+ const ox = style.offset[0] * style.size + anchorDx;
9475
+ const oy = style.offset[1] * style.size + anchorDy;
9476
+ const [iconXr, iconYr] = roundXY(point.x + ox, point.y + oy);
9477
+ // Register sprite sheet in global defs (once per unique data URI)
9478
+ const imgW = Math.round(sprite.sheetWidth * 10);
9479
+ const imgH = Math.round(sprite.sheetHeight * 10);
9480
+ const sheetKey = sprite.sheetDataUri;
9481
+ if (!this.#spriteSheetDefs.has(sheetKey)) {
9482
+ this.#spriteSheetDefs.set(sheetKey, {
9483
+ defId: `sprite-sheet-${String(this.#spriteSheetDefs.size)}`,
9484
+ width: imgW,
9485
+ height: imgH,
9486
+ href: sprite.sheetDataUri,
9487
+ });
9488
+ }
9489
+ const sheetDef = this.#spriteSheetDefs.get(sheetKey);
9490
+ // Register symbol for this sprite (once per sprite name + sheet)
9491
+ const sprX = Math.round(sprite.x * 10);
9492
+ const sprY = Math.round(sprite.y * 10);
9493
+ const sprW = Math.round(sprite.width * 10);
9494
+ const sprH = Math.round(sprite.height * 10);
9495
+ const symKey = `${style.image}\0${sheetKey}`;
9496
+ if (!this.#spriteSymbolDefs.has(symKey)) {
9497
+ this.#spriteSymbolDefs.set(symKey, {
9498
+ symbolId: `sprite-${escapeXml(style.image)}`,
9499
+ sheetDefId: sheetDef.defId,
9500
+ x: sprX,
9501
+ y: sprY,
9502
+ width: sprW,
9503
+ height: sprH,
9504
+ });
9505
+ }
9506
+ const symDef = this.#spriteSymbolDefs.get(symKey);
9507
+ // Build instance: translate to position, scale from native to desired size
9508
+ const scaleStr = scale === 1 ? '' : ` scale(${formatScale(scale)})`;
9509
+ const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9510
+ // SDF filter for colorable icons
9511
+ let filterAttr = '';
9512
+ if (style.sdf) {
9513
+ const iconColor = new Color(style.color);
9514
+ const haloColor = new Color(style.haloColor);
9515
+ const hasHalo = style.haloWidth > 0 && haloColor.alpha > 0;
9516
+ const filterKey = hasHalo
9517
+ ? `sdf\0${iconColor.hex}\0${haloColor.hex}\0${String(style.haloWidth)}`
9518
+ : `sdf\0${iconColor.hex}`;
9519
+ if (!this.#sdfFilterDefs.has(filterKey)) {
9520
+ const filterId = `sdf-${String(this.#sdfFilterDefs.size)}`;
9521
+ const iconFloodOpacity = iconColor.alpha < 255 ? ` flood-opacity="${iconColor.opacity.toFixed(3)}"` : '';
9522
+ let content;
9523
+ if (hasHalo) {
9524
+ const haloRadius = formatScale(style.haloWidth);
9525
+ const haloFloodOpacity = haloColor.alpha < 255 ? ` flood-opacity="${haloColor.opacity.toFixed(3)}"` : '';
9526
+ content =
9527
+ `<filter id="${filterId}" color-interpolation-filters="sRGB">` +
9528
+ // Threshold alpha at 0.75 (MapLibre SDF edge) to get sharp icon mask
9529
+ `<feComponentTransfer in="SourceGraphic" result="sharp"><feFuncA type="discrete" tableValues="0 0 0 1" /></feComponentTransfer>` +
9530
+ // Dilate sharp mask for halo
9531
+ `<feMorphology in="sharp" operator="dilate" radius="${haloRadius}" result="dilated" />` +
9532
+ `<feFlood flood-color="${haloColor.rgb}"${haloFloodOpacity} result="haloColor" />` +
9533
+ `<feComposite in="haloColor" in2="dilated" operator="in" result="halo" />` +
9534
+ // Color the sharp icon
9535
+ `<feFlood flood-color="${iconColor.rgb}"${iconFloodOpacity} result="iconColor" />` +
9536
+ `<feComposite in="iconColor" in2="sharp" operator="in" result="colored" />` +
9537
+ `<feComposite in="colored" in2="halo" operator="over" />` +
9538
+ `</filter>`;
9539
+ }
9540
+ else {
9541
+ content =
9542
+ `<filter id="${filterId}" x="0" y="0" width="1" height="1" color-interpolation-filters="sRGB">` +
9543
+ // Threshold alpha at 0.75 (MapLibre SDF edge) to get sharp mask
9544
+ `<feComponentTransfer in="SourceGraphic" result="sharp"><feFuncA type="discrete" tableValues="0 0 0 1" /></feComponentTransfer>` +
9545
+ // Replace color while keeping sharp alpha
9546
+ `<feFlood flood-color="${iconColor.rgb}"${iconFloodOpacity} result="color" />` +
9547
+ `<feComposite in="color" in2="sharp" operator="in" />` +
9548
+ `</filter>`;
9549
+ }
9550
+ this.#sdfFilterDefs.set(filterKey, { filterId, content });
9551
+ }
9552
+ const { filterId } = this.#sdfFilterDefs.get(filterKey);
9553
+ filterAttr = ` filter="url(#${filterId})"`;
9554
+ }
9555
+ if (style.rotate !== 0) {
9556
+ const [cx, cy] = roundXY(point.x + style.offset[0] * style.size, point.y + style.offset[1] * style.size);
9557
+ elements.push(`<g transform="rotate(${String(style.rotate)},${formatNum(cx)},${formatNum(cy)})">` +
9558
+ `<g transform="translate(${formatNum(iconXr)},${formatNum(iconYr)})${scaleStr}"${opacityAttr}${filterAttr}>` +
9559
+ `<use href="#${escapeXml(symDef.symbolId)}" />` +
9560
+ `</g></g>`);
9561
+ }
9562
+ else {
9563
+ elements.push(`<g transform="translate(${formatNum(iconXr)},${formatNum(iconYr)})${scaleStr}"${opacityAttr}${filterAttr}>` +
9564
+ `<use href="#${escapeXml(symDef.symbolId)}" />` +
9565
+ `</g>`);
9566
+ }
9567
+ }
9568
+ if (elements.length === 0)
9569
+ return;
9570
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9571
+ this.#svg.push(...elements);
9572
+ this.#svg.push('</g>');
9573
+ }
9574
+ drawRasterTiles(id, tiles, style) {
9323
9575
  if (tiles.length === 0)
9324
9576
  return;
9325
9577
  if (style.opacity <= 0)
@@ -9335,7 +9587,7 @@ class SVGRenderer {
9335
9587
  const brightness = (style.brightnessMin + style.brightnessMax) / 2;
9336
9588
  filters.push(`brightness(${String(brightness)})`);
9337
9589
  }
9338
- let gAttrs = `opacity="${String(style.opacity)}"`;
9590
+ let gAttrs = `id="${escapeXml(id)}" opacity="${String(style.opacity)}"`;
9339
9591
  if (filters.length > 0)
9340
9592
  gAttrs += ` filter="${filters.join(' ')}"`;
9341
9593
  this.#svg.push(`<g ${gAttrs}>`);
@@ -9352,9 +9604,21 @@ class SVGRenderer {
9352
9604
  getString() {
9353
9605
  const w = this.width.toFixed(0);
9354
9606
  const h = this.height.toFixed(0);
9607
+ // Build defs content
9608
+ const defsContent = [`<clipPath id="vb"><rect width="${w}" height="${h}"/></clipPath>`];
9609
+ for (const sheet of this.#spriteSheetDefs.values()) {
9610
+ defsContent.push(`<image id="${escapeXml(sheet.defId)}" width="${formatNum(sheet.width)}" height="${formatNum(sheet.height)}" href="${escapeXml(sheet.href)}" />`);
9611
+ }
9612
+ for (const sym of this.#spriteSymbolDefs.values()) {
9613
+ const clipId = `${sym.symbolId}-clip`;
9614
+ 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>`);
9615
+ }
9616
+ for (const { content } of this.#sdfFilterDefs.values()) {
9617
+ defsContent.push(content);
9618
+ }
9355
9619
  const parts = [
9356
9620
  `<svg viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" xmlns="http://www.w3.org/2000/svg">`,
9357
- `<defs><clipPath id="vb"><rect width="${w}" height="${h}"/></clipPath></defs>`,
9621
+ `<defs>\n ${defsContent.join('\n ')}\n</defs>`,
9358
9622
  `<g clip-path="url(#vb)">`,
9359
9623
  ];
9360
9624
  if (this.#backgroundColor.alpha > 0) {
@@ -9379,6 +9643,9 @@ function strokeAttr(color, width) {
9379
9643
  function formatScaled(v) {
9380
9644
  return formatNum(Math.round(v * 10));
9381
9645
  }
9646
+ function formatScale(v) {
9647
+ return (Math.round(v * 10000) / 10000).toString();
9648
+ }
9382
9649
  function roundXY(x, y) {
9383
9650
  return [Math.round(x * 10), Math.round(y * 10)];
9384
9651
  }
@@ -9386,6 +9653,57 @@ function formatPoint(p) {
9386
9653
  const [x, y] = roundXY(p[0], p[1]);
9387
9654
  return formatNum(x) + ',' + formatNum(y);
9388
9655
  }
9656
+ function mapTextAnchor(anchor) {
9657
+ switch (anchor) {
9658
+ case 'left':
9659
+ return ['start', 'central'];
9660
+ case 'right':
9661
+ return ['end', 'central'];
9662
+ case 'top':
9663
+ return ['middle', 'text-before-edge'];
9664
+ case 'bottom':
9665
+ return ['middle', 'text-after-edge'];
9666
+ case 'top-left':
9667
+ return ['start', 'text-before-edge'];
9668
+ case 'top-right':
9669
+ return ['end', 'text-before-edge'];
9670
+ case 'bottom-left':
9671
+ return ['start', 'text-after-edge'];
9672
+ case 'bottom-right':
9673
+ return ['end', 'text-after-edge'];
9674
+ default:
9675
+ return ['middle', 'central'];
9676
+ }
9677
+ }
9678
+ function mapIconAnchor(anchor, w, h) {
9679
+ switch (anchor) {
9680
+ case 'left':
9681
+ return [0, -h / 2];
9682
+ case 'right':
9683
+ return [-w, -h / 2];
9684
+ case 'top':
9685
+ return [-w / 2, 0];
9686
+ case 'bottom':
9687
+ return [-w / 2, -h];
9688
+ case 'top-left':
9689
+ return [0, 0];
9690
+ case 'top-right':
9691
+ return [-w, 0];
9692
+ case 'bottom-left':
9693
+ return [0, -h];
9694
+ case 'bottom-right':
9695
+ return [-w, -h];
9696
+ default:
9697
+ return [-w / 2, -h / 2];
9698
+ }
9699
+ }
9700
+ function escapeXml(s) {
9701
+ return s
9702
+ .replace(/&/g, '&amp;')
9703
+ .replace(/</g, '&lt;')
9704
+ .replace(/>/g, '&gt;')
9705
+ .replace(/"/g, '&quot;');
9706
+ }
9389
9707
 
9390
9708
  /*
9391
9709
  * bignumber.js v9.3.1
@@ -15790,14 +16108,29 @@ async function loadVectorSource(source, job, layerFeatures) {
15790
16108
  list = features.polygons;
15791
16109
  break;
15792
16110
  }
15793
- const feature = new Feature({
15794
- type,
15795
- geometry,
15796
- id: featureSrc.id,
15797
- properties: featureSrc.properties,
15798
- });
15799
- if (feature.doesOverlap([0, 0, width, height]))
15800
- list.push(feature);
16111
+ // Split MultiPoint into individual Point features
16112
+ if (type === 'Point' && geometry.length > 1) {
16113
+ for (const ring of geometry) {
16114
+ const feature = new Feature({
16115
+ type,
16116
+ geometry: [ring],
16117
+ id: featureSrc.id,
16118
+ properties: featureSrc.properties,
16119
+ });
16120
+ if (feature.doesOverlap([0, 0, width, height]))
16121
+ list.push(feature);
16122
+ }
16123
+ }
16124
+ else {
16125
+ const feature = new Feature({
16126
+ type,
16127
+ geometry,
16128
+ id: featureSrc.id,
16129
+ properties: featureSrc.properties,
16130
+ });
16131
+ if (feature.doesOverlap([0, 0, width, height]))
16132
+ list.push(feature);
16133
+ }
15801
16134
  }
15802
16135
  }
15803
16136
  }));
@@ -15934,6 +16267,73 @@ async function getRasterTiles(job, sourceName) {
15934
16267
  return rasterTiles.filter((tile) => tile !== null);
15935
16268
  }
15936
16269
 
16270
+ async function fetchSpritePair(url) {
16271
+ const [jsonResponse, imageResponse] = await Promise.all([
16272
+ fetch(`${url}.json`),
16273
+ fetch(`${url}.png`),
16274
+ ]);
16275
+ if (jsonResponse.ok && imageResponse.ok)
16276
+ return { jsonResponse, imageResponse };
16277
+ }
16278
+ async function loadSpriteAtlas(style) {
16279
+ const atlas = new Map();
16280
+ const sprite = style.sprite;
16281
+ if (!sprite)
16282
+ return atlas;
16283
+ const sources = [];
16284
+ if (typeof sprite === 'string') {
16285
+ sources.push({ id: 'default', url: sprite });
16286
+ }
16287
+ else if (Array.isArray(sprite)) {
16288
+ for (const s of sprite) {
16289
+ sources.push({
16290
+ id: s.id,
16291
+ url: s.url,
16292
+ });
16293
+ }
16294
+ }
16295
+ await Promise.all(sources.map(async ({ id, url }) => {
16296
+ try {
16297
+ // Try @2x retina sprites first, fall back to 1x
16298
+ const spritePair = (await fetchSpritePair(`${url}@2x`)) ?? (await fetchSpritePair(url));
16299
+ if (!spritePair)
16300
+ return;
16301
+ const { jsonResponse, imageResponse } = spritePair;
16302
+ const json = (await jsonResponse.json());
16303
+ const imageBuffer = await imageResponse.arrayBuffer();
16304
+ const base64 = typeof Buffer !== 'undefined'
16305
+ ? Buffer.from(imageBuffer).toString('base64')
16306
+ : btoa(String.fromCharCode(...new Uint8Array(imageBuffer)));
16307
+ const sheetDataUri = `data:image/png;base64,${base64}`;
16308
+ // Estimate sheet dimensions from sprite entries
16309
+ let sheetWidth = 0;
16310
+ let sheetHeight = 0;
16311
+ for (const entry of Object.values(json)) {
16312
+ sheetWidth = Math.max(sheetWidth, entry.x + entry.width);
16313
+ sheetHeight = Math.max(sheetHeight, entry.y + entry.height);
16314
+ }
16315
+ const prefix = id === 'default' ? '' : `${id}:`;
16316
+ for (const [name, entry] of Object.entries(json)) {
16317
+ atlas.set(`${prefix}${name}`, {
16318
+ width: entry.width,
16319
+ height: entry.height,
16320
+ x: entry.x,
16321
+ y: entry.y,
16322
+ pixelRatio: entry.pixelRatio ?? 1,
16323
+ sdf: entry.sdf ?? false,
16324
+ sheetDataUri,
16325
+ sheetWidth,
16326
+ sheetHeight,
16327
+ });
16328
+ }
16329
+ }
16330
+ catch {
16331
+ // Silently skip failed sprite loads
16332
+ }
16333
+ }));
16334
+ return atlas;
16335
+ }
16336
+
15937
16337
  async function getLayerFeatures(job) {
15938
16338
  const { width, height } = job.renderer;
15939
16339
  const { zoom, center } = job.view;
@@ -16094,6 +16494,12 @@ function getLayerStyles(layers) {
16094
16494
  return layers.map(createStyleLayer);
16095
16495
  }
16096
16496
 
16497
+ function resolveTokens(text, properties) {
16498
+ return text.replace(/\{([^}]+)\}/g, (_, key) => {
16499
+ const value = properties[key];
16500
+ return value != null ? String(value) : '';
16501
+ });
16502
+ }
16097
16503
  async function renderMap(job) {
16098
16504
  await render(job);
16099
16505
  return job.renderer.getString();
@@ -16104,9 +16510,12 @@ function getFeatures(layerFeatures, layerStyle) {
16104
16510
  async function render(job) {
16105
16511
  const { renderer } = job;
16106
16512
  const { zoom } = job.view;
16107
- const layerFeatures = await getLayerFeatures(job);
16513
+ const [layerFeatures, spriteAtlas] = await Promise.all([
16514
+ getLayerFeatures(job),
16515
+ job.renderLabels ? loadSpriteAtlas(job.style) : Promise.resolve(new Map()),
16516
+ ]);
16108
16517
  const layerStyles = getLayerStyles(job.style.layers);
16109
- const availableImages = [];
16518
+ const availableImages = [...spriteAtlas.keys()];
16110
16519
  const featureState = {};
16111
16520
  for (const layerStyle of layerStyles) {
16112
16521
  if (layerStyle.isHidden(zoom))
@@ -16127,6 +16536,7 @@ async function render(job) {
16127
16536
  function getLayout(key, feature) {
16128
16537
  return getStyleValue(layerStyle.layout, key, feature);
16129
16538
  }
16539
+ const layerId = layerStyle.id;
16130
16540
  switch (layerStyle.type) {
16131
16541
  case 'background':
16132
16542
  {
@@ -16146,7 +16556,7 @@ async function render(job) {
16146
16556
  : polygons;
16147
16557
  if (polygonFeatures.length === 0)
16148
16558
  continue;
16149
- renderer.drawPolygons(polygonFeatures.map((feature) => [
16559
+ renderer.drawPolygons(layerId, polygonFeatures.map((feature) => [
16150
16560
  feature,
16151
16561
  {
16152
16562
  color: getPaint('fill-color', feature),
@@ -16166,7 +16576,7 @@ async function render(job) {
16166
16576
  : lineStrings;
16167
16577
  if (lineStringFeatures.length === 0)
16168
16578
  continue;
16169
- renderer.drawLineStrings(lineStringFeatures.map((feature) => [
16579
+ renderer.drawLineStrings(layerId, lineStringFeatures.map((feature) => [
16170
16580
  feature,
16171
16581
  {
16172
16582
  color: getPaint('line-color', feature),
@@ -16185,7 +16595,7 @@ async function render(job) {
16185
16595
  case 'raster':
16186
16596
  {
16187
16597
  const tiles = await getRasterTiles(job, layerStyle.source);
16188
- renderer.drawRasterTiles(tiles, {
16598
+ renderer.drawRasterTiles(layerId, tiles, {
16189
16599
  opacity: getPaint('raster-opacity'),
16190
16600
  hueRotate: getPaint('raster-hue-rotate'),
16191
16601
  brightnessMin: getPaint('raster-brightness-min'),
@@ -16206,7 +16616,7 @@ async function render(job) {
16206
16616
  : points;
16207
16617
  if (pointFeatures.length === 0)
16208
16618
  continue;
16209
- renderer.drawCircles(pointFeatures.map((feature) => [
16619
+ renderer.drawCircles(layerId, pointFeatures.map((feature) => [
16210
16620
  feature,
16211
16621
  {
16212
16622
  color: getPaint('circle-color', feature),
@@ -16219,11 +16629,81 @@ async function render(job) {
16219
16629
  ]));
16220
16630
  }
16221
16631
  continue;
16632
+ case 'symbol':
16633
+ {
16634
+ if (!job.renderLabels)
16635
+ continue;
16636
+ const features = getFeatures(layerFeatures, layerStyle);
16637
+ const allFeatures = [
16638
+ ...(features?.points ?? []),
16639
+ ...(features?.linestrings ?? []),
16640
+ ...(features?.polygons ?? []),
16641
+ ];
16642
+ if (allFeatures.length === 0)
16643
+ continue;
16644
+ const symbolFeatures = layerStyle.filterFn
16645
+ ? allFeatures.filter((feature) => layerStyle.filterFn.filter({ zoom }, feature))
16646
+ : allFeatures;
16647
+ if (symbolFeatures.length === 0)
16648
+ continue;
16649
+ // Render icons first (underneath text)
16650
+ renderer.drawIcons(`${layerId}-icons`, symbolFeatures.flatMap((feature) => {
16651
+ const iconImage = getLayout('icon-image', feature);
16652
+ const iconName = iconImage != null
16653
+ ? resolveTokens(iconImage.toString(), feature.properties)
16654
+ : '';
16655
+ if (!iconName || !spriteAtlas.has(iconName))
16656
+ return [];
16657
+ const spriteEntry = spriteAtlas.get(iconName);
16658
+ return [
16659
+ [
16660
+ feature,
16661
+ {
16662
+ image: iconName,
16663
+ size: getLayout('icon-size', feature),
16664
+ anchor: getLayout('icon-anchor', feature),
16665
+ offset: getLayout('icon-offset', feature),
16666
+ rotate: getLayout('icon-rotate', feature),
16667
+ opacity: getPaint('icon-opacity', feature),
16668
+ sdf: spriteEntry.sdf,
16669
+ color: getPaint('icon-color', feature),
16670
+ haloColor: getPaint('icon-halo-color', feature),
16671
+ haloWidth: getPaint('icon-halo-width', feature),
16672
+ },
16673
+ ],
16674
+ ];
16675
+ }), spriteAtlas);
16676
+ // Render text labels on top
16677
+ renderer.drawLabels(`${layerId}-labels`, symbolFeatures.flatMap((feature) => {
16678
+ const textField = getLayout('text-field', feature);
16679
+ const textRaw = textField != null ? textField.toString() : '';
16680
+ const text = resolveTokens(textRaw, feature.properties);
16681
+ if (!text)
16682
+ return [];
16683
+ return [
16684
+ [
16685
+ feature,
16686
+ {
16687
+ text,
16688
+ size: getLayout('text-size', feature),
16689
+ font: getLayout('text-font', feature),
16690
+ anchor: getLayout('text-anchor', feature),
16691
+ offset: getLayout('text-offset', feature),
16692
+ rotate: getLayout('text-rotate', feature),
16693
+ color: getPaint('text-color', feature),
16694
+ opacity: getPaint('text-opacity', feature),
16695
+ haloColor: getPaint('text-halo-color', feature),
16696
+ haloWidth: getPaint('text-halo-width', feature),
16697
+ },
16698
+ ],
16699
+ ];
16700
+ }));
16701
+ }
16702
+ continue;
16222
16703
  case 'color-relief':
16223
16704
  case 'fill-extrusion':
16224
16705
  case 'heatmap':
16225
16706
  case 'hillshade':
16226
- case 'symbol':
16227
16707
  continue;
16228
16708
  default:
16229
16709
  throw Error('layerStyle.type: ' + String(layerStyle.type));
@@ -16245,6 +16725,7 @@ async function renderToSVG(options) {
16245
16725
  center: [options.lon ?? 0, options.lat ?? 0],
16246
16726
  zoom: options.zoom ?? 2,
16247
16727
  },
16728
+ renderLabels: options.renderLabels ?? false,
16248
16729
  });
16249
16730
  }
16250
16731