Zum Inhalt springen

LayerTree Control für Maplibre

In Leaflet nutze ich gerne Leaflet.Control.Layers.Tree und finde die Möglichkeiten super. Deshalb habe ich das Control, das ich hier beschreibe, Layertree genannt. Ich möchte aber zunächst klein anfangen und meine in Vanilla JavaScript gesammelten Lernschritte dokumentieren. Da ich noch recht wenig Erfahrung habe, sind Verbesserungsvorschläge oder Kritik willkommen! Ich freue mich, wenn dieser Beitrag Gleichgesinnten hilft.

Ein Baselayer ist die unterste Kartenschicht, die den allgemeinen Hintergrund oder geografischen Kontext liefert – also Dinge wie:

  • Straßen, Gebäude, Flüsse, Landschaften
  • Satellitenbilder oder einfache Kartenzeichnungen

Er bildet die Grundlage, auf der andere Daten als Overlays (z. B. Marker, Routen, thematische Layer) dargestellt werden können.

Es gibt bereits das offizielle Control maplibre-basemaps, das jedoch ausschließlich Raster Sources unterstützt. Ich möchte auch Vector Sources als Basemap ermöglichen.

Hier zunächst ein einfaches Codebeispiel, das zeigt, wie man in MapLibre GL JS einen eigenen Basemap-Layer-Switcher erstellt, um zwischen verschiedenen Basiskarten (Layern) – egal ob Vektor oder Raster – zu wechseln.

Eine einfache MapLibre-Karte mit eigenem Basemap-Layer-Switcher

Die HTML-Datei lädt MapLibre, das zugehörige CSS und JavaScript und erzeugt ein <div>-Element für die Karte.

<!DOCTYPE html>
<html lang="de">
<head>
<title>Demo Notes 1</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Demo Navication Control 1">
<link
href="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css"
rel="stylesheet"
>
<script
src="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js"
></script>
<link rel="stylesheet" href="index.css">
<script type="module" src="index.js" defer></script>
</head>
<body>
<div id="map"></div>
</body>
</html>

Die CSS-Datei sorgt dafür, dass die Karte den gesamten Bildschirm ausfüllt, und formatiert die Layer-Auswahl .layer-tree-item so, dass sie auch auf kleinen Bildschirmen per Finger bedienbar ist.

body {
margin: 0;
padding: 0;
}
html,
body,
#map {
height: 100%;
}
/* layer tree*/
.layer-tree-item {
padding: 8px 12px;
}

Die JavaScript-Datei enthält die gesamte Logik. Darin wird eine Klasse LayerTreeControl definiert, die ein Formular mit Radiobuttons erstellt. Je nach Auswahl im Menü wird mit _setLayer() der Kartenstil auf den entsprechenden Raster- oder Vektor-Layer umgestellt. baselayers enthält die verfügbaren Kartenquellen.

const map = new maplibregl.Map({
container: "map",
center: [12, 50],
zoom: 6,
style: "https://tiles.versatiles.org/assets/styles/colorful/style.json",
});
class LayerTreeControl {
constructor(baselayers = []) {
this._baselayers = baselayers;
this._activeLayer = null;
}
onAdd(map) {
this._map = map;
this._container = document.createElement("div");
this._container.className =
"maplibregl-ctrl maplibregl-ctrl-group layer-tree-container";
this._form = this._buildForm();
this._container.appendChild(this._form);
return this._container;
}
_buildForm() {
const form = document.createElement("form");
form.className = "layer-tree-form";
const baseHeader = document.createElement("span");
baseHeader.textContent = "Basiskarten";
form.appendChild(baseHeader);
this._baselayers.forEach((layer, idx) => {
const id = `baselayer-${idx}`;
const item = this._createInputItem("radio", id, layer.name, "baselayer");
if (idx === 0) {
item.input.checked = true;
this._setLayer(layer);
}
item.input.addEventListener("change", () => {
if (item.input.checked) this._setLayer(layer);
});
form.append(item.container);
});
return form;
}
_createInputItem(type, id, labelText, name = null) {
const container = document.createElement("div");
container.className = "layer-tree-item";
const input = document.createElement("input");
input.type = type;
input.id = id;
input.value = labelText;
if (name) input.name = name;
const label = document.createElement("label");
label.setAttribute("for", id);
label.textContent = labelText;
container.append(input, label);
return { container, input, label };
}
_setLayer(layer) {
if (this._activeLayer === layer.name) return;
if (layer.type === "vector") {
this._map.setStyle(layer.url);
} else if (layer.type === "raster") {
this._map.setStyle({
version: 8,
sources: {
[layer.name]: {
type: "raster",
tiles: [layer.url],
tileSize: 256,
},
},
layers: [
{
id: layer.name,
source: layer.name,
type: "raster",
},
],
});
}
this._activeLayer = layer.name;
}
}
const baselayers = [
{
name: "osmde raster",
type: "raster",
url: "https://tile.openstreetmap.de/{z}/{x}/{y}.png",
},
{
name: "maplibre demotiles",
type: "vector",
url: "https://demotiles.maplibre.org/style.json",
},
{
name: "osm raster",
type: "raster",
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
},
{
name: "satellit",
type: "raster",
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
},
{
name: "osm vector",
type: "vector",
url: "https://pnorman.github.io/tilekiln-shortbread-demo/colorful.json",
},
];
map.on("load", () => {
const control = new LayerTreeControl(baselayers);
map.addControl(control, "top-right");
});

Die Konstante baselayers habe ich bewusst einfach gehalten. Hier können bei Bedarf weitere Informationen wie Quellenangaben oder ein Minimalzoom übergeben werden.

Das LayerTree-Control wird nach dem Laden der Karte oben rechts hinzugefügt. Ich habe das Hinzufügen in den Event-Handler map.on("load", …) gesetzt:

map.on("load", () => {
const control = new LayerTreeControl(baselayers, overlays);
map.addControl(control, "top-right");
});

Damit ist sichergestellt, dass der Map-Style vollständig geladen ist, bevor setStyle() oder addLayer() ausgeführt werden. So werden Overlays korrekt geladen und Race Conditions vermieden – also Situationen, in denen mehrere Prozesse gleichzeitig oder asynchron ablaufen. map.on("load", …) wird ausgelöst, sobald der Style vollständig geladen wurde – also nachdem die Basiskarte (Style, Quellen und Layer) initialisiert ist.

Von https://usergroups.openstreetmap.de/ habe ich die Daten zu OpenStreetMap-Usergroups und -Communities übernommen.

Eine einfache MapLibre-Karte mit eigenem Basemap-Layer-Switcher

class LayerTreeControl {
+ constructor(baselayers = [], overlays = []) {
this._baselayers = baselayers;
+ this._overlays = overlays;
this._activeLayer = null;
+ this._activeOverlays = new Set();
+ this._controllers = new Map();
...
+ if (this._overlays.length > 0) {
+ form.appendChild(document.createElement("hr"));
+
+ const overlayHeader = document.createElement("span");
+ overlayHeader.textContent = "Overlays";
+ form.appendChild(overlayHeader);
+ }
+
+ this._overlays.forEach((overlay, idx) => {
+ const id = `overlay-${idx}`;
+ const item = this._createInputItem("checkbox", id, overlay.name);
+
+ item.input.addEventListener("change", () => {
+ this._setOverlay(overlay, true, item.input.checked);
+ });
+
+ form.append(item.container);
+ });
+
...
+ const restoreOverlays = () => {
+ this._activeOverlays.forEach((name) => {
+ const overlay = this._overlays.find((o) => o.name === name);
+ if (overlay) this._setOverlay(overlay, false, true);
+ });
+ };
+
if (layer.type === "vector") {
+ this._map.once("styledata", restoreOverlays);
this._map.setStyle(layer.url);
} else if (layer.type === "raster") {
+ this._map.once("styledata", restoreOverlays);
this._map.setStyle({
version: 8,
sources: {
...
+ _setOverlay(overlay, _updateHash = true, visible = true) {
+ if (!visible) {
+ this._removeOverlay(overlay);
+ return;
+ }
+
+ const prevController = this._controllers.get(overlay.name);
+ if (prevController) prevController.abort();
+
+ const controller = new AbortController();
+ this._controllers.set(overlay.name, controller);
+
+ fetch(overlay.url, { signal: controller.signal })
+ .then((res) => res.json())
+ .then((data) => {
+ if (!data.features?.length) return;
+
+ if (!this._controllers.has(overlay.name)) return;
+
+ const geomType = data.features[0].geometry.type;
+ const layerStyle = this._getLayerStyle(overlay.name, geomType);
+
+ if (this._map.getSource(overlay.name)) {
+ this._map.removeLayer(overlay.name);
+ this._map.removeSource(overlay.name);
+ }
+
+ this._map.addSource(overlay.name, { type: "geojson", data });
+ this._map.addLayer(layerStyle);
+
+ this._activeOverlays.add(overlay.name);
+ })
+ .catch((err) => {
+ if (err.name !== "AbortError") console.error("Overlay-Fehler:", err);
+ });
+ }
+
+ _removeOverlay(overlay) {
+ if (this._map.getLayer(overlay.name)) this._map.removeLayer(overlay.name);
+ if (this._map.getSource(overlay.name)) this._map.removeSource(overlay.name);
+ this._activeOverlays.delete(overlay.name);
+
+ const controller = this._controllers.get(overlay.name);
+ if (controller) controller.abort();
+ this._controllers.delete(overlay.name);
+ }
+
+ _getLayerStyle(name, geomType) {
+ switch (geomType) {
+ case "Point":
+ return {
+ id: name,
+ type: "circle",
+ source: name,
+ paint: { "circle-radius": 6, "circle-color": "#007cbf" },
+ };
+ case "LineString":
+ case "MultiLineString":
+ return {
+ id: name,
+ type: "line",
+ source: name,
+ paint: { "line-color": "#007cbf", "line-width": 2 },
+ };
+ case "Polygon":
+ case "MultiPolygon":
+ return {
+ id: name,
+ type: "fill",
+ source: name,
+ paint: { "fill-color": "#007cbf", "fill-opacity": 0.4 },
+ };
+ default:
+ console.warn("Unbekannter Geometrietyp:", geomType);
+ return null;
+ }
+ }

Der Unterschied zwischen der ersten und dieser Version des Codes liegt im Wesentlichen darin, dass diese Version um Overlay-Funktionen erweitert wurde, während die erste Version nur Basiskarten unterstützt:

  • overlays-Array für zusätzliche Ebenen
  • this._activeOverlays → speichert aktive Overlay-Layer
  • this._controllers → speichert AbortController, um laufende Overlay-Fetches abbrechen zu können. Ohne diesen Code könnte es passieren, dass beim Laden von GeoJSON-Daten die Basiskarte gewechselt wird, wodurch die Daten nicht korrekt angezeigt oder zugeordnet werden.

Als Nächstes ist es mir wichtig, einen Permalink anbieten zu können – also einen Link, der beim Aufrufen auf einem anderen Gerät exakt dieselbe Kartenansicht zeigt, inklusive Standort, Zoom, ausgewähltem Basislayer und aktiven Overlays.

Eine einfache MapLibre-Karte mit eigenem Basemap-Layer-Switcher

Beim Laden des Formulars prüfe ich zunächst, ob in der URL bereits ein Layer oder Overlays gesetzt sind:

const hashLayer = this._getLayerFromHash();
const hashOverlays = this._getOverlaysFromHash();
  • _getLayerFromHash() liest den layer-Parameter aus dem URL-Hash.
  • _getOverlaysFromHash() liest die durch Kommas getrennte overlays-Liste aus.

Damit kann ich die vorherige Auswahl direkt beim Initialisieren wiederherstellen:

this._baselayers.forEach((layer, idx) => {
const item = this._createInputItem("radio", `baselayer-${idx}`, layer.name, "baselayer");
if ((hashLayer && hashLayer === layer.name) || (!hashLayer && idx === 0)) {
item.input.checked = true;
this._setLayer(layer, false); // false = Hash nicht erneut setzen
}
});

Für Overlays:

this._overlays.forEach((overlay, idx) => {
const item = this._createInputItem("checkbox", `overlay-${idx}`, overlay.name);
if (hashOverlays.includes(overlay.name)) {
item.input.checked = true;
this._setOverlay(overlay, false, true); // false = Hash nicht ändern
}
});

Jedes Mal, wenn der Nutzer einen Layer oder ein Overlay ändert, wird der Hash aktualisiert:

_updateLayerInHash(name) {
const params = this._getParams();
params.set("layer", name);
window.location.hash = params.toString();
}
_updateOverlaysInHash() {
const params = this._getParams();
if (this._activeOverlays.size) {
params.set("overlays", [...this._activeOverlays].join(","));
} else {
params.delete("overlays");
}
window.location.hash = params.toString();
}

[Beispielcode https://codeberg.org/astrid/demos_maplibre/src/branch/main/layertree_3) und Demo

Eigentlich sollten MapLibre-Plugins die Methoden onAdd und onRemove implementieren (siehe MapLibre GL JS API). In diesem Fall habe ich onRemove bewusst weggelassen, da ich das Plugin ausschließlich selbst nutze und es voraussichtlich nur hinzugefügt, aber nie entfernt wird.

Impressum

Astrid Günther
Dachsenhäuser Str. 46 e
56338 Braubach
Germany
E-Mail: info At astrid-guenther.de

Ich freu mich über Anfragen zu den von mir hier beschriebenen Themen und beantworte diese zeitnah!

Datenschutz

Ich erhebe oder speichere keine personenbezogenen Daten über diese Website. Um den Aufruf dieser Seite zu ermöglichen, speichert der Internet-Provider einige Daten in Server-Log-Files, die ein Browser automatisch weiterleitet: Browsertyp und Browserversion, verwendetes Betriebssystem, Referrer URL, Hostname des zugreifenden Rechners, Uhrzeit der Serveranfrage, IP-Adresse. Die Grundlage für die Datenverarbeitung ist Art. 6 Abs. 1 DSGVO, der die Verarbeitung von Daten zur Erfüllung eines Vertrags oder vorvertraglicher Maßnahmen erlaubt.