Zum Inhalt springen

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.

Mein Ausgangspunkt ist eine einfache Karte.

Eine einfache MapLibre-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.

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.

Eine einfache MapLibre-Karte mit Kreis bei Zoom 6

Eine einfache MapLibre-Karte mit Kreis bei Zoom 3

<!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“

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.

Eine einfache MapLibre-Karte mit circle-Layer-Punkt bei Zoom 13 Eine einfache MapLibre-Karte mit circle-Layer-Punkt bei Zoom 1

<!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.

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.

Eine einfache MapLibre-Karte mit metergenauem Kreis bei Zoom 13 Eine einfache MapLibre-Karte mit metergenauem Kreis bei Zoom 11

<!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.

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.

Eine einfache MapLibre-Karte mit metergenauem Kreis bei Zoom 13

<!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.

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.

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.

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.

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.

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“.

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.

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.