osmfeatures 0.2.4 → 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 `way_shape` parameter:
26
+ - `node` → Point
27
+ - `way` → LineString or Polygon
28
+ - `relation` → MultiPolygon or a bundle of geometries
16
29
 
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).
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
 
@@ -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). |
@@ -173,7 +186,7 @@ Same filter params as `query` (`bbox`, `tags`, `orTags`, `notTags`, `type`, `way
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
@@ -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;
@@ -51,8 +52,9 @@ export type OSMFeaturesParams = OSMFeaturesLayer & {
51
52
  minAreaM2?: number;
52
53
  maxAreaM2?: number;
53
54
  disableBudgetWarning?: boolean;
55
+ /** When true, include `properties.centroid` on non-point features. Omit for the API default (false). */
54
56
  centroid?: boolean;
55
- /** 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). */
56
58
  clipGeometry?: boolean;
57
59
  /** Accept media type. Default application/geo+json; other types put bytes in ``data``. */
58
60
  accept?: string;
@@ -95,6 +97,7 @@ export type PlacesSearchParams = {
95
97
  tags?: string[];
96
98
  orTags?: string[];
97
99
  limit?: number;
100
+ /** Keep only places known open at ``asOf`` (or now). Untagged hours are dropped. */
98
101
  openNow?: boolean;
99
102
  asOf?: string;
100
103
  };
@@ -105,9 +108,29 @@ export type PlacesNearbyParams = {
105
108
  tags?: string[];
106
109
  orTags?: string[];
107
110
  limit?: number;
111
+ /** Keep only places known open at ``asOf`` (or now). Untagged hours are dropped. */
108
112
  openNow?: boolean;
109
113
  asOf?: string;
110
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;
111
134
  export type PlacesDetailsParams = {
112
135
  /** ``node`` / ``way`` / ``relation``, or a full ``node/123`` feature id. */
113
136
  osmType: string;
@@ -151,7 +174,7 @@ export declare class OSMFeatures {
151
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>;
152
175
  /** Auto-paginate (and optionally tile) until complete. Each page uses `_rawQuery`. */
153
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'> & {
154
- /** Upstream `limit` per HTTP request (page size). */
177
+ /** Upstream `limit` per HTTP request (page size). Omit to use the API default (1000). */
155
178
  limitPerPage?: number;
156
179
  bboxTiles?: number;
157
180
  maxPages?: number;
@@ -162,11 +185,11 @@ export declare class OSMFeatures {
162
185
  estimate_cost(params?: OSMFeaturesParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
163
186
  /** This month's unit-budget usage (``GET /v1/usage``). */
164
187
  usage(dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
165
- /** 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. */
166
189
  places_search(params: PlacesSearchParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
167
- /** 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. */
168
191
  places_nearby(params: PlacesNearbyParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
169
- /** 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. */
170
193
  places_details(params: PlacesDetailsParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
171
194
  /** Reach polygon along the walk/bike network (``POST /v1/routes/isochrone``). */
172
195
  routes_isochrone(params: RoutesIsochroneParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
@@ -176,4 +199,6 @@ export declare class OSMFeatures {
176
199
  routes_optimized_path(params: RoutesOptimizedPathParams, dependencies?: OSMFeaturesDependencies): Promise<Record<string, unknown>>;
177
200
  }
178
201
  export * from './geojson-feature.js';
202
+ export * from './geometry.js';
203
+ export * from './nearest.js';
179
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) {
@@ -232,7 +255,7 @@ function parsePlaceRef(osmType, osmId) {
232
255
  return { osmType: kind, osmId: n };
233
256
  }
234
257
  function placesSearchBody(params) {
235
- const body = { limit: params.limit ?? 100 };
258
+ const body = {};
236
259
  if (params.bbox != null) {
237
260
  body['bbox'] = params.bbox;
238
261
  }
@@ -251,6 +274,9 @@ function placesSearchBody(params) {
251
274
  if (params.orTags?.length) {
252
275
  body['orTags'] = params.orTags;
253
276
  }
277
+ if (params.limit != null) {
278
+ body['limit'] = params.limit;
279
+ }
254
280
  if (params.openNow) {
255
281
  body['openNow'] = true;
256
282
  }
@@ -262,9 +288,10 @@ function placesSearchBody(params) {
262
288
  function placesNearbyBody(params) {
263
289
  const body = {
264
290
  location: latlng(params.location),
265
- radius: params.radius ?? 1000,
266
- limit: params.limit ?? 10,
267
291
  };
292
+ if (params.radius != null) {
293
+ body['radius'] = params.radius;
294
+ }
268
295
  if (params.type != null) {
269
296
  body['type'] = params.type;
270
297
  }
@@ -274,6 +301,9 @@ function placesNearbyBody(params) {
274
301
  if (params.orTags?.length) {
275
302
  body['orTags'] = params.orTags;
276
303
  }
304
+ if (params.limit != null) {
305
+ body['limit'] = params.limit;
306
+ }
277
307
  if (params.openNow) {
278
308
  body['openNow'] = true;
279
309
  }
@@ -285,7 +315,6 @@ function placesNearbyBody(params) {
285
315
  function routesIsochroneBody(params) {
286
316
  const body = {
287
317
  origin: lonlat(params.origin),
288
- travelMode: params.travelMode ?? 'WALK',
289
318
  };
290
319
  if (params.maxDistanceM != null) {
291
320
  body['max_distance_m'] = params.maxDistanceM;
@@ -296,28 +325,37 @@ function routesIsochroneBody(params) {
296
325
  if (params.searchBufferM != null) {
297
326
  body['search_buffer_m'] = params.searchBufferM;
298
327
  }
328
+ if (params.travelMode != null) {
329
+ body['travelMode'] = params.travelMode;
330
+ }
299
331
  return body;
300
332
  }
301
333
  function routesPathBody(params) {
302
334
  const body = {
303
335
  stops: params.stops.map(lonlat),
304
- travelMode: params.travelMode ?? 'WALK',
305
336
  };
306
337
  if (params.searchBufferM != null) {
307
338
  body['search_buffer_m'] = params.searchBufferM;
308
339
  }
340
+ if (params.travelMode != null) {
341
+ body['travelMode'] = params.travelMode;
342
+ }
309
343
  return body;
310
344
  }
311
345
  function routesOptimizedPathBody(params) {
312
346
  const body = {
313
347
  start: lonlat(params.start),
314
348
  stops: params.stops.map(lonlat),
315
- loop: params.loop ?? true,
316
- travelMode: params.travelMode ?? 'WALK',
317
349
  };
350
+ if (params.loop != null) {
351
+ body['loop'] = params.loop;
352
+ }
318
353
  if (params.searchBufferM != null) {
319
354
  body['search_buffer_m'] = params.searchBufferM;
320
355
  }
356
+ if (params.travelMode != null) {
357
+ body['travelMode'] = params.travelMode;
358
+ }
321
359
  return body;
322
360
  }
323
361
  function sleep(ms) {
@@ -519,7 +557,7 @@ export class OSMFeatures {
519
557
  };
520
558
  }
521
559
  /** 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 = {}) {
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 = {}) {
523
561
  const payload = await this._rawQuery({
524
562
  bbox,
525
563
  tags,
@@ -546,7 +584,7 @@ export class OSMFeatures {
546
584
  return payload;
547
585
  }
548
586
  /** 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 = {}) {
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 = {}) {
550
588
  if (!isGeojsonAccept(accept)) {
551
589
  throw appError(400, 'invalid_accept', 'query_all only supports GeoJSON; use query({ accept }) for binary encodings.');
552
590
  }
@@ -674,7 +712,7 @@ export class OSMFeatures {
674
712
  notTags: params.notTags,
675
713
  type: params.type,
676
714
  wayShape: params.wayShape ?? params.shape,
677
- limit: params.limit ?? DEFAULT_LIMIT,
715
+ limit: params.limit,
678
716
  zoom: params.zoom,
679
717
  location: params.location,
680
718
  radius: params.radius,
@@ -692,15 +730,15 @@ export class OSMFeatures {
692
730
  async usage(dependencies = {}) {
693
731
  return this._getJson('/v1/usage', {}, dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
694
732
  }
695
- /** 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. */
696
734
  async places_search(params, dependencies = {}) {
697
735
  return this._postJson('/v1/places/search', placesSearchBody(params), dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
698
736
  }
699
- /** 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. */
700
738
  async places_nearby(params, dependencies = {}) {
701
739
  return this._postJson('/v1/places/nearby', placesNearbyBody(params), dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
702
740
  }
703
- /** 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. */
704
742
  async places_details(params, dependencies = {}) {
705
743
  const { osmType, osmId } = parsePlaceRef(params.osmType, params.osmId);
706
744
  return this._getJson(`/v1/places/${osmType}/${osmId}`, {}, dependencies.fetchFn ?? fetch, dependencies.sleepFn ?? sleep, dependencies.nowFn ?? Date.now);
@@ -719,4 +757,6 @@ export class OSMFeatures {
719
757
  }
720
758
  }
721
759
  export * from './geojson-feature.js';
760
+ export * from './geometry.js';
761
+ export * from './nearest.js';
722
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osmfeatures",
3
- "version": "0.2.4",
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 && 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
  },