@versatiles/svg-renderer 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,3 +1,9 @@
1
+ [![NPM version](https://img.shields.io/npm/v/%40versatiles%2Fsvg-renderer)](https://www.npmjs.com/package/@versatiles/svg-renderer)
2
+ [![NPM downloads](https://img.shields.io/npm/dt/%40versatiles%2Fsvg-renderer)](https://www.npmjs.com/package/@versatiles/svg-renderer)
3
+ [![Code coverage](https://codecov.io/gh/versatiles-org/versatiles-svg-renderer/branch/main/graph/badge.svg)](https://codecov.io/gh/versatiles-org/versatiles-svg-renderer)
4
+ [![CI status](https://img.shields.io/github/actions/workflow/status/versatiles-org/versatiles-svg-renderer/ci.yml)](https://github.com/versatiles-org/versatiles-svg-renderer/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
6
+
1
7
  # VersaTiles SVG Renderer
2
8
 
3
9
  Renders vector maps as SVG.
@@ -6,7 +12,111 @@ Renders vector maps as SVG.
6
12
 
7
13
  [Download SVG](docs/demo.svg)
8
14
 
9
- Currently only background, fill and line layers are supported.
15
+ Currently supported layer types: background, fill, line, and raster.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @versatiles/svg-renderer
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ### Node.js
26
+
27
+ ```typescript
28
+ import { renderToSVG } from '@versatiles/svg-renderer';
29
+ import { styles } from '@versatiles/style';
30
+ import { writeFileSync } from 'node:fs';
31
+
32
+ const svg = await renderToSVG({
33
+ style: styles.colorful(),
34
+ width: 800,
35
+ height: 600,
36
+ lon: 13.4,
37
+ lat: 52.5,
38
+ zoom: 10,
39
+ });
40
+
41
+ writeFileSync('map.svg', svg);
42
+ ```
43
+
44
+ ### Browser
45
+
46
+ ```typescript
47
+ import { renderToSVG } from '@versatiles/svg-renderer';
48
+
49
+ const svg = await renderToSVG({
50
+ style: await fetch('https://tiles.versatiles.org/assets/styles/colorful/style.json').then((r) =>
51
+ r.json(),
52
+ ),
53
+ width: 800,
54
+ height: 600,
55
+ lon: 13.4,
56
+ lat: 52.5,
57
+ zoom: 10,
58
+ });
59
+
60
+ document.body.innerHTML = svg;
61
+ ```
62
+
63
+ ### MapLibre Plugin
64
+
65
+ The package includes an `SVGExportControl` that adds an export button to any MapLibre GL JS map.
66
+
67
+ ```bash
68
+ npm install @versatiles/svg-renderer maplibre-gl
69
+ ```
70
+
71
+ ```html
72
+ <!DOCTYPE html>
73
+ <html>
74
+ <head>
75
+ <link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css" />
76
+ <script src="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js"></script>
77
+ <script src="…/svg-renderer/dist/maplibre.cjs"></script>
78
+ <style></style>
79
+ </head>
80
+ <body>
81
+ <div id="map"></div>
82
+ <script>
83
+ const map = new maplibregl.Map({
84
+ container: 'map',
85
+ style: 'https://tiles.versatiles.org/assets/styles/colorful/style.json',
86
+ center: [13.4, 52.5],
87
+ zoom: 10,
88
+ });
89
+ map.addControl(new SVGExportControl(), 'top-right');
90
+ </script>
91
+ </body>
92
+ </html>
93
+ ```
94
+
95
+ The control opens a panel where the user can set width, height, and scale, preview the SVG, download it, or open it in a new tab. Map interactions are disabled while the panel is open.
96
+
97
+ Options:
98
+
99
+ ```typescript
100
+ new SVGExportControl({
101
+ defaultWidth: 1024, // default: 1024
102
+ defaultHeight: 1024, // default: 1024
103
+ defaultScale: 1, // default: 1
104
+ });
105
+ ```
106
+
107
+ ## API
108
+
109
+ ### `renderToSVG(options): Promise<string>`
110
+
111
+ | Option | Type | Default | Description |
112
+ | -------- | -------------------- | ------------ | ---------------------------- |
113
+ | `style` | `StyleSpecification` | *(required)* | MapLibre style specification |
114
+ | `width` | `number` | `1024` | Output width in pixels |
115
+ | `height` | `number` | `1024` | Output height in pixels |
116
+ | `scale` | `number` | `1` | Scale factor |
117
+ | `lon` | `number` | `0` | Center longitude |
118
+ | `lat` | `number` | `0` | Center latitude |
119
+ | `zoom` | `number` | `2` | Zoom level |
10
120
 
11
121
  ## E2E Visual Comparison
12
122
 
@@ -31,33 +141,47 @@ subgraph 0["src"]
31
141
  subgraph 3["lib"]
32
142
  4["geometry.ts"]
33
143
  7["color.ts"]
34
- 9["style_layer.ts"]
144
+ B["style_layer.ts"]
35
145
  end
36
146
  subgraph 5["processor"]
37
147
  6["render.ts"]
38
- 8["styles.ts"]
39
- A["vector.ts"]
40
- B["helper.ts"]
148
+ 8["raster.ts"]
149
+ 9["tiles.ts"]
150
+ A["styles.ts"]
151
+ C["vector.ts"]
152
+ D["helper.ts"]
153
+ end
154
+ subgraph E["renderer"]
155
+ F["renderer_svg.ts"]
41
156
  end
42
- subgraph C["renderer"]
43
- D["renderer_svg.ts"]
157
+ subgraph G["maplibre"]
158
+ H["control.ts"]
159
+ I["styles.ts"]
160
+ J["index.ts"]
44
161
  end
45
- E["types.ts"]
162
+ K["types.ts"]
46
163
  end
47
164
  1-->2
48
165
  2-->4
49
166
  2-->6
50
- 2-->D
167
+ 2-->F
51
168
  6-->7
52
169
  6-->4
53
170
  6-->8
54
171
  6-->A
172
+ 6-->C
55
173
  8-->9
56
- A-->4
57
174
  A-->B
58
- B-->4
59
- D-->7
175
+ C-->4
176
+ C-->D
177
+ C-->9
178
+ D-->4
179
+ F-->7
180
+ H-->2
181
+ H-->I
182
+ J-->2
183
+ J-->H
60
184
 
61
- class 0,3,5,C subgraphs;
185
+ class 0,3,5,E,G subgraphs;
62
186
  classDef subgraphs fill-opacity:0.1, fill:#888, color:#888, stroke:#888;
63
187
  ```
package/dist/index.cjs CHANGED
@@ -9158,6 +9158,37 @@ class SVGRenderer {
9158
9158
  }
9159
9159
  this.#svg.push('</g>');
9160
9160
  }
9161
+ drawRasterTiles(tiles, style) {
9162
+ if (tiles.length === 0)
9163
+ return;
9164
+ if (style.opacity <= 0)
9165
+ return;
9166
+ const filters = [];
9167
+ if (style.hueRotate !== 0)
9168
+ filters.push(`hue-rotate(${String(style.hueRotate)}deg)`);
9169
+ if (style.saturation !== 0)
9170
+ filters.push(`saturate(${String(style.saturation + 1)})`);
9171
+ if (style.contrast !== 0)
9172
+ filters.push(`contrast(${String(style.contrast + 1)})`);
9173
+ if (style.brightnessMin !== 0 || style.brightnessMax !== 1) {
9174
+ const brightness = (style.brightnessMin + style.brightnessMax) / 2;
9175
+ filters.push(`brightness(${String(brightness)})`);
9176
+ }
9177
+ let gAttrs = `opacity="${String(style.opacity)}"`;
9178
+ if (filters.length > 0)
9179
+ gAttrs += ` filter="${filters.join(' ')}"`;
9180
+ this.#svg.push(`<g ${gAttrs}>`);
9181
+ const pixelated = style.resampling === 'nearest';
9182
+ for (const tile of tiles) {
9183
+ const overlap = Math.min(tile.width, tile.height) / 10000; // slight overlap to prevent sub-pixel gaps between tiles
9184
+ const s = this.#scale;
9185
+ 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}"`;
9186
+ if (pixelated)
9187
+ attrs += ' style="image-rendering:pixelated"';
9188
+ this.#svg.push(`<image ${attrs} />`);
9189
+ }
9190
+ this.#svg.push('</g>');
9191
+ }
9161
9192
  getString() {
9162
9193
  return [
9163
9194
  `<svg viewBox="0 0 ${String(this.width)} ${String(this.height)}" width="${String(this.width)}" height="${String(this.height)}" xmlns="http://www.w3.org/2000/svg" style="background-color:${this.#backgroundColor.hex}">`,
@@ -14049,6 +14080,45 @@ function mergePolygons(featureList) {
14049
14080
  return mergedFeatures;
14050
14081
  }
14051
14082
 
14083
+ function calculateTileGrid(width, height, center, zoom, maxzoom) {
14084
+ const zoomLevel = Math.min(Math.floor(zoom), maxzoom ?? Infinity);
14085
+ const tileCenterCoordinate = center.getProject2Pixel().scale(2 ** zoomLevel);
14086
+ const tileSize = 2 ** (zoom - zoomLevel + 9); // 512 (2^9) is the standard tile size
14087
+ const tileCols = width / tileSize;
14088
+ const tileRows = height / tileSize;
14089
+ const tileMinX = Math.floor(tileCenterCoordinate.x - tileCols / 2);
14090
+ const tileMinY = Math.floor(tileCenterCoordinate.y - tileRows / 2);
14091
+ const tileMaxX = Math.floor(tileCenterCoordinate.x + tileCols / 2);
14092
+ const tileMaxY = Math.floor(tileCenterCoordinate.y + tileRows / 2);
14093
+ const tiles = [];
14094
+ for (let x = tileMinX; x <= tileMaxX; x++) {
14095
+ for (let y = tileMinY; y <= tileMaxY; y++) {
14096
+ tiles.push({
14097
+ x,
14098
+ y,
14099
+ offsetX: width / 2 + (x - tileCenterCoordinate.x) * tileSize,
14100
+ offsetY: height / 2 + (y - tileCenterCoordinate.y) * tileSize,
14101
+ });
14102
+ }
14103
+ }
14104
+ return { zoomLevel, tileSize, tiles };
14105
+ }
14106
+ async function getTile(url, z, x, y) {
14107
+ const tileUrl = url.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y));
14108
+ try {
14109
+ const response = await fetch(tileUrl);
14110
+ if (!response.ok)
14111
+ return null;
14112
+ const buffer = await response.arrayBuffer();
14113
+ const contentType = response.headers.get('content-type') ?? 'application/octet-stream';
14114
+ return { buffer, contentType };
14115
+ }
14116
+ catch {
14117
+ console.warn(`Failed to load tile: ${tileUrl}`);
14118
+ return null;
14119
+ }
14120
+ }
14121
+
14052
14122
  /**
14053
14123
  * A standalone point geometry with useful accessor, comparison, and
14054
14124
  * modification methods.
@@ -15594,32 +15664,22 @@ async function getLayerFeatures(job) {
15594
15664
  const { zoom, center } = job.view;
15595
15665
  const { sources } = job.style;
15596
15666
  const source = sources['versatiles-shortbread'];
15597
- if (source?.type !== 'vector' || !source.tiles) {
15667
+ if (!source)
15668
+ return new Map();
15669
+ if (source.type !== 'vector' || !source.tiles) {
15670
+ console.error('Invalid source configuration. Expected a vector source with tile URLs.');
15671
+ console.error('Source config:', source);
15598
15672
  throw Error('Invalid source');
15599
15673
  }
15600
15674
  const sourceUrl = source.tiles[0];
15601
- const zoomLevel = Math.floor(zoom);
15602
- const tileCenterCoordinate = center.getProject2Pixel().scale(2 ** zoomLevel);
15603
- const tileSize = 2 ** (zoom - zoomLevel + 9); // 512 (2^9) is the standard tile size
15604
- const tileCols = width / tileSize;
15605
- const tileRows = height / tileSize;
15606
- const tileMinX = Math.floor(tileCenterCoordinate.x - tileCols / 2);
15607
- const tileMinY = Math.floor(tileCenterCoordinate.y - tileRows / 2);
15608
- const tileMaxX = Math.floor(tileCenterCoordinate.x + tileCols / 2);
15609
- const tileMaxY = Math.floor(tileCenterCoordinate.y + tileRows / 2);
15610
- const tileCoordinates = [];
15611
- for (let x = tileMinX; x <= tileMaxX; x++) {
15612
- for (let y = tileMinY; y <= tileMaxY; y++) {
15613
- tileCoordinates.push({ x, y });
15614
- }
15615
- }
15675
+ const { zoomLevel, tileSize, tiles: tileCoordinates, } = calculateTileGrid(width, height, center, zoom, source.maxzoom);
15616
15676
  const layerFeatures = new Map();
15617
- await Promise.all(tileCoordinates.map(async ({ x, y }) => {
15618
- const offset = new Point2D(width / 2 + (x - tileCenterCoordinate.x) * tileSize, height / 2 + (y - tileCenterCoordinate.y) * tileSize);
15619
- const buffer = await getTile(sourceUrl, zoomLevel, x, y);
15620
- if (!buffer)
15677
+ await Promise.all(tileCoordinates.map(async ({ x, y, offsetX, offsetY }) => {
15678
+ const offset = new Point2D(offsetX, offsetY);
15679
+ const tile = await getTile(sourceUrl, zoomLevel, x, y);
15680
+ if (!tile)
15621
15681
  return;
15622
- const vectorTile = new VectorTile(new Pbf(buffer));
15682
+ const vectorTile = new VectorTile(new Pbf(tile.buffer));
15623
15683
  for (const [name, layer] of Object.entries(vectorTile.layers)) {
15624
15684
  let features = layerFeatures.get(name);
15625
15685
  if (!features) {
@@ -15669,18 +15729,33 @@ async function getLayerFeatures(job) {
15669
15729
  }
15670
15730
  return layerFeatures;
15671
15731
  }
15672
- async function getTile(url, z, x, y) {
15673
- const tileUrl = url.replace('{z}', String(z)).replace('{x}', String(x)).replace('{y}', String(y));
15674
- try {
15675
- const response = await fetch(tileUrl);
15676
- if (!response.ok)
15677
- return null;
15678
- return await response.arrayBuffer();
15679
- }
15680
- catch {
15681
- console.warn(`Failed to load tile: ${tileUrl}`);
15682
- return null;
15732
+
15733
+ async function getRasterTiles(job, sourceName) {
15734
+ const { width, height } = job.renderer;
15735
+ const { zoom, center } = job.view;
15736
+ const source = job.style.sources[sourceName];
15737
+ if (source?.type !== 'raster' || !source.tiles) {
15738
+ throw Error('Invalid raster source: ' + sourceName);
15683
15739
  }
15740
+ const sourceUrl = source.tiles[0];
15741
+ const { zoomLevel, tileSize, tiles } = calculateTileGrid(width, height, center, zoom, source.maxzoom);
15742
+ const rasterTiles = await Promise.all(tiles.map(async ({ x, y, offsetX, offsetY }) => {
15743
+ const tile = await getTile(sourceUrl, zoomLevel, x, y);
15744
+ if (!tile)
15745
+ return null;
15746
+ const base64 = typeof Buffer !== 'undefined'
15747
+ ? Buffer.from(tile.buffer).toString('base64')
15748
+ : btoa(String.fromCharCode(...new Uint8Array(tile.buffer)));
15749
+ const dataUri = `data:${tile.contentType};base64,${base64}`;
15750
+ return {
15751
+ x: offsetX,
15752
+ y: offsetY,
15753
+ width: tileSize,
15754
+ height: tileSize,
15755
+ dataUri,
15756
+ };
15757
+ }));
15758
+ return rasterTiles.filter((tile) => tile !== null);
15684
15759
  }
15685
15760
 
15686
15761
  /**
@@ -15818,10 +15893,25 @@ async function render(job) {
15818
15893
  const layerStyles = getLayerStyles(job.style.layers);
15819
15894
  const availableImages = [];
15820
15895
  const featureState = {};
15821
- layerStyles.forEach((layerStyle) => {
15896
+ for (const layerStyle of layerStyles) {
15822
15897
  if (layerStyle.isHidden(zoom))
15823
- return;
15898
+ continue;
15824
15899
  layerStyle.recalculate({ zoom }, availableImages);
15900
+ function getStyleValue(obj, key, feature) {
15901
+ const getter = obj;
15902
+ const value = getter.get(key);
15903
+ if (typeof value === 'object' && value !== null && 'evaluate' in value) {
15904
+ const evaluatable = value;
15905
+ return evaluatable.evaluate(feature ?? {}, featureState, undefined, availableImages);
15906
+ }
15907
+ return value;
15908
+ }
15909
+ function getPaint(key, feature) {
15910
+ return getStyleValue(layerStyle.paint, key, feature);
15911
+ }
15912
+ function getLayout(key, feature) {
15913
+ return getStyleValue(layerStyle.layout, key, feature);
15914
+ }
15825
15915
  switch (layerStyle.type) {
15826
15916
  case 'background':
15827
15917
  {
@@ -15830,16 +15920,16 @@ async function render(job) {
15830
15920
  opacity: getPaint('background-opacity'),
15831
15921
  });
15832
15922
  }
15833
- return;
15923
+ continue;
15834
15924
  case 'fill':
15835
15925
  {
15836
15926
  const polygons = layerFeatures.get(layerStyle.sourceLayer)?.polygons;
15837
15927
  if (!polygons || polygons.length === 0)
15838
- return;
15928
+ continue;
15839
15929
  const filter = featureFilter(layerStyle.filter);
15840
15930
  const polygonFeatures = polygons.filter((feature) => filter.filter({ zoom }, feature));
15841
15931
  if (polygonFeatures.length === 0)
15842
- return;
15932
+ continue;
15843
15933
  renderer.drawPolygons(polygonFeatures.map((feature) => [
15844
15934
  feature,
15845
15935
  {
@@ -15848,16 +15938,16 @@ async function render(job) {
15848
15938
  },
15849
15939
  ]), getPaint('fill-opacity', polygonFeatures[0]));
15850
15940
  }
15851
- return;
15941
+ continue;
15852
15942
  case 'line':
15853
15943
  {
15854
15944
  const lineStrings = layerFeatures.get(layerStyle.sourceLayer)?.linestrings;
15855
15945
  if (!lineStrings || lineStrings.length === 0)
15856
- return;
15946
+ continue;
15857
15947
  const filter = featureFilter(layerStyle.filter);
15858
15948
  const lineStringFeatures = lineStrings.filter((feature) => filter.filter({ zoom }, feature));
15859
15949
  if (lineStringFeatures.length === 0)
15860
- return;
15950
+ continue;
15861
15951
  renderer.drawLineStrings(lineStringFeatures.map((feature) => [
15862
15952
  feature,
15863
15953
  {
@@ -15875,34 +15965,32 @@ async function render(job) {
15875
15965
  },
15876
15966
  ]), getPaint('line-opacity', lineStringFeatures[0]));
15877
15967
  }
15878
- return;
15968
+ continue;
15969
+ case 'raster':
15970
+ {
15971
+ const tiles = await getRasterTiles(job, layerStyle.source);
15972
+ renderer.drawRasterTiles(tiles, {
15973
+ opacity: getPaint('raster-opacity'),
15974
+ hueRotate: getPaint('raster-hue-rotate'),
15975
+ brightnessMin: getPaint('raster-brightness-min'),
15976
+ brightnessMax: getPaint('raster-brightness-max'),
15977
+ saturation: getPaint('raster-saturation'),
15978
+ contrast: getPaint('raster-contrast'),
15979
+ resampling: getPaint('raster-resampling'),
15980
+ });
15981
+ }
15982
+ continue;
15879
15983
  case 'circle':
15880
15984
  case 'color-relief':
15881
15985
  case 'fill-extrusion':
15882
15986
  case 'heatmap':
15883
15987
  case 'hillshade':
15884
- case 'raster':
15885
15988
  case 'symbol':
15886
- return;
15989
+ continue;
15887
15990
  default:
15888
15991
  throw Error('layerStyle.type: ' + String(layerStyle.type));
15889
15992
  }
15890
- function getStyleValue(obj, key, feature) {
15891
- const getter = obj;
15892
- const value = getter.get(key);
15893
- if (typeof value === 'object' && value !== null && 'evaluate' in value) {
15894
- const evaluatable = value;
15895
- return evaluatable.evaluate(feature ?? {}, featureState, undefined, availableImages);
15896
- }
15897
- return value;
15898
- }
15899
- function getPaint(key, feature) {
15900
- return getStyleValue(layerStyle.paint, key, feature);
15901
- }
15902
- function getLayout(key, feature) {
15903
- return getStyleValue(layerStyle.layout, key, feature);
15904
- }
15905
- });
15993
+ }
15906
15994
  }
15907
15995
 
15908
15996
  async function renderToSVG(options) {