/*
* Copyright (C) 2025 TomTom Navigation B.V.
* Licensed under the Apache License, Version 2.0
*/
import {
TomTomMap,
TrafficFlowModule,
TrafficIncidentsModule,
StandardStyleID,
} from "@tomtom-org/maps-sdk/map";
export interface MapControlsOptions {
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
showTrafficToggle?: boolean;
showIncidentsToggle?: boolean;
showThemeToggle?: boolean;
initialTrafficEnabled?: boolean;
initialIncidentsEnabled?: boolean;
initialTheme?: "light" | "dark";
/** Pass existing TrafficFlowModule to control instead of creating new one */
externalTrafficModule?: TrafficFlowModule;
/** Pass existing TrafficIncidentsModule to control instead of creating new one */
externalIncidentsModule?: TrafficIncidentsModule;
/** Called after a theme change once the new style has loaded. Use this to re-add custom sources/layers. */
onThemeChange?: () => void;
}
// Map theme names to StandardStyleID (correct SDK style names)
const THEME_STYLES: Record<"light" | "dark", StandardStyleID> = {
light: "standardLight" as StandardStyleID,
dark: "standardDark" as StandardStyleID,
};
/**
* Creates map control buttons for theme switching and traffic toggle
*/
export async function createMapControls(
map: TomTomMap,
options: MapControlsOptions = {}
): Promise<{
trafficModule: TrafficFlowModule | null;
incidentsModule: TrafficIncidentsModule | null;
setTheme: (theme: "light" | "dark") => void;
setTrafficVisible: (visible: boolean) => void;
setIncidentsVisible: (visible: boolean) => void;
destroy: () => void;
}> {
// Expose MapLibre map instance for E2E test automation (markers are canvas-rendered, not DOM)
(window as any).__e2e_ml = map.mapLibreMap;
const opts = {
position: options.position ?? ("top-right" as const),
showTrafficToggle: options.showTrafficToggle ?? true,
showIncidentsToggle: options.showIncidentsToggle ?? false,
showThemeToggle: options.showThemeToggle ?? true,
initialTrafficEnabled: options.initialTrafficEnabled ?? false,
initialIncidentsEnabled: options.initialIncidentsEnabled ?? false,
initialTheme: options.initialTheme ?? ("light" as const),
externalTrafficModule: options.externalTrafficModule,
externalIncidentsModule: options.externalIncidentsModule,
onThemeChange: options.onThemeChange,
};
let trafficModule: TrafficFlowModule | null = null;
let incidentsModule: TrafficIncidentsModule | null = null;
let currentTheme = opts.initialTheme;
let trafficEnabled = opts.initialTrafficEnabled;
let incidentsEnabled = opts.initialIncidentsEnabled;
// Create container
const container = document.createElement("div");
container.className = "map-controls";
container.setAttribute("data-position", opts.position);
// Initialize traffic module if needed (use external if provided)
if (opts.showTrafficToggle) {
if (options.externalTrafficModule) {
trafficModule = options.externalTrafficModule;
// Always start with traffic off by default, regardless of external module's current state
trafficModule.setVisible(opts.initialTrafficEnabled);
} else {
trafficModule = await TrafficFlowModule.get(map, { visible: opts.initialTrafficEnabled });
}
}
// Theme toggle button
let themeBtn: HTMLButtonElement | null = null;
if (opts.showThemeToggle) {
themeBtn = document.createElement("button");
themeBtn.className = "map-control-btn theme-btn";
themeBtn.title = "Toggle theme";
themeBtn.innerHTML = currentTheme === "light" ? getSunIcon() : getMoonIcon();
themeBtn.addEventListener("click", () => {
currentTheme = currentTheme === "light" ? "dark" : "light";
map.setStyle(THEME_STYLES[currentTheme]);
themeBtn!.innerHTML = currentTheme === "light" ? getSunIcon() : getMoonIcon();
if (opts.onThemeChange) {
map.mapLibreMap.once("style.load", () => opts.onThemeChange!());
}
});
container.appendChild(themeBtn);
}
// Traffic toggle button
let trafficBtn: HTMLButtonElement | null = null;
if (opts.showTrafficToggle && trafficModule) {
trafficBtn = document.createElement("button");
trafficBtn.className = `map-control-btn traffic-btn ${trafficEnabled ? "active" : ""}`;
trafficBtn.title = "Toggle traffic flow";
trafficBtn.innerHTML = getTrafficIcon();
trafficBtn.addEventListener("click", () => {
trafficEnabled = !trafficEnabled;
trafficModule!.setVisible(trafficEnabled);
trafficBtn!.classList.toggle("active", trafficEnabled);
});
container.appendChild(trafficBtn);
}
// Traffic incidents toggle button
let incidentsBtn: HTMLButtonElement | null = null;
if (opts.showIncidentsToggle && options.externalIncidentsModule) {
incidentsModule = options.externalIncidentsModule;
incidentsModule.setVisible(opts.initialIncidentsEnabled);
incidentsModule.setIconsVisible(opts.initialIncidentsEnabled);
incidentsBtn = document.createElement("button");
incidentsBtn.className = `map-control-btn incidents-btn ${incidentsEnabled ? "active" : ""}`;
incidentsBtn.title = "Toggle traffic incidents";
incidentsBtn.innerHTML = getIncidentsIcon();
incidentsBtn.addEventListener("click", () => {
incidentsEnabled = !incidentsEnabled;
incidentsModule!.setVisible(incidentsEnabled);
incidentsModule!.setIconsVisible(incidentsEnabled);
incidentsBtn!.classList.toggle("active", incidentsEnabled);
});
container.appendChild(incidentsBtn);
}
// Add to map container
const mapContainer = map.mapLibreMap.getContainer();
mapContainer.appendChild(container);
// Add styles
injectStyles();
return {
trafficModule,
incidentsModule,
setTheme: (theme: "light" | "dark") => {
currentTheme = theme;
map.setStyle(THEME_STYLES[theme]);
if (themeBtn) {
themeBtn.innerHTML = theme === "light" ? getSunIcon() : getMoonIcon();
}
if (opts.onThemeChange) {
map.mapLibreMap.once("style.load", () => opts.onThemeChange!());
}
},
setTrafficVisible: (visible: boolean) => {
trafficEnabled = visible;
if (trafficModule) {
trafficModule.setVisible(visible);
}
if (trafficBtn) {
trafficBtn.classList.toggle("active", visible);
}
},
setIncidentsVisible: (visible: boolean) => {
incidentsEnabled = visible;
if (incidentsModule) {
incidentsModule.setVisible(visible);
incidentsModule.setIconsVisible(visible);
}
if (incidentsBtn) {
incidentsBtn.classList.toggle("active", visible);
}
},
destroy: () => {
container.remove();
},
};
}
function getSunIcon(): string {
return ``;
}
function getMoonIcon(): string {
return ``;
}
function getTrafficIcon(): string {
return ``;
}
function getIncidentsIcon(): string {
return ``;
}
let stylesInjected = false;
function injectStyles(): void {
if (stylesInjected) return;
stylesInjected = true;
const style = document.createElement("style");
style.textContent = `
.map-controls {
position: absolute;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
}
.map-controls[data-position="top-right"] {
top: 10px;
right: 10px;
}
.map-controls[data-position="top-left"] {
top: 10px;
left: 10px;
}
.map-controls[data-position="bottom-right"] {
bottom: 30px;
right: 10px;
}
.map-controls[data-position="bottom-left"] {
bottom: 30px;
left: 10px;
}
.map-control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: white;
color: #333;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
}
.map-control-btn:hover {
background: #f5f5f5;
transform: scale(1.05);
}
.map-control-btn:active {
transform: scale(0.95);
}
.map-control-btn.active {
background: #2196F3;
color: white;
}
.map-control-btn.active:hover {
background: #1976D2;
}
.map-control-btn svg {
width: 20px;
height: 20px;
}
`;
document.head.appendChild(style);
}