Zum Hauptinhalt springen

MapLibre mit WebGIS-Funktionen

· 30 Minuten Lesezeit

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 :)

Es ist ein Screenshot zu sehen, der den Bereich Westring / Eckernförder Straße / Geibelplatz in Kiel zeigt. Bei den Straßen sind die verschiedenen Typen des Straßenraums (Gehweg, Radweg, Fahrbahn etc.) farblich hervorgehoben. Die Gebäude und Bäume sind in 3D.

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:

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.

Es ist ein sehr nah reingezoomter Kartenausschnitt mit einfacher 3D-Darstellung von Bäumen dargestellt.

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:

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');

Export-Funktion

Die Exportfunktion von maplibre-gl-export erlaubt es, Kartenausschnitte in verschiedene Bild- und Dateiformaten zu exportieren. Die Seitengrößen, die beim Export angeboten werden, können im Code konfiguriert werden.

//////////// Export-Funktion hinzufügen ////////////
if (window.innerWidth > 768) {
map.addControl(new MaplibreExportControl.MaplibreExportControl({
PageSize: MaplibreExportControl.Size.A4,
PageOrientation: MaplibreExportControl.PageOrientation.Landscape,
Format: MaplibreExportControl.Format.PNG,
DPI: MaplibreExportControl.DPI[96],
Crosshair: true,
PrintableArea: true,
Local: 'de',
AllowedSizes:[
"A2", "A3", "A4", "A5", "A6",
],
Filename: 'Karte-GeoDatenGuerilla',
attributionOptions: {
'visibility': 'visible',
'position': 'bottom-right',
},
}),
'top-right'
);
}

Info-Button

Zuletzt ist noch ein Info-Button eingefügt. Wenn dieser grün leuchtet, ist er aktiviert. Wenn mensch dann auf eine Straßenfläche klickt, werden einem Infos über diese Fläche eingeblendet. Der Button ist so konfiguriert, dass beim Klicken in die Karte nur bestimmte Layer abgefragt und die Infos angezeigt werden. Gleichzeitig wird die angeklickte Fläche hervorgehoben. Der Code für diesen Button ist recht umfangreich, kommt aber ohne Plugins aus.

//////////// Pop-Up bei Klick auf Objekte hinzufügen ////////////
// Popup vorbereiten
let popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false });

// Highlight-Sources vorbereiten
map.on('load', () => {
map.addSource('highlight-feature', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});

// Highlight-Layer hinzufügen
map.addLayer({
id: 'highlight-line-outline',
type: 'line',
source: 'highlight-feature',
paint: {
'line-color': '#000000',
'line-width': 8,
'line-opacity': 0.8
},
filter: ['==', '$type', 'LineString']
});

map.addLayer({
id: 'highlight-line',
type: 'line',
source: 'highlight-feature',
paint: {
'line-color': '#ffff00',
'line-width': 6,
'line-opacity': 0.7
},
filter: ['==', '$type', 'LineString']
});

map.addLayer({
id: 'highlight-fill',
type: 'fill',
source: 'highlight-feature',
paint: {
'fill-color': '#ffff00',
'fill-opacity': 0.3
},
filter: ['==', '$type', 'Polygon']
});

map.addLayer({
id: 'highlight-fill-outline',
type: 'line',
source: 'highlight-feature',
paint: {
'line-color': '#000000',
'line-width': 2,
'line-opacity': 0.8
},
filter: ['==', '$type', 'Polygon']
});

map.addLayer({
id: 'highlight-point',
type: 'circle',
source: 'highlight-feature',
paint: {
'circle-radius': 10,
'circle-color': '#ffff00',
'circle-opacity': 0.6,
'circle-stroke-color': '#000000',
'circle-stroke-width': 2
},
filter: ['==', '$type', 'Point']
});
});

// Layerliste für Popups
const popupLayers = [
'Fahrbahn', 'Radweg', 'Fahrbahn / Bushaltefläche', 'Fahrbahn / Busspur',
'Fahrbahn / Radfahrstreifen', 'Fahrbahn / Schutzstreifen', 'Fahrbahn / Überwege',
'Fahrbahn Sperrfläche', 'Fahrradbügelfläche', 'Gehweg',
'Gehweg / Fahrgastwartefläche', 'Gehweg / Radfahrer frei', 'Gehweg / Überfahrt',
'Komb. Geh- und Radweg', 'Parkstreifen', 'Radweg / Überfahrt',
'Schutz- / Trennstreifen', 'Seitenraum', 'Seitenstreifen', 'Sonstiges',
'Sonstiges / Mauerwerk / Stützwände', 'Straßenbegleitgrün', 'Überfahrt / Zufahrt'
];

// Button für Popups
let popupEnabled = true;

class TogglePopupControl {
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.title = 'Popup-Funktion ein-/ausschalten';
button.style.display = 'flex';
button.style.alignItems = 'center';
button.style.justifyContent = 'center';

// SVGs vorbereiten
const iconActive = `
<svg xmlns="http://www.w3.org/2000/svg"
width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="8"></line>
</svg>`;

const iconInactive = `
<svg xmlns="http://www.w3.org/2000/svg"
width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="8"></line>
<line x1="4" y1="20" x2="20" y2="4" stroke="red"></line>
</svg>`;

// Funktion für Zustand wechseln
function updateButtonState() {
if (popupEnabled) {
button.innerHTML = iconActive;
button.style.backgroundColor = '#4CAF50';
button.style.color = '#fff';
} else {
button.innerHTML = iconInactive;
button.style.backgroundColor = '';
button.style.color = '';
}
}

// Initialzustand: aktiv
updateButtonState();

// Klick-Handler
button.addEventListener('click', () => {
popupEnabled = !popupEnabled;
updateButtonState();

if (!popupEnabled) {
// Popup + Highlight zurücksetzen
map.getSource('highlight-feature').setData({
type: 'FeatureCollection',
features: []
});
popup.remove();
}
});

this.container.appendChild(button);
return this.container;
}

onRemove() {
this.container.parentNode.removeChild(this.container);
this.map = undefined;
}
}

// Button hinzufügen
map.addControl(new TogglePopupControl(), 'top-right');

// Klick-Events
popupLayers.forEach(layerId => {
map.on('click', layerId, (e) => {
if (!popupEnabled) return; // Nur wenn aktiv

if (!e.features.length) return;
const feature = e.features[0];

// Highlight aktualisieren
map.getSource('highlight-feature').setData(feature);

// Popup-Inhalt dynamisch aus Properties bauen
let propsHtml = '<b>Eigenschaften:</b><br><ul>';
for (let key in feature.properties) {
propsHtml += `<li><b>${key}</b>: ${feature.properties[key]}</li>`;
}
propsHtml += '</ul>';

popup
.setLngLat(e.lngLat)
.setHTML(propsHtml)
.addTo(map);
});

// Cursor ändern bei Hover
map.on('mouseenter', layerId, () => {
if (popupEnabled) map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', layerId, () => {
if (popupEnabled) map.getCanvas().style.cursor = '';
});
});

// Klick irgendwo anders → nur wenn Popups aktiv
map.on('click', (e) => {
if (!popupEnabled) return;

const features = map.queryRenderedFeatures(e.point, { layers: popupLayers });
if (!features.length) {
map.getSource('highlight-feature').setData({
type: 'FeatureCollection',
features: []
});
popup.remove();
}
});

Fazit und Ausblick

Mit einer Mischung aus Plugins und zusätzlichem eigenen Code lässt sich MapLibre mit grundlegenden WebGIS-Funktionen ausstatten. Die hier vorgestellte fortgeschrittene Kartenanwendung zur Straßenraumaufteilung hat zusätzlich noch das besondere Feature, Straßenflächen selbst in die Karte zeichnen zu können. Damit lassen sich Straßenräume verändern und bspw. mit breiteren Geh- und Radwegen ausstatten. Das Ergebnis wird dann in der Kartenanwendung der*dem Nutzer*in visualisiert.

Ich bin allerdings mit der 3D-Darstellung in MapLibre nicht zufrieden. Es werden zwar 3D-Gebäude in der Karte angezeigt, aber der Straßenraum-Layer scheint durch die Gebäude hindurch. Darum möchte in Zukunft auch noch andere Bibliotheken wie Cesium ausprobieren und prüfen, wie sie die Darstellung händeln.

Der vollständige Code

Hier ist der vollständige Code für die Kartenanwendung zu finden:

<!DOCTYPE html>
<html lang="de">
<head>
<title>Straßenraumaufteilung in Kiel | GeoDatenGuerilla</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="img/gdg_favicon.ico" type="image/x-icon">

<!-- MapLibre GL JS: https://github.com/maplibre/maplibre-gl-js -->
<link rel="stylesheet" href="libs/maplibre-gl-4-7-1.css" />
<script src="libs/maplibre-gl-4-7-1.js"></script>

<!-- basemap.de 3D / Testbetrieb-Lizenz: https://basemap.de/produkte-und-dienste/3d/ -->
<script src="libs/Maplibre3DTiles.js"></script>

<!-- watergis maplibre-gl-area-switcher / MIT-Lizenz: https://github.com/watergis/maplibre-gl-area-switcher -->
<link href='libs/maplibre-gl-area-switcher.css' rel='stylesheet' />
<script src="libs/maplibre-gl-area-switcher.umd.js"></script>

<!-- watergis maplibre-gl-export / MIT-Lizenz: https://github.com/watergis/maplibre-gl-export -->
<link href="libs/maplibre-gl-export.css" rel="stylesheet" />
<script src="libs/maplibre-gl-export.umd.js"></script>

<!-- watergis maplibre-gl-legend / MIT-Lizenz: https://github.com/watergis/maplibre-gl-legend -->
<link href='libs/maplibre-gl-legend.css' rel='stylesheet' />
<script src="libs/maplibre-gl-legend.umd.js"></script>

<!-- watergis maplibre-gl-terradraw / MIT-Lizenz: https://github.com/watergis/maplibre-gl-terradraw -->
<link rel="stylesheet" href="libs/maplibre-gl-terradraw.css" />
<script src="libs/maplibre-gl-terradraw.umd.js"></script>

<!-- JamesLMilner terra-draw / MIT-Lizenz: https://github.com/JamesLMilner/terra-draw -->
<script src="libs/terra-draw.umd.js"></script>

<!-- jdsantos maplibre-gl-measures / MIT-Lizenz: https://github.com/jdsantos/maplibre-gl-measures -->
<script src='libs/maplibre-gl-measures.js'></script>
<style>
body { margin: 0; padding: 0; }

/* Roboto VariableFont einbinden */
@font-face {
font-family: 'RobotoVariable';
src: url('/fonts/Roboto-VariableFont.woff2') format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}

@media (max-width: 768px) {
#topbar { height: 45px; }
#bottombar { height: 35px; font-size: 14px; }
#topbar-links { display: none; } /* Links ausblenden */
#menu-toggle { display: block; } /* z.B. Burger-Icon zeigen */
}

/* Einheitliche Schrift für UI-Elemente */
#topbar,
#bottombar {
font-family: 'Segoe UI', RobotoVariable, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}

/* Fokus für Tastaturen */
button:focus, a:focus {
outline: 2px solid #1976d2; /* Blau oder rot passend zum Design */
outline-offset: 2px;
}

/* Topbar */
#topbar {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 60px;
background-color: white;
z-index: 999;
}

#topbar-inner {
height: 32px;
margin: 14px 17px;
display: flex;
align-items: center;
}

#topbar-links {
display: flex;
align-items: center;
margin-left: 20px; /* Abstand zwischen Logo und erster Link */
}

#topbar-links a {
margin-left: 15px; /* Abstand zwischen den Links */
text-decoration: none;
color: black;
font-weight: 500;
font-size: 16px;
transition: color 0.2s;
}

#topbar-links a:hover {
color: red;
}

#logo-text-link {
display: flex;
align-items: center;
height: 100%;
text-decoration: none;
color: black;
font-size: 18px;
font-weight: bold;
}

#topbar-logo {
height: 100%;
display: block;
}

#site-name-text {
margin-left: 10px;
}

#logo-text-link:hover {
color: red;
}

/* Burger-Button Standard: versteckt */
#menu-toggle {
display: none;
font-size: 24px;
background: none;
border: none;
cursor: pointer;
margin-left: auto;
}

/* Mobile-Ansicht */
@media (max-width: 768px) {
#topbar-links {
display: none;
position: absolute;
top: 60px; /* direkt unter der Topbar */
left: 0;
width: 100%;
background: white;
flex-direction: column;
padding: 10px 0;
border-top: 1px solid #ddd;
z-index: 1000;
}

#topbar-links a {
padding: 12px 20px;
margin: 0;
font-size: 16px;
border-bottom: 1px solid #eee;
}

#menu-toggle {
display: block;
}

/* Wenn das Menü aktiv ist */
#topbar-links.active {
display: flex;
}
}

/* Bottombar */
#bottombar {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 40px;
background-color: #303846;
color: white;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
}

#bottombar a {
color: white;
text-decoration: none;
font-weight: bold;
margin: 0 15px;
transition: color 0.2s;
}

#bottombar a:hover {
color: red;
}

/* Farbauswahlmenü für maplibre-gl-terradraw --- */
:root {
--menu-padding: 8px;
--menu-radius: 8px;
--menu-gap: 6px;
--circle-size: 18px;
--menu-shadow: 0 2px 10px rgba(0,0,0,0.12);
--border: #e9e9e9;
}

#colorMenu {
position: absolute;
top: 70px;
right: 50px;
padding: var(--menu-padding);
background: #fff;
border-radius: var(--menu-radius);
border: 1px solid #ccc;
box-shadow: var(--menu-shadow);
font-family: 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
z-index: 2000;
}

/* einfache Möglichkeit, Menü zu verstecken */
#colorMenu[hidden] { display: none; }

/* Header */
#colorMenuHeader {
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
padding-bottom:8px;
border-bottom:1px solid var(--border);
margin-bottom:8px;
font-weight:600;
}

/* Schließen-Button */
#colorMenuClose {
border: none;
background: transparent;
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 2px 6px;
}
#colorMenuClose:hover { opacity: 0.8; }

/* Farben-Liste */
#colorList {
display:flex;
flex-direction:column;
gap: var(--menu-gap);
}

/* Jede Option ist ein Button (Keyboard + Screenreader ok) */
.color-option {
display:flex;
align-items:center;
gap:10px;
padding:6px;
border-radius:6px;
background:transparent;
border: none;
text-align:left;
cursor: pointer;
}
.color-option:hover,
.color-option:focus {
background: #f6f6f6;
outline: none;
}

/* Kreise */
.color-circle {
width: var(--circle-size);
height: var(--circle-size);
border-radius:50%;
box-shadow: 0 0 3px rgba(0,0,0,0.2);
flex-shrink:0;
}

/* Label */
.color-label { flex:1; }

/* Opacity-Block */
#opacityControl {
margin-top:10px;
padding-top:8px;
border-top:1px solid var(--border);
display:flex;
align-items:center;
gap:10px;
justify-content:space-between;
}
#opacitySlider { width:140px; }
#opacityValue { min-width:36px; text-align:right; font-family:monospace; }

#map {
position: absolute;
top: 60px;
bottom: 40px;
width: 100%;
}

.mapboxgl-popup {
max-width: 300px;
font: 12px/1.5 sans-serif;
}
</style>
</head>
<body>
<!-- Topbar mit innerer Box -->
<div id="topbar">
<div id="topbar-inner">
<a href="https://geodaten-guerilla.net/" target="_blank" id="logo-text-link">
<img src="img/gdg_logo_ohne_text.svg"
alt="Logo GeoDatenGuerilla und Link zur Webseite"
id="topbar-logo">
<span id="site-name-text">GeoDatenGuerilla</span>
</a>

<!-- Hamburger-Menü -->
<button id="menu-toggle" aria-expanded="false" aria-controls="topbar-links" aria-label="Menü öffnen">☰</button>

<!-- Link-Container für die Links in der TopBar -->
<nav id="topbar-links">
<a href="https://geodaten-guerilla.net/karten" target="_blank">Karten</a>
<a href="https://geodaten-guerilla.net/blog" target="_blank">Blog</a>
<a href="https://geodaten-guerilla.net/mitmachen-kontakt" target="_blank">Mitmachen und Kontakt</a>
<a href="https://geodaten-guerilla.net/docs/intro" target="_blank">Dokumentation</a>
<a href="https://geodaten-guerilla.net/downloads/" target="_blank">Downloads</a>
</nav>
</div>
</div>

<!-- Map -->
<div id="map"></div>

<!-- Farbauswahl-Menü maplibre-gl-terradraw -->
<div id="colorMenu" hidden>
<div id="colorMenuHeader">
<span>Füllstil</span>
<button id="colorMenuClose" aria-label="Menü schließen" title="Menü schließen">&times;</button>
</div>
<!-- Farbbuttons -->
<div id="colorList" role="radiogroup" aria-label="Füllfarbe auswählen"></div>
<!-- Opacity-Slider -->
<div id="opacityControl">
<label for="opacitySlider">Transparenz</label>
<div style="display:flex;align-items:center;gap:8px;">
<input id="opacitySlider" type="range" min="0" max="1" step="0.1" value="1">
<span id="opacityValue">1.0</span>
</div>
</div>
</div>

<!-- Bottombar -->
<div id="bottombar">
<a href="https://geodaten-guerilla.net/impressum" target="_blank">Impressum</a>
<a href="https://geodaten-guerilla.net/datenschutzerklaerung" target="_blank">Datenschutz</a>
<a href="https://climatejustice.social/@GeoDatenGuerilla" target="_blank">Mastodon</a>
</div>

<script>
//////////// Hamburger-Menü ////////////
const menuToggle = document.getElementById("menu-toggle");
const topbarLinks = document.getElementById("topbar-links");

menuToggle.addEventListener("click", () => {
const isActive = topbarLinks.classList.toggle("active");
menuToggle.setAttribute("aria-expanded", isActive);
});

//////////// 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;
}
});

//////////// 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');
}
})

//////////// 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'
);

//////////// 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 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');

//////////// Draw-Tool einfügen ////////////
// Cache für Styles (Farbe + Opacity pro Feature)
const colorCache = {};

// 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 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');

//////////// Export-Funktion hinzufügen ////////////
if (window.innerWidth > 768) {
map.addControl(new MaplibreExportControl.MaplibreExportControl({
PageSize: MaplibreExportControl.Size.A4,
PageOrientation: MaplibreExportControl.PageOrientation.Landscape,
Format: MaplibreExportControl.Format.PNG,
DPI: MaplibreExportControl.DPI[96],
Crosshair: true,
PrintableArea: true,
Local: 'de',
AllowedSizes:[
"A2", "A3", "A4", "A5", "A6",
],
Filename: 'Karte-GeoDatenGuerilla',
attributionOptions: {
'visibility': 'visible',
'position': 'bottom-right',
},
}),
'top-right' // Position des Export-Buttons
);
}

//////////// 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
);

//////////// Pop-Up bei Klick auf Objekte hinzufügen ////////////
// Popup vorbereiten
let popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false });

// Highlight-Sources vorbereiten
map.on('load', () => {
map.addSource('highlight-feature', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] }
});

// Highlight-Layer hinzufügen
map.addLayer({
id: 'highlight-line-outline',
type: 'line',
source: 'highlight-feature',
paint: {
'line-color': '#000000',
'line-width': 8,
'line-opacity': 0.8
},
filter: ['==', '$type', 'LineString']
});

map.addLayer({
id: 'highlight-line',
type: 'line',
source: 'highlight-feature',
paint: {
'line-color': '#ffff00',
'line-width': 6,
'line-opacity': 0.7
},
filter: ['==', '$type', 'LineString']
});

map.addLayer({
id: 'highlight-fill',
type: 'fill',
source: 'highlight-feature',
paint: {
'fill-color': '#ffff00',
'fill-opacity': 0.3
},
filter: ['==', '$type', 'Polygon']
});

map.addLayer({
id: 'highlight-fill-outline',
type: 'line',
source: 'highlight-feature',
paint: {
'line-color': '#000000',
'line-width': 2,
'line-opacity': 0.8
},
filter: ['==', '$type', 'Polygon']
});

map.addLayer({
id: 'highlight-point',
type: 'circle',
source: 'highlight-feature',
paint: {
'circle-radius': 10,
'circle-color': '#ffff00',
'circle-opacity': 0.6,
'circle-stroke-color': '#000000',
'circle-stroke-width': 2
},
filter: ['==', '$type', 'Point']
});
});

// Layerliste für Popups
const popupLayers = [
'Fahrbahn', 'Radweg', 'Fahrbahn / Bushaltefläche', 'Fahrbahn / Busspur',
'Fahrbahn / Radfahrstreifen', 'Fahrbahn / Schutzstreifen', 'Fahrbahn / Überwege',
'Fahrbahn Sperrfläche', 'Fahrradbügelfläche', 'Gehweg',
'Gehweg / Fahrgastwartefläche', 'Gehweg / Radfahrer frei', 'Gehweg / Überfahrt',
'Komb. Geh- und Radweg', 'Parkstreifen', 'Radweg / Überfahrt',
'Schutz- / Trennstreifen', 'Seitenraum', 'Seitenstreifen', 'Sonstiges',
'Sonstiges / Mauerwerk / Stützwände', 'Straßenbegleitgrün', 'Überfahrt / Zufahrt'
];

// Button für Popups
let popupEnabled = true;

class TogglePopupControl {
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.title = 'Popup-Funktion ein-/ausschalten';
button.style.display = 'flex';
button.style.alignItems = 'center';
button.style.justifyContent = 'center';

// SVGs vorbereiten
const iconActive = `
<svg xmlns="http://www.w3.org/2000/svg"
width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="8"></line>
</svg>`;

const iconInactive = `
<svg xmlns="http://www.w3.org/2000/svg"
width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="16" x2="12" y2="12"></line>
<line x1="12" y1="8" x2="12" y2="8"></line>
<line x1="4" y1="20" x2="20" y2="4" stroke="red"></line>
</svg>`;

// Funktion für Zustand wechseln
function updateButtonState() {
if (popupEnabled) {
button.innerHTML = iconActive;
button.style.backgroundColor = '#4CAF50';
button.style.color = '#fff';
} else {
button.innerHTML = iconInactive;
button.style.backgroundColor = '';
button.style.color = '';
}
}

// Initialzustand: aktiv
updateButtonState();

// Klick-Handler
button.addEventListener('click', () => {
popupEnabled = !popupEnabled;
updateButtonState();

if (!popupEnabled) {
// Popup + Highlight zurücksetzen
map.getSource('highlight-feature').setData({
type: 'FeatureCollection',
features: []
});
popup.remove();
}
});

this.container.appendChild(button);
return this.container;
}

onRemove() {
this.container.parentNode.removeChild(this.container);
this.map = undefined;
}
}
// Button hinzufügen
map.addControl(new TogglePopupControl(), 'top-right');

// Klick-Events
popupLayers.forEach(layerId => {
map.on('click', layerId, (e) => {
if (!popupEnabled) return; // Nur wenn aktiv

if (!e.features.length) return;
const feature = e.features[0];

// Highlight aktualisieren
map.getSource('highlight-feature').setData(feature);

// Popup-Inhalt dynamisch aus Properties bauen
let propsHtml = '<b>Eigenschaften:</b><br><ul>';
for (let key in feature.properties) {
propsHtml += `<li><b>${key}</b>: ${feature.properties[key]}</li>`;
}
propsHtml += '</ul>';

popup
.setLngLat(e.lngLat)
.setHTML(propsHtml)
.addTo(map);
});

// Cursor ändern bei Hover
map.on('mouseenter', layerId, () => {
if (popupEnabled) map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', layerId, () => {
if (popupEnabled) map.getCanvas().style.cursor = '';
});
});

// Klick irgendwo anders → nur wenn Popups aktiv
map.on('click', (e) => {
if (!popupEnabled) return;

const features = map.queryRenderedFeatures(e.point, { layers: popupLayers });
if (!features.length) {
map.getSource('highlight-feature').setData({
type: 'FeatureCollection',
features: []
});
popup.remove();
}
});
</script>
</body>
</html>