@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.
@@ -69,18 +69,26 @@
69
69
  }
70
70
 
71
71
  .svg-export-panel .panel-inputs {
72
+ margin-bottom: 12px;
73
+ }
74
+
75
+ .svg-export-panel .panel-inputs .grid {
72
76
  display: grid;
73
- grid-template-columns: 1fr 1fr 1fr;
77
+ grid-template-columns: 1fr 1fr;
74
78
  gap: 8px;
75
79
  margin-bottom: 12px;
76
80
  }
77
81
 
78
82
  .svg-export-panel .panel-inputs label {
83
+ font-size: 12px;
84
+ color: #666;
85
+ }
86
+
87
+ .svg-export-panel .panel-inputs .grid label {
79
88
  display: flex;
80
89
  flex-direction: column;
81
90
  gap: 4px;
82
- font-size: 12px;
83
- color: #666;
91
+ width: 100%;
84
92
  }
85
93
 
86
94
  .svg-export-panel .panel-inputs input {
@@ -88,7 +96,6 @@
88
96
  border: 1px solid #ccc;
89
97
  border-radius: 4px;
90
98
  font-size: 13px;
91
- width: 100%;
92
99
  box-sizing: border-box;
93
100
  }
94
101
 
@@ -3006,6 +3013,23 @@
3006
3013
  },
3007
3014
  "property-type": "data-constant"
3008
3015
  },
3016
+ resampling: {
3017
+ type: "enum",
3018
+ values: {
3019
+ linear: {
3020
+ },
3021
+ nearest: {
3022
+ }
3023
+ },
3024
+ "default": "linear",
3025
+ expression: {
3026
+ interpolated: false,
3027
+ parameters: [
3028
+ "zoom"
3029
+ ]
3030
+ },
3031
+ "property-type": "data-constant"
3032
+ },
3009
3033
  "raster-resampling": {
3010
3034
  type: "enum",
3011
3035
  values: {
@@ -3156,6 +3180,23 @@
3156
3180
  ]
3157
3181
  },
3158
3182
  "property-type": "data-constant"
3183
+ },
3184
+ resampling: {
3185
+ type: "enum",
3186
+ values: {
3187
+ linear: {
3188
+ },
3189
+ nearest: {
3190
+ }
3191
+ },
3192
+ "default": "linear",
3193
+ expression: {
3194
+ interpolated: false,
3195
+ parameters: [
3196
+ "zoom"
3197
+ ]
3198
+ },
3199
+ "property-type": "data-constant"
3159
3200
  }
3160
3201
  };
3161
3202
  var paint_background = {
@@ -3576,6 +3617,23 @@
3576
3617
  ]
3577
3618
  },
3578
3619
  "property-type": "color-ramp"
3620
+ },
3621
+ resampling: {
3622
+ type: "enum",
3623
+ values: {
3624
+ linear: {
3625
+ },
3626
+ nearest: {
3627
+ }
3628
+ },
3629
+ "default": "linear",
3630
+ expression: {
3631
+ interpolated: false,
3632
+ parameters: [
3633
+ "zoom"
3634
+ ]
3635
+ },
3636
+ "property-type": "data-constant"
3579
3637
  }
3580
3638
  },
3581
3639
  paint_background: paint_background,
@@ -6420,11 +6478,12 @@
6420
6478
  }
6421
6479
 
6422
6480
  class NumberFormat {
6423
- constructor(number, locale, currency, minFractionDigits, maxFractionDigits) {
6481
+ constructor(number, locale, currency, unit, minFractionDigits, maxFractionDigits) {
6424
6482
  this.type = StringType;
6425
6483
  this.number = number;
6426
6484
  this.locale = locale;
6427
6485
  this.currency = currency;
6486
+ this.unit = unit;
6428
6487
  this.minFractionDigits = minFractionDigits;
6429
6488
  this.maxFractionDigits = maxFractionDigits;
6430
6489
  }
@@ -6449,6 +6508,15 @@
6449
6508
  if (!currency)
6450
6509
  return null;
6451
6510
  }
6511
+ let unit = null;
6512
+ if (options['unit']) {
6513
+ unit = context.parse(options['unit'], 1, StringType);
6514
+ if (!unit)
6515
+ return null;
6516
+ }
6517
+ if (currency && unit) {
6518
+ return context.error('NumberFormat options `currency` and `unit` are mutually exclusive');
6519
+ }
6452
6520
  let minFractionDigits = null;
6453
6521
  if (options['min-fraction-digits']) {
6454
6522
  minFractionDigits = context.parse(options['min-fraction-digits'], 1, NumberType);
@@ -6461,12 +6529,13 @@
6461
6529
  if (!maxFractionDigits)
6462
6530
  return null;
6463
6531
  }
6464
- return new NumberFormat(number, locale, currency, minFractionDigits, maxFractionDigits);
6532
+ return new NumberFormat(number, locale, currency, unit, minFractionDigits, maxFractionDigits);
6465
6533
  }
6466
6534
  evaluate(ctx) {
6467
6535
  return new Intl.NumberFormat(this.locale ? this.locale.evaluate(ctx) : [], {
6468
- style: this.currency ? 'currency' : 'decimal',
6536
+ style: this.currency ? 'currency' : this.unit ? 'unit' : 'decimal',
6469
6537
  currency: this.currency ? this.currency.evaluate(ctx) : undefined,
6538
+ unit: this.unit ? this.unit.evaluate(ctx) : undefined,
6470
6539
  minimumFractionDigits: this.minFractionDigits
6471
6540
  ? this.minFractionDigits.evaluate(ctx)
6472
6541
  : undefined,
@@ -6483,6 +6552,9 @@
6483
6552
  if (this.currency) {
6484
6553
  fn(this.currency);
6485
6554
  }
6555
+ if (this.unit) {
6556
+ fn(this.unit);
6557
+ }
6486
6558
  if (this.minFractionDigits) {
6487
6559
  fn(this.minFractionDigits);
6488
6560
  }
@@ -8221,6 +8293,16 @@
8221
8293
  varargs(ValueType),
8222
8294
  (ctx, args) => args.map((arg) => valueToString(arg.evaluate(ctx))).join('')
8223
8295
  ],
8296
+ split: [
8297
+ array(StringType),
8298
+ [StringType, StringType],
8299
+ (ctx, [s, delim]) => s.evaluate(ctx).split(delim.evaluate(ctx))
8300
+ ],
8301
+ join: [
8302
+ StringType,
8303
+ [array(StringType), StringType],
8304
+ (ctx, [arr, delim]) => arr.value.join(delim.evaluate(ctx))
8305
+ ],
8224
8306
  'resolved-locale': [
8225
8307
  StringType,
8226
8308
  [CollatorType],
@@ -9365,13 +9447,11 @@
9365
9447
  width;
9366
9448
  height;
9367
9449
  #svg;
9368
- #scale;
9369
9450
  #backgroundColor;
9370
9451
  constructor(opt) {
9371
9452
  this.width = opt.width;
9372
9453
  this.height = opt.height;
9373
9454
  this.#svg = [];
9374
- this.#scale = opt.scale;
9375
9455
  this.#backgroundColor = Color.transparent;
9376
9456
  }
9377
9457
  drawBackgroundFill(style) {
@@ -9379,7 +9459,7 @@
9379
9459
  color.alpha *= style.opacity;
9380
9460
  this.#backgroundColor = color;
9381
9461
  }
9382
- drawPolygons(features) {
9462
+ drawPolygons(id, features) {
9383
9463
  if (features.length === 0)
9384
9464
  return;
9385
9465
  const groups = new Map();
@@ -9391,7 +9471,7 @@
9391
9471
  return;
9392
9472
  const translate = style.translate[0] === 0 && style.translate[1] === 0
9393
9473
  ? ''
9394
- : ` transform="translate(${formatPoint(style.translate, this.#scale)})"`;
9474
+ : ` transform="translate(${formatPoint(style.translate)})"`;
9395
9475
  const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9396
9476
  const key = color.hex + translate + opacityAttr;
9397
9477
  let group = groups.get(key);
@@ -9400,15 +9480,17 @@
9400
9480
  groups.set(key, group);
9401
9481
  }
9402
9482
  feature.geometry.forEach((ring) => {
9403
- group.segments.push(ring.map((p) => roundXY(p.x, p.y, this.#scale)));
9483
+ group.segments.push(ring.map((p) => roundXY(p.x, p.y)));
9404
9484
  });
9405
9485
  });
9486
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9406
9487
  for (const { segments, attrs } of groups.values()) {
9407
9488
  const d = segmentsToPath(segments, true);
9408
9489
  this.#svg.push(`<path d="${d}" ${attrs} />`);
9409
9490
  }
9491
+ this.#svg.push('</g>');
9410
9492
  }
9411
- drawLineStrings(features) {
9493
+ drawLineStrings(id, features) {
9412
9494
  if (features.length === 0)
9413
9495
  return;
9414
9496
  const groups = new Map();
@@ -9420,10 +9502,10 @@
9420
9502
  return;
9421
9503
  const translate = style.translate[0] === 0 && style.translate[1] === 0
9422
9504
  ? ''
9423
- : ` transform="translate(${formatPoint(style.translate, this.#scale)})"`;
9424
- const roundedWidth = formatScaled(style.width, this.#scale);
9505
+ : ` transform="translate(${formatPoint(style.translate)})"`;
9506
+ const roundedWidth = formatScaled(style.width);
9425
9507
  const dasharrayStr = style.dasharray
9426
- ? style.dasharray.map((v) => formatScaled(v * style.width, this.#scale)).join(',')
9508
+ ? style.dasharray.map((v) => formatScaled(v * style.width)).join(',')
9427
9509
  : '';
9428
9510
  const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9429
9511
  const key = [
@@ -9454,16 +9536,18 @@
9454
9536
  groups.set(key, group);
9455
9537
  }
9456
9538
  feature.geometry.forEach((line) => {
9457
- group.segments.push(line.map((p) => roundXY(p.x, p.y, this.#scale)));
9539
+ group.segments.push(line.map((p) => roundXY(p.x, p.y)));
9458
9540
  });
9459
9541
  });
9542
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9460
9543
  for (const { segments, attrs } of groups.values()) {
9461
9544
  const chains = chainSegments(segments);
9462
9545
  const d = segmentsToPath(chains);
9463
9546
  this.#svg.push(`<path d="${d}" ${attrs} />`);
9464
9547
  }
9548
+ this.#svg.push('</g>');
9465
9549
  }
9466
- drawCircles(features) {
9550
+ drawCircles(id, features) {
9467
9551
  if (features.length === 0)
9468
9552
  return;
9469
9553
  const groups = new Map();
@@ -9475,12 +9559,10 @@
9475
9559
  return;
9476
9560
  const translate = style.translate[0] === 0 && style.translate[1] === 0
9477
9561
  ? ''
9478
- : ` transform="translate(${formatPoint(style.translate, this.#scale)})"`;
9479
- const roundedRadius = formatScaled(style.radius, this.#scale);
9562
+ : ` transform="translate(${formatPoint(style.translate)})"`;
9563
+ const roundedRadius = formatScaled(style.radius);
9480
9564
  const strokeColor = new Color(style.strokeColor);
9481
- const strokeAttrs = style.strokeWidth > 0
9482
- ? ` ${strokeAttr(strokeColor, formatScaled(style.strokeWidth, this.#scale))}`
9483
- : '';
9565
+ const strokeAttrs = style.strokeWidth > 0 ? ` ${strokeAttr(strokeColor, formatScaled(style.strokeWidth))}` : '';
9484
9566
  const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9485
9567
  const key = [color.hex, roundedRadius, strokeAttrs, opacityAttr, translate].join('\0');
9486
9568
  let group = groups.get(key);
@@ -9494,16 +9576,111 @@
9494
9576
  feature.geometry.forEach((ring) => {
9495
9577
  const p = ring[0];
9496
9578
  if (p)
9497
- group.points.push(roundXY(p.x, p.y, this.#scale));
9579
+ group.points.push(roundXY(p.x, p.y));
9498
9580
  });
9499
9581
  });
9582
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9500
9583
  for (const { points, attrs } of groups.values()) {
9501
9584
  for (const [x, y] of points) {
9502
9585
  this.#svg.push(`<circle cx="${formatNum(x)}" cy="${formatNum(y)}" ${attrs} />`);
9503
9586
  }
9504
9587
  }
9588
+ this.#svg.push('</g>');
9589
+ }
9590
+ drawLabels(id, features) {
9591
+ if (features.length === 0)
9592
+ return;
9593
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9594
+ for (const [feature, style] of features) {
9595
+ if (style.opacity <= 0 || !style.text)
9596
+ continue;
9597
+ const color = new Color(style.color);
9598
+ if (color.alpha <= 0)
9599
+ continue;
9600
+ const ring = feature.geometry[0];
9601
+ if (!ring || ring.length === 0)
9602
+ continue;
9603
+ const point = ring[Math.floor(ring.length / 2)];
9604
+ const [px, py] = roundXY(point.x, point.y);
9605
+ const fontSize = formatScaled(style.size);
9606
+ const fontFamily = style.font.join(', ') + ', Helvetica, Arial, sans-serif';
9607
+ const [svgAnchor, baseline] = mapTextAnchor(style.anchor);
9608
+ const offsetX = style.offset[0] * style.size;
9609
+ const offsetY = style.offset[1] * style.size;
9610
+ const [dx, dy] = roundXY(offsetX, offsetY);
9611
+ const attrs = [
9612
+ `x="${formatNum(px)}"`,
9613
+ `y="${formatNum(py)}"`,
9614
+ `font-family="${escapeXml(fontFamily)}"`,
9615
+ `font-size="${fontSize}"`,
9616
+ `text-anchor="${svgAnchor}"`,
9617
+ `dominant-baseline="${baseline}"`,
9618
+ ];
9619
+ if (dx !== 0)
9620
+ attrs.push(`dx="${formatNum(dx)}"`);
9621
+ if (dy !== 0)
9622
+ attrs.push(`dy="${formatNum(dy)}"`);
9623
+ if (style.rotate !== 0) {
9624
+ attrs.push(`transform="rotate(${String(style.rotate)},${formatNum(px)},${formatNum(py)})"`);
9625
+ }
9626
+ const haloColor = new Color(style.haloColor);
9627
+ if (style.haloWidth > 0 && haloColor.alpha > 0) {
9628
+ const haloWidth = formatScaled(style.haloWidth);
9629
+ attrs.push('paint-order="stroke fill"', `stroke="${haloColor.rgb}"`, `stroke-width="${haloWidth}"`, 'stroke-linejoin="round"');
9630
+ if (haloColor.alpha < 255)
9631
+ attrs.push(`stroke-opacity="${haloColor.opacity.toFixed(3)}"`);
9632
+ }
9633
+ attrs.push(fillAttr(color));
9634
+ if (style.opacity < 1)
9635
+ attrs.push(`opacity="${style.opacity.toFixed(3)}"`);
9636
+ this.#svg.push(`<text ${attrs.join(' ')}>${escapeXml(style.text)}</text>`);
9637
+ }
9638
+ this.#svg.push('</g>');
9639
+ }
9640
+ drawIcons(id, features, spriteAtlas) {
9641
+ if (features.length === 0)
9642
+ return;
9643
+ this.#svg.push(`<g id="${escapeXml(id)}">`);
9644
+ for (const [feature, style] of features) {
9645
+ if (style.opacity <= 0)
9646
+ continue;
9647
+ const sprite = spriteAtlas.get(style.image);
9648
+ if (!sprite)
9649
+ continue;
9650
+ const ring = feature.geometry[0];
9651
+ if (!ring || ring.length === 0)
9652
+ continue;
9653
+ const point = ring[Math.floor(ring.length / 2)];
9654
+ const scale = style.size / sprite.pixelRatio;
9655
+ const iconW = sprite.width * scale;
9656
+ const iconH = sprite.height * scale;
9657
+ const [anchorDx, anchorDy] = mapIconAnchor(style.anchor, iconW, iconH);
9658
+ const ox = style.offset[0] * style.size + anchorDx;
9659
+ const oy = style.offset[1] * style.size + anchorDy;
9660
+ const x = point.x + ox;
9661
+ const y = point.y + oy;
9662
+ const [sx, sy] = roundXY(x, y);
9663
+ const [sw, sh] = roundXY(iconW, iconH);
9664
+ const viewBox = `${String(sprite.x)} ${String(sprite.y)} ${String(sprite.width)} ${String(sprite.height)}`;
9665
+ const attrs = [
9666
+ `x="${formatNum(sx)}"`,
9667
+ `y="${formatNum(sy)}"`,
9668
+ `width="${formatNum(sw)}"`,
9669
+ `height="${formatNum(sh)}"`,
9670
+ ];
9671
+ if (style.opacity < 1)
9672
+ attrs.push(`opacity="${style.opacity.toFixed(3)}"`);
9673
+ if (style.rotate !== 0) {
9674
+ const [cx, cy] = roundXY(point.x + style.offset[0] * style.size, point.y + style.offset[1] * style.size);
9675
+ attrs.push(`transform="rotate(${String(style.rotate)},${formatNum(cx)},${formatNum(cy)})"`);
9676
+ }
9677
+ this.#svg.push(`<svg ${attrs.join(' ')} viewBox="${viewBox}" xmlns="http://www.w3.org/2000/svg">` +
9678
+ `<image width="${String(sprite.sheetWidth)}" height="${String(sprite.sheetHeight)}" href="${sprite.sheetDataUri}" />` +
9679
+ `</svg>`);
9680
+ }
9681
+ this.#svg.push('</g>');
9505
9682
  }
9506
- drawRasterTiles(tiles, style) {
9683
+ drawRasterTiles(id, tiles, style) {
9507
9684
  if (tiles.length === 0)
9508
9685
  return;
9509
9686
  if (style.opacity <= 0)
@@ -9519,15 +9696,14 @@
9519
9696
  const brightness = (style.brightnessMin + style.brightnessMax) / 2;
9520
9697
  filters.push(`brightness(${String(brightness)})`);
9521
9698
  }
9522
- let gAttrs = `opacity="${String(style.opacity)}"`;
9699
+ let gAttrs = `id="${escapeXml(id)}" opacity="${String(style.opacity)}"`;
9523
9700
  if (filters.length > 0)
9524
9701
  gAttrs += ` filter="${filters.join(' ')}"`;
9525
9702
  this.#svg.push(`<g ${gAttrs}>`);
9526
9703
  const pixelated = style.resampling === 'nearest';
9527
9704
  for (const tile of tiles) {
9528
9705
  const overlap = Math.min(tile.width, tile.height) / 10000; // slight overlap to prevent sub-pixel gaps between tiles
9529
- const s = this.#scale;
9530
- 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}"`;
9706
+ 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}"`;
9531
9707
  if (pixelated)
9532
9708
  attrs += ' style="image-rendering:pixelated"';
9533
9709
  this.#svg.push(`<image ${attrs} />`);
@@ -9561,16 +9737,67 @@
9561
9737
  attr += ` stroke-opacity="${color.opacity.toFixed(3)}"`;
9562
9738
  return attr;
9563
9739
  }
9564
- function formatScaled(v, scale) {
9565
- return formatNum(Math.round(v * scale * 10));
9740
+ function formatScaled(v) {
9741
+ return formatNum(Math.round(v * 10));
9566
9742
  }
9567
- function roundXY(x, y, scale) {
9568
- return [Math.round(x * scale * 10), Math.round(y * scale * 10)];
9743
+ function roundXY(x, y) {
9744
+ return [Math.round(x * 10), Math.round(y * 10)];
9569
9745
  }
9570
- function formatPoint(p, scale) {
9571
- const [x, y] = roundXY(p[0], p[1], scale);
9746
+ function formatPoint(p) {
9747
+ const [x, y] = roundXY(p[0], p[1]);
9572
9748
  return formatNum(x) + ',' + formatNum(y);
9573
9749
  }
9750
+ function mapTextAnchor(anchor) {
9751
+ switch (anchor) {
9752
+ case 'left':
9753
+ return ['start', 'central'];
9754
+ case 'right':
9755
+ return ['end', 'central'];
9756
+ case 'top':
9757
+ return ['middle', 'text-before-edge'];
9758
+ case 'bottom':
9759
+ return ['middle', 'text-after-edge'];
9760
+ case 'top-left':
9761
+ return ['start', 'text-before-edge'];
9762
+ case 'top-right':
9763
+ return ['end', 'text-before-edge'];
9764
+ case 'bottom-left':
9765
+ return ['start', 'text-after-edge'];
9766
+ case 'bottom-right':
9767
+ return ['end', 'text-after-edge'];
9768
+ default:
9769
+ return ['middle', 'central'];
9770
+ }
9771
+ }
9772
+ function mapIconAnchor(anchor, w, h) {
9773
+ switch (anchor) {
9774
+ case 'left':
9775
+ return [0, -h / 2];
9776
+ case 'right':
9777
+ return [-w, -h / 2];
9778
+ case 'top':
9779
+ return [-w / 2, 0];
9780
+ case 'bottom':
9781
+ return [-w / 2, -h];
9782
+ case 'top-left':
9783
+ return [0, 0];
9784
+ case 'top-right':
9785
+ return [-w, 0];
9786
+ case 'bottom-left':
9787
+ return [0, -h];
9788
+ case 'bottom-right':
9789
+ return [-w, -h];
9790
+ default:
9791
+ return [-w / 2, -h / 2];
9792
+ }
9793
+ }
9794
+ function escapeXml(s) {
9795
+ return s
9796
+ .replace(/&/g, '&amp;')
9797
+ .replace(/</g, '&lt;')
9798
+ .replace(/>/g, '&gt;')
9799
+ .replace(/"/g, '&quot;');
9800
+ }
9574
9801
 
9575
9802
  /*
9576
9803
  * bignumber.js v9.3.1
@@ -16119,6 +16346,65 @@
16119
16346
  return rasterTiles.filter((tile) => tile !== null);
16120
16347
  }
16121
16348
 
16349
+ async function loadSpriteAtlas(style) {
16350
+ const atlas = new Map();
16351
+ const sprite = style.sprite;
16352
+ if (!sprite)
16353
+ return atlas;
16354
+ const sources = [];
16355
+ if (typeof sprite === 'string') {
16356
+ sources.push({ id: 'default', url: sprite });
16357
+ }
16358
+ else if (Array.isArray(sprite)) {
16359
+ for (const s of sprite) {
16360
+ sources.push({
16361
+ id: s.id,
16362
+ url: s.url,
16363
+ });
16364
+ }
16365
+ }
16366
+ await Promise.all(sources.map(async ({ id, url }) => {
16367
+ try {
16368
+ const [jsonResponse, imageResponse] = await Promise.all([
16369
+ fetch(`${url}.json`),
16370
+ fetch(`${url}.png`),
16371
+ ]);
16372
+ if (!jsonResponse.ok || !imageResponse.ok)
16373
+ return;
16374
+ const json = (await jsonResponse.json());
16375
+ const imageBuffer = await imageResponse.arrayBuffer();
16376
+ const base64 = typeof Buffer !== 'undefined'
16377
+ ? Buffer.from(imageBuffer).toString('base64')
16378
+ : btoa(String.fromCharCode(...new Uint8Array(imageBuffer)));
16379
+ const sheetDataUri = `data:image/png;base64,${base64}`;
16380
+ // Estimate sheet dimensions from sprite entries
16381
+ let sheetWidth = 0;
16382
+ let sheetHeight = 0;
16383
+ for (const entry of Object.values(json)) {
16384
+ sheetWidth = Math.max(sheetWidth, entry.x + entry.width);
16385
+ sheetHeight = Math.max(sheetHeight, entry.y + entry.height);
16386
+ }
16387
+ const prefix = id === 'default' ? '' : `${id}:`;
16388
+ for (const [name, entry] of Object.entries(json)) {
16389
+ atlas.set(`${prefix}${name}`, {
16390
+ width: entry.width,
16391
+ height: entry.height,
16392
+ x: entry.x,
16393
+ y: entry.y,
16394
+ pixelRatio: entry.pixelRatio ?? 1,
16395
+ sheetDataUri,
16396
+ sheetWidth,
16397
+ sheetHeight,
16398
+ });
16399
+ }
16400
+ }
16401
+ catch {
16402
+ // Silently skip failed sprite loads
16403
+ }
16404
+ }));
16405
+ return atlas;
16406
+ }
16407
+
16122
16408
  async function getLayerFeatures(job) {
16123
16409
  const { width, height } = job.renderer;
16124
16410
  const { zoom, center } = job.view;
@@ -16279,6 +16565,12 @@
16279
16565
  return layers.map(createStyleLayer);
16280
16566
  }
16281
16567
 
16568
+ function resolveTokens(text, properties) {
16569
+ return text.replace(/\{([^}]+)\}/g, (_, key) => {
16570
+ const value = properties[key];
16571
+ return value != null ? String(value) : '';
16572
+ });
16573
+ }
16282
16574
  async function renderMap(job) {
16283
16575
  await render(job);
16284
16576
  return job.renderer.getString();
@@ -16289,9 +16581,12 @@
16289
16581
  async function render(job) {
16290
16582
  const { renderer } = job;
16291
16583
  const { zoom } = job.view;
16292
- const layerFeatures = await getLayerFeatures(job);
16584
+ const [layerFeatures, spriteAtlas] = await Promise.all([
16585
+ getLayerFeatures(job),
16586
+ job.renderLabels ? loadSpriteAtlas(job.style) : Promise.resolve(new Map()),
16587
+ ]);
16293
16588
  const layerStyles = getLayerStyles(job.style.layers);
16294
- const availableImages = [];
16589
+ const availableImages = [...spriteAtlas.keys()];
16295
16590
  const featureState = {};
16296
16591
  for (const layerStyle of layerStyles) {
16297
16592
  if (layerStyle.isHidden(zoom))
@@ -16312,6 +16607,7 @@
16312
16607
  function getLayout(key, feature) {
16313
16608
  return getStyleValue(layerStyle.layout, key, feature);
16314
16609
  }
16610
+ const layerId = layerStyle.id;
16315
16611
  switch (layerStyle.type) {
16316
16612
  case 'background':
16317
16613
  {
@@ -16331,7 +16627,7 @@
16331
16627
  : polygons;
16332
16628
  if (polygonFeatures.length === 0)
16333
16629
  continue;
16334
- renderer.drawPolygons(polygonFeatures.map((feature) => [
16630
+ renderer.drawPolygons(layerId, polygonFeatures.map((feature) => [
16335
16631
  feature,
16336
16632
  {
16337
16633
  color: getPaint('fill-color', feature),
@@ -16351,7 +16647,7 @@
16351
16647
  : lineStrings;
16352
16648
  if (lineStringFeatures.length === 0)
16353
16649
  continue;
16354
- renderer.drawLineStrings(lineStringFeatures.map((feature) => [
16650
+ renderer.drawLineStrings(layerId, lineStringFeatures.map((feature) => [
16355
16651
  feature,
16356
16652
  {
16357
16653
  color: getPaint('line-color', feature),
@@ -16370,7 +16666,7 @@
16370
16666
  case 'raster':
16371
16667
  {
16372
16668
  const tiles = await getRasterTiles(job, layerStyle.source);
16373
- renderer.drawRasterTiles(tiles, {
16669
+ renderer.drawRasterTiles(layerId, tiles, {
16374
16670
  opacity: getPaint('raster-opacity'),
16375
16671
  hueRotate: getPaint('raster-hue-rotate'),
16376
16672
  brightnessMin: getPaint('raster-brightness-min'),
@@ -16391,7 +16687,7 @@
16391
16687
  : points;
16392
16688
  if (pointFeatures.length === 0)
16393
16689
  continue;
16394
- renderer.drawCircles(pointFeatures.map((feature) => [
16690
+ renderer.drawCircles(layerId, pointFeatures.map((feature) => [
16395
16691
  feature,
16396
16692
  {
16397
16693
  color: getPaint('circle-color', feature),
@@ -16404,11 +16700,76 @@
16404
16700
  ]));
16405
16701
  }
16406
16702
  continue;
16703
+ case 'symbol':
16704
+ {
16705
+ if (!job.renderLabels)
16706
+ continue;
16707
+ const features = getFeatures(layerFeatures, layerStyle);
16708
+ const allFeatures = [
16709
+ ...(features?.points ?? []),
16710
+ ...(features?.linestrings ?? []),
16711
+ ...(features?.polygons ?? []),
16712
+ ];
16713
+ if (allFeatures.length === 0)
16714
+ continue;
16715
+ const symbolFeatures = layerStyle.filterFn
16716
+ ? allFeatures.filter((feature) => layerStyle.filterFn.filter({ zoom }, feature))
16717
+ : allFeatures;
16718
+ if (symbolFeatures.length === 0)
16719
+ continue;
16720
+ // Render icons first (underneath text)
16721
+ renderer.drawIcons(`${layerId}-icons`, symbolFeatures.flatMap((feature) => {
16722
+ const iconImage = getLayout('icon-image', feature);
16723
+ const iconName = iconImage != null
16724
+ ? resolveTokens(iconImage.toString(), feature.properties)
16725
+ : '';
16726
+ if (!iconName || !spriteAtlas.has(iconName))
16727
+ return [];
16728
+ return [
16729
+ [
16730
+ feature,
16731
+ {
16732
+ image: iconName,
16733
+ size: getLayout('icon-size', feature),
16734
+ anchor: getLayout('icon-anchor', feature),
16735
+ offset: getLayout('icon-offset', feature),
16736
+ rotate: getLayout('icon-rotate', feature),
16737
+ opacity: getPaint('icon-opacity', feature),
16738
+ },
16739
+ ],
16740
+ ];
16741
+ }), spriteAtlas);
16742
+ // Render text labels on top
16743
+ renderer.drawLabels(`${layerId}-labels`, symbolFeatures.flatMap((feature) => {
16744
+ const textField = getLayout('text-field', feature);
16745
+ const textRaw = textField != null ? textField.toString() : '';
16746
+ const text = resolveTokens(textRaw, feature.properties);
16747
+ if (!text)
16748
+ return [];
16749
+ return [
16750
+ [
16751
+ feature,
16752
+ {
16753
+ text,
16754
+ size: getLayout('text-size', feature),
16755
+ font: getLayout('text-font', feature),
16756
+ anchor: getLayout('text-anchor', feature),
16757
+ offset: getLayout('text-offset', feature),
16758
+ rotate: getLayout('text-rotate', feature),
16759
+ color: getPaint('text-color', feature),
16760
+ opacity: getPaint('text-opacity', feature),
16761
+ haloColor: getPaint('text-halo-color', feature),
16762
+ haloWidth: getPaint('text-halo-width', feature),
16763
+ },
16764
+ ],
16765
+ ];
16766
+ }));
16767
+ }
16768
+ continue;
16407
16769
  case 'color-relief':
16408
16770
  case 'fill-extrusion':
16409
16771
  case 'heatmap':
16410
16772
  case 'hillshade':
16411
- case 'symbol':
16412
16773
  continue;
16413
16774
  default:
16414
16775
  throw Error('layerStyle.type: ' + String(layerStyle.type));
@@ -16419,20 +16780,18 @@
16419
16780
  async function renderToSVG(options) {
16420
16781
  const width = options.width ?? 1024;
16421
16782
  const height = options.height ?? 1024;
16422
- const scale = options.scale ?? 1;
16423
16783
  if (width <= 0)
16424
16784
  throw new Error('width must be positive');
16425
16785
  if (height <= 0)
16426
16786
  throw new Error('height must be positive');
16427
- if (scale <= 0)
16428
- throw new Error('scale must be positive');
16429
16787
  return await renderMap({
16430
- renderer: new SVGRenderer({ width, height, scale }),
16788
+ renderer: new SVGRenderer({ width, height }),
16431
16789
  style: options.style,
16432
16790
  view: {
16433
16791
  center: [options.lon ?? 0, options.lat ?? 0],
16434
16792
  zoom: options.zoom ?? 2,
16435
16793
  },
16794
+ renderLabels: options.renderLabels ?? false,
16436
16795
  });
16437
16796
  }
16438
16797
 
@@ -16488,7 +16847,6 @@
16488
16847
  this.options = {
16489
16848
  defaultWidth: options?.defaultWidth ?? 1024,
16490
16849
  defaultHeight: options?.defaultHeight ?? 1024,
16491
- defaultScale: options?.defaultScale ?? 1,
16492
16850
  };
16493
16851
  }
16494
16852
  onAdd(map) {
@@ -16529,12 +16887,14 @@
16529
16887
  <div class="panel-notice">
16530
16888
  Note:<br>
16531
16889
  <span class="panel-attribution"></span><br>
16532
- 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>
16890
+ 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>
16533
16891
  </div>
16534
16892
  <div class="panel-inputs">
16535
- <label>Width<input type="number" class="input-width" value="${String(this.options.defaultWidth)}" min="1" max="8192"></label>
16536
- <label>Height<input type="number" class="input-height" value="${String(this.options.defaultHeight)}" min="1" max="8192"></label>
16537
- <label>Scale<input type="number" class="input-scale" value="${String(this.options.defaultScale)}" min="0.1" max="10" step="0.1"></label>
16893
+ <div class="grid">
16894
+ <label>Width<input type="number" class="input-width" value="${String(this.options.defaultWidth)}" min="1" max="8192"></label>
16895
+ <label>Height<input type="number" class="input-height" value="${String(this.options.defaultHeight)}" min="1" max="8192"></label>
16896
+ </div>
16897
+ <label class="label-checkbox"><input type="checkbox" class="input-labels"> Include labels and icons (buggy)</label>
16538
16898
  </div>
16539
16899
  <div class="preview-container">
16540
16900
  <span class="preview-loading">Rendering preview\u2026</span>
@@ -16567,6 +16927,9 @@
16567
16927
  input.addEventListener('input', () => {
16568
16928
  this.schedulePreview();
16569
16929
  });
16930
+ input.addEventListener('change', () => {
16931
+ this.schedulePreview();
16932
+ });
16570
16933
  });
16571
16934
  querySelector(this.panel, '.btn-download').addEventListener('click', () => {
16572
16935
  this.downloadSVG();
@@ -16632,8 +16995,8 @@
16632
16995
  openBtn.disabled = true;
16633
16996
  const width = Number(querySelector(panel, '.input-width').value);
16634
16997
  const height = Number(querySelector(panel, '.input-height').value);
16635
- const scale = Number(querySelector(panel, '.input-scale').value);
16636
- if (!width || !height || !scale || width < 1 || height < 1 || scale < 0.1) {
16998
+ const renderLabels = querySelector(panel, '.input-labels').checked;
16999
+ if (!width || !height || width < 1 || height < 1) {
16637
17000
  previewContainer.innerHTML = '<span class="preview-loading">Invalid input values</span>';
16638
17001
  return;
16639
17002
  }
@@ -16644,11 +17007,11 @@
16644
17007
  const svg = await renderToSVG({
16645
17008
  width,
16646
17009
  height,
16647
- scale,
16648
17010
  style,
16649
17011
  lon: center.lng,
16650
17012
  lat: center.lat,
16651
17013
  zoom,
17014
+ renderLabels,
16652
17015
  });
16653
17016
  if (this.renderGeneration !== generation)
16654
17017
  return;