tileserver-gl-light 5.5.0-pre.1 → 5.5.0-pre.12
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/CHANGELOG.md +51 -34
- package/docs/config.rst +52 -11
- package/docs/endpoints.rst +12 -2
- package/docs/installation.rst +6 -6
- package/docs/usage.rst +26 -0
- package/package.json +14 -14
- package/public/resources/elevation-control.js +92 -21
- package/public/resources/maplibre-gl-inspect.js +2827 -2770
- package/public/resources/maplibre-gl-inspect.js.map +1 -1
- package/public/resources/maplibre-gl.css +1 -1
- package/public/resources/maplibre-gl.js +4 -4
- package/public/resources/maplibre-gl.js.map +1 -1
- package/src/main.js +31 -20
- package/src/pmtiles_adapter.js +104 -45
- package/src/promises.js +1 -1
- package/src/render.js +270 -93
- package/src/serve_data.js +266 -90
- package/src/serve_font.js +2 -2
- package/src/serve_light.js +2 -4
- package/src/serve_rendered.js +445 -236
- package/src/serve_style.js +29 -8
- package/src/server.js +115 -60
- package/src/utils.js +47 -20
- package/test/elevation.js +513 -0
- package/test/fixtures/visual/encoded-path-auto.png +0 -0
- package/test/fixtures/visual/linecap-linejoin-bevel-square.png +0 -0
- package/test/fixtures/visual/linecap-linejoin-round-round.png +0 -0
- package/test/fixtures/visual/path-auto.png +0 -0
- package/test/fixtures/visual/static-bbox.png +0 -0
- package/test/fixtures/visual/static-bearing-pitch.png +0 -0
- package/test/fixtures/visual/static-bearing.png +0 -0
- package/test/fixtures/visual/static-border-global.png +0 -0
- package/test/fixtures/visual/static-lat-lng.png +0 -0
- package/test/fixtures/visual/static-markers.png +0 -0
- package/test/fixtures/visual/static-multiple-paths.png +0 -0
- package/test/fixtures/visual/static-path-border-isolated.png +0 -0
- package/test/fixtures/visual/static-path-border-stroke.png +0 -0
- package/test/fixtures/visual/static-path-latlng.png +0 -0
- package/test/fixtures/visual/static-pixel-ratio-2x.png +0 -0
- package/test/static_images.js +241 -0
- package/test/tiles_data.js +1 -1
- package/test/utils/create_terrain_mbtiles.js +124 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// test/static_images.js
|
|
2
|
+
import { describe, it } from 'mocha';
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import supertest from 'supertest';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
import pixelmatch from 'pixelmatch';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
|
|
13
|
+
const FIXTURES_DIR = path.join(__dirname, 'fixtures', 'visual');
|
|
14
|
+
const THRESHOLD = 0.1;
|
|
15
|
+
const MAX_DIFF_PIXELS = 100;
|
|
16
|
+
|
|
17
|
+
// Check for the environment variable to conditionally generate fixtures
|
|
18
|
+
const shouldGenerateFixtures = process.env.GENERATE_FIXTURES === 'true';
|
|
19
|
+
|
|
20
|
+
// --- Test Definitions ---
|
|
21
|
+
const tests = [
|
|
22
|
+
{
|
|
23
|
+
name: 'static-lat-lng',
|
|
24
|
+
// Test default center format (lng,lat,zoom)
|
|
25
|
+
url: '/styles/test-style/static/8.5375,47.379,12/400x300.png',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'static-bearing',
|
|
29
|
+
// Test map bearing (rotation) at 180 degrees
|
|
30
|
+
url: '/styles/test-style/static/8.5375,47.379,12@180/400x300.png',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'static-bearing-pitch',
|
|
34
|
+
// Test map bearing and pitch (3D tilt)
|
|
35
|
+
url: '/styles/test-style/static/8.5375,47.379,12@15,80/400x300.png',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: 'static-pixel-ratio-2x',
|
|
39
|
+
// Test high-DPI rendering using @2x scale
|
|
40
|
+
url: '/styles/test-style/static/8.5375,47.379,11/200x150@2x.png',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'path-auto',
|
|
44
|
+
// Test path rendering with simple coordinates and auto-centering
|
|
45
|
+
url: '/styles/test-style/static/auto/400x300.png?fill=%23ff000080&path=8.53180,47.38713|8.53841,47.38248|8.53320,47.37457',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'encoded-path-auto',
|
|
49
|
+
// Test path rendering using encoded polyline and auto-centering
|
|
50
|
+
url: '/styles/test-style/static/auto/400x300.png?stroke=red&width=5&path=enc:wwg`Hyu}r@fNgn@hKyh@rR{ZlP{YrJmM`PJhNbH`P`VjUbNfJ|LzM~TtLnKxQZ',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'linecap-linejoin-round-round',
|
|
54
|
+
// Test custom line styling: round linejoin and round linecap
|
|
55
|
+
url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?width=30&linejoin=round&linecap=round&path=enc:uhd`Hqk_s@kiA}nAnfAqpA',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'linecap-linejoin-bevel-square',
|
|
59
|
+
// Test custom line styling: bevel linejoin and square linecap
|
|
60
|
+
url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?width=30&linejoin=bevel&linecap=square&path=enc:uhd`Hqk_s@kiA}nAnfAqpA',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'static-markers',
|
|
64
|
+
// Test multiple markers with scale and offset options
|
|
65
|
+
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',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'static-bbox',
|
|
69
|
+
// Test area-based map rendering using a bounding box (bbox)
|
|
70
|
+
url: '/styles/test-style/static/8.5,47.35,8.6,47.4/400x300.png',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'static-multiple-paths',
|
|
74
|
+
// Test rendering of multiple, individually styled path parameters
|
|
75
|
+
url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?path=stroke:blue|width:8|fill:none|8.53,47.38|8.54,47.385&path=stroke:red|width:3|fill:yellow|8.53,47.37|8.54,47.375',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'static-path-latlng',
|
|
79
|
+
// Test path rendering when the 'latlng' parameter reverses coordinate order
|
|
80
|
+
url: '/styles/test-style/static/auto/400x300.png?latlng=true&path=47.38,8.53|47.385,8.54&fill=rgba(0,0,255,0.5)',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'static-path-border-stroke',
|
|
84
|
+
// Test path border/halo functionality (line stroke with border halo)
|
|
85
|
+
url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?path=stroke:yellow|width:10|border:black|borderwidth:2|8.53,47.37|8.54,47.38|8.53,47.39',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'static-path-border-isolated',
|
|
89
|
+
// Test path border/halo in isolation (only border, no stroke)
|
|
90
|
+
url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?path=border:black|borderwidth:10|8.53,47.37|8.54,47.38|8.53,47.39',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'static-border-global',
|
|
94
|
+
// Test border functionality using global query parameters (less common, but valid)
|
|
95
|
+
url: '/styles/test-style/static/8.5375,47.379,12/400x300.png?stroke=yellow&width=10&border=black&borderwidth=2&path=8.53,47.37|8.54,47.38|8.53,47.39',
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Loads an image buffer and extracts its raw pixel data.
|
|
101
|
+
* @param {Buffer} buffer The raw image data buffer (e.g., from an HTTP response).
|
|
102
|
+
* @returns {Promise<{data: Buffer, width: number, height: number}>} An object containing the raw RGBA pixel data, width, and height.
|
|
103
|
+
*/
|
|
104
|
+
async function loadImageData(buffer) {
|
|
105
|
+
const image = sharp(buffer);
|
|
106
|
+
const { width, height } = await image.metadata();
|
|
107
|
+
|
|
108
|
+
// Get raw RGBA pixel data
|
|
109
|
+
const data = await image.ensureAlpha().raw().toBuffer();
|
|
110
|
+
|
|
111
|
+
return { data, width, height };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fetches an image from the test server URL.
|
|
116
|
+
* @param {string} url The URL of the static image endpoint to fetch.
|
|
117
|
+
* @returns {Promise<Buffer>} A promise that resolves with the image buffer.
|
|
118
|
+
*/
|
|
119
|
+
async function fetchImage(url) {
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
supertest(global.app)
|
|
122
|
+
.get(url)
|
|
123
|
+
.expect(200)
|
|
124
|
+
.expect('Content-Type', /image\/png/)
|
|
125
|
+
.end((err, res) => {
|
|
126
|
+
if (err) {
|
|
127
|
+
reject(err);
|
|
128
|
+
} else {
|
|
129
|
+
resolve(res.body);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Compares two images (actual result vs. expected fixture) and counts the differing pixels.
|
|
137
|
+
* @param {Buffer} actualBuffer The buffer of the image rendered by the server.
|
|
138
|
+
* @param {string} expectedPath The file path to the expected fixture image.
|
|
139
|
+
* @returns {Promise<{numDiffPixels: number, diffBuffer: Buffer, width: number, height: number}>} Comparison results.
|
|
140
|
+
*/
|
|
141
|
+
async function compareImages(actualBuffer, expectedPath) {
|
|
142
|
+
const actual = await loadImageData(actualBuffer);
|
|
143
|
+
const expectedBuffer = fs.readFileSync(expectedPath);
|
|
144
|
+
const expected = await loadImageData(expectedBuffer);
|
|
145
|
+
|
|
146
|
+
if (actual.width !== expected.width || actual.height !== expected.height) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Image dimensions don't match: ${actual.width}x${actual.height} vs ${expected.width}x${expected.height}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const diffBuffer = Buffer.alloc(actual.width * actual.height * 4);
|
|
153
|
+
const numDiffPixels = pixelmatch(
|
|
154
|
+
actual.data,
|
|
155
|
+
expected.data,
|
|
156
|
+
diffBuffer,
|
|
157
|
+
actual.width,
|
|
158
|
+
actual.height,
|
|
159
|
+
{ threshold: THRESHOLD },
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
numDiffPixels,
|
|
164
|
+
diffBuffer,
|
|
165
|
+
width: actual.width,
|
|
166
|
+
height: actual.height,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Conditional definition: Only define this suite if the GENERATE_FIXTURES environment variable is true
|
|
171
|
+
if (shouldGenerateFixtures) {
|
|
172
|
+
describe('GENERATE Visual Fixtures', function () {
|
|
173
|
+
this.timeout(10000);
|
|
174
|
+
|
|
175
|
+
it('should generate all fixture images', async function () {
|
|
176
|
+
fs.mkdirSync(FIXTURES_DIR, { recursive: true });
|
|
177
|
+
console.log(`\nGenerating fixtures to ${FIXTURES_DIR}\n`);
|
|
178
|
+
|
|
179
|
+
for (const { name, url } of tests) {
|
|
180
|
+
try {
|
|
181
|
+
const actualBuffer = await fetchImage(url);
|
|
182
|
+
const fixturePath = path.join(FIXTURES_DIR, `${name}.png`);
|
|
183
|
+
fs.writeFileSync(fixturePath, actualBuffer);
|
|
184
|
+
console.log(
|
|
185
|
+
`✓ Generated: ${name}.png (${actualBuffer.length} bytes)`,
|
|
186
|
+
);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error(`❌ Failed to generate ${name}:`, error.message);
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
console.log(
|
|
194
|
+
`\n✓ Successfully generated ${tests.length} fixture images!\n`,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
describe('Static Image Visual Regression Tests', function () {
|
|
201
|
+
this.timeout(10000);
|
|
202
|
+
|
|
203
|
+
tests.forEach(({ name, url }) => {
|
|
204
|
+
it(`should match expected output: ${name}`, async function () {
|
|
205
|
+
const expectedPath = path.join(FIXTURES_DIR, `${name}.png`);
|
|
206
|
+
|
|
207
|
+
if (!fs.existsSync(expectedPath)) {
|
|
208
|
+
this.skip();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const actualBuffer = await fetchImage(url);
|
|
213
|
+
const { numDiffPixels, diffBuffer, width, height } = await compareImages(
|
|
214
|
+
actualBuffer,
|
|
215
|
+
expectedPath,
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (numDiffPixels > MAX_DIFF_PIXELS) {
|
|
219
|
+
const diffPath = path.join(FIXTURES_DIR, 'diffs', `${name}-diff.png`);
|
|
220
|
+
fs.mkdirSync(path.dirname(diffPath), { recursive: true });
|
|
221
|
+
|
|
222
|
+
await sharp(diffBuffer, {
|
|
223
|
+
raw: {
|
|
224
|
+
width,
|
|
225
|
+
height,
|
|
226
|
+
channels: 4,
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
.png()
|
|
230
|
+
.toFile(diffPath);
|
|
231
|
+
|
|
232
|
+
console.log(`Diff image saved to: ${diffPath}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
expect(numDiffPixels).to.be.at.most(
|
|
236
|
+
MAX_DIFF_PIXELS,
|
|
237
|
+
`Expected at most ${MAX_DIFF_PIXELS} different pixels, but got ${numDiffPixels}`,
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
package/test/tiles_data.js
CHANGED
|
@@ -23,6 +23,6 @@ describe('Vector tiles', function () {
|
|
|
23
23
|
testTile(prefix, 0, 1, 0, 404);
|
|
24
24
|
testTile(prefix, 0, 0, 1, 404);
|
|
25
25
|
|
|
26
|
-
testTile(prefix, 14, 0, 0, 204); // non existent tile
|
|
26
|
+
testTile(prefix, 14, 0, 0, 204); // non existent tile (vector tiles default to 204)
|
|
27
27
|
});
|
|
28
28
|
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a simple terrain mbtiles file for testing the elevation API.
|
|
3
|
+
* Uses mapbox encoding: elevation = -10000 + (R * 256 * 256 + G * 256 + B) * 0.1
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import sqlite3 from 'sqlite3';
|
|
7
|
+
import { createCanvas } from 'canvas';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
|
|
14
|
+
function elevationToMapboxRGB(elevation) {
|
|
15
|
+
// elevation = -10000 + (R * 65536 + G * 256 + B) * 0.1
|
|
16
|
+
// (R * 65536 + G * 256 + B) = (elevation + 10000) / 0.1
|
|
17
|
+
const value = Math.round((elevation + 10000) / 0.1);
|
|
18
|
+
const r = Math.floor(value / 65536);
|
|
19
|
+
const g = Math.floor((value % 65536) / 256);
|
|
20
|
+
const b = value % 256;
|
|
21
|
+
return { r, g, b };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createTerrainTile(tileSize, elevation) {
|
|
25
|
+
const canvas = createCanvas(tileSize, tileSize);
|
|
26
|
+
const ctx = canvas.getContext('2d');
|
|
27
|
+
const { r, g, b } = elevationToMapboxRGB(elevation);
|
|
28
|
+
|
|
29
|
+
// Fill with solid color representing the elevation
|
|
30
|
+
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
|
31
|
+
ctx.fillRect(0, 0, tileSize, tileSize);
|
|
32
|
+
|
|
33
|
+
return canvas.toBuffer('image/png');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runDb(db, sql, params = []) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
db.run(sql, params, function (err) {
|
|
39
|
+
if (err) reject(err);
|
|
40
|
+
else resolve(this);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function createTerrainMbtiles(outputPath) {
|
|
46
|
+
const db = new sqlite3.Database(outputPath);
|
|
47
|
+
|
|
48
|
+
// Create mbtiles schema
|
|
49
|
+
await runDb(
|
|
50
|
+
db,
|
|
51
|
+
`
|
|
52
|
+
CREATE TABLE IF NOT EXISTS metadata (name TEXT, value TEXT)
|
|
53
|
+
`,
|
|
54
|
+
);
|
|
55
|
+
await runDb(
|
|
56
|
+
db,
|
|
57
|
+
`
|
|
58
|
+
CREATE TABLE IF NOT EXISTS tiles (zoom_level INTEGER, tile_column INTEGER, tile_row INTEGER, tile_data BLOB)
|
|
59
|
+
`,
|
|
60
|
+
);
|
|
61
|
+
await runDb(
|
|
62
|
+
db,
|
|
63
|
+
`
|
|
64
|
+
CREATE UNIQUE INDEX IF NOT EXISTS tile_index ON tiles (zoom_level, tile_column, tile_row)
|
|
65
|
+
`,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Insert metadata
|
|
69
|
+
const metadata = [
|
|
70
|
+
['name', 'test-terrain'],
|
|
71
|
+
['format', 'png'],
|
|
72
|
+
['encoding', 'mapbox'],
|
|
73
|
+
['minzoom', '0'],
|
|
74
|
+
['maxzoom', '1'],
|
|
75
|
+
['bounds', '-180,-85.051129,180,85.051129'],
|
|
76
|
+
['center', '0,0,0'],
|
|
77
|
+
['type', 'baselayer'],
|
|
78
|
+
['description', 'Test terrain tiles for elevation API testing'],
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
for (const [name, value] of metadata) {
|
|
82
|
+
await runDb(db, 'INSERT INTO metadata (name, value) VALUES (?, ?)', [
|
|
83
|
+
name,
|
|
84
|
+
value,
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const tileSize = 512;
|
|
89
|
+
|
|
90
|
+
// Zoom 0: single tile covering the world at elevation 100m
|
|
91
|
+
const tile0 = createTerrainTile(tileSize, 100);
|
|
92
|
+
await runDb(
|
|
93
|
+
db,
|
|
94
|
+
'INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)',
|
|
95
|
+
[0, 0, 0, tile0],
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// Zoom 1: 4 tiles with different elevations
|
|
99
|
+
const elevations = [
|
|
100
|
+
[0, 0, 200], // top-left
|
|
101
|
+
[1, 0, 500], // top-right
|
|
102
|
+
[0, 1, 1000], // bottom-left
|
|
103
|
+
[1, 1, 2500], // bottom-right
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
for (const [x, y, elevation] of elevations) {
|
|
107
|
+
const tile = createTerrainTile(tileSize, elevation);
|
|
108
|
+
// MBTiles uses TMS scheme where y is flipped
|
|
109
|
+
const tmsY = (1 << 1) - 1 - y;
|
|
110
|
+
await runDb(
|
|
111
|
+
db,
|
|
112
|
+
'INSERT INTO tiles (zoom_level, tile_column, tile_row, tile_data) VALUES (?, ?, ?, ?)',
|
|
113
|
+
[1, x, tmsY, tile],
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
db.close();
|
|
118
|
+
console.log(`Created terrain mbtiles at: ${outputPath}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Get output path from command line or use default
|
|
122
|
+
const outputPath =
|
|
123
|
+
process.argv[2] || path.join(__dirname, '../../test_data/terrain.mbtiles');
|
|
124
|
+
createTerrainMbtiles(outputPath);
|