@unmap/elevation 0.12.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maps & Apps Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,16 @@
1
+ # @unmap/elevation
2
+
3
+ Ground elevation anywhere in Canada, from NRCan's national 30 m DEM.
4
+
5
+ ```ts
6
+ import { Elevation } from "@unmap/elevation";
7
+
8
+ const elevation = new Elevation({ key: "um_live_..." });
9
+ const [metres] = await elevation.get([-114.0719, 51.0447]);
10
+ ```
11
+
12
+ Heights are metres above the CGVD2013 geoid. `null` means the dataset covers nothing there; it
13
+ never means sea level.
14
+
15
+ `@unmap/sdk` exposes the same object as `unmap.elevation`. Full documentation:
16
+ <https://unmap.dev/docs/elevation-api>.
@@ -0,0 +1,55 @@
1
+ /** [longitude, latitude]. Longitude first, everywhere in unmap. Not exported: `@unmap/routing`
2
+ * already exports a `LngLat` of the same shape, and both packages land in `@unmap/sdk`'s star
3
+ * exports, where two same-named exports would be a type error. */
4
+ type LngLat = [number, number];
5
+ interface ElevationOptions {
6
+ key: string;
7
+ gateway?: string;
8
+ fetch?: typeof fetch;
9
+ }
10
+ /** The gateway's own cap. Checked here so an over-long batch fails without spending a call. */
11
+ declare const MAX_ELEVATION_POINTS = 1000;
12
+ /**
13
+ * Cloudflare refuses a request URL longer than this at the edge, before the Worker runs.
14
+ *
15
+ * It bites well before `MAX_ELEVATION_POINTS` does. `URLSearchParams` percent-encodes both of
16
+ * the batch separators (`,` becomes `%2C`, `|` becomes `%7C`), so a coordinate costs about six
17
+ * bytes more than it reads, and a thousand six-decimal points is roughly 22 KB of URL. Past the
18
+ * limit the request never reaches the gateway: the caller gets an edge 414 with no error
19
+ * envelope to explain it, which is the least legible failure in the system. So the check is
20
+ * here, against the real encoded URL, and the message says what actually fits.
21
+ */
22
+ declare const MAX_REQUEST_URL_BYTES = 16384;
23
+ declare class ElevationError extends Error {
24
+ readonly status: number;
25
+ /** The gateway's machine-readable `code`, when the response carried one. */
26
+ readonly code?: string | undefined;
27
+ constructor(status: number, message: string,
28
+ /** The gateway's machine-readable `code`, when the response carried one. */
29
+ code?: string | undefined);
30
+ }
31
+ /**
32
+ * Typed client for the unmap elevation API (gateway `/elevation`).
33
+ *
34
+ * Heights are metres above CGVD2013. `null` means the dataset covers nothing at that coordinate;
35
+ * it never means sea level, and the client does not substitute a number for it.
36
+ */
37
+ declare class Elevation {
38
+ private readonly key;
39
+ private readonly base;
40
+ private readonly fetchImpl;
41
+ constructor(opts: ElevationOptions);
42
+ /**
43
+ * Ground elevation in metres, one entry per coordinate, in the order asked.
44
+ *
45
+ * Always an array, whether you passed one coordinate or a thousand, so the single and batch
46
+ * cases are the same call:
47
+ *
48
+ * const [metres] = await unmap.elevation.get([-114.0719, 51.0447]);
49
+ */
50
+ get(points: LngLat | LngLat[], opts?: {
51
+ signal?: AbortSignal;
52
+ }): Promise<(number | null)[]>;
53
+ }
54
+
55
+ export { Elevation, ElevationError, type ElevationOptions, MAX_ELEVATION_POINTS, MAX_REQUEST_URL_BYTES };
package/dist/index.js ADDED
@@ -0,0 +1,87 @@
1
+ // src/index.ts
2
+ import { DEFAULT_GATEWAY } from "@unmap/core";
3
+ var MAX_ELEVATION_POINTS = 1e3;
4
+ var MAX_REQUEST_URL_BYTES = 16384;
5
+ var URL_HEADROOM_BYTES = 256;
6
+ var ElevationError = class extends Error {
7
+ constructor(status, message, code) {
8
+ super(message);
9
+ this.status = status;
10
+ this.code = code;
11
+ this.name = "ElevationError";
12
+ }
13
+ status;
14
+ code;
15
+ };
16
+ var Elevation = class {
17
+ key;
18
+ base;
19
+ fetchImpl;
20
+ constructor(opts) {
21
+ this.key = opts.key;
22
+ this.base = (opts.gateway ?? DEFAULT_GATEWAY).replace(/\/+$/, "");
23
+ this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
24
+ }
25
+ /**
26
+ * Ground elevation in metres, one entry per coordinate, in the order asked.
27
+ *
28
+ * Always an array, whether you passed one coordinate or a thousand, so the single and batch
29
+ * cases are the same call:
30
+ *
31
+ * const [metres] = await unmap.elevation.get([-114.0719, 51.0447]);
32
+ */
33
+ async get(points, opts = {}) {
34
+ const list = isSingle(points) ? [points] : points;
35
+ if (list.length === 0) return [];
36
+ if (list.length > MAX_ELEVATION_POINTS) {
37
+ throw new ElevationError(400, `elevation.get takes at most ${MAX_ELEVATION_POINTS} coordinates`);
38
+ }
39
+ const q = new URLSearchParams({ key: this.key });
40
+ if (list.length === 1) {
41
+ const [lon, lat] = list[0];
42
+ q.set("lon", String(lon));
43
+ q.set("lat", String(lat));
44
+ } else {
45
+ q.set("points", list.map(([lng, lat]) => `${lng},${lat}`).join("|"));
46
+ }
47
+ const target = `${this.base}/elevation?${q}`;
48
+ const bytes = new TextEncoder().encode(target).length;
49
+ const budget = MAX_REQUEST_URL_BYTES - URL_HEADROOM_BYTES;
50
+ if (bytes > budget) {
51
+ const fits = Math.max(1, Math.floor(list.length * budget / bytes));
52
+ throw new ElevationError(
53
+ 414,
54
+ `elevation.get built a ${bytes} byte request URL; this client sends at most ${budget} bytes, keeping headroom under the edge's ${MAX_REQUEST_URL_BYTES} byte limit. Send at most about ${fits} of these coordinates per request (roughly 700 at six decimal places).`
55
+ );
56
+ }
57
+ const res = await this.fetchImpl(target, {
58
+ signal: opts.signal,
59
+ headers: { accept: "application/json" }
60
+ });
61
+ if (!res.ok) throw await errorFrom(res);
62
+ const body = await res.json();
63
+ return body.elevations;
64
+ }
65
+ };
66
+ function isSingle(points) {
67
+ return typeof points[0] === "number";
68
+ }
69
+ async function errorFrom(res) {
70
+ const fallback = `elevation ${res.status}`;
71
+ let body;
72
+ try {
73
+ body = await res.json();
74
+ } catch {
75
+ body = void 0;
76
+ }
77
+ const reason = typeof body?.error === "string" ? body.error : void 0;
78
+ const code = typeof body?.code === "string" ? body.code : void 0;
79
+ return new ElevationError(res.status, reason ? `${reason} (${fallback})` : fallback, code);
80
+ }
81
+ export {
82
+ Elevation,
83
+ ElevationError,
84
+ MAX_ELEVATION_POINTS,
85
+ MAX_REQUEST_URL_BYTES
86
+ };
87
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/index.ts"],"sourcesContent":["import { DEFAULT_GATEWAY } from \"@unmap/core\";\n\n/** [longitude, latitude]. Longitude first, everywhere in unmap. Not exported: `@unmap/routing`\n * already exports a `LngLat` of the same shape, and both packages land in `@unmap/sdk`'s star\n * exports, where two same-named exports would be a type error. */\ntype LngLat = [number, number];\n\nexport interface ElevationOptions {\n key: string;\n gateway?: string;\n fetch?: typeof fetch;\n}\n\n/** The gateway's own cap. Checked here so an over-long batch fails without spending a call. */\nexport const MAX_ELEVATION_POINTS = 1000;\n\n/**\n * Cloudflare refuses a request URL longer than this at the edge, before the Worker runs.\n *\n * It bites well before `MAX_ELEVATION_POINTS` does. `URLSearchParams` percent-encodes both of\n * the batch separators (`,` becomes `%2C`, `|` becomes `%7C`), so a coordinate costs about six\n * bytes more than it reads, and a thousand six-decimal points is roughly 22 KB of URL. Past the\n * limit the request never reaches the gateway: the caller gets an edge 414 with no error\n * envelope to explain it, which is the least legible failure in the system. So the check is\n * here, against the real encoded URL, and the message says what actually fits.\n */\nexport const MAX_REQUEST_URL_BYTES = 16384;\n\n/** Headroom under the edge limit, so a proxy that appends a parameter cannot tip a request over. */\nconst URL_HEADROOM_BYTES = 256;\n\nexport class ElevationError extends Error {\n constructor(\n public readonly status: number,\n message: string,\n /** The gateway's machine-readable `code`, when the response carried one. */\n public readonly code?: string,\n ) {\n super(message);\n this.name = \"ElevationError\";\n }\n}\n\ninterface ElevationResponse {\n elevations: (number | null)[];\n units: \"m\";\n datum: string;\n resolution: number;\n}\n\n/**\n * Typed client for the unmap elevation API (gateway `/elevation`).\n *\n * Heights are metres above CGVD2013. `null` means the dataset covers nothing at that coordinate;\n * it never means sea level, and the client does not substitute a number for it.\n */\nexport class Elevation {\n private readonly key: string;\n private readonly base: string;\n private readonly fetchImpl: typeof fetch;\n\n constructor(opts: ElevationOptions) {\n this.key = opts.key;\n this.base = (opts.gateway ?? DEFAULT_GATEWAY).replace(/\\/+$/, \"\");\n // Bound to globalThis for the same reason Router does it: a bare `fetch` stored on an\n // instance is invoked with that instance as its receiver, which browsers reject.\n this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);\n }\n\n /**\n * Ground elevation in metres, one entry per coordinate, in the order asked.\n *\n * Always an array, whether you passed one coordinate or a thousand, so the single and batch\n * cases are the same call:\n *\n * const [metres] = await unmap.elevation.get([-114.0719, 51.0447]);\n */\n async get(\n points: LngLat | LngLat[],\n opts: { signal?: AbortSignal } = {},\n ): Promise<(number | null)[]> {\n const list: LngLat[] = isSingle(points) ? [points] : points;\n if (list.length === 0) return [];\n if (list.length > MAX_ELEVATION_POINTS) {\n throw new ElevationError(400, `elevation.get takes at most ${MAX_ELEVATION_POINTS} coordinates`);\n }\n\n const q = new URLSearchParams({ key: this.key });\n if (list.length === 1) {\n // Safe: the length check above guarantees index 0 exists.\n const [lon, lat] = list[0]!;\n q.set(\"lon\", String(lon));\n q.set(\"lat\", String(lat));\n } else {\n q.set(\"points\", list.map(([lng, lat]) => `${lng},${lat}`).join(\"|\"));\n }\n\n const target = `${this.base}/elevation?${q}`;\n const bytes = new TextEncoder().encode(target).length;\n const budget = MAX_REQUEST_URL_BYTES - URL_HEADROOM_BYTES;\n if (bytes > budget) {\n // Scaled from the URL actually built, so the number quoted reflects this caller's own\n // coordinate precision rather than an average of somebody else's.\n const fits = Math.max(1, Math.floor((list.length * budget) / bytes));\n throw new ElevationError(\n 414,\n `elevation.get built a ${bytes} byte request URL; this client sends at most ${budget} ` +\n `bytes, keeping headroom under the edge's ${MAX_REQUEST_URL_BYTES} byte limit. Send ` +\n `at most about ${fits} of these coordinates per request (roughly 700 at six decimal ` +\n `places).`,\n );\n }\n\n const res = await this.fetchImpl(target, {\n signal: opts.signal,\n headers: { accept: \"application/json\" },\n });\n if (!res.ok) throw await errorFrom(res);\n const body = (await res.json()) as ElevationResponse;\n return body.elevations;\n }\n}\n\n/** `[number, number]` is a coordinate; `[[number, number], ...]` is a list of them. */\nfunction isSingle(points: LngLat | LngLat[]): points is LngLat {\n return typeof points[0] === \"number\";\n}\n\nasync function errorFrom(res: { status: number; json(): Promise<unknown> }): Promise<ElevationError> {\n const fallback = `elevation ${res.status}`;\n let body: { error?: unknown; code?: unknown } | undefined;\n try {\n body = (await res.json()) as { error?: unknown; code?: unknown };\n } catch {\n body = undefined;\n }\n const reason = typeof body?.error === \"string\" ? body.error : undefined;\n const code = typeof body?.code === \"string\" ? body.code : undefined;\n return new ElevationError(res.status, reason ? `${reason} (${fallback})` : fallback, code);\n}\n"],"mappings":";AAAA,SAAS,uBAAuB;AAczB,IAAM,uBAAuB;AAY7B,IAAM,wBAAwB;AAGrC,IAAM,qBAAqB;AAEpB,IAAM,iBAAN,cAA6B,MAAM;AAAA,EACxC,YACkB,QAChB,SAEgB,MAChB;AACA,UAAM,OAAO;AALG;AAGA;AAGhB,SAAK,OAAO;AAAA,EACd;AAAA,EAPkB;AAAA,EAGA;AAKpB;AAeO,IAAM,YAAN,MAAgB;AAAA,EACJ;AAAA,EACA;AAAA,EACA;AAAA,EAEjB,YAAY,MAAwB;AAClC,SAAK,MAAM,KAAK;AAChB,SAAK,QAAQ,KAAK,WAAW,iBAAiB,QAAQ,QAAQ,EAAE;AAGhE,SAAK,YAAY,KAAK,SAAS,WAAW,MAAM,KAAK,UAAU;AAAA,EACjE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,MAAM,IACJ,QACA,OAAiC,CAAC,GACN;AAC5B,UAAM,OAAiB,SAAS,MAAM,IAAI,CAAC,MAAM,IAAI;AACrD,QAAI,KAAK,WAAW,EAAG,QAAO,CAAC;AAC/B,QAAI,KAAK,SAAS,sBAAsB;AACtC,YAAM,IAAI,eAAe,KAAK,+BAA+B,oBAAoB,cAAc;AAAA,IACjG;AAEA,UAAM,IAAI,IAAI,gBAAgB,EAAE,KAAK,KAAK,IAAI,CAAC;AAC/C,QAAI,KAAK,WAAW,GAAG;AAErB,YAAM,CAAC,KAAK,GAAG,IAAI,KAAK,CAAC;AACzB,QAAE,IAAI,OAAO,OAAO,GAAG,CAAC;AACxB,QAAE,IAAI,OAAO,OAAO,GAAG,CAAC;AAAA,IAC1B,OAAO;AACL,QAAE,IAAI,UAAU,KAAK,IAAI,CAAC,CAAC,KAAK,GAAG,MAAM,GAAG,GAAG,IAAI,GAAG,EAAE,EAAE,KAAK,GAAG,CAAC;AAAA,IACrE;AAEA,UAAM,SAAS,GAAG,KAAK,IAAI,cAAc,CAAC;AAC1C,UAAM,QAAQ,IAAI,YAAY,EAAE,OAAO,MAAM,EAAE;AAC/C,UAAM,SAAS,wBAAwB;AACvC,QAAI,QAAQ,QAAQ;AAGlB,YAAM,OAAO,KAAK,IAAI,GAAG,KAAK,MAAO,KAAK,SAAS,SAAU,KAAK,CAAC;AACnE,YAAM,IAAI;AAAA,QACR;AAAA,QACA,yBAAyB,KAAK,gDAAgD,MAAM,6CACtC,qBAAqB,mCAChD,IAAI;AAAA,MAEzB;AAAA,IACF;AAEA,UAAM,MAAM,MAAM,KAAK,UAAU,QAAQ;AAAA,MACvC,QAAQ,KAAK;AAAA,MACb,SAAS,EAAE,QAAQ,mBAAmB;AAAA,IACxC,CAAC;AACD,QAAI,CAAC,IAAI,GAAI,OAAM,MAAM,UAAU,GAAG;AACtC,UAAM,OAAQ,MAAM,IAAI,KAAK;AAC7B,WAAO,KAAK;AAAA,EACd;AACF;AAGA,SAAS,SAAS,QAA6C;AAC7D,SAAO,OAAO,OAAO,CAAC,MAAM;AAC9B;AAEA,eAAe,UAAU,KAA4E;AACnG,QAAM,WAAW,aAAa,IAAI,MAAM;AACxC,MAAI;AACJ,MAAI;AACF,WAAQ,MAAM,IAAI,KAAK;AAAA,EACzB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,SAAS,OAAO,MAAM,UAAU,WAAW,KAAK,QAAQ;AAC9D,QAAM,OAAO,OAAO,MAAM,SAAS,WAAW,KAAK,OAAO;AAC1D,SAAO,IAAI,eAAe,IAAI,QAAQ,SAAS,GAAG,MAAM,KAAK,QAAQ,MAAM,UAAU,IAAI;AAC3F;","names":[]}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@unmap/elevation",
3
+ "version": "0.12.0",
4
+ "description": "Typed client for the unmap elevation API (Canadian ground elevation)",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "unmap",
8
+ "elevation",
9
+ "dem",
10
+ "terrain",
11
+ "canada"
12
+ ],
13
+ "homepage": "https://unmap.dev/docs/elevation-api",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/unmap-dev/unmap.git",
17
+ "directory": "packages/elevation"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/unmap-dev/unmap/issues"
21
+ },
22
+ "type": "module",
23
+ "main": "./dist/index.js",
24
+ "module": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist"
34
+ ],
35
+ "sideEffects": false,
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "@unmap/core": "0.12.0"
41
+ },
42
+ "devDependencies": {
43
+ "tsup": "^8.3.0",
44
+ "typescript": "^5.6.0",
45
+ "vitest": "^2.1.0"
46
+ },
47
+ "scripts": {
48
+ "build": "tsup",
49
+ "test": "vitest run",
50
+ "typecheck": "tsc --noEmit"
51
+ }
52
+ }