Search docs

MapLibre

A MapLibre GL JS 6 map with a geolith search box: autocomplete near the map centre, markers for results, and the place outline from /v1/place.

By · Updated

A search box over a MapLibre map. Typing calls /v1/autocomplete with the map centre as the focus point, results show as markers, and picking one calls /v1/place?geometry=true to draw the place outline when the place has one. Tested with MapLibre GL JS 6.15.0 and Vite 8.

Use a publishable key (gc_pk_…) whose allowed origins include your site and http://localhost:5173 for development.

Countries, regions, counties and venues mapped as areas return a Polygon or MultiPolygon; a city without an OSM city boundary returns a Point. The code below draws the outline when there is one and flies to the point otherwise.

Set up

npm create vite@latest geolith-map -- --template vanilla-ts
cd geolith-map
npm install maplibre-gl@6
echo "VITE_GEOLITH_KEY=gc_pk_your_publishable_key" > .env.local

index.html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>geolith search + MapLibre</title>
    <style>
      html, body, #map { margin: 0; height: 100%; }
      #search { position: absolute; top: 12px; left: 12px; z-index: 1; width: min(360px, calc(100% - 24px)); font: 14px system-ui, sans-serif; }
      #search input { box-sizing: border-box; width: 100%; padding: 10px 12px; border: 1px solid #ccc; border-radius: 6px; }
      #search ul { margin: 4px 0 0; padding: 0; list-style: none; background: #fff; border-radius: 6px; box-shadow: 0 4px 16px rgb(0 0 0 / 0.15); }
      #search li button { display: block; width: 100%; padding: 8px 12px; border: 0; background: none; text-align: left; cursor: pointer; }
      #search li button:hover { background: #f1f5f9; }
    </style>
  </head>
  <body>
    <div id="search">
      <input id="q" type="search" placeholder="Search places" autocomplete="off" />
      <ul id="results"></ul>
    </div>
    <div id="map"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

src/main.ts

import { LngLatBounds, Map, Marker, Popup, setWorkerUrl, type GeoJSONSource } from 'maplibre-gl';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
import 'maplibre-gl/dist/maplibre-gl.css';

// MapLibre 6 runs its worker from a separate module; let Vite emit it.
setWorkerUrl(workerUrl);

const KEY = import.meta.env.VITE_GEOLITH_KEY as string; // gc_pk_…, allowed origin: this site
const API = 'https://api.geolith.app';
const ATTRIBUTION = '© OpenStreetMap contributors (ODbL), Overture Maps Foundation';

interface Place {
  type: 'Feature';
  geometry:
    | { type: 'Point'; coordinates: [number, number] }
    | { type: 'Polygon'; coordinates: [number, number][][] }
    | { type: 'MultiPolygon'; coordinates: [number, number][][][] };
  bbox?: [number, number, number, number];
  properties: { gid: string; label: string; center?: [number, number] };
}

const map = new Map({
  container: 'map',
  style: 'https://demotiles.maplibre.org/style.json',
  center: [78.9629, 20.5937],
  zoom: 4,
  attributionControl: { customAttribution: ATTRIBUTION },
});

map.on('load', () => {
  map.addSource('outline', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
  map.addLayer({
    id: 'outline-fill',
    type: 'fill',
    source: 'outline',
    paint: { 'fill-color': '#14b8a6', 'fill-opacity': 0.2 },
  });
  map.addLayer({
    id: 'outline-line',
    type: 'line',
    source: 'outline',
    paint: { 'line-color': '#0d9488', 'line-width': 2 },
  });
});

async function geolith(path: string, params: Record<string, string>): Promise<Place[]> {
  const url = new URL(path, API);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
  url.searchParams.set('api_key', KEY);
  const res = await fetch(url);
  if (!res.ok) return [];
  return ((await res.json()) as { features: Place[] }).features;
}

let markers: Marker[] = [];

function showMarkers(places: Place[]) {
  for (const marker of markers) marker.remove();
  markers = places.flatMap((place) => {
    if (place.geometry.type !== 'Point') return [];
    return [
      new Marker()
        .setLngLat(place.geometry.coordinates)
        .setPopup(new Popup().setText(place.properties.label))
        .addTo(map),
    ];
  });
}

async function select(place: Place) {
  showMarkers([place]);
  // geometry=true returns the outline when the place has one; otherwise a point.
  const [full] = await geolith('/v1/place', { ids: place.properties.gid, geometry: 'true' });
  const source = map.getSource<GeoJSONSource>('outline');
  if (full && full.geometry.type !== 'Point') {
    source?.setData({ type: 'FeatureCollection', features: [full] });
    if (full.bbox) map.fitBounds(LngLatBounds.convert(full.bbox), { padding: 40 });
    return;
  }
  source?.setData({ type: 'FeatureCollection', features: [] });
  if (place.geometry.type === 'Point') map.flyTo({ center: place.geometry.coordinates, zoom: 14 });
}

const input = document.querySelector<HTMLInputElement>('#q');
const list = document.querySelector<HTMLUListElement>('#results');
let timer: ReturnType<typeof setTimeout> | undefined;

input?.addEventListener('input', () => {
  clearTimeout(timer);
  const text = input.value.trim();
  if (text.length < 3 || !list) return;
  timer = setTimeout(async () => {
    const center = map.getCenter();
    const places = await geolith('/v1/autocomplete', {
      text,
      size: '5',
      'focus.point.lat': center.lat.toFixed(5),
      'focus.point.lon': center.lng.toFixed(5),
    });
    showMarkers(places);
    list.replaceChildren(
      ...places.map((place) => {
        const item = document.createElement('li');
        const button = document.createElement('button');
        button.type = 'button';
        button.textContent = place.properties.label;
        button.addEventListener('click', () => {
          input.value = place.properties.label;
          list.replaceChildren();
          void select(place);
        });
        item.append(button);
        return item;
      }),
    );
  }, 200);
});

Run npm run dev and open http://localhost:5173.

Notes

  • MapLibre 6 loads its worker as a separate module. The ?url import and setWorkerUrl let Vite emit and serve it.
  • The base map here is MapLibre's demo style. Swap in your own style URL for street-level detail.
  • customAttribution puts the required line, © OpenStreetMap contributors (ODbL), Overture Maps Foundation, in the map's attribution control.
  • Vector tiles from GET https://api.geolith.app/v1/tiles/{set}.json can be added as a source: the TileJSON's tile URLs already carry your key.