feat: refactor crime data fetching and filtering logic in crime management components

This commit is contained in:
vergiLgood1 2025-05-03 19:12:13 +07:00
parent 91f9574285
commit 0d6f9acf66
6 changed files with 425 additions and 370 deletions

View File

@ -1,10 +1,7 @@
"use client" "use client"
import { useEffect, useState, useCallback, useRef } from "react" import { useState, useMemo } from "react"
import { useGetAvailableYears, useGetCrimes } from "../_queries/queries"
import { useQueryClient } from "@tanstack/react-query"
import { useGetAvailableYears } from "../_queries/queries"
import { getCrimeByYearAndMonth } from "../action"
type CrimeData = any // Replace with your actual crime data type type CrimeData = any // Replace with your actual crime data type
@ -24,87 +21,43 @@ interface PrefetchedCrimeDataResult {
export function usePrefetchedCrimeData(initialYear: number = 2024, initialMonth: number | "all" = "all"): PrefetchedCrimeDataResult { export function usePrefetchedCrimeData(initialYear: number = 2024, initialMonth: number | "all" = "all"): PrefetchedCrimeDataResult {
const [selectedYear, setSelectedYear] = useState<number>(initialYear) const [selectedYear, setSelectedYear] = useState<number>(initialYear)
const [selectedMonth, setSelectedMonth] = useState<number | "all">(initialMonth) const [selectedMonth, setSelectedMonth] = useState<number | "all">(initialMonth)
const [prefetchedData, setPrefetchedData] = useState<Record<string, CrimeData>>({})
const [isPrefetching, setIsPrefetching] = useState<boolean>(true)
const [prefetchError, setPrefetchError] = useState<Error | null>(null)
const queryClient = useQueryClient()
// Get available years // Get available years
const { data: availableYears, isLoading: isYearsLoading, error: yearsError } = useGetAvailableYears() const {
data: availableYears,
isLoading: isYearsLoading,
error: yearsError
} = useGetAvailableYears()
// Track if we've prefetched data // Get all crime data in a single request
const hasPrefetched = useRef<boolean>(false) const {
data: allCrimes,
isLoading: isCrimesLoading,
error: crimesError
} = useGetCrimes()
// Prefetch all data combinations // Filter crimes based on selected year and month
useEffect(() => { const filteredCrimes = useMemo(() => {
const prefetchAllData = async () => { if (!allCrimes) return []
if (!availableYears || hasPrefetched.current) return
setIsPrefetching(true) return allCrimes.filter((crime: any) => {
const dataCache: Record<string, CrimeData> = {} const yearMatch = crime.year === selectedYear
try { if (selectedMonth === "all") {
// Prefetch data for all years with "all" months return yearMatch
for (const year of availableYears) { } else {
if (year === null) continue return yearMatch && crime.month === selectedMonth
// Prefetch "all" months for this year
const allMonthsKey = `${year}-all`
const allMonthsData = await getCrimeByYearAndMonth(year, "all")
dataCache[allMonthsKey] = allMonthsData
// Also prefetch each individual month for this year
for (let month = 1; month <= 12; month++) {
const monthKey = `${year}-${month}`
const monthData = await getCrimeByYearAndMonth(year, month)
dataCache[monthKey] = monthData
// Pre-populate the React Query cache
queryClient.setQueryData(["crimes", year, month], monthData)
}
// Pre-populate the React Query cache for "all" months
queryClient.setQueryData(["crimes", year, "all"], allMonthsData)
}
setPrefetchedData(dataCache)
hasPrefetched.current = true
} catch (error) {
console.error("Error prefetching crime data:", error)
setPrefetchError(error instanceof Error ? error : new Error("Failed to prefetch data"))
} finally {
setIsPrefetching(false)
} }
} })
}, [allCrimes, selectedYear, selectedMonth])
prefetchAllData()
}, [availableYears, queryClient])
// Get the current data based on selected filters
const currentKey = `${selectedYear}-${selectedMonth}`
const currentData = prefetchedData[currentKey]
// Add better debugging and handle potential undefined values
// useEffect(() => {
// if (isPrefetching) {
// console.log("Currently prefetching data...")
// } else {
// console.log(`Current data for ${currentKey}:`, currentData);
// if (!currentData) {
// console.log("Available prefetched data keys:", Object.keys(prefetchedData));
// }
// }
// }, [isPrefetching, currentKey, currentData, prefetchedData]);
// Ensure we always return a valid crimes array, even if empty
return { return {
availableYears, availableYears,
isYearsLoading, isYearsLoading,
yearsError, yearsError,
crimes: Array.isArray(currentData) ? currentData : [], crimes: filteredCrimes,
isCrimesLoading: isPrefetching || !currentData, isCrimesLoading,
crimesError: prefetchError, crimesError,
setSelectedYear, setSelectedYear,
setSelectedMonth, setSelectedMonth,
selectedYear, selectedYear,

View File

@ -1,5 +1,6 @@
'use server'; 'use server';
import { ICrimes, ICrimesByYearAndMonth } from '@/app/_utils/types/crimes';
import { getInjection } from '@/di/container'; import { getInjection } from '@/di/container';
import db from '@/prisma/db'; import db from '@/prisma/db';
import { import {
@ -108,7 +109,7 @@ export async function getCrimeCategories() {
); );
} }
export async function getCrimes() { export async function getCrimes(): Promise<ICrimes[]> {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction( return await instrumentationService.instrumentServerAction(
'District Crime Data', 'District Crime Data',
@ -120,10 +121,28 @@ export async function getCrimes() {
districts: { districts: {
select: { select: {
name: true, name: true,
geographics: {
select: {
address: true,
land_area: true,
year: true,
latitude: true,
longitude: true,
},
},
demographics: {
select: {
number_of_unemployed: true,
population: true,
population_density: true,
year: true,
},
},
}, },
}, },
crime_incidents: { crime_incidents: {
select: { select: {
id: true,
timestamp: true, timestamp: true,
description: true, description: true,
status: true, status: true,
@ -182,7 +201,7 @@ export async function getCrimes() {
export async function getCrimeByYearAndMonth( export async function getCrimeByYearAndMonth(
year: number, year: number,
month: number | 'all' month: number | 'all'
) { ): Promise<ICrimesByYearAndMonth[]> {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction( return await instrumentationService.instrumentServerAction(
'District Crime Data', 'District Crime Data',

View File

@ -2,7 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
import { Skeleton } from "@/app/_components/ui/skeleton" import { Skeleton } from "@/app/_components/ui/skeleton"
import DistrictLayer, { type DistrictFeature, ICrimeData } from "./layers/district-layer" import DistrictLayer, { type DistrictFeature } from "./layers/district-layer"
import MapView from "./map" import MapView from "./map"
import { Button } from "@/app/_components/ui/button" import { Button } from "@/app/_components/ui/button"
import { AlertCircle } from "lucide-react" import { AlertCircle } from "lucide-react"
@ -11,8 +11,7 @@ import { useRef, useState, useCallback, useMemo, useEffect } from "react"
import { useFullscreen } from "@/app/_hooks/use-fullscreen" import { useFullscreen } from "@/app/_hooks/use-fullscreen"
import { Overlay } from "./overlay" import { Overlay } from "./overlay"
import MapLegend from "./controls/map-legend" import MapLegend from "./controls/map-legend"
import { usePrefetchedCrimeData } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes" import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
import { useGetCrimeCategories } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
import { ITopTooltipsMapId } from "./controls/map-tooltips" import { ITopTooltipsMapId } from "./controls/map-tooltips"
import MapSelectors from "./controls/map-selector" import MapSelectors from "./controls/map-selector"
import TopNavigation from "./controls/map-navigations" import TopNavigation from "./controls/map-navigations"
@ -20,10 +19,10 @@ import CrimeSidebar from "./sidebar/map-sidebar"
import SidebarToggle from "./sidebar/sidebar-toggle" import SidebarToggle from "./sidebar/sidebar-toggle"
import { cn } from "@/app/_lib/utils" import { cn } from "@/app/_lib/utils"
import CrimePopup from "./pop-up/crime-popup" import CrimePopup from "./pop-up/crime-popup"
import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client"
// Updated CrimeIncident type to match the structure in crime_incidents // Updated CrimeIncident type to match the structure in crime_incidents
export interface CrimeIncident { interface CrimeIncident {
id: string id: string
timestamp: Date timestamp: Date
description: string description: string
@ -42,6 +41,8 @@ export default function CrimeMap() {
const [selectedIncident, setSelectedIncident] = useState<CrimeIncident | null>(null) const [selectedIncident, setSelectedIncident] = useState<CrimeIncident | null>(null)
const [showLegend, setShowLegend] = useState<boolean>(true) const [showLegend, setShowLegend] = useState<boolean>(true)
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all") const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
const [selectedYear, setSelectedYear] = useState<number>(2024)
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
const [activeControl, setActiveControl] = useState<ITopTooltipsMapId>("incidents") const [activeControl, setActiveControl] = useState<ITopTooltipsMapId>("incidents")
const mapContainerRef = useRef<HTMLDivElement>(null) const mapContainerRef = useRef<HTMLDivElement>(null)
@ -49,23 +50,12 @@ export default function CrimeMap() {
// Use the custom fullscreen hook // Use the custom fullscreen hook
const { isFullscreen } = useFullscreen(mapContainerRef) const { isFullscreen } = useFullscreen(mapContainerRef)
// Toggle sidebar function // Get available years
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(!sidebarCollapsed)
}, [sidebarCollapsed])
// Use our new prefetched data hook
const { const {
availableYears, data: availableYears,
isYearsLoading, isLoading: isYearsLoading,
crimes, error: yearsError
isCrimesLoading, } = useGetAvailableYears()
crimesError,
setSelectedYear,
setSelectedMonth,
selectedYear,
selectedMonth,
} = usePrefetchedCrimeData()
// Extract all unique categories // Extract all unique categories
const { data: categoriesData, isLoading: isCategoryLoading } = useGetCrimeCategories() const { data: categoriesData, isLoading: isCategoryLoading } = useGetCrimeCategories()
@ -75,12 +65,34 @@ export default function CrimeMap() {
categoriesData ? categoriesData.map(category => category.name) : [] categoriesData ? categoriesData.map(category => category.name) : []
, [categoriesData]) , [categoriesData])
// Get all crime data in a single request
const {
data: crimes,
isLoading: isCrimesLoading,
error: crimesError
} = useGetCrimes()
// Filter crimes based on selected year and month
const filteredByYearAndMonth = useMemo(() => {
if (!crimes) return []
return crimes.filter((crime) => {
const yearMatch = crime.year === selectedYear
if (selectedMonth === "all") {
return yearMatch
} else {
return yearMatch && crime.month === selectedMonth
}
})
}, [crimes, selectedYear, selectedMonth])
// Filter incidents based on selected category // Filter incidents based on selected category
const filteredCrimes = useMemo(() => { const filteredCrimes = useMemo(() => {
if (!crimes) return [] if (!filteredByYearAndMonth) return []
if (selectedCategory === "all") return crimes if (selectedCategory === "all") return filteredByYearAndMonth
return crimes.map((crime: ICrimeData) => { return filteredByYearAndMonth.map((crime) => {
const filteredIncidents = crime.crime_incidents.filter( const filteredIncidents = crime.crime_incidents.filter(
incident => incident.crime_categories.name === selectedCategory incident => incident.crime_categories.name === selectedCategory
) )
@ -91,75 +103,100 @@ export default function CrimeMap() {
number_of_crime: filteredIncidents.length number_of_crime: filteredIncidents.length
} }
}) })
}, [crimes, selectedCategory]) }, [filteredByYearAndMonth, selectedCategory])
// Extract all incidents from all districts for marker display // Extract all incidents from all districts for marker display
const allIncidents = useMemo(() => { // const allIncidents = useMemo(() => {
if (!filteredCrimes) return [] // if (!filteredCrimes) return []
return filteredCrimes.flatMap((crime: ICrimeData) => // return filteredCrimes.flatMap((crime) =>
crime.crime_incidents.map((incident) => ({ // crime.crime_incidents.map((incident) => ({
id: incident.id, // id: incident.id,
timestamp: incident.timestamp, // timestamp: incident.timestamp,
description: incident.description, // description: incident.description,
status: incident.status, // status: incident.status,
category: incident.crime_categories.name, // category: incident.crime_categories.name,
type: incident.crime_categories.type, // type: incident.crime_categories.type,
address: incident.locations.address, // address: incident.locations.address,
latitude: incident.locations.latitude, // latitude: incident.locations.latitude,
longitude: incident.locations.longitude, // longitude: incident.locations.longitude,
})) // }))
) // )
}, [filteredCrimes]) // }, [filteredCrimes])
// Handle district click
const handleDistrictClick = (feature: DistrictFeature) => {
setSelectedDistrict(feature)
}
// Handle incident marker click // Handle incident marker click
const handleIncidentClick = (incident: CrimeIncident) => { const handleIncidentClick = (incident: CrimeIncident) => {
console.log("Incident clicked directly:", incident);
if (!incident.longitude || !incident.latitude) { if (!incident.longitude || !incident.latitude) {
console.error("Invalid incident coordinates:", incident); console.error("Invalid incident coordinates:", incident);
return; return;
} }
// When an incident is clicked, clear any selected district
setSelectedDistrict(null);
// Set the selected incident
setSelectedIncident(incident); setSelectedIncident(incident);
} }
// Set up event listener for incident clicks from the district layer // Set up event listener for incident clicks from the district layer
useEffect(() => { useEffect(() => {
const handleIncidentClick = (e: CustomEvent) => { const handleIncidentClickEvent = (e: CustomEvent) => {
// console.log("Received incident_click event:", e.detail) console.log("Received incident_click event:", e.detail);
if (e.detail) { if (e.detail) {
setSelectedIncident(e.detail) if (!e.detail.longitude || !e.detail.latitude) {
console.error("Invalid incident coordinates in event:", e.detail);
return;
}
// When an incident is clicked, clear any selected district
setSelectedDistrict(null);
// Set the selected incident
setSelectedIncident(e.detail);
} }
} }
// Add event listener to the map container and document // Add event listener to the map container and document
const mapContainer = mapContainerRef.current const mapContainer = mapContainerRef.current
// Clean up previous listeners to prevent duplicates
document.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
if (mapContainer) {
mapContainer.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
}
// Listen on both the container and document to ensure we catch the event // Listen on both the container and document to ensure we catch the event
document.addEventListener('incident_click', handleIncidentClick as EventListener) document.addEventListener('incident_click', handleIncidentClickEvent as EventListener);
if (mapContainer) { if (mapContainer) {
mapContainer.addEventListener('incident_click', handleIncidentClick as EventListener) mapContainer.addEventListener('incident_click', handleIncidentClickEvent as EventListener);
} }
return () => { return () => {
document.removeEventListener('incident_click', handleIncidentClick as EventListener) document.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
if (mapContainer) { if (mapContainer) {
mapContainer.removeEventListener('incident_click', handleIncidentClick as EventListener) mapContainer.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
} }
} }
}, []) }, []);
// Handle district click
const handleDistrictClick = (feature: DistrictFeature) => {
// When a district is clicked, clear any selected incident
setSelectedIncident(null);
// Set the selected district
setSelectedDistrict(feature);
}
// Reset filters // Reset filters
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
setSelectedYear(2024) setSelectedYear(2024)
setSelectedMonth("all") setSelectedMonth("all")
setSelectedCategory("all") setSelectedCategory("all")
}, [setSelectedYear, setSelectedMonth]) }, [])
// Determine the title based on filters // Determine the title based on filters
const getMapTitle = () => { const getMapTitle = () => {
@ -173,6 +210,11 @@ export default function CrimeMap() {
return title return title
} }
// Toggle sidebar function
const toggleSidebar = useCallback(() => {
setSidebarCollapsed(!sidebarCollapsed)
}, [sidebarCollapsed])
return ( return (
<Card className="w-full p-0 border-none shadow-none h-96"> <Card className="w-full p-0 border-none shadow-none h-96">
<CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between"> <CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between">
@ -218,19 +260,16 @@ export default function CrimeMap() {
/> />
{/* Popup for selected incident */} {/* Popup for selected incident */}
{selectedIncident && selectedIncident.longitude !== undefined && selectedIncident.latitude !== undefined && ( {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<> <>
{console.log("Rendering CrimePopup with:", selectedIncident)} {/* {console.log("About to render CrimePopup with:", selectedIncident)} */}
<CrimePopup <CrimePopup
longitude={selectedIncident.longitude} longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude} latitude={selectedIncident.latitude}
onClose={() => setSelectedIncident(null)} onClose={() => setSelectedIncident(null)}
crime={{ crime={selectedIncident}
...selectedIncident,
latitude: selectedIncident.latitude,
longitude: selectedIncident.longitude
}}
/> />
</> </>
)} )}

View File

@ -6,6 +6,7 @@ import { CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from "@/app/_utils/const/map"
import { $Enums } from "@prisma/client" import { $Enums } from "@prisma/client"
import DistrictPopup from "../pop-up/district-popup" import DistrictPopup from "../pop-up/district-popup"
import { ICrimes } from "@/app/_utils/types/crimes"
// Types for district properties // Types for district properties
export interface DistrictFeature { export interface DistrictFeature {
@ -44,47 +45,47 @@ export interface DistrictFeature {
selectedMonth?: string selectedMonth?: string
} }
// Updated interface to match the structure returned by getCrimeByYearAndMonth // Updated interface to match the structure in crimes.ts
export interface ICrimeData { // export interface ICrimeData {
id: string // id: string
district_id: string // district_id: string
districts: { // districts: {
name: string // name: string
geographics: { // geographics: {
address: string // address: string | null
land_area: number // land_area: number | null
year: number // year: number | null
latitude: number // latitude: number
longitude: number // longitude: number
} // }[]
demographics: { // demographics: {
number_of_unemployed: number // number_of_unemployed: number
population: number // population: number
population_density: number // population_density: number
year: number // year: number
} // }[]
} // }
number_of_crime: number // number_of_crime: number
level: $Enums.crime_rates // level: $Enums.crime_rates
score: number // score: number
month: number // month: number
year: number // year: number
crime_incidents: Array<{ // crime_incidents: Array<{
id: string // id: string
timestamp: Date // timestamp: Date
description: string // description: string
status: string // status: string
crime_categories: { // crime_categories: {
name: string // name: string
type: string // type: string | null
} // }
locations: { // locations: {
address: string // address: string | null
latitude: number // latitude: number
longitude: number // longitude: number
} // }
}> // }>
} // }
// District layer props // District layer props
export interface DistrictLayerProps { export interface DistrictLayerProps {
@ -93,7 +94,7 @@ export interface DistrictLayerProps {
year?: string year?: string
month?: string month?: string
filterCategory?: string | "all" filterCategory?: string | "all"
crimes?: ICrimeData[] crimes?: ICrimes[]
tilesetId?: string tilesetId?: string
} }
@ -114,18 +115,13 @@ export default function DistrictLayer({
feature: any feature: any
} | null>(null) } | 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 selectedDistrictRef = useRef<DistrictFeature | null>(null)
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null) const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
// Use a ref to track whether layers have been added
const layersAdded = useRef(false) const layersAdded = useRef(false)
// Process crime data to map to districts by district_id (kode_kec)
const crimeDataByDistrict = crimes.reduce( const crimeDataByDistrict = crimes.reduce(
(acc, crime) => { (acc, crime) => {
// Use district_id (which corresponds to kode_kec in the tileset) as the key
const districtId = crime.district_id const districtId = crime.district_id
acc[districtId] = { acc[districtId] = {
@ -137,15 +133,19 @@ export default function DistrictLayer({
{} as Record<string, { number_of_crime?: number; level?: $Enums.crime_rates }>, {} as Record<string, { number_of_crime?: number; level?: $Enums.crime_rates }>,
) )
// Handle click on district const handleDistrictClick = (e: any) => {
const handleClick = (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 if (!map || !e.features || e.features.length === 0) return
const feature = e.features[0] const feature = e.features[0]
const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier const districtId = feature.properties.kode_kec
const crimeData = crimeDataByDistrict[districtId] || {} const crimeData = crimeDataByDistrict[districtId] || {}
// Get ALL crime_incidents for this district by aggregating from all matching crime records
let crime_incidents: Array<{ let crime_incidents: Array<{
id: string id: string
timestamp: Date timestamp: Date
@ -158,12 +158,8 @@ export default function DistrictLayer({
longitude: number longitude: number
}> = [] }> = []
// Find all crime data records for this district (across all months)
const districtCrimes = crimes.filter(crime => crime.district_id === districtId) 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 => { districtCrimes.forEach(crimeRecord => {
if (crimeRecord && crimeRecord.crime_incidents) { if (crimeRecord && crimeRecord.crime_incidents) {
const incidents = crimeRecord.crime_incidents.map(incident => ({ const incidents = crimeRecord.crime_incidents.map(incident => ({
@ -182,14 +178,10 @@ export default function DistrictLayer({
} }
}) })
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 firstDistrictCrime = districtCrimes.length > 0 ? districtCrimes[0] : null
const demographics = firstDistrictCrime?.districts.demographics const demographics = firstDistrictCrime?.districts.demographics?.[0]
const geographics = firstDistrictCrime?.districts.geographics const geographics = firstDistrictCrime?.districts.geographics?.[0]
// Make sure we have valid coordinates from the click event
const clickLng = e.lngLat ? e.lngLat.lng : null const clickLng = e.lngLat ? e.lngLat.lng : null
const clickLat = e.lngLat ? e.lngLat.lat : null const clickLat = e.lngLat ? e.lngLat.lat : null
@ -203,62 +195,115 @@ export default function DistrictLayer({
return return
} }
// Create a complete district object ensuring all required properties are present
const district: DistrictFeature = { const district: DistrictFeature = {
id: districtId, id: districtId,
name: feature.properties.nama || feature.properties.kecamatan || "Unknown District", name: feature.properties.nama || feature.properties.kecamatan || "Unknown District",
properties: feature.properties, properties: feature.properties,
longitude: geographics.longitude || clickLng || 0, longitude: geographics.longitude || clickLng || 0,
latitude: geographics.latitude || clickLat || 0, latitude: geographics.latitude || clickLat || 0,
// Use the total crime count across all months
number_of_crime: crimeData.number_of_crime || 0, 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, level: crimeData.level || $Enums.crime_rates.low,
demographics, demographics: {
geographics, number_of_unemployed: demographics.number_of_unemployed,
// Include all aggregated crime incidents 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 || [], crime_incidents: crime_incidents || [],
selectedYear: year, selectedYear: year,
selectedMonth: month selectedMonth: month
} }
if (!district.longitude || !district.latitude) { if (!district.longitude || !district.latitude) {
console.error("Invalid district coordinates:", district); console.error("Invalid district coordinates:", district);
return; 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; selectedDistrictRef.current = district;
if (onClick) { if (onClick) {
onClick(district); onClick(district);
} else { } else {
setSelectedDistrict(district); setSelectedDistrict(district);
} }
} }
// Pastikan event handler klik selalu diperbarui const handleIncidentClick = useCallback((e: any) => {
// dan re-attach setiap kali ada perubahan data if (!map) return;
useEffect(() => {
if (!map || !visible || !map.getMap().getLayer("district-fill")) return;
// Re-attach click handler const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] })
map.off("click", "district-fill", handleClick); if (!features || features.length === 0) return
map.on("click", "district-fill", handleClick);
console.log("Re-attached click handler, current district:", selectedDistrict?.name || "None"); const incident = features[0]
if (!incident.properties) return
return () => { e.originalEvent.stopPropagation()
if (map) { e.preventDefault()
map.off("click", "district-fill", handleClick);
} const incidentDetails = {
}; id: incident.properties.id,
}, [map, visible, crimes, filterCategory, year, month]); 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);
}, []);
// Add district layer to the map when it's loaded
useEffect(() => { useEffect(() => {
if (!map || !visible) return; if (!map || !visible) return;
@ -404,6 +449,9 @@ export default function DistrictLayer({
], ],
"circle-opacity": 0.75, "circle-opacity": 0.75,
}, },
layout: {
"visibility": "visible",
}
}, },
firstSymbolId, firstSymbolId,
) )
@ -439,31 +487,15 @@ export default function DistrictLayer({
"circle-stroke-width": 1, "circle-stroke-width": 1,
"circle-stroke-color": "#fff", "circle-stroke-color": "#fff",
}, },
layout: {
"visibility": "visible",
}
}, },
firstSymbolId, firstSymbolId,
) )
} }
map.on("click", "clusters", (e) => { // Add improved mouse interaction for clusters and points
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.on("mouseenter", "clusters", () => {
map.getCanvas().style.cursor = "pointer" map.getCanvas().style.cursor = "pointer"
}) })
@ -479,15 +511,31 @@ export default function DistrictLayer({
map.on("mouseleave", "unclustered-point", () => { map.on("mouseleave", "unclustered-point", () => {
map.getCanvas().style.cursor = "" 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;
} }
// 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 { } else {
if (map.getMap().getLayer("district-fill")) { if (map.getMap().getLayer("district-fill")) {
map.getMap().setPaintProperty("district-fill", "fill-color", [ map.getMap().setPaintProperty("district-fill", "fill-color", [
@ -527,19 +575,16 @@ export default function DistrictLayer({
return () => { return () => {
if (map) { if (map) {
// Remove the click event listener when the component unmounts or dependencies change map.off("click", "district-fill", handleDistrictClick);
map.off("click", "district-fill", handleClick);
} }
}; };
}, [map, visible, tilesetId, crimes, filterCategory, year, month]); }, [map, visible, tilesetId, crimes, filterCategory, year, month, handleIncidentClick, handleClusterClick]);
useEffect(() => { useEffect(() => {
if (!map || !layersAdded.current) return if (!map || !layersAdded.current) return
try { try {
if (map.getMap().getLayer("district-fill")) { if (map.getMap().getLayer("district-fill")) {
// Create a safety check for empty or invalid data
const colorEntries = Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { const colorEntries = Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
if (!data || !data.level) { if (!data || !data.level) {
return [ return [
@ -584,10 +629,8 @@ export default function DistrictLayer({
try { try {
const allIncidents = crimes.flatMap((crime) => { const allIncidents = crimes.flatMap((crime) => {
// Make sure we handle cases where crime_incidents might be undefined
if (!crime.crime_incidents) return [] if (!crime.crime_incidents) return []
// Apply category filter if specified
let filteredIncidents = crime.crime_incidents let filteredIncidents = crime.crime_incidents
if (filterCategory !== "all") { if (filterCategory !== "all") {
@ -598,7 +641,6 @@ export default function DistrictLayer({
} }
return filteredIncidents.map((incident) => { return filteredIncidents.map((incident) => {
// Handle possible null/undefined values
if (!incident.locations) { if (!incident.locations) {
console.warn("Missing location for incident:", incident.id) console.warn("Missing location for incident:", incident.id)
return null return null
@ -622,7 +664,7 @@ export default function DistrictLayer({
], ],
}, },
} }
}).filter(Boolean) // Remove null values }).filter(Boolean)
}); });
(map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({ (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
@ -635,25 +677,22 @@ export default function DistrictLayer({
} }
}, [map, crimes, filterCategory]) }, [map, crimes, filterCategory])
// Effect khusus untuk memastikan state selectedDistrict dipertahankan
// ketika data berubah (tahun/bulan diubah)
useEffect(() => { useEffect(() => {
// Jika kita memiliki district yang dipilih dalam ref, pastikan
// state juga diperbarui dengan data terbaru
if (selectedDistrictRef.current) { if (selectedDistrictRef.current) {
// Cari crime data terbaru untuk district yang dipilih
const districtId = selectedDistrictRef.current.id; const districtId = selectedDistrictRef.current.id;
const crimeData = crimeDataByDistrict[districtId] || {}; const crimeData = crimeDataByDistrict[districtId] || {};
// Cari data district terkini
const districtCrime = crimes.find(crime => crime.district_id === districtId); const districtCrime = crimes.find(crime => crime.district_id === districtId);
// Perbarui data district dengan informasi terkini
if (districtCrime) { if (districtCrime) {
const demographics = districtCrime.districts.demographics; const demographics = districtCrime.districts.demographics?.[0];
const geographics = districtCrime.districts.geographics; const geographics = districtCrime.districts.geographics?.[0];
if (!demographics || !geographics) {
console.error("Missing district data:", { demographics, geographics });
return;
}
// Filter crime_incidents
const crime_incidents = districtCrime.crime_incidents const crime_incidents = districtCrime.crime_incidents
.filter(incident => .filter(incident =>
filterCategory === "all" || incident.crime_categories.name === filterCategory filterCategory === "all" || incident.crime_categories.name === filterCategory
@ -662,32 +701,39 @@ export default function DistrictLayer({
id: incident.id, id: incident.id,
timestamp: incident.timestamp, timestamp: incident.timestamp,
description: incident.description, description: incident.description,
status: incident.status, status: incident.status || "",
category: incident.crime_categories.name, category: incident.crime_categories.name,
type: incident.crime_categories.type, type: incident.crime_categories.type || "",
address: incident.locations.address, address: incident.locations.address || "",
latitude: incident.locations.latitude, latitude: incident.locations.latitude,
longitude: incident.locations.longitude longitude: incident.locations.longitude
})); }));
// Buat district object baru dengan data terkini
const updatedDistrict: DistrictFeature = { const updatedDistrict: DistrictFeature = {
...selectedDistrictRef.current, ...selectedDistrictRef.current,
...crimeData, number_of_crime: crimeData.number_of_crime || 0,
demographics, level: crimeData.level || selectedDistrictRef.current.level,
geographics, 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, selectedYear: year,
selectedMonth: month selectedMonth: month
}; };
// Perbarui ref tetapi BUKAN state di sini
selectedDistrictRef.current = updatedDistrict; selectedDistrictRef.current = updatedDistrict;
// Gunakan functional update untuk menghindari loop
// Hanya update jika berbeda dari state sebelumnya
setSelectedDistrict(prevDistrict => { setSelectedDistrict(prevDistrict => {
// Jika sudah sama, tidak perlu update
if (prevDistrict?.id === updatedDistrict.id && if (prevDistrict?.id === updatedDistrict.id &&
prevDistrict?.selectedYear === updatedDistrict.selectedYear && prevDistrict?.selectedYear === updatedDistrict.selectedYear &&
prevDistrict?.selectedMonth === updatedDistrict.selectedMonth) { prevDistrict?.selectedMonth === updatedDistrict.selectedMonth) {
@ -695,100 +741,19 @@ export default function DistrictLayer({
} }
return updatedDistrict; return updatedDistrict;
}); });
console.log("Updated selected district with new data:", updatedDistrict.name);
} }
} }
}, [crimes, filterCategory, year, month]); // hapus crimeDataByDistrict dari dependencies }, [crimes, filterCategory, year, month]);
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 if (!visible) return null
return ( 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 ? ( {selectedDistrictRef.current ? (
<DistrictPopup <DistrictPopup
longitude={selectedDistrictRef.current.longitude || 0} longitude={selectedDistrictRef.current.longitude || 0}
latitude={selectedDistrictRef.current.latitude || 0} latitude={selectedDistrictRef.current.latitude || 0}
onClose={() => { onClose={handleCloseDistrictPopup}
selectedDistrictRef.current = null;
setSelectedDistrict(null);
}}
district={selectedDistrictRef.current} district={selectedDistrictRef.current}
year={year} year={year}
month={month} month={month}

View File

@ -86,6 +86,7 @@ export default function DistrictPopup({
return year || "All time" return year || "All time"
} }
return ( return (
<Popup <Popup
longitude={longitude} longitude={longitude}
@ -117,10 +118,10 @@ export default function DistrictPopup({
</div> </div>
{getCrimeRateBadge(district.level)} {getCrimeRateBadge(district.level)}
</div> </div>
<div className="mt-1 text-white/80 text-xs flex items-center gap-2"> {/* <div className="mt-1 text-white/80 text-xs flex items-center gap-2">
<Calendar className="h-3 w-3" /> <Calendar className="h-3 w-3" />
<span>{getTimePeriod()}</span> <span>{getTimePeriod()}</span>
</div> </div> */}
</div> </div>
<div className="grid grid-cols-3 gap-1 p-2 bg-background"> <div className="grid grid-cols-3 gap-1 p-2 bg-background">

View File

@ -0,0 +1,78 @@
import {
$Enums,
crime_categories,
crime_incidents,
crimes,
demographics,
districts,
geographics,
locations,
} from '@prisma/client';
export interface ICrimes extends crimes {
districts: {
name: string;
geographics: {
year: number | null;
address: string | null;
longitude: number;
latitude: number;
land_area: number | null;
}[];
demographics: {
year: number;
population: number;
number_of_unemployed: number;
population_density: number;
}[];
};
crime_incidents: {
id: string;
description: string;
status: $Enums.crime_status | null;
timestamp: Date;
crime_categories: {
name: string;
type: string | null;
};
locations: {
address: string | null;
longitude: number;
latitude: number;
};
}[];
}
export interface ICrimesByYearAndMonth extends crimes {
districts: {
name: string;
geographics: {
year: number | null;
address: string | null;
longitude: number;
latitude: number;
land_area: number | null;
};
demographics: {
year: number;
population: number;
number_of_unemployed: number;
population_density: number;
};
};
crime_incidents: {
id: string;
description: string;
status: $Enums.crime_status | null;
timestamp: Date;
crime_categories: {
name: string;
type: string | null;
};
locations: {
address: string | null;
longitude: number;
latitude: number;
};
}[];
}