"use client" import { useEffect, useState, useRef, useCallback } from "react" import { useMap } from "react-map-gl/mapbox" import { CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from "@/app/_utils/const/map" import { $Enums } from "@prisma/client" import DistrictPopup from "../pop-up/district-popup" import { ICrimes } from "@/app/_utils/types/crimes" // Types for district properties export interface DistrictFeature { id: string name: string properties: Record longitude: number latitude: number number_of_crime: number level: $Enums.crime_rates demographics: { number_of_unemployed: number population: number population_density: number year: number } geographics: { address: string land_area: number year: number latitude: number longitude: number } crime_incidents: Array<{ id: string timestamp: Date description: string status: string category: string type: string address: string latitude: number longitude: number }> selectedYear?: string selectedMonth?: string } // Updated interface to match the structure in crimes.ts // export interface ICrimeData { // id: string // district_id: string // districts: { // name: string // geographics: { // address: string | null // land_area: number | null // year: number | null // latitude: number // longitude: number // }[] // demographics: { // number_of_unemployed: number // population: number // population_density: number // year: number // }[] // } // number_of_crime: number // level: $Enums.crime_rates // score: number // month: number // year: number // crime_incidents: Array<{ // id: string // timestamp: Date // description: string // status: string // crime_categories: { // name: string // type: string | null // } // locations: { // address: string | null // latitude: number // longitude: number // } // }> // } // District layer props export interface DistrictLayerProps { visible?: boolean onClick?: (feature: DistrictFeature) => void year?: string month?: string filterCategory?: string | "all" crimes?: ICrimes[] tilesetId?: string } export default function DistrictLayer({ visible = true, onClick, year, month, filterCategory = "all", crimes = [], tilesetId = MAPBOX_TILESET_ID, }: DistrictLayerProps) { const { current: map } = useMap() const [hoverInfo, setHoverInfo] = useState<{ x: number y: number feature: any } | null>(null) const selectedDistrictRef = useRef(null) const [selectedDistrict, setSelectedDistrict] = useState(null) const layersAdded = useRef(false) const crimeDataByDistrict = crimes.reduce( (acc, crime) => { const districtId = crime.district_id acc[districtId] = { number_of_crime: crime.number_of_crime, level: crime.level, } return acc }, {} as Record, ) const handleDistrictClick = (e: any) => { const incidentFeatures = map?.queryRenderedFeatures(e.point, { layers: ["unclustered-point", "clusters"] }); if (incidentFeatures && incidentFeatures.length > 0) { return; } if (!map || !e.features || e.features.length === 0) return const feature = e.features[0] const districtId = feature.properties.kode_kec const crimeData = crimeDataByDistrict[districtId] || {} let crime_incidents: Array<{ id: string timestamp: Date description: string status: string category: string type: string address: string latitude: number longitude: number }> = [] const districtCrimes = crimes.filter(crime => crime.district_id === districtId) districtCrimes.forEach(crimeRecord => { if (crimeRecord && crimeRecord.crime_incidents) { const incidents = crimeRecord.crime_incidents.map(incident => ({ id: incident.id, timestamp: incident.timestamp, description: incident.description || "", status: incident.status || "", category: incident.crime_categories?.name || "", type: incident.crime_categories?.type || "", address: incident.locations?.address || "", latitude: incident.locations?.latitude || 0, longitude: incident.locations?.longitude || 0 })) crime_incidents = [...crime_incidents, ...incidents] } }) const firstDistrictCrime = districtCrimes.length > 0 ? districtCrimes[0] : null const demographics = firstDistrictCrime?.districts.demographics?.[0] const geographics = firstDistrictCrime?.districts.geographics?.[0] const clickLng = e.lngLat ? e.lngLat.lng : null const clickLat = e.lngLat ? e.lngLat.lat : null if (!geographics) { console.error("Missing geographics data for district:", districtId) return } if (!demographics) { console.error("Missing demographics data for district:", districtId) return } const district: DistrictFeature = { id: districtId, name: feature.properties.nama || feature.properties.kecamatan || "Unknown District", properties: feature.properties, longitude: geographics.longitude || clickLng || 0, latitude: geographics.latitude || clickLat || 0, number_of_crime: crimeData.number_of_crime || 0, level: crimeData.level || $Enums.crime_rates.low, demographics: { number_of_unemployed: demographics.number_of_unemployed, population: demographics.population, population_density: demographics.population_density, year: demographics.year, }, geographics: { address: geographics.address || "", land_area: geographics.land_area || 0, year: geographics.year || 0, latitude: geographics.latitude, longitude: geographics.longitude, }, crime_incidents: crime_incidents || [], selectedYear: year, selectedMonth: month } if (!district.longitude || !district.latitude) { console.error("Invalid district coordinates:", district); return; } selectedDistrictRef.current = district; if (onClick) { onClick(district); } else { setSelectedDistrict(district); } } 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]); 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 handleCloseDistrictPopup = useCallback(() => { selectedDistrictRef.current = null; setSelectedDistrict(null); }, []); useEffect(() => { if (!map || !visible) return; const onStyleLoad = () => { if (!map) return; try { if (!map.getMap().getSource("districts")) { const layers = map.getStyle().layers let firstSymbolId: string | undefined for (const layer of layers) { if (layer.type === "symbol") { firstSymbolId = layer.id break } } map.getMap().addSource("districts", { type: "vector", url: `mapbox://${tilesetId}`, }) const fillColorExpression: any = [ "case", ["has", "kode_kec"], [ "match", ["get", "kode_kec"], ...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { return [ districtId, data.level === "low" ? CRIME_RATE_COLORS.low : data.level === "medium" ? CRIME_RATE_COLORS.medium : data.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, ] }), CRIME_RATE_COLORS.default, ], CRIME_RATE_COLORS.default, ] if (!map.getMap().getLayer("district-fill")) { map.getMap().addLayer( { id: "district-fill", type: "fill", source: "districts", "source-layer": "Districts", paint: { "fill-color": fillColorExpression, "fill-opacity": 0.6, }, }, firstSymbolId, ) } if (!map.getMap().getLayer("district-line")) { map.getMap().addLayer( { id: "district-line", type: "line", source: "districts", "source-layer": "Districts", paint: { "line-color": "#ffffff", "line-width": 1, "line-opacity": 0.5, }, }, firstSymbolId, ) } if (crimes.length > 0 && !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": "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, }, 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": "visible", } }, firstSymbolId, ) } // Add improved mouse interaction for clusters and points 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 = "" }) // Also add hover effect for district fill map.on("mouseenter", "district-fill", () => { map.getCanvas().style.cursor = "pointer" }) map.on("mouseleave", "district-fill", () => { map.getCanvas().style.cursor = "" }) // Remove old event listeners to avoid duplicates map.off("click", "clusters", handleClusterClick); map.off("click", "unclustered-point", handleIncidentClick); // Add event listeners map.on("click", "clusters", handleClusterClick); map.on("click", "unclustered-point", handleIncidentClick); map.off("click", "district-fill", handleDistrictClick); map.on("click", "district-fill", handleDistrictClick); map.on("mouseleave", "district-fill", () => setHoverInfo(null)); layersAdded.current = true; } } else { if (map.getMap().getLayer("district-fill")) { map.getMap().setPaintProperty("district-fill", "fill-color", [ "case", ["has", "kode_kec"], [ "match", ["get", "kode_kec"], ...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { return [ districtId, data.level === "low" ? CRIME_RATE_COLORS.low : data.level === "medium" ? CRIME_RATE_COLORS.medium : data.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, ] }), CRIME_RATE_COLORS.default, ], CRIME_RATE_COLORS.default, ] as any) } } } catch (error) { console.error("Error adding district layers:", error) } }; if (map.isStyleLoaded()) { onStyleLoad(); } else { map.once("style.load", onStyleLoad); } return () => { if (map) { map.off("click", "district-fill", handleDistrictClick); } }; }, [map, visible, tilesetId, crimes, filterCategory, year, month, handleIncidentClick, handleClusterClick]); useEffect(() => { if (!map || !layersAdded.current) return try { if (map.getMap().getLayer("district-fill")) { const colorEntries = Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { if (!data || !data.level) { return [ districtId, CRIME_RATE_COLORS.default ] } return [ districtId, data.level === "low" ? CRIME_RATE_COLORS.low : data.level === "medium" ? CRIME_RATE_COLORS.medium : data.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, ] }) const fillColorExpression = [ "case", ["has", "kode_kec"], [ "match", ["get", "kode_kec"], ...colorEntries, CRIME_RATE_COLORS.default, ], CRIME_RATE_COLORS.default, ] as any map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression) } } catch (error) { console.error("Error updating district layer:", error) } }, [map, crimes, crimeDataByDistrict]) useEffect(() => { if (!map || !map.getMap().getSource("crime-incidents")) return try { 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) }); (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]) useEffect(() => { if (selectedDistrictRef.current) { const districtId = selectedDistrictRef.current.id; const crimeData = crimeDataByDistrict[districtId] || {}; const districtCrime = crimes.find(crime => crime.district_id === districtId); if (districtCrime) { const demographics = districtCrime.districts.demographics?.[0]; const geographics = districtCrime.districts.geographics?.[0]; if (!demographics || !geographics) { console.error("Missing district data:", { demographics, geographics }); return; } const crime_incidents = districtCrime.crime_incidents .filter(incident => filterCategory === "all" || incident.crime_categories.name === filterCategory ) .map(incident => ({ id: incident.id, timestamp: incident.timestamp, description: incident.description, status: incident.status || "", category: incident.crime_categories.name, type: incident.crime_categories.type || "", address: incident.locations.address || "", latitude: incident.locations.latitude, longitude: incident.locations.longitude })); const updatedDistrict: DistrictFeature = { ...selectedDistrictRef.current, number_of_crime: crimeData.number_of_crime || 0, level: crimeData.level || selectedDistrictRef.current.level, demographics: { number_of_unemployed: demographics.number_of_unemployed, population: demographics.population, population_density: demographics.population_density, year: demographics.year, }, geographics: { address: geographics.address || "", land_area: geographics.land_area || 0, year: geographics.year || 0, latitude: geographics.latitude, longitude: geographics.longitude, }, crime_incidents, selectedYear: year, selectedMonth: month }; selectedDistrictRef.current = updatedDistrict; setSelectedDistrict(prevDistrict => { if (prevDistrict?.id === updatedDistrict.id && prevDistrict?.selectedYear === updatedDistrict.selectedYear && prevDistrict?.selectedMonth === updatedDistrict.selectedMonth) { return prevDistrict; } return updatedDistrict; }); } } }, [crimes, filterCategory, year, month]); if (!visible) return null return ( <> {selectedDistrictRef.current ? ( ) : null} ) }