From b036a21e711fb732be4ec88821b0459942dbbda3 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 20 Oct 2026 19:56:49 -0400 Subject: [PATCH 1/5] feat(tools): elevation_tool for ground elevation and climb along a route Reads Mapbox Terrain-DEM v1 raster tiles and decodes heights on the server (a small PNG decoder on node:zlib, no new dependency). Takes up to 25 points, a path, or a directions_tool ref, and returns elevations or climb, descent, high and low points and a profile, plus a map ref. Long paths drop to a coarser zoom so a call needs at most 48 tiles. Part of #289 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + README.md | 13 + docs/importing-tools.md | 1 + .../ElevationTool.input.schema.ts | 41 +++ .../ElevationTool.output.schema.ts | 42 +++ src/tools/elevation-tool/ElevationTool.ts | 333 ++++++++++++++++++ src/tools/elevation-tool/decodePng.ts | 118 +++++++ src/tools/elevation-tool/terrain.ts | 224 ++++++++++++ src/tools/index.ts | 5 + src/tools/toolRegistry.ts | 2 + .../elevation-tool/ElevationTool.test.ts | 308 ++++++++++++++++ test/tools/elevation-tool/decodePng.test.ts | 52 +++ test/tools/elevation-tool/pngFixtures.ts | 83 +++++ 13 files changed, 1230 insertions(+) create mode 100644 src/tools/elevation-tool/ElevationTool.input.schema.ts create mode 100644 src/tools/elevation-tool/ElevationTool.output.schema.ts create mode 100644 src/tools/elevation-tool/ElevationTool.ts create mode 100644 src/tools/elevation-tool/decodePng.ts create mode 100644 src/tools/elevation-tool/terrain.ts create mode 100644 test/tools/elevation-tool/ElevationTool.test.ts create mode 100644 test/tools/elevation-tool/decodePng.test.ts create mode 100644 test/tools/elevation-tool/pngFixtures.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1541150d..131f4e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ### New Features +- **`elevation_tool`: ground elevation at places, and climb and descent along a path or route (#289).** The server had no elevation at all, so "how hilly is this bike route?" or "how high is this town?" had no answer from Mapbox data. Part 4 of #289; the general `describe_tileset` and `tilequery` tools wait on the open questions there. + - **Inputs:** up to 25 `points`, a `path` of up to 1,000 coordinates, or the `directions_ref` from a `directions_tool` result, which the tool turns back into the full route with `directionsRequestFromRef` (#276), so the model never copies route geometry. + - **Data:** reads `mapbox.mapbox-terrain-dem-v1` raster tiles (`.pngraw`, terrain-RGB) and decodes each pixel's height, with bilinear interpolation between pixels. Tilequery can't read the DEM (it returns 422), and its Terrain v2 contours come in 10 m steps or coarser: 240 m near Twin Peaks against 249.5 m from the DEM, 10 m at Civic Center against 20.9 m. Longs Peak reads 4,346 m, its surveyed height. A small PNG decoder on Node's `zlib` handles the 8-bit RGB/RGBA tiles, so there's no new dependency. + - **Cost:** a path is sampled at up to 500 points, never closer than one DEM pixel. Samples share tiles, each tile is fetched once per call, and the zoom drops on long paths until the path needs at most 48 tiles. The result reports `zoom` and `resolution_m`. Ferry Building to Twin Peaks by bike: 1 tile request at 7.5 m resolution, 274 m of climb. San Francisco to Los Angeles: 1.3 s at about 120 m resolution, highest point 1,276 m near Tejon Pass. + - **Climb:** swings under 2 m don't count, so DEM noise on flat ground doesn't add up to phantom hills. + - **Result:** elevations, or distance, climb, descent, start, end, highest and lowest points and a profile of up to 50 points for a chart (12 in the text), plus a map ref with the line and ▲/▼ markers at the high and low points. For a directions ref, the text says to pass the route's ref alongside, rather than copying the route into this one. + - **Testing:** a PNG encoder in the tests covers every row filter, RGB and RGBA, and bad input (not a PNG, 16-bit, palette, truncated). Synthetic terrain tiles test point heights, interpolation, climb along a path, a directions ref, the zoom choice for a long route, the noise threshold, a refused token, and that `-0 m` never prints. + - **`feedback_tool`: report wrong map, place or routing data to Mapbox (#291).** When someone says a place has closed, a road is closed, or a route took an illegal turn, the agent can send it to the [Feedback API](https://docs.mapbox.com/api/feedback/) (`POST /user-feedback/v1/feedback`, Public Preview) with the text, a category and the location, usually taken from an earlier search or directions result. It returns the report's id and status (`received`). - **Confirm first:** the report goes to Mapbox under the user's account, so the description tells the model to show the user what it will send and get a yes. Clients that support elicitation also get a confirmation prompt from the tool itself. Declining sends nothing, and so does a failed prompt. - **Categories:** an enum of the Feedback Agent's default categories that make sense from chat (`poi_closed`, `poi_details`, `road_closure_issue`, `illegal_turn` and so on), leaving out the navigation-app ones such as voice guidance and rerouting. diff --git a/README.md b/README.md index 4494629a..56f5985f 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Each of the four prompts below exercises a different strategy in one tool call: - "Get driving directions from LAX to Hollywood with current traffic" - "How long would it take to walk from Central Park to Times Square?" +- "How much climbing is on a bike ride from the Ferry Building to Twin Peaks?" - "Calculate travel time from my hotel (Four Seasons) to JFK Airport by taxi during rush hour" ### Visualization & Maps @@ -516,6 +517,18 @@ Finds the optimal route through multiple locations using the [Mapbox Optimizatio **Note**: A V2 API with advanced features (time windows, capacity constraints, multiple vehicles) is available but requires beta access. The V2 implementation is included in the codebase but not registered by default. +#### Elevation tool + +Gets ground elevation from Mapbox's terrain data ([Mapbox Terrain-DEM v1](https://docs.mapbox.com/data/tilesets/reference/mapbox-terrain-dem-v1/)). Give it up to 25 places for the height above sea level of each, or a line for: + +- Total climb and descent +- The highest and lowest points, and how far along the line they are +- An elevation profile of up to 50 points + +The line can be a `path` of coordinates, or the `directions_ref` from a `directions_tool` result, which the tool turns back into the full route itself. The server reads the terrain tiles and decodes each pixel's height, so values aren't rounded to contour steps the way Tilequery's Terrain v2 contours are. Points along a line share tiles, and long lines are read at a coarser zoom so a call stays under 50 tile requests; `resolution_m` in the result says how coarse. Swings under 2 m don't count toward climb or descent, so DEM noise on flat ground doesn't add up. Heights are of the ground, not of buildings or bridges. The result includes a map ref with the line and its high and low points. + +**Example Usage**: "How hilly is the bike route from the Ferry Building to Twin Peaks?" or "How high is Longs Peak?" + #### Feedback tool Reports a problem with Mapbox map, place or routing data to Mapbox through the [Mapbox Feedback API](https://docs.mapbox.com/api/feedback/) (Public Preview). Mapbox reviews each report and may fix the data. Use it for things like: diff --git a/docs/importing-tools.md b/docs/importing-tools.md index 8b982b9d..6f17bf72 100644 --- a/docs/importing-tools.md +++ b/docs/importing-tools.md @@ -122,6 +122,7 @@ import { // API tools (HTTP pre-configured) categorySearch, directions, + elevation, feedback, isochrone, mapMatching, diff --git a/src/tools/elevation-tool/ElevationTool.input.schema.ts b/src/tools/elevation-tool/ElevationTool.input.schema.ts new file mode 100644 index 00000000..3356a14b --- /dev/null +++ b/src/tools/elevation-tool/ElevationTool.input.schema.ts @@ -0,0 +1,41 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +const coordinateSchema = z.object({ + longitude: z.number().min(-180).max(180), + latitude: z.number().min(-90).max(90) +}); + +/** + * Input schema for ElevationTool. Exactly one of `points`, `path` or + * `directions_ref` is required; the tool checks that itself so the + * published JSON schema stays a plain object. + */ +export const ElevationInputSchema = z.object({ + points: z + .array(coordinateSchema) + .min(1) + .max(25) + .optional() + .describe( + 'Up to 25 places to get the ground elevation of, e.g. a summit, a town or a campsite.' + ), + path: z + .array(coordinateSchema) + .min(2) + .max(1000) + .optional() + .describe( + 'A line (hike, ride, run) as ordered coordinates, for climb, descent and an elevation profile along it. For a route from directions_tool, pass directions_ref instead.' + ), + directions_ref: z + .string() + .optional() + .describe( + 'The structuredContent.mapboxRender.ref from a directions_tool result (mapbox://selffetch/directions?...), passed unchanged. The tool fetches the full route itself, so you never need to copy route geometry.' + ) +}); + +export type ElevationInput = z.infer; diff --git a/src/tools/elevation-tool/ElevationTool.output.schema.ts b/src/tools/elevation-tool/ElevationTool.output.schema.ts new file mode 100644 index 00000000..7dae38f4 --- /dev/null +++ b/src/tools/elevation-tool/ElevationTool.output.schema.ts @@ -0,0 +1,42 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +const elevationPointSchema = z.object({ + longitude: z.number(), + latitude: z.number(), + elevation_m: z.number() +}); + +const pathPointSchema = elevationPointSchema.extend({ + distance_m: z.number().describe('Distance along the path from its start') +}); + +export const ElevationOutputSchema = z.object({ + source: z.string().describe('Tileset the elevations were read from'), + zoom: z.number().describe('Tile zoom used; lower on long paths'), + resolution_m: z + .number() + .describe('Ground size of one elevation pixel at that zoom'), + points: z.array(elevationPointSchema).optional(), + path: z + .object({ + distance_m: z.number(), + sample_spacing_m: z.number(), + sample_count: z.number(), + climb_m: z.number(), + descent_m: z.number(), + start: pathPointSchema, + end: pathPointSchema, + highest: pathPointSchema, + lowest: pathPointSchema, + profile: z + .array(z.object({ distance_m: z.number(), elevation_m: z.number() })) + .describe('Up to 50 evenly spaced samples, for a chart') + }) + .optional(), + mapboxRender: z.object({ ref: z.string() }).passthrough().optional() +}); + +export type ElevationOutput = z.infer; diff --git a/src/tools/elevation-tool/ElevationTool.ts b/src/tools/elevation-tool/ElevationTool.ts new file mode 100644 index 00000000..eb039633 --- /dev/null +++ b/src/tools/elevation-tool/ElevationTool.ts @@ -0,0 +1,333 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { z } from 'zod'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { HttpRequest } from '../../utils/types.js'; +import type { MapAppMarker, MapAppPayload } from '../../utils/mapAppPayload.js'; +import { buildInlinePayloadRef } from '../../utils/inlinePayloadRef.js'; +import { renderHint } from '../../utils/storeMapPayload.js'; +import { directionsRequestFromRef } from '../../utils/directionsRef.js'; +import { ElevationInputSchema } from './ElevationTool.input.schema.js'; +import { + ElevationOutputSchema, + type ElevationOutput +} from './ElevationTool.output.schema.js'; +import { + MAX_ZOOM, + TERRAIN_TILESET, + TerrainTileError, + chooseZoom, + climbAndDescent, + elevationsAt, + pathLength, + pixelSizeMeters, + samplePath, + type LngLat +} from './terrain.js'; + +/** Most points sampled along a path; long paths get wider spacing. */ +const MAX_SAMPLES = 500; +const PROFILE_POINTS = 50; +/** The text gets a shorter profile; the full one is in structuredContent. */ +const TEXT_PROFILE_POINTS = 12; + +type PathPoint = NonNullable['start']; + +function round(value: number, digits = 1): number { + const f = 10 ** digits; + // `|| 0` turns -0 into 0, so it never prints as "-0". + return Math.round(value * f) / f || 0; +} + +function formatMeters(value: number): string { + return `${(Math.round(value) || 0).toLocaleString('en-US')} m`; +} + +function formatDistance(meters: number): string { + return meters >= 1000 + ? `${round(meters / 1000).toLocaleString('en-US')} km` + : `${Math.round(meters)} m`; +} + +/** + * ElevationTool: ground elevation at points, or climb, descent and a + * profile along a path or a directions_tool route, from Mapbox's terrain + * DEM tiles (decoded on the server, so no Tilequery contour rounding). + */ +export class ElevationTool extends MapboxApiBasedTool< + typeof ElevationInputSchema, + typeof ElevationOutputSchema +> { + name = 'elevation_tool'; + description = + 'Gets ground elevation from Mapbox terrain data. Give it places (`points`) for the height above sea level of each, ' + + 'or a line (`path`, or `directions_ref` from a directions_tool result) for total climb and descent, the highest and lowest points, and an elevation profile. ' + + 'Use it for questions like "how high is this town?", "how hilly is this bike route?" or "how much climbing is on this hike?". ' + + 'Elevations are of the ground (bare earth), not buildings or bridges. ' + + 'Long paths are read at a coarser resolution (see `resolution_m` in the result), which can undercount small hills.'; + annotations = { + title: 'Elevation Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + + constructor({ httpRequest }: { httpRequest: HttpRequest }) { + super({ + inputSchema: ElevationInputSchema, + outputSchema: ElevationOutputSchema, + httpRequest + }); + } + + protected async execute( + input: z.infer, + accessToken: string + ): Promise { + const given = [input.points, input.path, input.directions_ref].filter( + (v) => v !== undefined + ).length; + if (given !== 1) { + return this.error('Pass exactly one of points, path or directions_ref.'); + } + + const apiEndpoint = MapboxApiBasedTool.mapboxApiEndpoint; + const options = { httpRequest: this.httpRequest, accessToken, apiEndpoint }; + + try { + if (input.points) { + const points = input.points.map( + (p) => [p.longitude, p.latitude] as LngLat + ); + const heights = await elevationsAt(points, MAX_ZOOM, options); + return this.pointsResult(points, heights); + } + + let line: LngLat[]; + let drawLine = false; + if (input.path) { + line = input.path.map((p) => [p.longitude, p.latitude] as LngLat); + drawLine = true; + } else { + const route = await this.fetchRoute( + input.directions_ref as string, + accessToken, + apiEndpoint + ); + if (typeof route === 'string') return this.error(route); + line = route; + } + return await this.pathResult(line, drawLine, options); + } catch (error) { + if (error instanceof TerrainTileError) { + return this.error( + error.status === 401 || error.status === 403 + ? `Terrain tiles refused the access token (${error.status}). Check that the token is valid and can read Mapbox tilesets, which default public tokens can.` + : error.message + ); + } + throw error; + } + } + + private error(text: string): CallToolResult { + return { content: [{ type: 'text', text }], isError: true }; + } + + /** The route's coordinates, or an error message for the model. */ + private async fetchRoute( + ref: string, + accessToken: string, + apiEndpoint: string + ): Promise { + const request = directionsRequestFromRef(ref, { accessToken, apiEndpoint }); + if (!request) { + return 'directions_ref is not a directions_tool ref. Pass the structuredContent.mapboxRender.ref from a directions_tool result unchanged, or pass the line as path.'; + } + const response = await this.httpRequest(request.url); + if (!response.ok) { + return `Directions API error: ${await this.getErrorMessage(response)}`; + } + const data = (await response.json()) as { + routes?: Array<{ geometry?: { coordinates?: unknown } }>; + }; + const coords = + data.routes?.[request.selectedRouteIndex ?? 0]?.geometry?.coordinates ?? + data.routes?.[0]?.geometry?.coordinates; + if (!Array.isArray(coords) || coords.length < 2) { + return 'The Directions API returned no route for that ref.'; + } + return coords as LngLat[]; + } + + private pointsResult(points: LngLat[], heights: number[]): CallToolResult { + const out: ElevationOutput = { + source: TERRAIN_TILESET, + zoom: MAX_ZOOM, + resolution_m: round(pixelSizeMeters(MAX_ZOOM, points[0][1])), + points: points.map((p, i) => ({ + longitude: p[0], + latitude: p[1], + elevation_m: round(heights[i]) + })) + }; + const markers: MapAppMarker[] = points.map((p, i) => ({ + coordinates: p, + style: 'pin', + popup: formatMeters(heights[i]) + })); + const ref = buildInlinePayloadRef({ + summary: + points.length === 1 + ? `Elevation ${formatMeters(heights[0])}` + : 'Elevations', + layers: [], + markers + }); + out.mapboxRender = { ref }; + + const lines = points.map( + (p, i) => + `${points.length > 1 ? `${i + 1}. ` : ''}${formatMeters(heights[i])} at ${p[1].toFixed(5)}, ${p[0].toFixed(5)}` + ); + const text = `Ground elevation (${TERRAIN_TILESET}, about ${out.resolution_m} m resolution):\n${lines.join('\n')}`; + return { + content: [{ type: 'text', text: text + renderHint(ref) }], + structuredContent: out, + isError: false + }; + } + + private async pathResult( + line: LngLat[], + drawLine: boolean, + options: Parameters[2] + ): Promise { + const length = pathLength(line); + const finest = pixelSizeMeters(MAX_ZOOM, line[0][1]); + const spacing = Math.max(finest, length / (MAX_SAMPLES - 1)); + const samples = samplePath(line, spacing); + const points = samples.map((s) => s.point); + const zoom = chooseZoom(points); + const heights = await elevationsAt(points, zoom, options); + + const at = (i: number): PathPoint => ({ + longitude: points[i][0], + latitude: points[i][1], + elevation_m: round(heights[i]), + distance_m: Math.round(samples[i].distance) + }); + let hi = 0; + let lo = 0; + heights.forEach((h, i) => { + if (h > heights[hi]) hi = i; + if (h < heights[lo]) lo = i; + }); + const { climb, descent } = climbAndDescent(heights); + const step = Math.max(1, (samples.length - 1) / (PROFILE_POINTS - 1)); + const profile: Array<{ distance_m: number; elevation_m: number }> = []; + for (let k = 0; k < samples.length; k += step) { + const i = Math.round(k); + profile.push({ + distance_m: Math.round(samples[i].distance), + elevation_m: round(heights[i]) + }); + } + if (profile[profile.length - 1].distance_m !== Math.round(length)) { + const last = samples.length - 1; + profile.push({ + distance_m: Math.round(samples[last].distance), + elevation_m: round(heights[last]) + }); + } + + const resolution = round(pixelSizeMeters(zoom, line[0][1])); + const out: ElevationOutput = { + source: TERRAIN_TILESET, + zoom, + resolution_m: resolution, + path: { + distance_m: Math.round(length), + sample_spacing_m: round(spacing), + sample_count: samples.length, + climb_m: Math.round(climb), + descent_m: Math.round(descent), + start: at(0), + end: at(samples.length - 1), + highest: at(hi), + lowest: at(lo), + profile + } + }; + + const payload: MapAppPayload = { + summary: `Climb ${formatMeters(climb)} · descent ${formatMeters(descent)}`, + layers: drawLine + ? [ + { + id: 'elevation-path', + type: 'line', + data: { + type: 'Feature', + properties: {}, + geometry: { type: 'LineString', coordinates: line } + }, + paint: { 'line-color': '#3b82f6', 'line-width': 4 }, + layout: { 'line-join': 'round', 'line-cap': 'round' }, + slot: 'top' + } + ] + : [], + markers: [ + { + coordinates: points[hi], + style: 'numbered', + label: '▲', + color: '#b45309', + popup: `Highest: ${formatMeters(heights[hi])} at ${formatDistance(samples[hi].distance)}` + }, + { + coordinates: points[lo], + style: 'numbered', + label: '▼', + color: '#0369a1', + popup: `Lowest: ${formatMeters(heights[lo])} at ${formatDistance(samples[lo].distance)}` + } + ] + }; + const ref = buildInlinePayloadRef(payload); + out.mapboxRender = { ref }; + + const p = out.path!; + const text = [ + `Elevation along ${formatDistance(length)} (${TERRAIN_TILESET}, ${samples.length} samples, about ${resolution} m resolution):`, + `Climb: ${formatMeters(climb)}, descent: ${formatMeters(descent)}`, + `Start: ${formatMeters(p.start.elevation_m)}, end: ${formatMeters(p.end.elevation_m)}`, + `Highest: ${formatMeters(p.highest.elevation_m)} at ${formatDistance(p.highest.distance_m)}`, + `Lowest: ${formatMeters(p.lowest.elevation_m)} at ${formatDistance(p.lowest.distance_m)}`, + `Profile (distance, elevation): ${profile + .filter( + (_, i) => + i === profile.length - 1 || + i % Math.ceil(profile.length / TEXT_PROFILE_POINTS) === 0 + ) + .map( + (q) => + `${formatDistance(q.distance_m)} ${formatMeters(q.elevation_m)}` + ) + .join('; ')}` + ].join('\n'); + const hint = drawLine + ? renderHint(ref) + : renderHint(ref) + + '\nTo show the route too, pass the directions ref alongside it in payload_refs.'; + return { + content: [{ type: 'text', text: text + hint }], + structuredContent: out, + isError: false + }; + } +} diff --git a/src/tools/elevation-tool/decodePng.ts b/src/tools/elevation-tool/decodePng.ts new file mode 100644 index 00000000..da0ccf30 --- /dev/null +++ b/src/tools/elevation-tool/decodePng.ts @@ -0,0 +1,118 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { inflateSync } from 'node:zlib'; + +export interface DecodedPng { + width: number; + height: number; + /** Bytes per pixel in `pixels`: 3 (RGB) or 4 (RGBA). */ + channels: 3 | 4; + /** Unfiltered pixel rows, `width * channels` bytes each. */ + pixels: Uint8Array; +} + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +// Terrain tiles are 512 px at most; anything far bigger isn't one. +const MAX_DIMENSION = 4096; + +/** + * Decodes the PNGs terrain-RGB tiles come in: 8-bit RGB or RGBA, not + * interlaced. That's all the elevation tool reads, and Node's zlib does the + * heavy part, so it doesn't need an image library. Throws on anything else, + * including palette or 16-bit images. Chunk CRCs aren't checked; the tile + * came over TLS and a corrupt one fails to inflate. + */ +export function decodePng(data: Uint8Array): DecodedPng { + const buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength); + if (buf.length < 8 || PNG_SIGNATURE.some((byte, i) => buf[i] !== byte)) { + throw new Error('Not a PNG'); + } + + let width = 0; + let height = 0; + let channels: 3 | 4 = 4; + const idat: Buffer[] = []; + let offset = 8; + while (offset + 8 <= buf.length) { + const length = buf.readUInt32BE(offset); + const type = buf.toString('latin1', offset + 4, offset + 8); + const start = offset + 8; + const end = start + length; + if (end + 4 > buf.length) throw new Error('Truncated PNG'); + if (type === 'IHDR') { + width = buf.readUInt32BE(start); + height = buf.readUInt32BE(start + 4); + const bitDepth = buf[start + 8]; + const colorType = buf[start + 9]; + const interlace = buf[start + 12]; + if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6)) { + throw new Error('Unsupported PNG: only 8-bit RGB or RGBA'); + } + if (interlace !== 0) throw new Error('Unsupported PNG: interlaced'); + channels = colorType === 6 ? 4 : 3; + } else if (type === 'IDAT') { + idat.push(buf.subarray(start, end)); + } else if (type === 'IEND') { + break; + } + offset = end + 4; + } + if ( + width === 0 || + height === 0 || + width > MAX_DIMENSION || + height > MAX_DIMENSION + ) { + throw new Error('Bad PNG size'); + } + + const stride = width * channels; + const raw = inflateSync(Buffer.concat(idat), { + maxOutputLength: (stride + 1) * height + }); + if (raw.length < (stride + 1) * height) throw new Error('Truncated PNG'); + + // Undo each row's filter (PNG spec, section 9). `bpp` is the byte + // distance to the same channel of the pixel on the left. + const pixels = new Uint8Array(stride * height); + const bpp = channels; + for (let y = 0; y < height; y++) { + const filter = raw[y * (stride + 1)]; + const src = y * (stride + 1) + 1; + const row = y * stride; + const prev = row - stride; + for (let i = 0; i < stride; i++) { + const left = i >= bpp ? pixels[row + i - bpp] : 0; + const up = y > 0 ? pixels[prev + i] : 0; + const upLeft = y > 0 && i >= bpp ? pixels[prev + i - bpp] : 0; + let predictor: number; + switch (filter) { + case 0: + predictor = 0; + break; + case 1: + predictor = left; + break; + case 2: + predictor = up; + break; + case 3: + predictor = (left + up) >> 1; + break; + case 4: { + const p = left + up - upLeft; + const pa = Math.abs(p - left); + const pb = Math.abs(p - up); + const pc = Math.abs(p - upLeft); + predictor = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft; + break; + } + default: + throw new Error('Bad PNG filter'); + } + pixels[row + i] = (raw[src + i] + predictor) & 0xff; + } + } + return { width, height, channels, pixels }; +} diff --git a/src/tools/elevation-tool/terrain.ts b/src/tools/elevation-tool/terrain.ts new file mode 100644 index 00000000..79c9a905 --- /dev/null +++ b/src/tools/elevation-tool/terrain.ts @@ -0,0 +1,224 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { HttpRequest } from '../../utils/types.js'; +import { decodePng } from './decodePng.js'; + +export const TERRAIN_TILESET = 'mapbox.mapbox-terrain-dem-v1'; +export const MAX_ZOOM = 14; +const MIN_ZOOM = 5; +/** Most tiles one call may fetch; the zoom drops until the path fits. */ +export const MAX_TILES = 48; +const TILE_FETCH_CONCURRENCY = 8; +const EARTH_RADIUS_M = 6371008.8; +/** Swings smaller than this don't count toward climb or descent. */ +export const CLIMB_THRESHOLD_M = 2; + +export type LngLat = [number, number]; + +/** Tile coordinates of a point at `zoom`, with the fraction kept. */ +function tileXY(point: LngLat, zoom: number): { x: number; y: number } { + const n = 2 ** zoom; + const lat = Math.max(-85.0511, Math.min(85.0511, point[1])); + const rad = (lat * Math.PI) / 180; + return { + x: ((point[0] + 180) / 360) * n, + y: ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n + }; +} + +function tileKey(point: LngLat, zoom: number): string { + const { x, y } = tileXY(point, zoom); + const n = 2 ** zoom; + return `${zoom}/${Math.min(n - 1, Math.floor(x))}/${Math.min(n - 1, Math.floor(y))}`; +} + +/** + * The most detailed zoom at which `points` need at most MAX_TILES tiles. + * Long routes get a coarser zoom so a call stays a few dozen requests. + */ +export function chooseZoom(points: LngLat[]): number { + for (let zoom = MAX_ZOOM; zoom > MIN_ZOOM; zoom--) { + const tiles = new Set(points.map((p) => tileKey(p, zoom))); + if (tiles.size <= MAX_TILES) return zoom; + } + return MIN_ZOOM; +} + +/** Ground size of one 256 px tile pixel at `zoom` and `latitude`, in meters. */ +export function pixelSizeMeters(zoom: number, latitude: number): number { + return ( + (2 * Math.PI * EARTH_RADIUS_M * Math.cos((latitude * Math.PI) / 180)) / + (256 * 2 ** zoom) + ); +} + +export function haversineMeters(a: LngLat, b: LngLat): number { + const toRad = Math.PI / 180; + const dLat = (b[1] - a[1]) * toRad; + const dLng = (b[0] - a[0]) * toRad; + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(a[1] * toRad) * Math.cos(b[1] * toRad) * Math.sin(dLng / 2) ** 2; + return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h))); +} + +export interface PathSample { + point: LngLat; + /** Distance along the path from its start, in meters. */ + distance: number; +} + +/** + * Points every `spacing` meters along a line, plus its last vertex. + * Straight-line interpolation between vertices is fine at these distances. + */ +export function samplePath(line: LngLat[], spacing: number): PathSample[] { + const samples: PathSample[] = [{ point: line[0], distance: 0 }]; + let travelled = 0; + let next = spacing; + for (let i = 1; i < line.length; i++) { + const a = line[i - 1]; + const b = line[i]; + const segment = haversineMeters(a, b); + while (segment > 0 && next <= travelled + segment) { + const t = (next - travelled) / segment; + samples.push({ + point: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], + distance: next + }); + next += spacing; + } + travelled += segment; + } + const last = samples[samples.length - 1]; + if (travelled - last.distance > 0.5) { + samples.push({ point: line[line.length - 1], distance: travelled }); + } + return samples; +} + +export function pathLength(line: LngLat[]): number { + let total = 0; + for (let i = 1; i < line.length; i++) { + total += haversineMeters(line[i - 1], line[i]); + } + return total; +} + +interface Tile { + size: number; + heights: Float32Array; +} + +/** Terrain-RGB: height = -10000 + (R * 65536 + G * 256 + B) * 0.1 meters. */ +function decodeTile(bytes: Uint8Array): Tile { + const png = decodePng(bytes); + if (png.width !== png.height) throw new Error('Terrain tile is not square'); + const heights = new Float32Array(png.width * png.height); + for (let i = 0; i < heights.length; i++) { + const o = i * png.channels; + heights[i] = + -10000 + + (png.pixels[o] * 65536 + png.pixels[o + 1] * 256 + png.pixels[o + 2]) * + 0.1; + } + return { size: png.width, heights }; +} + +/** Bilinear height between the four pixel centers around a point. */ +function heightInTile(tile: Tile, fx: number, fy: number): number { + const max = tile.size - 1; + const px = Math.max(0, Math.min(max, fx * tile.size - 0.5)); + const py = Math.max(0, Math.min(max, fy * tile.size - 0.5)); + const x0 = Math.floor(px); + const y0 = Math.floor(py); + const x1 = Math.min(max, x0 + 1); + const y1 = Math.min(max, y0 + 1); + const tx = px - x0; + const ty = py - y0; + const h = (x: number, y: number) => tile.heights[y * tile.size + x]; + const top = h(x0, y0) * (1 - tx) + h(x1, y0) * tx; + const bottom = h(x0, y1) * (1 - tx) + h(x1, y1) * tx; + return top * (1 - ty) + bottom * ty; +} + +/** + * Elevation in meters at each point, read from Mapbox's terrain DEM tiles + * at `zoom`. Each tile is fetched once per call. Throws if a tile can't be + * fetched or decoded, with the HTTP status when there is one. + */ +export async function elevationsAt( + points: LngLat[], + zoom: number, + options: { + httpRequest: HttpRequest; + accessToken: string; + apiEndpoint: string; + } +): Promise { + const keys = [...new Set(points.map((p) => tileKey(p, zoom)))]; + const tiles = new Map(); + let index = 0; + const worker = async () => { + while (index < keys.length) { + const key = keys[index++]; + const url = new URL( + `${options.apiEndpoint}v4/${TERRAIN_TILESET}/${key}.pngraw` + ); + url.searchParams.set('access_token', options.accessToken); + const response = await options.httpRequest(url.toString()); + if (!response.ok) { + throw new TerrainTileError(response.status, response.statusText); + } + tiles.set(key, decodeTile(new Uint8Array(await response.arrayBuffer()))); + } + }; + await Promise.all( + Array.from( + { length: Math.min(TILE_FETCH_CONCURRENCY, keys.length) }, + worker + ) + ); + + const n = 2 ** zoom; + return points.map((p) => { + const { x, y } = tileXY(p, zoom); + const tx = Math.min(n - 1, Math.floor(x)); + const ty = Math.min(n - 1, Math.floor(y)); + const tile = tiles.get(`${zoom}/${tx}/${ty}`) as Tile; + return heightInTile(tile, x - tx, y - ty); + }); +} + +export class TerrainTileError extends Error { + constructor( + readonly status: number, + statusText: string + ) { + super(`Terrain tile request failed: ${status} ${statusText}`.trim()); + } +} + +/** + * Total climb and descent, ignoring swings under CLIMB_THRESHOLD_M so DEM + * noise on flat ground doesn't add up to phantom hills. + */ +export function climbAndDescent(heights: number[]): { + climb: number; + descent: number; +} { + let climb = 0; + let descent = 0; + let ref = heights[0]; + for (const h of heights) { + if (h - ref >= CLIMB_THRESHOLD_M) { + climb += h - ref; + ref = h; + } else if (ref - h >= CLIMB_THRESHOLD_M) { + descent += ref - h; + ref = h; + } + } + return { climb, descent }; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index a0f12dd3..d6dced88 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -43,6 +43,7 @@ export { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; export { MatrixTool } from './matrix-tool/MatrixTool.js'; export { MidpointTool } from './midpoint-tool/MidpointTool.js'; export { OptimizationTool } from './optimization-tool/OptimizationTool.js'; +export { ElevationTool } from './elevation-tool/ElevationTool.js'; export { FeedbackTool } from './feedback-tool/FeedbackTool.js'; export { GroundLocationTool } from './ground-location-tool/GroundLocationTool.js'; export { ResourceReaderTool } from './resource-reader-tool/ResourceReaderTool.js'; @@ -51,6 +52,7 @@ export { SearchAndGeocodeTool } from './search-and-geocode-tool/SearchAndGeocode export { SimplifyTool } from './simplify-tool/SimplifyTool.js'; export { StaticMapImageTool } from './static-map-image-tool/StaticMapImageTool.js'; // Import tool classes for instantiation +import { ElevationTool } from './elevation-tool/ElevationTool.js'; import { FeedbackTool } from './feedback-tool/FeedbackTool.js'; import { GroundLocationTool } from './ground-location-tool/GroundLocationTool.js'; import { AreaTool } from './area-tool/AreaTool.js'; @@ -102,6 +104,9 @@ export const directions = new DirectionsTool({ httpRequest }); /** Calculate distance between points */ export const distance = new DistanceTool(); +/** Ground elevation at points, or climb and descent along a path or route */ +export const elevation = new ElevationTool({ httpRequest }); + /** Report a map, place or routing data problem to Mapbox */ export const feedback = new FeedbackTool({ httpRequest }); diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 0b7339e3..26973212 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. // INSERT NEW TOOL IMPORT HERE +import { ElevationTool } from './elevation-tool/ElevationTool.js'; import { FeedbackTool } from './feedback-tool/FeedbackTool.js'; import { GroundLocationTool } from './ground-location-tool/GroundLocationTool.js'; import { PointsWithinPolygonTool } from './points-within-polygon-tool/PointsWithinPolygonTool.js'; @@ -42,6 +43,7 @@ import { httpRequest } from '../utils/httpPipeline.js'; */ export const CORE_TOOLS = [ // INSERT NEW TOOL INSTANCE HERE + new ElevationTool({ httpRequest }), new FeedbackTool({ httpRequest }), new GroundLocationTool({ httpRequest }), new PointsWithinPolygonTool(), diff --git a/test/tools/elevation-tool/ElevationTool.test.ts b/test/tools/elevation-tool/ElevationTool.test.ts new file mode 100644 index 00000000..1101653b --- /dev/null +++ b/test/tools/elevation-tool/ElevationTool.test.ts @@ -0,0 +1,308 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + setupHttpRequest, + assertHeadersSent +} from '../../utils/httpPipelineUtils.js'; +import { ElevationTool } from '../../../src/tools/elevation-tool/ElevationTool.js'; +import { + CLIMB_THRESHOLD_M, + MAX_TILES, + chooseZoom, + climbAndDescent, + samplePath, + type LngLat +} from '../../../src/tools/elevation-tool/terrain.js'; +import { buildSelfFetchRef } from '../../../src/utils/selfFetchRef.js'; +import { resolveInlinePayloadRef } from '../../../src/utils/inlinePayloadRef.js'; +import { terrainTile } from './pngFixtures.js'; + +// Zoom 14 tile 2620/6332 covers part of downtown San Francisco. +const TILE = { z: 14, x: 2620, y: 6332 }; +const tileLng = (x: number) => (x / 2 ** TILE.z) * 360 - 180; +const tileLat = (y: number) => { + const n = Math.PI - (2 * Math.PI * y) / 2 ** TILE.z; + return (180 / Math.PI) * Math.atan(Math.sinh(n)); +}; +const WEST = tileLng(TILE.x); +const EAST = tileLng(TILE.x + 1); +const MID_LAT = (tileLat(TILE.y) + tileLat(TILE.y + 1)) / 2; +// Just inside the tile's west and east edges. +const INSET = (EAST - WEST) / 100; + +function tileResponse(png: Buffer) { + return { + ok: true, + status: 200, + statusText: 'OK', + arrayBuffer: async () => + png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength) + }; +} + +/** Every tile request gets the same tile. */ +function setupTiles(height: (x: number, y: number) => number) { + const png = terrainTile(height); + const setup = setupHttpRequest(); + setup.mockHttpRequest.mockImplementation(async () => tileResponse(png)); + return setup; +} + +function textOf(result: { content: Array<{ type: string; text?: string }> }) { + return result.content[0].text as string; +} + +describe('ElevationTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('reads the height at each point from the terrain tile', async () => { + const { httpRequest, mockHttpRequest } = setupTiles(() => 123.4); + + const result = await new ElevationTool({ httpRequest }).run({ + points: [ + { longitude: WEST + INSET, latitude: MID_LAT }, + { longitude: EAST - INSET, latitude: MID_LAT } + ] + }); + + expect(result.isError).toBe(false); + assertHeadersSent(mockHttpRequest); + const url = new URL(mockHttpRequest.mock.calls[0][0]); + expect(url.pathname).toBe( + `/v4/mapbox.mapbox-terrain-dem-v1/14/${TILE.x}/${TILE.y}.pngraw` + ); + expect(url.searchParams.get('access_token')).toBe( + process.env.MAPBOX_ACCESS_TOKEN + ); + const sc = result.structuredContent as { + points: Array<{ elevation_m: number }>; + mapboxRender: { ref: string }; + }; + expect(sc.points.map((p) => p.elevation_m)).toEqual([123.4, 123.4]); + expect(textOf(result)).toContain('1. 123 m'); + expect(textOf(result)).toContain(sc.mapboxRender.ref); + expect(resolveInlinePayloadRef(sc.mapboxRender.ref)?.markers).toHaveLength( + 2 + ); + }); + + it('interpolates between pixels', async () => { + // Height = column, so a point's height tracks how far east it is. + const { httpRequest } = setupTiles((x) => x); + const result = await new ElevationTool({ httpRequest }).run({ + points: [{ longitude: (WEST + EAST) / 2, latitude: MID_LAT }] + }); + const sc = result.structuredContent as { + points: Array<{ elevation_m: number }>; + }; + // The tile's middle is between pixel centers 127 and 128. + expect(sc.points[0].elevation_m).toBeCloseTo(127.5, 1); + }); + + it('measures climb, descent and the high and low points along a path', async () => { + const { httpRequest, mockHttpRequest } = setupTiles((x) => x); + + const result = await new ElevationTool({ httpRequest }).run({ + path: [ + { longitude: WEST + INSET, latitude: MID_LAT }, + { longitude: EAST - INSET, latitude: MID_LAT } + ] + }); + + expect(result.isError).toBe(false); + // One tile, fetched once however many samples fall in it. + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + const path = ( + result.structuredContent as { + zoom: number; + path: { + climb_m: number; + descent_m: number; + sample_count: number; + start: { elevation_m: number }; + highest: { elevation_m: number; longitude: number }; + lowest: { elevation_m: number }; + profile: unknown[]; + }; + } + ).path; + expect(path.start.elevation_m).toBeCloseTo(2, 0); + expect(path.highest.elevation_m).toBeCloseTo(253, 0); + expect(path.highest.longitude).toBeCloseTo(EAST - INSET, 6); + expect(path.climb_m).toBeGreaterThan(245); + expect(path.descent_m).toBe(0); + expect(path.sample_count).toBeGreaterThan(100); + expect(path.profile.length).toBeLessThanOrEqual(51); + + const ref = (result.structuredContent as { mapboxRender: { ref: string } }) + .mapboxRender.ref; + const payload = resolveInlinePayloadRef(ref); + expect(payload?.layers[0]).toMatchObject({ id: 'elevation-path' }); + expect(payload?.markers?.map((m) => m.label)).toEqual(['▲', '▼']); + expect(payload?.summary).toContain('Climb'); + }); + + it('fetches the route for a directions ref and samples it', async () => { + const png = terrainTile(() => 50); + const route = [ + [WEST + INSET, MID_LAT], + [EAST - INSET, MID_LAT] + ]; + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockImplementation(async (url: string) => + url.includes('/directions/v5/') + ? { + ok: true, + status: 200, + json: async () => ({ + routes: [{ geometry: { coordinates: route } }] + }) + } + : tileResponse(png) + ); + const ref = buildSelfFetchRef('directions', { + coordinates: [ + { longitude: route[0][0], latitude: route[0][1] }, + { longitude: route[1][0], latitude: route[1][1] } + ], + routing_profile: 'mapbox/cycling' + }); + + const result = await new ElevationTool({ httpRequest }).run({ + directions_ref: ref + }); + + expect(result.isError).toBe(false); + const directionsUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(directionsUrl).toContain('/directions/v5/mapbox/cycling/'); + expect(directionsUrl).toContain('geometries=geojson'); + const sc = result.structuredContent as { + path: { climb_m: number; distance_m: number }; + mapboxRender: { ref: string }; + }; + expect(sc.path.climb_m).toBe(0); + expect(sc.path.distance_m).toBeGreaterThan(1000); + // The route itself isn't copied into the elevation map; the text says + // to pass the directions ref alongside. + expect(resolveInlinePayloadRef(sc.mapboxRender.ref)?.layers).toEqual([]); + expect(textOf(result)).toContain('pass the directions ref alongside'); + }); + + it('rejects a ref that is not from directions_tool', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const result = await new ElevationTool({ httpRequest }).run({ + directions_ref: buildSelfFetchRef('isochrone', { + coordinates: { longitude: 0, latitude: 0 }, + contours_minutes: [5] + }) + }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('not a directions_tool ref'); + expect(mockHttpRequest).not.toHaveBeenCalled(); + }); + + it.each([ + [{}], + [ + { + points: [{ longitude: 0, latitude: 0 }], + path: [ + { longitude: 0, latitude: 0 }, + { longitude: 1, latitude: 1 } + ] + } + ] + ])('needs exactly one of points, path or directions_ref', async (input) => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const result = await new ElevationTool({ httpRequest }).run(input); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('exactly one'); + expect(mockHttpRequest).not.toHaveBeenCalled(); + }); + + it('explains a refused token', async () => { + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 401, + statusText: 'Unauthorized' + }); + const result = await new ElevationTool({ httpRequest }).run({ + points: [{ longitude: WEST + INSET, latitude: MID_LAT }] + }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('refused the access token (401)'); + }); + + it('never prints -0', async () => { + const { httpRequest } = setupTiles(() => -0.04); + const result = await new ElevationTool({ httpRequest }).run({ + points: [{ longitude: WEST + INSET, latitude: MID_LAT }] + }); + expect(textOf(result)).toContain('0 m at'); + expect(textOf(result)).not.toContain('-0'); + }); +}); + +describe('terrain sampling', () => { + it('samples a path at the given spacing and keeps its end', () => { + const line: LngLat[] = [ + [0, 0], + [0.01, 0] + ]; + const samples = samplePath(line, 100); + // About 1112 m long: samples at 0, 100, ..., 1100, then the end. + expect(samples).toHaveLength(13); + expect(samples[1].distance).toBe(100); + expect(samples[samples.length - 1].point).toEqual([0.01, 0]); + expect(samples[samples.length - 1].distance).toBeCloseTo(1112, 0); + }); + + it('drops the zoom for long paths so a call stays a few dozen tiles', () => { + const short: LngLat[] = [ + [-122.42, 37.77], + [-122.41, 37.78] + ]; + expect(chooseZoom(short)).toBe(14); + + // San Francisco to Los Angeles, sampled every few km. + const long: LngLat[] = Array.from({ length: 200 }, (_, i) => [ + -122.42 + (4.18 * i) / 199, + 37.77 - (3.72 * i) / 199 + ]); + const zoom = chooseZoom(long); + expect(zoom).toBeLessThan(14); + const n = 2 ** zoom; + const tiles = new Set( + long.map(([lng, lat]) => { + const rad = (lat * Math.PI) / 180; + const x = Math.floor(((lng + 180) / 360) * n); + const y = Math.floor( + ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n + ); + return `${x}/${y}`; + }) + ); + expect(tiles.size).toBeLessThanOrEqual(MAX_TILES); + }); + + it('ignores swings smaller than the threshold', () => { + const small = CLIMB_THRESHOLD_M / 2; + // Flat ground with DEM noise: no climb at all. + expect(climbAndDescent([10, 10 + small, 10, 10 + small, 10])).toEqual({ + climb: 0, + descent: 0 + }); + // A real hill counts in full. + expect(climbAndDescent([10, 20, 30, 25, 15])).toEqual({ + climb: 20, + descent: 15 + }); + }); +}); diff --git a/test/tools/elevation-tool/decodePng.test.ts b/test/tools/elevation-tool/decodePng.test.ts new file mode 100644 index 00000000..f95ed6a9 --- /dev/null +++ b/test/tools/elevation-tool/decodePng.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { decodePng } from '../../../src/tools/elevation-tool/decodePng.js'; +import { encodePng } from './pngFixtures.js'; + +function noise(length: number, seed: number): Uint8Array { + const out = new Uint8Array(length); + let s = seed; + for (let i = 0; i < length; i++) { + s = (s * 1103515245 + 12345) & 0x7fffffff; + out[i] = s & 0xff; + } + return out; +} + +describe('decodePng', () => { + it.each([ + [4 as const, 'RGBA'], + [3 as const, 'RGB'] + ])('undoes every row filter (%s channels, %s)', (channels) => { + const pixels = noise(9 * 10 * channels, channels); + const png = encodePng(9, 10, channels, pixels, { + filters: [0, 1, 2, 3, 4] + }); + const decoded = decodePng(png); + expect(decoded).toMatchObject({ width: 9, height: 10, channels }); + expect(Buffer.from(decoded.pixels)).toEqual(Buffer.from(pixels)); + }); + + it('rejects data that is not a PNG', () => { + expect(() => decodePng(Buffer.from('challenge'))).toThrow( + 'Not a PNG' + ); + }); + + it('rejects 16-bit and palette images', () => { + const pixels = new Uint8Array(2 * 2 * 3); + expect(() => + decodePng(encodePng(2, 2, 3, pixels, { bitDepth: 16 })) + ).toThrow('Unsupported PNG'); + expect(() => + decodePng(encodePng(2, 2, 3, pixels, { colorType: 3 })) + ).toThrow('Unsupported PNG'); + }); + + it('rejects a truncated image', () => { + const png = encodePng(4, 4, 4, noise(64, 7)); + expect(() => decodePng(png.subarray(0, png.length - 20))).toThrow(); + }); +}); diff --git a/test/tools/elevation-tool/pngFixtures.ts b/test/tools/elevation-tool/pngFixtures.ts new file mode 100644 index 00000000..ddec3cac --- /dev/null +++ b/test/tools/elevation-tool/pngFixtures.ts @@ -0,0 +1,83 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { deflateSync } from 'node:zlib'; + +function chunk(type: string, data: Buffer): Buffer { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, 'latin1'); + // The decoder doesn't check CRCs, so a zero one is enough here. + return Buffer.concat([head, data, Buffer.alloc(4)]); +} + +/** + * Encodes raw 8-bit pixels as a PNG, filtering row `y` with + * `filters[y % filters.length]` (0 none, 1 sub, 2 up, 3 average, 4 Paeth) + * so tests cover every filter the decoder undoes. + */ +export function encodePng( + width: number, + height: number, + channels: 3 | 4, + pixels: Uint8Array, + options: { filters?: number[]; bitDepth?: number; colorType?: number } = {} +): Buffer { + const filters = options.filters ?? [0]; + const stride = width * channels; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y++) { + const filter = filters[y % filters.length]; + raw[y * (stride + 1)] = filter; + for (let i = 0; i < stride; i++) { + const cur = pixels[y * stride + i]; + const left = i >= channels ? pixels[y * stride + i - channels] : 0; + const up = y > 0 ? pixels[(y - 1) * stride + i] : 0; + const upLeft = + y > 0 && i >= channels ? pixels[(y - 1) * stride + i - channels] : 0; + let predictor = 0; + if (filter === 1) predictor = left; + else if (filter === 2) predictor = up; + else if (filter === 3) predictor = (left + up) >> 1; + else if (filter === 4) { + const p = left + up - upLeft; + const pa = Math.abs(p - left); + const pb = Math.abs(p - up); + const pc = Math.abs(p - upLeft); + predictor = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft; + } + raw[y * (stride + 1) + 1 + i] = (cur - predictor) & 0xff; + } + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = options.bitDepth ?? 8; + ihdr[9] = options.colorType ?? (channels === 4 ? 6 : 2); + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw)), + chunk('IEND', Buffer.alloc(0)) + ]); +} + +/** + * A 256 px terrain-RGB tile whose height at pixel (x, y) is + * `height(x, y)` meters, filtered with every PNG filter in turn. + */ +export function terrainTile(height: (x: number, y: number) => number): Buffer { + const size = 256; + const pixels = new Uint8Array(size * size * 4); + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const v = Math.round((height(x, y) + 10000) * 10); + const o = (y * size + x) * 4; + pixels[o] = (v >> 16) & 0xff; + pixels[o + 1] = (v >> 8) & 0xff; + pixels[o + 2] = v & 0xff; + pixels[o + 3] = 255; + } + } + return encodePng(size, size, 4, pixels, { filters: [0, 1, 2, 3, 4] }); +} From 42f03b67ca1c7cd4b136c61bedd4c3155b881968 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 20 Oct 2026 19:57:17 -0400 Subject: [PATCH 2/5] docs: CHANGELOG PR number for elevation_tool Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 131f4e5f..c1738bae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### New Features -- **`elevation_tool`: ground elevation at places, and climb and descent along a path or route (#289).** The server had no elevation at all, so "how hilly is this bike route?" or "how high is this town?" had no answer from Mapbox data. Part 4 of #289; the general `describe_tileset` and `tilequery` tools wait on the open questions there. +- **`elevation_tool`: ground elevation at places, and climb and descent along a path or route (#293).** The server had no elevation at all, so "how hilly is this bike route?" or "how high is this town?" had no answer from Mapbox data. Part 4 of #289; the general `describe_tileset` and `tilequery` tools wait on the open questions there. - **Inputs:** up to 25 `points`, a `path` of up to 1,000 coordinates, or the `directions_ref` from a `directions_tool` result, which the tool turns back into the full route with `directionsRequestFromRef` (#276), so the model never copies route geometry. - **Data:** reads `mapbox.mapbox-terrain-dem-v1` raster tiles (`.pngraw`, terrain-RGB) and decodes each pixel's height, with bilinear interpolation between pixels. Tilequery can't read the DEM (it returns 422), and its Terrain v2 contours come in 10 m steps or coarser: 240 m near Twin Peaks against 249.5 m from the DEM, 10 m at Civic Center against 20.9 m. Longs Peak reads 4,346 m, its surveyed height. A small PNG decoder on Node's `zlib` handles the 8-bit RGB/RGBA tiles, so there's no new dependency. - **Cost:** a path is sampled at up to 500 points, never closer than one DEM pixel. Samples share tiles, each tile is fetched once per call, and the zoom drops on long paths until the path needs at most 48 tiles. The result reports `zoom` and `resolution_m`. Ferry Building to Twin Peaks by bike: 1 tile request at 7.5 m resolution, 274 m of climb. San Francisco to Los Angeles: 1.3 s at about 120 m resolution, highest point 1,276 m near Tejon Pass. From abc37e4b858f754168d1cf0e3b135f7063217f8f Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Fri, 6 Nov 2026 12:06:05 -0500 Subject: [PATCH 3/5] fix: elevation_tool on MCP SDK v2 types Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 ++++++++++ src/tools/elevation-tool/ElevationTool.ts | 2 +- test/tools/elevation-tool/decodePng.test.ts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 384b0b96..ba86390f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ ## Unreleased +### New Features + +- **`elevation_tool`: ground elevation at places, and climb and descent along a path or route (#293).** The server had no elevation at all, so "how hilly is this bike route?" or "how high is this town?" had no answer from Mapbox data. Part 4 of #289. A general `tilequery` tool stays in mcp-devkit-server, since raw tileset layers are a developer tool. + - **Inputs:** up to 25 `points`, a `path` of up to 1,000 coordinates, or the `directions_ref` from a `directions_tool` result, which the tool turns back into the full route with `directionsRequestFromRef` (#276), so the model never copies route geometry. + - **Data:** reads `mapbox.mapbox-terrain-dem-v1` raster tiles (`.pngraw`, terrain-RGB) and decodes each pixel's height, with bilinear interpolation between pixels. Tilequery can't read the DEM (it returns 422), and its Terrain v2 contours come in 10 m steps or coarser: 240 m near Twin Peaks against 249.5 m from the DEM, 10 m at Civic Center against 20.9 m. Longs Peak reads 4,346 m, its surveyed height. A small PNG decoder on Node's `zlib` handles the 8-bit RGB/RGBA tiles, so there's no new dependency. + - **Cost:** a path is sampled at up to 500 points, never closer than one DEM pixel. Samples share tiles, each tile is fetched once per call, and the zoom drops on long paths until the path needs at most 48 tiles. The result reports `zoom` and `resolution_m`. Ferry Building to Twin Peaks by bike: 1 tile request at 7.5 m resolution, 274 m of climb. San Francisco to Los Angeles: 1.3 s at about 120 m resolution, highest point 1,276 m near Tejon Pass. + - **Climb:** swings under 2 m don't count, so DEM noise on flat ground doesn't add up to phantom hills. + - **Result:** elevations, or distance, climb, descent, start, end, highest and lowest points and a profile of up to 50 points for a chart (12 in the text), plus a map ref with the line and ▲/▼ markers at the high and low points. For a directions ref, the text says to pass the route's ref alongside, rather than copying the route into this one. + - **Testing:** a PNG encoder in the tests covers every row filter, RGB and RGBA, and bad input (not a PNG, 16-bit, palette, truncated). Synthetic terrain tiles test point heights, interpolation, climb along a path, a directions ref, the zoom choice for a long route, the noise threshold, a refused token, and that `-0 m` never prints. + ## 0.17.0 - 2026-11-06 ## 0.17.0-dev.3 - 2026-11-05 diff --git a/src/tools/elevation-tool/ElevationTool.ts b/src/tools/elevation-tool/ElevationTool.ts index eb039633..c3b7fb5c 100644 --- a/src/tools/elevation-tool/ElevationTool.ts +++ b/src/tools/elevation-tool/ElevationTool.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import type { z } from 'zod'; -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult } from '@modelcontextprotocol/server'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { HttpRequest } from '../../utils/types.js'; import type { MapAppMarker, MapAppPayload } from '../../utils/mapAppPayload.js'; diff --git a/test/tools/elevation-tool/decodePng.test.ts b/test/tools/elevation-tool/decodePng.test.ts index f95ed6a9..385a77d6 100644 --- a/test/tools/elevation-tool/decodePng.test.ts +++ b/test/tools/elevation-tool/decodePng.test.ts @@ -19,7 +19,7 @@ describe('decodePng', () => { it.each([ [4 as const, 'RGBA'], [3 as const, 'RGB'] - ])('undoes every row filter (%s channels, %s)', (channels) => { + ])('undoes every row filter (%s channels, %s)', (channels, _label) => { const pixels = noise(9 * 10 * channels, channels); const png = encodePng(9, 10, channels, pixels, { filters: [0, 1, 2, 3, 4] From f937ab77364d64ea067eaa062d6a8231f7526793 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Fri, 6 Nov 2026 12:19:53 -0500 Subject: [PATCH 4/5] feat(elevation_tool): read map_matching_tool refs for recorded runs directions_ref becomes route_ref, which also takes a map_matching_tool ref, so "how much did I climb on this run?" works from a GPS trace as well as a planned route. Matched pieces of a trace with gaps are joined in order. mapMatchingRequestFromRef is exported next to directionsRequestFromRef, and only accepts map_matching_tool's own profiles since the profile goes into the URL path. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 2 +- .../ElevationTool.input.schema.ts | 8 +- src/tools/elevation-tool/ElevationTool.ts | 51 ++++++++-- src/utils/index.ts | 2 + src/utils/mapMatchingRef.ts | 60 ++++++++++++ .../elevation-tool/ElevationTool.test.ts | 97 +++++++++++++++++-- test/utils/mapMatchingRef.test.ts | 56 +++++++++++ 8 files changed, 255 insertions(+), 23 deletions(-) create mode 100644 src/utils/mapMatchingRef.ts create mode 100644 test/utils/mapMatchingRef.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ba86390f..bdc193f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ### New Features - **`elevation_tool`: ground elevation at places, and climb and descent along a path or route (#293).** The server had no elevation at all, so "how hilly is this bike route?" or "how high is this town?" had no answer from Mapbox data. Part 4 of #289. A general `tilequery` tool stays in mcp-devkit-server, since raw tileset layers are a developer tool. - - **Inputs:** up to 25 `points`, a `path` of up to 1,000 coordinates, or the `directions_ref` from a `directions_tool` result, which the tool turns back into the full route with `directionsRequestFromRef` (#276), so the model never copies route geometry. + - **Inputs:** up to 25 `points`, a `path` of up to 1,000 coordinates, or the `route_ref` from a `directions_tool` or `map_matching_tool` result, which the tool turns back into the full line with `directionsRequestFromRef` (#276) or the new `mapMatchingRequestFromRef`, so a planned trail run or a recorded one both work, and the model never copies route geometry. - **Data:** reads `mapbox.mapbox-terrain-dem-v1` raster tiles (`.pngraw`, terrain-RGB) and decodes each pixel's height, with bilinear interpolation between pixels. Tilequery can't read the DEM (it returns 422), and its Terrain v2 contours come in 10 m steps or coarser: 240 m near Twin Peaks against 249.5 m from the DEM, 10 m at Civic Center against 20.9 m. Longs Peak reads 4,346 m, its surveyed height. A small PNG decoder on Node's `zlib` handles the 8-bit RGB/RGBA tiles, so there's no new dependency. - **Cost:** a path is sampled at up to 500 points, never closer than one DEM pixel. Samples share tiles, each tile is fetched once per call, and the zoom drops on long paths until the path needs at most 48 tiles. The result reports `zoom` and `resolution_m`. Ferry Building to Twin Peaks by bike: 1 tile request at 7.5 m resolution, 274 m of climb. San Francisco to Los Angeles: 1.3 s at about 120 m resolution, highest point 1,276 m near Tejon Pass. - **Climb:** swings under 2 m don't count, so DEM noise on flat ground doesn't add up to phantom hills. diff --git a/README.md b/README.md index 4d556520..2987ba7b 100644 --- a/README.md +++ b/README.md @@ -525,7 +525,7 @@ Gets ground elevation from Mapbox's terrain data ([Mapbox Terrain-DEM v1](https: - The highest and lowest points, and how far along the line they are - An elevation profile of up to 50 points -The line can be a `path` of coordinates, or the `directions_ref` from a `directions_tool` result, which the tool turns back into the full route itself. The server reads the terrain tiles and decodes each pixel's height, so values aren't rounded to contour steps the way Tilequery's Terrain v2 contours are. Points along a line share tiles, and long lines are read at a coarser zoom so a call stays under 50 tile requests; `resolution_m` in the result says how coarse. Swings under 2 m don't count toward climb or descent, so DEM noise on flat ground doesn't add up. Heights are of the ground, not of buildings or bridges. The result includes a map ref with the line and its high and low points. +The line can be a `path` of coordinates, or the `route_ref` from a `directions_tool` or `map_matching_tool` result, which the tool turns back into the full route itself. The server reads the terrain tiles and decodes each pixel's height, so values aren't rounded to contour steps the way Tilequery's Terrain v2 contours are. Points along a line share tiles, and long lines are read at a coarser zoom so a call stays under 50 tile requests; `resolution_m` in the result says how coarse. Swings under 2 m don't count toward climb or descent, so DEM noise on flat ground doesn't add up. Heights are of the ground, not of buildings or bridges. The result includes a map ref with the line and its high and low points. **Example Usage**: "How hilly is the bike route from the Ferry Building to Twin Peaks?" or "How high is Longs Peak?" diff --git a/src/tools/elevation-tool/ElevationTool.input.schema.ts b/src/tools/elevation-tool/ElevationTool.input.schema.ts index 3356a14b..fcc13e19 100644 --- a/src/tools/elevation-tool/ElevationTool.input.schema.ts +++ b/src/tools/elevation-tool/ElevationTool.input.schema.ts @@ -10,7 +10,7 @@ const coordinateSchema = z.object({ /** * Input schema for ElevationTool. Exactly one of `points`, `path` or - * `directions_ref` is required; the tool checks that itself so the + * `route_ref` is required; the tool checks that itself so the * published JSON schema stays a plain object. */ export const ElevationInputSchema = z.object({ @@ -28,13 +28,13 @@ export const ElevationInputSchema = z.object({ .max(1000) .optional() .describe( - 'A line (hike, ride, run) as ordered coordinates, for climb, descent and an elevation profile along it. For a route from directions_tool, pass directions_ref instead.' + 'A line (hike, ride, run) as ordered coordinates, for climb, descent and an elevation profile along it. For a route from directions_tool or a GPS trace matched with map_matching_tool, pass route_ref instead.' ), - directions_ref: z + route_ref: z .string() .optional() .describe( - 'The structuredContent.mapboxRender.ref from a directions_tool result (mapbox://selffetch/directions?...), passed unchanged. The tool fetches the full route itself, so you never need to copy route geometry.' + 'The structuredContent.mapboxRender.ref from a directions_tool result (a planned route) or a map_matching_tool result (a recorded run, ride or hike), passed unchanged. The tool fetches the full line itself, so you never need to copy route geometry.' ) }); diff --git a/src/tools/elevation-tool/ElevationTool.ts b/src/tools/elevation-tool/ElevationTool.ts index c3b7fb5c..2a8a031d 100644 --- a/src/tools/elevation-tool/ElevationTool.ts +++ b/src/tools/elevation-tool/ElevationTool.ts @@ -9,6 +9,8 @@ import type { MapAppMarker, MapAppPayload } from '../../utils/mapAppPayload.js'; import { buildInlinePayloadRef } from '../../utils/inlinePayloadRef.js'; import { renderHint } from '../../utils/storeMapPayload.js'; import { directionsRequestFromRef } from '../../utils/directionsRef.js'; +import { mapMatchingRequestFromRef } from '../../utils/mapMatchingRef.js'; +import { resolveSelfFetchRef } from '../../utils/selfFetchRef.js'; import { ElevationInputSchema } from './ElevationTool.input.schema.js'; import { ElevationOutputSchema, @@ -27,6 +29,9 @@ import { type LngLat } from './terrain.js'; +const NOT_A_ROUTE_REF = + 'route_ref is not a directions_tool or map_matching_tool ref. Pass the structuredContent.mapboxRender.ref from one of those results unchanged, or pass the line as path.'; + /** Most points sampled along a path; long paths get wider spacing. */ const MAX_SAMPLES = 500; const PROFILE_POINTS = 50; @@ -63,8 +68,8 @@ export class ElevationTool extends MapboxApiBasedTool< name = 'elevation_tool'; description = 'Gets ground elevation from Mapbox terrain data. Give it places (`points`) for the height above sea level of each, ' + - 'or a line (`path`, or `directions_ref` from a directions_tool result) for total climb and descent, the highest and lowest points, and an elevation profile. ' + - 'Use it for questions like "how high is this town?", "how hilly is this bike route?" or "how much climbing is on this hike?". ' + + 'or a line (`path`, or `route_ref` from a directions_tool or map_matching_tool result) for total climb and descent, the highest and lowest points, and an elevation profile. ' + + 'Use it for questions like "how high is this town?", "how hilly is this bike route?", "how much climbing is on this trail run?" or, for a recorded GPS trace matched with map_matching_tool, "how much did I climb?". ' + 'Elevations are of the ground (bare earth), not buildings or bridges. ' + 'Long paths are read at a coarser resolution (see `resolution_m` in the result), which can undercount small hills.'; annotations = { @@ -87,11 +92,11 @@ export class ElevationTool extends MapboxApiBasedTool< input: z.infer, accessToken: string ): Promise { - const given = [input.points, input.path, input.directions_ref].filter( + const given = [input.points, input.path, input.route_ref].filter( (v) => v !== undefined ).length; if (given !== 1) { - return this.error('Pass exactly one of points, path or directions_ref.'); + return this.error('Pass exactly one of points, path or route_ref.'); } const apiEndpoint = MapboxApiBasedTool.mapboxApiEndpoint; @@ -113,7 +118,7 @@ export class ElevationTool extends MapboxApiBasedTool< drawLine = true; } else { const route = await this.fetchRoute( - input.directions_ref as string, + input.route_ref as string, accessToken, apiEndpoint ); @@ -137,16 +142,42 @@ export class ElevationTool extends MapboxApiBasedTool< return { content: [{ type: 'text', text }], isError: true }; } - /** The route's coordinates, or an error message for the model. */ + /** + * The line behind a directions_tool or map_matching_tool ref, or an error + * message for the model. A trace with gaps matches in several pieces; + * they're joined in order. + */ private async fetchRoute( ref: string, accessToken: string, apiEndpoint: string ): Promise { - const request = directionsRequestFromRef(ref, { accessToken, apiEndpoint }); - if (!request) { - return 'directions_ref is not a directions_tool ref. Pass the structuredContent.mapboxRender.ref from a directions_tool result unchanged, or pass the line as path.'; + const options = { accessToken, apiEndpoint }; + const tool = resolveSelfFetchRef(ref)?.selfFetch?.[0]?.tool; + if (tool === 'map_matching') { + const url = mapMatchingRequestFromRef(ref, options); + if (!url) return NOT_A_ROUTE_REF; + const response = await this.httpRequest(url); + if (!response.ok) { + return `Map Matching API error: ${await this.getErrorMessage(response)}`; + } + const data = (await response.json()) as { + code?: string; + matchings?: Array<{ geometry?: { coordinates?: unknown } }>; + }; + const coords = (data.matchings ?? []).flatMap((m) => + Array.isArray(m.geometry?.coordinates) + ? (m.geometry.coordinates as LngLat[]) + : [] + ); + if (coords.length < 2) { + return `The Map Matching API matched no line for that ref (code: ${data.code ?? 'unknown'}).`; + } + return coords; } + + const request = directionsRequestFromRef(ref, options); + if (!request) return NOT_A_ROUTE_REF; const response = await this.httpRequest(request.url); if (!response.ok) { return `Directions API error: ${await this.getErrorMessage(response)}`; @@ -323,7 +354,7 @@ export class ElevationTool extends MapboxApiBasedTool< const hint = drawLine ? renderHint(ref) : renderHint(ref) + - '\nTo show the route too, pass the directions ref alongside it in payload_refs.'; + "\nTo show the route too, pass the route's ref alongside it in payload_refs."; return { content: [{ type: 'text', text: text + hint }], structuredContent: out, diff --git a/src/utils/index.ts b/src/utils/index.ts index 3a28b6e8..4cdab01d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -69,6 +69,8 @@ export { buildServerInstructions } from './serverInstructions.js'; // Directions API URL for the full route. export { directionsRequestFromRef } from './directionsRef.js'; export type { DirectionsRefRequest } from './directionsRef.js'; +// mapMatchingRequestFromRef does the same for a map_matching_tool ref. +export { mapMatchingRequestFromRef } from './mapMatchingRef.js'; export { resolveSelfFetchRef, isSelfFetchRef } from './selfFetchRef.js'; // The Directions URL builder directions_tool and the map preview use, for // apps that build their own request from a ref's params. diff --git a/src/utils/mapMatchingRef.ts b/src/utils/mapMatchingRef.ts new file mode 100644 index 00000000..1d877521 --- /dev/null +++ b/src/utils/mapMatchingRef.ts @@ -0,0 +1,60 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { buildMapMatchingRequestUrl } from '../tools/map-matching-tool/buildMapMatchingRequestUrl.js'; +import { resolveSelfFetchRef } from './selfFetchRef.js'; + +// The profile goes into the URL path, so only map_matching_tool's own +// values are accepted from a ref. +const PROFILES = new Set(['driving', 'driving-traffic', 'walking', 'cycling']); + +/** + * Turns the `structuredContent.mapboxRender.ref` from a `map_matching_tool` + * result back into a Map Matching API URL for the full matched line + * (`geometries=geojson`, `overview=full`), the same way + * `directionsRequestFromRef` does for directions. The response's + * `matchings` hold the line; a trace with gaps matches in several pieces. + * + * Returns null for anything that isn't a valid map matching ref. + */ +export function mapMatchingRequestFromRef( + ref: string, + options: { accessToken: string; apiEndpoint?: string } +): string | null { + const selfFetch = resolveSelfFetchRef(ref)?.selfFetch?.[0]; + if (!selfFetch || selfFetch.tool !== 'map_matching') return null; + const params = selfFetch.params as { + coordinates?: Array<{ longitude?: unknown; latitude?: unknown }>; + profile?: unknown; + timestamps?: number[]; + radiuses?: number[]; + }; + const coords = params.coordinates; + if ( + !Array.isArray(coords) || + coords.length < 2 || + !coords.every( + (c) => typeof c?.longitude === 'number' && typeof c?.latitude === 'number' + ) + ) { + return null; + } + const profile = params.profile ?? 'driving'; + if (typeof profile !== 'string' || !PROFILES.has(profile)) return null; + + return buildMapMatchingRequestUrl({ + input: { + coordinates: coords as Array<{ longitude: number; latitude: number }>, + profile, + timestamps: params.timestamps, + radiuses: params.radiuses, + geometries: 'geojson', + overview: 'full' + }, + accessToken: options.accessToken, + apiEndpoint: + options.apiEndpoint ?? + process.env.MAPBOX_API_ENDPOINT ?? + 'https://api.mapbox.com/' + }); +} diff --git a/test/tools/elevation-tool/ElevationTool.test.ts b/test/tools/elevation-tool/ElevationTool.test.ts index 1101653b..21096b29 100644 --- a/test/tools/elevation-tool/ElevationTool.test.ts +++ b/test/tools/elevation-tool/ElevationTool.test.ts @@ -176,7 +176,7 @@ describe('ElevationTool', () => { }); const result = await new ElevationTool({ httpRequest }).run({ - directions_ref: ref + route_ref: ref }); expect(result.isError).toBe(false); @@ -190,21 +190,104 @@ describe('ElevationTool', () => { expect(sc.path.climb_m).toBe(0); expect(sc.path.distance_m).toBeGreaterThan(1000); // The route itself isn't copied into the elevation map; the text says - // to pass the directions ref alongside. + // to pass the route's ref alongside. expect(resolveInlinePayloadRef(sc.mapboxRender.ref)?.layers).toEqual([]); - expect(textOf(result)).toContain('pass the directions ref alongside'); + expect(textOf(result)).toContain("pass the route's ref alongside"); }); - it('rejects a ref that is not from directions_tool', async () => { + it('reads the line behind a map_matching_tool ref, joining matched pieces', async () => { + // A recorded run that matched in two pieces (a gap in the trace). + const pieces: LngLat[][] = [ + [ + [WEST + INSET, MID_LAT], + [WEST + 3 * INSET, MID_LAT] + ], + [ + [WEST + 50 * INSET, MID_LAT], + [EAST - INSET, MID_LAT] + ] + ]; + const png = terrainTile((x) => x); const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockImplementation(async (url: string) => + url.includes('/matching/v5/') + ? { + ok: true, + status: 200, + json: async () => ({ + code: 'Ok', + matchings: pieces.map((coordinates) => ({ + geometry: { type: 'LineString', coordinates } + })) + }) + } + : tileResponse(png) + ); + const ref = buildSelfFetchRef('map_matching', { + coordinates: [pieces[0][0], pieces[1][1]].map( + ([longitude, latitude]) => ({ + longitude, + latitude + }) + ), + profile: 'walking', + timestamps: [1700000000, 1700000900] + }); + const result = await new ElevationTool({ httpRequest }).run({ - directions_ref: buildSelfFetchRef('isochrone', { + route_ref: ref + }); + + expect(result.isError).toBe(false); + const matchingUrl = new URL(mockHttpRequest.mock.calls[0][0] as string); + expect(matchingUrl.pathname).toContain('/matching/v5/mapbox/walking/'); + expect(matchingUrl.searchParams.get('geometries')).toBe('geojson'); + expect(matchingUrl.searchParams.get('overview')).toBe('full'); + expect(matchingUrl.searchParams.get('timestamps')).toBe( + '1700000000;1700000900' + ); + const sc = result.structuredContent as { + path: { distance_m: number; climb_m: number; end: { longitude: number } }; + }; + // Both pieces are read, so the line ends at the second piece's end. + expect(sc.path.end.longitude).toBeCloseTo(EAST - INSET, 6); + expect(sc.path.climb_m).toBeGreaterThan(0); + expect(textOf(result)).toContain("pass the route's ref alongside"); + }); + + it('reports a map matching ref that matched nothing', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + mockHttpRequest.mockImplementation(async () => ({ + ok: true, + status: 200, + json: async () => ({ code: 'NoMatch' }) + })); + const result = await new ElevationTool({ httpRequest }).run({ + route_ref: buildSelfFetchRef('map_matching', { + coordinates: [ + { longitude: 0, latitude: 0 }, + { longitude: 0.01, latitude: 0.01 } + ], + profile: 'walking' + }) + }); + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('matched no line'); + expect(textOf(result)).toContain('NoMatch'); + }); + + it('rejects a ref that is not a route', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest(); + const result = await new ElevationTool({ httpRequest }).run({ + route_ref: buildSelfFetchRef('isochrone', { coordinates: { longitude: 0, latitude: 0 }, contours_minutes: [5] }) }); expect(result.isError).toBe(true); - expect(textOf(result)).toContain('not a directions_tool ref'); + expect(textOf(result)).toContain( + 'not a directions_tool or map_matching_tool ref' + ); expect(mockHttpRequest).not.toHaveBeenCalled(); }); @@ -219,7 +302,7 @@ describe('ElevationTool', () => { ] } ] - ])('needs exactly one of points, path or directions_ref', async (input) => { + ])('needs exactly one of points, path or route_ref', async (input) => { const { httpRequest, mockHttpRequest } = setupHttpRequest(); const result = await new ElevationTool({ httpRequest }).run(input); expect(result.isError).toBe(true); diff --git a/test/utils/mapMatchingRef.test.ts b/test/utils/mapMatchingRef.test.ts new file mode 100644 index 00000000..fccdcf6e --- /dev/null +++ b/test/utils/mapMatchingRef.test.ts @@ -0,0 +1,56 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { mapMatchingRequestFromRef } from '../../src/utils/mapMatchingRef.js'; +import { buildSelfFetchRef } from '../../src/utils/selfFetchRef.js'; + +const coordinates = [ + { longitude: -122.4862, latitude: 37.7694 }, + { longitude: -122.4755, latitude: 37.7701 } +]; +const options = { accessToken: 'pk.test', apiEndpoint: 'https://api.test/' }; + +describe('mapMatchingRequestFromRef', () => { + it('builds a full geojson Map Matching request from the ref', () => { + const url = new URL( + mapMatchingRequestFromRef( + buildSelfFetchRef('map_matching', { + coordinates, + profile: 'cycling', + radiuses: [25, 25] + }), + options + ) as string + ); + expect(url.origin + url.pathname).toBe( + 'https://api.test/matching/v5/mapbox/cycling/-122.4862,37.7694;-122.4755,37.7701' + ); + expect(url.searchParams.get('geometries')).toBe('geojson'); + expect(url.searchParams.get('overview')).toBe('full'); + expect(url.searchParams.get('radiuses')).toBe('25;25'); + }); + + it('defaults to driving, like map_matching_tool', () => { + const url = mapMatchingRequestFromRef( + buildSelfFetchRef('map_matching', { coordinates }), + options + ); + expect(url).toContain('/matching/v5/mapbox/driving/'); + }); + + it.each([ + ['a directions ref', buildSelfFetchRef('directions', { coordinates })], + [ + 'an unknown profile', + buildSelfFetchRef('map_matching', { coordinates, profile: '../x' }) + ], + [ + 'one coordinate', + buildSelfFetchRef('map_matching', { coordinates: [coordinates[0]] }) + ], + ['not a ref', 'mapbox://nope'] + ])('returns null for %s', (_label, ref) => { + expect(mapMatchingRequestFromRef(ref, options)).toBeNull(); + }); +}); From df967a4efa2260bc230571a7ae7b5e07864634e8 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 10 Nov 2026 11:13:53 -0500 Subject: [PATCH 5/5] feat(ground_location_tool): include land, water, elevation or traffic at the point (#324) * feat(ground_location_tool): land use, water and elevation at the point ground_location_tool now says what the ground is at the coordinate: land use from Mapbox Streets v8 (park, cemetery, school...), a protected area it's in (with the name, e.g. a national park), a building it's inside, whether it's on water and how far the nearest water is, plus elevation from Terrain-DEM via elevation_tool's tile reader. Two Tilequery requests and one DEM tile, in parallel with the existing calls; a failure leaves those fields out. Adds tool-selection eval cases for land use and elevation questions. Co-Authored-By: Claude Opus 5.5 * docs: CHANGELOG entry for #324 Co-Authored-By: Claude Opus 5.5 * feat(ground_location_tool): include picks land, water, elevation or traffic The spot facets are now chosen with include, so a call only pays for what the question needs (default: land, one Tilequery request), and a new layer is a new include value rather than a new tool. Adds traffic: live congestion per road class within 100 m from Mapbox Traffic v1. Water moves out of land into its own facet. Co-Authored-By: Claude Opus 5.5 * fix(ground_location_tool): address review feedback - A facet request that throws or returns bad JSON is left out instead of failing the whole call. - Wetlands from landuse_overlay are land use; only national parks are protected areas. - include defaults to none, so existing calls make no extra request. - Traffic distance_m points at the worst segment for each road class. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 14 +- evals/tool-selection/cases.json | 112 ++++++ evals/tool-selection/harness.ts | 1 + .../GroundLocationTool.input.schema.ts | 13 +- .../GroundLocationTool.output.schema.ts | 72 ++++ .../GroundLocationTool.ts | 195 ++++++++--- .../ground-location-tool/groundFacets.ts | 206 +++++++++++ .../GroundLocationTool.test.ts | 321 +++++++++++++++++- 8 files changed, 885 insertions(+), 49 deletions(-) create mode 100644 src/tools/ground-location-tool/groundFacets.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc193f5..caf217cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,13 @@ ### New Features -- **`elevation_tool`: ground elevation at places, and climb and descent along a path or route (#293).** The server had no elevation at all, so "how hilly is this bike route?" or "how high is this town?" had no answer from Mapbox data. Part 4 of #289. A general `tilequery` tool stays in mcp-devkit-server, since raw tileset layers are a developer tool. - - **Inputs:** up to 25 `points`, a `path` of up to 1,000 coordinates, or the `route_ref` from a `directions_tool` or `map_matching_tool` result, which the tool turns back into the full line with `directionsRequestFromRef` (#276) or the new `mapMatchingRequestFromRef`, so a planned trail run or a recorded one both work, and the model never copies route geometry. - - **Data:** reads `mapbox.mapbox-terrain-dem-v1` raster tiles (`.pngraw`, terrain-RGB) and decodes each pixel's height, with bilinear interpolation between pixels. Tilequery can't read the DEM (it returns 422), and its Terrain v2 contours come in 10 m steps or coarser: 240 m near Twin Peaks against 249.5 m from the DEM, 10 m at Civic Center against 20.9 m. Longs Peak reads 4,346 m, its surveyed height. A small PNG decoder on Node's `zlib` handles the 8-bit RGB/RGBA tiles, so there's no new dependency. - - **Cost:** a path is sampled at up to 500 points, never closer than one DEM pixel. Samples share tiles, each tile is fetched once per call, and the zoom drops on long paths until the path needs at most 48 tiles. The result reports `zoom` and `resolution_m`. Ferry Building to Twin Peaks by bike: 1 tile request at 7.5 m resolution, 274 m of climb. San Francisco to Los Angeles: 1.3 s at about 120 m resolution, highest point 1,276 m near Tejon Pass. - - **Climb:** swings under 2 m don't count, so DEM noise on flat ground doesn't add up to phantom hills. - - **Result:** elevations, or distance, climb, descent, start, end, highest and lowest points and a profile of up to 50 points for a chart (12 in the text), plus a map ref with the line and ▲/▼ markers at the high and low points. For a directions ref, the text says to pass the route's ref alongside, rather than copying the route into this one. - - **Testing:** a PNG encoder in the tests covers every row filter, RGB and RGBA, and bad input (not a PNG, 16-bit, palette, truncated). Synthetic terrain tiles test point heights, interpolation, climb along a path, a directions ref, the zoom choice for a long route, the noise threshold, a refused token, and that `-0 m` never prints. +- **`ground_location_tool`: land use, water, elevation and live traffic at the point, picked with `include` (#324).** "Is this spot in a park?", "is this on the water?" or "how bad is traffic at this address?" had no answer: the tool named the neighborhood and nearby POIs, but not what the spot itself is. Folded in here rather than new tools, so the model doesn't have more "what's here" tools to pick between, and new layers become new `include` values instead of new tools. + - **`include`:** `land`, `water`, `elevation` and `traffic`, each its own request, run in parallel with the existing calls. The model picks what the question needs; left out, none are requested, so existing calls such as "coffee nearby" make no extra requests. A failed request, including a network error, leaves its field out instead of failing the answer. + - **`land`:** a point-in-polygon Tilequery on Mapbox Streets v8 for land use (park, cemetery, school, residential, wood...), protected areas (national parks, with names) and the building the point is in (type, height). Wetlands, which Streets keeps in the same overlay layer as national parks, are reported as land use. Streets park polygons have no names, so a city park is just "park". + - **`water`:** a 500 m radius Tilequery on `water,waterway`: on water, or the distance to the nearest water. + - **`elevation`:** one Terrain-DEM tile, read with `elevation_tool`'s decoder (#293). + - **`traffic`:** a 100 m radius Tilequery on Mapbox Traffic v1: live congestion (low, moderate, heavy, severe) per road class, at its worst, with the distance to that worst segment, nearest first. The tiles have road classes but no street names, and quiet streets often have no data, so an empty list says no data rather than no traffic. + - **Evals:** new tool-selection cases for land use, water, elevation and traffic questions. ## 0.17.0 - 2026-11-06 diff --git a/evals/tool-selection/cases.json b/evals/tool-selection/cases.json index 3f15ed18..07965867 100644 --- a/evals/tool-selection/cases.json +++ b/evals/tool-selection/cases.json @@ -293,5 +293,117 @@ "stubs": { "search_and_geocode_tool": "1. 410 Valencia St, San Francisco, CA 94103 (37.7655, -122.4218)\n2. 2001 Fillmore St, San Francisco, CA 94115 (37.7880, -122.4335)\n3. 1 Ferry Building, San Francisco, CA 94111 (37.7955, -122.3937)" } + }, + { + "id": "land-park", + "category": "land", + "prompt": "Is 37.7694, -122.4862 inside a park?", + "why": "Land use at a point: ground_location_tool reads the Streets land use polygons. Reverse geocoding only names the area, and can't say whether the point is in a park.", + "expect": { + "any_of": ["ground_location_tool"], + "must_not": [ + "render_map_tool", + "static_map_image_tool", + "places_map_display_v0" + ] + }, + "stubs": { + "ground_location_tool": "**Outer Sunset** (neighborhood context · live Mapbox data)\nOuter Sunset, San Francisco, California, United States\n\nLand use: park, grass (meadow)\nNearest water: 292 m\nElevation: 45 m\n\nReachable by walking: 5 min, 10 min, 15 min\n\n*Sources: Mapbox Geocoding API, Mapbox Isochrone API, Mapbox Tilequery API (Streets v8), Mapbox Terrain-DEM*", + "reverse_geocode_tool": "Outer Sunset, San Francisco, California 94122, United States", + "category_search_tool": "1. Golden Gate Park (37.7694, -122.4862), 0 m\n2. Spreckels Lake (37.7704, -122.4955), 820 m" + } + }, + { + "id": "land-water", + "category": "land", + "prompt": "I'm about to drop a meetup pin at 37.83, -122.42. Is that spot on land or in the water?", + "why": "Water at a point: ground_location_tool says on_water. No map asked for.", + "expect": { + "any_of": ["ground_location_tool"], + "must_not": [ + "render_map_tool", + "static_map_image_tool", + "places_map_display_v0" + ] + }, + "stubs": { + "ground_location_tool": "**San Francisco** (neighborhood context · live Mapbox data)\nSan Francisco, California, United States\n\nOn water\nElevation: 0 m\n\n*Sources: Mapbox Geocoding API, Mapbox Tilequery API (Streets v8), Mapbox Terrain-DEM*", + "reverse_geocode_tool": "San Francisco, California, United States" + } + }, + { + "id": "elevation-point", + "category": "elevation", + "prompt": "How high above sea level is 37.7544, -122.4475?", + "why": "Elevation at one point: ground_location_tool includes it, and elevation_tool with points is also right.", + "needs_tools": ["elevation_tool"], + "expect": { + "any_of": ["ground_location_tool", "elevation_tool"], + "must_not": [ + "render_map_tool", + "static_map_image_tool", + "places_map_display_v0" + ] + }, + "stubs": { + "ground_location_tool": "**Twin Peaks** (neighborhood context · live Mapbox data)\nTwin Peaks, San Francisco, California, United States\n\nLand use: park\nNearest water: 182 m\nElevation: 250 m\n\n*Sources: Mapbox Geocoding API, Mapbox Tilequery API (Streets v8), Mapbox Terrain-DEM*", + "elevation_tool": "Ground elevation (mapbox.mapbox-terrain-dem-v1, about 7.5 m resolution):\n250 m at 37.75440, -122.44750", + "reverse_geocode_tool": "Twin Peaks, San Francisco, California 94131, United States" + } + }, + { + "id": "elevation-summits", + "category": "elevation", + "prompt": "Which is higher: Mount Davidson or Twin Peaks in San Francisco? Use live map data, not memory.", + "why": "Elevation at several places: geocode both, then one elevation_tool call with points (or ground_location_tool for each).", + "needs_tools": ["elevation_tool"], + "expect": { + "any_of": ["elevation_tool", "ground_location_tool"], + "must_not": [ + "render_map_tool", + "static_map_image_tool", + "places_map_display_v0" + ] + }, + "stubs": { + "search_and_geocode_tool": "1. Mount Davidson, San Francisco, CA (37.7383, -122.4542)\n2. Twin Peaks, San Francisco, CA (37.7544, -122.4475)", + "elevation_tool": "Ground elevation (mapbox.mapbox-terrain-dem-v1, about 7.5 m resolution):\n1. 282 m at 37.73830, -122.45420\n2. 250 m at 37.75440, -122.44750", + "ground_location_tool": "**Twin Peaks** (neighborhood context · live Mapbox data)\nTwin Peaks, San Francisco, California, United States\n\nLand use: park\nNearest water: 182 m\nElevation: 250 m\n\n*Sources: Mapbox Geocoding API, Mapbox Tilequery API (Streets v8), Mapbox Terrain-DEM*" + } + }, + { + "id": "elevation-route", + "category": "elevation", + "prompt": "How much climbing is there biking from the Ferry Building to Twin Peaks in San Francisco?", + "why": "Climb along a route: directions_tool, then elevation_tool with the route's ref. No map asked for.", + "needs_tools": ["elevation_tool"], + "expect": { + "must": ["directions_tool", "elevation_tool"], + "must_not": [ + "render_map_tool", + "static_map_image_tool", + "places_map_display_v0" + ] + }, + "stubs": { + "search_and_geocode_tool": "1. Ferry Building, 1 Ferry Building, San Francisco, CA (37.7955, -122.3937)\n2. Twin Peaks, San Francisco, CA (37.7544, -122.4475)", + "directions_tool": "Cycling route: 8.4 km, about 38 min via Market St and Clarendon Ave.", + "elevation_tool": "Elevation along 8.4 km (mapbox.mapbox-terrain-dem-v1, 500 samples, about 7.5 m resolution):\nClimb: 274 m, descent: 27 m\nStart: 3 m, end: 250 m\nHighest: 250 m at 8.4 km\nLowest: 2 m at 0.3 km" + } + }, + { + "id": "traffic-address", + "category": "land", + "prompt": "How bad is traffic right now around 1 Market St, San Francisco?", + "why": "Live traffic at an address: geocode it, then ground_location_tool with include traffic. Showing the traffic layer on a map is fine too, but the reply needs numbers, not just a picture.", + "expect": { + "any_of": ["ground_location_tool"], + "must_not": ["static_map_image_tool", "places_map_display_v0"] + }, + "stubs": { + "search_and_geocode_tool": "1. 1 Market St, San Francisco, CA 94105 (37.7942, -122.3949)", + "ground_location_tool": "**Financial District** (neighborhood context · live Mapbox data)\nFinancial District, San Francisco, California, United States\n\nLive traffic within 100 m: moderate on primary (22 m), heavy on motorway link (64 m), low on street (80 m)\n\n*Sources: Mapbox Geocoding API, Mapbox Tilequery API (Traffic v1)*", + "directions_tool": "Driving route: 1.2 km, 6 min (typical 4 min) via The Embarcadero." + } } ] diff --git a/evals/tool-selection/harness.ts b/evals/tool-selection/harness.ts index 70d4a2b1..f1f9e3c7 100644 --- a/evals/tool-selection/harness.ts +++ b/evals/tool-selection/harness.ts @@ -351,6 +351,7 @@ const HINTED_TOOLS = new Set([ 'directions_tool', 'isochrone_tool', 'ground_location_tool', + 'elevation_tool', 'map_matching_tool', 'optimization_tool', 'union_tool', diff --git a/src/tools/ground-location-tool/GroundLocationTool.input.schema.ts b/src/tools/ground-location-tool/GroundLocationTool.input.schema.ts index 1590fba0..19a71d0c 100644 --- a/src/tools/ground-location-tool/GroundLocationTool.input.schema.ts +++ b/src/tools/ground-location-tool/GroundLocationTool.input.schema.ts @@ -47,7 +47,18 @@ export const GroundLocationInputSchema = z.object({ .optional() .default(10) .describe('Maximum number of nearby POIs to return (1-25)'), - language: languageSchema.optional() + language: languageSchema.optional(), + include: z + .array(z.enum(['land', 'water', 'elevation', 'traffic'])) + .optional() + .describe( + 'What to report about the spot itself, each one extra request: ' + + '"land" for land use (park, cemetery, school, residential...), a national park or other protected area, and the building it is in; ' + + '"water" for whether it is on water and how far the nearest water is; ' + + '"elevation" for height above sea level; ' + + '"traffic" for live congestion on the roads around it. ' + + 'Pick what the question needs, e.g. ["traffic"] for "how bad is traffic at this address?". Leave out when the question is only about the area or nearby places.' + ) }); export type GroundLocationInput = z.infer; diff --git a/src/tools/ground-location-tool/GroundLocationTool.output.schema.ts b/src/tools/ground-location-tool/GroundLocationTool.output.schema.ts index 51a0127d..6877216e 100644 --- a/src/tools/ground-location-tool/GroundLocationTool.output.schema.ts +++ b/src/tools/ground-location-tool/GroundLocationTool.output.schema.ts @@ -30,6 +30,63 @@ export const IsochroneSummarySchema = z.object({ contour_areas_sqkm: z.array(z.number()).optional() }); +export const LandSchema = z.object({ + land_use: z + .array(z.object({ class: z.string(), type: z.string().optional() })) + .describe( + 'Land use polygons the point is inside, e.g. park, cemetery, school, hospital, residential, commercial_area, wood, grass, wetland. Empty when none is mapped there.' + ), + protected_areas: z + .array(z.object({ class: z.string(), name: z.string().optional() })) + .optional() + .describe('Protected areas the point is inside, e.g. a national_park'), + building: z + .object({ + type: z.string().optional(), + height_m: z.number().optional(), + underground: z.boolean().optional() + }) + .optional() + .describe('The building the point is inside, if any') +}); + +export const WaterSchema = z.object({ + on_water: z + .boolean() + .describe('Whether the point is on a lake, river or sea'), + nearest_water_m: z + .number() + .optional() + .describe( + 'Distance to the nearest water within 500 m, when not on water. Absent with on_water false: no water within 500 m.' + ) +}); + +export const TrafficSchema = z.object({ + radius_m: z.number().describe('How far around the point roads were read'), + roads: z + .array( + z.object({ + class: z + .string() + .describe('Road class, e.g. motorway, primary, secondary, street'), + congestion: z + .string() + .describe('Live congestion: low, moderate, heavy or severe'), + distance_m: z + .number() + .describe('Distance to the nearest segment at that level') + }) + ) + .describe( + 'One entry per road class, at its worst level, nearest first. Empty means no live traffic data nearby (common on quiet streets), not no traffic.' + ), + worst: z + .string() + .optional() + .describe('Worst congestion on any of those roads') +}); + import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; export const GroundLocationOutputSchema = z @@ -47,6 +104,21 @@ export const GroundLocationOutputSchema = z isochrone: IsochroneSummarySchema.optional().describe( 'Travel-time reachability summary' ), + land: LandSchema.optional().describe( + 'What the ground is at the point, from Mapbox Streets (include: land)' + ), + water: WaterSchema.optional().describe( + 'On water, or distance to the nearest water (include: water)' + ), + traffic: TrafficSchema.optional().describe( + 'Live traffic on nearby roads, from Mapbox Traffic (include: traffic)' + ), + elevation_m: z + .number() + .optional() + .describe( + 'Ground elevation above sea level, from Mapbox Terrain-DEM (include: elevation)' + ), citations: z .array(z.string()) .describe('Mapbox APIs used to produce this grounded response'), diff --git a/src/tools/ground-location-tool/GroundLocationTool.ts b/src/tools/ground-location-tool/GroundLocationTool.ts index 5d5c7ca4..4a38d0d4 100644 --- a/src/tools/ground-location-tool/GroundLocationTool.ts +++ b/src/tools/ground-location-tool/GroundLocationTool.ts @@ -14,6 +14,8 @@ import { renderHint } from '../../utils/storeMapPayload.js'; import { buildSelfFetchRef } from '../../utils/selfFetchRef.js'; import { buildReverseGeocodeUrl } from './buildReverseGeocodeUrl.js'; import { buildCategorySearchRequestUrl } from '../category-search-tool/buildCategorySearchRequestUrl.js'; +import { landAt, waterNear, trafficNear } from './groundFacets.js'; +import { MAX_ZOOM, elevationsAt } from '../elevation-tool/terrain.js'; type GroundingStrategy = 'neighborhood' | 'routing' | 'poi' | 'region'; @@ -77,7 +79,9 @@ export class GroundLocationTool extends MapboxApiBasedTool< > { name = 'ground_location_tool'; description = - 'Answer questions about what is near a location, what neighborhood a coordinate is in, or what places are within walking/driving distance. Use this when given coordinates and asked about nearby places, neighborhood context, local discovery, or area summaries. It already includes reverse geocoding, so a separate reverse_geocode_tool call is not needed for the same coordinate. Pass the place category (e.g. "restaurant", "coffee", "park") as the query parameter to get nearby POIs in the same call. Returns place name, nearby POIs matching the query, and travel-time reachability — all sourced from live Mapbox data with citations.'; + 'Answer questions about what is near a location, what neighborhood a coordinate is in, or what places are within walking/driving distance. Use this when given coordinates and asked about nearby places, neighborhood context, local discovery, or area summaries. It already includes reverse geocoding, so a separate reverse_geocode_tool call is not needed for the same coordinate. Pass the place category (e.g. "restaurant", "coffee", "park") as the query parameter to get nearby POIs in the same call. Returns place name, nearby POIs matching the query, and travel-time reachability — all sourced from live Mapbox data with citations. ' + + 'Use `include` for what the spot itself is: land use (is it in a park, a national park, a building?), water (on it, or how near), elevation, and live traffic on the roads around it. ' + + 'So use it for "is this spot in a park?", "is this on the water?" or "how bad is traffic at this address?" too. For elevation along a path or route, use elevation_tool.'; annotations = { title: 'Ground Location Tool', readOnlyHint: true, @@ -244,6 +248,107 @@ export class GroundLocationTool extends MapboxApiBasedTool< }; } + /** + * The `include` facets: what the spot itself is. Each is its own request, + * run in parallel; one that fails is left out rather than failing the + * whole answer. + */ + private async groundFacets( + longitude: number, + latitude: number, + include: ReadonlyArray<'land' | 'water' | 'elevation' | 'traffic'>, + accessToken: string, + citations: string[] + ): Promise< + Pick + > { + const options = { + httpRequest: this.httpRequest, + accessToken, + apiEndpoint: MapboxApiBasedTool.mapboxApiEndpoint + }; + const wants = new Set(include); + const [land, water, traffic, elevation] = await Promise.all([ + wants.has('land') ? landAt(longitude, latitude, options) : undefined, + wants.has('water') ? waterNear(longitude, latitude, options) : undefined, + wants.has('traffic') + ? trafficNear(longitude, latitude, options) + : undefined, + wants.has('elevation') + ? elevationsAt([[longitude, latitude]], MAX_ZOOM, options).then( + ([height]) => Math.round(height * 10) / 10 || 0, + () => undefined + ) + : undefined + ]); + if (land || water) citations.push('Mapbox Tilequery API (Streets v8)'); + if (traffic) citations.push('Mapbox Tilequery API (Traffic v1)'); + if (elevation !== undefined) citations.push('Mapbox Terrain-DEM'); + return { land, water, traffic, elevation_m: elevation }; + } + + /** "On the ground" lines: land use, protected area, building, water, elevation. */ + private formatGround(result: GroundLocationOutput): string[] { + const lines: string[] = []; + const land = result.land; + if (land) { + const label = (v: string) => v.replace(/_/g, ' '); + if (land.land_use.length) { + lines.push( + `Land use: ${land.land_use + .map((u) => + u.type ? `${label(u.class)} (${label(u.type)})` : label(u.class) + ) + .join(', ')}` + ); + } + for (const area of land.protected_areas ?? []) { + lines.push( + `Inside ${area.name ? `${area.name} (${label(area.class)})` : `a ${label(area.class)}`}` + ); + } + if (land.building) { + const { type, height_m, underground } = land.building; + const details = [ + type && type !== 'building' ? label(type) : undefined, + height_m !== undefined ? `${Math.round(height_m)} m tall` : undefined, + underground ? 'underground' : undefined + ].filter(Boolean); + lines.push( + `Inside a building${details.length ? ` (${details.join(', ')})` : ''}` + ); + } + } + if (result.water) { + lines.push( + result.water.on_water + ? 'On water' + : result.water.nearest_water_m !== undefined + ? `Nearest water: ${result.water.nearest_water_m} m` + : 'No water within 500 m' + ); + } + if (result.traffic) { + const { roads, radius_m } = result.traffic; + lines.push( + roads.length + ? `Live traffic within ${radius_m} m: ${roads + .map( + (r) => + `${r.congestion} on ${r.class.replace(/_/g, ' ')} (${r.distance_m} m)` + ) + .join(', ')}` + : `No live traffic data within ${radius_m} m (common on quiet streets; it doesn't mean no traffic)` + ); + } + if (result.elevation_m !== undefined) { + lines.push( + `Elevation: ${(Math.round(result.elevation_m) || 0).toLocaleString('en-US')} m` + ); + } + return lines; + } + private formatOutput( result: GroundLocationOutput, strategy: GroundingStrategy @@ -272,6 +377,11 @@ export class GroundLocationTool extends MapboxApiBasedTool< lines.push(''); } + const groundLines = this.formatGround(result); + if (groundLines.length) { + lines.push(...groundLines, ''); + } + if (result.nearby_pois?.length) { lines.push(`Nearby places:`); for (const poi of result.nearby_pois) { @@ -310,7 +420,8 @@ export class GroundLocationTool extends MapboxApiBasedTool< profile, contours_minutes, limit, - language + language, + include = [] } = input; // Classify the grounding strategy via sampling (falls back gracefully if unsupported) @@ -331,44 +442,47 @@ export class GroundLocationTool extends MapboxApiBasedTool< : 'neighborhood,locality,place'; // Fan out requests in parallel, shaped by strategy - const [geocodeResult, poisResult, isochroneResult] = await Promise.all([ - this.reverseGeocode( - longitude, - latitude, - accessToken, - geocodeTypes, - language - ), - - // POIs: always fetch if query given; boost limit for poi-focused strategy - query || strategy === 'poi' - ? this.categorySearch( - query ?? 'place', - longitude, - latitude, - strategy === 'poi' ? Math.max(limit, 15) : limit, - accessToken, - language - ).then((pois) => { - if (pois?.length) citations.push('Mapbox Search API'); - return pois; - }) - : Promise.resolve(undefined), - - // Isochrone: always for region/neighborhood strategies - strategy === 'region' || strategy === 'neighborhood' - ? this.isochrone( - longitude, - latitude, - profile, - contours_minutes, - accessToken - ).then((iso) => { - if (iso) citations.push('Mapbox Isochrone API'); - return iso; - }) - : Promise.resolve(undefined) - ]); + const [geocodeResult, poisResult, isochroneResult, ground] = + await Promise.all([ + this.reverseGeocode( + longitude, + latitude, + accessToken, + geocodeTypes, + language + ), + + // POIs: always fetch if query given; boost limit for poi-focused strategy + query || strategy === 'poi' + ? this.categorySearch( + query ?? 'place', + longitude, + latitude, + strategy === 'poi' ? Math.max(limit, 15) : limit, + accessToken, + language + ).then((pois) => { + if (pois?.length) citations.push('Mapbox Search API'); + return pois; + }) + : Promise.resolve(undefined), + + // Isochrone: always for region/neighborhood strategies + strategy === 'region' || strategy === 'neighborhood' + ? this.isochrone( + longitude, + latitude, + profile, + contours_minutes, + accessToken + ).then((iso) => { + if (iso) citations.push('Mapbox Isochrone API'); + return iso; + }) + : Promise.resolve(undefined), + + this.groundFacets(longitude, latitude, include, accessToken, citations) + ]); const result: GroundLocationOutput = { place: geocodeResult.place, @@ -377,6 +491,7 @@ export class GroundLocationTool extends MapboxApiBasedTool< latitude, nearby_pois: poisResult ?? undefined, isochrone: isochroneResult ?? undefined, + ...ground, citations }; diff --git a/src/tools/ground-location-tool/groundFacets.ts b/src/tools/ground-location-tool/groundFacets.ts new file mode 100644 index 00000000..53fd1b7b --- /dev/null +++ b/src/tools/ground-location-tool/groundFacets.ts @@ -0,0 +1,206 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { HttpRequest } from '../../utils/types.js'; +import type { GroundLocationOutput } from './GroundLocationTool.output.schema.js'; + +export const STREETS_TILESET = 'mapbox.mapbox-streets-v8'; +export const TRAFFIC_TILESET = 'mapbox.mapbox-traffic-v1'; +/** How far to look for the nearest water. */ +export const WATER_RADIUS_M = 500; +/** How far to look for roads with live traffic. */ +export const TRAFFIC_RADIUS_M = 100; + +/** Live congestion levels in Mapbox Traffic v1, mildest first. */ +const CONGESTION = ['low', 'moderate', 'heavy', 'severe'] as const; + +type Land = NonNullable; +type Water = NonNullable; +type Traffic = NonNullable; + +interface TilequeryFeature { + properties?: { + tilequery?: { distance?: number; layer?: string }; + class?: string; + type?: string; + name?: string; + height?: number; + underground?: string; + congestion?: string; + }; +} + +interface FacetOptions { + httpRequest: HttpRequest; + accessToken: string; + apiEndpoint: string; +} + +/** + * Tilequery features at or near the point, or undefined if the request + * fails, throws or returns a body that isn't JSON, so a facet never fails + * the whole answer. + */ +async function tilequery( + tileset: string, + longitude: number, + latitude: number, + params: Record, + options: FacetOptions +): Promise { + const url = new URL( + `${options.apiEndpoint}v4/${tileset}/tilequery/${longitude},${latitude}.json` + ); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + url.searchParams.set('access_token', options.accessToken); + try { + const response = await options.httpRequest(url.toString()); + if (!response.ok) return undefined; + return ( + ((await response.json()) as { features?: TilequeryFeature[] }).features ?? + [] + ); + } catch { + return undefined; + } +} + +/** + * What the ground is at the point, from one point-in-polygon query on + * Mapbox Streets v8: land use (park, cemetery, school...), protected + * areas (national parks, with names) and the building it's inside. + * Streets' landuse_overlay layer also holds wetlands, which are land use, + * not protected areas. + */ +export async function landAt( + longitude: number, + latitude: number, + options: FacetOptions +): Promise { + const inside = await tilequery( + STREETS_TILESET, + longitude, + latitude, + { layers: 'landuse,landuse_overlay,building', limit: '20' }, + options + ); + if (!inside) return undefined; + const inLayer = (layer: string) => + inside.filter((f) => f.properties?.tilequery?.layer === layer); + + const overlay = inLayer('landuse_overlay'); + const isProtected = (f: TilequeryFeature) => + f.properties?.class === 'national_park'; + + const land: Land = { + land_use: [ + ...inLayer('landuse'), + ...overlay.filter((f) => !isProtected(f)) + ].map((f) => ({ + class: f.properties?.class ?? 'unknown', + ...(f.properties?.type && f.properties.type !== f.properties.class + ? { type: f.properties.type } + : {}) + })) + }; + + const protectedAreas = overlay.filter(isProtected).map((f) => ({ + class: f.properties?.class ?? 'unknown', + ...(f.properties?.name ? { name: f.properties.name } : {}) + })); + if (protectedAreas.length) land.protected_areas = protectedAreas; + + const building = inLayer('building')[0]?.properties; + if (building) { + land.building = { + ...(building.type ? { type: building.type } : {}), + ...(typeof building.height === 'number' + ? { height_m: building.height } + : {}), + ...(building.underground === 'true' ? { underground: true } : {}) + }; + } + return land; +} + +/** + * Whether the point is on water, or how far the nearest water (lake, sea, + * river, stream) is within WATER_RADIUS_M. Tilequery gives a point inside + * a water polygon distance 0. + */ +export async function waterNear( + longitude: number, + latitude: number, + options: FacetOptions +): Promise { + const near = await tilequery( + STREETS_TILESET, + longitude, + latitude, + { layers: 'water,waterway', radius: String(WATER_RADIUS_M), limit: '1' }, + options + ); + if (!near) return undefined; + const distance = near[0]?.properties?.tilequery?.distance; + if (typeof distance !== 'number') return { on_water: false }; + return distance === 0 + ? { on_water: true } + : { on_water: false, nearest_water_m: Math.round(distance) }; +} + +/** + * Live congestion on the roads within TRAFFIC_RADIUS_M, from Mapbox + * Traffic v1: one entry per road class, at its worst level, nearest + * first. The tiles carry road class but no street names, and quiet + * streets often have no traffic data at all. + */ +export async function trafficNear( + longitude: number, + latitude: number, + options: FacetOptions +): Promise { + const near = await tilequery( + TRAFFIC_TILESET, + longitude, + latitude, + { layers: 'traffic', radius: String(TRAFFIC_RADIUS_M), limit: '50' }, + options + ); + if (!near) return undefined; + + const rank = (c: string) => + CONGESTION.indexOf(c as (typeof CONGESTION)[number]); + const byClass = new Map(); + for (const f of near) { + const congestion = f.properties?.congestion; + if (!congestion || rank(congestion) < 0) continue; + const roadClass = f.properties?.class ?? 'road'; + const distance = Math.round(f.properties?.tilequery?.distance ?? 0); + const seen = byClass.get(roadClass); + if (!seen) { + byClass.set(roadClass, { + class: roadClass, + congestion, + distance_m: distance + }); + } else if (rank(congestion) > rank(seen.congestion)) { + seen.congestion = congestion; + seen.distance_m = distance; + } + } + const roads = [...byClass.values()].sort( + (a, b) => a.distance_m - b.distance_m + ); + const worst = roads.reduce( + (w, r) => + w === undefined || rank(r.congestion) > rank(w) ? r.congestion : w, + undefined + ); + return { + radius_m: TRAFFIC_RADIUS_M, + roads, + ...(worst ? { worst } : {}) + }; +} diff --git a/test/tools/ground-location-tool/GroundLocationTool.test.ts b/test/tools/ground-location-tool/GroundLocationTool.test.ts index 08e2951d..a693178e 100644 --- a/test/tools/ground-location-tool/GroundLocationTool.test.ts +++ b/test/tools/ground-location-tool/GroundLocationTool.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; import { GroundLocationTool } from '../../../src/tools/ground-location-tool/GroundLocationTool.js'; import { tokenFor } from '../../utils/tokenTestUtils.js'; +import { terrainTile } from '../elevation-tool/pngFixtures.js'; const geocodeResponse = { features: [ @@ -67,6 +68,17 @@ function setupMockHttp(responses: Record) { const mockFetch = vi.fn().mockImplementation((url: string) => { for (const [key, body] of Object.entries(responses)) { if (url.includes(key)) { + if (Buffer.isBuffer(body)) { + return Promise.resolve({ + ok: true, + status: 200, + arrayBuffer: async () => + body.buffer.slice( + body.byteOffset, + body.byteOffset + body.byteLength + ) + }); + } return Promise.resolve({ ok: true, status: 200, @@ -103,7 +115,8 @@ describe('GroundLocationTool', () => { const text = (result.content[0] as { type: string; text: string }).text; expect(text).toContain('Mission District'); expect(text).toContain('neighborhood context'); - // geocode + isochrone called, no category search (no query) + // geocode and isochrone only: no category search (no query) and no + // ground facets (no include) expect(mockFetch).toHaveBeenCalledTimes(2); }); @@ -378,4 +391,310 @@ describe('GroundLocationTool', () => { const payload = resolveSelfFetchRef(sc.mapboxRender!.ref!); expect(payload?.selfFetch?.[0]?.params).not.toHaveProperty('poi'); }); + + describe('the ground at the point (include)', () => { + // Golden Gate Park: a park polygon with a meadow inside it, and Stow + // Lake 292 m away. Same shapes Tilequery returned for that point. + const parkInside = { + features: [ + { + properties: { + tilequery: { distance: 0, layer: 'landuse' }, + class: 'park', + type: 'park' + } + }, + { + properties: { + tilequery: { distance: 0, layer: 'landuse' }, + class: 'grass', + type: 'meadow' + } + } + ] + }; + const lakeNearby = { + features: [ + { properties: { tilequery: { distance: 291.6, layer: 'water' } } } + ] + }; + // Market St: several segments per road class, at different levels. + const trafficNearby = { + features: [ + ['street', 'low', 19.4], + ['secondary', 'moderate', 47.2], + ['secondary', 'heavy', 60.1], + ['street', 'low', 80], + ['motorway', 'unknown', 90] + ].map(([cls, congestion, distance]) => ({ + properties: { + tilequery: { distance, layer: 'traffic' }, + class: cls, + congestion + } + })) + }; + const tilequeryUrls = (mockFetch: { mock: { calls: unknown[][] } }) => + mockFetch.mock.calls + .map(([url]) => new URL(url as string)) + .filter((u) => u.pathname.includes('/tilequery/')); + const textOf = (result: { content: unknown[] }) => + (result.content[0] as { text: string }).text; + + it('asks for nothing about the ground when include is left out', async () => { + const { tool, mockFetch } = setupMockHttp({ + 'geocode/v6/reverse': geocodeResponse, + 'isochrone/v1': isochroneResponse, + 'mapbox-streets-v8/tilequery': parkInside + }); + + const result = await tool.run({ + longitude: -122.4862, + latitude: 37.7694 + }); + + const sc = result.structuredContent as Record; + expect(sc.land).toBeUndefined(); + expect(tilequeryUrls(mockFetch)).toEqual([]); + }); + + it('land: one point-in-polygon query on Streets', async () => { + const { tool, mockFetch } = setupMockHttp({ + 'geocode/v6/reverse': geocodeResponse, + 'isochrone/v1': isochroneResponse, + 'mapbox-streets-v8/tilequery': parkInside + }); + + const result = await tool.run({ + longitude: -122.4862, + latitude: 37.7694, + include: ['land'] + }); + + const sc = result.structuredContent as Record; + expect(sc.land).toEqual({ + land_use: [{ class: 'park' }, { class: 'grass', type: 'meadow' }] + }); + expect(sc.water).toBeUndefined(); + expect(sc.traffic).toBeUndefined(); + expect(sc.elevation_m).toBeUndefined(); + expect(textOf(result)).toContain('Land use: park, grass (meadow)'); + const [pip, ...rest] = tilequeryUrls(mockFetch); + expect(rest).toEqual([]); + expect(pip.pathname).toBe( + '/v4/mapbox.mapbox-streets-v8/tilequery/-122.4862,37.7694.json' + ); + expect(pip.searchParams.get('layers')).toBe( + 'landuse,landuse_overlay,building' + ); + expect(pip.searchParams.has('radius')).toBe(false); + expect(mockFetch).not.toHaveBeenCalledWith( + expect.stringContaining('terrain-dem') + ); + }); + + it('reports only what include asks for', async () => { + const { tool, mockFetch } = setupMockHttp({ + 'geocode/v6/reverse': geocodeResponse, + 'isochrone/v1': isochroneResponse, + 'mapbox-traffic-v1/tilequery': trafficNearby, + 'mapbox-streets-v8/tilequery': lakeNearby, + 'mapbox-terrain-dem-v1': terrainTile(() => 18.04) + }); + + const result = await tool.run({ + longitude: -122.4013, + latitude: 37.7887, + include: ['water', 'elevation', 'traffic'] + }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as Record; + expect(sc.land).toBeUndefined(); + expect(sc.water).toEqual({ on_water: false, nearest_water_m: 292 }); + expect(sc.elevation_m).toBeCloseTo(18, 0); + // One entry per road class at its worst level and that segment's + // distance, nearest first; an unknown congestion value is skipped. + expect(sc.traffic).toEqual({ + radius_m: 100, + roads: [ + { class: 'street', congestion: 'low', distance_m: 19 }, + { class: 'secondary', congestion: 'heavy', distance_m: 60 } + ], + worst: 'heavy' + }); + const text = textOf(result); + expect(text).toContain('Nearest water: 292 m'); + expect(text).toContain('Elevation: 18 m'); + expect(text).toContain( + 'Live traffic within 100 m: low on street (19 m), heavy on secondary (60 m)' + ); + expect(sc.citations).toEqual( + expect.arrayContaining([ + 'Mapbox Tilequery API (Streets v8)', + 'Mapbox Tilequery API (Traffic v1)', + 'Mapbox Terrain-DEM' + ]) + ); + + const urls = tilequeryUrls(mockFetch); + const water = urls.find((u) => u.pathname.includes('streets'))!; + expect(water.searchParams.get('layers')).toBe('water,waterway'); + expect(water.searchParams.get('radius')).toBe('500'); + expect(water.searchParams.get('limit')).toBe('1'); + const traffic = urls.find((u) => u.pathname.includes('traffic'))!; + expect(traffic.pathname).toBe( + '/v4/mapbox.mapbox-traffic-v1/tilequery/-122.4013,37.7887.json' + ); + expect(traffic.searchParams.get('radius')).toBe('100'); + }); + + it('says no traffic data is not no traffic', async () => { + const { tool } = setupMockHttp({ + 'geocode/v6/reverse': geocodeResponse, + 'mapbox-traffic-v1/tilequery': { features: [] } + }); + const result = await tool.run({ + longitude: -122.4, + latitude: 37.7, + include: ['traffic'] + }); + const sc = result.structuredContent as Record; + expect(sc.traffic).toEqual({ radius_m: 100, roads: [] }); + expect(textOf(result)).toContain( + "No live traffic data within 100 m (common on quiet streets; it doesn't mean no traffic)" + ); + }); + + it('reports a wetland as land use, not a protected area', async () => { + const { tool } = setupMockHttp({ + 'geocode/v6/reverse': geocodeResponse, + 'layers=landuse': { + features: [ + { + properties: { + tilequery: { distance: 0, layer: 'landuse_overlay' }, + class: 'wetland', + type: 'marsh' + } + } + ] + } + }); + + const result = await tool.run({ + longitude: -122.05, + latitude: 37.5, + include: ['land'] + }); + + const sc = result.structuredContent as Record; + expect(sc.land).toEqual({ + land_use: [{ class: 'wetland', type: 'marsh' }] + }); + expect(textOf(result)).toContain('Land use: wetland (marsh)'); + expect(textOf(result)).not.toContain('Inside'); + }); + + it('still answers when a facet request throws', async () => { + const { tool, mockFetch } = setupMockHttp({ + 'geocode/v6/reverse': geocodeResponse + }); + const answer = mockFetch.getMockImplementation()!; + mockFetch.mockImplementation((url: string) => + url.includes('/tilequery/') + ? Promise.reject(new TypeError('fetch failed')) + : answer(url) + ); + + const result = await tool.run({ + longitude: -122.419, + latitude: 37.759, + include: ['land', 'water', 'traffic'] + }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as Record; + for (const key of ['land', 'water', 'traffic']) { + expect(sc[key]).toBeUndefined(); + } + expect(textOf(result)).toContain('Mission District'); + }); + + it('names a national park, describes a building, and says on water', async () => { + const { tool } = setupMockHttp({ + 'geocode/v6/reverse': geocodeResponse, + 'layers=landuse': { + features: [ + { + properties: { + tilequery: { distance: 0, layer: 'landuse_overlay' }, + class: 'national_park', + type: 'protected_area', + name: 'Yosemite National Park' + } + }, + { + properties: { + tilequery: { distance: 0, layer: 'building' }, + type: 'office', + height: 9.3, + underground: 'false' + } + } + ] + }, + 'layers=water': { + features: [ + { properties: { tilequery: { distance: 0, layer: 'water' } } } + ] + } + }); + + const result = await tool.run({ + longitude: -119.5885, + latitude: 37.7456, + include: ['land', 'water'] + }); + + const sc = result.structuredContent as Record; + expect(sc.land).toEqual({ + land_use: [], + protected_areas: [ + { class: 'national_park', name: 'Yosemite National Park' } + ], + building: { type: 'office', height_m: 9.3 } + }); + expect(sc.water).toEqual({ on_water: true }); + const text = textOf(result); + expect(text).toContain('Inside Yosemite National Park (national park)'); + expect(text).toContain('Inside a building (office, 9 m tall)'); + expect(text).toContain('On water'); + expect(text).not.toContain('Land use:'); + }); + + it('still answers when every facet request fails', async () => { + const { tool } = setupMockHttp({ + 'geocode/v6/reverse': geocodeResponse, + 'isochrone/v1': isochroneResponse + }); + + const result = await tool.run({ + longitude: -122.419, + latitude: 37.759, + include: ['land', 'water', 'elevation', 'traffic'] + }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as Record; + for (const key of ['land', 'water', 'traffic', 'elevation_m']) { + expect(sc[key]).toBeUndefined(); + } + expect(sc.citations).toEqual([ + 'Mapbox Geocoding API', + 'Mapbox Isochrone API' + ]); + expect(textOf(result)).toContain('Mission District'); + }); + }); });