osmfeatures 0.2.3 → 0.3.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,41 @@
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
+ - [estimate_cost](#estimate_cost)
14
+ - [usage](#usage)
15
+ - [Places and routes](#places-and-routes)
16
+ - [Places search](#places-search)
17
+ - [Nearby](#nearby-ranked-from-a-point)
18
+ - [Place details](#place-details)
19
+ - [Opening hours](#opening-hours)
20
+ - [X near Y](#x-near-y-local-join)
21
+ - [Walk and bike routes](#walk-and-bike-routes)
22
+ - [MCP server](#mcp-server)
12
23
 
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.
24
+ OSM types map to GeoJSON the way GIS tools expect:
14
25
 
15
- To narrow down between "open ways" and "closed ways", use the `shape` parameter:
26
+ - `node` → Point
27
+ - `way` → LineString or Polygon
28
+ - `relation` → MultiPolygon or a bundle of geometries
16
29
 
17
- - `shape=line` - open ways (roads, paths, rivers) or line-shaped relations (routes, boundaries)
18
- - `shape=polygon` - closed ways (buildings, parks) or multipolygon relations.
19
- - `shape=all` - both shapes (default when shape is omitted).
30
+ 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
31
 
21
- For example, to get all buildings in an area:
32
+ Use `way_shape` when you need lines vs areas:
22
33
 
23
- `type=way & tags=building`
34
+ - `way_shape=line` unclosed ways (streets, footpaths, rivers) and line-like relations (routes, some boundaries)
35
+ - `way_shape=polygon` — closed ways (building footprints, parks) and multipolygon relations
36
+ - `way_shape=all` — both (the default if you leave it off)
24
37
 
25
- This is the equivalent of the Overpass query `way[building]`.
38
+ Buildings in a box: `type=way & tags=building` — the same idea as Overpass `way[building]`.
26
39
 
27
40
  # Quick start
28
41
 
@@ -102,7 +115,7 @@ Geometric filters such specific OSM element type, min length, or including centr
102
115
  | Param | Type | Default | Description |
103
116
  | ------------ | ---------------------- | ------- | --------------------------------------------------------------------------------------- |
104
117
  | `type` | `string` | all | OSM element types, e.g. `node`, `way`, `relation`, or comma-separated (`way,relation`). |
105
- | `shape` | `line | polygon | all` | `all` | Geometry shape filter for ways and relations. |
118
+ | `wayShape` | `line | polygon | all` | `all` | Geometry class for ways and relations. `shape` is a deprecated alias. |
106
119
  | `centroid` | `boolean` | `false` | When `true`, include a centroid on non-point features. |
107
120
  | `clipGeometry` | `boolean` | `true` | When `true`, clip returned geometry to the requested `bbox`. Set `false` for full geometry. |
108
121
  | `minLengthM` | `number` | | Minimum length in metres (lines). |
@@ -121,7 +134,7 @@ Extra filters to for pagination, output format (accept),
121
134
  | Param | Type | Default | Description |
122
135
  | ---------------------- | --------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
136
  | `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`. |
137
+ | `limit` | `number` | API `1000` | Page size. Omit to use the API default. Max `6000`. |
125
138
  | `cursor` | `string` | | Pagination cursor from a previous `meta.next_cursor`. |
126
139
  | `disableBudgetWarning` | `boolean` | `false` | Ignore warnings for large queries that consume budget quotas. |
127
140
  | `zoom` | `number` | | Map zoom hint (used by presets / server-side simplification policies). |
@@ -168,12 +181,12 @@ console.log(all.meta.page_count, all.meta.has_more, all.meta.units_charged);
168
181
 
169
182
  ### Params
170
183
 
171
- Same filter params as `query` (`bbox`, `tags`, `orTags`, `notTags`, `type`, `shape`, `zoom`, `location`, `radius`, `osmIds`, `minLengthM`, `maxLengthM`, `minAreaM2`, `maxAreaM2`, `centroid`, `clipGeometry`, `disableBudgetWarning`), plus:
184
+ Same filter params as `query` (`bbox`, `tags`, `orTags`, `notTags`, `type`, `wayShape`, `zoom`, `location`, `radius`, `osmIds`, `minLengthM`, `maxLengthM`, `minAreaM2`, `maxAreaM2`, `centroid`, `clipGeometry`, `disableBudgetWarning`), plus:
172
185
 
173
186
 
174
187
  | Param | Type | Default | Description |
175
188
  | -------------- | --------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
176
- | `limitPerPage` | `number` | `1000` | Upstream `limit` per HTTP request (page size). |
189
+ | `limitPerPage` | `number` | API `1000` | Upstream `limit` per HTTP request (page size). Omit to use the API default. |
177
190
  | `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
191
  | `maxPages` | `number` | `15` | Max pages fetched **per tile**. |
179
192
  | `maxFeatures` | `number | null` | `55000` | Cap on merged features after dedupe. Pass `null` for no cap. |
@@ -197,8 +210,9 @@ Same fields as `query`, plus:
197
210
  | `relay_partial_reason` | e.g. `upstream_rejected_cursor` or `upstream_rate_limited_after_retries`. |
198
211
 
199
212
 
200
- Also exports layer presets (`resolveLayerFromQuery`, `OSM_FEATURES_LAYER_PRESETS`, ...)
201
- and GeoJSON payload helpers (`featureCentroid`, `geometryBounds`, `parseFeatureId`, ...).
213
+ Also exports layer presets (`resolveLayerFromQuery`, `OSM_FEATURES_LAYER_PRESETS`, ...),
214
+ GeoJSON payload helpers (`featureCentroid`, `geometryBounds`, `parseFeatureId`, ...),
215
+ and local helpers (`nearest_within`, `point_in_geometry`, `isOpenNow`).
202
216
 
203
217
  ## `estimate_cost`
204
218
 
@@ -221,37 +235,15 @@ const usage = await client.usage();
221
235
  console.log(usage.tier, usage.usage_this_month, usage.remaining_this_month);
222
236
  ```
223
237
 
224
- ## Geo Agent (places and routes)
238
+ ## Places and routes
225
239
 
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.
240
+ `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.
227
241
 
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.
242
+ 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).
229
243
 
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. |
244
+ ### Places search
238
245
 
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.
240
-
241
- #### Typical questions
242
-
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`
246
+ `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
247
 
256
248
  ```ts
257
249
  const origin = { lat: 59.316, lon: 18.075 };
@@ -263,7 +255,13 @@ const cafes = await client.places_search({
263
255
  openNow: true,
264
256
  asOf: '2026-08-10T18:00:00+02:00',
265
257
  });
258
+ ```
266
259
 
260
+ ### Nearby (ranked from a point)
261
+
262
+ `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.
263
+
264
+ ```ts
267
265
  const nearby = await client.places_nearby({
268
266
  location: origin,
269
267
  orTags: ['amenity=cafe'],
@@ -271,16 +269,55 @@ const nearby = await client.places_nearby({
271
269
  openNow: true,
272
270
  asOf: '2026-08-10T18:00:00+02:00',
273
271
  });
272
+ ```
274
273
 
274
+ ### Place details
275
+
276
+ `places_details()` reloads a single OSM place by the id search or nearby gave you (`node/123`), or as `{ osmType, osmId }`.
277
+
278
+ ```ts
275
279
  const first = (cafes.features as { id: string }[])[0];
276
280
  const details = await client.places_details({ osmType: first.id });
281
+ // same as: client.places_details({ osmType: 'node', osmId: 123 })
277
282
  ```
278
283
 
279
- `places_details` also accepts `{ osmType: 'node', osmId: 123 }`. Hours are annotated at request time in the place's local timezone.
284
+ Hours are evaluated at request time in that places timezone.
285
+
286
+ ### Opening hours
287
+
288
+ 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.
289
+
290
+ Timezone is inferred from coordinates (IANA). There is no `timezone` request field.
291
+
292
+ - `openNow: true` drops closed and unknown-hours POIs (Google Places–style `openNow`).
293
+ - `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.
294
+ - `asOf` or `openNow` also require an `opening_hours` tag, so untagged amenities do not pad the page.
295
+ - Places that are closed but tagged still appear unless `openNow` is set.
280
296
 
281
- ### Examples for `routes_isochrone` / `routes_path` / `routes_optimized_path`
297
+ ### "X near Y" (local join)
282
298
 
283
- Points accept `lon` or `lng`. `routes_isochrone` takes exactly one of `maxDistanceM` or `durationS`. Optional `searchBufferM` widens the highway fetch corridor.
299
+ `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.
300
+
301
+ ```ts
302
+ import { nearest_within } from 'osmfeatures';
303
+
304
+ const bbox = '18.05,59.33,18.10,59.36';
305
+ const restaurants = await client.places_search({ bbox, orTags: ['amenity=restaurant'] });
306
+ const stations = await client.places_search({ bbox, orTags: ['railway=station'] });
307
+ const pairs = nearest_within(restaurants, stations, 150, { limit: 20 });
308
+
309
+ for (const pair of pairs) {
310
+ console.log(pair.distance_m, pair.feature, 'near', pair.nearest);
311
+ }
312
+ ```
313
+
314
+ 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.
315
+
316
+ ### Walk and bike routes
317
+
318
+ Paths follow OSM walk and bicycle ways. Default `travelMode` is `WALK`; pass `'BICYCLE'` for bikes. Driving is not offered yet.
319
+
320
+ 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
321
 
285
322
  ```ts
286
323
  const origin = { lon: 18.075, lat: 59.316 };
@@ -299,6 +336,15 @@ const tour = await client.routes_optimized_path({
299
336
  start: origin,
300
337
  stops: [cafe],
301
338
  });
339
+
340
+ const office = { lon: 18.08, lat: 59.318 };
341
+ point_in_geometry(office.lon, office.lat, iso);
302
342
  ```
303
343
 
304
- Read the full API reference here [https://maplark.com/developer](https://maplark.com/developer) such as the OpenAPI 2.0 HTTP docs.
344
+ 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.
345
+
346
+
347
+ ## MCP server
348
+
349
+ 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.
350
+
@@ -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
@@ -32,10 +32,13 @@ export type OSMFeaturesLayer = {
32
32
  orTags?: string[];
33
33
  notTags?: string[];
34
34
  type?: string;
35
+ wayShape?: 'line' | 'polygon' | 'all';
36
+ /** @deprecated Use `wayShape`. */
35
37
  shape?: 'line' | 'polygon' | 'all';
36
38
  };
37
39
  /** Flat query params (same idea as Python `query(**params)`). */
38
40
  export type OSMFeaturesParams = OSMFeaturesLayer & {
41
+ /** Page size. Omit to use the API default (1000). Max `6000`. */
39
42
  limit?: number;
40
43
  cursor?: string;
41
44
  zoom?: number;
@@ -49,8 +52,9 @@ export type OSMFeaturesParams = OSMFeaturesLayer & {
49
52
  minAreaM2?: number;
50
53
  maxAreaM2?: number;
51
54
  disableBudgetWarning?: boolean;
55
+ /** When true, include `properties.centroid` on non-point features. Omit for the API default (false). */
52
56
  centroid?: boolean;
53
- /** When true (default), clip returned geometry to the requested bbox. */
57
+ /** When true, clip returned geometry to the requested bbox. Omit for the API default (false). */
54
58
  clipGeometry?: boolean;
55
59
  /** Accept media type. Default application/geo+json; other types put bytes in ``data``. */
56
60
  accept?: string;
@@ -93,6 +97,7 @@ export type PlacesSearchParams = {
93
97
  tags?: string[];
94
98
  orTags?: string[];
95
99
  limit?: number;
100
+ /** Keep only places known open at ``asOf`` (or now). Untagged hours are dropped. */
96
101
  openNow?: boolean;
97
102
  asOf?: string;
98
103
  };
@@ -103,9 +108,29 @@ export type PlacesNearbyParams = {
103
108
  tags?: string[];
104
109
  orTags?: string[];
105
110
  limit?: number;
111
+ /** Keep only places known open at ``asOf`` (or now). Untagged hours are dropped. */
106
112
  openNow?: boolean;
107
113
  asOf?: string;
108
114
  };
115
+ /**
116
+ * Place GeoJSON ``properties`` from search / nearby / details.
117
+ * ``openNow`` is true/false when hours are evaluable at ``asOf`` (or now);
118
+ * omitted when missing or unparseable.
119
+ */
120
+ export type PlaceProperties = {
121
+ tags?: Record<string, unknown>;
122
+ openNow?: boolean;
123
+ centroid?: unknown;
124
+ };
125
+ export type PlaceFeatureLike = {
126
+ properties?: PlaceProperties | Record<string, unknown>;
127
+ /** ``places_details`` envelope: ``{ status, feature }``. */
128
+ feature?: PlaceFeatureLike;
129
+ };
130
+ /** ``properties.openNow === true`` (known open). Missing / non-boolean is not open. Unwraps ``{ feature }``. */
131
+ export declare function isOpenNow(feature: PlaceFeatureLike | undefined): boolean;
132
+ /** ``true`` / ``false`` when hours are known; ``undefined`` when the field is omitted. Unwraps ``{ feature }``. */
133
+ export declare function readOpenNow(feature: PlaceFeatureLike | undefined): boolean | undefined;
109
134
  export type PlacesDetailsParams = {
110
135
  /** ``node`` / ``way`` / ``relation``, or a full ``node/123`` feature id. */
111
136
  osmType: string;
@@ -146,10 +171,10 @@ export declare class OSMFeatures {
146
171
  /** Single HTTP request with retry. Throws on non-OK (same role as Python `_raw_query`). */
147
172
  private _rawQuery;
148
173
  /** Single upstream page. Params map 1:1 to server query string (no tiling). */
149
- query({ bbox, tags, orTags, notTags, type, shape, limit, cursor, zoom, location, radius, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, }: OSMFeaturesParams, dependencies?: OSMFeaturesDependencies): Promise<OSMFeaturesResult>;
174
+ 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>;
150
175
  /** Auto-paginate (and optionally tile) until complete. Each page uses `_rawQuery`. */
151
- query_all({ bbox, tags, orTags, notTags, type, shape, zoom, location, radius, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, limitPerPage, bboxTiles, maxPages, maxFeatures, }: Omit<OSMFeaturesParams, 'limit' | 'cursor'> & {
152
- /** Upstream `limit` per HTTP request (page size). */
176
+ 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'> & {
177
+ /** Upstream `limit` per HTTP request (page size). Omit to use the API default (1000). */
153
178
  limitPerPage?: number;
154
179
  bboxTiles?: number;
155
180
  maxPages?: number;
@@ -160,11 +185,11 @@ export declare class OSMFeatures {
160
185
  estimate_cost(params?: OSMFeaturesParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
161
186
  /** This month's unit-budget usage (``GET /v1/usage``). */
162
187
  usage(dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
163
- /** Find places in a bbox or radius (``POST /v1/places/search``). */
188
+ /** Find places in a bbox or radius (``POST /v1/places/search``). Features have ``properties.openNow`` when hours are known. */
164
189
  places_search(params: PlacesSearchParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
165
- /** Nearest places from a point (``POST /v1/places/nearby``). */
190
+ /** Nearest places from a point (``POST /v1/places/nearby``). Features have ``properties.openNow`` when hours are known. */
166
191
  places_nearby(params: PlacesNearbyParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
167
- /** One place by OSM id (``GET /v1/places/{osm_type}/{osm_id}``). */
192
+ /** One place by OSM id (``GET /v1/places/{osm_type}/{osm_id}``). Feature has ``properties.openNow`` when hours are known. */
168
193
  places_details(params: PlacesDetailsParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
169
194
  /** Reach polygon along the walk/bike network (``POST /v1/routes/isochrone``). */
170
195
  routes_isochrone(params: RoutesIsochroneParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
@@ -174,4 +199,6 @@ export declare class OSMFeatures {
174
199
  routes_optimized_path(params: RoutesOptimizedPathParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
175
200
  }
176
201
  export * from './geojson-feature.js';
202
+ export * from './geometry.js';
203
+ export * from './nearest.js';
177
204
  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,14 @@ function optionalBoolean(query, key) {
88
109
  }
89
110
  return undefined;
90
111
  }
91
- function parseLimit(query, fallback = DEFAULT_LIMIT) {
112
+ function parseLimit(query) {
92
113
  const raw = optionalString(query, 'limit');
93
114
  if (raw == null) {
94
- return fallback;
115
+ return undefined;
95
116
  }
96
117
  const parsed = Number.parseInt(raw, 10);
97
118
  if (!Number.isFinite(parsed)) {
98
- return fallback;
119
+ return undefined;
99
120
  }
100
121
  if (parsed > MAX_LIMIT) {
101
122
  throw appError(400, 'invalid_limit', `limit must be <= ${MAX_LIMIT}.`);
@@ -149,7 +170,9 @@ function buildFeaturesQuery(params) {
149
170
  if (params.bbox) {
150
171
  query.set('bbox', params.bbox);
151
172
  }
152
- query.set('limit', String(params.limit));
173
+ if (params.limit != null) {
174
+ query.set('limit', String(params.limit));
175
+ }
153
176
  if (params.cursor) {
154
177
  query.set('cursor', params.cursor);
155
178
  }
@@ -177,10 +200,10 @@ function buildFeaturesQuery(params) {
177
200
  if (params.maxAreaM2 != null) {
178
201
  query.set('max_area_m2', String(params.maxAreaM2));
179
202
  }
180
- if (params.disableBudgetWarning != null) {
203
+ if (params.disableBudgetWarning) {
181
204
  query.set('disable_budget_warning', String(params.disableBudgetWarning));
182
205
  }
183
- if (params.centroid != null) {
206
+ if (params.centroid) {
184
207
  query.set('centroid', String(params.centroid));
185
208
  }
186
209
  if (params.clipGeometry != null) {
@@ -189,8 +212,9 @@ function buildFeaturesQuery(params) {
189
212
  if (params.type) {
190
213
  query.set('type', params.type);
191
214
  }
192
- if (params.shape) {
193
- query.set('shape', params.shape);
215
+ const wayShape = params.wayShape ?? params.shape;
216
+ if (wayShape) {
217
+ query.set('way_shape', wayShape);
194
218
  }
195
219
  for (const tag of params.tags ?? []) {
196
220
  query.append('tags', tag);
@@ -231,7 +255,7 @@ function parsePlaceRef(osmType, osmId) {
231
255
  return { osmType: kind, osmId: n };
232
256
  }
233
257
  function placesSearchBody(params) {
234
- const body = { limit: params.limit ?? 100 };
258
+ const body = {};
235
259
  if (params.bbox != null) {
236
260
  body['bbox'] = params.bbox;
237
261
  }
@@ -250,6 +274,9 @@ function placesSearchBody(params) {
250
274
  if (params.orTags?.length) {
251
275
  body['orTags'] = params.orTags;
252
276
  }
277
+ if (params.limit != null) {
278
+ body['limit'] = params.limit;
279
+ }
253
280
  if (params.openNow) {
254
281
  body['openNow'] = true;
255
282
  }
@@ -261,9 +288,10 @@ function placesSearchBody(params) {
261
288
  function placesNearbyBody(params) {
262
289
  const body = {
263
290
  location: latlng(params.location),
264
- radius: params.radius ?? 1000,
265
- limit: params.limit ?? 10,
266
291
  };
292
+ if (params.radius != null) {
293
+ body['radius'] = params.radius;
294
+ }
267
295
  if (params.type != null) {
268
296
  body['type'] = params.type;
269
297
  }
@@ -273,6 +301,9 @@ function placesNearbyBody(params) {
273
301
  if (params.orTags?.length) {
274
302
  body['orTags'] = params.orTags;
275
303
  }
304
+ if (params.limit != null) {
305
+ body['limit'] = params.limit;
306
+ }
276
307
  if (params.openNow) {
277
308
  body['openNow'] = true;
278
309
  }
@@ -284,7 +315,6 @@ function placesNearbyBody(params) {
284
315
  function routesIsochroneBody(params) {
285
316
  const body = {
286
317
  origin: lonlat(params.origin),
287
- travelMode: params.travelMode ?? 'WALK',
288
318
  };
289
319
  if (params.maxDistanceM != null) {
290
320
  body['max_distance_m'] = params.maxDistanceM;
@@ -295,28 +325,37 @@ function routesIsochroneBody(params) {
295
325
  if (params.searchBufferM != null) {
296
326
  body['search_buffer_m'] = params.searchBufferM;
297
327
  }
328
+ if (params.travelMode != null) {
329
+ body['travelMode'] = params.travelMode;
330
+ }
298
331
  return body;
299
332
  }
300
333
  function routesPathBody(params) {
301
334
  const body = {
302
335
  stops: params.stops.map(lonlat),
303
- travelMode: params.travelMode ?? 'WALK',
304
336
  };
305
337
  if (params.searchBufferM != null) {
306
338
  body['search_buffer_m'] = params.searchBufferM;
307
339
  }
340
+ if (params.travelMode != null) {
341
+ body['travelMode'] = params.travelMode;
342
+ }
308
343
  return body;
309
344
  }
310
345
  function routesOptimizedPathBody(params) {
311
346
  const body = {
312
347
  start: lonlat(params.start),
313
348
  stops: params.stops.map(lonlat),
314
- loop: params.loop ?? true,
315
- travelMode: params.travelMode ?? 'WALK',
316
349
  };
350
+ if (params.loop != null) {
351
+ body['loop'] = params.loop;
352
+ }
317
353
  if (params.searchBufferM != null) {
318
354
  body['search_buffer_m'] = params.searchBufferM;
319
355
  }
356
+ if (params.travelMode != null) {
357
+ body['travelMode'] = params.travelMode;
358
+ }
320
359
  return body;
321
360
  }
322
361
  function sleep(ms) {
@@ -518,13 +557,14 @@ export class OSMFeatures {
518
557
  };
519
558
  }
520
559
  /** Single upstream page. Params map 1:1 to server query string (no tiling). */
521
- async query({ bbox, tags, orTags, notTags, type, shape, limit = DEFAULT_LIMIT, cursor, zoom, location, radius, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, }, dependencies = {}) {
560
+ async query({ bbox, tags, orTags, notTags, type, wayShape, shape, limit, cursor, zoom, location, radius, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, }, dependencies = {}) {
522
561
  const payload = await this._rawQuery({
523
562
  bbox,
524
563
  tags,
525
564
  orTags,
526
565
  notTags,
527
566
  type,
567
+ wayShape,
528
568
  shape,
529
569
  limit,
530
570
  cursor,
@@ -544,7 +584,7 @@ export class OSMFeatures {
544
584
  return payload;
545
585
  }
546
586
  /** Auto-paginate (and optionally tile) until complete. Each page uses `_rawQuery`. */
547
- async query_all({ bbox, tags, orTags, notTags, type, shape, zoom, location, radius, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, limitPerPage = DEFAULT_LIMIT, bboxTiles = 2, maxPages = 15, maxFeatures = 55_000, }, dependencies = {}) {
587
+ async query_all({ bbox, tags, orTags, notTags, type, wayShape, shape, zoom, location, radius, osmIds, minLengthM, maxLengthM, minAreaM2, maxAreaM2, disableBudgetWarning, centroid, clipGeometry, accept, limitPerPage, bboxTiles = 2, maxPages = 15, maxFeatures = 55_000, }, dependencies = {}) {
548
588
  if (!isGeojsonAccept(accept)) {
549
589
  throw appError(400, 'invalid_accept', 'query_all only supports GeoJSON; use query({ accept }) for binary encodings.');
550
590
  }
@@ -570,6 +610,7 @@ export class OSMFeatures {
570
610
  orTags,
571
611
  notTags,
572
612
  type,
613
+ wayShape,
573
614
  shape,
574
615
  limit: limitPerPage,
575
616
  zoom,
@@ -670,8 +711,8 @@ export class OSMFeatures {
670
711
  orTags: params.orTags,
671
712
  notTags: params.notTags,
672
713
  type: params.type,
673
- shape: params.shape,
674
- limit: params.limit ?? DEFAULT_LIMIT,
714
+ wayShape: params.wayShape ?? params.shape,
715
+ limit: params.limit,
675
716
  zoom: params.zoom,
676
717
  location: params.location,
677
718
  radius: params.radius,
@@ -689,15 +730,15 @@ export class OSMFeatures {
689
730
  async usage(dependencies = {}) {
690
731
  return this._getJson('/v1/usage', {}, dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
691
732
  }
692
- /** Find places in a bbox or radius (``POST /v1/places/search``). */
733
+ /** Find places in a bbox or radius (``POST /v1/places/search``). Features have ``properties.openNow`` when hours are known. */
693
734
  async places_search(params, dependencies = {}) {
694
735
  return this._postJson('/v1/places/search', placesSearchBody(params), dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
695
736
  }
696
- /** Nearest places from a point (``POST /v1/places/nearby``). */
737
+ /** Nearest places from a point (``POST /v1/places/nearby``). Features have ``properties.openNow`` when hours are known. */
697
738
  async places_nearby(params, dependencies = {}) {
698
739
  return this._postJson('/v1/places/nearby', placesNearbyBody(params), dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
699
740
  }
700
- /** One place by OSM id (``GET /v1/places/{osm_type}/{osm_id}``). */
741
+ /** One place by OSM id (``GET /v1/places/{osm_type}/{osm_id}``). Feature has ``properties.openNow`` when hours are known. */
701
742
  async places_details(params, dependencies = {}) {
702
743
  const { osmType, osmId } = parsePlaceRef(params.osmType, params.osmId);
703
744
  return this._getJson(`/v1/places/${osmType}/${osmId}`, {}, dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
@@ -716,4 +757,6 @@ export class OSMFeatures {
716
757
  }
717
758
  }
718
759
  export * from './geojson-feature.js';
760
+ export * from './geometry.js';
761
+ export * from './nearest.js';
719
762
  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
+ }
@@ -7,6 +7,8 @@ export type OSMFeaturesLayerPreset = {
7
7
  orTags?: string[];
8
8
  notTags?: string[];
9
9
  type?: string;
10
+ wayShape?: 'line' | 'polygon';
11
+ /** @deprecated Use `wayShape`. */
10
12
  shape?: 'line' | 'polygon';
11
13
  };
12
14
  export declare const OSM_FEATURES_LAYER_PRESETS: Record<OSMFeaturesPresetId, OSMFeaturesLayerPreset>;
@@ -4,14 +4,14 @@ export const OSM_FEATURES_LAYER_PRESETS = {
4
4
  label: 'Buildings',
5
5
  tags: ['building'],
6
6
  type: 'way,relation',
7
- shape: 'polygon',
7
+ wayShape: 'polygon',
8
8
  },
9
9
  roads_paths: {
10
10
  id: 'roads_paths',
11
11
  label: 'Roads & paths',
12
12
  tags: ['highway'],
13
13
  type: 'way,relation',
14
- shape: 'line',
14
+ wayShape: 'line',
15
15
  },
16
16
  parks_green_space: {
17
17
  id: 'parks_green_space',
@@ -37,7 +37,7 @@ export const OSM_FEATURES_LAYER_PRESETS = {
37
37
  'amenity=pub',
38
38
  'amenity=biergarten',
39
39
  ],
40
- shape: 'polygon',
40
+ wayShape: 'polygon',
41
41
  },
42
42
  shops_commerce: {
43
43
  id: 'shops_commerce',
@@ -112,7 +112,7 @@ export const OSM_FEATURES_LAYER_PRESETS = {
112
112
  'waterway=ditch',
113
113
  ],
114
114
  type: 'way,relation',
115
- shape: 'line',
115
+ wayShape: 'line',
116
116
  },
117
117
  };
118
118
  export const OSM_FEATURES_LAYER_PRESET_ORDER = [
@@ -12,6 +12,8 @@ export type ResolvedCustomLayer = {
12
12
  orTags?: string[];
13
13
  notTags?: string[];
14
14
  type?: string;
15
+ wayShape?: 'line' | 'polygon' | 'all';
16
+ /** @deprecated Use `wayShape`. */
15
17
  shape?: 'line' | 'polygon' | 'all';
16
18
  };
17
19
  export type ResolvedDemoLayer = ResolvedPresetLayer | ResolvedCustomLayer;
@@ -24,7 +26,7 @@ export declare function getPreset(id: string): OSMFeaturesLayerPreset | undefine
24
26
  export declare function resolvePresetFromQuery(query: PresetQuery): ResolvedPresetLayer;
25
27
  /**
26
28
  * Custom layer: `tags` / `or_tags` / `not_tags` + required `bbox`.
27
- * Optional `type` / `shape`. Needs at least one positive filter (`tags` or `or_tags`).
29
+ * Optional `type` / `way_shape`. Needs at least one positive filter (`tags` or `or_tags`).
28
30
  */
29
31
  export declare function resolveCustomLayerFromQuery(query: PresetQuery): ResolvedCustomLayer;
30
32
  /**
@@ -91,7 +91,7 @@ export function resolvePresetFromQuery(query) {
91
91
  }
92
92
  /**
93
93
  * Custom layer: `tags` / `or_tags` / `not_tags` + required `bbox`.
94
- * Optional `type` / `shape`. Needs at least one positive filter (`tags` or `or_tags`).
94
+ * Optional `type` / `way_shape`. Needs at least one positive filter (`tags` or `or_tags`).
95
95
  */
96
96
  export function resolveCustomLayerFromQuery(query) {
97
97
  const tags = stringList(query, 'tags');
@@ -114,9 +114,9 @@ export function resolveCustomLayerFromQuery(query) {
114
114
  if (type) {
115
115
  layer.type = type;
116
116
  }
117
- const shape = optionalString(query, 'shape');
118
- if (shape === 'line' || shape === 'polygon' || shape === 'all') {
119
- layer.shape = shape;
117
+ const wayShape = optionalString(query, 'way_shape') ?? optionalString(query, 'shape');
118
+ if (wayShape === 'line' || wayShape === 'polygon' || wayShape === 'all') {
119
+ layer.wayShape = wayShape;
120
120
  }
121
121
  return layer;
122
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osmfeatures",
3
- "version": "0.2.3",
3
+ "version": "0.3.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",
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
  },
@@ -36,7 +36,7 @@
36
36
  "homepage": "https://maplark.com",
37
37
  "repository": {
38
38
  "type": "git",
39
- "url": "https://github.com/MapLark/osmfeatures-typescript.git"
39
+ "url": "https://github.com/MapLark/osmfeatures-ts.git"
40
40
  },
41
41
  "license": "MIT",
42
42
  "engines": {