Edit a Leaflet polyline right on the map

Posted

Leaflet polyline interactive editing

How to give your user a way to change the points of a polyline interactively.

When I was testing the first version of navJournal, I was on a cruise around Brittany.
Each day I was putting a few points to materialize the track of our sailing boat.
But the coastline is often full of surprises and my track was crossing some islands, point and patch of land. Not a nice way to render our journey 🐼

I wanted to easily make some some adjustments to the Leaflet polyline representing our track.

What we’ll need :

  • A user interface to act on the polyline (our track). A marker will be perfect for that (more on this later)
  • A way to update the polyline as the user add a point and move it around
  • An event to listen to when the user is done and save the point

Let’s start:

Add a marker that will act as the new point for the polyline.

Why are we using a marker for that? Because a Leaflet marker can be set as draggable which is exactly what we want the user to do.
We want to add this marker where the user has tapped and preferably within the vicinity of one of the segment of the polyline.
Leaflet gives us a handy event for that: contextmenu. This translates to a long tap on handheld device and a right click with a mouse.

Make the polyline layer listen to this event.

But we want it to make it a one time thing. We don’t want the user going around and clicking on multiple segment.

// one time listener
polyline.once('contextmenu', addVertice) 

we can then add the marker at the right place with a listener on the drag event which continually fires during the dragging.

function addVertice(evt) {

      verticeMarker = L.marker(evt.latlng, {
        draggable: true, // make the marker draggable
        autoPan: true // so that the map will pan along the dragging
      })

      verticeMarker.addTo(myMap)

      verticeMarker.on('drag', updateTrack)
    }

Where is this new marker ?

We want to know where the marker is positioned in relation to the polyline. ie: which segment are we going to modify.

Leaflet gives us a bunch of Geometry function. Conveniently there is just one that fits our needs : LineUtil.pointToSegmentDistance which measures the distance from a point to a segment.

Alas, this function accepts only Points as arguments :/
This means that we need to convert Latlng –which defne our polyline– in Cartesian coordinates.
And guess what? Leaflet just gave it to us with the project method

Which gives :

function closestSegment(map, latlngs, latlng) {

        var mindist = Infinity,
            result = {},
            i, n, distance
        ;

        // Let cycle through the Latlngs to test each segment
        for(i = 0, n = latlngs.length - 1; i < n; i++) {

            distance = distanceToSegment(map, latlng, latlngA, latlngB);

            if (distance < mindist) {
                mindist = distance;

                // we want to retrieve the 2 points defining the segment
                result.latlngA = L.latLng(latlngs[i]);
                result.latlngB = L.latLng(latlngs[i + 1]);

                result.idx = i
            }
        }
        return result;
    }

   // inspired by https://github.com/makinacorpus/Leaflet.GeometryUtil
    function distanceToSegment(map, latlng, latlngA, latlngB) {
        var maxzoom = map.getMaxZoom();

        if (maxzoom === Infinity)
            maxzoom = map.getZoom();

        var p = map.project(latlng, maxzoom), // latlng transformed to cartesian coordinates
           p1 = map.project(latlngA, maxzoom),
           p2 = map.project(latlngB, maxzoom)
           ;

        return L.LineUtil.pointToSegmentDistance(p, p1, p2);
    }

Update the polyline

We want to take the new Latlng that we got from our contextmenu event —the one that created the marker.
And we want to insert it between the 2 points of the closest segment we identified.
This is equivalent to adding a new Latlng at the right index in the array of Latlngs composing the polyline.

If this array is not known at the moment you can get it with <yourPolyline>.getLatLngs()
Take a look at the function closestSegment above. We are returning the index with the result.

So, let’s modify our addVertice function to insert our new Latlng.

function addVertice(evt) {

      const touchedLatlng = L.latLng(evt.latlng)

      // find the closest segment from the touched point
      const closestSegment = GeometryUtils.closestSegment(myMap, latlngs, touchedLatlng)

      // we returned the index of the first point of the segment but we want the furthest in range.
      newVerticeIdx = closestSegment.idx + 1 

      // insert the new Latlng at the right place. 
      // Do it only once at marker creation. Afterwards we'll use the index
      latlngs.splice(newVerticeIdx, 0, Object.values(evt.latlng))


      verticeMarker = L.marker(evt.latlng, {
        draggable: true,
        autoPan: true
      })

      verticeMarker.addTo(myMap)

      // give the user some clue about which segent is modified 
      // by putting special marker at the ends of the segment
      closestMarkerA = L.circleMarker(closestSegment.latlngA),
      closestMarkerB = L.circleMarker(closestSegment.latlngB)
      ;
      closestMarkerA.addTo(myMap)
      closestMarkerB.addTo(myMap)

      verticeMarker.on('drag', updateTrack)

      verticeMarker.on('dragend', storeNewVertice)  

    }

Listen to the drag event.

Now that our marker is on the map, let’s code our drag handler updateTrack
We need 3 things :

  1. replace the Latlng at the index by newVerticeIdx
  2. set the updated array to the polyline
  3. and refresh the plolyline
function updateTrack(evt) {

      latlngs[newVerticeIdx] = Object.values(evt.latlng) // this is a nice way to convert an object to an array of values

      polyline.setLatLngs(latlngs)

      polyline.redraw()

    }

Save the state when the user stops dragging

We’ll listen to the dragend event to know that the user is satisfied with the new state of the polyline and save the final Latlng of our marker.
When done with the storage, we’ll remove the marker and its closestMarkers.

You may want to save on a distant server.


   function addVertice(evt) {

      // ... 

      verticeMarker.addTo(myMap)

      verticeMarker.on('drag', updateTrack)

      verticeMarker.on('dragend', storeNewVertice)  

    }

    function storeNewVertice(evt) {

      postData(
        'your_storage_url', {
          prevpoint: pointsCollection[newVerticeIdx - 1],
          nextPoint: pointsCollection[newVerticeIdx],
          latlng: Object.values(evt.target._latlng)
        }).then( response => {

        // update pointsCollection 
        // assuming that our server will respond with a refreshed pointsCollection
        pointsCollection = response 

        // redraw polyline
        latlngs.length = 0
        pointsCollection.forEach(p => latlngs.push(p.latln))
        polyline.setLatLngs(latlngs)
        polyline.redraw()

        verticeMarker.remove()
        closestMarkerA.remove()
        closestMarkerB.remove()

        polyline.once('contextmenu', addVertice) // and add a one time listener again
      })

    }

    async function postData(url = '', data = {}) {

      const response = await fetch(url, {
        method: 'POST',
        mode: 'same-origin',
        credentials: 'include',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(data)
      });
      return response.json();
    }

Now go play with your map !

Author