Jürgen Berkemeier: Einbinden einer OSM-Karte als Beispiel für Custom Elements

Im Blog-Artikel Einstieg in Leaflet wurde beschrieben, wie man mit dem Leaflet-API eine Landkarte in seine Seite einbinden kann. Darauf basierend wird in diesem Artikel gezeigt, wie man Custom Elements für eine Landkarte mit Markern erstellen kann.

Im Selfwiki wird das Grundgerüst für die Definition von Custom Elements vorgestellt. Da beim Entfernen des Elements nichts Weiteres passieren soll, und der Fall „Bewegen in ein anderes Dokument“ nicht vorgesehen ist, werden die Methoden disconnectedCallback und adoptedCallback weggelassen.

Das Custom Element für die Karte soll <osm-map> sein und die Eckkoordinaten der Karte sollen als Attribute übergeben werden. Die Definition sieht daher so aus:

<osm-map topleft="52.65, 13.2" bottomright="52.35, 13.6"></osm-map>
// Custom-Element osm-map anlegen
class osmMap extends HTMLElement {}
customElements.define('osm-map', osmMap);

Im Getter observedAttributes wird festgelegt, dass auf die Änderung der Attribute topleft und bottomright reagiert werden soll:

  static get observedAttributes() {
    return ['topleft', 'bottomright'];
  }

Im Konstruktor muss als erstes die Methode „super“ aufgerufen werden, die den Konstruktor der Elternklasse aufruft:

  constructor() {
    super();

Als nächstes wird dann das Shadow Dom angelegt:

    const shadow = this.attachShadow({mode: closed});

Mode wurde auf „closed“ gesetzt, da von außen nicht per Javascript auf das Shadow Dom zugegriffen werden muss. Für die Landkarte benötigt das Leaflet-API ein DIV, das ins Shadow Dom eingehängt wird:

    this.mapcanvas = document.createElement('div');
    this.mapcanvas.className = "mapcanvas";
    shadow.appendChild(this.mapcanvas);

Dann wird noch ein Stylesheet erstellt, in dem das Leaflet-CSS importiert wird. So ist dieses CSS Teil des Shadow Doms.

    const style1 = document.createElement('style');
    // Leaflet-CSS laden
    style1.textContent = `@import url('${LeafletBasePath}leaflet.css')`;
    shadow.appendChild(style1);

Custom-Elemente sind Inline-Elemente. Um der Karte per css eine Größe geben zu können, erhält das osm-map-Element die Displayeigenschaft block, und das div für die Karte die Größe 100%.

    const style2 = document.createElement('style');
    style2.textContent = `
      :host { display: block; }
      .mapcanvas { width: 100%; height: 100%; }
    `;
    shadow.appendChild(style2);
  }

Die Methode connectedCallback wird aufgerufen, wenn das Element ins DOM eingehängt wird. Leaflet orientiert sich beim Erstellen der Karte an den Maßen des Karten-Divs. Da Safari (Stand Dez. 2020) die Style-Regeln verzögert umsetzt, werden hier die kritischen Regeln noch einmal per Javascript gesetzt und dann die Methode makeMap aufgerufen, die die Karte erstellt:

  connectedCallback() {
    this.style.display = "block";
    this.mapcanvas.style.height = "100%";
    this.mapcanvas.style.width = "100%";

    // Karte anlegen
    this.makeMap();
  }

Die Methode attributeChangedCallback wird bei Attributänderung aufgerufen. Da diese Methode vor der Methode connectedCallback aufgerufen werden kann, muss geprüft werden, ob die Methode makeMap schon gelaufen ist und das Kartenobjekt angelegt hat:

  attributeChangedCallback(name, oldValue, newValue) {
    if(this.map) {
      // Element-Attribute auslesen
      let topleft, bottomright;
      topleft = this.getAttribute("topleft").split(",").map(Number);
      bottomright = this.getAttribute("bottomright").split(",").map(Number);
      const bounds = [ topleft, bottomright ];
      this.map.fitBounds( bounds );
    }
  }

Die Methode makeMap liest die Attribute topleft und bottomright erstellt die Karte.

  makeMap() {
    // Element-Attribute auslesen
    let topleft, bottomright;
    topleft = this.getAttribute("topleft").split(",").map(Number);
    bottomright = this.getAttribute("bottomright").split(",").map(Number);
    const bounds = [ topleft, bottomright ];
    // Karte anlegen
    const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
      maxZoom: 19,
      attribution: 'Map data © <a href="https://www.openstreetmap.org/" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank" rel="noopener noreferrer">CC-BY-SA</a>'
    });
    this.map = L.map(this.mapcanvas, { layers: osm, tap: false } ) ;
    L.control.scale({imperial:false}).addTo(this.map);
    this.map.fitBounds( bounds );
    this.map.on("resize", function(e){
      this.map.fitBounds( bounds );
    });
  }

Auf der Karte sollen auch noch Orte mit einem Marker gekennzeichnet werden können. Das Custom Element sieht so aus:

<osm-marker latlon="52.363860434566206,13.489083283593702"
            title="Flughafen BER"
            popup="Endlich fertig.&lt;br&gt;Hat ja kaum noch einer dran geglaubt."></osm-marker>

Der Marker hat die Attribute latlon für die Koordinaten, title wird bei hover angezeigt und popup als Inhalt für ein Popup-Fenster, das sich bei Klick auf den Marker öffnet. Die letzten beiden sind optional.

Die Definition von osm-marker ist analog zu osm-map aufgebaut. Im Konstruktor wird nur der Basispfad für die Markergrafik angegeben. In connectedCallback wird geprüft, ob das Elternelement des Markers ein osm-map-Element ist und ob die Karte schon angelegt wurde. Wenn beides gegeben ist, wird der Marker erstellt. In attributeChangedCallback wird, wenn der Marker schon angelegt ist, auf das Setzen oder Ändern der jeweiligen Attribute reagiert.

Für die Erklärung zu den Methoden makeMap und makeMarker verweise ich auf Einstieg in Leaflet.

Da CSS, Script und Karten-Bilder von Fremdanbietern geladen werden, wird aus Datenschutzgründen gefragt, ob das OK ist. Daher wird das Leaflet-Script erts nach der Zustimmung mit einer Hilfsfunktion geladen, und die Definition der Elemente erfolgt im Callback der Hilfsfunktion. Das Leaflet-CSS wird dann erst in der Element-Definition importiert.

Das komplette Script sieht jetzt so aus (Live Beispiel):

// Leafletscript laden
const LeafletBasePath = "https://unpkg.com/leaflet@1.7.1/dist/";
loadScript(`${LeafletBasePath}leaflet.js`, function() {

  // Custom-Element osm-map anlegen
  class osmMap extends HTMLElement {

    // Festlegen, welche Attribute überwacht werden sollen
    static get observedAttributes() {
      return ['topleft', 'bottomright'];
    }

    constructor() {
      // super muss als erstes in constructor aufgerufen werden, super ruft construcor der Elternklasse auf
      super();

      // Shadow Dom anlegen
      const shadow = this.attachShadow({mode: 'closed'});

      // Canvas für die Karten anlegen und ins Shadow Dom einhängen
      this.mapcanvas = document.createElement('div');
      this.mapcanvas.className = "mapcanvas";
      shadow.appendChild(this.mapcanvas);

      // CSS für die Karten anlegen und ins Shadow Dom einhängen
      const style1 = document.createElement('style');
      // Leaflet-CSS laden
      style1.textContent = `@import url('${LeafletBasePath}leaflet.css')`;
      shadow.appendChild(style1);

      const style2 = document.createElement('style');
      style2.textContent = `
        :host { display: block; }
        .mapcanvas { width: 100%; height: 100%; }
      `;
      shadow.appendChild(style2);
    }

    connectedCallback() {
      // Safari setzt die folgenden Angaben verspätet bzw. erst bei Reload um ??? Daher direktes Setzen im connectedCallback
      this.style.display = "block";
      this.mapcanvas.style.height = "100%";
      this.mapcanvas.style.width = "100%";

      // Karte anlegen
      this.makeMap();
    }

    attributeChangedCallback(name, oldValue, newValue) {
      // attributeChangedCallback kommt vor connectedCallback, daher prüfen, ob makeMap schon gelaufen ist.
      if(this.map) {
        // Element-Attribute auslesen
        let topleft, bottomright;
        topleft = this.getAttribute("topleft").split(",").map(Number);
        bottomright = this.getAttribute("bottomright").split(",").map(Number);
        const bounds = [ topleft, bottomright ];
        this.map.fitBounds( bounds );
      }
    }

    makeMap() {
      // Element-Attribute auslesen
      let topleft, bottomright;
      topleft = this.getAttribute("topleft").split(",").map(Number);
      bottomright = this.getAttribute("bottomright").split(",").map(Number);
      const bounds = [ topleft, bottomright ];
      // Karte anlegen
      const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        maxZoom: 19,
        attribution: 'Map data © &lt;a href="https://www.openstreetmap.org/" target="_blank"&gt;OpenStreetMap&lt;/a&gt; and contributors &lt;a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank"&gt;CC-BY-SA&lt;/a&gt;'
      });
      const map = L.map(this.mapcanvas, { layers: osm, tap: false } ) ;
      this.map = map;
      L.control.scale({imperial:false}).addTo(map);
      map.fitBounds( bounds );
      map.on("resize", function(e){
        map.fitBounds( bounds );
      });
    }

  }
  customElements.define('osm-map', osmMap);

  // Custom-Element osm-marker anlegen
  class osmMarker extends HTMLElement {
    static get observedAttributes() {
      return ['latlon', 'title', 'popup'];
    }

    constructor() {
      super();
      L.Icon.Default.prototype.options.imagePath = `${LeafletBasePath}images/`;
    }

    connectedCallback() {
      if(!this.parentNode || this.parentNode.nodeName.toLowerCase() != "osm-map" ) {
        console.error("CB: Kein osm-map-Element als Elternelement gefunden.");
        return;
      }
      this.map = this.parentNode.map;
      if(!this.map) {
        console.error("CB: Kein Elternelement mit Karte gefunden.");
        return;
      }
      this.makeMarker();
    }

    attributeChangedCallback(name, oldValue, newValue) {
      // Geändertes Element-Attribut auslesen
      if(this.marker) {
        switch(name) {
        case "latlon":
          const latlon = newValue.split(",").map(Number);
          this.marker.setLatLng(latlon);
          break;
        case "title":
          this.marker.options.title = newValue; // Zeigt beim Marker keine Wirkung
          break;
        case "popup":
          const title = this.hasAttribute("title")?this.getAttribute("title"):"";
          if(newValue) this.marker.bindPopup("&lt;h3&gt;"+title+"&lt;/h3&gt;"+newValue);
          break;
        }
      }
    }

    makeMarker() {
      if(this.map) {
        // Alle Element-Attribute auslesen
        let latlon, title="", popup=null;
        if(this.hasAttribute("latlon")) latlon = this.getAttribute("latlon").split(",").map(Number);
        else return;
        if(this.hasAttribute("title")) title = this.getAttribute("title");
        if(this.hasAttribute("popup")) popup = this.getAttribute("popup");
        // Marker anzeigen
        this.marker = L.marker(latlon,{title:title}).addTo(this.map);
        if(popup) this.marker.bindPopup("&lt;h3&gt;"+title+"&lt;/h3&gt;"+popup);
      }
    }

  }
  customElements.define('osm-marker', osmMarker);

});

Live Beispiel

Um zu testen, ob die Custom-Elemente auch dynamisch geladen und modifiziert werden können, gibt es in der Testseite noch einen Eventhandler für das load-Event, der diese Tests durchführt.