tileserver-gl 5.7.0-pre.1 → 5.7.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.
@@ -7,11 +7,9 @@
7
7
  {{#use_maplibre}}
8
8
  <link rel="stylesheet" type="text/css" href="{{public_url}}maplibre-gl.css{{&key_query}}" />
9
9
  <link rel="stylesheet" type="text/css" href="{{public_url}}maplibre-gl-inspect.css{{&key_query}}" />
10
- <script src="{{public_url}}maplibre-gl.js{{&key_query}}"></script>
11
- <script src="{{public_url}}maplibre-gl-inspect.js{{&key_query}}"></script>
12
- {{^is_light}}
13
- <script src="{{public_url}}elevation-control.js{{&key_query}}"></script>
14
- {{/is_light}}
10
+ <script type="importmap">
11
+ { "imports": { "maplibre-gl": "{{public_url}}maplibre-gl.mjs{{&key_query}}" } }
12
+ </script>
15
13
  <style>
16
14
  body {background:#fff;color:#333;font-family:Arial, sans-serif;}
17
15
  {{^is_terrain}}
@@ -58,7 +56,15 @@
58
56
  <div id="layerList"></div>
59
57
  <pre id="propertyList"></pre>
60
58
  {{/is_terrain}}
61
- <script>
59
+ <script type="module">
60
+ import * as maplibregl from 'maplibre-gl';
61
+ {{^is_terrain}}
62
+ import MaplibreInspect from '{{public_url}}maplibre-gl-inspect.mjs{{&key_query}}';
63
+ {{/is_terrain}}
64
+ {{^is_light}}
65
+ import ElevationInfoControl from '{{public_url}}elevation-control.js{{&key_query}}';
66
+ {{/is_light}}
67
+
62
68
  var keyMatch = location.search.match(/[\?\&]key=([^&]+)/i);
63
69
  var keyParam = keyMatch ? '?key=' + keyMatch[1] : '';
64
70
 
@@ -7,8 +7,9 @@
7
7
  <link rel="stylesheet" type="text/css" href="{{public_url}}maplibre-gl.css{{&key_query}}" />
8
8
  <link rel="stylesheet" type="text/css" href="{{public_url}}maplibre-gl-inspect.css{{&key_query}}" />
9
9
  <link rel="stylesheet" type="text/css" href="{{public_url}}leaflet.css{{&key_query}}" />
10
- <script src="{{public_url}}maplibre-gl.js{{&key_query}}"></script>
11
- <script src="{{public_url}}maplibre-gl-inspect.js{{&key_query}}"></script>
10
+ <script type="importmap">
11
+ { "imports": { "maplibre-gl": "{{public_url}}maplibre-gl.mjs{{&key_query}}" } }
12
+ </script>
12
13
  <script src="{{public_url}}leaflet.js{{&key_query}}"></script>
13
14
  <script src="{{public_url}}leaflet-hash.js{{&key_query}}"></script>
14
15
  <style>
@@ -31,12 +32,15 @@
31
32
  <body>
32
33
  <h1 style="display:none;">{{name}}</h1>
33
34
  <div id='map'></div>
34
- <script>
35
+ <script type="module">
36
+ import * as maplibregl from 'maplibre-gl';
37
+ import MaplibreInspect from '{{public_url}}maplibre-gl-inspect.mjs{{&key_query}}';
38
+
35
39
  function isWebglSupported() {
36
- if (window.WebGLRenderingContext) {
40
+ if (window.WebGL2RenderingContext) {
37
41
  const canvas = document.createElement('canvas');
38
42
  try {
39
- const context = canvas.getContext('webgl2') || canvas.getContext('webgl');
43
+ const context = canvas.getContext('webgl2');
40
44
  if (context && typeof context.getParameter == 'function') {
41
45
  return true;
42
46
  }
@@ -45,7 +49,7 @@
45
49
  }
46
50
  return false;
47
51
  }
48
- // WebGL not supported
52
+ // WebGL2 not supported (required by maplibre-gl v6+)
49
53
  return false;
50
54
  }
51
55
  var q = (location.search || '').substr(1).split('&');
@@ -107,7 +111,7 @@
107
111
  req.open('GET', url, true);
108
112
  req.onload = function() {
109
113
  var jsonResponse = JSON.parse(req.responseText);
110
- for (key in jsonResponse) {
114
+ for (var key in jsonResponse) {
111
115
  var keyl = key.toLowerCase();
112
116
  switch(keyl) {
113
117
  case "tiles":
@@ -125,7 +129,7 @@
125
129
  }
126
130
  }
127
131
 
128
- for (tile_url in tile_urls) {
132
+ for (var tile_url in tile_urls) {
129
133
  var leafletUrl = tile_urls[tile_url];
130
134
  {{#leaflet_retina}}
131
135
  // Inject {r} for Leaflet retina support without modifying TileJSON directly.
@@ -18,3 +18,21 @@ export function getMapLibreRenderZoom(zoom, logicalWidth, mode) {
18
18
 
19
19
  throw new Error(`Unsupported render mode: ${mode}`);
20
20
  }
21
+
22
+ /**
23
+ * Calculates the TileServer zoom MapLibre Native effectively uses for a
24
+ * static-map overlay. Horizontal world copies can wrap, but the world must
25
+ * fill the viewport vertically. The 256px zoom-zero render has a dedicated
26
+ * resize path in serve_rendered.
27
+ * @param {number} zoom Requested TileServer zoom level.
28
+ * @param {number} logicalWidth Unscaled request width in pixels.
29
+ * @param {number} logicalHeight Unscaled request height in pixels.
30
+ * @returns {number} Effective TileServer zoom level.
31
+ */
32
+ export function getStaticOverlayZoom(zoom, logicalWidth, logicalHeight) {
33
+ const minimumViewportZoom = Math.log2(logicalHeight / 256);
34
+ if (zoom === 0 && logicalWidth === 256) {
35
+ return Math.max(0, minimumViewportZoom);
36
+ }
37
+ return Math.max(1, zoom, minimumViewportZoom);
38
+ }
package/src/serve_data.js CHANGED
@@ -133,9 +133,13 @@ export const serve_data = {
133
133
 
134
134
  let data = fetchTile.data;
135
135
  let headers = fetchTile.headers;
136
- let isGzipped = data.slice(0, 2).indexOf(Buffer.from([0x1f, 0x8b])) === 0;
137
136
 
138
- if (isGzipped) {
137
+ const isGzipped =
138
+ data.slice(0, 2).indexOf(Buffer.from([0x1f, 0x8b])) === 0;
139
+ const serveGzipped =
140
+ isGzipped && format === 'pbf' && !options.dataDecoratorFunc;
141
+
142
+ if (isGzipped && !serveGzipped) {
139
143
  data = await gunzipP(data);
140
144
  }
141
145
 
@@ -179,7 +183,9 @@ export const serve_data = {
179
183
  headers['Content-Encoding'] = 'gzip';
180
184
  res.set(headers);
181
185
 
182
- data = await gzipP(data);
186
+ if (!serveGzipped) {
187
+ data = await gzipP(data);
188
+ }
183
189
 
184
190
  if (metricsModule) {
185
191
  metricsModule.tilesServedTotal.inc({
@@ -40,7 +40,7 @@ import {
40
40
  } from './utils.js';
41
41
  import { openPMtiles, getPMtilesInfo } from './pmtiles_adapter.js';
42
42
  import { renderOverlay, renderWatermark, renderAttribution } from './render.js';
43
- import { getMapLibreRenderZoom } from './render_mode.js';
43
+ import { getMapLibreRenderZoom, getStaticOverlayZoom } from './render_mode.js';
44
44
  import fsp from 'node:fs/promises';
45
45
  import { existsP, gunzipP } from './promises.js';
46
46
  import { openMbTilesWrapper } from './mbtiles_wrapper.js';
@@ -1069,9 +1069,10 @@ async function handleStaticRequest(
1069
1069
 
1070
1070
  const paths = extractPathsFromQuery(req.query, transformer);
1071
1071
  const markers = extractMarkersFromQuery(req.query, options, transformer);
1072
+ const overlayZoom = getStaticOverlayZoom(z, parsedWidth, parsedHeight);
1072
1073
  // prettier-ignore
1073
1074
  const overlay = await renderOverlay(
1074
- z, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, paths, markers, req.query,
1075
+ overlayZoom, x, y, bearing, pitch, parsedWidth, parsedHeight, scale, paths, markers, req.query,
1075
1076
  );
1076
1077
 
1077
1078
  // prettier-ignore
@@ -1,5 +1,8 @@
1
1
  import assert from 'node:assert/strict';
2
- import { getMapLibreRenderZoom } from '../src/render_mode.js';
2
+ import {
3
+ getMapLibreRenderZoom,
4
+ getStaticOverlayZoom,
5
+ } from '../src/render_mode.js';
3
6
 
4
7
  describe('MapLibre render zoom', function () {
5
8
  it('uses the overlay-aligned zoom for a 512px static map', function () {
@@ -25,3 +28,21 @@ describe('MapLibre render zoom', function () {
25
28
  );
26
29
  });
27
30
  });
31
+
32
+ describe('Static overlay zoom', function () {
33
+ it('raises the zoom when the world would not fill the viewport height', function () {
34
+ assert.equal(getStaticOverlayZoom(1, 600, 600), Math.log2(600 / 256));
35
+ });
36
+
37
+ it('does not raise the zoom for a wide but short viewport', function () {
38
+ assert.equal(getStaticOverlayZoom(1, 1000, 500), 1);
39
+ });
40
+
41
+ it('preserves a requested zoom above the viewport minimum', function () {
42
+ assert.equal(getStaticOverlayZoom(12, 1000, 1000), 12);
43
+ });
44
+
45
+ it('preserves the dedicated 256px zoom-zero render path', function () {
46
+ assert.equal(getStaticOverlayZoom(0, 256, 256), 0);
47
+ });
48
+ });
package/test/static.js CHANGED
@@ -70,6 +70,26 @@ describe('Static endpoints', function () {
70
70
  testStatic(prefix, '8,40,2@10/300x150', 'png', 200, 3);
71
71
  testStatic(prefix, '8,40,2@10.3,20.4/300x300', 'png', 200);
72
72
  testStatic(prefix, '0,0,2@390,120/300x300', 'png', 200);
73
+
74
+ it('keeps overlays aligned when a large viewport raises the effective zoom', async function () {
75
+ const marker = '8.5432,47.3669|marker-icon.png|scale:0.1';
76
+ const requestedZoom = await supertest(app)
77
+ .get(`/styles/${prefix}/static/8.5432,47.3669,1/600x600.png`)
78
+ .query({ marker })
79
+ .expect(200);
80
+ const minimumZoom = Math.log2(600 / 256);
81
+ const effectiveZoom = await supertest(app)
82
+ .get(
83
+ `/styles/${prefix}/static/8.5432,47.3669,${minimumZoom}/600x600.png`,
84
+ )
85
+ .query({ marker })
86
+ .expect(200);
87
+
88
+ assert.ok(
89
+ requestedZoom.body.equals(effectiveZoom.body),
90
+ 'expected requested and effective zoom renders to match',
91
+ );
92
+ });
73
93
  });
74
94
  });
75
95
 
@@ -69,6 +69,11 @@ const tests = [
69
69
  // Test multiple markers with scale and offset options
70
70
  url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?marker=8.531,47.38|marker-icon.png|scale:0.8&marker=8.545,47.375|marker-icon-2x.png|offset:5,-10',
71
71
  },
72
+ {
73
+ name: 'static-marker-low-zoom-large-viewport',
74
+ // Test marker alignment when the viewport raises the effective low zoom
75
+ url: '/styles/test-style/static/8.5432,47.3669,1/1000x1000.png?marker=8.5432,47.3669|marker-icon.png|scale:0.8|offset:0,30',
76
+ },
72
77
  {
73
78
  name: 'static-bbox',
74
79
  // Test area-based map rendering using a bounding box (bbox)
@@ -0,0 +1,145 @@
1
+ import { createHook } from 'node:async_hooks';
2
+ import { once } from 'node:events';
3
+ import http from 'node:http';
4
+ import { gzipSync, gunzipSync } from 'node:zlib';
5
+ import { expect } from 'chai';
6
+ import { PbfWriter } from 'pbf';
7
+ import { serve_data } from '../src/serve_data.js';
8
+
9
+ /**
10
+ * Creates an MVT with one point at the center of the tile.
11
+ * @param {number} id Feature ID.
12
+ * @returns {Buffer} Encoded vector tile.
13
+ */
14
+ function createTile(id) {
15
+ const feature = new PbfWriter();
16
+ feature.writeVarintField(1, id);
17
+ feature.writeVarintField(3, 1);
18
+ // MoveTo one point at (2048, 2048), zigzag-encoded for a 4096-unit tile.
19
+ feature.writePackedVarint(4, [9, 4096, 4096]);
20
+ const layer = new PbfWriter();
21
+ layer.writeStringField(1, 'test');
22
+ layer.writeBytesField(2, feature.finish());
23
+ layer.writeVarintField(5, 4096);
24
+ layer.writeVarintField(15, 2);
25
+ const tile = new PbfWriter();
26
+ tile.writeBytesField(3, layer.finish());
27
+ return Buffer.from(tile.finish());
28
+ }
29
+
30
+ const tile = createTile(1);
31
+ const gzippedTile = gzipSync(tile, { level: 0 });
32
+
33
+ /**
34
+ * Requests a tile without decompressing the HTTP response.
35
+ * @param {Buffer} data Bytes returned by the MBTiles source.
36
+ * @param {object} [options] Tile server options.
37
+ * @param {string} [format] Requested output format.
38
+ * @returns {Promise<object>} Raw response body and headers.
39
+ */
40
+
41
+ async function requestTile(data, options = {}, format = 'pbf') {
42
+ const repo = {
43
+ test: {
44
+ sourceType: 'mbtiles',
45
+ source: { getTile: (_z, _x, _y, callback) => callback(null, data, {}) },
46
+ tileJSON: { format: 'pbf', minzoom: 0, maxzoom: 0 },
47
+ },
48
+ };
49
+
50
+ const app = serve_data.init(options, repo, {});
51
+ // Use a free loopback port to inspect the raw gzip response bytes.
52
+ const server = app.listen(0, '127.0.0.1');
53
+
54
+ try {
55
+ await once(server, 'listening');
56
+ return await new Promise((resolve, reject) => {
57
+ const request = http.get(
58
+ {
59
+ hostname: '127.0.0.1',
60
+ port: server.address().port,
61
+ path: `/test/0/0/0.${format}`,
62
+ agent: false,
63
+ },
64
+ (res) => {
65
+ const chunks = [];
66
+ res.on('data', (chunk) => chunks.push(chunk));
67
+ res.on('error', reject);
68
+ res.on('end', () =>
69
+ resolve({
70
+ status: res.statusCode,
71
+ headers: res.headers,
72
+ body: Buffer.concat(chunks),
73
+ }),
74
+ );
75
+ },
76
+ );
77
+ request.on('error', reject);
78
+ });
79
+ } finally {
80
+ if (server.listening) {
81
+ await new Promise((resolve) => server.close(resolve));
82
+ }
83
+ }
84
+ }
85
+
86
+ describe('Vector tile compression', function () {
87
+ it('returns an existing gzip buffer without decompressing or recompressing it', async function () {
88
+ let zlibOperations = 0;
89
+ const hook = createHook({
90
+ init(_asyncId, type) {
91
+ if (type === 'ZLIB') zlibOperations += 1;
92
+ },
93
+ });
94
+ hook.enable();
95
+ let response;
96
+ try {
97
+ response = await requestTile(gzippedTile);
98
+ } finally {
99
+ hook.disable();
100
+ }
101
+
102
+ expect(response.status).to.equal(200);
103
+ expect(response.headers['content-type']).to.equal('application/x-protobuf');
104
+ expect(response.headers['content-encoding']).to.equal('gzip');
105
+ expect(zlibOperations).to.equal(0);
106
+ expect(response.body).to.deep.equal(gzippedTile);
107
+ });
108
+
109
+ it('still decompresses, decorates and recompresses gzip tiles', async function () {
110
+ const decoratedTile = createTile(2);
111
+ const response = await requestTile(gzippedTile, {
112
+ dataDecoratorFunc: async (id, type, data) => {
113
+ expect([id, type]).to.deep.equal(['test', 'data']);
114
+ expect(data).to.deep.equal(tile);
115
+ return decoratedTile;
116
+ },
117
+ });
118
+
119
+ expect(response.status).to.equal(200);
120
+ expect(response.headers['content-encoding']).to.equal('gzip');
121
+ expect(gunzipSync(response.body)).to.deep.equal(decoratedTile);
122
+ });
123
+
124
+ it('still compresses uncompressed PBF tiles', async function () {
125
+ const response = await requestTile(tile);
126
+
127
+ expect(response.status).to.equal(200);
128
+ expect(response.headers['content-encoding']).to.equal('gzip');
129
+ expect(gunzipSync(response.body)).to.deep.equal(tile);
130
+ });
131
+
132
+ it('still converts gzip PBF tiles to GeoJSON', async function () {
133
+ const response = await requestTile(gzippedTile, {}, 'geojson');
134
+
135
+ expect(response.status).to.equal(200);
136
+ expect(response.headers['content-type']).to.match(/^application\/json/);
137
+ expect(response.headers['content-encoding']).to.equal('gzip');
138
+
139
+ const geojson = JSON.parse(gunzipSync(response.body).toString());
140
+
141
+ expect(geojson.type).to.equal('FeatureCollection');
142
+ expect(geojson.features).to.have.lengthOf(1);
143
+ expect(geojson.features[0].id).to.equal(1);
144
+ });
145
+ });