MapLibre mit WebGIS-Funktionen
Vor einem Jahr habe ich gezeigt, wie sich mit MapLibre und Open Data eine 3D-Karte mit der Straßenraumaufteilung in Kiel erstellen lässt. Die Online-Karte hat sich seitdem stark weiterentwickelt. Statt grauer Boxen als Gebäude (LoD1) und Straßendaten eingebunden als langsamer WMS-Dienst bietet die Karte nun bessere 3D-Visualisierungen der Gebäude (LoD2), Mess- und Malwerkzeuge, Druckmöglichkeiten, eine Legende, Druckfunktionen, Bäume und eine bessere Performance durch VectorTiles. Damit bietet die Karte nun die grundlegenden Funktionen, die mensch von einem WebGIS erwartet. Durch das hinzugefügten Malwerkzeug lassen sich nun auch Straßenflächen in die Karte zeichnen, wodurch sich veränderte Straßenräume visualisieren lassen.
Auf der Code-Basis der Straßenraumkarte habe ich die A20-Karte, ohne 3D-Funktionalität, von Mapbender auf MapLibre umgestellt. DIe Karte zur Südspange in Kiel habe ich in dem Zuge deaktiviert, da die Planungen an diesem unsinnigem Autobahnprojekt weiter vorangeschritten sind und die Südspange dadurch vorerst gestorben ist (leider nicht auch der A21-Ausbau im Vieburger Gehölz).
In diesem Blogpost möchte ich am Beispiel der Straßenraumkarte den Code für eine MapLibre-Karte mit den entsprechenden Daten, 3D-Funktionen, Mess- und Malwerkzeug, Druckmöglichkeit, Legende und Druckfunktion vorstellen. Außerdem zeige ich, welche Datengrundlagen und welche Software für die Karte benötigt werden. Natürlich komplett Open Data und Open Source :)

Datengrundlagen
Für eine Karte braucht es Geodaten. Um die Straßenraumkarte in ihrem derzeitigen Stand zu reproduzieren werden folgende Geodaten benötigt:
- Die Straßenraumaufteilung in Kiel
- Bäume in Kiel
- 3D-Gebäude
- eine Basemap
Die Straßenraumaufteilung und die Bäume in Kiel lassen sich von der Stadt Kiel beschaffen. Im Schleswig-Holsteinischen Metainformationssystem (SH-MIS) finden sich die Geodaten, die die Stadt Kiel als Open Data zur Verfügung stellt. In den Geodaten der Stadt Kiel finden sich, neben den einzelnen Datensätzen, sowohl ein WMS- als auch ein WFS-Dienst mit sämtlichen Datensätzen. Ich nutze aus dem WFS-Dienst (https://ims.kiel.de/geodatenextern/services/Stadtplan/LHKielWmsWfs/MapServer/WFSServer?request=GetCapabilities&service=WFS) die Datensätze Strassenbestandsflaechen für die Straßenraumaufteilung und Baeume für die Bäume. Zu letzterem sei gesagt, dass es sich hierbei nur um erfasste Bäume auf städtischem Grund handelt. Die Daten stehen unter der Lizenz CC BY 4.0. Zur Datenqualität der Straßenraumaufteilung habe ich bereits im letzten Blogpost eine Einschätzung abgegeben. Auch wie ich die Datensätze in VectorTiles konvertierte habe ich bereits beschrieben.
Sowohl die Basemap als auch die 3D-Gebäude beziehe ich aus der selben Quelle. Die basemap.de enthält nämlich beides. Es gibt sogar Code-Beispiele für funktionierende 3D-Karten mit MapLibre und Cesium. Ich habe den Code für LoD2-MapLibre als Ausgangspunkt für die Kartenerstellung genutzt.
Software
Um die Kartenanwendung zu betreiben, werden drei Komponenten benötigt:
- mbtileserver als Tileserver
- maputnik zur Erstellung von MapLibre-Styles
- MapLibre für die eigentliche Karte
Die Nutzung von mbtileserver habe ich bereits beschrieben. In mbtileserver lade ich die VectorTiles der Straßenraumaufteilung und der Bäume. Mit Maputnik erstelle ich nun einen Style, der die basemap.de, die Straßenraumaufteilung und die Bäume enthält (dieser lässt sich hier herunterladen). Diese Herangehensweise spart Code, da so nicht jeder einzelne Layer im Code eingebunden und gestyled werden muss. Die Bedienung von Maputnik habe ich auch bereits angeschnitten. Bei der Darstellung der Bäume habe ich mich eines Tricks bedient, da mir keine 3D-Modelle zur Verfügung standen.

Ich habe mit QGIS einen dünnen Buffer für die Stämme und einen breiten Buffer für die Kronen erstellt und in VectorTiles konvertiert. In Maputnik gebe ich beiden eine Extrusion und setze die Krone in die Luft. So entsteht die Illusion eines 3D-Objekts.
Mit MapLibre wird die eigentliche Kartenanwendung erstellt. Um die zusätzlichen Funktionen, wie Legende, Export oder Messungen, umzusetzen, nutze ich mehrere Plugins:
- maplibre-gl-area-switcher um zu vordefinierten Gebieten springen zu können.
- maplibre-gl-export um Druck-/Exportfunktion anzubieten.
- maplibre-gl-legend für eine Legende und um Layer ein-/auszuschalten.
- maplibre-gl-terradraw und terra-draw, um eine Zeichnenfunktion zu ermöglichen.
- maplibre-gl-measures ist ein Messwerkzeug.
Den Code mit den Plugins zeige ich unten.
Der Code
Ich werde an dieser Stelle einzelne Teile aus dem Code der Kartenanwendung vorstellen. Der gesamte Code befindet sich am Ende des Blogposts. In diesem befinden sich nämlich auch das CSS für die Gestaltung der Kartenanwendung und Funktionen für eine Mobilansicht, mit denen ich nicht große Lücken in den Fließtext reißen möchte. Zur Transparenz möchte ich noch anmerken, dass ich für die Code-Erstellung auf KI zurückgegriffen habe. Mir ist KI beim coden eine sehr große Hilfe. Mir sind die negativen Folgen von KI bewusst.
Die Grundfunktionen
Folgender Code-Schnipsel ermöglicht bereits die Darstellung einer grundlegenden Kartenanwendung. In dieser lässt sich scrollen, reinzoomen und der Blickwinkel ändern. Die 3D-Inhalte werden auch angezeigt. Oben werden Grenzen definiert, in denen sich der Kartenausschnitt bewegen lässt. Unter style ist der oben beschriebene Maputnik-Stil eingefügt. Dadurch wird Code gespart, da nicht jeder einzelne Layer im Code eingebunden wird. Unten werden noch Navigationsbuttons und eine dynamische Maßstableiste hinzugefügt. Die 3D-Gebäude werden auf dem selben Weg eingebunden, wie es auf basemap.de beschrieben wird.
//////////// Basemap einbinden und Karte generieren ////////////
// Grenzen, innerhalb derer die Karte verschoben werden kann, festlegen
const bounds = [
[9.44955, 53.7632],
[10.7106, 54.6175]
];
// Basemap und Karte laden
const map = new maplibregl.Map({
container: 'map',
style: 'tiles/strassenraumaufteilung-basemap.json',
center: [10.131885, 54.315275],
zoom: 14,
maxPitch: 80,
attributionControl: true,
hash: true,
maxBounds: bounds,
preserveDrawingBuffer: true,
});
//////////// 3D-Gebäude hinzufügen ////////////
if (window.innerWidth > 768) {
map.on('style.load', function () {
let lod2_layer = new Mapbox3DTiles.Mapbox3DTilesLayer({
id: 'lod2_building',
url: 'https://sg.geodatenzentrum.de/gdz_basemapde_3d_gebaeude/lod2_3857_null.json',
colorWall: '#c2c2c2',
colorRoof: '#ff5c4d',
colorBridge: '#999999'
});
map.addLayer(lod2_layer, 'Gebaeude3D_nicht_oeffentlich');
map.setLayoutProperty('Gebaeude3D_oeffentlich', 'visibility', 'none');
map.setLayoutProperty('Gebaeude3D_nicht_oeffentlich', 'visibility', 'none');
map.setLayoutProperty('Hauskoordinate', 'visibility', 'none');
});
}
// Gebäude erst ab Zoom 16 anzeigen
let lod2Visible = false;
map.on('zoomend', () => {
const zoom = map.getZoom();
const shouldBeVisible = zoom >= 16;
if (shouldBeVisible !== lod2Visible) {
map.setLayoutProperty('lod2_building', 'visibility', shouldBeVisible ? 'visible' : 'none');
lod2Visible = shouldBeVisible;
}
});
//////////// Vollbild-Button hinzufügen ////////////
map.addControl(new maplibregl.FullscreenControl(), 'top-right',);
//////////// Navigationsbuttons hinzufügen ////////////
map.addControl(new maplibregl.NavigationControl({
visualizePitch: true,
visualizeRoll: true,
showZoom: true,
showCompass: true,
zoomInTitle: 'Hineinzoomen',
zoomOutTitle: 'Herauszoomen',
compassTitle: 'Nach Norden ausrichten'
}),
'top-right'
);
//////////// Maßstableiste hinzufügen ////////////
map.addControl(
new maplibregl.ScaleControl({
maxWidth: 150, // maximale Breite in Pixel
unit: 'metric' // 'imperial' für Meilen/Fuß
}),
'bottom-left' // Position der Maßstableiste
);
Legende
Mit dem Plugin maplibre-gl-legend wird eine Legende eingefügt, mit der sich auch einzelne Layer ein- und ausschalten lassen. Wichtig ist, die in der Legende anzuzeigenden Layer zu definieren. Denn allein durch die basemap.de sind technisch gesehen sehr viele Layer in der Karte untergebracht. Die Layernamen sind diejenigen, die in Maputnik definiert wurden.
//////////// Legende hinzufügen ////////////
// Legenden-Einträge definieren
map.on('load', function() {
const targets = {
// Layername: Anzeigename
'Baumstämme': 'Baumstämme',
'Baumkronen': 'Baumkronen',
'Fahrbahn': 'Fahrbahn',
'Radweg': 'Radweg',
'Fahrbahn / Bushaltefläche': 'Fahrbahn / Bushaltefläche',
'Fahrbahn / Busspur': 'Fahrbahn / Busspur',
'Fahrbahn / Radfahrstreifen': 'Fahrbahn / Radfahrstreifen',
'Fahrbahn / Schutzstreifen': 'Fahrbahn / Schutzstreifen',
'Fahrbahn / Überwege': 'Fahrbahn / Überwege',
'Fahrbahn Sperrfläche': 'Fahrbahn Sperrfläche',
'Fahrradbügelfläche': 'Fahrradbügelfläche',
'Gehweg': 'Gehweg',
'Gehweg / Fahrgastwartefläche': 'Gehweg / Fahrgastwartefläche',
'Gehweg / Radfahrer frei': 'Gehweg / Radfahrer frei',
'Gehweg / Überfahrt': 'Gehweg / Überfahrt',
'Komb. Geh- und Radweg': 'Komb. Geh- und Radweg',
'Parkstreifen': 'Parkstreifen',
'Radweg / Überfahrt': 'Radweg / Überfahrt',
'Schutz- / Trennstreifen': 'Schutz- / Trennstreifen',
'Seitenraum': 'Seitenraum',
'Seitenstreifen': 'Seitenstreifen',
'Sonstiges': 'Sonstiges',
'Sonstiges / Mauerwerk / Stützwände': 'Sonstiges / Mauerwerk / Stützwände',
'Straßenbegleitgrün': 'Straßenbegleitgrün',
'Überfahrt / Zufahrt': 'Überfahrt / Zufahrt',
};
// Legende-Button hinzufügen
if (window.innerWidth > 768) {
map.addControl(new MaplibreLegendControl.MaplibreLegendControl(targets, {
showDefault: false,
showCheckbox: true,
onlyRendered: true,
reverseOrder: true,
title: 'Legende',
}), 'top-right');
}
})
3D-/2D-Ansicht-Button
Für einen Button, der zwischen 3D- und 2D-Ansicht wechselt, braucht es kein Zusatzplugin, aber viel Code. Wie sinnvoll ein solcher Button ist, ist tatsächlich fraglich, da sich mit der rechten Maustauste bereits der Blickwinkel ändern lässt. Aber der Button leuchtet grün, wenn mensch auf "3D" klickt und ist ein guter Hinweis bzw. Werkzeug für alle, die nicht wissen, dass sich mit der rechten Maustaste der Blickwinkel ändern lässt.
//////////// 3D-/2D-Ansicht-Button hinzufügen ////////////
// Custom-Control-Klasse mit 2D/3D-Umschaltung
class Toggle3DControl {
onAdd(map) {
this.map = map;
this.container = document.createElement('div');
this.container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
const button = document.createElement('button');
button.type = 'button';
button.textContent = '3D'; // Startmodus
button.title = '3D-Ansicht aktivieren';
button.style.fontWeight = 'bold';
button.style.transition = 'background-color 0.2s ease, color 0.2s ease';
let is3D = false;
button.addEventListener('click', () => {
is3D = !is3D;
if (is3D) {
// Cineastischer Wechsel zu 3D
map.easeTo({
pitch: 60,
bearing: -17.6,
duration: 1500, // sanfte Animation
easing: t => t*(2-t) // smoother easing
});
button.textContent = '2D';
button.title = '2D-Ansicht aktivieren';
button.style.backgroundColor = '#4CAF50';
button.style.color = '#fff';
} else {
// Zurück zu 2D
map.easeTo({
pitch: 0,
bearing: 0,
duration: 1500,
easing: t => t*(2-t)
});
button.textContent = '3D';
button.title = '3D-Ansicht aktivieren';
button.style.backgroundColor = '';
button.style.color = '';
}
});
this.container.appendChild(button);
return this.container;
}
onRemove() {
this.container.parentNode.removeChild(this.container);
this.map = undefined;
}
}
// 3D-Button hinzufügen
if (window.innerWidth > 768) {
map.addControl(new Toggle3DControl(), 'top-right');
}
Messwerkzeug
Mit dem Messwerkzeug lassen sich Entfernungen und Flächen in der Karte messen. Genutzt wird das Plugin maplibre-gl-measures.
//////////// Messwerkzeug hinzufügen ////////////
map.addControl(new maplibreGLMeasures.default({
lang: {
areaMeasurementButtonTitle: 'Fläche messen',
lengthMeasurementButtonTitle: 'Entfernung messen',
clearMeasurementsButtonTitle: 'Messungen löschen',
},
units: 'metric',
unitsGroupingSeparator: '.',
style: {
text: {
radialOffset: 0.9,
letterSpacing: 0.05,
color: '#0000FF',
haloColor: '#fff',
haloWidth: 4,
font: 'Open Sans Bold',
},
common: {
midPointRadius: 6,
midPointColor: '#0000FF',
midPointHaloRadius: 10,
midPointHaloColor: '#FFF',
},
areaMeasurement: {
fillColor: '#6969E6',
fillOutlineColor: '#0000FF',
fillOpacity: 0.2,
lineWidth: 3,
},
lengthMeasurement: {
lineWidth: 4,
lineColor: "#0000FF",
},
}
}), 'top-right');
Zeichnen-Tool
Das Tool, um Flächen in die Karte zu zeichnen, ist recht umfangreich. Denn es sollen nicht nur einfach Flächen eingezeichnet werden können, sondern diese sollen auch die Farben der verschiedenen Straßenräume haben können, also bspw. Fahrbahn, Gehweg oder Radweg. Das wird ermöglich, indem beim Aufruf der Flächenzeichnung ein Auswahlmenü erscheint. So lassen sich in der Kartendarstellung umgestaltete Straßenräume visualisieren. Ich nutze dafür die Plugins maplibre-gl-terradraw und terra-draw. Die Erstellung von Kreisen und Linien ist zwar auch aktiviert, aber nicht mit einem Farbauswahl-Menü versehen.
// Konfiguration von maplibre-gl-terradraw
const draw = new MaplibreTerradrawControl.MaplibreTerradrawControl({
modes: [
'render', // comment this to always show drawing tool
'point',
'linestring',
'polygon',
//'rectangle',
'circle',
//'freehand',
//'angled-rectangle',
//'sensor',
//'sector',
'select',
'delete-selection',
'delete',
'download'
],
open: false,
modeOptions: {
point: new terraDraw.TerraDrawPointMode({
styles: {
pointColor: '#FF0000',
pointWidth: 3,
pointOutlineColor: '#FF0000',
pointOutlineWidth: 1
}
}),
linestring: new terraDraw.TerraDrawLineStringMode({
styles: {
lineStringColor: '#FF0000',
lineStringWidth: 2,
closingPointColor: '#FFFFFF',
closingPointWidth: 3,
closingPointOutlineColor: '#FF0000',
closingPointOutlineWidth: 1
}
}),
polygon: new terraDraw.TerraDrawPolygonMode({
styles: {
// fillColor und fillOpacity werden über ein Farbmenü eingestellt
fillColor: ({ id }) => {
if (!colorCache[id]) {
showColorMenu(id);
return "#000000";
}
return colorCache[id].color;
},
fillOpacity: ({ id }) => {
return colorCache[id]?.opacity ?? 1.0;
},
//outlineColor: '#FF0000',
outlineWidth: 0,
closingPointColor: '#FAFAFA',
closingPointWidth: 3,
closingPointOutlineColor: '#FF0000',
closingPointOutlineWidth: 1
}
}),
circle: new terraDraw.TerraDrawCircleMode({
styles: {
fillColor: '#F5AEAE',
outlineColor: '#FF0000',
outlineWidth: 2,
fillOpacity: 0.7,
}
})
}
});
// Draw-Werkzeug wird hinzugefügt
if (window.innerWidth > 768) {
map.addControl(draw, 'top-right');
}
// Farben und Kategorien für das Farbauswahlmenü
const palette = [
{ color: "#464646", label: "Fahrbahn" },
{ color: "#0000FF", label: "Radweg" },
{ color: "#6E0000", label: "Fahrbahn / Bushaltefläche" },
{ color: "#FF7B7B", label: "Fahrbahn / Busspur" },
{ color: "#56EDD1", label: "Fahrbahn / Radfahrstreifen" },
{ color: "#0DC773", label: "Fahrbahn / Schutzstreifen" },
{ color: "#696D53", label: "Fahrbahn / Überwege" },
{ color: "#9F9F9F", label: "Fahrbahn Sperrfläche" },
{ color: "#7B7BB9", label: "Fahrradbügelfläche" },
{ color: "#E6DD79", label: "Gehweg" },
{ color: "#FF0000", label: "Gehweg / Fahrgastwartefläche" },
{ color: "#E09524", label: "Gehweg / Radfahrer frei" },
{ color: "#F9DB81", label: "Komb. Geh- und Radweg" },
{ color: "#F14D97", label: "Parkstreifen" },
{ color: "#313179", label: "Radweg / Überfahrt" },
{ color: "#9A6ABE", label: "Schutz- / Trennstreifen" },
{ color: "#52917E", label: "Seitenraum" },
{ color: "#A4A4A4", label: "Seitenstreifen" },
{ color: "#8E8E8E", label: "Sonstiges" },
{ color: "#7A7A88", label: "Sonstiges / Mauerwerk / Stützwände" },
{ color: "#94D180", label: "Straßenbegleitgrün" },
{ color: "#91AA23", label: "Überfahrt / Zufahrt" },
];
// state
let activeMenuFeatureId = null;
const dismissedMenus = new Set(); // optional: wenn Nutzer*in X drückt -> nicht wieder automatisch öffnen
// DOM refs
const menu = document.getElementById("colorMenu");
const colorList = document.getElementById("colorList");
const slider = document.getElementById("opacitySlider");
const valueLabel = document.getElementById("opacityValue");
const closeBtn = document.getElementById("colorMenuClose");
// Buttons bauen
function buildColorMenu() {
colorList.innerHTML = "";
for (const entry of palette) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "color-option";
btn.dataset.color = entry.color;
btn.innerHTML = `
<span class="color-circle" style="background:${entry.color}"></span>
<span class="color-label">${entry.label}</span>
`;
// optional: aria
btn.setAttribute("aria-label", `${entry.label} (${entry.color})`);
colorList.appendChild(btn);
}
}
buildColorMenu(); // einmal aufbauen
// Farbe mit Klick auswählen
colorList.addEventListener("click", (ev) => {
const btn = ev.target.closest(".color-option");
if (!btn) return;
if (!activeMenuFeatureId) return;
const selectedColor = btn.dataset.color;
if (!colorCache[activeMenuFeatureId]) colorCache[activeMenuFeatureId] = { color: selectedColor, opacity: 1.0 };
colorCache[activeMenuFeatureId].color = selectedColor;
draw.updateStyles();
});
// Opacity-Slider
slider.addEventListener("input", () => {
const v = parseFloat(slider.value);
valueLabel.textContent = v.toFixed(1);
if (activeMenuFeatureId) {
if (!colorCache[activeMenuFeatureId]) colorCache[activeMenuFeatureId] = { color: palette[0].color, opacity: v };
colorCache[activeMenuFeatureId].opacity = v;
draw.updateStyles();
}
});
function showColorMenu(featureId) {
if (dismissedMenus.has(featureId)) return;
activeMenuFeatureId = featureId;
if (!colorCache[featureId]) colorCache[featureId] = { color: palette[0].color, opacity: 1.0 };
slider.value = colorCache[featureId].opacity;
valueLabel.textContent = parseFloat(slider.value).toFixed(1);
menu.removeAttribute("hidden");
// optional: focus first button for accessibility
const first = colorList.querySelector(".color-option");
if (first) first.focus();
}
function hideColorMenu() {
activeMenuFeatureId = null;
menu.setAttribute("hidden", "");
}
// Close button (speichert dismiss-Einstellung)
closeBtn.addEventListener("click", () => {
if (activeMenuFeatureId) dismissedMenus.add(activeMenuFeatureId);
hideColorMenu();
});
// ESC schließt das Menü ebenfalls
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { if (activeMenuFeatureId) dismissedMenus.add(activeMenuFeatureId); hideColorMenu(); } });
// Optional: verberge Menü, wenn Draw-Mode gewechselt wird
if (draw && typeof draw.on === "function") {
draw.on("modechange", (event) => {
if (event.mode !== "polygon") hideColorMenu();
});
}
Area-Switcher
Mit dem Plugin maplibre-gl-area-switcher lassen Punkte definieren, wohin der Kartenausschnitt springen soll. So lassen sich mehrere Points of Interests hinterlegen, die besonders interessant erscheinen. Die Konfiguration ist mit Name, Koordinaten und Zoomstufe sehr zugänglich.
//////////// Area-Switcher einfügen ////////////
map.addControl(new MaplibreAreaSwitcherControl([
{title: "Holtenauer Straße",latlng: [10.131849, 54.331142],zoom: 18},
{title: "Andreas-Gayk-Straße",latlng: [10.133646, 54.3190029],zoom: 18},
{title: "Friedrichsorter Straße",latlng: [10.1729513, 54.3958302],zoom: 18},
{title: "Seefischmarkt",latlng: [10.180681, 54.325865],zoom: 18},
{title: "Gaarden-Ost",latlng: [10.145751, 54.31152],zoom: 16},
]), 'top-right');
