0

I'm implementing a feature where users can overlay an image onto a map. The idea is for users to select two reference points on the map and two on the image (but using map coordinates i receive). Based on these points, I aim to appropriately rotate, scale, and translate the image to fit the desired location on the map.

Currently, the rotation and scaling work to some extent, but it's not perfect. Additionally, I haven't addressed the translation aspect yet.

const PlantEditDrawer = () => {
  const { isPlantEditDrawerOpen } = useDashBoardContext();
  const [mounted, setMounted] = useState(false);
  const { leafletMap } = useLeafletMapContext();
  const { north, east, south, west } = usePositionBoundsContext();
  const [firstImage, setFirstImage] = useState<L.LatLng>();
  const [secondImage, setSecondImage] = useState<L.LatLng>();
  const [firstMap, setFirstMap] = useState<L.LatLng>();
  const [secondMap, setSecondMap] = useState<L.LatLng>();
  const [isDone, setDone] = useState(false);
  // const [imageBounds, setImageBounds] = useState<number[]>()
  const imageRef = useRef<L.ImageOverlay>();

  useEffect(() => {
    if (mounted) return;
    setMounted(true);
  }, [mounted]);
  const handleSecondMapClick = useCallback(
    (e: LeafletMouseEvent) => {
      setSecondMap(e.latlng);
      leafletMap?.current!.off("click");
      setDone(true);
    },
    [leafletMap]
  );

  const handleSecondImageClick = useCallback(
    (e: LeafletMouseEvent) => {
      setSecondImage(e.latlng);
      if (imageRef.current && leafletMap?.current) {
        leafletMap.current.removeLayer(imageRef.current);
        leafletMap.current.on("click", handleSecondMapClick);
      }
    },
    [handleSecondMapClick, leafletMap]
  );

  const handleFirstMapClick = useCallback(
    (e: LeafletMouseEvent) => {
      setFirstMap(e.latlng);
      imageRef.current?.addTo(leafletMap?.current!);
      imageRef.current?.on("click", handleSecondImageClick);
      leafletMap?.current!.off("click");
    },
    [handleSecondImageClick, leafletMap]
  );

  const handleFirstImageClick = useCallback(
    (e: LeafletMouseEvent) => {
      setFirstImage(e.latlng);
      if (imageRef.current && leafletMap?.current) {
        imageRef.current.off("click");
        leafletMap.current.removeLayer(imageRef.current);
        leafletMap.current.on("click", handleFirstMapClick);
      }
    },
    [handleFirstMapClick, leafletMap]
  );


  useEffect(() => {
    if (!leafletMap?.current) return;
    const bounds = L.latLngBounds([
      [south, west],
      [north, east],
    ]);
    imageRef.current = L.imageOverlay(image, bounds, {
      interactive: true,
      opacity: 0.5,
    }).addTo(leafletMap.current);
    imageRef.current?.on("click", handleFirstImageClick);
  }, [east, leafletMap, north, south, west, mounted, handleFirstImageClick]);

  function calculateAngle(p1:any, p2:any) {
    const deltaX = p2.lng - p1.lng;
    const deltaY = p2.lat - p1.lat;
    return Math.atan2(deltaX, deltaY) * (180 / Math.PI);
}


  useEffect(() => {
    if (!isDone || !firstImage || !secondImage || !firstMap || !secondMap) return;
    //calculate the angle here
    const mapAngle = calculateAngle(firstMap, secondMap)
    const imageAngle = calculateAngle(firstImage, secondImage)

    const rotationRequired = mapAngle - imageAngle;

    const firstImageLatLng = L.latLng(firstImage.lat, firstImage.lng);
    const secondImageLatLng = L.latLng(secondImage.lat, secondImage.lng);
    const imageDistance = firstImageLatLng.distanceTo(secondImageLatLng);

    const firstMapLatLng = L.latLng(firstMap.lat, firstMap.lng);
    const secondMapLatLng = L.latLng(secondMap.lat, secondMap.lng);
    const mapDistance = firstMapLatLng.distanceTo(secondMapLatLng);

    const scaleFactor = mapDistance / imageDistance;
    if (imageRef.current) {
      imageRef.current.addTo(leafletMap?.current!);
      const test = imageRef.current.getElement();
      test!.style.transformOrigin = "center";
      const { transform } = test!.style;
      test!.style.transform = ${transform} rotate(${rotationRequired}deg) scale(${scaleFactor});
    }
  }, [firstImage, firstMap, isDone, leafletMap, secondImage, secondMap]);

!!Did not add return value!!
}
Malxruxes
  • 29
  • 3

1 Answers1

0

Warning: I know nothing about leaflet. And I'm not sure what format you need for your transformation. I would hope that you can make use of a CSS matrix transform in some way.

Would I be correct to assume that you only have isotropic scaling, i.e. same scale factor in all directions, and no shearing either? Then 2 points should be enough.

Do you operate at scales where curvature of earth is relevant, or can we think of this as essentially a 2d planar problem? For the moment I'll stick to a planar setup for the sake of simplicity. A planar angle-preserving (orthogonal) affine transformation would be

x_out = a*x_in - b*y_in + c
y_out = b*x_in + a*y_in + d

In plain JS & CSS this corresponds to style.transform = `matrix(${a}, ${b}, ${-b}, ${a}, ${c}, ${d})`;. Typically you'd use a transformation origin in the top left, but in your lat/lng coordinate system the origin might actually be far off the page.

Given input coordinates and resulting output coordinates for two points this results in four linear equations in four unknowns. You can use that to solve for a,b,c,d. Note that there is no trigonometry needed for this, although you could determine the angle of rotation from the atan2(b,a).

I'd probably write up the equations symbolically, and use Sage to solve them once, then plug the resulting formulas in my application so I don't have to implement some Gaussian elimination myself. Assuming we map (x1i, y1i) to (x1o, y1o) and (x2i, y2i) to (x2o, y2o) I get this:

const dxi = x1i - x2i;
const dyi = y1i - y2i;
const dxo = x1o - x2o;
const dyo = y1o - y2o;
const denom = dxi*dxi + dyi*dyi;
const a = (dxi*dxo + dyi*dyo)/denom;
const b = (dxi*dyo - dyi*dxo)/denom;
const c = ((x1i*y2i - x2i*y1i)*dyo + dxi*(x1i*x2o - x2i*x1o) + dyi*(y1i*x2o - y2i*x1o))/denom;
const d = ((y1i*x2i - y2i*x1i)*dxo + dyi*(y1i*y2o - y2i*y1o) + dxi*(x1i*y2o - x2i*y1o))/denom;
elt.style.transform = `matrix(${a}, ${b}, ${-b}, ${a}, ${c}, ${d})`;

Note that this formulation as a matrix assumes a square coordinate system, i.e. one unit along one axis is as long as one unit along the other axis. In geographic terms, one unit of northing should be as long as one unit of easting. If that's not the case, e.g. because you are dealing with latitude and longitude given in degrees or radians, then you might want to convert to a square coordinate system first, and back to angles afterwards. Just scaling one axis with the cosine of your average latitude should be enough.

But do you really have to use geographic coordinates? All of this would be far simpler if you could use pixel coordinates instead of geographic coordinates. Reading the leaflet MouseEvent docs I see there is a layerPoint property which gives you pixel coordinates. If I were you I'd drop all the geographic concepts for this task here, and instead use pixel coordinates to do the computations and express the transformation.

MvG
  • 57,380
  • 22
  • 148
  • 276
  • Leaflet placement of "anything" (lines, shapes, images, etc.) is based on `[lat,long]` pairs, there are no pixel values. It's the open source equivalent of working with google maps. – Mike 'Pomax' Kamermans Aug 17 '23 at 17:55