DocsMapLibre

MapLibre GL JS + PickPoint

MapLibre GL JS is a community-maintained, MIT-licensed fork of Mapbox GL JS - created in 2020 when Mapbox changed its licence to require proprietary terms. The fork kept the full rendering engine: WebGL-powered vector tiles that stay pixel-sharp at every zoom, smooth 60fps pan/tilt, data-driven styling and 3D terrain. It is now used in production by major navigation, logistics and GIS platforms worldwide.

The key difference from Leaflet is the rendering model. Leaflet draws raster PNG tiles - each tile is a bitmap fetched from a server. MapLibre draws vector data with WebGL: the browser receives raw geometric data and applies a style definition locally. This means crisp rendering on retina and 4K displays, instant style changes without re-fetching tiles, and the ability to extrude buildings in 3D or colour road segments by speed.

We use OpenFreeMap vector tiles - fully free, no API key, with a clean professional style. For production, swap in MapTiler, Stadia Maps, or Protomaps self-hosted tiles.

    Key difference from Leaflet: MapLibre uses [longitude, latitude] order (GeoJSON convention). The route is added as a GeoJSON LineString source - updating it only requires a setData() call with no layer rebuild.
    1

    HTML scaffold

    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <link href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" rel="stylesheet">
      <style>
        html, body, #map { height: 100%; margin: 0; }
        #search-box { position: absolute; top: 12px; right: 12px; z-index: 1;
                      background: #fff; padding: 8px; border-radius: 8px;
                      box-shadow: 0 2px 8px rgba(0,0,0,.15); min-width: 280px; }
        #query { width: 100%; box-sizing: border-box; padding: 6px 10px;
                 border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
        #suggestions { list-style: none; margin: 4px 0 0; padding: 0; }
        #suggestions li { padding: 6px 10px; cursor: pointer; border-radius: 4px; font-size: 13px; }
        #suggestions li:hover { background: #f0f7f7; }
      </style>
    </head>
    <body>
      <div id="map"></div>
      <div id="search-box">
        <input id="query" type="search" placeholder="Search destination…" autocomplete="off">
        <ul id="suggestions"></ul>
      </div>
      <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
      <script src="app.js"></script>
    </body>
    </html>
    2

    Initialise map and reserve the route layer

    Pre-register an empty GeoJSON source and line layer - updating data later is instant with no layer recreation:

    // app.js
    const API_KEY = 'YOUR_PICKPOINT_KEY';
    
    const map = new maplibregl.Map({
      container: 'map',
      // OpenFreeMap - free vector tiles, no API key required
      style: 'https://tiles.openfreemap.org/styles/liberty',
      center: [-0.09, 51.505],   // [lon, lat]
      zoom: 12,
    });
    
    let originMarker = null;
    let destMarker   = null;
    
    map.on('load', () => {
      map.addSource('route', {
        type: 'geojson',
        data: { type: 'FeatureCollection', features: [] },
      });
      map.addLayer({
        id: 'route-line', type: 'line', source: 'route',
        layout: { 'line-join': 'round', 'line-cap': 'round' },
        paint:  { 'line-color': '#008080', 'line-width': 4 },
      });
    });
    3

    Get the user's position

    // MapLibre uses [lon, lat] everywhere
    navigator.geolocation.getCurrentPosition(({ coords }) => {
      const center = [coords.longitude, coords.latitude];
      map.flyTo({ center, zoom: 14 });
    
      originMarker = new maplibregl.Marker({ color: '#008080' })
        .setLngLat(center).addTo(map);
    });
    4

    Address autocomplete

    const input  = document.getElementById('query');
    const list   = document.getElementById('suggestions');
    let debounce;
    
    input.addEventListener('input', () => {
      clearTimeout(debounce);
      const q = input.value.trim();
      if (q.length < 2) { list.innerHTML = ''; return; }
    
      debounce = setTimeout(async () => {
        const b    = map.getBounds();
        // /v2/address/search uses bbox=minLon,minLat,maxLon,maxLat
        const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`;
        const res  = await fetch(
          `https://api.pickpoint.io/v2/address/search?q=${encodeURIComponent(q)}&limit=6&bbox=${bbox}`,
          { headers: { 'X-Api-Key': API_KEY } }
        );
        const { features = [] } = await res.json();
    
        // Response is GeoJSON: coordinates = [lon, lat], properties.name = label
        list.innerHTML = features.map(f => {
          const [lon, lat] = f.geometry.coordinates;
          const label = [f.properties.name, f.properties.city].filter(Boolean).join(', ');
          return `<li data-lat="${lat}" data-lon="${lon}">${label}</li>`;
        }).join('');
      }, 250);
    });
    
    list.addEventListener('click', (e) => {
      const li = e.target.closest('li');
      if (!li) return;
      const lat = parseFloat(li.dataset.lat);
      const lon = parseFloat(li.dataset.lon);
      input.value = li.textContent;
      list.innerHTML = '';
    
      destMarker?.remove();
      destMarker = new maplibregl.Marker({ color: '#e06060' })
        .setLngLat([lon, lat]).addTo(map);
    
      if (originMarker) drawRoute(originMarker.getLngLat(), { lat, lon });
    });
    5

    Fetch route and update the map

    shape is [lat, lon] pairs - flip to [lon, lat] for GeoJSON before calling setData():

    async function drawRoute(origin, dest) {
      const res  = await fetch('https://api.pickpoint.io/v2/route', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json', 'X-Api-Key': API_KEY },
        body: JSON.stringify({
          locations: [
            { lat: origin.lat, lon: origin.lng },
            { lat: dest.lat,   lon: dest.lon   },
          ],
          costing: 'auto',
        }),
      });
      const data  = await res.json();
      const shape = data.trip.legs[0].shape;         // [[lat, lon], …]
    
      // Flip to [lon, lat] for GeoJSON
      const coords = shape.map(([lat, lon]) => [lon, lat]);
    
      map.getSource('route').setData({
        type: 'Feature',
        geometry: { type: 'LineString', coordinates: coords },
      });
    
      const lons = coords.map(c => c[0]), lats = coords.map(c => c[1]);
      map.fitBounds(
        [[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
        { padding: 60 }
      );
    }
    
    // Real-time position updates via PickPoint Device Tracking WebSocket
    // npm install socket.io-client
    import { io } from 'socket.io-client';
    
    const vehicleMarker = new maplibregl.Marker({ color: '#008080' })
      .setLngLat([0, 0]).addTo(map);
    
    const socket = io('wss://ws.pickpoint.io', {
      transports: ['websocket'],
      path: '/v2/tracking',
      query: { 'x-api-key': API_KEY },   // subscriber auth - keep on server!
    });
    
    socket.on('connect', () => {
      socket.emit('device:subscribe', { deviceUid: 'DEVICE_UID' });
    });
    
    socket.on('location:added', ({ latitude, longitude }) => {
      vehicleMarker.setLngLat([longitude, latitude]);  // MapLibre: [lon, lat]
      map.panTo([longitude, latitude]);
    });

    MapLibre-specific extras

    • Animated route draw - animate line-dasharray to make the route appear stroke by stroke.
    • Data-driven styling - colour segments by speed or road class from the maneuvers array.
    • Custom tile style - replace the demo style URL with any MapLibre-compatible style JSON.

    ← Web MapsLeaflet →