diff --git a/README.ja.md b/README.ja.md index 587f506..66575c2 100644 --- a/README.ja.md +++ b/README.ja.md @@ -78,14 +78,55 @@ console.log(projected); // [[easting, northing], [easting, northing]] または メモリが尽きた場合を含む)は throw するので、変換できない target と取り違えることはありません。 入力は lon/lat の度数のみを受け付けます。 +### 単位 + +座標は単位と組にして初めて意味を持ちます。`transformLonLatToCandidate` は同じ lon/lat(度)の +点列を変換し、点列とその単位を組にして `{ points, unit }` で返します。`unit` は `LinearUnit` +(`{ name, metresPerUnit }`。1 単位が何 metre か)です。 + +- CRS のとき: 単位は PROJ の軸情報から取ります。metre の CRS なら + `{ name: "metre", metresPerUnit: 1 }`、フィートの CRS ならその名前と係数(たとえば `EPSG:2227` + なら `{ name: "US survey foot", metresPerUnit: 1200 / 3937 }`)です。座標系が直交(Cartesian。 + 軸が度の地理座標系は対象外)で、軸が east / north の 2 次元で、両軸の単位が同じ線形単位の CRS + だけを変換し、それ以外の target は `null` です。 +- custom / user の定義の pipeline(同梱の UTJCP 歴史的市区町村の格子など)のとき: 定義は + 「格子 → lon/lat(度)」の向きなので、点列は行の格子の単位(定義の `unit`)で返ります。同梱の + 3 つの zone は `{ name: "yard (Japan City Plans)", metresPerUnit: 0.9143985307444408 }` を + 持っています。`unit` の無い pipeline の行、`unit` が線形単位として不正な行(`name` が空、 + `metresPerUnit` が正の有限の number でない)は変換できず `null` になるので、pipeline を持つ + user の定義ファイルには行ごとに使える `unit` を書いてください。 +- `transformLonLatArrayToProjected` の契約は変わりません。同じ変換を使い、単位が metre のときだけ + 点列を返し、それ以外は `null` を返します。 +- Atlas は単位換算をしません。点列は候補の単位のままで、換算や書き戻しは呼ぶ側の仕事です + (この package の transformer を使うときは Teiresias が行います)。 + +```ts +import { listProjectedCrsFromProjWasm, transformLonLatToCandidate } from "@c4h/atlas"; + +const rows = await listProjectedCrsFromProjWasm({ + extent: { west: 135.6, south: 34.9, east: 135.9, north: 35.1 }, + area: "Japan", + includeCustomProjections: true, + onshoreOnly: false, +}); +const zoneB = rows.find((row) => row.id === "UTJCP:ZONE-B"); + +const converted = await transformLonLatToCandidate(zoneB!, [[135.75, 35.0]]); + +console.log(converted?.unit); // { name: "yard (Japan City Plans)", metresPerUnit: 0.9143985307444408 } +console.log(converted?.points); // その単位の [[x, y]]。変換できなければ null +``` + ### Teiresias と組み合わせる `createCandidateTransformer(rows)` は候補の行から、[`@c4h/teiresias`](https://github.com/code4history/Teiresias) の `CandidateTransformer` と同じ形の関数を作ります。Atlas で引いた候補を、そのまま `estimateCrs` で順位づけできます。Atlas は Teiresias に依存しません(型は形で合わせています)。 `geoBasis` が `{ kind: "lonlat_deg", axisOrder: "lon-lat" }` でないとき、および行に無い id のときは -`null` を返し、それ以外は `transformLonLatArrayToProjected` の結果を返します(throw もそのまま -伝えます)。行は関数を作った時点で写し取ります。 +`null` を返します。それ以外は、metre の候補なら plain な点列(`transformLonLatArrayToProjected` と +同じ点列)、別の単位の候補なら `{ points, unit }` を返します(throw もそのまま伝えます)。 +Teiresias はこの結果の単位を使うので、歴史的市区町村の格子の点列も yard のまま順位づけと +書き出しができます。行は関数を作った時点で `unit` ごと写し取ります。 ```ts import { createCandidateTransformer, listProjectedCrsFromProjWasm } from "@c4h/atlas"; @@ -163,7 +204,8 @@ proj-wasm 0.1.0-alpha8 は PROJ object を解放してもメモリを全部は |---|---| | `listProjectedCrsFromProjWasm(options)` | 適用範囲が `options.extent` を含む投影 CRS の候補を列挙します(基本的な使い方を参照)。戻り値は `ProjectedCrsInfo[]` です。 | | `initializeProjWasmRuntime(options?)` | 同梱の proj-wasm を最初の検索より前に読み込みます。省略してよく、ほかの呼び出しが必要なときに読み込みます。 | -| `transformLonLatArrayToProjected(target, points, options?)` | 経緯度(度・lon-lat 順)を 1 つの候補の `[easting, northing]` へ変換します。 | +| `transformLonLatArrayToProjected(target, points, options?)` | 経緯度(度・lon-lat 順)を 1 つの候補の `[easting, northing]` へ変換します(metre のときだけ点列を返します)。 | +| `transformLonLatToCandidate(target, points, options?)` | 経緯度(度・lon-lat 順)を 1 つの候補の座標へ変換し、線形単位といっしょに `{ points, unit }` で返します(単位を参照)。 | | `createCandidateTransformer(rows, options?)` | Teiresias の `estimateCrs` に渡す `AtlasCandidateTransformer` を作ります(Teiresias と組み合わせるを参照)。 | | `isProjectedPrjPath(pathname)` | 同梱の日本の WKT 集の `.prj` のパスが投影 CRS かどうかを返します。 | | `inferDatumFamily(pathname)` | そのパスから測地系の系統(`CandidateDatumFamily`)を読みます。 | @@ -180,12 +222,13 @@ proj-wasm 0.1.0-alpha8 は PROJ object を解放してもメモリを全部は | 名前 | 表すもの | |---|---| -| `ProjectedCrsInfo` | 候補 1 件。`id`・`authority`・`code`・`name`・`areaName`・`deprecated`・`bbox`・`wkt2`・`proj4` と任意の詳細。 | +| `ProjectedCrsInfo` | 候補 1 件。`id`・`authority`・`code`・`name`・`areaName`・`deprecated`・`bbox`・`wkt2`・`proj4` と任意の詳細。custom / user の pipeline の行は格子の `unit` も持ちます。 | +| `LinearUnit` | 座標の線形単位 `{ name, metresPerUnit }`(1 単位が何 metre か)。 | | `ProjectedCrsExclusion` | 検索から外した行と理由(`onExcluded` に渡ります)。 | | `ListProjectedCrsFromProjWasmOptions` | `listProjectedCrsFromProjWasm` の option。 | | `InitializeProjWasmRuntimeOptions` | `initializeProjWasmRuntime` の option(`projWasmAssetBaseUrl`)。 | -| `TransformLonLatToProjectedOptions` | `transformLonLatArrayToProjected` と `createCandidateTransformer` の option。 | -| `AtlasCandidateTransformer` | `createCandidateTransformer` が返す変換関数。 | +| `TransformLonLatToProjectedOptions` | `transformLonLatArrayToProjected`・`transformLonLatToCandidate`・`createCandidateTransformer` の option。 | +| `AtlasCandidateTransformer` | `createCandidateTransformer` が返す変換関数。metre の候補なら plain な点列、別の単位の候補なら `{ points, unit }`、変換できなければ `null` を返します。 | | `AtlasGeoBasisLike` | 変換関数が受け取る座標の基準(Teiresias の `GeoBasis` と互換)。 | | `CandidateCrsExtent` | 経緯度の範囲 `{ west, south, east, north }`。 | | `CandidateBbox` | 経緯度の外接矩形 `{ west, south, east, north }`。 | diff --git a/README.md b/README.md index 39d486c..7fead0b 100644 --- a/README.md +++ b/README.md @@ -81,15 +81,59 @@ call fails, for example because the wasm memory is full), the function throws, s never mistaken for an untransformable target. The function accepts lon/lat degree input only. +### Units + +Coordinates are only meaningful together with their unit. `transformLonLatToCandidate` converts the +same lon/lat degrees and returns the points with the linear unit they are in: `{ points, unit }`, +where `unit` is a `LinearUnit` (`{ name, metresPerUnit }`, the number of metres in one unit). + +- For a CRS, the unit comes from the axis information of PROJ. A CRS in metres gives + `{ name: "metre", metresPerUnit: 1 }`; a CRS in feet gives its own name and factor, for example + `{ name: "US survey foot", metresPerUnit: 1200 / 3937 }` for `EPSG:2227`. Only a CRS whose + coordinate system is Cartesian (a projected CRS, not a geographic CRS whose axes are in degrees), + whose axes are east/north in two dimensions, and whose two axes have the same unit, is + transformed; any other target gives `null`. +- For a pipeline of a custom or user definition (the bundled UTJCP historical zones, for example), + the definition describes the grid-to-lon/lat direction, so the points come back in the grid unit + of the row: the `unit` of the definition. The three historical zones declare + `{ name: "yard (Japan City Plans)", metresPerUnit: 0.9143985307444408 }`. A pipeline row without + `unit`, or with a `unit` that is not a linear unit (an empty `name`, a `metresPerUnit` that is not + a positive finite number), cannot be transformed and gives `null`, so a user projection file that + holds pipelines has to give each of them a usable `unit`. +- `transformLonLatArrayToProjected` keeps its contract: it uses the same conversion and returns the + points only when the unit is metre, and `null` otherwise. +- Units are never converted by Atlas. The points stay in the unit of the candidate; converting them, + or writing them out again, is up to the caller (Teiresias does it for the transformer built here). + +```ts +import { listProjectedCrsFromProjWasm, transformLonLatToCandidate } from "@c4h/atlas"; + +const rows = await listProjectedCrsFromProjWasm({ + extent: { west: 135.6, south: 34.9, east: 135.9, north: 35.1 }, + area: "Japan", + includeCustomProjections: true, + onshoreOnly: false, +}); +const zoneB = rows.find((row) => row.id === "UTJCP:ZONE-B"); + +const converted = await transformLonLatToCandidate(zoneB!, [[135.75, 35.0]]); + +console.log(converted?.unit); // { name: "yard (Japan City Plans)", metresPerUnit: 0.9143985307444408 } +console.log(converted?.points); // [[x, y]] in that unit, or null +``` + ### Using Atlas with Teiresias `createCandidateTransformer(rows)` turns candidate rows into a transformer with the same shape as the `CandidateTransformer` of [`@c4h/teiresias`](https://github.com/code4history/Teiresias), so the candidates found by Atlas can be ranked by `estimateCrs`. Atlas does not depend on Teiresias; the types match by shape. The transformer returns `null` for a geo basis other than -`{ kind: "lonlat_deg", axisOrder: "lon-lat" }` and for an id that is not among the rows; otherwise it -returns what `transformLonLatArrayToProjected` returns, and throws what it throws. The rows are copied -when the transformer is created. +`{ kind: "lonlat_deg", axisOrder: "lon-lat" }` and for an id that is not among the rows. Otherwise it +returns a plain point list for a candidate in metres (the same points as +`transformLonLatArrayToProjected`) and `{ points, unit }` for a candidate in another unit, and throws +what the conversion throws. Teiresias takes the unit of that result, so the points of a historical +grid are ranked and written out in yards. The rows are copied, `unit` included, when the transformer +is created. ```ts import { createCandidateTransformer, listProjectedCrsFromProjWasm } from "@c4h/atlas"; @@ -172,7 +216,8 @@ Everything below is exported from `@c4h/atlas`. |---|---| | `listProjectedCrsFromProjWasm(options)` | Lists projected CRS candidates whose area of use contains `options.extent` (see Basic Usage). Returns `ProjectedCrsInfo[]`. | | `initializeProjWasmRuntime(options?)` | Loads the bundled proj-wasm runtime ahead of the first search. Optional; the other calls load it on demand. | -| `transformLonLatArrayToProjected(target, points, options?)` | Transforms lon/lat degrees (lon-lat order) into `[easting, northing]` of one candidate. | +| `transformLonLatArrayToProjected(target, points, options?)` | Transforms lon/lat degrees (lon-lat order) into `[easting, northing]` of one candidate, in metres only. | +| `transformLonLatToCandidate(target, points, options?)` | Transforms lon/lat degrees (lon-lat order) into the coordinates of one candidate and returns them with their linear unit (`{ points, unit }`; see Units). | | `createCandidateTransformer(rows, options?)` | Builds an `AtlasCandidateTransformer` for Teiresias' `estimateCrs` (see Using Atlas with Teiresias). | | `isProjectedPrjPath(pathname)` | Tells whether a `.prj` path of the bundled Japanese WKT collection is a projected CRS. | | `inferDatumFamily(pathname)` | Reads the datum family (`CandidateDatumFamily`) from such a path. | @@ -189,12 +234,13 @@ Everything below is exported from `@c4h/atlas`. | Name | What it describes | |---|---| -| `ProjectedCrsInfo` | One candidate: `id`, `authority`, `code`, `name`, `areaName`, `deprecated`, `bbox`, `wkt2`, `proj4` and optional details. | +| `ProjectedCrsInfo` | One candidate: `id`, `authority`, `code`, `name`, `areaName`, `deprecated`, `bbox`, `wkt2`, `proj4` and optional details. A custom or user pipeline row also carries the `unit` of its grid. | +| `LinearUnit` | A linear unit of coordinates: `{ name, metresPerUnit }`, the number of metres in one unit. | | `ProjectedCrsExclusion` | A row left out of a search, with the reason (given to `onExcluded`). | | `ListProjectedCrsFromProjWasmOptions` | Options of `listProjectedCrsFromProjWasm`. | | `InitializeProjWasmRuntimeOptions` | Options of `initializeProjWasmRuntime` (`projWasmAssetBaseUrl`). | -| `TransformLonLatToProjectedOptions` | Options of `transformLonLatArrayToProjected` and `createCandidateTransformer`. | -| `AtlasCandidateTransformer` | The transformer `createCandidateTransformer` returns. | +| `TransformLonLatToProjectedOptions` | Options of `transformLonLatArrayToProjected`, `transformLonLatToCandidate` and `createCandidateTransformer`. | +| `AtlasCandidateTransformer` | The transformer `createCandidateTransformer` returns: a plain point list for a candidate in metres, otherwise `{ points, unit }`, or `null`. | | `AtlasGeoBasisLike` | The geo basis a transformer receives (compatible with Teiresias' `GeoBasis`). | | `CandidateCrsExtent` | A lon/lat extent `{ west, south, east, north }`. | | `CandidateBbox` | A lon/lat bounding box `{ west, south, east, north }`. | diff --git a/data/crs/custom-projections.json b/data/crs/custom-projections.json index 07db920..ff01940 100644 --- a/data/crs/custom-projections.json +++ b/data/crs/custom-projections.json @@ -13,6 +13,10 @@ "north": 46 }, "pipeline": "+proj=pipeline +step +inv +proj=poly +lat_0=40.5 +lon_0=143 +x_0=914398.5307444408 +y_0=1828797.0614888816 +ellps=clrk66 +to_meter=0.9143985307444408 +step +proj=cart +ellps=bessel +step +proj=helmert +x=-146.336 +y=506.832 +z=680.254 +step +inv +proj=cart +ellps=WGS84 +step +proj=unitconvert +xy_in=rad +xy_out=deg", + "unit": { + "name": "yard (Japan City Plans)", + "metresPerUnit": 0.9143985307444408 + }, "wkt2": null, "isApproximate": true, "source": [ @@ -33,6 +37,10 @@ "north": 46 }, "pipeline": "+proj=pipeline +step +inv +proj=poly +lat_0=40.5 +lon_0=135 +x_0=914398.5307444408 +y_0=1828797.0614888816 +ellps=clrk66 +to_meter=0.9143985307444408 +step +proj=cart +ellps=bessel +step +proj=helmert +x=-146.336 +y=506.832 +z=680.254 +step +inv +proj=cart +ellps=WGS84 +step +proj=unitconvert +xy_in=rad +xy_out=deg", + "unit": { + "name": "yard (Japan City Plans)", + "metresPerUnit": 0.9143985307444408 + }, "wkt2": null, "isApproximate": true, "source": [ @@ -53,6 +61,10 @@ "north": 46 }, "pipeline": "+proj=pipeline +step +inv +proj=poly +lat_0=40.5 +lon_0=127 +x_0=914398.5307444408 +y_0=1828797.0614888816 +ellps=clrk66 +to_meter=0.9143985307444408 +step +proj=cart +ellps=bessel +step +proj=helmert +x=-146.336 +y=506.832 +z=680.254 +step +inv +proj=cart +ellps=WGS84 +step +proj=unitconvert +xy_in=rad +xy_out=deg", + "unit": { + "name": "yard (Japan City Plans)", + "metresPerUnit": 0.9143985307444408 + }, "wkt2": null, "isApproximate": true, "source": [ diff --git a/dist/atlas.js b/dist/atlas.js index cec8c44..3e19179 100644 --- a/dist/atlas.js +++ b/dist/atlas.js @@ -1,18 +1,18 @@ -const j = { +const B = { west: -180, south: -85.06, east: 180, north: 85.06 -}, O = { +}, x = { west: 122, south: 20, east: 154, north: 46 }; -function c0(I) { +function T0(I) { return I.endsWith(".prj") && I.includes("日本周辺座標系_WKT2_英語/") && I.includes("/投影座標系/"); } -function G0(I) { +function M0(I) { if (I.includes("/投影座標系/Web メルカトル/") || I.includes("/WGS 1984/")) return "wgs84"; if (I.includes("/日本測地系 (Tokyo)/")) return "tokyo"; if (I.includes("/日本測地系2000 (JGD2000)/")) return "jgd2000"; @@ -20,7 +20,7 @@ function G0(I) { if (I.includes("/日本測地系2024 (JGD2024)/")) return "jgd2024"; throw new Error(`Unsupported datum family in path: ${I}`); } -function N0(I) { +function g0(I) { if (I.includes("/投影座標系/Web メルカトル/")) return "web_mercator"; if (I.includes("/投影座標系/UTM 座標系/")) return "utm"; if (I.includes("/投影座標系/平面直角座標系/")) @@ -32,17 +32,17 @@ function N0(I) { if (I.includes("/投影座標系/天気図/")) return "weather_map"; throw new Error(`Unsupported projected kind in path: ${I}`); } -function u0(I, A) { +function S0(I, A) { return A === "all" ? !0 : [ "web_mercator", "utm", "japan_plane_rectangular" ].includes(I); } -function F0(I) { +function m0(I) { return I !== "tokyo"; } -function d0(I) { +function f0(I) { const e = [...I.matchAll(/ID\["([^"]+)","([^"]+)"\]/g)].at(-1); if (e) return { @@ -50,12 +50,12 @@ function d0(I) { code: e[2] }; } -function C0(I) { +function h0(I) { const A = I.match(/^[A-Z]+CRS\["([^"]+)"/); if (!A) throw new Error(`Unable to extract WKT root name from: ${I}`); return A[1]; } -function l0(I) { +function R0(I) { switch (I) { case "web_mercator": return 100; @@ -71,41 +71,41 @@ function l0(I) { return 40; } } -function y(I) { +function z(I) { const A = I.match(/Zone[_ ](\d{2})N|第 ?(\d{2}) ?帯 ?N/u), e = A?.[1] ?? A?.[2]; if (!e) throw new Error(`Unable to extract UTM zone from path: ${I}`); return Number(e); } -function T0(I, A) { - if (A === "web_mercator") return j; +function p0(I, A) { + if (A === "web_mercator") return B; if (A === "utm") { - const U = (y(I) - 1) * 6 - 180; + const t = (z(I) - 1) * 6 - 180; return { - west: U, + west: t, south: 0, - east: U + 6, + east: t + 6, north: 84 }; } - return O; + return x; } -function M0(I) { - const { west: A, south: e, east: U, north: r } = I; - return `POLYGON((${A} ${e}, ${U} ${e}, ${U} ${r}, ${A} ${r}, ${A} ${e}))`; +function b0(I) { + const { west: A, south: e, east: t, north: U } = I; + return `POLYGON((${A} ${e}, ${t} ${e}, ${t} ${U}, ${A} ${U}, ${A} ${e}))`; } -async function g(I, A, e) { - const U = await A(); +async function S(I, A, e) { + const t = await A(); try { - return await e(U); + return await e(t); } finally { - if (U != null) + if (t != null) try { - await I.projDestroy({ pj: U }); + await I.projDestroy({ pj: t }); } catch { } } } -class W { +class J { entries = /* @__PURE__ */ new Map(); capacity; constructor(A) { @@ -117,31 +117,31 @@ class W { get size() { return this.entries.size; } - async use(A, e, U, r) { - let t = this.entries.get(e); - t === void 0 ? (t = { object: this.createObject(e, U), users: 0, evicted: !1, released: !1 }, this.entries.set(e, t), this.evictOverflow(A)) : (this.entries.delete(e), this.entries.set(e, t)), t.users += 1; + async use(A, e, t, U) { + let n = this.entries.get(e); + n === void 0 ? (n = { object: this.createObject(e, t), users: 0, evicted: !1, released: !1 }, this.entries.set(e, n), this.evictOverflow(A)) : (this.entries.delete(e), this.entries.set(e, n)), n.users += 1; try { - let n; + let o; try { - n = await t.object; - } catch (_) { - throw this.entries.get(e) === t && this.entries.delete(e), t.released = !0, _; + o = await n.object; + } catch (r) { + throw this.entries.get(e) === n && this.entries.delete(e), n.released = !0, r; } - return await r(n); + return await U(o); } finally { - t.users -= 1, t.evicted && t.users === 0 && await this.release(A, t); + n.users -= 1, n.evicted && n.users === 0 && await this.release(A, n); } } async createObject(A, e) { - const U = await e(); - if (U == null) + const t = await e(); + if (t == null) throw new Error(`The PROJ object for ${String(A)} could not be created`); - return U; + return t; } evictOverflow(A) { for (; this.entries.size > this.capacity; ) { - const [e, U] = this.entries.entries().next().value; - this.entries.delete(e), U.evicted = !0, U.users === 0 && this.release(A, U); + const [e, t] = this.entries.entries().next().value; + this.entries.delete(e), t.evicted = !0, t.users === 0 && this.release(A, t); } } async release(A, e) { @@ -154,7 +154,7 @@ class W { } } } -const b = { +const E = { wkt2Unavailable: [ "IAU_2015:1020", "IAU_2015:1025", @@ -1271,17 +1271,17 @@ const b = { reason: "Unsupported conversion method: Berghaus_Star" } ] -}, L = 15, v = "Earth"; -function w(I) { +}, w = 15, K = "Earth"; +function L(I) { const A = I.celestialBodyName; if (!(typeof A != "string" || A.length === 0)) - return A === v ? void 0 : A; + return A === K ? void 0 : A; } -const D = new Set(b.wkt2Unavailable), B = new Map( - b.proj4Unavailable.map((I) => [I.id, I.reason]) -), x = "+proj=longlat +datum=WGS84 +no_defs +type=crs"; -let l, T, M, h = !1, u; -const F = /* @__PURE__ */ new Map(), z = new URL("data:application/json;base64,ewogICJwcm9qZWN0aW9ucyI6IFsKICAgIHsKICAgICAgImF1dGhvcml0eSI6ICJVVEpDUCIsCiAgICAgICJjb2RlIjogIlpPTkUtQSIsCiAgICAgICJuYW1lIjogIkphcGFuIENpdHkgUGxhbnMgUG9seWNvbmljIEdyaWQgWm9uZSBBIiwKICAgICAgImFyZWFOYW1lIjogIkphcGFuIENpdHkgUGxhbnMgLSBIaXN0b3JpY2FsIEdyaWQgWm9uZSBBIiwKICAgICAgImRlcHJlY2F0ZWQiOiBmYWxzZSwKICAgICAgImJib3giOiB7CiAgICAgICAgIndlc3QiOiAxMzksCiAgICAgICAgInNvdXRoIjogMjQsCiAgICAgICAgImVhc3QiOiAxNDcsCiAgICAgICAgIm5vcnRoIjogNDYKICAgICAgfSwKICAgICAgInBpcGVsaW5lIjogIitwcm9qPXBpcGVsaW5lICtzdGVwICtpbnYgK3Byb2o9cG9seSArbGF0XzA9NDAuNSArbG9uXzA9MTQzICt4XzA9OTE0Mzk4LjUzMDc0NDQ0MDggK3lfMD0xODI4Nzk3LjA2MTQ4ODg4MTYgK2VsbHBzPWNscms2NiArdG9fbWV0ZXI9MC45MTQzOTg1MzA3NDQ0NDA4ICtzdGVwICtwcm9qPWNhcnQgK2VsbHBzPWJlc3NlbCArc3RlcCArcHJvaj1oZWxtZXJ0ICt4PS0xNDYuMzM2ICt5PTUwNi44MzIgK3o9NjgwLjI1NCArc3RlcCAraW52ICtwcm9qPWNhcnQgK2VsbHBzPVdHUzg0ICtzdGVwICtwcm9qPXVuaXRjb252ZXJ0ICt4eV9pbj1yYWQgK3h5X291dD1kZWciLAogICAgICAid2t0MiI6IG51bGwsCiAgICAgICJpc0FwcHJveGltYXRlIjogdHJ1ZSwKICAgICAgInNvdXJjZSI6IFsKICAgICAgICAiaHR0cHM6Ly9ibG9nLmNvZGU0aGlzdG9yeS5kZXYvZW50cnkvMjAxMzAyMjAvMTM2MTM5MDE0OCIsCiAgICAgICAgImh0dHBzOi8vYmxvZy5jb2RlNGhpc3RvcnkuZGV2L2VudHJ5LzIwMTMwMjE2LzEzNjA5NjgyMjAiCiAgICAgIF0KICAgIH0sCiAgICB7CiAgICAgICJhdXRob3JpdHkiOiAiVVRKQ1AiLAogICAgICAiY29kZSI6ICJaT05FLUIiLAogICAgICAibmFtZSI6ICJKYXBhbiBDaXR5IFBsYW5zIFBvbHljb25pYyBHcmlkIFpvbmUgQiIsCiAgICAgICJhcmVhTmFtZSI6ICJKYXBhbiBDaXR5IFBsYW5zIC0gSGlzdG9yaWNhbCBHcmlkIFpvbmUgQiIsCiAgICAgICJkZXByZWNhdGVkIjogZmFsc2UsCiAgICAgICJiYm94IjogewogICAgICAgICJ3ZXN0IjogMTMxLAogICAgICAgICJzb3V0aCI6IDI0LAogICAgICAgICJlYXN0IjogMTM5LAogICAgICAgICJub3J0aCI6IDQ2CiAgICAgIH0sCiAgICAgICJwaXBlbGluZSI6ICIrcHJvaj1waXBlbGluZSArc3RlcCAraW52ICtwcm9qPXBvbHkgK2xhdF8wPTQwLjUgK2xvbl8wPTEzNSAreF8wPTkxNDM5OC41MzA3NDQ0NDA4ICt5XzA9MTgyODc5Ny4wNjE0ODg4ODE2ICtlbGxwcz1jbHJrNjYgK3RvX21ldGVyPTAuOTE0Mzk4NTMwNzQ0NDQwOCArc3RlcCArcHJvaj1jYXJ0ICtlbGxwcz1iZXNzZWwgK3N0ZXAgK3Byb2o9aGVsbWVydCAreD0tMTQ2LjMzNiAreT01MDYuODMyICt6PTY4MC4yNTQgK3N0ZXAgK2ludiArcHJvaj1jYXJ0ICtlbGxwcz1XR1M4NCArc3RlcCArcHJvaj11bml0Y29udmVydCAreHlfaW49cmFkICt4eV9vdXQ9ZGVnIiwKICAgICAgIndrdDIiOiBudWxsLAogICAgICAiaXNBcHByb3hpbWF0ZSI6IHRydWUsCiAgICAgICJzb3VyY2UiOiBbCiAgICAgICAgImh0dHBzOi8vYmxvZy5jb2RlNGhpc3RvcnkuZGV2L2VudHJ5LzIwMTMwMjIwLzEzNjEzOTAxNDgiLAogICAgICAgICJodHRwczovL2Jsb2cuY29kZTRoaXN0b3J5LmRldi9lbnRyeS8yMDEzMDIxNi8xMzYwOTY4MjIwIgogICAgICBdCiAgICB9LAogICAgewogICAgICAiYXV0aG9yaXR5IjogIlVUSkNQIiwKICAgICAgImNvZGUiOiAiWk9ORS1DIiwKICAgICAgIm5hbWUiOiAiSmFwYW4gQ2l0eSBQbGFucyBQb2x5Y29uaWMgR3JpZCBab25lIEMiLAogICAgICAiYXJlYU5hbWUiOiAiSmFwYW4gQ2l0eSBQbGFucyAtIEhpc3RvcmljYWwgR3JpZCBab25lIEMiLAogICAgICAiZGVwcmVjYXRlZCI6IGZhbHNlLAogICAgICAiYmJveCI6IHsKICAgICAgICAid2VzdCI6IDEyMywKICAgICAgICAic291dGgiOiAyNCwKICAgICAgICAiZWFzdCI6IDEzMSwKICAgICAgICAibm9ydGgiOiA0NgogICAgICB9LAogICAgICAicGlwZWxpbmUiOiAiK3Byb2o9cGlwZWxpbmUgK3N0ZXAgK2ludiArcHJvaj1wb2x5ICtsYXRfMD00MC41ICtsb25fMD0xMjcgK3hfMD05MTQzOTguNTMwNzQ0NDQwOCAreV8wPTE4Mjg3OTcuMDYxNDg4ODgxNiArZWxscHM9Y2xyazY2ICt0b19tZXRlcj0wLjkxNDM5ODUzMDc0NDQ0MDggK3N0ZXAgK3Byb2o9Y2FydCArZWxscHM9YmVzc2VsICtzdGVwICtwcm9qPWhlbG1lcnQgK3g9LTE0Ni4zMzYgK3k9NTA2LjgzMiArej02ODAuMjU0ICtzdGVwICtpbnYgK3Byb2o9Y2FydCArZWxscHM9V0dTODQgK3N0ZXAgK3Byb2o9dW5pdGNvbnZlcnQgK3h5X2luPXJhZCAreHlfb3V0PWRlZyIsCiAgICAgICJ3a3QyIjogbnVsbCwKICAgICAgImlzQXBwcm94aW1hdGUiOiB0cnVlLAogICAgICAic291cmNlIjogWwogICAgICAgICJodHRwczovL2Jsb2cuY29kZTRoaXN0b3J5LmRldi9lbnRyeS8yMDEzMDIyMC8xMzYxMzkwMTQ4IiwKICAgICAgICAiaHR0cHM6Ly9ibG9nLmNvZGU0aGlzdG9yeS5kZXYvZW50cnkvMjAxMzAyMTYvMTM2MDk2ODIyMCIKICAgICAgXQogICAgfQogIF0KfQo=", import.meta.url), K = [ +const Z = new Set(E.wkt2Unavailable), Y = new Map( + E.proj4Unavailable.map((I) => [I.id, I.reason]) +), Q = "+proj=longlat +datum=WGS84 +no_defs +type=crs"; +let F, T, M, f = !1, N; +const l = /* @__PURE__ */ new Map(), V = new URL("data:application/json;base64,ewogICJwcm9qZWN0aW9ucyI6IFsKICAgIHsKICAgICAgImF1dGhvcml0eSI6ICJVVEpDUCIsCiAgICAgICJjb2RlIjogIlpPTkUtQSIsCiAgICAgICJuYW1lIjogIkphcGFuIENpdHkgUGxhbnMgUG9seWNvbmljIEdyaWQgWm9uZSBBIiwKICAgICAgImFyZWFOYW1lIjogIkphcGFuIENpdHkgUGxhbnMgLSBIaXN0b3JpY2FsIEdyaWQgWm9uZSBBIiwKICAgICAgImRlcHJlY2F0ZWQiOiBmYWxzZSwKICAgICAgImJib3giOiB7CiAgICAgICAgIndlc3QiOiAxMzksCiAgICAgICAgInNvdXRoIjogMjQsCiAgICAgICAgImVhc3QiOiAxNDcsCiAgICAgICAgIm5vcnRoIjogNDYKICAgICAgfSwKICAgICAgInBpcGVsaW5lIjogIitwcm9qPXBpcGVsaW5lICtzdGVwICtpbnYgK3Byb2o9cG9seSArbGF0XzA9NDAuNSArbG9uXzA9MTQzICt4XzA9OTE0Mzk4LjUzMDc0NDQ0MDggK3lfMD0xODI4Nzk3LjA2MTQ4ODg4MTYgK2VsbHBzPWNscms2NiArdG9fbWV0ZXI9MC45MTQzOTg1MzA3NDQ0NDA4ICtzdGVwICtwcm9qPWNhcnQgK2VsbHBzPWJlc3NlbCArc3RlcCArcHJvaj1oZWxtZXJ0ICt4PS0xNDYuMzM2ICt5PTUwNi44MzIgK3o9NjgwLjI1NCArc3RlcCAraW52ICtwcm9qPWNhcnQgK2VsbHBzPVdHUzg0ICtzdGVwICtwcm9qPXVuaXRjb252ZXJ0ICt4eV9pbj1yYWQgK3h5X291dD1kZWciLAogICAgICAidW5pdCI6IHsKICAgICAgICAibmFtZSI6ICJ5YXJkIChKYXBhbiBDaXR5IFBsYW5zKSIsCiAgICAgICAgIm1ldHJlc1BlclVuaXQiOiAwLjkxNDM5ODUzMDc0NDQ0MDgKICAgICAgfSwKICAgICAgIndrdDIiOiBudWxsLAogICAgICAiaXNBcHByb3hpbWF0ZSI6IHRydWUsCiAgICAgICJzb3VyY2UiOiBbCiAgICAgICAgImh0dHBzOi8vYmxvZy5jb2RlNGhpc3RvcnkuZGV2L2VudHJ5LzIwMTMwMjIwLzEzNjEzOTAxNDgiLAogICAgICAgICJodHRwczovL2Jsb2cuY29kZTRoaXN0b3J5LmRldi9lbnRyeS8yMDEzMDIxNi8xMzYwOTY4MjIwIgogICAgICBdCiAgICB9LAogICAgewogICAgICAiYXV0aG9yaXR5IjogIlVUSkNQIiwKICAgICAgImNvZGUiOiAiWk9ORS1CIiwKICAgICAgIm5hbWUiOiAiSmFwYW4gQ2l0eSBQbGFucyBQb2x5Y29uaWMgR3JpZCBab25lIEIiLAogICAgICAiYXJlYU5hbWUiOiAiSmFwYW4gQ2l0eSBQbGFucyAtIEhpc3RvcmljYWwgR3JpZCBab25lIEIiLAogICAgICAiZGVwcmVjYXRlZCI6IGZhbHNlLAogICAgICAiYmJveCI6IHsKICAgICAgICAid2VzdCI6IDEzMSwKICAgICAgICAic291dGgiOiAyNCwKICAgICAgICAiZWFzdCI6IDEzOSwKICAgICAgICAibm9ydGgiOiA0NgogICAgICB9LAogICAgICAicGlwZWxpbmUiOiAiK3Byb2o9cGlwZWxpbmUgK3N0ZXAgK2ludiArcHJvaj1wb2x5ICtsYXRfMD00MC41ICtsb25fMD0xMzUgK3hfMD05MTQzOTguNTMwNzQ0NDQwOCAreV8wPTE4Mjg3OTcuMDYxNDg4ODgxNiArZWxscHM9Y2xyazY2ICt0b19tZXRlcj0wLjkxNDM5ODUzMDc0NDQ0MDggK3N0ZXAgK3Byb2o9Y2FydCArZWxscHM9YmVzc2VsICtzdGVwICtwcm9qPWhlbG1lcnQgK3g9LTE0Ni4zMzYgK3k9NTA2LjgzMiArej02ODAuMjU0ICtzdGVwICtpbnYgK3Byb2o9Y2FydCArZWxscHM9V0dTODQgK3N0ZXAgK3Byb2o9dW5pdGNvbnZlcnQgK3h5X2luPXJhZCAreHlfb3V0PWRlZyIsCiAgICAgICJ1bml0IjogewogICAgICAgICJuYW1lIjogInlhcmQgKEphcGFuIENpdHkgUGxhbnMpIiwKICAgICAgICAibWV0cmVzUGVyVW5pdCI6IDAuOTE0Mzk4NTMwNzQ0NDQwOAogICAgICB9LAogICAgICAid2t0MiI6IG51bGwsCiAgICAgICJpc0FwcHJveGltYXRlIjogdHJ1ZSwKICAgICAgInNvdXJjZSI6IFsKICAgICAgICAiaHR0cHM6Ly9ibG9nLmNvZGU0aGlzdG9yeS5kZXYvZW50cnkvMjAxMzAyMjAvMTM2MTM5MDE0OCIsCiAgICAgICAgImh0dHBzOi8vYmxvZy5jb2RlNGhpc3RvcnkuZGV2L2VudHJ5LzIwMTMwMjE2LzEzNjA5NjgyMjAiCiAgICAgIF0KICAgIH0sCiAgICB7CiAgICAgICJhdXRob3JpdHkiOiAiVVRKQ1AiLAogICAgICAiY29kZSI6ICJaT05FLUMiLAogICAgICAibmFtZSI6ICJKYXBhbiBDaXR5IFBsYW5zIFBvbHljb25pYyBHcmlkIFpvbmUgQyIsCiAgICAgICJhcmVhTmFtZSI6ICJKYXBhbiBDaXR5IFBsYW5zIC0gSGlzdG9yaWNhbCBHcmlkIFpvbmUgQyIsCiAgICAgICJkZXByZWNhdGVkIjogZmFsc2UsCiAgICAgICJiYm94IjogewogICAgICAgICJ3ZXN0IjogMTIzLAogICAgICAgICJzb3V0aCI6IDI0LAogICAgICAgICJlYXN0IjogMTMxLAogICAgICAgICJub3J0aCI6IDQ2CiAgICAgIH0sCiAgICAgICJwaXBlbGluZSI6ICIrcHJvaj1waXBlbGluZSArc3RlcCAraW52ICtwcm9qPXBvbHkgK2xhdF8wPTQwLjUgK2xvbl8wPTEyNyAreF8wPTkxNDM5OC41MzA3NDQ0NDA4ICt5XzA9MTgyODc5Ny4wNjE0ODg4ODE2ICtlbGxwcz1jbHJrNjYgK3RvX21ldGVyPTAuOTE0Mzk4NTMwNzQ0NDQwOCArc3RlcCArcHJvaj1jYXJ0ICtlbGxwcz1iZXNzZWwgK3N0ZXAgK3Byb2o9aGVsbWVydCAreD0tMTQ2LjMzNiAreT01MDYuODMyICt6PTY4MC4yNTQgK3N0ZXAgK2ludiArcHJvaj1jYXJ0ICtlbGxwcz1XR1M4NCArc3RlcCArcHJvaj11bml0Y29udmVydCAreHlfaW49cmFkICt4eV9vdXQ9ZGVnIiwKICAgICAgInVuaXQiOiB7CiAgICAgICAgIm5hbWUiOiAieWFyZCAoSmFwYW4gQ2l0eSBQbGFucykiLAogICAgICAgICJtZXRyZXNQZXJVbml0IjogMC45MTQzOTg1MzA3NDQ0NDA4CiAgICAgIH0sCiAgICAgICJ3a3QyIjogbnVsbCwKICAgICAgImlzQXBwcm94aW1hdGUiOiB0cnVlLAogICAgICAic291cmNlIjogWwogICAgICAgICJodHRwczovL2Jsb2cuY29kZTRoaXN0b3J5LmRldi9lbnRyeS8yMDEzMDIyMC8xMzYxMzkwMTQ4IiwKICAgICAgICAiaHR0cHM6Ly9ibG9nLmNvZGU0aGlzdG9yeS5kZXYvZW50cnkvMjAxMzAyMTYvMTM2MDk2ODIyMCIKICAgICAgXQogICAgfQogIF0KfQo=", import.meta.url), H = [ "proj.db", "proj.ini", "proj-worker.mjs", @@ -1289,56 +1289,56 @@ const F = /* @__PURE__ */ new Map(), z = new URL("data:application/json;base64,e "proj-emscripten.js", "proj-emscripten.wasm" ]; -function J() { +function X() { return typeof globalThis.document < "u"; } -function Z() { +function k() { const I = new URL(import.meta.url); return I.pathname = I.pathname.replace(/[^/]*$/, ""), I.search = "", I.hash = "", I; } -function Y(I = Z()) { - if (!J() || h || typeof fetch != "function") +function $(I = k()) { + if (!X() || f || typeof fetch != "function") return; const A = typeof I == "string" ? new URL(I, import.meta.url) : I; globalThis.__ATLAS_PROJ_WASM_ASSET_BASE_URL__ = A.href; const e = globalThis.fetch.bind(globalThis); - globalThis.fetch = async (U, r) => { - const t = typeof U == "string" ? U : U instanceof URL ? U.href : U.url, n = globalThis.location?.href ?? import.meta.url, _ = new URL(t, n).pathname, s = K.find((i) => _.endsWith(`/${i}`)); - if (s) { - const i = new URL(s, A), c = await e(i, r); - if (s === "proj.db") { - const a = Number(c.headers.get("content-length") ?? 0) || (await c.clone().arrayBuffer()).byteLength; - if (a < 1e6) + globalThis.fetch = async (t, U) => { + const n = typeof t == "string" ? t : t instanceof URL ? t.href : t.url, o = globalThis.location?.href ?? import.meta.url, r = new URL(n, o).pathname, i = H.find((a) => r.endsWith(`/${a}`)); + if (i) { + const a = new URL(i, A), G = await e(a, U); + if (i === "proj.db") { + const s = Number(G.headers.get("content-length") ?? 0) || (await G.clone().arrayBuffer()).byteLength; + if (s < 1e6) throw new Error( - `Resolved proj.db from ${i.href}, but received only ${a} bytes. Restart the Vite dev server and verify that the proj-wasm runtime assets are served from the demo assets path.` + `Resolved proj.db from ${a.href}, but received only ${s} bytes. Restart the Vite dev server and verify that the proj-wasm runtime assets are served from the demo assets path.` ); } - return c; + return G; } - return e(U, r); - }, h = !0; + return e(t, U); + }, f = !0; } -function C() { +function d() { return T || (T = import("proj-wasm")), T; } async function m(I) { - return l || (Y(I?.projWasmAssetBaseUrl), l = C().then((A) => A.init({ workers: 1 }))), l; + return F || ($(I?.projWasmAssetBaseUrl), F = d().then((A) => A.init({ workers: 1 }))), F; } -function R(I, A) { +function h(I, A) { return I <= A ? [[I, A]] : [ [I, 180], [-180, A] ]; } -function H(I, A) { +function q(I, A) { if (I.south > A.south || I.north < A.north) return !1; - const e = R(I.west, I.east); - return R(A.west, A.east).every(([r, t]) => e.some( - ([n, _]) => n <= r && _ >= t + const e = h(I.west, I.east); + return h(A.west, A.east).every(([U, n]) => e.some( + ([o, r]) => o <= U && r >= n )); } -function V(I, A) { +function I0(I, A) { return A <= 0 ? I : { west: Math.max(-180, I.west - A), south: Math.max(-90, I.south - A), @@ -1346,34 +1346,34 @@ function V(I, A) { north: Math.min(90, I.north + A) }; } -function Q(I) { +function A0(I) { return { ...I, bbox: { ...I.bbox } }; } -async function X(I) { +async function e0(I) { const A = `${I.authName}:${I.code}`; - let e = F.get(A); - return e === void 0 && (e = $(I), F.set(A, e), e.catch(() => { - F.get(A) === e && F.delete(A); - })), Q(await e); + let e = l.get(A); + return e === void 0 && (e = U0(I), l.set(A, e), e.catch(() => { + l.get(A) === e && l.delete(A); + })), A0(await e); } -async function k(I) { - return u || (u = I.projGetCrsInfoListFromDatabase({}), u.catch(() => { - u = void 0; - })), u; +async function t0(I) { + return N || (N = I.projGetCrsInfoListFromDatabase({}), N.catch(() => { + N = void 0; + })), N; } -async function $(I) { - const A = await C(), e = `${I.authName}:${I.code}`, [U, r] = await g(A, () => A.projCreateFromDatabase({ +async function U0(I) { + const A = await d(), e = `${I.authName}:${I.code}`, [t, U] = await S(A, () => A.projCreateFromDatabase({ auth_name: I.authName, code: I.code, category: A.PJ_CATEGORY_CRS - }), (t) => Promise.all([ - D.has(e) ? null : A.projAsWkt({ - pj: t, + }), (n) => Promise.all([ + Z.has(e) ? null : A.projAsWkt({ + pj: n, type: A.PJ_WKT2_2019, options: null }), A.projAsProjString({ - pj: t, + pj: n, type: A.PJ_PROJ_5, options: null }) @@ -1391,17 +1391,17 @@ async function $(I) { east: I.eastLonDegree, north: I.northLatDegree }, - wkt2: U, - proj4: r, + wkt2: t, + proj4: U, definitionType: "projdb" }; } -function f(I) { +function R(I) { return { authName: I.authority, code: I.code, name: I.name, - type: L, + type: w, deprecated: I.deprecated, bboxValid: !0, westLonDegree: I.bbox.west, @@ -1430,25 +1430,26 @@ function p(I, A) { pipeline: I.pipeline, isApproximate: I.isApproximate ?? !1, source: [...I.source ?? []], + ...I.unit === void 0 ? {} : { unit: { ...I.unit } }, definitionType: A }; } -function S(I, A) { - const e = A.area?.trim().toLowerCase(), U = A.bboxPaddingDeg ?? 0.1, r = A.includeDeprecated ?? !1, t = A.includeOtherCelestialBodies ?? !1, n = A.onshoreOnly ?? !1; - return I.filter((_) => { - const s = _.areaName.toLowerCase(); - return _.type !== L || !_.bboxValid || !r && _.deprecated || !t && w(_) !== void 0 || e && !s.includes(e) || n && (!s.includes("onshore") || s.includes("onshore and offshore")) ? !1 : H(V({ - west: _.westLonDegree, - south: _.southLatDegree, - east: _.eastLonDegree, - north: _.northLatDegree - }, U), A.extent); +function g(I, A) { + const e = A.area?.trim().toLowerCase(), t = A.bboxPaddingDeg ?? 0.1, U = A.includeDeprecated ?? !1, n = A.includeOtherCelestialBodies ?? !1, o = A.onshoreOnly ?? !1; + return I.filter((r) => { + const i = r.areaName.toLowerCase(); + return r.type !== w || !r.bboxValid || !U && r.deprecated || !n && L(r) !== void 0 || e && !i.includes(e) || o && (!i.includes("onshore") || i.includes("onshore and offshore")) ? !1 : q(I0({ + west: r.westLonDegree, + south: r.southLatDegree, + east: r.eastLonDegree, + north: r.northLatDegree + }, t), A.extent); }); } -async function S0(I = {}) { +async function E0(I = {}) { await m(I); } -function q(I) { +function n0(I) { const A = I.match(/^data:.*?(;base64)?,(.*)$/); if (!A) throw new Error(`Unsupported data URL for custom projection resource: ${I}`); @@ -1456,160 +1457,227 @@ function q(I) { if (typeof Buffer < "u") return Buffer.from(A[2], "base64").toString("utf8"); const e = atob(A[2]); - return new TextDecoder().decode(Uint8Array.from(e, (U) => U.charCodeAt(0))); + return new TextDecoder().decode(Uint8Array.from(e, (t) => t.charCodeAt(0))); } return decodeURIComponent(A[2]); } -async function I0(I) { +async function r0(I) { const A = typeof I == "string" ? new URL(I, import.meta.url) : I, e = A.href; if (e.startsWith("data:")) - return q(e); + return n0(e); if (!e.startsWith("file:") && typeof fetch == "function") { - const t = await fetch(A); - if (!t.ok) - throw new Error(`Failed to load projection resource ${e}: ${t.status} ${t.statusText}`); - return t.text(); + const n = await fetch(A); + if (!n.ok) + throw new Error(`Failed to load projection resource ${e}: ${n.status} ${n.statusText}`); + return n.text(); } - const U = "node:fs/promises", { readFile: r } = await import( + const t = "node:fs/promises", { readFile: U } = await import( /* @vite-ignore */ - U + t ); - return r(A, "utf8"); + return U(A, "utf8"); } async function P(I) { - const A = await I0(I), e = JSON.parse(A); + const A = await r0(I), e = JSON.parse(A); return Array.isArray(e) ? e : e.projections; } -async function A0() { - return M || (M = P(z)), M; +async function o0() { + return M || (M = P(V)), M; } -async function g0(I) { +async function w0(I) { await m(I); - const A = await C(), e = await k(A), U = S(e, { ...I, includeOtherCelestialBodies: !0 }), r = I.includeOtherCelestialBodies ?? !1, t = /* @__PURE__ */ new Map(), n = [], _ = U.filter((o) => { - const a = `${o.authName}:${o.code}`, N = w(o); - if (!r && N !== void 0) - return n.push({ id: a, name: o.name, reason: `Not on the Earth (celestial body: ${N})` }), !1; - const G = B.get(a); - return G === void 0 ? !0 : (n.push({ id: a, name: o.name, reason: G }), !1); + const A = await d(), e = await t0(A), t = g(e, { ...I, includeOtherCelestialBodies: !0 }), U = I.includeOtherCelestialBodies ?? !1, n = /* @__PURE__ */ new Map(), o = [], r = t.filter((_) => { + const s = `${_.authName}:${_.code}`, u = L(_); + if (!U && u !== void 0) + return o.push({ id: s, name: _.name, reason: `Not on the Earth (celestial body: ${u})` }), !1; + const c = Y.get(s); + return c === void 0 ? !0 : (o.push({ id: s, name: _.name, reason: c }), !1); }); - I.onExcluded?.(n); - const s = await Promise.all(_.map((o) => X(o))); - for (const o of s) - t.set(o.id, o); + I.onExcluded?.(o); + const i = await Promise.all(r.map((_) => e0(_))); + for (const _ of i) + n.set(_.id, _); if (I.includeCustomProjections) { - const o = await A0(); - for (const a of o) { - const N = f(a); - if (!S([N], I).length) continue; - const G = p(a, "custom"); - t.set(G.id, G); + const _ = await o0(); + for (const s of _) { + const u = R(s); + if (!g([u], I).length) continue; + const c = p(s, "custom"); + n.set(c.id, c); } } - const i = I.userProjection ?? I.userProjectionFile; - if (i) { - const o = await P(i); - for (const a of o) { - const N = f(a); - if (!S([N], I).length) continue; - const G = p(a, "user"); - t.set(G.id, G); + const a = I.userProjection ?? I.userProjectionFile; + if (a) { + const _ = await P(a); + for (const s of _) { + const u = R(s); + if (!g([u], I).length) continue; + const c = p(s, "user"); + n.set(c.id, c); } } - const c = [...t.values()]; - return c.sort((o, a) => o.id.localeCompare(a.id)), c; + const G = [...n.values()]; + return G.sort((_, s) => _.id.localeCompare(s.id)), G; } -const d = /* @__PURE__ */ new Map(); -function e0(I, A) { - let e = d.get(A); +const C = /* @__PURE__ */ new Map(); +function _0(I, A) { + let e = C.get(A); if (e === void 0) { - const U = o0(I, A); - e = U, d.set(A, U), U.catch(() => { - d.get(A) === U && d.delete(A); + const t = i0(I, A); + e = t, C.set(A, t), t.catch(() => { + C.get(A) === t && C.delete(A); }); } return e; } -const U0 = /^osgeo::proj::io::(?:ParsingException|NoSuchAuthorityCodeException)\b/; -function t0(I) { - return I instanceof Error && U0.test(I.stack ?? ""); -} -const r0 = /^osgeo::proj::util::UnsupportedOperationException\b/; -function _0(I) { - return I instanceof Error && r0.test(I.stack ?? ""); -} -const E = /* @__PURE__ */ new Set(); -async function o0(I, A) { +async function i0(I, A) { try { - return await n0(I, A); + return await s0(I, A); } catch (e) { - if (t0(e)) return !1; + if (y(e)) return { kind: "unreadable", unit: null }; throw e; } } -async function n0(I, A) { - return g(I, () => I.projCreate({ definition: A }), async (e) => e == null || !await I.projIsCrs({ obj: e }) ? !1 : g(I, () => I.projCrsGetCoordinateSystem({ crs: e }), async (U) => { - if (U == null || await I.projCsGetAxisCount({ cs: U }) !== 2) return !1; - const r = [ - await I.projCsGetAxisInfo({ cs: U, index: 0 }), - await I.projCsGetAxisInfo({ cs: U, index: 1 }) +async function s0(I, A) { + return S(I, () => I.projCreate({ definition: A }), async (e) => e == null ? { kind: "unreadable", unit: null } : await I.projIsCrs({ obj: e }) ? S(I, () => I.projCrsGetCoordinateSystem({ crs: e }), async (t) => { + if (t == null) return { kind: "crs", unit: null }; + if (await I.projCsGetType({ cs: t }) !== I.PJ_CS_TYPE_CARTESIAN) + return { kind: "crs", unit: null }; + if (await I.projCsGetAxisCount({ cs: t }) !== 2) return { kind: "crs", unit: null }; + const U = [ + await I.projCsGetAxisInfo({ cs: t, index: 0 }), + await I.projCsGetAxisInfo({ cs: t, index: 1 }) ]; - return r[0]?.direction !== "east" || r[1]?.direction !== "north" ? !1 : r.every((t) => t.unitConvFactor === 1 && t.unitName === "metre"); - })); + return U[0]?.direction !== "east" || U[1]?.direction !== "north" ? { kind: "crs", unit: null } : { kind: "crs", unit: c0(U) }; + }) : { kind: "non-crs", unit: null }); +} +const a0 = 1e-12; +function c0(I) { + const [A, e] = I; + if (typeof A.unitName != "string" || A.unitName.length === 0 || A.unitName !== e.unitName) return null; + for (const U of I) + if (!Number.isFinite(U.unitConvFactor) || U.unitConvFactor <= 0) return null; + const t = Math.max(Math.abs(A.unitConvFactor), Math.abs(e.unitConvFactor)); + return Math.abs(A.unitConvFactor - e.unitConvFactor) > t * a0 ? null : { name: A.unitName, metresPerUnit: A.unitConvFactor }; +} +function G0(I) { + if (typeof I != "object" || I === null) return !1; + const A = I; + return typeof A.name != "string" || A.name.length === 0 ? !1 : typeof A.metresPerUnit == "number" && Number.isFinite(A.metresPerUnit) && A.metresPerUnit > 0; +} +function O(I) { + return I.metresPerUnit === 1 && I.name === "metre"; +} +const u0 = /^osgeo::proj::io::(?:ParsingException|NoSuchAuthorityCodeException)\b/; +function y(I) { + return I instanceof Error && u0.test(I.stack ?? ""); } -const s0 = 4096, a0 = new W(s0); -async function i0(I, A, e) { +const N0 = /^osgeo::proj::util::UnsupportedOperationException\b/; +function W(I) { + return I instanceof Error && N0.test(I.stack ?? ""); +} +const b = /* @__PURE__ */ new Set(), l0 = 4096, j = new J(l0); +async function v(I, A, e) { + const t = await C0(I, A, e); + return t === null ? null : { points: t.points, unit: { ...t.unit } }; +} +async function L0(I, A, e) { + const t = await v(I, A, e); + return t === null ? null : O(t.unit) ? t.points : null; +} +async function C0(I, A, e) { if (A.length === 0) return null; - for (const t of A) - if (!Number.isFinite(t[0]) || !Number.isFinite(t[1])) return null; + for (const r of A) + if (!Number.isFinite(r[0]) || !Number.isFinite(r[1])) return null; await m(e); - const U = await C(); - return !await e0(U, I.proj4) || E.has(I.proj4) ? null : await a0.use(U, I.proj4, () => U.projCreateCrsToCrs({ - source_crs: x, - target_crs: I.proj4 - }), async (t) => { - const n = await U.coordArray(A.length); - await U.setCoords( - n, - A.map(([s, i]) => [s, i, 0, 0]) - ), await U.projTransArray({ - p: t, - direction: U.PJ_FWD, - n: A.length, - coord: n - }); - const _ = []; - for (let s = 0; s < A.length; s += 1) { - const i = await U.getCoords(n, s), c = i?.[0], o = i?.[1]; - if (!Number.isFinite(c) || !Number.isFinite(o)) return null; - _.push([c, o]); - } - return _; - }).catch((t) => { - if (!_0(t)) throw t; - return E.add(I.proj4), null; + const t = await d(), U = await _0(t, I.proj4); + if (U.kind === "unreadable") return null; + if (U.kind === "crs") { + const r = U.unit; + if (r === null) return null; + const i = await d0(t, I.proj4, A); + return i === null ? null : { points: i, unit: r }; + } + const n = I.unit; + if (!G0(n)) return null; + const o = await F0(t, I.proj4, A); + return o === null ? null : { points: o, unit: { ...n } }; +} +function d0(I, A, e) { + return b.has(A) ? Promise.resolve(null) : j.use( + I, + A, + () => I.projCreateCrsToCrs({ + source_crs: Q, + target_crs: A + }), + (t) => D(I, t, e, I.PJ_FWD) + ).catch((t) => { + if (!W(t)) throw t; + return b.add(A), null; + }); +} +function F0(I, A, e) { + const t = `pipeline:${A}`; + return j.use( + I, + t, + () => I.projCreate({ definition: A }), + (U) => D(I, U, e, I.PJ_INV) + ).catch((U) => { + if (!y(U) && !W(U)) throw U; + return null; }); } -function m0(I, A) { - const e = new Map(I.map((U) => [U.id, { id: U.id, proj4: U.proj4 }])); - return async (U, r, t) => { - if (t.kind !== "lonlat_deg" || t.axisOrder !== "lon-lat") return null; - const n = e.get(U); - return n === void 0 ? null : i0(n, r, A); +async function D(I, A, e, t) { + const U = await I.coordArray(e.length); + await I.setCoords( + U, + e.map(([o, r]) => [o, r, 0, 0]) + ), await I.projTransArray({ + p: A, + direction: t, + n: e.length, + coord: U + }); + const n = []; + for (let o = 0; o < e.length; o += 1) { + const r = await I.getCoords(U, o), i = r?.[0], a = r?.[1]; + if (!Number.isFinite(i) || !Number.isFinite(a)) return null; + n.push([i, a]); + } + return n; +} +function P0(I, A) { + const e = /* @__PURE__ */ new Map(); + for (const t of I) + e.set(t.id, { + id: t.id, + proj4: t.proj4, + ...t.unit === void 0 ? {} : { unit: { ...t.unit } } + }); + return async (t, U, n) => { + if (n.kind !== "lonlat_deg" || n.axisOrder !== "lon-lat") return null; + const o = e.get(t); + if (o === void 0) return null; + const r = await v(o, U, A); + return r === null ? null : O(r.unit) ? r.points : { points: r.points, unit: r.unit }; }; } export { - M0 as bboxToPolygonWkt, - m0 as createCandidateTransformer, - d0 as extractAuthorityIdFromWkt, - C0 as extractWktName, - T0 as inferApproximateBbox, - G0 as inferDatumFamily, - N0 as inferKind, - l0 as inferPriority, - F0 as inferWgsEquivalent, - S0 as initializeProjWasmRuntime, - c0 as isProjectedPrjPath, - u0 as isScopeIncluded, - g0 as listProjectedCrsFromProjWasm, - i0 as transformLonLatArrayToProjected + b0 as bboxToPolygonWkt, + P0 as createCandidateTransformer, + f0 as extractAuthorityIdFromWkt, + h0 as extractWktName, + p0 as inferApproximateBbox, + M0 as inferDatumFamily, + g0 as inferKind, + R0 as inferPriority, + m0 as inferWgsEquivalent, + E0 as initializeProjWasmRuntime, + T0 as isProjectedPrjPath, + S0 as isScopeIncluded, + w0 as listProjectedCrsFromProjWasm, + L0 as transformLonLatArrayToProjected, + v as transformLonLatToCandidate }; diff --git a/dist/index.d.ts b/dist/index.d.ts index 6555e34..0b0453c 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,5 +1,5 @@ export { bboxToPolygonWkt, extractAuthorityIdFromWkt, extractWktName, inferApproximateBbox, inferDatumFamily, inferKind, inferPriority, inferWgsEquivalent, isProjectedPrjPath, isScopeIncluded, } from "./crs-seed"; -export { createCandidateTransformer, initializeProjWasmRuntime, listProjectedCrsFromProjWasm, transformLonLatArrayToProjected, } from "./proj-wasm-crs"; +export { createCandidateTransformer, initializeProjWasmRuntime, listProjectedCrsFromProjWasm, transformLonLatArrayToProjected, transformLonLatToCandidate, } from "./proj-wasm-crs"; export type { CandidateCrsExtent } from "./crs-types"; -export type { AtlasCandidateTransformer, AtlasGeoBasisLike, InitializeProjWasmRuntimeOptions, ListProjectedCrsFromProjWasmOptions, ProjectedCrsExclusion, ProjectedCrsInfo, TransformLonLatToProjectedOptions, } from "./proj-wasm-crs"; +export type { AtlasCandidateTransformer, AtlasGeoBasisLike, InitializeProjWasmRuntimeOptions, LinearUnit, ListProjectedCrsFromProjWasmOptions, ProjectedCrsExclusion, ProjectedCrsInfo, TransformLonLatToProjectedOptions, } from "./proj-wasm-crs"; export type { CandidateAuthorityId, CandidateBbox, CandidateDatumFamily, CandidateKind, CandidateSeedScope, } from "./crs-seed"; diff --git a/dist/proj-wasm-crs.d.ts b/dist/proj-wasm-crs.d.ts index 130a94b..208001c 100644 --- a/dist/proj-wasm-crs.d.ts +++ b/dist/proj-wasm-crs.d.ts @@ -1,4 +1,9 @@ import { CandidateCrsExtent } from "./crs-types"; +/** 座標の線形単位。`metresPerUnit` は 1 単位が何 metre か。 */ +export interface LinearUnit { + name: string; + metresPerUnit: number; +} export interface ProjectedCrsInfo { id: string; authority: string; @@ -13,6 +18,11 @@ export interface ProjectedCrsInfo { isApproximate?: boolean; source?: string[]; definitionType?: "projdb" | "custom" | "user"; + /** + * 格子の単位(定義ファイルの `unit`)。custom / user の pipeline の行にだけ付き、 + * proj.db の行には付かない(proj.db の行の単位は PROJ の軸情報から取る)。 + */ + unit?: LinearUnit; } /** A proj.db row that matched the search but was left out of the result, with the reason. */ export interface ProjectedCrsExclusion { @@ -90,13 +100,41 @@ export interface CustomProjectionDefinition { wkt2?: string | null; isApproximate?: boolean; source?: string[]; + /** 格子の単位。pipeline の行には必須(無いと変換できない)。 */ + unit?: LinearUnit; } export declare function filterProjectedCrsRows(rows: ProjDatabaseRow[], options: ListProjectedCrsFromProjWasmOptions): ProjDatabaseRow[]; export declare function initializeProjWasmRuntime(options?: InitializeProjWasmRuntimeOptions): Promise; export declare function listProjectedCrsFromProjWasm(options: ListProjectedCrsFromProjWasmOptions): Promise; +/** + * lon/lat(度・lon-lat 順)の点列を、候補の座標へ変換し、単位を添えて返す。 + * + * - CRS: 座標系が直交(Cartesian)で、軸が east / north の 2 次元で、両軸の単位が同じ線形単位 + * (換算係数が正の有限値・単位名が同じ)のときだけ変換する。地理座標系の軸は度で、換算係数が + * ラジアンであって metre への倍率ではないため対象外(`null`)。単位は PROJ の軸情報から取る + * (metre なら `{ name: "metre", metresPerUnit: 1 }`)。 + * - CRS でない pipeline(custom / user の行の「格子 → lon/lat(度)」の向きの定義): 行の `unit` を + * 添えて、pipeline を逆向き(`PJ_INV`)に流す。行に `unit` が無い・`unit` が線形単位として不正 + * (空の `name`、正の有限の number でない `metresPerUnit`)なときは `null`(業務的に変換不能)。 + * - 空入力・非有限の入力・非有限の出力・上の条件に合わない target は `null`(部分成功はしない)。 + * 実装側の理由で変換できないとき(PJ を作れない・proj-wasm の呼び出しが throw する。wasm の + * メモリが尽きた場合を含む)は `null` にせず throw する。 + * - 出力は入力と同数・同順で `[x, y]` 順(CRS なら easting / northing、pipeline なら格子の座標)。 + * - 返す `unit` は写しである。書き換えても次の呼び出しの結果は変わらない。 + * + * 本 export は **`lonlat_deg` / lon-lat 順の入力専用**である。`planar_xy` 入力の + * 実 engine 経路は持たない。 + */ +export declare function transformLonLatToCandidate(target: Pick & Partial>, points: ReadonlyArray, options?: TransformLonLatToProjectedOptions): Promise<{ + points: Array<[number, number]>; + unit: LinearUnit; +} | null>; /** * lon/lat(度・lon-lat 順)の点列を、target CRS の投影座標 `[easting, northing]` へ一括変換する。 * + * 変換は `transformLonLatToCandidate` が行う。本 export はその結果のうち、単位が metre + * (`name` が `metre` で `metresPerUnit` が 1)のときだけ点列を返し、それ以外は `null` を返す。 + * * 契約: * - (3) 返却は常に `[easting, northing]` 順である(proj 文字列 pipeline により機構で保証する)。 * - (4) 空入力・業務的に変換不能な target は `null`。 @@ -116,7 +154,12 @@ export type AtlasGeoBasisLike = { axisOrder?: string; }; /** A transformer with the same shape as Teiresias' `CandidateTransformer`. */ -export type AtlasCandidateTransformer = (id: string, points: ReadonlyArray, geoBasis: AtlasGeoBasisLike) => Promise | null>; +export type AtlasCandidateTransformer = (id: string, points: ReadonlyArray, geoBasis: AtlasGeoBasisLike) => Promise | { + points: Array<[number, number]>; + unit: LinearUnit; +} | null>; +/** The part of a candidate row that `createCandidateTransformer` keeps. */ +type CandidateTransformerRow = Pick & Partial>; /** * Builds a candidate transformer from candidate rows (for example the result of * `listProjectedCrsFromProjWasm`), so the rows can be passed to Teiresias' `estimateCrs` @@ -125,8 +168,13 @@ export type AtlasCandidateTransformer = (id: string, points: ReadonlyArray>, options?: TransformLonLatToProjectedOptions): AtlasCandidateTransformer; +export declare function createCandidateTransformer(rows: ReadonlyArray, options?: TransformLonLatToProjectedOptions): AtlasCandidateTransformer; +export {}; diff --git a/src/index.ts b/src/index.ts index b59e15a..8b73d26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export { initializeProjWasmRuntime, listProjectedCrsFromProjWasm, transformLonLatArrayToProjected, + transformLonLatToCandidate, } from "./proj-wasm-crs.ts"; export type { CandidateCrsExtent } from "./crs-types.ts"; @@ -22,6 +23,7 @@ export type { AtlasCandidateTransformer, AtlasGeoBasisLike, InitializeProjWasmRuntimeOptions, + LinearUnit, ListProjectedCrsFromProjWasmOptions, ProjectedCrsExclusion, ProjectedCrsInfo, diff --git a/src/proj-wasm-crs.ts b/src/proj-wasm-crs.ts index a4d4ac7..2958b21 100644 --- a/src/proj-wasm-crs.ts +++ b/src/proj-wasm-crs.ts @@ -2,6 +2,12 @@ import type { CandidateCrsExtent } from "./crs-types.ts"; import { PjCache, withPj } from "./proj-handle.ts"; import { projWasmExclusions } from "./proj-wasm-exclusions.ts"; +/** 座標の線形単位。`metresPerUnit` は 1 単位が何 metre か。 */ +export interface LinearUnit { + name: string; + metresPerUnit: number; +} + export interface ProjectedCrsInfo { id: string; authority: string; @@ -16,6 +22,11 @@ export interface ProjectedCrsInfo { isApproximate?: boolean; source?: string[]; definitionType?: "projdb" | "custom" | "user"; + /** + * 格子の単位(定義ファイルの `unit`)。custom / user の pipeline の行にだけ付き、 + * proj.db の行には付かない(proj.db の行の単位は PROJ の軸情報から取る)。 + */ + unit?: LinearUnit; } /** A proj.db row that matched the search but was left out of the result, with the reason. */ @@ -99,6 +110,8 @@ export interface CustomProjectionDefinition { wkt2?: string | null; isApproximate?: boolean; source?: string[]; + /** 格子の単位。pipeline の行には必須(無いと変換できない)。 */ + unit?: LinearUnit; } interface CustomProjectionFile { @@ -381,6 +394,7 @@ function expandCustomProjectedCrsInfo( pipeline: definition.pipeline, isApproximate: definition.isApproximate ?? false, source: [...(definition.source ?? [])], + ...(definition.unit === undefined ? {} : { unit: { ...definition.unit } }), definitionType, }; } @@ -546,100 +560,165 @@ interface ProjAxisInfo { } /** - * target が「軸が east / north の順で、両軸とも metre の 2 次元 CRS」であるかを機構で検証する。 + * target の定義を 1 回だけ読んで、CRS かどうか・CRS なら座標の線形単位を返す。 * - * - pipeline(CoordinateOperation)を `projCreateCrsToCrs` の target に渡しても **throw しない**。 + * - CRS でない定義(pipeline。例: 同梱の custom projection の行)は `kind: "non-crs"`。 + * その行は「格子 → lon/lat(度)」の向きの pipeline なので、逆向き(`PJ_INV`)に流す。 + * - PROJ が読めない定義は `kind: "unreadable"`(業務的に変換不能)。 + * - CRS でも、座標系が直交(Cartesian)でない・軸が east / north の 2 次元でない・両軸の単位が + * 同じ線形単位でないときは `unit: null`。地理座標系の 2 軸はどちらも `degree` で、換算係数は + * 「1 度が何ラジアンか」であり、metre への倍率ではない ∴ 線形単位として扱わない。 + * pipeline(CoordinateOperation)を `projCreateCrsToCrs` の target に渡しても **throw しない**。 * コンソールエラーだけを出して `undefined` を resolve し、それを `projTransArray` へ渡すと * wasm ヒープ故障になる(proj-wasm で実測)∴ 生成前に弾く必要がある。 - * - native 単位が metre でない候補は単位換算せず変換不能へ落とす。 */ +type ProjTargetKind = "crs" | "non-crs" | "unreadable"; + +interface ProjTargetInspection { + kind: ProjTargetKind; + /** 直交(Cartesian)な east / north の 2 次元 CRS で、両軸の単位が同じ線形単位のときだけ。 */ + unit: LinearUnit | null; +} + /** - * target の検証結果は定義の文字列で決まるので、文字列ごとに 1 回だけ PJ を作って確かめる + * 定義の文字列ごとに 1 回だけ PJ を作って確かめ、単位まで覚えておく * (解放しても戻らない資源を、同じ target への変換のたびに使わないため)。 * 検証が throw したとき(実行時の失敗)は結果を覚えず、次の呼び出しで確かめ直す。 */ -const eastNorthMetreCrsCache = new Map>(); +const targetUnitCache = new Map>(); -function isEastNorthMetreCrsOnce(proj: typeof import("proj-wasm"), definition: string): Promise { - let pending = eastNorthMetreCrsCache.get(definition); +function inspectTargetOnce(proj: typeof import("proj-wasm"), definition: string): Promise { + let pending = targetUnitCache.get(definition); if (pending === undefined) { - const created = isEastNorthMetreCrs(proj, definition); + const created = inspectTarget(proj, definition); pending = created; - eastNorthMetreCrsCache.set(definition, created); + targetUnitCache.set(definition, created); created.catch(() => { - if (eastNorthMetreCrsCache.get(definition) === created) eastNorthMetreCrsCache.delete(definition); + if (targetUnitCache.get(definition) === created) targetUnitCache.delete(definition); }); } return pending; } /** - * PROJ が「定義を読めない」と答えた例外か。proj-wasm 0.1.0-alpha8 は PROJ の C++ 例外を - * `new Error(message)` に包み、型名は worker の stack の先頭行(`osgeo::proj::io::ParsingException: …`) - * にだけ残す。定義の誤り(構文・値・未知の authority code)を表す 2 つの型だけを業務的な失敗とみなし、 - * それ以外(SQLite の out of memory を含む FactoryException、wasm のメモリの枯渇など)は実装側の失敗とする。 - * 型名が stack に無い実行環境では、読めない定義も throw になる(黙って null にするより安全な側)。 + * proj-wasm の呼び出しが「定義を読めない」以外の理由で throw したとき(メモリが尽きたなど)は、 + * そのまま throw する。 */ -const UNREADABLE_DEFINITION_ERROR = /^osgeo::proj::io::(?:ParsingException|NoSuchAuthorityCodeException)\b/; - -function isUnreadableDefinitionError(error: unknown): boolean { - return error instanceof Error && UNREADABLE_DEFINITION_ERROR.test(error.stack ?? ""); -} - -/** - * PROJ が「source から target への変換が無い」と答えた例外か(例: target が月や火星の CRS で、 - * 地球の lon/lat から変換できない)。これは業務的に変換不能である。型名の読み方は上と同じ。 - */ -const NO_OPERATION_ERROR = /^osgeo::proj::util::UnsupportedOperationException\b/; - -function isNoOperationError(error: unknown): boolean { - return error instanceof Error && NO_OPERATION_ERROR.test(error.stack ?? ""); -} - -/** pipeline を作れないと PROJ が答えた target。作り直しを試さないよう覚えておく(定義の文字列ごと)。 */ -const targetsWithoutOperation = new Set(); - -/** - * PROJ が読めない定義・CRS でないもの・座標系を持たないもの・軸と単位が east / north の metre で - * ないものは `false`(業務的に変換不能)。proj-wasm の呼び出しがそれ以外の理由で throw したとき - * (メモリが尽きたなど)は、そのまま throw する。 - */ -async function isEastNorthMetreCrs( +async function inspectTarget( proj: typeof import("proj-wasm"), definition: string, -): Promise { +): Promise { try { - return await checkEastNorthMetreCrs(proj, definition); + return await readTarget(proj, definition); } catch (error) { - if (isUnreadableDefinitionError(error)) return false; + if (isUnreadableDefinitionError(error)) return { kind: "unreadable", unit: null }; throw error; } } -async function checkEastNorthMetreCrs( +async function readTarget( proj: typeof import("proj-wasm"), definition: string, -): Promise { +): Promise { return withPj(proj, () => proj.projCreate({ definition }), async (pj) => { - if (pj === undefined || pj === null) return false; - if (!(await proj.projIsCrs({ obj: pj }))) return false; + if (pj === undefined || pj === null) return { kind: "unreadable", unit: null }; + if (!(await proj.projIsCrs({ obj: pj }))) return { kind: "non-crs", unit: null }; return withPj(proj, () => proj.projCrsGetCoordinateSystem({ crs: pj }), async (cs) => { - if (cs === undefined || cs === null) return false; - if ((await proj.projCsGetAxisCount({ cs })) !== 2) return false; + if (cs === undefined || cs === null) return { kind: "crs", unit: null }; + + // 地理座標系の軸は度(1 度が何ラジアンかの係数)であって線形の単位ではない。 + // 直交座標系(投影座標系)だけが metre への倍率を持つ。 + if ((await proj.projCsGetType({ cs })) !== proj.PJ_CS_TYPE_CARTESIAN) { + return { kind: "crs", unit: null }; + } + + if ((await proj.projCsGetAxisCount({ cs })) !== 2) return { kind: "crs", unit: null }; const axes: ProjAxisInfo[] = [ (await proj.projCsGetAxisInfo({ cs, index: 0 })) as ProjAxisInfo, (await proj.projCsGetAxisInfo({ cs, index: 1 })) as ProjAxisInfo, ]; - if (axes[0]?.direction !== "east" || axes[1]?.direction !== "north") return false; + if (axes[0]?.direction !== "east" || axes[1]?.direction !== "north") { + return { kind: "crs", unit: null }; + } - return axes.every((axis) => axis.unitConvFactor === 1 && axis.unitName === "metre"); + return { kind: "crs", unit: linearUnitOfAxes(axes) }; }); }); } +/** 同じ線形単位と見なす 2 つの換算係数の相対の許容差。同じ単位なら PROJ は同じ値を返す。 */ +const UNIT_FACTOR_RELATIVE_TOLERANCE = 1e-12; + +/** + * 2 軸の単位が同じ線形単位のときだけその単位を返す。片方でも欠けている・換算係数が正の有限値で + * ない・2 軸で違うときは `null`(業務的に変換不能)。 + * + * 呼ぶ側が座標系の型(直交)を確かめている前提で、軸の単位だけを見る。地理座標系の軸も単位名は + * 同じ `degree` で係数も正の有限値なので、型を見ないとここを通ってしまう。 + */ +function linearUnitOfAxes(axes: ProjAxisInfo[]): LinearUnit | null { + const [east, north] = axes as [ProjAxisInfo, ProjAxisInfo]; + if (typeof east.unitName !== "string" || east.unitName.length === 0) return null; + if (east.unitName !== north.unitName) return null; + + for (const axis of axes) { + if (!Number.isFinite(axis.unitConvFactor) || axis.unitConvFactor <= 0) return null; + } + + const larger = Math.max(Math.abs(east.unitConvFactor), Math.abs(north.unitConvFactor)); + if (Math.abs(east.unitConvFactor - north.unitConvFactor) > larger * UNIT_FACTOR_RELATIVE_TOLERANCE) return null; + + return { name: east.unitName, metresPerUnit: east.unitConvFactor }; +} + +/** + * 行が持つ `unit` を線形単位として使えるか。名前が空でない文字列で、`metresPerUnit` が正の有限の + * number であること。定義ファイルの誤り(1 行の `unit` の書き間違い)は、その行だけを変換不能に + * する(ほかの候補と、呼ぶ側の推定全体を止めない)。 + */ +function isValidLinearUnit(unit: unknown): unit is LinearUnit { + if (typeof unit !== "object" || unit === null) return false; + const candidate = unit as { name?: unknown; metresPerUnit?: unknown }; + if (typeof candidate.name !== "string" || candidate.name.length === 0) return false; + return typeof candidate.metresPerUnit === "number" + && Number.isFinite(candidate.metresPerUnit) + && candidate.metresPerUnit > 0; +} + +/** 1 単位が 1 metre で、名前が metre の単位か(metre の候補の返却は今までと同じ形にする)。 */ +function isMetreUnit(unit: LinearUnit): boolean { + return unit.metresPerUnit === 1 && unit.name === "metre"; +} + +/** + * PROJ が「定義を読めない」と答えた例外か。proj-wasm 0.1.0-alpha8 は PROJ の C++ 例外を + * `new Error(message)` に包み、型名は worker の stack の先頭行(`osgeo::proj::io::ParsingException: …`) + * にだけ残す。定義の誤り(構文・値・未知の authority code)を表す 2 つの型だけを業務的な失敗とみなし、 + * それ以外(SQLite の out of memory を含む FactoryException、wasm のメモリの枯渇など)は実装側の失敗とする。 + * 型名が stack に無い実行環境では、読めない定義も throw になる(黙って null にするより安全な側)。 + */ +const UNREADABLE_DEFINITION_ERROR = /^osgeo::proj::io::(?:ParsingException|NoSuchAuthorityCodeException)\b/; + +function isUnreadableDefinitionError(error: unknown): boolean { + return error instanceof Error && UNREADABLE_DEFINITION_ERROR.test(error.stack ?? ""); +} + +/** + * PROJ が「source から target への変換が無い」と答えた例外か(例: target が月や火星の CRS で、 + * 地球の lon/lat から変換できない)。これは業務的に変換不能である。型名の読み方は上と同じ。 + */ +const NO_OPERATION_ERROR = /^osgeo::proj::util::UnsupportedOperationException\b/; + +function isNoOperationError(error: unknown): boolean { + return error instanceof Error && NO_OPERATION_ERROR.test(error.stack ?? ""); +} + +/** pipeline を作れないと PROJ が答えた target。作り直しを試さないよう覚えておく(定義の文字列ごと)。 */ +const targetsWithoutOperation = new Set(); + /** * 変換の pipeline(lon/lat → target)の PJ を target の定義の文字列ごとに保持して使い回す。 * source は常に `LONLAT_WGS84_PROJ_STRING` なので、target の文字列だけで決まる。 @@ -650,9 +729,47 @@ async function checkEastNorthMetreCrs( const PIPELINE_CACHE_CAPACITY = 4096; const pipelineCache = new PjCache(PIPELINE_CACHE_CAPACITY); +/** `transformLonLatToCandidate` の返却(1 回の変換で決まる点列と単位)。 */ +interface CandidateTransform { + points: Array<[number, number]>; + unit: LinearUnit; +} + +/** + * lon/lat(度・lon-lat 順)の点列を、候補の座標へ変換し、単位を添えて返す。 + * + * - CRS: 座標系が直交(Cartesian)で、軸が east / north の 2 次元で、両軸の単位が同じ線形単位 + * (換算係数が正の有限値・単位名が同じ)のときだけ変換する。地理座標系の軸は度で、換算係数が + * ラジアンであって metre への倍率ではないため対象外(`null`)。単位は PROJ の軸情報から取る + * (metre なら `{ name: "metre", metresPerUnit: 1 }`)。 + * - CRS でない pipeline(custom / user の行の「格子 → lon/lat(度)」の向きの定義): 行の `unit` を + * 添えて、pipeline を逆向き(`PJ_INV`)に流す。行に `unit` が無い・`unit` が線形単位として不正 + * (空の `name`、正の有限の number でない `metresPerUnit`)なときは `null`(業務的に変換不能)。 + * - 空入力・非有限の入力・非有限の出力・上の条件に合わない target は `null`(部分成功はしない)。 + * 実装側の理由で変換できないとき(PJ を作れない・proj-wasm の呼び出しが throw する。wasm の + * メモリが尽きた場合を含む)は `null` にせず throw する。 + * - 出力は入力と同数・同順で `[x, y]` 順(CRS なら easting / northing、pipeline なら格子の座標)。 + * - 返す `unit` は写しである。書き換えても次の呼び出しの結果は変わらない。 + * + * 本 export は **`lonlat_deg` / lon-lat 順の入力専用**である。`planar_xy` 入力の + * 実 engine 経路は持たない。 + */ +export async function transformLonLatToCandidate( + target: Pick & Partial>, + points: ReadonlyArray, + options?: TransformLonLatToProjectedOptions, +): Promise<{ points: Array<[number, number]>; unit: LinearUnit } | null> { + const converted = await transformLonLatToCandidateOnce(target, points, options); + if (converted === null) return null; + return { points: converted.points, unit: { ...converted.unit } }; +} + /** * lon/lat(度・lon-lat 順)の点列を、target CRS の投影座標 `[easting, northing]` へ一括変換する。 * + * 変換は `transformLonLatToCandidate` が行う。本 export はその結果のうち、単位が metre + * (`name` が `metre` で `metresPerUnit` が 1)のときだけ点列を返し、それ以外は `null` を返す。 + * * 契約: * - (3) 返却は常に `[easting, northing]` 順である(proj 文字列 pipeline により機構で保証する)。 * - (4) 空入力・業務的に変換不能な target は `null`。 @@ -670,6 +787,16 @@ export async function transformLonLatArrayToProjected( points: ReadonlyArray, options?: TransformLonLatToProjectedOptions, ): Promise | null> { + const converted = await transformLonLatToCandidate(target, points, options); + if (converted === null) return null; + return isMetreUnit(converted.unit) ? converted.points : null; +} + +async function transformLonLatToCandidateOnce( + target: Pick & Partial>, + points: ReadonlyArray, + options?: TransformLonLatToProjectedOptions, +): Promise { // step 1: 入力検査(契約 (4)(5)) if (points.length === 0) return null; for (const point of points) { @@ -679,48 +806,101 @@ export async function transformLonLatArrayToProjected( await ensureProjInitialized(options); const proj = await loadProjWasm(); - // step 2: target 検証(CRS であること・軸順 east/north・両軸 metre) - if (!(await isEastNorthMetreCrsOnce(proj, target.proj4))) return null; + // step 2: target の定義を読む(CRS か pipeline か・CRS なら単位) + const inspection = await inspectTargetOnce(proj, target.proj4); - // step 3: pipeline 取得(proj 文字列 → proj 文字列。軸順は生成方法で決まる)。 - // target ごとに 1 つ作って使い回す。PROJ が「変換が無い」と答えた target は null(業務的に - // 変換不能)。それ以外の理由で作れない・変換できないときは throw する(契約 (7))。 - // step 4: 一括変換(座標配列は JS 側の Float64Array で、wasm 側の解放は要らない) - // step 5: 出力検査(1 点でも非有限なら全体 null。契約 (5)) - if (targetsWithoutOperation.has(target.proj4)) return null; - const result = await pipelineCache.use(proj, target.proj4, () => proj.projCreateCrsToCrs({ - source_crs: LONLAT_WGS84_PROJ_STRING, - target_crs: target.proj4, - }), async (operation): Promise | null> => { - const coords = await proj.coordArray(points.length); - await proj.setCoords( - coords, - points.map(([lon, lat]) => [lon, lat, 0, 0] as const), - ); - await proj.projTransArray({ - p: operation, - direction: proj.PJ_FWD, - n: points.length, - coord: coords, - }); + if (inspection.kind === "unreadable") return null; - const transformed: Array<[number, number]> = []; - for (let index = 0; index < points.length; index += 1) { - const coordinate = await proj.getCoords(coords, index); - const easting = coordinate?.[0]; - const northing = coordinate?.[1]; - if (!Number.isFinite(easting) || !Number.isFinite(northing)) return null; - transformed.push([easting as number, northing as number]); - } - return transformed; - }).catch((error: unknown) => { + if (inspection.kind === "crs") { + const unit = inspection.unit; + if (unit === null) return null; + + // step 3: pipeline 取得(proj 文字列 → proj 文字列)。target ごとに 1 つ作って使い回す。 + // PROJ が「変換が無い」と答えた target は null(業務的に変換不能)。それ以外の理由で + // 作れない・変換できないときは throw する(契約 (7))。 + const transformed = await transformWithCrsOnce(proj, target.proj4, points); + return transformed === null ? null : { points: transformed, unit }; + } + + // step 3': CRS でない pipeline。格子の単位は定義ファイルの行にしかない。 + const unit = target.unit; + if (!isValidLinearUnit(unit)) return null; + + const transformed = await transformWithPipelineOnce(proj, target.proj4, points); + return transformed === null ? null : { points: transformed, unit: { ...unit } }; +} + +/** + * 定義の CRS へ lon/lat から変換する(`projCreateCrsToCrs` の CRS → CRS の変換を、`PJ_FWD` で流す)。 + * 変換の PJ は定義の文字列ごとに使い回す。 + */ +function transformWithCrsOnce( + proj: typeof import("proj-wasm"), + definition: string, + points: ReadonlyArray, +): Promise | null> { + if (targetsWithoutOperation.has(definition)) return Promise.resolve(null); + + return pipelineCache.use(proj, definition, () => proj.projCreateCrsToCrs({ + source_crs: LONLAT_WGS84_PROJ_STRING, + target_crs: definition, + }), (operation) => transformPointsOnce(proj, operation, points, proj.PJ_FWD) + ).catch((error: unknown) => { if (!isNoOperationError(error)) throw error; - targetsWithoutOperation.add(target.proj4); + targetsWithoutOperation.add(definition); + return null; + }); +} + +/** + * 定義の pipeline を逆向き(`PJ_INV`)に流す。`pipeline:` の接頭辞で、CRS → CRS の変換の key と + * 混ざらないようにする。PROJ が定義を読めない・変換が無いと答えたときは `null`(業務的に変換不能)。 + */ +function transformWithPipelineOnce( + proj: typeof import("proj-wasm"), + definition: string, + points: ReadonlyArray, +): Promise | null> { + const key = `pipeline:${definition}`; + return pipelineCache.use(proj, key, () => proj.projCreate({ definition }), + (operation) => transformPointsOnce(proj, operation, points, proj.PJ_INV), + ).catch((error: unknown) => { + if (!isUnreadableDefinitionError(error) && !isNoOperationError(error)) throw error; return null; }); +} - // step 6: 入力と同数・同順で返却(契約 (8)) - return result; +/** + * 一括変換(座標配列は JS 側の Float64Array で、wasm 側の解放は要らない)。 + * 1 点でも非有限なら全体 `null`(契約 (5))。入力と同数・同順で返す(契約 (8))。 + */ +async function transformPointsOnce( + proj: typeof import("proj-wasm"), + operation: unknown, + points: ReadonlyArray, + direction: number, +): Promise | null> { + const coords = await proj.coordArray(points.length); + await proj.setCoords( + coords, + points.map(([lon, lat]) => [lon, lat, 0, 0] as const), + ); + await proj.projTransArray({ + p: operation, + direction, + n: points.length, + coord: coords, + }); + + const transformed: Array<[number, number]> = []; + for (let index = 0; index < points.length; index += 1) { + const coordinate = await proj.getCoords(coords, index); + const easting = coordinate?.[0]; + const northing = coordinate?.[1]; + if (!Number.isFinite(easting) || !Number.isFinite(northing)) return null; + transformed.push([easting as number, northing as number]); + } + return transformed; } /** The geo basis of the points given to a candidate transformer (structurally compatible with Teiresias' `GeoBasis`). */ @@ -731,7 +911,10 @@ export type AtlasCandidateTransformer = ( id: string, points: ReadonlyArray, geoBasis: AtlasGeoBasisLike, -) => Promise | null>; +) => Promise | { points: Array<[number, number]>; unit: LinearUnit } | null>; + +/** The part of a candidate row that `createCandidateTransformer` keeps. */ +type CandidateTransformerRow = Pick & Partial>; /** * Builds a candidate transformer from candidate rows (for example the result of @@ -741,19 +924,36 @@ export type AtlasCandidateTransformer = ( * - Points must be lon/lat degrees in lon-lat order (`{ kind: "lonlat_deg", axisOrder: "lon-lat" }`); * any other geo basis gives `null`. * - An id that is not among `rows` gives `null`. - * - Otherwise the result is `transformLonLatArrayToProjected(row, points)`, and what it throws - * (a failure on the implementation side, never an untransformable target) is passed on. - * - The rows are copied when the transformer is created; changing them later has no effect. + * - Otherwise the result is `transformLonLatToCandidate(row, points)`: a plain point list when the + * unit is metre (the same result as `transformLonLatArrayToProjected`), and `{ points, unit }` + * when the row gives another unit (for example the yard of a historical grid). + * - What the conversion throws (a failure on the implementation side, never an untransformable + * target) is passed on. + * - The rows are copied when the transformer is created, `unit` included; changing them later has + * no effect, and neither does changing an earlier result. */ export function createCandidateTransformer( - rows: ReadonlyArray>, + rows: ReadonlyArray, options?: TransformLonLatToProjectedOptions, ): AtlasCandidateTransformer { - const byId = new Map(rows.map((row) => [row.id, { id: row.id, proj4: row.proj4 }])); + const byId = new Map(); + for (const row of rows) { + byId.set(row.id, { + id: row.id, + proj4: row.proj4, + ...(row.unit === undefined ? {} : { unit: { ...row.unit } }), + }); + } + return async (id, points, geoBasis) => { if (geoBasis.kind !== "lonlat_deg" || geoBasis.axisOrder !== "lon-lat") return null; const target = byId.get(id); if (target === undefined) return null; - return transformLonLatArrayToProjected(target, points, options); + + const converted = await transformLonLatToCandidate(target, points, options); + if (converted === null) return null; + return isMetreUnit(converted.unit) + ? converted.points + : { points: converted.points, unit: converted.unit }; }; } diff --git a/tests/custom-projection-units.test.ts b/tests/custom-projection-units.test.ts new file mode 100644 index 0000000..fef9f5e --- /dev/null +++ b/tests/custom-projection-units.test.ts @@ -0,0 +1,117 @@ +/** + * 同梱の custom projection の定義(`data/crs/custom-projections.json`)と、その行の `unit`。 + * + * 定義ファイルは利用者が読む。pipeline の格子の単位は定義の `unit` でだけ決まるので、 + * pipeline のある行には `unit` を必ず置き、pipeline に書かれた `+to_meter` と一致させる。 + */ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +import { listProjectedCrsFromProjWasm, type ProjectedCrsInfo } from "../src/proj-wasm-crs.ts"; + +const YARD_OF_THE_HISTORICAL_GRID = 0.9143985307444408; +const PROJ_WASM_TEST_TIMEOUT_MS = 16_000; + +interface UnitInFile { + name: string; + metresPerUnit: number; +} + +interface ProjectionInFile { + authority: string; + code: string; + proj4?: string; + pipeline?: string; + unit?: UnitInFile; +} + +async function loadDefinitions(): Promise { + const raw = await readFile(new URL("../data/crs/custom-projections.json", import.meta.url), "utf8"); + const parsed = JSON.parse(raw) as { projections?: ProjectionInFile[] } | ProjectionInFile[]; + return Array.isArray(parsed) ? parsed : (parsed.projections ?? []); +} + +function idOf(definition: ProjectionInFile): string { + return `${definition.authority}:${definition.code}`; +} + +describe("units of the bundled custom projections", () => { + it("gives every row that has a pipeline a unit", async () => { + const definitions = await loadDefinitions(); + expect(definitions.length).toBeGreaterThan(0); + + for (const definition of definitions) { + if (definition.proj4 === undefined && definition.pipeline === undefined) continue; + const unit = definition.unit; + expect(unit, `${idOf(definition)} has no unit`).toBeDefined(); + expect(typeof unit!.name).toBe("string"); + expect(unit!.name.length).toBeGreaterThan(0); + expect(Number.isFinite(unit!.metresPerUnit)).toBe(true); + expect(unit!.metresPerUnit).toBeGreaterThan(0); + } + }); + + it("agrees with the +to_meter factor written in the pipeline", async () => { + const definitions = await loadDefinitions(); + + for (const definition of definitions) { + const text = definition.pipeline ?? definition.proj4; + if (text === undefined) continue; + const found = text.match(/(?:^| )\+to_meter=([^ ]+)/); + if (found === null) continue; + expect(definition.unit, `${idOf(definition)} has no unit`).toBeDefined(); + expect(definition.unit!.metresPerUnit).toBe(Number(found[1])); + } + }); + + it("declares the yard of the Japan city plans for the three historical grid zones", async () => { + const definitions = await loadDefinitions(); + const zones = definitions.filter((definition) => definition.authority === "UTJCP"); + + expect(zones.map(idOf).sort()).toEqual(["UTJCP:ZONE-A", "UTJCP:ZONE-B", "UTJCP:ZONE-C"]); + for (const zone of zones) { + expect(zone.pipeline).toContain(`+to_meter=${YARD_OF_THE_HISTORICAL_GRID}`); + expect(zone.unit).toEqual({ + name: "yard (Japan City Plans)", + metresPerUnit: YARD_OF_THE_HISTORICAL_GRID, + }); + } + }); +}); + +describe("units of the rows built from those definitions", { timeout: PROJ_WASM_TEST_TIMEOUT_MS }, () => { + const KYOTO_EXTENT = { west: 135.6, south: 34.9, east: 135.9, north: 35.1 }; + + async function zoneBRows(): Promise { + return listProjectedCrsFromProjWasm({ + extent: KYOTO_EXTENT, + area: "Japan City Plans - Historical Grid", + includeCustomProjections: true, + }); + } + + it("hands out a copy of the unit of the definition", async () => { + const first = (await zoneBRows()).find((row) => row.id === "UTJCP:ZONE-B"); + expect(first).toBeDefined(); + const originalUnit = { ...first!.unit! }; + + expect(first!.unit!.name).toBe("yard (Japan City Plans)"); + first!.unit!.name = "changed by the caller"; + first!.unit!.metresPerUnit = 42; + + const second = (await zoneBRows()).find((row) => row.id === "UTJCP:ZONE-B"); + expect(second!.unit).toEqual(originalUnit); + expect(second!.unit).not.toBe(first!.unit); + }); + + it("does not attach a unit to the rows of proj.db", async () => { + const rows = await listProjectedCrsFromProjWasm({ + extent: KYOTO_EXTENT, + area: "Japan", + onshoreOnly: true, + }); + const fromProjDb = rows.filter((row) => row.definitionType === "projdb"); + expect(fromProjDb.length).toBeGreaterThan(0); + expect(fromProjDb.filter((row) => row.unit !== undefined)).toEqual([]); + }); +}); diff --git a/tests/proj-wasm-output-basis.test.ts b/tests/proj-wasm-output-basis.test.ts new file mode 100644 index 0000000..759bf61 --- /dev/null +++ b/tests/proj-wasm-output-basis.test.ts @@ -0,0 +1,430 @@ +/** + * `transformLonLatToCandidate` と、単位つきの返却に対応した `createCandidateTransformer`。 + * + * 実 engine(proj-wasm)を通す。候補の `proj4` はハードコードせず、実カタログ + * (`listProjectedCrsFromProjWasm`)と実 DB(`projCreateFromDatabase`)から取る。 + */ +import { describe, expect, it } from "vitest"; + +import { + createCandidateTransformer, + listProjectedCrsFromProjWasm, + transformLonLatArrayToProjected, + transformLonLatToCandidate, + type LinearUnit, + type ProjectedCrsInfo, +} from "../src/proj-wasm-crs.ts"; + +/** 京都近郊(lon/lat 度・lon-lat 順)。 */ +const KYOTO_GCPS: ReadonlyArray = [ + [135.6, 34.9], + [135.9, 34.9], + [135.9, 35.1], + [135.6, 35.1], + [135.75, 35.0], +]; +const KYOTO_THREE: ReadonlyArray = [ + [135.7, 34.98], + [135.75, 35.02], + [135.79, 34.96], +]; +/** カリフォルニアの 3 点(lon/lat 度・lon-lat 順)。 */ +const CALIFORNIA_THREE: ReadonlyArray = [ + [-121.5, 40.5], + [-120.0, 38.0], + [-118.5, 35.5], +]; + +const LONLAT = { kind: "lonlat_deg", axisOrder: "lon-lat" } as const; +const KYOTO_EXTENT = { west: 135.6, south: 34.9, east: 135.9, north: 35.1 }; + +const YARD_OF_THE_HISTORICAL_GRID = 0.9143985307444408; +/** 1 m を 1e-6 m 以内で見る(m 単位の値どうしの比較)。 */ +const METRE_TOLERANCE = 1e-6; + +/** + * Tests here go through the real proj-wasm engine; the first one in a file also pays for its start-up. + */ +const PROJ_WASM_TEST_TIMEOUT_MS = 16_000; + +let catalogPromise: Promise | undefined; + +/** 京都の範囲の候補(同梱の custom projection を含む)。 */ +function kyotoCatalog(): Promise { + catalogPromise ??= listProjectedCrsFromProjWasm({ + extent: KYOTO_EXTENT, + area: "Japan", + onshoreOnly: false, + includeCustomProjections: true, + }); + return catalogPromise; +} + +async function catalogRow(id: string): Promise { + const rows = await kyotoCatalog(); + const found = rows.find((row) => row.id === id); + if (found === undefined) { + throw new Error(`${id} is not in the catalog (candidates: ${rows.map((row) => row.id).join(", ")})`); + } + return found; +} + +/** 実 proj.db から PROJ 文字列を取る(検索の範囲の外にある CRS 用)。 */ +async function databaseTarget(id: string): Promise> { + const [authority, code] = id.split(":"); + const proj = await import("proj-wasm"); + await proj.init(); + const pj = await proj.projCreateFromDatabase({ + auth_name: authority, + code, + category: proj.PJ_CATEGORY_CRS, + }); + const proj4 = await proj.projAsProjString({ pj, type: proj.PJ_PROJ_5, options: null }); + return { id, proj4 }; +} + +/** 定義を `data:` URL にして `userProjection` へ渡す(ファイルを書かずに user の行を作る)。 */ +function dataUrl(value: unknown): string { + return `data:application/json,${encodeURIComponent(JSON.stringify({ projections: [value] }))}`; +} + +function expectFinitePoints(points: ReadonlyArray, expectedCount: number): void { + expect(points).toHaveLength(expectedCount); + for (const point of points) { + expect(Number.isFinite(point[0])).toBe(true); + expect(Number.isFinite(point[1])).toBe(true); + } +} + +describe("transformLonLatToCandidate", { timeout: PROJ_WASM_TEST_TIMEOUT_MS }, () => { + it("returns the grid points with the unit of the historical Japan city plans grid", async () => { + const zoneB = await catalogRow("UTJCP:ZONE-B"); + expect(zoneB.unit).toBeDefined(); + + const converted = await transformLonLatToCandidate(zoneB, KYOTO_THREE); + expect(converted).not.toBeNull(); + expect(converted!.unit).toEqual({ + name: "yard (Japan City Plans)", + metresPerUnit: YARD_OF_THE_HISTORICAL_GRID, + }); + expectFinitePoints(converted!.points, KYOTO_THREE.length); + + // 同じ pipeline から `+to_meter=…` だけを外すと、格子ではなく metre の座標が返る。 + // 格子の値に 1 単位が何 metre かを掛けた値と 1e-6 m 以内で一致する。 + const proj = await import("proj-wasm"); + await proj.init(); + const definitionInMetres = zoneB.proj4.replace(/ \+to_meter=[^ ]+/, ""); + expect(definitionInMetres).not.toBe(zoneB.proj4); + + const pipeline = await proj.projCreate({ definition: definitionInMetres }); + const coords = await proj.coordArray(KYOTO_THREE.length); + await proj.setCoords( + coords, + KYOTO_THREE.map(([lon, lat]) => [lon, lat, 0, 0] as const), + ); + await proj.projTransArray({ + p: pipeline, + direction: proj.PJ_INV, + n: KYOTO_THREE.length, + coord: coords, + }); + + for (let index = 0; index < KYOTO_THREE.length; index += 1) { + const [easting, northing] = await proj.getCoords(coords, index); + const [ourEasting, ourNorthing] = converted!.points[index]; + expect(Math.abs(ourEasting * YARD_OF_THE_HISTORICAL_GRID - easting)).toBeLessThan(METRE_TOLERANCE); + expect(Math.abs(ourNorthing * YARD_OF_THE_HISTORICAL_GRID - northing)).toBeLessThan(METRE_TOLERANCE); + } + }); + + it("returns the axis unit of a CRS that is not in metres", async () => { + const target = await databaseTarget("EPSG:2227"); + expect(target.proj4).toContain("+units=us-ft"); + + const converted = await transformLonLatToCandidate(target, CALIFORNIA_THREE); + expect(converted).not.toBeNull(); + expect(converted!.unit.name).toBe("US survey foot"); + + const expected = 1200 / 3937; + expect(Math.abs(converted!.unit.metresPerUnit - expected) / expected).toBeLessThan(1e-12); + expectFinitePoints(converted!.points, CALIFORNIA_THREE.length); + }); + + it("returns metres for a CRS in metres, with the same points as transformLonLatArrayToProjected", async () => { + const row = await catalogRow("EPSG:6674"); + + const converted = await transformLonLatToCandidate(row, KYOTO_GCPS); + expect(converted).not.toBeNull(); + expect(converted!.unit).toEqual({ name: "metre", metresPerUnit: 1 }); + expect(converted!.points).toEqual(await transformLonLatArrayToProjected(row, KYOTO_GCPS)); + }); + + it("returns null for a geographic CRS, whose axes are in degrees and not in a linear unit", async () => { + // 地理座標系の 2 軸はどちらも degree で、換算係数は「1 度が何ラジアンか」である。 + // これは metre への倍率ではないので、線形単位として返してはならない。 + for (const definition of ["OGC:CRS84", "+proj=longlat +datum=WGS84 +type=crs"]) { + const target = { id: definition, proj4: definition }; + await expect(transformLonLatToCandidate(target, KYOTO_THREE)).resolves.toBeNull(); + await expect(transformLonLatArrayToProjected(target, KYOTO_THREE)).resolves.toBeNull(); + } + }); + + it("returns null for a pipeline row that declares no unit", async () => { + const zoneB = await catalogRow("UTJCP:ZONE-B"); + const withoutUnit = { id: zoneB.id, proj4: zoneB.proj4 }; + + await expect(transformLonLatToCandidate(withoutUnit, KYOTO_THREE)).resolves.toBeNull(); + await expect(transformLonLatArrayToProjected(withoutUnit, KYOTO_THREE)).resolves.toBeNull(); + }); + + it("carries the unit of a user projection definition", async () => { + const zoneB = await catalogRow("UTJCP:ZONE-B"); + const [row] = await listProjectedCrsFromProjWasm({ + extent: KYOTO_EXTENT, + area: "Atlas test grid", + userProjection: dataUrl({ + authority: "ATLAS-TEST", + code: "GRID", + name: "Atlas test grid zone", + areaName: "Atlas test grid zone", + deprecated: false, + bbox: { west: 131, south: 24, east: 139, north: 46 }, + pipeline: zoneB.proj4, + unit: { name: "yard (Japan City Plans)", metresPerUnit: YARD_OF_THE_HISTORICAL_GRID }, + }), + }); + expect(row?.definitionType).toBe("user"); + expect(row?.unit).toEqual({ + name: "yard (Japan City Plans)", + metresPerUnit: YARD_OF_THE_HISTORICAL_GRID, + }); + + const converted = await transformLonLatToCandidate(row!, KYOTO_THREE); + const builtIn = await transformLonLatToCandidate(zoneB, KYOTO_THREE); + expect(converted).not.toBeNull(); + expect(converted!.unit).toEqual({ + name: "yard (Japan City Plans)", + metresPerUnit: YARD_OF_THE_HISTORICAL_GRID, + }); + expect(converted!.points).toEqual(builtIn!.points); + + // 正しい `unit` の行は、transformer でも今までどおり点列と単位を返す。 + const transformer = createCandidateTransformer([row!]); + const transformed = await transformer(row!.id, KYOTO_THREE, LONLAT); + expect(transformed).not.toBeNull(); + expect(Array.isArray(transformed)).toBe(false); + expect((transformed as { unit: LinearUnit }).unit).toEqual(converted!.unit); + }); + + it("returns null for a user projection definition without a unit", async () => { + const zoneB = await catalogRow("UTJCP:ZONE-B"); + const [row] = await listProjectedCrsFromProjWasm({ + extent: KYOTO_EXTENT, + area: "Atlas test grid", + userProjection: dataUrl({ + authority: "ATLAS-TEST", + code: "NO-UNIT", + name: "Atlas test grid without a unit", + areaName: "Atlas test grid zone", + deprecated: false, + bbox: { west: 131, south: 24, east: 139, north: 46 }, + pipeline: zoneB.proj4, + }), + }); + expect(row?.definitionType).toBe("user"); + expect(row?.unit).toBeUndefined(); + + await expect(transformLonLatToCandidate(row!, KYOTO_THREE)).resolves.toBeNull(); + }); + + it("returns null for a user pipeline row whose unit is not a linear unit", async () => { + const zoneB = await catalogRow("UTJCP:ZONE-B"); + // JSON には NaN と Infinity が無い(どちらも `null` として書かれる)。有限性の検査は、 + // 行を直接渡す下の 2 回で確かめる。 + const units = [ + { name: "", metresPerUnit: -1 }, + { name: "yard", metresPerUnit: 0 }, + { name: "yard", metresPerUnit: "0.9144" }, + ]; + + for (const [index, unit] of units.entries()) { + const [row] = await listProjectedCrsFromProjWasm({ + extent: KYOTO_EXTENT, + area: "Atlas test grid", + userProjection: dataUrl({ + authority: "ATLAS-TEST", + code: `BAD-UNIT-${index}`, + name: `Atlas test grid with a unit ${index}`, + areaName: "Atlas test grid zone", + deprecated: false, + bbox: { west: 131, south: 24, east: 139, north: 46 }, + pipeline: zoneB.proj4, + unit, + }), + }); + expect(row?.definitionType).toBe("user"); + + await expect(transformLonLatToCandidate(row!, KYOTO_THREE)).resolves.toBeNull(); + + const transformer = createCandidateTransformer([row!]); + await expect(transformer(row!.id, KYOTO_THREE, LONLAT)).resolves.toBeNull(); + } + + // `metresPerUnit` が number でない・有限でない場合も線形単位ではない。 + for (const metresPerUnit of [Number.NaN, Number.POSITIVE_INFINITY]) { + const row = { id: "ATLAS-TEST:BAD-UNIT", proj4: zoneB.proj4, unit: { name: "yard", metresPerUnit } }; + await expect(transformLonLatToCandidate(row, KYOTO_THREE)).resolves.toBeNull(); + const transformer = createCandidateTransformer([row]); + await expect(transformer(row.id, KYOTO_THREE, LONLAT)).resolves.toBeNull(); + } + }); + + it("returns null for an empty list, a non-finite point and a definition that cannot be read", async () => { + const row = await catalogRow("EPSG:6674"); + + await expect(transformLonLatToCandidate(row, [])).resolves.toBeNull(); + await expect( + transformLonLatToCandidate(row, [KYOTO_THREE[0], [Number.NaN, 35.0]]), + ).resolves.toBeNull(); + await expect( + transformLonLatToCandidate({ id: "missing", proj4: "+proj=not-a-projection" }, KYOTO_THREE), + ).resolves.toBeNull(); + }); + + it("hands out a unit a caller can change without changing the next result", async () => { + const zoneB = await catalogRow("UTJCP:ZONE-B"); + + const first = await transformLonLatToCandidate(zoneB, KYOTO_THREE); + first!.unit.name = "changed by the caller"; + first!.unit.metresPerUnit = 42; + first!.points[0][0] = 0; + + const second = await transformLonLatToCandidate(zoneB, KYOTO_THREE); + expect(second!.unit).toEqual({ + name: "yard (Japan City Plans)", + metresPerUnit: YARD_OF_THE_HISTORICAL_GRID, + }); + expect(second!.points[0][0]).not.toBe(0); + }); +}); + +describe("createCandidateTransformer with units", { timeout: PROJ_WASM_TEST_TIMEOUT_MS }, () => { + it("returns the points and the unit for a row whose unit is not metre", async () => { + const rows = await kyotoCatalog(); + const transformer = createCandidateTransformer(rows); + + const result = await transformer("UTJCP:ZONE-B", KYOTO_THREE, LONLAT); + expect(result).not.toBeNull(); + expect(Array.isArray(result)).toBe(false); + const converted = result as { points: Array<[number, number]>; unit: LinearUnit }; + expect(converted.unit).toEqual({ + name: "yard (Japan City Plans)", + metresPerUnit: YARD_OF_THE_HISTORICAL_GRID, + }); + expectFinitePoints(converted.points, KYOTO_THREE.length); + }); + + it("returns a plain point list for a row in metres", async () => { + const rows = await kyotoCatalog(); + const transformer = createCandidateTransformer(rows); + const row = rows.find((candidate) => candidate.id === "EPSG:6674")!; + + const result = await transformer("EPSG:6674", KYOTO_GCPS, LONLAT); + expect(Array.isArray(result)).toBe(true); + expect(result).toEqual(await transformLonLatArrayToProjected(row, KYOTO_GCPS)); + }); + + it("returns null for a geographic CRS row, whose axes are in degrees", async () => { + const transformer = createCandidateTransformer([{ id: "geographic", proj4: "OGC:CRS84" }]); + await expect(transformer("geographic", KYOTO_GCPS, LONLAT)).resolves.toBeNull(); + }); + + it("is not changed by a caller that changes an earlier result", async () => { + const zoneB = await catalogRow("UTJCP:ZONE-B"); + const transformer = createCandidateTransformer([zoneB]); + + const first = await transformer("UTJCP:ZONE-B", KYOTO_THREE, LONLAT) as { + points: Array<[number, number]>; + unit: LinearUnit; + }; + const originalUnit = { ...first.unit }; + const originalFirstPoint = [...first.points[0]] as [number, number]; + first.unit.name = "changed by the caller"; + first.unit.metresPerUnit = 42; + first.points[0][0] = 0; + + const second = await transformer("UTJCP:ZONE-B", KYOTO_THREE, LONLAT) as { + points: Array<[number, number]>; + unit: LinearUnit; + }; + expect(second.unit).toEqual(originalUnit); + expect(second.points[0]).toEqual(originalFirstPoint); + expect(second.points).not.toBe(first.points); + }); + + it("keeps the unit it was created with when the caller changes the rows later", async () => { + const zoneB = await catalogRow("UTJCP:ZONE-B"); + const rows = [{ id: zoneB.id, proj4: zoneB.proj4, unit: { ...zoneB.unit! } }]; + const transformer = createCandidateTransformer(rows); + + rows[0].unit.name = "changed by the caller"; + rows[0].unit.metresPerUnit = 42; + + const result = await transformer("UTJCP:ZONE-B", KYOTO_THREE, LONLAT) as { + points: Array<[number, number]>; + unit: LinearUnit; + }; + expect(result.unit).toEqual({ + name: "yard (Japan City Plans)", + metresPerUnit: YARD_OF_THE_HISTORICAL_GRID, + }); + }); +}); + +/** + * 逆向き(`PJ_INV`)に流した pipeline の後に、proj-wasm の実行時が答え続けるかを見る。 + * このファイルの前のテストは、同じ process で格子の変換を何度も行っている。 + */ +describe("the runtime after a pipeline has been run backwards", { timeout: PROJ_WASM_TEST_TIMEOUT_MS }, () => { + const CALL_TIMEOUT_MS = 60_000; + + async function listWithTimeout(options: Parameters[0]): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + listProjectedCrsFromProjWasm(options), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`the search did not answer within ${CALL_TIMEOUT_MS} ms`)), + CALL_TIMEOUT_MS, + ); + }), + ]); + } finally { + clearTimeout(timer); + } + } + + it( + "answers consecutive searches after the grid conversions", + async () => { + const calls = [ + { extent: KYOTO_EXTENT }, + { extent: KYOTO_EXTENT, area: "Japan", includeCustomProjections: true }, + { extent: KYOTO_EXTENT }, + ]; + + const ids: string[][] = []; + for (const options of calls) { + const rows = await listWithTimeout(options); + expect(rows.length).toBeGreaterThan(0); + ids.push(rows.map((row) => row.id)); + } + + expect(ids[0]).toContain("EPSG:6674"); + expect(ids[1]).toContain("UTJCP:ZONE-B"); + expect(ids[2]).toEqual(ids[0]); + }, + CALL_TIMEOUT_MS * 3 + 30_000, + ); +}); diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index 19a5e5c..ed62f53 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -9,6 +9,8 @@ import { shouldExternalize } from "../vite.config.ts"; * (m2a-t1 設計 K2 / §4.4)。m2a-t1 以前の baseline は**関数 12・型 9 の計 21 件**であり、 * 本タスクの増分は `transformLonLatArrayToProjected`(関数)と * `TransformLonLatToProjectedOptions`(型)の 2 件だけである。 + * その後に足した `transformLonLatToCandidate`(関数)と `LinearUnit`(型)の 2 件も、 + * 同じ数え方で固定する。 * * 型 export は runtime に現れない ∴ 関数は import した module の key で、型は * `src/index.ts` のソーステキストで固定する(両方を測らないと増分件数が数えられない)。 @@ -28,6 +30,7 @@ const EXPECTED_RUNTIME_EXPORTS = [ "isScopeIncluded", "listProjectedCrsFromProjWasm", "transformLonLatArrayToProjected", + "transformLonLatToCandidate", ] as const; const EXPECTED_TYPE_EXPORTS = [ @@ -40,6 +43,7 @@ const EXPECTED_TYPE_EXPORTS = [ "CandidateKind", "CandidateSeedScope", "InitializeProjWasmRuntimeOptions", + "LinearUnit", "ListProjectedCrsFromProjWasmOptions", "ProjectedCrsExclusion", "ProjectedCrsInfo", @@ -69,9 +73,10 @@ describe("public API alignment", () => { expect(typeExportNames).toEqual([...EXPECTED_TYPE_EXPORTS]); // 上の 2 件に、検索から外した行を知らせる型 `ProjectedCrsExclusion` の 1 件と、 - // Teiresias へつなぐ adapter(関数 `createCandidateTransformer` と型 2 件)の 3 件を足す + // Teiresias へつなぐ adapter(関数 `createCandidateTransformer` と型 2 件)の 3 件を足す。 + // さらに、単位つきの変換(関数 `transformLonLatToCandidate` と型 `LinearUnit`)の 2 件を足す。 expect(EXPECTED_RUNTIME_EXPORTS.length + EXPECTED_TYPE_EXPORTS.length).toBe( - BASELINE_EXPORT_COUNT + 6, + BASELINE_EXPORT_COUNT + 8, ); expect("CandidateCrsStore" in atlas).toBe(false);