osmfeatures 0.2.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,28 +1,42 @@
1
- # OSM Features API client
1
+ # TypeScript OSM Features client
2
2
 
3
- Official client for the [MapLark OSM Features API](https://maplark.com) to get GeoJSON, FlatGeobuf, GeoParquet, or CSV from OpenStreetMap. The API gets data from dedicated postgis OSM servers separate from public Overpass.
3
+ npm package for the [MapLark OSM Features API](https://maplark.com). Fetch OpenStreetMap buildings, roads, parks, and POIs as GeoJSON, FlatGeobuf, GeoParquet, or CSV from Node.js or the browser without standing up Overpass or converting extracts by hand. Simply search by tag and bounding box or location + radius. The API keeps OSM semantics intact, like node, way, relation, and returns GeoJSON Features you can feed straight into Leaflet, MapLibre, OpenLayers, or any geospatial toolchain. Use this lib to build geospatial apps on OSM easily without hitting rate limits or setting up complex and expensive infrastructure yourself.
4
4
 
5
- Query OpenStreetMap features such as buildings, streets, and POIs easily. Search for OSM features by bounding box, tags, and geometry shape and get GeoJSON back within less than 250ms (dependent on query size). No converting between formats manually. The API keeps OSM semantics intact, like tags and ways, and returns OSM features you can feed straight into Leaflet, MapLibre, OpenLayers, or any geospatial toolchain. It is backed by postgis with tiered API keys and rate limiting to keep noisy neighbours out to give you low, predictable latency for real traffic. It also has a self-host path for those willing to host complex infrastructure themselves, and Geo Agent methods for places search, opening hours, and walk or bike routing.
5
+ The backend is dedicated PostGIS, not the public Overpass endpoint, with API keys and rate limits so map tiles and POI queries stay fast under load. This SDK also covers local-search and mobility: amenity lookup, OSM opening hours, and walk or bicycle routing.
6
6
 
7
- The translation layer is very simple:
7
+ ## Contents
8
8
 
9
- - `node` - GIS Point
10
- - `way` - LineString or Polygon
11
- - `relation` - MultiPolygon or grouped geometries
9
+ - [Quick start](#quick-start)
10
+ - [Functions and Parameters](#functions-and-parameters)
11
+ - [query()](#query)
12
+ - [query_all](#query_all)
13
+ - [stats](#stats)
14
+ - [estimate_cost](#estimate_cost)
15
+ - [usage](#usage)
16
+ - [Places and routes](#places-and-routes)
17
+ - [Places search](#places-search)
18
+ - [Nearby](#nearby-ranked-from-a-point)
19
+ - [Place details](#place-details)
20
+ - [Opening hours](#opening-hours)
21
+ - [X near Y](#x-near-y-local-join)
22
+ - [Walk and bike routes](#walk-and-bike-routes)
23
+ - [MCP server](#mcp-server)
12
24
 
13
- You filter with the same tags mappers already use (`amenity=cafe`, `building=yes`, and so on). Knowledge from OSM, Overpass, and tagging docs transfers immediately.
25
+ OSM types map to GeoJSON the way GIS tools expect:
14
26
 
15
- To narrow down between "open ways" and "closed ways", use the `way_shape` parameter:
27
+ - `node` → Point
28
+ - `way` → LineString or Polygon
29
+ - `relation` → MultiPolygon or a bundle of geometries
16
30
 
17
- - `way_shape=line` - open ways (roads, paths, rivers) or line-shaped relations (routes, boundaries)
18
- - `way_shape=polygon` - closed ways (buildings, parks) or multipolygon relations.
19
- - `way_shape=all` - both shapes (default when way_shape is omitted).
31
+ Filters use ordinary OSM tags (`amenity=cafe`, `building=yes`). If you already write Overpass or edit OSM, the same keys work here. Drop a FeatureCollection into Leaflet, MapLibre, OpenLayers, or Turf.
20
32
 
21
- For example, to get all buildings in an area:
33
+ Use `way_shape` when you need lines vs areas:
22
34
 
23
- `type=way & tags=building`
35
+ - `way_shape=line` unclosed ways (streets, footpaths, rivers) and line-like relations (routes, some boundaries)
36
+ - `way_shape=polygon` — closed ways (building footprints, parks) and multipolygon relations
37
+ - `way_shape=all` — both (the default if you leave it off)
24
38
 
25
- This is the equivalent of the Overpass query `way[building]`.
39
+ Buildings in a box: `type=way & tags=building` — the same idea as Overpass `way[building]`.
26
40
 
27
41
  # Quick start
28
42
 
@@ -75,6 +89,7 @@ The geographical area for the request in terms of GPS coordinates or specific OS
75
89
  | `bbox` | `string` | Bounding box as `min_lon,min_lat,max_lon,max_lat`. |
76
90
  | `location` | `string` | Point for a radius search as `lat,lng`. Requires `radius`. |
77
91
  | `radius` | `number` | Search radius in metres. Requires `location`. |
92
+ | `within` | `string` | Polygon spatial anchor as `way/<id>` or `relation/<id>`. Mutually exclusive with `bbox` / `location`. |
78
93
  | `osmIds` | `string` | Comma-separated OSM IDs to fetch by id. |
79
94
 
80
95
 
@@ -121,7 +136,7 @@ Extra filters to for pagination, output format (accept),
121
136
  | Param | Type | Default | Description |
122
137
  | ---------------------- | --------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
138
  | `accept` | `string` | `application/geo+json` | Response media type in header. Options - `application/geo+json`, `text/csv`, `text/tab-separated-values`, `application/flatgeobuf`, and `application/vnd.apache.parquet`. |
124
- | `limit` | `number` | `1000` | Page size. Max `6000`. |
139
+ | `limit` | `number` | API `1000` | Page size. Omit to use the API default. Max `6000`. |
125
140
  | `cursor` | `string` | | Pagination cursor from a previous `meta.next_cursor`. |
126
141
  | `disableBudgetWarning` | `boolean` | `false` | Ignore warnings for large queries that consume budget quotas. |
127
142
  | `zoom` | `number` | | Map zoom hint (used by presets / server-side simplification policies). |
@@ -148,7 +163,7 @@ Fields for pagination and usage.
148
163
 
149
164
  Auto-paginates (and optionally tiles the bbox) until the result is complete or a client-side cap is hit. GeoJSON only — for FlatGeobuf / other encodings, use `query` with `accept`.
150
165
 
151
- Does not take `limit` or `cursor`; paging is handled internally.
166
+ Does not take `limit` or `cursor`; paging is handled internally. Does not tile when `within` is set (`bboxTiles` is ignored).
152
167
 
153
168
  ```ts
154
169
  const all = await client.query_all({
@@ -168,12 +183,12 @@ console.log(all.meta.page_count, all.meta.has_more, all.meta.units_charged);
168
183
 
169
184
  ### Params
170
185
 
171
- Same filter params as `query` (`bbox`, `tags`, `orTags`, `notTags`, `type`, `wayShape`, `zoom`, `location`, `radius`, `osmIds`, `minLengthM`, `maxLengthM`, `minAreaM2`, `maxAreaM2`, `centroid`, `clipGeometry`, `disableBudgetWarning`), plus:
186
+ Same filter params as `query` (`bbox`, `tags`, `orTags`, `notTags`, `type`, `wayShape`, `zoom`, `location`, `radius`, `within`, `osmIds`, `minLengthM`, `maxLengthM`, `minAreaM2`, `maxAreaM2`, `centroid`, `clipGeometry`, `disableBudgetWarning`), plus:
172
187
 
173
188
 
174
189
  | Param | Type | Default | Description |
175
190
  | -------------- | --------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
176
- | `limitPerPage` | `number` | `1000` | Upstream `limit` per HTTP request (page size). |
191
+ | `limitPerPage` | `number` | API `1000` | Upstream `limit` per HTTP request (page size). Omit to use the API default. |
177
192
  | `bboxTiles` | `number` | `2` | Split `bbox` into this many tiles (must be a power of 2: `1`, `2`, `4`, `8`, …). Each tile is paginated separately, then features are merged and deduped. |
178
193
  | `maxPages` | `number` | `15` | Max pages fetched **per tile**. |
179
194
  | `maxFeatures` | `number | null` | `55000` | Cap on merged features after dedupe. Pass `null` for no cap. |
@@ -197,8 +212,33 @@ Same fields as `query`, plus:
197
212
  | `relay_partial_reason` | e.g. `upstream_rejected_cursor` or `upstream_rate_limited_after_retries`. |
198
213
 
199
214
 
200
- Also exports layer presets (`resolveLayerFromQuery`, `OSM_FEATURES_LAYER_PRESETS`, ...)
201
- and GeoJSON payload helpers (`featureCentroid`, `geometryBounds`, `parseFeatureId`, ...).
215
+ Also exports layer presets (`resolveLayerFromQuery`, `OSM_FEATURES_LAYER_PRESETS`, ...),
216
+ GeoJSON payload helpers (`featureCentroid`, `geometryBounds`, `parseFeatureId`, ...),
217
+ and local helpers (`nearest_within`, `point_in_geometry`, `isOpenNow`).
218
+
219
+ ## `stats`
220
+
221
+ Count features grouped by a tag key via `GET /v2/osm_features/stats`. Returns `{ groups, total, truncated }`. Spatial windows are larger than `query` (country-scale on every tier) and billed count-only. `limit` is max histogram buckets (API default 100), not a scan cap. `groupBy` is required. Same tag filters as `query`; no `osmIds`, `cursor`, `zoom`, `centroid`, or `clipGeometry`. Map Express/query strings with `resolveStatsRequest` (requires `group_by`).
222
+
223
+ ```ts
224
+ const histogram = await client.stats({
225
+ groupBy: 'amenity',
226
+ bbox: '18.05,59.32,18.10,59.34',
227
+ type: 'node',
228
+ tags: ['amenity'],
229
+ });
230
+ console.log(histogram.total, histogram.groups);
231
+ ```
232
+
233
+ City boundary:
234
+
235
+ ```ts
236
+ const mix = await client.stats({
237
+ groupBy: 'amenity',
238
+ within: 'relation/398021',
239
+ tags: ['amenity'],
240
+ });
241
+ ```
202
242
 
203
243
  ## `estimate_cost`
204
244
 
@@ -221,37 +261,15 @@ const usage = await client.usage();
221
261
  console.log(usage.tier, usage.usage_this_month, usage.remaining_this_month);
222
262
  ```
223
263
 
224
- ## Geo Agent (places and routes)
225
-
226
- `query()` is the generic OpenStreetMap layer: buildings, roads, park polygons, any tag and geometry shape. Geo Agent is the place and mobility layer on top of the same OSM data. You pick OSM tags (`amenity=cafe`), an area, a time, and walk or bike. The API returns coordinates, opening-hours status, nearest-first ranks, and walk or bike geometry.
227
-
228
- These endpoints answer questions like "cafes near me", "bars open at 20:00", or "suggest a walking bar crawl in Stockholm". An AI agent or a script can call the same methods.
264
+ ## Places and routes
229
265
 
230
- | Endpoint | HTTP | What it does |
231
- | -------- | ---- | ------------ |
232
- | `places_search` | `POST /v1/places/search` | Find places in a bounding box **or** a `location` plus `radius`. Filter with OSM tags. Optional `openNow` / `asOf` for opening hours. |
233
- | `places_nearby` | `POST /v1/places/nearby` | "X near this point". Same tags and hours filters, ranked nearest-first by straight-line distance. |
234
- | `places_details` | `GET /v1/places/{osm_type}/{osm_id}` | Reload one place by the id search or nearby returned (`node/123`). |
235
- | `routes_isochrone` | `POST /v1/routes/isochrone` | Walk or bike reach polygon from an origin (how far you can get in N metres or seconds). |
236
- | `routes_path` | `POST /v1/routes/path` | Walk or bike through stops in the order you list them. No reordering. |
237
- | `routes_optimized_path` | `POST /v1/routes/optimized_path` | Order the stops for you (a tour from `start`). `loop` (default true) returns to start. |
266
+ `query()` is the raw OSM layer: footprints, highways, park polygons, any tag and geometry class. Places and routes sit on the same planet extract but answer product questions: amenities in a box, ranked POIs from a pin, opening hours, walk/bike isochrones, and multi-stop paths. You supply tags, extent, time, and `WALK` or `BICYCLE`. The API returns coordinates, `openNow`, distances, and network geometry.
238
267
 
239
- Search and nearby hours use each place's local timezone. Optional `asOf` pins the evaluation instant. Routing is walk or bicycle on the OSM network (`travelMode`: `WALK` or `BICYCLE`). Car routing is not available yet.
268
+ Unset fields are omitted so server defaults apply (`places_search` limit 100, `places_nearby` radius 1000 m and limit 100, `loop` true). HTTP docs: [maplark.com/developer](https://maplark.com/developer).
240
269
 
241
- #### Typical questions
270
+ ### Places search
242
271
 
243
- | Prompt | SDK |
244
- |------|-----|
245
- | "Cafes near me" | `client.places_nearby()` or `client.places_search()` with `location` + `radius` |
246
- | "Restaurants within 150 m of a station" | two `client.places_search()` calls, then join locally by distance |
247
- | "Bars open at 20:00" | `client.places_search()` with `asOf`, keep `openingHours.status == "open"` |
248
- | "Cafes within a 10-minute bike ride" | `client.routes_isochrone()` + `client.places_search()` in a covering radius + keep points inside the polygon |
249
- | "A walking bar crawl in Stockholm" | `client.places_search()` + `client.routes_optimized_path()` (`loop: true`) |
250
- | "Walk from my hotel to the cafe, then the office" | `client.routes_path()` with those stops in listed order |
251
- | "Suggest a walk to a bar, a restaurant, and a cafe, no particular order" | `client.routes_optimized_path()` with `loop: false` |
252
- | "Is the office a 20-minute walk from the apartment?" | `client.routes_isochrone()` from A, point-in-polygon for B |
253
-
254
- ### Examples for `places_search` / `places_nearby` / `places_details`
272
+ `places_search()` looks up POIs inside a bounding box **or** around `{ lat, lon }` + `radius` (pick one). `tags` is AND; `orTags` is OR; both use the same OSM keys as `query()`. Leave `limit` off for the API default (100, max 10_000).
255
273
 
256
274
  ```ts
257
275
  const origin = { lat: 59.316, lon: 18.075 };
@@ -263,7 +281,13 @@ const cafes = await client.places_search({
263
281
  openNow: true,
264
282
  asOf: '2026-08-10T18:00:00+02:00',
265
283
  });
284
+ ```
266
285
 
286
+ ### Nearby (ranked from a point)
287
+
288
+ `places_nearby()` is “what is closest to this coordinate?”. You must pass `tags` or `orTags`. Hits are ordered by straight-line spheroid distance. Defaults if omitted: 1000 m radius, 100 results.
289
+
290
+ ```ts
267
291
  const nearby = await client.places_nearby({
268
292
  location: origin,
269
293
  orTags: ['amenity=cafe'],
@@ -271,16 +295,55 @@ const nearby = await client.places_nearby({
271
295
  openNow: true,
272
296
  asOf: '2026-08-10T18:00:00+02:00',
273
297
  });
298
+ ```
299
+
300
+ ### Place details
274
301
 
302
+ `places_details()` reloads a single OSM place by the id search or nearby gave you (`node/123`), or as `{ osmType, osmId }`.
303
+
304
+ ```ts
275
305
  const first = (cafes.features as { id: string }[])[0];
276
306
  const details = await client.places_details({ osmType: first.id });
307
+ // same as: client.places_details({ osmType: 'node', osmId: 123 })
308
+ ```
309
+
310
+ Hours are evaluated at request time in that place’s timezone.
311
+
312
+ ### Opening hours
313
+
314
+ When OSM `opening_hours` can be parsed, the feature gets `properties.openNow` as `true` or `false`. Missing or junk hours omit the field. `isOpenNow(feature)` keeps known-open places and unwraps the details `{ feature }` envelope.
315
+
316
+ Timezone is inferred from coordinates (IANA). There is no `timezone` request field.
317
+
318
+ - `openNow: true` drops closed and unknown-hours POIs (Google Places–style `openNow`).
319
+ - `asOf` is the evaluation instant (default: now). An offset (`Z`, `+02:00`) is an absolute instant. A naive `2026-08-10T20:00:00` is local clock at the search point or bbox centre.
320
+ - `asOf` or `openNow` also require an `opening_hours` tag, so untagged amenities do not pad the page.
321
+ - Places that are closed but tagged still appear unless `openNow` is set.
322
+
323
+ ### "X near Y" (local join)
324
+
325
+ `places_nearby` ranks against one origin. “Restaurants within 150 m of a station” is two searches plus an in-process join. `nearest_within` does not hit the API.
326
+
327
+ ```ts
328
+ import { nearest_within } from 'osmfeatures';
329
+
330
+ const bbox = '18.05,59.33,18.10,59.36';
331
+ const restaurants = await client.places_search({ bbox, orTags: ['amenity=restaurant'] });
332
+ const stations = await client.places_search({ bbox, orTags: ['railway=station'] });
333
+ const pairs = nearest_within(restaurants, stations, 150, { limit: 20 });
334
+
335
+ for (const pair of pairs) {
336
+ console.log(pair.distance_m, pair.feature, 'near', pair.nearest);
337
+ }
277
338
  ```
278
339
 
279
- `places_details` also accepts `{ osmType: 'node', osmId: 123 }`. Hours are annotated at request time in the place's local timezone.
340
+ Each pair is `{ feature, distance_m, nearest }`. The point is `geometry` when it is a Point, otherwise `properties.centroid` (same as `featureCentroid`). Neither present throws. Empty secondary → `[]`. Distances are spherical haversine (mean Earth radius 6371000 m). `limit` keeps the closest pairs (default 20); `{ limit: null }` returns every primary with a match. More than 500000 comparisons throws — lower `places_search` / `places_nearby` `limit`, do not use `query_all`. `{ data, meta }` from `query()` / `query_all()` is accepted.
341
+
342
+ ### Walk and bike routes
280
343
 
281
- ### Examples for `routes_isochrone` / `routes_path` / `routes_optimized_path`
344
+ Paths follow OSM walk and bicycle ways. Default `travelMode` is `WALK`; pass `'BICYCLE'` for bikes. Driving is not offered yet.
282
345
 
283
- Points accept `lon` or `lng`. `routes_isochrone` takes exactly one of `maxDistanceM` or `durationS`. Optional `searchBufferM` widens the highway fetch corridor.
346
+ Coordinates take `lon` or `lng`. For `routes_isochrone`, set exactly one of `maxDistanceM` or `durationS`. `searchBufferM` widens the highway fetch if the default corridor cannot form a path.
284
347
 
285
348
  ```ts
286
349
  const origin = { lon: 18.075, lat: 59.316 };
@@ -299,6 +362,15 @@ const tour = await client.routes_optimized_path({
299
362
  start: origin,
300
363
  stops: [cafe],
301
364
  });
365
+
366
+ const office = { lon: 18.08, lat: 59.318 };
367
+ point_in_geometry(office.lon, office.lat, iso);
302
368
  ```
303
369
 
304
- Read the full API reference here [https://maplark.com/developer](https://maplark.com/developer) such as the OpenAPI 2.0 HTTP docs.
370
+ In-process (no HTTP): `nearest_within(primary, secondary, maxDistanceM)` for proximity joins, `point_in_geometry(lon, lat, geom)` for isochrone containment. The latter accepts a Polygon/MultiPolygon, a Feature, a GeometryCollection, or `{ geometry }` from the isochrone response.
371
+
372
+
373
+ ## MCP server
374
+
375
+ Maplark has an MCP server to integrate OpenStreetMap data into AI and LLMs such as Claude, Cursor, and Copilot. However, it is implemented in another Python sister repo. See [maplark.com/products/mcp-server](https://maplark.com/products/mcp-server) for more details.
376
+
@@ -42,6 +42,15 @@ export function readTags(properties) {
42
42
  }
43
43
  return {};
44
44
  }
45
+ function finiteLonLat(lon, lat) {
46
+ if (typeof lon !== 'number' || typeof lat !== 'number') {
47
+ return null;
48
+ }
49
+ if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
50
+ return null;
51
+ }
52
+ return [lon, lat];
53
+ }
45
54
  function collectPositions(coordinates, out) {
46
55
  if (!Array.isArray(coordinates) || coordinates.length === 0) {
47
56
  return;
@@ -86,22 +95,20 @@ export function featureCentroid(feature) {
86
95
  const group = geometryGroup(feature.geometry?.type?.trim() ?? '');
87
96
  if (group === 'points') {
88
97
  const coords = feature.geometry?.coordinates;
89
- if (feature.geometry?.type === 'Point' && Array.isArray(coords) && typeof coords[0] === 'number') {
90
- return [coords[0], coords[1]];
98
+ if (feature.geometry?.type === 'Point' && Array.isArray(coords)) {
99
+ return finiteLonLat(coords[0], coords[1]);
91
100
  }
92
101
  if (feature.geometry?.type === 'MultiPoint' && Array.isArray(coords) && coords.length === 1) {
93
102
  const point = coords[0];
94
- if (typeof point?.[0] === 'number') {
95
- return [point[0], point[1]];
96
- }
103
+ return finiteLonLat(point?.[0], point?.[1]);
97
104
  }
98
105
  return null;
99
106
  }
100
107
  const centroid = feature.properties?.['centroid'];
101
108
  if (centroid && typeof centroid === 'object' && !Array.isArray(centroid)) {
102
109
  const coords = centroid.coordinates;
103
- if (Array.isArray(coords) && typeof coords[0] === 'number' && typeof coords[1] === 'number') {
104
- return [coords[0], coords[1]];
110
+ if (Array.isArray(coords)) {
111
+ return finiteLonLat(coords[0], coords[1]);
105
112
  }
106
113
  }
107
114
  return null;
@@ -0,0 +1,12 @@
1
+ /** Local geometry helpers. No HTTP. The planner must not invent containment. */
2
+ export type GeometryLike = {
3
+ type?: string;
4
+ coordinates?: unknown;
5
+ geometries?: unknown;
6
+ geometry?: GeometryLike | null;
7
+ };
8
+ /**
9
+ * Point-in-polygon for GeoJSON Polygon/MultiPolygon, including holes.
10
+ * Also accepts a Feature, GeometryCollection, or `{ geometry }` (isochrone body).
11
+ */
12
+ export declare function point_in_geometry(lon: number, lat: number, geom: unknown): boolean;
@@ -0,0 +1,60 @@
1
+ /** Local geometry helpers. No HTTP. The planner must not invent containment. */
2
+ function pointInRing(lon, lat, ring) {
3
+ let inside = false;
4
+ const n = ring.length;
5
+ for (let i = 0; i < n; i += 1) {
6
+ const x1 = ring[i][0];
7
+ const y1 = ring[i][1];
8
+ const next = ring[(i + 1) % n];
9
+ const x2 = next[0];
10
+ const y2 = next[1];
11
+ if ((y1 > lat) !== (y2 > lat)) {
12
+ const xAtLat = x1 + ((lat - y1) * (x2 - x1)) / (y2 - y1);
13
+ if (lon < xAtLat) {
14
+ inside = !inside;
15
+ }
16
+ }
17
+ }
18
+ return inside;
19
+ }
20
+ function pointInPolygonRings(lon, lat, rings) {
21
+ let inside = false;
22
+ for (const ring of rings) {
23
+ if (Array.isArray(ring) && ring.length > 0 && pointInRing(lon, lat, ring)) {
24
+ inside = !inside;
25
+ }
26
+ }
27
+ return inside;
28
+ }
29
+ function pointInUnknown(lon, lat, geom, depth) {
30
+ if (geom == null || typeof geom !== 'object' || depth > 4) {
31
+ return false;
32
+ }
33
+ const g = geom;
34
+ const gtype = g.type;
35
+ if (gtype === 'Polygon') {
36
+ const coords = g.coordinates;
37
+ return Array.isArray(coords) && pointInPolygonRings(lon, lat, coords);
38
+ }
39
+ if (gtype === 'MultiPolygon') {
40
+ const coords = g.coordinates;
41
+ return Array.isArray(coords)
42
+ && coords.some((poly) => Array.isArray(poly) && poly.length > 0
43
+ && pointInPolygonRings(lon, lat, poly));
44
+ }
45
+ if (gtype === 'GeometryCollection') {
46
+ const parts = g.geometries;
47
+ return Array.isArray(parts) && parts.some((part) => pointInUnknown(lon, lat, part, depth + 1));
48
+ }
49
+ if (gtype === 'Feature' || (gtype == null && g.geometry != null)) {
50
+ return pointInUnknown(lon, lat, g.geometry, depth + 1);
51
+ }
52
+ return false;
53
+ }
54
+ /**
55
+ * Point-in-polygon for GeoJSON Polygon/MultiPolygon, including holes.
56
+ * Also accepts a Feature, GeometryCollection, or `{ geometry }` (isochrone body).
57
+ */
58
+ export function point_in_geometry(lon, lat, geom) {
59
+ return pointInUnknown(lon, lat, geom, 0);
60
+ }
package/dist/index.d.ts CHANGED
@@ -38,6 +38,7 @@ export type OSMFeaturesLayer = {
38
38
  };
39
39
  /** Flat query params (same idea as Python `query(**params)`). */
40
40
  export type OSMFeaturesParams = OSMFeaturesLayer & {
41
+ /** Page size. Omit to use the API default (1000). Max `6000`. */
41
42
  limit?: number;
42
43
  cursor?: string;
43
44
  zoom?: number;
@@ -45,18 +46,53 @@ export type OSMFeaturesParams = OSMFeaturesLayer & {
45
46
  location?: string;
46
47
  /** Search radius in metres. Requires `location`. */
47
48
  radius?: number;
49
+ /** Polygon spatial anchor as `way/<id>` or `relation/<id>`. Mutually exclusive with `bbox` / `location`. */
50
+ within?: string;
48
51
  osmIds?: string;
49
52
  minLengthM?: number;
50
53
  maxLengthM?: number;
51
54
  minAreaM2?: number;
52
55
  maxAreaM2?: number;
53
56
  disableBudgetWarning?: boolean;
57
+ /** When true, include `properties.centroid` on non-point features. Omit for the API default (false). */
54
58
  centroid?: boolean;
55
- /** When true (default), clip returned geometry to the requested bbox. */
59
+ /** When true, clip returned geometry to the requested bbox. Omit for the API default (false). */
56
60
  clipGeometry?: boolean;
57
61
  /** Accept media type. Default application/geo+json; other types put bytes in ``data``. */
58
62
  accept?: string;
59
63
  };
64
+ /** `GET /v2/osm_features/stats`. Same filters as `query` except paging/geometry extras. */
65
+ export type OSMFeaturesStatsParams = {
66
+ /** Tag key to group on (required). Features without this key are not counted. */
67
+ groupBy: string;
68
+ bbox?: string;
69
+ location?: string;
70
+ radius?: number;
71
+ within?: string;
72
+ type?: string;
73
+ wayShape?: 'line' | 'polygon' | 'all';
74
+ /** @deprecated Use `wayShape`. */
75
+ shape?: 'line' | 'polygon' | 'all';
76
+ tags?: string[];
77
+ orTags?: string[];
78
+ notTags?: string[];
79
+ /** Max histogram buckets (API default 100, max 10000). Does not cap the scan. */
80
+ limit?: number;
81
+ minLengthM?: number;
82
+ maxLengthM?: number;
83
+ minAreaM2?: number;
84
+ maxAreaM2?: number;
85
+ disableBudgetWarning?: boolean;
86
+ };
87
+ export type OSMStatsGroup = {
88
+ value: string;
89
+ count: number;
90
+ };
91
+ export type OSMStatsResponse = {
92
+ groups: OSMStatsGroup[];
93
+ total: number;
94
+ truncated: boolean;
95
+ };
60
96
  type QueryValue = unknown;
61
97
  export type OSMFeaturesQuery = Record<string, QueryValue>;
62
98
  /** Routing point. Accepts ``lon`` or ``lng``. */
@@ -95,6 +131,7 @@ export type PlacesSearchParams = {
95
131
  tags?: string[];
96
132
  orTags?: string[];
97
133
  limit?: number;
134
+ /** Keep only places known open at ``asOf`` (or now). Untagged hours are dropped. */
98
135
  openNow?: boolean;
99
136
  asOf?: string;
100
137
  };
@@ -105,9 +142,29 @@ export type PlacesNearbyParams = {
105
142
  tags?: string[];
106
143
  orTags?: string[];
107
144
  limit?: number;
145
+ /** Keep only places known open at ``asOf`` (or now). Untagged hours are dropped. */
108
146
  openNow?: boolean;
109
147
  asOf?: string;
110
148
  };
149
+ /**
150
+ * Place GeoJSON ``properties`` from search / nearby / details.
151
+ * ``openNow`` is true/false when hours are evaluable at ``asOf`` (or now);
152
+ * omitted when missing or unparseable.
153
+ */
154
+ export type PlaceProperties = {
155
+ tags?: Record<string, unknown>;
156
+ openNow?: boolean;
157
+ centroid?: unknown;
158
+ };
159
+ export type PlaceFeatureLike = {
160
+ properties?: PlaceProperties | Record<string, unknown>;
161
+ /** ``places_details`` envelope: ``{ status, feature }``. */
162
+ feature?: PlaceFeatureLike;
163
+ };
164
+ /** ``properties.openNow === true`` (known open). Missing / non-boolean is not open. Unwraps ``{ feature }``. */
165
+ export declare function isOpenNow(feature: PlaceFeatureLike | undefined): boolean;
166
+ /** ``true`` / ``false`` when hours are known; ``undefined`` when the field is omitted. Unwraps ``{ feature }``. */
167
+ export declare function readOpenNow(feature: PlaceFeatureLike | undefined): boolean | undefined;
111
168
  export type PlacesDetailsParams = {
112
169
  /** ``node`` / ``way`` / ``relation``, or a full ``node/123`` feature id. */
113
170
  osmType: string;
@@ -138,6 +195,8 @@ export declare class OSMFeatures {
138
195
  });
139
196
  /** Map Express/query params + resolved layer into flat `query` / `query_all` params. */
140
197
  resolveRequest(query: OSMFeaturesQuery, layer: OSMFeaturesLayer): OSMFeaturesParams;
198
+ /** Map Express/query params into `stats` params. `group_by` is required. */
199
+ resolveStatsRequest(query: OSMFeaturesQuery | URLSearchParams): OSMFeaturesStatsParams;
141
200
  private throwUpstreamError;
142
201
  /** GET/POST with 429 retry. Throws on non-OK. */
143
202
  private _fetchOk;
@@ -148,25 +207,27 @@ export declare class OSMFeatures {
148
207
  /** Single HTTP request with retry. Throws on non-OK (same role as Python `_raw_query`). */
149
208
  private _rawQuery;
150
209
  /** Single upstream page. Params map 1:1 to server query string (no tiling). */
151
- query({ bbox, tags, orTags, notTags, type, wayShape, shape, limit, cursor, zoom, location, radius, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, }: OSMFeaturesParams, dependencies?: OSMFeaturesDependencies): Promise<OSMFeaturesResult>;
210
+ query({ bbox, tags, orTags, notTags, type, wayShape, shape, limit, cursor, zoom, location, radius, within, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, }: OSMFeaturesParams, dependencies?: OSMFeaturesDependencies): Promise<OSMFeaturesResult>;
152
211
  /** Auto-paginate (and optionally tile) until complete. Each page uses `_rawQuery`. */
153
- query_all({ bbox, tags, orTags, notTags, type, wayShape, shape, zoom, location, radius, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, limitPerPage, bboxTiles, maxPages, maxFeatures, }: Omit<OSMFeaturesParams, 'limit' | 'cursor'> & {
154
- /** Upstream `limit` per HTTP request (page size). */
212
+ query_all({ bbox, tags, orTags, notTags, type, wayShape, shape, zoom, location, radius, within, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, limitPerPage, bboxTiles, maxPages, maxFeatures, }: Omit<OSMFeaturesParams, 'limit' | 'cursor'> & {
213
+ /** Upstream `limit` per HTTP request (page size). Omit to use the API default (1000). */
155
214
  limitPerPage?: number;
156
215
  bboxTiles?: number;
157
216
  maxPages?: number;
158
217
  /** Cap on merged features. `null` = no cap. */
159
218
  maxFeatures?: number | null;
160
219
  }, dependencies?: OSMFeaturesDependencies): Promise<OSMGeoJSONResult>;
220
+ /** Count features grouped by a tag key (``GET /v2/osm_features/stats``). */
221
+ stats(params: OSMFeaturesStatsParams, dependencies?: OSMFeaturesDependencies): Promise<OSMStatsResponse>;
161
222
  /** Preflight credit cost (``GET /v2/osm_features/cost``). */
162
223
  estimate_cost(params?: OSMFeaturesParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
163
224
  /** This month's unit-budget usage (``GET /v1/usage``). */
164
225
  usage(dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
165
- /** Find places in a bbox or radius (``POST /v1/places/search``). */
226
+ /** Find places in a bbox or radius (``POST /v1/places/search``). Features have ``properties.openNow`` when hours are known. */
166
227
  places_search(params: PlacesSearchParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
167
- /** Nearest places from a point (``POST /v1/places/nearby``). */
228
+ /** Nearest places from a point (``POST /v1/places/nearby``). Features have ``properties.openNow`` when hours are known. */
168
229
  places_nearby(params: PlacesNearbyParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
169
- /** One place by OSM id (``GET /v1/places/{osm_type}/{osm_id}``). */
230
+ /** One place by OSM id (``GET /v1/places/{osm_type}/{osm_id}``). Feature has ``properties.openNow`` when hours are known. */
170
231
  places_details(params: PlacesDetailsParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
171
232
  /** Reach polygon along the walk/bike network (``POST /v1/routes/isochrone``). */
172
233
  routes_isochrone(params: RoutesIsochroneParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
@@ -176,4 +237,6 @@ export declare class OSMFeatures {
176
237
  routes_optimized_path(params: RoutesOptimizedPathParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
177
238
  }
178
239
  export * from './geojson-feature.js';
240
+ export * from './geometry.js';
241
+ export * from './nearest.js';
179
242
  export * from './preset/index.js';
package/dist/index.js CHANGED
@@ -21,9 +21,30 @@ function metaFromHeaders(headers, featureCount) {
21
21
  }
22
22
  return meta;
23
23
  }
24
+ function openNowValue(feature) {
25
+ if (feature == null) {
26
+ return undefined;
27
+ }
28
+ const props = feature.properties;
29
+ if (props && typeof props === 'object' && 'openNow' in props) {
30
+ return props['openNow'];
31
+ }
32
+ if (feature.feature != null && feature.feature !== feature) {
33
+ return openNowValue(feature.feature);
34
+ }
35
+ return undefined;
36
+ }
37
+ /** ``properties.openNow === true`` (known open). Missing / non-boolean is not open. Unwraps ``{ feature }``. */
38
+ export function isOpenNow(feature) {
39
+ return openNowValue(feature) === true;
40
+ }
41
+ /** ``true`` / ``false`` when hours are known; ``undefined`` when the field is omitted. Unwraps ``{ feature }``. */
42
+ export function readOpenNow(feature) {
43
+ const value = openNowValue(feature);
44
+ return typeof value === 'boolean' ? value : undefined;
45
+ }
24
46
  const PLACE_TYPES = new Set(['node', 'way', 'relation']);
25
47
  const DEFAULT_BASE_URL = 'https://api.maplark.com';
26
- const DEFAULT_LIMIT = 1000;
27
48
  const MAX_LIMIT = 6000;
28
49
  const GEOJSON_ACCEPT = 'application/geo+json';
29
50
  function isGeojsonAccept(accept) {
@@ -88,14 +109,43 @@ function optionalBoolean(query, key) {
88
109
  }
89
110
  return undefined;
90
111
  }
91
- function parseLimit(query, fallback = DEFAULT_LIMIT) {
112
+ function stringList(query, key) {
113
+ const raw = query[key];
114
+ if (raw == null || raw === '') {
115
+ return [];
116
+ }
117
+ const values = Array.isArray(raw) ? raw : [raw];
118
+ const out = [];
119
+ for (const value of values) {
120
+ if (value == null || value === '') {
121
+ continue;
122
+ }
123
+ const trimmed = String(value).trim();
124
+ if (trimmed) {
125
+ out.push(trimmed);
126
+ }
127
+ }
128
+ return out;
129
+ }
130
+ function queryFromSearchParams(params) {
131
+ const out = {};
132
+ for (const key of new Set(params.keys())) {
133
+ const all = params.getAll(key);
134
+ out[key] = all.length <= 1 ? (all[0] ?? '') : all;
135
+ }
136
+ return out;
137
+ }
138
+ function asQueryMap(query) {
139
+ return query instanceof URLSearchParams ? queryFromSearchParams(query) : query;
140
+ }
141
+ function parseLimit(query) {
92
142
  const raw = optionalString(query, 'limit');
93
143
  if (raw == null) {
94
- return fallback;
144
+ return undefined;
95
145
  }
96
146
  const parsed = Number.parseInt(raw, 10);
97
147
  if (!Number.isFinite(parsed)) {
98
- return fallback;
148
+ return undefined;
99
149
  }
100
150
  if (parsed > MAX_LIMIT) {
101
151
  throw appError(400, 'invalid_limit', `limit must be <= ${MAX_LIMIT}.`);
@@ -149,7 +199,9 @@ function buildFeaturesQuery(params) {
149
199
  if (params.bbox) {
150
200
  query.set('bbox', params.bbox);
151
201
  }
152
- query.set('limit', String(params.limit));
202
+ if (params.limit != null) {
203
+ query.set('limit', String(params.limit));
204
+ }
153
205
  if (params.cursor) {
154
206
  query.set('cursor', params.cursor);
155
207
  }
@@ -162,6 +214,12 @@ function buildFeaturesQuery(params) {
162
214
  if (params.radius != null) {
163
215
  query.set('radius', String(params.radius));
164
216
  }
217
+ if (params.within) {
218
+ query.set('within', params.within);
219
+ }
220
+ if (params.groupBy) {
221
+ query.set('group_by', params.groupBy);
222
+ }
165
223
  if (params.osmIds) {
166
224
  query.set('osm_ids', params.osmIds);
167
225
  }
@@ -177,10 +235,10 @@ function buildFeaturesQuery(params) {
177
235
  if (params.maxAreaM2 != null) {
178
236
  query.set('max_area_m2', String(params.maxAreaM2));
179
237
  }
180
- if (params.disableBudgetWarning != null) {
238
+ if (params.disableBudgetWarning) {
181
239
  query.set('disable_budget_warning', String(params.disableBudgetWarning));
182
240
  }
183
- if (params.centroid != null) {
241
+ if (params.centroid) {
184
242
  query.set('centroid', String(params.centroid));
185
243
  }
186
244
  if (params.clipGeometry != null) {
@@ -232,7 +290,7 @@ function parsePlaceRef(osmType, osmId) {
232
290
  return { osmType: kind, osmId: n };
233
291
  }
234
292
  function placesSearchBody(params) {
235
- const body = { limit: params.limit ?? 100 };
293
+ const body = {};
236
294
  if (params.bbox != null) {
237
295
  body['bbox'] = params.bbox;
238
296
  }
@@ -251,6 +309,9 @@ function placesSearchBody(params) {
251
309
  if (params.orTags?.length) {
252
310
  body['orTags'] = params.orTags;
253
311
  }
312
+ if (params.limit != null) {
313
+ body['limit'] = params.limit;
314
+ }
254
315
  if (params.openNow) {
255
316
  body['openNow'] = true;
256
317
  }
@@ -262,9 +323,10 @@ function placesSearchBody(params) {
262
323
  function placesNearbyBody(params) {
263
324
  const body = {
264
325
  location: latlng(params.location),
265
- radius: params.radius ?? 1000,
266
- limit: params.limit ?? 10,
267
326
  };
327
+ if (params.radius != null) {
328
+ body['radius'] = params.radius;
329
+ }
268
330
  if (params.type != null) {
269
331
  body['type'] = params.type;
270
332
  }
@@ -274,6 +336,9 @@ function placesNearbyBody(params) {
274
336
  if (params.orTags?.length) {
275
337
  body['orTags'] = params.orTags;
276
338
  }
339
+ if (params.limit != null) {
340
+ body['limit'] = params.limit;
341
+ }
277
342
  if (params.openNow) {
278
343
  body['openNow'] = true;
279
344
  }
@@ -285,7 +350,6 @@ function placesNearbyBody(params) {
285
350
  function routesIsochroneBody(params) {
286
351
  const body = {
287
352
  origin: lonlat(params.origin),
288
- travelMode: params.travelMode ?? 'WALK',
289
353
  };
290
354
  if (params.maxDistanceM != null) {
291
355
  body['max_distance_m'] = params.maxDistanceM;
@@ -296,28 +360,37 @@ function routesIsochroneBody(params) {
296
360
  if (params.searchBufferM != null) {
297
361
  body['search_buffer_m'] = params.searchBufferM;
298
362
  }
363
+ if (params.travelMode != null) {
364
+ body['travelMode'] = params.travelMode;
365
+ }
299
366
  return body;
300
367
  }
301
368
  function routesPathBody(params) {
302
369
  const body = {
303
370
  stops: params.stops.map(lonlat),
304
- travelMode: params.travelMode ?? 'WALK',
305
371
  };
306
372
  if (params.searchBufferM != null) {
307
373
  body['search_buffer_m'] = params.searchBufferM;
308
374
  }
375
+ if (params.travelMode != null) {
376
+ body['travelMode'] = params.travelMode;
377
+ }
309
378
  return body;
310
379
  }
311
380
  function routesOptimizedPathBody(params) {
312
381
  const body = {
313
382
  start: lonlat(params.start),
314
383
  stops: params.stops.map(lonlat),
315
- loop: params.loop ?? true,
316
- travelMode: params.travelMode ?? 'WALK',
317
384
  };
385
+ if (params.loop != null) {
386
+ body['loop'] = params.loop;
387
+ }
318
388
  if (params.searchBufferM != null) {
319
389
  body['search_buffer_m'] = params.searchBufferM;
320
390
  }
391
+ if (params.travelMode != null) {
392
+ body['travelMode'] = params.travelMode;
393
+ }
321
394
  return body;
322
395
  }
323
396
  function sleep(ms) {
@@ -390,6 +463,7 @@ export class OSMFeatures {
390
463
  zoom: optionalNumber(query, 'zoom'),
391
464
  location: optionalString(query, 'location'),
392
465
  radius: optionalNumber(query, 'radius'),
466
+ within: optionalString(query, 'within'),
393
467
  osmIds: optionalString(query, 'osm_ids'),
394
468
  minLengthM: optionalNumber(query, 'min_length_m'),
395
469
  maxLengthM: optionalNumber(query, 'max_length_m'),
@@ -400,6 +474,64 @@ export class OSMFeatures {
400
474
  clipGeometry: optionalBoolean(query, 'clipGeometry'),
401
475
  };
402
476
  }
477
+ /** Map Express/query params into `stats` params. `group_by` is required. */
478
+ resolveStatsRequest(query) {
479
+ const q = asQueryMap(query);
480
+ const groupBy = optionalString(q, 'group_by');
481
+ if (groupBy == null) {
482
+ throw appError(400, 'invalid_group_by', 'group_by is required.');
483
+ }
484
+ const tags = stringList(q, 'tags');
485
+ const orTags = stringList(q, 'or_tags');
486
+ const notTags = stringList(q, 'not_tags');
487
+ const wayShapeRaw = optionalString(q, 'way_shape') ?? optionalString(q, 'shape');
488
+ const wayShape = wayShapeRaw === 'line' || wayShapeRaw === 'polygon' || wayShapeRaw === 'all'
489
+ ? wayShapeRaw
490
+ : undefined;
491
+ const params = { groupBy };
492
+ const bbox = optionalString(q, 'bbox');
493
+ if (bbox)
494
+ params.bbox = bbox;
495
+ const within = optionalString(q, 'within');
496
+ if (within)
497
+ params.within = within;
498
+ const location = optionalString(q, 'location');
499
+ if (location)
500
+ params.location = location;
501
+ const radius = optionalNumber(q, 'radius');
502
+ if (radius != null)
503
+ params.radius = radius;
504
+ const type = optionalString(q, 'type');
505
+ if (type)
506
+ params.type = type;
507
+ if (wayShape)
508
+ params.wayShape = wayShape;
509
+ if (tags.length > 0)
510
+ params.tags = tags;
511
+ if (orTags.length > 0)
512
+ params.orTags = orTags;
513
+ if (notTags.length > 0)
514
+ params.notTags = notTags;
515
+ const limit = optionalNumber(q, 'limit');
516
+ if (limit != null && Number.isFinite(limit))
517
+ params.limit = Math.trunc(limit);
518
+ const minLengthM = optionalNumber(q, 'min_length_m');
519
+ if (minLengthM != null)
520
+ params.minLengthM = minLengthM;
521
+ const maxLengthM = optionalNumber(q, 'max_length_m');
522
+ if (maxLengthM != null)
523
+ params.maxLengthM = maxLengthM;
524
+ const minAreaM2 = optionalNumber(q, 'min_area_m2');
525
+ if (minAreaM2 != null)
526
+ params.minAreaM2 = minAreaM2;
527
+ const maxAreaM2 = optionalNumber(q, 'max_area_m2');
528
+ if (maxAreaM2 != null)
529
+ params.maxAreaM2 = maxAreaM2;
530
+ const disableBudgetWarning = optionalBoolean(q, 'disable_budget_warning');
531
+ if (disableBudgetWarning)
532
+ params.disableBudgetWarning = true;
533
+ return params;
534
+ }
403
535
  async throwUpstreamError(upstream) {
404
536
  const subtype = upstream.status === 429 ? 'upstream_rate_limit' : undefined;
405
537
  const upstreamDetail = await readUpstreamErrorDetail(upstream);
@@ -519,7 +651,7 @@ export class OSMFeatures {
519
651
  };
520
652
  }
521
653
  /** Single upstream page. Params map 1:1 to server query string (no tiling). */
522
- async query({ bbox, tags, orTags, notTags, type, wayShape, shape, limit = DEFAULT_LIMIT, cursor, zoom, location, radius, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, }, dependencies = {}) {
654
+ async query({ bbox, tags, orTags, notTags, type, wayShape, shape, limit, cursor, zoom, location, radius, within, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, }, dependencies = {}) {
523
655
  const payload = await this._rawQuery({
524
656
  bbox,
525
657
  tags,
@@ -533,6 +665,7 @@ export class OSMFeatures {
533
665
  zoom,
534
666
  location,
535
667
  radius,
668
+ within,
536
669
  osmIds,
537
670
  minLengthM,
538
671
  maxLengthM,
@@ -546,7 +679,7 @@ export class OSMFeatures {
546
679
  return payload;
547
680
  }
548
681
  /** Auto-paginate (and optionally tile) until complete. Each page uses `_rawQuery`. */
549
- async query_all({ bbox, tags, orTags, notTags, type, wayShape, shape, zoom, location, radius, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, limitPerPage = DEFAULT_LIMIT, bboxTiles = 2, maxPages = 15, maxFeatures = 55_000, }, dependencies = {}) {
682
+ async query_all({ bbox, tags, orTags, notTags, type, wayShape, shape, zoom, location, radius, within, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, limitPerPage, bboxTiles = 2, maxPages = 15, maxFeatures = 55_000, }, dependencies = {}) {
550
683
  if (!isGeojsonAccept(accept)) {
551
684
  throw appError(400, 'invalid_accept', 'query_all only supports GeoJSON; use query({ accept }) for binary encodings.');
552
685
  }
@@ -557,7 +690,7 @@ export class OSMFeatures {
557
690
  const sleepFn = dependencies.sleepFn ?? sleep;
558
691
  const nowFn = dependencies.nowFn ?? Date.now;
559
692
  const featureCap = maxFeatures == null ? Number.POSITIVE_INFINITY : maxFeatures;
560
- const tileBboxes = bbox ? splitBbox(bbox, bboxTiles) : [undefined];
693
+ const tileBboxes = within || !bbox ? [undefined] : splitBbox(bbox, bboxTiles);
561
694
  const allFeatures = [];
562
695
  let pageCount = 0;
563
696
  let lastPage = null;
@@ -578,6 +711,7 @@ export class OSMFeatures {
578
711
  zoom,
579
712
  location,
580
713
  radius,
714
+ within,
581
715
  osmIds,
582
716
  minLengthM,
583
717
  maxLengthM,
@@ -601,7 +735,7 @@ export class OSMFeatures {
601
735
  while (tilePages < maxPages && allFeatures.length < featureCap) {
602
736
  let page;
603
737
  try {
604
- const raw = await this._rawQuery({ ...baseParams, bbox: tileBbox, cursor }, fetchFn, sleepFn, nowFn);
738
+ const raw = await this._rawQuery({ ...baseParams, bbox: within ? undefined : tileBbox, cursor }, fetchFn, sleepFn, nowFn);
605
739
  if (raw.data instanceof ArrayBuffer) {
606
740
  throw appError(400, 'invalid_accept', 'query_all only supports GeoJSON; use query({ accept }) for binary encodings.');
607
741
  }
@@ -665,6 +799,31 @@ export class OSMFeatures {
665
799
  }
666
800
  return resultFromFeatures(features, meta);
667
801
  }
802
+ /** Count features grouped by a tag key (``GET /v2/osm_features/stats``). */
803
+ async stats(params, dependencies = {}) {
804
+ if (!params.groupBy) {
805
+ throw appError(400, 'invalid_group_by', 'group_by is required.');
806
+ }
807
+ const body = await this._getJson('/v2/osm_features/stats', buildFeaturesQuery({
808
+ bbox: params.bbox,
809
+ tags: params.tags,
810
+ orTags: params.orTags,
811
+ notTags: params.notTags,
812
+ type: params.type,
813
+ wayShape: params.wayShape ?? params.shape,
814
+ limit: params.limit,
815
+ location: params.location,
816
+ radius: params.radius,
817
+ within: params.within,
818
+ groupBy: params.groupBy,
819
+ minLengthM: params.minLengthM,
820
+ maxLengthM: params.maxLengthM,
821
+ minAreaM2: params.minAreaM2,
822
+ maxAreaM2: params.maxAreaM2,
823
+ disableBudgetWarning: params.disableBudgetWarning,
824
+ }), dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
825
+ return body;
826
+ }
668
827
  /** Preflight credit cost (``GET /v2/osm_features/cost``). */
669
828
  async estimate_cost(params = {}, dependencies = {}) {
670
829
  return this._getJson('/v2/osm_features/cost', buildFeaturesQuery({
@@ -674,10 +833,11 @@ export class OSMFeatures {
674
833
  notTags: params.notTags,
675
834
  type: params.type,
676
835
  wayShape: params.wayShape ?? params.shape,
677
- limit: params.limit ?? DEFAULT_LIMIT,
836
+ limit: params.limit,
678
837
  zoom: params.zoom,
679
838
  location: params.location,
680
839
  radius: params.radius,
840
+ within: params.within,
681
841
  osmIds: params.osmIds,
682
842
  minLengthM: params.minLengthM,
683
843
  maxLengthM: params.maxLengthM,
@@ -692,15 +852,15 @@ export class OSMFeatures {
692
852
  async usage(dependencies = {}) {
693
853
  return this._getJson('/v1/usage', {}, dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
694
854
  }
695
- /** Find places in a bbox or radius (``POST /v1/places/search``). */
855
+ /** Find places in a bbox or radius (``POST /v1/places/search``). Features have ``properties.openNow`` when hours are known. */
696
856
  async places_search(params, dependencies = {}) {
697
857
  return this._postJson('/v1/places/search', placesSearchBody(params), dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
698
858
  }
699
- /** Nearest places from a point (``POST /v1/places/nearby``). */
859
+ /** Nearest places from a point (``POST /v1/places/nearby``). Features have ``properties.openNow`` when hours are known. */
700
860
  async places_nearby(params, dependencies = {}) {
701
861
  return this._postJson('/v1/places/nearby', placesNearbyBody(params), dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
702
862
  }
703
- /** One place by OSM id (``GET /v1/places/{osm_type}/{osm_id}``). */
863
+ /** One place by OSM id (``GET /v1/places/{osm_type}/{osm_id}``). Feature has ``properties.openNow`` when hours are known. */
704
864
  async places_details(params, dependencies = {}) {
705
865
  const { osmType, osmId } = parsePlaceRef(params.osmType, params.osmId);
706
866
  return this._getJson(`/v1/places/${osmType}/${osmId}`, {}, dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
@@ -719,4 +879,6 @@ export class OSMFeatures {
719
879
  }
720
880
  }
721
881
  export * from './geojson-feature.js';
882
+ export * from './geometry.js';
883
+ export * from './nearest.js';
722
884
  export * from './preset/index.js';
@@ -0,0 +1,28 @@
1
+ /** Local nearest-neighbor join over two GeoJSON feature sets. No HTTP. */
2
+ export declare const MAX_COMPARISONS = 500000;
3
+ export type NearestWithinPair = {
4
+ feature: unknown;
5
+ distance_m: number;
6
+ nearest: unknown;
7
+ };
8
+ /**
9
+ * Nearest secondary for each primary, within `maxDistanceM`.
10
+ *
11
+ * Accepts a FeatureCollection, a `{ data, meta }` `query` / `query_all` result,
12
+ * or a list of Features. Point comes from `geometry` when it is a Point, else
13
+ * `properties.centroid` (same rules as `featureCentroid`). A feature with
14
+ * neither throws.
15
+ *
16
+ * Returns pairs sorted by `distance_m`:
17
+ * `{ feature, distance_m, nearest }`
18
+ *
19
+ * `limit` keeps the closest pairs (SDK default 20). Pass `null` for every
20
+ * primary that has a match. O(n×m) haversine. Fine at search `limit`
21
+ * (default 100). Joins whose `len(primary) * len(secondary)` exceeds
22
+ * `maxComparisons` (default 500_000) throw. Pass `maxComparisons: null`
23
+ * for no cap.
24
+ */
25
+ export declare function nearest_within(primary: unknown, secondary: unknown, maxDistanceM: number, { limit, maxComparisons, }?: {
26
+ limit?: number | null;
27
+ maxComparisons?: number | null;
28
+ }): NearestWithinPair[];
@@ -0,0 +1,98 @@
1
+ /** Local nearest-neighbor join over two GeoJSON feature sets. No HTTP. */
2
+ import { featureCentroid } from './geojson-feature.js';
3
+ // Mean Earth radius. ponytail: sphere, not WGS84 ellipsoid; swap if you need centimetre-grade distances.
4
+ const EARTH_RADIUS_M = 6_371_000;
5
+ // O(n×m) haversine. 500k is ~1000×500 or 10_000×50; search-sized joins fit.
6
+ export const MAX_COMPARISONS = 500_000;
7
+ function haversineM(lon1, lat1, lon2, lat2) {
8
+ const p1 = (lat1 * Math.PI) / 180;
9
+ const p2 = (lat2 * Math.PI) / 180;
10
+ const dlat = ((lat2 - lat1) * Math.PI) / 180;
11
+ const dlon = ((lon2 - lon1) * Math.PI) / 180;
12
+ const a = Math.sin(dlat / 2) ** 2 + Math.cos(p1) * Math.cos(p2) * Math.sin(dlon / 2) ** 2;
13
+ return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(Math.min(a, 1)));
14
+ }
15
+ function features(src) {
16
+ if (Array.isArray(src)) {
17
+ return src;
18
+ }
19
+ if (src && typeof src === 'object') {
20
+ const record = src;
21
+ if (Array.isArray(record['features'])) {
22
+ return record['features'];
23
+ }
24
+ const data = record['data'];
25
+ if (data && typeof data === 'object' && Array.isArray(data.features)) {
26
+ return data.features;
27
+ }
28
+ }
29
+ throw new TypeError('expected a FeatureCollection or list of Features');
30
+ }
31
+ function lonLat(feat) {
32
+ if (!feat || typeof feat !== 'object') {
33
+ throw new Error('feature must be a GeoJSON Feature dict');
34
+ }
35
+ const record = feat;
36
+ const point = featureCentroid({
37
+ geometry: (record['geometry'] ?? null),
38
+ properties: record['properties'],
39
+ });
40
+ if (point == null || !Number.isFinite(point[0]) || !Number.isFinite(point[1])) {
41
+ throw new Error('feature has no Point geometry or properties.centroid');
42
+ }
43
+ return point;
44
+ }
45
+ /**
46
+ * Nearest secondary for each primary, within `maxDistanceM`.
47
+ *
48
+ * Accepts a FeatureCollection, a `{ data, meta }` `query` / `query_all` result,
49
+ * or a list of Features. Point comes from `geometry` when it is a Point, else
50
+ * `properties.centroid` (same rules as `featureCentroid`). A feature with
51
+ * neither throws.
52
+ *
53
+ * Returns pairs sorted by `distance_m`:
54
+ * `{ feature, distance_m, nearest }`
55
+ *
56
+ * `limit` keeps the closest pairs (SDK default 20). Pass `null` for every
57
+ * primary that has a match. O(n×m) haversine. Fine at search `limit`
58
+ * (default 100). Joins whose `len(primary) * len(secondary)` exceeds
59
+ * `maxComparisons` (default 500_000) throw. Pass `maxComparisons: null`
60
+ * for no cap.
61
+ */
62
+ export function nearest_within(primary, secondary, maxDistanceM, { limit = 20, maxComparisons = MAX_COMPARISONS, } = {}) {
63
+ if (limit != null && limit < 1) {
64
+ throw new Error('limit must be a positive int');
65
+ }
66
+ if (maxComparisons != null && maxComparisons < 1) {
67
+ throw new Error('max_comparisons must be a positive int');
68
+ }
69
+ const primaries = features(primary);
70
+ const secondaries = features(secondary);
71
+ if (primaries.length === 0 || secondaries.length === 0) {
72
+ return [];
73
+ }
74
+ const n = primaries.length;
75
+ const m = secondaries.length;
76
+ if (maxComparisons != null && n * m > maxComparisons) {
77
+ throw new Error(`nearest_within join is ${n}×${m} comparisons `
78
+ + `(cap ${maxComparisons}). Shrink the collections `
79
+ + '(places_search/nearby limit, not query_all).');
80
+ }
81
+ const secPts = secondaries.map((s) => [s, lonLat(s)]);
82
+ const pairs = [];
83
+ for (const p of primaries) {
84
+ const [plon, plat] = lonLat(p);
85
+ let best = null;
86
+ for (const [s, [slon, slat]] of secPts) {
87
+ const d = haversineM(plon, plat, slon, slat);
88
+ if (best === null || d < best[0]) {
89
+ best = [d, s];
90
+ }
91
+ }
92
+ if (best !== null && best[0] <= maxDistanceM) {
93
+ pairs.push({ feature: p, distance_m: best[0], nearest: best[1] });
94
+ }
95
+ }
96
+ pairs.sort((a, b) => a.distance_m - b.distance_m);
97
+ return limit == null ? pairs : pairs.slice(0, limit);
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osmfeatures",
3
- "version": "0.2.4",
3
+ "version": "0.5.0",
4
4
  "description": "Get OpenStreetMap features such as buildings, roads, or points of interest from dedicated servers with a single API call in GeoJSON, FlatGeobuf, Geoparquet, and CSV formats.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "scripts": {
20
20
  "build": "tsc -p tsconfig.json",
21
- "test": "tsx src/index.test.ts && tsx src/geojson-feature.test.ts && tsx src/preset/preset.test.ts && tsx src/tutorial.test.ts",
21
+ "test": "tsx src/index.test.ts && tsx src/geojson-feature.test.ts && tsx src/geometry.test.ts && tsx src/nearest.test.ts && tsx src/preset/preset.test.ts && tsx src/tutorial.test.ts",
22
22
  "prepare": "npm run build",
23
23
  "prepublishOnly": "npm run build"
24
24
  },