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.<br>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 © <a href="https://www.openstreetmap.org/" target="_blank">OpenStreetMap</a> and contributors <a href="https://creativecommons.org/licenses/by-sa/2.0/" target="_blank">CC-BY-SA</a>'
});
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("<h3>"+title+"</h3>"+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("<h3>"+title+"</h3>"+popup);
}
}
}
customElements.define('osm-marker', osmMarker);
});
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.