Back to all examples

EV charging stations custom display

This example on GitHub

Customize EV charging station icons, text, and availability display

EV charging stations custom display
import { type Place, type POICategory, TomTomConfig } from '@tomtom-org/maps-sdk/core';
import { type AvailabilityLevel, PlacesModule, POIsModule, TomTomMap } from '@tomtom-org/maps-sdk/map';
import {
    getPlacesWithEVAvailability,
    getPlaceWithEVAvailability,
    type SearchResponse,
    search,
} from '@tomtom-org/maps-sdk/services';
import { Popup } from 'maplibre-gl';
import { API_KEY } from './config';
const customEvCircleSVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"54\" height=\"54\" viewBox=\"0 0 54 54\">\n    <!-- Circular background station icon - Base variant -->\n    <!-- Main circle -->\n    <circle cx=\"27\" cy=\"27\" r=\"27\" fill=\"#00D166\"/>\n    <circle cx=\"27\" cy=\"27\" r=\"23\" fill=\"#00D166\" stroke=\"#263543\" stroke-width=\"2\" stroke-opacity=\"0.3\"/>\n    <!-- Lightning bolt -->\n    <polygon points=\"32,16 22,29 27,30 25,40 35,27 30,26\" fill=\"white\"/>\n</svg>\n";
const customEvCircleAvailable = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"54\" height=\"54\" viewBox=\"0 0 54 54\">\n    <!-- Circular background station icon - Available variant -->\n    <!-- Main circle -->\n    <circle cx=\"27\" cy=\"27\" r=\"27\" fill=\"#00D166\"/>\n    <circle cx=\"27\" cy=\"27\" r=\"23\" fill=\"#00D166\" stroke=\"#263543\" stroke-width=\"2\" stroke-opacity=\"0.3\"/>\n    <!-- Lightning bolt -->\n    <polygon points=\"32,16 22,29 27,30 25,40 35,27 30,26\" fill=\"white\"/>\n    <!-- Availability indicator - Green (top-left) -->\n    <circle cx=\"10\" cy=\"10\" r=\"9\" fill=\"#24D09D\" stroke=\"#263543\" stroke-width=\"2.4\"/>\n</svg>\n";
const customEvCircleUnavailable = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"54\" height=\"54\" viewBox=\"0 0 54 54\">\n    <!-- Circular background station icon - Unavailable variant -->\n    <!-- Main circle -->\n    <circle cx=\"27\" cy=\"27\" r=\"27\" fill=\"#00D166\"/>\n    <circle cx=\"27\" cy=\"27\" r=\"23\" fill=\"#00D166\" stroke=\"#263543\" stroke-width=\"2\" stroke-opacity=\"0.3\"/>\n    <!-- Lightning bolt -->\n    <polygon points=\"32,16 22,29 27,30 25,40 35,27 30,26\" fill=\"white\"/>\n    <!-- Availability indicator - Red (top-left) -->\n    <circle cx=\"10\" cy=\"10\" r=\"9\" fill=\"#FF0000\" stroke=\"#263543\" stroke-width=\"2.4\"/>\n</svg>\n";
const customEvPinSVG = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"140\">\n    <!-- Custom EV charging station icon example -->\n    <path id=\"background\" d=\"M63.95 137.516c6.556-12.737 19.098-21.09 30.828-29.388C110.046 97.324 120 79.603 120 59.574 120 26.671 93.138 0 60 0S0 26.671 0 59.574c0 20.029 9.954 37.75 25.22 48.55 11.734 8.302 24.274 16.652 30.83 29.388 1.706 3.316 6.194 3.316 7.9 0z\"\n          fill=\"#00D166\"/>\n    <path id=\"outline\" d=\"M91.298 103.272C105.052 93.544 114 77.596 114 59.574c0-29.61-24.176-53.617-54-53.617S6 29.964 6 59.574c0 18.022 8.95 33.97 22.7 43.698l2.27 1.601c10.172 7.153 21.922 15.423 29.03 27.416 7.104-11.993 18.858-20.259 29.03-27.416l2.268-1.601zm1.468 6.274c-11.13 7.845-22.632 15.957-28.816 27.966-1.706 3.316-6.194 3.316-7.9 0-6.182-12.009-17.686-20.121-28.816-27.966l-2.016-1.422C9.954 97.324 0 79.603 0 59.574 0 26.671 26.862 0 60 0s60 26.671 60 59.574c0 20.029-9.954 37.75-25.222 48.55l-2.016 1.422z\"\n          fill-opacity=\".3\"/>\n    <!-- Centered lightning bolt  -->\n    <polygon points=\"65,35 39,65 57,67 55,85 81,55 63,53\" fill=\"white\"/>\n</svg>";
const customEvPinAvailable = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"140\">\n    <!-- Custom EV charging station icon - Available variant -->\n    <path id=\"background\" d=\"M63.95 137.516c6.556-12.737 19.098-21.09 30.828-29.388C110.046 97.324 120 79.603 120 59.574 120 26.671 93.138 0 60 0S0 26.671 0 59.574c0 20.029 9.954 37.75 25.22 48.55 11.734 8.302 24.274 16.652 30.83 29.388 1.706 3.316 6.194 3.316 7.9 0z\"\n          fill=\"#00D166\"/>\n    <path id=\"outline\" d=\"M91.298 103.272C105.052 93.544 114 77.596 114 59.574c0-29.61-24.176-53.617-54-53.617S6 29.964 6 59.574c0 18.022 8.95 33.97 22.7 43.698l2.27 1.601c10.172 7.153 21.922 15.423 29.03 27.416 7.104-11.993 18.858-20.259 29.03-27.416l2.268-1.601zm1.468 6.274c-11.13 7.845-22.632 15.957-28.816 27.966-1.706 3.316-6.194 3.316-7.9 0-6.182-12.009-17.686-20.121-28.816-27.966l-2.016-1.422C9.954 97.324 0 79.603 0 59.574 0 26.671 26.862 0 60 0s60 26.671 60 59.574c0 20.029-9.954 37.75-25.222 48.55l-2.016 1.422z\"\n          fill-opacity=\".3\"/>\n    <!-- Centered lightning bolt -->\n    <polygon points=\"65,35 39,65 57,67 55,85 81,55 63,53\" fill=\"white\"/>\n    <!-- Availability indicator circle - Green -->\n    <circle id=\"availability\" cx=\"17\" cy=\"17\" r=\"15\" fill=\"#24D09D\" stroke=\"#263543\" stroke-width=\"4\"/>\n</svg>\n";
const customEvPinUnavailable = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"140\">\n    <!-- Custom EV charging station icon - Unavailable variant -->\n    <path id=\"background\" d=\"M63.95 137.516c6.556-12.737 19.098-21.09 30.828-29.388C110.046 97.324 120 79.603 120 59.574 120 26.671 93.138 0 60 0S0 26.671 0 59.574c0 20.029 9.954 37.75 25.22 48.55 11.734 8.302 24.274 16.652 30.83 29.388 1.706 3.316 6.194 3.316 7.9 0z\"\n          fill=\"#00D166\"/>\n    <path id=\"outline\" d=\"M91.298 103.272C105.052 93.544 114 77.596 114 59.574c0-29.61-24.176-53.617-54-53.617S6 29.964 6 59.574c0 18.022 8.95 33.97 22.7 43.698l2.27 1.601c10.172 7.153 21.922 15.423 29.03 27.416 7.104-11.993 18.858-20.259 29.03-27.416l2.268-1.601zm1.468 6.274c-11.13 7.845-22.632 15.957-28.816 27.966-1.706 3.316-6.194 3.316-7.9 0-6.182-12.009-17.686-20.121-28.816-27.966l-2.016-1.422C9.954 97.324 0 79.603 0 59.574 0 26.671 26.862 0 60 0s60 26.671 60 59.574c0 20.029-9.954 37.75-25.222 48.55l-2.016 1.422z\"\n          fill-opacity=\".3\"/>\n    <!-- Centered lightning bolt -->\n    <polygon points=\"65,35 39,65 57,67 55,85 81,55 63,53\" fill=\"white\"/>\n    <!-- Availability indicator circle - Red -->\n    <circle id=\"availability\" cx=\"17\" cy=\"17\" r=\"15\" fill=\"#FF0000\" stroke=\"#263543\" stroke-width=\"4\"/>\n</svg>\n";
import { setupEventListeners } from './eventListeners';
import { connectorsHTML } from './htmlTemplates';
import './style.css';
import { initTogglePanel } from './togglePanel';

// (Set your own API key when working in your own environment)
TomTomConfig.instance.put({ apiKey: API_KEY, language: 'en-GB' });

(async () => {
    // =============================================================================
    // SETUP: Map, POIs, and Popup
    // =============================================================================
    const map = new TomTomMap({
        mapLibre: { container: 'sdk-map', center: [2.3597, 48.85167], zoom: 11 },
    });

    const mapBasePOIs = await POIsModule.get(map, {
        filters: { categories: { show: 'all_except', values: ['ELECTRIC_VEHICLE_STATION'] } },
    });

    const popUp = new Popup({ closeButton: false, offset: 35, className: 'sdk-example-maps-sdk-js-popup' });

    // =============================================================================
    // CUSTOMIZATION STATE: All configurable options
    // =============================================================================
    const state = {
        bgAvailability: false,
        bgCustomIcon: false,
        searchAvailability: true,
        searchCustomIcon: true,
        threshold: 0.3,
        textColor: undefined as string | undefined,
        haloColor: undefined as string | undefined,
        haloWidth: 1,
        formatOption: 'slash' as 'slash' | 'of' | 'available',
        textOffset: 0,
        useCustomOffset: false,
    };

    // =============================================================================
    // CONFIG BUILDERS: Convert state to PlacesModule configuration
    // =============================================================================
    const buildIconConfig = (useCustom: boolean, withAvailability: boolean) => {
        if (!useCustom) {
            return undefined;
        }

        // If availability is enabled, use the 2 availability-aware custom pin icons
        if (withAvailability) {
            return {
                categoryIcons: [
                    {
                        id: 'ELECTRIC_VEHICLE_STATION' as POICategory,
                        image: customEvPinAvailable,
                        availabilityLevel: 'available' as AvailabilityLevel,
                    },
                    {
                        id: 'ELECTRIC_VEHICLE_STATION' as POICategory,
                        image: customEvPinUnavailable,
                        availabilityLevel: 'occupied' as AvailabilityLevel,
                    },
                ],
            };
        }

        // Otherwise, use single custom pin icon without availability indicator
        return {
            categoryIcons: [
                {
                    id: 'ELECTRIC_VEHICLE_STATION' as POICategory,
                    image: customEvPinSVG,
                },
            ],
        };
    };

    const buildCircleIconConfig = (useCustom: boolean, withAvailability: boolean) => {
        if (!useCustom) {
            return undefined;
        }

        // If availability is enabled, use the 2 availability-aware circular icons
        if (withAvailability) {
            return {
                categoryIcons: [
                    {
                        id: 'ELECTRIC_VEHICLE_STATION' as POICategory,
                        image: customEvCircleAvailable,
                        availabilityLevel: 'available' as AvailabilityLevel,
                    },
                    {
                        id: 'ELECTRIC_VEHICLE_STATION' as POICategory,
                        image: customEvCircleUnavailable,
                        availabilityLevel: 'occupied' as AvailabilityLevel,
                    },
                ],
            };
        }

        // Otherwise, use single circular icon without availability indicator
        return {
            categoryIcons: [
                {
                    id: 'ELECTRIC_VEHICLE_STATION' as POICategory,
                    image: customEvCircleSVG,
                },
            ],
        };
    };

    const buildEVConfig = (enabled: boolean) => {
        if (!enabled) {
            return undefined;
        }

        const formats = {
            slash: (a: number, t: number) => `${a}/${t}`,
            of: (a: number, t: number) => `${a} of ${t}`,
            available: (a: number) => `${a} available`,
        };

        return {
            enabled: true,
            threshold: state.threshold,
            formatText: formats[state.formatOption],
        };
    };

    const buildTextConfig = () => {
        const hasCustomColors = state.textColor || state.haloColor;
        const hasCustomHaloWidth = state.haloWidth !== 1;
        const hasCustomOffset = state.useCustomOffset;

        // If nothing is customized, return undefined to use SDK defaults
        if (!hasCustomColors && !hasCustomHaloWidth && !hasCustomOffset) {
            return undefined;
        }

        return {
            ...(state.textColor && { color: state.textColor }),
            ...(state.haloColor && { haloColor: state.haloColor }),
            haloWidth: state.haloWidth,
            ...(state.useCustomOffset && { offset: state.textOffset }),
        };
    };

    // =============================================================================
    // PLACES MODULES: Three separate layers for different use cases
    // =============================================================================

    // Background stations: Show all stations on the map
    const bgStations = await PlacesModule.get(map, {
        theme: 'base-map',
        icon: buildCircleIconConfig(state.bgCustomIcon, state.bgAvailability),
        evAvailability: buildEVConfig(state.bgAvailability),
        text: buildTextConfig(),
    });

    // Searched stations: User-searched results
    const searchedStations = await PlacesModule.get(map, {
        theme: 'pin',
        icon: buildIconConfig(state.searchCustomIcon, state.searchAvailability),
        evAvailability: buildEVConfig(state.searchAvailability),
        text: buildTextConfig(),
    });

    // Selected station: With highlighted style
    const selectedStation = await PlacesModule.get(map, {
        icon: buildIconConfig(state.searchCustomIcon, state.searchAvailability),
        evAvailability: buildEVConfig(state.searchAvailability),
        text: { ...buildTextConfig(), color: '#90D5FF', haloWidth: 2 },
    });

    // =============================================================================
    // DATA MANAGEMENT: Store search results for re-rendering on config changes
    // =============================================================================
    let bgStationsData: SearchResponse | null = null;
    let searchedStationsData: SearchResponse | null = null;

    // =============================================================================
    // UI INTERACTIONS: Search, display, and selection logic
    // =============================================================================

    // Update background stations based on zoom level and viewport
    const updateBackgroundStations = async () => {
        const zoom = map.mapLibreMap.getZoom();
        if (zoom < 7) {
            bgStations.clear();
            bgStationsData = null;
        } else {
            bgStationsData = await search({
                poiCategories: ['ELECTRIC_VEHICLE_STATION'],
                minPowerKW: 50,
                boundingBox: map.getBBox(),
                limit: zoom < 10 ? 50 : 100,
            });
            const dataToShow = state.bgAvailability
                ? await getPlacesWithEVAvailability(bgStationsData)
                : bgStationsData.features;
            bgStations.show(dataToShow);
        }
    };

    // Simplified search focusing on EV stations by brand name
    const searchEVStations = async () => {
        const evBrandTextBox = document.querySelector('#sdk-example-evBrandTextBox') as HTMLInputElement;
        popUp.remove();
        mapBasePOIs.setVisible(false);

        searchedStationsData = await search({
            query: evBrandTextBox.value,
            boundingBox: map.getBBox(),
            poiCategories: ['ELECTRIC_VEHICLE_STATION'],
            limit: 100,
        });
        const dataToShow = state.searchAvailability
            ? await getPlacesWithEVAvailability(searchedStationsData)
            : searchedStationsData.features;
        searchedStations.show(dataToShow);
    };

    // Clear search results
    const clear = () => {
        const evBrandTextBox = document.querySelector('#sdk-example-evBrandTextBox') as HTMLInputElement;
        evBrandTextBox.value = '';
        popUp.remove();
        searchedStations.clear();
        selectedStation.clear();
        mapBasePOIs.setVisible(true);
        searchedStationsData = null;
    };

    // Show selected station with popup
    const selectEVStation = async (station: Place) => {
        // Fetch availability for popup display (detailed view)
        const stationWithAvailability = (await getPlaceWithEVAvailability(station)) ?? station;
        selectedStation.show(stationWithAvailability);

        const { address, poi, chargingPark } = stationWithAvailability.properties;
        popUp
            .setHTML(`
                <h3>${poi?.name}</h3>
                <label class="sdk-example-address sdk-example-label">${address.freeformAddress}</label>
                <br/><br/>
                ${chargingPark ? connectorsHTML(chargingPark) : 'Charging park data not available.'}
            `)
            .setLngLat(stationWithAvailability.geometry.coordinates as [number, number])
            .addTo(map.mapLibreMap);
    };

    // =============================================================================
    // DYNAMIC RECONFIGURATION: Apply customizations menu state changes to modules
    // =============================================================================

    // Update background stations configuration and re-render
    const applyBackgroundConfig = async () => {
        bgStations.applyConfig({
            theme: 'base-map',
            icon: buildCircleIconConfig(state.bgCustomIcon, state.bgAvailability),
            evAvailability: buildEVConfig(state.bgAvailability),
            text: buildTextConfig(),
        });
        if (bgStationsData) {
            const dataToShow = state.bgAvailability
                ? await getPlacesWithEVAvailability(bgStationsData)
                : bgStationsData.features;
            bgStations.show(dataToShow);
        }
    };

    // Update searched/selected stations configuration and re-render
    const applySearchedConfig = async () => {
        searchedStations.applyConfig({
            icon: buildIconConfig(state.searchCustomIcon, state.searchAvailability),
            evAvailability: buildEVConfig(state.searchAvailability),
            text: buildTextConfig(),
        });
        selectedStation.applyConfig({
            icon: buildIconConfig(state.searchCustomIcon, state.searchAvailability),
            evAvailability: buildEVConfig(state.searchAvailability),
            text: { ...buildTextConfig(), color: '#90D5FF', haloWidth: 2 },
        });
        if (searchedStationsData) {
            const dataToShow = state.searchAvailability
                ? await getPlacesWithEVAvailability(searchedStationsData)
                : searchedStationsData.features;
            searchedStations.show(dataToShow);
        }
    };

    // =============================================================================
    // INITIALIZE: Setup event listeners and load initial data
    // =============================================================================
    await updateBackgroundStations();
    setupEventListeners({
        map,
        bgStations,
        searchedStations,
        selectedStation,
        popUp,
        state,
        operations: {
            searchEVStations,
            clear,
            selectEVStation,
            updateBackgroundStations,
            applyBackgroundConfig,
            applySearchedConfig,
        },
    });

    initTogglePanel();
})();

Related examples