Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

### New Features

- **`render_map_tool`: live traffic, and Satellite/Traffic buttons on the map (#278).** Asked to show traffic downtown, ChatGPT told a user the map couldn't do it, which was true: the map only drew GeoJSON, and traffic showed up only in route summaries. Switching to satellite also took another tool call. Fixes #277.
- **Buttons:** two icon buttons sit under the zoom control, the same size as zoom and compass: a globe for satellite view and a traffic light for live traffic, each labeled with a tooltip and `aria-label`. One that's on gets a blue icon on a light blue background and `aria-pressed`.
- **Traffic:** the Mapbox Traffic v1 tileset (`mapbox://mapbox.mapbox-traffic-v1`) is drawn as lines colored by congestion (light, moderate, heavy, severe) in the `middle` slot, so road labels and our own layers stay on top. Light traffic shows only on main roads (motorway through tertiary, plus ramps), because on every side street it turned the whole map green; moderate and worse show on every road. Each direction is offset to its own side of the road, and lines run from 1 px at zoom 12 to 4 px at zoom 18. While it's on, the legend lists the four colors. The tiles come from `api.mapbox.com`, which the CSP already allows. The map doesn't refetch them on a timer, so a map left open goes stale until it's moved or reloaded.
- **`showTraffic`:** a new `render_map_tool` input, and a line in the tool description, so the model knows it can show traffic and can open the map with it on. `false` turns it off. Left out, the map keeps whatever the user picked, the same way `baseStyle` works. A `showTraffic`-only call is renderable on its own, like `baseStyle`.
- **Style switches keep what's drawn:** `setStyle()` drops every source and layer the page added. The page now keeps a copy of each GeoJSON layer it draws (including self-fetched routes and isochrones, and deferred data once it arrives), and after a switch from the Satellite button it re-adds them, the base map config and traffic without moving the camera. A `baseStyle` switch from the model keeps traffic on too.
- **Summary chip:** it sat on top of the zoom buttons in the top-left corner. While it shows, the corner's controls now sit below it.
- **Logo and attribution:** the legend covered the Mapbox logo, and an open results panel covered the attribution, both of which the Mapbox terms require to stay visible. The legend now sits above the logo and the panel stops above the attribution, and the map's fit leaves room for the legend's new spot.
- **Testing:** added tests for both buttons, the traffic layer and legend, restoring layers and base map config after a switch, `showTraffic` on, off and left out, and the controls moving below the summary. The test sandbox's fake map now tracks sources and layers like a style does (`setStyle` clears them), and mounts controls through `onAdd`. Checked a live San Francisco tile: the source layer is `traffic`, with `congestion`, `class` and `structure`. Checked in Claude Desktop on both styles.

- **`feedback_tool`: report wrong map, place or routing data to Mapbox (#291).** When someone says a place has closed, a road is closed, or a route took an illegal turn, the agent can send it to the [Feedback API](https://docs.mapbox.com/api/feedback/) (`POST /user-feedback/v1/feedback`, Public Preview) with the text, a category and the location, usually taken from an earlier search or directions result. It returns the report's id and status (`received`).
- **Confirm first:** the report goes to Mapbox under the user's account, so the description tells the model to show the user what it will send and get a yes. Clients that support elicitation also get a confirmation prompt from the tool itself. Declining sends nothing, and so does a failed prompt.
- **Categories:** an enum of the Feedback Agent's default categories that make sense from chat (`poi_closed`, `poi_details`, `road_closure_issue`, `illegal_turn` and so on), leaving out the navigation-app ones such as voice guidance and rerouting.
Expand Down
260 changes: 234 additions & 26 deletions src/resources/ui-apps/mapAppHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,10 @@ export function renderMapAppHtml(params: {
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
max-width: calc(100% - 24px); white-space: pre-line;
}
/* Bottom offsets keep the Mapbox logo (bottom left) and attribution
(bottom right) visible, as the Mapbox terms require. */
#legend {
position: absolute; bottom: 12px; left: 12px; z-index: 10;
position: absolute; bottom: 40px; left: 12px; z-index: 10;
background: rgba(15, 23, 42, 0.88); color: #f1f5f9;
padding: 8px 12px; border-radius: 6px;
font-size: 11px;
Expand All @@ -85,8 +87,14 @@ export function renderMapAppHtml(params: {
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
cursor: pointer;
}
/* Satellite / Traffic buttons under the zoom control (see buildLayerToggles). */
.mapboxgl-ctrl-group.layer-toggles button {
display: flex; align-items: center; justify-content: center; color: #333;
}
.mapboxgl-ctrl-group.layer-toggles button.active { color: #2563eb; background: #dbeafe; }
.mapboxgl-ctrl-group.layer-toggles button:focus:not(:focus-visible) { box-shadow: none; }
#side-panel {
position: absolute; top: 12px; right: 12px; bottom: 12px; z-index: 10;
position: absolute; top: 12px; right: 12px; bottom: 34px; z-index: 10;
width: 280px; max-width: calc(100% - 24px);
background: rgba(15, 23, 42, 0.88); color: #f1f5f9;
border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
Expand Down Expand Up @@ -162,6 +170,39 @@ ${initialDataScript}
var trackedLayerIds = [];
var trackedSourceIds = [];
var trackedMarkers = [];
// Copies of the GeoJSON layers drawn so far. setStyle() drops every
// source and layer we added (DOM markers survive it), so a style switch
// from the Satellite button puts these back (see restoreOverlays).
var drawnLayers = [];
var currentBaseMapConfig = null;
var payloadLegend = [];
var trafficOn = false;
// Mapbox Traffic v1: live congestion per road segment, refreshed every few
// minutes. Served from api.mapbox.com, which the page's CSP already allows.
var TRAFFIC_ID = 'mapbox-live-traffic';
var TRAFFIC_LEVELS = [
{ value: 'low', label: 'Light traffic', color: '#22c55e' },
{ value: 'moderate', label: 'Moderate traffic', color: '#f59e0b' },
{ value: 'heavy', label: 'Heavy traffic', color: '#ef4444' },
{ value: 'severe', label: 'Severe traffic', color: '#7f1d1d' }
];
var TRAFFIC_MAIN_ROADS = [
'motorway', 'motorway_link', 'trunk', 'trunk_link',
'primary', 'secondary', 'tertiary', 'link'
];
// 24px stroke icons, sized to match the zoom buttons.
var TOGGLE_ICONS = {
satellite: '<circle cx="12" cy="12" r="9"/>' +
'<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>',
traffic: '<rect x="8" y="2.5" width="8" height="19" rx="3"/>' +
'<circle cx="12" cy="7" r="1.4"/><circle cx="12" cy="12" r="1.4"/>' +
'<circle cx="12" cy="17" r="1.4"/>'
};

// Set by the layer toggle control, which initMap() adds before the rest
// of this script has run.
var satelliteButton = null;
var trafficButton = null;

// --- MCP App postMessage protocol -----------------------------------------
var messageId = 0;
Expand Down Expand Up @@ -283,6 +324,7 @@ ${initialDataScript}
}
map = new mapboxgl.Map(mapOptions);
map.addControl(new mapboxgl.NavigationControl(), 'top-left');
map.addControl(new LayerToggleControl(), 'top-left');
map.on('load', function() {
mapLoaded = true;
if (pendingPayload) {
Expand Down Expand Up @@ -415,6 +457,7 @@ ${initialDataScript}
} else {
summaryEl.style.display = 'none';
}
placeTopLeftControls();
if (!map) { loadingEl.style.display = 'none'; return; }
if (mapLoaded) render(payload);
else pendingPayload = payload;
Expand All @@ -438,6 +481,7 @@ ${initialDataScript}
trackedLayerIds = [];
trackedSourceIds = [];
trackedMarkers = [];
drawnLayers = [];
markersById = {};
panelEl.style.display = 'none';
clearPanel();
Expand Down Expand Up @@ -490,7 +534,24 @@ ${initialDataScript}
if (!layer || !layer.id || !layer.data) return;
map.addSource(layer.id, { type: 'geojson', data: layer.data });
trackedSourceIds.push(layer.id);
map.addLayer(layerDefinition(layer));
trackedLayerIds.push(layer.id);
drawnLayers.push({
id: layer.id, type: layer.type, data: layer.data,
paint: layer.paint, layout: layer.layout, slot: layer.slot
});

var feature = layer.data;
if (feature.type === 'FeatureCollection' && Array.isArray(feature.features)) {
feature.features.forEach(function(f) {
if (f && f.geometry) bbox.extend(f.geometry.coordinates);
});
} else if (feature.geometry) {
bbox.extend(feature.geometry.coordinates);
}
}

function layerDefinition(layer) {
var def = {
id: layer.id,
type: layer.type,
Expand All @@ -499,17 +560,167 @@ ${initialDataScript}
if (layer.paint) def.paint = layer.paint;
if (layer.layout) def.layout = layer.layout;
if (layer.slot) def.slot = layer.slot;
map.addLayer(def);
trackedLayerIds.push(layer.id);
return def;
}

var feature = layer.data;
if (feature.type === 'FeatureCollection' && Array.isArray(feature.features)) {
feature.features.forEach(function(f) {
if (f && f.geometry) bbox.extend(f.geometry.coordinates);
// --- Satellite / Traffic toggles -----------------------------------------
function addTrafficLayer() {
if (!map.getSource(TRAFFIC_ID)) {
map.addSource(TRAFFIC_ID, {
type: 'vector',
url: 'mapbox://mapbox.mapbox-traffic-v1'
});
} else if (feature.geometry) {
bbox.extend(feature.geometry.coordinates);
}
if (map.getLayer(TRAFFIC_ID)) return;
var colors = ['match', ['get', 'congestion']];
TRAFFIC_LEVELS.forEach(function(level) { colors.push(level.value, level.color); });
colors.push('rgba(0,0,0,0)');
map.addLayer({
id: TRAFFIC_ID,
type: 'line',
source: TRAFFIC_ID,
'source-layer': 'traffic',
// Above land and water, below roads' labels and anything we draw.
slot: 'middle',
// Light traffic on every side street turns the whole map green, so
// show it only on main roads; any slowdown shows on every road.
filter: ['any',
['!=', ['get', 'congestion'], 'low'],
['match', ['get', 'class'], TRAFFIC_MAIN_ROADS, true, false]
],
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: {
'line-color': colors,
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 1, 15, 2, 18, 4],
// Each direction is its own line; shift it to its side of the road.
'line-offset': ['interpolate', ['linear'], ['zoom'], 12, 0.5, 15, 1.5, 18, 3],
'line-opacity': 0.9
}
});
}

function removeTrafficLayer() {
if (map.getLayer(TRAFFIC_ID)) map.removeLayer(TRAFFIC_ID);
if (map.getSource(TRAFFIC_ID)) map.removeSource(TRAFFIC_ID);
}

function setTraffic(on) {
trafficOn = on;
if (map && mapLoaded) {
if (on) addTrafficLayer();
else removeTrafficLayer();
}
renderLegend();
syncToggleButtons();
}

// Switches the base style, then runs afterLoad once the new style is up.
function switchBaseStyle(styleId, afterLoad) {
currentBaseStyleId = styleId;
syncToggleButtons();
map.setStyle('mapbox://styles/mapbox/' + styleId);
map.once('style.load', afterLoad);
}

// After a style switch from the Satellite button: put back what setStyle
// dropped, without refitting the camera the user may have moved.
function restoreOverlays() {
applyBaseMapConfig(currentBaseMapConfig);
drawnLayers.forEach(function(layer) {
if (!map.getSource(layer.id)) {
map.addSource(layer.id, { type: 'geojson', data: layer.data });
}
if (!map.getLayer(layer.id)) map.addLayer(layerDefinition(layer));
});
if (trafficOn) addTrafficLayer();
}

function toggleSatellite() {
if (!map || !mapLoaded) return;
switchBaseStyle(
currentBaseStyleId === 'standard-satellite' ? 'standard' : 'standard-satellite',
restoreOverlays
);
}

// A GL JS control (onAdd returns its element). Built by a returned object
// rather than prototype methods because initMap() runs before the rest
// of this script, and addControl calls onAdd right away.
function LayerToggleControl() {
return { onAdd: buildLayerToggles, onRemove: function() {} };
}

function buildLayerToggles() {
var container = document.createElement('div');
container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group layer-toggles';
satelliteButton = buildToggleButton('Satellite view', TOGGLE_ICONS.satellite, toggleSatellite);
trafficButton = buildToggleButton('Live traffic', TOGGLE_ICONS.traffic, function() {
setTraffic(!trafficOn);
});
container.appendChild(satelliteButton);
container.appendChild(trafficButton);
syncToggleButtons();
return container;
}

function buildToggleButton(label, icon, onClick) {
var button = document.createElement('button');
button.type = 'button';
button.title = label;
if (typeof button.setAttribute === 'function') button.setAttribute('aria-label', label);
button.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" ' +
'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
'stroke-linejoin="round" aria-hidden="true">' + icon + '</svg>';
button.addEventListener('click', onClick);
return button;
}

function syncToggleButtons() {
setPressed(satelliteButton, currentBaseStyleId === 'standard-satellite');
setPressed(trafficButton, trafficOn);
}

function setPressed(button, pressed) {
if (!button) return;
button.className = pressed ? 'active' : '';
if (typeof button.setAttribute === 'function') {
button.setAttribute('aria-pressed', pressed ? 'true' : 'false');
}
}

// The summary chip covers GL JS's top-left control corner, so move the
// zoom and layer buttons below it while it's shown.
function placeTopLeftControls() {
if (!map || typeof map.getContainer !== 'function') return;
var container = map.getContainer();
var corner = container && typeof container.querySelector === 'function'
? container.querySelector('.mapboxgl-ctrl-top-left')
: null;
if (!corner) return;
corner.style.top = summaryEl.style.display === 'block'
? (summaryEl.offsetHeight + 8) + 'px'
: '';
}

// The payload's legend rows, plus the traffic colors while traffic is on.
function renderLegend() {
var rows = payloadLegend.slice();
if (trafficOn) {
TRAFFIC_LEVELS.forEach(function(level) {
rows.push({ color: level.color, label: level.label });
});
}
if (rows.length === 0) {
legendEl.style.display = 'none';
return;
}
legendEl.innerHTML = rows.map(function(row) {
var op = (typeof row.opacity === 'number') ? row.opacity : 1;
return '<div class="row"><span class="swatch" style="background:' +
escapeAttr(row.color) + ';opacity:' + op + '"></span>' +
escapeText(row.label) + '</div>';
}).join('');
legendEl.style.display = 'block';
}

function addOneMarker(m, bbox) {
Expand Down Expand Up @@ -602,7 +813,8 @@ ${initialDataScript}
// (bottom-left), unless the map is too short to spare the room.
function bottomPadding() {
if (legendEl.style.display !== 'block') return 50;
var bottom = (legendEl.offsetHeight || 0) + 24;
// The legend sits 40px up, above the logo.
var bottom = (legendEl.offsetHeight || 0) + 52;
var el = document.getElementById('map');
var height = (el && el.clientHeight) || window.innerHeight || 0;
if (bottom <= 50 || (height && height - bottom - 70 < 160)) return 50;
Expand Down Expand Up @@ -652,6 +864,7 @@ ${initialDataScript}
if (payload.summary && summaryEl.style.display === 'none') {
summaryEl.textContent = payload.summary;
summaryEl.style.display = 'block';
placeTopLeftControls();
}
maybeShowSidePanel(markers, payload.summary);
var bounds = bbox.bounds();
Expand All @@ -660,6 +873,7 @@ ${initialDataScript}

function applyBaseMapConfig(baseMapConfig) {
if (!baseMapConfig || typeof baseMapConfig !== 'object') return;
currentBaseMapConfig = baseMapConfig;
if (typeof map.setConfigProperty !== 'function') return;
Object.keys(baseMapConfig).forEach(function(key) {
try { map.setConfigProperty('basemap', key, baseMapConfig[key]); } catch (_) { /* not a Standard-based style */ }
Expand All @@ -678,9 +892,7 @@ ${initialDataScript}
? 'standard-satellite'
: (payload.baseStyle === 'standard' ? 'standard' : null);
if (desiredBaseStyleId && desiredBaseStyleId !== currentBaseStyleId) {
currentBaseStyleId = desiredBaseStyleId;
map.setStyle('mapbox://styles/mapbox/' + desiredBaseStyleId);
map.once('style.load', function() { renderBody(payload); });
switchBaseStyle(desiredBaseStyleId, function() { renderBody(payload); });
return;
}
renderBody(payload);
Expand All @@ -701,18 +913,11 @@ ${initialDataScript}
// markers gets the same results panel.
maybeShowSidePanel(markers, payload.summary);

// Legend
if (Array.isArray(payload.legend) && payload.legend.length > 0) {
legendEl.innerHTML = payload.legend.map(function(row) {
var op = (typeof row.opacity === 'number') ? row.opacity : 1;
return '<div class="row"><span class="swatch" style="background:' +
escapeAttr(row.color) + ';opacity:' + op + '"></span>' +
escapeText(row.label) + '</div>';
}).join('');
legendEl.style.display = 'block';
} else {
legendEl.style.display = 'none';
}
payloadLegend = Array.isArray(payload.legend) ? payload.legend : [];
// showTraffic set by the caller wins; left out, keep whatever the user
// picked with the Traffic button (a style switch dropped the layer, so
// setTraffic puts it back).
setTraffic(typeof payload.showTraffic === 'boolean' ? payload.showTraffic : trafficOn);

loadingEl.style.display = 'none';
requestSizeToFit();
Expand Down Expand Up @@ -744,6 +949,9 @@ ${initialDataScript}
if (!data) return;
var src = map.getSource(payload.defer.layerId);
if (src && typeof src.setData === 'function') src.setData(data);
drawnLayers.forEach(function(layer) {
if (layer.id === payload.defer.layerId) layer.data = data;
});
},
function() { /* ignore — keep what we rendered inline */ }
);
Expand Down
6 changes: 6 additions & 0 deletions src/tools/render-map-tool/RenderMapTool.input.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,12 @@ export const RenderMapInputSchema = z.object({
.optional()
.describe(
'Which Mapbox-owned base style to load; defaults to "standard". "standard-satellite" is the same Standard style family rendered over global satellite imagery instead of a vector basemap — same config-property system and bottom/middle/top slots apply, but it has no theme and no flat-color overrides like colorWater/colorBuildings (there\'s no vector surface to recolor). See https://docs.mapbox.com/map-styles/reference/standard-satellite/ for its supported baseMapConfig properties.'
),
showTraffic: z
.boolean()
.optional()
.describe(
'Show live traffic congestion (light/moderate/heavy/severe) on every road in view, from Mapbox Traffic. Use when the user asks to see traffic or congestion in an area. Works on both base styles. Left out, the map keeps whatever the user picked with its Traffic button.'
)
});

Expand Down
Loading
Loading