Kreise in MapLibre
Ich möchte auf einer MapLibre-Karte ein Geolocate-Control integrieren und dieses sowohl farblich als auch funktionell an meine App anpassen.
Zunächst überlege ich, das GeolocateControl zu verwenden, das MapLibre standardmäßig mitbringt. Allerdings habe ich diesen Ansatz schnell verworfen, da es mir zu aufwendig erscheint, dieses Control an meine Bedürfnisse ohne zu frickeln anzupassen.
Mein Ziel ist es, dass beim Klick auf den Button die Anzeige der aktuellen Position getoggelt wird – also ein- und ausgeschaltet werden kann.
MapLibre selbst bietet mehrere Möglichkeiten, Kreise auf der Karte darzustellen – je nachdem, ob diese pixelgenau oder metergenau sein sollen.
Kreise auf einer MapLibre-Karte
Abschnitt betitelt „Kreise auf einer MapLibre-Karte“Mein Ausgangspunkt ist eine einfache Karte.

<!DOCTYPE html><html lang="de"> <head> <title>Demo 1</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=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> <style> body { margin: 0; padding: 0; } html, body, #map { height: 100%; } </style> </head> <body> <div id="map"></div>
<script type="module" src="index.js"></script> </body></html>const _map = new maplibregl.Map({ container: "map", hash: "map", center: [12, 50], zoom: 6, style: "https://tiles.versatiles.org/assets/styles/colorful/style.json",});Dieser Code bindet eine einfache MapLibre-Karte ein.
Das <div id="map"> dient als Container, CSS und JS kommen aus dem CDN. Im JS wird die Karte mit Mittelpunkt [12, 50], Zoom 6 und dem Style von tiles.versatiles.org erstellt. Mit hash: "map" bleibt die aktuelle Ansicht in der URL erhalten.
Mithilfe von Turf.js
Abschnitt betitelt „Mithilfe von Turf.js“Die offizielle MapLibre-GL-JS-Dokumentation zeigt, wie man einen metergenauen Kreis als Polygon oder Vieleck mit Turf.js erzeugt und auf der Karte darstellt.
Die nachfolgenden Bilder zeigen, dass der Kreis metergenau ist: Die Größe des Kreises passt sich dem Zoomlevel der Karte an. Im ersten Bild ist die Karte auf Zoom 6 eingestellt, im zweiten auf Zoom 3. Der Kreis bei Zoom 3 ist deutlich kleiner als bei Zoom 6 – entsprechend dem größeren Kartenausschnitt.


<!DOCTYPE html><html lang="de"> <head> <title>Demo 2</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=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> <style> body { margin: 0; padding: 0; } html, body, #map { height: 100%; } </style> </head> <body> <script src="https://cdn.jsdelivr.net/npm/@turf/turf@7/turf.min.js" ></script>
<div id="map"></div>
<script type="module" src="index.js"></script> </body></html>const radiusCenter = [12, 50];const map = new maplibregl.Map({ container: "map", hash: "map", center: radiusCenter, zoom: 6, style: "https://tiles.versatiles.org/assets/styles/colorful/style.json",});
map.on("load", () => { const radius = 100; const options = { steps: 64, units: "kilometers", }; const circle = turf.circle(radiusCenter, radius, options);
map.addSource("location-radius", { type: "geojson", data: circle, });
map.addLayer({ id: "location-radius", type: "fill", source: "location-radius", paint: { "fill-color": "#7ebc6f", "fill-opacity": 1, }, });
map.addLayer({ id: "location-radius-outline", type: "line", source: "location-radius", paint: { "line-color": "#000000", "line-width": 13, }, });});Im Vergleich zum Ausgangsbeispiel wurde Turf.js eingebunden, um einen metergenauen Kreis um das Kartenzentrum zu erzeugen. Der Kreis wird als GeoJSON-Source (map.addSource) hinzugefügt und mit zwei Layern angezeigt: ein gefüllter Kreis (fill) und eine schwarze Umrandung (line).
Ohne zusätzliche Plugins oder Drittanbieter-Software
Abschnitt betitelt „Ohne zusätzliche Plugins oder Drittanbieter-Software“Unabhängig von Geometrie oder Distanz
Abschnitt betitelt „Unabhängig von Geometrie oder Distanz“MapLibre selbst bringt circle-Layer mit (type: "circle"). Damit kann man Kreise darstellen – allerdings sind das Kreise in Pixeln, deren Meterbereich sich mit dem Zoom ändert. Beispielsweise ein Marker, der als runder Punkt eine Position markiert.
Die nachfolgenden Bilder zeigen, dass der Kreis nicht metergenau ist, sondern pixelgenau: Die Größe des Kreises verändert sich nicht analog dem Zoomlevel. Im ersten Bild ist die Karte auf Zoom 13 eingestellt, im zweiten auf Zoom 1. Beide Kreise sind gleich groß, obwohl der Kartenausschnitt sich verändert hat.

<!DOCTYPE html><html lang="de"> <head> <title>Demo 3</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=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> <style> body { margin: 0; padding: 0; } html, body, #map { height: 100%; } </style> </head> <body> <div id="map"></div>
<script type="module" src="index.js"></script> </body></html>const radiusCenter = [12, 50];const map = new maplibregl.Map({ container: "map", hash: "map", center: radiusCenter, zoom: 6, style: "https://tiles.versatiles.org/assets/styles/colorful/style.json",});
map.on("load", () => { map.addSource("point", { type: "geojson", data: { type: "Feature", geometry: { type: "Point", coordinates: radiusCenter, }, }, });
map.addLayer({ id: "circle-layer", type: "circle", source: "point", paint: { "circle-radius": 20, "circle-color": "#7ebc6f", "circle-opacity": 1, }, });});Im Vergleich zum zweiten Beispiel wurde Turf.js entfernt und der Kreis wird nun als pixelgenauer Punkt (circle-Layer) dargestellt, statt als metergenaues Polygon. Es gibt nur einen Layer auf Basis einer GeoJSON-Source.
Abhängig von Geometrie oder Distanz
Abschnitt betitelt „Abhängig von Geometrie oder Distanz“Die Idee: Ich erzeugen ein GeoJSON-Polygon, das den Kreis approximiert - zum Beispiel mit 64 Punkten. Also ein Polygon, das wie ein Kreis aussieht.
Ich erinnere mich, dass ich eine solche Entfernungen schon einmal berechnet habe. Bei einem unserer ersten Geocaches – als unser GPS-Gerät noch keine Koordinate für “255 Metern in 25°” berechnen konnte – habe ich einmal von Hand eine solche Berechnung durchgeführt.
Die nachfolgenden Bilder zeigen wieder, dass der Kreis metergenau ist: Die Größe des Kreises passt sich dem Zoomlevel der Karte an. Im ersten Bild ist die Karte auf Zoom 13 eingestellt, im zweiten auf Zoom 11. Der Kreis bei Zoom 11 ist deutlich kleiner als bei Zoom 13 – entsprechend dem größeren Kartenausschnitt.

<!DOCTYPE html><html lang="de"> <head> <title>Demo 4</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=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> <style> body { margin: 0; padding: 0; } html, body, #map { height: 100%; } </style> </head> <body> <div id="map"></div>
<script type="module" src="index.js"></script> </body></html>const radiusCenter = [12, 50];const map = new maplibregl.Map({ container: "map", hash: "map", center: radiusCenter, zoom: 13, style: "https://tiles.versatiles.org/assets/styles/colorful/style.json",});
function createCircle(center, radiusInMeters, steps = 64) { const coords = []; const [lon, lat] = center; const earthRadius = 6378137;
for (let i = 0; i < steps; i++) { const angle = (((i * 360) / steps) * Math.PI) / 180;
const dx = radiusInMeters * Math.cos(angle); const dy = radiusInMeters * Math.sin(angle);
const newLon = lon + (dx / (earthRadius * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI); const newLat = lat + (dy / earthRadius) * (180 / Math.PI);
coords.push([newLon, newLat]); } coords.push(coords[0]); return { type: "Feature", geometry: { type: "Polygon", coordinates: [coords], }, };}
map.on("load", () => { const circle = createCircle(radiusCenter, 100);
map.addSource("circle", { type: "geojson", data: circle, });
map.addLayer({ id: "circle-layer", type: "fill", source: "circle", paint: { "fill-color": "#7ebc6f", "fill-opacity": 1, }, });
map.addLayer({ id: "circle-outline", type: "line", source: "circle", paint: { "line-color": "#ffffff", "line-width": 2, }, });});Im Vergleich zu Beispiel 3 wird der “Kreis” wieder als metergenaues Polygon dargestellt, das mittels einer eigenen Funktion (createCircle) berechnet wird. Zusätzlich gibt es nun zwei Layer: einen gefüllten Kreis (fill) und eine Umrandung (line).
So kann ich mit auf einer MapLibre Karte ohne Plugins nur mit 25 zusätzlichern Zeilen Code einen metergenauen Kreis zeichnen.
Mein Geolocate Button für MapLibre
Abschnitt betitelt „Mein Geolocate Button für MapLibre“Ich möchte gerne die Genauigkeit anzeigen, deshalb reicht mir der circle-Layer nicht. Ich entscheide mich für die “ungenaue” createCircle-Funktion und gegen den Import von Turf.js.

<!DOCTYPE html><html lang="de"> <head> <title>Demo 5</title> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=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> <style> body { margin: 0; padding: 0; } html, body, #map { height: 100%; } </style> </head> <body> <button id="btn">Position setzen</button> <div id="map"></div>
<script type="module" src="index.js"></script> </body></html>const radiusCenter = [12, 50];const map = new maplibregl.Map({ container: "map", hash: "map", center: radiusCenter, zoom: 13, style: "https://tiles.versatiles.org/assets/styles/colorful/style.json",});
function createCircle(center, radiusInMeters, steps = 64) { const coords = []; const [lon, lat] = center; const earthRadius = 6378137;
for (let i = 0; i < steps; i++) { const angle = (((i * 360) / steps) * Math.PI) / 180;
const dx = radiusInMeters * Math.cos(angle); const dy = radiusInMeters * Math.sin(angle);
const newLon = lon + (dx / (earthRadius * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI); const newLat = lat + (dy / earthRadius) * (180 / Math.PI);
coords.push([newLon, newLat]); } coords.push(coords[0]); return { type: "Feature", geometry: { type: "Polygon", coordinates: [coords], }, };}
document.getElementById("btn").addEventListener("click", () => { if (!navigator.geolocation) { alert("Geolocation wird von Ihrem Browser nicht unterstützt."); return; }
navigator.geolocation.getCurrentPosition( (position) => { const lon = position.coords.longitude; const lat = position.coords.latitude; map.flyTo({ center: [lon, lat], zoom: 15 }); addGPSCircle(position); }, (err) => { alert(`Fehler beim Abrufen der Position: ${err.message}`); }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }, );});
function addGPSCircle(position) { const lon = position.coords.longitude; const lat = position.coords.latitude; const accuracy = position.coords.accuracy;
const mainCircle = createCircle([lon, lat], accuracy / 10); const accuracyCircle = createCircle([lon, lat], accuracy);
["main-circle", "accuracy-circle"].forEach((id) => { if (map.getLayer(id)) map.removeLayer(id); if (map.getSource(id)) map.removeSource(id); });
map.addSource("main-circle", { type: "geojson", data: mainCircle }); map.addLayer({ id: "main-circle", type: "fill", source: "main-circle", paint: { "fill-color": "#7ebc6f", "fill-opacity": 1 }, });
map.addSource("accuracy-circle", { type: "geojson", data: accuracyCircle }); map.addLayer({ id: "accuracy-circle", type: "fill", source: "accuracy-circle", paint: { "fill-color": "#7ebc6f", "fill-opacity": 0.5 }, });
setTimeout(() => { if (map.getLayer("main-circle")) map.removeLayer("main-circle"); if (map.getSource("main-circle")) map.removeSource("main-circle"); }, 10000);
setTimeout(() => { if (map.getLayer("accuracy-circle")) map.removeLayer("accuracy-circle"); if (map.getSource("accuracy-circle")) map.removeSource("accuracy-circle"); }, 5000);
map.flyTo({ center: [lon, lat], zoom: 10 });}Neu ist ein Button („Position setzen“), der die Browser-Geolocation nutzt. Beim Klick wird die aktuelle Position ermittelt, die Karte dorthin verschoben und zwei Kreise gezeichnet. Ein kleiner Kreis für die Hauptposition und ein größerer Kreis für die Genauigkeit. Beide Kreise werden nach kurzer Zeit automatisch wieder entfernt.
Open-Source
Abschnitt betitelt „Open-Source“MapLibre ist Open Source, und grundsätzlich finde ich es super, Projekte zu ergänzen, wenn einem Funktionen fehlen, die man selbst braucht. Oft profitieren auch andere davon, die dann wiederum motiviert sind, die Software weiter zu verbessern – genau das ist der Vorteil von Open Source. Dennoch sollte eine Ergänzung wirklich sinnvoll sein und in einem angemessenen Verhältnis zum Aufwand stehen.
Beim Geolocate-Control von MapLibre kann man optional Tracking aktivieren – ich selbst benötige das nicht. Mit aktiviertem Tracking wird es jedoch in meinen Augen schnell kompliziert zu entscheiden, welcher Layer bei welchem Status auf der Karte angezeigt oder ausgeblendet werden soll. Deshalb halte ich es hier nicht für sinnvoll, das bestehende Control zu erweitern.
Nachtrag
Abschnitt betitelt „Nachtrag“Ich habe mich zu sehr auf MapLibre und den Geolocation-Workflow konzentriert und meine Snippets mit den Formeln nicht ordentlich genug behandelt. Ich bin dankbar für den Hinweis, da ich mir nun alles noch einmal genauer angesehen habe.
Geringe Distanzen (30 Kilometer)
Abschnitt betitelt „Geringe Distanzen (30 Kilometer)“Der Fehler in meiner vereinfachten Formel fällt bei geringen Distanzen nicht sofort auf. Ich habe einen etwas größeren gelben Kreis mit meiner vereinfachten Formel und einen blauen Kreis mit der korrekteren Formel gezeichnet. Wenn ich richtig gerechnet habe, beträgt die größte Abweichung 0,03235 Kilometer.

3000 Kilometer Distanz
Abschnitt betitelt „3000 Kilometer Distanz“Bei größeren Distanzen und besonders im Bereich der Pole sind die Fehler deutlich erkennbar. Bei 3.000 Kilometern beträgt die größte Abweichung −387,06285 Kilometer.
Ich finde, das Bild erklärt die Unterschiede sehr gut. Meine vereinfachte Formel berechnet die Distanz auf einer Ebene. In Nord-Süd-Richtung wird einfach durch den Erdradius geteilt – das passt. In Ost-West-Richtung wird durch den Erdradius mal Cosinus des Breitengrads geteilt, wobei berücksichtigt wird, dass die Längengrade zum Pol hin enger werden. Dadurch sind die gelben Punkte auf der Nordhalbkugel nach oben hin zu eng.

Mit const lat = -50; sieht alles spiegelverkehrt aus.

Der korrekte blaue Kreis wird aber auch noch nicht richtig rund angezeigt. Ich glaube, das liegt an der Kartenprojektion, die MapLibre verwendet. Wenn ich es richtig verstehe, wird mit der Haversine-Formel der korrekte Kreis auf der dreidimensionalen Erde berechnet. MapLibre zeigt die Erde jedoch zweidimensional an. Formen, die auf der Kugel „rund“ sind, sehen auf der Karte gestreckt oder verzerrt aus.
MapLibre Globe View
Abschnitt betitelt „MapLibre Globe View“Die MapLibre Globe View style: "https://demotiles.maplibre.org/globe.json", ermöglicht die Darstellung von Karten nicht nur flach, sondern auf einer 3D-Kugel, also als „Globus“.

Allerdings
Abschnitt betitelt „Allerdings“hat auch die Haversine-Formel ihre Grenzen. Sie geht von einer perfekten Kugel aus. Die Erde ist jedoch keine perfekte Kugel. Genauere Formeln berücksichtigen dies.