Building a Keyless EV Charging Map with MapLibre GL and OpenFreeMap

Render thousands of Indian EV chargers on a responsive map using open-source MapLibre GL JS and OpenFreeMap vector tiles with zero proprietary API keys.

7 min read


## Overview Building a nationwide EV charging station locator using proprietary mapping APIs like Google Maps Platform or Mapbox can quickly run up thousands of dollars in tile requests and geocoding bills.

By pairing MapLibre GL JS (an open-source WebGL vector map library) with OpenFreeMap (free vector map tile hosting) and this keyless EV Charging Station API, you can deploy a zero-cost, high-performance charging map.

1. MapLibre GL + OpenFreeMap setup OpenFreeMap provides production-grade vector tiles hosted on Cloudflare with zero rate limits or registration keys.

html
<!-- Include MapLibre GL CSS & JS -->
<link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>

Initialize the map container with the OpenFreeMap vector style:

javascript
import maplibregl from 'maplibre-gl';

const map = new maplibregl.Map({ container: 'map-container', style: 'https://tiles.openfreemap.org/styles/liberty', center: [77.5946, 12.9716], // Bengaluru center [lng, lat] zoom: 11, }); ```

2. Converting API radius responses to GeoJSON When the user pans or searches a location, query `/v1/stations/near` and convert the `data` array into a standard GeoJSON FeatureCollection:

javascript
async function loadStations(lat, lng, radiusKm = 15) {
  const url = new URL('https://ev-charging-stations-api.vercel.app/v1/stations/near');
  url.searchParams.set('lat', lat);
  url.searchParams.set('lng', lng);
  url.searchParams.set('radius', radiusKm);

const res = await fetch(url); const json = await res.json(); if (!json.success) return null;

return { type: 'FeatureCollection', features: json.data.map((station) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [station.coordinates.lng, station.coordinates.lat], }, properties: { id: station.id, operator: station.operator, powerKw: station.max_power_kw, hasDc: station.chargers.some((c) => c.current === 'dc'), address: station.address, }, })), }; } ```

3. Clustered circle rendering When displaying hundreds of chargers in dense urban centers like Delhi NCR or Mumbai, render points using MapLibre's native clustering engine so the browser maintains 60 FPS:

javascript
map.on('load', async () => {

map.addSource('stations', { type: 'geojson', data: geojson, cluster: true, clusterMaxZoom: 14, clusterRadius: 50, });

// Cluster circles map.addLayer({ id: 'clusters', type: 'circle', source: 'stations', filter: ['has', 'point_count'], paint: { 'circle-color': 'teal', 'circle-radius': ['step', ['get', 'point_count'], 18, 10, 24, 50, 32], }, });

// Cluster numbers map.addLayer({ id: 'cluster-count', type: 'symbol', source: 'stations', filter: ['has', 'point_count'], layout: { 'text-field': '{point_count_abbreviated}', 'text-size': 12, }, paint: { 'text-color': 'white' }, });

// Individual unclustered station points map.addLayer({ id: 'unclustered-point', type: 'circle', source: 'stations', filter: ['!', ['has', 'point_count']], paint: { 'circle-color': ['case', ['get', 'hasDc'], 'teal', 'blue'], 'circle-radius': 7, 'circle-stroke-width': 2, 'circle-stroke-color': 'white', }, }); }); ```

4. Mobile responsiveness & touch UX On mobile devices, ensure gesture interactions (pinch-to-zoom and two-finger drag) do not hijack page scrolling. Add navigation controls with large touch targets (`min-width: 44px`) and bind station click popups with full operator and power ratings.