Teil 5 - Abfrage und Anzeige der Suchergebnisse

Dieses Lab ist Teil der Amenity Finder Serie, einer umfassenden Anleitung, die Schritt für Schritt zeigt, wie man eine Web-Applikation mithilfe von Web-Components entwickelt. In jedem Teil der Serie werden spezifische Aspekte der Entwicklung behandelt und es werden bewährte Methoden und Techniken vermittelt, um eine moderne und skalierbare Webanwendung aufzubauen.

VorschauResultat von Lektion 5

Im fünften Teil der Serie setzen wir die Abfrage-Logik um und kümmern uns um die Anzeige der Suchergebnisse. Wir verbinden die eingegebenen Suchkriterien mit der Datenquelle und stellen die gefundenen Annehmlichkeiten in einer übersichtlichen Art und Weise dar.

Wenn du dieses Lab bereits abgeschlossen hast, wirst du im sechsten Teil der Amenity Finder Serie lernen, wie die Amenity Finder Applikation in einzelne Seiten unterteilt wird und das Routing implementiert wird.

Vorbereitung

Ziele

In diesem Lab verfolgen wir mehrere Ziele, um die Funktionalität und Benutzererfahrung unserer Amenity Finder Applikation weiter zu verbessern:

  • Anzeige von Suchresultaten zur abgesetzten Suche: Wir möchten die Fähigkeit implementieren, die Ergebnisse einer Suchanfrage anzuzeigen. Benutzer sollen in der Lage sein, ihre Suchparameter einzugeben und die entsprechenden Resultate präsentiert zu bekommen. Dies ermöglicht es ihnen, die gewünschten Informationen schnell und effektiv zu finden.
  • Abfrage der Suchresultate über eine REST API: Um die Suchresultate zu erhalten, werden wir eine REST API verwenden. Wir werden die API-Anfragen entsprechend konfigurieren, um die gewünschten Daten zurückzuerhalten. Dies ermöglicht es uns, auf externe Datenquellen zuzugreifen und aktuelle und genaue Informationen für die Benutzer bereitzustellen.
  • Neue Komponenten für Aufteilung der Suchresultate schreiben: Um die Suchresultate ansprechend darzustellen und die Benutzerfreundlichkeit zu verbessern, werden wir neue Web Components schreiben. Diese Komponenten werden die Aufgabe haben, die erhaltenen Resultate in einer strukturierten und benutzerfreundlichen Weise darzustellen. Durch die Verwendung von Web Components können wir wiederverwendbaren Code erstellen und die Modularität unserer Applikation fördern.
  • Suchresultate sind unter einer eindeutigen URL erreichbar: Wir möchten sicherstellen, dass die Suchresultate über eine eindeutige URL erreichbar sind. Dies ermöglicht es Benutzern, die Ergebnisse ihrer Suche zu teilen oder später erneut darauf zuzugreifen. Durch die Implementierung eines soliden Routing-Systems können wir die einzelnen Suchresultate unter einer spezifischen URL verfügbar machen.

Branch

Der fünfte Teil des Amenity Finder Labs ist auf GitHub verfügbar.

Weiterführende Informationen zu diesem Thema sind unter folgenden Links nachzulesen:


Aufgabenstellung

  • Erweiterung des <results-view> Web Components um Properties: Das <results-view> Web Component soll um zusätzliche Properties erweitert werden, um Informationen über Breitengrad (Latitude), Längengrad (Longitude) und Radius zu speichern. Diese Properties werden verwendet, um die Suchparameter für die Abfrage der Amenities festzulegen.

  • Laden der Amenities über ein REST API: Implementiere die Methode ResultsView._fetchResults(), um die Amenities über ein REST API zu laden.

  • Setze diese Methode im connectedCallback() des Web Components auf, um die Amenities bei der Initialisierung des Components zu laden. Speichere die Ergebnisse in einem Property des Components.

  • Erstellung des <amenity-browser> Web Components: Erstelle ein <amenity-browser> Web Component, das über Properties für Breitengrad, Längengrad, Radius und die gefundenen Amenities verfügt. Dieses Component wird die Amenities anzeigen, z. B. in Form einer Karte.

  • Erstellung des <amenity-item> Web Components: Erstelle ein <amenity-item> Web Component, das über Properties für den Namen des Amenities und möglicherweise die Distanz zu den Suchkoordinaten verfügt. Dieses Component wird einzelne Amenities in einer Liste oder auf der Karte darstellen.

Optional

  • Berechnung der Distanz zu den Suchkoordinaten für jedes Amenity: Du kannst die Distanz zwischen den Koordinaten jedes Amenities und den Suchkoordinaten berechnen. Hierfür gibt es verschiedene Algorithmen und Formeln zur Berechnung der Distanz auf der Erdoberfläche. Zum Beispiel kann die Haversine-Formel verwendet werden, um die Entfernung zwischen zwei Punkten auf der Erde zu berechnen. Du kannst diese Berechnung in der ResultsView._fetchResults() Methode oder imWeb Component implementieren und die Distanz als zusätzliches Property speichern.
  • Sortierung der resultierenden Amenities nach Distanz: Nachdem du die Distanzen berechnet hast, kannst du die resultierenden Amenities basierend auf ihrer Distanz zu den Suchkoordinaten sortieren. Dies kann entweder in der ResultsView._fetchResults() Methode oder imWeb Component durchgeführt werden. Verwende eine geeignete Sortierfunktion, um die Amenities basierend auf ihrer Distanz aufsteigend oder absteigend zu sortieren. Dadurch werden die Amenities in der gewünschten Reihenfolge präsentiert.

Du hast jetzt die Möglichkeit, die Aufgabenstellung eigenständig zu lösen. Wenn du deine Lösung abgeschlossen hast oder wenn du sofort weiterlesen möchtest, stellen wir im nächsten Abschnitt unseren Lösungsvorschlag für den dritten Teil der Amenity Finder Serie vor.


Unser Lösungsvorschlag

Schritte

Die Abfrage und Anzeige der Suchresultate werden in fünf Schritten umgesetzt:

  1. ResultsView parametrisieren
  2. Abfrage der Resultate über API
  3. Komponenten für AmenityBrowser und AmenityItem
  4. Verbesserungen des Layouts (optional)
  5. UX Improvements (optional)

ResultsView parametrisieren

Die ResultsView soll eine Liste von Restaurants (Amenities) zu den gewählten Suchparametern darstellen. Die Parameter werden durch den Orchestrator AmenityFinder verwaltet. Der Orchestrator instanziiert jeweils die ResultView mit den richtigen latitude, longitude und radius Parametern. Dafür müssen wir diese aber zuerst in der ResultView als solche definieren:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js
 import { html, LitElement } from 'lit';

 export class ResultsView extends LitElement {
+  static get properties() {
+    return {
+      latitude: { type: String },
+      longitude: { type: String },
+      radius: { type: Number },
+    };
+  }
+
   render() {
     return html`Results…`;
   }

und im AmenityFinder noch entsprechend binden:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
         return html`<search-view .latitude="${this.latitude}" .longitude="${this.longitude}" .radius="${this.radius}" @execute-search="${(e) => this._onExecuteSearch(e)}"></search-view>`;
       case 'results':
-        return html`<results-view></results-view>`;
+        return html`<results-view .latitude="${this.latitude}" .longitude="${this.longitude}" .radius="${this.radius}"></results-view>`;
       default:

Das Gute an diesem Ansatz ist, dass die ResultView Komponente eine hohe Wiederverwendbarkeit hat. Sie hat keine Abhängigkeit zum Routing oder zum Orchestrator und wird lediglich durch die drei Parameter latitude, longitude und radius konfiguriert. Damit wir überprüfen können, dass die Parametrisierung der ResultsView funktioniert, bauen wir noch einen temporären Output der Parameter in der render() Methode ein:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js
   }

   render() {
-    return html`Results…`;
+    return html`
+      <h1>Results</h1>
+      <p>
+        Displaying <strong>results</strong> for
+        <code>latitude</code> = <code>${this.latitude}</code>,
+        <code>longitude</code> = <code>${this.longitude}</code> and
+        <code>radius</code> = <code>${this.radius}</code>
+      </p>
+    `;
   }
 }

Wir können nun überprüfen, ob die Weitergabe der Parameter aus dem Suchformular und über die URL funktioniert:

Parametrisierung und Routing

Abfrage der Resultate über API

Für die Abfrage der Resultate verwenden wir die OSM Overpass API

The Overpass API (formerly known as OSM Server Side Scripting, or OSM3S before 2011) is a read-only API that serves up custom selected parts of the OSM map data. It acts as a database over the web: the client sends a query to the API and gets back the data set that corresponds to the query.

Wir verwenden die Abfragesprache Overpass QL. Eine Abfrage besteht aus Statements. Jedes Statement endet mit einem ;.

API-Klasse erstellen und einbinden

Wir erstellen dazu eine Klasse mit einer Methode. Die Methode soll anhand von latitude, longitude und radius eine Liste von Objekten vom Typ «Restaurant» zurückliefern. Jedes Objekt enthält neben den Lokalisierungsdaten zusätzliche Informationen wie Name, Adresse, URL usw. die wir für die Anzeige verwenden können.

Wir definieren die API-Klasse OverpassApi im Verzeichnis services (später werden wir auf diese Nomenklatur zurückkommen) wie folgt:

src/services/OverpassApi.js
const DEFAULT_BASE_URL = 'https://overpass.osm.ch/api';

/**
 * Overpass API class
 *
 * @see https://overpass.osm.ch/
 * @see https://wiki.openstreetmap.org/wiki/Overpass_API
 */
export class OverpassApi {
  constructor(baseUrl = DEFAULT_BASE_URL) {
    this.baseUrl = baseUrl;
  }

  async getNodeByLatLng(lat, lng, radius, nodeType = 'restaurant') {
    const query = `
      [out:json];
      (
      node["amenity"="${nodeType}"](around:${radius},${lat},${lng});
      );
      out;
    `;

    const targetUrl = new URL(`${this.baseUrl}/interpreter`);
    targetUrl.searchParams.append('data', query);

    const response = await fetch(targetUrl.href);
    const jsonResponse = await response.json();

    const { elements } = jsonResponse;
    if (!elements) {
      return [];
    }

    return elements;
  }
}

Unsere verwendete Overpass QL Query besteht aus 3 Statements:

  1. als Format soll JSON retourniert werden
  2. wir suchen nach Amenities vom Typ restaurant im Umkreis einer Koordinate
  3. das Resultat soll ausgegeben werden

Nun müssen wir die API-Klasse noch in der ResultsView einbinden:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js
 import { html, LitElement } from 'lit';

+import { OverpassApi } from '../services/OverpassApi.js';
+
 export class ResultsView extends LitElement {
   static get properties() {
     return {
--- src/views/ResultsView.js
+++ src/views/ResultsView.js
     };
   }

+  constructor() {
+    super();
+
+    this.api = new OverpassApi();
+  }
+
   render() {
     return html`
       <h1>Results</h1>

Abfrage der Resultate einbauen

Die API-Klasse liefert eine Liste von Objekten (Resultaten) zurück. Wir definieren für diese Liste in der ResultView ein entsprechendes Property, welches wir beim Resultat des API-Calls updaten können:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js
       latitude: { type: String },
       longitude: { type: String },
       radius: { type: Number },
+      results: { type: Array, attribute: false },
     };
   }
--- src/views/ResultsView.js
+++ src/views/ResultsView.js
     super();

     this.api = new OverpassApi();
+    this.results = [];
   }

Lit kennt einen Decorator @state. Damit können «interne» Properties automatisch mit bestimmten Eigenschaften versehen werden. Da wir ohne Transpilierung unterwegs sind, können wir keine Decorators verwenden. Wir setzen aber beim results Property attribute: false. Damit können wir auch ohne Decorators schonmal definieren, dass dieses Property kein zugehöriges “Observed Attribute” hat.

Nun müssen wir noch eine geeignete Stelle finden im Lebenszyklus der ResultView für den Zeitpunkt der API-Abfrage der Suchresultate. Die Lit Dokumentation führt eine Liste von Beispielen für Lifecycle-Hooks von Komponenten. Weil unser API-Call nicht vom DOM-Inhalt der Komponente abhängig ist, können wir dafür den connectedCallback Callback verwenden. Wir bauen die Abfrage wie folgt ein:

async connectedCallback() {
  super.connectedCallback();
  await this._fetchResults();
}

Die _fetchResults() Methode definieren wir wie folgt:

async _fetchResults() {
  try {
    this.results = await this.api.getNodeByLatLng(this.latitude, this.longitude, this.radius);
  } catch (err) {
    console.error(err);
  }
}

Die Resultate zeigen wir vorerst in einer rudimentären Form an und ergänzen dafür die render() Methode:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js
         <code>longitude</code> = <code>${this.longitude}</code> and
         <code>radius</code> = <code>${this.radius}</code>
       </p>
+      ${this.results.map(result => this._resultTemplate(result))}
     `;
   }
+
+  // eslint-disable-next-line class-methods-use-this
+  _resultTemplate(result) {
+    const {
+      lat,
+      lon,
+      id,
+      tags: { name },
+    } = result;
+
+    return html`<p>
+      ${id}, ${lat}, ${lon}<br />
+      ${name}
+    </p> `;
+  }

   async _fetchResults() {
     try {

Damit sollten wir eine funktionsfähige Abfrage möglicher Resultate über die REST API haben. Das Zwischenresultat sieht in etwa so aus:

Abfrage der API

Komponenten für AmenityBrowser und AmenityItem

Als Nächstes wollen wir die Darstellung und Logik der einzelnen Elemente, der ResultsView etwas aufteilen und kapseln. Wir werden zwei neue Komponenten erstellen: einen <amenity-browser> und ein <amenity-item>. Im AmenityBrowser soll links die Liste aller AmenitItems sein. Rechts davon können wir alle aufgelisteten AmenityItems auf einer Karte darstellen. Dafür können wir die <leaflet-map> Komponente wiederverwenden. Wir legen beide Komponenten in einem neuen Verzeichnis components ab.

Amenity Komponenten

AmenityItem

Als Nächstes definieren wir die AmenityItem Komponente. Zwecks Vereinfachung werden wir bei einem AmenityItem nur den Namen des Restaurants darstellen. Ihr könnt dies natürlich beliebig anpassen und um weitere Eigenschafen ergänzen. Zusätzlich soll jedes AmenityItem auch noch eine Distanz zum Suchpunkt haben. Die gesamte AmenityItem Klasse sieht wie folgt aus:

src/components/AmenityItem.js
import { css, html, LitElement, nothing } from 'lit';

export class AmenityItem extends LitElement {
  static get properties() {
    return {
      name: { type: String },
    };
  }

  static get styles() {
    return css`
      .amenity-item {
        padding: var(--amenity-container-padding);
        border-bottom: 1px solid hsl(0, 0%, 86%);
        background-color: hsl(0, 0%, 96%);
        cursor: pointer;
        display: flex;
        justify-content: space-between;
      }

      .amenity-item > .name {
        font-size: 125%;
      }
    `;
  }

  constructor() {
    super();

    this.name = '';
  }

  render() {
    if (!this.name) {
      return nothing;
    }

    return html`<div class="amenity-item">
      <span class="name">${this.name}</span>
    </div>`;
  }
}

customElements.define('amenity-item', AmenityItem);

AmenityBrowser

Die neue AmenityBrowser Komponente ist etwas komplexer, allerdings haben wir auch einige bekannte Eigenschaften. Für die Darstellung der Karteninformationen brauchen wir die drei Suchparameter latitude, longitude und radius. Diese werden vom AmenityFinder über die ResultView zum AmenityBrowser durchgereicht. Die Liste der Restaurants speichern wir im Property amenities. Daneben haben wir noch ein Property für die Map-Marker, damit wir die Positionen der Restaurants auf der Karte einzeichnen können. Das Property soll nur intern im AmenityBrowser verwaltet werden und sich jedes Mal anpassen, wenn sich der Wert des amenities Property ändert. Der AmenityBrowser sieht wie folgt aus:

src/components/AmenityBrowser.js
import { css, html, LitElement, nothing } from 'lit';
import '@inventage/leaflet-map';

import './AmenityItem.js';

export class AmenityBrowser extends LitElement {
  static get properties() {
    return {
      latitude: { type: String },
      longitude: { type: String },
      radius: { type: Number },
      amenities: { type: Array },

      // Internal properties
      markers: { type: Array, attribute: false },
    };
  }

  static get styles() {
    return css`
      :host {
        display: flex;
      }

      .amenities:not(:empty) {
        width: 70ch;
        max-width: 50vw;
      }

      leaflet-map {
        flex: 1;
        position: sticky;
        top: 0;
        right: 0;
      }
    `;
  }

  constructor() {
    super();

    this.amenities = [];
    this.markers = [];
  }

  updated(changedProperties) {
    super.updated(changedProperties);

    if (changedProperties.has('amenities')) {
      this._updateMarkersFromResults();
    }
  }

  render() {
    return html`<div class="amenities">${this._renderAmenities()}</div>
      <leaflet-map .latitude="${this.latitude}" .longitude="${this.longitude}" .radius="${this.radius}" .markers="${this.markers}"></leaflet-map>`;
  }

  _renderAmenities() {
    if (!this._hasAmenities()) {
      return nothing;
    }

    return html`${this.amenities.map((result) => {
      const {
        tags: { name },
      } = result;

      return html`<amenity-item .name="${name}"></amenity-item>`;
    })}`;
  }

  _hasAmenities() {
    return this.amenities.length > 0;
  }

  _updateMarkersFromResults() {
    if (this.amenities.length < 1) {
      return;
    }

    this.markers = this.amenities.map((result) => {
      const {
        id,
        lat: latitude,
        lon: longitude,
        tags: { name: title },
      } = result;

      return {
        id,
        latitude,
        longitude,
        title,
      };
    });
  }
}

customElements.define('amenity-browser', AmenityBrowser);

Neue Komponente(n) einbinden

In der ResultsView können wir nun die temporäre Darstellung der Restaurants mit dem neue AmenityBrowser ersetzen:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js
 import { OverpassApi } from '../services/OverpassApi.js';

+import '../components/AmenityBrowser.js';
+import '../components/AmenityItem.js';
+
 export class ResultsView extends LitElement {
   static get properties() {
     return {
     return {
--- src/views/ResultsView.js
+++ src/views/ResultsView.js
         <code>longitude</code> = <code>${this.longitude}</code> and
         <code>radius</code> = <code>${this.radius}</code>
       </p>
-      ${this.results.map(result => this._resultTemplate(result))}
+      <amenity-browser .amenities="${this.results}" .latitude="${this.latitude}" .longitude="${this.longitude}" .radius="${this.radius}"></amenity-browser>
     `;
   }
-
-  // eslint-disable-next-line class-methods-use-this
-  _resultTemplate(result) {
-    const {
-      lat,
-      lon,
-      id,
-      tags: { name },
-    } = result;
-
-    return html`<p>
-      ${id}, ${lat}, ${lon}<br />
-      ${name}
-    </p> `;
-  }

   async _fetchResults() {
     try {

Das Resultat sieht in etwa so aus:

AmenityBrowser ungestyled

Verbesserungen des Layouts (optional)

Beim Layout des ResultsView haben wir noch etwas Luft nach oben. Und weil ja bekanntlich CSS so schön (einfach) ist, können wir mit ein paar kleinen Anpassungen das Layout verbessern. Wir möchten, dass der Benutzer nur die Liste der Restaurants im AmenityBrowser scrollen kann. Die Karte rechts davon soll fix positioniert sein. Die Amenities und die Karte sollen jeweils bildschirmfüllend in der Höhe sein aber nicht darüber hinausragen. Folgende Anpassungen sind notwendig:

src/AmenityFinder.js

Styles deklarieren:

static get styles() {
  return css`
    :host {
      --amenity-container-padding: 1rem;
    }

    main {
      padding: var(--amenity-container-padding, 1rem);
      box-sizing: border-box;
      display: flex;
      flex: 1;
      max-height: calc(100vh - 64px);
    }

    [slot='appContent'] {
      display: flex;
      flex-direction: column;
      height: 100%;

      /* fixes issues where content would overlay sidebar */
      z-index: 1;
      position: relative;
    }
  `;
}
src/views/ResultsView.js

css Direktive importieren:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js
-import { html, LitElement } from 'lit';
+import { css, html, LitElement } from 'lit';

 import { OverpassApi } from '../services/OverpassApi.js';

Styles deklarieren:

static get styles() {
  return css`
    :host {
      width: 100%;
      display: flex;
      flex-direction: column;
    }

    amenity-browser {
      position: relative;
      overflow-y: auto;
      flex: 1;

      /* stretch to edges */
      margin-left: calc(var(--amenity-container-padding) * -1);
      margin-right: calc(var(--amenity-container-padding) * -1);
      margin-bottom: calc(var(--amenity-container-padding) * -1);
    }
  `;
}
src/views/SearchView.js

css Direktive importieren:

--- src/views/SearchView.js
+++ src/views/SearchView.js
-import { html, LitElement } from 'lit';
+import { css, html, LitElement } from 'lit';

Styles deklarieren:

static get styles() {
  return css`
    :host {
      --amenity-search-form-spacing: 1rem;

      flex: 1;
      display: flex;
      flex-direction: column;
    }

    leaflet-map {
      flex: 1;

      /* stretch to edges */
      margin-left: calc(var(--amenity-container-padding) * -1);
      margin-right: calc(var(--amenity-container-padding) * -1);
      margin-bottom: calc(var(--amenity-container-padding) * -1);
    }

    .search-form {
      margin-bottom: 1rem;
    }
  `;
}

und das Formular mit einem .search-form Element wrappen:

--- src/views/SearchView.js
+++ src/views/SearchView.js
     return html`
       <h1>Search</h1>

-      <mwc-textfield label="Latitude" .value="${this.latitude}" @keyup="${e => (this.latitude = e.target.value)}"></mwc-textfield>
-      <mwc-textfield label="Longitude" .value="${this.longitude}" @keyup="${e => (this.longitude = e.target.value)}"></mwc-textfield>
-      <mwc-textfield label="Radius (m)" .value="${this.radius}" @keyup="${e => (this.radius = e.target.value)}"></mwc-textfield>
+      <div class="search-form">
+        <mwc-textfield label="Latitude" .value="${this.latitude}" @keyup="${e => (this.latitude = e.target.value)}"></mwc-textfield>
+        <mwc-textfield label="Longitude" .value="${this.longitude}" @keyup="${e => (this.longitude = e.target.value)}"></mwc-textfield>
+        <mwc-textfield label="Radius (m)" .value="${this.radius}" @keyup="${e => (this.radius = e.target.value)}"></mwc-textfield>

-      <mwc-button outlined label="Locate Me" icon="my_location" @click="${this._handleLocateMeClick}" .disabled="${!canGeolocate()}"></mwc-button>
-      <mwc-button raised label="Search" @click="${this._triggerSearch}" .disabled="${!this._canSearch()}"></mwc-button>
+        <mwc-button outlined label="Locate Me" icon="my_location" @click="${this._handleLocateMeClick}" .disabled="${!canGeolocate()}"></mwc-button>
+        <mwc-button raised label="Search" @click="${this._triggerSearch}" .disabled="${!this._canSearch()}"></mwc-button>
+      </div>

       <leaflet-map
         .latitude="${this.latitude}"

Das Layout nach dieser Iteration gibt schon etwas mehr her:

Styling Verbesserungen

UX Improvements (optional)

Wir haben jetzt eine Version der ResultView, die schon ganz akzeptabel ist. Gleichzeitig können wir noch mit ein paar wenigen Anpassungen die UX unserer Applikation verbessern.

Distanz bei Suchresultaten

Die Suchresultate sind im Moment nicht explizit sortiert. Wir zeigen sie in der Reihenfolge an, wie sie von der Schnittstelle geliefert werden. Aus Benutzersicht wäre es allerdings besser, wenn wir die Resultate anhand der Distanz zum gesuchten Mittelpunkt absteigend sortieren würden.

Wir müssen dafür zwei Sachen anpassen: Wir müssen die Distanz zwischen den einzelnen Resultaten berechnen und dann eine entsprechende Sortierung einbauen. Der AmenityBrowser soll die ihm übergebenen Amenities nur anzeigen und keine zusätzliche Sortierlogik beinhalten. Wir implementieren das Berechnen der Distanz und das Sortieren in der ResultView und übergeben die vorbereitete Liste an den AmenityBrowser.

Bei jedem Resultat kennen wir die latitude und longitude. Wir können mittels Trigonometrie die Distanz zwischen zwei solchen Punkten berechnen. Wir benutzen dafür die Library geodesy. Sie bietet eine Ansammlung einfacher und komplexer trigonometrischer Funktionen für die Berechnung der Distanz zwischen zwei Punkten auf der Erde.

Wir installieren zuerst die Library

$ npm install geodesy@^2.2.1

und erweitern dann unsere Helper-Klasse geolocation.js um eine neue Funktion distanceBetween. Diese soll die Distanz in Metern zwischen zwei Punkten, definiert durch ihre latitude und longitude, berechnen. Wir importieren die LatLon Klasse der geodesy Bibliothek:

import LatLon from 'geodesy/latlon-ellipsoidal-vincenty';

und definieren unsere distanceBetween Funktion wie folgt:

const distanceBetween = ([lat1, lon1], [lat2, lng2]) => {
  const p1 = new LatLon(lat1, lon1);
  const p2 = new LatLon(lat2, lng2);

  return p1.distanceTo(p2);
};

zum Schluss müssen wir diese noch der Liste aller exportierten Variablen hinzufügen:

export { canGeolocate, detectUserLocation, distanceBetween };

In der ResultView können wir nun die Methode _fetchResults() entsprechend um die Berechnung der Distanz bei den Resultaten erweitern:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js
 import '../components/AmenityBrowser.js';
 import '../components/AmenityItem.js';
+import { distanceBetween } from '../utils/geolocation.js';

 export class ResultsView extends LitElement {
   static get properties() {
--- src/views/ResultsView.js
+++ src/views/ResultsView.js
   async _fetchResults() {
     try {
-      this.results = await this.api.getNodeByLatLng(this.latitude, this.longitude, this.radius);
+      const results = await this.api.getNodeByLatLng(this.latitude, this.longitude, this.radius);
+      this.results = results.map(result => {
+        return {
+          ...result,
+          distance: distanceBetween([this.latitude, this.longitude], [result.lat, result.lon]),
+        };
+      });
     } catch (err) {
       console.error(err);
     }

Nun ergänzen wir noch die AmenityItem Komponente um ein Property für die Distanz:

--- src/components/AmenityItem.js
+++ src/components/AmenityItem.js
   static get properties() {
     return {
       name: { type: String },
+      distance: { type: String },
     };
   }
--- src/components/AmenityItem.js
+++ src/components/AmenityItem.js
       .amenity-item > .name {
         font-size: 125%;
       }
+
+      .amenity-item > .distance {
+        color: #6a7071;
+      }
     `;
   }
--- src/components/AmenityItem.js
+++ src/components/AmenityItem.js
     super();

     this.name = '';
+    this.distance = '';
   }

   render() {
--- src/components/AmenityItem.js
+++ src/components/AmenityItem.js
     return html`<div class="amenity-item">
       <span class="name">${this.name}</span>
+      <span class="distance">${Number.parseFloat(this.distance).toFixed(2)} m</span>
     </div>`;
   }
 }

Und reichen diese beim AmenityBrowser noch entsprechend durch:

--- src/components/AmenityBrowser.js
+++ src/components/AmenityBrowser.js

     return html`${this.amenities.map(result => {
       const {
+        distance,
         tags: { name },
       } = result;

-      return html`<amenity-item .name="${name}"></amenity-item>`;
+      return html`<amenity-item .name="${name}" .distance="${distance}"></amenity-item>`;
     })}`;
   }

Das Sortieren der Elemente im ResultView nach Distanz ist nun trivial. Wir können dies über Chaining einer weiteren Array-Methode sort() direkt im Anschluss auf das map() in der _fetchResults() Methode machen:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js
           ...result,
           distance: distanceBetween([this.latitude, this.longitude], [result.lat, result.lon]),
         };
-      });
+      }).sort((a, b) => a.distance - b.distance);
     } catch (err) {
       console.error(err);
     }

Wir haben nun im AmenityBrowser eine nach Distanz zum Suchmittelpunkt sortierte Liste inkl. Anzeige der Distanz in Metern:

Suchresultate sortiert nach Distanz

Ausgewähltes Element und Karteninteraktion

Als Nächstes wollen wir jeweils das ausgewählte Element aus der Liste der Amenities im AmenityBrowser (links) in der Karte hervorheben (rechts). Der Benutzer kann sich so besser beim Durchgehen der Resultate orientieren. Er sieht so auf Anhieb, um welches Element auf der Karte es sich beim Suchresultat handelt.

Die <leaflet-map> Komponente kennt ein selectedMarker Property. Weil wir im AmenityBrowser bereits die Liste der Marker führen, die wir jeweils bei Änderungen des amenities Property aktualisieren, können wir ein neues (internes) Property für selectedMarker einführen:

--- src/components/AmenityBrowser.js
+++ src/components/AmenityBrowser.js

       // Internal properties
       markers: { type: Array, attribute: false },
+      selectedMarker: { type: Object, attribute: false },
     };
   }
--- src/components/AmenityBrowser.js
+++ src/components/AmenityBrowser.js
     this.amenities = [];
     this.markers = [];
+    this.selectedMarker = null;
   }

   updated(changedProperties) {
--- src/components/AmenityBrowser.js
+++ src/components/AmenityBrowser.js
   render() {
     return html`<div class="amenities">${this._renderAmenities()}</div>
-      <leaflet-map .latitude="${this.latitude}" .longitude="${this.longitude}" .radius="${this.radius}" .markers="${this.markers}"></leaflet-map>`;
+      <leaflet-map
+        .latitude="${this.latitude}"
+        .longitude="${this.longitude}"
+        .radius="${this.radius}"
+        .markers="${this.markers}"
+        .selectedMarker="${this.selectedMarker}"
+      ></leaflet-map>`;
   }

   _renderAmenities() {

Nun brauchen wir noch eine Methode, um das neue selectedMarker Property zu aktualisieren. Dafür binden wir eine neue Methode _selectMarker() als Event-Handler auf den click Event der einzelnen AmenityItem Komponenten. Die Methode sucht den entsprechenden Marker (über das id Property) in der Liste aller Marker und setzt, falls gefunden, das Objekt als selectedMarker Property:

--- src/components/AmenityBrowser.js
+++ src/components/AmenityBrowser.js

     return html`${this.amenities.map(result => {
       const {
+        id,
         distance,
         tags: { name },
       } = result;

-      return html`<amenity-item .name="${name}" .distance="${distance}"></amenity-item>`;
+      return html`<amenity-item
+        .name="${name}"
+        .distance="${distance}"
+        .selected="${this.selectedMarker && this.selectedMarker.id === id}"
+        @click="${() => this._selectMarker(id)}"
+      ></amenity-item>`;
     })}`;
   }

+  _selectMarker(id) {
+    const selectedMarker = this.markers.find(marker => marker.id === id);
+    if (selectedMarker) {
+      this.selectedMarker = selectedMarker;
+    }
+  }
+
   _hasAmenities() {
     return this.amenities.length > 0;
   }

Damit wir die selektierten Elemente in der Liste der Amenities besser erkennen, führen wir noch ein selected Property beim AmenityItem ein und passen die Darstellung eines selektierten Items leicht an:

--- src/components/AmenityItem.js
+++ src/components/AmenityItem.js
 import { css, html, LitElement } from 'lit';
+import { classMap } from 'lit/directives/class-map.js';

 export class AmenityItem extends LitElement {
   static get properties() {
     return {
       name: { type: String },
       distance: { type: String },
+      selected: { type: Boolean },
     };
   }
--- src/components/AmenityItem.js
+++ src/components/AmenityItem.js
       .amenity-item > .distance {
         color: #6a7071;
       }
+
+      .amenity-item.-selected {
+        background-color: hsl(0, 0%, 92%);
+      }
+
+      .amenity-item.-selected > .distance {
+        color: #535859;
+      }
     `;
   }
--- src/components/AmenityItem.js
+++ src/components/AmenityItem.js
     this.name = '';
     this.distance = '';
+    this.selected = false;
   }

   render() {
--- src/components/AmenityItem.js
+++ src/components/AmenityItem.js
       return nothing;
     }

-    return html`<div class="amenity-item">
+    return html`<div class="amenity-item ${classMap({ '-selected': this.selected })}">
       <span class="name">${this.name}</span>
       <span class="distance">${Number.parseFloat(this.distance).toFixed(2)} m</span>
     </div>`;

Interessant dabei ist vor allem die classMap Direktive. Lit hat einige solche eingebaute Direktiven, die nützlich sind. open-wc pflegt ebenfalls eine Sammlung von nützlichen Helfern für das Rendern von Lit-Bindings.

Das Resultat dieser Anpassung kann etwa so aussehen:

Ausgewähltes Element auf der Karte anzeigen

Bevor wir mit der nächsten Optimierung weitermachen, korrigieren wir noch einen Bug. Unsere Titelleiste zeigt schon seit einigen Lektionen einfach Title an. Dort sollte der Name unserer Applikation stehen:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
                 this.showSidebar = !this.showSidebar;
               }}"
             ></mwc-icon-button>
-            <div slot="title">Title</div>
+            <div slot="title">Amenity Finder</div>
           </mwc-top-app-bar>
           <main>
             ${this._renderCurrentView()}

Fortan sollten auch die Screenshots und Kurzvideos eine bessere Falle machen.

Navigation zu den Suchresultaten

Das Navigieren zu den Suchresultaten über die Suche funktioniert zwar, unser Navigationspunkt Results leitet aber immer noch auf die URL /results. Diese URL haben wir nicht mehr in unseren Routes definiert, sodass beim Navigieren zu Results über die Navigation in der Sidebar immer die Startseite als Fallback verwendet wird.

Navigation zu Results

Wir wissen allerdings im AmenityFinder als Orchestrator, ob bereits eine Suche abgesetzt wurde oder nicht. Wir könnten unser Routing wie folgt erweitern:

  • Ist noch keine Suche abgesetzt worden, soll der Navigationspunkt Results auf die Suche weiterleiten
  • Ist bereits eine Suche abgesetzt worden, sind auch die latitude, longitude und radius Properties im AmenityFinder aktuell und wir können auf die Route /results/:lat/:lon/:radius weiterleiten

Dafür erweitern wir den AmenityFinder um ein weiteres Property: alreadySearched. Dieses aktualisieren wir in der _setSearchParametersFromRouteContext() Methode, dort wo wir jetzt schon unsere Daten anhand der URL Parameter setzen. Zum Schluss ergänzen wir unsere Routes um die erwähnten Weiterleitungen:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
     return {
       showSidebar: { type: Boolean },
       currentView: { type: String },
+      alreadySearched: { type: Boolean },
       latitude: { type: String },
       longitude: { type: String },
       radius: { type: Number },
--- src/AmenityFinder.js
+++ src/AmenityFinder.js
     super();
     this.showSidebar = false;
     this.currentView = 'home';
+    this.alreadySearched = false;

     this.latitude = '47.3902';
     this.longitude = '8.5158';
--- src/AmenityFinder.js
+++ src/AmenityFinder.js
+    page('/results', () => {
+      if (this.alreadySearched) {
+        page.redirect(`/results/${this.latitude}/${this.longitude}/${this.radius}`);
+        return;
+      }
+
+      page.redirect('/search');
+    });
     page('/results/:lat/:lon/:radius', ctx => {
       this._setSearchParametersFromRouteContext(ctx);
       this.currentView = 'results';
--- src/AmenityFinder.js
+++ src/AmenityFinder.js

  _setSearchParametersFromRouteContext(ctx) {
     const {
       params: { radius, lat, lon },
     } = ctx;

     if (!radius || !lat || !lon) {
       return;
     }

     this.radius = radius;
     this.latitude = lat;
     this.longitude = lon;
+    this.alreadySearched = true;
   }
 }

Zurück zur Suche

Eine ähnliche Problematik haben wir noch bei der Navigation zur Suche. Der Navigationspunkt Search führt immer zu einer neuen Suche, obwohl wir bereits eine Suche abgesetzt haben. Unter Umständen möchte der Benutzer eine neue Suche mit nur leicht geänderten Parametern absetzen. Benutzt er dafür bei den Suchresultaten nicht den «Zurück» Button, ist die Information zur aktuellen Suche auf der Suchseite verloren.

Um dieses Problem zu beheben, müssen beim Routing zur Suchseite die Suchparameter als Parameter der Route definieren:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
       this.currentView = 'results';
     });
     page('/search', () => {
+      if (this.alreadySearched) {
+        page.redirect(`/search/${this.latitude}/${this.longitude}/${this.radius}`);
+        return;
+      }
+
+      this.currentView = 'search';
+    });
+    page('/search/:lat/:lon/:radius', ctx => {
+      this._setSearchParametersFromRouteContext(ctx);
       this.currentView = 'search';
     });
     page();

Der Navigationspunkt Search führt nun die Suchparameter über die URL beim Navigieren zur Suchseite mit. Ähnlich wie die ResultView erhält auch jede Suchseite so ihre eigene, eindeutige URL: http://localhost:8000/search/47.3902/8.5158/1000

Als Bonus können wir noch einen einfachen Default-Slot in der ResultView einführen:

--- src/views/ResultsView.js
+++ src/views/ResultsView.js

   render() {
     return html`
-      <h1>Results</h1>
-      <p>
-        Displaying results</strong> for
-        <code>latitude</code> = <code>${this.latitude}</code>,
-        <code>longitude</code> = <code>${this.longitude}</code> and
-        <code>radius</code> = <code>${this.radius}</code>
-      </p>
+      <div>
+        <h1>Results</h1>
+        <p>
+          Displaying results</strong> for
+          <code>latitude</code> = <code>${this.latitude}</code>,
+          <code>longitude</code> = <code>${this.longitude}</code> and
+          <code>radius</code> = <code>${this.radius}</code>
+        </p>
+        <slot></slot>
+      </div>
       <amenity-browser .amenities="${this.results}" .latitude="${this.latitude}" .longitude="${this.longitude}" .radius="${this.radius}"></amenity-browser>
     `;
   }

und im AmenityFinder einen «Back to search» Link einbauen:

--- src/AmenityFinder.js
+++ src/AmenityFinder.js
           @execute-search="${e => page(`/results/${e.detail.latitude}/${e.detail.longitude}/${e.detail.radius}`)}"
         ></search-view>`;
       case 'results':
-        return html`<results-view .latitude="${this.latitude}" .longitude="${this.longitude}" .radius="${this.radius}"></results-view>`;
+        return html`<results-view .latitude="${this.latitude}" .longitude="${this.longitude}" .radius="${this.radius}">
+          <p><a href="${`/search/${this.latitude}/${this.longitude}/${this.radius}`}">← Back to search</a></p>
+        </results-view>`;
       default:

Damit können wir das Navigieren von der ResultView auf die SearchView noch weiter vereinfachen:

Back to search Navigation

Ausgezeichnet!

Du hast den fünften Teil der Amenity Finder Serie erfolgreich abgeschlossen. In diesem Teil haben wir die Abfrage und Anzeige der Suchergebnisse in der Amenity Finder Applikation implementiert. Du hast möglicherweise eine REST-API genutzt, um die Amenities basierend auf den Suchparametern zu laden. Die Ergebnisse werden nun in der Applikation angezeigt, sodass Benutzer die gefundenen Amenities überblicken können. Im sechsten Teil werden wir Unit-Tests für Helfer-Funktionen und Komponenten implementieren.