/* * Copyright (C) 2025 TomTom Navigation B.V. * Licensed under the Apache License, Version 2.0 */ import { App } from "@modelcontextprotocol/ext-apps"; import { TomTomMap, TrafficIncidentsModule } from "@tomtom-org/maps-sdk/map"; import type { LayerSpecification, SourceSpecification } from "maplibre-gl"; import { Marker, Popup } from "maplibre-gl"; import type { MapState as ServerMapState } from "../../../services/map/dynamicMapTypes"; import { extractSvgPaths, POI_ICON_SVGS } from "../../../services/map/poiIconData"; import { trackAppData } from "../../shared/appData"; import { buildIncidentPopupHtml } from "../../shared/incident-popup"; import { createMapControls } from "../../shared/map-controls"; import { escapeHtml, injectPoiPopupStyles } from "../../shared/poi-popup"; import { ensureTomTomConfigured } from "../../shared/sdk-config"; import { hideMapUI, shouldShowUI, showErrorUI, showMapUI } from "../../shared/ui-visibility"; import "./styles.css"; // Subset of MapLibre layer types used by the dynamic map tool type LayerDefinition = Extract; type MapState = Omit & { layers: LayerDefinition[] }; // State tracking let map: TomTomMap | null = null; let mapReady = false; let pendingData: MapState | null = null; let activePopup: Popup | null = null; let currentMapState: MapState | null = null; let polygonLabelMarkers: Marker[] = []; let trafficIncidentsModule: TrafficIncidentsModule | null = null; const registeredIconImages = new Set(); // Map pin SVG path (24x29 viewBox) — compact teardrop pin from search-poi-default-big.svg const MAP_PIN_PATH = "M12 0.299805C18.6274 0.299805 24 5.67239 24 12.2998C24 16.3318 22.011 19.8976 18.9609 22.0724C16.6127 23.7469 14.1021 25.4307 12.79 27.999C12.4489 28.6666 11.5511 28.6666 11.21 27.999C9.89722 25.4306 7.38622 23.7468 5.03788 22.0718C1.98845 19.8968 0 16.3313 0 12.2998C0 5.67239 5.37258 0.299805 12 0.299805Z"; const MAP_PIN_SVG_WIDTH = 24; const MAP_PIN_SVG_HEIGHT = 29; /** * Generate map pin image for MapLibre. * Returns ImageData — blue teardrop pin on transparent background. */ function generatePinImage(): ImageData { const w = 48; const h = 58; const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d")!; const scale = h / MAP_PIN_SVG_HEIGHT; const offsetX = (w - MAP_PIN_SVG_WIDTH * scale) / 2; ctx.save(); ctx.translate(offsetX, 0); ctx.scale(scale, scale); const path = new Path2D(MAP_PIN_PATH); ctx.fillStyle = "#1988CF"; ctx.fill(path); // Dark border for depth ctx.strokeStyle = "rgba(0, 0, 0, 0.3)"; ctx.lineWidth = 1; ctx.stroke(path); ctx.restore(); return ctx.getImageData(0, 0, w, h); } /** * Generate an icon marker image for MapLibre: colored teardrop pin with white SVG icon. * Same shape as the blue pin-marker but filled with the category color and icon inside. * Returns ImageData at 2x resolution (68x82) for retina displays. */ function generateIconMarkerImage(svgContent: string, color: string): ImageData { const w = 68; const h = 82; const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; const ctx = canvas.getContext("2d")!; const pinScale = h / MAP_PIN_SVG_HEIGHT; const offsetX = (w - MAP_PIN_SVG_WIDTH * pinScale) / 2; ctx.save(); ctx.translate(offsetX, 0); ctx.scale(pinScale, pinScale); // Colored teardrop background const pinPath = new Path2D(MAP_PIN_PATH); ctx.fillStyle = color; ctx.fill(pinPath); // Subtle dark border for depth ctx.strokeStyle = "rgba(0, 0, 0, 0.2)"; ctx.lineWidth = 0.4; ctx.stroke(pinPath); // White icon centered in the circular head (circle center ≈ 12, 12 in the 24x29 viewBox) const paths = extractSvgPaths(svgContent); const iconSize = 18; // icon size in viewBox units (scaled up) const iconScale = iconSize / 24; // SVGs are 24x24 viewBox const circleCenterX = MAP_PIN_SVG_WIDTH / 2; // 12 const circleCenterY = 12; // center of circular head ctx.translate(circleCenterX - iconSize / 2, circleCenterY - iconSize / 2); ctx.scale(iconScale, iconScale); for (const p of paths) { const path = new Path2D(p.d); ctx.fillStyle = "#ffffff"; ctx.fill(path, p.fillRule); } ctx.restore(); return ctx.getImageData(0, 0, w, h); } function clearPolygonLabelMarkers(): void { for (const marker of polygonLabelMarkers) { marker.remove(); } polygonLabelMarkers = []; } /** * Add polygon label pills as HTML markers on the map. * Uses DOM elements with CSS border-radius for a guaranteed pill shape. */ function addPolygonLabelMarkers(mapState: MapState): void { if (!map) return; clearPolygonLabelMarkers(); const source = mapState.sources.polygonCenters; if (!source?.data?.features) return; for (const feature of source.data.features) { const coords = (feature.geometry as { coordinates: [number, number] }).coordinates; const props = feature.properties || {}; const label = String(props.label || ""); const color = String(props.strokeColor || "#333333"); const el = document.createElement("div"); el.className = "polygon-label-pill"; const dot = document.createElement("span"); dot.className = "polygon-label-dot"; dot.style.backgroundColor = color; el.appendChild(dot); const text = document.createElement("span"); text.className = "polygon-label-text"; text.textContent = label; el.appendChild(text); const marker = new Marker({ element: el, anchor: "center" }) .setLngLat(coords) .addTo(map.mapLibreMap); polygonLabelMarkers.push(marker); } } /** * Show a popup on the map, closing any existing popup first. */ function showPopup( lngLat: [number, number], html: string, offset: [number, number] = [0, -6] ): void { if (!map) return; if (activePopup) { activePopup.remove(); activePopup = null; } activePopup = new Popup({ closeButton: true, maxWidth: "360px", className: "poi-popup-container dynamic-map-popup-container", offset, }) .setLngLat(lngLat) .setHTML(html) .addTo(map.mapLibreMap); activePopup.on("close", () => { activePopup = null; }); } const app = new App({ name: "TomTom Dynamic Map", version: "1.0.0" }); const appData = trackAppData(app); async function initializeMap(mapState: MapState): Promise { if (map) { await updateMapState(mapState); return; } injectPoiPopupStyles(); await ensureTomTomConfigured(app); map = new TomTomMap({ mapLibre: { container: "sdk-map", center: mapState.view.center, zoom: mapState.view.zoom, }, }); // Initialize traffic incidents module (hidden by default, user toggles via button) trafficIncidentsModule = await TrafficIncidentsModule.get(map, { visible: false }); trafficIncidentsModule.events.on("click", (feature, lngLat) => { showPopup([lngLat.lng, lngLat.lat], buildIncidentPopupHtml(feature.properties), [0, -12]); }); await createMapControls(map, { position: "top-right", showTrafficToggle: true, showIncidentsToggle: true, showThemeToggle: true, externalIncidentsModule: trafficIncidentsModule, onThemeChange: () => { if (currentMapState && map && mapReady) { addSourcesAndLayers(currentMapState); setupInteractivity(currentMapState); } }, }); return new Promise((resolve) => { const onReady = () => { mapReady = true; currentMapState = mapState; addSourcesAndLayers(mapState); setupInteractivity(mapState); fitMapToBounds(mapState.view.bounds); if (pendingData) { updateMapState(pendingData); pendingData = null; } resolve(); }; if (map!.mapLibreMap.loaded()) { onReady(); } else { map!.mapLibreMap.on("load", onReady); } }); } /** * Add GeoJSON sources and layers to the map */ function addSourcesAndLayers(mapState: MapState): void { if (!map) return; const mlMap = map.mapLibreMap; // Fixed-color (blue) pin image, not SDF if (!mlMap.hasImage("pin-marker")) { mlMap.addImage("pin-marker", generatePinImage(), { pixelRatio: 2 }); } // Register icon marker images for each unique (iconKey, color) pair if (mapState.sources.markers) { for (const feature of mapState.sources.markers.data.features) { const props = feature.properties; if (props?.markerType === "icon" && props?.iconKey && props?.color) { const imageId = props.iconImageId as string; if (imageId && !registeredIconImages.has(imageId) && !mlMap.hasImage(imageId)) { const svgContent = POI_ICON_SVGS[props.iconKey as string]; if (svgContent) { const imageData = generateIconMarkerImage(svgContent, props.color as string); mlMap.addImage(imageId, imageData, { pixelRatio: 2 }); registeredIconImages.add(imageId); } } } } } for (const [sourceName, sourceData] of Object.entries(mapState.sources)) { if (sourceData && !mlMap.getSource(sourceName)) { mlMap.addSource(sourceName, sourceData as SourceSpecification); } } // Add layers in order (they're already ordered: polygons -> routes -> markers) // Skip polygon-labels symbol layer — we use HTML markers instead for pill shape for (const layer of mapState.layers) { if (layer.id === "polygon-labels") continue; if (!mlMap.getLayer(layer.id)) { mlMap.addLayer(layer); } } addPolygonLabelMarkers(mapState); } /** * Build popup HTML for a marker feature. */ function buildMarkerPopupHtml(props: Record): string { const label = escapeHtml(String(props.label || "Marker")); const category = props.category as string | undefined; const description = props.description as string | undefined; const address = props.address as string | undefined; const priority = props.priority as string | undefined; // Parse tags — stored as JSON string in GeoJSON properties let tags: string[] = []; if (props.tags) { try { tags = JSON.parse(String(props.tags)); } catch { /* not valid JSON */ } } let html = `
`; // Category line (small, muted — like poi-popup) if (category) { html += `
${escapeHtml(category)}
`; } html += `

${label}

`; if (description) { html += `
${escapeHtml(description)}
`; } if (address) { html += `
${escapeHtml(address)}
`; } // Tags as badges if (tags.length > 0) { html += `
`; for (const tag of tags) { html += `${escapeHtml(tag)}`; } html += `
`; } // Priority badge if (priority && priority !== "normal") { html += `${escapeHtml(priority)}`; } html += `
`; return html; } /** * Build popup HTML for a route feature. */ function buildRoutePopupHtml(props: Record): string { const name = escapeHtml(String(props.routeName || "Route")); const isCalculatedRoute = !!(props.distance && props.travelTime); let html = `
`; if (!isCalculatedRoute) { html += `
Custom path
`; } html += `

${name}

`; if (isCalculatedRoute) { const stats: string[] = []; stats.push(`Distance: ${escapeHtml(String(props.distance))}`); stats.push(`Time: ${escapeHtml(String(props.travelTime))}`); if (props.trafficDelayInSeconds && Number(props.trafficDelayInSeconds) > 0) { stats.push( `+${escapeHtml(String(props.trafficDelay))} delay` ); } html += `
`; for (const stat of stats) { html += `
${stat}
`; } html += `
`; } html += `
`; return html; } /** * Build popup HTML for a polygon feature. */ function buildPolygonPopupHtml(props: Record): string { const label = escapeHtml(String(props.label || props.name || "Area")); let html = `

${label}

`; const skipKeys = new Set([ "id", "label", "name", "color", "fillColor", "strokeColor", "strokeWidth", "fillOpacity", "strokeOpacity", ]); const entries = Object.entries(props).filter(([k]) => !skipKeys.has(k)); if (entries.length > 0) { html += `
`; for (const [key, value] of entries) { if (value == null || value === "") continue; html += `
${escapeHtml(key)}${escapeHtml(String(value))}
`; } html += `
`; } html += `
`; return html; } /** * Setup click handlers for markers, routes, and polygons. * Uses a single activePopup to prevent multiple popups from appearing. */ function setupInteractivity(mapState: MapState): void { if (!map) return; const mlMap = map.mapLibreMap; const markerLayers = ["marker-dot", "marker-icon", "marker-pin"]; for (const layerId of markerLayers) { if (mapState.sources.markers && mlMap.getLayer(layerId)) { const popupOffset: [number, number] = layerId === "marker-pin" ? [0, -20] : [0, -10]; mlMap.on("click", layerId, (e) => { if (e.features && e.features.length > 0) { (e.originalEvent as MouseEvent & { _handled?: boolean })._handled = true; const feature = e.features[0]; const coordinates = ( feature.geometry as { type: "Point"; coordinates: number[] } ).coordinates.slice() as [number, number]; const props = (feature.properties as Record) || {}; showPopup(coordinates, buildMarkerPopupHtml(props), popupOffset); } }); mlMap.on("mouseenter", layerId, () => { mlMap.getCanvas().style.cursor = "pointer"; }); mlMap.on("mouseleave", layerId, () => { mlMap.getCanvas().style.cursor = ""; }); } } const routeLayerId = "route-layer"; if (mapState.sources.routes && mlMap.getLayer(routeLayerId)) { mlMap.on("click", routeLayerId, (e) => { if (e.features && e.features.length > 0) { (e.originalEvent as MouseEvent & { _handled?: boolean })._handled = true; const props = (e.features[0].properties as Record) || {}; showPopup([e.lngLat.lng, e.lngLat.lat], buildRoutePopupHtml(props)); } }); mlMap.on("mouseenter", routeLayerId, () => { mlMap.getCanvas().style.cursor = "pointer"; }); mlMap.on("mouseleave", routeLayerId, () => { mlMap.getCanvas().style.cursor = ""; }); } // Make polygons clickable — skipped if a marker/route already handled this click const polygonLayerId = "polygon-fill"; if (mapState.sources.polygons && mlMap.getLayer(polygonLayerId)) { mlMap.on("click", polygonLayerId, (e) => { if ((e.originalEvent as MouseEvent & { _handled?: boolean })._handled) return; if (e.features && e.features.length > 0) { const props = (e.features[0].properties as Record) || {}; showPopup([e.lngLat.lng, e.lngLat.lat], buildPolygonPopupHtml(props)); } }); mlMap.on("mouseenter", polygonLayerId, () => { mlMap.getCanvas().style.cursor = "pointer"; }); mlMap.on("mouseleave", polygonLayerId, () => { mlMap.getCanvas().style.cursor = ""; }); } } /** * Fit map view to the specified bounds */ function fitMapToBounds(bounds: MapState["view"]["bounds"]): void { if (!map) return; map.mapLibreMap.fitBounds( [ [bounds.west, bounds.south], [bounds.east, bounds.north], ], { padding: 50, maxZoom: 17 } ); } /** * Update map with new state (clear and re-add) */ async function updateMapState(mapState: MapState): Promise { if (!map || !mapReady) { pendingData = mapState; return; } clearMap(); currentMapState = mapState; addSourcesAndLayers(mapState); setupInteractivity(mapState); fitMapToBounds(mapState.view.bounds); } /** * Clear custom layers and sources from the map */ function clearMap(): void { if (!map) return; if (activePopup) { activePopup.remove(); activePopup = null; } clearPolygonLabelMarkers(); const mlMap = map.mapLibreMap; // Remove all custom layers (identified by source name patterns) const customSources = ["markers", "routes", "routeLabels", "polygons", "polygonCenters"]; const style = mlMap.getStyle(); if (style?.layers) { for (const layer of style.layers) { const layerSource = layer.type !== "background" ? layer.source : undefined; if (layerSource && customSources.includes(layerSource)) { try { mlMap.removeLayer(layer.id); } catch { /* layer may not exist */ } } } } for (const src of customSources) { try { if (mlMap.getSource(src)) { mlMap.removeSource(src); } } catch { /* source may not exist */ } } for (const imageId of registeredIconImages) { try { if (mlMap.hasImage(imageId)) mlMap.removeImage(imageId); } catch { /* image may not exist */ } } registeredIconImages.clear(); } /** * Process incoming map data */ async function processMapData(mapState: MapState): Promise { if (!mapReady) { pendingData = mapState; await initializeMap(mapState); return; } await updateMapState(mapState); } app.ontoolresult = async (r) => { if (r.isError) { showErrorUI(); return; } try { if (r.content[0]?.type !== "text") return; const agentResponse = JSON.parse(r.content[0].text) as unknown; if (!shouldShowUI(r)) { hideMapUI(); return; } showMapUI(); const mapState = await appData.load(r, agentResponse); if (!mapState?.sources) throw new Error("The map state has no sources"); await processMapData(mapState); appData.drawn(); } catch (error) { console.error("Error processing dynamic map data:", error); showErrorUI(); appData.failed(error); } }; app.onteardown = async () => { if (trafficIncidentsModule) { trafficIncidentsModule.events.off("click"); trafficIncidentsModule.events.off("hover"); trafficIncidentsModule.setVisible(false); trafficIncidentsModule = null; } if (map) { clearMap(); } return {}; }; app.connect();