@jarenjs/core 0.34.0 → 0.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/geo/bbox.js CHANGED
@@ -17,6 +17,14 @@
17
17
  * The bounding box of a list of positions, as `[west, south, east, north]`.
18
18
  * Returns null for an empty list — there is no box that bounds nothing,
19
19
  * and an all-Infinity placeholder would silently intersect everything.
20
+ *
21
+ * Null too when any position is non-finite. Narrowing would drop it —
22
+ * every comparison against `NaN` is false — and answer with a finite,
23
+ * plausible box that does not contain the input it was asked to bound.
24
+ * A pre-filter built on such a box loses matching rows silently, which
25
+ * is the one failure a two-stage spatial plan cannot survive, so a
26
+ * position that cannot be bounded refuses the box rather than leaving
27
+ * it too small.
20
28
  * @param {Array<number[]>} positions
21
29
  * @returns {number[] | null}
22
30
  */
@@ -31,6 +39,8 @@ export function bboxOfPositions(positions) {
31
39
  for (let i = 0; i < n; i++) {
32
40
  const x = positions[i][0];
33
41
  const y = positions[i][1];
42
+ if (!Number.isFinite(x) || !Number.isFinite(y))
43
+ return null;
34
44
  if (x < west) west = x;
35
45
  if (x > east) east = x;
36
46
  if (y < south) south = y;
@@ -77,4 +87,35 @@ export function bboxUnion(a, b) {
77
87
  ];
78
88
  }
79
89
 
90
+ /**
91
+ * A box as the `Polygon` that covers it — the rectangle written
92
+ * counter-clockwise from its south-west corner, which is RFC 7946's
93
+ * exterior winding, and closed.
94
+ *
95
+ * A box is four numbers and a polygon is a value the rest of the
96
+ * language can measure, contain and intersect, so this is the crossing
97
+ * between the two: a geohash cell, an index probe box or a `bbox`
98
+ * member becomes something `$within` and `$area` can take.
99
+ *
100
+ * @param {number[] | null} box - [west, south, east, north]
101
+ * @returns {{ type: string, coordinates: number[][][] } | null} null for no box
102
+ * @example
103
+ * bboxPolygon([4, 52, 5, 53]);
104
+ * // { type: 'Polygon', coordinates: [[[4,52],[5,52],[5,53],[4,53],[4,52]]] }
105
+ */
106
+ export function bboxPolygon(box) {
107
+ if (box === null || !Array.isArray(box) || box.length < 4)
108
+ return null;
109
+ const west = box[0];
110
+ const south = box[1];
111
+ const east = box[2];
112
+ const north = box[3];
113
+ return {
114
+ type: 'Polygon',
115
+ coordinates: [[
116
+ [west, south], [east, south], [east, north], [west, north], [west, south],
117
+ ]],
118
+ };
119
+ }
120
+
80
121
  //#endregion
@@ -135,6 +135,54 @@ export function destinationPoint(lon, lat, bearing, distance, radius = EARTH_RAD
135
135
  return [((l2 * RAD + 540) % 360) - 180, p2 * RAD];
136
136
  }
137
137
 
138
+ /**
139
+ * The bounding box of a geodesic circle: every position within
140
+ * `distance` metres of `(lon, lat)` on the same sphere lies inside it.
141
+ * This is what turns "nearer than r" into a box a range index can seek.
142
+ *
143
+ * The latitude bounds are the circle's due-north and due-south points,
144
+ * but the longitude bounds are **not** its due-east and due-west ones:
145
+ * the circle reaches its extreme meridians where it runs tangent to
146
+ * them, further out than a 90° bearing travels — 0.4 % further at 80°N
147
+ * over 100 km. A box built from four bearings is therefore too small,
148
+ * and too small is the one error a conservative pre-filter cannot
149
+ * survive. The extreme half-width is `asin(sin δ / cos φ)` for angular
150
+ * radius δ at latitude φ.
151
+ *
152
+ * `null` when the circle reaches a pole (`|lat| + δ >= 90°`), where the
153
+ * longitude bound does not exist because the circle spans every
154
+ * meridian, and for any non-finite or negative input.
155
+ *
156
+ * West and east are NOT wrapped into `[-180, 180]`: a circle spanning
157
+ * the antimeridian answers a west below -180 or an east above 180, so
158
+ * a caller can tell that case apart — RFC 7946 asks producers to cut
159
+ * there, and a wrapped box would silently claim the short way round.
160
+ *
161
+ * @param {number} lon - centre longitude, degrees
162
+ * @param {number} lat - centre latitude, degrees
163
+ * @param {number} distance - radius in metres (or in `radius`'s unit)
164
+ * @param {number} [radius] - sphere radius
165
+ * @returns {[number, number, number, number] | null} `[west, south, east, north]`
166
+ * @example
167
+ * circleBounds(5, 52, 1000); // [4.98539…, 51.99101…, 5.01461…, 52.00899…]
168
+ */
169
+ export function circleBounds(lon, lat, distance, radius = EARTH_RADIUS) {
170
+ if (!Number.isFinite(lon) || !Number.isFinite(lat)
171
+ || !Number.isFinite(distance) || distance < 0) return null;
172
+ const d = distance / radius;
173
+ const dDeg = d * RAD;
174
+ // at or past a pole the circle covers every meridian, so no box bounds it
175
+ if (Math.abs(lat) + dDeg >= 90) return null;
176
+ // the tangent half-width is never narrower than the angular radius
177
+ // itself (cos φ <= 1), and at the equator the asin(sin …) round trip
178
+ // loses the last bit of exactly that value — taking the wider of the
179
+ // two restores an inequality the real numbers always satisfy rather
180
+ // than padding by a constant nobody could justify
181
+ const tangent = Math.asin(Math.sin(d) / Math.cos(lat * DEG)) * RAD;
182
+ const half = tangent > dDeg ? tangent : dDeg;
183
+ return [lon - half, lat - dDeg, lon + half, lat + dDeg];
184
+ }
185
+
138
186
  /**
139
187
  * Total great-circle length of a line of positions, in metres. An empty
140
188
  * or single-position line has length 0.
@@ -105,7 +105,10 @@ export function eachPosition(value, visit) {
105
105
 
106
106
  /**
107
107
  * The bounding box of any GeoJSON value, as `[west, south, east, north]`.
108
- * Null when the value contains no positions.
108
+ * Null when the value contains no positions, and null when any position
109
+ * it walks is non-finite — the rule `bboxOfPositions` states, for the
110
+ * same reason: a box that does not bound its input is worse than no
111
+ * box.
109
112
  *
110
113
  * A `bbox` member already present on the value is ignored: it is an
111
114
  * optimization the producer may have got wrong, and recomputing is the
@@ -123,16 +126,21 @@ export function bboxOf(value) {
123
126
  let east = -Infinity;
124
127
  let north = -Infinity;
125
128
  let seen = false;
129
+ let bounded = true;
126
130
  eachPosition(value, (p) => {
127
131
  const x = p[0];
128
132
  const y = p[1];
129
133
  seen = true;
134
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
135
+ bounded = false;
136
+ return;
137
+ }
130
138
  if (x < west) west = x;
131
139
  if (x > east) east = x;
132
140
  if (y < south) south = y;
133
141
  if (y > north) north = y;
134
142
  });
135
- return seen ? [west, south, east, north] : null;
143
+ return seen && bounded ? [west, south, east, north] : null;
136
144
  }
137
145
 
138
146
  /**
package/src/geo/index.js CHANGED
@@ -21,7 +21,7 @@
21
21
  // geohash.js the string encoding that needs no new vocabulary
22
22
  // geojson.js the one layer that knows the `type` discriminator
23
23
  // valid.js the one-call structural judgment (rings must close)
24
- // wkt.js a validity tester for the databases' text encoding
24
+ // wkt.js the round trip with the databases' text encoding
25
25
  // index-tree.js a static packed-Hilbert box index for spatial joins
26
26
  // mercator.js the projection out, for anything that draws a map
27
27
  // simplify.js dropping the vertices that land on the same pixel
@@ -127,4 +127,73 @@ export function simplifyRing(ring, tolerance) {
127
127
  return simplified.length >= 4 ? simplified : ring.slice();
128
128
  }
129
129
 
130
+ /** Every line/ring of a coordinate nest `depth` levels above the leaf. */
131
+ function simplifyNest(value, depth, tolerance, simplify) {
132
+ if (!Array.isArray(value))
133
+ return value;
134
+ if (depth === 0)
135
+ return simplify(value, tolerance);
136
+ const out = new Array(value.length);
137
+ for (let i = 0; i < value.length; i++)
138
+ out[i] = simplifyNest(value[i], depth - 1, tolerance, simplify);
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * A whole GeoJSON value with its vertices dropped — the same value,
144
+ * reduced. Lines go through {@link simplifyLine} and rings through
145
+ * {@link simplifyRing}, so **a ring stays closed and a line keeps both
146
+ * endpoints**; points and multipoints have no run to collapse and come
147
+ * back untouched.
148
+ *
149
+ * Accepts what the traversal layer accepts: a geometry, a Feature, a
150
+ * FeatureCollection or a GeometryCollection. Foreign members, ids and
151
+ * properties ride along — the value that comes back is the value that
152
+ * went in, with fewer positions, so it can be stored or sent as-is.
153
+ * Anything this does not recognize is returned unchanged rather than
154
+ * dropped.
155
+ *
156
+ * The tolerance is in the coordinate's own units, which for GeoJSON is
157
+ * **degrees** — a planar vertex-dropping threshold, never a distance. A
158
+ * degree of longitude is not a fixed length, so calling it metres would
159
+ * be the planar-for-geodesic confusion the rest of this module exists to
160
+ * prevent.
161
+ *
162
+ * @param {any} value
163
+ * @param {number} tolerance - in degrees
164
+ * @returns {any} a new value; the input is not modified
165
+ * @example
166
+ * simplifyGeometry({ type: 'LineString', coordinates: [[0,0],[1,0.001],[2,0]] }, 0.01);
167
+ * // { type: 'LineString', coordinates: [[0,0],[2,0]] }
168
+ */
169
+ export function simplifyGeometry(value, tolerance) {
170
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
171
+ return value;
172
+ if (value.type === 'FeatureCollection') {
173
+ if (!Array.isArray(value.features))
174
+ return value;
175
+ return { ...value, features: value.features.map((f) => simplifyGeometry(f, tolerance)) };
176
+ }
177
+ if (value.type === 'Feature') {
178
+ return { ...value, geometry: simplifyGeometry(value.geometry, tolerance) };
179
+ }
180
+ if (value.type === 'GeometryCollection') {
181
+ if (!Array.isArray(value.geometries))
182
+ return value;
183
+ return { ...value, geometries: value.geometries.map((g) => simplifyGeometry(g, tolerance)) };
184
+ }
185
+ switch (value.type) {
186
+ case 'LineString':
187
+ return { ...value, coordinates: simplifyNest(value.coordinates, 0, tolerance, simplifyLine) };
188
+ case 'MultiLineString':
189
+ return { ...value, coordinates: simplifyNest(value.coordinates, 1, tolerance, simplifyLine) };
190
+ case 'Polygon':
191
+ return { ...value, coordinates: simplifyNest(value.coordinates, 1, tolerance, simplifyRing) };
192
+ case 'MultiPolygon':
193
+ return { ...value, coordinates: simplifyNest(value.coordinates, 2, tolerance, simplifyRing) };
194
+ default: // Point, MultiPoint, and anything unrecognized: nothing to drop
195
+ return value;
196
+ }
197
+ }
198
+
130
199
  //#endregion