/*
* Copyright (C) 2025 TomTom NV
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { tomtomClient, validateApiKey, CONFIG } from "../base/tomtomClient";
import axios from "axios";
import { VERSION } from "../../version";
import { logger } from "../../utils/logger";
import { DynamicMapOptions, DynamicMapResponse } from "./dynamicMapTypes";
import { getRoute, getMultiWaypointRoute } from "../routing/routingService";
import { RouteOptions } from "../routing/types";
// Conditionally import MapLibre GL Native, Canvas, and Turf.js
// These will be undefined if the packages are not installed
let mbgl: any;
let createCanvas: any;
let turf: any;
// Only attempt to import these dependencies if dynamic maps are enabled
if (process.env.ENABLE_DYNAMIC_MAPS !== "false") {
try {
// Dynamic imports for MapLibre GL Native and Canvas
const importMapLibre = async () => {
try {
return await import("@maplibre/maplibre-gl-native");
} catch (error) {
logger.warn("⚠️ MapLibre GL Native not available: dynamic maps will not function");
return undefined;
}
};
const importCanvas = async () => {
try {
return await import("canvas");
} catch (error) {
logger.warn("⚠️ Canvas library not available: dynamic maps will not function");
return undefined;
}
};
const importTurf = async () => {
try {
return await import("@turf/turf");
} catch (error) {
logger.warn("⚠️ Turf.js not available: dynamic maps will not function");
return undefined;
}
};
// Execute imports immediately and synchronously
Promise.all([importMapLibre(), importCanvas(), importTurf()])
.then(([maplibreModule, canvasModule, turfModule]) => {
mbgl = maplibreModule?.default;
createCanvas = canvasModule?.createCanvas;
turf = turfModule;
if (mbgl && createCanvas && turf) {
logger.info("✅ Dynamic map dependencies loaded successfully");
} else {
logger.warn("⚠️ Some dynamic map dependencies could not be loaded");
}
})
.catch((error) => {
logger.error(`❌ Error loading dynamic map dependencies: ${error.message}`);
});
} catch (error: any) {
logger.error(`❌ Failed to import dynamic map dependencies: ${error.message}`);
}
}
/**
* Dynamic Map Service
* Provides advanced map rendering capabilities using MapLibre GL Native, Turf.js, and Canvas
*/
/**
* Format time in seconds to human-readable format
*/
function formatTime(seconds: number): string {
if (!seconds || seconds < 60) {
return `${Math.round(seconds || 0)}s`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
} else {
const hours = Math.floor(seconds / 3600);
const remainingMinutes = Math.floor((seconds % 3600) / 60);
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}
}
/**
* Format distance in meters to human-readable format
*/
function formatDistance(meters: number): string {
if (!meters || meters < 1000) {
return `${Math.round(meters || 0)}m`;
} else if (meters < 100000) {
return `${(meters / 1000).toFixed(1)}km`;
} else {
return `${Math.round(meters / 1000)}km`;
}
}
/**
* Get traffic color based on delay percentage
*/
function getTrafficColor(travelTime: number, trafficDelay: number): string {
if (!trafficDelay || trafficDelay <= 0) return "#22c55e"; // Green - no traffic
const delayPercentage = (trafficDelay / travelTime) * 100;
if (delayPercentage < 10) return "#84cc16"; // Light green - light traffic
if (delayPercentage < 25) return "#eab308"; // Yellow - moderate traffic
if (delayPercentage < 50) return "#f97316"; // Orange - heavy traffic
return "#ef4444"; // Red - severe delays
}
/**
* Default options for dynamic map rendering
*/
const DEFAULT_DYNAMIC_MAP_OPTIONS = {
width: 800,
height: 600,
showLabels: false,
routeInfoDetail: "basic" as const,
};
/**
* Validate and sanitize coordinate values
*/
function validateCoordinate(value: any, type: string): number {
const num = parseFloat(value);
if (isNaN(num)) {
throw new Error(`Invalid ${type} coordinate: ${value}`);
}
if (type === "latitude" && (num < -90 || num > 90)) {
throw new Error(`Latitude out of range [-90, 90]: ${num}`);
}
if (type === "longitude" && (num < -180 || num > 180)) {
throw new Error(`Longitude out of range [-180, 180]: ${num}`);
}
return num;
}
/**
* Extract and validate coordinates from various formats
*/
function extractCoordinates(
item: any,
index: number | string,
type: string = "marker"
): { lat: number; lon: number } | null {
let lat: number | undefined, lon: number | undefined;
if (Array.isArray(item)) {
// Handle array format [lat, lon]
if (item.length >= 2) {
lat = item[0];
lon = item[1];
}
} else if (item.coordinates && Array.isArray(item.coordinates)) {
// Handle {coordinates: [lat, lon]} format
if (item.coordinates.length >= 2) {
lat = item.coordinates[0];
lon = item.coordinates[1];
}
} else if (item.lat !== undefined && item.lon !== undefined) {
// Handle {lat: x, lon: y} format (standard)
lat = item.lat;
lon = item.lon;
}
if (lat === undefined || lon === undefined) {
logger.warn(`❌ Could not extract coordinates from ${type} ${index}`);
return null;
}
try {
const validLat = validateCoordinate(lat, "latitude");
const validLon = validateCoordinate(lon, "longitude");
return { lat: validLat, lon: validLon };
} catch (error: any) {
logger.warn(`❌ Invalid coordinates for ${type} ${index}: ${error.message}`);
return null;
}
}
/**
* Calculate optimal zoom level using web mercator projection math
*/
function calculateOptimalZoom(
bounds: any,
mapWidth: number,
mapHeight: number,
paddingPixels: number = 80
): number {
const { north, south, east, west } = bounds;
// Calculate the effective map dimensions after padding
const effectiveWidth = mapWidth - paddingPixels * 2;
const effectiveHeight = mapHeight - paddingPixels * 2;
// Calculate spans in degrees
const latSpan = north - south;
const lngSpan = east - west;
// Web Mercator zoom calculation
const latRad1 = (south * Math.PI) / 180;
const latRad2 = (north * Math.PI) / 180;
const latZoom = Math.log2(
(effectiveHeight * (180 / Math.PI)) /
(Math.log(Math.tan(latRad2 / 2 + Math.PI / 4)) -
Math.log(Math.tan(latRad1 / 2 + Math.PI / 4)))
);
// For longitude: simpler calculation
const lngZoom = Math.log2((effectiveWidth * 360) / (lngSpan * 256));
// Use the more restrictive zoom (smaller value)
const zoom = Math.min(latZoom, lngZoom);
// Clamp to reasonable bounds and add buffer for marker visibility
const finalZoom = Math.max(1, Math.min(17, zoom - 0.1));
return finalZoom;
}
/**
* Enhanced bounds calculation using Turf.js with smarter buffering
*/
function calculateEnhancedBounds(
markers: any[],
routes: any[],
mapWidth: number,
mapHeight: number,
polygons: any[] = []
): any {
const features: any[] = [];
let totalPoints = 0;
// Add markers to feature collection
if (markers && markers.length > 0) {
markers.forEach((marker, index) => {
const coords = extractCoordinates(marker, index, "marker");
if (coords) {
features.push(turf.point([coords.lon, coords.lat]));
totalPoints++;
}
});
}
// Add route points to feature collection
if (routes && routes.length > 0) {
routes.forEach((route, routeIndex) => {
if (Array.isArray(route)) {
// Legacy format: route is array of coordinates
route.forEach((point, pointIndex) => {
const coords = extractCoordinates(point, `${routeIndex}-${pointIndex}`, "route point");
if (coords) {
features.push(turf.point([coords.lon, coords.lat]));
totalPoints++;
}
});
} else if (route.points && Array.isArray(route.points)) {
// New format: route is object with points array
route.points.forEach((point: any, pointIndex: number) => {
const coords = extractCoordinates(point, `${routeIndex}-${pointIndex}`, "route point");
if (coords) {
features.push(turf.point([coords.lon, coords.lat]));
totalPoints++;
}
});
}
});
}
// Add polygon coordinates to feature collection (Phase 2: Multi-polygon support)
if (polygons && polygons.length > 0) {
polygons.forEach((polygon, polygonIndex) => {
// Handle polygon coordinates
if (polygon.coordinates && Array.isArray(polygon.coordinates)) {
polygon.coordinates.forEach((coord: [number, number], coordIndex: number) => {
if (Array.isArray(coord) && coord.length >= 2) {
const [lon, lat] = coord;
if (typeof lon === "number" && typeof lat === "number") {
features.push(turf.point([lon, lat]));
totalPoints++;
}
}
});
}
// Handle circle center (circles also contribute to bounds)
if (
polygon.center &&
typeof polygon.center.lat === "number" &&
typeof polygon.center.lon === "number"
) {
features.push(turf.point([polygon.center.lon, polygon.center.lat]));
totalPoints++;
// Add points at circle edges for better bounds calculation
if (polygon.radius && polygon.radius > 0) {
const radiusKm = polygon.radius / 1000; // Convert meters to km
const options = { steps: 8, units: "kilometers" as const };
const circleFeature = turf.circle(
[polygon.center.lon, polygon.center.lat],
radiusKm,
options
);
const coords = circleFeature.geometry.coordinates[0];
coords.forEach((coord: any) => {
if (Array.isArray(coord) && coord.length >= 2) {
features.push(turf.point([coord[0], coord[1]]));
totalPoints++;
}
});
}
}
});
}
if (features.length === 0) {
throw new Error("No valid coordinates found to calculate bounds");
}
// Create feature collection and get initial bounds
const collection = turf.featureCollection(features);
const rawBbox = turf.bbox(collection);
const [west, south, east, north] = rawBbox;
// Calculate geographic spans
const latSpan = north - south;
const lngSpan = east - west;
const maxSpan = Math.max(latSpan, lngSpan);
const markerCount = markers ? markers.length : 0;
// Intelligent buffer calculation (adapted from original implementation)
let bufferKm: number;
if (markerCount === 1) {
bufferKm = Math.max(5, maxSpan * 111 * 0.3);
} else if (maxSpan < 0.001) {
bufferKm = 1;
} else if (maxSpan < 0.01) {
bufferKm = Math.max(2, maxSpan * 111 * 0.5);
} else if (maxSpan < 0.1) {
bufferKm = Math.max(3, maxSpan * 111 * 0.4);
} else if (maxSpan < 1.0) {
bufferKm = Math.max(10, maxSpan * 111 * 0.3);
} else if (maxSpan < 5.0) {
bufferKm = Math.max(50, maxSpan * 111 * 0.35);
} else if (maxSpan < 10.0) {
bufferKm = Math.max(75, maxSpan * 111 * 0.3);
} else {
bufferKm = Math.max(100, maxSpan * 111 * 0.25);
}
// Extra buffer for routes and multiple markers
const hasRoutes = routes && routes.length > 0;
if (hasRoutes && markerCount > 1) {
bufferKm *= 1.5;
}
if (markerCount > 3) {
bufferKm *= 1.2;
}
// Apply buffer using Turf.js
const bufferedCollection = turf.buffer(collection, bufferKm, { units: "kilometers" });
if (!bufferedCollection) {
throw new Error("Failed to calculate buffered bounds");
}
const bufferedBbox = turf.bbox(bufferedCollection);
const [buffWest, buffSouth, buffEast, buffNorth] = bufferedBbox;
const bounds = {
west: buffWest,
south: buffSouth,
east: buffEast,
north: buffNorth,
};
// Calculate center point
const centerLng = (bounds.west + bounds.east) / 2;
const centerLat = (bounds.south + bounds.north) / 2;
const center = [centerLng, centerLat];
// Calculate optimal padding in pixels
let paddingPixels: number;
if (markerCount === 1) {
paddingPixels = Math.min(mapWidth, mapHeight) * 0.15;
} else if (markerCount <= 3) {
paddingPixels = Math.min(mapWidth, mapHeight) * 0.12;
} else {
paddingPixels = Math.min(mapWidth, mapHeight) * 0.1;
}
paddingPixels = Math.max(50, Math.min(150, paddingPixels));
// Calculate zoom using enhanced algorithm
const zoom = calculateOptimalZoom(bounds, mapWidth, mapHeight, paddingPixels);
return { bounds, center, zoom };
}
/**
* Render a dynamic map using MapLibre GL Native (adapted from original renderMap function)
*/
async function renderMapWithMapLibre(options: any): Promise<Buffer> {
const {
bbox,
width,
height,
markers,
routes,
polygons,
routeData,
showLabels,
routeLabel,
useOrbis,
} = options;
let bounds: any, center: any, zoom: number;
// Calculate enhanced bounds (adapted from original implementation)
if (bbox && Array.isArray(bbox) && bbox.length === 4) {
try {
const providedBounds = {
west: validateCoordinate(bbox[0], "longitude"),
south: validateCoordinate(bbox[1], "latitude"),
east: validateCoordinate(bbox[2], "longitude"),
north: validateCoordinate(bbox[3], "latitude"),
};
if (
providedBounds.west >= providedBounds.east ||
providedBounds.south >= providedBounds.north
) {
throw new Error(`Invalid bounds: west must be < east and south must be < north`);
}
const result = calculateEnhancedBounds(
[
{ lat: providedBounds.south, lon: providedBounds.west },
{ lat: providedBounds.north, lon: providedBounds.east },
],
[],
width,
height,
[]
);
bounds = result.bounds;
center = result.center;
zoom = result.zoom;
} catch (error: any) {
logger.warn(`⚠️ Invalid bbox: ${error.message}. Calculating from markers/routes.`);
const result = calculateEnhancedBounds(markers, routes, width, height, polygons);
bounds = result.bounds;
center = result.center;
zoom = result.zoom;
}
} else {
const result = calculateEnhancedBounds(markers, routes, width, height, polygons);
bounds = result.bounds;
center = result.center;
zoom = result.zoom;
}
// Fetch TomTom style (adapted from original)
const STYLE_VERSION = "22.3.0-1";
const MAP_STYLE = "basic_main";
// Check environment to determine if Orbis should be used
let styleUrl: string;
if (useOrbis) {
styleUrl = `maps/orbis/assets/styles/0.5.0-0/style.json?apiVersion=1&map=basic_street-light`;
logger.info(`🌍 Using TomTom Orbis style endpoint`);
} else {
styleUrl = `style/1/style/${STYLE_VERSION}?map=${MAP_STYLE}`;
logger.info(`🗺️ Using default TomTom style endpoint`);
}
const response = await tomtomClient.get(styleUrl, {
responseType: "json",
});
const style = response.data;
// Validate style data
if (!style || typeof style !== "object") {
throw new Error("Invalid style data received from TomTom API");
}
// Initialize MapLibre Native map
const map = new mbgl.Map({
request: (req: any, callback: any) => {
// Handle both absolute and relative URLs
const url = req.url;
// Debug the request URL
logger.debug(`MapLibre requesting: ${url}`);
// Handle URLs with special care to prevent double API keys
const requestOptions: any = {
responseType: "arraybuffer",
};
// Make direct axios request instead of tomtomClient to avoid adding key again
axios
.get(url, requestOptions)
.then((r: any) => callback(null, { data: r.data }))
.catch((e: any) => {
logger.error(`MapLibre request failed for ${url}: ${e.message}`);
callback(e);
});
},
ratio: 1,
});
try {
map.load(style);
// Add polygons if present (Phase 2: Multi-polygon support with circles)
// Polygons are rendered first so they appear underneath markers and routes
if (polygons && polygons.length > 0) {
const polygonFeatures = polygons
.map((polygon: any, index: number) => {
// Handle circle geometry
if (polygon.type === "circle" || (polygon.center && polygon.radius)) {
if (
!polygon.center ||
typeof polygon.center.lat !== "number" ||
typeof polygon.center.lon !== "number"
) {
logger.warn(`⚠️ Circle ${index} has invalid center coordinates.`);
return null;
}
if (!polygon.radius || polygon.radius <= 0) {
logger.warn(`⚠️ Circle ${index} has invalid radius.`);
return null;
}
// Convert circle to polygon using turf
const radiusKm = polygon.radius / 1000; // Convert meters to km
const options = { steps: 64, units: "kilometers" as const };
const circleFeature = turf.circle(
[polygon.center.lon, polygon.center.lat],
radiusKm,
options
);
return {
type: "Feature",
geometry: circleFeature.geometry,
properties: {
id: index,
label: polygon.label || polygon.name || `Circle ${index + 1}`,
fillColor: polygon.fillColor || "rgba(255, 193, 7, 0.3)",
strokeColor: polygon.strokeColor || "#ffc107",
strokeWidth: polygon.strokeWidth || 2,
name: polygon.name || `Circle ${index + 1}`,
},
};
}
// Handle polygon coordinates (Phase 1 backward compatibility)
if (polygon.coordinates && Array.isArray(polygon.coordinates)) {
// Validate coordinates
if (polygon.coordinates.length < 3) {
logger.warn(
`⚠️ Polygon ${index} has invalid coordinates. Minimum 3 points required.`
);
return null;
}
// Ensure polygon is closed (first and last points are the same)
const coords = [...polygon.coordinates];
const firstPoint = coords[0];
const lastPoint = coords[coords.length - 1];
if (firstPoint[0] !== lastPoint[0] || firstPoint[1] !== lastPoint[1]) {
coords.push([firstPoint[0], firstPoint[1]]); // Close the polygon
}
return {
type: "Feature",
geometry: {
type: "Polygon",
coordinates: [coords], // Wrap in array for exterior ring
},
properties: {
id: index,
label: polygon.label || polygon.name || `Area ${index + 1}`,
fillColor: polygon.fillColor || "rgba(0, 123, 255, 0.3)",
strokeColor: polygon.strokeColor || "#007bff",
strokeWidth: polygon.strokeWidth || 2,
name: polygon.name || `Polygon ${index + 1}`,
},
};
}
logger.warn(`⚠️ Polygon ${index} has neither valid coordinates nor circle definition.`);
return null;
})
.filter(Boolean);
if (polygonFeatures.length > 0) {
// Add polygon data source
map.addSource("polygons", {
type: "geojson",
data: { type: "FeatureCollection", features: polygonFeatures },
});
// Add fill layer (rendered first, underneath strokes)
map.addLayer({
id: "polygon-fill",
type: "fill",
source: "polygons",
paint: {
"fill-color": ["get", "fillColor"],
"fill-opacity": 0.6,
},
});
// Add stroke layer
map.addLayer({
id: "polygon-stroke",
type: "line",
source: "polygons",
layout: {
"line-join": "round",
"line-cap": "round",
},
paint: {
"line-color": ["get", "strokeColor"],
"line-width": ["get", "strokeWidth"],
"line-opacity": 0.8,
},
});
// Add labels if showLabels is enabled
if (showLabels) {
map.addLayer({
id: "polygon-labels",
type: "symbol",
source: "polygons",
layout: {
"text-field": ["get", "label"],
"text-font": ["Noto-Bold"],
"text-size": 11,
"text-anchor": "center",
"text-allow-overlap": false,
"text-padding": 10,
},
paint: {
"text-color": "#333333",
"text-halo-color": "#ffffff",
"text-halo-width": 2,
"text-halo-blur": 1,
},
});
}
logger.info(`✅ Added ${polygonFeatures.length} polygons to map`);
}
}
// Add markers if present (adapted from original implementation)
if (markers && markers.length > 0) {
// Sort markers by priority for better label visibility
// Higher priority markers are processed first and get label preference
const priorityOrder = { critical: 0, high: 1, normal: 2, low: 3 };
const sortedMarkers = [...markers].sort((a, b) => {
const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] ?? 2; // default to normal
const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] ?? 2;
return aPriority - bPriority;
});
const markerFeatures = sortedMarkers
.map((marker: any, index: number) => {
const coords = extractCoordinates(marker, index, "marker");
if (coords) {
return {
type: "Feature",
geometry: { type: "Point", coordinates: [coords.lon, coords.lat] },
properties: {
id: index,
label: marker.label || `Marker ${index + 1}`,
color: marker.color || "#ff4444",
priority: marker.priority || "normal",
},
};
}
return null;
})
.filter(Boolean);
if (markerFeatures.length > 0) {
map.addSource("markers", {
type: "geojson",
data: { type: "FeatureCollection", features: markerFeatures },
});
// Add enhanced marker styling (from original implementation)
map.addLayer({
id: "marker-shadow",
type: "circle",
source: "markers",
paint: {
"circle-radius": 20,
"circle-color": "rgba(0, 0, 0, 0.25)",
"circle-blur": 1,
"circle-translate": [3, 3],
},
});
map.addLayer({
id: "marker-outer",
type: "circle",
source: "markers",
paint: {
"circle-radius": 18,
"circle-color": "rgba(255, 255, 255, 0.9)",
"circle-stroke-width": 2,
"circle-stroke-color": "rgba(0, 0, 0, 0.3)",
},
});
map.addLayer({
id: "marker-layer",
type: "circle",
source: "markers",
paint: {
"circle-radius": 14,
"circle-color": ["get", "color"],
"circle-stroke-width": 3,
"circle-stroke-color": "#ffffff",
"circle-opacity": 1,
},
});
map.addLayer({
id: "marker-inner",
type: "circle",
source: "markers",
paint: {
"circle-radius": 4,
"circle-color": "#ffffff",
"circle-opacity": 1,
},
});
// Add marker labels if enabled - enhanced with priority-based styling
if (showLabels) {
// Create separate label layers for each priority level for better browser compatibility
const priorities = ["critical", "high", "normal", "low"];
priorities.forEach((priority) => {
map.addLayer({
id: `marker-labels-${priority}`,
type: "symbol",
source: "markers",
filter: ["==", ["get", "priority"], priority],
layout: {
"text-field": ["get", "label"],
"text-font": ["Noto-Bold"],
"text-offset": [0, 3.0],
"text-anchor": "top",
"text-size":
priority === "critical"
? 15
: priority === "high"
? 14
: priority === "low"
? 12
: 13,
"text-max-width": 12,
"text-allow-overlap": priority === "critical",
"text-padding": priority === "critical" ? 2 : priority === "high" ? 3 : 5,
"text-line-height": 1.1,
},
paint: {
"text-color":
priority === "critical" ? "#000000" : priority === "high" ? "#1a202c" : "#1a365d",
"text-halo-color": "#ffffff",
"text-halo-width": priority === "critical" ? 5 : priority === "high" ? 4.5 : 4,
"text-halo-blur": 1,
},
});
});
}
logger.info(`✅ Added ${markerFeatures.length} enhanced markers to map`);
}
}
// Add routes if present (adapted from original implementation)
if (routes && routes.length > 0) {
const routeFeatures = routes
.map((route: any, routeIndex: number) => {
let routePoints: any[] = [];
if (Array.isArray(route)) {
routePoints = route;
} else if (route.points && Array.isArray(route.points)) {
routePoints = route.points;
}
if (routePoints.length > 1) {
const validCoords = routePoints
.map((point, pointIndex) =>
extractCoordinates(point, `${routeIndex}-${pointIndex}`, "route point")
)
.filter((coord) => coord !== null)
.map((coord) => [coord!.lon, coord!.lat]);
if (validCoords.length > 1) {
// Get route data for this specific route if available
const currentRouteData = (routeData && routeData[routeIndex]) || {
distance: "",
travelTime: "",
trafficDelay: "",
trafficColor: "#007cbf",
hasTrafficData: false,
lengthInMeters: 0,
travelTimeInSeconds: 0,
trafficDelayInSeconds: 0,
name: routeLabel || `Route ${routeIndex + 1}`,
};
// Create route summary label with route information
let routeSummary = currentRouteData.name || routeLabel || `Route ${routeIndex + 1}`;
if (currentRouteData.distance && currentRouteData.travelTime) {
routeSummary += ` (${currentRouteData.distance}, ${currentRouteData.travelTime})`;
if (currentRouteData.trafficDelayInSeconds > 0) {
routeSummary += ` +${currentRouteData.trafficDelay} delay`;
}
}
return {
type: "Feature",
geometry: {
type: "LineString",
coordinates: validCoords,
},
properties: {
id: routeIndex,
label: routeSummary,
routeName: currentRouteData.name || routeLabel || `Route ${routeIndex + 1}`,
distance: currentRouteData.distance,
travelTime: currentRouteData.travelTime,
trafficDelay: currentRouteData.trafficDelay,
trafficColor: currentRouteData.trafficColor,
hasTrafficData: currentRouteData.hasTrafficData,
lengthInMeters: currentRouteData.lengthInMeters,
travelTimeInSeconds: currentRouteData.travelTimeInSeconds,
trafficDelayInSeconds: currentRouteData.trafficDelayInSeconds,
},
};
}
}
return null;
})
.filter(Boolean);
if (routeFeatures.length > 0) {
map.addSource("routes", {
type: "geojson",
data: { type: "FeatureCollection", features: routeFeatures },
});
// Create separate features for route labels positioned at start and end points
const routeLabelFeatures: any[] = [];
routeFeatures.forEach((routeFeature: any, index: number) => {
const coords = routeFeature.geometry.coordinates;
if (coords && coords.length > 1) {
// Start point label
const startPoint = coords[0];
routeLabelFeatures.push({
type: "Feature",
geometry: {
type: "Point",
coordinates: [startPoint[0], startPoint[1] + 0.0005], // Slight offset above
},
properties: {
label: `Start: ${routeFeature.properties.routeName}`,
summary: `${routeFeature.properties.distance}, ${routeFeature.properties.travelTime}`,
routeId: routeFeature.properties.id,
type: "start",
},
});
// End point label with route summary
const endPoint = coords[coords.length - 1];
routeLabelFeatures.push({
type: "Feature",
geometry: {
type: "Point",
coordinates: [endPoint[0], endPoint[1] - 0.0005], // Slight offset below
},
properties: {
label: `End: ${routeFeature.properties.label}`,
summary: routeFeature.properties.hasTrafficData
? `${routeFeature.properties.distance}, ${routeFeature.properties.travelTime} (+${routeFeature.properties.trafficDelay})`
: `${routeFeature.properties.distance}, ${routeFeature.properties.travelTime}`,
routeId: routeFeature.properties.id,
type: "end",
},
});
}
});
// Add route label source
if (routeLabelFeatures.length > 0) {
map.addSource("route-labels", {
type: "geojson",
data: { type: "FeatureCollection", features: routeLabelFeatures },
});
}
// Add route outline for better visibility
map.addLayer({
id: "route-outline",
type: "line",
source: "routes",
paint: {
"line-width": 8,
"line-color": "#ffffff",
"line-opacity": 0.8,
},
});
// Add main route layer with traffic-based coloring
map.addLayer({
id: "route-layer",
type: "line",
source: "routes",
paint: {
"line-width": 6,
"line-color": ["get", "trafficColor"],
"line-opacity": 1,
},
});
// Add route summary labels if enabled - positioned to avoid marker label conflicts
if (showLabels && routeLabelFeatures.length > 0) {
map.addLayer({
id: "route-labels",
type: "symbol",
source: "route-labels",
layout: {
"text-field": ["get", "summary"],
"text-font": ["Noto-Bold"],
"symbol-placement": "point",
"text-anchor": "center",
"text-size": 11,
"text-max-width": 18,
"text-allow-overlap": false,
"text-padding": 15,
"text-line-height": 1.0,
"text-justify": "center",
},
paint: {
"text-color": "#1976d2",
"text-halo-color": "#ffffff",
"text-halo-width": 3,
"text-halo-blur": 1,
},
});
}
logger.info(`✅ Added ${routeFeatures.length} enhanced routes to map`);
}
}
// Render map to buffer (adapted from original Promise-based implementation)
return new Promise((resolve, reject) => {
map.render(
{ zoom, center, width, height },
(err: Error | undefined, buffer: Uint8Array | undefined) => {
if (map) map.release();
if (err) {
reject(new Error(`Map rendering failed: ${err.message}`));
} else if (!buffer) {
reject(new Error("Map rendering failed: No buffer returned"));
} else {
try {
// Convert raw buffer to PNG using canvas (from original implementation)
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
// Create ImageData from the raw buffer
const imageData = ctx.createImageData(width, height);
// MapLibre returns RGBA data, copy it to ImageData
for (let i = 0; i < buffer.length; i++) {
imageData.data[i] = buffer[i];
}
// Put the image data on canvas and convert to PNG
ctx.putImageData(imageData, 0, 0);
const pngBuffer = canvas.toBuffer("image/png");
resolve(pngBuffer);
} catch (conversionError: any) {
reject(new Error(`PNG conversion failed: ${conversionError.message}`));
}
}
}
);
});
} catch (error: any) {
if (map) map.release();
throw error;
}
}
/**
* Renders a dynamic map with advanced features
* @param options Dynamic map rendering options
* @returns Promise resolving to the rendered map data
*/
export async function renderDynamicMap(options: DynamicMapOptions): Promise<DynamicMapResponse> {
// Validate TomTom API key
validateApiKey();
logger.info("🗺️ Processing dynamic map request");
try {
// Check if all required dependencies are available
if (!mbgl || !createCanvas || !turf) {
throw new Error("Dynamic map dependencies not available. Install @maplibre/maplibre-gl-native, canvas, and @turf/turf to enable this feature, or use Docker for a pre-configured environment.");
}
// Apply default options
const finalOptions = { ...DEFAULT_DYNAMIC_MAP_OPTIONS, ...options };
// Prepare markers array (adapted from original route handling logic)
let markers: any[] = [];
if (finalOptions.markers) {
markers = [...finalOptions.markers];
}
// Validate origin/destination pairing
const hasOrigin = !!finalOptions.origin;
const hasDestination = !!finalOptions.destination;
if (hasOrigin && !hasDestination) {
throw new Error(
"Origin provided without destination. Both origin and destination are required for route planning."
);
}
if (!hasOrigin && hasDestination) {
throw new Error(
"Destination provided without origin. Both origin and destination are required for route planning."
);
}
// Determine if we're in route planning mode
const isRoutePlanningMode = hasOrigin && hasDestination;
// Prepare polygons array
let polygons: any[] = [];
if (finalOptions.polygons) {
polygons = [...finalOptions.polygons];
}
// Validate that we have some content to display
const hasMarkers = markers && markers.length > 0;
const hasPolygons = polygons && polygons.length > 0;
const hasDirectRoutes = (finalOptions as any).routes && (finalOptions as any).routes.length > 0;
const hasBbox =
finalOptions.bbox && Array.isArray(finalOptions.bbox) && finalOptions.bbox.length === 4;
if (!isRoutePlanningMode && !hasMarkers && !hasPolygons && !hasDirectRoutes && !hasBbox) {
throw new Error(
"Map requires content to display. Please provide at least one of: markers, polygons, routes, origin+destination (for route planning), or bbox (for area bounds)."
);
}
// Handle route planning mode (auto-detect based on origin/destination)
if (isRoutePlanningMode) {
const originCoords = extractCoordinates(finalOptions.origin, 0, "origin");
const destCoords = extractCoordinates(finalOptions.destination, 0, "destination");
if (!originCoords || !destCoords) {
throw new Error("Invalid origin or destination coordinates");
}
// Add route planning markers to existing markers (preserve user markers)
const originLabel = (finalOptions.origin as any)?.label || "Start";
const destLabel = (finalOptions.destination as any)?.label || "End";
markers.push({
lat: originCoords.lat,
lon: originCoords.lon,
label: originLabel,
color: "#22c55e",
});
// Add waypoints if provided with custom labels
if (finalOptions.waypoints && finalOptions.waypoints.length > 0) {
finalOptions.waypoints.forEach((wp, i) => {
const wpCoords = extractCoordinates(wp, i, "waypoint");
if (wpCoords) {
const waypointLabel = (wp as any)?.label || `Waypoint ${i + 1}`;
markers.push({
lat: wpCoords.lat,
lon: wpCoords.lon,
label: waypointLabel,
color: "#f97316",
});
}
});
}
markers.push({
lat: destCoords.lat,
lon: destCoords.lon,
label: destLabel,
color: "#ef4444",
});
}
// Calculate routes intelligently using TomTom routing service
let routes: Array<Array<{ lat: number; lon: number }>> = [];
const routeData: Array<{
lengthInMeters: number;
travelTimeInSeconds: number;
trafficDelayInSeconds: number;
distance: string;
travelTime: string;
trafficDelay: string;
trafficColor: string;
hasTrafficData: boolean;
name: string;
}> = [];
// Handle direct routes (when routes are provided directly, not in route planning mode)
if (
(finalOptions as any).routes &&
(finalOptions as any).routes.length > 0 &&
!isRoutePlanningMode
) {
routes = (finalOptions as any).routes
.map((route: any, routeIndex: number) => {
let routePoints: any[] = [];
if (Array.isArray(route)) {
routePoints = route;
} else if (route.points && Array.isArray(route.points)) {
routePoints = route.points;
}
if (routePoints.length > 1) {
const validCoords = routePoints
.map((point, pointIndex) =>
extractCoordinates(point, `${routeIndex}-${pointIndex}`, "route point")
)
.filter((coord) => coord !== null)
.map((coord) => [coord!.lat, coord!.lon]);
if (validCoords.length > 1) {
// Add start/end markers for routes if no specific markers provided for these points
const startCoord = validCoords[0];
const endCoord = validCoords[validCoords.length - 1];
// Check if we already have markers at start/end points (within reasonable distance)
const hasStartMarker = markers.some(
(m) =>
Math.abs(m.lat - startCoord[0]) < 0.001 && Math.abs(m.lon - startCoord[1]) < 0.001
);
const hasEndMarker = markers.some(
(m) =>
Math.abs(m.lat - endCoord[0]) < 0.001 && Math.abs(m.lon - endCoord[1]) < 0.001
);
// Add automatic start/end markers if not present
if (!hasStartMarker) {
markers.push({
lat: startCoord[0],
lon: startCoord[1],
label: route.name ? `${route.name} Start` : `Route ${routeIndex + 1} Start`,
color: "#22c55e",
});
}
if (!hasEndMarker) {
markers.push({
lat: endCoord[0],
lon: endCoord[1],
label: route.name ? `${route.name} End` : `Route ${routeIndex + 1} End`,
color: "#ef4444",
});
}
return validCoords.map((coord) => ({ lat: coord[0], lon: coord[1] }));
}
}
return [];
})
.filter((route: any) => route.length > 0);
logger.info(`✅ Processed ${routes.length} direct routes with automatic start/end markers`);
}
// Calculate routes using TomTom routing service (route planning mode)
else if (finalOptions.origin && finalOptions.destination) {
try {
const routeOptions: RouteOptions = {
routeType: finalOptions.routeType || "fastest",
travelMode: finalOptions.travelMode || "car",
avoid: finalOptions.avoid,
traffic: finalOptions.traffic || false,
instructionsType: "text",
sectionType: [],
computeTravelTimeFor: "all",
};
let routeResult;
if (finalOptions.waypoints && finalOptions.waypoints.length > 0) {
// Use multi-waypoint routing
const waypoints = [
finalOptions.origin,
...finalOptions.waypoints,
finalOptions.destination,
];
routeResult = await getMultiWaypointRoute(waypoints, routeOptions);
} else {
// Use simple routing
routeResult = await getRoute(finalOptions.origin, finalOptions.destination, routeOptions);
}
if (routeResult && routeResult.routes && routeResult.routes.length > 0) {
routes = routeResult.routes.map((route, index) => {
const coordinates: Array<{ lat: number; lon: number }> = [];
route.legs?.forEach((leg) => {
leg.points?.forEach((point) => {
coordinates.push({
lat: point.latitude,
lon: point.longitude,
});
});
});
// Extract route summary data
const lengthInMeters = route.summary?.lengthInMeters || 0;
const travelTimeInSeconds = route.summary?.travelTimeInSeconds || 0;
const trafficDelayInSeconds = route.summary?.trafficDelayInSeconds || 0;
// Format the information
const distance = formatDistance(lengthInMeters);
const travelTime = formatTime(travelTimeInSeconds);
const trafficDelay = formatTime(trafficDelayInSeconds);
const trafficColor = getTrafficColor(travelTimeInSeconds, trafficDelayInSeconds);
// Store route metadata
routeData.push({
lengthInMeters,
travelTimeInSeconds,
trafficDelayInSeconds,
distance,
travelTime,
trafficDelay,
trafficColor,
hasTrafficData: trafficDelayInSeconds > 0,
name: finalOptions.routeLabel || `Route ${index + 1}`,
});
return coordinates;
});
logger.info(
`Calculated ${routes.length} routes with total ${routes.reduce((sum, route) => sum + route.length, 0)} coordinates`
);
}
} catch (routeError) {
logger.warn(
`Failed to calculate route: ${routeError}. Proceeding without route visualization.`
);
}
}
// Render the map using the adapted MapLibre implementation
const buffer = await renderMapWithMapLibre({
bbox: finalOptions.bbox,
width: finalOptions.width,
height: finalOptions.height,
markers,
routes,
polygons,
routeData,
showLabels: finalOptions.showLabels || false,
routeLabel: finalOptions.routeLabel,
useOrbis: finalOptions.use_orbis || false,
});
// Convert buffer to base64
const base64 = buffer.toString("base64");
const responseData: DynamicMapResponse = {
base64,
contentType: "image/png",
width: finalOptions.width || DEFAULT_DYNAMIC_MAP_OPTIONS.width,
height: finalOptions.height || DEFAULT_DYNAMIC_MAP_OPTIONS.height,
};
logger.info(`✅ Dynamic map rendered successfully: ${(buffer.length / 1024).toFixed(2)} KB`);
return responseData;
} catch (error: any) {
logger.error(`❌ Dynamic map generation failed: ${error.message}`);
// Since we're using static imports, dependency errors will be caught at module load time
// This provides cleaner error handling for actual runtime issues
throw error;
}
}