"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import type { ICrimes } from "@/app/_utils/types/crimes"; import { BASE_BEARING, BASE_DURATION, BASE_PITCH, BASE_ZOOM, PITCH_3D, ZOOM_3D, } from "@/app/_utils/const/map"; import IncidentPopup from "../pop-up/incident-popup"; import type mapboxgl from "mapbox-gl"; import type { MapGeoJSONFeature, MapMouseEvent } from "react-map-gl/mapbox"; import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility"; interface IAllIncidentsLayerProps { visible?: boolean; map: mapboxgl.Map | null; crimes: ICrimes[]; filterCategory: string | "all"; } interface IIncidentFeatureProperties { id: string; category: string; description: string; timestamp: string; district: string; district_id: string; year: number; month: number; address: string | null; latitude: number; longitude: number; } interface IIncidentDetails { id: string; category: string; description: string; timestamp: Date; district: string; district_id: string; year: number; month: number; address: string | null; latitude: number; longitude: number; } export default function AllIncidentsLayer( { visible = false, map, crimes = [], filterCategory = "all" }: IAllIncidentsLayerProps, ) { const isInteractingWithMarker = useRef(false); const animationFrameRef = useRef(null); const [selectedIncident, setSelectedIncident] = useState< IIncidentDetails | null >(null); // Define layer IDs for consistent management const LAYER_IDS = [ "all-incidents-pulse", "all-incidents-circles", "all-incidents", ]; const handleIncidentClick = useCallback( (e: MapMouseEvent & { features?: MapGeoJSONFeature[] }) => { if (!map) return; const features = map.queryRenderedFeatures(e.point, { layers: ["all-incidents"], }); if (!features || features.length === 0) return; // Stop event propagation e.originalEvent.stopPropagation(); e.preventDefault(); isInteractingWithMarker.current = true; const incident = features[0]; if (!incident.properties) return; const props = incident .properties as unknown as IIncidentFeatureProperties; const IincidentDetails: IIncidentDetails = { id: props.id, description: props.description, category: props.category, district: props.district, district_id: props.district_id, year: props.year, month: props.month, address: props.address, latitude: props.latitude, longitude: props.longitude, timestamp: new Date(props.timestamp || Date.now()), }; // Fly to the incident location map.flyTo({ center: [IincidentDetails.longitude, IincidentDetails.latitude], zoom: ZOOM_3D, bearing: BASE_BEARING, pitch: PITCH_3D, duration: BASE_DURATION, }); // Set selected incident for the popup setSelectedIncident(IincidentDetails); // Reset the flag after a delay setTimeout(() => { isInteractingWithMarker.current = false; }, 1000); }, [map], ); // Handle popup close const handleClosePopup = useCallback(() => { if (!map) return; map.easeTo({ zoom: BASE_ZOOM, bearing: BASE_BEARING, pitch: BASE_PITCH, duration: BASE_DURATION, }); setSelectedIncident(null); }, [map]); // Effect to manage layer visibility consistently useEffect(() => { const cleanup = manageLayerVisibility(map, LAYER_IDS, visible, () => { // When layers become invisible, close any open popup if (!visible) setSelectedIncident(null); // Cancel animation frame when hiding the layer if (!visible && animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } }); return cleanup; }, [visible, map]); useEffect(() => { if (!map || !visible) return; // Convert incidents to GeoJSON format const allIncidents = crimes.flatMap((crime) => { return crime.crime_incidents .filter((incident) => // Apply category filter if specified (filterCategory === "all" || incident.crime_categories?.name === filterCategory) && // Make sure we have valid location data incident.locations?.latitude && incident.locations?.longitude ) .map((incident) => ({ type: "Feature" as const, geometry: { type: "Point" as const, coordinates: [ incident.locations!.longitude, incident.locations!.latitude, ], }, properties: { id: incident.id, description: incident.description || "No description", timestamp: incident.timestamp?.toString() || new Date().toString(), category: incident.crime_categories?.name || "Unknown", district: crime.districts?.name || "Unknown", district_id: crime.district_id, year: crime.year, month: crime.month, address: incident.locations?.address || null, latitude: incident.locations!.latitude, longitude: incident.locations!.longitude, }, })); }); const incidentsGeoJSON = { type: "FeatureCollection" as const, features: allIncidents, }; const setupLayersAndSources = () => { try { // Check if source already exists and update it if (map.getSource("all-incidents-source")) { const source = map.getSource( "all-incidents-source", ) as mapboxgl.GeoJSONSource; source.setData(incidentsGeoJSON); } else { // Add source if it doesn't exist map.addSource("all-incidents-source", { type: "geojson", data: incidentsGeoJSON, }); // Get first symbol layer for insertion order const layers = map.getStyle().layers; let firstSymbolId: string | undefined; for (const layer of layers) { if (layer.type === "symbol") { firstSymbolId = layer.id; break; } } // Pulsing circle effect for very recent incidents map.addLayer({ id: "all-incidents-pulse", type: "circle", source: "all-incidents-source", paint: { "circle-radius": [ "interpolate", ["linear"], ["zoom"], 10, 10, 15, 20, ], "circle-color": [ "match", ["get", "category"], "Theft", "#FF5733", "Assault", "#C70039", "Robbery", "#900C3F", "Burglary", "#581845", "Fraud", "#FFC300", "Homicide", "#FF0000", // Default color for other categories "#2874A6", ], "circle-opacity": 0.4, "circle-blur": 0.6, }, }, firstSymbolId); // Background circle for all incidents map.addLayer({ id: "all-incidents-circles", type: "circle", source: "all-incidents-source", paint: { "circle-radius": [ "interpolate", ["linear"], ["zoom"], 10, 5, 15, 10, ], "circle-color": [ "match", ["get", "category"], "Theft", "#FF5733", "Assault", "#C70039", "Robbery", "#900C3F", "Burglary", "#581845", "Fraud", "#FFC300", "Homicide", "#FF0000", // Default color for other categories "#2874A6", ], "circle-stroke-width": 1, "circle-stroke-color": "#FFFFFF", "circle-opacity": 0.6, }, }, firstSymbolId); // Main incident point map.addLayer({ id: "all-incidents", type: "circle", source: "all-incidents-source", paint: { "circle-radius": [ "interpolate", ["linear"], ["zoom"], 10, 3, 15, 6, ], "circle-color": [ "match", ["get", "category"], "Theft", "#FF5733", "Assault", "#C70039", "Robbery", "#900C3F", "Burglary", "#581845", "Fraud", "#FFC300", "Homicide", "#FF0000", // Default color for other categories "#2874A6", ], "circle-stroke-width": 1, "circle-stroke-color": "#FFFFFF", "circle-opacity": 1, }, }, firstSymbolId); } // Add mouse events map.on("mouseenter", "all-incidents", () => { map.getCanvas().style.cursor = "pointer"; }); map.on("mouseleave", "all-incidents", () => { map.getCanvas().style.cursor = ""; }); map.on("click", "all-incidents", handleIncidentClick); } catch (error) { console.error("Error setting up all incidents layer:", error); } }; // Set up layers when the map is ready if (map.isStyleLoaded()) { setupLayersAndSources(); } else { map.once("load", setupLayersAndSources); } // Start the pulse animation effect const animatePulse = () => { if (!map || !visible || !map.getLayer("all-incidents-pulse")) { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } return; } const pulseSize = 10 + 5 * Math.sin(Date.now() / 500); try { map.setPaintProperty("all-incidents-pulse", "circle-radius", [ "interpolate", ["linear"], ["zoom"], 10, pulseSize, 15, pulseSize * 2, ]); animationFrameRef.current = requestAnimationFrame(animatePulse); } catch (error) { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } } }; animationFrameRef.current = requestAnimationFrame(animatePulse); // Clean up event listeners and animation return () => { if (map) { map.off("click", "all-incidents", handleIncidentClick); map.off("mouseenter", "all-incidents", () => { map.getCanvas().style.cursor = "pointer"; }); map.off("mouseleave", "all-incidents", () => { map.getCanvas().style.cursor = ""; }); } if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } }; }, [map, visible, crimes, filterCategory, handleIncidentClick]); return ( <> {selectedIncident && ( )} ); }