This is the documentation of version 0.62.3. Go to the latest version, 1.0.0-rc.5.

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.

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:

TomTom SDK Solutions:

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.create(map);
const trafficModule = await TrafficIncidentsModule.get(map);
const routesModule = await RoutingModule.create(map);

// Access module-specific events through the events object
placesModule.events.on('click', handlePlaceClick);
trafficModule.events.on('hover', handleTrafficHover);
routesModule.events.mainLines.on('click', handleRouteClick);

Basic event handling

Standard MapLibre event integration

MapLibre's own events stay available on map.mapLibreMap for map-level lifecycle such as moveend or zoom. A raw MapLibre click, however, fires on every click, including one on a place or a route a module already handles; for clicks on the rest of the map, use BaseMapModule, which receives only what no module above it takes:

import { TomTomMap } from '@tomtom-org/maps-sdk/map';

const map = new TomTomMap({ mapLibre: { container: 'map' } });

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.create(map);

// Use the module's events object for TomTom-specific interactions
placesModule.events.on('click', (feature, lngLat) => {
    console.log('Place clicked:', feature.properties.title);
    console.log('Click coordinates:', lngLat);
    displayPlaceDetails(feature.properties);
});

placesModule.events.on('hover', (feature) => {
    console.log('Place hovered:', feature.properties.title);
    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({
        category: feature.properties.category,
        magnitudeOfDelay: feature.properties.magnitudeOfDelay,
        description: feature.properties.description,
        delayInSeconds: feature.properties.delayInSeconds,
        location: lngLat
    });
});

trafficIncidentsModule.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.create(map);

routesModule.events.mainLines.on('click', (feature) => {
    console.log('Route clicked:', feature.properties.index);
    console.log('Route state:', feature.properties.routeState);
    showRouteDetails(feature.properties);
});

Scoping events

events.where(scope, config?) narrows a module's events to part of what it covers, and returns an events object for just that part. Each scope carries its own event configuration, so two parts of one module can have different hover cursors.

There are two things you can narrow.

A feature predicate works on every module — only your data can tell a major incident from a minor one:

trafficIncidents.events
    .where((incident) => incident.properties.magnitude === 'major')
    .on('click', showIncidentDetails);

Layer groups work on BaseMapModule, the one module whose layers have a stable, published vocabulary:

baseMap.events
    .where({ layerGroups: { mode: 'include', names: ['roadLabels'] } }, { cursorOnHover: 'pointer' })
    .on('click', (feature) => showRoadName(feature.properties.name));

A module's named scopes narrow the same way:

routing.events.tunnels
    .where((section) => section.properties.lengthInMeters > 500)
    .on('click', showLongTunnel);

To narrow on both axes at once, give BaseMapModule's scope object a features predicate alongside its layerGroups — one where call, rather than two:

baseMap.events
    .where({
        layerGroups: { mode: 'include', names: ['roadLabels'] },
        features: (feature) => feature.properties.name?.startsWith('A'),
    })
    .on('click', showRoadName);

A predicate filters every feature under the pointer, not just the topmost one, and the first match becomes the handler's primary feature. Clicking a stack whose top hit is a minor incident and whose second is major still reaches a 'major' handler, with the major incident. When nothing under the pointer matches, the handler is not called.

Module event scopes

A module that draws several distinct things exposes one named scope per thing. A scope covers the user events of that part, with the same on, off and where as anywhere else. Lifecycle events (config-change, shown-features) stay on the module: a module has one configuration and one show stream, and a scope narrows neither.

Module Named scopes
RoutingModule mainLines, waypoints, chargingStops, summaryBubbles, countryCrossings, incidents, vehicleRestricted, ferries, tollRoads, tunnels, instructionLines, plus one <type>Sections scope per generated section type
PlacesModule places, connections, entryPoints
GeometriesModule geometry, geometryLabel
CustomGeoJSONModule your own sources keys
routing.events.mainLines.on('click', (route) => showRouteSummary(route));
routing.events.waypoints.on('hover', (waypoint) => showTooltip(waypoint));

// The module's own events cover all of its scopes at once, plus the lifecycle events:
routing.events.on('click', (feature) => console.log('something on the route', feature));
routing.events.on('config-change', (config) => console.log(config));

Modules that draw one thing — BaseMapModule, POIsModule, TrafficFlowModule, TrafficIncidentsModule, TrafficIncidentDetailsModule, TrafficAreaAnalyticsModule — have no named scopes. Their events already is that scope; narrow it with where(). TerrainModule draws a raster relief with no features to interact with, so it has lifecycle events only.

Event states

A user interaction marks the feature it lands on with an event state — click, contextmenu, hover, long-hover or recently-hovered — in its properties.eventState, as long as a handler covers that feature. Every module that draws data you pass to show lets you put, clean and read those states yourself: PlacesModule, RoutingModule, GeometriesModule, CustomGeoJSONModule, TrafficIncidentDetailsModule and TrafficAreaAnalyticsModule.

// Select a route as if clicked, replacing the click on any other route
routing.putEventState({ id: routeID, state: 'click' });

// Read them back: the ids with a state, by state, whoever put them
const { click: [selectedID] = [], hover = [] } = routing.getEventStates();

routing.cleanEventState({ id: routeID });
routing.cleanEventStates({ states: ['hover'] });
routing.cleanEventStates(); // every state

The methods act on the module's main features, the ones getShown() returns them in, so an id read from there, or from a handler, is an id they take:

Module Acts on
PlacesModule places, clustered or not
RoutingModule mainLines, the routes
GeometriesModule geometry
CustomGeoJSONModule every source
TrafficIncidentDetailsModule incidents
TrafficAreaAnalyticsModule hexgrid, square and heatmap

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:

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:

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.id)
        .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,
        hoverGracePeriodMS: 400
    }
});

Changing the configuration at runtime

updateEventsConfig merges new values into the map's events configuration; what you leave out keeps its value. It takes effect from the next hover or click, while a long hover or a grace period already running keeps the delay it started with.

map.updateEventsConfig({ longHoverDelayOnStillMapMS: 600, hoverGracePeriodMS: 1000 });

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
}

Recently hovered features

When the pointer leaves a GeoJSON-backed feature (places, routes, geometries, your own GeoJSON), its hover turns into recently-hovered for hoverGracePeriodMS (400 by default), unless it is hovered or clicked again first. 0 clears it at once, on the map or on one module's events.

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 the hover cursor and hoverGracePeriodMS for their own features. updateEventsConfig changes the map's values and leaves these overrides in place:

import { PlacesModule } from '@tomtom-org/maps-sdk/map';

const placesModule = await PlacesModule.create(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:

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 treat some map layers as interactive and the rest as background. Use two scopes over the one base map module — each with its own handlers and its own cursor.

import { BaseMapModule } from '@tomtom-org/maps-sdk/map';

// Define which layer groups should be interactive
const interactiveLayerGroups = ['roads', 'roadLabels', 'buildings3D'];

const baseMap = await BaseMapModule.get(map);

const interactiveLayers = baseMap.events.where({
    layerGroups: { mode: 'include', names: interactiveLayerGroups }
});

const restOfTheMap = baseMap.events.where(
    { layerGroups: { mode: 'exclude', names: interactiveLayerGroups } },
    { cursorOnHover: 'default' }  // Keep default cursor on background
);

// Handle interactive layer clicks
interactiveLayers.on('click', (feature, lngLat) => {
    console.log('Interactive feature clicked:', feature.properties);
    showFeatureDetails(feature);
});

// Handle background clicks
restOfTheMap.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.create(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 scope with default cursor
const baseMap = await BaseMapModule.get(map);

const restOfTheMap = baseMap.events.where(
    { layerGroups: { mode: 'exclude', names: ['roads', 'roadLabels'] } },
    { cursorOnHover: 'default' }  // Prevents pointer cursor on hover
);

Cursor Configuration Patterns:

// Pattern 1: two scopes of one module, each with its own cursor
const baseMap = await BaseMapModule.get(map);

const interactiveFeatures = baseMap.events.where(
    { layerGroups: { mode: 'include', names: ['roads'] } },
    { cursorOnHover: 'pointer' }  // Shows as clickable
);

const background = baseMap.events.where(
    { layerGroups: { mode: 'exclude', names: ['roads'] } },
    { cursorOnHover: 'default' }  // Shows as not interactive
);

// Pattern 2: map-wide configuration, overridden per module or per scope
const map = new TomTomMap({
    mapLibre: {
        container: 'map'
    },
    events: {
        cursorOnHover: 'pointer'  // Default for all modules
    }
});

// Override for the whole base map module...
const baseMapWithDefaultCursor = await BaseMapModule.get(map, {
    events: { cursorOnHover: 'default' }
});

// ...or for just one scope of it
const labels = baseMapWithDefaultCursor.events.where(
    { layerGroups: { mode: 'include', names: ['roadLabels'] } },
    { cursorOnHover: 'pointer' }
);

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.create(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.create(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 scopes of one module:

Scopes of the same module follow the same rule — whichever layer is on top wins, regardless of which scope registered first:

const baseMap = await BaseMapModule.get(map);

const roads = baseMap.events.where({ layerGroups: { mode: 'include', names: ['roads'] } });
const buildings = baseMap.events.where({ layerGroups: { mode: 'include', names: ['buildings3D'] } });

// Where a building covers a road, the building is drawn on top, so `buildings` fires

Key Principles:

Related guides and examples

Services integration

Related examples

Related map modules