bbox-mcp-server 1.4.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 +112 -322
- package/dist/index.js +2078 -283
- package/dist/package.json +2 -2
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -115,6 +115,14 @@ function safeMax(arr) {
|
|
|
115
115
|
// ---------------------------------------------------------------------------
|
|
116
116
|
// Pure Functions Ported from bbox.js
|
|
117
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
|
+
}
|
|
118
126
|
function validateBBox(lat1, lng1, lat2, lng2) {
|
|
119
127
|
if (isNaN(lat1) || isNaN(lng1) || isNaN(lat2) || isNaN(lng2))
|
|
120
128
|
return false;
|
|
@@ -124,6 +132,14 @@ function validateBBox(lat1, lng1, lat2, lng2) {
|
|
|
124
132
|
return false;
|
|
125
133
|
return true;
|
|
126
134
|
}
|
|
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
|
+
}
|
|
127
143
|
function parseBBox(input, warnings) {
|
|
128
144
|
if (!input || typeof input !== 'string') {
|
|
129
145
|
throw new Error(`Invalid bbox input.`);
|
|
@@ -132,75 +148,138 @@ function parseBBox(input, warnings) {
|
|
|
132
148
|
if (input.length > MAX_INPUT_LENGTH) {
|
|
133
149
|
throw new Error(`Input too large (${(input.length / 1024).toFixed(0)} KB). Maximum allowed is ${(MAX_INPUT_LENGTH / 1024).toFixed(0)} KB.`);
|
|
134
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
|
+
}
|
|
135
163
|
// --- 1. ogrinfo Extent Sniffer ---
|
|
136
164
|
// Matches: "Extent: (-74.006000, 40.712800) - (-73.985500, 40.758000)"
|
|
137
165
|
const ogrMatch = input.match(/Extent\:\s\((.*)\)/);
|
|
138
166
|
if (ogrMatch && ogrMatch[1]) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
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}`);
|
|
146
173
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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]);
|
|
150
179
|
}
|
|
151
180
|
}
|
|
152
181
|
// --- 2. GeoJSON Sniffer ---
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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);
|
|
158
202
|
}
|
|
159
|
-
// Multi-Geometry Coordinate Extraction (using shared, stack-safe utility)
|
|
160
203
|
const allCoords = extractCoords(json);
|
|
161
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
|
+
}
|
|
162
208
|
const lngs = allCoords.map(c => c[0]);
|
|
163
209
|
const lats = allCoords.map(c => c[1]);
|
|
164
210
|
return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
|
|
165
211
|
}
|
|
166
|
-
|
|
167
|
-
catch (e) {
|
|
168
|
-
log('info', 'parseBBox: GeoJSON parse failed, trying next parser', { error: e?.message });
|
|
212
|
+
throw new Error(`GeoJSON input contains no valid coordinates or bbox.`);
|
|
169
213
|
}
|
|
170
214
|
// --- 3. WKT Sniffer ---
|
|
171
|
-
if (
|
|
215
|
+
if (/^\s*(MULTI)?(POLYGON|POINT|LINESTRING)|^\s*GEOMETRYCOLLECTION/i.test(input)) {
|
|
216
|
+
let geojson;
|
|
172
217
|
try {
|
|
173
|
-
|
|
174
|
-
const allCoords = extractCoords(geojson.coordinates || geojson);
|
|
175
|
-
if (allCoords.length > 0) {
|
|
176
|
-
const lngs = allCoords.map(c => c[0]);
|
|
177
|
-
const lats = allCoords.map(c => c[1]);
|
|
178
|
-
return ensureValidBBox(safeMin(lats), safeMin(lngs), safeMax(lats), safeMax(lngs));
|
|
179
|
-
}
|
|
218
|
+
geojson = parseWKT(input);
|
|
180
219
|
}
|
|
181
220
|
catch (e) {
|
|
182
|
-
|
|
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);
|
|
183
254
|
}
|
|
255
|
+
throw new Error(`Invalid point coordinates: (${p0}, ${p1}). Latitude must be [-90, 90] and longitude [-180, 180].`);
|
|
184
256
|
}
|
|
185
|
-
// --- 4. Standard Coordinate Array Sniffer ---
|
|
186
|
-
// Strip brackets/parentheses and split by commas or spaces
|
|
187
|
-
const cleanInput = input.replace(/[\[\]\(\)]/g, '').trim();
|
|
188
|
-
// Support space-separated (GDAL) or comma-separated
|
|
189
|
-
const parts = cleanInput.split(/[\s,]+/).map(s => parseFloat(s.trim())).filter(n => !isNaN(n));
|
|
190
257
|
if (parts.length === 4) {
|
|
191
|
-
// Default interpretation: lat1, lng1, lat2, lng2
|
|
192
|
-
// Detect ambiguity: if both (lat,lng) and (lng,lat) orderings are valid,
|
|
193
|
-
// the caller may have a transposed box without knowing it.
|
|
194
258
|
const asLatLng = validateBBox(parts[0], parts[1], parts[2], parts[3]);
|
|
195
259
|
const asLngLat = validateBBox(parts[1], parts[0], parts[3], parts[2]);
|
|
196
|
-
if (asLatLng && asLngLat
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
`
|
|
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]);
|
|
200
279
|
}
|
|
201
|
-
|
|
280
|
+
throw new Error(`Invalid coordinates or out of bounds: lat[-90, 90], lng[-180, 180]. Provided: ${parts.join(', ')}`);
|
|
202
281
|
}
|
|
203
|
-
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)}`);
|
|
204
283
|
}
|
|
205
284
|
function ensureValidBBox(lat1, lng1, lat2, lng2) {
|
|
206
285
|
if (!validateBBox(lat1, lng1, lat2, lng2)) {
|
|
@@ -355,14 +434,78 @@ function latLngToTile(lat, lng, zoom) {
|
|
|
355
434
|
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n);
|
|
356
435
|
return { z: zoom, x, y };
|
|
357
436
|
}
|
|
358
|
-
function
|
|
359
|
-
const
|
|
360
|
-
|
|
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);
|
|
361
446
|
}
|
|
362
|
-
function
|
|
363
|
-
|
|
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) {
|
|
364
507
|
const MAX_MARKERS = 30;
|
|
365
|
-
|
|
508
|
+
return elements
|
|
366
509
|
.filter(e => e.coordinates?.lat !== undefined && e.coordinates?.lon !== undefined)
|
|
367
510
|
.slice(0, MAX_MARKERS)
|
|
368
511
|
.map(e => {
|
|
@@ -371,10 +514,75 @@ function buildShareUrlWithMarkers(bbox, elements) {
|
|
|
371
514
|
name = name.slice(0, 27) + '...';
|
|
372
515
|
return `${e.coordinates.lat.toFixed(6)},${e.coordinates.lon.toFixed(6)},${encodeURIComponent(name)}`;
|
|
373
516
|
});
|
|
374
|
-
|
|
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];
|
|
375
535
|
return `https://vibhorsingh.com/boundingbox/#${hashParts.join('|')}`;
|
|
376
536
|
}
|
|
377
|
-
|
|
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) {
|
|
378
586
|
if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
|
|
379
587
|
throw new Error(`Invalid H3 resolution: ${resolution}. Must be an integer between 0 and 15.`);
|
|
380
588
|
}
|
|
@@ -388,15 +596,30 @@ function getH3Cells(bbox, resolution, compact) {
|
|
|
388
596
|
if (estimatedCount > MAX_H3_CELLS * 2) {
|
|
389
597
|
throw new Error(`Area too large for resolution ${resolution}. Estimated ${Math.floor(estimatedCount).toLocaleString()} cells. Maximum allowed is ${MAX_H3_CELLS.toLocaleString()}.`);
|
|
390
598
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
+
}
|
|
400
623
|
if (cells.length > MAX_H3_CELLS) {
|
|
401
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.`);
|
|
402
625
|
}
|
|
@@ -431,30 +654,62 @@ function h3CellsToGeoJSON(cells) {
|
|
|
431
654
|
features
|
|
432
655
|
};
|
|
433
656
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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);
|
|
438
682
|
const NOMINATIM_CACHE = new Map();
|
|
439
683
|
const NOMINATIM_TTL_MS = 60 * 60 * 1000; // 1 hour TTL
|
|
440
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(/\/+$/, "");
|
|
441
686
|
async function fetchNominatim(url) {
|
|
442
687
|
const cached = NOMINATIM_CACHE.get(url);
|
|
443
688
|
if (cached && (Date.now() - cached.timestamp < NOMINATIM_TTL_MS)) {
|
|
444
|
-
return
|
|
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
|
+
};
|
|
445
695
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
+
};
|
|
450
706
|
}
|
|
451
|
-
lastNominatimRequestTime = Date.now();
|
|
452
707
|
const controller = new AbortController();
|
|
453
708
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
454
709
|
try {
|
|
455
710
|
const response = await fetch(url, {
|
|
456
711
|
headers: {
|
|
457
|
-
'User-Agent':
|
|
712
|
+
'User-Agent': APP_USER_AGENT,
|
|
458
713
|
'Accept': 'application/json'
|
|
459
714
|
},
|
|
460
715
|
signal: controller.signal
|
|
@@ -463,8 +718,14 @@ async function fetchNominatim(url) {
|
|
|
463
718
|
throw new Error(`Nominatim request failed with status: ${response.status} ${response.statusText}`);
|
|
464
719
|
}
|
|
465
720
|
const data = await response.json();
|
|
466
|
-
|
|
467
|
-
|
|
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
|
+
};
|
|
468
729
|
}
|
|
469
730
|
catch (err) {
|
|
470
731
|
if (err.name === 'AbortError') {
|
|
@@ -476,11 +737,14 @@ async function fetchNominatim(url) {
|
|
|
476
737
|
clearTimeout(timeout);
|
|
477
738
|
}
|
|
478
739
|
}
|
|
479
|
-
|
|
740
|
+
const PHOTON_CACHE = new Map();
|
|
741
|
+
async function geocodeLocation(query, includePolygon = false, warnings) {
|
|
480
742
|
// 1. Try Nominatim first (free, returns exact OSM area boundaries & alternatives)
|
|
481
743
|
try {
|
|
482
|
-
const
|
|
483
|
-
const
|
|
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);
|
|
484
748
|
if (Array.isArray(items) && items.length > 0) {
|
|
485
749
|
const parsedItems = items.map((item) => {
|
|
486
750
|
let bbox;
|
|
@@ -505,6 +769,7 @@ async function geocodeLocation(query) {
|
|
|
505
769
|
else if (item.osm_type === "way" && !isNaN(osmIdNum)) {
|
|
506
770
|
osm_area_id = 2400000000 + osmIdNum;
|
|
507
771
|
}
|
|
772
|
+
const polygons_with_holes = item.geojson ? (extractPolygonsWithHolesLatLng(item.geojson) || undefined) : undefined;
|
|
508
773
|
return {
|
|
509
774
|
matched_place: item.display_name,
|
|
510
775
|
bbox,
|
|
@@ -513,10 +778,10 @@ async function geocodeLocation(query) {
|
|
|
513
778
|
class: item.class,
|
|
514
779
|
type: item.type,
|
|
515
780
|
osm_type: item.osm_type,
|
|
516
|
-
osm_id: osmIdNum
|
|
781
|
+
osm_id: osmIdNum,
|
|
782
|
+
...(polygons_with_holes ? { polygons_with_holes } : {})
|
|
517
783
|
};
|
|
518
784
|
});
|
|
519
|
-
// Boundary relation preference: prefer relation with class=boundary or type=administrative/boundary
|
|
520
785
|
let primaryIndex = parsedItems.findIndex(i => i.osm_type === "relation" &&
|
|
521
786
|
(i.class === "boundary" || i.type === "administrative" || i.type === "boundary"));
|
|
522
787
|
if (primaryIndex === -1)
|
|
@@ -525,14 +790,111 @@ async function geocodeLocation(query) {
|
|
|
525
790
|
const alternatives = parsedItems.filter((_, idx) => idx !== primaryIndex);
|
|
526
791
|
return {
|
|
527
792
|
...primary,
|
|
528
|
-
alternatives
|
|
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();
|
|
821
|
+
}
|
|
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
|
+
}
|
|
844
|
+
}
|
|
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
|
+
});
|
|
880
|
+
return {
|
|
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
|
+
}
|
|
529
891
|
};
|
|
530
892
|
}
|
|
531
893
|
}
|
|
532
894
|
catch (err) {
|
|
533
|
-
log('warn', `
|
|
895
|
+
log('warn', `Photon fallback geocoding failed for '${query}'`, { error: err.message });
|
|
534
896
|
}
|
|
535
|
-
//
|
|
897
|
+
// 3. Optional fallback to Mapbox if configured
|
|
536
898
|
if (MAPBOX_ACCESS_TOKEN) {
|
|
537
899
|
const controller = new AbortController();
|
|
538
900
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
@@ -571,7 +933,14 @@ async function geocodeLocation(query) {
|
|
|
571
933
|
const alternatives = mapboxItems.slice(1);
|
|
572
934
|
return {
|
|
573
935
|
...primary,
|
|
574
|
-
alternatives
|
|
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
|
+
}
|
|
575
944
|
};
|
|
576
945
|
}
|
|
577
946
|
catch (err) {
|
|
@@ -591,8 +960,9 @@ async function reverseGeocode(lat, lng, zoom = 18) {
|
|
|
591
960
|
throw new Error(`Invalid lat/lng coordinates for reverse geocoding: (${lat}, ${lng})`);
|
|
592
961
|
}
|
|
593
962
|
const clampedZoom = Math.max(0, Math.min(18, Math.round(zoom)));
|
|
594
|
-
const
|
|
595
|
-
const
|
|
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);
|
|
596
966
|
if (!data || !data.display_name) {
|
|
597
967
|
throw new Error(`No reverse geocoding results found for coordinates: (${lat}, ${lng})`);
|
|
598
968
|
}
|
|
@@ -607,17 +977,27 @@ async function reverseGeocode(lat, lng, zoom = 18) {
|
|
|
607
977
|
else {
|
|
608
978
|
bbox = ensureValidBBox(lat - 0.005, lng - 0.005, lat + 0.005, lng + 0.005);
|
|
609
979
|
}
|
|
610
|
-
const shareUrl =
|
|
980
|
+
const shareUrl = buildShareUrlMarkersOnly([{ coordinates: { lat, lon: lng }, name: data.display_name }]);
|
|
611
981
|
return {
|
|
612
982
|
matched_place: data.display_name,
|
|
613
983
|
center: { lat, lng },
|
|
614
984
|
bbox,
|
|
615
985
|
address: data.address || {},
|
|
616
|
-
class: data.class,
|
|
617
|
-
type: data.type,
|
|
618
|
-
osm_type: data.osm_type,
|
|
619
|
-
|
|
620
|
-
share_url: shareUrl
|
|
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: []
|
|
621
1001
|
};
|
|
622
1002
|
}
|
|
623
1003
|
function splitOverpassStatements(query) {
|
|
@@ -687,42 +1067,329 @@ function calculateHaversineDistanceMeters(lat1, lng1, lat2, lng2) {
|
|
|
687
1067
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
688
1068
|
return R * c;
|
|
689
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}`);
|
|
1259
|
+
}
|
|
1260
|
+
finally {
|
|
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);
|
|
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
|
+
};
|
|
1290
|
+
}
|
|
690
1291
|
async function getTaginfoSuggestions(keyword) {
|
|
691
|
-
const
|
|
692
|
-
const
|
|
693
|
-
|
|
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
|
+
};
|
|
1302
|
+
}
|
|
694
1303
|
const MAINSTREAM_KEYS = new Set([
|
|
695
1304
|
"amenity", "shop", "leisure", "tourism", "natural", "highway",
|
|
696
1305
|
"office", "healthcare", "craft", "sport", "landuse", "building",
|
|
697
|
-
"historic", "emergency", "railway", "aeroway", "public_transport"
|
|
1306
|
+
"historic", "emergency", "railway", "aeroway", "public_transport",
|
|
1307
|
+
"man_made", "Waterway", "waterway", "barrier", "power", "place"
|
|
698
1308
|
]);
|
|
1309
|
+
const controller = new AbortController();
|
|
1310
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
699
1311
|
try {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
+
}
|
|
706
1321
|
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
.
|
|
711
|
-
|
|
712
|
-
|
|
1322
|
+
else {
|
|
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
|
+
})
|
|
713
1353
|
.sort((a, b) => (b.count_all ?? 0) - (a.count_all ?? 0))
|
|
714
1354
|
.slice(0, 10);
|
|
1355
|
+
const now = Date.now();
|
|
715
1356
|
if (filtered.length === 0) {
|
|
716
|
-
|
|
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
|
+
};
|
|
717
1365
|
}
|
|
1366
|
+
const tags = [];
|
|
718
1367
|
const lines = filtered.map((item) => {
|
|
719
1368
|
const valStr = item.value ? `="${item.value}"` : '';
|
|
720
|
-
|
|
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}`;
|
|
721
1373
|
});
|
|
722
|
-
|
|
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
|
+
};
|
|
723
1383
|
}
|
|
724
1384
|
catch (err) {
|
|
725
|
-
return
|
|
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
|
+
};
|
|
726
1393
|
}
|
|
727
1394
|
finally {
|
|
728
1395
|
clearTimeout(timeout);
|
|
@@ -745,22 +1412,55 @@ function buildShareUrlCircleWithMarkers(center, radiusMeters, elements) {
|
|
|
745
1412
|
// ---------------------------------------------------------------------------
|
|
746
1413
|
// Tool Handlers
|
|
747
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
|
+
};
|
|
748
1437
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
749
1438
|
return {
|
|
750
1439
|
tools: [
|
|
751
1440
|
{
|
|
752
1441
|
name: "get_bounds",
|
|
753
|
-
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
|
+
},
|
|
754
1450
|
inputSchema: {
|
|
755
1451
|
type: "object",
|
|
756
1452
|
properties: {
|
|
757
1453
|
location: {
|
|
758
1454
|
type: "string",
|
|
759
|
-
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."
|
|
760
1456
|
},
|
|
761
1457
|
bbox: {
|
|
762
1458
|
type: "string",
|
|
763
|
-
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."
|
|
764
1464
|
},
|
|
765
1465
|
epsg: {
|
|
766
1466
|
type: "string",
|
|
@@ -790,10 +1490,60 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
790
1490
|
{ required: ["bbox"] }
|
|
791
1491
|
]
|
|
792
1492
|
},
|
|
1493
|
+
outputSchema: {
|
|
1494
|
+
type: "object",
|
|
1495
|
+
properties: {
|
|
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
|
+
}
|
|
793
1536
|
},
|
|
794
1537
|
{
|
|
795
1538
|
name: "get_h3_indices",
|
|
796
|
-
description: "Get Uber H3 cell indices for a bounding box
|
|
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
|
+
},
|
|
797
1547
|
inputSchema: {
|
|
798
1548
|
type: "object",
|
|
799
1549
|
properties: {
|
|
@@ -803,7 +1553,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
803
1553
|
},
|
|
804
1554
|
bbox: {
|
|
805
1555
|
type: "string",
|
|
806
|
-
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.",
|
|
807
1557
|
},
|
|
808
1558
|
resolution: {
|
|
809
1559
|
type: "number",
|
|
@@ -820,6 +1570,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
820
1570
|
return_geometry: {
|
|
821
1571
|
type: "boolean",
|
|
822
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."
|
|
823
1577
|
}
|
|
824
1578
|
},
|
|
825
1579
|
required: ["resolution"],
|
|
@@ -828,24 +1582,68 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
828
1582
|
{ required: ["bbox"] }
|
|
829
1583
|
]
|
|
830
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
|
+
}
|
|
831
1604
|
},
|
|
832
1605
|
{
|
|
833
1606
|
name: "generate_share_url",
|
|
834
|
-
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
|
+
},
|
|
835
1615
|
inputSchema: {
|
|
836
1616
|
type: "object",
|
|
837
1617
|
properties: {
|
|
838
1618
|
bbox: {
|
|
839
1619
|
type: "string",
|
|
840
|
-
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.",
|
|
841
1621
|
}
|
|
842
1622
|
},
|
|
843
1623
|
required: ["bbox"],
|
|
844
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
|
+
}
|
|
845
1636
|
},
|
|
846
1637
|
{
|
|
847
1638
|
name: "reverse_geocode",
|
|
848
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
|
+
},
|
|
849
1647
|
inputSchema: {
|
|
850
1648
|
type: "object",
|
|
851
1649
|
properties: {
|
|
@@ -863,11 +1661,40 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
863
1661
|
}
|
|
864
1662
|
},
|
|
865
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"]
|
|
866
1686
|
}
|
|
867
1687
|
},
|
|
868
1688
|
{
|
|
869
1689
|
name: "search_overpass",
|
|
870
|
-
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 and returns structured JSON with 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\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\"]`",
|
|
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
|
+
},
|
|
871
1698
|
inputSchema: {
|
|
872
1699
|
type: "object",
|
|
873
1700
|
properties: {
|
|
@@ -910,6 +1737,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
910
1737
|
return_geometry: {
|
|
911
1738
|
type: "boolean",
|
|
912
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."
|
|
913
1744
|
}
|
|
914
1745
|
},
|
|
915
1746
|
required: ["query"],
|
|
@@ -918,10 +1749,53 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
918
1749
|
{ required: ["bbox"] }
|
|
919
1750
|
]
|
|
920
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
|
+
}
|
|
921
1788
|
},
|
|
922
1789
|
{
|
|
923
1790
|
name: "list_osm_tags",
|
|
924
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
|
+
},
|
|
925
1799
|
inputSchema: {
|
|
926
1800
|
type: "object",
|
|
927
1801
|
properties: {
|
|
@@ -931,11 +1805,31 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
931
1805
|
}
|
|
932
1806
|
},
|
|
933
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"]
|
|
934
1821
|
}
|
|
935
1822
|
},
|
|
936
1823
|
{
|
|
937
1824
|
name: "aggregate_overpass_h3",
|
|
938
|
-
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
|
+
},
|
|
939
1833
|
inputSchema: {
|
|
940
1834
|
type: "object",
|
|
941
1835
|
properties: {
|
|
@@ -955,6 +1849,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
955
1849
|
type: "number",
|
|
956
1850
|
description: "The H3 resolution level (0-15) for binning. Default is 8."
|
|
957
1851
|
},
|
|
1852
|
+
radius_meters: {
|
|
1853
|
+
type: "number",
|
|
1854
|
+
description: "Optional search radius in metres around the location/bbox center."
|
|
1855
|
+
},
|
|
958
1856
|
timeout_seconds: {
|
|
959
1857
|
type: "number",
|
|
960
1858
|
description: "Overpass server query timeout in seconds. Default is 25."
|
|
@@ -969,9 +1867,257 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
969
1867
|
{ required: ["location"] },
|
|
970
1868
|
{ required: ["bbox"] }
|
|
971
1869
|
]
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
|
|
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"]
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
],
|
|
975
2121
|
};
|
|
976
2122
|
});
|
|
977
2123
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -1003,6 +2149,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1003
2149
|
else {
|
|
1004
2150
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
1005
2151
|
}
|
|
2152
|
+
const bufferMeters = args.buffer_meters;
|
|
2153
|
+
if (typeof bufferMeters === 'number' && bufferMeters !== 0) {
|
|
2154
|
+
bboxObj = expandBBoxMeters(bboxObj, bufferMeters);
|
|
2155
|
+
}
|
|
1006
2156
|
let epsg = args.epsg || "4326";
|
|
1007
2157
|
let format = args.format || "csv";
|
|
1008
2158
|
let coordOrder = args.coord_order || "lng,lat";
|
|
@@ -1030,36 +2180,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1030
2180
|
const altInfo = geocodeRes && geocodeRes.alternatives.length > 0
|
|
1031
2181
|
? `Alternates: ${geocodeRes.alternatives.map(a => `"${a.matched_place}"`).join(', ')}\n\n`
|
|
1032
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
|
+
};
|
|
1033
2212
|
return {
|
|
1034
2213
|
content: [
|
|
1035
2214
|
{
|
|
1036
2215
|
type: "text",
|
|
1037
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)}`
|
|
1038
|
-
},
|
|
1039
|
-
{
|
|
1040
|
-
type: "text",
|
|
1041
|
-
text: JSON.stringify({
|
|
1042
|
-
...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
|
|
1043
|
-
original_wgs84: bboxObj,
|
|
1044
|
-
projected: projCoords,
|
|
1045
|
-
center: {
|
|
1046
|
-
lat: centerLat,
|
|
1047
|
-
lng: centerLng
|
|
1048
|
-
},
|
|
1049
|
-
tile_indices: tileIndices,
|
|
1050
|
-
epsg,
|
|
1051
|
-
format,
|
|
1052
|
-
coord_order: coordOrder,
|
|
1053
|
-
precision,
|
|
1054
|
-
area_km2: areaKm2,
|
|
1055
|
-
dimensions: {
|
|
1056
|
-
width_km: dims.widthKm,
|
|
1057
|
-
height_km: dims.heightKm
|
|
1058
|
-
},
|
|
1059
|
-
share_url: shareUrl
|
|
1060
|
-
}, null, 2)
|
|
1061
2217
|
}
|
|
1062
|
-
]
|
|
2218
|
+
],
|
|
2219
|
+
structuredContent: structuredData
|
|
1063
2220
|
};
|
|
1064
2221
|
}
|
|
1065
2222
|
else if (toolName === "get_h3_indices") {
|
|
@@ -1068,16 +2225,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1068
2225
|
const compact = args.compact || false;
|
|
1069
2226
|
const summaryOnly = args.summary_only || false;
|
|
1070
2227
|
const returnGeometry = args.return_geometry || false;
|
|
2228
|
+
const usePolygonBoundary = args.use_polygon_boundary !== false;
|
|
1071
2229
|
const warnings = [];
|
|
1072
2230
|
if (resolution === undefined) {
|
|
1073
2231
|
return { content: [{ type: "text", text: "Error: 'resolution' is required." }], isError: true };
|
|
1074
2232
|
}
|
|
1075
2233
|
let bboxObj;
|
|
1076
2234
|
let geocodeRes;
|
|
2235
|
+
let polygonsWithHoles = null;
|
|
1077
2236
|
if (args.location) {
|
|
1078
2237
|
try {
|
|
1079
|
-
geocodeRes = await geocodeLocation(args.location);
|
|
2238
|
+
geocodeRes = await geocodeLocation(args.location, usePolygonBoundary);
|
|
1080
2239
|
bboxObj = geocodeRes.bbox;
|
|
2240
|
+
if (usePolygonBoundary && geocodeRes.polygons_with_holes) {
|
|
2241
|
+
polygonsWithHoles = geocodeRes.polygons_with_holes;
|
|
2242
|
+
}
|
|
1081
2243
|
}
|
|
1082
2244
|
catch (err) {
|
|
1083
2245
|
if (!args.bbox) {
|
|
@@ -1085,49 +2247,69 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1085
2247
|
return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
|
|
1086
2248
|
}
|
|
1087
2249
|
bboxObj = parseBBox(args.bbox, warnings);
|
|
2250
|
+
if (usePolygonBoundary) {
|
|
2251
|
+
polygonsWithHoles = extractPolygonsWithHolesLatLng(args.bbox);
|
|
2252
|
+
}
|
|
1088
2253
|
}
|
|
1089
2254
|
}
|
|
1090
2255
|
else if (args.bbox) {
|
|
1091
2256
|
bboxObj = parseBBox(args.bbox, warnings);
|
|
2257
|
+
if (usePolygonBoundary) {
|
|
2258
|
+
polygonsWithHoles = extractPolygonsWithHolesLatLng(args.bbox);
|
|
2259
|
+
}
|
|
1092
2260
|
}
|
|
1093
2261
|
else {
|
|
1094
2262
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
1095
2263
|
}
|
|
1096
|
-
const cells = getH3Cells(bboxObj, resolution, compact);
|
|
1097
|
-
const
|
|
1098
|
-
|
|
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 });
|
|
1099
2276
|
if (summaryOnly) {
|
|
1100
2277
|
const sampleCells = cells.slice(0, 10);
|
|
1101
2278
|
const responseData = {
|
|
1102
2279
|
resolution,
|
|
1103
2280
|
compact,
|
|
1104
2281
|
summary_only: true,
|
|
2282
|
+
used_polygon_boundary: usedPolygon,
|
|
1105
2283
|
cell_count: cells.length,
|
|
1106
2284
|
sample_cells: sampleCells,
|
|
1107
2285
|
original_bbox: bboxObj,
|
|
1108
|
-
share_url: shareUrl
|
|
2286
|
+
share_url: shareUrl,
|
|
2287
|
+
provenance,
|
|
2288
|
+
complete: false,
|
|
2289
|
+
warnings
|
|
1109
2290
|
};
|
|
1110
2291
|
return {
|
|
1111
2292
|
content: [
|
|
1112
2293
|
{
|
|
1113
2294
|
type: "text",
|
|
1114
|
-
text: `H3 summary: ${cells.length.toLocaleString()} cells generated at resolution ${resolution} (compacted: ${compact}).\nSample cells: ${sampleCells.join(', ')}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
1115
|
-
},
|
|
1116
|
-
{
|
|
1117
|
-
type: "text",
|
|
1118
|
-
text: JSON.stringify(responseData, null, 2)
|
|
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)}`
|
|
1119
2296
|
}
|
|
1120
|
-
]
|
|
2297
|
+
],
|
|
2298
|
+
structuredContent: responseData
|
|
1121
2299
|
};
|
|
1122
2300
|
}
|
|
1123
2301
|
const responseData = {
|
|
1124
2302
|
resolution,
|
|
1125
2303
|
compact,
|
|
1126
2304
|
summary_only: false,
|
|
2305
|
+
used_polygon_boundary: usedPolygon,
|
|
1127
2306
|
cell_count: cells.length,
|
|
1128
2307
|
cells,
|
|
1129
2308
|
original_bbox: bboxObj,
|
|
1130
|
-
share_url: shareUrl
|
|
2309
|
+
share_url: shareUrl,
|
|
2310
|
+
provenance,
|
|
2311
|
+
complete: true,
|
|
2312
|
+
warnings
|
|
1131
2313
|
};
|
|
1132
2314
|
if (returnGeometry) {
|
|
1133
2315
|
responseData.geojson = h3CellsToGeoJSON(cells);
|
|
@@ -1136,13 +2318,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1136
2318
|
content: [
|
|
1137
2319
|
{
|
|
1138
2320
|
type: "text",
|
|
1139
|
-
text: `H3 cells generated: ${cells.length.toLocaleString()}\nResolution: ${resolution} | Compacted: ${compact}\n\nCells:\n${JSON.stringify(cells)}\n\nView on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
1140
|
-
},
|
|
1141
|
-
{
|
|
1142
|
-
type: "text",
|
|
1143
|
-
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)}`
|
|
1144
2322
|
}
|
|
1145
|
-
]
|
|
2323
|
+
],
|
|
2324
|
+
structuredContent: responseData
|
|
1146
2325
|
};
|
|
1147
2326
|
}
|
|
1148
2327
|
else if (toolName === "generate_share_url") {
|
|
@@ -1155,20 +2334,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1155
2334
|
const bboxObj = parseBBox(bbox, warnings);
|
|
1156
2335
|
const shareUrl = buildShareUrl(bboxObj);
|
|
1157
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
|
+
};
|
|
1158
2349
|
return {
|
|
1159
2350
|
content: [
|
|
1160
2351
|
{
|
|
1161
2352
|
type: "text",
|
|
1162
2353
|
text: `View on map: ${shareUrl}${formatWarnings(warnings)}`
|
|
1163
|
-
},
|
|
1164
|
-
{
|
|
1165
|
-
type: "text",
|
|
1166
|
-
text: JSON.stringify({
|
|
1167
|
-
bbox: bboxObj,
|
|
1168
|
-
share_url: shareUrl
|
|
1169
|
-
}, null, 2)
|
|
1170
2354
|
}
|
|
1171
|
-
]
|
|
2355
|
+
],
|
|
2356
|
+
structuredContent: structuredData
|
|
1172
2357
|
};
|
|
1173
2358
|
}
|
|
1174
2359
|
else if (toolName === "reverse_geocode") {
|
|
@@ -1186,12 +2371,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1186
2371
|
{
|
|
1187
2372
|
type: "text",
|
|
1188
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}`
|
|
1189
|
-
},
|
|
1190
|
-
{
|
|
1191
|
-
type: "text",
|
|
1192
|
-
text: JSON.stringify(result, null, 2)
|
|
1193
2374
|
}
|
|
1194
|
-
]
|
|
2375
|
+
],
|
|
2376
|
+
structuredContent: result
|
|
1195
2377
|
};
|
|
1196
2378
|
}
|
|
1197
2379
|
else if (toolName === "search_overpass") {
|
|
@@ -1203,6 +2385,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1203
2385
|
const useAreaBoundary = args.use_area_boundary !== false;
|
|
1204
2386
|
const radiusMeters = args.radius_meters;
|
|
1205
2387
|
const returnGeometry = args.return_geometry || false;
|
|
2388
|
+
const countOnly = args.count_only || false;
|
|
1206
2389
|
const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
|
|
1207
2390
|
if (!queryRaw) {
|
|
1208
2391
|
return { content: [{ type: "text", text: "Error: 'query' is required (e.g. `node[\"amenity\"=\"restaurant\"]`)." }], isError: true };
|
|
@@ -1220,6 +2403,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1220
2403
|
let bboxObj;
|
|
1221
2404
|
let geocodeRes;
|
|
1222
2405
|
let usedAreaId;
|
|
2406
|
+
const outDirective = countOnly ? "out count;" : (returnGeometry ? "out geom;" : "out center;");
|
|
1223
2407
|
if (useCircle) {
|
|
1224
2408
|
if (args.location) {
|
|
1225
2409
|
try {
|
|
@@ -1245,7 +2429,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1245
2429
|
else {
|
|
1246
2430
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
1247
2431
|
}
|
|
1248
|
-
const outDirective = returnGeometry ? "out geom;" : "out center;";
|
|
1249
2432
|
const circleFilter = `(around:${radiusMeters},${refPoint.lat},${refPoint.lng})`;
|
|
1250
2433
|
const formattedCore = applySpatialFilter(queryRaw, circleFilter);
|
|
1251
2434
|
overpassQuery = `[out:json][timeout:${timeoutSec}];\n(${formattedCore});\n${outDirective}`;
|
|
@@ -1280,7 +2463,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1280
2463
|
else {
|
|
1281
2464
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
1282
2465
|
}
|
|
1283
|
-
const outDirective = returnGeometry ? "out geom;" : "out center;";
|
|
1284
2466
|
if (usedAreaId) {
|
|
1285
2467
|
const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
|
|
1286
2468
|
overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\n${outDirective}`;
|
|
@@ -1292,53 +2474,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1292
2474
|
searchModeLabel = `bounding box`;
|
|
1293
2475
|
}
|
|
1294
2476
|
}
|
|
1295
|
-
log('info', `search_overpass starting`, { query: queryRaw, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'), radius_meters: radiusMeters, timeoutSec });
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
];
|
|
1300
|
-
if (process.env.OVERPASS_API_URL) {
|
|
1301
|
-
endpoints.unshift(process.env.OVERPASS_API_URL);
|
|
1302
|
-
}
|
|
1303
|
-
let data = null;
|
|
1304
|
-
let lastError = null;
|
|
1305
|
-
const params = new URLSearchParams();
|
|
1306
|
-
params.append('data', overpassQuery);
|
|
1307
|
-
const clientTimeoutMs = (timeoutSec + 5) * 1000;
|
|
1308
|
-
for (const endpoint of endpoints) {
|
|
1309
|
-
const controller = new AbortController();
|
|
1310
|
-
const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
|
|
1311
|
-
try {
|
|
1312
|
-
log('info', `Executing Overpass query at ${endpoint}`);
|
|
1313
|
-
const response = await fetch(endpoint, {
|
|
1314
|
-
method: "POST",
|
|
1315
|
-
body: params.toString(),
|
|
1316
|
-
headers: {
|
|
1317
|
-
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
1318
|
-
'Accept': 'application/json',
|
|
1319
|
-
'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
|
|
1320
|
-
},
|
|
1321
|
-
signal: controller.signal
|
|
1322
|
-
});
|
|
1323
|
-
if (!response.ok) {
|
|
1324
|
-
throw new Error(`Overpass API returned status: ${response.status} ${response.statusText}`);
|
|
1325
|
-
}
|
|
1326
|
-
data = await response.json();
|
|
1327
|
-
break;
|
|
1328
|
-
}
|
|
1329
|
-
catch (err) {
|
|
1330
|
-
lastError = err;
|
|
1331
|
-
log('warn', `Overpass API failed at ${endpoint}`, { error: err.message });
|
|
1332
|
-
}
|
|
1333
|
-
finally {
|
|
1334
|
-
clearTimeout(timeout);
|
|
1335
|
-
}
|
|
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}`);
|
|
1336
2481
|
}
|
|
1337
|
-
if (
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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
|
+
};
|
|
1342
2514
|
}
|
|
1343
2515
|
const seenIds = new Set();
|
|
1344
2516
|
let parsedElements = (data.elements || []).reduce((acc, e) => {
|
|
@@ -1388,15 +2560,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1388
2560
|
const nextOffset = hasMore ? offset + limit : undefined;
|
|
1389
2561
|
const shareUrl = useCircle
|
|
1390
2562
|
? buildShareUrlCircleWithMarkers(refPoint, radiusMeters, pagedElements)
|
|
1391
|
-
:
|
|
2563
|
+
: (usedAreaId && !args.bbox && pagedElements.length > 0
|
|
2564
|
+
? buildShareUrlMarkersOnly(pagedElements)
|
|
2565
|
+
: buildShareUrlWithMarkers(bboxObj || geocodeRes.bbox, pagedElements));
|
|
1392
2566
|
log('info', `search_overpass completed`, { total: totalCount, returned: pagedElements.length, offset, limit, truncated, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox') });
|
|
1393
2567
|
const responseData = {
|
|
1394
2568
|
search_mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox'),
|
|
2569
|
+
count_only: false,
|
|
1395
2570
|
...(geocodeRes ? { matched_place: geocodeRes.matched_place, alternatives: geocodeRes.alternatives } : {}),
|
|
1396
2571
|
...(usedAreaId ? { osm_area_id: usedAreaId } : {}),
|
|
1397
2572
|
distance_from: refPoint,
|
|
1398
|
-
generator: data.generator,
|
|
1399
|
-
osm3s: data.osm3s,
|
|
2573
|
+
...(data.generator ? { generator: data.generator } : {}),
|
|
2574
|
+
...(data.osm3s ? { osm3s: data.osm3s } : {}),
|
|
1400
2575
|
elements: pagedElements,
|
|
1401
2576
|
total_count: totalCount,
|
|
1402
2577
|
returned_count: pagedElements.length,
|
|
@@ -1408,29 +2583,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1408
2583
|
pagination_hint: hasMore
|
|
1409
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.`
|
|
1410
2585
|
: "End of result set.",
|
|
1411
|
-
share_url: shareUrl
|
|
2586
|
+
share_url: shareUrl,
|
|
2587
|
+
provenance,
|
|
2588
|
+
complete: !truncated && !remarkWarning,
|
|
2589
|
+
warnings
|
|
1412
2590
|
};
|
|
1413
2591
|
const topResults = pagedElements.slice(0, 5)
|
|
1414
2592
|
.map((e) => `- [${e.type}] ${e.name} (${e.coordinates.lat.toFixed(5)}, ${e.coordinates.lon.toFixed(5)}) — ${e.distance_meters}m away`)
|
|
1415
2593
|
.join('\n');
|
|
1416
|
-
const moreNote = pagedElements.length > 5 ? `\n … and ${pagedElements.length - 5} more on this page
|
|
2594
|
+
const moreNote = pagedElements.length > 5 ? `\n … and ${pagedElements.length - 5} more on this page` : '';
|
|
1417
2595
|
return {
|
|
1418
2596
|
content: [
|
|
1419
2597
|
{
|
|
1420
2598
|
type: "text",
|
|
1421
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)}`
|
|
1422
|
-
},
|
|
1423
|
-
{
|
|
1424
|
-
type: "text",
|
|
1425
|
-
text: JSON.stringify(responseData, null, 2)
|
|
1426
2600
|
}
|
|
1427
|
-
]
|
|
2601
|
+
],
|
|
2602
|
+
structuredContent: responseData
|
|
1428
2603
|
};
|
|
1429
2604
|
}
|
|
1430
2605
|
else if (toolName === "list_osm_tags") {
|
|
1431
2606
|
const args = request.params.arguments || {};
|
|
1432
2607
|
const category = (args.category || "").toLowerCase().trim();
|
|
2608
|
+
const warnings = [];
|
|
1433
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
|
+
};
|
|
1434
2618
|
switch (true) {
|
|
1435
2619
|
case category.includes('food') || category.includes('restaurant') || category.includes('cafe') || category.includes('pizza') || category.includes('dining'):
|
|
1436
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"]`;
|
|
@@ -1483,16 +2667,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1483
2667
|
case category.includes('childcare') || category.includes('daycare') || category.includes('child'):
|
|
1484
2668
|
recommendations = `Childcare/Daycare: nwr["amenity"="childcare"]\nPlaygrounds: nwr["leisure"="playground"]`;
|
|
1485
2669
|
break;
|
|
1486
|
-
default:
|
|
2670
|
+
default: {
|
|
1487
2671
|
log('info', `Category '${category}' not in index, querying Taginfo API`);
|
|
1488
|
-
|
|
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
|
+
};
|
|
1489
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()) : [];
|
|
1490
2691
|
}
|
|
1491
2692
|
return {
|
|
1492
2693
|
content: [{
|
|
1493
2694
|
type: "text",
|
|
1494
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.`
|
|
1495
|
-
}]
|
|
2696
|
+
}],
|
|
2697
|
+
structuredContent: {
|
|
2698
|
+
category,
|
|
2699
|
+
source,
|
|
2700
|
+
tags,
|
|
2701
|
+
recommendations_text: recommendations,
|
|
2702
|
+
provenance,
|
|
2703
|
+
complete: warnings.length === 0,
|
|
2704
|
+
warnings
|
|
2705
|
+
}
|
|
1496
2706
|
};
|
|
1497
2707
|
}
|
|
1498
2708
|
else if (toolName === "aggregate_overpass_h3") {
|
|
@@ -1501,10 +2711,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1501
2711
|
const resolution = args.resolution ?? 8;
|
|
1502
2712
|
const timeoutSec = args.timeout_seconds || 25;
|
|
1503
2713
|
const useAreaBoundary = args.use_area_boundary !== false;
|
|
2714
|
+
const radiusMeters = args.radius_meters;
|
|
2715
|
+
const useCircle = typeof radiusMeters === 'number' && radiusMeters > 0;
|
|
1504
2716
|
const warnings = [];
|
|
1505
2717
|
if (!queryRaw) {
|
|
1506
2718
|
return { content: [{ type: "text", text: "Error: 'query' is required." }], isError: true };
|
|
1507
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
|
+
}
|
|
1508
2726
|
if (resolution < 0 || resolution > 15 || !Number.isInteger(resolution)) {
|
|
1509
2727
|
return { content: [{ type: "text", text: `Error: Invalid H3 resolution: ${resolution}. Must be 0-15.` }], isError: true };
|
|
1510
2728
|
}
|
|
@@ -1532,9 +2750,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1532
2750
|
else {
|
|
1533
2751
|
return { content: [{ type: "text", text: "Error: Either 'location' or 'bbox' argument must be provided." }], isError: true };
|
|
1534
2752
|
}
|
|
1535
|
-
log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution, timeoutSec, mode: usedAreaId ? 'area' : 'bbox' });
|
|
2753
|
+
log('info', `aggregate_overpass_h3 starting`, { query: queryRaw, resolution, timeoutSec, mode: useCircle ? 'circle' : (usedAreaId ? 'area' : 'bbox') });
|
|
1536
2754
|
let overpassQuery;
|
|
1537
|
-
|
|
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;`;
|
|
2760
|
+
}
|
|
2761
|
+
else if (usedAreaId) {
|
|
1538
2762
|
const formattedCore = applySpatialFilter(queryRaw, "(area.searchArea)");
|
|
1539
2763
|
overpassQuery = `[out:json][timeout:${timeoutSec}];\narea(${usedAreaId})->.searchArea;\n(${formattedCore});\nout center;`;
|
|
1540
2764
|
}
|
|
@@ -1542,46 +2766,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1542
2766
|
const formattedCore = applySpatialFilter(queryRaw, "");
|
|
1543
2767
|
overpassQuery = `[out:json][timeout:${timeoutSec}][bbox:${bboxObj.lat1},${bboxObj.lng1},${bboxObj.lat2},${bboxObj.lng2}];\n(${formattedCore});\nout center;`;
|
|
1544
2768
|
}
|
|
1545
|
-
let
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
params.append('data', overpassQuery);
|
|
1549
|
-
const endpoints = [
|
|
1550
|
-
"https://overpass-api.de/api/interpreter",
|
|
1551
|
-
"https://overpass.kumi.systems/api/interpreter"
|
|
1552
|
-
];
|
|
1553
|
-
if (process.env.OVERPASS_API_URL) {
|
|
1554
|
-
endpoints.unshift(process.env.OVERPASS_API_URL);
|
|
2769
|
+
let execResult;
|
|
2770
|
+
try {
|
|
2771
|
+
execResult = await executeOverpassQuery(overpassQuery, timeoutSec);
|
|
1555
2772
|
}
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
const controller = new AbortController();
|
|
1559
|
-
const timeout = setTimeout(() => controller.abort(), clientTimeoutMs);
|
|
1560
|
-
try {
|
|
1561
|
-
const response = await fetch(endpoint, {
|
|
1562
|
-
method: "POST",
|
|
1563
|
-
body: params.toString(),
|
|
1564
|
-
headers: {
|
|
1565
|
-
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
|
1566
|
-
'Accept': 'application/json',
|
|
1567
|
-
'User-Agent': `${SERVER_NAME}/${SERVER_VERSION} (https://github.com/iamvibhorsingh/bbox-mcp-server)`
|
|
1568
|
-
},
|
|
1569
|
-
signal: controller.signal
|
|
1570
|
-
});
|
|
1571
|
-
if (!response.ok)
|
|
1572
|
-
throw new Error(`Status: ${response.status}`);
|
|
1573
|
-
data = await response.json();
|
|
1574
|
-
break;
|
|
1575
|
-
}
|
|
1576
|
-
catch (err) {
|
|
1577
|
-
lastError = err;
|
|
1578
|
-
}
|
|
1579
|
-
finally {
|
|
1580
|
-
clearTimeout(timeout);
|
|
1581
|
-
}
|
|
2773
|
+
catch (err) {
|
|
2774
|
+
return { content: [{ type: "text", text: `Error: Overpass query failed: ${err.message}` }], isError: true };
|
|
1582
2775
|
}
|
|
1583
|
-
|
|
1584
|
-
|
|
2776
|
+
const { data, provenance, remarkWarning } = execResult;
|
|
2777
|
+
if (remarkWarning) {
|
|
2778
|
+
warnings.push(`Overpass remark: ${remarkWarning}`);
|
|
1585
2779
|
}
|
|
1586
2780
|
let rawElements = data.elements || [];
|
|
1587
2781
|
const hexCounts = {};
|
|
@@ -1605,7 +2799,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1605
2799
|
});
|
|
1606
2800
|
}
|
|
1607
2801
|
});
|
|
1608
|
-
const shareUrl = buildShareUrl(bboxObj);
|
|
1609
2802
|
const totalGrouped = Object.values(hexCounts).reduce((a, b) => a + b, 0);
|
|
1610
2803
|
const activeHexes = Object.keys(hexCounts);
|
|
1611
2804
|
const geojson = h3CellsToGeoJSON(activeHexes);
|
|
@@ -1614,6 +2807,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1614
2807
|
f.properties.count = hexCounts[cellId];
|
|
1615
2808
|
f.properties.elements = hexElements[cellId];
|
|
1616
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));
|
|
1617
2828
|
log('info', `aggregate_overpass_h3 completed`, { resolution, elements: totalGrouped, activeHexes: activeHexes.length });
|
|
1618
2829
|
const responseData = {
|
|
1619
2830
|
resolution,
|
|
@@ -1624,23 +2835,607 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1624
2835
|
hex_counts: hexCounts,
|
|
1625
2836
|
hex_geojson: geojson,
|
|
1626
2837
|
original_bbox: bboxObj,
|
|
1627
|
-
share_url: shareUrl
|
|
2838
|
+
share_url: shareUrl,
|
|
2839
|
+
provenance,
|
|
2840
|
+
complete: !remarkWarning,
|
|
2841
|
+
warnings
|
|
1628
2842
|
};
|
|
1629
|
-
const topHexes = Object.entries(hexCounts)
|
|
1630
|
-
.sort((a, b) => b[1] - a[1])
|
|
1631
|
-
.slice(0, 10);
|
|
1632
2843
|
return {
|
|
1633
2844
|
content: [
|
|
1634
2845
|
{
|
|
1635
2846
|
type: "text",
|
|
1636
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)"
|
|
1637
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: [
|
|
1638
3025
|
{
|
|
1639
3026
|
type: "text",
|
|
1640
|
-
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)}`
|
|
1641
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`);
|
|
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` }
|
|
1642
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)"
|
|
1643
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
|
|
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
|
+
}
|
|
1644
3439
|
}
|
|
1645
3440
|
else {
|
|
1646
3441
|
log('warn', `Unknown tool requested: ${toolName}`);
|