Search code examples
leafletgeojsonreact-leaflet

Cannot access GeoJSON layer in react-leaflet


I am using React Leaflet to render Leaflet map and its GeoJSON component to render polygons. I am trying to implement dragging multiple polygons together at once, as a group.

I added Leaflet.Path.Drag library and tried to reuse this code. I am able to get transformation matrix which is in parent's state. If I want to apply this matrix to multiple polygons with _transform method, it doesn't work. I think the reason is that matrix is not applied to correct layers, but I have no idea how to fix this.

codesandbox.io

App.js

import React from "react";
import { MapContainer, GeoJSON, TileLayer } from "react-leaflet";
import { geoJson, latLngBounds } from "leaflet";
import "./styles.css";
import "leaflet/dist/leaflet.css";

import { GeoJsonContainer } from "./GeoJsonContainer";

const objects = [
  {
    polygon: {
      type: "FeatureCollection",
      features: [
        {
          type: "Feature",
          properties: {},
          geometry: {
            type: "Polygon",
            coordinates: [
              [
                [-104.98569488525392, 39.63431579014969],
                [-104.98569488525392, 39.64165260123419],
                [-104.97161865234376, 39.64165260123419],
                [-104.97161865234376, 39.63431579014969]
              ]
            ]
          }
        }
      ]
    }
  },
  {
    polygon: {
      type: "FeatureCollection",
      features: [
        {
          type: "Feature",
          properties: {},
          geometry: {
            type: "Polygon",
            coordinates: [
              [
                [-105.02964019775392, 39.6206315500488],
                [-105.02964019775392, 39.65685252543906],
                [-104.99067306518556, 39.65685252543906],
                [-104.99067306518556, 39.6206315500488]
              ]
            ]
          }
        }
      ]
    }
  }
];

const getPolygonPointFromBounds = (latLngBounds) => {
  const center = latLngBounds.getCenter();
  const latlngs = [];

  latlngs.push(latLngBounds.getSouthWest()); //bottom left
  latlngs.push({ lat: latLngBounds.getSouth(), lng: center.lng }); //bottom center
  latlngs.push(latLngBounds.getSouthEast()); //bottom right
  latlngs.push({ lat: center.lat, lng: latLngBounds.getEast() }); // center right
  latlngs.push(latLngBounds.getNorthEast()); //top right
  latlngs.push({
    lat: latLngBounds.getNorth(),
    lng: latLngBounds.getCenter().lng
  }); //top center
  latlngs.push(latLngBounds.getNorthWest()); //top left
  latlngs.push({
    lat: latLngBounds.getCenter().lat,
    lng: latLngBounds.getWest()
  }); //center left

  return latlngs;
};

export default function App() {
  const [matrix, setMatrix] = React.useState(null);

  let newBounds = [];
  let selectBoundingBox = [];

  objects.forEach((building) => {
    const polygonBounds = geoJson(building.polygon).getBounds();
    newBounds = [...newBounds, polygonBounds];
  });

  const polygonPoints = getPolygonPointFromBounds(latLngBounds(newBounds));
  const convertedData = polygonPoints.map((point) => [point.lng, point.lat]);
  convertedData.push([polygonPoints[0].lng, polygonPoints[0].lat]);
  selectBoundingBox = convertedData;

  let selectBoxData = null;

  if (selectBoundingBox) {
    selectBoxData = {
      type: "FeatureCollection",
      features: [
        {
          type: "Feature",
          properties: {},
          geometry: {
            type: "Polygon",
            coordinates: [selectBoundingBox]
          }
        }
      ]
    };
  }

  const handleFeature = (layer) => {
    layer.makeDraggable();
    layer.dragging.enable();

    layer.on("drag", function (e) {
      setMatrix(layer.dragging._matrix);
    });
  };

  return (
    <MapContainer center={[39.63563779557324, -104.99234676361085]} zoom={12}>
      <TileLayer
        attribution='&amp;copy <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
        url="https://{s}.tile.osm.org/{z}/{x}/{y}.png"
      />
      {objects.map((object, i) => (
        <GeoJsonContainer data={object} key={i} matrix={matrix} />
      ))}
      <GeoJSON
        data={selectBoxData}
        style={() => ({
          color: "green",
          weight: 3,
          opacity: 0.5
        })}
        draggable={true}
        onEachFeature={(feature, layer) => handleFeature(layer)}
      ></GeoJSON>
    </MapContainer>
  );
}

GeoJsonContainer.js

import React from "react";
import { GeoJSON } from "react-leaflet";

require("leaflet-path-drag");

export const GeoJsonContainer = (props) => {
  const geoJSONRef = React.useRef(null);
  const layerRef = React.useRef(null);

  React.useEffect(() => {
    if (geoJSONRef?.current?._layers) {
      console.log("mount layers", geoJSONRef.current?._layers);
    }
  }, []);

  React.useEffect(() => {
    if (geoJSONRef?.current._layers && props.matrix) {
      console.log("transform layers", geoJSONRef.current._layers);
      const key = Object.keys(geoJSONRef.current._layers)[0];
      const geoJSONElementLayer = geoJSONRef.current._layers[key];
      if (geoJSONElementLayer) {
        console.log("geoJSONElementLayer", geoJSONElementLayer);
        console.log("layerRef.current", layerRef.current);
        geoJSONElementLayer._transform(props.matrix);
        layerRef.current._transform(props.matrix);
      }
    }
  }, [props.matrix]);

  const handleFeature = (layer) => {
    console.log("handleFeature layer", layer);
    layerRef.current = layer;
    layer.makeDraggable();
    layer.dragging.enable();
  };

  return (
    <GeoJSON
      ref={geoJSONRef}
      data={props.data.polygon}
      style={() => ({
        color: "#3388ff",
        weight: 3,
        opacity: 1
      })}
      dragging={true}
      onEachFeature={(feature, layer) => handleFeature(layer)}
    ></GeoJSON>
  );
};

Solution

  • Regarding

    If I want to apply this matrix to multiple polygons with _transform method, it doesn't work

    This is the expected behavior in React since matrix prop needs to be immutable meaning a new array needs to be passed each time there is a change:

     layer.on("drag", function (e) {
       setMatrix([...layer.dragging._matrix]);
    });
    

    instead of:

    layer.on("drag", function (e) {
       setMatrix(layer.dragging._matrix);
    });
    

    This way GeoJsonContainer component should get re-rendered as expected.

    Another matter concerns Leaflet.Path.Drag plugin, according to the referenced thread, in fact both drop and dropend events needs to be captured to properly apply transformation, so maybe instead of matrix prop, introduce a transform prop to keep matrix array and a flag to determine whether drop or is dropend event is triggered:

    const handleFeature = (layer) => {
        layer.makeDraggable();
        layer.dragging.enable();
    
        layer.on("drag", function (e) {
          setTransform({matrix: layer.dragging._matrix, "end": false});
        });
    
        layer.on("dragend", function (e) {
          setTransform({matrix: layer.dragging._matrix, "end": true});
        });
      };
    

    and pass it into GeoJsonContainer component to apply geometry transform:

    React.useEffect(() => {
    
        if (props.transform) {
          geoJSONRef.current.eachLayer((layer) => {
            if (props.transform.end) dragDropTransform(layer);
            else __dragTransform(layer);
          });
        }
    }, [props.transform]);
    

    where

      function __dragTransform(layer) {
          layer._transform(props.transform.matrix);
       }
    
       function dragDropTransform(layer) {
         layer.dragging._transformPoints(props.transform.matrix);
         layer._updatePath();
         layer._project();
         layer._transform(null);
       }
    

    Updated live demo

    A possible improvement(s):

    • in the provided example two instances of JSON layers are instantiated, maybe to consider create a single instance of GeoJSON and apply a style per geometry?

    Solution improvements proposal

    In the provided example two layers are instantiated:

    in App component

    • GeoJSON layer which renders a single outer geometry (polygon)

    and in GeoJsonContainer component another one

    • GeoJSON layer which in turn renders a two inner geometries (polygons)

    How about to merge both GeoJSON objects, something like this:

    const dataSource = {...selectBoxData,...objects[0],...objects[1]}    
    

    and create a single layer instead:

    <GeoJSON data={dataSource}></GeoJSON>
    

    This way twice initialization for dragable layers could be avoided (refactoring of duplicated code)