Building a custom draw mode for mapbox-gl-draw

Chris Whong
NYC Planning Tech
Published in
5 min readMar 1, 2018

When our apps need a web map, we turn to MapboxGL, a cutting-edge open source mapping library that allows us to build fast, beautiful maps. Not too long after we started tinkering with MapboxGL, we were faced with the need to let the user draw polygons. Enter mapbox-gl-draw, an add-on to MapboxGL that gives you some simple draw controls, renders shapes on the screen as the user draws a point, line, or polygon, and gives you some nice event hooks.

You can see mapbox-gl-draw in action in ZoLa’s measurement tool:

In this case, we’re still using the out-of-the-box functionality of the draw tools but have added a custom control to choose between line and polygon mode, and a UI for displaying the measurement (and switching units!).

We recently encountered a need to have the user select geometries on a web map by radius from a center point. Mapbox-gl-draw does not include a circle-drawing mode.

One approach would be to have the user select a center point, and then enter a radius using a text input, but it would be better if the user could see the circle they are defining as they define it, just like they can with the polygon and line tools.

What’s a radius, really? Well, it’s a line, and mapbox-gl-draw already has a line tool. So, what we need to do is modify the line tool with the following rules:

  1. The user’s first click is the center point
  2. As the user moves the pointer, render both the line between the center point and the pointer, and the circle that they are defining.
  3. The user’s second click always ends the drawing.
  4. When drawing is complete, the resulting GeoJSON is a Point feature for the center point, with a radius property.

Here’s what it looks like in our app:

Using the custom radius mode to select census tracts

How we built it

After some snooping, we discovered that mapbox-gl-draw allows you to define custom drawing modes, and has a nice markdown file all about it. We didn’t want to start from scratch, so we decided to hijack the Line mode.

const draw = new MapboxDraw({
displayControlsDefault: false,
styles: drawStyles,
modes: Object.assign({
draw_radius: RadiusMode,
}, MapboxDraw.modes),
});

We instantiate mapbox-gl-draw using a modes property in the options. RadiusMode is our new custom mode which is really just the built-in line mode that’s been modified to meet our needs.

To create RadiusMode, we started with importing the existing line tool.

const RadiusMode = MapboxDraw.modes.draw_line_string;

Then we define new functions for some of the line tool’s event listeners, in order to achieve the rules described above.

Rule #1 works out of the box: the first click will set the first vertex of the line.

For rule #2, we get half of the functionality for free because the line tool already draws the line between the first vertex and the pointer. To draw the circle, we actually end up drawing a circle-like polygon.

RadiusMode.toDisplayFeatures() is called with each move of the mouse, and fires display() for each GeoJSON feature that should be displayed in real time. The existing line tool displays the vertices and the line, so we added one more to display the circle-like polygon.

const circleFeature = createGeoJSONCircle(center, radiusInKm, state.line.id);display(circleFeature);

Creating the circle-like polygon was simple using an algorithm we found in this Stack Overflow post. Given a center point and radius in kilometers, it creates a 64-point polygon that resembles a circle.

function createGeoJSONCircle(center, radiusInKm, parentId, points = 64) {
const coords = {
latitude: center[1],
longitude: center[0],
};
const km = radiusInKm;const ret = [];
const distanceX = km / (111.320 * Math.cos((coords.latitude * Math.PI) / 180));
const distanceY = km / 110.574;
let theta;
let x;
let y;
for (let i = 0; i < points; i += 1) {
theta = (i / points) * (2 * Math.PI);
x = distanceX * Math.cos(theta);
y = distanceY * Math.sin(theta);
ret.push([coords.longitude + x, coords.latitude + y]);
}
ret.push(ret[0]);
return {
type: 'Feature',
geometry: {
type: 'Polygon',
coordinates: [ret],
},
properties: {
parent: parentId,
},
};
}

We had originally attempted to use a MapboxGL circle marker, but by using a circle-like polygon the feature still remains accurate even if the map is pitched!

For the best user experience when drawing a radius, we also added a label layer to the map showing the in-progress radius in both standard and metric formats. We borrowed the same logic for switching from meters to kilometers, and feet to miles as was used in ZoLa’s drawing tool.

The last step was to hijack RadiusMode.clickAnywhere() to force drawing to stop after the second click (rule #3 above). clickAnywhere(), as the name suggests, is called when the user clicks anywhere after drawing begins.

RadiusMode.clickAnywhere = function(state, e) {
// this ends the drawing after the user creates a second point, triggering this.onStop
if (state.currentVertexPosition === 1) {
state.line.addCoordinate(0, e.lngLat.lng, e.lngLat.lat);
return this.changeMode('simple_select', { featureIds: [state.line.id] });
}
...
// default click handling remains the same, adding the vertex to the state.
}

There’s a bit more to it, but those are the highlights. In our app, we use the GeoJSON Point feature created by the drawing tools to do an ST_Dwithin() query in PostGIS, selecting polygons that are within the specified distance of the center point.

You can inspect the full RadiusMode module here, and see it in action in our NYC Population Factfinder census data tool. We hope this will be a useful starting point for anyone thinking of extending a mapbox-gl-draw mode or writing their own.

Happy hacking!

--

--

Chris Whong
NYC Planning Tech

Urbanist, Mapmaker, & Data Junkie. Outreach Engineer at Qri.io