diff --git a/CHANGELOG.md b/CHANGELOG.md index c151f788..3018e4a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ - **Fields:** the text, a category (`bug`, `wrong_result`, `feature_request` or `other`) and an optional tool name. The endpoint requires a location, which feedback about the server doesn't have, so it sends 0, 0 as the CLI does. - **Where it came from:** the server appends `— mapbox-mcp-server via , about ` to the text, so the team can tell these from the CLI's reports. The client comes from the server handling the call, which the hosted server needs, since one process serves many clients. - **Confirmation:** like `feedback_tool`, it asks through elicitation when the client supports it and sends nothing if the user declines or the prompt fails. It sends its own id, so a retried POST doesn't make a second report. +- **`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. ### Bug Fixes diff --git a/README.md b/README.md index ed3b51cc..128e990f 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 `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?" + #### Feedback tool Sends a user's feedback about the app or agent to its developer, through the [Mapbox Feedback API](https://docs.mapbox.com/api/feedback/) (Public Preview). It's filed under the account that owns the access token, so when you build an app or agent on this server with your own token, your users' feedback lands in the same feed as your apps' Feedback Agent reports, and you review it in Feedback Explorer. It does not report problems to Mapbox. Like [Feedback Agent](https://docs.mapbox.com/android/navigation/guides/feedback-agent/), it's for anything about your product that helps you understand what users need, for example: diff --git a/docs/importing-tools.md b/docs/importing-tools.md index c7689090..904315dd 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/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/elevation-tool/ElevationTool.input.schema.ts b/src/tools/elevation-tool/ElevationTool.input.schema.ts new file mode 100644 index 00000000..fcc13e19 --- /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 + * `route_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 or a GPS trace matched with map_matching_tool, pass route_ref instead.' + ), + route_ref: z + .string() + .optional() + .describe( + '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.' + ) +}); + +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..2a8a031d --- /dev/null +++ b/src/tools/elevation-tool/ElevationTool.ts @@ -0,0 +1,364 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { z } from 'zod'; +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'; +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, + type ElevationOutput +} from './ElevationTool.output.schema.js'; +import { + MAX_ZOOM, + TERRAIN_TILESET, + TerrainTileError, + chooseZoom, + climbAndDescent, + elevationsAt, + pathLength, + pixelSizeMeters, + samplePath, + 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; +/** 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 `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 = { + 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.route_ref].filter( + (v) => v !== undefined + ).length; + if (given !== 1) { + return this.error('Pass exactly one of points, path or route_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.route_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 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 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)}`; + } + 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 route's 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/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/src/tools/index.ts b/src/tools/index.ts index 6562970b..0e1d3c18 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 { ReportTool } from './report-tool/ReportTool.js'; export { GroundLocationTool } from './ground-location-tool/GroundLocationTool.js'; @@ -52,6 +53,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 { ReportTool } from './report-tool/ReportTool.js'; import { GroundLocationTool } from './ground-location-tool/GroundLocationTool.js'; @@ -104,6 +106,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 }); + /** Send a user's feedback about the app to its developer's Feedback Explorer */ export const feedback = new FeedbackTool({ httpRequest }); diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index f334c1a6..1de89913 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -3,6 +3,7 @@ // INSERT NEW TOOL IMPORT HERE import { ListPublicTokensTool } from './list-public-tokens-tool/ListPublicTokensTool.js'; +import { ElevationTool } from './elevation-tool/ElevationTool.js'; import { FeedbackTool } from './feedback-tool/FeedbackTool.js'; import { ReportTool } from './report-tool/ReportTool.js'; import { GroundLocationTool } from './ground-location-tool/GroundLocationTool.js'; @@ -45,6 +46,7 @@ import { httpRequest } from '../utils/httpPipeline.js'; export const CORE_TOOLS = [ // INSERT NEW TOOL INSTANCE HERE new ListPublicTokensTool({ httpRequest }), + new ElevationTool({ httpRequest }), new FeedbackTool({ httpRequest }), new ReportTool({ httpRequest }), new GroundLocationTool({ httpRequest }), 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 new file mode 100644 index 00000000..21096b29 --- /dev/null +++ b/test/tools/elevation-tool/ElevationTool.test.ts @@ -0,0 +1,391 @@ +// 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({ + route_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 route's ref alongside. + expect(resolveInlinePayloadRef(sc.mapboxRender.ref)?.layers).toEqual([]); + expect(textOf(result)).toContain("pass the route's ref alongside"); + }); + + 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({ + 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 or map_matching_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 route_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..385a77d6 --- /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, _label) => { + 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] }); +} 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'); + }); + }); }); 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(); + }); +});