From 6031e06639baef2a2ab397bf0314e5576c458c74 Mon Sep 17 00:00:00 2001 From: Kohei Otsuka Date: Tue, 27 Oct 2026 22:11:59 +0900 Subject: [PATCH 1/2] feat: return candidate coordinates with their linear unit (transformLonLatToCandidate) - Add transformLonLatToCandidate and the LinearUnit type. A CRS gives the unit from the axis information of PROJ (metre, US survey foot, ...); a custom or user pipeline row is run in the inverse direction and gives the unit declared by the row. - transformLonLatArrayToProjected keeps its contract (metres only, otherwise null) and is now a thin wrapper around the new function. - createCandidateTransformer returns a plain point list for a candidate in metres and { points, unit } for any other unit; rows are copied with their unit. - The three historical Japan city plan zones declare their grid unit (yard). - README (English and Japanese): Units section and API entries. dist rebuilt. Co-Authored-By: Claude Opus 5.5 --- README.ja.md | 53 ++- README.md | 58 ++- data/crs/custom-projections.json | 12 + dist/atlas.js | 497 +++++++++++++++----------- dist/index.d.ts | 4 +- dist/proj-wasm-crs.d.ts | 56 ++- src/index.ts | 2 + src/proj-wasm-crs.ts | 360 ++++++++++++++----- tests/custom-projection-units.test.ts | 117 ++++++ tests/proj-wasm-output-basis.test.ts | 362 +++++++++++++++++++ tests/public-api.test.ts | 9 +- 11 files changed, 1196 insertions(+), 334 deletions(-) create mode 100644 tests/custom-projection-units.test.ts create mode 100644 tests/proj-wasm-output-basis.test.ts diff --git a/README.ja.md b/README.ja.md index 5e0f5a1..90dd5dc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -78,14 +78,53 @@ 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 }`)です。軸が 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 の行は変換できず `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 +202,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 +220,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 fca7e25..218ffa1 100644 --- a/README.md +++ b/README.md @@ -81,15 +81,57 @@ 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 axes are + east/north in two dimensions, with the same unit on both axes, 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` cannot be transformed and gives `null`, so a user projection file that holds pipelines has + to give each of them a `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 +214,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 +232,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..d12fecc 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 F0(I) { return I.endsWith(".prj") && I.includes("日本周辺座標系_WKT2_英語/") && I.includes("/投影座標系/"); } -function G0(I) { +function T0(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 M0(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 g0(I, A) { return A === "all" ? !0 : [ "web_mercator", "utm", "japan_plane_rectangular" ].includes(I); } -function F0(I) { +function S0(I) { return I !== "tokyo"; } -function d0(I) { +function m0(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 f0(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 R0(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 p0(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, h = !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() || h || 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); + return e(t, U); }, h = !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 f(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 = f(I.west, I.east); + return f(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 b0(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,220 @@ 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 E0(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.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 O(I) { + return I.metresPerUnit === 1 && I.name === "metre"; +} +const G0 = /^osgeo::proj::io::(?:ParsingException|NoSuchAuthorityCodeException)\b/; +function y(I) { + return I instanceof Error && G0.test(I.stack ?? ""); +} +const u0 = /^osgeo::proj::util::UnsupportedOperationException\b/; +function W(I) { + return I instanceof Error && u0.test(I.stack ?? ""); } -const s0 = 4096, a0 = new W(s0); -async function i0(I, A, e) { +const b = /* @__PURE__ */ new Set(), N0 = 4096, j = new J(N0); +async function v(I, A, e) { + const t = await l0(I, A, e); + return t === null ? null : { points: t.points, unit: { ...t.unit } }; +} +async function w0(I, A, e) { + const t = await v(I, A, e); + return t === null ? null : O(t.unit) ? t.points : null; +} +async function l0(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 C0(t, I.proj4, A); + return i === null ? null : { points: i, unit: r }; + } + const n = I.unit; + if (n === void 0) return null; + const o = await d0(t, I.proj4, A); + return o === null ? null : { points: o, unit: { ...n } }; +} +function C0(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 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); +function d0(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; + }); +} +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 L0(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 + p0 as bboxToPolygonWkt, + L0 as createCandidateTransformer, + m0 as extractAuthorityIdFromWkt, + h0 as extractWktName, + R0 as inferApproximateBbox, + T0 as inferDatumFamily, + M0 as inferKind, + f0 as inferPriority, + S0 as inferWgsEquivalent, + b0 as initializeProjWasmRuntime, + F0 as isProjectedPrjPath, + g0 as isScopeIncluded, + E0 as listProjectedCrsFromProjWasm, + w0 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..a614326 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,39 @@ 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: 軸が east / north の 2 次元で、両軸の単位が同じ線形単位(換算係数が正の有限値・単位名が + * 同じ)のときだけ変換する。単位は PROJ の軸情報から取る(metre なら + * `{ name: "metre", metresPerUnit: 1 }`)。 + * - CRS でない pipeline(custom / user の行の「格子 → lon/lat(度)」の向きの定義): 行の `unit` を + * 添えて、pipeline を逆向き(`PJ_INV`)に流す。行に `unit` が無ければ `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 +152,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 +166,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..03e15c0 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,139 @@ 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 でも、軸が east / north の 2 次元でない・両軸の単位が同じ線形単位でないときは `unit: null`。 + * pipeline(CoordinateOperation)を `projCreateCrsToCrs` の target に渡しても **throw しない**。 * コンソールエラーだけを出して `undefined` を resolve し、それを `projTransArray` へ渡すと * wasm ヒープ故障になる(proj-wasm で実測)∴ 生成前に弾く必要がある。 - * - native 単位が metre でない候補は単位換算せず変換不能へ落とす。 */ +type ProjTargetKind = "crs" | "non-crs" | "unreadable"; + +interface ProjTargetInspection { + kind: ProjTargetKind; + /** 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 }; + 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`(業務的に変換不能)。 + */ +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 }; +} + +/** 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 +703,45 @@ 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: 軸が east / north の 2 次元で、両軸の単位が同じ線形単位(換算係数が正の有限値・単位名が + * 同じ)のときだけ変換する。単位は PROJ の軸情報から取る(metre なら + * `{ name: "metre", metresPerUnit: 1 }`)。 + * - CRS でない pipeline(custom / user の行の「格子 → lon/lat(度)」の向きの定義): 行の `unit` を + * 添えて、pipeline を逆向き(`PJ_INV`)に流す。行に `unit` が無ければ `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 +759,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 +778,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 (unit === undefined) 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; }); +} + +/** + * 一括変換(座標配列は 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, + }); - // step 6: 入力と同数・同順で返却(契約 (8)) - return result; + 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 +883,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 +896,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..563a873 --- /dev/null +++ b/tests/proj-wasm-output-basis.test.ts @@ -0,0 +1,362 @@ +/** + * `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 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!.points).toEqual(builtIn!.points); + }); + + 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 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("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); From 0f1dccc1f9ac7915c4a38678e9c7cf3409211645 Mon Sep 17 00:00:00 2001 From: Kohei Otsuka Date: Tue, 27 Oct 2026 23:54:51 +0900 Subject: [PATCH 2/2] fix: treat only Cartesian CRSs and valid row units as linear units - A geographic CRS (OGC:CRS84, +proj=longlat) has east/north axes in degrees whose factor is radians per degree, not metres per unit. Check the coordinate system type and give null unless it is Cartesian, as before this API existed. - A pipeline row whose unit has an empty name or a metresPerUnit that is not a positive finite number cannot be transformed (null), so one bad user row does not stop the other candidates. - README (English and Japanese): both conditions in the Units section. dist rebuilt. Co-Authored-By: Claude Opus 5.5 --- README.ja.md | 10 +-- README.md | 12 ++-- dist/atlas.js | 95 +++++++++++++++------------- dist/proj-wasm-crs.d.ts | 10 +-- src/proj-wasm-crs.ts | 42 ++++++++++-- tests/proj-wasm-output-basis.test.ts | 68 ++++++++++++++++++++ 6 files changed, 173 insertions(+), 64 deletions(-) diff --git a/README.ja.md b/README.ja.md index ee650f4..66575c2 100644 --- a/README.ja.md +++ b/README.ja.md @@ -86,13 +86,15 @@ console.log(projected); // [[easting, northing], [easting, northing]] または - CRS のとき: 単位は PROJ の軸情報から取ります。metre の CRS なら `{ name: "metre", metresPerUnit: 1 }`、フィートの CRS ならその名前と係数(たとえば `EPSG:2227` - なら `{ name: "US survey foot", metresPerUnit: 1200 / 3937 }`)です。軸が east / north の 2 次元で、 - 両軸の単位が同じ線形単位の CRS だけを変換し、それ以外の target は `null` です。 + なら `{ 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 の行は変換できず `null` になるので、pipeline を持つ user の - 定義ファイルには行ごとに `unit` を書いてください。 + 持っています。`unit` の無い pipeline の行、`unit` が線形単位として不正な行(`name` が空、 + `metresPerUnit` が正の有限の number でない)は変換できず `null` になるので、pipeline を持つ + user の定義ファイルには行ごとに使える `unit` を書いてください。 - `transformLonLatArrayToProjected` の契約は変わりません。同じ変換を使い、単位が metre のときだけ 点列を返し、それ以外は `null` を返します。 - Atlas は単位換算をしません。点列は候補の単位のままで、換算や書き戻しは呼ぶ側の仕事です diff --git a/README.md b/README.md index ed4ad89..7fead0b 100644 --- a/README.md +++ b/README.md @@ -89,15 +89,17 @@ where `unit` is a `LinearUnit` (`{ name, metresPerUnit }`, the number of metres - 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 axes are - east/north in two dimensions, with the same unit on both axes, is transformed; any other target - gives `null`. + `{ 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` cannot be transformed and gives `null`, so a user projection file that holds pipelines has - to give each of them a `unit`. + `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, diff --git a/dist/atlas.js b/dist/atlas.js index d12fecc..3e19179 100644 --- a/dist/atlas.js +++ b/dist/atlas.js @@ -9,10 +9,10 @@ const B = { east: 154, north: 46 }; -function F0(I) { +function T0(I) { return I.endsWith(".prj") && I.includes("日本周辺座標系_WKT2_英語/") && I.includes("/投影座標系/"); } -function T0(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 T0(I) { if (I.includes("/日本測地系2024 (JGD2024)/")) return "jgd2024"; throw new Error(`Unsupported datum family in path: ${I}`); } -function M0(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 M0(I) { if (I.includes("/投影座標系/天気図/")) return "weather_map"; throw new Error(`Unsupported projected kind in path: ${I}`); } -function g0(I, A) { +function S0(I, A) { return A === "all" ? !0 : [ "web_mercator", "utm", "japan_plane_rectangular" ].includes(I); } -function S0(I) { +function m0(I) { return I !== "tokyo"; } -function m0(I) { +function f0(I) { const e = [...I.matchAll(/ID\["([^"]+)","([^"]+)"\]/g)].at(-1); if (e) return { @@ -55,7 +55,7 @@ function h0(I) { if (!A) throw new Error(`Unable to extract WKT root name from: ${I}`); return A[1]; } -function f0(I) { +function R0(I) { switch (I) { case "web_mercator": return 100; @@ -76,7 +76,7 @@ function z(I) { if (!e) throw new Error(`Unable to extract UTM zone from path: ${I}`); return Number(e); } -function R0(I, A) { +function p0(I, A) { if (A === "web_mercator") return B; if (A === "utm") { const t = (z(I) - 1) * 6 - 180; @@ -89,7 +89,7 @@ function R0(I, A) { } return x; } -function p0(I) { +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}))`; } @@ -1280,7 +1280,7 @@ function L(I) { 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, h = !1, N; +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", @@ -1297,7 +1297,7 @@ function k() { return I.pathname = I.pathname.replace(/[^/]*$/, ""), I.search = "", I.hash = "", I; } function $(I = k()) { - if (!X() || h || typeof fetch != "function") + 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; @@ -1316,7 +1316,7 @@ function $(I = k()) { return G; } return e(t, U); - }, h = !0; + }, f = !0; } function d() { return T || (T = import("proj-wasm")), T; @@ -1324,7 +1324,7 @@ function d() { async function m(I) { return F || ($(I?.projWasmAssetBaseUrl), F = d().then((A) => A.init({ workers: 1 }))), F; } -function f(I, A) { +function h(I, A) { return I <= A ? [[I, A]] : [ [I, 180], [-180, A] @@ -1333,8 +1333,8 @@ function f(I, A) { function q(I, A) { if (I.south > A.south || I.north < A.north) return !1; - const e = f(I.west, I.east); - return f(A.west, A.east).every(([U, n]) => e.some( + const e = h(I.west, I.east); + return h(A.west, A.east).every(([U, n]) => e.some( ([o, r]) => o <= U && r >= n )); } @@ -1446,7 +1446,7 @@ function g(I, A) { }, t), A.extent); }); } -async function b0(I = {}) { +async function E0(I = {}) { await m(I); } function n0(I) { @@ -1484,7 +1484,7 @@ async function P(I) { async function o0() { return M || (M = P(V)), M; } -async function E0(I) { +async function w0(I) { await m(I); 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(_); @@ -1541,6 +1541,8 @@ async function i0(I, A) { 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 }), @@ -1558,27 +1560,32 @@ function c0(I) { 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 G0 = /^osgeo::proj::io::(?:ParsingException|NoSuchAuthorityCodeException)\b/; +const u0 = /^osgeo::proj::io::(?:ParsingException|NoSuchAuthorityCodeException)\b/; function y(I) { - return I instanceof Error && G0.test(I.stack ?? ""); + return I instanceof Error && u0.test(I.stack ?? ""); } -const u0 = /^osgeo::proj::util::UnsupportedOperationException\b/; +const N0 = /^osgeo::proj::util::UnsupportedOperationException\b/; function W(I) { - return I instanceof Error && u0.test(I.stack ?? ""); + return I instanceof Error && N0.test(I.stack ?? ""); } -const b = /* @__PURE__ */ new Set(), N0 = 4096, j = new J(N0); +const b = /* @__PURE__ */ new Set(), l0 = 4096, j = new J(l0); async function v(I, A, e) { - const t = await l0(I, A, e); + const t = await C0(I, A, e); return t === null ? null : { points: t.points, unit: { ...t.unit } }; } -async function w0(I, A, e) { +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 l0(I, A, e) { +async function C0(I, A, e) { if (A.length === 0) return null; for (const r of A) if (!Number.isFinite(r[0]) || !Number.isFinite(r[1])) return null; @@ -1588,15 +1595,15 @@ async function l0(I, A, e) { if (U.kind === "crs") { const r = U.unit; if (r === null) return null; - const i = await C0(t, I.proj4, A); + const i = await d0(t, I.proj4, A); return i === null ? null : { points: i, unit: r }; } const n = I.unit; - if (n === void 0) return null; - const o = await d0(t, I.proj4, A); + if (!G0(n)) return null; + const o = await F0(t, I.proj4, A); return o === null ? null : { points: o, unit: { ...n } }; } -function C0(I, A, e) { +function d0(I, A, e) { return b.has(A) ? Promise.resolve(null) : j.use( I, A, @@ -1610,7 +1617,7 @@ function C0(I, A, e) { return b.add(A), null; }); } -function d0(I, A, e) { +function F0(I, A, e) { const t = `pipeline:${A}`; return j.use( I, @@ -1641,7 +1648,7 @@ async function D(I, A, e, t) { } return n; } -function L0(I, A) { +function P0(I, A) { const e = /* @__PURE__ */ new Map(); for (const t of I) e.set(t.id, { @@ -1658,19 +1665,19 @@ function L0(I, A) { }; } export { - p0 as bboxToPolygonWkt, - L0 as createCandidateTransformer, - m0 as extractAuthorityIdFromWkt, + b0 as bboxToPolygonWkt, + P0 as createCandidateTransformer, + f0 as extractAuthorityIdFromWkt, h0 as extractWktName, - R0 as inferApproximateBbox, - T0 as inferDatumFamily, - M0 as inferKind, - f0 as inferPriority, - S0 as inferWgsEquivalent, - b0 as initializeProjWasmRuntime, - F0 as isProjectedPrjPath, - g0 as isScopeIncluded, - E0 as listProjectedCrsFromProjWasm, - w0 as transformLonLatArrayToProjected, + 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/proj-wasm-crs.d.ts b/dist/proj-wasm-crs.d.ts index a614326..208001c 100644 --- a/dist/proj-wasm-crs.d.ts +++ b/dist/proj-wasm-crs.d.ts @@ -109,11 +109,13 @@ export declare function listProjectedCrsFromProjWasm(options: ListProjectedCrsFr /** * lon/lat(度・lon-lat 順)の点列を、候補の座標へ変換し、単位を添えて返す。 * - * - CRS: 軸が east / north の 2 次元で、両軸の単位が同じ線形単位(換算係数が正の有限値・単位名が - * 同じ)のときだけ変換する。単位は PROJ の軸情報から取る(metre なら - * `{ name: "metre", metresPerUnit: 1 }`)。 + * - CRS: 座標系が直交(Cartesian)で、軸が east / north の 2 次元で、両軸の単位が同じ線形単位 + * (換算係数が正の有限値・単位名が同じ)のときだけ変換する。地理座標系の軸は度で、換算係数が + * ラジアンであって metre への倍率ではないため対象外(`null`)。単位は PROJ の軸情報から取る + * (metre なら `{ name: "metre", metresPerUnit: 1 }`)。 * - CRS でない pipeline(custom / user の行の「格子 → lon/lat(度)」の向きの定義): 行の `unit` を - * 添えて、pipeline を逆向き(`PJ_INV`)に流す。行に `unit` が無ければ `null`(業務的に変換不能)。 + * 添えて、pipeline を逆向き(`PJ_INV`)に流す。行に `unit` が無い・`unit` が線形単位として不正 + * (空の `name`、正の有限の number でない `metresPerUnit`)なときは `null`(業務的に変換不能)。 * - 空入力・非有限の入力・非有限の出力・上の条件に合わない target は `null`(部分成功はしない)。 * 実装側の理由で変換できないとき(PJ を作れない・proj-wasm の呼び出しが throw する。wasm の * メモリが尽きた場合を含む)は `null` にせず throw する。 diff --git a/src/proj-wasm-crs.ts b/src/proj-wasm-crs.ts index 03e15c0..2958b21 100644 --- a/src/proj-wasm-crs.ts +++ b/src/proj-wasm-crs.ts @@ -565,7 +565,9 @@ interface ProjAxisInfo { * - CRS でない定義(pipeline。例: 同梱の custom projection の行)は `kind: "non-crs"`。 * その行は「格子 → lon/lat(度)」の向きの pipeline なので、逆向き(`PJ_INV`)に流す。 * - PROJ が読めない定義は `kind: "unreadable"`(業務的に変換不能)。 - * - CRS でも、軸が east / north の 2 次元でない・両軸の単位が同じ線形単位でないときは `unit: null`。 + * - CRS でも、座標系が直交(Cartesian)でない・軸が east / north の 2 次元でない・両軸の単位が + * 同じ線形単位でないときは `unit: null`。地理座標系の 2 軸はどちらも `degree` で、換算係数は + * 「1 度が何ラジアンか」であり、metre への倍率ではない ∴ 線形単位として扱わない。 * pipeline(CoordinateOperation)を `projCreateCrsToCrs` の target に渡しても **throw しない**。 * コンソールエラーだけを出して `undefined` を resolve し、それを `projTransArray` へ渡すと * wasm ヒープ故障になる(proj-wasm で実測)∴ 生成前に弾く必要がある。 @@ -574,7 +576,7 @@ type ProjTargetKind = "crs" | "non-crs" | "unreadable"; interface ProjTargetInspection { kind: ProjTargetKind; - /** east / north の 2 次元 CRS で、両軸の単位が同じ線形単位のときだけ。 */ + /** 直交(Cartesian)な east / north の 2 次元 CRS で、両軸の単位が同じ線形単位のときだけ。 */ unit: LinearUnit | null; } @@ -624,6 +626,13 @@ async function readTarget( return withPj(proj, () => proj.projCrsGetCoordinateSystem({ crs: pj }), async (cs) => { 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[] = [ @@ -646,6 +655,9 @@ const UNIT_FACTOR_RELATIVE_TOLERANCE = 1e-12; /** * 2 軸の単位が同じ線形単位のときだけその単位を返す。片方でも欠けている・換算係数が正の有限値で * ない・2 軸で違うときは `null`(業務的に変換不能)。 + * + * 呼ぶ側が座標系の型(直交)を確かめている前提で、軸の単位だけを見る。地理座標系の軸も単位名は + * 同じ `degree` で係数も正の有限値なので、型を見ないとここを通ってしまう。 */ function linearUnitOfAxes(axes: ProjAxisInfo[]): LinearUnit | null { const [east, north] = axes as [ProjAxisInfo, ProjAxisInfo]; @@ -662,6 +674,20 @@ function linearUnitOfAxes(axes: ProjAxisInfo[]): LinearUnit | 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"; @@ -712,11 +738,13 @@ interface CandidateTransform { /** * lon/lat(度・lon-lat 順)の点列を、候補の座標へ変換し、単位を添えて返す。 * - * - CRS: 軸が east / north の 2 次元で、両軸の単位が同じ線形単位(換算係数が正の有限値・単位名が - * 同じ)のときだけ変換する。単位は PROJ の軸情報から取る(metre なら - * `{ name: "metre", metresPerUnit: 1 }`)。 + * - CRS: 座標系が直交(Cartesian)で、軸が east / north の 2 次元で、両軸の単位が同じ線形単位 + * (換算係数が正の有限値・単位名が同じ)のときだけ変換する。地理座標系の軸は度で、換算係数が + * ラジアンであって metre への倍率ではないため対象外(`null`)。単位は PROJ の軸情報から取る + * (metre なら `{ name: "metre", metresPerUnit: 1 }`)。 * - CRS でない pipeline(custom / user の行の「格子 → lon/lat(度)」の向きの定義): 行の `unit` を - * 添えて、pipeline を逆向き(`PJ_INV`)に流す。行に `unit` が無ければ `null`(業務的に変換不能)。 + * 添えて、pipeline を逆向き(`PJ_INV`)に流す。行に `unit` が無い・`unit` が線形単位として不正 + * (空の `name`、正の有限の number でない `metresPerUnit`)なときは `null`(業務的に変換不能)。 * - 空入力・非有限の入力・非有限の出力・上の条件に合わない target は `null`(部分成功はしない)。 * 実装側の理由で変換できないとき(PJ を作れない・proj-wasm の呼び出しが throw する。wasm の * メモリが尽きた場合を含む)は `null` にせず throw する。 @@ -796,7 +824,7 @@ async function transformLonLatToCandidateOnce( // step 3': CRS でない pipeline。格子の単位は定義ファイルの行にしかない。 const unit = target.unit; - if (unit === undefined) return null; + if (!isValidLinearUnit(unit)) return null; const transformed = await transformWithPipelineOnce(proj, target.proj4, points); return transformed === null ? null : { points: transformed, unit: { ...unit } }; diff --git a/tests/proj-wasm-output-basis.test.ts b/tests/proj-wasm-output-basis.test.ts index 563a873..759bf61 100644 --- a/tests/proj-wasm-output-basis.test.ts +++ b/tests/proj-wasm-output-basis.test.ts @@ -159,6 +159,16 @@ describe("transformLonLatToCandidate", { timeout: PROJ_WASM_TEST_TIMEOUT_MS }, ( 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 }; @@ -192,7 +202,18 @@ describe("transformLonLatToCandidate", { timeout: PROJ_WASM_TEST_TIMEOUT_MS }, ( 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 () => { @@ -216,6 +237,48 @@ describe("transformLonLatToCandidate", { timeout: PROJ_WASM_TEST_TIMEOUT_MS }, ( 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"); @@ -271,6 +334,11 @@ describe("createCandidateTransformer with units", { timeout: PROJ_WASM_TEST_TIME 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]);