Menu

GeoDB City API - Find Cities For Your Criteria | Wirefree Thought

header photo

Why should I care about this?

Having the ability to auto-suggest results while typing is almost considered expected behavior these days. Below, we walk through the basic steps of how to go about implementing city auto-complete.

In plain English, what are we actually trying to do?

Basically, as the user begins entering the first letters of a city name, we want to get back a list of possible matches. The user should then be able to select from one of these possible options, saving additional typing calories!

Got it, 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.findCities method to return an Observable over the cities, with the namePrefix filter applied. Our initial code will look something like this:

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

This code takes a cityNamePrefix string representing the beginning of the city name to match on and creates an Observable on all cities matching the following:

  • City name must start with the passed-in cityNamePrefix. (Case doesn't matter.)
  • City 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:

  • Cities 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.

Ok, but how do we actually wire in the user's typing?

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.cityControl = new FormControl();

this.cityAutoSuggestions: Observable<CitySummary>[]>  = this.cityControl.valueChanges
  .pipe(
    map( (cityNamePrefix: string) => {
      let cities: Observable<CitySummary[]> = this.geoDbService.findCities({
        namePrefix: cityNamePrefix,
        minPopulation: 100000,
        limit: 5,
        offset: 0
      })
      .pipe(
        map(
          (response: GeoResponse) =>  response.data,
          (error: any) => console.log(error)
        )
      );
  
      return cities;
    })
  );

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

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

You mean a simple thing like this actually has 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 findCities 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.cityControl = new FormControl();

this.cityAutoSuggestions: Observable<CitySummary>[]>  = this.cityControl.valueChanges
  .pipe(
    map( (cityNamePrefix: string) => {
      let cities: Observable<CitySummary[]> = of([]);

      if (cityNamePrefix && cityNamePrefix.length >= 3) {
        cities = this.geoDbService.findCities({
          namePrefix: cityNamePrefix,
          minPopulation: 100000,
          limit: 5,
          offset: 0
        })
        .pipe(
          map(
            (response: GeoResponse) =>  response.data,
            (error: any) => console.log(error)
          )
        );
      }
    
      return cities;
    })
  );

With this change, we initially create our cities Observable over an empty array. If the current cityNamePrefix is undefined or less than three characters long, we short-circuit out. Otherwise, we create the findCities 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 right 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.cityControl = new FormControl();

this.cityAutoSuggestions: Observable<CitySummary>[]>  = this.cityControl.valueChanges
    .pipe(
      switchMap( (cityNamePrefix: string) => {
        let cities: Observable<CitySummary[]> = of([]);

        if (cityNamePrefix && cityNamePrefix.length >= 3) {
            cities = this.geoDbService.findCities({
              namePrefix: cityNamePrefix,
              minPopulation: 100000,
              limit: 5,
              offset: 0
        })
        .pipe(
          map(
            (response: GeoResponse) =>  response.data,
            (error: any) => console.log(error)
          )
        );
      }

      return cities;
    })
  );

Ok Mr Backend Hotshot, but 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 
    auto
    autocomplete widget.
  • For the 
    auto
    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, 
    cityControl
    .
  • Set our cities Observable as the source for generating autoComplete options.
<mat-form-field>
  <input type="text" matInput [formControl]="cityControl" [matAutocomplete]="auto">
</mat-form-field>

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

Some differences to note

  • Because 
    cityAutoSuggestions
    is an Observable, we use the Angular async pipe to actually trigger it to start emitting cities.
  • Since the value emitted is actually a CitySummary object, we use a custom 
    getCityDisplayName
    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!