@proximap/core 1.0.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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/index.cjs +2221 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +973 -0
- package/dist/index.d.ts +973 -0
- package/dist/index.js +2139 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
CATEGORIES: () => CATEGORIES,
|
|
24
|
+
CATEGORY_LABELS: () => CATEGORY_LABELS,
|
|
25
|
+
DEFAULT_DAILY_NEEDS: () => DEFAULT_DAILY_NEEDS,
|
|
26
|
+
DEFAULT_USER_AGENT: () => DEFAULT_USER_AGENT,
|
|
27
|
+
DEFAULT_WALK_CATEGORIES: () => DEFAULT_WALK_CATEGORIES,
|
|
28
|
+
DatasetPlacesProvider: () => DatasetPlacesProvider,
|
|
29
|
+
HaversineRoutingProvider: () => HaversineRoutingProvider,
|
|
30
|
+
HttpError: () => HttpError,
|
|
31
|
+
InMemoryCache: () => InMemoryCache,
|
|
32
|
+
MODE_SPEED_MPS: () => MODE_SPEED_MPS,
|
|
33
|
+
NominatimGeocoder: () => NominatimGeocoder,
|
|
34
|
+
ODBL_ATTRIBUTION: () => ODBL_ATTRIBUTION,
|
|
35
|
+
OsrmRoutingProvider: () => OsrmRoutingProvider,
|
|
36
|
+
OverpassPlacesProvider: () => OverpassPlacesProvider,
|
|
37
|
+
RateLimiter: () => RateLimiter,
|
|
38
|
+
VERSION: () => VERSION,
|
|
39
|
+
ValhallaRoutingProvider: () => ValhallaRoutingProvider,
|
|
40
|
+
accessibleScorer: () => accessibleScorer,
|
|
41
|
+
buildOverpassQuery: () => buildOverpassQuery,
|
|
42
|
+
buildTargetedOverpassQuery: () => buildTargetedOverpassQuery,
|
|
43
|
+
categorize: () => categorize,
|
|
44
|
+
categoryVocabulary: () => categoryVocabulary,
|
|
45
|
+
circlePolygon: () => circlePolygon,
|
|
46
|
+
compareLocations: () => compareLocations,
|
|
47
|
+
compileFacets: () => compileFacets,
|
|
48
|
+
completenessOf: () => completenessOf,
|
|
49
|
+
dedupePois: () => dedupePois,
|
|
50
|
+
detectGaps: () => detectGaps,
|
|
51
|
+
disambiguateLocation: () => disambiguateLocation,
|
|
52
|
+
findNearbyAmenities: () => findNearbyAmenities,
|
|
53
|
+
formatDistance: () => formatDistance,
|
|
54
|
+
formatDuration: () => formatDuration,
|
|
55
|
+
haversineMeters: () => haversineMeters,
|
|
56
|
+
isCategory: () => isCategory,
|
|
57
|
+
isKnownTerm: () => isKnownTerm,
|
|
58
|
+
isOpenAt: () => isOpenAt,
|
|
59
|
+
lastVerifiedOf: () => lastVerifiedOf,
|
|
60
|
+
matchesFacets: () => matchesFacets,
|
|
61
|
+
nearestMatchingPoi: () => nearestMatchingPoi,
|
|
62
|
+
parseCoordinates: () => parseCoordinates,
|
|
63
|
+
planErrands: () => planErrands,
|
|
64
|
+
pointInPolygon: () => pointInPolygon,
|
|
65
|
+
rankByProximity: () => rankByProximity,
|
|
66
|
+
reachableAmenities: () => reachableAmenities,
|
|
67
|
+
requestJson: () => requestJson,
|
|
68
|
+
resolveCategories: () => resolveCategories,
|
|
69
|
+
resolveOrigin: () => resolveOrigin,
|
|
70
|
+
selectorToOverpassFilter: () => selectorToOverpassFilter,
|
|
71
|
+
snapshotArea: () => snapshotArea,
|
|
72
|
+
suggestCategories: () => suggestCategories,
|
|
73
|
+
tagsMatchAnySelector: () => tagsMatchAnySelector,
|
|
74
|
+
tagsMatchSelector: () => tagsMatchSelector,
|
|
75
|
+
toCSV: () => toCSV,
|
|
76
|
+
toGeoJSON: () => toGeoJSON,
|
|
77
|
+
walkSubScore: () => walkSubScore,
|
|
78
|
+
walkabilityScore: () => walkabilityScore
|
|
79
|
+
});
|
|
80
|
+
module.exports = __toCommonJS(index_exports);
|
|
81
|
+
|
|
82
|
+
// src/types.ts
|
|
83
|
+
var CATEGORIES = [
|
|
84
|
+
"food",
|
|
85
|
+
"grocery",
|
|
86
|
+
"shopping",
|
|
87
|
+
"healthcare",
|
|
88
|
+
"education",
|
|
89
|
+
"finance",
|
|
90
|
+
"transport",
|
|
91
|
+
"fuel",
|
|
92
|
+
"parking",
|
|
93
|
+
"accommodation",
|
|
94
|
+
"leisure",
|
|
95
|
+
"tourism",
|
|
96
|
+
"worship",
|
|
97
|
+
"public_service",
|
|
98
|
+
"utility",
|
|
99
|
+
"other"
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
// src/geo.ts
|
|
103
|
+
var EARTH_RADIUS_M = 63710088e-1;
|
|
104
|
+
var toRadians = (degrees) => degrees * Math.PI / 180;
|
|
105
|
+
function haversineMeters(a2, b) {
|
|
106
|
+
const dLat = toRadians(b.lat - a2.lat);
|
|
107
|
+
const dLng = toRadians(b.lng - a2.lng);
|
|
108
|
+
const lat1 = toRadians(a2.lat);
|
|
109
|
+
const lat2 = toRadians(b.lat);
|
|
110
|
+
const sinLat = Math.sin(dLat / 2);
|
|
111
|
+
const sinLng = Math.sin(dLng / 2);
|
|
112
|
+
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng;
|
|
113
|
+
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
114
|
+
}
|
|
115
|
+
function formatDistance(meters) {
|
|
116
|
+
if (!Number.isFinite(meters) || meters < 0) return "\u2014";
|
|
117
|
+
if (meters < 1e3) return `${Math.round(meters)} m`;
|
|
118
|
+
const km = meters / 1e3;
|
|
119
|
+
return `${km < 10 ? km.toFixed(1) : Math.round(km)} km`;
|
|
120
|
+
}
|
|
121
|
+
function formatDuration(seconds) {
|
|
122
|
+
if (!Number.isFinite(seconds) || seconds < 0) return "\u2014";
|
|
123
|
+
const minutes = Math.round(seconds / 60);
|
|
124
|
+
if (minutes < 60) return `${minutes} min`;
|
|
125
|
+
const hours = Math.floor(minutes / 60);
|
|
126
|
+
const remainder = minutes % 60;
|
|
127
|
+
return remainder === 0 ? `${hours} h` : `${hours} h ${remainder} min`;
|
|
128
|
+
}
|
|
129
|
+
function parseCoordinates(input) {
|
|
130
|
+
const match = input.trim().match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/);
|
|
131
|
+
if (!match) return null;
|
|
132
|
+
const lat = Number(match[1]);
|
|
133
|
+
const lng = Number(match[2]);
|
|
134
|
+
if (Number.isNaN(lat) || Number.isNaN(lng)) return null;
|
|
135
|
+
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null;
|
|
136
|
+
return { lat, lng };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// src/categories.ts
|
|
140
|
+
var AMENITY_GROUPS = {
|
|
141
|
+
food: ["restaurant", "cafe", "fast_food", "bar", "pub", "food_court", "biergarten", "ice_cream"],
|
|
142
|
+
healthcare: [
|
|
143
|
+
"hospital",
|
|
144
|
+
"clinic",
|
|
145
|
+
"doctors",
|
|
146
|
+
"dentist",
|
|
147
|
+
"pharmacy",
|
|
148
|
+
"veterinary",
|
|
149
|
+
"nursing_home"
|
|
150
|
+
],
|
|
151
|
+
education: ["school", "college", "university", "kindergarten", "library", "language_school"],
|
|
152
|
+
finance: ["bank", "atm", "bureau_de_change"],
|
|
153
|
+
fuel: ["fuel", "charging_station"],
|
|
154
|
+
parking: ["parking", "bicycle_parking", "motorcycle_parking", "parking_entrance"],
|
|
155
|
+
transport: [
|
|
156
|
+
"bus_station",
|
|
157
|
+
"taxi",
|
|
158
|
+
"ferry_terminal",
|
|
159
|
+
"car_rental",
|
|
160
|
+
"bicycle_rental",
|
|
161
|
+
"car_sharing"
|
|
162
|
+
],
|
|
163
|
+
worship: ["place_of_worship"],
|
|
164
|
+
public_service: [
|
|
165
|
+
"police",
|
|
166
|
+
"fire_station",
|
|
167
|
+
"post_office",
|
|
168
|
+
"townhall",
|
|
169
|
+
"courthouse",
|
|
170
|
+
"community_centre"
|
|
171
|
+
],
|
|
172
|
+
leisure: ["cinema", "theatre", "nightclub", "arts_centre"],
|
|
173
|
+
shopping: ["marketplace"],
|
|
174
|
+
utility: [
|
|
175
|
+
"toilets",
|
|
176
|
+
"drinking_water",
|
|
177
|
+
"shower",
|
|
178
|
+
"recycling",
|
|
179
|
+
"waste_disposal",
|
|
180
|
+
"post_box",
|
|
181
|
+
"telephone",
|
|
182
|
+
"fountain",
|
|
183
|
+
"shelter"
|
|
184
|
+
]
|
|
185
|
+
};
|
|
186
|
+
var AMENITY_TO_CATEGORY = /* @__PURE__ */ new Map();
|
|
187
|
+
for (const [category, values] of Object.entries(AMENITY_GROUPS)) {
|
|
188
|
+
for (const value of values) AMENITY_TO_CATEGORY.set(value, category);
|
|
189
|
+
}
|
|
190
|
+
var GROCERY_SHOPS = /* @__PURE__ */ new Set([
|
|
191
|
+
"supermarket",
|
|
192
|
+
"convenience",
|
|
193
|
+
"greengrocer",
|
|
194
|
+
"bakery",
|
|
195
|
+
"butcher",
|
|
196
|
+
"general",
|
|
197
|
+
"deli",
|
|
198
|
+
"farm",
|
|
199
|
+
"dairy",
|
|
200
|
+
"health_food"
|
|
201
|
+
]);
|
|
202
|
+
var LODGING_TOURISM = /* @__PURE__ */ new Set([
|
|
203
|
+
"hotel",
|
|
204
|
+
"hostel",
|
|
205
|
+
"guest_house",
|
|
206
|
+
"motel",
|
|
207
|
+
"apartment",
|
|
208
|
+
"chalet",
|
|
209
|
+
"camp_site",
|
|
210
|
+
"caravan_site"
|
|
211
|
+
]);
|
|
212
|
+
var TRANSPORT_RAILWAY = /* @__PURE__ */ new Set(["station", "halt", "tram_stop", "subway_entrance", "stop"]);
|
|
213
|
+
function categorize(tags) {
|
|
214
|
+
const {
|
|
215
|
+
amenity,
|
|
216
|
+
shop: shop2,
|
|
217
|
+
tourism: tourism2,
|
|
218
|
+
leisure: leisure2,
|
|
219
|
+
healthcare,
|
|
220
|
+
railway,
|
|
221
|
+
public_transport,
|
|
222
|
+
highway,
|
|
223
|
+
aeroway,
|
|
224
|
+
office
|
|
225
|
+
} = tags;
|
|
226
|
+
if (amenity) {
|
|
227
|
+
const category = AMENITY_TO_CATEGORY.get(amenity);
|
|
228
|
+
return category ? { category, kind: amenity } : { category: "other", kind: amenity };
|
|
229
|
+
}
|
|
230
|
+
if (shop2) return { category: GROCERY_SHOPS.has(shop2) ? "grocery" : "shopping", kind: shop2 };
|
|
231
|
+
if (tourism2)
|
|
232
|
+
return { category: LODGING_TOURISM.has(tourism2) ? "accommodation" : "tourism", kind: tourism2 };
|
|
233
|
+
if (healthcare) return { category: "healthcare", kind: healthcare };
|
|
234
|
+
if (leisure2) return { category: "leisure", kind: leisure2 };
|
|
235
|
+
if (railway && TRANSPORT_RAILWAY.has(railway)) return { category: "transport", kind: railway };
|
|
236
|
+
if (public_transport) return { category: "transport", kind: public_transport };
|
|
237
|
+
if (highway === "bus_stop") return { category: "transport", kind: "bus_stop" };
|
|
238
|
+
if (aeroway) return { category: "transport", kind: aeroway };
|
|
239
|
+
if (office)
|
|
240
|
+
return {
|
|
241
|
+
category: office === "government" ? "public_service" : "other",
|
|
242
|
+
kind: `office:${office}`
|
|
243
|
+
};
|
|
244
|
+
return { category: "other" };
|
|
245
|
+
}
|
|
246
|
+
var CATEGORY_LABELS = {
|
|
247
|
+
food: "Food & drink",
|
|
248
|
+
grocery: "Groceries",
|
|
249
|
+
shopping: "Shopping",
|
|
250
|
+
healthcare: "Healthcare",
|
|
251
|
+
education: "Education",
|
|
252
|
+
finance: "Money",
|
|
253
|
+
transport: "Transport",
|
|
254
|
+
fuel: "Fuel & charging",
|
|
255
|
+
parking: "Parking",
|
|
256
|
+
accommodation: "Accommodation",
|
|
257
|
+
leisure: "Leisure",
|
|
258
|
+
tourism: "Tourism",
|
|
259
|
+
worship: "Places of worship",
|
|
260
|
+
public_service: "Public services",
|
|
261
|
+
utility: "Utilities",
|
|
262
|
+
other: "Other"
|
|
263
|
+
};
|
|
264
|
+
function isCategory(value) {
|
|
265
|
+
return CATEGORIES.includes(value);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/taxonomy.ts
|
|
269
|
+
var a = (value) => ({ key: "amenity", value });
|
|
270
|
+
var shop = (value) => ({ key: "shop", value });
|
|
271
|
+
var leisure = (value) => ({ key: "leisure", value });
|
|
272
|
+
var tourism = (value) => ({ key: "tourism", value });
|
|
273
|
+
var rx = (key, value) => ({ key, value, regex: true });
|
|
274
|
+
var present = (key) => ({ key });
|
|
275
|
+
var TERMS = [
|
|
276
|
+
// food
|
|
277
|
+
{
|
|
278
|
+
term: "food",
|
|
279
|
+
category: "food",
|
|
280
|
+
selectors: [a("restaurant"), a("cafe"), a("fast_food"), a("bar"), a("pub"), a("food_court")],
|
|
281
|
+
synonyms: ["food and drink", "places to eat", "eat", "dining", "somewhere to eat"]
|
|
282
|
+
},
|
|
283
|
+
{ term: "restaurant", category: "food", selectors: [a("restaurant")], synonyms: ["restaurants"] },
|
|
284
|
+
{ term: "cafe", category: "food", selectors: [a("cafe")], synonyms: ["caf\xE9", "cafes"] },
|
|
285
|
+
{
|
|
286
|
+
term: "coffee",
|
|
287
|
+
category: "food",
|
|
288
|
+
selectors: [a("cafe"), rx("cuisine", "coffee_shop"), shop("coffee")],
|
|
289
|
+
synonyms: ["coffee shop", "coffeehouse"]
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
term: "fast food",
|
|
293
|
+
category: "food",
|
|
294
|
+
selectors: [a("fast_food")],
|
|
295
|
+
synonyms: ["fastfood", "takeaway food"]
|
|
296
|
+
},
|
|
297
|
+
{ term: "bar", category: "food", selectors: [a("bar")], synonyms: ["bars"] },
|
|
298
|
+
{ term: "pub", category: "food", selectors: [a("pub")], synonyms: ["pubs"] },
|
|
299
|
+
{ term: "pizza", category: "food", selectors: [rx("cuisine", "pizza")], synonyms: ["pizzeria"] },
|
|
300
|
+
{ term: "ice cream", category: "food", selectors: [a("ice_cream")], synonyms: ["gelato"] },
|
|
301
|
+
// grocery
|
|
302
|
+
{
|
|
303
|
+
term: "grocery",
|
|
304
|
+
category: "grocery",
|
|
305
|
+
selectors: [shop("supermarket"), shop("convenience"), shop("greengrocer"), shop("grocery")],
|
|
306
|
+
synonyms: ["groceries", "grocery store", "food shopping"]
|
|
307
|
+
},
|
|
308
|
+
{ term: "supermarket", category: "grocery", selectors: [shop("supermarket")] },
|
|
309
|
+
{
|
|
310
|
+
term: "convenience",
|
|
311
|
+
category: "grocery",
|
|
312
|
+
selectors: [shop("convenience")],
|
|
313
|
+
synonyms: ["convenience store", "corner shop"]
|
|
314
|
+
},
|
|
315
|
+
{ term: "bakery", category: "grocery", selectors: [shop("bakery")], synonyms: ["baker"] },
|
|
316
|
+
// shopping
|
|
317
|
+
{
|
|
318
|
+
term: "shopping",
|
|
319
|
+
category: "shopping",
|
|
320
|
+
selectors: [present("shop")],
|
|
321
|
+
synonyms: ["shops", "stores", "retail"]
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
term: "mall",
|
|
325
|
+
category: "shopping",
|
|
326
|
+
selectors: [shop("mall"), shop("department_store")],
|
|
327
|
+
synonyms: ["shopping mall", "shopping centre", "shopping center"]
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
term: "clothes",
|
|
331
|
+
category: "shopping",
|
|
332
|
+
selectors: [shop("clothes")],
|
|
333
|
+
synonyms: ["clothing", "fashion"]
|
|
334
|
+
},
|
|
335
|
+
// healthcare
|
|
336
|
+
{
|
|
337
|
+
term: "healthcare",
|
|
338
|
+
category: "healthcare",
|
|
339
|
+
selectors: [
|
|
340
|
+
a("hospital"),
|
|
341
|
+
a("clinic"),
|
|
342
|
+
a("doctors"),
|
|
343
|
+
a("dentist"),
|
|
344
|
+
a("pharmacy"),
|
|
345
|
+
present("healthcare")
|
|
346
|
+
],
|
|
347
|
+
synonyms: ["health", "medical", "health care"]
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
term: "pharmacy",
|
|
351
|
+
category: "healthcare",
|
|
352
|
+
selectors: [a("pharmacy"), rx("healthcare", "pharmacy")],
|
|
353
|
+
synonyms: ["chemist", "drugstore", "drug store"]
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
term: "hospital",
|
|
357
|
+
category: "healthcare",
|
|
358
|
+
selectors: [a("hospital")],
|
|
359
|
+
synonyms: ["hospitals", "emergency room", "a&e"]
|
|
360
|
+
},
|
|
361
|
+
{ term: "clinic", category: "healthcare", selectors: [a("clinic"), rx("healthcare", "clinic")] },
|
|
362
|
+
{
|
|
363
|
+
term: "doctor",
|
|
364
|
+
category: "healthcare",
|
|
365
|
+
selectors: [a("doctors"), rx("healthcare", "doctor")],
|
|
366
|
+
synonyms: ["doctors", "gp", "physician"]
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
term: "dentist",
|
|
370
|
+
category: "healthcare",
|
|
371
|
+
selectors: [a("dentist"), rx("healthcare", "dentist")],
|
|
372
|
+
synonyms: ["dental"]
|
|
373
|
+
},
|
|
374
|
+
// education
|
|
375
|
+
{
|
|
376
|
+
term: "education",
|
|
377
|
+
category: "education",
|
|
378
|
+
selectors: [a("school"), a("college"), a("university"), a("kindergarten"), a("library")],
|
|
379
|
+
synonyms: ["schools"]
|
|
380
|
+
},
|
|
381
|
+
{ term: "school", category: "education", selectors: [a("school")] },
|
|
382
|
+
{
|
|
383
|
+
term: "university",
|
|
384
|
+
category: "education",
|
|
385
|
+
selectors: [a("university"), a("college")],
|
|
386
|
+
synonyms: ["college", "uni"]
|
|
387
|
+
},
|
|
388
|
+
{ term: "library", category: "education", selectors: [a("library")], synonyms: ["libraries"] },
|
|
389
|
+
// finance
|
|
390
|
+
{
|
|
391
|
+
term: "finance",
|
|
392
|
+
category: "finance",
|
|
393
|
+
selectors: [a("bank"), a("atm"), a("bureau_de_change")],
|
|
394
|
+
synonyms: ["money", "banking"]
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
term: "atm",
|
|
398
|
+
category: "finance",
|
|
399
|
+
selectors: [a("atm")],
|
|
400
|
+
synonyms: ["cash machine", "cashpoint"]
|
|
401
|
+
},
|
|
402
|
+
{ term: "bank", category: "finance", selectors: [a("bank")], synonyms: ["banks"] },
|
|
403
|
+
// transport
|
|
404
|
+
{
|
|
405
|
+
term: "transport",
|
|
406
|
+
category: "transport",
|
|
407
|
+
selectors: [
|
|
408
|
+
a("bus_station"),
|
|
409
|
+
{ key: "public_transport", value: "station" },
|
|
410
|
+
rx("railway", "station|halt|tram_stop|subway_entrance|stop"),
|
|
411
|
+
{ key: "highway", value: "bus_stop" },
|
|
412
|
+
{ key: "aeroway", value: "aerodrome" }
|
|
413
|
+
],
|
|
414
|
+
synonyms: ["public transport", "transit"]
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
term: "bus stop",
|
|
418
|
+
category: "transport",
|
|
419
|
+
selectors: [{ key: "highway", value: "bus_stop" }, a("bus_station")],
|
|
420
|
+
synonyms: ["bus", "bus station"]
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
term: "train station",
|
|
424
|
+
category: "transport",
|
|
425
|
+
selectors: [rx("railway", "station|halt"), { key: "public_transport", value: "station" }],
|
|
426
|
+
synonyms: ["railway station", "train", "metro", "metro station", "subway", "subway station"]
|
|
427
|
+
},
|
|
428
|
+
{ term: "taxi", category: "transport", selectors: [a("taxi")] },
|
|
429
|
+
{
|
|
430
|
+
term: "airport",
|
|
431
|
+
category: "transport",
|
|
432
|
+
selectors: [{ key: "aeroway", value: "aerodrome" }],
|
|
433
|
+
synonyms: ["airports"]
|
|
434
|
+
},
|
|
435
|
+
// fuel
|
|
436
|
+
{
|
|
437
|
+
term: "fuel",
|
|
438
|
+
category: "fuel",
|
|
439
|
+
selectors: [a("fuel"), a("charging_station")],
|
|
440
|
+
synonyms: ["petrol", "gas", "gas station", "petrol station", "filling station"]
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
term: "ev charging",
|
|
444
|
+
category: "fuel",
|
|
445
|
+
selectors: [a("charging_station")],
|
|
446
|
+
synonyms: ["charging station", "ev charger", "charger"]
|
|
447
|
+
},
|
|
448
|
+
// parking
|
|
449
|
+
{
|
|
450
|
+
term: "parking",
|
|
451
|
+
category: "parking",
|
|
452
|
+
selectors: [a("parking")],
|
|
453
|
+
synonyms: ["car park", "parking lot"]
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
term: "bicycle parking",
|
|
457
|
+
category: "parking",
|
|
458
|
+
selectors: [a("bicycle_parking")],
|
|
459
|
+
synonyms: ["bike parking"]
|
|
460
|
+
},
|
|
461
|
+
// accommodation
|
|
462
|
+
{
|
|
463
|
+
term: "accommodation",
|
|
464
|
+
category: "accommodation",
|
|
465
|
+
selectors: [tourism("hotel"), tourism("hostel"), tourism("guest_house"), tourism("motel")],
|
|
466
|
+
synonyms: ["lodging", "places to stay", "stay"]
|
|
467
|
+
},
|
|
468
|
+
{ term: "hotel", category: "accommodation", selectors: [tourism("hotel")], synonyms: ["hotels"] },
|
|
469
|
+
{ term: "hostel", category: "accommodation", selectors: [tourism("hostel")] },
|
|
470
|
+
// leisure
|
|
471
|
+
{
|
|
472
|
+
term: "leisure",
|
|
473
|
+
category: "leisure",
|
|
474
|
+
selectors: [present("leisure")],
|
|
475
|
+
synonyms: ["recreation"]
|
|
476
|
+
},
|
|
477
|
+
{ term: "park", category: "leisure", selectors: [leisure("park")], synonyms: ["parks"] },
|
|
478
|
+
{
|
|
479
|
+
term: "gym",
|
|
480
|
+
category: "leisure",
|
|
481
|
+
selectors: [leisure("fitness_centre"), leisure("sports_centre")],
|
|
482
|
+
synonyms: ["fitness", "fitness centre", "gymnasium"]
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
term: "cinema",
|
|
486
|
+
category: "leisure",
|
|
487
|
+
selectors: [a("cinema")],
|
|
488
|
+
synonyms: ["movie theater", "movie theatre", "movies"]
|
|
489
|
+
},
|
|
490
|
+
{ term: "playground", category: "leisure", selectors: [leisure("playground")] },
|
|
491
|
+
// tourism
|
|
492
|
+
{
|
|
493
|
+
term: "tourism",
|
|
494
|
+
category: "tourism",
|
|
495
|
+
selectors: [present("tourism")],
|
|
496
|
+
synonyms: ["attractions", "sights", "things to do"]
|
|
497
|
+
},
|
|
498
|
+
{ term: "museum", category: "tourism", selectors: [tourism("museum")], synonyms: ["museums"] },
|
|
499
|
+
{ term: "viewpoint", category: "tourism", selectors: [tourism("viewpoint")] },
|
|
500
|
+
// worship
|
|
501
|
+
{
|
|
502
|
+
term: "worship",
|
|
503
|
+
category: "worship",
|
|
504
|
+
selectors: [a("place_of_worship")],
|
|
505
|
+
synonyms: ["place of worship", "church", "mosque", "temple", "synagogue"]
|
|
506
|
+
},
|
|
507
|
+
// public_service
|
|
508
|
+
{
|
|
509
|
+
term: "public_service",
|
|
510
|
+
category: "public_service",
|
|
511
|
+
selectors: [a("police"), a("fire_station"), a("post_office"), a("townhall")],
|
|
512
|
+
synonyms: ["public services", "government", "civic"]
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
term: "police",
|
|
516
|
+
category: "public_service",
|
|
517
|
+
selectors: [a("police")],
|
|
518
|
+
synonyms: ["police station"]
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
term: "post office",
|
|
522
|
+
category: "public_service",
|
|
523
|
+
selectors: [a("post_office")],
|
|
524
|
+
synonyms: ["postal"]
|
|
525
|
+
},
|
|
526
|
+
{ term: "fire station", category: "public_service", selectors: [a("fire_station")] },
|
|
527
|
+
// utility
|
|
528
|
+
{
|
|
529
|
+
term: "utility",
|
|
530
|
+
category: "utility",
|
|
531
|
+
selectors: [a("toilets"), a("drinking_water"), a("recycling"), a("post_box")],
|
|
532
|
+
synonyms: ["utilities"]
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
term: "toilets",
|
|
536
|
+
category: "utility",
|
|
537
|
+
selectors: [a("toilets")],
|
|
538
|
+
synonyms: ["toilet", "restroom", "restrooms", "wc", "public toilet", "bathroom"]
|
|
539
|
+
},
|
|
540
|
+
{
|
|
541
|
+
term: "drinking water",
|
|
542
|
+
category: "utility",
|
|
543
|
+
selectors: [a("drinking_water")],
|
|
544
|
+
synonyms: ["water fountain", "water point"]
|
|
545
|
+
},
|
|
546
|
+
// other (catch-all; not directly queryable)
|
|
547
|
+
{ term: "other", category: "other", selectors: [] }
|
|
548
|
+
];
|
|
549
|
+
var normalize = (term) => term.trim().toLowerCase().replace(/\s+/g, " ");
|
|
550
|
+
var TERM_INDEX = /* @__PURE__ */ new Map();
|
|
551
|
+
for (const def of TERMS) {
|
|
552
|
+
TERM_INDEX.set(normalize(def.term), def);
|
|
553
|
+
for (const synonym of def.synonyms ?? []) TERM_INDEX.set(normalize(synonym), def);
|
|
554
|
+
}
|
|
555
|
+
var selectorKey = (s) => `${s.key}|${s.value ?? ""}|${s.regex ? 1 : 0}`;
|
|
556
|
+
function resolveCategories(terms) {
|
|
557
|
+
const selectors = [];
|
|
558
|
+
const seen = /* @__PURE__ */ new Set();
|
|
559
|
+
const matched = [];
|
|
560
|
+
const unknown = [];
|
|
561
|
+
for (const input of terms) {
|
|
562
|
+
const def = TERM_INDEX.get(normalize(input));
|
|
563
|
+
if (!def) {
|
|
564
|
+
unknown.push(input);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
matched.push({ input, term: def.term, category: def.category });
|
|
568
|
+
for (const selector of def.selectors) {
|
|
569
|
+
const key = selectorKey(selector);
|
|
570
|
+
if (!seen.has(key)) {
|
|
571
|
+
seen.add(key);
|
|
572
|
+
selectors.push(selector);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return { selectors, matched, unknown };
|
|
577
|
+
}
|
|
578
|
+
function isKnownTerm(term) {
|
|
579
|
+
return TERM_INDEX.has(normalize(term));
|
|
580
|
+
}
|
|
581
|
+
function categoryVocabulary() {
|
|
582
|
+
return TERMS.map(({ term, category }) => ({ term, category }));
|
|
583
|
+
}
|
|
584
|
+
function editDistance(a2, b) {
|
|
585
|
+
const m = a2.length;
|
|
586
|
+
const n = b.length;
|
|
587
|
+
const row = Array.from({ length: n + 1 }, (_, i) => i);
|
|
588
|
+
for (let i = 1; i <= m; i++) {
|
|
589
|
+
let prev = row[0];
|
|
590
|
+
row[0] = i;
|
|
591
|
+
for (let j = 1; j <= n; j++) {
|
|
592
|
+
const temp = row[j];
|
|
593
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a2[i - 1] === b[j - 1] ? 0 : 1));
|
|
594
|
+
prev = temp;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
return row[n];
|
|
598
|
+
}
|
|
599
|
+
function suggestCategories(term, limit = 5) {
|
|
600
|
+
const query = normalize(term);
|
|
601
|
+
const scored = [];
|
|
602
|
+
for (const def of TERMS) {
|
|
603
|
+
const candidates = [def.term, ...def.synonyms ?? []].map(normalize);
|
|
604
|
+
let best = Infinity;
|
|
605
|
+
for (const candidate of candidates) {
|
|
606
|
+
if (candidate.includes(query) || query.includes(candidate)) best = Math.min(best, 0);
|
|
607
|
+
else best = Math.min(best, editDistance(query, candidate));
|
|
608
|
+
}
|
|
609
|
+
if (best <= 2) scored.push({ term: def.term, score: best });
|
|
610
|
+
}
|
|
611
|
+
scored.sort((x, y) => x.score - y.score || x.term.localeCompare(y.term));
|
|
612
|
+
return scored.slice(0, limit).map((s) => s.term);
|
|
613
|
+
}
|
|
614
|
+
function selectorToOverpassFilter(selector) {
|
|
615
|
+
if (selector.value === void 0) return `["${selector.key}"]`;
|
|
616
|
+
const op = selector.regex ? "~" : "=";
|
|
617
|
+
return `["${selector.key}"${op}"${selector.value}"]`;
|
|
618
|
+
}
|
|
619
|
+
function tagsMatchSelector(tags, selector) {
|
|
620
|
+
const value = tags[selector.key];
|
|
621
|
+
if (value === void 0) return false;
|
|
622
|
+
if (selector.value === void 0) return true;
|
|
623
|
+
return selector.regex ? new RegExp(selector.value).test(value) : value === selector.value;
|
|
624
|
+
}
|
|
625
|
+
function tagsMatchAnySelector(tags, selectors) {
|
|
626
|
+
return selectors.some((selector) => tagsMatchSelector(tags, selector));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// src/quality.ts
|
|
630
|
+
var BASE_EXPECTED = ["name"];
|
|
631
|
+
var EXPECTED_BY_CATEGORY = {
|
|
632
|
+
food: ["name", "opening_hours", "cuisine", "website", "phone", "wheelchair"],
|
|
633
|
+
grocery: ["name", "opening_hours", "website", "phone"],
|
|
634
|
+
shopping: ["name", "opening_hours", "website", "phone"],
|
|
635
|
+
healthcare: ["name", "opening_hours", "phone", "website", "wheelchair"],
|
|
636
|
+
education: ["name", "website", "phone"],
|
|
637
|
+
finance: ["name", "opening_hours", "operator"],
|
|
638
|
+
transport: ["name", "network", "operator"],
|
|
639
|
+
fuel: ["name", "opening_hours", "operator"],
|
|
640
|
+
parking: ["capacity", "fee", "access"],
|
|
641
|
+
accommodation: ["name", "website", "phone", "stars"],
|
|
642
|
+
leisure: ["name", "opening_hours"],
|
|
643
|
+
tourism: ["name", "website", "opening_hours"],
|
|
644
|
+
worship: ["name", "religion"],
|
|
645
|
+
public_service: ["name", "opening_hours", "phone"],
|
|
646
|
+
utility: ["fee", "access"],
|
|
647
|
+
other: ["name"]
|
|
648
|
+
};
|
|
649
|
+
function completenessOf(category, tags) {
|
|
650
|
+
const expected = EXPECTED_BY_CATEGORY[category] ?? BASE_EXPECTED;
|
|
651
|
+
if (expected.length === 0) return 1;
|
|
652
|
+
const present2 = expected.filter((key) => {
|
|
653
|
+
const value = tags[key];
|
|
654
|
+
return value !== void 0 && value.trim() !== "";
|
|
655
|
+
}).length;
|
|
656
|
+
return Math.round(present2 / expected.length * 100) / 100;
|
|
657
|
+
}
|
|
658
|
+
var DATE_TAGS = ["check_date", "check_date:opening_hours", "survey:date"];
|
|
659
|
+
var ISO_DATE = /^\d{4}-\d{2}-\d{2}/;
|
|
660
|
+
function lastVerifiedOf(tags, timestamp) {
|
|
661
|
+
for (const key of DATE_TAGS) {
|
|
662
|
+
const value = tags[key];
|
|
663
|
+
if (value && ISO_DATE.test(value)) return value.slice(0, 10);
|
|
664
|
+
}
|
|
665
|
+
if (timestamp && ISO_DATE.test(timestamp)) return timestamp.slice(0, 10);
|
|
666
|
+
return void 0;
|
|
667
|
+
}
|
|
668
|
+
var normalizeName = (poi) => (poi.name ?? "").trim().toLowerCase();
|
|
669
|
+
function isDuplicate(a2, b) {
|
|
670
|
+
if (a2.category !== b.category) return false;
|
|
671
|
+
const distance = haversineMeters(a2.location, b.location);
|
|
672
|
+
if (distance > 40) return false;
|
|
673
|
+
const an = normalizeName(a2);
|
|
674
|
+
const bn = normalizeName(b);
|
|
675
|
+
if (an && bn) return an === bn;
|
|
676
|
+
return distance <= 15 && a2.kind === b.kind;
|
|
677
|
+
}
|
|
678
|
+
function richness(poi) {
|
|
679
|
+
let score = 0;
|
|
680
|
+
if (poi.name) score += 100;
|
|
681
|
+
score += (poi.completeness ?? 0) * 10;
|
|
682
|
+
if (poi.id.startsWith("way/") || poi.id.startsWith("relation/")) score += 1;
|
|
683
|
+
return score;
|
|
684
|
+
}
|
|
685
|
+
function dedupePois(pois) {
|
|
686
|
+
const kept = [];
|
|
687
|
+
for (const poi of pois) {
|
|
688
|
+
const index = kept.findIndex((other) => isDuplicate(other, poi));
|
|
689
|
+
if (index === -1) kept.push(poi);
|
|
690
|
+
else if (richness(poi) > richness(kept[index])) kept[index] = poi;
|
|
691
|
+
}
|
|
692
|
+
return kept;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/hours.ts
|
|
696
|
+
function isOpenAt(openingHours, when) {
|
|
697
|
+
const raw = (openingHours ?? "").trim();
|
|
698
|
+
if (!raw) return { state: "unknown" };
|
|
699
|
+
const lower = raw.toLowerCase();
|
|
700
|
+
if (/^24\s*\/\s*7$/.test(lower) || lower === "24/7") return { state: "open" };
|
|
701
|
+
if (lower === "off" || lower === "closed") return { state: "closed" };
|
|
702
|
+
if (hasUnsupported(lower)) return { state: "unknown" };
|
|
703
|
+
const segments = buildSegments(lower);
|
|
704
|
+
if (!segments) return { state: "unknown" };
|
|
705
|
+
const day = when.getDay();
|
|
706
|
+
const minute = when.getHours() * 60 + when.getMinutes();
|
|
707
|
+
const open = isOpenInSegments(segments, day, minute);
|
|
708
|
+
const evaluation = { state: open ? "open" : "closed" };
|
|
709
|
+
const next = nextChange(segments, when, open);
|
|
710
|
+
if (next) evaluation.nextChange = next;
|
|
711
|
+
return evaluation;
|
|
712
|
+
}
|
|
713
|
+
var DAY_INDEX = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
|
|
714
|
+
var WEEK_ORDER = ["mo", "tu", "we", "th", "fr", "sa", "su"];
|
|
715
|
+
var MONTHS = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b/;
|
|
716
|
+
function hasUnsupported(s) {
|
|
717
|
+
return /sunrise|sunset|dawn|dusk|easter|\bweek\b/.test(s) || /\+/.test(s) || // open-ended times like 08:00+
|
|
718
|
+
/\b\d{4}\b/.test(s) || // explicit years
|
|
719
|
+
MONTHS.test(s);
|
|
720
|
+
}
|
|
721
|
+
function buildSegments(input) {
|
|
722
|
+
const own = new Array(7).fill(void 0);
|
|
723
|
+
for (const rulePart of input.split(";")) {
|
|
724
|
+
const rule = rulePart.trim();
|
|
725
|
+
if (!rule) continue;
|
|
726
|
+
const parsed = parseRule(rule.replace(/\s*([,:\-])\s*/g, "$1"));
|
|
727
|
+
if (parsed === "unknown") return null;
|
|
728
|
+
if (parsed === null) continue;
|
|
729
|
+
for (const day of parsed.days) own[day] = parsed.intervals;
|
|
730
|
+
}
|
|
731
|
+
const segments = Array.from({ length: 7 }, () => []);
|
|
732
|
+
for (let day = 0; day < 7; day++) {
|
|
733
|
+
const intervals = own[day];
|
|
734
|
+
if (!intervals) continue;
|
|
735
|
+
for (const [start, end] of intervals) {
|
|
736
|
+
if (start === end)
|
|
737
|
+
segments[day].push([0, 1440]);
|
|
738
|
+
else if (end > start) segments[day].push([start, end]);
|
|
739
|
+
else {
|
|
740
|
+
segments[day].push([start, 1440]);
|
|
741
|
+
segments[(day + 1) % 7].push([0, end]);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return segments;
|
|
746
|
+
}
|
|
747
|
+
function parseRule(rule) {
|
|
748
|
+
let dayPart;
|
|
749
|
+
let timePart;
|
|
750
|
+
const space = rule.indexOf(" ");
|
|
751
|
+
if (space === -1) {
|
|
752
|
+
if (/\d/.test(rule)) {
|
|
753
|
+
dayPart = "";
|
|
754
|
+
timePart = rule;
|
|
755
|
+
} else if (rule === "off" || rule === "closed") {
|
|
756
|
+
dayPart = "";
|
|
757
|
+
timePart = rule;
|
|
758
|
+
} else {
|
|
759
|
+
dayPart = rule;
|
|
760
|
+
timePart = "";
|
|
761
|
+
}
|
|
762
|
+
} else {
|
|
763
|
+
dayPart = rule.slice(0, space);
|
|
764
|
+
timePart = rule.slice(space + 1).trim();
|
|
765
|
+
}
|
|
766
|
+
const days = parseDays(dayPart);
|
|
767
|
+
if (days === "unknown") return "unknown";
|
|
768
|
+
if (days === null) return null;
|
|
769
|
+
if (timePart === "") return { days, intervals: [[0, 1440]] };
|
|
770
|
+
if (timePart === "off" || timePart === "closed") return { days, intervals: [] };
|
|
771
|
+
const intervals = parseTimes(timePart);
|
|
772
|
+
if (!intervals) return "unknown";
|
|
773
|
+
return { days, intervals };
|
|
774
|
+
}
|
|
775
|
+
function parseDays(part) {
|
|
776
|
+
if (part === "") return [0, 1, 2, 3, 4, 5, 6];
|
|
777
|
+
const days = /* @__PURE__ */ new Set();
|
|
778
|
+
let sawReal = false;
|
|
779
|
+
let sawHoliday = false;
|
|
780
|
+
for (const token of part.split(",")) {
|
|
781
|
+
if (token === "ph" || token === "sh") {
|
|
782
|
+
sawHoliday = true;
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const match = token.match(/^([a-z]{2})(?:-([a-z]{2}))?$/);
|
|
786
|
+
if (!match) return "unknown";
|
|
787
|
+
const from = match[1];
|
|
788
|
+
if (!(from in DAY_INDEX)) return "unknown";
|
|
789
|
+
if (match[2] === void 0) {
|
|
790
|
+
days.add(DAY_INDEX[from]);
|
|
791
|
+
sawReal = true;
|
|
792
|
+
} else {
|
|
793
|
+
const to = match[2];
|
|
794
|
+
if (!(to in DAY_INDEX)) return "unknown";
|
|
795
|
+
addWeekdayRange(days, from, to);
|
|
796
|
+
sawReal = true;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (!sawReal) return sawHoliday ? null : "unknown";
|
|
800
|
+
return [...days];
|
|
801
|
+
}
|
|
802
|
+
function addWeekdayRange(set, from, to) {
|
|
803
|
+
let i = WEEK_ORDER.indexOf(from);
|
|
804
|
+
const end = WEEK_ORDER.indexOf(to);
|
|
805
|
+
for (; ; ) {
|
|
806
|
+
set.add(DAY_INDEX[WEEK_ORDER[i]]);
|
|
807
|
+
if (i === end) break;
|
|
808
|
+
i = (i + 1) % 7;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
function parseTimes(part) {
|
|
812
|
+
const intervals = [];
|
|
813
|
+
for (const piece of part.split(",")) {
|
|
814
|
+
const match = piece.match(/^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/);
|
|
815
|
+
if (!match) return null;
|
|
816
|
+
const h1 = Number(match[1]);
|
|
817
|
+
const m1 = Number(match[2]);
|
|
818
|
+
const h2 = Number(match[3]);
|
|
819
|
+
const m2 = Number(match[4]);
|
|
820
|
+
if (h1 > 24 || h2 > 24 || m1 > 59 || m2 > 59) return null;
|
|
821
|
+
intervals.push([h1 * 60 + m1, h2 * 60 + m2]);
|
|
822
|
+
}
|
|
823
|
+
return intervals;
|
|
824
|
+
}
|
|
825
|
+
function isOpenInSegments(segments, day, minute) {
|
|
826
|
+
return segments[day].some(([start, end]) => minute >= start && minute < end);
|
|
827
|
+
}
|
|
828
|
+
function nextChange(segments, when, currentlyOpen) {
|
|
829
|
+
const nowMinute = when.getHours() * 60 + when.getMinutes();
|
|
830
|
+
const baseDay = when.getDay();
|
|
831
|
+
const candidates = [];
|
|
832
|
+
for (let offset = 0; offset <= 8; offset++) {
|
|
833
|
+
const day = (baseDay + offset) % 7;
|
|
834
|
+
for (const [start, end] of segments[day]) {
|
|
835
|
+
candidates.push(offset * 1440 + start, offset * 1440 + end);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
candidates.sort((a2, b) => a2 - b);
|
|
839
|
+
for (const candidate of candidates) {
|
|
840
|
+
if (candidate <= nowMinute) continue;
|
|
841
|
+
const day = (baseDay + Math.floor(candidate / 1440)) % 7;
|
|
842
|
+
const open = isOpenInSegments(segments, day, candidate % 1440);
|
|
843
|
+
if (open !== currentlyOpen) {
|
|
844
|
+
const midnight = new Date(when.getFullYear(), when.getMonth(), when.getDate(), 0, 0, 0, 0);
|
|
845
|
+
return new Date(midnight.getTime() + candidate * 6e4).toISOString();
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return void 0;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// src/http.ts
|
|
852
|
+
var DEFAULT_USER_AGENT = "proximap/0.1 (+https://github.com/AmeyaBorkar/proximap)";
|
|
853
|
+
var InMemoryCache = class {
|
|
854
|
+
store = /* @__PURE__ */ new Map();
|
|
855
|
+
get(key) {
|
|
856
|
+
return this.store.get(key);
|
|
857
|
+
}
|
|
858
|
+
set(key, value) {
|
|
859
|
+
this.store.set(key, value);
|
|
860
|
+
}
|
|
861
|
+
};
|
|
862
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
863
|
+
var RateLimiter = class {
|
|
864
|
+
constructor(minIntervalMs) {
|
|
865
|
+
this.minIntervalMs = minIntervalMs;
|
|
866
|
+
}
|
|
867
|
+
minIntervalMs;
|
|
868
|
+
last = 0;
|
|
869
|
+
chain = Promise.resolve();
|
|
870
|
+
acquire() {
|
|
871
|
+
this.chain = this.chain.then(async () => {
|
|
872
|
+
if (this.minIntervalMs <= 0) return;
|
|
873
|
+
const wait = this.last + this.minIntervalMs - Date.now();
|
|
874
|
+
if (wait > 0) await sleep(wait);
|
|
875
|
+
this.last = Date.now();
|
|
876
|
+
});
|
|
877
|
+
return this.chain;
|
|
878
|
+
}
|
|
879
|
+
};
|
|
880
|
+
var HttpError = class extends Error {
|
|
881
|
+
constructor(status, url, message) {
|
|
882
|
+
super(message);
|
|
883
|
+
this.status = status;
|
|
884
|
+
this.url = url;
|
|
885
|
+
this.name = "HttpError";
|
|
886
|
+
}
|
|
887
|
+
status;
|
|
888
|
+
url;
|
|
889
|
+
};
|
|
890
|
+
function isRetryable(error) {
|
|
891
|
+
if (error instanceof HttpError) {
|
|
892
|
+
return error.status === 0 || error.status === 429 || error.status >= 500;
|
|
893
|
+
}
|
|
894
|
+
if (error instanceof Error && error.name === "AbortError") return false;
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
async function fetchJsonOnce(url, options) {
|
|
898
|
+
const { method = "GET", headers = {}, body, signal, timeoutMs = 2e4 } = options;
|
|
899
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
900
|
+
const composite = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
901
|
+
let response;
|
|
902
|
+
try {
|
|
903
|
+
response = await fetch(url, {
|
|
904
|
+
method,
|
|
905
|
+
headers: { "User-Agent": DEFAULT_USER_AGENT, Accept: "application/json", ...headers },
|
|
906
|
+
...body === void 0 ? {} : { body },
|
|
907
|
+
signal: composite
|
|
908
|
+
});
|
|
909
|
+
} catch (error) {
|
|
910
|
+
if (timeout.aborted) throw new HttpError(0, url, `Request timed out after ${timeoutMs} ms`);
|
|
911
|
+
throw error;
|
|
912
|
+
}
|
|
913
|
+
if (!response.ok) {
|
|
914
|
+
const detail = await response.text().catch(() => "");
|
|
915
|
+
throw new HttpError(
|
|
916
|
+
response.status,
|
|
917
|
+
url,
|
|
918
|
+
`${response.status} ${response.statusText}: ${detail.slice(0, 200)}`.trim()
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
return await response.json();
|
|
922
|
+
}
|
|
923
|
+
async function requestJson(url, options = {}) {
|
|
924
|
+
const { method = "GET", body, retries = 0, retryDelayMs = 500, cache } = options;
|
|
925
|
+
const cacheKey = cache ? `${method} ${url} ${body ?? ""}` : void 0;
|
|
926
|
+
if (cache && cacheKey !== void 0) {
|
|
927
|
+
const cached = await cache.get(cacheKey);
|
|
928
|
+
if (cached !== void 0) return cached;
|
|
929
|
+
}
|
|
930
|
+
let lastError;
|
|
931
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
932
|
+
if (attempt > 0) await sleep(retryDelayMs * 2 ** (attempt - 1));
|
|
933
|
+
try {
|
|
934
|
+
const value = await fetchJsonOnce(url, options);
|
|
935
|
+
if (cache && cacheKey !== void 0) await cache.set(cacheKey, value);
|
|
936
|
+
return value;
|
|
937
|
+
} catch (error) {
|
|
938
|
+
lastError = error;
|
|
939
|
+
if (attempt === retries || !isRetryable(error)) throw error;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
throw lastError;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// src/providers/nominatim.ts
|
|
946
|
+
var NominatimGeocoder = class {
|
|
947
|
+
name = "nominatim";
|
|
948
|
+
endpoint;
|
|
949
|
+
userAgent;
|
|
950
|
+
timeoutMs;
|
|
951
|
+
retries;
|
|
952
|
+
cache;
|
|
953
|
+
limiter;
|
|
954
|
+
constructor(options = {}) {
|
|
955
|
+
this.endpoint = (options.endpoint ?? "https://nominatim.openstreetmap.org").replace(/\/+$/, "");
|
|
956
|
+
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
|
|
957
|
+
this.timeoutMs = options.timeoutMs;
|
|
958
|
+
this.retries = options.retries ?? 2;
|
|
959
|
+
this.cache = options.cache;
|
|
960
|
+
this.limiter = new RateLimiter(options.minIntervalMs ?? 1e3);
|
|
961
|
+
}
|
|
962
|
+
async geocode(query, options = {}) {
|
|
963
|
+
const params = new URLSearchParams({
|
|
964
|
+
q: query,
|
|
965
|
+
format: "jsonv2",
|
|
966
|
+
limit: String(options.limit ?? 5)
|
|
967
|
+
});
|
|
968
|
+
if (options.language) params.set("accept-language", options.language);
|
|
969
|
+
const results = await this.request(`/search?${params}`, options.signal);
|
|
970
|
+
return results.map((result) => this.toPlace(result));
|
|
971
|
+
}
|
|
972
|
+
async reverse(location, options = {}) {
|
|
973
|
+
const params = new URLSearchParams({
|
|
974
|
+
lat: String(location.lat),
|
|
975
|
+
lon: String(location.lng),
|
|
976
|
+
format: "jsonv2"
|
|
977
|
+
});
|
|
978
|
+
if (options.language) params.set("accept-language", options.language);
|
|
979
|
+
const result = await this.request(
|
|
980
|
+
`/reverse?${params}`,
|
|
981
|
+
options.signal
|
|
982
|
+
);
|
|
983
|
+
if (!result || "error" in result) return null;
|
|
984
|
+
return this.toPlace(result);
|
|
985
|
+
}
|
|
986
|
+
async request(path, signal) {
|
|
987
|
+
await this.limiter.acquire();
|
|
988
|
+
return requestJson(`${this.endpoint}${path}`, {
|
|
989
|
+
headers: { "User-Agent": this.userAgent },
|
|
990
|
+
retries: this.retries,
|
|
991
|
+
...this.cache ? { cache: this.cache } : {},
|
|
992
|
+
...signal ? { signal } : {},
|
|
993
|
+
...this.timeoutMs ? { timeoutMs: this.timeoutMs } : {}
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
toPlace(result) {
|
|
997
|
+
const fallbackName = result.display_name.split(",")[0]?.trim() ?? result.display_name;
|
|
998
|
+
const place = {
|
|
999
|
+
name: result.name && result.name.length > 0 ? result.name : fallbackName,
|
|
1000
|
+
displayName: result.display_name,
|
|
1001
|
+
location: { lat: Number(result.lat), lng: Number(result.lon) },
|
|
1002
|
+
source: this.name,
|
|
1003
|
+
raw: result
|
|
1004
|
+
};
|
|
1005
|
+
const kind = result.type ?? result.class;
|
|
1006
|
+
if (kind) place.kind = kind;
|
|
1007
|
+
if (result.boundingbox) {
|
|
1008
|
+
const [s, n, w, e] = result.boundingbox;
|
|
1009
|
+
place.boundingBox = [Number(s), Number(n), Number(w), Number(e)];
|
|
1010
|
+
}
|
|
1011
|
+
return place;
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
// src/providers/overpass.ts
|
|
1016
|
+
var SELECTORS = [
|
|
1017
|
+
'nwr["amenity"]',
|
|
1018
|
+
'nwr["shop"]',
|
|
1019
|
+
'nwr["tourism"]',
|
|
1020
|
+
'nwr["leisure"]',
|
|
1021
|
+
'nwr["healthcare"]',
|
|
1022
|
+
'nwr["office"="government"]',
|
|
1023
|
+
'nwr["railway"~"^(station|halt|tram_stop|subway_entrance|stop)$"]',
|
|
1024
|
+
'nwr["public_transport"="station"]',
|
|
1025
|
+
'node["highway"="bus_stop"]',
|
|
1026
|
+
'nwr["aeroway"="aerodrome"]'
|
|
1027
|
+
];
|
|
1028
|
+
function buildOverpassQuery(center, radiusMeters) {
|
|
1029
|
+
const around = `around:${Math.max(1, Math.round(radiusMeters))},${center.lat},${center.lng}`;
|
|
1030
|
+
const body = SELECTORS.map((selector) => ` ${selector}(${around});`).join("\n");
|
|
1031
|
+
return `[out:json][timeout:25];
|
|
1032
|
+
(
|
|
1033
|
+
${body}
|
|
1034
|
+
);
|
|
1035
|
+
out center tags meta;`;
|
|
1036
|
+
}
|
|
1037
|
+
function buildTargetedOverpassQuery(center, radiusMeters, selectors) {
|
|
1038
|
+
const around = `around:${Math.max(1, Math.round(radiusMeters))},${center.lat},${center.lng}`;
|
|
1039
|
+
const body = selectors.map((selector) => ` nwr${selectorToOverpassFilter(selector)}(${around});`).join("\n");
|
|
1040
|
+
return `[out:json][timeout:25];
|
|
1041
|
+
(
|
|
1042
|
+
${body}
|
|
1043
|
+
);
|
|
1044
|
+
out center tags meta;`;
|
|
1045
|
+
}
|
|
1046
|
+
function coordOf(lat, lon) {
|
|
1047
|
+
if (typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
1048
|
+
return { lat, lng: lon };
|
|
1049
|
+
}
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
var OverpassPlacesProvider = class {
|
|
1053
|
+
name = "overpass";
|
|
1054
|
+
endpoint;
|
|
1055
|
+
userAgent;
|
|
1056
|
+
timeoutMs;
|
|
1057
|
+
retries;
|
|
1058
|
+
cache;
|
|
1059
|
+
limiter;
|
|
1060
|
+
constructor(options = {}) {
|
|
1061
|
+
this.endpoint = options.endpoint ?? "https://overpass-api.de/api/interpreter";
|
|
1062
|
+
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
|
|
1063
|
+
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
1064
|
+
this.retries = options.retries ?? 2;
|
|
1065
|
+
this.cache = options.cache;
|
|
1066
|
+
this.limiter = new RateLimiter(options.minIntervalMs ?? 1e3);
|
|
1067
|
+
}
|
|
1068
|
+
async findNearby(center, options) {
|
|
1069
|
+
const query = options.selectors && options.selectors.length > 0 ? buildTargetedOverpassQuery(center, options.radiusMeters, options.selectors) : buildOverpassQuery(center, options.radiusMeters);
|
|
1070
|
+
await this.limiter.acquire();
|
|
1071
|
+
const data = await requestJson(this.endpoint, {
|
|
1072
|
+
method: "POST",
|
|
1073
|
+
headers: {
|
|
1074
|
+
"User-Agent": this.userAgent,
|
|
1075
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
1076
|
+
},
|
|
1077
|
+
body: `data=${encodeURIComponent(query)}`,
|
|
1078
|
+
timeoutMs: this.timeoutMs,
|
|
1079
|
+
retries: this.retries,
|
|
1080
|
+
...this.cache ? { cache: this.cache } : {},
|
|
1081
|
+
...options.signal ? { signal: options.signal } : {}
|
|
1082
|
+
});
|
|
1083
|
+
if (data.remark && /error|timed out|rate_limited|too many|memory/i.test(data.remark)) {
|
|
1084
|
+
throw new HttpError(0, this.endpoint, `Overpass: ${data.remark}`);
|
|
1085
|
+
}
|
|
1086
|
+
const wanted = options.categories ? new Set(options.categories) : null;
|
|
1087
|
+
const pois = [];
|
|
1088
|
+
for (const element of data.elements ?? []) {
|
|
1089
|
+
const poi = this.toPoi(element);
|
|
1090
|
+
if (!poi) continue;
|
|
1091
|
+
if (wanted && !wanted.has(poi.category)) continue;
|
|
1092
|
+
pois.push(poi);
|
|
1093
|
+
}
|
|
1094
|
+
return dedupePois(pois);
|
|
1095
|
+
}
|
|
1096
|
+
toPoi(element) {
|
|
1097
|
+
const tags = element.tags;
|
|
1098
|
+
if (!tags) return null;
|
|
1099
|
+
const coords = element.type === "node" ? coordOf(element.lat, element.lon) : element.center ? coordOf(element.center.lat, element.center.lon) : null;
|
|
1100
|
+
if (!coords) return null;
|
|
1101
|
+
const { category, kind } = categorize(tags);
|
|
1102
|
+
const poi = {
|
|
1103
|
+
id: `${element.type}/${element.id}`,
|
|
1104
|
+
category,
|
|
1105
|
+
location: coords,
|
|
1106
|
+
tags,
|
|
1107
|
+
source: this.name
|
|
1108
|
+
};
|
|
1109
|
+
if (tags.name) poi.name = tags.name;
|
|
1110
|
+
if (kind) poi.kind = kind;
|
|
1111
|
+
poi.completeness = completenessOf(category, tags);
|
|
1112
|
+
const lastVerified = lastVerifiedOf(tags, element.timestamp);
|
|
1113
|
+
if (lastVerified) poi.lastVerified = lastVerified;
|
|
1114
|
+
return poi;
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
// src/providers/valhalla.ts
|
|
1119
|
+
var COSTING = {
|
|
1120
|
+
walk: "pedestrian",
|
|
1121
|
+
bike: "bicycle",
|
|
1122
|
+
drive: "auto"
|
|
1123
|
+
};
|
|
1124
|
+
var ValhallaRoutingProvider = class {
|
|
1125
|
+
name = "valhalla";
|
|
1126
|
+
endpoint;
|
|
1127
|
+
userAgent;
|
|
1128
|
+
timeoutMs;
|
|
1129
|
+
retries;
|
|
1130
|
+
cache;
|
|
1131
|
+
limiter;
|
|
1132
|
+
constructor(options = {}) {
|
|
1133
|
+
this.endpoint = (options.endpoint ?? "https://valhalla1.openstreetmap.de").replace(/\/+$/, "");
|
|
1134
|
+
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
|
|
1135
|
+
this.timeoutMs = options.timeoutMs;
|
|
1136
|
+
this.retries = options.retries ?? 1;
|
|
1137
|
+
this.cache = options.cache;
|
|
1138
|
+
this.limiter = new RateLimiter(options.minIntervalMs ?? 1e3);
|
|
1139
|
+
}
|
|
1140
|
+
async matrix(origin, targets, mode, options = {}) {
|
|
1141
|
+
if (targets.length === 0) return [];
|
|
1142
|
+
const body = JSON.stringify({
|
|
1143
|
+
sources: [{ lat: origin.lat, lon: origin.lng }],
|
|
1144
|
+
targets: targets.map((target) => ({ lat: target.lat, lon: target.lng })),
|
|
1145
|
+
costing: COSTING[mode]
|
|
1146
|
+
});
|
|
1147
|
+
const data = await this.post("/sources_to_targets", body, options.signal);
|
|
1148
|
+
if (data.error) throw new HttpError(0, this.endpoint, `Valhalla: ${data.error}`);
|
|
1149
|
+
const row = data.sources_to_targets?.[0] ?? [];
|
|
1150
|
+
const metrics = targets.map(() => null);
|
|
1151
|
+
for (const cell of row) {
|
|
1152
|
+
if (cell.time === null || cell.distance === null) continue;
|
|
1153
|
+
if (cell.to_index < 0 || cell.to_index >= metrics.length) continue;
|
|
1154
|
+
metrics[cell.to_index] = {
|
|
1155
|
+
seconds: Math.round(cell.time),
|
|
1156
|
+
meters: Math.round(cell.distance * 1e3)
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
return metrics;
|
|
1160
|
+
}
|
|
1161
|
+
async isochrone(origin, minutes, mode, options = {}) {
|
|
1162
|
+
const body = JSON.stringify({
|
|
1163
|
+
locations: [{ lat: origin.lat, lon: origin.lng }],
|
|
1164
|
+
costing: COSTING[mode],
|
|
1165
|
+
contours: [{ time: minutes }],
|
|
1166
|
+
polygons: true
|
|
1167
|
+
});
|
|
1168
|
+
const data = await this.post("/isochrone", body, options.signal);
|
|
1169
|
+
if (data.error) throw new HttpError(0, this.endpoint, `Valhalla: ${data.error}`);
|
|
1170
|
+
const ring = extractRing(data.features ?? []);
|
|
1171
|
+
if (!ring) throw new HttpError(0, this.endpoint, "Valhalla: no isochrone polygon returned");
|
|
1172
|
+
return ring;
|
|
1173
|
+
}
|
|
1174
|
+
async post(path, body, signal) {
|
|
1175
|
+
await this.limiter.acquire();
|
|
1176
|
+
return requestJson(`${this.endpoint}${path}`, {
|
|
1177
|
+
method: "POST",
|
|
1178
|
+
headers: { "User-Agent": this.userAgent, "Content-Type": "application/json" },
|
|
1179
|
+
body,
|
|
1180
|
+
retries: this.retries,
|
|
1181
|
+
...this.cache ? { cache: this.cache } : {},
|
|
1182
|
+
...signal ? { signal } : {},
|
|
1183
|
+
...this.timeoutMs ? { timeoutMs: this.timeoutMs } : {}
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
function extractRing(features) {
|
|
1188
|
+
for (const feature of features) {
|
|
1189
|
+
const geometry = feature.geometry;
|
|
1190
|
+
if (!geometry) continue;
|
|
1191
|
+
const coords = geometry.coordinates;
|
|
1192
|
+
if (geometry.type === "LineString" && isRing(coords)) return coords;
|
|
1193
|
+
if (geometry.type === "Polygon" && Array.isArray(coords) && isRing(coords[0])) {
|
|
1194
|
+
return coords[0];
|
|
1195
|
+
}
|
|
1196
|
+
if (geometry.type === "MultiPolygon" && Array.isArray(coords) && Array.isArray(coords[0]) && isRing(coords[0][0])) {
|
|
1197
|
+
return coords[0][0];
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
return null;
|
|
1201
|
+
}
|
|
1202
|
+
function isRing(value) {
|
|
1203
|
+
return Array.isArray(value) && value.length >= 3 && value.every(
|
|
1204
|
+
(point) => Array.isArray(point) && point.length >= 2 && typeof point[0] === "number" && typeof point[1] === "number"
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// src/providers/osrm.ts
|
|
1209
|
+
var PROFILE = { walk: "foot", bike: "bike", drive: "driving" };
|
|
1210
|
+
var OsrmRoutingProvider = class {
|
|
1211
|
+
name = "osrm";
|
|
1212
|
+
endpoint;
|
|
1213
|
+
userAgent;
|
|
1214
|
+
timeoutMs;
|
|
1215
|
+
retries;
|
|
1216
|
+
cache;
|
|
1217
|
+
limiter;
|
|
1218
|
+
constructor(options = {}) {
|
|
1219
|
+
this.endpoint = (options.endpoint ?? "https://router.project-osrm.org").replace(/\/+$/, "");
|
|
1220
|
+
this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
|
|
1221
|
+
this.timeoutMs = options.timeoutMs;
|
|
1222
|
+
this.retries = options.retries ?? 1;
|
|
1223
|
+
this.cache = options.cache;
|
|
1224
|
+
this.limiter = new RateLimiter(options.minIntervalMs ?? 1e3);
|
|
1225
|
+
}
|
|
1226
|
+
async matrix(origin, targets, mode, options = {}) {
|
|
1227
|
+
if (targets.length === 0) return [];
|
|
1228
|
+
const coordinates = [origin, ...targets].map((c) => `${c.lng},${c.lat}`).join(";");
|
|
1229
|
+
const params = new URLSearchParams({ sources: "0", annotations: "duration,distance" });
|
|
1230
|
+
const url = `${this.endpoint}/table/v1/${PROFILE[mode]}/${coordinates}?${params}`;
|
|
1231
|
+
await this.limiter.acquire();
|
|
1232
|
+
const data = await requestJson(url, {
|
|
1233
|
+
headers: { "User-Agent": this.userAgent },
|
|
1234
|
+
retries: this.retries,
|
|
1235
|
+
...this.cache ? { cache: this.cache } : {},
|
|
1236
|
+
...options.signal ? { signal: options.signal } : {},
|
|
1237
|
+
...this.timeoutMs ? { timeoutMs: this.timeoutMs } : {}
|
|
1238
|
+
});
|
|
1239
|
+
const durations = data.durations?.[0] ?? [];
|
|
1240
|
+
const distances = data.distances?.[0] ?? [];
|
|
1241
|
+
return targets.map((_, index) => {
|
|
1242
|
+
const seconds = durations[index + 1];
|
|
1243
|
+
if (seconds === null || seconds === void 0) return null;
|
|
1244
|
+
const meters = distances[index + 1];
|
|
1245
|
+
return { seconds: Math.round(seconds), meters: meters == null ? 0 : Math.round(meters) };
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
// src/ranking.ts
|
|
1251
|
+
var clamp01 = (n) => Math.min(1, Math.max(0, n));
|
|
1252
|
+
function defaultScorer(weights) {
|
|
1253
|
+
return ({ poi, distanceMeters, radiusMeters }) => {
|
|
1254
|
+
const proximity = 1 - clamp01(distanceMeters / radiusMeters);
|
|
1255
|
+
const completeness = poi.name ? 0.05 : 0;
|
|
1256
|
+
const weight = weights?.[poi.category] ?? 1;
|
|
1257
|
+
return (proximity + completeness) * weight;
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
function rankByProximity(origin, pois, options = {}) {
|
|
1261
|
+
if (pois.length === 0) return [];
|
|
1262
|
+
const measured = pois.map((poi) => ({
|
|
1263
|
+
poi,
|
|
1264
|
+
distanceMeters: haversineMeters(origin, poi.location)
|
|
1265
|
+
}));
|
|
1266
|
+
const maxDistance = measured.reduce((max, m) => Math.max(max, m.distanceMeters), 0);
|
|
1267
|
+
const radiusMeters = options.radiusMeters ?? Math.max(maxDistance, 1);
|
|
1268
|
+
const score = options.scoreFn ?? defaultScorer(options.categoryWeights);
|
|
1269
|
+
const scored = measured.map(({ poi, distanceMeters }) => ({
|
|
1270
|
+
poi,
|
|
1271
|
+
distanceMeters,
|
|
1272
|
+
score: clamp01(score({ poi, distanceMeters, radiusMeters }))
|
|
1273
|
+
}));
|
|
1274
|
+
const byScore = Boolean(options.scoreFn || options.categoryWeights);
|
|
1275
|
+
scored.sort((a2, b) => {
|
|
1276
|
+
const primary = byScore ? b.score - a2.score || a2.distanceMeters - b.distanceMeters : a2.distanceMeters - b.distanceMeters;
|
|
1277
|
+
return primary || a2.poi.id.localeCompare(b.poi.id);
|
|
1278
|
+
});
|
|
1279
|
+
return scored.map((entry, index) => ({
|
|
1280
|
+
...entry.poi,
|
|
1281
|
+
distanceMeters: entry.distanceMeters,
|
|
1282
|
+
score: entry.score,
|
|
1283
|
+
rank: index + 1
|
|
1284
|
+
}));
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// src/routing.ts
|
|
1288
|
+
var MODE_SPEED_MPS = { walk: 1.4, bike: 4.2, drive: 11.1 };
|
|
1289
|
+
var HaversineRoutingProvider = class {
|
|
1290
|
+
name = "haversine";
|
|
1291
|
+
async matrix(origin, targets, mode) {
|
|
1292
|
+
const speed = MODE_SPEED_MPS[mode];
|
|
1293
|
+
return targets.map((target) => {
|
|
1294
|
+
const meters = haversineMeters(origin, target);
|
|
1295
|
+
return { meters: Math.round(meters), seconds: Math.round(meters / speed) };
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
async isochrone(origin, minutes, mode) {
|
|
1299
|
+
return circlePolygon(origin, MODE_SPEED_MPS[mode] * minutes * 60);
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
function circlePolygon(center, radiusMeters, steps = 48) {
|
|
1303
|
+
const latDelta = radiusMeters / 111320;
|
|
1304
|
+
const lngDelta = radiusMeters / (111320 * Math.cos(center.lat * Math.PI / 180));
|
|
1305
|
+
const ring = [];
|
|
1306
|
+
for (let i = 0; i <= steps; i++) {
|
|
1307
|
+
const angle = 2 * Math.PI * i / steps;
|
|
1308
|
+
ring.push([center.lng + lngDelta * Math.cos(angle), center.lat + latDelta * Math.sin(angle)]);
|
|
1309
|
+
}
|
|
1310
|
+
return ring;
|
|
1311
|
+
}
|
|
1312
|
+
function pointInPolygon(point, ring) {
|
|
1313
|
+
const x = point.lng;
|
|
1314
|
+
const y = point.lat;
|
|
1315
|
+
let inside = false;
|
|
1316
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
1317
|
+
const a2 = ring[i];
|
|
1318
|
+
const b = ring[j];
|
|
1319
|
+
const intersect = a2[1] > y !== b[1] > y && x < (b[0] - a2[0]) * (y - a2[1]) / (b[1] - a2[1]) + a2[0];
|
|
1320
|
+
if (intersect) inside = !inside;
|
|
1321
|
+
}
|
|
1322
|
+
return inside;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// src/filters.ts
|
|
1326
|
+
var toArray = (v) => v === void 0 ? [] : Array.isArray(v) ? v : [v];
|
|
1327
|
+
var YES_ONLY = /* @__PURE__ */ new Set(["yes", "only"]);
|
|
1328
|
+
var NEGATIVE = /* @__PURE__ */ new Set(["no", "false", "0", ""]);
|
|
1329
|
+
var isAffirmative = (value) => value !== void 0 && !NEGATIVE.has(value.trim().toLowerCase());
|
|
1330
|
+
var cuisineTokens = (tags) => (tags.cuisine ?? "").toLowerCase().split(";").map((s) => s.trim()).filter(Boolean);
|
|
1331
|
+
function compileFacets(filters) {
|
|
1332
|
+
const preds = [];
|
|
1333
|
+
for (const diet of toArray(filters.diet)) {
|
|
1334
|
+
const key = `diet:${diet.toLowerCase()}`;
|
|
1335
|
+
preds.push((t) => YES_ONLY.has((t[key] ?? "").toLowerCase()));
|
|
1336
|
+
}
|
|
1337
|
+
for (const cuisine of toArray(filters.cuisine)) {
|
|
1338
|
+
const want = cuisine.toLowerCase();
|
|
1339
|
+
preds.push((t) => cuisineTokens(t).includes(want));
|
|
1340
|
+
}
|
|
1341
|
+
for (const payment of toArray(filters.payment)) {
|
|
1342
|
+
const key = `payment:${payment.toLowerCase()}`;
|
|
1343
|
+
preds.push((t) => isAffirmative(t[key]));
|
|
1344
|
+
}
|
|
1345
|
+
if (filters.internetAccess) preds.push((t) => isAffirmative(t.internet_access));
|
|
1346
|
+
if (filters.outdoorSeating) preds.push((t) => isAffirmative(t.outdoor_seating));
|
|
1347
|
+
if (filters.takeaway) preds.push((t) => YES_ONLY.has((t.takeaway ?? "").toLowerCase()));
|
|
1348
|
+
if (filters.delivery) preds.push((t) => isAffirmative(t.delivery));
|
|
1349
|
+
const wheelchair = toArray(filters.wheelchair).map((v) => v.toLowerCase());
|
|
1350
|
+
if (wheelchair.length > 0) {
|
|
1351
|
+
preds.push((t) => wheelchair.includes((t.wheelchair ?? "").toLowerCase()));
|
|
1352
|
+
}
|
|
1353
|
+
for (const [key, value] of Object.entries(filters.tags ?? {})) {
|
|
1354
|
+
if (value === true) preds.push((t) => t[key] !== void 0);
|
|
1355
|
+
else if (value === false) preds.push((t) => t[key] === void 0);
|
|
1356
|
+
else {
|
|
1357
|
+
const want = String(value).toLowerCase();
|
|
1358
|
+
preds.push((t) => (t[key] ?? "").toLowerCase() === want);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return preds;
|
|
1362
|
+
}
|
|
1363
|
+
function matchesFacets(tags, predicates) {
|
|
1364
|
+
return predicates.every((predicate) => predicate(tags));
|
|
1365
|
+
}
|
|
1366
|
+
var clamp012 = (n) => Math.min(1, Math.max(0, n));
|
|
1367
|
+
function accessibleScorer() {
|
|
1368
|
+
return ({ poi, distanceMeters, radiusMeters }) => {
|
|
1369
|
+
const proximity = 1 - clamp012(distanceMeters / radiusMeters);
|
|
1370
|
+
const wheelchair = (poi.tags.wheelchair ?? "").toLowerCase();
|
|
1371
|
+
const tierBase = wheelchair === "yes" ? 0.66 : wheelchair === "limited" ? 0.33 : 0;
|
|
1372
|
+
return tierBase + proximity * 0.33;
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// src/origin.ts
|
|
1377
|
+
async function resolveOrigin(query, geocoder, options = {}) {
|
|
1378
|
+
if (typeof query !== "string") return originFromCoords(query, geocoder, options);
|
|
1379
|
+
const coords = parseCoordinates(query);
|
|
1380
|
+
if (coords) return originFromCoords(coords, geocoder, options);
|
|
1381
|
+
const matches = await geocoder.geocode(query, { limit: 1, ...geoOptions(options) });
|
|
1382
|
+
const first = matches[0];
|
|
1383
|
+
if (!first) throw new Error(`No location found for query: "${query}"`);
|
|
1384
|
+
return first;
|
|
1385
|
+
}
|
|
1386
|
+
async function originFromCoords(coords, geocoder, options) {
|
|
1387
|
+
const label = `${coords.lat}, ${coords.lng}`;
|
|
1388
|
+
const fallback = {
|
|
1389
|
+
name: label,
|
|
1390
|
+
displayName: label,
|
|
1391
|
+
location: coords,
|
|
1392
|
+
source: "coordinates"
|
|
1393
|
+
};
|
|
1394
|
+
if (geocoder.reverse) {
|
|
1395
|
+
try {
|
|
1396
|
+
const reversed = await geocoder.reverse(coords, geoOptions(options));
|
|
1397
|
+
if (reversed) return reversed;
|
|
1398
|
+
} catch {
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
return fallback;
|
|
1402
|
+
}
|
|
1403
|
+
function geoOptions(options) {
|
|
1404
|
+
const out = {};
|
|
1405
|
+
if (options.language) out.language = options.language;
|
|
1406
|
+
if (options.signal) out.signal = options.signal;
|
|
1407
|
+
return out;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// src/disambiguate.ts
|
|
1411
|
+
var DISTINCT_KM = 25;
|
|
1412
|
+
var RIVAL_IMPORTANCE_RATIO = 0.8;
|
|
1413
|
+
async function disambiguateLocation(query, options = {}) {
|
|
1414
|
+
const geocoder = options.geocoder ?? new NominatimGeocoder();
|
|
1415
|
+
const limit = options.limit ?? 5;
|
|
1416
|
+
const candidates = await geocoder.geocode(query, {
|
|
1417
|
+
limit,
|
|
1418
|
+
...options.language ? { language: options.language } : {},
|
|
1419
|
+
...options.signal ? { signal: options.signal } : {}
|
|
1420
|
+
});
|
|
1421
|
+
const best = candidates[0] ?? null;
|
|
1422
|
+
return { query, ambiguous: isAmbiguous(candidates), best, candidates };
|
|
1423
|
+
}
|
|
1424
|
+
var importanceOf = (place) => {
|
|
1425
|
+
const raw = place.raw;
|
|
1426
|
+
return typeof raw?.importance === "number" ? raw.importance : 0;
|
|
1427
|
+
};
|
|
1428
|
+
var shortName = (place) => (place.name || place.displayName.split(",")[0] || "").trim().toLowerCase();
|
|
1429
|
+
function isAmbiguous(candidates) {
|
|
1430
|
+
if (candidates.length < 2) return false;
|
|
1431
|
+
const best = candidates[0];
|
|
1432
|
+
const bestImportance = importanceOf(best);
|
|
1433
|
+
const bestName = shortName(best);
|
|
1434
|
+
return candidates.slice(1).some((rival) => {
|
|
1435
|
+
const farApart = haversineMeters(best.location, rival.location) > DISTINCT_KM * 1e3;
|
|
1436
|
+
if (!farApart) return false;
|
|
1437
|
+
const comparablyRelevant = bestImportance > 0 && importanceOf(rival) >= bestImportance * RIVAL_IMPORTANCE_RATIO;
|
|
1438
|
+
const sameName = bestName.length > 0 && shortName(rival) === bestName;
|
|
1439
|
+
return comparablyRelevant || sameName;
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// src/proximity.ts
|
|
1444
|
+
function nearestMatchingPoi(origin, pois, selectors) {
|
|
1445
|
+
let bestMeters = null;
|
|
1446
|
+
let bestPoi = null;
|
|
1447
|
+
for (const poi of pois) {
|
|
1448
|
+
if (!tagsMatchAnySelector(poi.tags, selectors)) continue;
|
|
1449
|
+
const meters = haversineMeters(origin, poi.location);
|
|
1450
|
+
if (bestMeters === null || meters < bestMeters) {
|
|
1451
|
+
bestMeters = meters;
|
|
1452
|
+
bestPoi = poi;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return { meters: bestMeters, poi: bestPoi };
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// src/nearby.ts
|
|
1459
|
+
var DEFAULT_RADIUS_M = 1e3;
|
|
1460
|
+
var DEFAULT_LIMIT = 30;
|
|
1461
|
+
async function findNearbyAmenities(query, options = {}) {
|
|
1462
|
+
const radiusMeters = options.radiusMeters ?? DEFAULT_RADIUS_M;
|
|
1463
|
+
const geocoder = options.geocoder ?? new NominatimGeocoder();
|
|
1464
|
+
const places = options.places ?? new OverpassPlacesProvider();
|
|
1465
|
+
const selectors = resolveSelectors(options.categories);
|
|
1466
|
+
const origin = await resolveOrigin(query, geocoder, {
|
|
1467
|
+
language: options.language,
|
|
1468
|
+
signal: options.signal
|
|
1469
|
+
});
|
|
1470
|
+
const nearbyOptions = { radiusMeters };
|
|
1471
|
+
if (selectors.length > 0) nearbyOptions.selectors = selectors;
|
|
1472
|
+
if (options.signal) nearbyOptions.signal = options.signal;
|
|
1473
|
+
const found = await places.findNearby(origin.location, nearbyOptions);
|
|
1474
|
+
let pois = selectors.length > 0 ? found.filter((poi) => tagsMatchAnySelector(poi.tags, selectors)) : found;
|
|
1475
|
+
if (options.filters) {
|
|
1476
|
+
const predicates = compileFacets(options.filters);
|
|
1477
|
+
if (predicates.length > 0) pois = pois.filter((poi) => matchesFacets(poi.tags, predicates));
|
|
1478
|
+
}
|
|
1479
|
+
let openEval = null;
|
|
1480
|
+
if (options.open) {
|
|
1481
|
+
const when = options.open === "now" ? /* @__PURE__ */ new Date() : new Date(options.open.at);
|
|
1482
|
+
openEval = /* @__PURE__ */ new Map();
|
|
1483
|
+
pois = pois.filter((poi) => {
|
|
1484
|
+
const evaluation = isOpenAt(poi.tags.opening_hours, when);
|
|
1485
|
+
openEval.set(poi.id, evaluation);
|
|
1486
|
+
return evaluation.state !== "closed";
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
let ranked;
|
|
1490
|
+
let routingInfo;
|
|
1491
|
+
if (options.rankBy === "travelTime") {
|
|
1492
|
+
const outcome = await rankByTravelTime(origin.location, pois, {
|
|
1493
|
+
mode: options.mode ?? "walk",
|
|
1494
|
+
routing: options.routing ?? new HaversineRoutingProvider(),
|
|
1495
|
+
...options.signal ? { signal: options.signal } : {}
|
|
1496
|
+
});
|
|
1497
|
+
ranked = outcome.ranked;
|
|
1498
|
+
routingInfo = outcome.info;
|
|
1499
|
+
} else {
|
|
1500
|
+
const rankOptions = { radiusMeters, ...options.rank };
|
|
1501
|
+
if (options.accessible && !rankOptions.scoreFn) rankOptions.scoreFn = accessibleScorer();
|
|
1502
|
+
ranked = rankByProximity(origin.location, pois, rankOptions);
|
|
1503
|
+
}
|
|
1504
|
+
if (openEval) {
|
|
1505
|
+
ranked = ranked.map((poi) => {
|
|
1506
|
+
const evaluation = openEval.get(poi.id);
|
|
1507
|
+
if (!evaluation) return poi;
|
|
1508
|
+
const annotated = { ...poi, openState: evaluation.state };
|
|
1509
|
+
if (evaluation.nextChange) annotated.nextChange = evaluation.nextChange;
|
|
1510
|
+
return annotated;
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
if (options.explain) {
|
|
1514
|
+
const mode = options.mode ?? "walk";
|
|
1515
|
+
ranked = ranked.map((poi) => ({ ...poi, rankingReason: rankingReasonFor(poi, mode) }));
|
|
1516
|
+
}
|
|
1517
|
+
const limit = options.limit ?? DEFAULT_LIMIT;
|
|
1518
|
+
const results = limit > 0 ? ranked.slice(0, limit) : ranked;
|
|
1519
|
+
return {
|
|
1520
|
+
origin,
|
|
1521
|
+
results,
|
|
1522
|
+
total: ranked.length,
|
|
1523
|
+
...routingInfo ? { routing: routingInfo } : {}
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
var MODE_ADVERB = {
|
|
1527
|
+
walk: "on foot",
|
|
1528
|
+
bike: "by bike",
|
|
1529
|
+
drive: "by car"
|
|
1530
|
+
};
|
|
1531
|
+
function ordinalCloseness(rank) {
|
|
1532
|
+
if (rank === 1) return "closest";
|
|
1533
|
+
const mod100 = rank % 100;
|
|
1534
|
+
const mod10 = rank % 10;
|
|
1535
|
+
const suffix = mod10 === 1 && mod100 !== 11 ? "st" : mod10 === 2 && mod100 !== 12 ? "nd" : mod10 === 3 && mod100 !== 13 ? "rd" : "th";
|
|
1536
|
+
return `${rank}${suffix}-closest`;
|
|
1537
|
+
}
|
|
1538
|
+
function rankingReasonFor(poi, mode) {
|
|
1539
|
+
const what = poi.kind ?? CATEGORY_LABELS[poi.category].toLowerCase();
|
|
1540
|
+
const openness = poi.openState === "open" ? "open " : "";
|
|
1541
|
+
if (poi.travelSeconds !== void 0) {
|
|
1542
|
+
return `${ordinalCloseness(poi.rank)} ${openness}${what} ${MODE_ADVERB[mode]}, ${formatDuration(poi.travelSeconds)}`;
|
|
1543
|
+
}
|
|
1544
|
+
return `${ordinalCloseness(poi.rank)} ${openness}${what}, ${formatDistance(poi.distanceMeters)}`;
|
|
1545
|
+
}
|
|
1546
|
+
var TRAVEL_MATRIX_CAP = 80;
|
|
1547
|
+
async function rankByTravelTime(origin, pois, options) {
|
|
1548
|
+
const candidates = [...pois].sort((a2, b) => haversineMeters(origin, a2.location) - haversineMeters(origin, b.location)).slice(0, TRAVEL_MATRIX_CAP);
|
|
1549
|
+
const points = candidates.map((poi) => poi.location);
|
|
1550
|
+
const requestOptions = options.signal ? { signal: options.signal } : {};
|
|
1551
|
+
let metrics;
|
|
1552
|
+
let provider = options.routing.name;
|
|
1553
|
+
let fellBack = false;
|
|
1554
|
+
try {
|
|
1555
|
+
metrics = await options.routing.matrix(origin, points, options.mode, requestOptions);
|
|
1556
|
+
} catch (error) {
|
|
1557
|
+
if (options.routing instanceof HaversineRoutingProvider) throw error;
|
|
1558
|
+
const fallback = new HaversineRoutingProvider();
|
|
1559
|
+
metrics = await fallback.matrix(origin, points, options.mode);
|
|
1560
|
+
provider = fallback.name;
|
|
1561
|
+
fellBack = true;
|
|
1562
|
+
}
|
|
1563
|
+
const reachable = candidates.map((poi, index) => ({ poi, metric: metrics[index] ?? null })).filter((entry) => entry.metric !== null).sort((a2, b) => a2.metric.seconds - b.metric.seconds);
|
|
1564
|
+
const slowest = reachable.reduce((max, entry) => Math.max(max, entry.metric.seconds), 0) || 1;
|
|
1565
|
+
const ranked = reachable.map((entry, index) => ({
|
|
1566
|
+
...entry.poi,
|
|
1567
|
+
distanceMeters: haversineMeters(origin, entry.poi.location),
|
|
1568
|
+
score: Math.round((1 - entry.metric.seconds / slowest) * 100) / 100,
|
|
1569
|
+
rank: index + 1,
|
|
1570
|
+
travelSeconds: entry.metric.seconds,
|
|
1571
|
+
travelMeters: entry.metric.meters
|
|
1572
|
+
}));
|
|
1573
|
+
return { ranked, info: { provider, mode: options.mode, fellBack } };
|
|
1574
|
+
}
|
|
1575
|
+
function resolveSelectors(categories) {
|
|
1576
|
+
if (!categories || categories.length === 0) return [];
|
|
1577
|
+
const { selectors, unknown } = resolveCategories(categories);
|
|
1578
|
+
if (unknown.length > 0) {
|
|
1579
|
+
const detail = unknown.map((term) => {
|
|
1580
|
+
const suggestions = suggestCategories(term);
|
|
1581
|
+
return suggestions.length > 0 ? `"${term}" (did you mean: ${suggestions.join(", ")}?)` : `"${term}"`;
|
|
1582
|
+
}).join("; ");
|
|
1583
|
+
throw new Error(`Unknown categor${unknown.length > 1 ? "ies" : "y"}: ${detail}`);
|
|
1584
|
+
}
|
|
1585
|
+
return selectors;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
// src/reachable.ts
|
|
1589
|
+
var MAX_FETCH_RADIUS_M = 15e3;
|
|
1590
|
+
var REACHABLE_MATRIX_CAP = 100;
|
|
1591
|
+
async function reachableAmenities(query, options) {
|
|
1592
|
+
const withinMinutes = options.within;
|
|
1593
|
+
if (!(withinMinutes > 0)) {
|
|
1594
|
+
throw new Error("reachableAmenities needs a positive `within` (minutes).");
|
|
1595
|
+
}
|
|
1596
|
+
const mode = options.mode ?? "walk";
|
|
1597
|
+
const routing = options.routing ?? new HaversineRoutingProvider();
|
|
1598
|
+
const geocoder = options.geocoder ?? new NominatimGeocoder();
|
|
1599
|
+
const places = options.places ?? new OverpassPlacesProvider();
|
|
1600
|
+
const budgetSeconds = withinMinutes * 60;
|
|
1601
|
+
const selectors = resolveSelectors2(options.categories);
|
|
1602
|
+
const origin = await resolveOrigin(query, geocoder, {
|
|
1603
|
+
language: options.language,
|
|
1604
|
+
signal: options.signal
|
|
1605
|
+
});
|
|
1606
|
+
const fetchRadius = Math.min(
|
|
1607
|
+
Math.round(MODE_SPEED_MPS[mode] * budgetSeconds),
|
|
1608
|
+
MAX_FETCH_RADIUS_M
|
|
1609
|
+
);
|
|
1610
|
+
const nearbyOptions = { radiusMeters: fetchRadius };
|
|
1611
|
+
if (selectors.length > 0) nearbyOptions.selectors = selectors;
|
|
1612
|
+
if (options.signal) nearbyOptions.signal = options.signal;
|
|
1613
|
+
const found = await places.findNearby(origin.location, nearbyOptions);
|
|
1614
|
+
const pois = selectors.length > 0 ? found.filter((poi) => tagsMatchAnySelector(poi.tags, selectors)) : found;
|
|
1615
|
+
const isochrone = await tryIsochrone(
|
|
1616
|
+
routing,
|
|
1617
|
+
origin.location,
|
|
1618
|
+
withinMinutes,
|
|
1619
|
+
mode,
|
|
1620
|
+
options.signal
|
|
1621
|
+
);
|
|
1622
|
+
const candidates = isochrone ? pois.filter((poi) => pointInPolygon(poi.location, isochrone)) : pois;
|
|
1623
|
+
const nearest = [...candidates].sort(
|
|
1624
|
+
(a2, b) => haversineMeters(origin.location, a2.location) - haversineMeters(origin.location, b.location)
|
|
1625
|
+
).slice(0, REACHABLE_MATRIX_CAP);
|
|
1626
|
+
const metrics = await safeMatrix(routing, origin.location, nearest, mode, options.signal);
|
|
1627
|
+
const members = nearest.map((poi, index) => ({ poi, metric: metrics[index] ?? null })).filter((entry) => isochrone !== null || withinBudget(entry.metric, budgetSeconds));
|
|
1628
|
+
members.sort(
|
|
1629
|
+
(a2, b) => (a2.metric?.seconds ?? Infinity) - (b.metric?.seconds ?? Infinity) || haversineMeters(origin.location, a2.poi.location) - haversineMeters(origin.location, b.poi.location)
|
|
1630
|
+
);
|
|
1631
|
+
const results = members.map((entry, index) => {
|
|
1632
|
+
const ranked = {
|
|
1633
|
+
...entry.poi,
|
|
1634
|
+
distanceMeters: haversineMeters(origin.location, entry.poi.location),
|
|
1635
|
+
score: entry.metric ? Math.round((1 - entry.metric.seconds / budgetSeconds) * 100) / 100 : 0,
|
|
1636
|
+
rank: index + 1
|
|
1637
|
+
};
|
|
1638
|
+
if (entry.metric) {
|
|
1639
|
+
ranked.travelSeconds = entry.metric.seconds;
|
|
1640
|
+
ranked.travelMeters = entry.metric.meters;
|
|
1641
|
+
}
|
|
1642
|
+
return ranked;
|
|
1643
|
+
});
|
|
1644
|
+
return { origin, withinMinutes, mode, isochrone, results, count: results.length };
|
|
1645
|
+
}
|
|
1646
|
+
function withinBudget(metric, budgetSeconds) {
|
|
1647
|
+
return metric !== null && metric.seconds <= budgetSeconds;
|
|
1648
|
+
}
|
|
1649
|
+
async function tryIsochrone(routing, origin, minutes, mode, signal) {
|
|
1650
|
+
if (!routing.isochrone) return null;
|
|
1651
|
+
try {
|
|
1652
|
+
return await routing.isochrone(origin, minutes, mode, signal ? { signal } : {});
|
|
1653
|
+
} catch {
|
|
1654
|
+
return null;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
async function safeMatrix(routing, origin, pois, mode, signal) {
|
|
1658
|
+
if (pois.length === 0) return [];
|
|
1659
|
+
const points = pois.map((poi) => poi.location);
|
|
1660
|
+
try {
|
|
1661
|
+
return await routing.matrix(origin, points, mode, signal ? { signal } : {});
|
|
1662
|
+
} catch {
|
|
1663
|
+
if (routing instanceof HaversineRoutingProvider) return points.map(() => null);
|
|
1664
|
+
return new HaversineRoutingProvider().matrix(origin, points, mode);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
function resolveSelectors2(categories) {
|
|
1668
|
+
if (!categories || categories.length === 0) return [];
|
|
1669
|
+
const { selectors, unknown } = resolveCategories(categories);
|
|
1670
|
+
if (unknown.length > 0) {
|
|
1671
|
+
const detail = unknown.map((term) => {
|
|
1672
|
+
const suggestions = suggestCategories(term);
|
|
1673
|
+
return suggestions.length > 0 ? `"${term}" (did you mean: ${suggestions.join(", ")}?)` : `"${term}"`;
|
|
1674
|
+
}).join("; ");
|
|
1675
|
+
throw new Error(`Unknown categor${unknown.length > 1 ? "ies" : "y"}: ${detail}`);
|
|
1676
|
+
}
|
|
1677
|
+
return selectors;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// src/gaps.ts
|
|
1681
|
+
var DEFAULT_DAILY_NEEDS = [
|
|
1682
|
+
"grocery",
|
|
1683
|
+
"pharmacy",
|
|
1684
|
+
"healthcare",
|
|
1685
|
+
"food",
|
|
1686
|
+
"finance",
|
|
1687
|
+
"transport",
|
|
1688
|
+
"education",
|
|
1689
|
+
"park"
|
|
1690
|
+
];
|
|
1691
|
+
var DEFAULT_SEARCH_RADIUS_M = 5e3;
|
|
1692
|
+
var DEFAULT_THRESHOLD_M = 1500;
|
|
1693
|
+
async function detectGaps(query, options = {}) {
|
|
1694
|
+
const terms = options.categories && options.categories.length > 0 ? options.categories : [...DEFAULT_DAILY_NEEDS];
|
|
1695
|
+
const searchRadiusMeters = options.searchRadiusMeters ?? DEFAULT_SEARCH_RADIUS_M;
|
|
1696
|
+
const thresholdMeters = options.thresholdMeters ?? DEFAULT_THRESHOLD_M;
|
|
1697
|
+
const geocoder = options.geocoder ?? new NominatimGeocoder();
|
|
1698
|
+
const places = options.places ?? new OverpassPlacesProvider();
|
|
1699
|
+
const resolved = resolveCategories(terms);
|
|
1700
|
+
if (resolved.unknown.length > 0) {
|
|
1701
|
+
const detail = resolved.unknown.map((term) => {
|
|
1702
|
+
const suggestions = suggestCategories(term);
|
|
1703
|
+
return suggestions.length > 0 ? `"${term}" (did you mean: ${suggestions.join(", ")}?)` : `"${term}"`;
|
|
1704
|
+
}).join("; ");
|
|
1705
|
+
throw new Error(`Unknown categor${resolved.unknown.length > 1 ? "ies" : "y"}: ${detail}`);
|
|
1706
|
+
}
|
|
1707
|
+
const origin = await resolveOrigin(query, geocoder, {
|
|
1708
|
+
language: options.language,
|
|
1709
|
+
signal: options.signal
|
|
1710
|
+
});
|
|
1711
|
+
const nearbyOptions = {
|
|
1712
|
+
radiusMeters: searchRadiusMeters,
|
|
1713
|
+
selectors: resolved.selectors
|
|
1714
|
+
};
|
|
1715
|
+
if (options.signal) nearbyOptions.signal = options.signal;
|
|
1716
|
+
const pois = await places.findNearby(origin.location, nearbyOptions);
|
|
1717
|
+
const gaps = terms.map((term) => {
|
|
1718
|
+
const selectors = resolveCategories([term]).selectors;
|
|
1719
|
+
const { meters, poi } = nearestMatchingPoi(origin.location, pois, selectors);
|
|
1720
|
+
const gap = {
|
|
1721
|
+
category: term,
|
|
1722
|
+
nearestMeters: meters === null ? null : Math.round(meters),
|
|
1723
|
+
isGap: meters === null || meters > thresholdMeters
|
|
1724
|
+
};
|
|
1725
|
+
if (poi?.completeness !== void 0) gap.nearestCompleteness = poi.completeness;
|
|
1726
|
+
return gap;
|
|
1727
|
+
});
|
|
1728
|
+
return {
|
|
1729
|
+
origin,
|
|
1730
|
+
searchRadiusMeters,
|
|
1731
|
+
thresholdMeters,
|
|
1732
|
+
gaps,
|
|
1733
|
+
missing: gaps.filter((gap) => gap.isGap).map((gap) => gap.category)
|
|
1734
|
+
};
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// src/walkability.ts
|
|
1738
|
+
var DEFAULT_WALK_CATEGORIES = [
|
|
1739
|
+
{ term: "grocery", weight: 3 },
|
|
1740
|
+
{ term: "food", weight: 2 },
|
|
1741
|
+
{ term: "pharmacy", weight: 2 },
|
|
1742
|
+
{ term: "transport", weight: 2 },
|
|
1743
|
+
{ term: "education", weight: 1 },
|
|
1744
|
+
{ term: "healthcare", weight: 1 },
|
|
1745
|
+
{ term: "finance", weight: 1 },
|
|
1746
|
+
{ term: "park", weight: 1 },
|
|
1747
|
+
{ term: "shopping", weight: 1 }
|
|
1748
|
+
];
|
|
1749
|
+
var DEFAULT_IDEAL_M = 400;
|
|
1750
|
+
var DEFAULT_MAX_M = 2400;
|
|
1751
|
+
function walkSubScore(meters, idealMeters, maxMeters) {
|
|
1752
|
+
if (meters === null) return 0;
|
|
1753
|
+
if (meters <= idealMeters) return 1;
|
|
1754
|
+
if (meters >= maxMeters) return 0;
|
|
1755
|
+
return round2((maxMeters - meters) / (maxMeters - idealMeters));
|
|
1756
|
+
}
|
|
1757
|
+
var round2 = (n) => Math.round(n * 100) / 100;
|
|
1758
|
+
var mean = (xs) => xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : 0;
|
|
1759
|
+
async function walkabilityScore(query, options = {}) {
|
|
1760
|
+
const categories = options.categories && options.categories.length > 0 ? options.categories : DEFAULT_WALK_CATEGORIES;
|
|
1761
|
+
const idealMeters = options.decay?.idealMeters ?? DEFAULT_IDEAL_M;
|
|
1762
|
+
const maxMeters = options.decay?.maxMeters ?? DEFAULT_MAX_M;
|
|
1763
|
+
const searchRadiusMeters = options.searchRadiusMeters ?? maxMeters;
|
|
1764
|
+
const geocoder = options.geocoder ?? new NominatimGeocoder();
|
|
1765
|
+
const places = options.places ?? new OverpassPlacesProvider();
|
|
1766
|
+
const terms = categories.map((c) => c.term);
|
|
1767
|
+
const resolved = resolveCategories(terms);
|
|
1768
|
+
if (resolved.unknown.length > 0) {
|
|
1769
|
+
const detail = resolved.unknown.map((term) => {
|
|
1770
|
+
const suggestions = suggestCategories(term);
|
|
1771
|
+
return suggestions.length > 0 ? `"${term}" (did you mean: ${suggestions.join(", ")}?)` : `"${term}"`;
|
|
1772
|
+
}).join("; ");
|
|
1773
|
+
throw new Error(`Unknown categor${resolved.unknown.length > 1 ? "ies" : "y"}: ${detail}`);
|
|
1774
|
+
}
|
|
1775
|
+
const origin = await resolveOrigin(query, geocoder, {
|
|
1776
|
+
language: options.language,
|
|
1777
|
+
signal: options.signal
|
|
1778
|
+
});
|
|
1779
|
+
const nearbyOptions = {
|
|
1780
|
+
radiusMeters: searchRadiusMeters,
|
|
1781
|
+
selectors: resolved.selectors
|
|
1782
|
+
};
|
|
1783
|
+
if (options.signal) nearbyOptions.signal = options.signal;
|
|
1784
|
+
const pois = await places.findNearby(origin.location, nearbyOptions);
|
|
1785
|
+
const breakdown = categories.map(({ term, weight }) => {
|
|
1786
|
+
const selectors = resolveCategories([term]).selectors;
|
|
1787
|
+
const { meters, poi } = nearestMatchingPoi(origin.location, pois, selectors);
|
|
1788
|
+
const nearestMeters = meters === null ? null : Math.round(meters);
|
|
1789
|
+
const entry = {
|
|
1790
|
+
category: term,
|
|
1791
|
+
weight,
|
|
1792
|
+
nearestMeters,
|
|
1793
|
+
subScore: walkSubScore(meters, idealMeters, maxMeters)
|
|
1794
|
+
};
|
|
1795
|
+
if (poi?.completeness !== void 0) entry.nearestCompleteness = poi.completeness;
|
|
1796
|
+
return entry;
|
|
1797
|
+
});
|
|
1798
|
+
const totalWeight = categories.reduce((sum, c) => sum + c.weight, 0) || 1;
|
|
1799
|
+
const weighted = breakdown.reduce((sum, b) => sum + b.weight * b.subScore, 0);
|
|
1800
|
+
const score = Math.round(100 * weighted / totalWeight);
|
|
1801
|
+
const found = breakdown.filter((b) => b.nearestMeters !== null);
|
|
1802
|
+
const coverage = breakdown.length ? found.length / breakdown.length : 0;
|
|
1803
|
+
const avgCompleteness = mean(found.map((b) => b.nearestCompleteness ?? 0));
|
|
1804
|
+
const confidence = round2(0.7 * coverage + 0.3 * avgCompleteness);
|
|
1805
|
+
return {
|
|
1806
|
+
origin,
|
|
1807
|
+
score,
|
|
1808
|
+
confidence,
|
|
1809
|
+
breakdown,
|
|
1810
|
+
missing: breakdown.filter((b) => b.nearestMeters === null).map((b) => b.category),
|
|
1811
|
+
decay: { idealMeters, maxMeters }
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
// src/compare.ts
|
|
1816
|
+
async function compareLocations(queries, options = {}) {
|
|
1817
|
+
if (queries.length < 2) {
|
|
1818
|
+
throw new Error("compareLocations needs at least two locations to compare.");
|
|
1819
|
+
}
|
|
1820
|
+
const categories = options.categories && options.categories.length > 0 ? options.categories : DEFAULT_WALK_CATEGORIES;
|
|
1821
|
+
const geocoder = options.geocoder ?? new NominatimGeocoder();
|
|
1822
|
+
const places = options.places ?? new OverpassPlacesProvider();
|
|
1823
|
+
const locations = [];
|
|
1824
|
+
for (const query of queries) {
|
|
1825
|
+
const walk = await walkabilityScore(query, {
|
|
1826
|
+
categories,
|
|
1827
|
+
geocoder,
|
|
1828
|
+
places,
|
|
1829
|
+
...options.decay ? { decay: options.decay } : {},
|
|
1830
|
+
...options.searchRadiusMeters ? { searchRadiusMeters: options.searchRadiusMeters } : {},
|
|
1831
|
+
...options.language ? { language: options.language } : {},
|
|
1832
|
+
...options.signal ? { signal: options.signal } : {}
|
|
1833
|
+
});
|
|
1834
|
+
locations.push({
|
|
1835
|
+
origin: walk.origin,
|
|
1836
|
+
score: walk.score,
|
|
1837
|
+
confidence: walk.confidence,
|
|
1838
|
+
breakdown: walk.breakdown,
|
|
1839
|
+
missing: walk.missing
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
const ranked = locations.map((location, index) => ({ index, location })).sort(
|
|
1843
|
+
(a2, b) => b.location.score - a2.location.score || b.location.confidence - a2.location.confidence || a2.index - b.index
|
|
1844
|
+
).map(({ index, location }) => ({ index, score: location.score, origin: location.origin }));
|
|
1845
|
+
const dimensions = categories.map(({ term, weight }) => ({
|
|
1846
|
+
category: term,
|
|
1847
|
+
weight,
|
|
1848
|
+
bestIndex: bestForDimension(locations, term)
|
|
1849
|
+
}));
|
|
1850
|
+
return {
|
|
1851
|
+
locations,
|
|
1852
|
+
ranked,
|
|
1853
|
+
best: ranked[0] ?? null,
|
|
1854
|
+
dimensions,
|
|
1855
|
+
weights: categories
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
function bestForDimension(locations, term) {
|
|
1859
|
+
let bestIndex = null;
|
|
1860
|
+
let bestSub = -1;
|
|
1861
|
+
let bestMeters = Infinity;
|
|
1862
|
+
locations.forEach((location, index) => {
|
|
1863
|
+
const entry = location.breakdown.find((b) => b.category === term);
|
|
1864
|
+
if (!entry || entry.nearestMeters === null) return;
|
|
1865
|
+
if (entry.subScore > bestSub || entry.subScore === bestSub && entry.nearestMeters < bestMeters) {
|
|
1866
|
+
bestSub = entry.subScore;
|
|
1867
|
+
bestMeters = entry.nearestMeters;
|
|
1868
|
+
bestIndex = index;
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
return bestIndex;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// src/errands.ts
|
|
1875
|
+
var DEFAULT_CANDIDATES = 5;
|
|
1876
|
+
var DEFAULT_SEARCH_RADIUS_M2 = 3e3;
|
|
1877
|
+
var MAX_CATEGORIES = 12;
|
|
1878
|
+
async function planErrands(query, options) {
|
|
1879
|
+
const terms = options.categories ?? [];
|
|
1880
|
+
if (terms.length === 0) throw new Error("planErrands needs at least one category.");
|
|
1881
|
+
if (terms.length > MAX_CATEGORIES) {
|
|
1882
|
+
throw new Error(
|
|
1883
|
+
`planErrands supports up to ${MAX_CATEGORIES} categories (got ${terms.length}).`
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
const mode = options.mode ?? "walk";
|
|
1887
|
+
const perCategory = options.candidatesPerCategory ?? DEFAULT_CANDIDATES;
|
|
1888
|
+
const searchRadiusMeters = options.searchRadiusMeters ?? DEFAULT_SEARCH_RADIUS_M2;
|
|
1889
|
+
const routing = options.routing ?? new HaversineRoutingProvider();
|
|
1890
|
+
const geocoder = options.geocoder ?? new NominatimGeocoder();
|
|
1891
|
+
const places = options.places ?? new OverpassPlacesProvider();
|
|
1892
|
+
const resolved = resolveCategories(terms);
|
|
1893
|
+
if (resolved.unknown.length > 0) {
|
|
1894
|
+
const detail = resolved.unknown.map((term) => {
|
|
1895
|
+
const suggestions = suggestCategories(term);
|
|
1896
|
+
return suggestions.length > 0 ? `"${term}" (did you mean: ${suggestions.join(", ")}?)` : `"${term}"`;
|
|
1897
|
+
}).join("; ");
|
|
1898
|
+
throw new Error(`Unknown categor${resolved.unknown.length > 1 ? "ies" : "y"}: ${detail}`);
|
|
1899
|
+
}
|
|
1900
|
+
const origin = await resolveOrigin(query, geocoder, {
|
|
1901
|
+
language: options.language,
|
|
1902
|
+
signal: options.signal
|
|
1903
|
+
});
|
|
1904
|
+
const end = options.end !== void 0 ? await resolveEnd(options.end, geocoder, options) : null;
|
|
1905
|
+
const nearbyOptions = {
|
|
1906
|
+
radiusMeters: searchRadiusMeters,
|
|
1907
|
+
selectors: resolved.selectors
|
|
1908
|
+
};
|
|
1909
|
+
if (options.signal) nearbyOptions.signal = options.signal;
|
|
1910
|
+
const pois = await places.findNearby(origin.location, nearbyOptions);
|
|
1911
|
+
const missing = [];
|
|
1912
|
+
const groups = [];
|
|
1913
|
+
for (const term of terms) {
|
|
1914
|
+
const selectors = resolveCategories([term]).selectors;
|
|
1915
|
+
const candidates = pois.filter((poi) => tagsMatchAnySelector(poi.tags, selectors)).sort(
|
|
1916
|
+
(a2, b) => haversineMeters(origin.location, a2.location) - haversineMeters(origin.location, b.location)
|
|
1917
|
+
).slice(0, perCategory);
|
|
1918
|
+
if (candidates.length === 0) missing.push(String(term));
|
|
1919
|
+
else groups.push({ term: String(term), candidates });
|
|
1920
|
+
}
|
|
1921
|
+
if (groups.length === 0) {
|
|
1922
|
+
return {
|
|
1923
|
+
origin,
|
|
1924
|
+
end,
|
|
1925
|
+
mode,
|
|
1926
|
+
stops: [],
|
|
1927
|
+
totalSeconds: 0,
|
|
1928
|
+
totalMeters: 0,
|
|
1929
|
+
missing,
|
|
1930
|
+
candidatesPerCategory: perCategory
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
const solution = await solveGeneralizedTsp(
|
|
1934
|
+
origin.location,
|
|
1935
|
+
groups,
|
|
1936
|
+
end?.location ?? null,
|
|
1937
|
+
mode,
|
|
1938
|
+
routing
|
|
1939
|
+
);
|
|
1940
|
+
return {
|
|
1941
|
+
origin,
|
|
1942
|
+
end,
|
|
1943
|
+
mode,
|
|
1944
|
+
stops: solution.stops,
|
|
1945
|
+
totalSeconds: solution.totalSeconds,
|
|
1946
|
+
totalMeters: solution.totalMeters,
|
|
1947
|
+
missing,
|
|
1948
|
+
candidatesPerCategory: perCategory
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
async function solveGeneralizedTsp(origin, groups, end, mode, routing) {
|
|
1952
|
+
const nodes = [];
|
|
1953
|
+
groups.forEach((group, groupIndex) => {
|
|
1954
|
+
for (const poi of group.candidates) nodes.push({ group: groupIndex, term: group.term, poi });
|
|
1955
|
+
});
|
|
1956
|
+
const points = [origin, ...nodes.map((node2) => node2.poi.location)];
|
|
1957
|
+
const endPointIndex = end ? points.push(end) - 1 : -1;
|
|
1958
|
+
const matrix = await buildCostMatrix(points, mode, routing);
|
|
1959
|
+
const seconds = (from, to) => matrix[from][to].seconds;
|
|
1960
|
+
const groupCount = groups.length;
|
|
1961
|
+
const nodeCount = nodes.length;
|
|
1962
|
+
const full = (1 << groupCount) - 1;
|
|
1963
|
+
const cost = Array.from(
|
|
1964
|
+
{ length: 1 << groupCount },
|
|
1965
|
+
() => new Array(nodeCount).fill(Infinity)
|
|
1966
|
+
);
|
|
1967
|
+
const parent = Array.from(
|
|
1968
|
+
{ length: 1 << groupCount },
|
|
1969
|
+
() => new Array(nodeCount).fill(-1)
|
|
1970
|
+
);
|
|
1971
|
+
for (let n = 0; n < nodeCount; n++) {
|
|
1972
|
+
cost[1 << nodes[n].group][n] = seconds(0, n + 1);
|
|
1973
|
+
}
|
|
1974
|
+
for (let mask2 = 1; mask2 <= full; mask2++) {
|
|
1975
|
+
const row = cost[mask2];
|
|
1976
|
+
for (let n = 0; n < nodeCount; n++) {
|
|
1977
|
+
const here = row[n];
|
|
1978
|
+
if (here === Infinity || !(mask2 & 1 << nodes[n].group)) continue;
|
|
1979
|
+
for (let m = 0; m < nodeCount; m++) {
|
|
1980
|
+
const groupBit = 1 << nodes[m].group;
|
|
1981
|
+
if (mask2 & groupBit) continue;
|
|
1982
|
+
const nextMask = mask2 | groupBit;
|
|
1983
|
+
const candidate = here + seconds(n + 1, m + 1);
|
|
1984
|
+
if (candidate < cost[nextMask][m]) {
|
|
1985
|
+
cost[nextMask][m] = candidate;
|
|
1986
|
+
parent[nextMask][m] = n;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
let best = Infinity;
|
|
1992
|
+
let lastNode = -1;
|
|
1993
|
+
for (let n = 0; n < nodeCount; n++) {
|
|
1994
|
+
let total = cost[full][n];
|
|
1995
|
+
if (total === Infinity) continue;
|
|
1996
|
+
if (endPointIndex >= 0) total += seconds(n + 1, endPointIndex);
|
|
1997
|
+
if (total < best) {
|
|
1998
|
+
best = total;
|
|
1999
|
+
lastNode = n;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
const order = [];
|
|
2003
|
+
let mask = full;
|
|
2004
|
+
let node = lastNode;
|
|
2005
|
+
while (node !== -1) {
|
|
2006
|
+
order.push(node);
|
|
2007
|
+
const previous = parent[mask][node];
|
|
2008
|
+
mask &= ~(1 << nodes[node].group);
|
|
2009
|
+
node = previous;
|
|
2010
|
+
}
|
|
2011
|
+
order.reverse();
|
|
2012
|
+
const stops = [];
|
|
2013
|
+
let previousPoint = 0;
|
|
2014
|
+
let totalMeters = 0;
|
|
2015
|
+
for (const n of order) {
|
|
2016
|
+
const leg = matrix[previousPoint][n + 1];
|
|
2017
|
+
totalMeters += leg.meters;
|
|
2018
|
+
stops.push({
|
|
2019
|
+
category: nodes[n].term,
|
|
2020
|
+
poi: nodes[n].poi,
|
|
2021
|
+
legSeconds: leg.seconds,
|
|
2022
|
+
legMeters: leg.meters
|
|
2023
|
+
});
|
|
2024
|
+
previousPoint = n + 1;
|
|
2025
|
+
}
|
|
2026
|
+
if (endPointIndex >= 0) totalMeters += matrix[previousPoint][endPointIndex].meters;
|
|
2027
|
+
return { stops, totalSeconds: best === Infinity ? 0 : best, totalMeters };
|
|
2028
|
+
}
|
|
2029
|
+
async function buildCostMatrix(points, mode, routing) {
|
|
2030
|
+
const rows = [];
|
|
2031
|
+
for (const source of points) {
|
|
2032
|
+
const row = await routing.matrix(source, points, mode);
|
|
2033
|
+
rows.push(row.map((metric) => metric ?? { seconds: Infinity, meters: Infinity }));
|
|
2034
|
+
}
|
|
2035
|
+
return rows;
|
|
2036
|
+
}
|
|
2037
|
+
async function resolveEnd(end, geocoder, options) {
|
|
2038
|
+
return resolveOrigin(end, geocoder, {
|
|
2039
|
+
language: options.language,
|
|
2040
|
+
signal: options.signal
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// src/export.ts
|
|
2045
|
+
var ODBL_ATTRIBUTION = "\xA9 OpenStreetMap contributors, ODbL (https://www.openstreetmap.org/copyright)";
|
|
2046
|
+
function toGeoJSON(result) {
|
|
2047
|
+
return {
|
|
2048
|
+
type: "FeatureCollection",
|
|
2049
|
+
attribution: ODBL_ATTRIBUTION,
|
|
2050
|
+
features: result.results.map(poiToFeature)
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
function poiToFeature(poi) {
|
|
2054
|
+
const properties = {
|
|
2055
|
+
rank: poi.rank,
|
|
2056
|
+
name: poi.name ?? null,
|
|
2057
|
+
category: poi.category,
|
|
2058
|
+
kind: poi.kind ?? null,
|
|
2059
|
+
distanceMeters: Math.round(poi.distanceMeters),
|
|
2060
|
+
osmId: poi.id
|
|
2061
|
+
};
|
|
2062
|
+
if (poi.completeness !== void 0) properties.completeness = poi.completeness;
|
|
2063
|
+
if (poi.lastVerified) properties.lastVerified = poi.lastVerified;
|
|
2064
|
+
if (poi.openState) properties.openState = poi.openState;
|
|
2065
|
+
if (poi.nextChange) properties.nextChange = poi.nextChange;
|
|
2066
|
+
return {
|
|
2067
|
+
type: "Feature",
|
|
2068
|
+
geometry: { type: "Point", coordinates: [poi.location.lng, poi.location.lat] },
|
|
2069
|
+
properties
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
var CSV_COLUMNS = [
|
|
2073
|
+
"rank",
|
|
2074
|
+
"name",
|
|
2075
|
+
"category",
|
|
2076
|
+
"kind",
|
|
2077
|
+
"distance_m",
|
|
2078
|
+
"lat",
|
|
2079
|
+
"lng",
|
|
2080
|
+
"osm_id",
|
|
2081
|
+
"completeness",
|
|
2082
|
+
"last_verified",
|
|
2083
|
+
"open_state"
|
|
2084
|
+
];
|
|
2085
|
+
function toCSV(result) {
|
|
2086
|
+
const rows = [CSV_COLUMNS.join(",")];
|
|
2087
|
+
for (const poi of result.results) {
|
|
2088
|
+
rows.push(
|
|
2089
|
+
[
|
|
2090
|
+
poi.rank,
|
|
2091
|
+
csvField(poi.name ?? ""),
|
|
2092
|
+
poi.category,
|
|
2093
|
+
csvField(poi.kind ?? ""),
|
|
2094
|
+
Math.round(poi.distanceMeters),
|
|
2095
|
+
poi.location.lat,
|
|
2096
|
+
poi.location.lng,
|
|
2097
|
+
poi.id,
|
|
2098
|
+
poi.completeness ?? "",
|
|
2099
|
+
poi.lastVerified ?? "",
|
|
2100
|
+
poi.openState ?? ""
|
|
2101
|
+
].join(",")
|
|
2102
|
+
);
|
|
2103
|
+
}
|
|
2104
|
+
return rows.join("\n");
|
|
2105
|
+
}
|
|
2106
|
+
function csvField(value) {
|
|
2107
|
+
return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// src/snapshot.ts
|
|
2111
|
+
var DEFAULT_SNAPSHOT_RADIUS_M = 2e3;
|
|
2112
|
+
async function snapshotArea(query, options = {}) {
|
|
2113
|
+
const radiusMeters = options.radiusMeters ?? DEFAULT_SNAPSHOT_RADIUS_M;
|
|
2114
|
+
const geocoder = options.geocoder ?? new NominatimGeocoder();
|
|
2115
|
+
const places = options.places ?? new OverpassPlacesProvider();
|
|
2116
|
+
let selectors;
|
|
2117
|
+
if (options.categories && options.categories.length > 0) {
|
|
2118
|
+
const resolved = resolveCategories(options.categories);
|
|
2119
|
+
if (resolved.unknown.length > 0) {
|
|
2120
|
+
const detail = resolved.unknown.map((term) => {
|
|
2121
|
+
const suggestions = suggestCategories(term);
|
|
2122
|
+
return suggestions.length > 0 ? `"${term}" (did you mean: ${suggestions.join(", ")}?)` : `"${term}"`;
|
|
2123
|
+
}).join("; ");
|
|
2124
|
+
throw new Error(`Unknown categor${resolved.unknown.length > 1 ? "ies" : "y"}: ${detail}`);
|
|
2125
|
+
}
|
|
2126
|
+
selectors = resolved.selectors;
|
|
2127
|
+
}
|
|
2128
|
+
const origin = await resolveOrigin(query, geocoder, {
|
|
2129
|
+
language: options.language,
|
|
2130
|
+
signal: options.signal
|
|
2131
|
+
});
|
|
2132
|
+
const nearbyOptions = { radiusMeters };
|
|
2133
|
+
if (selectors && selectors.length > 0) nearbyOptions.selectors = selectors;
|
|
2134
|
+
if (options.signal) nearbyOptions.signal = options.signal;
|
|
2135
|
+
const pois = await places.findNearby(origin.location, nearbyOptions);
|
|
2136
|
+
return {
|
|
2137
|
+
attribution: ODBL_ATTRIBUTION,
|
|
2138
|
+
createdAt: options.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2139
|
+
center: origin.location,
|
|
2140
|
+
radiusMeters,
|
|
2141
|
+
pois
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
var DatasetPlacesProvider = class {
|
|
2145
|
+
name = "dataset";
|
|
2146
|
+
pois;
|
|
2147
|
+
constructor(dataset) {
|
|
2148
|
+
this.pois = dataset.pois;
|
|
2149
|
+
}
|
|
2150
|
+
async findNearby(center, options) {
|
|
2151
|
+
const { radiusMeters, selectors } = options;
|
|
2152
|
+
return this.pois.filter((poi) => {
|
|
2153
|
+
if (haversineMeters(center, poi.location) > radiusMeters) return false;
|
|
2154
|
+
if (selectors && selectors.length > 0) return tagsMatchAnySelector(poi.tags, selectors);
|
|
2155
|
+
return true;
|
|
2156
|
+
});
|
|
2157
|
+
}
|
|
2158
|
+
};
|
|
2159
|
+
|
|
2160
|
+
// src/index.ts
|
|
2161
|
+
var VERSION = "1.0.0";
|
|
2162
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2163
|
+
0 && (module.exports = {
|
|
2164
|
+
CATEGORIES,
|
|
2165
|
+
CATEGORY_LABELS,
|
|
2166
|
+
DEFAULT_DAILY_NEEDS,
|
|
2167
|
+
DEFAULT_USER_AGENT,
|
|
2168
|
+
DEFAULT_WALK_CATEGORIES,
|
|
2169
|
+
DatasetPlacesProvider,
|
|
2170
|
+
HaversineRoutingProvider,
|
|
2171
|
+
HttpError,
|
|
2172
|
+
InMemoryCache,
|
|
2173
|
+
MODE_SPEED_MPS,
|
|
2174
|
+
NominatimGeocoder,
|
|
2175
|
+
ODBL_ATTRIBUTION,
|
|
2176
|
+
OsrmRoutingProvider,
|
|
2177
|
+
OverpassPlacesProvider,
|
|
2178
|
+
RateLimiter,
|
|
2179
|
+
VERSION,
|
|
2180
|
+
ValhallaRoutingProvider,
|
|
2181
|
+
accessibleScorer,
|
|
2182
|
+
buildOverpassQuery,
|
|
2183
|
+
buildTargetedOverpassQuery,
|
|
2184
|
+
categorize,
|
|
2185
|
+
categoryVocabulary,
|
|
2186
|
+
circlePolygon,
|
|
2187
|
+
compareLocations,
|
|
2188
|
+
compileFacets,
|
|
2189
|
+
completenessOf,
|
|
2190
|
+
dedupePois,
|
|
2191
|
+
detectGaps,
|
|
2192
|
+
disambiguateLocation,
|
|
2193
|
+
findNearbyAmenities,
|
|
2194
|
+
formatDistance,
|
|
2195
|
+
formatDuration,
|
|
2196
|
+
haversineMeters,
|
|
2197
|
+
isCategory,
|
|
2198
|
+
isKnownTerm,
|
|
2199
|
+
isOpenAt,
|
|
2200
|
+
lastVerifiedOf,
|
|
2201
|
+
matchesFacets,
|
|
2202
|
+
nearestMatchingPoi,
|
|
2203
|
+
parseCoordinates,
|
|
2204
|
+
planErrands,
|
|
2205
|
+
pointInPolygon,
|
|
2206
|
+
rankByProximity,
|
|
2207
|
+
reachableAmenities,
|
|
2208
|
+
requestJson,
|
|
2209
|
+
resolveCategories,
|
|
2210
|
+
resolveOrigin,
|
|
2211
|
+
selectorToOverpassFilter,
|
|
2212
|
+
snapshotArea,
|
|
2213
|
+
suggestCategories,
|
|
2214
|
+
tagsMatchAnySelector,
|
|
2215
|
+
tagsMatchSelector,
|
|
2216
|
+
toCSV,
|
|
2217
|
+
toGeoJSON,
|
|
2218
|
+
walkSubScore,
|
|
2219
|
+
walkabilityScore
|
|
2220
|
+
});
|
|
2221
|
+
//# sourceMappingURL=index.cjs.map
|