Skip to content

Geocoding with Nominatim

Search for places using OpenStreetMap Nominatim, a free geocoding API. Results appear as markers on the map, and clicking a marker shows the place name in a popup.

gts
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import type { Map } from 'maplibre-gl';
import MapLibreGL from 'ember-maplibre-gl/components/maplibre-gl';
import { LngLatBounds } from 'maplibre-gl';

interface GeocoderResult {
  name: string;
  center: [number, number];
  bbox: [number, number, number, number];
}

const mapOptions = {
  style: 'https://tiles.openfreemap.org/styles/liberty',
  center: [10.4, 55.4],
  zoom: 4,
};

export default class GeocoderDemo extends Component {
  @tracked query = '';
  @tracked results: GeocoderResult[] = [];
  @tracked selectedResult: GeocoderResult | null = null;
  map: Map | null = null;
  debounceTimer: ReturnType<typeof setTimeout> | null = null;

  onMapLoaded = (map: Map) => {
    this.map = map;
  };

  onInput = (event: Event) => {
    this.query = (event.target as HTMLInputElement).value;
    clearTimeout(this.debounceTimer);
    if (this.query.trim().length < 2) {
      this.results = [];
      this.selectedResult = null;
      return;
    }
    this.debounceTimer = setTimeout(() => this.search(), 400);
  };

  search = async () => {
    const q = this.query.trim();
    if (!q) return;
    try {
      const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=geojson&limit=5&addressdetails=1`;
      const response = await fetch(url);
      const geojson = await response.json();
      this.results = geojson.features.map((feature) => ({
        name: feature.properties.display_name,
        center: [
          feature.bbox[0] + (feature.bbox[2] - feature.bbox[0]) / 2,
          feature.bbox[1] + (feature.bbox[3] - feature.bbox[1]) / 2,
        ],
        bbox: feature.bbox,
      }));
      this.selectedResult = null;
      if (this.results.length > 0 && this.map) {
        const bounds = this.results.reduce(
          (b, r) => b.extend(r.center),
          new LngLatBounds(this.results[0].center, this.results[0].center),
        );
        this.map.fitBounds(bounds, { padding: 80, maxZoom: 12 });
      }
    } catch (e) {
      console.error('Geocoding failed:', e);
    }
  };

  selectResult = (result: GeocoderResult) => {
    this.selectedResult = result;
    if (this.map && result.bbox) {
      this.map.fitBounds(
        [[result.bbox[0], result.bbox[1]], [result.bbox[2], result.bbox[3]]],
        { padding: 60, maxZoom: 16 },
      );
    }
  };

  isSelected = (result: GeocoderResult) => Object.is(this.selectedResult, result);

  clearSearch = () => {
    this.query = '';
    this.results = [];
    this.selectedResult = null;
  };

  willDestroy() {
    super.willDestroy();
    clearTimeout(this.debounceTimer);
  }

  <template>
    <div style="position: relative;">
      <MapLibreGL
        @initOptions={{mapOptions}}
        @mapLoaded={{this.onMapLoaded}}
        style="height: 500px; width: 100%; border-radius: 8px;"
      as |map|>
        {{#each this.results as |result|}}
          <map.marker @lngLat={{result.center}}>
            <div
              role="button"
              {{on "click" (fn this.selectResult result)}}
              style="width: 20px; height: 20px; background: #E04E39; border: 3px solid white; border-radius: 50%; box-shadow: 0 2px 6px rgba(0,0,0,0.3); cursor: pointer;"
            />
          </map.marker>
        {{/each}}

        {{#if this.selectedResult}}
          <map.popup @lngLat={{this.selectedResult.center}}>
            <div style="padding: 8px 12px; font: 13px/1.4 system-ui; max-width: 260px;">
              {{this.selectedResult.name}}
            </div>
          </map.popup>
        {{/if}}
      </MapLibreGL>

      <div style="position: absolute; top: 12px; left: 12px; z-index: 1; display: flex; flex-direction: column; gap: 4px; width: 320px;">
        <div style="display: flex; gap: 4px;">
          <input
            type="text"
            value={{this.query}}
            placeholder="Search for a place..."
            {{on "input" this.onInput}}
            style="flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 6px; font: 14px system-ui; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.15);"
          />
          {{#if this.query}}
            <button
              type="button"
              {{on "click" this.clearSearch}}
              style="padding: 8px 12px; background: #fff; border: 1px solid #ddd; border-radius: 6px; font: 14px system-ui; cursor: pointer; box-shadow: 0 1px 3px rgba(0,0,0,0.15);"
            >Clear</button>
          {{/if}}
        </div>

        {{#if this.results.length}}
          <ul style="list-style: none; margin: 0; padding: 0; background: #fff; border: 1px solid #ddd; border-radius: 6px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); max-height: 240px; overflow-y: auto;">
            {{#each this.results as |result|}}
              <li>
                <button
                  type="button"
                  {{on "click" (fn this.selectResult result)}}
                  style="display: block; width: 100%; text-align: left; padding: 8px 12px; border: none; background: transparent; font: 13px/1.4 system-ui; cursor: pointer; border-bottom: 1px solid #eee;"
                >{{result.name}}</button>
              </li>
            {{/each}}
          </ul>
        {{/if}}
      </div>
    </div>
  </template>
}

Based on the MapLibre GL JS Geocode with Nominatim example.