diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes.tsx b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes.tsx index 712ef1b..59ed935 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes.tsx @@ -1,10 +1,7 @@ "use client" -import { useEffect, useState, useCallback, useRef } from "react" - -import { useQueryClient } from "@tanstack/react-query" -import { useGetAvailableYears } from "../_queries/queries" -import { getCrimeByYearAndMonth } from "../action" +import { useState, useMemo } from "react" +import { useGetAvailableYears, useGetCrimes } from "../_queries/queries" 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 { const [selectedYear, setSelectedYear] = useState(initialYear) const [selectedMonth, setSelectedMonth] = useState(initialMonth) - const [prefetchedData, setPrefetchedData] = useState>({}) - const [isPrefetching, setIsPrefetching] = useState(true) - const [prefetchError, setPrefetchError] = useState(null) - - const queryClient = useQueryClient() // 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 - const hasPrefetched = useRef(false) + // Get all crime data in a single request + const { + data: allCrimes, + isLoading: isCrimesLoading, + error: crimesError + } = useGetCrimes() - // Prefetch all data combinations - useEffect(() => { - const prefetchAllData = async () => { - if (!availableYears || hasPrefetched.current) return + // Filter crimes based on selected year and month + const filteredCrimes = useMemo(() => { + if (!allCrimes) return [] - setIsPrefetching(true) - const dataCache: Record = {} + return allCrimes.filter((crime: any) => { + const yearMatch = crime.year === selectedYear - try { - // Prefetch data for all years with "all" months - for (const year of availableYears) { - if (year === null) continue - - // 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) + if (selectedMonth === "all") { + return yearMatch + } else { + return yearMatch && crime.month === selectedMonth } - } + }) + }, [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 { availableYears, isYearsLoading, yearsError, - crimes: Array.isArray(currentData) ? currentData : [], - isCrimesLoading: isPrefetching || !currentData, - crimesError: prefetchError, + crimes: filteredCrimes, + isCrimesLoading, + crimesError, setSelectedYear, setSelectedMonth, selectedYear, diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts index 18b3645..5f41f50 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts @@ -1,5 +1,6 @@ 'use server'; +import { ICrimes, ICrimesByYearAndMonth } from '@/app/_utils/types/crimes'; import { getInjection } from '@/di/container'; import db from '@/prisma/db'; import { @@ -108,7 +109,7 @@ export async function getCrimeCategories() { ); } -export async function getCrimes() { +export async function getCrimes(): Promise { const instrumentationService = getInjection('IInstrumentationService'); return await instrumentationService.instrumentServerAction( 'District Crime Data', @@ -120,10 +121,28 @@ export async function getCrimes() { districts: { select: { 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: { select: { + id: true, timestamp: true, description: true, status: true, @@ -182,7 +201,7 @@ export async function getCrimes() { export async function getCrimeByYearAndMonth( year: number, month: number | 'all' -) { +): Promise { const instrumentationService = getInjection('IInstrumentationService'); return await instrumentationService.instrumentServerAction( 'District Crime Data', diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index 4303ea7..c0046ae 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -2,7 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card" 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 { Button } from "@/app/_components/ui/button" 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 { Overlay } from "./overlay" import MapLegend from "./controls/map-legend" -import { usePrefetchedCrimeData } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes" -import { useGetCrimeCategories } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries" +import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries" import { ITopTooltipsMapId } from "./controls/map-tooltips" import MapSelectors from "./controls/map-selector" import TopNavigation from "./controls/map-navigations" @@ -20,10 +19,10 @@ import CrimeSidebar from "./sidebar/map-sidebar" import SidebarToggle from "./sidebar/sidebar-toggle" import { cn } from "@/app/_lib/utils" 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 -export interface CrimeIncident { +interface CrimeIncident { id: string timestamp: Date description: string @@ -42,6 +41,8 @@ export default function CrimeMap() { const [selectedIncident, setSelectedIncident] = useState(null) const [showLegend, setShowLegend] = useState(true) const [selectedCategory, setSelectedCategory] = useState("all") + const [selectedYear, setSelectedYear] = useState(2024) + const [selectedMonth, setSelectedMonth] = useState("all") const [activeControl, setActiveControl] = useState("incidents") const mapContainerRef = useRef(null) @@ -49,23 +50,12 @@ export default function CrimeMap() { // Use the custom fullscreen hook const { isFullscreen } = useFullscreen(mapContainerRef) - // Toggle sidebar function - const toggleSidebar = useCallback(() => { - setSidebarCollapsed(!sidebarCollapsed) - }, [sidebarCollapsed]) - - // Use our new prefetched data hook + // Get available years const { - availableYears, - isYearsLoading, - crimes, - isCrimesLoading, - crimesError, - setSelectedYear, - setSelectedMonth, - selectedYear, - selectedMonth, - } = usePrefetchedCrimeData() + data: availableYears, + isLoading: isYearsLoading, + error: yearsError + } = useGetAvailableYears() // Extract all unique categories const { data: categoriesData, isLoading: isCategoryLoading } = useGetCrimeCategories() @@ -75,12 +65,34 @@ export default function CrimeMap() { categoriesData ? categoriesData.map(category => category.name) : [] , [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 const filteredCrimes = useMemo(() => { - if (!crimes) return [] - if (selectedCategory === "all") return crimes + if (!filteredByYearAndMonth) return [] + if (selectedCategory === "all") return filteredByYearAndMonth - return crimes.map((crime: ICrimeData) => { + return filteredByYearAndMonth.map((crime) => { const filteredIncidents = crime.crime_incidents.filter( incident => incident.crime_categories.name === selectedCategory ) @@ -91,75 +103,100 @@ export default function CrimeMap() { number_of_crime: filteredIncidents.length } }) - }, [crimes, selectedCategory]) + }, [filteredByYearAndMonth, selectedCategory]) // Extract all incidents from all districts for marker display - const allIncidents = useMemo(() => { - if (!filteredCrimes) return [] + // const allIncidents = useMemo(() => { + // if (!filteredCrimes) return [] - return filteredCrimes.flatMap((crime: ICrimeData) => - crime.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, - longitude: incident.locations.longitude, - })) - ) - }, [filteredCrimes]) - - // Handle district click - const handleDistrictClick = (feature: DistrictFeature) => { - setSelectedDistrict(feature) - } + // return filteredCrimes.flatMap((crime) => + // crime.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, + // longitude: incident.locations.longitude, + // })) + // ) + // }, [filteredCrimes]) // Handle incident marker click const handleIncidentClick = (incident: CrimeIncident) => { + console.log("Incident clicked directly:", incident); if (!incident.longitude || !incident.latitude) { console.error("Invalid incident coordinates:", incident); return; } + + // When an incident is clicked, clear any selected district + setSelectedDistrict(null); + + // Set the selected incident setSelectedIncident(incident); } // Set up event listener for incident clicks from the district layer useEffect(() => { - const handleIncidentClick = (e: CustomEvent) => { - // console.log("Received incident_click event:", e.detail) + const handleIncidentClickEvent = (e: CustomEvent) => { + console.log("Received incident_click event:", 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 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 - document.addEventListener('incident_click', handleIncidentClick as EventListener) + document.addEventListener('incident_click', handleIncidentClickEvent as EventListener); if (mapContainer) { - mapContainer.addEventListener('incident_click', handleIncidentClick as EventListener) + mapContainer.addEventListener('incident_click', handleIncidentClickEvent as EventListener); } return () => { - document.removeEventListener('incident_click', handleIncidentClick as EventListener) + document.removeEventListener('incident_click', handleIncidentClickEvent as EventListener); 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 const resetFilters = useCallback(() => { setSelectedYear(2024) setSelectedMonth("all") setSelectedCategory("all") - }, [setSelectedYear, setSelectedMonth]) + }, []) // Determine the title based on filters const getMapTitle = () => { @@ -173,6 +210,11 @@ export default function CrimeMap() { return title } + // Toggle sidebar function + const toggleSidebar = useCallback(() => { + setSidebarCollapsed(!sidebarCollapsed) + }, [sidebarCollapsed]) + return ( @@ -218,19 +260,16 @@ export default function CrimeMap() { /> {/* 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)} */} setSelectedIncident(null)} - crime={{ - ...selectedIncident, - latitude: selectedIncident.latitude, - longitude: selectedIncident.longitude - }} + crime={selectedIncident} /> + )} diff --git a/sigap-website/app/_components/map/layers/district-layer.tsx b/sigap-website/app/_components/map/layers/district-layer.tsx index b1f272e..ddb8e37 100644 --- a/sigap-website/app/_components/map/layers/district-layer.tsx +++ b/sigap-website/app/_components/map/layers/district-layer.tsx @@ -6,6 +6,7 @@ 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 { @@ -44,47 +45,47 @@ export interface DistrictFeature { 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 - } - }> -} +// 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 { @@ -93,7 +94,7 @@ export interface DistrictLayerProps { year?: string month?: string filterCategory?: string | "all" - crimes?: ICrimeData[] + crimes?: ICrimes[] tilesetId?: string } @@ -114,18 +115,13 @@ export default function DistrictLayer({ feature: any } | null>(null) - // Menggunakan useRef untuk menyimpan informasi distrik yang dipilih - // sehingga nilainya tidak hilang saat komponen di-render ulang const selectedDistrictRef = useRef(null) const [selectedDistrict, setSelectedDistrict] = useState(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] = { @@ -137,15 +133,19 @@ export default function DistrictLayer({ {} as Record, ) - // Handle click on district - const handleClick = (e: any) => { + 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 // Using kode_kec as the unique identifier + const districtId = feature.properties.kode_kec 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 @@ -158,12 +158,8 @@ export default function DistrictLayer({ 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 => ({ @@ -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 demographics = firstDistrictCrime?.districts.demographics - const geographics = firstDistrictCrime?.districts.geographics + const demographics = firstDistrictCrime?.districts.demographics?.[0] + 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 clickLat = e.lngLat ? e.lngLat.lat : null @@ -203,62 +195,115 @@ export default function DistrictLayer({ 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 + 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; } - 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 { + } 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; + const handleIncidentClick = useCallback((e: any) => { + if (!map) return; - // Re-attach click handler - map.off("click", "district-fill", handleClick); - map.on("click", "district-fill", handleClick); + const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] }) + if (!features || features.length === 0) return - console.log("Re-attached click handler, current district:", selectedDistrict?.name || "None"); + const incident = features[0] + if (!incident.properties) return - return () => { - if (map) { - map.off("click", "district-fill", handleClick); - } - }; - }, [map, visible, crimes, filterCategory, year, month]); + 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); + }, []); - // Add district layer to the map when it's loaded useEffect(() => { if (!map || !visible) return; @@ -404,6 +449,9 @@ export default function DistrictLayer({ ], "circle-opacity": 0.75, }, + layout: { + "visibility": "visible", + } }, firstSymbolId, ) @@ -439,31 +487,15 @@ export default function DistrictLayer({ "circle-stroke-width": 1, "circle-stroke-color": "#fff", }, + layout: { + "visibility": "visible", + } }, 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, - }) - }, - ) - }) - + // Add improved mouse interaction for clusters and points map.on("mouseenter", "clusters", () => { map.getCanvas().style.cursor = "pointer" }) @@ -479,15 +511,31 @@ export default function DistrictLayer({ 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; } - - // 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", [ @@ -527,19 +575,16 @@ export default function DistrictLayer({ return () => { if (map) { - // Remove the click event listener when the component unmounts or dependencies change - map.off("click", "district-fill", handleClick); - + map.off("click", "district-fill", handleDistrictClick); } }; - }, [map, visible, tilesetId, crimes, filterCategory, year, month]); + }, [map, visible, tilesetId, crimes, filterCategory, year, month, handleIncidentClick, handleClusterClick]); 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 [ @@ -584,10 +629,8 @@ export default function DistrictLayer({ 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") { @@ -598,7 +641,6 @@ export default function DistrictLayer({ } return filteredIncidents.map((incident) => { - // Handle possible null/undefined values if (!incident.locations) { console.warn("Missing location for incident:", incident.id) 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({ @@ -635,25 +677,22 @@ export default function DistrictLayer({ } }, [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; + const demographics = districtCrime.districts.demographics?.[0]; + 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 .filter(incident => filterCategory === "all" || incident.crime_categories.name === filterCategory @@ -662,32 +701,39 @@ export default function DistrictLayer({ id: incident.id, timestamp: incident.timestamp, description: incident.description, - status: incident.status, + status: incident.status || "", category: incident.crime_categories.name, - type: incident.crime_categories.type, - address: incident.locations.address, + 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, + 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 }; - // 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) { @@ -695,100 +741,19 @@ export default function DistrictLayer({ } 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]); + }, [crimes, filterCategory, year, month]); if (!visible) return null return ( <> - {/* {hoverInfo && ( -
-

- {hoverInfo.feature.properties.nama || hoverInfo.feature.properties.kecamatan} -

- {hoverInfo.feature.properties.number_of_crime !== undefined && ( -

- {hoverInfo.feature.properties.number_of_crime} crime_incidents - {hoverInfo.feature.properties.level && ( - ({hoverInfo.feature.properties.level}) - )} -

- )} -
- )} */} - {selectedDistrictRef.current ? ( { - selectedDistrictRef.current = null; - setSelectedDistrict(null); - }} + onClose={handleCloseDistrictPopup} district={selectedDistrictRef.current} year={year} month={month} diff --git a/sigap-website/app/_components/map/pop-up/district-popup.tsx b/sigap-website/app/_components/map/pop-up/district-popup.tsx index 93dcecb..acb2e7d 100644 --- a/sigap-website/app/_components/map/pop-up/district-popup.tsx +++ b/sigap-website/app/_components/map/pop-up/district-popup.tsx @@ -86,6 +86,7 @@ export default function DistrictPopup({ return year || "All time" } + return ( {getCrimeRateBadge(district.level)} -
+ {/*
{getTimePeriod()} -
+
*/}
diff --git a/sigap-website/app/_utils/types/crimes.ts b/sigap-website/app/_utils/types/crimes.ts new file mode 100644 index 0000000..904ae1b --- /dev/null +++ b/sigap-website/app/_utils/types/crimes.ts @@ -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; + }; + }[]; +}