MIF_E31221222/sigap-website/app/_components/map/layers/district-layer.tsx

801 lines
32 KiB
TypeScript

"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"
// Types for district properties
export interface DistrictFeature {
id: string
name: string
properties: Record<string, any>
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 returned by getCrimeByYearAndMonth
export interface ICrimeData {
id: string
district_id: string
districts: {
name: string
geographics: {
address: string
land_area: number
year: number
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
}
locations: {
address: string
latitude: number
longitude: number
}
}>
}
// District layer props
export interface DistrictLayerProps {
visible?: boolean
onClick?: (feature: DistrictFeature) => void
year?: string
month?: string
filterCategory?: string | "all"
crimes?: ICrimeData[]
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)
// Menggunakan useRef untuk menyimpan informasi distrik yang dipilih
// sehingga nilainya tidak hilang saat komponen di-render ulang
const selectedDistrictRef = useRef<DistrictFeature | null>(null)
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
// Use a ref to track whether layers have been added
const layersAdded = useRef(false)
// Process crime data to map to districts by district_id (kode_kec)
const crimeDataByDistrict = crimes.reduce(
(acc, crime) => {
// Use district_id (which corresponds to kode_kec in the tileset) as the key
const districtId = crime.district_id
acc[districtId] = {
number_of_crime: crime.number_of_crime,
level: crime.level,
}
return acc
},
{} as Record<string, { number_of_crime?: number; level?: $Enums.crime_rates }>,
)
// Handle click on district
const handleClick = (e: any) => {
if (!map || !e.features || e.features.length === 0) return
const feature = e.features[0]
const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier
const crimeData = crimeDataByDistrict[districtId] || {}
// Get ALL crime_incidents for this district by aggregating from all matching crime records
let crime_incidents: Array<{
id: string
timestamp: Date
description: string
status: string
category: string
type: string
address: string
latitude: number
longitude: number
}> = []
// Find all crime data records for this district (across all months)
const districtCrimes = crimes.filter(crime => crime.district_id === districtId)
console.log(`Found ${districtCrimes.length} crime data records for district ID ${districtId}`)
// Collect all crime incidents from all month records for this district
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]
}
})
console.log(`Aggregated ${crime_incidents.length} total crime incidents for district`)
// Get demographics and geographics from the first record (should be the same across all records)
const firstDistrictCrime = districtCrimes.length > 0 ? districtCrimes[0] : null
const demographics = firstDistrictCrime?.districts.demographics
const geographics = firstDistrictCrime?.districts.geographics
// Make sure we have valid coordinates from the click event
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
}
// Create a complete district object ensuring all required properties are present
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,
// Use the total crime count across all months
number_of_crime: crimeData.number_of_crime || 0,
// Use the level from the currently selected month/year (or default to low)
level: crimeData.level || $Enums.crime_rates.low,
demographics,
geographics,
// Include all aggregated crime incidents
crime_incidents: crime_incidents || [],
selectedYear: year,
selectedMonth: month
}
if (!district.longitude || !district.latitude) {
console.error("Invalid district coordinates:", district);
return;
}
console.log("Selected district:", district);
console.log(`Selected district has ${district.crime_incidents?.length || 0} crime_incidents out of ${district.number_of_crime} total crimes`);
// Set the reference BEFORE handling the onClick or setState
selectedDistrictRef.current = district;
if (onClick) {
onClick(district);
} else {
setSelectedDistrict(district);
}
}
// Pastikan event handler klik selalu diperbarui
// dan re-attach setiap kali ada perubahan data
useEffect(() => {
if (!map || !visible || !map.getMap().getLayer("district-fill")) return;
// Re-attach click handler
map.off("click", "district-fill", handleClick);
map.on("click", "district-fill", handleClick);
console.log("Re-attached click handler, current district:", selectedDistrict?.name || "None");
return () => {
if (map) {
map.off("click", "district-fill", handleClick);
}
};
}, [map, visible, crimes, filterCategory, year, month]);
// Add district layer to the map when it's loaded
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,
},
},
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",
},
},
firstSymbolId,
)
}
map.on("click", "clusters", (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })
if (!features || features.length === 0) return
const clusterId = features[0].properties?.cluster_id
; (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.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 = ""
})
}
// Rebind the click event listener for "district-fill"
map.on("click", "district-fill", handleClick);
// Ensure hover info is cleared when leaving the layer
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) {
// Remove the click event listener when the component unmounts or dependencies change
map.off("click", "district-fill", handleClick);
}
};
}, [map, visible, tilesetId, crimes, filterCategory, year, month]);
useEffect(() => {
if (!map || !layersAdded.current) return
try {
if (map.getMap().getLayer("district-fill")) {
// Create a safety check for empty or invalid data
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) => {
// Make sure we handle cases where crime_incidents might be undefined
if (!crime.crime_incidents) return []
// Apply category filter if specified
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) => {
// Handle possible null/undefined values
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) // Remove null values
});
(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])
// Effect khusus untuk memastikan state selectedDistrict dipertahankan
// ketika data berubah (tahun/bulan diubah)
useEffect(() => {
// Jika kita memiliki district yang dipilih dalam ref, pastikan
// state juga diperbarui dengan data terbaru
if (selectedDistrictRef.current) {
// Cari crime data terbaru untuk district yang dipilih
const districtId = selectedDistrictRef.current.id;
const crimeData = crimeDataByDistrict[districtId] || {};
// Cari data district terkini
const districtCrime = crimes.find(crime => crime.district_id === districtId);
// Perbarui data district dengan informasi terkini
if (districtCrime) {
const demographics = districtCrime.districts.demographics;
const geographics = districtCrime.districts.geographics;
// Filter crime_incidents
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
}));
// Buat district object baru dengan data terkini
const updatedDistrict: DistrictFeature = {
...selectedDistrictRef.current,
...crimeData,
demographics,
geographics,
crime_incidents,
selectedYear: year,
selectedMonth: month
};
// Perbarui ref tetapi BUKAN state di sini
selectedDistrictRef.current = updatedDistrict;
// Gunakan functional update untuk menghindari loop
// Hanya update jika berbeda dari state sebelumnya
setSelectedDistrict(prevDistrict => {
// Jika sudah sama, tidak perlu update
if (prevDistrict?.id === updatedDistrict.id &&
prevDistrict?.selectedYear === updatedDistrict.selectedYear &&
prevDistrict?.selectedMonth === updatedDistrict.selectedMonth) {
return prevDistrict;
}
return updatedDistrict;
});
console.log("Updated selected district with new data:", updatedDistrict.name);
}
}
}, [crimes, filterCategory, year, month]); // hapus crimeDataByDistrict dari dependencies
const handleIncidentClick = useCallback((e: any) => {
if (!map) return;
// Try to query for crime_incidents at the click location
const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] })
if (!features || features.length === 0) return
const incident = features[0]
if (!incident.properties) return
// Prevent the click from propagating to other layers
e.originalEvent.stopPropagation()
// Extract the incident details from the feature properties
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(),
}
// Dispatch the custom event to the map container element directly
const customEvent = new CustomEvent('incident_click', {
detail: incidentDetails,
bubbles: true // Make sure the event bubbles up
})
if (map.getMap().getCanvas()) {
map.getMap().getCanvas().dispatchEvent(customEvent)
} else {
document.dispatchEvent(customEvent) // Fallback
}
}, [map]);
useEffect(() => {
if (!map || !visible) return;
// Add click handler for individual incident points
if (map.getMap().getLayer("unclustered-point")) {
map.on("click", "unclustered-point", handleIncidentClick)
}
return () => {
if (map && map.getMap().getLayer("unclustered-point")) {
map.off("click", "unclustered-point", handleIncidentClick)
}
}
}, [map, visible, handleIncidentClick]);
if (!visible) return null
return (
<>
{/* {hoverInfo && (
<div
className="absolute z-10 bg-white rounded-md shadow-md px-3 py-2 pointer-events-none"
style={{
left: hoverInfo.x + 10,
top: hoverInfo.y + 10,
}}
>
<p className="text-sm font-medium">
{hoverInfo.feature.properties.nama || hoverInfo.feature.properties.kecamatan}
</p>
{hoverInfo.feature.properties.number_of_crime !== undefined && (
<p className="text-xs text-gray-600">
{hoverInfo.feature.properties.number_of_crime} crime_incidents
{hoverInfo.feature.properties.level && (
<span className="ml-2 text-xs font-semibold text-gray-500">({hoverInfo.feature.properties.level})</span>
)}
</p>
)}
</div>
)} */}
{selectedDistrictRef.current ? (
<DistrictPopup
longitude={selectedDistrictRef.current.longitude || 0}
latitude={selectedDistrictRef.current.latitude || 0}
onClose={() => {
selectedDistrictRef.current = null;
setSelectedDistrict(null);
}}
district={selectedDistrictRef.current}
year={year}
month={month}
filterCategory={filterCategory}
/>
) : null}
</>
)
}