OpenStreetMap logo OpenStreetMap

Drawing Circles on Digital Maps

Posted by aselnigu on 14 September 2025 in English. Last updated on 20 September 2025.

You can find a German version of this article here: Kreise in MapLibre

I want to integrate a Geolocate control into a MapLibre map and customize it both visually and functionally to fit my app.

At first, I considered using the GeolocateControl that comes standard with MapLibre. However, I quickly discarded this approach because adapting it to my needs seemed too cumbersome without a lot of fiddling.

My goal is that when the button is clicked, the display of the current location is toggled—so it can be turned on and off.

MapLibre itself offers several ways to draw circles on the map, depending on whether they should be pixel-accurate or meter-accurate.

Circles on a MapLibre Map

My starting point is a simple map.

A simple map

<!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",
});

This code embeds a simple MapLibre map.

The <div id="map"> serves as the container, while CSS and JS are loaded from the CDN. In the JavaScript, the map is initialized with a center at [12, 50], a zoom level of 6, and a style from tiles.versatiles.org. Using hash: "map" ensures that the current view is preserved in the URL.

Using Turf.js

The official MapLibre-GL-JS documentation demonstrates how to create a meter-accurate circle as a polygon using Turf.js and display it on the map.

The images below show that the circle is meter-accurate: its size adjusts according to the map’s zoom level. In the first image, the map is set to zoom 6, and in the second, zoom 3. The circle at zoom 3 appears much smaller than at zoom 6, reflecting the larger map area.

Circle at Zoom 6 Circle at 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,
    },
  });
});

Compared to the original example, Turf.js has been included to create a meter-accurate circle around the map center. The circle is added as a GeoJSON source (map.addSource) and displayed using two layers: a filled circle (fill) and a black outline (line).

Without Additional Plugins or Third-Party Software

Independent of Geometry or Distance

MapLibre itself provides circle layers (type: "circle"), which can be used to display circles. However, these are pixel-based circles, meaning their size in meters changes with the zoom level. For example, a marker can be displayed as a circular point on the map.

The images below illustrate that the circle is not meter-accurate but pixel-accurate: its size does not scale with the zoom level. In the first image, the map is set to zoom 13, and in the second, zoom 1. Both circles appear the same size on the screen, even though the map view covers vastly different areas.

Circle Layer Zoom 13 Circle Layer 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,
    },
  });
});

Compared to the second example, Turf.js has been removed, and the circle is now displayed as a pixel-accurate point using a circle layer instead of a meter-accurate polygon. There is only a single layer based on a GeoJSON source.

Dependent on Geometry or Distance

The idea: I create a GeoJSON polygon that approximates the circle—for example, using 64 points. Essentially, a polygon that looks like a circle.

I remember calculating such distances manually once. During one of our first geocaching trips—when our GPS device couldn’t yet compute coordinates like “255 meters at 25°”—I made such a calculation by hand.

The images below again show that the circle is meter-accurate: its size adjusts according to the map’s zoom level. In the first image, the map is set to zoom 13, and in the second, zoom 11. The circle at zoom 11 is significantly smaller than at zoom 13, reflecting the larger map area.

A Simple MapLibre Map with a Meter-Accurate Circle at Zoom 13 A Simple MapLibre Map with a Meter-Accurate Circle at 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,
    },
  });
});

Compared to Example 3, the “circle” is once again displayed as a meter-accurate polygon, calculated using a custom createCircle function. In addition, there are now two layers: a filled circle (fill) and an outline (line).

The calculation based on the Haversine formula and the projection of meters into WGS84 coordinates is the correctest one - see Follow Up at the end of this text. The Haversine formula computes the shortest path between two points on the Earth’s surface—the great-circle distance, also known as an “orthodrome”. Movable Type Scripts – “Calculate distance, bearing and more between Latitude/Longitude points” is another useful resource on spherical trigonometry. I use earthRadius = 6,378,137 m, although alternatives exist.

With this approach, I can draw a meter-accurate circle on a MapLibre map without plugins, requiring only about 25 additional lines of code.

My Geolocate Button for MapLibre

Since I want to display accuracy, the circle layer alone is not sufficient. I opted for the custom createCircle function instead of importing Turf.js.

Geolocate Button

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

A new button (“Position setzen”) uses the browser’s geolocation API. When clicked, it retrieves the current position, pans the map to that location, and draws two circles: a small one for the main position and a larger one for the accuracy. Both circles are automatically removed after a short time.

Open Source

MapLibre is open source, and in general I think it’s great to contribute features when something you need is missing. Often, others benefit as well, which in turn motivates them to further improve the software—that’s the real strength of open source. Still, any addition should truly be useful and reasonably balanced against the effort required.

With the MapLibre Geolocate Control, it’s possible to enable tracking as an option—but I don’t personally need it. Once tracking is enabled, however, it quickly becomes complicated in my view to decide which layer should be shown or hidden depending on the current state. For this reason, I don’t consider it worthwhile to extend the existing control in this case.

Follow-up

I focused too much on MapLibre and the geolocation workflow and didn’t handle my snippets with the math formulas properly. I am grateful for the command, as I have now taken another closer look at everything.

I compared the formula used above with this one:

function createCorrectCircle(center, radiusInMeters, steps = 64) {
  const coords = [];
  const [lon, lat] = center;

  for (let i = 0; i < steps; i++) {
    const angle = (((i * 360) / steps) * Math.PI) / 180;
    const drad = radiusInMeters / earthRadius;
    const lat_rad = (lat * Math.PI) / 180;
    const lon_rad = (lon * Math.PI) / 180;

    let newLat = Math.asin(
      Math.sin(lat_rad) * Math.cos(drad) +
        Math.cos(lat_rad) * Math.sin(drad) * Math.cos(angle),
    );
    let newLon =
      lon_rad +
      Math.atan2(
        Math.sin(angle) * Math.sin(drad) * Math.cos(lat_rad),
        Math.cos(drad) - Math.sin(lat_rad) * Math.sin(newLat),
      );
    newLat = (newLat * 180) / Math.PI;
    newLon = (newLon * 180) / Math.PI;

    coords.push([newLon, newLat]);
  }
  coords.push(coords[0]);
  return coords;
}

Short distances (30 kilometres)

The error in my simplified formula is not immediately noticeable for short distances. I drew a slightly larger yellow circle with my simplified formula and a blue circle with the more accurate formula. If I calculated correctly, the largest deviation is 0.03235 kilometres.

3000 kilometre distance

For longer distances, and especially in the polar regions, the errors are clearly visible. At 3,000 kilometres, the largest deviation is −387.06285 kilometres.

I think the next image explains the differences. My simplified formula calculates the distance on a flat plane. In the north-south direction, it is simply divided by the Earth’s radius – that works. In the east-west direction, it is divided by the Earth’s radius times the cosine of the latitude what taking into account that the longitudes become narrower towards the pole. As a result, the yellow dots in the northern hemisphere are too close together at the top.

With lat = -50 everything looks mirror-inverted.

However, the correct blue circle is still not displayed properly round like a circle. I believe this is due to the map projection used by MapLibre. If I understand correctly, the Haversine formula is used to calculate the correct circle on the three-dimensional Earth. However, MapLibre displays the Earth in two dimensions. Shapes that are ‘round’ on the sphere appear stretched or distorted on the map.

MapLibre Globe View

The MapLibre Globe View style: ‘https://demotiles.maplibre.org/globe.json’, allows maps to be displayed not only flat, but also on a 3D sphere, i.e. as a ‘globe’.

However,

even the Haversine formula has its limitations. It assumes a perfect sphere. However, the Earth is not a perfect sphere. More accurate formulas take this into account.

Email icon Bluesky Icon Facebook Icon LinkedIn Icon Mastodon Icon Telegram Icon X Icon

Discussion

Comment from imagico on 15 September 2025 at 13:22

While it is nice to see practical instructions involving basic mathematics are given, this is, unfortunately, so wrong on the mathematics that i don’t want to let this stand uncommented.

Drawing an accurate circle on a map is hard - others have struggled that before. Turf.js does it right but the code shown here does not and does not provide meter-accuracy in the general case.

Here a python code verifying the accuracy of the circle by calculating the distances of its outline points to the center - with the inaccurate version in projected coordinates shown here and the more accurate version based on what turf.js does.

#!/usr/bin/env python3

from math import *

lon = 12
lat = 50

earthRadius = 6378137

radiusInMeters = 100000

steps = 64

# https://stackoverflow.com/questions/4913349/haversine-formula-in-python-bearing-and-distance-between-two-gps-points

def haversine(lon1, lat1, lon2, lat2):
    """
    Calculate the great circle distance in kilometers between two points 
    on the earth (specified in decimal degrees)
    """
    # convert decimal degrees to radians 
    lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2])

    # haversine formula 
    dlon = lon2 - lon1 
    dlat = lat2 - lat1 
    a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
    c = 2 * asin(sqrt(a)) 
    r = earthRadius
    return c * r

print ('== incorrect ==')

for i in range(steps):

    angle = (((i * 360.0) / steps) * pi) / 180

    dx = radiusInMeters * cos(angle);
    dy = radiusInMeters * sin(angle);

    newLon = lon + (dx / (earthRadius * cos((lat * pi) / 180))) * (180 / pi)
    newLat = lat + (dy / earthRadius) * (180 / pi);

    print (haversine(lon, lat, newLon, newLat))


print ('== correct ==')

for i in range(steps):

    angle = (((i * 360.0) / steps) * pi) / 180
    
    drad = radiusInMeters/earthRadius
    lat_rad = lat * pi / 180
    lon_rad = lon * pi / 180

    newLat = asin(sin(lat_rad) * cos(drad) + cos(lat_rad) * sin(drad) * cos(angle))
    newLon = lon_rad + atan2( sin(angle) * sin(drad) * cos(lat_rad), cos(drad) - sin(lat_rad) * sin(newLat) )

    newLat = newLat * (180 / pi)
    newLon = newLon * (180 / pi)

    print (haversine(lon, lat, newLon, newLat))

Comment from aselnigu on 18 September 2025 at 14:10

Thank you very much for reading, and for pointing out the error in particular. I am always happy to learn more. I have just added a ‘Follow-Up’ section to the end of the text, and I hope it is OK.

Log in to leave a comment