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