@versatiles/svg-renderer 0.5.1 → 0.6.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/maplibre.cjs CHANGED
@@ -65,18 +65,26 @@ const PANEL_CSS = `
65
65
  }
66
66
 
67
67
  .svg-export-panel .panel-inputs {
68
+ margin-bottom: 12px;
69
+ }
70
+
71
+ .svg-export-panel .panel-inputs .grid {
68
72
  display: grid;
69
- grid-template-columns: 1fr 1fr 1fr;
73
+ grid-template-columns: 1fr 1fr;
70
74
  gap: 8px;
71
75
  margin-bottom: 12px;
72
76
  }
73
77
 
74
78
  .svg-export-panel .panel-inputs label {
79
+ font-size: 12px;
80
+ color: #666;
81
+ }
82
+
83
+ .svg-export-panel .panel-inputs .grid label {
75
84
  display: flex;
76
85
  flex-direction: column;
77
86
  gap: 4px;
78
- font-size: 12px;
79
- color: #666;
87
+ width: 100%;
80
88
  }
81
89
 
82
90
  .svg-export-panel .panel-inputs input {
@@ -84,7 +92,6 @@ const PANEL_CSS = `
84
92
  border: 1px solid #ccc;
85
93
  border-radius: 4px;
86
94
  font-size: 13px;
87
- width: 100%;
88
95
  box-sizing: border-box;
89
96
  }
90
97
 
@@ -3002,6 +3009,23 @@ var paint_raster = {
3002
3009
  },
3003
3010
  "property-type": "data-constant"
3004
3011
  },
3012
+ resampling: {
3013
+ type: "enum",
3014
+ values: {
3015
+ linear: {
3016
+ },
3017
+ nearest: {
3018
+ }
3019
+ },
3020
+ "default": "linear",
3021
+ expression: {
3022
+ interpolated: false,
3023
+ parameters: [
3024
+ "zoom"
3025
+ ]
3026
+ },
3027
+ "property-type": "data-constant"
3028
+ },
3005
3029
  "raster-resampling": {
3006
3030
  type: "enum",
3007
3031
  values: {
@@ -3152,6 +3176,23 @@ var paint_hillshade = {
3152
3176
  ]
3153
3177
  },
3154
3178
  "property-type": "data-constant"
3179
+ },
3180
+ resampling: {
3181
+ type: "enum",
3182
+ values: {
3183
+ linear: {
3184
+ },
3185
+ nearest: {
3186
+ }
3187
+ },
3188
+ "default": "linear",
3189
+ expression: {
3190
+ interpolated: false,
3191
+ parameters: [
3192
+ "zoom"
3193
+ ]
3194
+ },
3195
+ "property-type": "data-constant"
3155
3196
  }
3156
3197
  };
3157
3198
  var paint_background = {
@@ -3572,6 +3613,23 @@ var v8Spec = {
3572
3613
  ]
3573
3614
  },
3574
3615
  "property-type": "color-ramp"
3616
+ },
3617
+ resampling: {
3618
+ type: "enum",
3619
+ values: {
3620
+ linear: {
3621
+ },
3622
+ nearest: {
3623
+ }
3624
+ },
3625
+ "default": "linear",
3626
+ expression: {
3627
+ interpolated: false,
3628
+ parameters: [
3629
+ "zoom"
3630
+ ]
3631
+ },
3632
+ "property-type": "data-constant"
3575
3633
  }
3576
3634
  },
3577
3635
  paint_background: paint_background,
@@ -6416,11 +6474,12 @@ class CollatorExpression {
6416
6474
  }
6417
6475
 
6418
6476
  class NumberFormat {
6419
- constructor(number, locale, currency, minFractionDigits, maxFractionDigits) {
6477
+ constructor(number, locale, currency, unit, minFractionDigits, maxFractionDigits) {
6420
6478
  this.type = StringType;
6421
6479
  this.number = number;
6422
6480
  this.locale = locale;
6423
6481
  this.currency = currency;
6482
+ this.unit = unit;
6424
6483
  this.minFractionDigits = minFractionDigits;
6425
6484
  this.maxFractionDigits = maxFractionDigits;
6426
6485
  }
@@ -6445,6 +6504,15 @@ class NumberFormat {
6445
6504
  if (!currency)
6446
6505
  return null;
6447
6506
  }
6507
+ let unit = null;
6508
+ if (options['unit']) {
6509
+ unit = context.parse(options['unit'], 1, StringType);
6510
+ if (!unit)
6511
+ return null;
6512
+ }
6513
+ if (currency && unit) {
6514
+ return context.error('NumberFormat options `currency` and `unit` are mutually exclusive');
6515
+ }
6448
6516
  let minFractionDigits = null;
6449
6517
  if (options['min-fraction-digits']) {
6450
6518
  minFractionDigits = context.parse(options['min-fraction-digits'], 1, NumberType);
@@ -6457,12 +6525,13 @@ class NumberFormat {
6457
6525
  if (!maxFractionDigits)
6458
6526
  return null;
6459
6527
  }
6460
- return new NumberFormat(number, locale, currency, minFractionDigits, maxFractionDigits);
6528
+ return new NumberFormat(number, locale, currency, unit, minFractionDigits, maxFractionDigits);
6461
6529
  }
6462
6530
  evaluate(ctx) {
6463
6531
  return new Intl.NumberFormat(this.locale ? this.locale.evaluate(ctx) : [], {
6464
- style: this.currency ? 'currency' : 'decimal',
6532
+ style: this.currency ? 'currency' : this.unit ? 'unit' : 'decimal',
6465
6533
  currency: this.currency ? this.currency.evaluate(ctx) : undefined,
6534
+ unit: this.unit ? this.unit.evaluate(ctx) : undefined,
6466
6535
  minimumFractionDigits: this.minFractionDigits
6467
6536
  ? this.minFractionDigits.evaluate(ctx)
6468
6537
  : undefined,
@@ -6479,6 +6548,9 @@ class NumberFormat {
6479
6548
  if (this.currency) {
6480
6549
  fn(this.currency);
6481
6550
  }
6551
+ if (this.unit) {
6552
+ fn(this.unit);
6553
+ }
6482
6554
  if (this.minFractionDigits) {
6483
6555
  fn(this.minFractionDigits);
6484
6556
  }
@@ -8217,6 +8289,16 @@ CompoundExpression.register(expressions$1, {
8217
8289
  varargs(ValueType),
8218
8290
  (ctx, args) => args.map((arg) => valueToString(arg.evaluate(ctx))).join('')
8219
8291
  ],
8292
+ split: [
8293
+ array(StringType),
8294
+ [StringType, StringType],
8295
+ (ctx, [s, delim]) => s.evaluate(ctx).split(delim.evaluate(ctx))
8296
+ ],
8297
+ join: [
8298
+ StringType,
8299
+ [array(StringType), StringType],
8300
+ (ctx, [arr, delim]) => arr.value.join(delim.evaluate(ctx))
8301
+ ],
8220
8302
  'resolved-locale': [
8221
8303
  StringType,
8222
8304
  [CollatorType],
@@ -9361,13 +9443,11 @@ class SVGRenderer {
9361
9443
  width;
9362
9444
  height;
9363
9445
  #svg;
9364
- #scale;
9365
9446
  #backgroundColor;
9366
9447
  constructor(opt) {
9367
9448
  this.width = opt.width;
9368
9449
  this.height = opt.height;
9369
9450
  this.#svg = [];
9370
- this.#scale = opt.scale;
9371
9451
  this.#backgroundColor = Color.transparent;
9372
9452
  }
9373
9453
  drawBackgroundFill(style) {
@@ -9375,7 +9455,7 @@ class SVGRenderer {
9375
9455
  color.alpha *= style.opacity;
9376
9456
  this.#backgroundColor = color;
9377
9457
  }
9378
- drawPolygons(features) {
9458
+ drawPolygons(id, features) {
9379
9459
  if (features.length === 0)
9380
9460
  return;
9381
9461
  const groups = new Map();
@@ -9387,7 +9467,7 @@ class SVGRenderer {
9387
9467
  return;
9388
9468
  const translate = style.translate[0] === 0 && style.translate[1] === 0
9389
9469
  ? ''
9390
- : ` transform="translate(${formatPoint(style.translate, this.#scale)})"`;
9470
+ : ` transform="translate(${formatPoint(style.translate)})"`;
9391
9471
  const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9392
9472
  const key = color.hex + translate + opacityAttr;
9393
9473
  let group = groups.get(key);
@@ -9396,15 +9476,17 @@ class SVGRenderer {
9396
9476
  groups.set(key, group);
9397
9477
  }
9398
9478
  feature.geometry.forEach((ring) => {
9399
- group.segments.push(ring.map((p) => roundXY(p.x, p.y, this.#scale)));
9479
+ group.segments.push(ring.map((p) => roundXY(p.x, p.y)));
9400
9480
  });
9401
9481
  });
9482
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9402
9483
  for (const { segments, attrs } of groups.values()) {
9403
9484
  const d = segmentsToPath(segments, true);
9404
9485
  this.#svg.push(`<path d="${d}" ${attrs} />`);
9405
9486
  }
9487
+ this.#svg.push('</g>');
9406
9488
  }
9407
- drawLineStrings(features) {
9489
+ drawLineStrings(id, features) {
9408
9490
  if (features.length === 0)
9409
9491
  return;
9410
9492
  const groups = new Map();
@@ -9416,10 +9498,10 @@ class SVGRenderer {
9416
9498
  return;
9417
9499
  const translate = style.translate[0] === 0 && style.translate[1] === 0
9418
9500
  ? ''
9419
- : ` transform="translate(${formatPoint(style.translate, this.#scale)})"`;
9420
- const roundedWidth = formatScaled(style.width, this.#scale);
9501
+ : ` transform="translate(${formatPoint(style.translate)})"`;
9502
+ const roundedWidth = formatScaled(style.width);
9421
9503
  const dasharrayStr = style.dasharray
9422
- ? style.dasharray.map((v) => formatScaled(v * style.width, this.#scale)).join(',')
9504
+ ? style.dasharray.map((v) => formatScaled(v * style.width)).join(',')
9423
9505
  : '';
9424
9506
  const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9425
9507
  const key = [
@@ -9450,16 +9532,18 @@ class SVGRenderer {
9450
9532
  groups.set(key, group);
9451
9533
  }
9452
9534
  feature.geometry.forEach((line) => {
9453
- group.segments.push(line.map((p) => roundXY(p.x, p.y, this.#scale)));
9535
+ group.segments.push(line.map((p) => roundXY(p.x, p.y)));
9454
9536
  });
9455
9537
  });
9538
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9456
9539
  for (const { segments, attrs } of groups.values()) {
9457
9540
  const chains = chainSegments(segments);
9458
9541
  const d = segmentsToPath(chains);
9459
9542
  this.#svg.push(`<path d="${d}" ${attrs} />`);
9460
9543
  }
9544
+ this.#svg.push('</g>');
9461
9545
  }
9462
- drawCircles(features) {
9546
+ drawCircles(id, features) {
9463
9547
  if (features.length === 0)
9464
9548
  return;
9465
9549
  const groups = new Map();
@@ -9471,12 +9555,10 @@ class SVGRenderer {
9471
9555
  return;
9472
9556
  const translate = style.translate[0] === 0 && style.translate[1] === 0
9473
9557
  ? ''
9474
- : ` transform="translate(${formatPoint(style.translate, this.#scale)})"`;
9475
- const roundedRadius = formatScaled(style.radius, this.#scale);
9558
+ : ` transform="translate(${formatPoint(style.translate)})"`;
9559
+ const roundedRadius = formatScaled(style.radius);
9476
9560
  const strokeColor = new Color(style.strokeColor);
9477
- const strokeAttrs = style.strokeWidth > 0
9478
- ? ` ${strokeAttr(strokeColor, formatScaled(style.strokeWidth, this.#scale))}`
9479
- : '';
9561
+ const strokeAttrs = style.strokeWidth > 0 ? ` ${strokeAttr(strokeColor, formatScaled(style.strokeWidth))}` : '';
9480
9562
  const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9481
9563
  const key = [color.hex, roundedRadius, strokeAttrs, opacityAttr, translate].join('\0');
9482
9564
  let group = groups.get(key);
@@ -9490,16 +9572,111 @@ class SVGRenderer {
9490
9572
  feature.geometry.forEach((ring) => {
9491
9573
  const p = ring[0];
9492
9574
  if (p)
9493
- group.points.push(roundXY(p.x, p.y, this.#scale));
9575
+ group.points.push(roundXY(p.x, p.y));
9494
9576
  });
9495
9577
  });
9578
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9496
9579
  for (const { points, attrs } of groups.values()) {
9497
9580
  for (const [x, y] of points) {
9498
9581
  this.#svg.push(`<circle cx="${formatNum(x)}" cy="${formatNum(y)}" ${attrs} />`);
9499
9582
  }
9500
9583
  }
9584
+ this.#svg.push('</g>');
9585
+ }
9586
+ drawLabels(id, features) {
9587
+ if (features.length === 0)
9588
+ return;
9589
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9590
+ for (const [feature, style] of features) {
9591
+ if (style.opacity <= 0 || !style.text)
9592
+ continue;
9593
+ const color = new Color(style.color);
9594
+ if (color.alpha <= 0)
9595
+ continue;
9596
+ const ring = feature.geometry[0];
9597
+ if (!ring || ring.length === 0)
9598
+ continue;
9599
+ const point = ring[Math.floor(ring.length / 2)];
9600
+ const [px, py] = roundXY(point.x, point.y);
9601
+ const fontSize = formatScaled(style.size);
9602
+ const fontFamily = style.font.join(', ') + ', Helvetica, Arial, sans-serif';
9603
+ const [svgAnchor, baseline] = mapTextAnchor(style.anchor);
9604
+ const offsetX = style.offset[0] * style.size;
9605
+ const offsetY = style.offset[1] * style.size;
9606
+ const [dx, dy] = roundXY(offsetX, offsetY);
9607
+ const attrs = [
9608
+ `x="${formatNum(px)}"`,
9609
+ `y="${formatNum(py)}"`,
9610
+ `font-family="${escapeXml(fontFamily)}"`,
9611
+ `font-size="${fontSize}"`,
9612
+ `text-anchor="${svgAnchor}"`,
9613
+ `dominant-baseline="${baseline}"`,
9614
+ ];
9615
+ if (dx !== 0)
9616
+ attrs.push(`dx="${formatNum(dx)}"`);
9617
+ if (dy !== 0)
9618
+ attrs.push(`dy="${formatNum(dy)}"`);
9619
+ if (style.rotate !== 0) {
9620
+ attrs.push(`transform="rotate(${String(style.rotate)},${formatNum(px)},${formatNum(py)})"`);
9621
+ }
9622
+ const haloColor = new Color(style.haloColor);
9623
+ if (style.haloWidth > 0 && haloColor.alpha > 0) {
9624
+ const haloWidth = formatScaled(style.haloWidth);
9625
+ attrs.push('paint-order="stroke fill"', `stroke="${haloColor.rgb}"`, `stroke-width="${haloWidth}"`, 'stroke-linejoin="round"');
9626
+ if (haloColor.alpha < 255)
9627
+ attrs.push(`stroke-opacity="${haloColor.opacity.toFixed(3)}"`);
9628
+ }
9629
+ attrs.push(fillAttr(color));
9630
+ if (style.opacity < 1)
9631
+ attrs.push(`opacity="${style.opacity.toFixed(3)}"`);
9632
+ this.#svg.push(`<text ${attrs.join(' ')}>${escapeXml(style.text)}</text>`);
9633
+ }
9634
+ this.#svg.push('</g>');
9635
+ }
9636
+ drawIcons(id, features, spriteAtlas) {
9637
+ if (features.length === 0)
9638
+ return;
9639
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9640
+ for (const [feature, style] of features) {
9641
+ if (style.opacity <= 0)
9642
+ continue;
9643
+ const sprite = spriteAtlas.get(style.image);
9644
+ if (!sprite)
9645
+ continue;
9646
+ const ring = feature.geometry[0];
9647
+ if (!ring || ring.length === 0)
9648
+ continue;
9649
+ const point = ring[Math.floor(ring.length / 2)];
9650
+ const scale = style.size / sprite.pixelRatio;
9651
+ const iconW = sprite.width * scale;
9652
+ const iconH = sprite.height * scale;
9653
+ const [anchorDx, anchorDy] = mapIconAnchor(style.anchor, iconW, iconH);
9654
+ const ox = style.offset[0] * style.size + anchorDx;
9655
+ 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)}"`);
9669
+ if (style.rotate !== 0) {
9670
+ 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)})"`);
9672
+ }
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
+ }
9677
+ this.#svg.push('</g>');
9501
9678
  }
9502
- drawRasterTiles(tiles, style) {
9679
+ drawRasterTiles(id, tiles, style) {
9503
9680
  if (tiles.length === 0)
9504
9681
  return;
9505
9682
  if (style.opacity <= 0)
@@ -9515,15 +9692,14 @@ class SVGRenderer {
9515
9692
  const brightness = (style.brightnessMin + style.brightnessMax) / 2;
9516
9693
  filters.push(`brightness(${String(brightness)})`);
9517
9694
  }
9518
- let gAttrs = `opacity="${String(style.opacity)}"`;
9695
+ let gAttrs = `id="${escapeXml(id)}" opacity="${String(style.opacity)}"`;
9519
9696
  if (filters.length > 0)
9520
9697
  gAttrs += ` filter="${filters.join(' ')}"`;
9521
9698
  this.#svg.push(`<g ${gAttrs}>`);
9522
9699
  const pixelated = style.resampling === 'nearest';
9523
9700
  for (const tile of tiles) {
9524
9701
  const overlap = Math.min(tile.width, tile.height) / 10000; // slight overlap to prevent sub-pixel gaps between tiles
9525
- const s = this.#scale;
9526
- let attrs = `x="${formatScaled(tile.x - overlap, s)}" y="${formatScaled(tile.y - overlap, s)}" width="${formatScaled(tile.width + overlap * 2, s)}" height="${formatScaled(tile.height + overlap * 2, s)}" href="${tile.dataUri}"`;
9702
+ let attrs = `x="${formatScaled(tile.x - overlap)}" y="${formatScaled(tile.y - overlap)}" width="${formatScaled(tile.width + overlap * 2)}" height="${formatScaled(tile.height + overlap * 2)}" href="${tile.dataUri}"`;
9527
9703
  if (pixelated)
9528
9704
  attrs += ' style="image-rendering:pixelated"';
9529
9705
  this.#svg.push(`<image ${attrs} />`);
@@ -9557,16 +9733,67 @@ function strokeAttr(color, width) {
9557
9733
  attr += ` stroke-opacity="${color.opacity.toFixed(3)}"`;
9558
9734
  return attr;
9559
9735
  }
9560
- function formatScaled(v, scale) {
9561
- return formatNum(Math.round(v * scale * 10));
9736
+ function formatScaled(v) {
9737
+ return formatNum(Math.round(v * 10));
9562
9738
  }
9563
- function roundXY(x, y, scale) {
9564
- return [Math.round(x * scale * 10), Math.round(y * scale * 10)];
9739
+ function roundXY(x, y) {
9740
+ return [Math.round(x * 10), Math.round(y * 10)];
9565
9741
  }
9566
- function formatPoint(p, scale) {
9567
- const [x, y] = roundXY(p[0], p[1], scale);
9742
+ function formatPoint(p) {
9743
+ const [x, y] = roundXY(p[0], p[1]);
9568
9744
  return formatNum(x) + ',' + formatNum(y);
9569
9745
  }
9746
+ function mapTextAnchor(anchor) {
9747
+ switch (anchor) {
9748
+ case 'left':
9749
+ return ['start', 'central'];
9750
+ case 'right':
9751
+ return ['end', 'central'];
9752
+ case 'top':
9753
+ return ['middle', 'text-before-edge'];
9754
+ case 'bottom':
9755
+ return ['middle', 'text-after-edge'];
9756
+ case 'top-left':
9757
+ return ['start', 'text-before-edge'];
9758
+ case 'top-right':
9759
+ return ['end', 'text-before-edge'];
9760
+ case 'bottom-left':
9761
+ return ['start', 'text-after-edge'];
9762
+ case 'bottom-right':
9763
+ return ['end', 'text-after-edge'];
9764
+ default:
9765
+ return ['middle', 'central'];
9766
+ }
9767
+ }
9768
+ function mapIconAnchor(anchor, w, h) {
9769
+ switch (anchor) {
9770
+ case 'left':
9771
+ return [0, -h / 2];
9772
+ case 'right':
9773
+ return [-w, -h / 2];
9774
+ case 'top':
9775
+ return [-w / 2, 0];
9776
+ case 'bottom':
9777
+ return [-w / 2, -h];
9778
+ case 'top-left':
9779
+ return [0, 0];
9780
+ case 'top-right':
9781
+ return [-w, 0];
9782
+ case 'bottom-left':
9783
+ return [0, -h];
9784
+ case 'bottom-right':
9785
+ return [-w, -h];
9786
+ default:
9787
+ return [-w / 2, -h / 2];
9788
+ }
9789
+ }
9790
+ function escapeXml(s) {
9791
+ return s
9792
+ .replace(/&/g, '&amp;')
9793
+ .replace(/</g, '&lt;')
9794
+ .replace(/>/g, '&gt;')
9795
+ .replace(/"/g, '&quot;');
9796
+ }
9570
9797
 
9571
9798
  /*
9572
9799
  * bignumber.js v9.3.1
@@ -16115,6 +16342,65 @@ async function getRasterTiles(job, sourceName) {
16115
16342
  return rasterTiles.filter((tile) => tile !== null);
16116
16343
  }
16117
16344
 
16345
+ async function loadSpriteAtlas(style) {
16346
+ const atlas = new Map();
16347
+ const sprite = style.sprite;
16348
+ if (!sprite)
16349
+ return atlas;
16350
+ const sources = [];
16351
+ if (typeof sprite === 'string') {
16352
+ sources.push({ id: 'default', url: sprite });
16353
+ }
16354
+ else if (Array.isArray(sprite)) {
16355
+ for (const s of sprite) {
16356
+ sources.push({
16357
+ id: s.id,
16358
+ url: s.url,
16359
+ });
16360
+ }
16361
+ }
16362
+ await Promise.all(sources.map(async ({ id, url }) => {
16363
+ try {
16364
+ const [jsonResponse, imageResponse] = await Promise.all([
16365
+ fetch(`${url}.json`),
16366
+ fetch(`${url}.png`),
16367
+ ]);
16368
+ if (!jsonResponse.ok || !imageResponse.ok)
16369
+ return;
16370
+ const json = (await jsonResponse.json());
16371
+ const imageBuffer = await imageResponse.arrayBuffer();
16372
+ const base64 = typeof Buffer !== 'undefined'
16373
+ ? Buffer.from(imageBuffer).toString('base64')
16374
+ : btoa(String.fromCharCode(...new Uint8Array(imageBuffer)));
16375
+ const sheetDataUri = `data:image/png;base64,${base64}`;
16376
+ // Estimate sheet dimensions from sprite entries
16377
+ let sheetWidth = 0;
16378
+ let sheetHeight = 0;
16379
+ for (const entry of Object.values(json)) {
16380
+ sheetWidth = Math.max(sheetWidth, entry.x + entry.width);
16381
+ sheetHeight = Math.max(sheetHeight, entry.y + entry.height);
16382
+ }
16383
+ const prefix = id === 'default' ? '' : `${id}:`;
16384
+ for (const [name, entry] of Object.entries(json)) {
16385
+ atlas.set(`${prefix}${name}`, {
16386
+ width: entry.width,
16387
+ height: entry.height,
16388
+ x: entry.x,
16389
+ y: entry.y,
16390
+ pixelRatio: entry.pixelRatio ?? 1,
16391
+ sheetDataUri,
16392
+ sheetWidth,
16393
+ sheetHeight,
16394
+ });
16395
+ }
16396
+ }
16397
+ catch {
16398
+ // Silently skip failed sprite loads
16399
+ }
16400
+ }));
16401
+ return atlas;
16402
+ }
16403
+
16118
16404
  async function getLayerFeatures(job) {
16119
16405
  const { width, height } = job.renderer;
16120
16406
  const { zoom, center } = job.view;
@@ -16275,6 +16561,12 @@ function getLayerStyles(layers) {
16275
16561
  return layers.map(createStyleLayer);
16276
16562
  }
16277
16563
 
16564
+ function resolveTokens(text, properties) {
16565
+ return text.replace(/\{([^}]+)\}/g, (_, key) => {
16566
+ const value = properties[key];
16567
+ return value != null ? String(value) : '';
16568
+ });
16569
+ }
16278
16570
  async function renderMap(job) {
16279
16571
  await render(job);
16280
16572
  return job.renderer.getString();
@@ -16285,9 +16577,12 @@ function getFeatures(layerFeatures, layerStyle) {
16285
16577
  async function render(job) {
16286
16578
  const { renderer } = job;
16287
16579
  const { zoom } = job.view;
16288
- const layerFeatures = await getLayerFeatures(job);
16580
+ const [layerFeatures, spriteAtlas] = await Promise.all([
16581
+ getLayerFeatures(job),
16582
+ job.renderLabels ? loadSpriteAtlas(job.style) : Promise.resolve(new Map()),
16583
+ ]);
16289
16584
  const layerStyles = getLayerStyles(job.style.layers);
16290
- const availableImages = [];
16585
+ const availableImages = [...spriteAtlas.keys()];
16291
16586
  const featureState = {};
16292
16587
  for (const layerStyle of layerStyles) {
16293
16588
  if (layerStyle.isHidden(zoom))
@@ -16308,6 +16603,7 @@ async function render(job) {
16308
16603
  function getLayout(key, feature) {
16309
16604
  return getStyleValue(layerStyle.layout, key, feature);
16310
16605
  }
16606
+ const layerId = layerStyle.id;
16311
16607
  switch (layerStyle.type) {
16312
16608
  case 'background':
16313
16609
  {
@@ -16327,7 +16623,7 @@ async function render(job) {
16327
16623
  : polygons;
16328
16624
  if (polygonFeatures.length === 0)
16329
16625
  continue;
16330
- renderer.drawPolygons(polygonFeatures.map((feature) => [
16626
+ renderer.drawPolygons(layerId, polygonFeatures.map((feature) => [
16331
16627
  feature,
16332
16628
  {
16333
16629
  color: getPaint('fill-color', feature),
@@ -16347,7 +16643,7 @@ async function render(job) {
16347
16643
  : lineStrings;
16348
16644
  if (lineStringFeatures.length === 0)
16349
16645
  continue;
16350
- renderer.drawLineStrings(lineStringFeatures.map((feature) => [
16646
+ renderer.drawLineStrings(layerId, lineStringFeatures.map((feature) => [
16351
16647
  feature,
16352
16648
  {
16353
16649
  color: getPaint('line-color', feature),
@@ -16366,7 +16662,7 @@ async function render(job) {
16366
16662
  case 'raster':
16367
16663
  {
16368
16664
  const tiles = await getRasterTiles(job, layerStyle.source);
16369
- renderer.drawRasterTiles(tiles, {
16665
+ renderer.drawRasterTiles(layerId, tiles, {
16370
16666
  opacity: getPaint('raster-opacity'),
16371
16667
  hueRotate: getPaint('raster-hue-rotate'),
16372
16668
  brightnessMin: getPaint('raster-brightness-min'),
@@ -16387,7 +16683,7 @@ async function render(job) {
16387
16683
  : points;
16388
16684
  if (pointFeatures.length === 0)
16389
16685
  continue;
16390
- renderer.drawCircles(pointFeatures.map((feature) => [
16686
+ renderer.drawCircles(layerId, pointFeatures.map((feature) => [
16391
16687
  feature,
16392
16688
  {
16393
16689
  color: getPaint('circle-color', feature),
@@ -16400,11 +16696,76 @@ async function render(job) {
16400
16696
  ]));
16401
16697
  }
16402
16698
  continue;
16699
+ case 'symbol':
16700
+ {
16701
+ if (!job.renderLabels)
16702
+ continue;
16703
+ const features = getFeatures(layerFeatures, layerStyle);
16704
+ const allFeatures = [
16705
+ ...(features?.points ?? []),
16706
+ ...(features?.linestrings ?? []),
16707
+ ...(features?.polygons ?? []),
16708
+ ];
16709
+ if (allFeatures.length === 0)
16710
+ continue;
16711
+ const symbolFeatures = layerStyle.filterFn
16712
+ ? allFeatures.filter((feature) => layerStyle.filterFn.filter({ zoom }, feature))
16713
+ : allFeatures;
16714
+ if (symbolFeatures.length === 0)
16715
+ continue;
16716
+ // Render icons first (underneath text)
16717
+ renderer.drawIcons(`${layerId}-icons`, symbolFeatures.flatMap((feature) => {
16718
+ const iconImage = getLayout('icon-image', feature);
16719
+ const iconName = iconImage != null
16720
+ ? resolveTokens(iconImage.toString(), feature.properties)
16721
+ : '';
16722
+ if (!iconName || !spriteAtlas.has(iconName))
16723
+ return [];
16724
+ return [
16725
+ [
16726
+ feature,
16727
+ {
16728
+ image: iconName,
16729
+ size: getLayout('icon-size', feature),
16730
+ anchor: getLayout('icon-anchor', feature),
16731
+ offset: getLayout('icon-offset', feature),
16732
+ rotate: getLayout('icon-rotate', feature),
16733
+ opacity: getPaint('icon-opacity', feature),
16734
+ },
16735
+ ],
16736
+ ];
16737
+ }), spriteAtlas);
16738
+ // Render text labels on top
16739
+ renderer.drawLabels(`${layerId}-labels`, symbolFeatures.flatMap((feature) => {
16740
+ const textField = getLayout('text-field', feature);
16741
+ const textRaw = textField != null ? textField.toString() : '';
16742
+ const text = resolveTokens(textRaw, feature.properties);
16743
+ if (!text)
16744
+ return [];
16745
+ return [
16746
+ [
16747
+ feature,
16748
+ {
16749
+ text,
16750
+ size: getLayout('text-size', feature),
16751
+ font: getLayout('text-font', feature),
16752
+ anchor: getLayout('text-anchor', feature),
16753
+ offset: getLayout('text-offset', feature),
16754
+ rotate: getLayout('text-rotate', feature),
16755
+ color: getPaint('text-color', feature),
16756
+ opacity: getPaint('text-opacity', feature),
16757
+ haloColor: getPaint('text-halo-color', feature),
16758
+ haloWidth: getPaint('text-halo-width', feature),
16759
+ },
16760
+ ],
16761
+ ];
16762
+ }));
16763
+ }
16764
+ continue;
16403
16765
  case 'color-relief':
16404
16766
  case 'fill-extrusion':
16405
16767
  case 'heatmap':
16406
16768
  case 'hillshade':
16407
- case 'symbol':
16408
16769
  continue;
16409
16770
  default:
16410
16771
  throw Error('layerStyle.type: ' + String(layerStyle.type));
@@ -16415,20 +16776,18 @@ async function render(job) {
16415
16776
  async function renderToSVG(options) {
16416
16777
  const width = options.width ?? 1024;
16417
16778
  const height = options.height ?? 1024;
16418
- const scale = options.scale ?? 1;
16419
16779
  if (width <= 0)
16420
16780
  throw new Error('width must be positive');
16421
16781
  if (height <= 0)
16422
16782
  throw new Error('height must be positive');
16423
- if (scale <= 0)
16424
- throw new Error('scale must be positive');
16425
16783
  return await renderMap({
16426
- renderer: new SVGRenderer({ width, height, scale }),
16784
+ renderer: new SVGRenderer({ width, height }),
16427
16785
  style: options.style,
16428
16786
  view: {
16429
16787
  center: [options.lon ?? 0, options.lat ?? 0],
16430
16788
  zoom: options.zoom ?? 2,
16431
16789
  },
16790
+ renderLabels: options.renderLabels ?? false,
16432
16791
  });
16433
16792
  }
16434
16793
 
@@ -16484,7 +16843,6 @@ class SVGExportControl {
16484
16843
  this.options = {
16485
16844
  defaultWidth: options?.defaultWidth ?? 1024,
16486
16845
  defaultHeight: options?.defaultHeight ?? 1024,
16487
- defaultScale: options?.defaultScale ?? 1,
16488
16846
  };
16489
16847
  }
16490
16848
  onAdd(map) {
@@ -16525,12 +16883,14 @@ class SVGExportControl {
16525
16883
  <div class="panel-notice">
16526
16884
  Note:<br>
16527
16885
  <span class="panel-attribution"></span><br>
16528
- Also export of symbols and texts is not supported yet, but you can improve me on <a href="https://github.com/versatiles-org/versatiles-svg-renderer" target="_blank" rel="noopener noreferrer">GitHub</a>.<br>
16886
+ Text labels are rendered without collision detection, so labels may overlap. You can improve me on <a href="https://github.com/versatiles-org/versatiles-svg-renderer" target="_blank" rel="noopener noreferrer">GitHub</a>.<br>
16529
16887
  </div>
16530
16888
  <div class="panel-inputs">
16531
- <label>Width<input type="number" class="input-width" value="${String(this.options.defaultWidth)}" min="1" max="8192"></label>
16532
- <label>Height<input type="number" class="input-height" value="${String(this.options.defaultHeight)}" min="1" max="8192"></label>
16533
- <label>Scale<input type="number" class="input-scale" value="${String(this.options.defaultScale)}" min="0.1" max="10" step="0.1"></label>
16889
+ <div class="grid">
16890
+ <label>Width<input type="number" class="input-width" value="${String(this.options.defaultWidth)}" min="1" max="8192"></label>
16891
+ <label>Height<input type="number" class="input-height" value="${String(this.options.defaultHeight)}" min="1" max="8192"></label>
16892
+ </div>
16893
+ <label class="label-checkbox"><input type="checkbox" class="input-labels"> Include labels and icons (buggy)</label>
16534
16894
  </div>
16535
16895
  <div class="preview-container">
16536
16896
  <span class="preview-loading">Rendering preview\u2026</span>
@@ -16563,6 +16923,9 @@ class SVGExportControl {
16563
16923
  input.addEventListener('input', () => {
16564
16924
  this.schedulePreview();
16565
16925
  });
16926
+ input.addEventListener('change', () => {
16927
+ this.schedulePreview();
16928
+ });
16566
16929
  });
16567
16930
  querySelector(this.panel, '.btn-download').addEventListener('click', () => {
16568
16931
  this.downloadSVG();
@@ -16628,8 +16991,8 @@ class SVGExportControl {
16628
16991
  openBtn.disabled = true;
16629
16992
  const width = Number(querySelector(panel, '.input-width').value);
16630
16993
  const height = Number(querySelector(panel, '.input-height').value);
16631
- const scale = Number(querySelector(panel, '.input-scale').value);
16632
- if (!width || !height || !scale || width < 1 || height < 1 || scale < 0.1) {
16994
+ const renderLabels = querySelector(panel, '.input-labels').checked;
16995
+ if (!width || !height || width < 1 || height < 1) {
16633
16996
  previewContainer.innerHTML = '<span class="preview-loading">Invalid input values</span>';
16634
16997
  return;
16635
16998
  }
@@ -16640,11 +17003,11 @@ class SVGExportControl {
16640
17003
  const svg = await renderToSVG({
16641
17004
  width,
16642
17005
  height,
16643
- scale,
16644
17006
  style,
16645
17007
  lon: center.lng,
16646
17008
  lat: center.lat,
16647
17009
  zoom,
17010
+ renderLabels,
16648
17011
  });
16649
17012
  if (this.renderGeneration !== generation)
16650
17013
  return;