Search code examples
mapbox-gl-js

Drawing a circle with the radius in miles/meters with Mapbox GL JS


I'm in the process of converting a map from using mapbox.js to mapbox-gl.js, and am having trouble drawing a circle that uses miles or meters for its radius instead of pixels. This particular circle is used to show the area for distance in any direction from a central point.

Previously I was able to use the following, which was then added to a layer group:

// 500 miles = 804672 meters
L.circle(L.latLng(41.0804, -85.1392), 804672, {
    stroke: false,
    fill: true,
    fillOpacity: 0.6,
    fillColor: "#5b94c6",
    className: "circle_500"
});

The only documentation I've found to do this in Mapbox GL is the following:

map.addSource("source_circle_500", {
    "type": "geojson",
    "data": {
        "type": "FeatureCollection",
        "features": [{
            "type": "Feature",
            "geometry": {
                "type": "Point",
                "coordinates": [-85.1392, 41.0804]
            }
        }]
    }
});

map.addLayer({
    "id": "circle500",
    "type": "circle",
    "source": "source_circle_500",
    "layout": {
        "visibility": "none"
    },
    "paint": {
        "circle-radius": 804672,
        "circle-color": "#5b94c6",
        "circle-opacity": 0.6
    }
});

But this renders the circle in pixels, which does not scale with zoom. Is there currently a way with Mapbox GL to render a layer with a circle (or multiple) that's based on distance and scales with zoom?

I am currently using v0.19.0 of Mapbox GL.


Solution

  • Elaborating on Lucas' answer, I've come up with a way of estimating the parameters in order to draw a circle based on a certain metric size.

    The map supports zoom levels between 0 and 20. Let's say we define the radius as follows:

    "circle-radius": {
      stops: [
        [0, 0],
        [20, RADIUS]
      ],
      base: 2
    }
    

    The map is going to render the circle at all zoom levels since we defined a value for the smallest zoom level (0) and the largest (20). For all zoom levels in between it results in a radius of (approximately) RADIUS/2^(20-zoom). Thus, if we set RADIUS to the correct pixel size that matches our metric value, we get the correct radius for all zoom levels.

    So we're basically after a conversion factor that transforms meters to a pixel size at zoom level 20. Of course this factor depends on the latitude. If we measure the length of a horizontal line at the equator at the max zoom level 20 and divide by the number of pixels that this line spans, we get a factor ~0.075m/px (meters per pixel). Applying the mercator latitude scaling factor of 1 / cos(phi), we obtain the correct meter to pixel ratio for any latitude:

    const metersToPixelsAtMaxZoom = (meters, latitude) =>
      meters / 0.075 / Math.cos(latitude * Math.PI / 180)
    

    Thus, setting RADIUS to metersToPixelsAtMaxZoom(radiusInMeters, latitude) gets us a circle with the correct size:

    "circle-radius": {
      stops: [
        [0, 0],
        [20, metersToPixelsAtMaxZoom(radiusInMeters, latitude)]
      ],
      base: 2
    }