Skip to content

Commit df6f331

Browse files
authored
CL-3843 Use elevation from Elevation Cloud API (#61)
* CL-3843 Compute elevation on server or client * CL-3843 Add Bug Fixes into Changelog * CL-3843 Update CHANGELOG * CL-3843 Fix canParsePixelData * CL-3843 Bump version to v2.5.0 * CL-3843 Improve computeOnServer error logging * CL-3843 Use functional style * CL-3843 Fixup: Use functional style
1 parent ed5b5f5 commit df6f331

File tree

6 files changed

+144
-109
lines changed

6 files changed

+144
-109
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# MapTiler Client Changelog
22

3+
## 2.5.0
4+
### New Features
5+
- `at` and `batch` functions compute elevation on the server using MapTiler Elevation API by default
6+
- Elevation supports Node.js: computed on server when `bufferToPixelData` is not provided
7+
- Added `computeOn` option to force client/server elevation processing
8+
- Added `canParsePixelData` function to check if elevation can be computed on the client
9+
10+
### Bug Fixes
11+
- `bufferToPixelData` can be undefined
12+
13+
### Others
14+
None
15+
316
## 2.4.0
417
### New Features
518
- Added `elevation` option to Geolocation API

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@maptiler/client",
3-
"version": "2.4.0",
3+
"version": "2.5.0",
44
"description": "Javascript & Typescript wrapper to MapTiler Cloud API",
55
"module": "dist/maptiler-client.mjs",
66
"types": "dist/maptiler-client.d.ts",

src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ class ClientConfig {
3838
*/
3939
public tileCacheSize: number = 200;
4040

41-
public bufferToPixelData: BufferToPixelDataFunction | null;
41+
public bufferToPixelData?: BufferToPixelDataFunction | null;
4242

4343
/**
4444
* Set the MapTiler Cloud API key

src/services/elevation.ts

Lines changed: 123 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ import { ServiceError } from "./ServiceError";
77
import { math } from "./math";
88
import {
99
TileJSON,
10+
canParsePixelData,
1011
getBufferToPixelDataParser,
1112
getTileCache,
1213
} from "../tiledecoding";
1314

14-
const terrainTileJsonURL = "tiles/terrain-rgb-v2/tiles.json";
15+
const TERRAIN_TILESET = "terrain-rgb-v2";
16+
const API_BATCH_SIZE = 50;
17+
const API_WARN_SIZE = 1000;
18+
1519
let terrainTileJson: TileJSON = null;
1620

1721
export type ElevationAtOptions = {
@@ -21,10 +25,17 @@ export type ElevationAtOptions = {
2125
apiKey?: string;
2226

2327
/**
24-
* Zoom level to use for the terrain RGB tileset.
25-
* If not provided, the highest zoom level will be used
28+
* Zoom level to use for the terrain tileset in `client` mode.
29+
* If not provided, the highest zoom level will be used.
2630
*/
2731
zoom?: number;
32+
33+
/**
34+
* If set to `client`, the elevation will be computed from the terrain tiles on the client side.
35+
* If set to `server`, the elevation will be obtained from the MapTiler Elevation API.
36+
* Defaults to `server` for `at`, `batch`, and `client` for `fromLineString`, `fromMultiLineString` (in browser envs).
37+
*/
38+
computeOn?: "client" | "server";
2839
};
2940

3041
/**
@@ -42,110 +53,72 @@ const customMessages = {
4253
403: "Key is missing, invalid or restricted",
4354
};
4455

45-
async function fetchTerrainTileJson(apiKey: string): Promise<TileJSON> {
46-
const endpoint = new URL(terrainTileJsonURL, defaults.maptilerApiURL);
47-
endpoint.searchParams.set("key", apiKey);
48-
const urlWithParams = endpoint.toString();
49-
50-
const res = await callFetch(urlWithParams);
51-
if (res.ok) {
52-
terrainTileJson = (await res.json()) as TileJSON;
53-
return terrainTileJson;
54-
} else {
55-
if (!res.ok) {
56-
throw new ServiceError(res, customMessages[res.status] ?? "");
57-
}
58-
}
59-
}
60-
61-
/**
62-
* Get the elevation at a given position.
63-
* The returned position is of form [longitude, latitude, altitude]
64-
*/
65-
async function at(
66-
/**
67-
* Wgs84 position as [longitude, latitude]
68-
*/
69-
position: Position,
70-
/**
71-
* Options
72-
*/
73-
options: ElevationAtOptions = {},
74-
): Promise<Position> {
75-
const apiKey = options.apiKey ?? config.apiKey;
76-
77-
if (!terrainTileJson) {
78-
await fetchTerrainTileJson(apiKey);
79-
}
80-
81-
const maxZoom = terrainTileJson.maxzoom;
82-
let zoom = ~~(options.zoom ?? maxZoom);
83-
if (zoom > maxZoom || zoom < 0) {
84-
zoom = maxZoom;
85-
}
86-
const tileIndex = math.wgs84ToTileIndex(position, zoom, false);
87-
88-
const tileX = ~~tileIndex[0];
89-
const tileY = ~~tileIndex[1];
90-
91-
if (!terrainTileJson.tiles.length) {
92-
throw new Error("Terrain tileJSON tile list is empty.");
56+
async function computeOnServer(
57+
positions: Position[],
58+
apiKey: string,
59+
): Promise<Position[]> {
60+
if (positions.length > API_WARN_SIZE) {
61+
console.warn(
62+
"Computing elevation for complex geometries is discouraged - simplify the geometry before proceeding",
63+
);
9364
}
9465

95-
const tileID = `terrain_${zoom.toString()}_${tileX.toString()}_${tileY.toString()}`;
96-
let tilePixelData;
97-
98-
const cache = getTileCache();
99-
100-
if (cache.has(tileID)) {
101-
tilePixelData = cache.get(tileID);
102-
} else {
103-
const tileURL = terrainTileJson.tiles[0]
104-
.replace("{x}", tileX.toString())
105-
.replace("{y}", tileY.toString())
106-
.replace("{z}", zoom.toString());
107-
108-
const tileRes = await callFetch(tileURL);
109-
110-
if (!tileRes.ok) {
111-
throw new ServiceError(tileRes, customMessages[tileRes.status] ?? "");
112-
}
113-
114-
const tileBuff = await tileRes.arrayBuffer();
115-
const tileParser = getBufferToPixelDataParser();
116-
tilePixelData = await tileParser(tileBuff);
117-
cache.set(tileID, tilePixelData);
118-
}
66+
const parts = Math.ceil(positions.length / API_BATCH_SIZE);
67+
const respPromises = Array.from({ length: parts }, () => null).map(
68+
(_, part) => {
69+
const startPos = part * API_BATCH_SIZE;
70+
const batch = positions.slice(startPos, startPos + API_BATCH_SIZE);
71+
const batchEncoded = batch.map((pos) => pos.join(",")).join(";");
72+
const endpoint = new URL(
73+
`elevation/${batchEncoded}.json`,
74+
defaults.maptilerApiURL,
75+
);
76+
endpoint.searchParams.set("key", apiKey);
77+
return callFetch(endpoint.toString());
78+
},
79+
);
11980

120-
const pixelX = ~~(tilePixelData.width * (tileIndex[0] % 1));
121-
const pixelY = ~~(tilePixelData.height * (tileIndex[1] % 1));
122-
const pixelDataIndex =
123-
(pixelY * tilePixelData.width + pixelX) * tilePixelData.components;
124-
const R = tilePixelData.pixels[pixelDataIndex];
125-
const G = tilePixelData.pixels[pixelDataIndex + 1];
126-
const B = tilePixelData.pixels[pixelDataIndex + 2];
127-
const elevation = -10000 + (R * 256 * 256 + G * 256 + B) * 0.1;
81+
const resps = await Promise.allSettled(respPromises);
82+
const jsons = await Promise.all(
83+
resps.map(async (resp) => {
84+
if (resp.status === "rejected") {
85+
throw new Error(
86+
`Some segments could not be fetched, error: ${resp.reason}`,
87+
);
88+
}
89+
if (!resp.value.ok) {
90+
throw new Error(
91+
`Some segments could not be fetched, response: ${
92+
resp.value.status
93+
} ${await resp.value.text()}, url: ${resp.value.url}`,
94+
);
95+
}
96+
return resp.value.json();
97+
}),
98+
);
12899

129-
return [position[0], position[1], elevation];
100+
return jsons.flat();
130101
}
131102

132-
/**
133-
* Perform a batch elevation request
134-
*/
135-
async function batch(
136-
/**
137-
* Wgs84 positions as [[lng0, lat0], [lng1, lat1], [lng2, lat2], ...]
138-
*/
103+
async function computeOnClient(
139104
positions: Position[],
140-
/**
141-
* Options
142-
*/
143-
options: ElevationBatchOptions = {},
105+
apiKey: string,
106+
zoom?: number,
144107
): Promise<Position[]> {
145-
const apiKey = options.apiKey ?? config.apiKey;
146-
108+
// Fetch terrain TileJSON
147109
if (!terrainTileJson) {
148-
await fetchTerrainTileJson(apiKey);
110+
const endpoint = new URL(
111+
`tiles/${TERRAIN_TILESET}/tiles.json`,
112+
defaults.maptilerApiURL,
113+
);
114+
endpoint.searchParams.set("key", apiKey);
115+
const urlWithParams = endpoint.toString();
116+
const res = await callFetch(urlWithParams);
117+
if (res.ok) {
118+
terrainTileJson = (await res.json()) as TileJSON;
119+
} else {
120+
throw new ServiceError(res, customMessages[res.status] ?? "");
121+
}
149122
}
150123

151124
// Better throw about not bein able to parse tiles before fetching them
@@ -154,20 +127,20 @@ async function batch(
154127
const cache = getTileCache();
155128

156129
const maxZoom = terrainTileJson.maxzoom;
157-
let zoom = ~~(options.zoom ?? maxZoom);
158-
if (zoom > maxZoom || zoom < 0) {
159-
zoom = maxZoom;
130+
let usedZoom = ~~(zoom ?? maxZoom);
131+
if (usedZoom > maxZoom || usedZoom < 0) {
132+
usedZoom = maxZoom;
160133
}
161134
const tileIndicesFloats = positions.map((position) =>
162-
math.wgs84ToTileIndex(position, zoom, false),
135+
math.wgs84ToTileIndex(position, usedZoom, false),
163136
);
164137
const tileIndicesInteger = tileIndicesFloats.map((index) => [
165138
~~index[0],
166139
~~index[1],
167140
]);
168141
const tileIDs = tileIndicesInteger.map(
169142
(index) =>
170-
`terrain_${zoom.toString()}_${index[0].toString()}_${index[1].toString()}`,
143+
`terrain_${usedZoom.toString()}_${index[0].toString()}_${index[1].toString()}`,
171144
);
172145

173146
// unique tiles to fetch (excluding those already in cache and the doublons)
@@ -247,6 +220,49 @@ async function batch(
247220
return [position[0], position[1], ~~(elevation * 1000) / 1000];
248221
});
249222

223+
return elevatedPositions;
224+
}
225+
226+
/**
227+
* Get the elevation at a given position.
228+
* The returned position is of form [longitude, latitude, altitude]
229+
*/
230+
async function at(
231+
/**
232+
* Wgs84 position as [longitude, latitude]
233+
*/
234+
position: Position,
235+
/**
236+
* Options
237+
*/
238+
options: ElevationAtOptions = {},
239+
): Promise<Position> {
240+
const elevatedPositions = await batch([position], options);
241+
return elevatedPositions[0];
242+
}
243+
244+
/**
245+
* Perform a batch elevation request
246+
*/
247+
async function batch(
248+
/**
249+
* Wgs84 positions as [[lng0, lat0], [lng1, lat1], [lng2, lat2], ...]
250+
*/
251+
positions: Position[],
252+
/**
253+
* Options
254+
*/
255+
options: ElevationBatchOptions = {},
256+
): Promise<Position[]> {
257+
if (positions.length === 0) return [];
258+
259+
const apiKey = options.apiKey ?? config.apiKey;
260+
261+
const elevatedPositions =
262+
options.computeOn === "client"
263+
? await computeOnClient(positions, apiKey, options.zoom)
264+
: await computeOnServer(positions, apiKey);
265+
250266
// Smoothing
251267
if (options.smoothingKernelSize) {
252268
// make sure the kernel is of an odd size
@@ -286,6 +302,7 @@ async function fromLineString(
286302
throw new Error("The provided object is not a GeoJSON LineString");
287303
}
288304

305+
options.computeOn ??= canParsePixelData() ? "client" : "server";
289306
const clone = structuredClone(ls) as LineString;
290307
const elevatedPositions = await batch(clone.coordinates, options);
291308
clone.coordinates = elevatedPositions;
@@ -311,11 +328,12 @@ async function fromMultiLineString(
311328
throw new Error("The provided object is not a GeoJSON MultiLineString");
312329
}
313330

331+
options.computeOn ??= canParsePixelData() ? "client" : "server";
314332
const clone = structuredClone(ls) as MultiLineString;
315333
const multiLengths = clone.coordinates.map((poss) => poss.length);
316334

317-
// This is equivalent to a batch of batch, so we makes the multilinestring a unique
318-
// line string to prevent batch to fetch multiple times the same tile
335+
// This is equivalent to a batch of batch, so we makes the multilinestring
336+
// a unique line string to reduce number of requests
319337
const flattenPositions = clone.coordinates.flat();
320338
const flattenPositionsElevated = await batch(flattenPositions, options);
321339

src/tiledecoding.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,7 @@ export function getBufferToPixelDataParser(): BufferToPixelDataFunction {
103103
"An image file buffer to pixel data parser is necessary. Specify it in `config.bufferToPixelData`",
104104
);
105105
}
106+
107+
export function canParsePixelData(): boolean {
108+
return !!config.bufferToPixelData || typeof window !== "undefined";
109+
}

0 commit comments

Comments
 (0)