By Douglas Code

For anyone evaluating terrain or managing assets in the field, having detailed terrain profiles is critical. It is useful to have a visualization of elevation changes along a path whether you’re planning electric transmission lines, gas pipelines or just getting a sense of elevation changes for your next field survey. As a part of a larger project, we needed to create a modern web-based tool for a client who was familiar with Google Earth’s elevation profile tool.

Our goal was to create a client-side JavaScript tool that would take a line on a map and graph the elevation of the terrain along the line. Mapbox provides a good starting place for this with the Terrain-RGB tileset, which encodes elevation data into the RGB color of each pixel in the tile. However, the documentation on how to implement an elevation profile tool in a Mapbox GL JS map was limited. We decided to create our own and provide some background for others who might be facing the same problem in their projects.

## Generate Points Along Line

In our map, the user can use one of two options to see the elevation profile 1) select a linestring from an existing layer in the Mapbox GL JS map or 2) draw their own line which we allowed them to do by implementing mapbox-gl-draw.

To create an elevation profile, we need to generate a list of points along the line that we’d like to check. This can be done by deciding on a step distance and creating points along the line with that distance between them. For example, on a 100 meter line with a step distance of 2 meters, we’d create a point 0 meters along the line, 2 meters along the line, 4 meters along the line, 6 meters along the line, etc.

There are a few options for calculating a step distance: you can take the step distance as a parameter when calling your function, you can divide the line length by a desired number of points, or you can calculate the distance of a single pixel in your tile and use that. To calculate the pixel distance at a given latitude, we can use the following function:

```
function _getPixelDistanceAtLatitudeInMeters(latitude) {
const EQUATORIAL_EARTH_CIRCUMFERENCE = 40075016.686;
return EQUATORIAL_EARTH_CIRCUMFERENCE *
(Math.cos(latitude * Math.PI / 180) / Math.pow(2, ZOOM_LEVEL + 8)); }
```

Using the pixel distance gives you a high elevation resolution while preventing sampling the same pixel multiple times.

Once we have a list of distances along the line to check, we need to create point features along the line at those distances. These are the points that will appear in our elevation profile graph. To do this, we can use the Turf.js function *along*:

```
function _getPointsToCheck(lineString, distancesToCheck)
{
let points = [];
distancesToCheck.forEach(function (distance) {
let feature = along(lineString, distance, {units: "kilometers"});
feature.properties.distanceAlongLine = distance * 1000;
points.push(feature);
});
return points;
}
```

## Get the Relevant Tiles

Now that we have a set of points to check, we need the Mapbox Terrain-RGB tiles where those points appear. The Mapbox tile-cover library provides a function, *tiles*, that takes a geometry and returns an array of tile [x, y, z] coordinates for the tiles that cover the geometry at the provided zoom level.

```
let requiredTiles2DArray =
TileCover.tiles(lineString.geometry, {min_zoom: minZoom, max_zoom: maxZoom});
```

With the xyz coordinates of each required tile, we can now request the Terrain-RGB tiles using the getPixels library. This will give us a ndarray of the RGB values of each pixel in the tile.

```
return new Promise((resolve, reject) => {getPixels(`https://api.mapbox.com/v4/mapbox.terrain-rgb/${z}/${x}/${y}.pngraw?access_token=${access_token}`, function (error, pixels) {
if (error) {
reject(error);
}
resolve(pixels);
})
})
```

## Convert Points to X/Y Coordinates

In order to know which tile and pixel to check for each generated point geometry, the latitude/longitude point geometries we generated along the line must be converted to x, y coordinates to match the coordinates of the tiles returned by TileCover.tiles. This can be achieved with the *px* function from Mapbox’s sphericalmercator library.

```
function _addXYCoordinatesToPointsGeoJSON(pointsArray, zoomLevel) {
let sphericalMercator = new SphericalMercator({
size: TILE_SIZE
});
pointsArray.forEach(function (pointGeoJSON) {
let pointSMCoordinates = sphericalMercator.px([pointGeoJSON.geometry.coordinates[0], pointGeoJSON.geometry.coordinates[1]], zoomLevel);
pointGeoJSON.properties.smCoordinates = {
x: pointSMCoordinates[0],
y: pointSMCoordinates[1]
}
});
}
```

## Find the Tile and Pixel for Each Point

The x, y coordinates of each point can now be checked against the tiles list to see which tile the point belongs to:

```
function _pointIsWithinTile(pointSMCoordinates, tileSMCoordinates) {
return pointSMCoordinates.x >= tileSMCoordinates.x
&& pointSMCoordinates.x <= (tileSMCoordinates.x + TILE_SIZE)
&& pointSMCoordinates.y >= tileSMCoordinates.y
&& pointSMCoordinates.y <= (tileSMCoordinates.y + TILE_SIZE)
}
```

Once the correct tile is found, the relative location of the point within the tile must be calculated and the RGB value of the pixel at that location can then be passed to a function calculating the elevation for that pixel.

```
let xRelativeToTile = point.properties.smCoordinates.x - matchingTile.smCoordinates.x;
let yRelativeToTile = point.properties.smCoordinates.y - matchingTile.smCoordinates.y;
point.properties.elevation = _calculateHeightFromPixel([
matchingTile.tileData.get(xRelativeToTile, yRelativeToTile, 0),
matchingTile.tileData.get(xRelativeToTile, yRelativeToTile, 1),
matchingTile.tileData.get(xRelativeToTile, yRelativeToTile, 2)
]);
```

## Calculate Elevation for Each Point

The elevation can then be calculated using the formula provided by Mapbox:

```
function _calculateHeightFromPixel(pixelRGBArray) {
let red = pixelRGBArray[0];
let green = pixelRGBArray[1];
let blue = pixelRGBArray[2];
return -10000 + ((red * 256 * 256 + green * 256 + blue) * 0.1);
}
```

Now that the elevation of each point along the line is stored in that point’s properties.elevation attribute, we can return the set of points as a GeoJSON feature collection. By adding the distanceAlongLine property in the _getPointsToCheck function and returning it in the GeoJSON, we can use a graphing library like Plotly or Chart.js to graph the elevation profile for the line.

Check out the code here.

### Have more questions?

If you have more questions or want help on your next project, let us know. Email us at contact@line-45.com or call us at (833) 254-6345.

## Comments