Maps SDK for JavaScript
Back to all examples
EV charging stations custom display
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
Along route search
Search for EV charging stations along a route using the along-route search API
Routing
Places and Search
Electric Vehicles
Web
EV charging stations search
Interactive exploration of charging station availability
Playground
Places and Search
Electric Vehicles
Web
Bring Your Own Data: Yorkshire Heatmap
Add your own data with heatmap and place icons visualization.
Web
Bring Your Own Data
Places and Search
Customization
Long Distance EV Routing charging stops customization
A-B Long Distance EV Route with charging stops having custom icons and text.
Routing
Electric Vehicles
Web
Customization