"use client" import { useEffect, useRef, useCallback } from "react" import { useMap } from "react-map-gl/mapbox" import type { ICrimes } from "@/app/_utils/types/crimes" export interface CrimeClusterLayerProps { visible?: boolean crimes: ICrimes[] filterCategory: string | "all" isTimelapsePlaying?: boolean beforeId?: string } export default function CrimeClusterLayer({ visible = true, crimes = [], filterCategory = "all", isTimelapsePlaying = false, beforeId, }: CrimeClusterLayerProps) { const { current: map } = useMap() const layersAdded = useRef(false) const handleClusterClick = useCallback( (e: any) => { if (!map) return e.originalEvent.stopPropagation() e.preventDefault() const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] }) if (!features || features.length === 0) return const clusterId: number = features[0].properties?.cluster_id as number ; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(clusterId, (err, zoom) => { if (err) return map.easeTo({ center: (features[0].geometry as any).coordinates, zoom: zoom ?? undefined, }) }) }, [map], ) const handleIncidentClick = useCallback( (e: any) => { if (!map) return const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] }) if (!features || features.length === 0) return const incident = features[0] if (!incident.properties) return e.originalEvent.stopPropagation() e.preventDefault() const incidentDetails = { id: incident.properties.id, district: incident.properties.district, category: incident.properties.category, type: incident.properties.incidentType, description: incident.properties.description, status: incident.properties?.status || "Unknown", longitude: (incident.geometry as any).coordinates[0], latitude: (incident.geometry as any).coordinates[1], timestamp: new Date(), } console.log("Incident clicked:", incidentDetails) const customEvent = new CustomEvent("incident_click", { detail: incidentDetails, bubbles: true, }) if (map.getMap().getCanvas()) { map.getMap().getCanvas().dispatchEvent(customEvent) } else { document.dispatchEvent(customEvent) } }, [map], ) // Initialize crime clusters and points useEffect(() => { if (!map || !visible || crimes.length === 0) return const onStyleLoad = () => { if (!map) return try { // Get the first symbol layer let firstSymbolId = beforeId if (!firstSymbolId) { const layers = map.getStyle().layers for (const layer of layers) { if (layer.type === "symbol") { firstSymbolId = layer.id break } } } if (!map.getMap().getSource("crime-incidents")) { const allIncidents = crimes.flatMap((crime) => { let filteredIncidents = crime.crime_incidents if (filterCategory !== "all") { filteredIncidents = crime.crime_incidents.filter( (incident) => incident.crime_categories.name === filterCategory, ) } return filteredIncidents.map((incident) => ({ type: "Feature" as const, properties: { id: incident.id, district: crime.districts.name, category: incident.crime_categories.name, incidentType: incident.crime_categories.type, level: crime.level, description: incident.description, }, geometry: { type: "Point" as const, coordinates: [incident.locations.longitude, incident.locations.latitude], }, })) }) map.getMap().addSource("crime-incidents", { type: "geojson", data: { type: "FeatureCollection", features: allIncidents, }, cluster: true, clusterMaxZoom: 14, clusterRadius: 50, }) if (!map.getMap().getLayer("clusters")) { map.getMap().addLayer( { id: "clusters", type: "circle", source: "crime-incidents", filter: ["has", "point_count"], paint: { "circle-color": ["step", ["get", "point_count"], "#51bbd6", 5, "#f1f075", 15, "#f28cb1"], "circle-radius": ["step", ["get", "point_count"], 20, 5, 30, 15, 40], "circle-opacity": 0.75, }, layout: { visibility: isTimelapsePlaying ? "none" : "visible", }, }, firstSymbolId, ) } if (!map.getMap().getLayer("cluster-count")) { map.getMap().addLayer({ id: "cluster-count", type: "symbol", source: "crime-incidents", filter: ["has", "point_count"], layout: { "text-field": "{point_count_abbreviated}", "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"], "text-size": 12, "visibility": isTimelapsePlaying ? "none" : "visible", }, paint: { "text-color": "#ffffff", }, }) } if (!map.getMap().getLayer("unclustered-point")) { map.getMap().addLayer( { id: "unclustered-point", type: "circle", source: "crime-incidents", filter: ["!", ["has", "point_count"]], paint: { "circle-color": "#11b4da", "circle-radius": 8, "circle-stroke-width": 1, "circle-stroke-color": "#fff", }, layout: { visibility: isTimelapsePlaying ? "none" : "visible", }, }, firstSymbolId, ) } // Add event handlers map.on("mouseenter", "clusters", () => { map.getCanvas().style.cursor = "pointer" }) map.on("mouseleave", "clusters", () => { map.getCanvas().style.cursor = "" }) map.on("mouseenter", "unclustered-point", () => { map.getCanvas().style.cursor = "pointer" }) map.on("mouseleave", "unclustered-point", () => { map.getCanvas().style.cursor = "" }) // Attach click handlers map.off("click", "clusters", handleClusterClick) map.off("click", "unclustered-point", handleIncidentClick) map.on("click", "clusters", handleClusterClick) map.on("click", "unclustered-point", handleIncidentClick) layersAdded.current = true } } catch (error) { console.error("Error adding crime cluster layers:", error) } } if (map.isStyleLoaded()) { onStyleLoad() } else { map.once("style.load", onStyleLoad) } return () => { if (map) { map.off("click", "clusters", handleClusterClick) map.off("click", "unclustered-point", handleIncidentClick) } } }, [map, visible, crimes, filterCategory, handleClusterClick, handleIncidentClick, beforeId, isTimelapsePlaying]) // Update crime data when filters change useEffect(() => { if (!map || !map.getMap().getSource("crime-incidents")) return try { // If timeline is playing, hide all point/cluster layers to improve performance if (isTimelapsePlaying) { // Hide all incident points during timelapse if (map.getMap().getLayer("clusters")) { map.getMap().setLayoutProperty("clusters", "visibility", "none") } if (map.getMap().getLayer("unclustered-point")) { map.getMap().setLayoutProperty("unclustered-point", "visibility", "none") } if (map.getMap().getLayer("cluster-count")) { map.getMap().setLayoutProperty("cluster-count", "visibility", "none") } // Update the source with empty data to free up resources ; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({ type: "FeatureCollection", features: [], }) } else { // When not playing, show all layers again if (map.getMap().getLayer("clusters")) { map.getMap().setLayoutProperty("clusters", "visibility", "visible") } if (map.getMap().getLayer("unclustered-point")) { map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible") } if (map.getMap().getLayer("cluster-count")) { map.getMap().setLayoutProperty("cluster-count", "visibility", "visible") } // Restore detailed incidents when timelapse stops const allIncidents = crimes.flatMap((crime) => { if (!crime.crime_incidents) return [] let filteredIncidents = crime.crime_incidents if (filterCategory !== "all") { filteredIncidents = crime.crime_incidents.filter( (incident) => incident.crime_categories && incident.crime_categories.name === filterCategory, ) } return filteredIncidents .map((incident) => { if (!incident.locations) { console.warn("Missing location for incident:", incident.id) return null } return { type: "Feature" as const, properties: { id: incident.id, district: crime.districts?.name || "Unknown", category: incident.crime_categories?.name || "Unknown", incidentType: incident.crime_categories?.type || "Unknown", level: crime.level || "low", description: incident.description || "", }, geometry: { type: "Point" as const, coordinates: [incident.locations.longitude || 0, incident.locations.latitude || 0], }, } }) .filter(Boolean) }) // Update the source with detailed data ; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({ type: "FeatureCollection", features: allIncidents as GeoJSON.Feature[], }) } } catch (error) { console.error("Error updating incident data:", error) } }, [map, crimes, filterCategory, isTimelapsePlaying]) return null }