GeoDB Cities

פרמיום
Verified
על ידי Michael Mogley | מְעוּדכָּן 4 days ago | Data
פּוֹפּוּלָרִיוּת

9.9 / 10

חֶבִיוֹן

86ms

רמת שירות

100%

Health Check

100%

חזרה לכל ההדרכות (1)

Implementing City Auto-Complete

Why should I care about this?

Having the ability to auto-suggest results as the user types 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 city 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, lay it on me

Using the GeoDB Angular SDK, we leverage 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:

  • Results 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 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,
        sortDirectives: [
          "-population"
        ],				
        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.

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 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,
          sortDirectives: [
            "-population"
          ],			
          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 on its heels? We want to 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,
              sortDirectives: [
                "-population"
              ],										
              limit: 5,
              offset: 0
        })
        .pipe(
          map(
            (response: GeoResponse) => response.data,
            (error: any) => console.log(error)
          )
        );
      }

      return cities;
    })
  );

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, 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?

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!