mapbox / mapbox-gl-js

Interactive, thoroughly customizable maps in the browser, powered by vector tiles and WebGL
https://docs.mapbox.com/mapbox-gl-js/
Other
10.87k stars 2.19k forks source link

How to prevent event of top layer to trigger the bottom one? #13184

Open alamenai opened 1 month ago

alamenai commented 1 month ago

mapbox-gl-js version:

Question

Hi everyone,

I spent two days trying to solve this issue but no way.

I did not open this until I could not find a solution on Google or using ChatGPT.

As you see in the image below, I have stack of layers from bottom to top:

Layer 01 : Roof ( Polygon )

Layer 02: Solar Panels (Polygon )

Layer 03: Roof ID (Cirlce)

As you see in the picture when I hover on the cirlce layer ( Layer 3 ) , it triggers also hover the panel ( Layer 2 ) while It should not happen.

Please could share an example on how to handle this situation?

Code

"use client"

import { getRoofSurfaces } from "@/app/actions/roof-surfaces"
import { getEnv } from "@/env"
import * as turf from "@turf/turf"
import { Feature, GeoJsonProperties, Geometry, Position } from "geojson"
import mapboxGL, { Map as MapBoxMap } from "mapbox-gl"
import proj4 from "proj4"
import React, { SyntheticEvent, useEffect, useRef, useState } from "react"

import { Input } from "../ui/input"

/* eslint-disable */

interface Panel {
  panelID: number
  selected: boolean
}

interface Roof {
  id: number
  coords: any
  holes: any
  panelCoords: any
}

interface SelectedPanelsState {
  [roofId: number]: Panel[]
}

const FROM_PROJECTION = "+proj=utm +zone=32 +datum=WGS84 +units=m +no_defs" // Define the source and destination projections

const TO_PROJECTION = "EPSG:4326" // WGS84 Geographic

// Please check the backend response to understand how this function works

function convertCoordinates(wkt: any) {
  // Remove the POLYGON prefix and the outer parentheses, then split into rings
  const rings = wkt
    .replace(/POLYGON\s*\(\(/, "")
    .replace(/\)\)/, "")
    .split("), (")

  // Convert the rings into arrays of coordinates
  const convertedRings = rings.map((ring: string) => {
    // Map each ring to its coordinates
    let coordinates = ring.split(",").map((pair) => {
      let points = pair.trim().split(" ")
      return proj4(FROM_PROJECTION, TO_PROJECTION).forward([parseFloat(points[0]), parseFloat(points[1])])
    })

    // Ensure the ring is closed
    if (
      coordinates.length > 1 &&
      (coordinates[0][0] !== coordinates[coordinates.length - 1][0] ||
        coordinates[0][1] !== coordinates[coordinates.length - 1][1])
    ) {
      coordinates.push(coordinates[0])
    }

    return coordinates
  })

  // Structure the result as an object with 'outer' which is the roof and 'inners' which are the holes

  return {
    outer: convertedRings[0], // The first array is the outer boundary : Ex: Roof
    inners: convertedRings.slice(1), // All subsequent arrays are inner boundaries : Ex: Holes
  }
}

const BuildingMap = () => {
  const [address, setAddress] = useState("")

  const [selectedPanels, setSelectedPanels] = useState<SelectedPanelsState>({})

  const mapRef = useRef<MapBoxMap | null>(null)

  const panelEventHandlers = useRef<{ [key: string]: any }>({})

  const roofEventHandlers = useRef<{ [key: string]: any }>({})

  const initMap = (token: string) => {
    if (mapRef.current) return

    mapRef.current = new mapboxGL.Map({
      accessToken: token,
      container: "map-container",
      style: "mapbox://styles/mapbox/satellite-v9",
      zoom: 20,
      center: [6.934471401630646, 50.96733244414443],
      antialias: true,
      pitch: 20,
      attributionControl: true,
    })
  }

  const resetMap = () => {
    if (!mapRef.current) return

    // Remove all event handlers for panels and roofs
    Object.keys(panelEventHandlers.current).forEach((panelId) => {
      const { click, mouseenter, mouseleave } = panelEventHandlers.current[panelId]
      mapRef.current?.off("click", panelId, click)
      mapRef.current?.off("mouseenter", panelId, mouseenter)
      mapRef.current?.off("mouseleave", panelId, mouseleave)
    })
    panelEventHandlers.current = {}

    Object.keys(roofEventHandlers.current).forEach((roofSourceId) => {
      const { mouseenter, mouseleave, click, mouseenterCursor, mouseleaveCursor, roofCircleId } =
        roofEventHandlers.current[roofSourceId]
      mapRef.current?.off("mouseenter", roofSourceId, mouseenter)
      mapRef.current?.off("mouseleave", roofSourceId, mouseleave)
      mapRef.current?.off("click", roofCircleId, click)
      mapRef.current?.off("mouseenter", roofSourceId, mouseenterCursor)
      mapRef.current?.off("mouseleave", roofSourceId, mouseleaveCursor)
    })
    roofEventHandlers.current = {}

    // Remove all sources and layers starting with 'roof' or 'panels'
    const sources = mapRef.current.getStyle().sources
    const layers = mapRef.current.getStyle().layers

    layers.forEach((layer) => {
      if (layer.id.startsWith("roof") || layer.id.startsWith("panels")) {
        mapRef.current?.removeLayer(layer.id)
      }
    })

    for (const sourceId in sources) {
      if (sourceId.startsWith("roof") || sourceId.startsWith("panels")) {
        mapRef.current.removeSource(sourceId)
      }
    }
  }

  const fetchCoordinates = async (event: SyntheticEvent<HTMLFormElement>) => {
    event.preventDefault()

    try {
      const response = await fetch(`/api/address-search/coordinates/?address=${address}`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ address }),
      })

      if (!response.ok) {
        throw new Error("Failed to fetch coordinates")
      }

      const data = await response.json()

      const [lng, lat] = data.features[0].center

      mapRef?.current?.flyTo({
        center: [lng, lat],
      })

      // Reset the map layout
      resetMap()

      setSelectedPanels({})

      getRoofSurfaces(address)
        .then((roofSurfaces: any) => {
          installPvSystem(roofSurfaces)
        })
        .catch((error: unknown) => {
          if (error instanceof Error) {
            console.error("Error fetching coordinates:", error)
          }
        })
    } catch (error) {
      console.error("Error fetching coordinates:", error)
    }
  }

  const installPvSystem = (roofSurfaces: any) => {
    roofSurfaces.forEach((surface: any) => {
      surface.id = surface.roofId
      surface.coords = convertCoordinates(surface.roofGeometryWGS84).outer
      surface.holes = convertCoordinates(surface.roofGeometryWGS84).inners
      surface.panelCoords = surface.panelGeometriesWGS84.map((panelCoord: any) => convertCoordinates(panelCoord).outer)
    })

    roofSurfaces.forEach((roof: Roof) => {
      addRoof(roof)
    })
  }

  // Ensure that the toggleAllPanels function updates the paint properties as well
  function toggleAllPanels(roof: Roof, selectStatus: boolean) {
    mapRef.current?.setPaintProperty(
      `roof-circle-${address}-${roof.id}`,
      "circle-color",
      selectStatus ? "#1d4ed8" : "transparent"
    )

    mapRef.current?.setPaintProperty(
      `roof-${address}-${roof.id}`,
      "fill-color",
      selectStatus ? "#8fe03f" : "transparent"
    )

    mapRef.current?.setPaintProperty(
      `roof-border-${address}-${roof.id}`,
      "line-color",
      selectStatus ? "#a3e635" : "#f8fafc"
    )

    mapRef.current?.setPaintProperty(
      `roof-border-${address}-${roof.id}`,
      "line-dasharray",
      selectStatus ? null : [2, 2]
    )

    mapRef.current?.setLayoutProperty(
      `roof-text-${address}-${roof.id}`,
      "text-field",
      selectStatus ? `${roof.id}` : "+"
    )

    mapRef.current?.setPaintProperty(`roof-${address}-${roof.id}`, "fill-opacity", selectStatus ? 0.4 : 0)

    // Update properties for each panel
    if (roof?.panelCoords) {
      roof.panelCoords.forEach((_: any, index: number) => {
        const panelId = `panels-${address}-${roof.id}-${index}`
        const panelIdLine = `${panelId}-line`

        mapRef.current?.setPaintProperty(panelId, "fill-color", selectStatus ? "#020617" : "transparent")
        mapRef.current?.setPaintProperty(panelIdLine, "line-width", selectStatus ? 0.2 : 0)
        mapRef.current?.setPaintProperty(panelIdLine, "line-opacity", selectStatus ? 0.5 : 0)

        // Ensure hover and click events are managed correctly
        if (!selectStatus) {
          // Remove hover and click effects
          if (panelEventHandlers.current[panelId]) {
            const { click, mouseenter, mouseleave } = panelEventHandlers.current[panelId]
            mapRef.current?.off("click", panelId, click)
            mapRef.current?.off("mouseenter", panelId, mouseenter)
            mapRef.current?.off("mouseleave", panelId, mouseleave)
          }
        } else {
          // Reattach hover and click handlers if selecting
          const mouseEnterHandler = () => {
            if (mapRef.current) {
              const currentColor = mapRef.current?.getPaintProperty(panelId, "fill-color")
              if (currentColor !== "transparent") {
                mapRef.current.getCanvas().style.cursor = "pointer"
                mapRef.current?.setPaintProperty(panelId, "fill-color", "#1e40af")
              }
            }
          }

          const mouseLeaveHandler = () => {
            if (mapRef.current) {
              const currentColor = mapRef.current?.getPaintProperty(panelId, "fill-color")
              if (currentColor !== "transparent") {
                mapRef.current.getCanvas().style.cursor = ""
                mapRef.current?.setPaintProperty(panelId, "fill-color", "#020617")
              }
            }
          }

          const clickHandler = () => togglePanel(panelId, roof, index)

          panelEventHandlers.current[panelId] = {
            click: clickHandler,
            mouseenter: mouseEnterHandler,
            mouseleave: mouseLeaveHandler,
          }

          mapRef.current?.on("click", panelId, clickHandler)
          mapRef.current?.on("mouseenter", panelId, mouseEnterHandler)
          mapRef.current?.on("mouseleave", panelId, mouseLeaveHandler)
        }
      })
    }

    // Update selected panels state
    setSelectedPanels((prevSelectedPanels) => {
      const updatedRoofPanels = roof.panelCoords.map((_: any, index: number) => ({
        panelID: index,
        selected: selectStatus,
      }))

      return { ...prevSelectedPanels, [roof.id]: updatedRoofPanels }
    })
  }

  const addRoof = (roof: Roof) => {
    if (!mapRef.current) return

    const roofPolygonData = createRoofPolygonData(roof)
    const roofSourceId = `roof-${address}-${roof.id}`

    addRoofSource(roofSourceId, roofPolygonData)
    addRoofLayers(roofSourceId, roof)
    addRoofEventHandlers(roofSourceId, roof)
    const center = turf.center(turf.center(roofPolygonData))
    addCenterSourceAndLayer(roof.id, center)
    addPanelLayers(roof, roofPolygonData)
  }

  const createRoofPolygonData = (roof: Roof): Feature<Geometry, GeoJsonProperties> => {
    const polygon = turf.polygon([roof.coords])
    const roofCoordinates = polygon.geometry.coordinates

    roof.holes.forEach((hole: Position[]) => {
      roofCoordinates.push(hole)
    })

    return {
      type: "Feature",
      properties: {},
      geometry: {
        type: "Polygon",
        coordinates: roofCoordinates,
      },
    }
  }

  const addRoofSource = (roofSourceId: string, roofPolygonData: Feature<Geometry, GeoJsonProperties>) => {
    if (mapRef.current?.getSource(roofSourceId)) {
      const source = mapRef.current.getSource(roofSourceId) as mapboxGL.GeoJSONSource
      source.setData(roofPolygonData)
    } else {
      mapRef.current?.addSource(roofSourceId, {
        type: "geojson",
        data: roofPolygonData,
      })
    }
  }

  const addRoofLayers = (roofSourceId: string, roof: Roof) => {
    if (!mapRef.current?.getLayer(roofSourceId)) {
      mapRef.current?.addLayer({
        id: roofSourceId,
        type: "fill",
        source: roofSourceId,
        paint: {
          "fill-color": "#8fe03f",
          "fill-opacity": 0.5,
        },
      })
    }

    const roofBorderId = `roof-border-${address}-${roof.id}`
    if (!mapRef.current?.getLayer(roofBorderId)) {
      mapRef.current?.addLayer({
        id: roofBorderId,
        type: "line",
        source: roofSourceId,
        layout: {},
        paint: {
          "line-color": "#a3e635",
          "line-width": 2,
        },
      })
    }
  }

  const addRoofEventHandlers = (roofSourceId: string, roof: Roof) => {
    const handleMouseEnter = () => handleRoofMouseEnter(roofSourceId, roof)
    const handleMouseLeave = () => handleRoofMouseLeave(roofSourceId, roof)
    const handleClick = () => handleRoofClick(roofSourceId, roof)

    const handleMouseEnterCursor = () => {
      if (mapRef.current) {
        mapRef.current.getCanvas().style.cursor = "pointer"
      }
    }
    const handleMouseLeaveCursor = () => {
      if (mapRef.current) {
        mapRef.current.getCanvas().style.cursor = ""
      }
    }

    const roofCircleId = `roof-circle-${address}-${roof.id}`

    // Store event handlers for cleanup

    roofEventHandlers.current[roofSourceId] = {
      mouseenter: handleMouseEnter,
      mouseleave: handleMouseLeave,
      click: handleClick,
      mouseenterCursor: handleMouseEnterCursor,
      mouseleaveCursor: handleMouseLeaveCursor,
      roofCircleId,
    }

    mapRef.current?.on("mouseenter", roofSourceId, handleMouseEnter)
    mapRef.current?.on("mouseleave", roofSourceId, handleMouseLeave)
    mapRef.current?.on("click", roofCircleId, handleClick)
  }

  const handleRoofMouseEnter = (roofSourceId: string, roof: Roof) => {
    if (mapRef.current) {
      if (mapRef.current.getPaintProperty(roofSourceId, "fill-color") === "transparent") {
        mapRef.current.setPaintProperty(roofSourceId, "fill-color", "#f8fafc")
        mapRef.current.setPaintProperty(roofSourceId, "fill-opacity", 0.1)
        return
      }

      if (mapRef.current.getPaintProperty(roofSourceId, "fill-color") !== "transparent") {
        mapRef.current.setPaintProperty(roofSourceId, "fill-color", "#500724")
        mapRef.current.setPaintProperty(`roof-border-${address}-${roof.id}`, "line-color", "#f8fafc")
        mapRef.current.setPaintProperty(roofSourceId, "fill-opacity", 0)
        mapRef.current.setPaintProperty(`roof-circle-${address}-${roof.id}`, "circle-color", "transparent")
        mapRef.current.setLayoutProperty(`roof-text-${address}-${roof.id}`, "text-field", "X")
      }
    }
  }

  const handleRoofMouseLeave = (roofSourceId: string, roof: Roof) => {
    if (mapRef.current) {
      if (mapRef.current.getPaintProperty(`roof-border-${address}-${roof.id}`, "line-dasharray")) {
        mapRef.current.setPaintProperty(roofSourceId, "fill-color", "transparent")
        return
      }

      if (mapRef.current.getPaintProperty(roofSourceId, "fill-color") !== "transparent") {
        mapRef.current.setPaintProperty(roofSourceId, "fill-color", "#8fe03f")
        mapRef.current.setPaintProperty(`roof-border-${address}-${roof.id}`, "line-color", "#a3e635")
        mapRef.current.setPaintProperty(roofSourceId, "fill-opacity", 0.4)
        mapRef.current.setPaintProperty(`roof-circle-${address}-${roof.id}`, "circle-color", "#1e3a8a")
        mapRef.current.setLayoutProperty(`roof-text-${address}-${roof.id}`, "text-field", `${roof.id}`)
      }
    }
  }

  const handleRoofClick = (roofSourceId: string, roof: Roof) => {
    if (mapRef.current) {
      const currentColor = mapRef.current.getPaintProperty(roofSourceId, "fill-color")
      if (currentColor !== "#f8fafc" && currentColor !== "transparent") {
        toggleAllPanels(roof, false)
      } else {
        toggleAllPanels(roof, true)
      }
      // Stop event propagation to ensure it doesn't affect underlying layers
      mapRef.current.getCanvas().style.cursor = ""
    }
  }

  const addPanelLayers = (roof: Roof, roofPolygonData: Feature<Geometry, GeoJsonProperties>) => {
    if (roof.panelCoords) {
      roof.panelCoords.forEach((panelCoords: any, index: number) => {
        addPanel(roof, panelCoords, index)
      })

      setSelectedPanels((prevSelectedPanels: SelectedPanelsState) => {
        const updatedRoofPanels = roof.panelCoords.map((_: any, index: number) => ({
          panelID: index,
          selected: true,
        }))

        return { ...prevSelectedPanels, [roof.id]: updatedRoofPanels }
      })
    }
  }

  const addCenterSourceAndLayer = (roofId: number, center: Feature<Geometry, GeoJsonProperties>) => {
    const centerSourceId = `roof-circle-${address}-${roofId}`

    mapRef.current?.addSource(centerSourceId, {
      type: "geojson",
      data: center,
    })

    if (!mapRef.current?.getLayer(centerSourceId)) {
      mapRef.current?.addLayer({
        id: centerSourceId,
        type: "circle",
        source: centerSourceId,
        paint: {
          "circle-radius": 30,
          "circle-color": "#1e3a8a",
          "circle-stroke-width": 4,
          "circle-stroke-color": "white",
        },
      })
    }

    const textLayerId = `roof-text-${address}-${roofId}`

    mapRef.current?.addLayer({
      id: textLayerId,
      type: "symbol",
      source: centerSourceId,
      layout: {
        "text-field": `${roofId}`,
        "text-size": 20,
      },
      paint: {
        "text-color": "white",
      },
    })
  }

  useEffect(() => {
    console.log(selectedPanels)
  }, [selectedPanels])

  const togglePanel = (panelId: string, roof: Roof, index: number) => {
    setSelectedPanels((prevSelectedPanels) => {
      const updatedRoof = [...prevSelectedPanels[roof.id]]
      updatedRoof[index] = { ...updatedRoof[index], selected: !updatedRoof[index].selected }

      const allUnselected = updatedRoof.every((panel) => !panel.selected)

      // Update the paint properties based on the new selected state
      const newColor = updatedRoof[index].selected ? "#1e40af" : "transparent"
      const panelLineId = `${panelId}-line`

      mapRef.current?.setPaintProperty(panelId, "fill-color", newColor)
      mapRef.current?.setPaintProperty(panelLineId, "line-width", updatedRoof[index].selected ? 1 : 2)
      mapRef.current?.setPaintProperty(panelLineId, "line-color", updatedRoof[index].selected ? "white" : "#020617")
      mapRef.current?.setPaintProperty(panelLineId, "line-opacity", updatedRoof[index].selected ? 0.5 : 1)
      mapRef.current?.setPaintProperty(panelLineId, "line-dasharray", updatedRoof[index].selected ? null : [2, 2])

      if (allUnselected) {
        toggleAllPanels(roof, false)
      }

      return { ...prevSelectedPanels, [roof.id]: updatedRoof }
    })
  }

  const addPanel = (roof: Roof, panelCoords: any, index: number) => {
    const panelFeature: Feature<Geometry, GeoJsonProperties> = {
      type: "Feature",
      properties: { panelId: index },
      geometry: { type: "Polygon", coordinates: [panelCoords] },
    }

    const panelId = `panels-${address}-${roof.id}-${index}`

    const panelLineId = `${panelId}-line`

    mapRef.current?.addSource(panelId, {
      type: "geojson",
      data: panelFeature,
    })

    // Check if the panel layer exists
    mapRef.current?.addLayer(
      {
        id: panelId,
        type: "fill",
        source: panelId,
        paint: {
          "fill-color": "#020617",
          "fill-opacity": 1,
        },
      },
      `roof-circle-${address}-${roof.id}`
    )

    mapRef.current?.addLayer(
      {
        id: panelLineId,
        type: "line",
        source: panelId,
        paint: {
          "line-color": "white",
          "line-opacity": 0.5,
        },
      },
      `roof-circle-${address}-${roof.id}`
    )

    const clickHandler = () => togglePanel(panelId, roof, index)

    const mouseEnterHandler = () => {
      if (mapRef.current) {
        mapRef.current.getCanvas().style.cursor = "pointer"
        const currentColor = mapRef.current?.getPaintProperty(panelId, "fill-color")
        if (currentColor !== "transparent") {
          mapRef.current?.setPaintProperty(panelId, "fill-color", "#1e40af")
        }
      }
    }
    const mouseLeaveHandler = () => {
      if (mapRef.current) {
        mapRef.current.getCanvas().style.cursor = ""
        const currentColor = mapRef.current?.getPaintProperty(panelId, "fill-color")
        if (currentColor !== "transparent") {
          mapRef.current?.setPaintProperty(panelId, "fill-color", "#020617")
        }
      }
    }

    // Store event handlers for cleanup

    panelEventHandlers.current[panelId] = {
      click: clickHandler,
      mouseenter: mouseEnterHandler,
      mouseleave: mouseLeaveHandler,
    }

    mapRef.current?.on("click", panelId, clickHandler)
    mapRef.current?.on("mouseenter", panelId, mouseEnterHandler)
    mapRef.current?.on("mouseleave", panelId, mouseLeaveHandler)
  }

  useEffect(() => {
    getEnv().then((env) => {
      const token = env.NEXT_PUBLIC_DOCKER_MAPBOX_ACCESS_TOKEN!
      initMap(token)
    })
    return () => mapRef?.current?.remove()
  }, [])

  return (
    <div className='h-screen flex flex-col items-stretch'>
      <form onSubmit={fetchCoordinates}>
        <Input
          className='my-2 w-1/3 mx-auto'
          placeholder='Enter address'
          value={address}
          onChange={(e) => setAddress(e.target.value)}
        />
      </form>
      <div id='map-container' className='flex-1'></div>
    </div>
  )
}

export default BuildingMap

Demo

d15a71d1-8f46-4ae6-a951-5aa405b967ae.webm