Using Map Bearings and Trigonometry to Style Custom Mapbox GL Draw Tools
By: Taylor Mcginnis
Tags: Design, Ember, Mapbox, Engineering, Nyc Planning Labs

As a team that builds tools for the often-complicated world of Urban Planning in NYC, we run into a number of unique engineering challenges typically related to web mapping. For our newest application, Applicant Maps, we built our own custom draw tools through combining mapbox-gl-draw line and symbol layers. Users are able to draw five different “annotations” on their project map — such as our Parallel Measurement tool, which we created by placing a custom symbol on both ends of a line.

Our Parallel Measurement Tool which consists of a line and symbols on both ends

Symbol layers in Mapbox GL are point/marker layers on which developers can define their own icon image. Our custom arrow symbols ➤ are PNG files we created specifically for our annotations. We set the location of the arrows to match the coordinates of the line. We then rotate the arrows using the bearing of the line, lineBearing , which is the angle of a line from true north.

Here is our symbol layer, startArrowLayer , which was placed at the first coordinate of our line.

const { coordinates } = lineFeature.geometry;

const lineBearing = bearing(coordinates[0], coordinates[1]); const startArrowLayer = {

type: 'symbol',

source: {

type: 'geojson',

data: {

type: 'Feature',

geometry: {

type: 'Point',

coordinates: lineFeature.geometry.coordinates[0],

},

properties: {

rotation: lineBearing + 180,

},

},

},

layout: {

'icon-image': 'arrow',

'icon-size': 0.04,

'icon-rotate': {

type: 'identity',

property: 'rotation',

},

'icon-anchor': 'top',

'icon-rotation-alignment': 'map',

'icon-allow-overlap': true,

'icon-ignore-placement': true,

},

};

Learn more about how to style layers with the Mapbox GL Style Specification. And check out how we build the entire annotation in this JavaScript file.

Centerline Annotation

A drawing displaying the new centerline tool from a meeting we had with planners

I ran into an interesting engineering issue while building the Centerline annotation tool. Planners from our Technical Review Division wanted this tool to consist of an arrow as well as a custom centerline icon. I built the tool to mirror that of the Parallel Measurement annotation shown above, by placing an arrow on one end of the line and our centerline symbol on the other end.

It was easy enough to replicate the code we used for the Parallel Measurement tool, and replace the startArrowLayer with the centerlineLayer .

And there it was! All I had left to do were a couple of minor styling changes: resize the icon and move it a little further away from the line. While the size modification only required a simple fixed value change, offsetting the icon ended up being a little more complicated.

Dynamic Offsetting with Trigonometry

Mapbox GL’s icon-translate property allows developers to offset an icon relative to its anchor (the location where the point is originally placed) based on fixed x and y values. Because our users can draw a line in any direction, a fixed offset would produce something like this:

Example of a fixed offset [10, 0] with icon-translate

Similarly to how we used lineBearing to calculate the rotation of arrows, we can use this same angle to calculate a dynamic offset for our centerline icons and avoid the above situation.

After console logging the lineBearing of several lines in different directions, I created this graphical depiction. I drew in the x and y input values that would translate the icon, an example of the line bearing (represented by 45°), and the distance between the initial location of the icon and the offset location (represented by c).

In Mapbox GL, a negative y value implies a translation UP, and a positive y value implies a translation DOWN

While we have to calculate new x and y values every time the line is drawn, there are two variables that are always known: (1) the distance in pixels that the icon should travel from the end of the line, which I called c , and (2) the angle of the line from true north, or the lineBearing , represented by ɵ.

Revisiting my trigonometry days, I then calculated x and y using the pythagorean theorem and the equation of the tangent.

Using the substitution method, I was able to isolate y , remove x , and produce an equation with just the lineBearing (ɵ) and c .

I then plugged this new y value into the pythagorean theorem in order to find x . Note: I had to convert the lineBearing to radians before finding its tangent and a double asterisk ** represents exponents in JavaScript.

const radiansBearing = (lineBearing * Math.PI) / 180; let x = null;

let y = null; y = Math.sqrt((c ** 2) / ((Math.tan(radiansBearing) ** 2) + 1));

x = Math.sqrt((c ** 2) - (y ** 2));

I now had formulas for the x and y values needed to situate the icon correctly on the map. In Mapbox GL, a positive x value means a translation to the RIGHT, and a negative x value means a translation to the LEFT. A positive y value means a translation DOWN, and a negative y value means a translation UP. In order to assure that the icon was being translated appropriately based on the quadrant where the line existed, I had to set some of the x and y values to negative.

Depending on the quadrant, the x and y values will need to be made negative or positive. Quadrant 1: the icon will be translated right and up [+x, -y]. Quadrant 2: right and down [+x, +y]. Quadrant 3: left and down [-x, +y]. Quadrant 4: left and up [-x, -y].

icon-translate is a weird property. It’s defined by Mapbox as: “Distance that the icon’s anchor is moved from its original placement. Positive values indicate right and down, while negative values indicate left and up.” As mentioned earlier, the anchor is the location where the point was originally placed by the user. So while we are physically translating the icon away from the line (the line will not move unless the user explicitly moves it), icon-translate is measuring the translation as a movement of the anchor not a movement of the icon. Therefore, I had to set the x and y values to the opposite of what I initially expected.

if (lineBearing > 0 && lineBearing < 90) { // quadrant I

x = -x;

} else if (lineBearing < -90) { // quadrant II

y = -y;

} else if (lineBearing > 90 && lineBearing < 180) { // quadrant IV

y = -y;

x = -x;

}

I then added these x and y values to the icon-translate paint property on the centerline symbol layer.

const centerlineLayer = {

type: 'symbol',

source: {

type: 'geojson',

data: {

type: 'Feature',

geometry: {

type: 'Point',

coordinates: lineFeature.geometry.coordinates[0],

},

},

},

layout: layoutCenterline,

paint: {

'icon-translate': [

x,

y,

],

},

};

The offset distance will now be the same regardless of the direction of the line. And that’s how we were able to create this cool centerline annotation on our maps!