Back to all examples

Map ev charging stations playground

EV charging station discovery with availability data

import {
    type ChargingParkWithAvailability,
    type ConnectorAvailability,
    type EVChargingStationPlaceProps,
    geographyTypes,
    type Place,
    type PolygonFeatures,
    TomTomConfig,
} from '@tomtom-org/maps-sdk/core';
import { GeometriesModule, PlacesModule, PlacesModuleConfig, POIsModule, TomTomMap } from '@tomtom-org/maps-sdk/map';
import {
    buildPlacesWithEVAvailability,
    buildPlaceWithEVAvailability,
    geometryData,
    search,
} from '@tomtom-org/maps-sdk/services';
import { bboxPolygon, difference } from '@turf/turf';
import { isEmpty, without } from 'lodash-es';
import { type LngLatBoundsLike, NavigationControl, Popup } from 'maplibre-gl';
import { connectorIcons } from './connectorIcons';
import { connectorNames } from './connectorNames';
import genericIcon from './ic-generic-24.svg?raw';
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, language: 'en-GB' });

(async () => {
    const evBrandTextBox = document.querySelector('#sdk-example-evBrandTextBox') as HTMLInputElement;
    const areaTextBox = document.querySelector('#sdk-example-areaTextBox') as HTMLInputElement;
    const fitBoundsOptions = { padding: 50 };
    const popUp = new Popup({
        closeButton: false,
        offset: 35,
        className: 'maps-sdk-js-popup',
    });

    const map = new TomTomMap({
        container: 'sdk-map',
        center: [2.3597, 48.85167],
        zoom: 11,
        fitBoundsOptions,
    });

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

    const buildAvailabilityText = (place: Place<EVChargingStationPlaceProps>): string => {
        const availability = getChargingPointAvailability(place);
        return availability ? `${availability.availableCount}/${availability.totalCount}` : '';
    };

    const evStationPinConfig: PlacesModuleConfig = {
        extraFeatureProps: {
            availabilityText: buildAvailabilityText,
            availabilityRatio: (place: Place) => getChargingPointAvailability(place)?.ratio ?? 0,
        },
        text: {
            title: [
                'format',
                ['get', 'title'],
                {},
                '\n',
                {},
                ['get', 'availabilityText'],
                {
                    'font-scale': 1.1,
                    'text-color': [
                        'case',
                        ['>=', ['get', 'availabilityRatio'], 0.25],
                        'green',
                        ['>', ['get', 'availabilityRatio'], 0],
                        'orange',
                        'red',
                    ],
                },
            ],
        },
    };

    const mapSearchedEVStationsModule = await PlacesModule.get(map, evStationPinConfig);
    const selectedEVStationModule = await PlacesModule.get(map, evStationPinConfig);
    const mapGeometryModule = await GeometriesModule.get(map);

    let minPowerKWMapEVStations = 50;
    let minPowerKWSearchedEVStations = 0;

    const connectorsHTML = (chargingPark: ChargingParkWithAvailability): string => {
        const availability = chargingPark?.availability;
        const connectorAvailabilities = availability
            ? availability.connectorAvailabilities
            : chargingPark.connectorCounts;
        return `<ul class="sdk-example-connector-ul">
        ${connectorAvailabilities
            .map((connectorAvailability) => {
                const statusCounts = (connectorAvailability as ConnectorAvailability).statusCounts;
                const hasStatuses = !isEmpty(statusCounts);
                const availableCount = statusCounts.Available ?? 0;
                const connectorType = connectorAvailability.connector.type;
                const connectorName = connectorNames[connectorType] ?? connectorType;
                return `
                <li class="sdk-example-connector-li">
                    <div class="sdk-example-connectorIcon">${connectorIcons[connectorType] ?? genericIcon}</div>
                    <label class="sdk-example-connectorName sdk-example-label">${connectorName ?? ''}</label>
                    <label class="sdk-example-connectorPower sdk-example-label"> | ${connectorAvailability.connector.ratedPowerKW} KW</label>
                    <label class="sdk-example-label ${
                        hasStatuses
                            ? availableCount
                                ? 'sdk-example-available'
                                : 'sdk-example-unavailable'
                            : 'sdk-example-noStatus'
                    }">${hasStatuses ? `${availableCount} / ` : ''}${connectorAvailability.count}</label>
                </li>`;
            })
            .join('')}
        </ul>`;
    };

    const showPopup = (evStation: Place<EVChargingStationPlaceProps>) => {
        const { address, poi, chargingPark } = evStation.properties;
        popUp
            .setHTML(
                `
                    <h3>${poi?.name}</h3>
                    <label class="sdk-example-address sdk-example-label">${address.freeformAddress}</label>
                    <br/><br/>
                    ${connectorsHTML(chargingPark as ChargingParkWithAvailability)}
                `,
            )
            .setLngLat(evStation.geometry.coordinates as [number, number])
            .addTo(map.mapLibreMap);
    };

    const updateMapEVStations = async () => {
        const zoom = map.mapLibreMap.getZoom();
        if (zoom < 7) {
            mapEVStations.clear();
        } else {
            const chargingStations = await search({
                query: '',
                poiCategories: ['ELECTRIC_VEHICLE_STATION'],
                minPowerKW: minPowerKWMapEVStations,
                boundingBox: map.getBBox(),
                limit: zoom < 10 ? 50 : 100,
            });
            mapEVStations.show(chargingStations);
        }
    };

    // inverts the polygon, so it looks like a hole on the map instead
    const invert = (geometry: PolygonFeatures): PolygonFeatures => {
        const invertedArea = difference({
            type: 'FeatureCollection',
            features: [bboxPolygon([-180, 90, 180, -90]), geometry?.features?.[0]],
        });
        return invertedArea
            ? ({
                  type: 'FeatureCollection',
                  features: [invertedArea],
              } as PolygonFeatures)
            : geometry;
    };

    const searchEVStations = async () => {
        popUp.remove();
        mapBasePOIs.setVisible(false);

        const areaToSearch =
            areaTextBox.value &&
            (await search({
                query: areaTextBox.value,
                geographyTypes: without(geographyTypes, 'Country'),
                limit: 1,
            }));

        areaToSearch && map.mapLibreMap.fitBounds(areaToSearch.bbox as LngLatBoundsLike, fitBoundsOptions);
        const geometryToSearch = areaToSearch && (await geometryData({ geometries: areaToSearch }));
        if (geometryToSearch) {
            mapGeometryModule.show(invert(geometryToSearch));
        } else {
            mapGeometryModule.clear();
        }

        const places = await search({
            query: evBrandTextBox.value,
            ...(geometryToSearch && { geometries: [geometryToSearch] }),
            ...(!geometryToSearch && { boundingBox: map.getBBox() }),
            poiCategories: ['ELECTRIC_VEHICLE_STATION'],
            minPowerKW: minPowerKWSearchedEVStations,
            limit: 100,
        });
        // We first show the places, then fetch their EV availability and show them again:
        mapSearchedEVStationsModule.show(places);
        mapSearchedEVStationsModule.show(await buildPlacesWithEVAvailability(places));
    };

    const clear = () => {
        evBrandTextBox.value = '';
        areaTextBox.value = '';
        popUp.remove();
        mapSearchedEVStationsModule.clear();
        selectedEVStationModule.clear();
        mapGeometryModule.clear();
        mapBasePOIs.setVisible(true);
    };

    const selectEVStation = (evStation: Place<EVChargingStationPlaceProps>) => {
        selectedEVStationModule.show(evStation);
        showPopup(evStation);
    };

    const listenToMapUserEvents = async () => {
        map.mapLibreMap.on('moveend', updateMapEVStations);
        mapEVStations.events.on('click', async (evStation) =>
            selectEVStation(await buildPlaceWithEVAvailability(evStation)),
        );
        mapSearchedEVStationsModule.events.on('click', async (evWithAvailability) =>
            selectEVStation(evWithAvailability),
        );
        popUp.on('close', () => selectedEVStationModule.clear());
    };

    const listenToHTMLUserEvents = () => {
        const searchButton = document.querySelector('#searchButton') as HTMLButtonElement;
        searchButton.addEventListener('click', searchEVStations);
        (document.querySelector('#clearButton') as HTMLButtonElement).addEventListener('click', clear);
        evBrandTextBox.addEventListener('keypress', (event) => event.key === 'Enter' && searchButton.click());
        areaTextBox.addEventListener('keypress', (event) => event.key === 'Enter' && searchButton.click());

        const minPowerKWMapEVStationsInput = document.querySelector('#minPowerKWMapEVStations') as HTMLInputElement;
        minPowerKWMapEVStationsInput.value = String(minPowerKWMapEVStations);
        const minPowerKWSearchedEVStationsInput = document.querySelector(
            '#minPowerKWSearchedEVStations',
        ) as HTMLInputElement;
        minPowerKWSearchedEVStationsInput.value = String(minPowerKWSearchedEVStations);
        minPowerKWMapEVStationsInput.addEventListener('keyup', async () => {
            minPowerKWMapEVStations = Number(minPowerKWMapEVStationsInput.value);
            await updateMapEVStations();
        });
        minPowerKWSearchedEVStationsInput.addEventListener(
            'keyup',
            () => (minPowerKWSearchedEVStations = Number(minPowerKWSearchedEVStationsInput.value)),
        );
    };

    const getChargingPointAvailability = (
        place: Place<EVChargingStationPlaceProps>,
    ): { availableCount: number; totalCount: number; ratio: number } | undefined => {
        const availability = place.properties.chargingPark?.availability?.chargingPointAvailability;
        if (availability) {
            const available = availability.statusCounts.Available ?? 0;
            return {
                availableCount: available,
                totalCount: availability.count,
                ratio: available / availability.count,
            };
        }
        return undefined;
    };

    map.mapLibreMap.addControl(new NavigationControl(), 'bottom-right');

    await updateMapEVStations();
    await listenToMapUserEvents();
    listenToHTMLUserEvents();
})();

Related examples