Menu

GeoDB Cities API

Why should I care about this?

Having the ability to auto-suggest results while typing is pretty much expected behavior these days. Below, we walk through the basic steps of how to implement a city auto-complete feature.

In plain English, what are we trying to do?

Basically, as the user begins typing a place name, we want to get back a list of possible matches. The user should then be able to select from one of the listed options.

What do I need to know before we start?

This tutorial assumes a basic knowledge of:

Having said that, the core logic and concepts will be based on ReactiveX, so it should be fairly straightforward to apply them to any environment with a ReactiveX implementation.

I got my coffee now, lay it on me

Using the GeoDB Angular SDK, we will be leveraging the GeoDbService.findPlaces method to return an Observable over the cities, with the namePrefix filter applied. Our initial code will look something like this:

this.placeAutoSuggestions: Observable<CitySummary>[]>  = this.geoDbService.findPlaces({
    namePrefix: placeNamePrefix,
    minPopulation: 100000,
    sortDirectives: [
      "-population"
    ],
    limit: 5,
    offset: 0
  })
  .pipe(
    map(
      (response: GeoResponse) =>  response.data,
      (error: any) => console.log(error)
    )
  );

This code takes a placeNamePrefix string representing the beginning of the place name to match on and creates an Observable on all places matching the following:

  • Place name must start with the passed-in placeNamePrefix. (Case doesn't matter.)
  • Place population must be at least 100,000. Depending on your use-case, you may want to tweak this number in order to give more contextually appropriate results.

In addition:

  • Places will be sorted, highest population first. So typing 'Los' should show Los Angeles ahead of Los Cerros de Paja.
  • Since we don't want to overwhelm the user, we limit our results to only the top 5 matches. Again, you may want to adjust this number for your specific case.

But how do we actually wire in the user input?

One way to capture the user input is to define an Observable on all such events, then use that Observable to bootstrap our autocomplete pipeline, the output of which is also an Observable.

If you're using Angular, this Observable is already exposed to you as the FormControl.valueChanges property. When connected to a text input field, the valueChanges Observable will emit an event reflecting the current value of the input every time the user presses a key. Let's change our code to take advantage of this:

this.placeControl = new FormControl();

this.placeAutoSuggestions: Observable<PlaceSummary>[]>  = this.placeControl.valueChanges
  .pipe(
    map( (placeNamePrefix: string) => {
      let places: Observable<PlaceSummary[]> = this.geoDbService.findPlaces({
        namePrefix: placeNamePrefix,
        minPopulation: 100000,
        sortDirectives: [
          "-population"
        ],   
        limit: 5,
        offset: 0
      })
      .pipe(
        map(
          (response: GeoResponse) =>  response.data,
          (error: any) => console.log(error)
        )
      );
  
      return places;
    })
  );

Great! Now every time the user presses a key, the placeControl.valueChanges Observable will emit the updated field value as an event. This field value will then become the current placeNamePrefix, which then gets mapped to the Observable returned from the findPlaces method as before.

If you think that seemed too easy, you are unfortunately right.

What are the edge cases?

The "Blank Input" Case

What happens if the user begins typing, then suddenly decides they made a mistake and deletes to start over? Obviously, we want to avoid even creating our findPlaces Observable for a blank string.

In fact, what we probably want is to avoid creating this Observable for any input whose length is less than our specified minimum. Would it really make sense to try to generate an autocomplete list for a single letter? Let's change the code as follows:

this.placeControl = new FormControl();

this.placeAutoSuggestions: Observable<PlaceSummary>[]>  = this.placeControl.valueChanges
  .pipe(
    map( (placeNamePrefix: string) => {
      let places: Observable<PlaceSummary[]> = of([]);

      if (placeNamePrefix && placeNamePrefix.length >= 3) {
        places = this.geoDbService.findPlaces({
          namePrefix: cityNamePrefix,
          minPopulation: 100000,
          sortDirectives: [
            "-population"
          ],
          limit: 5,
          offset: 0
        })
        .pipe(
          map(
            (response: GeoResponse) =>  response.data,
            (error: any) => console.log(error)
          )
        );
      }
    
      return places;
    })
  );

With this change, we initially create our places Observable over an empty array. If the current placeNamePrefix is undefined or less than three characters long, we short-circuit out. Otherwise, we create the findPlaces Observable as before.

The "Fast Fingers" Case

What happens if the user types so fast that our Observable pipeline can't finish processing one event before the next comes on its heels? Given that our pipeline involves a network call, you don't have to be The Flash to trigger this situation.

What we want to do is somehow exclude all but the most recent valueChanges events. This is easily done by taking advantage of the Observable switchMap operator:

this.placeControl = new FormControl();

this.placeAutoSuggestions: Observable<PlaceSummary>[]>  = this.placeControl.valueChanges
    .pipe(
      switchMap( (placeNamePrefix: string) => {
        let cities: Observable<PlaceSummary[]> = of([]);

        if (placeNamePrefix && placeNamePrefix.length >= 3) {
            places = this.geoDbService.findPlaces({
              namePrefix: placeNamePrefix,
              minPopulation: 100000,
              sortDirectives: [
                "-population"
              ],
              limit: 5,
              offset: 0
        })
        .pipe(
          map(
            (response: GeoResponse) =>  response.data,
            (error: any) => console.log(error)
          )
        );
      }

      return places;
    })
  );

What about the UI?

Here, there are many ways to skin a cat. All you really need is an autocomplete widget that ties a user input field with a list of suggestions.

In this tutorial, we assume you're using Angular and will demonstrate this with the excellent Angular Material Autocomplete component. After going through the below steps, you should be able to easily adapt the techniques presented to your specific framework and widget.

From the Angular Material Autocomplete overview page, we have the following snippet:

<mat-form-field>
   <input type="text" matInput [formControl]="myControl" [matAutocomplete]="auto">
</mat-form-field>

<mat-autocomplete #auto="matAutocomplete">
   <mat-option *ngFor="let option of options" [value]="option">
      {{ option }}
   </mat-option>
</mat-autocomplete>

This says the following:

  • Tie the input field to the 
    myControl
    component field and the autocomplete widget.
  • For the autocomplete widget, generate the dropdown list of possible options based on the value of the 
    options
    component field.

To adapt this snippet to our specific case, we really only have to do two things:

  • Specify our own input FormControl, place
    Control
    .
  • Set our cities Observable as the source for generating autoComplete options.
<mat-form-field>
  <input type="text" matInput [formControl]="placeControl" [matAutocomplete]="auto">
</mat-form-field>

<mat-autocomplete #auto="matAutocomplete" [displayWith]="getPlaceDisplayName">
  <mat-option *ngFor="let place of placeAutoSuggestions | async" [value]="place">
    {{getPlaceDisplayName(place)}}
  </mat-option>
</mat-autocomplete>

Some differences to note:

  • Because place
    AutoSuggestions
    is an Observable, we use the Angular async pipe to actually trigger it to start emitting cities.
  • Since the value emitted is actually a PlaceSummary object, we use a custom 
    getPlaceDisplayName
    function to format it for display. Something like this:
    getCityDisplayName(city: CitySummary) {
        if (!city) {
            return null;
        }
    
        let name = city.city;
    
        if (city.region) {
            name += ", " + city.region;
        }
    
        name += ", " + city.country;
    
        return name;
    }
    

Can I get a complete working example?

No problem! You can see the running example here and the corresponding source code here.

Now go get yourself a proper latte with a chocolate croissant. You've earned it!