@versatiles/svg-renderer 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -117,6 +117,23 @@
117
117
  font-size: 13px;
118
118
  }
119
119
 
120
+ .svg-export-panel .panel-notice {
121
+ font-size: 11px;
122
+ color: #888;
123
+ line-height: 1.4;
124
+ margin-bottom: 12px;
125
+ max-width: 320px;
126
+ }
127
+
128
+ .svg-export-panel .panel-notice a {
129
+ color: inherit;
130
+ text-decoration: underline;
131
+ }
132
+
133
+ .svg-export-panel .panel-notice a:hover {
134
+ text-decoration: underline;
135
+ }
136
+
120
137
  .svg-export-panel .panel-actions {
121
138
  display: flex;
122
139
  gap: 8px;
@@ -9194,7 +9211,9 @@
9194
9211
  this.values = [args[0], args[1], args[2], args[3]];
9195
9212
  return;
9196
9213
  }
9197
- throw Error('Unsupported Color arguments: ' + JSON.stringify(args));
9214
+ throw Error('Unsupported Color arguments: ' +
9215
+ JSON.stringify(args) +
9216
+ '. Expected a MaplibreColor, hex string (#RRGGBB or #RRGGBBAA), or 3-4 numeric components.');
9198
9217
  function h2d(text) {
9199
9218
  return parseInt(text, 16);
9200
9219
  }
@@ -9204,25 +9223,9 @@
9204
9223
  }
9205
9224
  get hex() {
9206
9225
  return `#${d2h(this.values[0])}${d2h(this.values[1])}${d2h(this.values[2])}${this.values[3] === 255 ? '' : d2h(this.values[3])}`;
9207
- function d2h(num) {
9208
- if (num < 0)
9209
- num = 0;
9210
- if (num > 255)
9211
- num = 255;
9212
- const str = Math.round(num).toString(16).toUpperCase();
9213
- return str.length < 2 ? '0' + str : str;
9214
- }
9215
9226
  }
9216
9227
  get rgb() {
9217
9228
  return `#${d2h(this.values[0])}${d2h(this.values[1])}${d2h(this.values[2])}`;
9218
- function d2h(num) {
9219
- if (num < 0)
9220
- num = 0;
9221
- if (num > 255)
9222
- num = 255;
9223
- const str = Math.round(num).toString(16).toUpperCase();
9224
- return str.length < 2 ? '0' + str : str;
9225
- }
9226
9229
  }
9227
9230
  get opacity() {
9228
9231
  return this.values[3] / 255;
@@ -9237,6 +9240,126 @@
9237
9240
  return new Color(...this.values);
9238
9241
  }
9239
9242
  }
9243
+ function d2h(num) {
9244
+ if (num < 0)
9245
+ num = 0;
9246
+ if (num > 255)
9247
+ num = 255;
9248
+ const str = Math.round(num).toString(16).toUpperCase();
9249
+ return str.length < 2 ? '0' + str : str;
9250
+ }
9251
+
9252
+ function chainSegments(segments) {
9253
+ // Phase 1: normalize segments left-to-right, then chain
9254
+ normalizeSegments(segments, 0);
9255
+ let chains = greedyChain(segments);
9256
+ // Phase 2: normalize remaining chains top-to-bottom, then chain again
9257
+ normalizeSegments(chains, 1);
9258
+ chains = greedyChain(chains);
9259
+ return chains;
9260
+ }
9261
+ function normalizeSegments(segments, coordIndex) {
9262
+ for (const seg of segments) {
9263
+ const first = seg[0];
9264
+ const last = seg[seg.length - 1];
9265
+ if (first && last && last[coordIndex] < first[coordIndex])
9266
+ seg.reverse();
9267
+ }
9268
+ }
9269
+ function greedyChain(segments) {
9270
+ const byStart = new Map();
9271
+ for (const seg of segments) {
9272
+ const start = seg[0];
9273
+ if (!start)
9274
+ continue;
9275
+ const key = String(start[0]) + ',' + String(start[1]);
9276
+ let list = byStart.get(key);
9277
+ if (!list) {
9278
+ list = [];
9279
+ byStart.set(key, list);
9280
+ }
9281
+ list.push(seg);
9282
+ }
9283
+ const visited = new Set();
9284
+ const chains = [];
9285
+ for (const seg of segments) {
9286
+ if (visited.has(seg))
9287
+ continue;
9288
+ visited.add(seg);
9289
+ const chain = [...seg];
9290
+ let endPoint = chain[chain.length - 1];
9291
+ let candidates = endPoint
9292
+ ? byStart.get(String(endPoint[0]) + ',' + String(endPoint[1]))
9293
+ : undefined;
9294
+ while (candidates) {
9295
+ let next;
9296
+ for (const c of candidates) {
9297
+ if (!visited.has(c)) {
9298
+ next = c;
9299
+ break;
9300
+ }
9301
+ }
9302
+ if (!next)
9303
+ break;
9304
+ visited.add(next);
9305
+ for (let i = 1; i < next.length; i++)
9306
+ chain.push(next[i]);
9307
+ endPoint = chain[chain.length - 1];
9308
+ candidates = endPoint
9309
+ ? byStart.get(String(endPoint[0]) + ',' + String(endPoint[1]))
9310
+ : undefined;
9311
+ }
9312
+ chains.push(chain);
9313
+ }
9314
+ return chains;
9315
+ }
9316
+ function segmentsToPath(chains, close = false) {
9317
+ let d = '';
9318
+ for (const chain of chains) {
9319
+ const first = chain[0];
9320
+ if (!first)
9321
+ continue;
9322
+ d += 'M' + formatNum(first[0]) + ',' + formatNum(first[1]);
9323
+ let px = first[0];
9324
+ let py = first[1];
9325
+ for (let i = 1; i < chain.length; i++) {
9326
+ const x = chain[i][0];
9327
+ const y = chain[i][1];
9328
+ const dx = x - px;
9329
+ const dy = y - py;
9330
+ if (dy === 0) {
9331
+ const rel = 'h' + formatNum(dx);
9332
+ const abs = 'H' + formatNum(x);
9333
+ d += rel.length <= abs.length ? rel : abs;
9334
+ }
9335
+ else if (dx === 0) {
9336
+ const rel = 'v' + formatNum(dy);
9337
+ const abs = 'V' + formatNum(y);
9338
+ d += rel.length <= abs.length ? rel : abs;
9339
+ }
9340
+ else {
9341
+ const rel = 'l' + formatNum(dx) + ',' + formatNum(dy);
9342
+ const abs = 'L' + formatNum(x) + ',' + formatNum(y);
9343
+ d += rel.length <= abs.length ? rel : abs;
9344
+ }
9345
+ px = x;
9346
+ py = y;
9347
+ }
9348
+ if (close)
9349
+ d += 'z';
9350
+ }
9351
+ return d;
9352
+ }
9353
+ function formatNum(tenths) {
9354
+ if (tenths % 10 === 0)
9355
+ return String(tenths / 10);
9356
+ const negative = tenths < 0;
9357
+ if (negative)
9358
+ tenths = -tenths;
9359
+ const whole = Math.floor(tenths / 10);
9360
+ const frac = tenths % 10;
9361
+ return (negative ? '-' : '') + String(whole) + '.' + String(frac);
9362
+ }
9240
9363
 
9241
9364
  class SVGRenderer {
9242
9365
  width;
@@ -9252,77 +9375,86 @@
9252
9375
  this.#backgroundColor = Color.transparent;
9253
9376
  }
9254
9377
  drawBackgroundFill(style) {
9255
- const color = style.color.clone();
9378
+ const color = new Color(style.color);
9256
9379
  color.alpha *= style.opacity;
9257
9380
  this.#backgroundColor = color;
9258
9381
  }
9259
- drawPolygons(features, opacity) {
9382
+ drawPolygons(features) {
9260
9383
  if (features.length === 0)
9261
9384
  return;
9262
- if (opacity <= 0)
9263
- return;
9264
- this.#svg.push(`<g opacity="${String(opacity)}">`);
9265
9385
  const groups = new Map();
9266
9386
  features.forEach(([feature, style]) => {
9267
- if (style.color.alpha <= 0)
9387
+ if (style.opacity <= 0)
9388
+ return;
9389
+ const color = new Color(style.color);
9390
+ if (color.alpha <= 0)
9268
9391
  return;
9269
- const translate = style.translate.isZero()
9392
+ const translate = style.translate[0] === 0 && style.translate[1] === 0
9270
9393
  ? ''
9271
9394
  : ` transform="translate(${formatPoint(style.translate, this.#scale)})"`;
9272
- const key = style.color.hex + translate;
9395
+ const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9396
+ const key = color.hex + translate + opacityAttr;
9273
9397
  let group = groups.get(key);
9274
9398
  if (!group) {
9275
- group = { segments: [], attrs: `${fillAttr(style.color)}${translate}` };
9399
+ group = { segments: [], attrs: `${fillAttr(color)}${translate}${opacityAttr}` };
9276
9400
  groups.set(key, group);
9277
9401
  }
9278
9402
  feature.geometry.forEach((ring) => {
9279
- group.segments.push(ring.map((p) => roundXY(p, this.#scale)));
9403
+ group.segments.push(ring.map((p) => roundXY(p.x, p.y, this.#scale)));
9280
9404
  });
9281
9405
  });
9282
9406
  for (const { segments, attrs } of groups.values()) {
9283
9407
  const d = segmentsToPath(segments, true);
9284
9408
  this.#svg.push(`<path d="${d}" ${attrs} />`);
9285
9409
  }
9286
- this.#svg.push('</g>');
9287
9410
  }
9288
- drawLineStrings(features, opacity) {
9411
+ drawLineStrings(features) {
9289
9412
  if (features.length === 0)
9290
9413
  return;
9291
- if (opacity <= 0)
9292
- return;
9293
- this.#svg.push(`<g opacity="${String(opacity)}">`);
9294
9414
  const groups = new Map();
9295
9415
  features.forEach(([feature, style]) => {
9296
- if (style.width <= 0 || style.color.alpha <= 0)
9416
+ if (style.opacity <= 0)
9297
9417
  return;
9298
- const translate = style.translate.isZero()
9418
+ const color = new Color(style.color);
9419
+ if (style.width <= 0 || color.alpha <= 0)
9420
+ return;
9421
+ const translate = style.translate[0] === 0 && style.translate[1] === 0
9299
9422
  ? ''
9300
9423
  : ` transform="translate(${formatPoint(style.translate, this.#scale)})"`;
9301
- const roundedWidth = roundValue(style.width, this.#scale);
9424
+ const roundedWidth = formatScaled(style.width, this.#scale);
9425
+ const dasharrayStr = style.dasharray
9426
+ ? style.dasharray.map((v) => formatScaled(v * style.width, this.#scale)).join(',')
9427
+ : '';
9428
+ const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9302
9429
  const key = [
9303
- style.color.hex,
9430
+ color.hex,
9304
9431
  roundedWidth,
9305
9432
  style.cap,
9306
9433
  style.join,
9307
9434
  String(style.miterLimit),
9435
+ dasharrayStr,
9436
+ opacityAttr,
9308
9437
  translate,
9309
9438
  ].join('\0');
9310
9439
  let group = groups.get(key);
9311
9440
  if (!group) {
9441
+ const attrs = [
9442
+ 'fill="none"',
9443
+ strokeAttr(color, roundedWidth),
9444
+ `stroke-linecap="${style.cap}"`,
9445
+ `stroke-linejoin="${style.join}"`,
9446
+ `stroke-miterlimit="${String(style.miterLimit)}"`,
9447
+ ];
9448
+ if (dasharrayStr)
9449
+ attrs.push(`stroke-dasharray="${dasharrayStr}"`);
9312
9450
  group = {
9313
9451
  segments: [],
9314
- attrs: [
9315
- 'fill="none"',
9316
- strokeAttr(style.color, roundedWidth),
9317
- `stroke-linecap="${style.cap}"`,
9318
- `stroke-linejoin="${style.join}"`,
9319
- `stroke-miterlimit="${String(style.miterLimit)}"`,
9320
- ].join(' ') + translate,
9452
+ attrs: attrs.join(' ') + translate + opacityAttr,
9321
9453
  };
9322
9454
  groups.set(key, group);
9323
9455
  }
9324
9456
  feature.geometry.forEach((line) => {
9325
- group.segments.push(line.map((p) => roundXY(p, this.#scale)));
9457
+ group.segments.push(line.map((p) => roundXY(p.x, p.y, this.#scale)));
9326
9458
  });
9327
9459
  });
9328
9460
  for (const { segments, attrs } of groups.values()) {
@@ -9330,36 +9462,39 @@
9330
9462
  const d = segmentsToPath(chains);
9331
9463
  this.#svg.push(`<path d="${d}" ${attrs} />`);
9332
9464
  }
9333
- this.#svg.push('</g>');
9334
9465
  }
9335
- drawCircles(features, opacity) {
9466
+ drawCircles(features) {
9336
9467
  if (features.length === 0)
9337
9468
  return;
9338
- if (opacity <= 0)
9339
- return;
9340
- this.#svg.push(`<g opacity="${String(opacity)}">`);
9341
9469
  const groups = new Map();
9342
9470
  features.forEach(([feature, style]) => {
9343
- if (style.radius <= 0 || style.color.alpha <= 0)
9471
+ if (style.opacity <= 0)
9472
+ return;
9473
+ const color = new Color(style.color);
9474
+ if (style.radius <= 0 || color.alpha <= 0)
9344
9475
  return;
9345
- const translate = style.translate.isZero()
9476
+ const translate = style.translate[0] === 0 && style.translate[1] === 0
9346
9477
  ? ''
9347
9478
  : ` transform="translate(${formatPoint(style.translate, this.#scale)})"`;
9348
- const roundedRadius = roundValue(style.radius, this.#scale);
9479
+ const roundedRadius = formatScaled(style.radius, this.#scale);
9480
+ const strokeColor = new Color(style.strokeColor);
9349
9481
  const strokeAttrs = style.strokeWidth > 0
9350
- ? ` ${strokeAttr(style.strokeColor, roundValue(style.strokeWidth, this.#scale))}`
9482
+ ? ` ${strokeAttr(strokeColor, formatScaled(style.strokeWidth, this.#scale))}`
9351
9483
  : '';
9352
- const key = [style.color.hex, roundedRadius, strokeAttrs, translate].join('\0');
9484
+ const opacityAttr = style.opacity < 1 ? ` opacity="${style.opacity.toFixed(3)}"` : '';
9485
+ const key = [color.hex, roundedRadius, strokeAttrs, opacityAttr, translate].join('\0');
9353
9486
  let group = groups.get(key);
9354
9487
  if (!group) {
9355
9488
  group = {
9356
9489
  points: [],
9357
- attrs: `r="${roundedRadius}" ${fillAttr(style.color)}${strokeAttrs}${translate}`,
9490
+ attrs: `r="${roundedRadius}" ${fillAttr(color)}${strokeAttrs}${translate}${opacityAttr}`,
9358
9491
  };
9359
9492
  groups.set(key, group);
9360
9493
  }
9361
9494
  feature.geometry.forEach((ring) => {
9362
- group.points.push(roundXY(ring[0], this.#scale));
9495
+ const p = ring[0];
9496
+ if (p)
9497
+ group.points.push(roundXY(p.x, p.y, this.#scale));
9363
9498
  });
9364
9499
  });
9365
9500
  for (const { points, attrs } of groups.values()) {
@@ -9367,7 +9502,6 @@
9367
9502
  this.#svg.push(`<circle cx="${formatNum(x)}" cy="${formatNum(y)}" ${attrs} />`);
9368
9503
  }
9369
9504
  }
9370
- this.#svg.push('</g>');
9371
9505
  }
9372
9506
  drawRasterTiles(tiles, style) {
9373
9507
  if (tiles.length === 0)
@@ -9393,7 +9527,7 @@
9393
9527
  for (const tile of tiles) {
9394
9528
  const overlap = Math.min(tile.width, tile.height) / 10000; // slight overlap to prevent sub-pixel gaps between tiles
9395
9529
  const s = this.#scale;
9396
- let attrs = `x="${roundValue(tile.x - overlap, s)}" y="${roundValue(tile.y - overlap, s)}" width="${roundValue(tile.width + overlap * 2, s)}" height="${roundValue(tile.height + overlap * 2, s)}" href="${tile.dataUri}"`;
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}"`;
9397
9531
  if (pixelated)
9398
9532
  attrs += ' style="image-rendering:pixelated"';
9399
9533
  this.#svg.push(`<image ${attrs} />`);
@@ -9427,115 +9561,16 @@
9427
9561
  attr += ` stroke-opacity="${color.opacity.toFixed(3)}"`;
9428
9562
  return attr;
9429
9563
  }
9430
- function roundValue(v, scale) {
9431
- return (v * scale).toFixed(3);
9564
+ function formatScaled(v, scale) {
9565
+ return formatNum(Math.round(v * scale * 10));
9432
9566
  }
9433
- function roundXY(p, scale) {
9434
- return [Math.round(p.x * scale * 10), Math.round(p.y * scale * 10)];
9567
+ function roundXY(x, y, scale) {
9568
+ return [Math.round(x * scale * 10), Math.round(y * scale * 10)];
9435
9569
  }
9436
9570
  function formatPoint(p, scale) {
9437
- const [x, y] = roundXY(p, scale);
9571
+ const [x, y] = roundXY(p[0], p[1], scale);
9438
9572
  return formatNum(x) + ',' + formatNum(y);
9439
9573
  }
9440
- function chainSegments(segments) {
9441
- // Phase 1: normalize segments left-to-right, then chain
9442
- normalizeSegments(segments, 0);
9443
- let chains = greedyChain(segments);
9444
- // Phase 2: normalize remaining chains top-to-bottom, then chain again
9445
- normalizeSegments(chains, 1);
9446
- chains = greedyChain(chains);
9447
- return chains;
9448
- }
9449
- function normalizeSegments(segments, coordIndex) {
9450
- for (const seg of segments) {
9451
- if (seg[seg.length - 1][coordIndex] < seg[0][coordIndex])
9452
- seg.reverse();
9453
- }
9454
- }
9455
- function greedyChain(segments) {
9456
- const byStart = new Map();
9457
- for (const seg of segments) {
9458
- const key = String(seg[0][0]) + ',' + String(seg[0][1]);
9459
- let list = byStart.get(key);
9460
- if (!list) {
9461
- list = [];
9462
- byStart.set(key, list);
9463
- }
9464
- list.push(seg);
9465
- }
9466
- const visited = new Set();
9467
- const chains = [];
9468
- for (const seg of segments) {
9469
- if (visited.has(seg))
9470
- continue;
9471
- visited.add(seg);
9472
- const chain = [...seg];
9473
- let endPoint = chain[chain.length - 1];
9474
- let candidates = byStart.get(String(endPoint[0]) + ',' + String(endPoint[1]));
9475
- while (candidates) {
9476
- let next;
9477
- for (const c of candidates) {
9478
- if (!visited.has(c)) {
9479
- next = c;
9480
- break;
9481
- }
9482
- }
9483
- if (!next)
9484
- break;
9485
- visited.add(next);
9486
- for (let i = 1; i < next.length; i++)
9487
- chain.push(next[i]);
9488
- endPoint = chain[chain.length - 1];
9489
- candidates = byStart.get(String(endPoint[0]) + ',' + String(endPoint[1]));
9490
- }
9491
- chains.push(chain);
9492
- }
9493
- return chains;
9494
- }
9495
- function segmentsToPath(chains, close = false) {
9496
- let d = '';
9497
- for (const chain of chains) {
9498
- d += 'M' + formatNum(chain[0][0]) + ',' + formatNum(chain[0][1]);
9499
- let px = chain[0][0];
9500
- let py = chain[0][1];
9501
- for (let i = 1; i < chain.length; i++) {
9502
- const x = chain[i][0];
9503
- const y = chain[i][1];
9504
- const dx = x - px;
9505
- const dy = y - py;
9506
- if (dy === 0) {
9507
- const rel = 'h' + formatNum(dx);
9508
- const abs = 'H' + formatNum(x);
9509
- d += rel.length <= abs.length ? rel : abs;
9510
- }
9511
- else if (dx === 0) {
9512
- const rel = 'v' + formatNum(dy);
9513
- const abs = 'V' + formatNum(y);
9514
- d += rel.length <= abs.length ? rel : abs;
9515
- }
9516
- else {
9517
- const rel = 'l' + formatNum(dx) + ',' + formatNum(dy);
9518
- const abs = 'L' + formatNum(x) + ',' + formatNum(y);
9519
- d += rel.length <= abs.length ? rel : abs;
9520
- }
9521
- px = x;
9522
- py = y;
9523
- }
9524
- if (close)
9525
- d += 'z';
9526
- }
9527
- return d;
9528
- }
9529
- function formatNum(tenths) {
9530
- if (tenths % 10 === 0)
9531
- return String(tenths / 10);
9532
- const negative = tenths < 0;
9533
- if (negative)
9534
- tenths = -tenths;
9535
- const whole = Math.floor(tenths / 10);
9536
- const frac = tenths % 10;
9537
- return (negative ? '-' : '') + String(whole) + '.' + String(frac);
9538
- }
9539
9574
 
9540
9575
  /*
9541
9576
  * bignumber.js v9.3.1
@@ -14199,6 +14234,7 @@
14199
14234
  properties;
14200
14235
  patterns;
14201
14236
  geometry;
14237
+ #bbox;
14202
14238
  constructor(opt) {
14203
14239
  this.type = opt.type;
14204
14240
  this.id = opt.id;
@@ -14207,6 +14243,8 @@
14207
14243
  this.geometry = opt.geometry;
14208
14244
  }
14209
14245
  getBbox() {
14246
+ if (this.#bbox)
14247
+ return this.#bbox;
14210
14248
  let xMin = Infinity;
14211
14249
  let yMin = Infinity;
14212
14250
  let xMax = -Infinity;
@@ -14223,7 +14261,8 @@
14223
14261
  yMax = point.y;
14224
14262
  });
14225
14263
  });
14226
- return [xMin, yMin, xMax, yMax];
14264
+ this.#bbox = [xMin, yMin, xMax, yMax];
14265
+ return this.#bbox;
14227
14266
  }
14228
14267
  doesOverlap(bbox) {
14229
14268
  const featureBbox = this.getBbox();
@@ -14241,7 +14280,7 @@
14241
14280
 
14242
14281
  function geojsonToFeature(id, polygonFeature) {
14243
14282
  const geometry = polygonFeature.geometry.coordinates.map((ring) => {
14244
- return ring.map((coord) => new Point2D(coord[0], coord[1]));
14283
+ return ring.map((coord) => new Point2D(coord[0] ?? 0, coord[1] ?? 0));
14245
14284
  });
14246
14285
  return new Feature({
14247
14286
  type: 'Polygon',
@@ -14250,7 +14289,7 @@
14250
14289
  properties: polygonFeature.properties ?? {},
14251
14290
  });
14252
14291
  }
14253
- function mergePolygons(featureList) {
14292
+ function mergePolygonsByFeatureId(featureList) {
14254
14293
  const featuresById = new Map();
14255
14294
  let nextId = -1;
14256
14295
  for (const feature of featureList) {
@@ -14310,7 +14349,9 @@
14310
14349
 
14311
14350
  function calculateTileGrid(width, height, center, zoom, maxzoom) {
14312
14351
  const zoomLevel = Math.min(Math.floor(zoom), maxzoom ?? Infinity);
14313
- const tileCenterCoordinate = center.getProject2Pixel().scale(2 ** zoomLevel);
14352
+ const tileCenterCoordinate = new Point2D(center[0], center[1])
14353
+ .getProject2Pixel()
14354
+ .scale(2 ** zoomLevel);
14314
14355
  const tileSize = 2 ** (zoom - zoomLevel + 9); // 512 (2^9) is the standard tile size
14315
14356
  const tileCols = width / tileSize;
14316
14357
  const tileRows = height / tileSize;
@@ -14318,11 +14359,15 @@
14318
14359
  const tileMinY = Math.floor(tileCenterCoordinate.y - tileRows / 2);
14319
14360
  const tileMaxX = Math.floor(tileCenterCoordinate.x + tileCols / 2);
14320
14361
  const tileMaxY = Math.floor(tileCenterCoordinate.y + tileRows / 2);
14362
+ const tilesPerZoom = 2 ** zoomLevel;
14321
14363
  const tiles = [];
14322
14364
  for (let x = tileMinX; x <= tileMaxX; x++) {
14365
+ const wrappedX = ((x % tilesPerZoom) + tilesPerZoom) % tilesPerZoom;
14323
14366
  for (let y = tileMinY; y <= tileMaxY; y++) {
14367
+ if (y < 0 || y >= tilesPerZoom)
14368
+ continue;
14324
14369
  tiles.push({
14325
- x,
14370
+ x: wrappedX,
14326
14371
  y,
14327
14372
  offsetX: width / 2 + (x - tileCenterCoordinate.x) * tileSize,
14328
14373
  offsetY: height / 2 + (y - tileCenterCoordinate.y) * tileSize,
@@ -14341,8 +14386,8 @@
14341
14386
  const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
14342
14387
  return { buffer, contentType };
14343
14388
  }
14344
- catch {
14345
- console.warn(`Failed to load tile: ${tileUrl}`);
14389
+ catch (error) {
14390
+ console.warn(`Failed to load tile: ${tileUrl}`, error);
14346
14391
  return null;
14347
14392
  }
14348
14393
  }
@@ -15887,6 +15932,7 @@
15887
15932
  }
15888
15933
 
15889
15934
  const TILE_EXTENT = 4096;
15935
+ const VTFeatureType = { Unknown: 0, Point: 1, LineString: 2, Polygon: 3 };
15890
15936
  async function loadVectorSource(source, job, layerFeatures) {
15891
15937
  const tiles = source.tiles;
15892
15938
  if (!tiles)
@@ -15914,17 +15960,17 @@
15914
15960
  let type;
15915
15961
  let list;
15916
15962
  switch (featureSrc.type) {
15917
- case 0: //Unknown
15963
+ case VTFeatureType.Unknown:
15918
15964
  throw Error('Unknown feature type in vector tile');
15919
- case 1: //Point
15965
+ case VTFeatureType.Point:
15920
15966
  type = 'Point';
15921
15967
  list = features.points;
15922
15968
  break;
15923
- case 2: //LineString
15969
+ case VTFeatureType.LineString:
15924
15970
  type = 'LineString';
15925
15971
  list = features.linestrings;
15926
15972
  break;
15927
- case 3: //Polygon
15973
+ case VTFeatureType.Polygon:
15928
15974
  type = 'Polygon';
15929
15975
  list = features.polygons;
15930
15976
  break;
@@ -15942,13 +15988,14 @@
15942
15988
  }));
15943
15989
  }
15944
15990
 
15945
- function loadGeoJSONSource(sourceName, data, width, height, zoom, center, layerFeatures) {
15991
+ function loadGeoJSONSource(options) {
15992
+ const { sourceName, data, width, height, zoom, center, layerFeatures } = options;
15946
15993
  const existing = layerFeatures.get(sourceName);
15947
15994
  const features = existing ?? { points: [], linestrings: [], polygons: [] };
15948
15995
  if (!existing)
15949
15996
  layerFeatures.set(sourceName, features);
15950
15997
  const worldSize = 512 * 2 ** zoom;
15951
- const centerMercator = center.getProject2Pixel();
15998
+ const centerMercator = new Point2D(center[0], center[1]).getProject2Pixel();
15952
15999
  function projectCoord(coord) {
15953
16000
  const mercator = new Point2D(coord[0], coord[1]).getProject2Pixel();
15954
16001
  return new Point2D((mercator.x - centerMercator.x) * worldSize + width / 2, (mercator.y - centerMercator.y) * worldSize + height / 2);
@@ -16001,25 +16048,26 @@
16001
16048
  }
16002
16049
  }
16003
16050
  function processGeometry(geom, id, properties) {
16004
- const coords = geom.coordinates;
16005
16051
  switch (geom.type) {
16006
16052
  case 'Point':
16007
- addFeature('Point', [[projectCoord(coords)]], id, properties);
16053
+ addFeature('Point', [[projectCoord(geom.coordinates)]], id, properties);
16008
16054
  break;
16009
16055
  case 'MultiPoint':
16010
- addFeature('Point', coords.map((c) => [projectCoord(c)]), id, properties);
16056
+ addFeature('Point', geom.coordinates.map((c) => [projectCoord(c)]), id, properties);
16011
16057
  break;
16012
16058
  case 'LineString':
16013
- addFeature('LineString', [coords.map((c) => projectCoord(c))], id, properties);
16059
+ addFeature('LineString', [geom.coordinates.map((c) => projectCoord(c))], id, properties);
16014
16060
  break;
16015
16061
  case 'MultiLineString':
16016
- addFeature('LineString', coords.map((line) => line.map((c) => projectCoord(c))), id, properties);
16062
+ addFeature('LineString', geom.coordinates.map((line) => line.map((c) => projectCoord(c))), id, properties);
16017
16063
  break;
16018
16064
  case 'Polygon':
16019
- addFeature('Polygon', coords.map((ring) => ring.map((c) => projectCoord(c))), id, properties);
16065
+ addFeature('Polygon', geom.coordinates.map((ring) => ring.map((c) => projectCoord(c))), id, properties);
16020
16066
  break;
16021
16067
  case 'MultiPolygon':
16022
- addFeature('Polygon', coords.flatMap((polygon) => polygon.map((ring) => ring.map((c) => projectCoord(c)))), id, properties);
16068
+ for (const polygon of geom.coordinates) {
16069
+ addFeature('Polygon', polygon.map((ring) => ring.map((c) => projectCoord(c))), id, properties);
16070
+ }
16023
16071
  break;
16024
16072
  case 'GeometryCollection':
16025
16073
  for (const g of geom.geometries) {
@@ -16028,18 +16076,17 @@
16028
16076
  break;
16029
16077
  }
16030
16078
  }
16031
- const geojson = data;
16032
- switch (geojson.type) {
16079
+ switch (data.type) {
16033
16080
  case 'FeatureCollection':
16034
- for (const f of geojson.features) {
16081
+ for (const f of data.features) {
16035
16082
  processGeometry(f.geometry, f.id, (f.properties ?? {}));
16036
16083
  }
16037
16084
  break;
16038
16085
  case 'Feature':
16039
- processGeometry(geojson.geometry, geojson.id, (geojson.properties ?? {}));
16086
+ processGeometry(data.geometry, data.id, (data.properties ?? {}));
16040
16087
  break;
16041
16088
  default:
16042
- processGeometry(geojson, undefined, {});
16089
+ processGeometry(data, undefined, {});
16043
16090
  break;
16044
16091
  }
16045
16092
  }
@@ -16049,7 +16096,7 @@
16049
16096
  const { zoom, center } = job.view;
16050
16097
  const source = job.style.sources[sourceName];
16051
16098
  if (source?.type !== 'raster' || !source.tiles) {
16052
- throw Error('Invalid raster source: ' + sourceName);
16099
+ throw Error(`Invalid raster source "${sourceName}": expected type "raster" with a "tiles" array`);
16053
16100
  }
16054
16101
  const sourceUrl = source.tiles[0];
16055
16102
  const { zoomLevel, tileSize, tiles } = calculateTileGrid(width, height, center, zoom, source.maxzoom);
@@ -16077,24 +16124,34 @@
16077
16124
  const { zoom, center } = job.view;
16078
16125
  const { sources } = job.style;
16079
16126
  const layerFeatures = new Map();
16127
+ const loadPromises = [];
16080
16128
  for (const [sourceName, sourceSpec] of Object.entries(sources)) {
16081
16129
  const source = sourceSpec;
16082
16130
  switch (source.type) {
16083
16131
  case 'vector':
16084
- await loadVectorSource(source, job, layerFeatures);
16132
+ loadPromises.push(loadVectorSource(source, job, layerFeatures));
16085
16133
  break;
16086
16134
  case 'geojson':
16087
16135
  if (source.data) {
16088
- loadGeoJSONSource(sourceName, source.data, width, height, zoom, center, layerFeatures);
16136
+ loadGeoJSONSource({
16137
+ sourceName,
16138
+ data: source.data,
16139
+ width,
16140
+ height,
16141
+ zoom,
16142
+ center,
16143
+ layerFeatures,
16144
+ });
16089
16145
  }
16090
16146
  break;
16091
16147
  }
16092
16148
  }
16149
+ await Promise.all(loadPromises);
16093
16150
  for (const [name, features] of layerFeatures) {
16094
16151
  layerFeatures.set(name, {
16095
16152
  points: features.points,
16096
16153
  linestrings: features.linestrings,
16097
- polygons: mergePolygons(features.polygons),
16154
+ polygons: mergePolygonsByFeatureId(features.polygons),
16098
16155
  });
16099
16156
  }
16100
16157
  return layerFeatures;
@@ -16142,6 +16199,7 @@
16142
16199
  minzoom;
16143
16200
  maxzoom;
16144
16201
  filter;
16202
+ filterFn;
16145
16203
  paint;
16146
16204
  layout;
16147
16205
  paintExpressions;
@@ -16160,6 +16218,7 @@
16160
16218
  this.source = spec.source;
16161
16219
  this.sourceLayer = spec['source-layer'];
16162
16220
  this.filter = spec.filter;
16221
+ this.filterFn = featureFilter(this.filter);
16163
16222
  }
16164
16223
  this.visibility = (spec.layout?.visibility ?? 'visible');
16165
16224
  // Initialize paint property expressions
@@ -16197,7 +16256,7 @@
16197
16256
  this.layout = new EvaluatedProperties();
16198
16257
  for (const [name, expr] of this.paintExpressions) {
16199
16258
  if (expr.kind === 'constant' || expr.kind === 'camera') {
16200
- this.paint.set(name, expr.evaluate(params, null, {}, undefined, availableImages));
16259
+ this.paint.set(name, expr.evaluate(params, undefined, {}, undefined, availableImages));
16201
16260
  }
16202
16261
  else {
16203
16262
  this.paint.set(name, new PossiblyEvaluatedPropertyValue(expr, params));
@@ -16205,7 +16264,7 @@
16205
16264
  }
16206
16265
  for (const [name, expr] of this.layoutExpressions) {
16207
16266
  if (expr.kind === 'constant' || expr.kind === 'camera') {
16208
- this.layout.set(name, expr.evaluate(params, null, {}, undefined, availableImages));
16267
+ this.layout.set(name, expr.evaluate(params, undefined, {}, undefined, availableImages));
16209
16268
  }
16210
16269
  else {
16211
16270
  this.layout.set(name, new PossiblyEvaluatedPropertyValue(expr, params));
@@ -16216,18 +16275,17 @@
16216
16275
  function createStyleLayer(spec) {
16217
16276
  return new StyleLayer(spec);
16218
16277
  }
16219
-
16220
16278
  function getLayerStyles(layers) {
16221
- return layers.map((layerSpecification) => {
16222
- const styleLayer = createStyleLayer(layerSpecification);
16223
- return styleLayer;
16224
- });
16279
+ return layers.map(createStyleLayer);
16225
16280
  }
16226
16281
 
16227
- async function renderVectorTiles(job) {
16282
+ async function renderMap(job) {
16228
16283
  await render(job);
16229
16284
  return job.renderer.getString();
16230
16285
  }
16286
+ function getFeatures(layerFeatures, layerStyle) {
16287
+ return layerFeatures.get(layerStyle.sourceLayer) ?? layerFeatures.get(layerStyle.source);
16288
+ }
16231
16289
  async function render(job) {
16232
16290
  const { renderer } = job;
16233
16291
  const { zoom } = job.view;
@@ -16258,54 +16316,55 @@
16258
16316
  case 'background':
16259
16317
  {
16260
16318
  renderer.drawBackgroundFill({
16261
- color: new Color(getPaint('background-color')),
16319
+ color: getPaint('background-color'),
16262
16320
  opacity: getPaint('background-opacity'),
16263
16321
  });
16264
16322
  }
16265
16323
  continue;
16266
16324
  case 'fill':
16267
16325
  {
16268
- const polygons = (layerFeatures.get(layerStyle.sourceLayer) ?? layerFeatures.get(layerStyle.source))?.polygons;
16326
+ const polygons = getFeatures(layerFeatures, layerStyle)?.polygons;
16269
16327
  if (!polygons || polygons.length === 0)
16270
16328
  continue;
16271
- const filter = featureFilter(layerStyle.filter);
16272
- const polygonFeatures = polygons.filter((feature) => filter.filter({ zoom }, feature));
16329
+ const polygonFeatures = layerStyle.filterFn
16330
+ ? polygons.filter((feature) => layerStyle.filterFn.filter({ zoom }, feature))
16331
+ : polygons;
16273
16332
  if (polygonFeatures.length === 0)
16274
16333
  continue;
16275
16334
  renderer.drawPolygons(polygonFeatures.map((feature) => [
16276
16335
  feature,
16277
16336
  {
16278
- color: new Color(getPaint('fill-color', feature)),
16279
- translate: new Point2D(...getPaint('fill-translate', feature)),
16337
+ color: getPaint('fill-color', feature),
16338
+ opacity: getPaint('fill-opacity', feature),
16339
+ translate: getPaint('fill-translate', feature),
16280
16340
  },
16281
- ]), getPaint('fill-opacity', polygonFeatures[0]));
16341
+ ]));
16282
16342
  }
16283
16343
  continue;
16284
16344
  case 'line':
16285
16345
  {
16286
- const lineStrings = (layerFeatures.get(layerStyle.sourceLayer) ?? layerFeatures.get(layerStyle.source))?.linestrings;
16346
+ const lineStrings = getFeatures(layerFeatures, layerStyle)?.linestrings;
16287
16347
  if (!lineStrings || lineStrings.length === 0)
16288
16348
  continue;
16289
- const filter = featureFilter(layerStyle.filter);
16290
- const lineStringFeatures = lineStrings.filter((feature) => filter.filter({ zoom }, feature));
16349
+ const lineStringFeatures = layerStyle.filterFn
16350
+ ? lineStrings.filter((feature) => layerStyle.filterFn.filter({ zoom }, feature))
16351
+ : lineStrings;
16291
16352
  if (lineStringFeatures.length === 0)
16292
16353
  continue;
16293
16354
  renderer.drawLineStrings(lineStringFeatures.map((feature) => [
16294
16355
  feature,
16295
16356
  {
16296
- color: new Color(getPaint('line-color', feature)),
16297
- translate: new Point2D(...getPaint('line-translate', feature)),
16298
- blur: getPaint('line-blur', feature),
16357
+ color: getPaint('line-color', feature),
16358
+ translate: getPaint('line-translate', feature),
16299
16359
  cap: getLayout('line-cap', feature),
16300
16360
  dasharray: getPaint('line-dasharray', feature),
16301
- gapWidth: getPaint('line-gap-width', feature),
16302
16361
  join: getLayout('line-join', feature),
16303
16362
  miterLimit: getLayout('line-miter-limit', feature),
16304
16363
  offset: getPaint('line-offset', feature),
16305
- roundLimit: getLayout('line-round-limit', feature),
16364
+ opacity: getPaint('line-opacity', feature),
16306
16365
  width: getPaint('line-width', feature),
16307
16366
  },
16308
- ]), getPaint('line-opacity', lineStringFeatures[0]));
16367
+ ]));
16309
16368
  }
16310
16369
  continue;
16311
16370
  case 'raster':
@@ -16324,24 +16383,25 @@
16324
16383
  continue;
16325
16384
  case 'circle':
16326
16385
  {
16327
- const points = (layerFeatures.get(layerStyle.sourceLayer) ?? layerFeatures.get(layerStyle.source))?.points;
16386
+ const points = getFeatures(layerFeatures, layerStyle)?.points;
16328
16387
  if (!points || points.length === 0)
16329
16388
  continue;
16330
- const filter = featureFilter(layerStyle.filter);
16331
- const pointFeatures = points.filter((feature) => filter.filter({ zoom }, feature));
16389
+ const pointFeatures = layerStyle.filterFn
16390
+ ? points.filter((feature) => layerStyle.filterFn.filter({ zoom }, feature))
16391
+ : points;
16332
16392
  if (pointFeatures.length === 0)
16333
16393
  continue;
16334
16394
  renderer.drawCircles(pointFeatures.map((feature) => [
16335
16395
  feature,
16336
16396
  {
16337
- color: new Color(getPaint('circle-color', feature)),
16397
+ color: getPaint('circle-color', feature),
16398
+ opacity: getPaint('circle-opacity', feature),
16338
16399
  radius: getPaint('circle-radius', feature),
16339
- blur: getPaint('circle-blur', feature),
16340
- translate: new Point2D(...getPaint('circle-translate', feature)),
16400
+ translate: getPaint('circle-translate', feature),
16341
16401
  strokeWidth: getPaint('circle-stroke-width', feature),
16342
- strokeColor: new Color(getPaint('circle-stroke-color', feature)),
16402
+ strokeColor: getPaint('circle-stroke-color', feature),
16343
16403
  },
16344
- ]), getPaint('circle-opacity', pointFeatures[0]));
16404
+ ]));
16345
16405
  }
16346
16406
  continue;
16347
16407
  case 'color-relief':
@@ -16357,15 +16417,20 @@
16357
16417
  }
16358
16418
 
16359
16419
  async function renderToSVG(options) {
16360
- return await renderVectorTiles({
16361
- renderer: new SVGRenderer({
16362
- width: options.width ?? 1024,
16363
- height: options.height ?? 1024,
16364
- scale: options.scale ?? 1,
16365
- }),
16420
+ const width = options.width ?? 1024;
16421
+ const height = options.height ?? 1024;
16422
+ const scale = options.scale ?? 1;
16423
+ if (width <= 0)
16424
+ throw new Error('width must be positive');
16425
+ if (height <= 0)
16426
+ throw new Error('height must be positive');
16427
+ if (scale <= 0)
16428
+ throw new Error('scale must be positive');
16429
+ return await renderMap({
16430
+ renderer: new SVGRenderer({ width, height, scale }),
16366
16431
  style: options.style,
16367
16432
  view: {
16368
- center: new Point2D(options.lon ?? 0, options.lat ?? 0),
16433
+ center: [options.lon ?? 0, options.lat ?? 0],
16369
16434
  zoom: options.zoom ?? 2,
16370
16435
  },
16371
16436
  });
@@ -16377,6 +16442,39 @@
16377
16442
  throw new Error(`Element not found: ${selector}`);
16378
16443
  return el;
16379
16444
  }
16445
+ const ALLOWED_TAGS = new Set(['a', 'b', 'i', 'em', 'strong', 'span']);
16446
+ function sanitizeHTML(html) {
16447
+ const parser = new DOMParser();
16448
+ const doc = parser.parseFromString(html, 'text/html');
16449
+ return sanitizeNode(doc.body).textContent ?? '';
16450
+ }
16451
+ function sanitizeNode(node) {
16452
+ if (node.nodeType === Node.TEXT_NODE) {
16453
+ return document.createTextNode(node.textContent ?? '');
16454
+ }
16455
+ if (node.nodeType === Node.ELEMENT_NODE && node instanceof HTMLElement) {
16456
+ const tag = node.tagName.toLowerCase();
16457
+ let span;
16458
+ if (!ALLOWED_TAGS.has(tag)) {
16459
+ span = document.createDocumentFragment();
16460
+ }
16461
+ else {
16462
+ span = document.createElement(tag);
16463
+ if (tag === 'a') {
16464
+ const href = node.getAttribute('href');
16465
+ if (href && /^https?:\/\//i.test(href))
16466
+ span.setAttribute('href', href);
16467
+ span.setAttribute('target', '_blank');
16468
+ span.setAttribute('rel', 'noopener noreferrer');
16469
+ }
16470
+ }
16471
+ for (const child of Array.from(node.childNodes)) {
16472
+ span.append(sanitizeNode(child));
16473
+ }
16474
+ return span;
16475
+ }
16476
+ return document.createTextNode('');
16477
+ }
16380
16478
  class SVGExportControl {
16381
16479
  map;
16382
16480
  container;
@@ -16428,6 +16526,11 @@
16428
16526
  <h3>Export SVG</h3>
16429
16527
  <button class="panel-close" title="Close">\u00d7</button>
16430
16528
  </div>
16529
+ <div class="panel-notice">
16530
+ Note:<br>
16531
+ <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>
16533
+ </div>
16431
16534
  <div class="panel-inputs">
16432
16535
  <label>Width<input type="number" class="input-width" value="${String(this.options.defaultWidth)}" min="1" max="8192"></label>
16433
16536
  <label>Height<input type="number" class="input-height" value="${String(this.options.defaultHeight)}" min="1" max="8192"></label>
@@ -16441,6 +16544,22 @@
16441
16544
  <button class="btn-open" disabled>Open in Tab</button>
16442
16545
  </div>
16443
16546
  `;
16547
+ const noticeEl = querySelector(this.panel, '.panel-attribution');
16548
+ const sources = this.map.getStyle().sources;
16549
+ const attributions = [
16550
+ ...new Set(Object.values(sources)
16551
+ .map((s) => s.attribution?.trim())
16552
+ .filter((a) => !!a)),
16553
+ ];
16554
+ if (attributions.length > 0) {
16555
+ noticeEl.innerHTML =
16556
+ "When publishing the exported map, don't forget to add an attribution like: " +
16557
+ attributions.map(sanitizeHTML).join(', ');
16558
+ }
16559
+ else {
16560
+ noticeEl.textContent =
16561
+ 'When publishing the exported map, please check the license terms of the data and include proper attribution.';
16562
+ }
16444
16563
  querySelector(this.panel, '.panel-close').addEventListener('click', () => {
16445
16564
  this.closePanel();
16446
16565
  });
@@ -16545,7 +16664,11 @@
16545
16664
  if (this.renderGeneration !== generation)
16546
16665
  return;
16547
16666
  const message = error instanceof Error ? error.message : 'Unknown error';
16548
- previewContainer.innerHTML = `<span class="preview-loading">Error: ${message}</span>`;
16667
+ const errorSpan = document.createElement('span');
16668
+ errorSpan.className = 'preview-loading';
16669
+ errorSpan.textContent = `Error: ${message}`;
16670
+ previewContainer.innerHTML = '';
16671
+ previewContainer.appendChild(errorSpan);
16549
16672
  }
16550
16673
  }
16551
16674
  downloadSVG() {
@@ -16567,6 +16690,7 @@
16567
16690
  const blob = new Blob([this.currentSVG], { type: 'image/svg+xml' });
16568
16691
  const url = URL.createObjectURL(blob);
16569
16692
  window.open(url, '_blank');
16693
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
16570
16694
  }
16571
16695
  }
16572
16696