geolibre-wasm 1.4.2 → 1.4.3

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/geolibre-cli.wasm CHANGED
Binary file
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "geolibre-wasm",
3
3
  "type": "module",
4
- "version": "1.4.2",
4
+ "version": "1.4.3",
5
5
  "description": "Pure-Rust geospatial toolkit (whitebox_next_gen) compiled to WebAssembly (WASI) for GeoLibre's in-browser tool execution",
6
6
  "license": "MIT",
7
7
  "repository": {
package/tools.mjs CHANGED
@@ -19,6 +19,7 @@ const COG_SUBSET_TOOL_ID = "extract_cog_subset";
19
19
  const WMS_SUBSET_TOOL_ID = "extract_wms_subset";
20
20
  const XYZ_SUBSET_TOOL_ID = "extract_xyz_tile_subset";
21
21
  const PMTILES_EXTRACT_TOOL_ID = "pmtiles_extract";
22
+ const OSM_DOWNLOAD_TOOL_ID = "download_osm_vector";
22
23
  // The `url` + `bbox_crs` params the JS interception adds to the WASI
23
24
  // `pmtiles_extract` tool's own manifest, so a host can extract from a remote
24
25
  // archive by byte-range (no whole-file download) and offer the same
@@ -248,6 +249,7 @@ export async function listManifests() {
248
249
  */
249
250
  export async function runTool(tool, opts = {}) {
250
251
  const { args = [], input = {} } = opts;
252
+ if (tool === OSM_DOWNLOAD_TOOL_ID) return runOsmDownloadTool(args, input);
251
253
  if (tool === COG_SUBSET_TOOL_ID) return runCogSubsetTool(args, input);
252
254
  if (tool === WMS_SUBSET_TOOL_ID) return runWmsSubsetTool(args);
253
255
  if (tool === XYZ_SUBSET_TOOL_ID) return runXyzTileSubsetTool(args);
@@ -255,6 +257,171 @@ export async function runTool(tool, opts = {}) {
255
257
  return exec([tool, ...args], input);
256
258
  }
257
259
 
260
+ const OSM_FILTER_PRESETS = {
261
+ all: [], roads: ["[highway]"], buildings: ["[building]"],
262
+ water: ["[waterway]", "[natural=water]"], landuse: ["[landuse]"],
263
+ trails: ["[highway=path]", "[highway=footway]", "[highway=cycleway]", "[highway=bridleway]"],
264
+ parks: ["[leisure=park]", "[boundary=national_park]", "[landuse=recreation_ground]", "[leisure=nature_reserve]"],
265
+ rail: ["[railway]"], amenities: ["[amenity]"], boundaries: ["[boundary]"],
266
+ transit: ["[public_transport]", "[railway=station]", "[highway=bus_stop]"],
267
+ poi: ["[amenity]", "[tourism]", "[shop]", "[leisure]"],
268
+ };
269
+
270
+ function flagBoolean(value, fallback) {
271
+ if (value == null) return fallback;
272
+ return value === true || String(value).toLowerCase() === "true";
273
+ }
274
+
275
+ function osmFilters(flags) {
276
+ const tags = String(flags.include_tags ?? flags.filter_key ?? "").split(";").map((v) => v.trim()).filter(Boolean);
277
+ const pairs = String(flags.include_key_values ?? flags.filter_key_value ?? "").split(";").map((v) => v.trim()).filter(Boolean);
278
+ if (!tags.length && !pairs.length) {
279
+ const preset = String(flags.filter_preset ?? "all").toLowerCase();
280
+ if (!Object.hasOwn(OSM_FILTER_PRESETS, preset)) throw new Error(`unsupported filter_preset: ${preset}`);
281
+ return OSM_FILTER_PRESETS[preset];
282
+ }
283
+ const quote = (value) => JSON.stringify(String(value));
284
+ const explicit = [];
285
+ for (const key of tags) explicit.push(`[${quote(key)}]`);
286
+ for (const pair of pairs) {
287
+ const pos = pair.indexOf("=");
288
+ const key = (pos < 0 ? pair : pair.slice(0, pos)).trim();
289
+ if (!key) throw new Error("Overpass tag filters require a non-empty key");
290
+ if (pos < 0) explicit.push(`[${quote(key)}]`);
291
+ else explicit.push(`[${quote(key)}=${quote(pair.slice(pos + 1).trim())}]`);
292
+ }
293
+ return [...new Set(explicit)].sort();
294
+ }
295
+
296
+ function positiveNumberFlag(raw, fallback, name) {
297
+ const value = raw == null || raw === "" ? fallback : Number(raw);
298
+ if (!Number.isFinite(value) || value <= 0) throw new Error(`--${name} must be a positive number`);
299
+ return value;
300
+ }
301
+
302
+ function positiveIntegerFlag(raw, fallback, name, maximum = Number.MAX_SAFE_INTEGER) {
303
+ const value = positiveNumberFlag(raw, fallback, name);
304
+ if (!Number.isInteger(value)) throw new Error(`--${name} must be a positive integer`);
305
+ if (value > maximum) throw new Error(`--${name} must not exceed ${maximum}`);
306
+ return value;
307
+ }
308
+
309
+ function osmQuery([west, south, east, north], flags) {
310
+ const timeout = Math.max(5, positiveNumberFlag(flags.timeout_seconds, 25, "timeout_seconds"));
311
+ const bbox = `${south.toFixed(7)},${west.toFixed(7)},${north.toFixed(7)},${east.toFixed(7)}`;
312
+ const nodes = flagBoolean(flags.include_points, true);
313
+ const ways = flagBoolean(flags.include_lines, true) || flagBoolean(flags.include_polygons, true);
314
+ const relations = flagBoolean(flags.include_polygons, true);
315
+ const filters = osmFilters(flags);
316
+ const effective = filters.length ? filters : [""];
317
+ const parts = [];
318
+ for (const filter of effective) {
319
+ if (nodes) parts.push(` node${filter}(${bbox});`);
320
+ if (ways) parts.push(` way${filter}(${bbox});`);
321
+ if (relations) parts.push(` relation${filter}(${bbox});`);
322
+ }
323
+ return `[out:json][timeout:${timeout}];\n(\n${parts.join("\n")}\n);\nout body;\n>;\nout skel qt;`;
324
+ }
325
+
326
+ function osmChunks([west, south, east, north], flags) {
327
+ if (!flagBoolean(flags.chunk_large_aoi, true)) return [[west, south, east, north]];
328
+ const maxArea = positiveNumberFlag(flags.chunk_max_area_deg2, 4, "chunk_max_area_deg2");
329
+ const maxChunkCount = positiveIntegerFlag(flags.max_chunk_count, 64, "max_chunk_count", 1024);
330
+ const width = east - west;
331
+ const height = north - south;
332
+ const area = width * height;
333
+ if (area <= maxArea) return [[west, south, east, north]];
334
+ const target = Math.max(1, Math.ceil(area / maxArea));
335
+ const nx = Math.max(1, Math.ceil(Math.sqrt(target * (height > 1e-12 ? width / height : 1))));
336
+ const ny = Math.max(1, Math.ceil(target / nx));
337
+ if (nx * ny > maxChunkCount) throw new Error(`chunking would require ${nx * ny} tiles`);
338
+ const chunks = [];
339
+ for (let iy = 0; iy < ny; iy++) for (let ix = 0; ix < nx; ix++) {
340
+ chunks.push([
341
+ west + width * ix / nx, south + height * iy / ny,
342
+ ix + 1 === nx ? east : west + width * (ix + 1) / nx,
343
+ iy + 1 === ny ? north : south + height * (iy + 1) / ny,
344
+ ]);
345
+ }
346
+ return chunks;
347
+ }
348
+
349
+ async function runOsmDownloadTool(args, inputFiles) {
350
+ const flags = parseFlagArgs(args);
351
+ const stdout = [];
352
+ try {
353
+ let bbox = ["west", "south", "east", "north"].map((key) => Number(flags[key]));
354
+ if (bbox.some((value) => !Number.isFinite(value))) throw new Error("west, south, east, and north must be numbers");
355
+ const inputEpsg = Number(flags.input_extent_epsg ?? 4326);
356
+ if (inputEpsg !== 4326) {
357
+ await initLibrary();
358
+ bbox = Array.from(transform_bbox_epsg(inputEpsg, 4326, Float64Array.from(bbox)));
359
+ }
360
+ const [west, south, east, north] = bbox;
361
+ if (west >= east || south >= north) throw new Error("west/east or south/north extent is reversed");
362
+ if (west < -180 || east > 180 || south < -90 || north > 90) {
363
+ throw new Error("extent must be within WGS84 longitude/latitude bounds");
364
+ }
365
+ const endpointProfiles = {
366
+ main: "https://overpass-api.de/api/interpreter",
367
+ kumi: "https://overpass.kumi.systems/api/interpreter",
368
+ fr: "https://overpass.openstreetmap.fr/api/interpreter",
369
+ };
370
+ const endpoint = String(flags.overpass_url || endpointProfiles[String(flags.overpass_profile ?? "main")] || endpointProfiles.main);
371
+ const endpointUrl = new URL(endpoint);
372
+ if (endpointUrl.protocol !== "https:" || endpointUrl.username || endpointUrl.password) {
373
+ throw new Error("Overpass endpoints must be credential-free HTTPS URLs");
374
+ }
375
+ const chunks = osmChunks(bbox, flags);
376
+ const input = { ...inputFiles };
377
+ const timeout = Math.max(5, positiveNumberFlag(flags.timeout_seconds, 25, "timeout_seconds"));
378
+ const concurrency = Math.min(
379
+ chunks.length,
380
+ positiveIntegerFlag(flags.chunk_parallel_requests, 1, "chunk_parallel_requests", 16),
381
+ );
382
+ const responses = new Array(chunks.length);
383
+ let nextChunk = 0;
384
+ await Promise.all(Array.from({ length: concurrency }, async () => {
385
+ while (nextChunk < chunks.length) {
386
+ const index = nextChunk++;
387
+ const response = await fetchOverpass(endpoint, osmQuery(chunks[index], flags), timeout);
388
+ responses[index] = new Uint8Array(await response.arrayBuffer());
389
+ }
390
+ }));
391
+ responses.forEach((bytes, index) => { input[`overpass_chunk_${index}.json`] = bytes; });
392
+ return exec([OSM_DOWNLOAD_TOOL_ID, ...args, "--overpass_response_prefix=/work/overpass_chunk_"], input);
393
+ } catch (error) {
394
+ const cause = error?.cause?.message ? `: ${error.cause.message}` : "";
395
+ stdout.push(`${String(error?.message || error)}${cause}`);
396
+ return { exitCode: 1, stdout, files: {} };
397
+ }
398
+ }
399
+
400
+ async function fetchOverpass(endpoint, query, timeoutSeconds) {
401
+ let lastError;
402
+ for (let attempt = 0; attempt < 4; attempt++) {
403
+ try {
404
+ const response = await fetch(endpoint, {
405
+ method: "POST",
406
+ headers: {
407
+ "Content-Type": "application/x-www-form-urlencoded",
408
+ "User-Agent": "geolibre-wasm (https://github.com/opengeos/geolibre-rust)",
409
+ },
410
+ body: new URLSearchParams({ data: query }),
411
+ signal: AbortSignal.timeout((timeoutSeconds + 5) * 1000),
412
+ });
413
+ if (response.ok) return response;
414
+ lastError = new Error(`Overpass returned HTTP ${response.status}`);
415
+ if (response.status !== 429 && response.status < 500) lastError.nonRetryable = true;
416
+ } catch (error) {
417
+ lastError = error;
418
+ }
419
+ if (lastError?.nonRetryable) throw lastError;
420
+ if (attempt < 3) await new Promise((resolve) => setTimeout(resolve, 1000 * (2 ** attempt)));
421
+ }
422
+ throw lastError || new Error("Overpass request failed");
423
+ }
424
+
258
425
  function parseFlagArgs(args) {
259
426
  const out = {};
260
427
  for (let i = 0; i < args.length; i++) {