From a685b19da5a649f0cd2b27a5da8948fd784e72b8 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Thu, 15 Oct 2026 11:05:22 -0400 Subject: [PATCH 1/4] feat(render_map_tool): live traffic and Satellite/Traffic buttons on the map Adds Satellite and Traffic buttons under the zoom control, and a showTraffic input so the model can open the map with traffic on. Traffic is the Mapbox Traffic v1 tileset colored by congestion in the middle slot. The page keeps a copy of what it drew so a style switch puts it back, and the top-left controls move below the summary chip. Fixes #277 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + src/resources/ui-apps/mapAppHtml.ts | 225 ++++++++++++++-- .../RenderMapTool.input.schema.ts | 6 + src/tools/render-map-tool/RenderMapTool.ts | 7 +- .../render-map-tool/assembleMapPayload.ts | 15 +- src/utils/mapAppPayload.ts | 8 +- .../ui-apps/mapAppHtml.script.test.ts | 242 +++++++++++++++++- .../render-map-tool/RenderMapTool.test.ts | 33 +++ 8 files changed, 506 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8266dcf..3626a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ### New Features +- **`render_map_tool`: live traffic, and Satellite/Traffic buttons on the map (#PRNUM).** 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:** a Satellite button and a Traffic button sit under the zoom control. They show which one is on (`aria-pressed`). Pages from `export_map_html_tool` get them too, since they share the script. + - **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. 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. + - **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 that the tileset's source layer is `traffic`. + - **`export_map_html_tool`: the `render_map_tool` map as a standalone HTML page, for agents building sites or files (#275).** `render_map_tool` renders only inside the chat (as an MCP App), so an agent building a page had to write its own Mapbox GL JS code and ask the user for a public token. That's what happened in ChatGPT/Codex when someone asked for a site with a map. The new tool takes the same input as `render_map_tool` and returns a complete HTML document of about 3 KB plus the map data. The data and a public token are written into JSON tags in the page, and the map script loads from jsDelivr. The token is the account's default public token, resolved the same way the in-chat map does it, or `public_token` if passed. The tool result recommends a URL-restricted token for published sites. The page stays small because the agent has to copy the whole document into a file write; inlining the ~60 KB map script would mean re-emitting ~16k tokens. - **Shared map script:** the in-chat page's script is now `mapAppScript()`, and its markup and styles are `mapAppShell()`. The in-chat page output is byte-for-byte unchanged. The build writes a standalone copy that reads its token and API endpoint from the page (`dist/map-app/standalone.js`, shipped in the npm package). Pages pin it by package version and a sha384 integrity hash. Running from source, where the version can't be read, the tool inlines the script instead. - **Live layers:** routes, isochrones and search results are still fetched by the page on load, as the in-chat map does, so each page view calls the Mapbox API on the token's account. The tool result says how many layers do this. diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index 1a376c4..2e5b44b 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -104,6 +104,12 @@ export function mapAppShell(glVersion: string): string { 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 { + width: auto; min-width: 29px; padding: 0 8px; + font-size: 11px; font-weight: 600; color: #1f2937; + } + .mapboxgl-ctrl-group.layer-toggles button.active { background: #2563eb; color: #fff; } #side-panel { position: absolute; top: 12px; right: 12px; bottom: 12px; z-index: 10; width: 280px; max-width: calc(100% - 24px); @@ -190,6 +196,27 @@ export function mapAppScript(config: { 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' } + ]; + + // 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; @@ -311,6 +338,7 @@ export function mapAppScript(config: { } 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) { @@ -443,6 +471,7 @@ export function mapAppScript(config: { } else { summaryEl.style.display = 'none'; } + placeTopLeftControls(); if (!map) { loadingEl.style.display = 'none'; return; } if (mapLoaded) render(payload); else pendingPayload = payload; @@ -466,6 +495,7 @@ export function mapAppScript(config: { trackedLayerIds = []; trackedSourceIds = []; trackedMarkers = []; + drawnLayers = []; markersById = {}; panelEl.style.display = 'none'; clearPanel(); @@ -518,7 +548,24 @@ export function mapAppScript(config: { 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, @@ -527,17 +574,153 @@ export function mapAppScript(config: { 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', + layout: { 'line-join': 'round', 'line-cap': 'round' }, + paint: { + 'line-color': colors, + 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 14, 2.5, 18, 6] + } + }); + } + + 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', toggleSatellite); + trafficButton = buildToggleButton('Traffic', function() { setTraffic(!trafficOn); }); + container.appendChild(satelliteButton); + container.appendChild(trafficButton); + syncToggleButtons(); + return container; + } + + function buildToggleButton(label, onClick) { + var button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.title = 'Show ' + label.toLowerCase(); + 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 '
' + + escapeText(row.label) + '
'; + }).join(''); + legendEl.style.display = 'block'; } function addOneMarker(m, bbox) { @@ -680,6 +863,7 @@ export function mapAppScript(config: { if (payload.summary && summaryEl.style.display === 'none') { summaryEl.textContent = payload.summary; summaryEl.style.display = 'block'; + placeTopLeftControls(); } maybeShowSidePanel(markers, payload.summary); var bounds = bbox.bounds(); @@ -688,6 +872,7 @@ export function mapAppScript(config: { 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 */ } @@ -706,9 +891,7 @@ export function mapAppScript(config: { ? '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); @@ -729,18 +912,11 @@ export function mapAppScript(config: { // 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 '
' + - escapeText(row.label) + '
'; - }).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(); @@ -772,6 +948,9 @@ export function mapAppScript(config: { 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 */ } ); diff --git a/src/tools/render-map-tool/RenderMapTool.input.schema.ts b/src/tools/render-map-tool/RenderMapTool.input.schema.ts index e548104..4c8e6d8 100644 --- a/src/tools/render-map-tool/RenderMapTool.input.schema.ts +++ b/src/tools/render-map-tool/RenderMapTool.input.schema.ts @@ -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.' ) }); diff --git a/src/tools/render-map-tool/RenderMapTool.ts b/src/tools/render-map-tool/RenderMapTool.ts index 69da087..6ff1348 100644 --- a/src/tools/render-map-tool/RenderMapTool.ts +++ b/src/tools/render-map-tool/RenderMapTool.ts @@ -63,6 +63,9 @@ export class RenderMapTool extends BaseTool< 'To restyle the base map itself (e.g. "make the water red", switch to night ' + 'lighting, hide POI labels) pass `baseMapConfig`. To switch to satellite ' + 'imagery, pass `baseStyle: "standard-satellite"` (defaults to "standard"). ' + + 'To show live traffic congestion on the roads in view, pass ' + + '`showTraffic: true`. The map also has Satellite and Traffic buttons ' + + 'users can toggle themselves. ' + 'Every call is a fresh render — it does not accumulate state — so to ' + 'restyle an already-drawn map without losing its content, re-pass the ' + 'same `payload_refs`/`layers` alongside `baseMapConfig`/`baseStyle` rather ' + @@ -172,7 +175,9 @@ export class RenderMapTool extends BaseTool< : ''; const baseMapConfigNote = - payload.baseMapConfig || payload.baseStyle + payload.baseMapConfig || + payload.baseStyle || + payload.showTraffic !== undefined ? ' Updated the base map style.' : ''; diff --git a/src/tools/render-map-tool/assembleMapPayload.ts b/src/tools/render-map-tool/assembleMapPayload.ts index 4eb8ab0..06a0789 100644 --- a/src/tools/render-map-tool/assembleMapPayload.ts +++ b/src/tools/render-map-tool/assembleMapPayload.ts @@ -61,32 +61,35 @@ export function assembleMapPayload( camera: input.camera as MapAppPayload['camera'], summary: input.summary, baseMapConfig: input.baseMapConfig, - baseStyle: input.baseStyle + baseStyle: input.baseStyle, + showTraffic: input.showTraffic }; const hasInlineContent = (inline.layers && inline.layers.length > 0) || (inline.markers && inline.markers.length > 0); const all: MapAppPayload[] = [...fromRefs]; - // baseMapConfig/baseStyle-only calls (e.g. "now turn the water red" or - // "switch to satellite" with no new data) are renderable on their own — - // treated like summary/legend. + // baseMapConfig/baseStyle/showTraffic-only calls (e.g. "now turn the + // water red", "switch to satellite" or "show traffic" with no new data) + // are renderable on their own — treated like summary/legend. if ( hasInlineContent || inline.summary || inline.legend || inline.baseMapConfig || - inline.baseStyle + inline.baseStyle || + inline.showTraffic !== undefined ) all.push(inline); if (all.length === 0) return { payload: null, unresolvedRefs }; const merged = mergeMapPayloads(all); - // Inline summary/camera/legend/baseMapConfig/baseStyle take precedence when provided. + // Inline summary/camera/legend/baseMapConfig/baseStyle/showTraffic take precedence when provided. if (input.summary) merged.summary = input.summary; if (input.camera) merged.camera = inline.camera; if (input.legend) merged.legend = input.legend; if (input.baseMapConfig) merged.baseMapConfig = input.baseMapConfig; if (input.baseStyle) merged.baseStyle = input.baseStyle; + if (input.showTraffic !== undefined) merged.showTraffic = input.showTraffic; return { payload: merged, unresolvedRefs }; } diff --git a/src/utils/mapAppPayload.ts b/src/utils/mapAppPayload.ts index 20bcee3..b0170c6 100644 --- a/src/utils/mapAppPayload.ts +++ b/src/utils/mapAppPayload.ts @@ -200,6 +200,11 @@ export interface MapAppPayload { baseMapConfig?: MapAppBaseMapConfig; /** Which Mapbox-owned base style to load; defaults to `standard`. */ baseStyle?: MapAppBaseStyle; + /** + * Live traffic congestion over the base map. Left out, the map keeps + * whatever the user picked with its Traffic button. + */ + showTraffic?: boolean; } // Zod mirror of MapAppPayload, kept loose on paint/layout/geometry internals @@ -316,7 +321,8 @@ export const MapAppPayloadSchema = z.object({ camera: MapAppCameraSchema.optional(), selfFetch: z.array(MapAppSelfFetchSchema).optional(), baseMapConfig: MapAppBaseMapConfigSchema.optional(), - baseStyle: z.enum(['standard', 'standard-satellite']).optional() + baseStyle: z.enum(['standard', 'standard-satellite']).optional(), + showTraffic: z.boolean().optional() }); /** diff --git a/test/resources/ui-apps/mapAppHtml.script.test.ts b/test/resources/ui-apps/mapAppHtml.script.test.ts index b7b72ee..fca34f0 100644 --- a/test/resources/ui-apps/mapAppHtml.script.test.ts +++ b/test/resources/ui-apps/mapAppHtml.script.test.ts @@ -64,6 +64,10 @@ function loadScriptSandbox(options?: { style: {} as Record, textContent: '', className: '', + attributes: {} as Record, + setAttribute(name: string, value: string) { + this.attributes[name] = value; + }, children, appendChild: (child: FakeElement) => { children.push(child); @@ -89,6 +93,8 @@ function loadScriptSandbox(options?: { style: Record; textContent: string; className: string; + attributes: Record; + setAttribute: (name: string, value: string) => void; children: FakeElement[]; appendChild: (child: FakeElement) => FakeElement; removeChild: (child: FakeElement) => FakeElement; @@ -121,8 +127,22 @@ function loadScriptSandbox(options?: { const markerConstructorCalls: Array> = []; const setStyleCalls: string[] = []; const fitBoundsCalls: Array<{ padding: Record }> = []; + // What's currently on the style, like GL JS: setStyle() drops it all. + const styleSources: Record> = {}; + const styleLayers: Record> = {}; + const controls: FakeElement[] = []; + const topLeftCorner = fakeElement(); const fakeMapInstance = { - addControl: () => {}, + // GL JS calls a control's onAdd right away and mounts what it returns. + addControl: (control: { onAdd?: (map: unknown) => FakeElement }) => { + if (control && typeof control.onAdd === 'function') { + controls.push(control.onAdd(fakeMapInstance)); + } + }, + getContainer: () => ({ + querySelector: (selector: string) => + selector === '.mapboxgl-ctrl-top-left' ? topLeftCorner : null + }), on: (event: string, cb: () => void) => { if (event === 'load') cb(); }, @@ -134,13 +154,23 @@ function loadScriptSandbox(options?: { }, setStyle: (styleUrl: string) => { setStyleCalls.push(styleUrl); + for (const id of Object.keys(styleSources)) delete styleSources[id]; + for (const id of Object.keys(styleLayers)) delete styleLayers[id]; + }, + addSource: (id: string, source: Record) => { + styleSources[id] = source; + }, + addLayer: (layer: Record) => { + styleLayers[layer.id as string] = layer; + }, + getLayer: (id: string) => styleLayers[id] ?? null, + getSource: (id: string) => styleSources[id] ?? null, + removeLayer: (id: string) => { + delete styleLayers[id]; + }, + removeSource: (id: string) => { + delete styleSources[id]; }, - addSource: () => {}, - addLayer: () => {}, - getLayer: () => null, - getSource: () => null, - removeLayer: () => {}, - removeSource: () => {}, fitBounds: ( _bounds: unknown, opts: { padding: Record } @@ -276,6 +306,14 @@ function loadScriptSandbox(options?: { mapConstructorCalls, markerConstructorCalls, setStyleCalls, + styleSources, + styleLayers, + controls, + topLeftCorner, + legendEl: getElementById('legend') as unknown as { + innerHTML?: string; + style: Record; + }, errorEl: elementsById.error, summaryEl: elementsById.summary, sidePanelEl: getElementById('side-panel'), @@ -672,6 +710,196 @@ describe('mapAppHtml baseMapConfig and slot', () => { }); }); +describe('mapAppHtml Satellite and Traffic toggles', () => { + const routeLayer = { + id: 'route', + type: 'line', + slot: 'top', + data: { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: [ + [-122.4, 37.78], + [-122.41, 37.79] + ] + } + } + }; + + function sendPayload( + sandbox: ReturnType, + payload: Record + ) { + sandbox.sendToolResult({ + structuredContent: { mapboxRender: { layers: [], ...payload } } + }); + } + + function buttons(sandbox: ReturnType) { + expect(sandbox.controls).toHaveLength(1); + const [satellite, traffic] = sandbox.controls[0].children; + return { satellite, traffic }; + } + + it('adds Satellite and Traffic buttons under the zoom control, both off', () => { + const sandbox = loadScriptSandbox(); + const { satellite, traffic } = buttons(sandbox); + + expect(sandbox.controls[0].className).toContain('mapboxgl-ctrl-group'); + expect(satellite.textContent).toBe('Satellite'); + expect(traffic.textContent).toBe('Traffic'); + expect(satellite.attributes['aria-pressed']).toBe('false'); + expect(traffic.attributes['aria-pressed']).toBe('false'); + }); + + it('the Traffic button adds live congestion under labels and adds it to the legend', () => { + const sandbox = loadScriptSandbox(); + sendPayload(sandbox, { layers: [routeLayer] }); + const { traffic } = buttons(sandbox); + + traffic.__fireClick(); + + expect(sandbox.styleSources['mapbox-live-traffic']).toEqual({ + type: 'vector', + url: 'mapbox://mapbox.mapbox-traffic-v1' + }); + const layer = sandbox.styleLayers['mapbox-live-traffic']; + expect(layer).toMatchObject({ + type: 'line', + 'source-layer': 'traffic', + slot: 'middle' + }); + expect((layer.paint as Record)['line-color']).toContain( + 'severe' + ); + expect(traffic.attributes['aria-pressed']).toBe('true'); + expect(traffic.className).toBe('active'); + expect(sandbox.legendEl.style.display).toBe('block'); + expect(sandbox.legendEl.innerHTML).toContain('Heavy traffic'); + + traffic.__fireClick(); + + expect(sandbox.styleLayers['mapbox-live-traffic']).toBeUndefined(); + expect(sandbox.styleSources['mapbox-live-traffic']).toBeUndefined(); + expect(traffic.attributes['aria-pressed']).toBe('false'); + expect(sandbox.legendEl.style.display).toBe('none'); + }); + + it('keeps the payload legend rows alongside the traffic ones', () => { + const sandbox = loadScriptSandbox(); + sendPayload(sandbox, { + layers: [routeLayer], + legend: [{ color: '#3b82f6', label: 'Route' }] + }); + + buttons(sandbox).traffic.__fireClick(); + + expect(sandbox.legendEl.innerHTML).toContain('Route'); + expect(sandbox.legendEl.innerHTML).toContain('Light traffic'); + }); + + it('the Satellite button switches style and puts back the drawn layers and traffic, without moving the camera', async () => { + const sandbox = loadScriptSandbox(); + sendPayload(sandbox, { layers: [routeLayer] }); + await new Promise((resolve) => setTimeout(resolve, 80)); + const fitsBefore = sandbox.fitBoundsCalls.length; + const { satellite, traffic } = buttons(sandbox); + traffic.__fireClick(); + + satellite.__fireClick(); + + expect(sandbox.setStyleCalls).toEqual([ + 'mapbox://styles/mapbox/standard-satellite' + ]); + expect(sandbox.styleLayers.route).toMatchObject({ + type: 'line', + source: 'route', + slot: 'top' + }); + expect(sandbox.styleSources.route).toMatchObject({ type: 'geojson' }); + expect(sandbox.styleLayers['mapbox-live-traffic']).toBeDefined(); + expect(satellite.attributes['aria-pressed']).toBe('true'); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(sandbox.fitBoundsCalls.length).toBe(fitsBefore); + + satellite.__fireClick(); + + expect(sandbox.setStyleCalls[1]).toBe('mapbox://styles/mapbox/standard'); + expect(sandbox.styleLayers.route).toBeDefined(); + expect(satellite.attributes['aria-pressed']).toBe('false'); + }); + + it('reapplies the base map config after the Satellite button switches style', () => { + const sandbox = loadScriptSandbox(); + const setConfigProperty = vi.fn(); + (sandbox.map as { setConfigProperty?: unknown }).setConfigProperty = + setConfigProperty; + sendPayload(sandbox, { + baseMapConfig: { showPointOfInterestLabels: false } + }); + setConfigProperty.mockClear(); + + buttons(sandbox).satellite.__fireClick(); + + expect(setConfigProperty).toHaveBeenCalledWith( + 'basemap', + 'showPointOfInterestLabels', + false + ); + }); + + it('a baseStyle from the model updates the Satellite button', () => { + const sandbox = loadScriptSandbox(); + sendPayload(sandbox, { baseStyle: 'standard-satellite' }); + + expect(buttons(sandbox).satellite.attributes['aria-pressed']).toBe('true'); + }); + + it('showTraffic: true turns traffic on, and a later render without it keeps it on', () => { + const sandbox = loadScriptSandbox(); + sendPayload(sandbox, { layers: [routeLayer], showTraffic: true }); + + expect(sandbox.styleLayers['mapbox-live-traffic']).toBeDefined(); + expect(buttons(sandbox).traffic.attributes['aria-pressed']).toBe('true'); + + sendPayload(sandbox, { layers: [routeLayer] }); + expect(sandbox.styleLayers['mapbox-live-traffic']).toBeDefined(); + + sendPayload(sandbox, { layers: [routeLayer], showTraffic: false }); + expect(sandbox.styleLayers['mapbox-live-traffic']).toBeUndefined(); + }); + + it('keeps traffic on when the model switches base style', () => { + const sandbox = loadScriptSandbox(); + sendPayload(sandbox, { layers: [routeLayer], showTraffic: true }); + + sendPayload(sandbox, { + layers: [routeLayer], + baseStyle: 'standard-satellite' + }); + + expect(sandbox.setStyleCalls).toEqual([ + 'mapbox://styles/mapbox/standard-satellite' + ]); + expect(sandbox.styleLayers['mapbox-live-traffic']).toBeDefined(); + expect(sandbox.styleLayers.route).toBeDefined(); + }); + + it('moves the top-left controls below the summary chip while it shows', () => { + const sandbox = loadScriptSandbox(); + (sandbox.summaryEl as unknown as { offsetHeight: number }).offsetHeight = + 30; + + sendPayload(sandbox, { summary: 'Downtown traffic' }); + expect(sandbox.topLeftCorner.style.top).toBe('38px'); + + sendPayload(sandbox, { layers: [routeLayer] }); + expect(sandbox.topLeftCorner.style.top).toBe(''); + }); +}); + describe('mapAppHtml directions self-fetch', () => { it('fetches and draws the route itself from a selfFetch descriptor', async () => { const { sendToolResult, setFetchImpl, map, summaryEl, errorEl } = diff --git a/test/tools/render-map-tool/RenderMapTool.test.ts b/test/tools/render-map-tool/RenderMapTool.test.ts index db28643..cf02002 100644 --- a/test/tools/render-map-tool/RenderMapTool.test.ts +++ b/test/tools/render-map-tool/RenderMapTool.test.ts @@ -747,4 +747,37 @@ describe('RenderMapTool', () => { }; expect(sc.mapboxRender?.baseStyle).toBe('standard-satellite'); }); + + it('accepts a showTraffic-only call and carries it through the inline ref', async () => { + const { resolveInlinePayloadRef } = + await import('../../../src/utils/inlinePayloadRef.js'); + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run({ showTraffic: true }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { + mapboxRender?: { showTraffic?: boolean; ref?: string }; + }; + expect(sc.mapboxRender?.showTraffic).toBe(true); + expect(sc.mapboxRender?.ref).toBeDefined(); + expect(resolveInlinePayloadRef(sc.mapboxRender!.ref!)?.showTraffic).toBe( + true + ); + }); + + it('carries showTraffic: false through so the model can turn traffic off', async () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run({ showTraffic: false }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { + mapboxRender?: { showTraffic?: boolean }; + }; + expect(sc.mapboxRender?.showTraffic).toBe(false); + }); + + it('tells the model it can show live traffic', () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + expect(tool.description).toContain('showTraffic: true'); + }); }); From a7a457b8b121158392e1589841dbbfc3f34ada4f Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Thu, 15 Oct 2026 11:05:50 -0400 Subject: [PATCH 2/4] docs(changelog): PR number for traffic toggles Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3626a8d..325995e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### New Features -- **`render_map_tool`: live traffic, and Satellite/Traffic buttons on the map (#PRNUM).** 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. +- **`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:** a Satellite button and a Traffic button sit under the zoom control. They show which one is on (`aria-pressed`). Pages from `export_map_html_tool` get them too, since they share the script. - **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. 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`. From b4372d572804d8d7991ef81ce93b9d46fc78a404 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Thu, 15 Oct 2026 11:21:03 -0400 Subject: [PATCH 3/4] fix(render_map_tool): icon toggle buttons, lighter traffic lines The text buttons didn't fit GL JS's 29px control column, so they're now icon buttons (globe, traffic light) like zoom and compass. Light traffic on every side street turned the map green, so it shows only on main roads; each direction is offset to its side of the road, and lines are thinner. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +-- src/resources/ui-apps/mapAppHtml.ts | 44 +++++++++++++++---- .../ui-apps/mapAppHtml.script.test.ts | 9 +++- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 325995e..e0697aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,12 @@ ### 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:** a Satellite button and a Traffic button sit under the zoom control. They show which one is on (`aria-pressed`). Pages from `export_map_html_tool` get them too, since they share the script. - - **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. 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. + - **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`. Pages from `export_map_html_tool` get them too, since they share the script. + - **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. - - **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 that the tileset's source layer is `traffic`. + - **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. - **`export_map_html_tool`: the `render_map_tool` map as a standalone HTML page, for agents building sites or files (#275).** `render_map_tool` renders only inside the chat (as an MCP App), so an agent building a page had to write its own Mapbox GL JS code and ask the user for a public token. That's what happened in ChatGPT/Codex when someone asked for a site with a map. The new tool takes the same input as `render_map_tool` and returns a complete HTML document of about 3 KB plus the map data. The data and a public token are written into JSON tags in the page, and the map script loads from jsDelivr. The token is the account's default public token, resolved the same way the in-chat map does it, or `public_token` if passed. The tool result recommends a URL-restricted token for published sites. The page stays small because the agent has to copy the whole document into a file write; inlining the ~60 KB map script would mean re-emitting ~16k tokens. - **Shared map script:** the in-chat page's script is now `mapAppScript()`, and its markup and styles are `mapAppShell()`. The in-chat page output is byte-for-byte unchanged. The build writes a standalone copy that reads its token and API endpoint from the page (`dist/map-app/standalone.js`, shipped in the npm package). Pages pin it by package version and a sha384 integrity hash. Running from source, where the version can't be read, the tool inlines the script instead. diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index 2e5b44b..d0b90ab 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -106,10 +106,10 @@ export function mapAppShell(glVersion: string): string { } /* Satellite / Traffic buttons under the zoom control (see buildLayerToggles). */ .mapboxgl-ctrl-group.layer-toggles button { - width: auto; min-width: 29px; padding: 0 8px; - font-size: 11px; font-weight: 600; color: #1f2937; + display: flex; align-items: center; justify-content: center; color: #333; } - .mapboxgl-ctrl-group.layer-toggles button.active { background: #2563eb; color: #fff; } + .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; width: 280px; max-width: calc(100% - 24px); @@ -212,6 +212,18 @@ export function mapAppScript(config: { { 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: '' + + '', + traffic: '' + + '' + + '' + }; // Set by the layer toggle control, which initMap() adds before the rest // of this script has run. @@ -596,10 +608,19 @@ export function mapAppScript(config: { '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'], 10, 1, 14, 2.5, 18, 6] + '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 } }); } @@ -658,19 +679,24 @@ export function mapAppScript(config: { function buildLayerToggles() { var container = document.createElement('div'); container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group layer-toggles'; - satelliteButton = buildToggleButton('Satellite', toggleSatellite); - trafficButton = buildToggleButton('Traffic', function() { setTraffic(!trafficOn); }); + 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, onClick) { + function buildToggleButton(label, icon, onClick) { var button = document.createElement('button'); button.type = 'button'; - button.textContent = label; - button.title = 'Show ' + label.toLowerCase(); + button.title = label; + if (typeof button.setAttribute === 'function') button.setAttribute('aria-label', label); + button.innerHTML = ''; button.addEventListener('click', onClick); return button; } diff --git a/test/resources/ui-apps/mapAppHtml.script.test.ts b/test/resources/ui-apps/mapAppHtml.script.test.ts index fca34f0..ea28c45 100644 --- a/test/resources/ui-apps/mapAppHtml.script.test.ts +++ b/test/resources/ui-apps/mapAppHtml.script.test.ts @@ -748,8 +748,8 @@ describe('mapAppHtml Satellite and Traffic toggles', () => { const { satellite, traffic } = buttons(sandbox); expect(sandbox.controls[0].className).toContain('mapboxgl-ctrl-group'); - expect(satellite.textContent).toBe('Satellite'); - expect(traffic.textContent).toBe('Traffic'); + expect(satellite.attributes['aria-label']).toBe('Satellite view'); + expect(traffic.attributes['aria-label']).toBe('Live traffic'); expect(satellite.attributes['aria-pressed']).toBe('false'); expect(traffic.attributes['aria-pressed']).toBe('false'); }); @@ -774,6 +774,11 @@ describe('mapAppHtml Satellite and Traffic toggles', () => { expect((layer.paint as Record)['line-color']).toContain( 'severe' ); + // Light traffic only on main roads; slowdowns on every road. + const filter = JSON.stringify(layer.filter); + expect(filter).toContain('["!=",["get","congestion"],"low"]'); + expect(filter).toContain('"primary"'); + expect(filter).not.toContain('"street"'); expect(traffic.attributes['aria-pressed']).toBe('true'); expect(traffic.className).toBe('active'); expect(sandbox.legendEl.style.display).toBe('block'); From 9d1feef8c821d27567e6f6c75502d704982ae469 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Thu, 15 Oct 2026 11:23:32 -0400 Subject: [PATCH 4/4] fix(render_map_tool): keep the Mapbox logo and attribution uncovered The legend sat on the logo and an open results panel on the attribution. Move the legend above the logo, stop the panel above the attribution, and fit data above the legend's new spot. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + src/resources/ui-apps/mapAppHtml.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0697aa..10c49b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **`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. - **`export_map_html_tool`: the `render_map_tool` map as a standalone HTML page, for agents building sites or files (#275).** `render_map_tool` renders only inside the chat (as an MCP App), so an agent building a page had to write its own Mapbox GL JS code and ask the user for a public token. That's what happened in ChatGPT/Codex when someone asked for a site with a map. The new tool takes the same input as `render_map_tool` and returns a complete HTML document of about 3 KB plus the map data. The data and a public token are written into JSON tags in the page, and the map script loads from jsDelivr. The token is the account's default public token, resolved the same way the in-chat map does it, or `public_token` if passed. The tool result recommends a URL-restricted token for published sites. The page stays small because the agent has to copy the whole document into a file write; inlining the ~60 KB map script would mean re-emitting ~16k tokens. diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index d0b90ab..b742681 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -76,8 +76,10 @@ export function mapAppShell(glVersion: string): string { 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; @@ -111,7 +113,7 @@ export function mapAppShell(glVersion: string): string { .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); @@ -839,7 +841,8 @@ export function mapAppScript(config: { // (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;