User Interaction Events

The User Interaction Events system in the TomTom Maps SDK provides enhanced event handling capabilities that build upon MapLibre’s native events. This system simplifies complex user interactions and provides consistent event handling across different map elements and modules, enabling rich interactive mapping experiences.

import { Place, TomTomConfig } from '@tomtom-org/maps-sdk/core';
import { BaseMapModule, PlacesModule, TomTomMap, TrafficIncidentsModule } from '@tomtom-org/maps-sdk/map';
import { reverseGeocode, search } from '@tomtom-org/maps-sdk/services';
import { LngLat, MapGeoJSONFeature, Marker, NavigationControl, Popup } from 'maplibre-gl';
import './style.css';
import { API_KEY } from './config';

// (Set your own API key when working in your own environment)
TomTomConfig.instance.put({ apiKey: API_KEY });

(async () => {
    const map = new TomTomMap({
        mapLibre: {
            container: 'sdk-map',
            center: [-0.12634, 51.50276],
            zoom: 14,
        },
    });
    const popUp = new Popup({
        closeButton: true,
        closeOnClick: true,
        closeOnMove: true,
        offset: 15,
        className: 'sdk-example-popup',
    });
    let isMarkerVisible = false;
    const revGeocodingMarker = new Marker({ color: '#df1b12' });

    const showTrafficPopup = (topFeature: MapGeoJSONFeature, lngLat: LngLat) => {
        const { properties } = topFeature;

        const incidentSeverity: Record<number, string> = {
            3: 'major',
            2: 'moderate',
            1: 'minor',
        };

        popUp
            .setOffset(5)
            .setHTML(
                `
        <div id="traffic-incident-popup">
        <h3>Traffic incident</h3>
        <b id="traffic-incident-road-type">Road type</b> ${topFeature.properties.road_category}<br />
        <b id="traffic-incident-magnitude">Magnitude</b> ${incidentSeverity[properties.magnitude]} <br />
        <b id="traffic-incident-delay">Delay</b> ${Math.floor(properties.delay / 60)} m ${
            properties.delay % 60
        } s</b> <br />
        </div>
        `,
            )
            .setLngLat(lngLat)
            .addTo(map.mapLibreMap);
    };

    const initTrafficIncidents = async () => {
        (await TrafficIncidentsModule.get(map, { visible: true })).events.on('long-hover', showTrafficPopup);
    };

    const showPlacesPopUp = (topFeature: Place, lngLat: LngLat) => {
        const { address, poi } = topFeature.properties;

        if (isMarkerVisible) {
            revGeocodingMarker.remove();
        }

        new Popup({
            closeButton: true,
            closeOnClick: true,
            closeOnMove: true,
            offset: 15,
            className: 'sdk-example-popup',
        })
            .setHTML(
                `
            <div id="place-popup">
            <h3 id="place-name">${poi?.name}</h3>
            <b id="place-address"> Address: </b> ${address.freeformAddress}
            <br />
            ${poi?.phone ? `<b> Phone: </b> ${poi?.phone}` : ''}
            <div id="sdk-example-popup-tags">
            ${poi?.categories?.map((category) => `<span class="sdk-example-popup-tags-item">${category}</span>`)}
            </div>
            </div> 
            `,
            )
            .setLngLat(lngLat)
            .addTo(map.mapLibreMap)
            .once('close', () => (isMarkerVisible = true));
    };

    const initPlacesModule = async () => {
        const placesModule = await PlacesModule.get(map);

        const places = await search({
            query: 'pharmacy',
            limit: 35,
            boundingBox: map.getBBox(),
        });

        placesModule.show(places);
        placesModule.events.on('click', showPlacesPopUp);
    };

    const showBasemapPopup = async (_: any, lnglat: LngLat) => {
        const { properties } = await reverseGeocode({ position: [lnglat.lng, lnglat.lat] });
        revGeocodingMarker.setLngLat(lnglat).addTo(map.mapLibreMap);

        if (!isMarkerVisible) {
            new Popup({
                closeButton: true,
                closeOnClick: true,
                closeOnMove: true,
                offset: 6,
                className: 'sdk-example-popup-basemap',
            })
                .setHTML(
                    `
                <div id="sdk-example-popup-basemap">
                ${
                    properties.address.freeformAddress
                        ? ` <h4 id="sdk-example-popup-basemap-address">${properties.address.freeformAddress}</h4> <hr class="sdk-example-hr" />`
                        : ''
                }
                    <div id="sdk-example-popup-lnglat">
                        <span> ${lnglat.lng.toFixed(5)}, ${lnglat.lat.toFixed(5)}</span>
                    </div>
                </div> 
                `,
                )
                .setLngLat(lnglat)
                .addTo(map.mapLibreMap);
            isMarkerVisible = true;
        } else {
            revGeocodingMarker.remove();
            isMarkerVisible = false;
        }
    };

    const initBaseMapModule = async () => {
        const baseModule = await BaseMapModule.get(map);
        // Listening hover events on Basemap module to remove traffic popups.
        baseModule.events.on('hover', () => popUp.isOpen() && popUp.remove());
        baseModule.events.on('click', showBasemapPopup);
    };

    map.mapLibreMap.addControl(new NavigationControl());
    map.mapLibreMap.on('dragstart', () => {
        revGeocodingMarker.remove();
        isMarkerVisible = false;
    });

    await initBaseMapModule();
    await initPlacesModule();
    await initTrafficIncidents();
})();

Event System Architecture

The TomTom SDK’s event system addresses common MapLibre event handling challenges while maintaining full compatibility with the underlying MapLibre event infrastructure. This dual-layer approach provides both familiar MapLibre patterns and enhanced TomTom-specific functionality.

Enhanced Event Capabilities

Problems Solved by TomTom Events:

  • Complex hover implementation: MapLibre hover events require manual timing, state management, and precision handling
  • Event conflicts: Multiple interactive sources can interfere with each other without proper coordination
  • Data transformation: Raw MapLibre events need conversion to application-specific formats with relevant context
  • Precision issues: Accurate event detection with appropriate tolerance is difficult to implement consistently

TomTom SDK Solutions:

  • Unified event API: Consistent event handling patterns across all SDK modules through dedicated events objects
  • Built-in hover support: Automatic hover detection with configurable delays, thresholds, and state management
  • Conflict resolution: Smart event routing between different map modules and layers prevents interference
  • Rich data integration: Events include relevant TomTom data and contextual information automatically

Module-Specific Event Systems

Each TomTom module provides its own dedicated event system through an events object, eliminating the need to work directly with MapLibre events for module-specific interactions:

import { PlacesModule, TrafficIncidentsModule, RoutingModule } from '@tomtom-org/maps-sdk/map';
// Each module exposes its own events object
const placesModule = await PlacesModule.get(map);
const trafficModule = await TrafficIncidentsModule.get(map);
const routesModule = await RoutingModule.get(map);
// Access module-specific events through the events object
placesModule.events.on('click', handlePlaceClick);
trafficModule.events.on('hover', handleTrafficHover);
routesModule.events.on('click', handleRouteClick);

Basic Event Handling

Standard MapLibre Event Integration

The SDK maintains full compatibility with MapLibre events for general map interactions:

import { TomTomMap } from '@tomtom-org/maps-sdk/map';
const map = new TomTomMap({ mapLibre: { container: 'map' } });
// Standard MapLibre events for general map interaction
map.mapLibreMap.on('click', (event) => {
console.log('Map clicked at:', event.lngLat);
});
map.mapLibreMap.on('moveend', () => {
const bounds = map.mapLibreMap.getBounds();
console.log('New map bounds:', bounds);
});

TomTom Module Event Handling

Places Module Events

The Places Module provides rich event handling for place interactions through its dedicated events system:

import { PlacesModule } from '@tomtom-org/maps-sdk/map';
const placesModule = await PlacesModule.get(map);
// Use the module's events object for TomTom-specific interactions
placesModule.events.on('click', (feature, lngLat) => {
console.log('Place clicked:', feature.properties.name);
console.log('Click coordinates:', lngLat);
displayPlaceDetails(feature.properties);
});
placesModule.events.on('hover', (feature) => {
console.log('Place hovered:', feature.properties.name);
showPlacePreview(feature.properties);
});

Traffic Module Events

Traffic incidents and flow data provide specialized event handling for traffic-related interactions:

import { TrafficIncidentsModule } from '@tomtom-org/maps-sdk/map';
const trafficIncidentsModule = await TrafficIncidentsModule.get(map);
// Traffic-specific events with enriched data
trafficIncidentsModule.events.on('click', (feature, lngLat) => {
displayTrafficIncident({
type: feature.properties.type,
severity: feature.properties.severity,
description: feature.properties.description,
delay: feature.properties.delay,
location: lngLat
});
});
trafficModule.events.on('long-hover', (feature, lngLat) => {
showTrafficTooltip(feature.properties, lngLat);
});

Routing Module Events

Route interactions provide detailed information about route segments and waypoints:

import { RoutingModule } from '@tomtom-org/maps-sdk/map';
const routesModule = await RoutingModule.get(map);
routesModule.events.on('click', (feature) => {
console.log('Route clicked:', feature.properties.routeId);
console.log('Route segment:', feature.properties.segmentIndex);
showRouteDetails(feature.properties);
});

Advanced Event Types

Hover Events

The SDK introduces sophisticated hover detection that addresses the complexity of implementing reliable hover functionality with MapLibre. Traditional hover implementation requires managing mouse enter/leave events, dealing with timing issues, and handling state management across different map features.

Hover Event Challenges:

  • Timing complexity: Raw mouse events require debouncing and timing management
  • State management: Tracking which features are currently hovered requires careful state handling
  • Performance concerns: Frequent mouse events can impact application performance
  • Cross-layer conflicts: Multiple interactive layers can interfere with hover detection

TomTom Hover Solutions: The SDK provides automatic hover detection with built-in timing, state management, and performance optimization:

// Standard hover: Immediate response to mouse enter
placesModule.events.on('hover', (feature) => {
showQuickPreview(feature.properties);
});

Long-Hover Events

Long-hover events are particularly valuable for displaying detailed information without cluttering the interface with immediate popups. This pattern is ideal for progressive disclosure interfaces where users can get quick information by hovering briefly, or detailed information by maintaining hover focus.

Long-Hover Implementation Benefits:

  • Prevents accidental triggers: Users must maintain focus for a specified duration
  • Reduces UI noise: Detailed information appears only when users demonstrate intent
  • Improves performance: Expensive operations (like API calls) are triggered only after sustained interest
  • Enhanced UX: Creates a natural progression from quick glance to detailed exploration

Configurable Long-Hover Timing: The long-hover delay is configurable to match your application’s UX requirements:

// Long hover with default timing (typically 800ms)
trafficModule.events.on('long-hover', (feature, lngLat) => {
fetchDetailedTrafficInfo(feature.properties.incidentId)
.then(details => showDetailedIncident(details, lngLat));
});

Event Timing and Configuration

Different modules may have different timing requirements based on the type of interaction and data complexity:

// Example of how timing might vary by module type
// (Note: Actual configuration methods may vary by implementation)
trafficModule.events.on('long-hover', (feature) => {
// Triggered after longer delay for complex traffic data
displayComprehensiveTrafficAnalysis(feature.properties);
});
placesModule.events.on('hover', (feature) => {
// Triggered quickly for simple place information
showPlaceBasicInfo(feature.properties);
});

Event Data Structure

Enhanced Event Payloads

TomTom module events provide enriched data compared to raw MapLibre events.

Modules where service data is added (places, routes, geometries) will map their user events to the original added data.

placesModule.events.on('click', (feature, lngLat, allFeatures, source) => {
// feature: GeoJSON feature with TomTom-specific properties
console.log('Feature data:', feature.properties);
// lngLat: Precise click coordinates
console.log('Click location:', lngLat);
// allFeatures: All features at the event location (array of MapGeoJSONFeature)
console.log('All features at location:', allFeatures.length);
// source: Source and layer configuration for the feature
console.log('Source:', source);
});

Event Configuration

Map-Level Configuration (MapEventsConfig)

Configure global event behavior when initializing the map through the events option. These settings affect all interactive modules and layers:

import { TomTomMap } from '@tomtom-org/maps-sdk/map';
const map = new TomTomMap({
mapLibre: {
container: 'map'
},
events: {
precisionMode: 'box',
paddingBoxPx: 10,
cursorOnHover: 'pointer',
longHoverDelayAfterMapMoveMS: 800,
longHoverDelayOnStillMapMS: 300
}
});

Precision Configuration

Control how accurately events detect features:

// Standard: Easier clicking with 5px tolerance (default)
events: {
precisionMode: 'box',
paddingBoxPx: 5
}
// Mobile: Larger touch targets
events: {
precisionMode: 'box',
paddingBoxPx: 15
}
// Precise: Exact pixel matching for dense data
events: {
precisionMode: 'point'
}

Hover Timing Configuration

Adjust long-hover delays to match your UX requirements:

// Responsive: Quick tooltips after map settles
events: {
longHoverDelayAfterMapMoveMS: 500, // After panning
longHoverDelayOnStillMapMS: 200 // Subsequent hovers
}
// Conservative: Prevent accidental triggers
events: {
longHoverDelayAfterMapMoveMS: 1200,
longHoverDelayOnStillMapMS: 500
}

Cursor Styling

Customize cursor appearance for different interaction states:

events: {
cursorOnMap: 'grab', // Default cursor
cursorOnHover: 'pointer', // When hovering features
cursorOnMouseDown: 'grabbing' // During click/drag
}

Module-Level Configuration (EventHandlerConfig)

Individual modules can override cursor behavior for their specific features:

import { PlacesModule } from '@tomtom-org/maps-sdk/map';
const placesModule = await PlacesModule.get(map, {
events: {
cursorOnHover: 'help' // Custom cursor for places
}
});
// Different cursor for different modules
const trafficModule = await TrafficIncidentsModule.get(map, {
events: {
cursorOnHover: 'crosshair' // Different cursor for traffic
}
});

Handling Events on the Rest of the Map

Detecting Clicks Outside Interactive Features

When building interactive maps, you often need to detect when users interact with the base map itself—areas outside of your custom features like places, routes, or traffic incidents. This is essential for:

  • Clearing selections: Deselecting features when users click empty map areas
  • Resetting UI state: Hiding popups or info panels when focus moves away
  • Triggering map-wide actions: Performing reverse geocoding on arbitrary map locations
  • Managing interaction modes: Switching between different interaction states

The BaseMapModule provides a solution for detecting these “rest of the map” interactions through its event system.

Using BaseMapModule for Background Interactions

The BaseMapModule manages all fundamental map layers (roads, buildings, land, water, etc.). By using its event handlers, you can detect user interactions with the underlying map:

import { BaseMapModule } from '@tomtom-org/maps-sdk/map';
const baseMap = await BaseMapModule.get(map);
// Detect clicks on the base map
baseMap.events.on('click', (feature, lngLat) => {
console.log('Map clicked at:', lngLat);
clearAllSelections();
performReverseGeocoding(lngLat);
});
// Detect hovers over the base map
baseMap.events.on('hover', (feature, lngLat) => {
console.log('Hovering over:', feature.properties);
hideAllPopups();
});

Separating Interactive and Background Layers

For more sophisticated applications, you can create separate BaseMapModule instances to differentiate between interactive features and the “rest” of the map. This pattern is particularly useful when you want certain map layers to be interactive while treating others as background.

Pattern: Interactive vs. Background Modules

This approach uses layer group filtering to create two distinct modules:

  1. Interactive module: Handles specific layer groups you want users to interact with
  2. Background module: Handles everything else, representing the “rest of the map”
import { BaseMapModule } from '@tomtom-org/maps-sdk/map';
// Define which layer groups should be interactive
const interactiveLayerGroups = ['roadLines', 'roadLabels', 'buildings3D'];
// Module for interactive features
const interactiveLayers = await BaseMapModule.get(map, {
layerGroupsFilter: {
mode: 'include',
names: interactiveLayerGroups
}
});
// Module for the rest of the map (background)
const restOfTheMap = await BaseMapModule.get(map, {
layerGroupsFilter: {
mode: 'exclude',
names: interactiveLayerGroups
},
events: {
cursorOnHover: 'default' // Keep default cursor on background
}
});
// Handle interactive layer clicks
interactiveLayers.events.on('click', (feature, lngLat) => {
console.log('Interactive feature clicked:', feature.properties);
showFeatureDetails(feature);
});
// Handle background clicks
restOfTheMap.events.on('click', (feature, lngLat) => {
console.log('Background clicked');
clearAllSelections();
});

Practical Example: Clearing Selections

A common use case is clearing feature selections when users click outside interactive elements:

import { PlacesModule, BaseMapModule } from '@tomtom-org/maps-sdk/map';
const placesModule = await PlacesModule.get(map);
const baseMap = await BaseMapModule.get(map, {
events: { cursorOnHover: 'default' }
});
let selectedPlace = null;
// Select places on click
placesModule.events.on('click', (feature, lngLat) => {
selectedPlace = feature;
highlightPlace(feature);
showPlaceDetails(feature);
});
// Clear selection when clicking the base map
baseMap.events.on('click', () => {
if (selectedPlace) {
selectedPlace = null;
clearHighlights();
hidePlaceDetails();
}
});

See the Rest of the Map Click example for a complete working implementation of this pattern.

Cursor Behavior for Background Interactions

When handling background map interactions, it’s important to configure the cursor appropriately to provide proper visual feedback to users.

Why Set cursorOnHover: 'default'?

By default, when hovering over any interactive module features, the cursor changes to indicate interactivity (typically to 'pointer'). However, for background map layers, you usually want to maintain the default cursor to signal that these elements are not primary interactive targets:

// Background module with default cursor
const restOfTheMap = await BaseMapModule.get(map, {
layerGroupsFilter: {
mode: 'exclude',
names: ['places', 'traffic']
},
events: {
cursorOnHover: 'default' // Prevents pointer cursor on hover
}
});

Cursor Configuration Patterns:

// Pattern 1: Interactive features with pointer, background with default
const interactiveFeatures = await BaseMapModule.get(map, {
layerGroupsFilter: { mode: 'include', names: ['roadLines'] },
events: { cursorOnHover: 'pointer' } // Shows as clickable
});
const background = await BaseMapModule.get(map, {
layerGroupsFilter: { mode: 'exclude', names: ['roadLines'] },
events: { cursorOnHover: 'default' } // Shows as not interactive
});
// Pattern 2: Map-wide configuration with module overrides
const map = new TomTomMap({
mapLibre: {
container: 'map'
},
events: {
cursorOnHover: 'pointer' // Default for all modules
}
});
// Override for background to use default cursor
const background = await BaseMapModule.get(map, {
events: { cursorOnHover: 'default' }
});

Complete Example: Multi-Module Interaction

Here’s a comprehensive example showing how different modules work together with background detection:

import {
BaseMapModule,
PlacesModule,
TrafficIncidentsModule
} from '@tomtom-org/maps-sdk/map';
// Initialize modules
const placesModule = await PlacesModule.get(map);
const trafficModule = await TrafficIncidentsModule.get(map);
const baseMap = await BaseMapModule.get(map, {
events: { cursorOnHover: 'default' }
});
let activePopup = null;
// Places interactions
placesModule.events.on('click', (feature, lngLat) => {
activePopup = showPopup('place', feature, lngLat);
});
// Traffic interactions
trafficModule.events.on('click', (feature, lngLat) => {
activePopup = showPopup('traffic', feature, lngLat);
});
// Base map hover - clear popups when moving to background
baseMap.events.on('hover', () => {
if (activePopup) {
activePopup.remove();
activePopup = null;
}
});
// Base map click - perform reverse geocoding
baseMap.events.on('click', async (feature, lngLat) => {
if (activePopup) {
activePopup.remove();
activePopup = null;
}
const result = await reverseGeocode({
position: [lngLat.lng, lngLat.lat]
});
showAddressInfo(result, lngLat);
});

Event Priority and Layer Order

Event priority is determined by layer rendering order - whichever layer is rendered on top receives the event first. This follows the natural visual stacking of map elements.

How Event Priority Works:

The module whose layers are rendered highest (top-most) in the layer stack receives the event:

const placesModule = await PlacesModule.get(map);
const baseMap = await BaseMapModule.get(map);
// Click on a place marker: placesModule receives the event
// Click on a road: baseMap receives the event

Multiple Module Instances:

When creating multiple instances of the same module type, later instances are typically rendered on top:

const roads = await BaseMapModule.get(map, {
layerGroupsFilter: { mode: 'include', names: ['roadLines'] }
});
const buildings = await BaseMapModule.get(map, {
layerGroupsFilter: { mode: 'include', names: ['buildings3D'] }
});
// buildings module is on top - receives events first for overlapping areas

Key Principles:

  • Layer-based priority: Top-most visible layer receives the event
  • No event bubbling: Events don’t propagate to lower layers
  • Visual hierarchy: What users see on top gets the interaction
  • Module independence: Each module’s events operate separately

Services Integration

  • Reverse Geocoding - Use user click events to trigger reverse geocoding and display address information
  • Search Services - Handle user interactions to trigger place searches and display results
  • Geocoding - Process user input events to geocode addresses and show locations