Mapbox GL-JS – How to Crop Specific Area of Map Using React

mapbox-gl-jsreact

I am working on Mapbox. Is there any possible way to crop map image using Mapbox? Right now I am getting complete map as canvas but I just need to crop a specific area.

The thing which I want is to crop only that area where I am drawing polygon, I don't know whether it is possible or not.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Draw a polygon and calculate its area</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
<link href="https://api.mapbox.com/mapbox-gl-js/v2.7.0/mapbox-gl.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v2.7.0/mapbox-gl.js"></script>
<style>
body { margin: 0; padding: 0; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
</style>
</head>
<body>
<style>
    .calculation-box {
        height: 75px;
        width: 150px;
        position: absolute;
        bottom: 40px;
        left: 10px;
        background-color: rgba(255, 255, 255, 0.9);
        padding: 15px;
        text-align: center;
    }

    p {
        font-family: 'Open Sans';
        margin: 0;
        font-size: 13px;
    }
</style>

<script src="https://unpkg.com/@turf/turf@6/turf.min.js"></script>
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.2.2/mapbox-gl-draw.js"></script>
<link rel="stylesheet" href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.2.2/mapbox-gl-draw.css" type="text/css">
<div id="map"></div>
<div class="calculation-box">
    <p>Click the map to draw a polygon.</p>
    <div id="calculated-area"></div>
</div>

<script>
    mapboxgl.accessToken = 'pk.eyJ1Ijoib21lcjA5IiwiYSI6ImNreWJkcjdmczBlYmMydm9mazYwMnBxMm8ifQ.yC2TC8C5hsIFb13-HgP_Pw';
    const map = new mapboxgl.Map({
        container: 'map', // container ID
        style: 'mapbox://styles/mapbox/satellite-v9', // style URL
        center: [-91.874, 42.76], // starting position [lng, lat]
        zoom: 12 // starting zoom
    });

    const draw = new MapboxDraw({
        displayControlsDefault: false,
        // Select which mapbox-gl-draw control buttons to add to the map.
        controls: {
            polygon: true,
            trash: true
        },
        // Set mapbox-gl-draw to draw by default.
        // The user does not have to click the polygon control button first.
        defaultMode: 'draw_polygon'
    });
    map.addControl(draw);

    map.on('draw.create', updateArea);
    map.on('draw.delete', updateArea);
    map.on('draw.update', updateArea);

    function updateArea(e) {
        const data = draw.getAll();
        const answer = document.getElementById('calculated-area');
        if (data.features.length > 0) {
            const area = turf.area(data);
            // Restrict the area to 2 decimal points.
            const rounded_area = Math.round(area * 100) / 100;
            answer.innerHTML = `<p><strong>${rounded_area}</strong></p><p>square meters</p>`;
        } else {
            answer.innerHTML = '';
            if (e.type !== 'draw.delete')
                alert('Click the map to draw a polygon.');
        }
    }
</script>

</body>
</html>

MapBox-GL-JS map

Best Answer

Mapbox GL JS is not using 2d context for canvas, but webgl context, so operation is not trivial. Logic goes like this:

  • Polygon coordinates are converted to map pixel coordinates with map.project method.
  • Drawn polygon is temporary deleted so that it does not mix with the clipped image.
  • Get context of map canvas with mapCanvas.getContext('webgl'). For that to work, map option preserveDrawingBuffer has to be set to true.
  • Convert webgl context to 2d context.
  • Actual size of Mapbox GL JS map canvas is bigger than it is shown (resized with style), so calculation of size factors is needed, since point coordinates returned by map.project method are not given for actual canvas, but for resized one.
  • Calculate bounding box for clipped polygon.
  • Translate canvas so that polygon bounding box is at [0, 0].
  • Create new empty canvas for clipped image and draw polygon on it and then clip context to this polygon.
  • Draw map image to this clipped canvas.
  • Create empty final canvas and draw only the clipped polygon to it.
  • Download final canvas as image.

Since I'm not some canvas expert, code is probably not optimal, but it works. Below is relevant part of the code:

const map = new mapboxgl.Map({
  container: 'map', 
  style: 'mapbox://styles/mapbox/satellite-v9', 
  center: [-91.874, 42.76], 
  zoom: 12,
  preserveDrawingBuffer: true
});

function getPixels(ctx) {
  return ctx.readPixels
    ? getPixels3d(ctx)
    : getPixels2d(ctx)
}

function getPixels3d(gl) {
  var canvas = gl.canvas
  var height = canvas.height
  var width  = canvas.width
  var buffer = new Uint8Array(width * height * 4)

  gl.readPixels(0, 0
    , canvas.width
    , canvas.height
    , gl.RGBA
    , gl.UNSIGNED_BYTE
    , buffer
  )

  return buffer
}

function getPixels2d(ctx) {
  var canvas = ctx.canvas
  var height = canvas.height
  var width  = canvas.width

  return ctx.getImageData(0, 0, width, height).data
}

function webglToCanvas2d(webgl, canvas2D) {
  var outCanvas = canvas2D ? canvas2D.canvas || canvas2D : document.createElement('canvas');
  var outContext = outCanvas.getContext('2d');
  var outImageData;

  webgl = webgl instanceof WebGLRenderingContext ? webgl : webgl.getContext('webgl') || webgl.getContext('experimental-webgl');

  outCanvas.width = webgl.canvas.width;
  outCanvas.height = webgl.canvas.height;
  outImageData = outContext.getImageData(0, 0, outCanvas.width, outCanvas.height);

  outImageData.data.set(new Uint8ClampedArray(getPixels3d(webgl).buffer));
  outContext.putImageData(outImageData, 0, 0);
  outContext.translate(0, outCanvas.height);
  outContext.scale(1, -1);
  outContext.drawImage(outCanvas, 0, 0);
  outContext.setTransform(1, 0, 0, 1, 0, 0);

  return outCanvas;
}

function downloadCanvasAsImage(canvas, fileName){
  var link = document.createElement('a');
  link.download = fileName;
  link.href = canvas.toDataURL()
  link.click();
}

var points = [];
var savedPolygon;

function saveClipped() {
  var mapCanvas = map.getCanvas();
  var ctx = mapCanvas.getContext("webgl");
  var canvas2d = webglToCanvas2d(ctx);
  var ctx2d = canvas2d.getContext('2d');
  var clippedCanvas = document.createElement('canvas');
  clippedCanvas.width = mapCanvas.clientWidth;
  clippedCanvas.height = mapCanvas.clientHeight;
  var clippedCtx = clippedCanvas.getContext('2d');
  var factX = mapCanvas.clientWidth / mapCanvas.width;
  var factY = mapCanvas.clientHeight / mapCanvas.height
  var minX = points[0].x;
  var maxX = points[0].x;
  var minY = points[0].y;
  var maxY = points[0].y;
  for (var i = 1; i < points.length; i++) {
    if (points[i].x < minX)
      minX = points[i].x
    else if (points[i].x > maxX) {
      maxX = points[i].x
    }
    if (points[i].y < minY)
      minY = points[i].y
    else if (points[i].y > maxY) {
      maxY = points[i].y
    }
  }
  clippedCtx.scale(1/factX, 1/factY);
  clippedCtx.translate(-minX, -minY);
  clippedCtx.beginPath();
  clippedCtx.moveTo(points[0].x, points[0].y);
  for (var i = 1; i < points.length; i++) {
    clippedCtx.lineTo(points[i].x, points[i].y);
  }
  clippedCtx.closePath();
  clippedCtx.lineWidth = 1;
  clippedCtx.stroke();
  clippedCtx.clip();
  clippedCtx.scale(factX, factY);
  clippedCtx.drawImage(canvas2d, 0, 0);
  var finalCanvas = document.createElement('canvas');
  var finalWidth = (maxX - minX) / factX;
  var finalHeight = (maxY - minY) / factY;
  finalCanvas.width = finalWidth;
  finalCanvas.height = finalHeight;
  var finalCtx = finalCanvas.getContext('2d');
  finalCtx.drawImage(clippedCanvas, 0, 0, finalWidth, finalHeight, 0, 0, finalWidth, finalHeight);
  downloadCanvasAsImage(finalCanvas, 'clipped-image.png');
  draw.add(savedPolygon);
}

function updateArea(e) {
  if (e.features[0].geometry.type == 'Polygon') {
    savedPolygon = e.features[0];
    var polygonId = e.features[0].id;
    var coords = savedPolygon.geometry.coordinates[0];
    coords.forEach(function(coord) {
      points.push(map.project(coord));
    });
    draw.delete(polygonId);
    setTimeout(saveClipped, 100);
  }
  const data = draw.getAll();
  const answer = document.getElementById('calculated-area');
  if (data.features.length > 0) {
    const area = turf.area(data);
    const rounded_area = Math.round(area * 100) / 100;
    answer.innerHTML = `<p><strong>${rounded_area}</strong></p><p>square meters</p>`;
  } else {
    answer.innerHTML = '';
  }
}

Here is an example:

enter image description here