mapbox-gl-js
mapbox-gl-js copied to clipboard
How to prevent event of top layer to trigger the bottom one?
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