bbox-mcp-server 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -274
- package/dist/index.js +2606 -363
- package/dist/package.json +70 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -4,11 +4,21 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
|
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import * as h3 from "h3-js";
|
|
6
6
|
import proj4 from "proj4";
|
|
7
|
+
import pkg from "./package.json" with { type: "json" };
|
|
7
8
|
import proj4defs from "./proj4defs.json" with { type: "json" };
|
|
8
9
|
import { wktToGeoJSON as parseWKT } from "@terraformer/wkt";
|
|
9
10
|
const SERVER_NAME = "bbox-mcp-server";
|
|
10
|
-
const SERVER_VERSION =
|
|
11
|
-
const MAX_H3_CELLS =
|
|
11
|
+
const SERVER_VERSION = pkg.version;
|
|
12
|
+
const MAX_H3_CELLS = (() => {
|
|
13
|
+
if (!process.env.MAX_H3_CELLS)
|
|
14
|
+
return 50000;
|
|
15
|
+
const v = parseInt(process.env.MAX_H3_CELLS, 10);
|
|
16
|
+
if (!Number.isFinite(v) || v <= 0) {
|
|
17
|
+
console.error(JSON.stringify({ timestamp: new Date().toISOString(), level: 'warn', server: 'bbox-mcp-server', message: `Invalid MAX_H3_CELLS="${process.env.MAX_H3_CELLS}", using default 50000` }));
|
|
18
|
+
return 50000;
|
|
19
|
+
}
|
|
20
|
+
return v;
|
|
21
|
+
})();
|
|
12
22
|
const MAPBOX_ACCESS_TOKEN = process.env.MAPBOX_ACCESS_TOKEN;
|
|
13
23
|
const MAX_INPUT_LENGTH = 1_000_000; // 1 MB input size guard
|
|
14
24
|
const FETCH_TIMEOUT_MS = 10_000; // 10 second timeout for external API calls
|
|
@@ -56,6 +66,9 @@ initProj4();
|
|
|
56
66
|
*/
|
|
57
67
|
function extractCoords(obj) {
|
|
58
68
|
const result = [];
|
|
69
|
+
// Only follow GeoJSON-structural keys to avoid walking into properties
|
|
70
|
+
// which may contain arbitrary numeric arrays.
|
|
71
|
+
const GEO_KEYS = new Set(['coordinates', 'geometry', 'geometries', 'features']);
|
|
59
72
|
const stack = [obj];
|
|
60
73
|
while (stack.length > 0) {
|
|
61
74
|
const current = stack.pop();
|
|
@@ -70,9 +83,10 @@ function extractCoords(obj) {
|
|
|
70
83
|
}
|
|
71
84
|
}
|
|
72
85
|
else if (current && typeof current === 'object') {
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
for (const key of Object.keys(current)) {
|
|
87
|
+
if (GEO_KEYS.has(key)) {
|
|
88
|
+
stack.push(current[key]);
|
|
89
|
+
}
|
|
76
90
|
}
|
|
77
91
|
}
|
|
78
92
|
}
|
|
@@ -101,6 +115,14 @@ function safeMax(arr) {
|
|
|
101
115
|
// ---------------------------------------------------------------------------
|
|
102
116
|
// Pure Functions Ported from bbox.js
|
|
103
117
|
// ---------------------------------------------------------------------------
|
|
118
|
+
const STRICT_NUM_RE = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
119
|
+
function parseStrictNumber(raw) {
|
|
120
|
+
const trimmed = raw.trim();
|
|
121
|
+
if (!STRICT_NUM_RE.test(trimmed))
|
|
122
|
+
return NaN;
|
|
123
|
+
const n = Number(trimmed);
|
|
124
|
+
return Number.isFinite(n) ? n : NaN;
|
|
125
|
+
}
|
|
104
126
|
function validateBBox(lat1, lng1, lat2, lng2) {
|
|
105
127
|
if (isNaN(lat1) || isNaN(lng1) || isNaN(lat2) || isNaN(lng2))
|
|
106
128
|
return false;
|
|
@@ -110,7 +132,15 @@ function validateBBox(lat1, lng1, lat2, lng2) {
|
|
|
110
132
|
return false;
|
|
111
133
|
return true;
|
|
112
134
|
}
|
|
113
|
-
function
|
|
135
|
+
function hasAntimeridianJump(coordsLngLat) {
|
|
136
|
+
for (let i = 1; i < coordsLngLat.length; i++) {
|
|
137
|
+
if (Math.abs(coordsLngLat[i][0] - coordsLngLat[i - 1][0]) > 180) {
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
function parseBBox(input, warnings) {
|
|
114
144
|
if (!input || typeof input !== 'string') {
|
|
115
145
|
throw new Error(`Invalid bbox input.`);
|
|
116
146
|
}
|
|
@@ -118,67 +148,138 @@ function parseBBox(input) {
|
|
|
118
148
|
if (input.length > MAX_INPUT_LENGTH) {
|
|
119
149
|
throw new Error(`Input too large (${(input.length / 1024).toFixed(0)} KB). Maximum allowed is ${(MAX_INPUT_LENGTH / 1024).toFixed(0)} KB.`);
|
|
120
150
|
}
|
|
151
|
+
// --- 0. Web Mercator Tile Sniffer (z/x/y) ---
|
|
152
|
+
const tileMatch = input.trim().match(/^(\d{1,2})\s*\/\s*(\d+)\s*\/\s*(\d+)$/);
|
|
153
|
+
if (tileMatch) {
|
|
154
|
+
const z = parseInt(tileMatch[1], 10);
|
|
155
|
+
const x = parseInt(tileMatch[2], 10);
|
|
156
|
+
const y = parseInt(tileMatch[3], 10);
|
|
157
|
+
const maxTile = Math.pow(2, z);
|
|
158
|
+
if (z >= 0 && z <= 22 && x >= 0 && x < maxTile && y >= 0 && y < maxTile) {
|
|
159
|
+
return tileToBBox(z, x, y);
|
|
160
|
+
}
|
|
161
|
+
throw new Error(`Invalid Web Mercator tile coordinates: ${input}`);
|
|
162
|
+
}
|
|
121
163
|
// --- 1. ogrinfo Extent Sniffer ---
|
|
122
164
|
// Matches: "Extent: (-74.006000, 40.712800) - (-73.985500, 40.758000)"
|
|
123
165
|
const ogrMatch = input.match(/Extent\:\s\((.*)\)/);
|
|
124
166
|
if (ogrMatch && ogrMatch[1]) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return ensureValidBBox(minCoords[1], minCoords[0], maxCoords[1], maxCoords[0]);
|
|
167
|
+
const pairs = ogrMatch[1].split(") - (");
|
|
168
|
+
if (pairs.length === 2) {
|
|
169
|
+
const minCoords = pairs[0].replace('(', '').split(',').map(parseStrictNumber);
|
|
170
|
+
const maxCoords = pairs[1].replace(')', '').split(',').map(parseStrictNumber);
|
|
171
|
+
if (minCoords.length !== 2 || maxCoords.length !== 2 || minCoords.some(isNaN) || maxCoords.some(isNaN)) {
|
|
172
|
+
throw new Error(`Invalid numeric values in ogrinfo extent: ${input}`);
|
|
132
173
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
174
|
+
if (minCoords[0] > maxCoords[0]) {
|
|
175
|
+
throw new Error(`Antimeridian (date-line) crossing bounding box detected (west=${minCoords[0]} > east=${maxCoords[0]}). Split the region at ±180° longitude.`);
|
|
176
|
+
}
|
|
177
|
+
// Format: (minX, minY) - (maxX, maxY) where X=lng, Y=lat
|
|
178
|
+
return ensureValidBBox(minCoords[1], minCoords[0], maxCoords[1], maxCoords[0]);
|
|
136
179
|
}
|
|
137
180
|
}
|
|
138
181
|
// --- 2. GeoJSON Sniffer ---
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
182
|
+
if (input.trim().startsWith('{')) {
|
|
183
|
+
let json;
|
|
184
|
+
try {
|
|
185
|
+
json = JSON.parse(input);
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
throw new Error(`Invalid GeoJSON input: ${e?.message}`);
|
|
189
|
+
}
|
|
190
|
+
if (json.bbox && Array.isArray(json.bbox) && json.bbox.length === 4) {
|
|
191
|
+
const [west, south, east, north] = json.bbox.map((v) => typeof v === 'number' ? v : parseStrictNumber(String(v)));
|
|
192
|
+
if ([west, south, east, north].some(isNaN)) {
|
|
193
|
+
throw new Error(`Invalid numeric values in GeoJSON bbox.`);
|
|
194
|
+
}
|
|
195
|
+
if (west > east) {
|
|
196
|
+
throw new Error(`Antimeridian (date-line) crossing GeoJSON bbox detected (west=${west} > east=${east}). Split the bounding box at ±180° longitude.`);
|
|
197
|
+
}
|
|
198
|
+
if (south > north) {
|
|
199
|
+
throw new Error(`Invalid GeoJSON bbox: south (${south}) > north (${north}).`);
|
|
200
|
+
}
|
|
201
|
+
return ensureValidBBox(south, west, north, east);
|
|
144
202
|
}
|
|
145
|
-
// Multi-Geometry Coordinate Extraction (using shared, stack-safe utility)
|
|
146
203
|
const allCoords = extractCoords(json);
|
|
147
204
|
if (allCoords.length > 0) {
|
|
205
|
+
if (hasAntimeridianJump(allCoords)) {
|
|
206
|
+
throw new Error(`Antimeridian (date-line) crossing geometry detected (longitude jump > 180°). Split the geometry at ±180° longitude.`);
|
|
207
|
+
}
|
|
148
208
|
const lngs = allCoords.map(c => c[0]);
|
|
149
209
|
const lats = allCoords.map(c => c[1]);
|
|
150
210
|
return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
|
|
151
211
|
}
|
|
152
|
-
|
|
153
|
-
catch (e) {
|
|
154
|
-
log('info', 'parseBBox: GeoJSON parse failed, trying next parser', { error: e?.message });
|
|
212
|
+
throw new Error(`GeoJSON input contains no valid coordinates or bbox.`);
|
|
155
213
|
}
|
|
156
214
|
// --- 3. WKT Sniffer ---
|
|
157
|
-
if (
|
|
215
|
+
if (/^\s*(MULTI)?(POLYGON|POINT|LINESTRING)|^\s*GEOMETRYCOLLECTION/i.test(input)) {
|
|
216
|
+
let geojson;
|
|
158
217
|
try {
|
|
159
|
-
|
|
160
|
-
const allCoords = extractCoords(geojson.coordinates || geojson);
|
|
161
|
-
if (allCoords.length > 0) {
|
|
162
|
-
const lngs = allCoords.map(c => c[0]);
|
|
163
|
-
const lats = allCoords.map(c => c[1]);
|
|
164
|
-
return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
|
|
165
|
-
}
|
|
218
|
+
geojson = parseWKT(input);
|
|
166
219
|
}
|
|
167
220
|
catch (e) {
|
|
168
|
-
|
|
221
|
+
throw new Error(`Failed to parse WKT geometry: ${e?.message}`);
|
|
222
|
+
}
|
|
223
|
+
const allCoords = extractCoords(geojson?.coordinates ? geojson : { geometry: geojson });
|
|
224
|
+
if (allCoords.length > 0) {
|
|
225
|
+
if (hasAntimeridianJump(allCoords)) {
|
|
226
|
+
throw new Error(`Antimeridian (date-line) crossing WKT geometry detected (longitude jump > 180°). Split the geometry at ±180° longitude.`);
|
|
227
|
+
}
|
|
228
|
+
const lngs = allCoords.map(c => c[0]);
|
|
229
|
+
const lats = allCoords.map(c => c[1]);
|
|
230
|
+
return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
|
|
231
|
+
}
|
|
232
|
+
throw new Error(`Failed to extract coordinates from WKT geometry.`);
|
|
233
|
+
}
|
|
234
|
+
// --- 4. Strict Coordinate Array Sniffer ---
|
|
235
|
+
const trimmed = input.trim();
|
|
236
|
+
// Reject strings containing unexpected characters outside brackets, parens, commas, whitespace, digits, signs, decimal point, exponent
|
|
237
|
+
if (!/^[\[\(\s]*[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?(?:\s*[\s,]\s*[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)*[\]\)\s]*$/i.test(trimmed)) {
|
|
238
|
+
throw new Error(`Failed to parse bounding box: contains invalid coordinate tokens. Input must be valid WKT, GeoJSON, ogrinfo extent, tile "z/x/y", point "lat,lng", or "lat1,lng1,lat2,lng2". Got: ${input.substring(0, 50)}`);
|
|
239
|
+
}
|
|
240
|
+
const cleanInput = trimmed.replace(/^[\[\(\s]+|[\]\)\s]+$/g, '');
|
|
241
|
+
const rawTokens = cleanInput.split(/[\s,]+/).filter(Boolean);
|
|
242
|
+
const parts = rawTokens.map(parseStrictNumber);
|
|
243
|
+
if (parts.some(isNaN)) {
|
|
244
|
+
throw new Error(`Failed to parse bounding box: invalid numeric token in "${input.substring(0, 50)}".`);
|
|
245
|
+
}
|
|
246
|
+
if (parts.length === 2) {
|
|
247
|
+
const p0 = parts[0], p1 = parts[1];
|
|
248
|
+
if (validateBBox(p0, p1, p0, p1)) {
|
|
249
|
+
return ensureValidBBox(p0, p1, p0, p1);
|
|
250
|
+
}
|
|
251
|
+
else if (validateBBox(p1, p0, p1, p0)) {
|
|
252
|
+
warnings?.push(`Detected single point in lng,lat order (${p0}, ${p1}); auto-converted to lat=${p1}, lng=${p0}.`);
|
|
253
|
+
return ensureValidBBox(p1, p0, p1, p0);
|
|
169
254
|
}
|
|
255
|
+
throw new Error(`Invalid point coordinates: (${p0}, ${p1}). Latitude must be [-90, 90] and longitude [-180, 180].`);
|
|
170
256
|
}
|
|
171
|
-
// --- 4. Standard Coordinate Array Sniffer ---
|
|
172
|
-
// Strip brackets/parentheses and split by commas or spaces
|
|
173
|
-
const cleanInput = input.replace(/[\[\]\(\)]/g, '').trim();
|
|
174
|
-
// Support space-separated (GDAL) or comma-separated
|
|
175
|
-
const parts = cleanInput.split(/[\s,]+/).map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
|
|
176
257
|
if (parts.length === 4) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
258
|
+
const asLatLng = validateBBox(parts[0], parts[1], parts[2], parts[3]);
|
|
259
|
+
const asLngLat = validateBBox(parts[1], parts[0], parts[3], parts[2]);
|
|
260
|
+
if (!asLatLng && asLngLat) {
|
|
261
|
+
// Check antimeridian crossing in lng,lat,lng,lat order: parts[0] = west, parts[2] = east
|
|
262
|
+
if (parts[0] > parts[2] && (parts[0] - parts[2]) > 180) {
|
|
263
|
+
throw new Error(`Antimeridian (date-line) crossing bounding box detected (west=${parts[0]} > east=${parts[2]}). Split the bounding box at ±180° longitude.`);
|
|
264
|
+
}
|
|
265
|
+
warnings?.push(`Detected lng,lat,lng,lat coordinate order (${parts[0]}, ${parts[1]}, ${parts[2]}, ${parts[3]}) because longitude is outside [-90, 90]. Automatically transposed to lat,lng.`);
|
|
266
|
+
return ensureValidBBox(parts[1], parts[0], parts[3], parts[2]);
|
|
267
|
+
}
|
|
268
|
+
if (asLatLng) {
|
|
269
|
+
// Check antimeridian crossing in lat1,lng1,lat2,lng2 order: parts[1] = west, parts[3] = east
|
|
270
|
+
if (parts[1] > parts[3] && (parts[1] - parts[3]) > 180) {
|
|
271
|
+
throw new Error(`Antimeridian (date-line) crossing bounding box detected (west=${parts[1]} > east=${parts[3]}). Split the bounding box at ±180° longitude.`);
|
|
272
|
+
}
|
|
273
|
+
if (asLngLat && (parts[0] !== parts[1] || parts[2] !== parts[3])) {
|
|
274
|
+
warnings?.push(`Ambiguous coordinate order: interpreted as lat,lng,lat,lng → (${parts[0]}, ${parts[1]}, ${parts[2]}, ${parts[3]}). ` +
|
|
275
|
+
`If your source uses lng,lat order (GeoJSON, GDAL, most GIS tools), the bounding box may be transposed. ` +
|
|
276
|
+
`Use GeoJSON or WKT input to avoid ambiguity.`);
|
|
277
|
+
}
|
|
278
|
+
return ensureValidBBox(parts[0], parts[1], parts[2], parts[3]);
|
|
279
|
+
}
|
|
280
|
+
throw new Error(`Invalid coordinates or out of bounds: lat[-90, 90], lng[-180, 180]. Provided: ${parts.join(', ')}`);
|
|
180
281
|
}
|
|
181
|
-
throw new Error(`Failed to parse bounding box.
|
|
282
|
+
throw new Error(`Failed to parse bounding box. Expected 2 (point) or 4 (bbox) numbers, WKT, GeoJSON, ogrinfo extent, or tile "z/x/y". Got ${parts.length} numbers in: ${input.substring(0, 50)}`);
|
|
182
283
|
}
|
|
183
284
|
function ensureValidBBox(lat1, lng1, lat2, lng2) {
|
|
184
285
|
if (!validateBBox(lat1, lng1, lat2, lng2)) {
|
|
@@ -192,6 +293,9 @@ function ensureValidBBox(lat1, lng1, lat2, lng2) {
|
|
|
192
293
|
lng2: Math.max(lng1, lng2)
|
|
193
294
|
};
|
|
194
295
|
}
|
|
296
|
+
function formatWarnings(warnings) {
|
|
297
|
+
return warnings.length > 0 ? '\n\n⚠️ ' + warnings.join('\n⚠️ ') : '';
|
|
298
|
+
}
|
|
195
299
|
function projectBounds(bbox, epsg) {
|
|
196
300
|
if (epsg === "4326") {
|
|
197
301
|
return { xmin: bbox.lng1, ymin: bbox.lat1, xmax: bbox.lng2, ymax: bbox.lat2 };
|
|
@@ -200,13 +304,20 @@ function projectBounds(bbox, epsg) {
|
|
|
200
304
|
if (!proj4.defs(epsgKey)) {
|
|
201
305
|
throw new Error(`Unknown EPSG code: ${epsg}. Make sure it exists in proj4defs.`);
|
|
202
306
|
}
|
|
203
|
-
|
|
204
|
-
const
|
|
307
|
+
// Project all four corners to handle axis flips and oblique/rotated CRS
|
|
308
|
+
const corners = [
|
|
309
|
+
proj4(epsgKey, [bbox.lng1, bbox.lat1]),
|
|
310
|
+
proj4(epsgKey, [bbox.lng2, bbox.lat1]),
|
|
311
|
+
proj4(epsgKey, [bbox.lng1, bbox.lat2]),
|
|
312
|
+
proj4(epsgKey, [bbox.lng2, bbox.lat2]),
|
|
313
|
+
];
|
|
314
|
+
const xs = corners.map(c => c[0]);
|
|
315
|
+
const ys = corners.map(c => c[1]);
|
|
205
316
|
return {
|
|
206
|
-
xmin:
|
|
207
|
-
ymin:
|
|
208
|
-
xmax:
|
|
209
|
-
ymax:
|
|
317
|
+
xmin: Math.min(...xs),
|
|
318
|
+
ymin: Math.min(...ys),
|
|
319
|
+
xmax: Math.max(...xs),
|
|
320
|
+
ymax: Math.max(...ys),
|
|
210
321
|
};
|
|
211
322
|
}
|
|
212
323
|
/**
|
|
@@ -224,6 +335,9 @@ async function ensureProjection(epsg) {
|
|
|
224
335
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
225
336
|
try {
|
|
226
337
|
const response = await fetch(`https://epsg.io/${epsg}.proj4`, {
|
|
338
|
+
headers: {
|
|
339
|
+
'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
|
|
340
|
+
},
|
|
227
341
|
signal: controller.signal,
|
|
228
342
|
});
|
|
229
343
|
if (!response.ok) {
|
|
@@ -312,20 +426,86 @@ function calculateBboxArea(bbox) {
|
|
|
312
426
|
return widthKm * heightKm;
|
|
313
427
|
}
|
|
314
428
|
function latLngToTile(lat, lng, zoom) {
|
|
429
|
+
// Clamp to Web Mercator latitude limits to avoid Math.tan(±90°) → Infinity → NaN
|
|
430
|
+
const clampedLat = Math.max(-85.0511, Math.min(85.0511, lat));
|
|
315
431
|
const n = Math.pow(2, zoom);
|
|
316
432
|
const x = Math.floor((lng + 180) / 360 * n);
|
|
317
|
-
const latRad =
|
|
433
|
+
const latRad = clampedLat * Math.PI / 180;
|
|
318
434
|
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
|
|
319
435
|
return { z: zoom, x, y };
|
|
320
436
|
}
|
|
321
|
-
function
|
|
322
|
-
const
|
|
323
|
-
|
|
437
|
+
function tileToBBox(z, x, y) {
|
|
438
|
+
const n = Math.pow(2, z);
|
|
439
|
+
const lng1 = (x / n) * 360 - 180;
|
|
440
|
+
const lng2 = ((x + 1) / n) * 360 - 180;
|
|
441
|
+
const latRad1 = Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n)));
|
|
442
|
+
const latRad2 = Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n)));
|
|
443
|
+
const lat1 = (latRad1 * 180) / Math.PI;
|
|
444
|
+
const lat2 = (latRad2 * 180) / Math.PI;
|
|
445
|
+
return ensureValidBBox(lat1, lng1, lat2, lng2);
|
|
324
446
|
}
|
|
325
|
-
function
|
|
326
|
-
|
|
447
|
+
function expandBBoxMeters(bbox, bufferMeters) {
|
|
448
|
+
if (!bufferMeters || bufferMeters === 0)
|
|
449
|
+
return bbox;
|
|
450
|
+
const midLat = ((bbox.lat1 + bbox.lat2) / 2) * (Math.PI / 180);
|
|
451
|
+
const dLat = bufferMeters / 111320;
|
|
452
|
+
const cosLat = Math.max(0.01, Math.cos(midLat));
|
|
453
|
+
const dLng = bufferMeters / (111320 * cosLat);
|
|
454
|
+
return ensureValidBBox(Math.max(-90, bbox.lat1 - dLat), Math.max(-180, bbox.lng1 - dLng), Math.min(90, bbox.lat2 + dLat), Math.min(180, bbox.lng2 + dLng));
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Extracts polygons (with interior holes preserved) as arrays of [lat, lng] rings
|
|
458
|
+
* for H3 polygonToCells and point-in-polygon checks.
|
|
459
|
+
* Return shape: number[][][][] = Array of Polygons, where each Polygon is [outerRing, ...holeRings],
|
|
460
|
+
* and each Ring is Array of [lat, lng].
|
|
461
|
+
*/
|
|
462
|
+
function extractPolygonsWithHolesLatLng(input) {
|
|
463
|
+
try {
|
|
464
|
+
let geom = input;
|
|
465
|
+
if (typeof input === 'string') {
|
|
466
|
+
const trimmed = input.trim();
|
|
467
|
+
if (trimmed.startsWith('{')) {
|
|
468
|
+
geom = JSON.parse(trimmed);
|
|
469
|
+
}
|
|
470
|
+
else if (/^\s*(MULTI)?POLYGON/i.test(trimmed)) {
|
|
471
|
+
geom = parseWKT(trimmed);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
if (!geom || typeof geom !== 'object')
|
|
478
|
+
return null;
|
|
479
|
+
if (geom.type === 'Feature')
|
|
480
|
+
geom = geom.geometry;
|
|
481
|
+
if (geom.type === 'FeatureCollection' && Array.isArray(geom.features) && geom.features.length > 0) {
|
|
482
|
+
const polys = [];
|
|
483
|
+
for (const f of geom.features) {
|
|
484
|
+
const sub = extractPolygonsWithHolesLatLng(f.geometry);
|
|
485
|
+
if (sub)
|
|
486
|
+
polys.push(...sub);
|
|
487
|
+
}
|
|
488
|
+
return polys.length > 0 ? polys : null;
|
|
489
|
+
}
|
|
490
|
+
if (!geom)
|
|
491
|
+
return null;
|
|
492
|
+
if (geom.type === 'Polygon' && Array.isArray(geom.coordinates) && geom.coordinates.length > 0) {
|
|
493
|
+
// Preserve outer ring AND all interior hole rings, converting [lng, lat] -> [lat, lng]
|
|
494
|
+
const ringsLatLng = geom.coordinates.map((ring) => ring.map((c) => [c[1], c[0]]));
|
|
495
|
+
return [ringsLatLng];
|
|
496
|
+
}
|
|
497
|
+
if (geom.type === 'MultiPolygon' && Array.isArray(geom.coordinates) && geom.coordinates.length > 0) {
|
|
498
|
+
return geom.coordinates.map((poly) => poly.map((ring) => ring.map((c) => [c[1], c[0]])));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
// Ignore and fall back to bounding box
|
|
503
|
+
}
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
function formatMarkerParts(elements) {
|
|
327
507
|
const MAX_MARKERS = 30;
|
|
328
|
-
|
|
508
|
+
return elements
|
|
329
509
|
.filter(e => e.coordinates?.lat !== undefined && e.coordinates?.lon !== undefined)
|
|
330
510
|
.slice(0, MAX_MARKERS)
|
|
331
511
|
.map(e => {
|
|
@@ -334,10 +514,75 @@ function buildShareUrlWithMarkers(bbox, elements) {
|
|
|
334
514
|
name = name.slice(0, 27) + '...';
|
|
335
515
|
return `${e.coordinates.lat.toFixed(6)},${e.coordinates.lon.toFixed(6)},${encodeURIComponent(name)}`;
|
|
336
516
|
});
|
|
337
|
-
|
|
517
|
+
}
|
|
518
|
+
function buildShareUrl(bbox) {
|
|
519
|
+
const hash = `${bbox.lat1.toFixed(6)},${bbox.lng1.toFixed(6)},${bbox.lat2.toFixed(6)},${bbox.lng2.toFixed(6)}`;
|
|
520
|
+
return `https://vibhorsingh.com/boundingbox/#${hash}`;
|
|
521
|
+
}
|
|
522
|
+
function buildShareUrlWithMarkers(bbox, elements, extraParts) {
|
|
523
|
+
const bboxPart = `${bbox.lat1.toFixed(6)},${bbox.lng1.toFixed(6)},${bbox.lat2.toFixed(6)},${bbox.lng2.toFixed(6)}`;
|
|
524
|
+
const markerParts = formatMarkerParts(elements);
|
|
525
|
+
const hashParts = [bboxPart, ...(extraParts || []), ...markerParts];
|
|
526
|
+
return `https://vibhorsingh.com/boundingbox/#${hashParts.join('|')}`;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Builds a verification map URL without an active orange bounding-box rectangle (empty parts[0]).
|
|
530
|
+
* The viewer renders any PB: polylines/polygons and markers, and auto-fits the map bounds to them.
|
|
531
|
+
*/
|
|
532
|
+
function buildShareUrlMarkersOnly(elements, extraParts) {
|
|
533
|
+
const markerParts = formatMarkerParts(elements);
|
|
534
|
+
const hashParts = ["", ...(extraParts || []), ...markerParts];
|
|
338
535
|
return `https://vibhorsingh.com/boundingbox/#${hashParts.join('|')}`;
|
|
339
536
|
}
|
|
340
|
-
|
|
537
|
+
/**
|
|
538
|
+
* Builds a verification map URL with a polygon boundary (P:lat,lng,...) in parts[0] instead of a rectangle.
|
|
539
|
+
*/
|
|
540
|
+
function buildShareUrlPolygon(outerRingLatLng, elements = []) {
|
|
541
|
+
const MAX_RING_PTS = 60;
|
|
542
|
+
let sampled = outerRingLatLng;
|
|
543
|
+
if (outerRingLatLng.length > MAX_RING_PTS) {
|
|
544
|
+
sampled = [];
|
|
545
|
+
for (let i = 0; i < MAX_RING_PTS; i++) {
|
|
546
|
+
const idx = Math.min(outerRingLatLng.length - 1, Math.round((i / (MAX_RING_PTS - 1)) * (outerRingLatLng.length - 1)));
|
|
547
|
+
sampled.push(outerRingLatLng[idx]);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const polyPart = `P:${sampled.map(([lat, lng]) => `${lat.toFixed(5)},${lng.toFixed(5)}`).join(',')}`;
|
|
551
|
+
const markerParts = formatMarkerParts(elements);
|
|
552
|
+
return `https://vibhorsingh.com/boundingbox/#${[polyPart, ...markerParts].join('|')}`;
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Encodes a route LineString ([lng, lat][]) onto the verification map URL using the viewer's
|
|
556
|
+
* PB: layer format (tracing forward and backward so Leaflet renders the exact path line)
|
|
557
|
+
* alongside origin/destination markers, WITHOUT drawing an unnecessary bounding box rectangle.
|
|
558
|
+
*/
|
|
559
|
+
function buildShareUrlWithRoute(_bbox, routeCoordsLngLat, markers) {
|
|
560
|
+
const MAX_PATH_PTS = 45;
|
|
561
|
+
let sampled = routeCoordsLngLat;
|
|
562
|
+
if (routeCoordsLngLat.length > MAX_PATH_PTS) {
|
|
563
|
+
sampled = [];
|
|
564
|
+
for (let i = 0; i < MAX_PATH_PTS; i++) {
|
|
565
|
+
const idx = Math.min(routeCoordsLngLat.length - 1, Math.round((i / (MAX_PATH_PTS - 1)) * (routeCoordsLngLat.length - 1)));
|
|
566
|
+
sampled.push(routeCoordsLngLat[idx]);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const extraParts = [];
|
|
570
|
+
if (sampled.length >= 2) {
|
|
571
|
+
// Trace forward then backward so L.Polygon renders a zero-area polyline trace on the viewer
|
|
572
|
+
const forward = sampled.map(([lng, lat]) => `${lat.toFixed(5)},${lng.toFixed(5)}`);
|
|
573
|
+
const backward = sampled
|
|
574
|
+
.slice(1, -1)
|
|
575
|
+
.reverse()
|
|
576
|
+
.map(([lng, lat]) => `${lat.toFixed(5)},${lng.toFixed(5)}`);
|
|
577
|
+
const allPts = [...forward, ...backward];
|
|
578
|
+
if (allPts.length < 3 && sampled.length === 2) {
|
|
579
|
+
allPts.push(forward[0]);
|
|
580
|
+
}
|
|
581
|
+
extraParts.push(`PB:${allPts.join(',')}`);
|
|
582
|
+
}
|
|
583
|
+
return buildShareUrlMarkersOnly(markers, extraParts);
|
|
584
|
+
}
|
|
585
|
+
function getH3Cells(bbox, resolution, compact, customPolygons) {
|
|
341
586
|
if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
|
|
342
587
|
throw new Error(`Invalid H3 resolution: ${resolution}. Must be an integer between 0 and 15.`);
|
|
343
588
|
}
|
|
@@ -351,15 +596,30 @@ function getH3Cells(bbox, resolution, compact) {
|
|
|
351
596
|
if (estimatedCount > MAX_H3_CELLS * 2) {
|
|
352
597
|
throw new Error(`Area too large for resolution ${resolution}. Estimated ${Math.floor(estimatedCount).toLocaleString()} cells. Maximum allowed is ${MAX_H3_CELLS.toLocaleString()}.`);
|
|
353
598
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
599
|
+
let cells = [];
|
|
600
|
+
if (customPolygons && customPolygons.length > 0) {
|
|
601
|
+
const cellSet = new Set();
|
|
602
|
+
for (const polyWithHoles of customPolygons) {
|
|
603
|
+
if (polyWithHoles.length > 0 && polyWithHoles[0].length >= 3) {
|
|
604
|
+
// Pass full [outerRing, ...holeRings] to h3.polygonToCells so holes are excluded
|
|
605
|
+
for (const c of h3.polygonToCells(polyWithHoles, resolution)) {
|
|
606
|
+
cellSet.add(c);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
cells = Array.from(cellSet);
|
|
611
|
+
}
|
|
612
|
+
if (cells.length === 0 && (!customPolygons || customPolygons.length === 0)) {
|
|
613
|
+
// Fallback to rectangular bbox polygon: [ [lat, lng]... ]
|
|
614
|
+
const polygon = [
|
|
615
|
+
[bbox.lat1, bbox.lng1],
|
|
616
|
+
[bbox.lat2, bbox.lng1],
|
|
617
|
+
[bbox.lat2, bbox.lng2],
|
|
618
|
+
[bbox.lat1, bbox.lng2],
|
|
619
|
+
[bbox.lat1, bbox.lng1]
|
|
620
|
+
];
|
|
621
|
+
cells = h3.polygonToCells(polygon, resolution);
|
|
622
|
+
}
|
|
363
623
|
if (cells.length > MAX_H3_CELLS) {
|
|
364
624
|
throw new Error(`Too many cells generated (${cells.length.toLocaleString()}). Maximum allowed is ${MAX_H3_CELLS.toLocaleString()}. Try a lower resolution or smaller bounds.`);
|
|
365
625
|
}
|
|
@@ -394,90 +654,742 @@ function h3CellsToGeoJSON(cells) {
|
|
|
394
654
|
features
|
|
395
655
|
};
|
|
396
656
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
657
|
+
const OSM_ATTRIBUTION = "© OpenStreetMap contributors (ODbL - https://www.openstreetmap.org/copyright)";
|
|
658
|
+
const APP_USER_AGENT = process.env.OSM_USER_AGENT || `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`;
|
|
659
|
+
/**
|
|
660
|
+
* Creates a concurrency-safe rate limiter using a serialized promise queue.
|
|
661
|
+
* Concurrent callers are strictly spaced by at least `minIntervalMs`.
|
|
662
|
+
*/
|
|
663
|
+
function createRateLimiter(minIntervalMs) {
|
|
664
|
+
let nextAllowedTime = 0;
|
|
665
|
+
let queue = Promise.resolve();
|
|
666
|
+
return () => {
|
|
667
|
+
const run = queue.then(async () => {
|
|
668
|
+
const now = Date.now();
|
|
669
|
+
const waitMs = Math.max(0, nextAllowedTime - now);
|
|
670
|
+
nextAllowedTime = Math.max(now, nextAllowedTime) + minIntervalMs;
|
|
671
|
+
if (waitMs > 0) {
|
|
672
|
+
await new Promise(resolve => setTimeout(resolve, waitMs));
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
queue = run.catch(() => { });
|
|
676
|
+
return run;
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
const nominatimRateLimit = createRateLimiter(1000);
|
|
680
|
+
const photonRateLimit = createRateLimiter(1000);
|
|
681
|
+
const routingRateLimit = createRateLimiter(1000);
|
|
682
|
+
const NOMINATIM_CACHE = new Map();
|
|
683
|
+
const NOMINATIM_TTL_MS = 60 * 60 * 1000; // 1 hour TTL
|
|
684
|
+
const NOMINATIM_BASE_URL = (process.env.NOMINATIM_API_URL || "https://nominatim.openstreetmap.org").replace(/\/+$/, "");
|
|
685
|
+
const PHOTON_BASE_URL = (process.env.PHOTON_API_URL || "https://photon.komoot.io").replace(/\/+$/, "");
|
|
686
|
+
async function fetchNominatim(url) {
|
|
687
|
+
const cached = NOMINATIM_CACHE.get(url);
|
|
688
|
+
if (cached && (Date.now() - cached.timestamp < NOMINATIM_TTL_MS)) {
|
|
689
|
+
return {
|
|
690
|
+
data: cached.data,
|
|
691
|
+
cached: true,
|
|
692
|
+
cache_age_ms: Date.now() - cached.timestamp,
|
|
693
|
+
timestamp: new Date(cached.timestamp).toISOString()
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
await nominatimRateLimit();
|
|
697
|
+
// Check cache again after waiting in queue in case a concurrent caller populated the same URL
|
|
698
|
+
const cachedAfterWait = NOMINATIM_CACHE.get(url);
|
|
699
|
+
if (cachedAfterWait && (Date.now() - cachedAfterWait.timestamp < NOMINATIM_TTL_MS)) {
|
|
700
|
+
return {
|
|
701
|
+
data: cachedAfterWait.data,
|
|
702
|
+
cached: true,
|
|
703
|
+
cache_age_ms: Date.now() - cachedAfterWait.timestamp,
|
|
704
|
+
timestamp: new Date(cachedAfterWait.timestamp).toISOString()
|
|
705
|
+
};
|
|
400
706
|
}
|
|
401
707
|
const controller = new AbortController();
|
|
402
708
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
403
709
|
try {
|
|
404
|
-
const
|
|
405
|
-
|
|
710
|
+
const response = await fetch(url, {
|
|
711
|
+
headers: {
|
|
712
|
+
'User-Agent': APP_USER_AGENT,
|
|
713
|
+
'Accept': 'application/json'
|
|
714
|
+
},
|
|
715
|
+
signal: controller.signal
|
|
716
|
+
});
|
|
406
717
|
if (!response.ok) {
|
|
407
|
-
throw new Error(`
|
|
718
|
+
throw new Error(`Nominatim request failed with status: ${response.status} ${response.statusText}`);
|
|
408
719
|
}
|
|
409
720
|
const data = await response.json();
|
|
410
|
-
|
|
411
|
-
|
|
721
|
+
const now = Date.now();
|
|
722
|
+
NOMINATIM_CACHE.set(url, { timestamp: now, data });
|
|
723
|
+
return {
|
|
724
|
+
data,
|
|
725
|
+
cached: false,
|
|
726
|
+
cache_age_ms: 0,
|
|
727
|
+
timestamp: new Date(now).toISOString()
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
if (err.name === 'AbortError') {
|
|
732
|
+
throw new Error(`Nominatim request timed out after ${FETCH_TIMEOUT_MS / 1000}s for URL: ${url}`);
|
|
733
|
+
}
|
|
734
|
+
throw err;
|
|
735
|
+
}
|
|
736
|
+
finally {
|
|
737
|
+
clearTimeout(timeout);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
const PHOTON_CACHE = new Map();
|
|
741
|
+
async function geocodeLocation(query, includePolygon = false, warnings) {
|
|
742
|
+
// 1. Try Nominatim first (free, returns exact OSM area boundaries & alternatives)
|
|
743
|
+
try {
|
|
744
|
+
const polyParam = includePolygon ? "&polygon_geojson=1" : "";
|
|
745
|
+
const emailParam = process.env.NOMINATIM_EMAIL ? `&email=${encodeURIComponent(process.env.NOMINATIM_EMAIL)}` : "";
|
|
746
|
+
const nominatimUrl = `${NOMINATIM_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&addressdetails=1&extratags=1&limit=5${polyParam}${emailParam}`;
|
|
747
|
+
const { data: items, cached, cache_age_ms, timestamp } = await fetchNominatim(nominatimUrl);
|
|
748
|
+
if (Array.isArray(items) && items.length > 0) {
|
|
749
|
+
const parsedItems = items.map((item) => {
|
|
750
|
+
let bbox;
|
|
751
|
+
if (item.boundingbox && item.boundingbox.length === 4) {
|
|
752
|
+
const south = parseFloat(item.boundingbox[0]);
|
|
753
|
+
const north = parseFloat(item.boundingbox[1]);
|
|
754
|
+
const west = parseFloat(item.boundingbox[2]);
|
|
755
|
+
const east = parseFloat(item.boundingbox[3]);
|
|
756
|
+
bbox = ensureValidBBox(south, west, north, east);
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
const lat = parseFloat(item.lat);
|
|
760
|
+
const lon = parseFloat(item.lon);
|
|
761
|
+
bbox = ensureValidBBox(lat - 0.05, lon - 0.05, lat + 0.05, lon + 0.05);
|
|
762
|
+
}
|
|
763
|
+
const center = { lat: parseFloat(item.lat), lng: parseFloat(item.lon) };
|
|
764
|
+
let osm_area_id;
|
|
765
|
+
const osmIdNum = parseInt(item.osm_id, 10);
|
|
766
|
+
if (item.osm_type === "relation" && !isNaN(osmIdNum)) {
|
|
767
|
+
osm_area_id = 3600000000 + osmIdNum;
|
|
768
|
+
}
|
|
769
|
+
else if (item.osm_type === "way" && !isNaN(osmIdNum)) {
|
|
770
|
+
osm_area_id = 2400000000 + osmIdNum;
|
|
771
|
+
}
|
|
772
|
+
const polygons_with_holes = item.geojson ? (extractPolygonsWithHolesLatLng(item.geojson) || undefined) : undefined;
|
|
773
|
+
return {
|
|
774
|
+
matched_place: item.display_name,
|
|
775
|
+
bbox,
|
|
776
|
+
center,
|
|
777
|
+
osm_area_id,
|
|
778
|
+
class: item.class,
|
|
779
|
+
type: item.type,
|
|
780
|
+
osm_type: item.osm_type,
|
|
781
|
+
osm_id: osmIdNum,
|
|
782
|
+
...(polygons_with_holes ? { polygons_with_holes } : {})
|
|
783
|
+
};
|
|
784
|
+
});
|
|
785
|
+
let primaryIndex = parsedItems.findIndex(i => i.osm_type === "relation" &&
|
|
786
|
+
(i.class === "boundary" || i.type === "administrative" || i.type === "boundary"));
|
|
787
|
+
if (primaryIndex === -1)
|
|
788
|
+
primaryIndex = 0;
|
|
789
|
+
const primary = parsedItems[primaryIndex];
|
|
790
|
+
const alternatives = parsedItems.filter((_, idx) => idx !== primaryIndex);
|
|
791
|
+
return {
|
|
792
|
+
...primary,
|
|
793
|
+
alternatives,
|
|
794
|
+
provenance: {
|
|
795
|
+
provider: `nominatim (${NOMINATIM_BASE_URL})`,
|
|
796
|
+
attribution: OSM_ATTRIBUTION,
|
|
797
|
+
data_timestamp: timestamp,
|
|
798
|
+
cached,
|
|
799
|
+
cache_age_ms,
|
|
800
|
+
usage_policy: "https://operations.osmfoundation.org/policies/nominatim/ (max 1 req/s, cache results, set NOMINATIM_API_URL for high-volume/shared deployments)"
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
log('warn', `Nominatim geocoding failed for '${query}', trying fallback provider`, { error: err.message });
|
|
807
|
+
}
|
|
808
|
+
// 2. Fallback to Photon (Komoot) — configurable via PHOTON_API_URL, cached & rate-limited
|
|
809
|
+
try {
|
|
810
|
+
const photonUrl = `${PHOTON_BASE_URL}/api/?q=${encodeURIComponent(query)}&limit=5`;
|
|
811
|
+
let data;
|
|
812
|
+
let cached = false;
|
|
813
|
+
let cache_age_ms = 0;
|
|
814
|
+
let timestamp = new Date().toISOString();
|
|
815
|
+
const cachedEntry = PHOTON_CACHE.get(photonUrl);
|
|
816
|
+
if (cachedEntry && (Date.now() - cachedEntry.timestamp < NOMINATIM_TTL_MS)) {
|
|
817
|
+
data = cachedEntry.data;
|
|
818
|
+
cached = true;
|
|
819
|
+
cache_age_ms = Date.now() - cachedEntry.timestamp;
|
|
820
|
+
timestamp = new Date(cachedEntry.timestamp).toISOString();
|
|
412
821
|
}
|
|
413
|
-
|
|
414
|
-
|
|
822
|
+
else {
|
|
823
|
+
await photonRateLimit();
|
|
824
|
+
const controller = new AbortController();
|
|
825
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
826
|
+
try {
|
|
827
|
+
const response = await fetch(photonUrl, {
|
|
828
|
+
headers: {
|
|
829
|
+
'User-Agent': APP_USER_AGENT,
|
|
830
|
+
'Accept': 'application/json'
|
|
831
|
+
},
|
|
832
|
+
signal: controller.signal
|
|
833
|
+
});
|
|
834
|
+
if (response.ok) {
|
|
835
|
+
data = await response.json();
|
|
836
|
+
const now = Date.now();
|
|
837
|
+
PHOTON_CACHE.set(photonUrl, { timestamp: now, data });
|
|
838
|
+
timestamp = new Date(now).toISOString();
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
finally {
|
|
842
|
+
clearTimeout(timeout);
|
|
843
|
+
}
|
|
415
844
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
845
|
+
if (data && Array.isArray(data.features) && data.features.length > 0) {
|
|
846
|
+
if (PHOTON_BASE_URL.includes("photon.komoot.io")) {
|
|
847
|
+
warnings?.push("Used public Photon demo server (photon.komoot.io) as geocoding fallback; extensive usage may be throttled. Configure NOMINATIM_API_URL, PHOTON_API_URL, or MAPBOX_ACCESS_TOKEN for production.");
|
|
848
|
+
}
|
|
849
|
+
const photonItems = data.features.map((f) => {
|
|
850
|
+
const coords = f.geometry?.coordinates || [0, 0];
|
|
851
|
+
const center = { lat: coords[1], lng: coords[0] };
|
|
852
|
+
const props = f.properties || {};
|
|
853
|
+
let bbox;
|
|
854
|
+
if (Array.isArray(props.extent) && props.extent.length === 4) {
|
|
855
|
+
bbox = ensureValidBBox(props.extent[3], props.extent[0], props.extent[1], props.extent[2]);
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
bbox = ensureValidBBox(center.lat - 0.05, center.lng - 0.05, center.lat + 0.05, center.lng + 0.05);
|
|
859
|
+
}
|
|
860
|
+
const osmTypeMap = { R: "relation", W: "way", N: "node" };
|
|
861
|
+
const osm_type = osmTypeMap[props.osm_type] || props.osm_type;
|
|
862
|
+
const osm_id = typeof props.osm_id === 'number' ? props.osm_id : parseInt(props.osm_id, 10);
|
|
863
|
+
let osm_area_id;
|
|
864
|
+
if (osm_type === "relation" && !isNaN(osm_id))
|
|
865
|
+
osm_area_id = 3600000000 + osm_id;
|
|
866
|
+
else if (osm_type === "way" && !isNaN(osm_id))
|
|
867
|
+
osm_area_id = 2400000000 + osm_id;
|
|
868
|
+
const labelParts = [props.name, props.city, props.state, props.country].filter(Boolean);
|
|
869
|
+
return {
|
|
870
|
+
matched_place: labelParts.length > 0 ? labelParts.join(", ") : query,
|
|
871
|
+
bbox,
|
|
872
|
+
center,
|
|
873
|
+
osm_area_id,
|
|
874
|
+
class: props.osm_key,
|
|
875
|
+
type: props.osm_value,
|
|
876
|
+
osm_type,
|
|
877
|
+
osm_id: !isNaN(osm_id) ? osm_id : undefined
|
|
878
|
+
};
|
|
879
|
+
});
|
|
419
880
|
return {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
881
|
+
...photonItems[0],
|
|
882
|
+
alternatives: photonItems.slice(1),
|
|
883
|
+
provenance: {
|
|
884
|
+
provider: `photon (${PHOTON_BASE_URL})`,
|
|
885
|
+
attribution: OSM_ATTRIBUTION,
|
|
886
|
+
data_timestamp: timestamp,
|
|
887
|
+
cached,
|
|
888
|
+
cache_age_ms,
|
|
889
|
+
usage_policy: "https://github.com/komoot/photon#demo-server (public demo server; extensive usage may be throttled)"
|
|
890
|
+
}
|
|
424
891
|
};
|
|
425
892
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
log('warn', `Photon fallback geocoding failed for '${query}'`, { error: err.message });
|
|
896
|
+
}
|
|
897
|
+
// 3. Optional fallback to Mapbox if configured
|
|
898
|
+
if (MAPBOX_ACCESS_TOKEN) {
|
|
899
|
+
const controller = new AbortController();
|
|
900
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
901
|
+
try {
|
|
902
|
+
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${MAPBOX_ACCESS_TOKEN}&limit=5`;
|
|
903
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
904
|
+
if (!response.ok) {
|
|
905
|
+
throw new Error(`Geocoding failed with status: ${response.status} ${response.statusText}`);
|
|
906
|
+
}
|
|
907
|
+
const data = await response.json();
|
|
908
|
+
if (!data || !Array.isArray(data.features) || data.features.length === 0) {
|
|
909
|
+
throw new Error(`No results found for location: ${query}`);
|
|
910
|
+
}
|
|
911
|
+
const mapboxItems = data.features.map((feature) => {
|
|
912
|
+
let bbox;
|
|
913
|
+
let center;
|
|
914
|
+
if (feature.bbox) {
|
|
915
|
+
bbox = ensureValidBBox(feature.bbox[1], feature.bbox[0], feature.bbox[3], feature.bbox[2]);
|
|
916
|
+
center = { lat: (bbox.lat1 + bbox.lat2) / 2, lng: (bbox.lng1 + bbox.lng2) / 2 };
|
|
917
|
+
}
|
|
918
|
+
else if (feature.center) {
|
|
919
|
+
center = { lat: feature.center[1], lng: feature.center[0] };
|
|
920
|
+
bbox = ensureValidBBox(center.lat - 0.05, center.lng - 0.05, center.lat + 0.05, center.lng + 0.05);
|
|
921
|
+
}
|
|
922
|
+
else {
|
|
923
|
+
throw new Error(`Unable to determine location bounds for feature`);
|
|
924
|
+
}
|
|
925
|
+
return {
|
|
926
|
+
matched_place: feature.place_name || feature.text || query,
|
|
927
|
+
bbox,
|
|
928
|
+
center,
|
|
929
|
+
class: feature.properties?.category
|
|
930
|
+
};
|
|
931
|
+
});
|
|
932
|
+
const primary = mapboxItems[0];
|
|
933
|
+
const alternatives = mapboxItems.slice(1);
|
|
431
934
|
return {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
935
|
+
...primary,
|
|
936
|
+
alternatives,
|
|
937
|
+
provenance: {
|
|
938
|
+
provider: "mapbox (api.mapbox.com)",
|
|
939
|
+
attribution: "© Mapbox © OpenStreetMap",
|
|
940
|
+
data_timestamp: new Date().toISOString(),
|
|
941
|
+
cached: false,
|
|
942
|
+
cache_age_ms: 0
|
|
943
|
+
}
|
|
436
944
|
};
|
|
437
945
|
}
|
|
946
|
+
catch (err) {
|
|
947
|
+
if (err.name === 'AbortError') {
|
|
948
|
+
throw new Error(`Geocoding request timed out for query: ${query}`);
|
|
949
|
+
}
|
|
950
|
+
throw err;
|
|
951
|
+
}
|
|
952
|
+
finally {
|
|
953
|
+
clearTimeout(timeout);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
throw new Error(`No geocoding results found for location: ${query}`);
|
|
957
|
+
}
|
|
958
|
+
async function reverseGeocode(lat, lng, zoom = 18) {
|
|
959
|
+
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
|
960
|
+
throw new Error(`Invalid lat/lng coordinates for reverse geocoding: (${lat}, ${lng})`);
|
|
961
|
+
}
|
|
962
|
+
const clampedZoom = Math.max(0, Math.min(18, Math.round(zoom)));
|
|
963
|
+
const emailParam = process.env.NOMINATIM_EMAIL ? `&email=${encodeURIComponent(process.env.NOMINATIM_EMAIL)}` : "";
|
|
964
|
+
const url = `${NOMINATIM_BASE_URL}/reverse?format=json&lat=${lat}&lon=${lng}&zoom=${clampedZoom}&addressdetails=1${emailParam}`;
|
|
965
|
+
const { data, cached, cache_age_ms, timestamp } = await fetchNominatim(url);
|
|
966
|
+
if (!data || !data.display_name) {
|
|
967
|
+
throw new Error(`No reverse geocoding results found for coordinates: (${lat}, ${lng})`);
|
|
968
|
+
}
|
|
969
|
+
let bbox;
|
|
970
|
+
if (data.boundingbox && data.boundingbox.length === 4) {
|
|
971
|
+
const south = parseFloat(data.boundingbox[0]);
|
|
972
|
+
const north = parseFloat(data.boundingbox[1]);
|
|
973
|
+
const west = parseFloat(data.boundingbox[2]);
|
|
974
|
+
const east = parseFloat(data.boundingbox[3]);
|
|
975
|
+
bbox = ensureValidBBox(south, west, north, east);
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
bbox = ensureValidBBox(lat - 0.005, lng - 0.005, lat + 0.005, lng + 0.005);
|
|
979
|
+
}
|
|
980
|
+
const shareUrl = buildShareUrlMarkersOnly([{ coordinates: { lat, lon: lng }, name: data.display_name }]);
|
|
981
|
+
return {
|
|
982
|
+
matched_place: data.display_name,
|
|
983
|
+
center: { lat, lng },
|
|
984
|
+
bbox,
|
|
985
|
+
address: data.address || {},
|
|
986
|
+
...(data.class ? { class: String(data.class) } : {}),
|
|
987
|
+
...(data.type ? { type: String(data.type) } : {}),
|
|
988
|
+
...(data.osm_type ? { osm_type: String(data.osm_type) } : {}),
|
|
989
|
+
...(data.osm_id ? { osm_id: parseInt(data.osm_id, 10) } : {}),
|
|
990
|
+
share_url: shareUrl,
|
|
991
|
+
provenance: {
|
|
992
|
+
provider: `nominatim (${NOMINATIM_BASE_URL})`,
|
|
993
|
+
attribution: OSM_ATTRIBUTION,
|
|
994
|
+
data_timestamp: timestamp,
|
|
995
|
+
cached,
|
|
996
|
+
cache_age_ms,
|
|
997
|
+
usage_policy: "https://operations.osmfoundation.org/policies/nominatim/"
|
|
998
|
+
},
|
|
999
|
+
complete: true,
|
|
1000
|
+
warnings: []
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
function splitOverpassStatements(query) {
|
|
1004
|
+
const statements = [];
|
|
1005
|
+
let current = '';
|
|
1006
|
+
let inDouble = false;
|
|
1007
|
+
let inSingle = false;
|
|
1008
|
+
for (let i = 0; i < query.length; i++) {
|
|
1009
|
+
const char = query[i];
|
|
1010
|
+
if (char === '"' && !inSingle) {
|
|
1011
|
+
inDouble = !inDouble;
|
|
1012
|
+
current += char;
|
|
1013
|
+
}
|
|
1014
|
+
else if (char === "'" && !inDouble) {
|
|
1015
|
+
inSingle = !inSingle;
|
|
1016
|
+
current += char;
|
|
1017
|
+
}
|
|
1018
|
+
else if (char === ';' && !inDouble && !inSingle) {
|
|
1019
|
+
if (current.trim()) {
|
|
1020
|
+
statements.push(current.trim());
|
|
1021
|
+
}
|
|
1022
|
+
current = '';
|
|
1023
|
+
}
|
|
438
1024
|
else {
|
|
439
|
-
|
|
1025
|
+
current += char;
|
|
440
1026
|
}
|
|
441
1027
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
1028
|
+
if (current.trim()) {
|
|
1029
|
+
statements.push(current.trim());
|
|
1030
|
+
}
|
|
1031
|
+
return statements;
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Attaches a spatial filter (e.g. `(area.searchArea)` or `(around:2000,lat,lng)`)
|
|
1035
|
+
* to every selector statement in a multi-statement Overpass QL query string.
|
|
1036
|
+
* Respects quoted semicolons in tag values and skips non-selector/output statements.
|
|
1037
|
+
*/
|
|
1038
|
+
function applySpatialFilter(queryRaw, spatialFilter) {
|
|
1039
|
+
if (!spatialFilter)
|
|
1040
|
+
return queryRaw.trim().endsWith(';') ? queryRaw.trim() : `${queryRaw.trim()};`;
|
|
1041
|
+
const statements = splitOverpassStatements(queryRaw);
|
|
1042
|
+
if (statements.length === 0)
|
|
1043
|
+
return queryRaw;
|
|
1044
|
+
const processed = statements.map(stmt => {
|
|
1045
|
+
// Skip non-selector statements like 'out center', directives, set operations, comments
|
|
1046
|
+
if (/^(out|[()\[]|->)/i.test(stmt) || /\bout\s+(body|skel|ids|meta|geom|center)/i.test(stmt)) {
|
|
1047
|
+
return stmt;
|
|
445
1048
|
}
|
|
446
|
-
|
|
1049
|
+
if (stmt.includes(spatialFilter.trim())) {
|
|
1050
|
+
return stmt;
|
|
1051
|
+
}
|
|
1052
|
+
// If statement defines a named set (e.g., `... -> .searchSet`), insert spatial filter before `->`
|
|
1053
|
+
if (/->\s*\.[a-zA-Z0-9_]+/.test(stmt)) {
|
|
1054
|
+
return stmt.replace(/(->\s*\.[a-zA-Z0-9_]+)/, `${spatialFilter} $1`);
|
|
1055
|
+
}
|
|
1056
|
+
return `${stmt}${spatialFilter}`;
|
|
1057
|
+
});
|
|
1058
|
+
return processed.join('; ') + ';';
|
|
1059
|
+
}
|
|
1060
|
+
function calculateHaversineDistanceMeters(lat1, lng1, lat2, lng2) {
|
|
1061
|
+
const R = 6371000; // Earth radius in meters
|
|
1062
|
+
const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
1063
|
+
const dLng = (lng2 - lng1) * Math.PI / 180;
|
|
1064
|
+
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
1065
|
+
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
1066
|
+
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
|
1067
|
+
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
1068
|
+
return R * c;
|
|
1069
|
+
}
|
|
1070
|
+
function calculateBearingDegrees(lat1, lng1, lat2, lng2) {
|
|
1071
|
+
const phi1 = (lat1 * Math.PI) / 180;
|
|
1072
|
+
const phi2 = (lat2 * Math.PI) / 180;
|
|
1073
|
+
const dLambda = ((lng2 - lng1) * Math.PI) / 180;
|
|
1074
|
+
const y = Math.sin(dLambda) * Math.cos(phi2);
|
|
1075
|
+
const x = Math.cos(phi1) * Math.sin(phi2) - Math.sin(phi1) * Math.cos(phi2) * Math.cos(dLambda);
|
|
1076
|
+
const theta = Math.atan2(y, x);
|
|
1077
|
+
const degrees = ((theta * 180) / Math.PI + 360) % 360;
|
|
1078
|
+
const dirs = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
|
|
1079
|
+
const cardinal = dirs[Math.round(degrees / 22.5) % 16];
|
|
1080
|
+
return { degrees: Math.round(degrees * 100) / 100, cardinal };
|
|
1081
|
+
}
|
|
1082
|
+
function isPointInPolygonRing(lat, lng, ringLatLng) {
|
|
1083
|
+
let inside = false;
|
|
1084
|
+
for (let i = 0, j = ringLatLng.length - 1; i < ringLatLng.length; j = i++) {
|
|
1085
|
+
const yi = ringLatLng[i][0], xi = ringLatLng[i][1];
|
|
1086
|
+
const yj = ringLatLng[j][0], xj = ringLatLng[j][1];
|
|
1087
|
+
const intersect = ((yi > lat) !== (yj > lat)) &&
|
|
1088
|
+
(lng < ((xj - xi) * (lat - yi)) / (yj - yi + 1e-15) + xi);
|
|
1089
|
+
if (intersect)
|
|
1090
|
+
inside = !inside;
|
|
1091
|
+
}
|
|
1092
|
+
return inside;
|
|
1093
|
+
}
|
|
1094
|
+
function isPointInPolygonWithHoles(lat, lng, polyRingsLatLng) {
|
|
1095
|
+
if (!polyRingsLatLng || polyRingsLatLng.length === 0)
|
|
1096
|
+
return false;
|
|
1097
|
+
if (!isPointInPolygonRing(lat, lng, polyRingsLatLng[0]))
|
|
1098
|
+
return false;
|
|
1099
|
+
for (let h = 1; h < polyRingsLatLng.length; h++) {
|
|
1100
|
+
if (isPointInPolygonRing(lat, lng, polyRingsLatLng[h])) {
|
|
1101
|
+
return false; // Point lies inside an interior hole
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return true;
|
|
1105
|
+
}
|
|
1106
|
+
// ---------------------------------------------------------------------------
|
|
1107
|
+
// Overpass & Taginfo Bounded LRU Caches + Shared Overpass Executor
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
const OVERPASS_CACHE = new Map();
|
|
1110
|
+
const OVERPASS_CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
|
1111
|
+
const OVERPASS_CACHE_MAX = 100;
|
|
1112
|
+
const OVERPASS_INFLIGHT = new Map();
|
|
1113
|
+
let preferredOverpassEndpoint = null;
|
|
1114
|
+
const TAGINFO_CACHE = new Map();
|
|
1115
|
+
const TAGINFO_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
1116
|
+
const ROUTE_CACHE = new Map();
|
|
1117
|
+
const ROUTE_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
1118
|
+
const ELEVATION_CACHE = new Map();
|
|
1119
|
+
const ELEVATION_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
1120
|
+
async function executeOverpassQuery(overpassQuery, timeoutSec) {
|
|
1121
|
+
const cached = OVERPASS_CACHE.get(overpassQuery);
|
|
1122
|
+
if (cached && (Date.now() - cached.timestamp < OVERPASS_CACHE_TTL_MS)) {
|
|
1123
|
+
log('info', 'Overpass cache hit');
|
|
1124
|
+
const osmTimestamp = cached.data.osm3s?.timestamp_osm_base || new Date(cached.timestamp).toISOString();
|
|
1125
|
+
return {
|
|
1126
|
+
data: cached.data,
|
|
1127
|
+
provenance: {
|
|
1128
|
+
provider: `overpass (${cached.endpoint})`,
|
|
1129
|
+
attribution: OSM_ATTRIBUTION,
|
|
1130
|
+
data_timestamp: osmTimestamp,
|
|
1131
|
+
cached: true,
|
|
1132
|
+
cache_age_ms: Date.now() - cached.timestamp
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
const existingInflight = OVERPASS_INFLIGHT.get(overpassQuery);
|
|
1137
|
+
if (existingInflight) {
|
|
1138
|
+
log('info', 'Joining in-flight Overpass query');
|
|
1139
|
+
return existingInflight;
|
|
1140
|
+
}
|
|
1141
|
+
const promise = executeOverpassQueryUncached(overpassQuery, timeoutSec).finally(() => {
|
|
1142
|
+
OVERPASS_INFLIGHT.delete(overpassQuery);
|
|
1143
|
+
});
|
|
1144
|
+
OVERPASS_INFLIGHT.set(overpassQuery, promise);
|
|
1145
|
+
return promise;
|
|
1146
|
+
}
|
|
1147
|
+
async function executeOverpassQueryUncached(overpassQuery, timeoutSec) {
|
|
1148
|
+
// Distinct independent operators:
|
|
1149
|
+
// 1. overpass-api.de (FOSSGIS / main German instance)
|
|
1150
|
+
// 2. overpass.private.coffee (Private.coffee — successor to Kumi Systems)
|
|
1151
|
+
// 3. overpass.openstreetmap.fr (OSM France instance)
|
|
1152
|
+
// 4. maps.mail.ru (VK Maps independent Overpass mirror)
|
|
1153
|
+
const defaultEndpoints = [
|
|
1154
|
+
"https://overpass-api.de/api/interpreter",
|
|
1155
|
+
"https://overpass.private.coffee/api/interpreter",
|
|
1156
|
+
"https://overpass.openstreetmap.fr/api/interpreter",
|
|
1157
|
+
"https://maps.mail.ru/osm/tools/overpass/api/interpreter"
|
|
1158
|
+
];
|
|
1159
|
+
const customEndpoints = process.env.OVERPASS_API_URL
|
|
1160
|
+
? process.env.OVERPASS_API_URL.split(",").map(s => s.trim()).filter(Boolean)
|
|
1161
|
+
: [];
|
|
1162
|
+
const baseEndpoints = process.env.OVERPASS_DISABLE_PUBLIC_FALLBACK === "1" && customEndpoints.length > 0
|
|
1163
|
+
? customEndpoints
|
|
1164
|
+
: [...customEndpoints, ...defaultEndpoints];
|
|
1165
|
+
const endpoints = preferredOverpassEndpoint
|
|
1166
|
+
? [preferredOverpassEndpoint, ...baseEndpoints.filter(e => e !== preferredOverpassEndpoint)]
|
|
1167
|
+
: baseEndpoints;
|
|
1168
|
+
const params = new URLSearchParams();
|
|
1169
|
+
params.append('data', overpassQuery);
|
|
1170
|
+
const bodyStr = params.toString();
|
|
1171
|
+
const clientTimeoutMs = endpoints.length > 1
|
|
1172
|
+
? Math.min((timeoutSec + 5) * 1000, 18000)
|
|
1173
|
+
: (timeoutSec + 5) * 1000;
|
|
1174
|
+
// Adaptive hedging: start endpoint[0] immediately; start each subsequent endpoint
|
|
1175
|
+
// either after 1200ms OR immediately if the previous endpoint fails early (e.g. HTTP 502/504).
|
|
1176
|
+
const controllers = [];
|
|
1177
|
+
const timers = [];
|
|
1178
|
+
const startCallbacks = [];
|
|
1179
|
+
let settled = false;
|
|
1180
|
+
const queryEndpoint = (endpoint, idx) => new Promise((resolve, reject) => {
|
|
1181
|
+
let started = false;
|
|
1182
|
+
const startRequest = async () => {
|
|
1183
|
+
if (started)
|
|
1184
|
+
return;
|
|
1185
|
+
started = true;
|
|
1186
|
+
if (settled) {
|
|
1187
|
+
reject(new Error("Cancelled"));
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
const controller = new AbortController();
|
|
1191
|
+
controllers.push(controller);
|
|
1192
|
+
const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
|
|
1193
|
+
try {
|
|
1194
|
+
log('info', `Executing Overpass query at ${endpoint}`);
|
|
1195
|
+
const response = await fetch(endpoint, {
|
|
1196
|
+
method: "POST",
|
|
1197
|
+
body: bodyStr,
|
|
1198
|
+
headers: {
|
|
1199
|
+
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
1200
|
+
'Accept': 'application/json',
|
|
1201
|
+
'User-Agent': APP_USER_AGENT
|
|
1202
|
+
},
|
|
1203
|
+
signal: controller.signal
|
|
1204
|
+
});
|
|
1205
|
+
if (!response.ok) {
|
|
1206
|
+
throw new Error(`Overpass API returned status: ${response.status} ${response.statusText}`);
|
|
1207
|
+
}
|
|
1208
|
+
const candidate = await response.json();
|
|
1209
|
+
// Detect Overpass runtime errors (e.g. query timeout, out of memory) returned inside HTTP 200 JSON
|
|
1210
|
+
if (typeof candidate?.remark === 'string' && /runtime error|timed out|out of memory|error/i.test(candidate.remark)) {
|
|
1211
|
+
throw new Error(`Overpass runtime error: ${candidate.remark}`);
|
|
1212
|
+
}
|
|
1213
|
+
if (!candidate || !Array.isArray(candidate.elements)) {
|
|
1214
|
+
throw new Error(`Overpass response missing 'elements' array`);
|
|
1215
|
+
}
|
|
1216
|
+
resolve({ data: candidate, endpoint });
|
|
1217
|
+
}
|
|
1218
|
+
catch (err) {
|
|
1219
|
+
if (!settled) {
|
|
1220
|
+
log('warn', `Overpass API failed at ${endpoint}`, { error: err.message });
|
|
1221
|
+
// Immediately wake the next endpoint in line if it hasn't started yet
|
|
1222
|
+
if (startCallbacks[idx + 1]) {
|
|
1223
|
+
startCallbacks[idx + 1]();
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
reject(err);
|
|
1227
|
+
}
|
|
1228
|
+
finally {
|
|
1229
|
+
clearTimeout(timeout);
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
startCallbacks[idx] = startRequest;
|
|
1233
|
+
if (idx === 0) {
|
|
1234
|
+
startRequest();
|
|
1235
|
+
}
|
|
1236
|
+
else {
|
|
1237
|
+
const t = setTimeout(startRequest, idx * 1200);
|
|
1238
|
+
timers.push(t);
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
let data = null;
|
|
1242
|
+
let usedEndpoint = endpoints[0];
|
|
1243
|
+
try {
|
|
1244
|
+
const winner = await Promise.any(endpoints.map((ep, idx) => queryEndpoint(ep, idx)));
|
|
1245
|
+
settled = true;
|
|
1246
|
+
data = winner.data;
|
|
1247
|
+
usedEndpoint = winner.endpoint;
|
|
1248
|
+
preferredOverpassEndpoint = usedEndpoint;
|
|
1249
|
+
}
|
|
1250
|
+
catch (aggErr) {
|
|
1251
|
+
settled = true;
|
|
1252
|
+
const errors = Array.isArray(aggErr?.errors) ? aggErr.errors : [aggErr];
|
|
1253
|
+
const nonAbort = errors.find(e => e && e.name !== 'AbortError' && e.message !== 'Cancelled');
|
|
1254
|
+
if (!nonAbort && errors.some(e => e?.name === 'AbortError')) {
|
|
1255
|
+
throw new Error(`Overpass API request timed out on all endpoints after ${clientTimeoutMs / 1000}s. The query or area may be too large.`);
|
|
1256
|
+
}
|
|
1257
|
+
const errMsg = (nonAbort || errors[0])?.message || "Unknown Overpass error";
|
|
1258
|
+
throw new Error(`Overpass query failed on all endpoints: ${errMsg}`);
|
|
447
1259
|
}
|
|
448
1260
|
finally {
|
|
449
|
-
|
|
1261
|
+
settled = true;
|
|
1262
|
+
for (const t of timers)
|
|
1263
|
+
clearTimeout(t);
|
|
1264
|
+
for (const c of controllers) {
|
|
1265
|
+
try {
|
|
1266
|
+
c.abort();
|
|
1267
|
+
}
|
|
1268
|
+
catch { /* ignore */ }
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
const now = Date.now();
|
|
1272
|
+
if (OVERPASS_CACHE.size >= OVERPASS_CACHE_MAX) {
|
|
1273
|
+
const oldestKey = OVERPASS_CACHE.keys().next().value;
|
|
1274
|
+
if (oldestKey)
|
|
1275
|
+
OVERPASS_CACHE.delete(oldestKey);
|
|
450
1276
|
}
|
|
1277
|
+
OVERPASS_CACHE.set(overpassQuery, { timestamp: now, data, endpoint: usedEndpoint });
|
|
1278
|
+
const osmTimestamp = data.osm3s?.timestamp_osm_base || new Date(now).toISOString();
|
|
1279
|
+
return {
|
|
1280
|
+
data,
|
|
1281
|
+
provenance: {
|
|
1282
|
+
provider: `overpass (${usedEndpoint})`,
|
|
1283
|
+
attribution: OSM_ATTRIBUTION,
|
|
1284
|
+
data_timestamp: osmTimestamp,
|
|
1285
|
+
cached: false,
|
|
1286
|
+
cache_age_ms: 0
|
|
1287
|
+
},
|
|
1288
|
+
remarkWarning: typeof data.remark === 'string' ? data.remark : undefined
|
|
1289
|
+
};
|
|
451
1290
|
}
|
|
452
|
-
async function
|
|
453
|
-
|
|
454
|
-
|
|
1291
|
+
async function getTaginfoSuggestions(keyword) {
|
|
1292
|
+
const cacheKey = keyword.toLowerCase().trim();
|
|
1293
|
+
const cached = TAGINFO_CACHE.get(cacheKey);
|
|
1294
|
+
if (cached && (Date.now() - cached.timestamp < TAGINFO_CACHE_TTL_MS)) {
|
|
1295
|
+
return {
|
|
1296
|
+
text: cached.text,
|
|
1297
|
+
tags: cached.tags,
|
|
1298
|
+
cached: true,
|
|
1299
|
+
cache_age_ms: Date.now() - cached.timestamp,
|
|
1300
|
+
timestamp: new Date(cached.timestamp).toISOString()
|
|
1301
|
+
};
|
|
455
1302
|
}
|
|
1303
|
+
const MAINSTREAM_KEYS = new Set([
|
|
1304
|
+
"amenity", "shop", "leisure", "tourism", "natural", "highway",
|
|
1305
|
+
"office", "healthcare", "craft", "sport", "landuse", "building",
|
|
1306
|
+
"historic", "emergency", "railway", "aeroway", "public_transport",
|
|
1307
|
+
"man_made", "Waterway", "waterway", "barrier", "power", "place"
|
|
1308
|
+
]);
|
|
456
1309
|
const controller = new AbortController();
|
|
457
1310
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
458
1311
|
try {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
return { lat: feature.center[1], lng: feature.center[0] };
|
|
469
|
-
}
|
|
470
|
-
else if (feature.bbox) {
|
|
471
|
-
return { lat: (feature.bbox[1] + feature.bbox[3]) / 2, lng: (feature.bbox[0] + feature.bbox[2]) / 2 };
|
|
1312
|
+
let items = [];
|
|
1313
|
+
if (MAINSTREAM_KEYS.has(cacheKey)) {
|
|
1314
|
+
// User asked for a top-level OSM key (e.g. "amenity", "shop", "tourism") -> fetch its most popular values
|
|
1315
|
+
const keyUrl = `https://taginfo.openstreetmap.org/api/4/keys/values?key=${encodeURIComponent(cacheKey)}&sortname=count_all&sortorder=desc&rp=15`;
|
|
1316
|
+
const res = await fetch(keyUrl, { headers: { 'User-Agent': APP_USER_AGENT }, signal: controller.signal });
|
|
1317
|
+
if (res.ok) {
|
|
1318
|
+
const json = await res.json();
|
|
1319
|
+
items = (json.data || []).map((d) => ({ key: cacheKey, value: d.value, count_all: d.count }));
|
|
1320
|
+
}
|
|
472
1321
|
}
|
|
473
1322
|
else {
|
|
474
|
-
|
|
1323
|
+
// Query both by_value (sorted by real-world count_all) and by_keyword (matches natural-language wiki terms)
|
|
1324
|
+
const valUrl = `https://taginfo.openstreetmap.org/api/4/search/by_value?query=${encodeURIComponent(keyword)}&sortname=count_all&sortorder=desc&rp=25`;
|
|
1325
|
+
const kwUrl = `https://taginfo.openstreetmap.org/api/4/search/by_keyword?query=${encodeURIComponent(keyword)}&rp=25`;
|
|
1326
|
+
const [valRes, kwRes] = await Promise.allSettled([
|
|
1327
|
+
fetch(valUrl, { headers: { 'User-Agent': APP_USER_AGENT }, signal: controller.signal }),
|
|
1328
|
+
fetch(kwUrl, { headers: { 'User-Agent': APP_USER_AGENT }, signal: controller.signal })
|
|
1329
|
+
]);
|
|
1330
|
+
if (valRes.status === "fulfilled" && valRes.value.ok) {
|
|
1331
|
+
const json = await valRes.value.json();
|
|
1332
|
+
items.push(...(json.data || []));
|
|
1333
|
+
}
|
|
1334
|
+
if (kwRes.status === "fulfilled" && kwRes.value.ok) {
|
|
1335
|
+
const json = await kwRes.value.json();
|
|
1336
|
+
items.push(...(json.data || []));
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
const seenSelectors = new Set();
|
|
1340
|
+
const filtered = items
|
|
1341
|
+
.filter((item) => {
|
|
1342
|
+
if (!item.key || !MAINSTREAM_KEYS.has(item.key))
|
|
1343
|
+
return false;
|
|
1344
|
+
if (item.count_all !== undefined && item.count_all < 1000)
|
|
1345
|
+
return false;
|
|
1346
|
+
const valStr = item.value ? `="${item.value}"` : '';
|
|
1347
|
+
const selector = `nwr["${item.key}"${valStr}]`;
|
|
1348
|
+
if (seenSelectors.has(selector))
|
|
1349
|
+
return false;
|
|
1350
|
+
seenSelectors.add(selector);
|
|
1351
|
+
return true;
|
|
1352
|
+
})
|
|
1353
|
+
.sort((a, b) => (b.count_all ?? 0) - (a.count_all ?? 0))
|
|
1354
|
+
.slice(0, 10);
|
|
1355
|
+
const now = Date.now();
|
|
1356
|
+
if (filtered.length === 0) {
|
|
1357
|
+
const text = `No mainstream OSM tags found for '${keyword}' with usage count > 1,000.\nTry searching standard keys like amenity, shop, leisure, or tourism.`;
|
|
1358
|
+
return {
|
|
1359
|
+
text,
|
|
1360
|
+
tags: [],
|
|
1361
|
+
cached: false,
|
|
1362
|
+
cache_age_ms: 0,
|
|
1363
|
+
timestamp: new Date(now).toISOString()
|
|
1364
|
+
};
|
|
475
1365
|
}
|
|
1366
|
+
const tags = [];
|
|
1367
|
+
const lines = filtered.map((item) => {
|
|
1368
|
+
const valStr = item.value ? `="${item.value}"` : '';
|
|
1369
|
+
const selector = `nwr["${item.key}"${valStr}]`;
|
|
1370
|
+
tags.push(selector);
|
|
1371
|
+
const countInfo = item.count_all !== undefined ? ` (${item.count_all.toLocaleString()} objects)` : '';
|
|
1372
|
+
return `- ${selector}${countInfo}`;
|
|
1373
|
+
});
|
|
1374
|
+
const text = `Taginfo OSM Tag Recommendations for '${keyword}':\n\n${lines.join('\n')}`;
|
|
1375
|
+
TAGINFO_CACHE.set(cacheKey, { timestamp: now, text, tags });
|
|
1376
|
+
return {
|
|
1377
|
+
text,
|
|
1378
|
+
tags,
|
|
1379
|
+
cached: false,
|
|
1380
|
+
cache_age_ms: 0,
|
|
1381
|
+
timestamp: new Date(now).toISOString()
|
|
1382
|
+
};
|
|
476
1383
|
}
|
|
477
1384
|
catch (err) {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
1385
|
+
return {
|
|
1386
|
+
text: `Taginfo lookup failed for '${keyword}': ${err.message}`,
|
|
1387
|
+
tags: [],
|
|
1388
|
+
cached: false,
|
|
1389
|
+
cache_age_ms: 0,
|
|
1390
|
+
timestamp: new Date().toISOString(),
|
|
1391
|
+
warning: `Taginfo lookup failed: ${err.message}`
|
|
1392
|
+
};
|
|
481
1393
|
}
|
|
482
1394
|
finally {
|
|
483
1395
|
clearTimeout(timeout);
|
|
@@ -500,22 +1412,55 @@ function buildShareUrlCircleWithMarkers(center, radiusMeters, elements) {
|
|
|
500
1412
|
// ---------------------------------------------------------------------------
|
|
501
1413
|
// Tool Handlers
|
|
502
1414
|
// ---------------------------------------------------------------------------
|
|
1415
|
+
const BBOX_OBJ_SCHEMA = {
|
|
1416
|
+
type: "object",
|
|
1417
|
+
properties: {
|
|
1418
|
+
lat1: { type: "number" },
|
|
1419
|
+
lng1: { type: "number" },
|
|
1420
|
+
lat2: { type: "number" },
|
|
1421
|
+
lng2: { type: "number" }
|
|
1422
|
+
},
|
|
1423
|
+
required: ["lat1", "lng1", "lat2", "lng2"]
|
|
1424
|
+
};
|
|
1425
|
+
const PROVENANCE_SCHEMA = {
|
|
1426
|
+
type: "object",
|
|
1427
|
+
properties: {
|
|
1428
|
+
provider: { type: "string" },
|
|
1429
|
+
attribution: { type: "string" },
|
|
1430
|
+
data_timestamp: { type: "string" },
|
|
1431
|
+
cached: { type: "boolean" },
|
|
1432
|
+
cache_age_ms: { type: "number" },
|
|
1433
|
+
usage_policy: { type: "string" }
|
|
1434
|
+
},
|
|
1435
|
+
required: ["provider", "data_timestamp", "cached", "cache_age_ms"]
|
|
1436
|
+
};
|
|
503
1437
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
504
1438
|
return {
|
|
505
1439
|
tools: [
|
|
506
1440
|
{
|
|
507
1441
|
name: "get_bounds",
|
|
508
|
-
description: "Get converted coordinates for a bounding box or
|
|
1442
|
+
description: "Get converted coordinates for a bounding box or natural-language location search (powered by Nominatim with configurable Photon & optional Mapbox fallbacks). Supports parsing WKT, GeoJSON, ogrinfo extent, Web Mercator tiles ('z/x/y'), single points ('lat,lng'), and raw coordinate strings, plus optional metric buffering.",
|
|
1443
|
+
annotations: {
|
|
1444
|
+
title: "Get Bounding Box & Projections",
|
|
1445
|
+
readOnlyHint: true,
|
|
1446
|
+
destructiveHint: false,
|
|
1447
|
+
idempotentHint: true,
|
|
1448
|
+
openWorldHint: true
|
|
1449
|
+
},
|
|
509
1450
|
inputSchema: {
|
|
510
1451
|
type: "object",
|
|
511
1452
|
properties: {
|
|
512
1453
|
location: {
|
|
513
1454
|
type: "string",
|
|
514
|
-
description: "A text location to search for (e.g. 'New York City').
|
|
1455
|
+
description: "A text location to search for (e.g. 'New York City', 'Central Park'). Uses OpenStreetMap Nominatim (or configurable NOMINATIM_API_URL / PHOTON_API_URL / optional MAPBOX_ACCESS_TOKEN). Either 'location' or 'bbox' MUST be provided."
|
|
515
1456
|
},
|
|
516
1457
|
bbox: {
|
|
517
1458
|
type: "string",
|
|
518
|
-
description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), a WKT polygon/linestring/point, a GeoJSON payload, or an `ogrinfo` extent block.
|
|
1459
|
+
description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), a single point ('lat,lng'), a Web Mercator tile ('z/x/y'), a WKT polygon/linestring/point, a GeoJSON payload, or an `ogrinfo` extent block. Either 'location' or 'bbox' MUST be provided."
|
|
1460
|
+
},
|
|
1461
|
+
buffer_meters: {
|
|
1462
|
+
type: "number",
|
|
1463
|
+
description: "Optional distance in meters to expand (positive) or shrink (negative) the bounding box in all directions. Ideal for creating a bounding box around a single point."
|
|
519
1464
|
},
|
|
520
1465
|
epsg: {
|
|
521
1466
|
type: "string",
|
|
@@ -545,20 +1490,70 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
545
1490
|
{ required: ["bbox"] }
|
|
546
1491
|
]
|
|
547
1492
|
},
|
|
548
|
-
|
|
549
|
-
{
|
|
550
|
-
name: "get_h3_indices",
|
|
551
|
-
description: "Get Uber H3 cell indices for a bounding box area at a target resolution. Input supports WKT, GeoJSON, ogrinfo extent, and raw coordinate strings.",
|
|
552
|
-
inputSchema: {
|
|
1493
|
+
outputSchema: {
|
|
553
1494
|
type: "object",
|
|
554
1495
|
properties: {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1496
|
+
matched_place: { type: "string" },
|
|
1497
|
+
alternatives: { type: "array", items: { type: "object" } },
|
|
1498
|
+
original_wgs84: BBOX_OBJ_SCHEMA,
|
|
1499
|
+
projected: {
|
|
1500
|
+
type: "object",
|
|
1501
|
+
properties: {
|
|
1502
|
+
xmin: { type: "number" },
|
|
1503
|
+
ymin: { type: "number" },
|
|
1504
|
+
xmax: { type: "number" },
|
|
1505
|
+
ymax: { type: "number" }
|
|
1506
|
+
},
|
|
1507
|
+
required: ["xmin", "ymin", "xmax", "ymax"]
|
|
1508
|
+
},
|
|
1509
|
+
center: {
|
|
1510
|
+
type: "object",
|
|
1511
|
+
properties: { lat: { type: "number" }, lng: { type: "number" } },
|
|
1512
|
+
required: ["lat", "lng"]
|
|
1513
|
+
},
|
|
1514
|
+
tile_indices: {
|
|
1515
|
+
type: "object",
|
|
1516
|
+
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
|
|
1517
|
+
required: ["x", "y", "z"]
|
|
1518
|
+
},
|
|
1519
|
+
epsg: { type: "string" },
|
|
1520
|
+
format: { type: "string" },
|
|
1521
|
+
coord_order: { type: "string" },
|
|
1522
|
+
precision: { type: "number" },
|
|
1523
|
+
area_km2: { type: "number" },
|
|
1524
|
+
dimensions: {
|
|
1525
|
+
type: "object",
|
|
1526
|
+
properties: { width_km: { type: "number" }, height_km: { type: "number" } },
|
|
1527
|
+
required: ["width_km", "height_km"]
|
|
1528
|
+
},
|
|
1529
|
+
share_url: { type: "string" },
|
|
1530
|
+
provenance: PROVENANCE_SCHEMA,
|
|
1531
|
+
complete: { type: "boolean" },
|
|
1532
|
+
warnings: { type: "array", items: { type: "string" } }
|
|
1533
|
+
},
|
|
1534
|
+
required: ["original_wgs84", "projected", "center", "tile_indices", "epsg", "format", "coord_order", "precision", "area_km2", "dimensions", "share_url", "provenance", "complete", "warnings"]
|
|
1535
|
+
}
|
|
1536
|
+
},
|
|
1537
|
+
{
|
|
1538
|
+
name: "get_h3_indices",
|
|
1539
|
+
description: "Get Uber H3 cell indices for a polygon (including interior holes), location boundary, or bounding box at a target resolution. When a WKT/GeoJSON polygon or named location boundary is provided, fills the exact polygon geometry (subtracting interior holes) rather than just its bounding rectangle.",
|
|
1540
|
+
annotations: {
|
|
1541
|
+
title: "Generate H3 Hexagonal Grid Indices",
|
|
1542
|
+
readOnlyHint: true,
|
|
1543
|
+
destructiveHint: false,
|
|
1544
|
+
idempotentHint: true,
|
|
1545
|
+
openWorldHint: true
|
|
1546
|
+
},
|
|
1547
|
+
inputSchema: {
|
|
1548
|
+
type: "object",
|
|
1549
|
+
properties: {
|
|
1550
|
+
location: {
|
|
1551
|
+
type: "string",
|
|
1552
|
+
description: "A text location to search for (e.g. 'San Francisco'). Either 'location' or 'bbox' MUST be provided."
|
|
558
1553
|
},
|
|
559
1554
|
bbox: {
|
|
560
1555
|
type: "string",
|
|
561
|
-
description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), WKT, GeoJSON, or ogrinfo extent. Either 'location' or 'bbox' MUST be provided.",
|
|
1556
|
+
description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), WKT polygon (with optional holes), GeoJSON polygon/multipolygon, tile 'z/x/y', or ogrinfo extent. Either 'location' or 'bbox' MUST be provided.",
|
|
562
1557
|
},
|
|
563
1558
|
resolution: {
|
|
564
1559
|
type: "number",
|
|
@@ -568,9 +1563,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
568
1563
|
type: "boolean",
|
|
569
1564
|
description: "Whether to return a compacted list of cells (mixing resolutions) to minimize response size. Defaults to false."
|
|
570
1565
|
},
|
|
1566
|
+
summary_only: {
|
|
1567
|
+
type: "boolean",
|
|
1568
|
+
description: "If true, returns only cell count, sample cells, and map share URL without dumping thousands of cell IDs into context. Ideal for high resolutions. Defaults to false."
|
|
1569
|
+
},
|
|
571
1570
|
return_geometry: {
|
|
572
1571
|
type: "boolean",
|
|
573
1572
|
description: "If true, return GeoJSON FeatureCollection with hex boundary polygons in addition to cell IDs. Defaults to false."
|
|
1573
|
+
},
|
|
1574
|
+
use_polygon_boundary: {
|
|
1575
|
+
type: "boolean",
|
|
1576
|
+
description: "If true (default), fills the exact WKT/GeoJSON/OSM polygon boundary (including interior holes) instead of its rectangular bounding box when available."
|
|
574
1577
|
}
|
|
575
1578
|
},
|
|
576
1579
|
required: ["resolution"],
|
|
@@ -579,30 +1582,125 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
579
1582
|
{ required: ["bbox"] }
|
|
580
1583
|
]
|
|
581
1584
|
},
|
|
1585
|
+
outputSchema: {
|
|
1586
|
+
type: "object",
|
|
1587
|
+
properties: {
|
|
1588
|
+
resolution: { type: "number" },
|
|
1589
|
+
compact: { type: "boolean" },
|
|
1590
|
+
summary_only: { type: "boolean" },
|
|
1591
|
+
used_polygon_boundary: { type: "boolean" },
|
|
1592
|
+
cell_count: { type: "number" },
|
|
1593
|
+
cells: { type: "array", items: { type: "string" } },
|
|
1594
|
+
sample_cells: { type: "array", items: { type: "string" } },
|
|
1595
|
+
geojson: { type: "object" },
|
|
1596
|
+
original_bbox: BBOX_OBJ_SCHEMA,
|
|
1597
|
+
share_url: { type: "string" },
|
|
1598
|
+
provenance: PROVENANCE_SCHEMA,
|
|
1599
|
+
complete: { type: "boolean" },
|
|
1600
|
+
warnings: { type: "array", items: { type: "string" } }
|
|
1601
|
+
},
|
|
1602
|
+
required: ["resolution", "compact", "summary_only", "used_polygon_boundary", "cell_count", "original_bbox", "share_url", "provenance", "complete", "warnings"]
|
|
1603
|
+
}
|
|
582
1604
|
},
|
|
583
1605
|
{
|
|
584
1606
|
name: "generate_share_url",
|
|
585
|
-
description: "Generates a URL that links to the visual Bounding Box tool to display these coordinates on a map. Supports WKT, GeoJSON, ogrinfo extent, and raw coordinate strings as input.",
|
|
1607
|
+
description: "Generates a URL that links to the visual Bounding Box tool to display these coordinates on a map. Supports WKT, GeoJSON, ogrinfo extent, tile 'z/x/y', and raw coordinate strings as input.",
|
|
1608
|
+
annotations: {
|
|
1609
|
+
title: "Generate Map Verification URL",
|
|
1610
|
+
readOnlyHint: true,
|
|
1611
|
+
destructiveHint: false,
|
|
1612
|
+
idempotentHint: true,
|
|
1613
|
+
openWorldHint: false
|
|
1614
|
+
},
|
|
586
1615
|
inputSchema: {
|
|
587
1616
|
type: "object",
|
|
588
1617
|
properties: {
|
|
589
1618
|
bbox: {
|
|
590
1619
|
type: "string",
|
|
591
|
-
description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), WKT, GeoJSON, or ogrinfo extent.",
|
|
1620
|
+
description: "The geometry to parse. Can be a raw bounding box string ('lat1,lng1,lat2,lng2'), WKT, GeoJSON, tile 'z/x/y', or ogrinfo extent.",
|
|
592
1621
|
}
|
|
593
1622
|
},
|
|
594
1623
|
required: ["bbox"],
|
|
595
1624
|
},
|
|
1625
|
+
outputSchema: {
|
|
1626
|
+
type: "object",
|
|
1627
|
+
properties: {
|
|
1628
|
+
bbox: BBOX_OBJ_SCHEMA,
|
|
1629
|
+
share_url: { type: "string" },
|
|
1630
|
+
provenance: PROVENANCE_SCHEMA,
|
|
1631
|
+
complete: { type: "boolean" },
|
|
1632
|
+
warnings: { type: "array", items: { type: "string" } }
|
|
1633
|
+
},
|
|
1634
|
+
required: ["bbox", "share_url", "provenance", "complete", "warnings"]
|
|
1635
|
+
}
|
|
1636
|
+
},
|
|
1637
|
+
{
|
|
1638
|
+
name: "reverse_geocode",
|
|
1639
|
+
description: "Reverse geocode latitude and longitude coordinates to discover context, place name, address components, and OSM details ('what is here?').",
|
|
1640
|
+
annotations: {
|
|
1641
|
+
title: "Reverse Geocode Coordinates",
|
|
1642
|
+
readOnlyHint: true,
|
|
1643
|
+
destructiveHint: false,
|
|
1644
|
+
idempotentHint: true,
|
|
1645
|
+
openWorldHint: true
|
|
1646
|
+
},
|
|
1647
|
+
inputSchema: {
|
|
1648
|
+
type: "object",
|
|
1649
|
+
properties: {
|
|
1650
|
+
lat: {
|
|
1651
|
+
type: "number",
|
|
1652
|
+
description: "Latitude coordinate (-90 to 90)."
|
|
1653
|
+
},
|
|
1654
|
+
lng: {
|
|
1655
|
+
type: "number",
|
|
1656
|
+
description: "Longitude coordinate (-180 to 180)."
|
|
1657
|
+
},
|
|
1658
|
+
zoom: {
|
|
1659
|
+
type: "number",
|
|
1660
|
+
description: "Detail zoom level from 0 (country) to 18 (building/address). Defaults to 18."
|
|
1661
|
+
}
|
|
1662
|
+
},
|
|
1663
|
+
required: ["lat", "lng"]
|
|
1664
|
+
},
|
|
1665
|
+
outputSchema: {
|
|
1666
|
+
type: "object",
|
|
1667
|
+
properties: {
|
|
1668
|
+
matched_place: { type: "string" },
|
|
1669
|
+
center: {
|
|
1670
|
+
type: "object",
|
|
1671
|
+
properties: { lat: { type: "number" }, lng: { type: "number" } },
|
|
1672
|
+
required: ["lat", "lng"]
|
|
1673
|
+
},
|
|
1674
|
+
bbox: BBOX_OBJ_SCHEMA,
|
|
1675
|
+
address: { type: "object" },
|
|
1676
|
+
class: { type: "string" },
|
|
1677
|
+
type: { type: "string" },
|
|
1678
|
+
osm_type: { type: "string" },
|
|
1679
|
+
osm_id: { type: "number" },
|
|
1680
|
+
share_url: { type: "string" },
|
|
1681
|
+
provenance: PROVENANCE_SCHEMA,
|
|
1682
|
+
complete: { type: "boolean" },
|
|
1683
|
+
warnings: { type: "array", items: { type: "string" } }
|
|
1684
|
+
},
|
|
1685
|
+
required: ["matched_place", "center", "bbox", "address", "share_url", "provenance", "complete", "warnings"]
|
|
1686
|
+
}
|
|
596
1687
|
},
|
|
597
1688
|
{
|
|
598
1689
|
name: "search_overpass",
|
|
599
|
-
description: "Execute an Overpass QL query to find POIs, roads, or other OSM features within
|
|
1690
|
+
description: "Execute an Overpass QL query to find POIs, roads, or other OSM features within an admin boundary area, bounding box, or radius. The server wraps your query in spatial filters, detects Overpass runtime errors, and returns structured JSON with provenance, completeness metadata, distance calculations, and a map link.\n\nSEARCH MODES:\n- Location / Admin Area: when 'location' resolves to an OSM boundary polygon (e.g. 'Manhattan', 'Seattle'), searches inside exact boundary polygon.\n- Bounding box (default): searches a rectangular area.\n- Circle: searches within an exact radius when 'radius_meters' is provided.\n- Fast Count: set 'count_only: true' to get instant element counts without downloading full element payloads.\n\nCOMMON TAG EXAMPLES:\n- Restaurants: `nwr[\"amenity\"=\"restaurant\"]`\n- Pizza: `nwr[\"amenity\"=\"fast_food\"][\"cuisine\"=\"pizza\"]`\n- Supermarkets: `nwr[\"shop\"=\"supermarket\"]`\n- Parks: `nwr[\"leisure\"=\"park\"]`\n- Hospitals: `nwr[\"amenity\"=\"hospital\"]`\n- EV Charging: `nwr[\"amenity\"=\"charging_station\"]`\n- Gyms: `nwr[\"leisure\"=\"fitness_centre\"]`\n- Banks: `nwr[\"amenity\"=\"bank\"]`",
|
|
1691
|
+
annotations: {
|
|
1692
|
+
title: "Search OpenStreetMap via Overpass QL",
|
|
1693
|
+
readOnlyHint: true,
|
|
1694
|
+
destructiveHint: false,
|
|
1695
|
+
idempotentHint: true,
|
|
1696
|
+
openWorldHint: true
|
|
1697
|
+
},
|
|
600
1698
|
inputSchema: {
|
|
601
1699
|
type: "object",
|
|
602
1700
|
properties: {
|
|
603
1701
|
location: {
|
|
604
1702
|
type: "string",
|
|
605
|
-
description: "A text location to search for (e.g. '
|
|
1703
|
+
description: "A text location to search for (e.g. 'Manhattan', 'San Francisco'). Either 'location' or 'bbox' MUST be provided."
|
|
606
1704
|
},
|
|
607
1705
|
bbox: {
|
|
608
1706
|
type: "string",
|
|
@@ -610,19 +1708,39 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
610
1708
|
},
|
|
611
1709
|
query: {
|
|
612
1710
|
type: "string",
|
|
613
|
-
description: "The Overpass QL core query. Example: `node[\"amenity\"=\"cafe\"]` or `nwr[\"leisure\"=\"park\"]`. DO NOT include spatial filters
|
|
1711
|
+
description: "The Overpass QL core query. Example: `node[\"amenity\"=\"cafe\"]` or `nwr[\"leisure\"=\"park\"]`. Multi-statement queries supported (e.g. `nwr[\"office\"=\"coworking\"]; nwr[\"amenity\"=\"coworking_space\"]`). DO NOT include spatial filters or output directives."
|
|
614
1712
|
},
|
|
615
1713
|
radius_meters: {
|
|
616
1714
|
type: "number",
|
|
617
|
-
description: "Search radius in metres. Enables circle mode — use ONLY when
|
|
1715
|
+
description: "Search radius in metres. Enables circle mode — use ONLY when explicit distance is specified ('2km' → 2000)."
|
|
618
1716
|
},
|
|
619
1717
|
limit: {
|
|
620
1718
|
type: "number",
|
|
621
|
-
description: "Maximum number of elements to return. Default is 100.
|
|
1719
|
+
description: "Maximum number of elements to return per page. Default is 100."
|
|
1720
|
+
},
|
|
1721
|
+
offset: {
|
|
1722
|
+
type: "number",
|
|
1723
|
+
description: "Pagination offset for retrieving subsequent pages of results. Default is 0."
|
|
1724
|
+
},
|
|
1725
|
+
timeout_seconds: {
|
|
1726
|
+
type: "number",
|
|
1727
|
+
description: "Overpass server query timeout in seconds. Default is 25."
|
|
1728
|
+
},
|
|
1729
|
+
use_area_boundary: {
|
|
1730
|
+
type: "boolean",
|
|
1731
|
+
description: "If true (default), uses exact OSM administrative boundary polygon for named locations when available, avoiding bounding box collapse."
|
|
1732
|
+
},
|
|
1733
|
+
sort_by_distance: {
|
|
1734
|
+
type: "boolean",
|
|
1735
|
+
description: "If true, sorts returned elements by distance from origin/center. Enabled by default for circle mode and point locations."
|
|
622
1736
|
},
|
|
623
1737
|
return_geometry: {
|
|
624
1738
|
type: "boolean",
|
|
625
|
-
description: "If true, returns
|
|
1739
|
+
description: "If true, returns full geometry (polygon/polyline) for ways and relations instead of center point. Defaults to false."
|
|
1740
|
+
},
|
|
1741
|
+
count_only: {
|
|
1742
|
+
type: "boolean",
|
|
1743
|
+
description: "If true, executes an ultra-fast Overpass 'out count;' query and returns total counts (nodes, ways, relations, total) without downloading individual features. Defaults to false."
|
|
626
1744
|
}
|
|
627
1745
|
},
|
|
628
1746
|
required: ["query"],
|
|
@@ -631,24 +1749,87 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
631
1749
|
{ required: ["bbox"] }
|
|
632
1750
|
]
|
|
633
1751
|
},
|
|
1752
|
+
outputSchema: {
|
|
1753
|
+
type: "object",
|
|
1754
|
+
properties: {
|
|
1755
|
+
search_mode: { type: "string" },
|
|
1756
|
+
count_only: { type: "boolean" },
|
|
1757
|
+
matched_place: { type: "string" },
|
|
1758
|
+
alternatives: { type: "array", items: { type: "object" } },
|
|
1759
|
+
osm_area_id: { type: "number" },
|
|
1760
|
+
counts: {
|
|
1761
|
+
type: "object",
|
|
1762
|
+
properties: {
|
|
1763
|
+
nodes: { type: "number" },
|
|
1764
|
+
ways: { type: "number" },
|
|
1765
|
+
relations: { type: "number" },
|
|
1766
|
+
total: { type: "number" }
|
|
1767
|
+
}
|
|
1768
|
+
},
|
|
1769
|
+
distance_from: { type: "object" },
|
|
1770
|
+
generator: { type: "string" },
|
|
1771
|
+
osm3s: { type: "object" },
|
|
1772
|
+
elements: { type: "array", items: { type: "object" } },
|
|
1773
|
+
total_count: { type: "number" },
|
|
1774
|
+
returned_count: { type: "number" },
|
|
1775
|
+
offset: { type: "number" },
|
|
1776
|
+
limit: { type: "number" },
|
|
1777
|
+
truncated: { type: "boolean" },
|
|
1778
|
+
has_more: { type: "boolean" },
|
|
1779
|
+
next_offset: { type: "number" },
|
|
1780
|
+
pagination_hint: { type: "string" },
|
|
1781
|
+
share_url: { type: "string" },
|
|
1782
|
+
provenance: PROVENANCE_SCHEMA,
|
|
1783
|
+
complete: { type: "boolean" },
|
|
1784
|
+
warnings: { type: "array", items: { type: "string" } }
|
|
1785
|
+
},
|
|
1786
|
+
required: ["search_mode", "count_only", "share_url", "provenance", "complete", "warnings"]
|
|
1787
|
+
}
|
|
634
1788
|
},
|
|
635
1789
|
{
|
|
636
1790
|
name: "list_osm_tags",
|
|
637
|
-
description: "Discovery tool to look up the correct OpenStreetMap tags for
|
|
1791
|
+
description: "Discovery tool to look up the correct OpenStreetMap tags for any category. Uses built-in category index and live Taginfo API fallback to prevent tag hallucination.",
|
|
1792
|
+
annotations: {
|
|
1793
|
+
title: "Lookup OpenStreetMap Tags",
|
|
1794
|
+
readOnlyHint: true,
|
|
1795
|
+
destructiveHint: false,
|
|
1796
|
+
idempotentHint: true,
|
|
1797
|
+
openWorldHint: true
|
|
1798
|
+
},
|
|
638
1799
|
inputSchema: {
|
|
639
1800
|
type: "object",
|
|
640
1801
|
properties: {
|
|
641
1802
|
category: {
|
|
642
1803
|
type: "string",
|
|
643
|
-
description: "
|
|
1804
|
+
description: "Category or keyword to look up (e.g. 'food', 'ev charging', 'gym', 'coworking', 'banks', 'dog park')."
|
|
644
1805
|
}
|
|
645
1806
|
},
|
|
646
1807
|
required: ["category"]
|
|
1808
|
+
},
|
|
1809
|
+
outputSchema: {
|
|
1810
|
+
type: "object",
|
|
1811
|
+
properties: {
|
|
1812
|
+
category: { type: "string" },
|
|
1813
|
+
source: { type: "string" },
|
|
1814
|
+
tags: { type: "array", items: { type: "string" } },
|
|
1815
|
+
recommendations_text: { type: "string" },
|
|
1816
|
+
provenance: PROVENANCE_SCHEMA,
|
|
1817
|
+
complete: { type: "boolean" },
|
|
1818
|
+
warnings: { type: "array", items: { type: "string" } }
|
|
1819
|
+
},
|
|
1820
|
+
required: ["category", "source", "tags", "recommendations_text", "provenance", "complete", "warnings"]
|
|
647
1821
|
}
|
|
648
1822
|
},
|
|
649
1823
|
{
|
|
650
1824
|
name: "aggregate_overpass_h3",
|
|
651
|
-
description: "Executes an Overpass query and bins the results into H3 hexagons at a specified resolution to analyze spatial density.",
|
|
1825
|
+
description: "Executes an Overpass query and bins the results into H3 hexagons at a specified resolution to analyze spatial density. Plots the top density hotspots on the shareable verification map.",
|
|
1826
|
+
annotations: {
|
|
1827
|
+
title: "Aggregate OSM Features into H3 Hexagons",
|
|
1828
|
+
readOnlyHint: true,
|
|
1829
|
+
destructiveHint: false,
|
|
1830
|
+
idempotentHint: true,
|
|
1831
|
+
openWorldHint: true
|
|
1832
|
+
},
|
|
652
1833
|
inputSchema: {
|
|
653
1834
|
type: "object",
|
|
654
1835
|
properties: {
|
|
@@ -667,6 +1848,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
667
1848
|
resolution: {
|
|
668
1849
|
type: "number",
|
|
669
1850
|
description: "The H3 resolution level (0-15) for binning. Default is 8."
|
|
1851
|
+
},
|
|
1852
|
+
radius_meters: {
|
|
1853
|
+
type: "number",
|
|
1854
|
+
description: "Optional search radius in metres around the location/bbox center."
|
|
1855
|
+
},
|
|
1856
|
+
timeout_seconds: {
|
|
1857
|
+
type: "number",
|
|
1858
|
+
description: "Overpass server query timeout in seconds. Default is 25."
|
|
1859
|
+
},
|
|
1860
|
+
use_area_boundary: {
|
|
1861
|
+
type: "boolean",
|
|
1862
|
+
description: "If true (default), uses exact OSM boundary polygon for named locations when available."
|
|
670
1863
|
}
|
|
671
1864
|
},
|
|
672
1865
|
required: ["query", "resolution"],
|
|
@@ -674,6 +1867,254 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
674
1867
|
{ required: ["location"] },
|
|
675
1868
|
{ required: ["bbox"] }
|
|
676
1869
|
]
|
|
1870
|
+
},
|
|
1871
|
+
outputSchema: {
|
|
1872
|
+
type: "object",
|
|
1873
|
+
properties: {
|
|
1874
|
+
resolution: { type: "number" },
|
|
1875
|
+
matched_place: { type: "string" },
|
|
1876
|
+
alternatives: { type: "array", items: { type: "object" } },
|
|
1877
|
+
osm_area_id: { type: "number" },
|
|
1878
|
+
total_elements: { type: "number" },
|
|
1879
|
+
active_hex_count: { type: "number" },
|
|
1880
|
+
hex_counts: { type: "object" },
|
|
1881
|
+
hex_geojson: { type: "object" },
|
|
1882
|
+
original_bbox: BBOX_OBJ_SCHEMA,
|
|
1883
|
+
share_url: { type: "string" },
|
|
1884
|
+
provenance: PROVENANCE_SCHEMA,
|
|
1885
|
+
complete: { type: "boolean" },
|
|
1886
|
+
warnings: { type: "array", items: { type: "string" } }
|
|
1887
|
+
},
|
|
1888
|
+
required: ["resolution", "total_elements", "active_hex_count", "hex_counts", "hex_geojson", "original_bbox", "share_url", "provenance", "complete", "warnings"]
|
|
1889
|
+
}
|
|
1890
|
+
},
|
|
1891
|
+
{
|
|
1892
|
+
name: "calculate_route",
|
|
1893
|
+
description: "Calculate real-world travel distance, duration, turn-by-turn summary, GeoJSON route geometry, and route bounding box between two locations or coordinate pairs using OpenStreetMap routing (FOSSGIS / OSRM, or configurable OSRM_API_URL). Draws the actual route path on the verification map and enforces rate limits (max 1 req/s).",
|
|
1894
|
+
annotations: {
|
|
1895
|
+
title: "Calculate Route Distance, Duration & Geometry",
|
|
1896
|
+
readOnlyHint: true,
|
|
1897
|
+
destructiveHint: false,
|
|
1898
|
+
idempotentHint: true,
|
|
1899
|
+
openWorldHint: true
|
|
1900
|
+
},
|
|
1901
|
+
inputSchema: {
|
|
1902
|
+
type: "object",
|
|
1903
|
+
properties: {
|
|
1904
|
+
origin: {
|
|
1905
|
+
type: "string",
|
|
1906
|
+
description: "Starting point as 'lat,lng' coordinates (e.g. '40.7128,-74.0060') or a natural-language place name (e.g. 'Times Square, NYC')."
|
|
1907
|
+
},
|
|
1908
|
+
destination: {
|
|
1909
|
+
type: "string",
|
|
1910
|
+
description: "Ending point as 'lat,lng' coordinates (e.g. '40.6413,-73.7781') or a natural-language place name (e.g. 'JFK Airport')."
|
|
1911
|
+
},
|
|
1912
|
+
profile: {
|
|
1913
|
+
type: "string",
|
|
1914
|
+
description: "Travel mode profile: 'driving' (car), 'walking' (foot), or 'cycling' (bicycle). Defaults to 'driving'.",
|
|
1915
|
+
enum: ["driving", "walking", "cycling"]
|
|
1916
|
+
},
|
|
1917
|
+
return_steps: {
|
|
1918
|
+
type: "boolean",
|
|
1919
|
+
description: "If true, includes step-by-step road names and maneuver instructions. Defaults to false."
|
|
1920
|
+
}
|
|
1921
|
+
},
|
|
1922
|
+
required: ["origin", "destination"]
|
|
1923
|
+
},
|
|
1924
|
+
outputSchema: {
|
|
1925
|
+
type: "object",
|
|
1926
|
+
properties: {
|
|
1927
|
+
profile: { type: "string" },
|
|
1928
|
+
origin: {
|
|
1929
|
+
type: "object",
|
|
1930
|
+
properties: { lat: { type: "number" }, lng: { type: "number" }, label: { type: "string" } },
|
|
1931
|
+
required: ["lat", "lng", "label"]
|
|
1932
|
+
},
|
|
1933
|
+
destination: {
|
|
1934
|
+
type: "object",
|
|
1935
|
+
properties: { lat: { type: "number" }, lng: { type: "number" }, label: { type: "string" } },
|
|
1936
|
+
required: ["lat", "lng", "label"]
|
|
1937
|
+
},
|
|
1938
|
+
distance_meters: { type: "number" },
|
|
1939
|
+
distance_km: { type: "number" },
|
|
1940
|
+
duration_seconds: { type: "number" },
|
|
1941
|
+
duration_minutes: { type: "number" },
|
|
1942
|
+
straight_line_meters: { type: "number" },
|
|
1943
|
+
detour_factor: { type: "number" },
|
|
1944
|
+
initial_bearing: {
|
|
1945
|
+
type: "object",
|
|
1946
|
+
properties: { degrees: { type: "number" }, cardinal: { type: "string" } },
|
|
1947
|
+
required: ["degrees", "cardinal"]
|
|
1948
|
+
},
|
|
1949
|
+
route_bbox: BBOX_OBJ_SCHEMA,
|
|
1950
|
+
route_geometry: {
|
|
1951
|
+
type: "object",
|
|
1952
|
+
properties: {
|
|
1953
|
+
type: { type: "string" },
|
|
1954
|
+
coordinates: { type: "array", items: { type: "array", items: { type: "number" } } }
|
|
1955
|
+
},
|
|
1956
|
+
required: ["type", "coordinates"]
|
|
1957
|
+
},
|
|
1958
|
+
steps: { type: "array", items: { type: "object" } },
|
|
1959
|
+
share_url: { type: "string" },
|
|
1960
|
+
provenance: PROVENANCE_SCHEMA,
|
|
1961
|
+
complete: { type: "boolean" },
|
|
1962
|
+
warnings: { type: "array", items: { type: "string" } }
|
|
1963
|
+
},
|
|
1964
|
+
required: ["profile", "origin", "destination", "distance_meters", "distance_km", "duration_seconds", "duration_minutes", "straight_line_meters", "detour_factor", "initial_bearing", "route_bbox", "route_geometry", "share_url", "provenance", "complete", "warnings"]
|
|
1965
|
+
}
|
|
1966
|
+
},
|
|
1967
|
+
{
|
|
1968
|
+
name: "get_elevation",
|
|
1969
|
+
description: "Query terrain elevation in meters above sea level using the Open-Meteo 90m Copernicus DEM API (free for non-commercial use; commercial use requires a paid key or self-hosted ELEVATION_API_URL). Supports querying specific point coordinates OR sampling an N×N point grid across a bounding box to compute sample statistics (note: finite grid sampling over a 90m DEM reports sample statistics, not guaranteed area-wide true minimum, maximum, or slope).",
|
|
1970
|
+
annotations: {
|
|
1971
|
+
title: "Get Terrain Elevation & Sample Statistics",
|
|
1972
|
+
readOnlyHint: true,
|
|
1973
|
+
destructiveHint: false,
|
|
1974
|
+
idempotentHint: true,
|
|
1975
|
+
openWorldHint: true
|
|
1976
|
+
},
|
|
1977
|
+
inputSchema: {
|
|
1978
|
+
type: "object",
|
|
1979
|
+
properties: {
|
|
1980
|
+
points: {
|
|
1981
|
+
type: "array",
|
|
1982
|
+
description: "List of {lat, lng, label?} points to query elevation for (up to 100 points).",
|
|
1983
|
+
items: {
|
|
1984
|
+
type: "object",
|
|
1985
|
+
properties: {
|
|
1986
|
+
lat: { type: "number" },
|
|
1987
|
+
lng: { type: "number" },
|
|
1988
|
+
label: { type: "string" }
|
|
1989
|
+
},
|
|
1990
|
+
required: ["lat", "lng"]
|
|
1991
|
+
}
|
|
1992
|
+
},
|
|
1993
|
+
location: {
|
|
1994
|
+
type: "string",
|
|
1995
|
+
description: "A text location to sample elevation across (e.g. 'Mount Rainier', 'Denver')."
|
|
1996
|
+
},
|
|
1997
|
+
bbox: {
|
|
1998
|
+
type: "string",
|
|
1999
|
+
description: "A bounding box or point string to sample terrain elevation across."
|
|
2000
|
+
},
|
|
2001
|
+
grid_size: {
|
|
2002
|
+
type: "number",
|
|
2003
|
+
description: "Grid dimension N (N x N samples, from 2 to 10) when sampling a bounding box. Defaults to 5 (25 sample points)."
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
},
|
|
2007
|
+
outputSchema: {
|
|
2008
|
+
type: "object",
|
|
2009
|
+
properties: {
|
|
2010
|
+
mode: { type: "string" },
|
|
2011
|
+
matched_place: { type: "string" },
|
|
2012
|
+
dem_resolution_meters: { type: "number" },
|
|
2013
|
+
sample_count: { type: "number" },
|
|
2014
|
+
sample_stats: {
|
|
2015
|
+
type: "object",
|
|
2016
|
+
properties: {
|
|
2017
|
+
min_elevation_meters: { type: "number" },
|
|
2018
|
+
max_elevation_meters: { type: "number" },
|
|
2019
|
+
mean_elevation_meters: { type: "number" },
|
|
2020
|
+
sample_relief_meters: { type: "number" },
|
|
2021
|
+
lowest_sample: { type: "object" },
|
|
2022
|
+
highest_sample: { type: "object" },
|
|
2023
|
+
is_finite_sample: { type: "boolean" }
|
|
2024
|
+
},
|
|
2025
|
+
required: ["min_elevation_meters", "max_elevation_meters", "mean_elevation_meters", "sample_relief_meters", "lowest_sample", "highest_sample", "is_finite_sample"]
|
|
2026
|
+
},
|
|
2027
|
+
samples: { type: "array", items: { type: "object" } },
|
|
2028
|
+
bbox: BBOX_OBJ_SCHEMA,
|
|
2029
|
+
share_url: { type: "string" },
|
|
2030
|
+
provenance: PROVENANCE_SCHEMA,
|
|
2031
|
+
complete: { type: "boolean" },
|
|
2032
|
+
warnings: { type: "array", items: { type: "string" } }
|
|
2033
|
+
},
|
|
2034
|
+
required: ["mode", "dem_resolution_meters", "sample_count", "sample_stats", "samples", "bbox", "share_url", "provenance", "complete", "warnings"]
|
|
2035
|
+
}
|
|
2036
|
+
},
|
|
2037
|
+
{
|
|
2038
|
+
name: "spatial_calc",
|
|
2039
|
+
description: "Perform deterministic offline geometric calculations on bounding boxes, points, and polygons (with holes, zero network calls): 'relation' (intersects, contains, overlap area %, union & intersection bbox between two geometries), 'point_in_geometry' (check if a point is inside a bbox or WKT/GeoJSON polygon respecting interior holes), 'distance_bearing' (exact Haversine distance, initial compass bearing, and midpoint between two points), or 'split_grid' (subdivide a bbox into an N x M grid of sub-bboxes for chunked queries).",
|
|
2040
|
+
annotations: {
|
|
2041
|
+
title: "Deterministic Spatial Geometry Calculator",
|
|
2042
|
+
readOnlyHint: true,
|
|
2043
|
+
destructiveHint: false,
|
|
2044
|
+
idempotentHint: true,
|
|
2045
|
+
openWorldHint: false
|
|
2046
|
+
},
|
|
2047
|
+
inputSchema: {
|
|
2048
|
+
type: "object",
|
|
2049
|
+
properties: {
|
|
2050
|
+
operation: {
|
|
2051
|
+
type: "string",
|
|
2052
|
+
description: "The geometric operation to perform.",
|
|
2053
|
+
enum: ["relation", "point_in_geometry", "distance_bearing", "split_grid"]
|
|
2054
|
+
},
|
|
2055
|
+
bbox_a: {
|
|
2056
|
+
type: "string",
|
|
2057
|
+
description: "First geometry / bounding box (for 'relation', 'point_in_geometry', 'split_grid') or origin point 'lat,lng' (for 'distance_bearing')."
|
|
2058
|
+
},
|
|
2059
|
+
bbox_b: {
|
|
2060
|
+
type: "string",
|
|
2061
|
+
description: "Second geometry / bounding box (for 'relation') or destination point 'lat,lng' (for 'distance_bearing')."
|
|
2062
|
+
},
|
|
2063
|
+
point: {
|
|
2064
|
+
type: "string",
|
|
2065
|
+
description: "Point coordinates 'lat,lng' to test in 'point_in_geometry'."
|
|
2066
|
+
},
|
|
2067
|
+
rows: {
|
|
2068
|
+
type: "number",
|
|
2069
|
+
description: "Number of latitude rows for 'split_grid' (1-20, default 2)."
|
|
2070
|
+
},
|
|
2071
|
+
cols: {
|
|
2072
|
+
type: "number",
|
|
2073
|
+
description: "Number of longitude columns for 'split_grid' (1-20, default 2)."
|
|
2074
|
+
}
|
|
2075
|
+
},
|
|
2076
|
+
required: ["operation", "bbox_a"]
|
|
2077
|
+
},
|
|
2078
|
+
outputSchema: {
|
|
2079
|
+
type: "object",
|
|
2080
|
+
properties: {
|
|
2081
|
+
operation: { type: "string" },
|
|
2082
|
+
intersects: { type: "boolean" },
|
|
2083
|
+
a_contains_b: { type: "boolean" },
|
|
2084
|
+
b_contains_a: { type: "boolean" },
|
|
2085
|
+
area_a_km2: { type: "number" },
|
|
2086
|
+
area_b_km2: { type: "number" },
|
|
2087
|
+
intersection_area_km2: { type: "number" },
|
|
2088
|
+
union_area_km2: { type: "number" },
|
|
2089
|
+
overlap_percent_of_a: { type: "number" },
|
|
2090
|
+
overlap_percent_of_b: { type: "number" },
|
|
2091
|
+
centroid_distance_meters: { type: "number" },
|
|
2092
|
+
intersection_bbox: { type: ["object", "null"] },
|
|
2093
|
+
union_bbox: BBOX_OBJ_SCHEMA,
|
|
2094
|
+
point: { type: "object" },
|
|
2095
|
+
inside: { type: "boolean" },
|
|
2096
|
+
inside_bbox: { type: "boolean" },
|
|
2097
|
+
inside_polygon: { type: "boolean" },
|
|
2098
|
+
target_bbox: BBOX_OBJ_SCHEMA,
|
|
2099
|
+
from: { type: "object" },
|
|
2100
|
+
to: { type: "object" },
|
|
2101
|
+
distance_meters: { type: "number" },
|
|
2102
|
+
distance_km: { type: "number" },
|
|
2103
|
+
bearing_degrees: { type: "number" },
|
|
2104
|
+
cardinal_direction: { type: "string" },
|
|
2105
|
+
midpoint: { type: "object" },
|
|
2106
|
+
bbox: BBOX_OBJ_SCHEMA,
|
|
2107
|
+
rows: { type: "number" },
|
|
2108
|
+
cols: { type: "number" },
|
|
2109
|
+
total_cells: { type: "number" },
|
|
2110
|
+
original_bbox: BBOX_OBJ_SCHEMA,
|
|
2111
|
+
cells: { type: "array", items: { type: "object" } },
|
|
2112
|
+
share_url: { type: "string" },
|
|
2113
|
+
provenance: PROVENANCE_SCHEMA,
|
|
2114
|
+
complete: { type: "boolean" },
|
|
2115
|
+
warnings: { type: "array", items: { type: "string" } }
|
|
2116
|
+
},
|
|
2117
|
+
required: ["operation", "share_url", "provenance", "complete", "warnings"]
|
|
677
2118
|
}
|
|
678
2119
|
}
|
|
679
2120
|
],
|
|
@@ -685,38 +2126,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
685
2126
|
try {
|
|
686
2127
|
if (toolName === "get_bounds") {
|
|
687
2128
|
const args = request.params.arguments || {};
|
|
2129
|
+
const warnings = [];
|
|
688
2130
|
let bboxObj;
|
|
2131
|
+
let geocodeRes;
|
|
689
2132
|
if (args.location) {
|
|
690
2133
|
try {
|
|
691
|
-
|
|
2134
|
+
geocodeRes = await geocodeLocation(args.location);
|
|
2135
|
+
bboxObj = geocodeRes.bbox;
|
|
692
2136
|
}
|
|
693
2137
|
catch (err) {
|
|
694
2138
|
if (!args.bbox) {
|
|
695
2139
|
log('error', `Geocoding failed for location: ${args.location}`, { error: err.message });
|
|
696
2140
|
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
697
2141
|
}
|
|
698
|
-
// User provided bbox fallback
|
|
699
2142
|
log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
|
|
700
|
-
bboxObj = parseBBox(args.bbox);
|
|
2143
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
701
2144
|
}
|
|
702
2145
|
}
|
|
703
2146
|
else if (args.bbox) {
|
|
704
|
-
bboxObj = parseBBox(args.bbox);
|
|
2147
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
705
2148
|
}
|
|
706
2149
|
else {
|
|
707
2150
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
708
2151
|
}
|
|
2152
|
+
const bufferMeters = args.buffer_meters;
|
|
2153
|
+
if (typeof bufferMeters === 'number' && bufferMeters !== 0) {
|
|
2154
|
+
bboxObj = expandBBoxMeters(bboxObj, bufferMeters);
|
|
2155
|
+
}
|
|
709
2156
|
let epsg = args.epsg || "4326";
|
|
710
2157
|
let format = args.format || "csv";
|
|
711
2158
|
let coordOrder = args.coord_order || "lng,lat";
|
|
712
|
-
let zoom = args.zoom
|
|
713
|
-
let precision = args.precision
|
|
2159
|
+
let zoom = args.zoom ?? 15;
|
|
2160
|
+
let precision = args.precision ?? 6;
|
|
714
2161
|
epsg = epsg.replace(/[^0-9]/g, "");
|
|
715
|
-
// Dynamically fetch projection if not bundled
|
|
716
2162
|
await ensureProjection(epsg);
|
|
717
2163
|
const projCoords = projectBounds(bboxObj, epsg);
|
|
718
2164
|
const WGS84Formatted = getFormattedBox(bboxObj, format, precision, coordOrder);
|
|
719
|
-
// Build projected bbox for formatting (uses projected values)
|
|
720
2165
|
const projBbox = {
|
|
721
2166
|
lat1: projCoords.ymin,
|
|
722
2167
|
lng1: projCoords.xmin,
|
|
@@ -727,79 +2172,144 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
727
2172
|
const shareUrl = buildShareUrl(bboxObj);
|
|
728
2173
|
const dims = calculateBboxDimensions(bboxObj);
|
|
729
2174
|
const areaKm2 = calculateBboxArea(bboxObj);
|
|
730
|
-
// Calculate center and tile indices based on WGS84 original bounds
|
|
731
2175
|
const centerLat = (bboxObj.lat1 + bboxObj.lat2) / 2;
|
|
732
2176
|
const centerLng = (bboxObj.lng1 + bboxObj.lng2) / 2;
|
|
733
2177
|
const tileIndices = latLngToTile(centerLat, centerLng, zoom);
|
|
734
2178
|
log('info', `get_bounds completed`, { epsg, format, area_km2: areaKm2 });
|
|
2179
|
+
const geocodeInfo = geocodeRes ? `Matched Place: "${geocodeRes.matched_place}"\n` : '';
|
|
2180
|
+
const altInfo = geocodeRes && geocodeRes.alternatives.length > 0
|
|
2181
|
+
? `Alternates: ${geocodeRes.alternatives.map(a => `"${a.matched_place}"`).join(', ')}\n\n`
|
|
2182
|
+
: '\n';
|
|
2183
|
+
const provenance = geocodeRes?.provenance || {
|
|
2184
|
+
provider: "offline-proj4",
|
|
2185
|
+
data_timestamp: new Date().toISOString(),
|
|
2186
|
+
cached: false,
|
|
2187
|
+
cache_age_ms: 0
|
|
2188
|
+
};
|
|
2189
|
+
const structuredData = {
|
|
2190
|
+
...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
|
|
2191
|
+
original_wgs84: bboxObj,
|
|
2192
|
+
projected: projCoords,
|
|
2193
|
+
center: {
|
|
2194
|
+
lat: centerLat,
|
|
2195
|
+
lng: centerLng
|
|
2196
|
+
},
|
|
2197
|
+
tile_indices: tileIndices,
|
|
2198
|
+
epsg,
|
|
2199
|
+
format,
|
|
2200
|
+
coord_order: coordOrder,
|
|
2201
|
+
precision,
|
|
2202
|
+
area_km2: areaKm2,
|
|
2203
|
+
dimensions: {
|
|
2204
|
+
width_km: dims.widthKm,
|
|
2205
|
+
height_km: dims.heightKm
|
|
2206
|
+
},
|
|
2207
|
+
share_url: shareUrl,
|
|
2208
|
+
provenance,
|
|
2209
|
+
complete: true,
|
|
2210
|
+
warnings
|
|
2211
|
+
};
|
|
735
2212
|
return {
|
|
736
2213
|
content: [
|
|
737
2214
|
{
|
|
738
2215
|
type: "text",
|
|
739
|
-
text:
|
|
740
|
-
},
|
|
741
|
-
{
|
|
742
|
-
type: "text",
|
|
743
|
-
text: JSON.stringify({
|
|
744
|
-
original_wgs84: bboxObj,
|
|
745
|
-
projected: projCoords,
|
|
746
|
-
center: {
|
|
747
|
-
lat: centerLat,
|
|
748
|
-
lng: centerLng
|
|
749
|
-
},
|
|
750
|
-
tile_indices: tileIndices,
|
|
751
|
-
epsg,
|
|
752
|
-
format,
|
|
753
|
-
coord_order: coordOrder,
|
|
754
|
-
precision,
|
|
755
|
-
area_km2: areaKm2,
|
|
756
|
-
dimensions: {
|
|
757
|
-
width_km: dims.widthKm,
|
|
758
|
-
height_km: dims.heightKm
|
|
759
|
-
},
|
|
760
|
-
share_url: shareUrl
|
|
761
|
-
}, null, 2)
|
|
2216
|
+
text: `${geocodeInfo}${altInfo}Original WGS84 Bounds (${format}):\n${WGS84Formatted}\n\nProjected to EPSG:${epsg} (${format}):\n${projectedFormatted}\n\nArea: ${areaKm2.toFixed(3)} km² (${dims.widthKm.toFixed(3)}km x ${dims.heightKm.toFixed(3)}km)\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
762
2217
|
}
|
|
763
|
-
]
|
|
2218
|
+
],
|
|
2219
|
+
structuredContent: structuredData
|
|
764
2220
|
};
|
|
765
2221
|
}
|
|
766
2222
|
else if (toolName === "get_h3_indices") {
|
|
767
2223
|
const args = request.params.arguments || {};
|
|
768
2224
|
const resolution = args.resolution;
|
|
769
2225
|
const compact = args.compact || false;
|
|
2226
|
+
const summaryOnly = args.summary_only || false;
|
|
770
2227
|
const returnGeometry = args.return_geometry || false;
|
|
2228
|
+
const usePolygonBoundary = args.use_polygon_boundary !== false;
|
|
2229
|
+
const warnings = [];
|
|
771
2230
|
if (resolution === undefined) {
|
|
772
2231
|
return { content: [{ type: "text", text: "Error: 'resolution' is required." }], isError: true };
|
|
773
2232
|
}
|
|
774
2233
|
let bboxObj;
|
|
2234
|
+
let geocodeRes;
|
|
2235
|
+
let polygonsWithHoles = null;
|
|
775
2236
|
if (args.location) {
|
|
776
2237
|
try {
|
|
777
|
-
|
|
2238
|
+
geocodeRes = await geocodeLocation(args.location, usePolygonBoundary);
|
|
2239
|
+
bboxObj = geocodeRes.bbox;
|
|
2240
|
+
if (usePolygonBoundary && geocodeRes.polygons_with_holes) {
|
|
2241
|
+
polygonsWithHoles = geocodeRes.polygons_with_holes;
|
|
2242
|
+
}
|
|
778
2243
|
}
|
|
779
2244
|
catch (err) {
|
|
780
2245
|
if (!args.bbox) {
|
|
781
2246
|
log('error', `Geocoding failed for H3 location: ${args.location}`, { error: err.message });
|
|
782
2247
|
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
783
2248
|
}
|
|
784
|
-
bboxObj = parseBBox(args.bbox);
|
|
2249
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
2250
|
+
if (usePolygonBoundary) {
|
|
2251
|
+
polygonsWithHoles = extractPolygonsWithHolesLatLng(args.bbox);
|
|
2252
|
+
}
|
|
785
2253
|
}
|
|
786
2254
|
}
|
|
787
2255
|
else if (args.bbox) {
|
|
788
|
-
bboxObj = parseBBox(args.bbox);
|
|
2256
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
2257
|
+
if (usePolygonBoundary) {
|
|
2258
|
+
polygonsWithHoles = extractPolygonsWithHolesLatLng(args.bbox);
|
|
2259
|
+
}
|
|
789
2260
|
}
|
|
790
2261
|
else {
|
|
791
2262
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
792
2263
|
}
|
|
793
|
-
const cells = getH3Cells(bboxObj, resolution, compact);
|
|
794
|
-
const
|
|
795
|
-
|
|
2264
|
+
const cells = getH3Cells(bboxObj, resolution, compact, polygonsWithHoles);
|
|
2265
|
+
const usedPolygon = !!(polygonsWithHoles && polygonsWithHoles.length > 0);
|
|
2266
|
+
const shareUrl = usedPolygon && polygonsWithHoles[0]?.[0]?.length >= 3
|
|
2267
|
+
? buildShareUrlPolygon(polygonsWithHoles[0][0])
|
|
2268
|
+
: buildShareUrl(bboxObj);
|
|
2269
|
+
const provenance = geocodeRes?.provenance || {
|
|
2270
|
+
provider: "offline-h3-js",
|
|
2271
|
+
data_timestamp: new Date().toISOString(),
|
|
2272
|
+
cached: false,
|
|
2273
|
+
cache_age_ms: 0
|
|
2274
|
+
};
|
|
2275
|
+
log('info', `get_h3_indices completed`, { resolution, compact, summary_only: summaryOnly, cell_count: cells.length, used_polygon: usedPolygon });
|
|
2276
|
+
if (summaryOnly) {
|
|
2277
|
+
const sampleCells = cells.slice(0, 10);
|
|
2278
|
+
const responseData = {
|
|
2279
|
+
resolution,
|
|
2280
|
+
compact,
|
|
2281
|
+
summary_only: true,
|
|
2282
|
+
used_polygon_boundary: usedPolygon,
|
|
2283
|
+
cell_count: cells.length,
|
|
2284
|
+
sample_cells: sampleCells,
|
|
2285
|
+
original_bbox: bboxObj,
|
|
2286
|
+
share_url: shareUrl,
|
|
2287
|
+
provenance,
|
|
2288
|
+
complete: false,
|
|
2289
|
+
warnings
|
|
2290
|
+
};
|
|
2291
|
+
return {
|
|
2292
|
+
content: [
|
|
2293
|
+
{
|
|
2294
|
+
type: "text",
|
|
2295
|
+
text: `H3 summary: ${cells.length.toLocaleString()} cells generated at resolution ${resolution} (compacted: ${compact}, polygon_boundary: ${usedPolygon}).\nSample cells: ${sampleCells.join(', ')}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
2296
|
+
}
|
|
2297
|
+
],
|
|
2298
|
+
structuredContent: responseData
|
|
2299
|
+
};
|
|
2300
|
+
}
|
|
796
2301
|
const responseData = {
|
|
797
2302
|
resolution,
|
|
798
2303
|
compact,
|
|
2304
|
+
summary_only: false,
|
|
2305
|
+
used_polygon_boundary: usedPolygon,
|
|
799
2306
|
cell_count: cells.length,
|
|
800
2307
|
cells,
|
|
801
2308
|
original_bbox: bboxObj,
|
|
802
|
-
share_url: shareUrl
|
|
2309
|
+
share_url: shareUrl,
|
|
2310
|
+
provenance,
|
|
2311
|
+
complete: true,
|
|
2312
|
+
warnings
|
|
803
2313
|
};
|
|
804
2314
|
if (returnGeometry) {
|
|
805
2315
|
responseData.geojson = h3CellsToGeoJSON(cells);
|
|
@@ -808,46 +2318,74 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
808
2318
|
content: [
|
|
809
2319
|
{
|
|
810
2320
|
type: "text",
|
|
811
|
-
text: `H3 cells generated: ${cells.length.toLocaleString()}\nResolution: ${resolution} | Compacted: ${compact}\n\nCells:\n${JSON.stringify(cells)}\n\nView on map: ${shareUrl}`
|
|
812
|
-
},
|
|
813
|
-
{
|
|
814
|
-
type: "text",
|
|
815
|
-
text: JSON.stringify(responseData, null, 2)
|
|
2321
|
+
text: `H3 cells generated: ${cells.length.toLocaleString()}\nResolution: ${resolution} | Compacted: ${compact} | Polygon boundary: ${usedPolygon}\n\nCells:\n${JSON.stringify(cells)}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
816
2322
|
}
|
|
817
|
-
]
|
|
2323
|
+
],
|
|
2324
|
+
structuredContent: responseData
|
|
818
2325
|
};
|
|
819
2326
|
}
|
|
820
2327
|
else if (toolName === "generate_share_url") {
|
|
821
2328
|
const args = request.params.arguments || {};
|
|
822
2329
|
const bbox = args.bbox;
|
|
2330
|
+
const warnings = [];
|
|
823
2331
|
if (!bbox) {
|
|
824
2332
|
return { content: [{ type: "text", text: "Error: 'bbox' is required." }], isError: true };
|
|
825
2333
|
}
|
|
826
|
-
const bboxObj = parseBBox(bbox);
|
|
2334
|
+
const bboxObj = parseBBox(bbox, warnings);
|
|
827
2335
|
const shareUrl = buildShareUrl(bboxObj);
|
|
828
2336
|
log('info', `generate_share_url completed`, { share_url: shareUrl });
|
|
2337
|
+
const structuredData = {
|
|
2338
|
+
bbox: bboxObj,
|
|
2339
|
+
share_url: shareUrl,
|
|
2340
|
+
provenance: {
|
|
2341
|
+
provider: "offline-parser",
|
|
2342
|
+
data_timestamp: new Date().toISOString(),
|
|
2343
|
+
cached: false,
|
|
2344
|
+
cache_age_ms: 0
|
|
2345
|
+
},
|
|
2346
|
+
complete: true,
|
|
2347
|
+
warnings
|
|
2348
|
+
};
|
|
829
2349
|
return {
|
|
830
2350
|
content: [
|
|
831
2351
|
{
|
|
832
2352
|
type: "text",
|
|
833
|
-
text: `View on map: ${shareUrl}`
|
|
834
|
-
}
|
|
2353
|
+
text: `View on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
2354
|
+
}
|
|
2355
|
+
],
|
|
2356
|
+
structuredContent: structuredData
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
else if (toolName === "reverse_geocode") {
|
|
2360
|
+
const args = request.params.arguments || {};
|
|
2361
|
+
const lat = args.lat;
|
|
2362
|
+
const lng = args.lng;
|
|
2363
|
+
const zoom = args.zoom ?? 18;
|
|
2364
|
+
if (lat === undefined || lng === undefined) {
|
|
2365
|
+
return { content: [{ type: "text", text: "Error: Both 'lat' and 'lng' arguments are required." }], isError: true };
|
|
2366
|
+
}
|
|
2367
|
+
const result = await reverseGeocode(lat, lng, zoom);
|
|
2368
|
+
log('info', `reverse_geocode completed`, { lat, lng, matched: result.matched_place });
|
|
2369
|
+
return {
|
|
2370
|
+
content: [
|
|
835
2371
|
{
|
|
836
2372
|
type: "text",
|
|
837
|
-
text:
|
|
838
|
-
bbox: bboxObj,
|
|
839
|
-
share_url: shareUrl
|
|
840
|
-
}, null, 2)
|
|
2373
|
+
text: `Matched Location: "${result.matched_place}"\nCoordinates: (${result.center.lat.toFixed(5)}, ${result.center.lng.toFixed(5)})\nOSM Details: [${result.osm_type || 'point'}] ${result.osm_id || 'N/A'}\n\nView on map: ${result.share_url}`
|
|
841
2374
|
}
|
|
842
|
-
]
|
|
2375
|
+
],
|
|
2376
|
+
structuredContent: result
|
|
843
2377
|
};
|
|
844
2378
|
}
|
|
845
2379
|
else if (toolName === "search_overpass") {
|
|
846
2380
|
const args = request.params.arguments || {};
|
|
847
2381
|
const queryRaw = args.query;
|
|
848
2382
|
const limit = args.limit || 100;
|
|
2383
|
+
const offset = args.offset || 0;
|
|
2384
|
+
const timeoutSec = args.timeout_seconds || 25;
|
|
2385
|
+
const useAreaBoundary = args.use_area_boundary !== false;
|
|
849
2386
|
const radiusMeters = args.radius_meters;
|
|
850
2387
|
const returnGeometry = args.return_geometry || false;
|
|
2388
|
+
const countOnly = args.count_only || false;
|
|
851
2389
|
const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
|
|
852
2390
|
if (!queryRaw) {
|
|
853
2391
|
return { content: [{ type: "text", text: "Error: 'query' is required (e.g. `node[\"amenity\"=\"restaurant\"]`)." }], isError: true };
|
|
@@ -858,20 +2396,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
858
2396
|
if (queryRaw.includes("[out:") || queryRaw.includes("[timeout:") || queryRaw.includes("[bbox:")) {
|
|
859
2397
|
return { content: [{ type: "text", text: "Error: Query must not contain [out:], [timeout:], or [bbox:] directives. The server handles these automatically." }], isError: true };
|
|
860
2398
|
}
|
|
2399
|
+
const warnings = [];
|
|
861
2400
|
let overpassQuery;
|
|
862
2401
|
let searchModeLabel;
|
|
863
|
-
let
|
|
2402
|
+
let refPoint;
|
|
864
2403
|
let bboxObj;
|
|
2404
|
+
let geocodeRes;
|
|
2405
|
+
let usedAreaId;
|
|
2406
|
+
const outDirective = countOnly ? "out count;" : (returnGeometry ? "out geom;" : "out center;");
|
|
865
2407
|
if (useCircle) {
|
|
866
2408
|
if (args.location) {
|
|
867
2409
|
try {
|
|
868
|
-
|
|
2410
|
+
geocodeRes = await geocodeLocation(args.location);
|
|
2411
|
+
refPoint = { lat: geocodeRes.center.lat, lng: geocodeRes.center.lng, description: `geocoded center of "${geocodeRes.matched_place}"` };
|
|
869
2412
|
}
|
|
870
2413
|
catch (err) {
|
|
871
2414
|
if (args.bbox) {
|
|
872
2415
|
log('warn', `Geocoding failed for radius search, using bbox center`, { error: err.message });
|
|
873
|
-
const fb = parseBBox(args.bbox);
|
|
874
|
-
|
|
2416
|
+
const fb = parseBBox(args.bbox, warnings);
|
|
2417
|
+
refPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2, description: "centroid of provided bounding box" };
|
|
875
2418
|
}
|
|
876
2419
|
else {
|
|
877
2420
|
log('error', `Geocoding failed for radius search: ${args.location}`, { error: err.message });
|
|
@@ -880,25 +2423,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
880
2423
|
}
|
|
881
2424
|
}
|
|
882
2425
|
else if (args.bbox) {
|
|
883
|
-
const fb = parseBBox(args.bbox);
|
|
884
|
-
|
|
2426
|
+
const fb = parseBBox(args.bbox, warnings);
|
|
2427
|
+
refPoint = { lat: (fb.lat1 + fb.lat2) / 2, lng: (fb.lng1 + fb.lng2) / 2, description: "centroid of provided bounding box" };
|
|
885
2428
|
}
|
|
886
2429
|
else {
|
|
887
2430
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
888
2431
|
}
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
|
|
2432
|
+
const circleFilter = `(around:${radiusMeters},${refPoint.lat},${refPoint.lng})`;
|
|
2433
|
+
const formattedCore = applySpatialFilter(queryRaw, circleFilter);
|
|
2434
|
+
overpassQuery = `[out:json][timeout:${timeoutSec}];\n(${formattedCore});\n${outDirective}`;
|
|
2435
|
+
searchModeLabel = `circle — ${radiusMeters}m radius around (${refPoint.lat.toFixed(5)}, ${refPoint.lng.toFixed(5)})`;
|
|
892
2436
|
}
|
|
893
2437
|
else {
|
|
894
2438
|
if (args.location) {
|
|
895
2439
|
try {
|
|
896
|
-
|
|
2440
|
+
geocodeRes = await geocodeLocation(args.location);
|
|
2441
|
+
bboxObj = geocodeRes.bbox;
|
|
2442
|
+
refPoint = { lat: geocodeRes.center.lat, lng: geocodeRes.center.lng, description: `geocoded centroid of "${geocodeRes.matched_place}"` };
|
|
2443
|
+
if (useAreaBoundary && geocodeRes.osm_area_id) {
|
|
2444
|
+
usedAreaId = geocodeRes.osm_area_id;
|
|
2445
|
+
}
|
|
897
2446
|
}
|
|
898
2447
|
catch (err) {
|
|
899
2448
|
if (args.bbox) {
|
|
900
2449
|
log('warn', `Geocoding failed, falling back to bbox argument`, { error: err.message });
|
|
901
|
-
bboxObj = parseBBox(args.bbox);
|
|
2450
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
2451
|
+
refPoint = { lat: (bboxObj.lat1 + bboxObj.lat2) / 2, lng: (bboxObj.lng1 + bboxObj.lng2) / 2, description: "centroid of provided bounding box" };
|
|
902
2452
|
}
|
|
903
2453
|
else {
|
|
904
2454
|
log('error', `Geocoding failed for overpass location: ${args.location}`, { error: err.message });
|
|
@@ -907,75 +2457,77 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
907
2457
|
}
|
|
908
2458
|
}
|
|
909
2459
|
else if (args.bbox) {
|
|
910
|
-
bboxObj = parseBBox(args.bbox);
|
|
2460
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
2461
|
+
refPoint = { lat: (bboxObj.lat1 + bboxObj.lat2) / 2, lng: (bboxObj.lng1 + bboxObj.lng2) / 2, description: "centroid of provided bounding box" };
|
|
911
2462
|
}
|
|
912
2463
|
else {
|
|
913
2464
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
914
2465
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
log('info', `search_overpass starting`, { query: queryRaw, mode: useCircle ? 'circle' : 'bbox', radius_meters: radiusMeters });
|
|
920
|
-
const endpoints = [
|
|
921
|
-
"https://overpass-api.de/api/interpreter",
|
|
922
|
-
"https://overpass.kumi.systems/api/interpreter"
|
|
923
|
-
];
|
|
924
|
-
if (process.env.OVERPASS_API_URL) {
|
|
925
|
-
endpoints.unshift(process.env.OVERPASS_API_URL);
|
|
926
|
-
}
|
|
927
|
-
let data = null;
|
|
928
|
-
let lastError = null;
|
|
929
|
-
const params = new URLSearchParams();
|
|
930
|
-
params.append('data', overpassQuery);
|
|
931
|
-
for (const endpoint of endpoints) {
|
|
932
|
-
const controller = new AbortController();
|
|
933
|
-
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
|
|
934
|
-
try {
|
|
935
|
-
log('info', `Executing Overpass query at ${endpoint}`);
|
|
936
|
-
const response = await fetch(endpoint, {
|
|
937
|
-
method: "POST",
|
|
938
|
-
body: params.toString(),
|
|
939
|
-
headers: {
|
|
940
|
-
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
941
|
-
'Accept': 'application/json',
|
|
942
|
-
'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
|
|
943
|
-
},
|
|
944
|
-
signal: controller.signal
|
|
945
|
-
});
|
|
946
|
-
if (!response.ok) {
|
|
947
|
-
throw new Error(`Overpass API returned status: ${response.status} ${response.statusText}`);
|
|
948
|
-
}
|
|
949
|
-
data = await response.json();
|
|
950
|
-
break;
|
|
951
|
-
}
|
|
952
|
-
catch (err) {
|
|
953
|
-
lastError = err;
|
|
954
|
-
log('warn', `Overpass API failed at ${endpoint}`, { error: err.message });
|
|
2466
|
+
if (usedAreaId) {
|
|
2467
|
+
const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
|
|
2468
|
+
overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\n${outDirective}`;
|
|
2469
|
+
searchModeLabel = `exact OSM boundary area (id: ${usedAreaId}) for "${geocodeRes?.matched_place}"`;
|
|
955
2470
|
}
|
|
956
|
-
|
|
957
|
-
|
|
2471
|
+
else {
|
|
2472
|
+
const formattedCore = applySpatialFilter(queryRaw, "");
|
|
2473
|
+
overpassQuery = `[out:json][timeout:${timeoutSec}][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${formattedCore});\n${outDirective}`;
|
|
2474
|
+
searchModeLabel = `bounding box`;
|
|
958
2475
|
}
|
|
959
2476
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
}
|
|
964
|
-
|
|
2477
|
+
log('info', `search_overpass starting`, { query: queryRaw, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'), radius_meters: radiusMeters, count_only: countOnly, timeoutSec });
|
|
2478
|
+
const { data, provenance, remarkWarning } = await executeOverpassQuery(overpassQuery, timeoutSec);
|
|
2479
|
+
if (remarkWarning) {
|
|
2480
|
+
warnings.push(`Overpass remark: ${remarkWarning}`);
|
|
2481
|
+
}
|
|
2482
|
+
if (countOnly) {
|
|
2483
|
+
const countEl = (data.elements || []).find((e) => e.type === "count") || data.elements?.[0] || {};
|
|
2484
|
+
const tags = countEl.tags || {};
|
|
2485
|
+
const counts = {
|
|
2486
|
+
nodes: parseInt(tags.nodes || "0", 10),
|
|
2487
|
+
ways: parseInt(tags.ways || "0", 10),
|
|
2488
|
+
relations: parseInt(tags.relations || "0", 10),
|
|
2489
|
+
total: parseInt(tags.total || "0", 10)
|
|
2490
|
+
};
|
|
2491
|
+
const shareUrl = useCircle
|
|
2492
|
+
? buildShareUrlCircleWithMarkers(refPoint, radiusMeters, [])
|
|
2493
|
+
: buildShareUrl(bboxObj || geocodeRes.bbox);
|
|
2494
|
+
const countData = {
|
|
2495
|
+
search_mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'),
|
|
2496
|
+
count_only: true,
|
|
2497
|
+
...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
|
|
2498
|
+
...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
|
|
2499
|
+
counts,
|
|
2500
|
+
share_url: shareUrl,
|
|
2501
|
+
provenance,
|
|
2502
|
+
complete: !remarkWarning,
|
|
2503
|
+
warnings
|
|
2504
|
+
};
|
|
2505
|
+
return {
|
|
2506
|
+
content: [
|
|
2507
|
+
{
|
|
2508
|
+
type: "text",
|
|
2509
|
+
text: `Overpass fast count executed within a ${searchModeLabel}.\nTotal matching elements: ${counts.total.toLocaleString()} (nodes: ${counts.nodes.toLocaleString()}, ways: ${counts.ways.toLocaleString()}, relations: ${counts.relations.toLocaleString()})\n\nView area on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
2510
|
+
}
|
|
2511
|
+
],
|
|
2512
|
+
structuredContent: countData
|
|
2513
|
+
};
|
|
965
2514
|
}
|
|
966
2515
|
const seenIds = new Set();
|
|
967
2516
|
let parsedElements = (data.elements || []).reduce((acc, e) => {
|
|
968
|
-
|
|
2517
|
+
const dedupKey = `${e.type}/${e.id}`;
|
|
2518
|
+
if (seenIds.has(dedupKey))
|
|
969
2519
|
return acc;
|
|
970
|
-
seenIds.add(
|
|
2520
|
+
seenIds.add(dedupKey);
|
|
971
2521
|
const lat = e.lat ?? e.center?.lat ?? e.geometry?.[0]?.lat;
|
|
972
2522
|
const lon = e.lon ?? e.center?.lon ?? e.geometry?.[0]?.lon;
|
|
973
2523
|
if (lat !== undefined && lon !== undefined) {
|
|
2524
|
+
const distM = calculateHaversineDistanceMeters(refPoint.lat, refPoint.lng, lat, lon);
|
|
974
2525
|
const el = {
|
|
975
2526
|
id: e.id,
|
|
976
2527
|
type: e.type,
|
|
977
2528
|
name: e.tags?.name || "Unnamed",
|
|
978
2529
|
coordinates: { lat, lon },
|
|
2530
|
+
distance_meters: Math.round(distM * 10) / 10,
|
|
979
2531
|
tags: e.tags || {}
|
|
980
2532
|
};
|
|
981
2533
|
if (returnGeometry && e.geometry) {
|
|
@@ -985,163 +2537,255 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
985
2537
|
}
|
|
986
2538
|
return acc;
|
|
987
2539
|
}, []);
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
2540
|
+
// Deterministic sorting: sort by (distance_meters, type, id) ascending
|
|
2541
|
+
const sortByDistance = args.sort_by_distance !== false;
|
|
2542
|
+
parsedElements.sort((a, b) => {
|
|
2543
|
+
if (sortByDistance) {
|
|
2544
|
+
const diff = (a.distance_meters ?? 0) - (b.distance_meters ?? 0);
|
|
2545
|
+
if (Math.abs(diff) > 0.001)
|
|
2546
|
+
return diff;
|
|
2547
|
+
}
|
|
2548
|
+
const typeDiff = (a.type || "").localeCompare(b.type || "");
|
|
2549
|
+
if (typeDiff !== 0)
|
|
2550
|
+
return typeDiff;
|
|
2551
|
+
return (a.id ?? 0) - (b.id ?? 0);
|
|
2552
|
+
});
|
|
2553
|
+
if (args.location && useAreaBoundary && !usedAreaId && geocodeRes) {
|
|
2554
|
+
warnings.push(`Exact admin boundary polygon unavailable for '${geocodeRes.matched_place}', fell back to bounding box query.`);
|
|
2555
|
+
}
|
|
2556
|
+
const totalCount = parsedElements.length;
|
|
2557
|
+
const pagedElements = parsedElements.slice(offset, offset + limit);
|
|
2558
|
+
const truncated = totalCount > offset + limit;
|
|
2559
|
+
const hasMore = totalCount > offset + limit;
|
|
2560
|
+
const nextOffset = hasMore ? offset + limit : undefined;
|
|
2561
|
+
const shareUrl = useCircle
|
|
2562
|
+
? buildShareUrlCircleWithMarkers(refPoint, radiusMeters, pagedElements)
|
|
2563
|
+
: (usedAreaId && !args.bbox && pagedElements.length > 0
|
|
2564
|
+
? buildShareUrlMarkersOnly(pagedElements)
|
|
2565
|
+
: buildShareUrlWithMarkers(bboxObj || geocodeRes.bbox, pagedElements));
|
|
2566
|
+
log('info', `search_overpass completed`, { total: totalCount, returned: pagedElements.length, offset, limit, truncated, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox') });
|
|
998
2567
|
const responseData = {
|
|
999
|
-
search_mode: useCircle ? 'circle' : 'bbox',
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
2568
|
+
search_mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'),
|
|
2569
|
+
count_only: false,
|
|
2570
|
+
...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
|
|
2571
|
+
...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
|
|
2572
|
+
distance_from: refPoint,
|
|
2573
|
+
...(data.generator ? { generator: data.generator } : {}),
|
|
2574
|
+
...(data.osm3s ? { osm3s: data.osm3s } : {}),
|
|
2575
|
+
elements: pagedElements,
|
|
2576
|
+
total_count: totalCount,
|
|
2577
|
+
returned_count: pagedElements.length,
|
|
2578
|
+
offset,
|
|
2579
|
+
limit,
|
|
1008
2580
|
truncated,
|
|
1009
|
-
|
|
2581
|
+
has_more: hasMore,
|
|
2582
|
+
...(nextOffset !== undefined ? { next_offset: nextOffset } : {}),
|
|
2583
|
+
pagination_hint: hasMore
|
|
2584
|
+
? `Use offset=${nextOffset} to fetch the next page. Note: Each page re-queries Overpass; for large result sets, consider narrowing your query filters or bounding area instead of deep paging.`
|
|
2585
|
+
: "End of result set.",
|
|
2586
|
+
share_url: shareUrl,
|
|
2587
|
+
provenance,
|
|
2588
|
+
complete: !truncated && !remarkWarning,
|
|
2589
|
+
warnings
|
|
1010
2590
|
};
|
|
1011
|
-
const topResults =
|
|
1012
|
-
.map((e) => `- [${e.type}] ${e.name} (${e.coordinates.lat.toFixed(5)}, ${e.coordinates.lon.toFixed(5)})`)
|
|
2591
|
+
const topResults = pagedElements.slice(0, 5)
|
|
2592
|
+
.map((e) => `- [${e.type}] ${e.name} (${e.coordinates.lat.toFixed(5)}, ${e.coordinates.lon.toFixed(5)}) — ${e.distance_meters}m away`)
|
|
1013
2593
|
.join('\n');
|
|
1014
|
-
const moreNote =
|
|
2594
|
+
const moreNote = pagedElements.length > 5 ? `\n … and ${pagedElements.length - 5} more on this page` : '';
|
|
1015
2595
|
return {
|
|
1016
2596
|
content: [
|
|
1017
2597
|
{
|
|
1018
2598
|
type: "text",
|
|
1019
|
-
text: `Overpass query executed within a ${searchModeLabel}.\nFound ${
|
|
1020
|
-
},
|
|
1021
|
-
{
|
|
1022
|
-
type: "text",
|
|
1023
|
-
text: JSON.stringify(responseData, null, 2)
|
|
2599
|
+
text: `Overpass query executed within a ${searchModeLabel}.\nFound ${totalCount} element${totalCount !== 1 ? 's' : ''}${totalCount > limit ? ` (showing items ${offset + 1}-${offset + pagedElements.length} of ${totalCount})` : ''}.\nDistance measured from: ${refPoint.description}\n\nTop results:\n${topResults}${moreNote}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
1024
2600
|
}
|
|
1025
|
-
]
|
|
2601
|
+
],
|
|
2602
|
+
structuredContent: responseData
|
|
1026
2603
|
};
|
|
1027
2604
|
}
|
|
1028
2605
|
else if (toolName === "list_osm_tags") {
|
|
1029
2606
|
const args = request.params.arguments || {};
|
|
1030
|
-
const category = (args.category || "").toLowerCase();
|
|
2607
|
+
const category = (args.category || "").toLowerCase().trim();
|
|
2608
|
+
const warnings = [];
|
|
1031
2609
|
let recommendations = "";
|
|
2610
|
+
let source = "built-in";
|
|
2611
|
+
let tags = [];
|
|
2612
|
+
let provenance = {
|
|
2613
|
+
provider: "built-in-osm-tag-index",
|
|
2614
|
+
data_timestamp: new Date().toISOString(),
|
|
2615
|
+
cached: true,
|
|
2616
|
+
cache_age_ms: 0
|
|
2617
|
+
};
|
|
1032
2618
|
switch (true) {
|
|
1033
|
-
case category.includes('food') || category.includes('restaurant') || category.includes('cafe'):
|
|
1034
|
-
recommendations = `Restaurants: nwr["amenity"="restaurant"]\nFast Food: nwr["amenity"="fast_food"]\nCafes: nwr["amenity"="cafe"]\nBars: nwr["amenity"="bar"]\nPubs: nwr["amenity"="pub"]`;
|
|
2619
|
+
case category.includes('food') || category.includes('restaurant') || category.includes('cafe') || category.includes('pizza') || category.includes('dining'):
|
|
2620
|
+
recommendations = `Restaurants: nwr["amenity"="restaurant"]\nFast Food: nwr["amenity"="fast_food"]\nCafes: nwr["amenity"="cafe"]\nBars: nwr["amenity"="bar"]\nPubs: nwr["amenity"="pub"]\nIce Cream: nwr["amenity"="ice_cream"]\nCoffee Shops: nwr["shop"="coffee"]\nBakeries: nwr["shop"="bakery"]`;
|
|
1035
2621
|
break;
|
|
1036
|
-
case category.includes('health') || category.includes('medical'):
|
|
1037
|
-
recommendations = `Hospitals: nwr["amenity"="hospital"]\nClinics: nwr["amenity"="clinic"]\nPharmacies: nwr["amenity"="pharmacy"]\nDoctors: nwr["amenity"="doctors"]\nDentists: nwr["amenity"="dentist"]`;
|
|
2622
|
+
case category.includes('health') || category.includes('medical') || category.includes('doctor') || category.includes('hospital') || category.includes('pharmacy'):
|
|
2623
|
+
recommendations = `Hospitals: nwr["amenity"="hospital"]\nClinics: nwr["amenity"="clinic"]\nPharmacies: nwr["amenity"="pharmacy"]\nDoctors: nwr["amenity"="doctors"]\nDentists: nwr["amenity"="dentist"]\nGeneral Healthcare: nwr["healthcare"]`;
|
|
1038
2624
|
break;
|
|
1039
|
-
case category.includes('transport') || category.includes('transit') || category.includes('
|
|
1040
|
-
recommendations = `Parking: nwr["amenity"="parking"]\nBus Stops: nwr["highway"="bus_stop"]\nSubway Stations: nwr["railway"="station"]["station"="subway"]\nTrain Stations: nwr["railway"="station"]\nBicycle Parking: nwr["amenity"="bicycle_parking"]\nGas Stations: nwr["amenity"="fuel"]`;
|
|
2625
|
+
case category.includes('transport') || category.includes('transit') || category.includes('bus') || category.includes('train') || category.includes('subway') || category.includes('gas') || category.includes('fuel') || category.includes('parking') || category.includes('airport') || category.includes('taxi'):
|
|
2626
|
+
recommendations = `Parking: nwr["amenity"="parking"]\nBus Stops: nwr["highway"="bus_stop"]\nSubway Stations: nwr["railway"="station"]["station"="subway"]\nTrain Stations: nwr["railway"="station"]\nBicycle Parking: nwr["amenity"="bicycle_parking"]\nGas Stations: nwr["amenity"="fuel"]\nFerry Terminals: nwr["amenity"="ferry_terminal"]\nAerodromes: nwr["aeroway"="aerodrome"]\nTaxi Stands: nwr["amenity"="taxi"]`;
|
|
1041
2627
|
break;
|
|
1042
|
-
case category.includes('leisure') || category.includes('park') || category.includes('
|
|
2628
|
+
case category.includes('leisure') || category.includes('park') || category.includes('playground') || category.includes('theatre') || category.includes('cinema'):
|
|
1043
2629
|
recommendations = `Parks: nwr["leisure"="park"]\nPlaygrounds: nwr["leisure"="playground"]\nTheatres: nwr["amenity"="theatre"]\nCinemas: nwr["amenity"="cinema"]\nSports Centres: nwr["leisure"="sports_centre"]`;
|
|
1044
2630
|
break;
|
|
1045
|
-
case category.includes('retail') || category.includes('shop') || category.includes('store'):
|
|
1046
|
-
recommendations = `Supermarkets: nwr["shop"="supermarket"]\nConvenience Stores: nwr["shop"="convenience"]\nMalls: nwr["shop"="mall"]\nClothes Shops: nwr["shop"="clothes"]\nBakeries: nwr["shop"="bakery"]`;
|
|
2631
|
+
case category.includes('retail') || category.includes('shop') || category.includes('store') || category.includes('supermarket') || category.includes('mall'):
|
|
2632
|
+
recommendations = `Supermarkets: nwr["shop"="supermarket"]\nConvenience Stores: nwr["shop"="convenience"]\nMalls: nwr["shop"="mall"]\nClothes Shops: nwr["shop"="clothes"]\nBakeries: nwr["shop"="bakery"]\nAlcohol Shops: nwr["shop"="alcohol"]\nHairdressers: nwr["shop"="hairdresser"]\nLaundries: nwr["shop"="laundry"]`;
|
|
1047
2633
|
break;
|
|
1048
|
-
case category.includes('education') || category.includes('school'):
|
|
2634
|
+
case category.includes('education') || category.includes('school') || category.includes('university') || category.includes('college') || category.includes('library'):
|
|
1049
2635
|
recommendations = `Schools: nwr["amenity"="school"]\nUniversities: nwr["amenity"="university"]\nColleges: nwr["amenity"="college"]\nKindergartens: nwr["amenity"="kindergarten"]\nLibraries: nwr["amenity"="library"]`;
|
|
1050
2636
|
break;
|
|
1051
|
-
case category.includes('tourism') || category.includes('hotel'):
|
|
1052
|
-
recommendations = `Hotels: nwr["tourism"="hotel"]\nMotels: nwr["tourism"="motel"]\nHostels: nwr["tourism"="hostel"]\nAttractions: nwr["tourism"="attraction"]\nMuseums: nwr["tourism"="museum"]\
|
|
2637
|
+
case category.includes('tourism') || category.includes('hotel') || category.includes('motel') || category.includes('hostel') || category.includes('museum') || category.includes('attraction'):
|
|
2638
|
+
recommendations = `Hotels: nwr["tourism"="hotel"]\nMotels: nwr["tourism"="motel"]\nHostels: nwr["tourism"="hostel"]\nAttractions: nwr["tourism"="attraction"]\nMuseums: nwr["tourism"="museum"]\nGuest Houses: nwr["tourism"="guest_house"]\nApartments: nwr["tourism"="apartment"]\nCampsites: nwr["tourism"="camp_site"]`;
|
|
2639
|
+
break;
|
|
2640
|
+
case category.includes('finance') || category.includes('bank') || category.includes('atm') || category.includes('post') || category.includes('police') || category.includes('fire') || category.includes('embassy'):
|
|
2641
|
+
recommendations = `Banks: nwr["amenity"="bank"]\nATMs: nwr["amenity"="atm"]\nPost Offices: nwr["amenity"="post_office"]\nPolice: nwr["amenity"="police"]\nFire Stations: nwr["amenity"="fire_station"]\nCourthouses: nwr["amenity"="courthouse"]\nEmbassies: nwr["office"="diplomatic"]`;
|
|
2642
|
+
break;
|
|
2643
|
+
case category.includes('fitness') || category.includes('sport') || category.includes('gym') || category.includes('swimming') || category.includes('pool') || category.includes('stadium') || category.includes('golf') || category.includes('climbing'):
|
|
2644
|
+
recommendations = `Gyms: nwr["leisure"="fitness_centre"]\nSwimming Pools: nwr["leisure"="swimming_pool"]\nStadiums: nwr["leisure"="stadium"]\nSports Pitches: nwr["leisure"="pitch"]\nGolf Courses: nwr["leisure"="golf_course"]\nClimbing: nwr["sport"="climbing"]`;
|
|
2645
|
+
break;
|
|
2646
|
+
case category.includes('nature') || category.includes('outdoor') || category.includes('forest') || category.includes('water') || category.includes('beach') || category.includes('peak') || category.includes('viewpoint') || category.includes('hike') || category.includes('trail'):
|
|
2647
|
+
recommendations = `Forests: nwr["landuse"="forest"]\nWater Bodies: nwr["natural"="water"]\nBeaches: nwr["natural"="beach"]\nPeaks: nwr["natural"="peak"]\nViewpoints: nwr["tourism"="viewpoint"]\nNature Reserves: nwr["leisure"="nature_reserve"]\nPicnic Sites: nwr["tourism"="picnic_site"]\nHiking Trails: way["highway"="path"]["sac_scale"]`;
|
|
2648
|
+
break;
|
|
2649
|
+
case category.includes('religion') || category.includes('worship') || category.includes('church') || category.includes('mosque') || category.includes('synagogue') || category.includes('temple') || category.includes('cemetery'):
|
|
2650
|
+
recommendations = `Places of Worship: nwr["amenity"="place_of_worship"]\nChurches: nwr["amenity"="place_of_worship"]["religion"="christian"]\nMosques: nwr["amenity"="place_of_worship"]["religion"="muslim"]\nSynagogues: nwr["amenity"="place_of_worship"]["religion"="jewish"]\nTemples: nwr["amenity"="place_of_worship"]["religion"~"buddhist|hindu"]\nCemeteries: nwr["landuse"="cemetery"]`;
|
|
2651
|
+
break;
|
|
2652
|
+
case category.includes('ev') || category.includes('automotive') || category.includes('car') || category.includes('charging') || category.includes('mechanic') || category.includes('rental'):
|
|
2653
|
+
recommendations = `EV Charging: nwr["amenity"="charging_station"]\nCar Repair: nwr["shop"="car_repair"]\nCar Dealers: nwr["shop"="car"]\nCar Wash: nwr["amenity"="car_wash"]\nCar Rental: nwr["amenity"="car_rental"]\nDriving Schools: nwr["amenity"="driving_school"]`;
|
|
2654
|
+
break;
|
|
2655
|
+
case category.includes('nightlife') || category.includes('nightclub') || category.includes('casino') || category.includes('arcade') || category.includes('bowling') || category.includes('escape'):
|
|
2656
|
+
recommendations = `Nightclubs: nwr["amenity"="nightclub"]\nCasinos: nwr["amenity"="casino"]\nArcades: nwr["leisure"="amusement_arcade"]\nBowling Alleys: nwr["leisure"="bowling_alley"]\nEscape Rooms: nwr["leisure"="escape_game"]`;
|
|
2657
|
+
break;
|
|
2658
|
+
case category.includes('utility') || category.includes('toilet') || category.includes('water') || category.includes('bench') || category.includes('waste') || category.includes('recycling') || category.includes('shower') || category.includes('locker'):
|
|
2659
|
+
recommendations = `Toilets: nwr["amenity"="toilets"]\nDrinking Water: nwr["amenity"="drinking_water"]\nBenches: nwr["amenity"="bench"]\nWaste Baskets: nwr["amenity"="waste_basket"]\nRecycling: nwr["amenity"="recycling"]\nPublic Showers: nwr["amenity"="shower"]\nLockers: nwr["amenity"="locker"]`;
|
|
2660
|
+
break;
|
|
2661
|
+
case category.includes('work') || category.includes('office') || category.includes('coworking') || category.includes('conference'):
|
|
2662
|
+
recommendations = `Coworking Spaces: nwr["office"="coworking"]; nwr["amenity"="coworking_space"];\nGeneral Offices: nwr["office"]\nConference Centres: nwr["amenity"="conference_centre"]`;
|
|
1053
2663
|
break;
|
|
1054
|
-
|
|
1055
|
-
recommendations = `
|
|
2664
|
+
case category.includes('pet') || category.includes('dog') || category.includes('vet') || category.includes('zoo') || category.includes('animal'):
|
|
2665
|
+
recommendations = `Dog Parks: nwr["leisure"="dog_park"]\nVeterinarians: nwr["amenity"="veterinary"]\nPet Shops: nwr["shop"="pet"]\nZoos: nwr["tourism"="zoo"]`;
|
|
1056
2666
|
break;
|
|
2667
|
+
case category.includes('childcare') || category.includes('daycare') || category.includes('child'):
|
|
2668
|
+
recommendations = `Childcare/Daycare: nwr["amenity"="childcare"]\nPlaygrounds: nwr["leisure"="playground"]`;
|
|
2669
|
+
break;
|
|
2670
|
+
default: {
|
|
2671
|
+
log('info', `Category '${category}' not in index, querying Taginfo API`);
|
|
2672
|
+
source = "taginfo";
|
|
2673
|
+
const taginfoRes = await getTaginfoSuggestions(category);
|
|
2674
|
+
recommendations = taginfoRes.text;
|
|
2675
|
+
tags = taginfoRes.tags;
|
|
2676
|
+
if (taginfoRes.warning)
|
|
2677
|
+
warnings.push(taginfoRes.warning);
|
|
2678
|
+
provenance = {
|
|
2679
|
+
provider: "taginfo (https://taginfo.openstreetmap.org)",
|
|
2680
|
+
attribution: OSM_ATTRIBUTION,
|
|
2681
|
+
data_timestamp: taginfoRes.timestamp,
|
|
2682
|
+
cached: taginfoRes.cached,
|
|
2683
|
+
cache_age_ms: taginfoRes.cache_age_ms
|
|
2684
|
+
};
|
|
2685
|
+
break;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
if (source === "built-in") {
|
|
2689
|
+
const matches = recommendations.match(/(?:nwr|node|way|relation)\[[^\n;]+/g);
|
|
2690
|
+
tags = matches ? matches.map(m => m.trim()) : [];
|
|
1057
2691
|
}
|
|
1058
2692
|
return {
|
|
1059
2693
|
content: [{
|
|
1060
2694
|
type: "text",
|
|
1061
|
-
text: `OSM Tag Recommendations for '${category}':\n\n${recommendations}\n\nTip: You can
|
|
1062
|
-
}]
|
|
2695
|
+
text: `OSM Tag Recommendations for '${category}':\n\n${recommendations}\n\nTip: You can use these tag selectors directly in the 'query' argument of search_overpass.`
|
|
2696
|
+
}],
|
|
2697
|
+
structuredContent: {
|
|
2698
|
+
category,
|
|
2699
|
+
source,
|
|
2700
|
+
tags,
|
|
2701
|
+
recommendations_text: recommendations,
|
|
2702
|
+
provenance,
|
|
2703
|
+
complete: warnings.length === 0,
|
|
2704
|
+
warnings
|
|
2705
|
+
}
|
|
1063
2706
|
};
|
|
1064
2707
|
}
|
|
1065
2708
|
else if (toolName === "aggregate_overpass_h3") {
|
|
1066
2709
|
const args = request.params.arguments || {};
|
|
1067
2710
|
const queryRaw = args.query;
|
|
1068
|
-
const resolution = args.resolution
|
|
2711
|
+
const resolution = args.resolution ?? 8;
|
|
2712
|
+
const timeoutSec = args.timeout_seconds || 25;
|
|
2713
|
+
const useAreaBoundary = args.use_area_boundary !== false;
|
|
2714
|
+
const radiusMeters = args.radius_meters;
|
|
2715
|
+
const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
|
|
2716
|
+
const warnings = [];
|
|
1069
2717
|
if (!queryRaw) {
|
|
1070
2718
|
return { content: [{ type: "text", text: "Error: 'query' is required." }], isError: true };
|
|
1071
2719
|
}
|
|
2720
|
+
if (queryRaw.length > 2000) {
|
|
2721
|
+
return { content: [{ type: "text", text: "Error: Query is too long (maximum 2000 characters)." }], isError: true };
|
|
2722
|
+
}
|
|
2723
|
+
if (queryRaw.includes("[out:") || queryRaw.includes("[timeout:") || queryRaw.includes("[bbox:")) {
|
|
2724
|
+
return { content: [{ type: "text", text: "Error: Query must not contain [out:], [timeout:], or [bbox:] directives." }], isError: true };
|
|
2725
|
+
}
|
|
2726
|
+
if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
|
|
2727
|
+
return { content: [{ type: "text", text: `Error: Invalid H3 resolution: ${resolution}. Must be 0-15.` }], isError: true };
|
|
2728
|
+
}
|
|
1072
2729
|
let bboxObj;
|
|
2730
|
+
let geocodeRes;
|
|
2731
|
+
let usedAreaId;
|
|
1073
2732
|
if (args.location) {
|
|
1074
2733
|
try {
|
|
1075
|
-
|
|
2734
|
+
geocodeRes = await geocodeLocation(args.location);
|
|
2735
|
+
bboxObj = geocodeRes.bbox;
|
|
2736
|
+
if (useAreaBoundary && geocodeRes.osm_area_id) {
|
|
2737
|
+
usedAreaId = geocodeRes.osm_area_id;
|
|
2738
|
+
}
|
|
1076
2739
|
}
|
|
1077
2740
|
catch (err) {
|
|
1078
2741
|
if (!args.bbox) {
|
|
1079
2742
|
return { content: [{ type: "text", text: `Error: Geocoding failed: ${err.message}` }], isError: true };
|
|
1080
2743
|
}
|
|
1081
|
-
bboxObj = parseBBox(args.bbox);
|
|
2744
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
1082
2745
|
}
|
|
1083
2746
|
}
|
|
1084
2747
|
else if (args.bbox) {
|
|
1085
|
-
bboxObj = parseBBox(args.bbox);
|
|
2748
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
1086
2749
|
}
|
|
1087
2750
|
else {
|
|
1088
2751
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
1089
2752
|
}
|
|
1090
|
-
log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution });
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
2753
|
+
log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution, timeoutSec, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox') });
|
|
2754
|
+
let overpassQuery;
|
|
2755
|
+
const centerPt = geocodeRes?.center || { lat: (bboxObj.lat1 + bboxObj.lat2) / 2, lng: (bboxObj.lng1 + bboxObj.lng2) / 2 };
|
|
2756
|
+
if (useCircle) {
|
|
2757
|
+
const circleFilter = `(around:${radiusMeters},${centerPt.lat},${centerPt.lng})`;
|
|
2758
|
+
const formattedCore = applySpatialFilter(queryRaw, circleFilter);
|
|
2759
|
+
overpassQuery = `[out:json][timeout:${timeoutSec}];\n(${formattedCore});\nout center;`;
|
|
1094
2760
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
const params = new URLSearchParams();
|
|
1099
|
-
params.append('data', overpassQuery);
|
|
1100
|
-
const endpoints = [
|
|
1101
|
-
"https://overpass-api.de/api/interpreter",
|
|
1102
|
-
"https://overpass.kumi.systems/api/interpreter"
|
|
1103
|
-
];
|
|
1104
|
-
if (process.env.OVERPASS_API_URL) {
|
|
1105
|
-
endpoints.unshift(process.env.OVERPASS_API_URL);
|
|
2761
|
+
else if (usedAreaId) {
|
|
2762
|
+
const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
|
|
2763
|
+
overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\nout center;`;
|
|
1106
2764
|
}
|
|
1107
|
-
|
|
1108
|
-
const
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
headers: {
|
|
1115
|
-
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
1116
|
-
'Accept': 'application/json',
|
|
1117
|
-
'User-Agent': `${SERVER_NAME}/${SERVER_VERSION}`
|
|
1118
|
-
},
|
|
1119
|
-
signal: controller.signal
|
|
1120
|
-
});
|
|
1121
|
-
if (!response.ok)
|
|
1122
|
-
throw new Error(`Status: ${response.status}`);
|
|
1123
|
-
data = await response.json();
|
|
1124
|
-
break;
|
|
1125
|
-
}
|
|
1126
|
-
catch (err) {
|
|
1127
|
-
lastError = err;
|
|
1128
|
-
}
|
|
1129
|
-
finally {
|
|
1130
|
-
clearTimeout(timeout);
|
|
1131
|
-
}
|
|
2765
|
+
else {
|
|
2766
|
+
const formattedCore = applySpatialFilter(queryRaw, "");
|
|
2767
|
+
overpassQuery = `[out:json][timeout:${timeoutSec}][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${formattedCore});\nout center;`;
|
|
2768
|
+
}
|
|
2769
|
+
let execResult;
|
|
2770
|
+
try {
|
|
2771
|
+
execResult = await executeOverpassQuery(overpassQuery, timeoutSec);
|
|
1132
2772
|
}
|
|
1133
|
-
|
|
1134
|
-
return { content: [{ type: "text", text: `Error: Overpass query failed: ${
|
|
2773
|
+
catch (err) {
|
|
2774
|
+
return { content: [{ type: "text", text: `Error: Overpass query failed: ${err.message}` }], isError: true };
|
|
2775
|
+
}
|
|
2776
|
+
const { data, provenance, remarkWarning } = execResult;
|
|
2777
|
+
if (remarkWarning) {
|
|
2778
|
+
warnings.push(`Overpass remark: ${remarkWarning}`);
|
|
1135
2779
|
}
|
|
1136
2780
|
let rawElements = data.elements || [];
|
|
1137
|
-
// Bin into H3 Hexagons
|
|
1138
2781
|
const hexCounts = {};
|
|
1139
2782
|
const hexElements = {};
|
|
1140
2783
|
const seenIds = new Set();
|
|
1141
2784
|
rawElements.forEach((e) => {
|
|
1142
|
-
|
|
2785
|
+
const dedupKey = `${e.type}/${e.id}`;
|
|
2786
|
+
if (seenIds.has(dedupKey))
|
|
1143
2787
|
return;
|
|
1144
|
-
seenIds.add(
|
|
2788
|
+
seenIds.add(dedupKey);
|
|
1145
2789
|
const lat = e.lat ?? e.center?.lat;
|
|
1146
2790
|
const lon = e.lon ?? e.center?.lon;
|
|
1147
2791
|
if (lat !== undefined && lon !== undefined) {
|
|
@@ -1149,50 +2793,649 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1149
2793
|
hexCounts[h3Index] = (hexCounts[h3Index] || 0) + 1;
|
|
1150
2794
|
if (!hexElements[h3Index])
|
|
1151
2795
|
hexElements[h3Index] = [];
|
|
1152
|
-
// Keep element data minimal to avoid massive payloads
|
|
1153
2796
|
hexElements[h3Index].push({
|
|
1154
2797
|
id: e.id,
|
|
1155
2798
|
name: e.tags?.name || "Unnamed"
|
|
1156
2799
|
});
|
|
1157
2800
|
}
|
|
1158
2801
|
});
|
|
1159
|
-
const shareUrl = buildShareUrl(bboxObj);
|
|
1160
2802
|
const totalGrouped = Object.values(hexCounts).reduce((a, b) => a + b, 0);
|
|
1161
|
-
// Generate GeoJSON of the active hexes
|
|
1162
2803
|
const activeHexes = Object.keys(hexCounts);
|
|
1163
2804
|
const geojson = h3CellsToGeoJSON(activeHexes);
|
|
1164
|
-
// Inject the counts into the GeoJSON properties
|
|
1165
2805
|
geojson.features.forEach((f) => {
|
|
1166
2806
|
const cellId = f.properties.h3_index;
|
|
1167
2807
|
f.properties.count = hexCounts[cellId];
|
|
1168
2808
|
f.properties.elements = hexElements[cellId];
|
|
1169
2809
|
});
|
|
2810
|
+
const topHexes = Object.entries(hexCounts)
|
|
2811
|
+
.sort((a, b) => b[1] - a[1])
|
|
2812
|
+
.slice(0, 10);
|
|
2813
|
+
// Plot the top hex hotspot centroids as pins on the verification map!
|
|
2814
|
+
const hotspotMarkers = topHexes.map(([hex, count], idx) => {
|
|
2815
|
+
const [lat, lon] = h3.cellToLatLng(hex);
|
|
2816
|
+
return {
|
|
2817
|
+
coordinates: { lat, lon },
|
|
2818
|
+
name: `#${idx + 1} (${count} items)`
|
|
2819
|
+
};
|
|
2820
|
+
});
|
|
2821
|
+
const shareUrl = useCircle
|
|
2822
|
+
? buildShareUrlCircleWithMarkers(centerPt, radiusMeters, hotspotMarkers)
|
|
2823
|
+
: (hotspotMarkers.length > 0
|
|
2824
|
+
? (usedAreaId && !args.bbox
|
|
2825
|
+
? buildShareUrlMarkersOnly(hotspotMarkers)
|
|
2826
|
+
: buildShareUrlWithMarkers(bboxObj, hotspotMarkers))
|
|
2827
|
+
: buildShareUrl(bboxObj));
|
|
1170
2828
|
log('info', `aggregate_overpass_h3 completed`, { resolution, elements: totalGrouped, activeHexes: activeHexes.length });
|
|
1171
2829
|
const responseData = {
|
|
1172
2830
|
resolution,
|
|
2831
|
+
...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
|
|
2832
|
+
...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
|
|
1173
2833
|
total_elements: totalGrouped,
|
|
1174
2834
|
active_hex_count: activeHexes.length,
|
|
1175
2835
|
hex_counts: hexCounts,
|
|
1176
2836
|
hex_geojson: geojson,
|
|
1177
2837
|
original_bbox: bboxObj,
|
|
1178
|
-
share_url: shareUrl
|
|
2838
|
+
share_url: shareUrl,
|
|
2839
|
+
provenance,
|
|
2840
|
+
complete: !remarkWarning,
|
|
2841
|
+
warnings
|
|
1179
2842
|
};
|
|
1180
|
-
// Sort map for display
|
|
1181
|
-
const topHexes = Object.entries(hexCounts)
|
|
1182
|
-
.sort((a, b) => b[1] - a[1])
|
|
1183
|
-
.slice(0, 10);
|
|
1184
2843
|
return {
|
|
1185
2844
|
content: [
|
|
1186
2845
|
{
|
|
1187
2846
|
type: "text",
|
|
1188
|
-
text: `Aggregated ${totalGrouped} element${totalGrouped !== 1 ? 's' : ''} into ${activeHexes.length} H3 hexagon${activeHexes.length !== 1 ? 's' : ''} at resolution ${resolution}.\n\nTop hexagons by density:\n${topHexes.map(([hex, count]) => `- ${hex}: ${count} element${count !== 1 ? 's' : ''}`).join('\n')}\n\nView on map: ${shareUrl}`
|
|
2847
|
+
text: `Aggregated ${totalGrouped} element${totalGrouped !== 1 ? 's' : ''} into ${activeHexes.length} H3 hexagon${activeHexes.length !== 1 ? 's' : ''} at resolution ${resolution}.\n\nTop hexagons by density:\n${topHexes.map(([hex, count]) => `- ${hex}: ${count} element${count !== 1 ? 's' : ''}`).join('\n')}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
2848
|
+
}
|
|
2849
|
+
],
|
|
2850
|
+
structuredContent: responseData
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
else if (toolName === "calculate_route") {
|
|
2854
|
+
const args = request.params.arguments || {};
|
|
2855
|
+
const originInput = (args.origin || "").trim();
|
|
2856
|
+
const destInput = (args.destination || "").trim();
|
|
2857
|
+
const profile = args.profile || "driving";
|
|
2858
|
+
const returnSteps = args.return_steps || false;
|
|
2859
|
+
const warnings = [];
|
|
2860
|
+
if (!originInput || !destInput) {
|
|
2861
|
+
return { content: [{ type: "text", text: "Error: Both 'origin' and 'destination' are required." }], isError: true };
|
|
2862
|
+
}
|
|
2863
|
+
async function resolveEndpoint(input) {
|
|
2864
|
+
const coordMatch = input.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/);
|
|
2865
|
+
if (coordMatch) {
|
|
2866
|
+
const p0 = parseFloat(coordMatch[1]);
|
|
2867
|
+
const p1 = parseFloat(coordMatch[2]);
|
|
2868
|
+
if (validateBBox(p0, p1, p0, p1)) {
|
|
2869
|
+
return { lat: p0, lng: p1, label: `(${p0.toFixed(5)}, ${p1.toFixed(5)})` };
|
|
2870
|
+
}
|
|
2871
|
+
else if (validateBBox(p1, p0, p1, p0)) {
|
|
2872
|
+
warnings.push(`Endpoint '${input}' interpreted as lng,lat -> converted to lat=${p1}, lng=${p0}.`);
|
|
2873
|
+
return { lat: p1, lng: p0, label: `(${p1.toFixed(5)}, ${p0.toFixed(5)})` };
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
const geo = await geocodeLocation(input);
|
|
2877
|
+
return { lat: geo.center.lat, lng: geo.center.lng, label: geo.matched_place };
|
|
2878
|
+
}
|
|
2879
|
+
const start = await resolveEndpoint(originInput);
|
|
2880
|
+
const end = await resolveEndpoint(destInput);
|
|
2881
|
+
const straightLineMeters = calculateHaversineDistanceMeters(start.lat, start.lng, end.lat, end.lng);
|
|
2882
|
+
const bearing = calculateBearingDegrees(start.lat, start.lng, end.lat, end.lng);
|
|
2883
|
+
// OSRM public servers (FOSSGIS Germany regional profiles + OSRM main demo)
|
|
2884
|
+
const osrmProfileMap = {
|
|
2885
|
+
driving: { fossgis: "routed-car/route/v1/driving", main: "route/v1/driving" },
|
|
2886
|
+
walking: { fossgis: "routed-foot/route/v1/driving", main: "route/v1/foot" },
|
|
2887
|
+
cycling: { fossgis: "routed-bike/route/v1/driving", main: "route/v1/bike" }
|
|
2888
|
+
};
|
|
2889
|
+
const prof = osrmProfileMap[profile] || osrmProfileMap.driving;
|
|
2890
|
+
const coordsPair = `${start.lng.toFixed(6)},${start.lat.toFixed(6)};${end.lng.toFixed(6)},${end.lat.toFixed(6)}`;
|
|
2891
|
+
const queryParams = `?overview=full&geometries=geojson&steps=${returnSteps ? 'true' : 'false'}`;
|
|
2892
|
+
const routeCacheKey = `${profile}:${coordsPair}:${returnSteps}`;
|
|
2893
|
+
let routeData = null;
|
|
2894
|
+
let usedProvider = "";
|
|
2895
|
+
let cachedRoute = false;
|
|
2896
|
+
let cacheAgeMs = 0;
|
|
2897
|
+
let dataTimestamp = new Date().toISOString();
|
|
2898
|
+
const cachedEntry = ROUTE_CACHE.get(routeCacheKey);
|
|
2899
|
+
if (cachedEntry && (Date.now() - cachedEntry.timestamp < ROUTE_CACHE_TTL_MS)) {
|
|
2900
|
+
routeData = cachedEntry.data;
|
|
2901
|
+
usedProvider = cachedEntry.provider;
|
|
2902
|
+
cachedRoute = true;
|
|
2903
|
+
cacheAgeMs = Date.now() - cachedEntry.timestamp;
|
|
2904
|
+
dataTimestamp = new Date(cachedEntry.timestamp).toISOString();
|
|
2905
|
+
}
|
|
2906
|
+
else {
|
|
2907
|
+
const routingUrls = [
|
|
2908
|
+
{
|
|
2909
|
+
url: `https://routing.openstreetmap.de/${prof.fossgis}/${coordsPair}${queryParams}`,
|
|
2910
|
+
provider: "fossgis-osrm (https://routing.openstreetmap.de)"
|
|
1189
2911
|
},
|
|
2912
|
+
{
|
|
2913
|
+
url: `https://router.project-osrm.org/${prof.main}/${coordsPair}${queryParams}`,
|
|
2914
|
+
provider: "project-osrm-demo (https://router.project-osrm.org)"
|
|
2915
|
+
}
|
|
2916
|
+
];
|
|
2917
|
+
if (process.env.OSRM_API_URL) {
|
|
2918
|
+
const customBase = process.env.OSRM_API_URL.replace(/\/+$/, "");
|
|
2919
|
+
routingUrls.unshift({
|
|
2920
|
+
url: `${customBase}/${prof.main}/${coordsPair}${queryParams}`,
|
|
2921
|
+
provider: `custom-osrm (${customBase})`
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2924
|
+
let lastRouteErr = null;
|
|
2925
|
+
for (const candidate of routingUrls) {
|
|
2926
|
+
await routingRateLimit();
|
|
2927
|
+
const controller = new AbortController();
|
|
2928
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
2929
|
+
try {
|
|
2930
|
+
const res = await fetch(candidate.url, {
|
|
2931
|
+
headers: {
|
|
2932
|
+
'User-Agent': APP_USER_AGENT,
|
|
2933
|
+
'Accept': 'application/json'
|
|
2934
|
+
},
|
|
2935
|
+
signal: controller.signal
|
|
2936
|
+
});
|
|
2937
|
+
if (!res.ok)
|
|
2938
|
+
throw new Error(`Status ${res.status}`);
|
|
2939
|
+
const json = await res.json();
|
|
2940
|
+
if (json.code === "Ok" && Array.isArray(json.routes) && json.routes.length > 0) {
|
|
2941
|
+
routeData = json.routes[0];
|
|
2942
|
+
usedProvider = candidate.provider;
|
|
2943
|
+
const now = Date.now();
|
|
2944
|
+
dataTimestamp = new Date(now).toISOString();
|
|
2945
|
+
ROUTE_CACHE.set(routeCacheKey, { timestamp: now, data: routeData, provider: usedProvider });
|
|
2946
|
+
break;
|
|
2947
|
+
}
|
|
2948
|
+
throw new Error(json.message || `Routing returned code ${json.code}`);
|
|
2949
|
+
}
|
|
2950
|
+
catch (err) {
|
|
2951
|
+
lastRouteErr = err;
|
|
2952
|
+
log('warn', `OSRM routing failed at ${candidate.url}`, { error: err.message });
|
|
2953
|
+
}
|
|
2954
|
+
finally {
|
|
2955
|
+
clearTimeout(timeout);
|
|
2956
|
+
}
|
|
2957
|
+
}
|
|
2958
|
+
if (!routeData) {
|
|
2959
|
+
return {
|
|
2960
|
+
content: [{ type: "text", text: `Error: Route calculation failed on all configured OSRM servers: ${lastRouteErr?.message}` }],
|
|
2961
|
+
isError: true
|
|
2962
|
+
};
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
const distanceMeters = Math.round(routeData.distance * 10) / 10;
|
|
2966
|
+
const durationSeconds = Math.round(routeData.duration);
|
|
2967
|
+
const durationMinutes = Math.round((durationSeconds / 60) * 10) / 10;
|
|
2968
|
+
const detourFactor = straightLineMeters > 0 ? Math.round((distanceMeters / straightLineMeters) * 100) / 100 : 1;
|
|
2969
|
+
// Extract actual route LineString geometry and compute route bounding box
|
|
2970
|
+
const geomCoords = (routeData.geometry?.coordinates && routeData.geometry.coordinates.length >= 2)
|
|
2971
|
+
? routeData.geometry.coordinates
|
|
2972
|
+
: [[start.lng, start.lat], [end.lng, end.lat]];
|
|
2973
|
+
const routeGeometry = {
|
|
2974
|
+
type: "LineString",
|
|
2975
|
+
coordinates: geomCoords
|
|
2976
|
+
};
|
|
2977
|
+
const lats = geomCoords.map(c => c[1]);
|
|
2978
|
+
const lngs = geomCoords.map(c => c[0]);
|
|
2979
|
+
const routeBBox = ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
|
|
2980
|
+
const midIdx = Math.floor(geomCoords.length / 2);
|
|
2981
|
+
const midPt = geomCoords[midIdx] || [(start.lng + end.lng) / 2, (start.lat + end.lat) / 2];
|
|
2982
|
+
const markers = [
|
|
2983
|
+
{ coordinates: { lat: start.lat, lon: start.lng }, name: `Origin: ${start.label}` },
|
|
2984
|
+
{ coordinates: { lat: midPt[1], lon: midPt[0] }, name: `Route (${(distanceMeters / 1000).toFixed(1)}km, ${durationMinutes}min)` },
|
|
2985
|
+
{ coordinates: { lat: end.lat, lon: end.lng }, name: `Dest: ${end.label}` }
|
|
2986
|
+
];
|
|
2987
|
+
const shareUrl = buildShareUrlWithRoute(routeBBox, geomCoords, markers);
|
|
2988
|
+
const steps = returnSteps && routeData.legs?.[0]?.steps
|
|
2989
|
+
? routeData.legs[0].steps.map((s) => ({
|
|
2990
|
+
name: s.name || "unnamed road",
|
|
2991
|
+
distance_meters: Math.round(s.distance),
|
|
2992
|
+
duration_seconds: Math.round(s.duration),
|
|
2993
|
+
...(s.maneuver?.type ? { maneuver: `${s.maneuver.type}${s.maneuver.modifier ? ' ' + s.maneuver.modifier : ''}` } : {})
|
|
2994
|
+
}))
|
|
2995
|
+
: undefined;
|
|
2996
|
+
const provenance = {
|
|
2997
|
+
provider: usedProvider,
|
|
2998
|
+
attribution: OSM_ATTRIBUTION,
|
|
2999
|
+
data_timestamp: dataTimestamp,
|
|
3000
|
+
cached: cachedRoute,
|
|
3001
|
+
cache_age_ms: cacheAgeMs,
|
|
3002
|
+
usage_policy: "https://routing.openstreetmap.de/about.html (Max 1 req/s, no heavy/bulk usage; configure OSRM_API_URL for production workloads)"
|
|
3003
|
+
};
|
|
3004
|
+
const resultPayload = {
|
|
3005
|
+
profile,
|
|
3006
|
+
origin: start,
|
|
3007
|
+
destination: end,
|
|
3008
|
+
distance_meters: distanceMeters,
|
|
3009
|
+
distance_km: Math.round((distanceMeters / 1000) * 100) / 100,
|
|
3010
|
+
duration_seconds: durationSeconds,
|
|
3011
|
+
duration_minutes: durationMinutes,
|
|
3012
|
+
straight_line_meters: Math.round(straightLineMeters * 10) / 10,
|
|
3013
|
+
detour_factor: detourFactor,
|
|
3014
|
+
initial_bearing: bearing,
|
|
3015
|
+
route_bbox: routeBBox,
|
|
3016
|
+
route_geometry: routeGeometry,
|
|
3017
|
+
...(steps ? { steps } : {}),
|
|
3018
|
+
share_url: shareUrl,
|
|
3019
|
+
provenance,
|
|
3020
|
+
complete: true,
|
|
3021
|
+
warnings
|
|
3022
|
+
};
|
|
3023
|
+
return {
|
|
3024
|
+
content: [
|
|
1190
3025
|
{
|
|
1191
3026
|
type: "text",
|
|
1192
|
-
text:
|
|
3027
|
+
text: `Route (${profile}): ${start.label} → ${end.label}\nDistance: ${(distanceMeters / 1000).toFixed(2)} km (${distanceMeters.toLocaleString()} m) | Straight-line: ${(straightLineMeters / 1000).toFixed(2)} km (detour factor: ${detourFactor}x)\nEstimated Duration: ${durationMinutes} min (${durationSeconds} s)\nInitial Bearing: ${bearing.degrees}° (${bearing.cardinal})\nGeometry: LineString (${geomCoords.length} vertices)\n\nView route on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
3028
|
+
}
|
|
3029
|
+
],
|
|
3030
|
+
structuredContent: resultPayload
|
|
3031
|
+
};
|
|
3032
|
+
}
|
|
3033
|
+
else if (toolName === "get_elevation") {
|
|
3034
|
+
const args = request.params.arguments || {};
|
|
3035
|
+
const warnings = [];
|
|
3036
|
+
let queryPoints = [];
|
|
3037
|
+
let bboxObj;
|
|
3038
|
+
let geocodeRes;
|
|
3039
|
+
let mode = "points";
|
|
3040
|
+
if (Array.isArray(args.points) && args.points.length > 0) {
|
|
3041
|
+
if (args.points.length > 100) {
|
|
3042
|
+
return { content: [{ type: "text", text: "Error: Maximum 100 points allowed per call." }], isError: true };
|
|
3043
|
+
}
|
|
3044
|
+
queryPoints = args.points.map((p, i) => {
|
|
3045
|
+
const lat = Number(p.lat);
|
|
3046
|
+
const lng = Number(p.lng);
|
|
3047
|
+
if (!validateBBox(lat, lng, lat, lng)) {
|
|
3048
|
+
throw new Error(`Invalid coordinates at index ${i}: (${lat}, ${lng})`);
|
|
3049
|
+
}
|
|
3050
|
+
return { lat, lng, label: p.label || `Point ${i + 1}` };
|
|
3051
|
+
});
|
|
3052
|
+
const lats = queryPoints.map(p => p.lat);
|
|
3053
|
+
const lngs = queryPoints.map(p => p.lng);
|
|
3054
|
+
bboxObj = ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
|
|
3055
|
+
}
|
|
3056
|
+
else {
|
|
3057
|
+
const locStr = typeof args.location === 'string' ? args.location.trim() : '';
|
|
3058
|
+
const coordMatch = locStr.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/);
|
|
3059
|
+
if (coordMatch) {
|
|
3060
|
+
const p0 = parseFloat(coordMatch[1]);
|
|
3061
|
+
const p1 = parseFloat(coordMatch[2]);
|
|
3062
|
+
if (validateBBox(p0, p1, p0, p1)) {
|
|
3063
|
+
bboxObj = { lat1: p0, lng1: p1, lat2: p0, lng2: p1 };
|
|
3064
|
+
}
|
|
3065
|
+
else if (validateBBox(p1, p0, p1, p0)) {
|
|
3066
|
+
warnings.push(`Coordinate '${locStr}' interpreted as lng,lat -> converted to lat=${p1}, lng=${p0}.`);
|
|
3067
|
+
bboxObj = { lat1: p1, lng1: p0, lat2: p1, lng2: p0 };
|
|
3068
|
+
}
|
|
3069
|
+
else {
|
|
3070
|
+
geocodeRes = await geocodeLocation(locStr);
|
|
3071
|
+
bboxObj = geocodeRes.bbox;
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
else if (args.location) {
|
|
3075
|
+
geocodeRes = await geocodeLocation(locStr);
|
|
3076
|
+
bboxObj = geocodeRes.bbox;
|
|
3077
|
+
}
|
|
3078
|
+
else if (args.bbox) {
|
|
3079
|
+
bboxObj = parseBBox(args.bbox, warnings);
|
|
3080
|
+
}
|
|
3081
|
+
else {
|
|
3082
|
+
return { content: [{ type: "text", text: "Error: Provide either 'points', 'location', or 'bbox'." }], isError: true };
|
|
3083
|
+
}
|
|
3084
|
+
const isSinglePoint = Math.abs(bboxObj.lat2 - bboxObj.lat1) < 1e-6 && Math.abs(bboxObj.lng2 - bboxObj.lng1) < 1e-6;
|
|
3085
|
+
if (isSinglePoint) {
|
|
3086
|
+
queryPoints = [{ lat: bboxObj.lat1, lng: bboxObj.lng1, label: geocodeRes?.matched_place || "Point" }];
|
|
3087
|
+
}
|
|
3088
|
+
else {
|
|
3089
|
+
mode = "grid";
|
|
3090
|
+
const gridSize = Math.max(2, Math.min(10, Math.round(args.grid_size ?? 5)));
|
|
3091
|
+
warnings.push(`Grid mode samples a finite ${gridSize}x${gridSize} point grid (${gridSize * gridSize} points) over a 90m resolution DEM (Copernicus DEM GLO-90). Sample statistics do not guarantee true area-wide minimum, maximum, or slope measurements.`);
|
|
3092
|
+
for (let r = 0; r < gridSize; r++) {
|
|
3093
|
+
const lat = bboxObj.lat1 + (bboxObj.lat2 - bboxObj.lat1) * (r / (gridSize - 1));
|
|
3094
|
+
for (let c = 0; c < gridSize; c++) {
|
|
3095
|
+
const lng = bboxObj.lng1 + (bboxObj.lng2 - bboxObj.lng1) * (c / (gridSize - 1));
|
|
3096
|
+
queryPoints.push({ lat, lng, label: `Grid (${r + 1},${c + 1})` });
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
}
|
|
3101
|
+
const latsParam = queryPoints.map(p => p.lat.toFixed(6)).join(",");
|
|
3102
|
+
const lngsParam = queryPoints.map(p => p.lng.toFixed(6)).join(",");
|
|
3103
|
+
const elevationBase = (process.env.ELEVATION_API_URL || "https://api.open-meteo.com/v1/elevation").replace(/\/+$/, "");
|
|
3104
|
+
const url = `${elevationBase}?latitude=${latsParam}&longitude=${lngsParam}`;
|
|
3105
|
+
const cacheKey = `${latsParam}|${lngsParam}`;
|
|
3106
|
+
let elevations = [];
|
|
3107
|
+
let cachedElev = false;
|
|
3108
|
+
let cacheAgeMs = 0;
|
|
3109
|
+
let dataTimestamp = new Date().toISOString();
|
|
3110
|
+
const cachedEntry = ELEVATION_CACHE.get(cacheKey);
|
|
3111
|
+
if (cachedEntry && (Date.now() - cachedEntry.timestamp < ELEVATION_CACHE_TTL_MS)) {
|
|
3112
|
+
elevations = cachedEntry.elevations;
|
|
3113
|
+
cachedElev = true;
|
|
3114
|
+
cacheAgeMs = Date.now() - cachedEntry.timestamp;
|
|
3115
|
+
dataTimestamp = new Date(cachedEntry.timestamp).toISOString();
|
|
3116
|
+
}
|
|
3117
|
+
else {
|
|
3118
|
+
const controller = new AbortController();
|
|
3119
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
3120
|
+
try {
|
|
3121
|
+
const res = await fetch(url, {
|
|
3122
|
+
headers: {
|
|
3123
|
+
'User-Agent': APP_USER_AGENT,
|
|
3124
|
+
'Accept': 'application/json'
|
|
3125
|
+
},
|
|
3126
|
+
signal: controller.signal
|
|
3127
|
+
});
|
|
3128
|
+
if (!res.ok) {
|
|
3129
|
+
throw new Error(`Open-Meteo Elevation API returned status ${res.status}`);
|
|
3130
|
+
}
|
|
3131
|
+
const json = await res.json();
|
|
3132
|
+
if (!Array.isArray(json.elevation)) {
|
|
3133
|
+
throw new Error(`Unexpected response from elevation API`);
|
|
1193
3134
|
}
|
|
3135
|
+
elevations = json.elevation;
|
|
3136
|
+
const now = Date.now();
|
|
3137
|
+
dataTimestamp = new Date(now).toISOString();
|
|
3138
|
+
ELEVATION_CACHE.set(cacheKey, { timestamp: now, elevations });
|
|
3139
|
+
}
|
|
3140
|
+
finally {
|
|
3141
|
+
clearTimeout(timeout);
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
const samples = queryPoints.map((pt, idx) => ({
|
|
3145
|
+
label: pt.label,
|
|
3146
|
+
lat: Math.round(pt.lat * 1e6) / 1e6,
|
|
3147
|
+
lng: Math.round(pt.lng * 1e6) / 1e6,
|
|
3148
|
+
elevation_meters: elevations[idx] ?? null
|
|
3149
|
+
})).filter((s) => typeof s.elevation_meters === 'number');
|
|
3150
|
+
if (samples.length === 0) {
|
|
3151
|
+
return { content: [{ type: "text", text: "Error: No elevation data returned for the requested coordinates." }], isError: true };
|
|
3152
|
+
}
|
|
3153
|
+
let minSample = samples[0];
|
|
3154
|
+
let maxSample = samples[0];
|
|
3155
|
+
let sum = 0;
|
|
3156
|
+
for (const s of samples) {
|
|
3157
|
+
if (s.elevation_meters < minSample.elevation_meters)
|
|
3158
|
+
minSample = s;
|
|
3159
|
+
if (s.elevation_meters > maxSample.elevation_meters)
|
|
3160
|
+
maxSample = s;
|
|
3161
|
+
sum += s.elevation_meters;
|
|
3162
|
+
}
|
|
3163
|
+
const meanElevation = Math.round((sum / samples.length) * 10) / 10;
|
|
3164
|
+
const reliefMeters = Math.round((maxSample.elevation_meters - minSample.elevation_meters) * 10) / 10;
|
|
3165
|
+
const markers = mode === "grid"
|
|
3166
|
+
? [
|
|
3167
|
+
{ coordinates: { lat: maxSample.lat, lon: maxSample.lng }, name: `Sample High: ${maxSample.elevation_meters}m` },
|
|
3168
|
+
{ coordinates: { lat: minSample.lat, lon: minSample.lng }, name: `Sample Low: ${minSample.elevation_meters}m` }
|
|
1194
3169
|
]
|
|
3170
|
+
: samples.map(s => ({ coordinates: { lat: s.lat, lon: s.lng }, name: `${s.label}: ${s.elevation_meters}m` }));
|
|
3171
|
+
const displayBBox = bboxObj.lat1 === bboxObj.lat2 && bboxObj.lng1 === bboxObj.lng2
|
|
3172
|
+
? expandBBoxMeters(bboxObj, 500)
|
|
3173
|
+
: bboxObj;
|
|
3174
|
+
// Only draw a bounding box rectangle on the map in grid mode; for point lookups, show pins only
|
|
3175
|
+
const shareUrl = mode === "grid"
|
|
3176
|
+
? buildShareUrlWithMarkers(displayBBox, markers)
|
|
3177
|
+
: buildShareUrlMarkersOnly(markers);
|
|
3178
|
+
const provenance = {
|
|
3179
|
+
provider: `open-meteo-copernicus-dem-90m (${elevationBase})`,
|
|
3180
|
+
attribution: "Copernicus DEM GLO-90 via Open-Meteo (https://open-meteo.com/)",
|
|
3181
|
+
data_timestamp: dataTimestamp,
|
|
3182
|
+
cached: cachedElev,
|
|
3183
|
+
cache_age_ms: cacheAgeMs,
|
|
3184
|
+
usage_policy: "https://open-meteo.com/en/terms (Hosted API is free for non-commercial use only; commercial use requires a paid API subscription or self-hosted ELEVATION_API_URL)"
|
|
3185
|
+
};
|
|
3186
|
+
const sampleStats = {
|
|
3187
|
+
min_elevation_meters: minSample.elevation_meters,
|
|
3188
|
+
max_elevation_meters: maxSample.elevation_meters,
|
|
3189
|
+
mean_elevation_meters: meanElevation,
|
|
3190
|
+
sample_relief_meters: reliefMeters,
|
|
3191
|
+
lowest_sample: minSample,
|
|
3192
|
+
highest_sample: maxSample,
|
|
3193
|
+
is_finite_sample: mode === "grid"
|
|
3194
|
+
};
|
|
3195
|
+
const responseData = {
|
|
3196
|
+
mode,
|
|
3197
|
+
...(geocodeRes ? { matched_place: geocodeRes.matched_place } : {}),
|
|
3198
|
+
dem_resolution_meters: 90,
|
|
3199
|
+
sample_count: samples.length,
|
|
3200
|
+
sample_stats: sampleStats,
|
|
3201
|
+
samples,
|
|
3202
|
+
bbox: displayBBox,
|
|
3203
|
+
share_url: shareUrl,
|
|
3204
|
+
provenance,
|
|
3205
|
+
complete: true,
|
|
3206
|
+
warnings
|
|
1195
3207
|
};
|
|
3208
|
+
const summaryText = mode === "grid"
|
|
3209
|
+
? `Terrain Elevation Sample Grid (${samples.length} samples at 90m DEM resolution${geocodeRes ? ` for "${geocodeRes.matched_place}"` : ''}):\n- Sample Min: ${minSample.elevation_meters} m at (${minSample.lat}, ${minSample.lng})\n- Sample Max: ${maxSample.elevation_meters} m at (${maxSample.lat}, ${maxSample.lng})\n- Sample Mean: ${meanElevation} m | Sample Relief: ${reliefMeters} m`
|
|
3210
|
+
: `Point Elevations (${samples.length}, 90m Copernicus DEM):\n` + samples.map(s => `- ${s.label} (${s.lat}, ${s.lng}): ${s.elevation_meters} m`).join('\n');
|
|
3211
|
+
return {
|
|
3212
|
+
content: [
|
|
3213
|
+
{
|
|
3214
|
+
type: "text",
|
|
3215
|
+
text: `${summaryText}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
3216
|
+
}
|
|
3217
|
+
],
|
|
3218
|
+
structuredContent: responseData
|
|
3219
|
+
};
|
|
3220
|
+
}
|
|
3221
|
+
else if (toolName === "spatial_calc") {
|
|
3222
|
+
const args = request.params.arguments || {};
|
|
3223
|
+
const operation = args.operation;
|
|
3224
|
+
const bboxAStr = args.bbox_a;
|
|
3225
|
+
const warnings = [];
|
|
3226
|
+
if (!operation || !bboxAStr) {
|
|
3227
|
+
return { content: [{ type: "text", text: "Error: 'operation' and 'bbox_a' are required." }], isError: true };
|
|
3228
|
+
}
|
|
3229
|
+
const provenance = {
|
|
3230
|
+
provider: "offline-geometry-engine",
|
|
3231
|
+
data_timestamp: new Date().toISOString(),
|
|
3232
|
+
cached: false,
|
|
3233
|
+
cache_age_ms: 0
|
|
3234
|
+
};
|
|
3235
|
+
if (operation === "relation") {
|
|
3236
|
+
const bboxBStr = args.bbox_b;
|
|
3237
|
+
if (!bboxBStr) {
|
|
3238
|
+
return { content: [{ type: "text", text: "Error: 'bbox_b' is required for operation='relation'." }], isError: true };
|
|
3239
|
+
}
|
|
3240
|
+
const a = parseBBox(bboxAStr, warnings);
|
|
3241
|
+
const b = parseBBox(bboxBStr, warnings);
|
|
3242
|
+
const intersects = !(b.lng1 > a.lng2 || b.lng2 < a.lng1 || b.lat1 > a.lat2 || b.lat2 < a.lat1);
|
|
3243
|
+
const aContainsB = a.lat1 <= b.lat1 && a.lat2 >= b.lat2 && a.lng1 <= b.lng1 && a.lng2 >= b.lng2;
|
|
3244
|
+
const bContainsA = b.lat1 <= a.lat1 && b.lat2 >= a.lat2 && b.lng1 <= a.lng1 && b.lng2 >= a.lng2;
|
|
3245
|
+
const unionBBox = {
|
|
3246
|
+
lat1: Math.min(a.lat1, b.lat1),
|
|
3247
|
+
lng1: Math.min(a.lng1, b.lng1),
|
|
3248
|
+
lat2: Math.max(a.lat2, b.lat2),
|
|
3249
|
+
lng2: Math.max(a.lng2, b.lng2)
|
|
3250
|
+
};
|
|
3251
|
+
let intersectionBBox = null;
|
|
3252
|
+
let intersectionAreaKm2 = 0;
|
|
3253
|
+
if (intersects) {
|
|
3254
|
+
intersectionBBox = {
|
|
3255
|
+
lat1: Math.max(a.lat1, b.lat1),
|
|
3256
|
+
lng1: Math.max(a.lng1, b.lng1),
|
|
3257
|
+
lat2: Math.min(a.lat2, b.lat2),
|
|
3258
|
+
lng2: Math.min(a.lng2, b.lng2)
|
|
3259
|
+
};
|
|
3260
|
+
intersectionAreaKm2 = calculateBboxArea(intersectionBBox);
|
|
3261
|
+
}
|
|
3262
|
+
const areaAKm2 = calculateBboxArea(a);
|
|
3263
|
+
const areaBKm2 = calculateBboxArea(b);
|
|
3264
|
+
const unionAreaKm2 = calculateBboxArea(unionBBox);
|
|
3265
|
+
const overlapPctOfA = areaAKm2 > 0 ? Math.round((intersectionAreaKm2 / areaAKm2) * 10000) / 100 : 0;
|
|
3266
|
+
const overlapPctOfB = areaBKm2 > 0 ? Math.round((intersectionAreaKm2 / areaBKm2) * 10000) / 100 : 0;
|
|
3267
|
+
const centroidDistanceMeters = calculateHaversineDistanceMeters((a.lat1 + a.lat2) / 2, (a.lng1 + a.lng2) / 2, (b.lat1 + b.lat2) / 2, (b.lng1 + b.lng2) / 2);
|
|
3268
|
+
const shareUrl = buildShareUrl(intersectionBBox || unionBBox);
|
|
3269
|
+
const payload = {
|
|
3270
|
+
operation: "relation",
|
|
3271
|
+
intersects,
|
|
3272
|
+
a_contains_b: aContainsB,
|
|
3273
|
+
b_contains_a: bContainsA,
|
|
3274
|
+
area_a_km2: Math.round(areaAKm2 * 1000) / 1000,
|
|
3275
|
+
area_b_km2: Math.round(areaBKm2 * 1000) / 1000,
|
|
3276
|
+
intersection_area_km2: Math.round(intersectionAreaKm2 * 1000) / 1000,
|
|
3277
|
+
union_area_km2: Math.round(unionAreaKm2 * 1000) / 1000,
|
|
3278
|
+
overlap_percent_of_a: overlapPctOfA,
|
|
3279
|
+
overlap_percent_of_b: overlapPctOfB,
|
|
3280
|
+
centroid_distance_meters: Math.round(centroidDistanceMeters * 10) / 10,
|
|
3281
|
+
intersection_bbox: intersectionBBox,
|
|
3282
|
+
union_bbox: unionBBox,
|
|
3283
|
+
share_url: shareUrl,
|
|
3284
|
+
provenance,
|
|
3285
|
+
complete: true,
|
|
3286
|
+
warnings
|
|
3287
|
+
};
|
|
3288
|
+
return {
|
|
3289
|
+
content: [
|
|
3290
|
+
{
|
|
3291
|
+
type: "text",
|
|
3292
|
+
text: `Spatial Relation:\n- Intersects: ${intersects} | A contains B: ${aContainsB} | B contains A: ${bContainsA}\n- Intersection Area: ${payload.intersection_area_km2} km² (${overlapPctOfA}% of A, ${overlapPctOfB}% of B)\n- Centroid Distance: ${payload.centroid_distance_meters} m\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
3293
|
+
}
|
|
3294
|
+
],
|
|
3295
|
+
structuredContent: payload
|
|
3296
|
+
};
|
|
3297
|
+
}
|
|
3298
|
+
else if (operation === "point_in_geometry") {
|
|
3299
|
+
const ptStr = args.point;
|
|
3300
|
+
if (!ptStr) {
|
|
3301
|
+
return { content: [{ type: "text", text: "Error: 'point' ('lat,lng') is required for operation='point_in_geometry'." }], isError: true };
|
|
3302
|
+
}
|
|
3303
|
+
const ptBbox = parseBBox(ptStr, warnings);
|
|
3304
|
+
const ptLat = (ptBbox.lat1 + ptBbox.lat2) / 2;
|
|
3305
|
+
const ptLng = (ptBbox.lng1 + ptBbox.lng2) / 2;
|
|
3306
|
+
const targetBBox = parseBBox(bboxAStr, warnings);
|
|
3307
|
+
const insideBBox = ptLat >= targetBBox.lat1 && ptLat <= targetBBox.lat2 && ptLng >= targetBBox.lng1 && ptLng <= targetBBox.lng2;
|
|
3308
|
+
const polygonsWithHoles = extractPolygonsWithHolesLatLng(bboxAStr);
|
|
3309
|
+
let insidePolygon;
|
|
3310
|
+
if (polygonsWithHoles && polygonsWithHoles.length > 0) {
|
|
3311
|
+
insidePolygon = polygonsWithHoles.some(polyRings => isPointInPolygonWithHoles(ptLat, ptLng, polyRings));
|
|
3312
|
+
}
|
|
3313
|
+
const isInside = insidePolygon !== undefined ? insidePolygon : insideBBox;
|
|
3314
|
+
const ptMarker = [{ coordinates: { lat: ptLat, lon: ptLng }, name: isInside ? "Inside" : "Outside" }];
|
|
3315
|
+
const shareUrl = polygonsWithHoles && polygonsWithHoles[0]?.[0]?.length >= 3
|
|
3316
|
+
? buildShareUrlPolygon(polygonsWithHoles[0][0], ptMarker)
|
|
3317
|
+
: buildShareUrlWithMarkers(targetBBox, ptMarker);
|
|
3318
|
+
const payload = {
|
|
3319
|
+
operation: "point_in_geometry",
|
|
3320
|
+
point: { lat: ptLat, lng: ptLng },
|
|
3321
|
+
inside: isInside,
|
|
3322
|
+
inside_bbox: insideBBox,
|
|
3323
|
+
...(insidePolygon !== undefined ? { inside_polygon: insidePolygon } : {}),
|
|
3324
|
+
target_bbox: targetBBox,
|
|
3325
|
+
share_url: shareUrl,
|
|
3326
|
+
provenance,
|
|
3327
|
+
complete: true,
|
|
3328
|
+
warnings
|
|
3329
|
+
};
|
|
3330
|
+
return {
|
|
3331
|
+
content: [
|
|
3332
|
+
{
|
|
3333
|
+
type: "text",
|
|
3334
|
+
text: `Point (${ptLat}, ${ptLng}) is ${isInside ? "INSIDE" : "OUTSIDE"} the target geometry (inside_bbox: ${insideBBox}${insidePolygon !== undefined ? `, inside_polygon: ${insidePolygon}` : ''}).\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
3335
|
+
}
|
|
3336
|
+
],
|
|
3337
|
+
structuredContent: payload
|
|
3338
|
+
};
|
|
3339
|
+
}
|
|
3340
|
+
else if (operation === "distance_bearing") {
|
|
3341
|
+
const bboxBStr = args.bbox_b || args.point;
|
|
3342
|
+
if (!bboxBStr) {
|
|
3343
|
+
return { content: [{ type: "text", text: "Error: 'bbox_b' (or 'point') is required as the destination for operation='distance_bearing'." }], isError: true };
|
|
3344
|
+
}
|
|
3345
|
+
const a = parseBBox(bboxAStr, warnings);
|
|
3346
|
+
const b = parseBBox(bboxBStr, warnings);
|
|
3347
|
+
const lat1 = (a.lat1 + a.lat2) / 2, lng1 = (a.lng1 + a.lng2) / 2;
|
|
3348
|
+
const lat2 = (b.lat1 + b.lat2) / 2, lng2 = (b.lng1 + b.lng2) / 2;
|
|
3349
|
+
const distMeters = calculateHaversineDistanceMeters(lat1, lng1, lat2, lng2);
|
|
3350
|
+
const bearing = calculateBearingDegrees(lat1, lng1, lat2, lng2);
|
|
3351
|
+
const midpoint = {
|
|
3352
|
+
lat: Math.round(((lat1 + lat2) / 2) * 1e6) / 1e6,
|
|
3353
|
+
lng: Math.round(((lng1 + lng2) / 2) * 1e6) / 1e6
|
|
3354
|
+
};
|
|
3355
|
+
const combinedBBox = ensureValidBBox(Math.min(lat1, lat2), Math.min(lng1, lng2), Math.max(lat1, lat2), Math.max(lng1, lng2));
|
|
3356
|
+
const lineTrace = `PB:${lat1.toFixed(5)},${lng1.toFixed(5)},${lat2.toFixed(5)},${lng2.toFixed(5)},${lat1.toFixed(5)},${lng1.toFixed(5)}`;
|
|
3357
|
+
const shareUrl = buildShareUrlMarkersOnly([
|
|
3358
|
+
{ coordinates: { lat: lat1, lon: lng1 }, name: "Point A" },
|
|
3359
|
+
{ coordinates: { lat: midpoint.lat, lon: midpoint.lng }, name: `Midpoint (${(distMeters / 1000).toFixed(2)}km)` },
|
|
3360
|
+
{ coordinates: { lat: lat2, lon: lng2 }, name: "Point B" }
|
|
3361
|
+
], [lineTrace]);
|
|
3362
|
+
const payload = {
|
|
3363
|
+
operation: "distance_bearing",
|
|
3364
|
+
from: { lat: lat1, lng: lng1 },
|
|
3365
|
+
to: { lat: lat2, lng: lng2 },
|
|
3366
|
+
distance_meters: Math.round(distMeters * 10) / 10,
|
|
3367
|
+
distance_km: Math.round((distMeters / 1000) * 1000) / 1000,
|
|
3368
|
+
bearing_degrees: bearing.degrees,
|
|
3369
|
+
cardinal_direction: bearing.cardinal,
|
|
3370
|
+
midpoint,
|
|
3371
|
+
bbox: combinedBBox,
|
|
3372
|
+
share_url: shareUrl,
|
|
3373
|
+
provenance,
|
|
3374
|
+
complete: true,
|
|
3375
|
+
warnings
|
|
3376
|
+
};
|
|
3377
|
+
return {
|
|
3378
|
+
content: [
|
|
3379
|
+
{
|
|
3380
|
+
type: "text",
|
|
3381
|
+
text: `Distance & Bearing: (${lat1}, ${lng1}) → (${lat2}, ${lng2})\n- Distance: ${payload.distance_km} km (${payload.distance_meters} m)\n- Initial Bearing: ${bearing.degrees}° (${bearing.cardinal})\n- Midpoint: (${midpoint.lat}, ${midpoint.lng})\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
3382
|
+
}
|
|
3383
|
+
],
|
|
3384
|
+
structuredContent: payload
|
|
3385
|
+
};
|
|
3386
|
+
}
|
|
3387
|
+
else if (operation === "split_grid") {
|
|
3388
|
+
const rows = Math.max(1, Math.min(20, Math.round(args.rows ?? 2)));
|
|
3389
|
+
const cols = Math.max(1, Math.min(20, Math.round(args.cols ?? 2)));
|
|
3390
|
+
const bbox = parseBBox(bboxAStr, warnings);
|
|
3391
|
+
const dLat = (bbox.lat2 - bbox.lat1) / rows;
|
|
3392
|
+
const dLng = (bbox.lng2 - bbox.lng1) / cols;
|
|
3393
|
+
const cells = [];
|
|
3394
|
+
for (let r = 0; r < rows; r++) {
|
|
3395
|
+
for (let c = 0; c < cols; c++) {
|
|
3396
|
+
const cellBBox = {
|
|
3397
|
+
lat1: Math.round((bbox.lat1 + r * dLat) * 1e6) / 1e6,
|
|
3398
|
+
lng1: Math.round((bbox.lng1 + c * dLng) * 1e6) / 1e6,
|
|
3399
|
+
lat2: Math.round((bbox.lat1 + (r + 1) * dLat) * 1e6) / 1e6,
|
|
3400
|
+
lng2: Math.round((bbox.lng1 + (c + 1) * dLng) * 1e6) / 1e6
|
|
3401
|
+
};
|
|
3402
|
+
cells.push({
|
|
3403
|
+
row: r + 1,
|
|
3404
|
+
col: c + 1,
|
|
3405
|
+
bbox: cellBBox,
|
|
3406
|
+
csv: `${cellBBox.lat1},${cellBBox.lng1},${cellBBox.lat2},${cellBBox.lng2}`
|
|
3407
|
+
});
|
|
3408
|
+
}
|
|
3409
|
+
}
|
|
3410
|
+
const shareUrl = buildShareUrl(bbox);
|
|
3411
|
+
const payload = {
|
|
3412
|
+
operation: "split_grid",
|
|
3413
|
+
rows,
|
|
3414
|
+
cols,
|
|
3415
|
+
total_cells: cells.length,
|
|
3416
|
+
original_bbox: bbox,
|
|
3417
|
+
cells,
|
|
3418
|
+
share_url: shareUrl,
|
|
3419
|
+
provenance,
|
|
3420
|
+
complete: true,
|
|
3421
|
+
warnings
|
|
3422
|
+
};
|
|
3423
|
+
return {
|
|
3424
|
+
content: [
|
|
3425
|
+
{
|
|
3426
|
+
type: "text",
|
|
3427
|
+
text: `Split bounding box into ${rows}x${cols} grid (${cells.length} sub-boxes).\n\nView parent bbox on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
3428
|
+
}
|
|
3429
|
+
],
|
|
3430
|
+
structuredContent: payload
|
|
3431
|
+
};
|
|
3432
|
+
}
|
|
3433
|
+
else {
|
|
3434
|
+
return {
|
|
3435
|
+
content: [{ type: "text", text: `Error: Unknown spatial_calc operation '${operation}'. Supported: relation, point_in_geometry, distance_bearing, split_grid.` }],
|
|
3436
|
+
isError: true
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
1196
3439
|
}
|
|
1197
3440
|
else {
|
|
1198
3441
|
log('warn', `Unknown tool requested: ${toolName}`);
|