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 5bd0544..712ef1b 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 @@ -85,12 +85,25 @@ export function usePrefetchedCrimeData(initialYear: number = 2024, initialMonth: 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: currentData, - isCrimesLoading: isPrefetching && !currentData, + crimes: Array.isArray(currentData) ? currentData : [], + isCrimesLoading: isPrefetching || !currentData, crimesError: prefetchError, setSelectedYear, setSelectedMonth, 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 af43479..18b3645 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 @@ -145,29 +145,7 @@ export async function getCrimes() { }, }); - return crimes.map((crime) => { - return { - id: crime.id, - district: crime.districts.name, - number_of_crime: crime.number_of_crime, - level: crime.level, - score: crime.score, - month: crime.month, - year: crime.year, - incidents: crime.crime_incidents.map((incident) => { - return { - 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, - }; - }), - }; - }); + return crimes; } catch (err) { if (err instanceof InputParseError) { // return { @@ -233,6 +211,8 @@ export async function getCrimeByYearAndMonth( address: true, land_area: true, year: true, + latitude: true, + longitude: true, }, }, demographics: { @@ -270,46 +250,21 @@ export async function getCrimeByYearAndMonth( }, }); - return crimes.map((crime) => { + // Process the data to transform geographics and demographics from array to single object + const processedCrimes = crimes.map((crime) => { return { - id: crime.id, - distrcit_id: crime.district_id, - district_name: crime.districts.name, - number_of_crime: crime.number_of_crime, - level: crime.level, - score: crime.score, - month: crime.month, - year: crime.year, - geographics: crime.districts.geographics.map((geo) => { - return { - address: geo.address, - land_area: geo.land_area, - year: geo.year, - }; - }), - demographics: crime.districts.demographics.map((demo) => { - return { - number_of_unemployed: demo.number_of_unemployed, - population: demo.population, - population_density: demo.population_density, - year: demo.year, - }; - }), - incidents: crime.crime_incidents.map((incident) => { - return { - 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, - }; - }), + ...crime, + districts: { + ...crime.districts, + // Convert geographics array to single object matching the year + geographics: crime.districts.geographics[0] || null, + // Convert demographics array to single object matching the year + demographics: crime.districts.demographics[0] || null, + }, }; }); + + return processedCrimes; } catch (err) { if (err instanceof InputParseError) { throw new InputParseError(err.message); diff --git a/sigap-website/app/_components/map/controls/year-selector.tsx b/sigap-website/app/_components/map/controls/year-selector.tsx index 9d88d48..14a6632 100644 --- a/sigap-website/app/_components/map/controls/year-selector.tsx +++ b/sigap-website/app/_components/map/controls/year-selector.tsx @@ -43,7 +43,7 @@ function YearSelectorUI({ return (
{isLoading ? ( -
+
) : ( diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index d934a9b..4303ea7 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -2,14 +2,12 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card" import { Skeleton } from "@/app/_components/ui/skeleton" -import DistrictLayer, { type DistrictFeature } from "./layers/district-layer" +import DistrictLayer, { type DistrictFeature, ICrimeData } from "./layers/district-layer" import MapView from "./map" import { Button } from "@/app/_components/ui/button" import { AlertCircle } from "lucide-react" import { getMonthName } from "@/app/_utils/common" -import { useRef, useState, useCallback, useMemo } from "react" -import { CrimePopup } from "./pop-up" -import type { CrimeIncident } from "./markers/crime-marker" +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" @@ -20,6 +18,22 @@ import MapSelectors from "./controls/map-selector" import TopNavigation from "./controls/map-navigations" 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" + + +// Updated CrimeIncident type to match the structure in crime_incidents +export interface CrimeIncident { + id: string + timestamp: Date + description: string + status: string + category?: string + type?: string + address?: string + latitude?: number + longitude?: number +} export default function CrimeMap() { // State for sidebar @@ -66,33 +80,34 @@ export default function CrimeMap() { if (!crimes) return [] if (selectedCategory === "all") return crimes - return crimes.map((district: { incidents: CrimeIncident[], number_of_crime: number }) => ({ - ...district, - incidents: district.incidents.filter(incident => - incident.category === selectedCategory - ), - // Update number_of_crime to reflect the filtered count - number_of_crime: district.incidents.filter( - incident => incident.category === selectedCategory - ).length - })) + return crimes.map((crime: ICrimeData) => { + const filteredIncidents = crime.crime_incidents.filter( + incident => incident.crime_categories.name === selectedCategory + ) + + return { + ...crime, + crime_incidents: filteredIncidents, + number_of_crime: filteredIncidents.length + } + }) }, [crimes, selectedCategory]) // Extract all incidents from all districts for marker display const allIncidents = useMemo(() => { if (!filteredCrimes) return [] - return filteredCrimes.flatMap((district: { incidents: CrimeIncident[] }) => - district.incidents.map((incident) => ({ + return filteredCrimes.flatMap((crime: ICrimeData) => + crime.crime_incidents.map((incident) => ({ id: incident.id, timestamp: incident.timestamp, description: incident.description, status: incident.status, - category: incident.category, - type: incident.type, - address: incident.address, - latitude: incident.latitude, - longitude: incident.longitude, + category: incident.crime_categories.name, + type: incident.crime_categories.type, + address: incident.locations.address, + latitude: incident.locations.latitude, + longitude: incident.locations.longitude, })) ) }, [filteredCrimes]) @@ -104,9 +119,41 @@ export default function CrimeMap() { // Handle incident marker click const handleIncidentClick = (incident: CrimeIncident) => { - setSelectedIncident(incident) + if (!incident.longitude || !incident.latitude) { + console.error("Invalid incident coordinates:", incident); + return; + } + 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) + if (e.detail) { + setSelectedIncident(e.detail) + } + } + + // Add event listener to the map container and document + const mapContainer = mapContainerRef.current + + // Listen on both the container and document to ensure we catch the event + document.addEventListener('incident_click', handleIncidentClick as EventListener) + + if (mapContainer) { + mapContainer.addEventListener('incident_click', handleIncidentClick as EventListener) + } + + return () => { + document.removeEventListener('incident_click', handleIncidentClick as EventListener) + + if (mapContainer) { + mapContainer.removeEventListener('incident_click', handleIncidentClick as EventListener) + } + } + }, []) + // Reset filters const resetFilters = useCallback(() => { setSelectedYear(2024) @@ -156,53 +203,70 @@ export default function CrimeMap() {
) : (
- - {/* District Layer with crime data */} - - - {/* Popup for selected incident */} - {selectedIncident && ( - setSelectedIncident(null)} - crime={selectedIncident} +
+ + {/* District Layer with crime data */} + - )} - {/* Components that are only visible in fullscreen mode */} - {isFullscreen && ( - <> - -
- -
-
+ {/* Popup for selected incident */} + {selectedIncident && selectedIncident.longitude !== undefined && selectedIncident.latitude !== undefined && ( + <> + {console.log("Rendering CrimePopup with:", selectedIncident)} + setSelectedIncident(null)} + crime={{ + ...selectedIncident, + latitude: selectedIncident.latitude, + longitude: selectedIncident.longitude + }} + /> + + )} - {/* Sidebar component without overlay */} - + {/* Components that are only visible in fullscreen mode */} + {isFullscreen && ( + <> + +
+ +
+
- - - )} -
+ {/* Pass selectedCategory, selectedYear, and selectedMonth to the sidebar */} + + + + + )} + +
)} diff --git a/sigap-website/app/_components/map/layers/district-layer.tsx b/sigap-website/app/_components/map/layers/district-layer.tsx index 46b481b..b1f272e 100644 --- a/sigap-website/app/_components/map/layers/district-layer.tsx +++ b/sigap-website/app/_components/map/layers/district-layer.tsx @@ -1,19 +1,89 @@ "use client" -import { useEffect, useState, useRef } from "react" +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 { DistrictPopup } from "../pop-up" + +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 - longitude?: number - latitude?: number - number_of_crime?: number - level?: "low" | "medium" | "high" | "critical" + 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 @@ -23,14 +93,7 @@ export interface DistrictLayerProps { year?: string month?: string filterCategory?: string | "all" - crimes?: Array<{ - id: string - district_name: string - distrcit_id?: string - number_of_crime?: number - level?: "low" | "medium" | "high" | "critical" - incidents: any[] - }> + crimes?: ICrimeData[] tilesetId?: string } @@ -46,10 +109,14 @@ export default function DistrictLayer({ const { current: map } = useMap() const [hoverInfo, setHoverInfo] = useState<{ - x: number - y: number - feature: any - } | null>(null) + 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(null) const [selectedDistrict, setSelectedDistrict] = useState(null) // Use a ref to track whether layers have been added @@ -59,520 +126,640 @@ export default function DistrictLayer({ const crimeDataByDistrict = crimes.reduce( (acc, crime) => { // Use district_id (which corresponds to kode_kec in the tileset) as the key - const districtId = crime.distrcit_id || crime.district_name + const districtId = crime.district_id - // console.log("Mapping district:", districtId, "level:", crime.level) - - acc[districtId] = { - number_of_crime: crime.number_of_crime, - level: crime.level, - } - return acc - }, - {} as Record, - ) + acc[districtId] = { + number_of_crime: crime.number_of_crime, + level: crime.level, + } + return acc + }, + {} as Record, + ) // Handle click on district const handleClick = (e: any) => { - if (!map || !e.features || e.features.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 crimeData = crimeDataByDistrict[districtId] || {} + const feature = e.features[0] + const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier + const crimeData = crimeDataByDistrict[districtId] || {} - const district: DistrictFeature = { - id: districtId, - name: feature.properties.nama || feature.properties.kecamatan, - properties: feature.properties, - longitude: e.lngLat.lng, - latitude: e.lngLat.lat, - ...crimeData, + // 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 (onClick) { - onClick(district) + 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) + setSelectedDistrict(district); } - } - - // Handle mouse move for hover effect - const handleMouseMove = (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] || {} - - // console.log("Hover district:", districtId, "found data:", crimeData) - - // Enhance feature with crime data - feature.properties = { - ...feature.properties, - ...crimeData, } - setHoverInfo({ - x: e.point.x, - y: e.point.y, - feature: feature, - }) - } + // 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 + if (!map || !visible) return; - // Handler for style load event - const onStyleLoad = () => { - // Skip if map is not available - if (!map) return + const onStyleLoad = () => { + if (!map) return; - try { - // Check if the source already exists to prevent duplicates - if (!map.getMap().getSource("districts")) { - // Get the first symbol layer ID from the map style - // This ensures our layers appear below labels and POIs - const layers = map.getStyle().layers - let firstSymbolId: string | undefined - for (const layer of layers) { - if (layer.type === "symbol") { - firstSymbolId = layer.id - break - } - } - - // Add the vector tile source - map.getMap().addSource("districts", { - type: "vector", - url: `mapbox://${tilesetId}`, - }) - - // Create the dynamic fill color expression based on crime data - const fillColorExpression: any = [ - "case", - ["has", "kode_kec"], - [ - "match", - ["get", "kode_kec"], - ...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { - console.log("Initial color setting for:", districtId, "level:", data.level) - 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, - ] - - // Only add layers if they don't already exist - if (!map.getMap().getLayer("district-fill")) { - // Add the fill layer for districts with dynamic colors from the start - // Insert below the first symbol layer to preserve Mapbox default layers - map.getMap().addLayer( - { - id: "district-fill", - type: "fill", - source: "districts", - "source-layer": "Districts", - paint: { - "fill-color": fillColorExpression, // Apply colors based on crime data - "fill-opacity": 0.6, - }, - }, - firstSymbolId, - ) // Add before the first symbol layer - } - - if (!map.getMap().getLayer("district-line")) { - // Add the line layer for district borders - 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 (!map.getMap().getLayer("district-labels")) { - // // Add district labels with improved visibility and responsive sizing - // map.getMap().addLayer( - // { - // id: "district-labels", - // type: "symbol", - // source: "districts", - // "source-layer": "Districts", - // layout: { - // "text-field": ["get", "nama"], - // "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"], - // // Make text size responsive to zoom level - // "text-size": [ - // "interpolate", - // ["linear"], - // ["zoom"], - // 9, - // 8, // At zoom level 9, size 8px - // 12, - // 12, // At zoom level 12, size 12px - // 15, - // 14, // At zoom level 15, size 14px - // ], - // "text-allow-overlap": false, - // "text-ignore-placement": false, - // // Adjust text anchor based on zoom level - // "text-anchor": "center", - // "text-justify": "center", - // "text-max-width": 8, - // // Show labels only at certain zoom levels - // "text-optional": true, - // "symbol-sort-key": ["get", "kode_kec"], // Sort labels by district code - // "symbol-z-order": "source", - // }, - // paint: { - // "text-color": "#000000", - // "text-halo-color": "#ffffff", - // "text-halo-width": 2, - // "text-halo-blur": 1, - // // Fade in text opacity based on zoom level - // "text-opacity": [ - // "interpolate", - // ["linear"], - // ["zoom"], - // 8, - // 0, // Fully transparent at zoom level 8 - // 9, - // 0.6, // 60% opacity at zoom level 9 - // 10, - // 1.0, // Fully opaque at zoom level 10 - // ], - // }, - // }, - // firstSymbolId, - // ) - // } - - // Create a source for clustered incident markers - if (crimes.length > 0 && !map.getMap().getSource("crime-incidents")) { - // Collect all incidents from all districts - const allIncidents = crimes.flatMap((crime) => { - // Apply category filter if specified - let filteredIncidents = crime.incidents; - if (filterCategory !== "all") { - filteredIncidents = crime.incidents.filter( - incident => incident.category === filterCategory - ); + 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 + } } - return filteredIncidents.map((incident) => ({ + 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.district_name, - category: incident.category, - incidentType: incident.type, - level: crime.level, - description: incident.description, + 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.longitude, incident.latitude], + coordinates: [ + incident.locations.longitude || 0, + incident.locations.latitude || 0 + ], }, - })); - }); - - // Add a clustered GeoJSON source for incidents - map.getMap().addSource("crime-incidents", { - type: "geojson", - data: { - type: "FeatureCollection", - features: allIncidents, - }, - cluster: true, - clusterMaxZoom: 14, - clusterRadius: 50, - }); - - // Only add layers if they don't already exist - if (!map.getMap().getLayer("clusters")) { - // Add a layer for the clusters - place below default symbol layers - map.getMap().addLayer( - { - id: "clusters", - type: "circle", - source: "crime-incidents", - filter: ["has", "point_count"], - paint: { - "circle-color": [ - "step", - ["get", "point_count"], - "#51bbd6", // Blue for small clusters - 5, - "#f1f075", // Yellow for medium clusters - 15, - "#f28cb1", // Pink for large clusters - ], - "circle-radius": [ - "step", - ["get", "point_count"], - 20, // Size for small clusters - 5, - 30, // Size for medium clusters - 15, - 40, // Size for large clusters - ], - "circle-opacity": 0.75, - }, - }, - firstSymbolId, - ) - } - - if (!map.getMap().getLayer("cluster-count")) { - // Add a layer for cluster counts - 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")) { - // Add a layer for individual incident points - 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, - ) - } - - // Add click handler for clusters - 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 - - // Get the cluster expansion zoom - ; (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, - }) - }, - ) - }) - - // Show pointer cursor on clusters and points - map.on("mouseenter", "clusters", () => { - map.getCanvas().style.cursor = "pointer" - }) - - map.on("mouseleave", "clusters", () => { - map.getCanvas().style.cursor = "" - }) - - map.on("mouseenter", "unclustered-point", () => { - map.getCanvas().style.cursor = "pointer" - }) - - map.on("mouseleave", "unclustered-point", () => { - map.getCanvas().style.cursor = "" - }) - } - - // Set event handlers - map.on("click", "district-fill", handleClick) - map.on("mousemove", "district-fill", handleMouseMove) - map.on("mouseleave", "district-fill", () => setHoverInfo(null)) - - // Mark layers as added - layersAdded.current = true - console.log("District layers added successfully") - } else { - // If the source already exists, just update the data - console.log("District source already exists, updating data") - - // Update the district-fill layer with new crime data if it exists - 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 the map's style is already loaded, add the layers immediately - if (map.isStyleLoaded()) { - onStyleLoad() - } else { - // Otherwise, wait for the style.load event - map.once("style.load", onStyleLoad) - } - - // Cleanup function - return () => { - if (map) { - // Only remove event listeners, not the layers themselves - map.off("click", "district-fill", handleClick) - map.off("mousemove", "district-fill", handleMouseMove) - map.off("mouseleave", "district-fill", () => setHoverInfo(null)) - - // We're not removing the layers or sources here to avoid disrupting the map - // This prevents the issue of removing default layers - } - } - }, [map, visible, tilesetId, crimes, filterCategory]) - - // Update the crime data when it changes - useEffect(() => { - if (!map || !layersAdded.current) return - - console.log("Updating district colors with data:", crimeDataByDistrict) - - // Update the district-fill layer with new crime data - try { - // Check if the layer exists before updating it - if (map.getMap().getLayer("district-fill")) { - // We need to update the layer paint property to correctly apply colors - map.getMap().setPaintProperty("district-fill", "fill-color", [ - "case", - ["has", "kode_kec"], - [ - "match", - ["get", "kode_kec"], - ...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { - console.log("Setting color for:", districtId, "level:", data.level) - 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 updating district layer:", error) - } - }, [map, crimes]) - - // Update the incident data when it changes - useEffect(() => { - if (!map || !map.getMap().getSource("crime-incidents")) return; - - try { - // Get all incidents, filtered by category if needed - const allIncidents = crimes.flatMap((crime) => { - // Apply category filter if specified - let filteredIncidents = crime.incidents; - if (filterCategory !== "all") { - filteredIncidents = crime.incidents.filter( - incident => incident.category === filterCategory - ); - } - - return filteredIncidents.map((incident) => ({ - type: "Feature" as const, - properties: { - id: incident.id, - district: crime.district_name, - category: incident.category, - incidentType: incident.type, - level: crime.level, - description: incident.description, - }, - geometry: { - type: "Point" as const, - coordinates: [incident.longitude, incident.latitude], - }, - })); + } + }).filter(Boolean) // Remove null values }); - // Update the data source (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({ type: "FeatureCollection", - features: allIncidents, - }); + features: allIncidents as GeoJSON.Feature[], + }) + } catch (error) { - console.error("Error updating incident data:", error); + console.error("Error updating incident data:", error) } - }, [map, crimes, filterCategory]); + }, [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 ( <> - {/* Hover tooltip */} - {hoverInfo && ( + {/* {hoverInfo && (
{hoverInfo.feature.properties.number_of_crime !== undefined && (

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

- )} -
- )} + )} +
+ )} */} - {/* District popup */} - {selectedDistrict && selectedDistrict.longitude && selectedDistrict.latitude && ( - setSelectedDistrict(null)} - district={selectedDistrict} - year={year} - month={month} - /> - )} - - ) + {selectedDistrictRef.current ? ( + { + selectedDistrictRef.current = null; + setSelectedDistrict(null); + }} + district={selectedDistrictRef.current} + year={year} + month={month} + filterCategory={filterCategory} + /> + ) : null} + + ) } diff --git a/sigap-website/app/_components/map/map.tsx b/sigap-website/app/_components/map/map.tsx index d18bb77..a10c88e 100644 --- a/sigap-website/app/_components/map/map.tsx +++ b/sigap-website/app/_components/map/map.tsx @@ -1,11 +1,14 @@ "use client" import type React from "react" -import { useState, useCallback } from "react" +import { useState, useCallback, useRef } from "react" import { type ViewState, Map, type MapRef, NavigationControl, GeolocateControl } from "react-map-gl/mapbox" import { FullscreenControl } from "react-map-gl/mapbox" import { BASE_LATITUDE, BASE_LONGITUDE, BASE_ZOOM, MAPBOX_STYLES, type MapboxStyle } from "@/app/_utils/const/map" import "mapbox-gl/dist/mapbox-gl.css" +import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'; +import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'; +import mapboxgl from "mapbox-gl" interface MapViewProps { children?: React.ReactNode @@ -29,7 +32,8 @@ export default function MapView({ mapboxApiAccessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN, onMoveEnd, }: MapViewProps) { - const [mapRef, setMapRef] = useState(null) + const mapContainerRef = useRef(null); + const mapRef = useRef(null); const defaultViewState: Partial = { longitude: BASE_LONGITUDE, @@ -40,6 +44,32 @@ export default function MapView({ ...initialViewState, } + const geocoder = new MapboxGeocoder( + { + accessToken: mapboxApiAccessToken!, + mapboxgl: mapboxgl as any, // Type assertion to bypass type checking + marker: false, + placeholder: "Search for places", + proximity: { + longitude: BASE_LONGITUDE, + latitude: BASE_LATITUDE, + }, + }, + ) + + const fullscreenControl = new mapboxgl.FullscreenControl(); + const navigationControl = new mapboxgl.NavigationControl({ + showCompass: false, + }); + + const handleMapLoad = useCallback(() => { + if (mapRef.current) { + // mapRef.current.addControl(geocoder, "top-right") + mapRef.current.addControl(fullscreenControl, "top-right") + mapRef.current.addControl(navigationControl, "top-right") + } + }, [mapRef, geocoder, fullscreenControl, navigationControl]) + const handleMoveEnd = useCallback( (event: any) => { if (onMoveEnd) { @@ -54,16 +84,18 @@ export default function MapView({
- - + {/* + */} {children} @@ -71,4 +103,4 @@ export default function MapView({
) -} +} \ No newline at end of file diff --git a/sigap-website/app/_components/map/pop-up.tsx b/sigap-website/app/_components/map/pop-up.tsx deleted file mode 100644 index 698d81e..0000000 --- a/sigap-website/app/_components/map/pop-up.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { Popup } from 'react-map-gl/mapbox'; -import { useState, useEffect } from 'react'; -import { X } from 'lucide-react'; -import { DistrictFeature } from './layers/district-layer'; -import { useQuery } from "@tanstack/react-query"; -import { Skeleton } from "@/app/_components/ui/skeleton"; -import { getCrimeRateInfo } from "@/app/_utils/common"; -import { CrimeIncident } from './markers/crime-marker'; - - -interface MapPopupProps { - longitude: number - latitude: number - onClose: () => void - children: React.ReactNode - title?: string -} - -export function MapPopup({ - longitude, - latitude, - onClose, - children, - title -}: MapPopupProps) { - return ( - -
- {title &&

{title}

} - - {children} -
-
- ) -} - -// Function to fetch district details - this would typically be implemented -// to fetch more detailed information about a district -const getDistrictDetails = async (districtId: string, year?: string, month?: string) => { - // This would be an API call to get district details - // For now, we'll return mock data - return { - category_breakdown: [ - { category: "Theft", count: 12 }, - { category: "Assault", count: 5 }, - { category: "Vandalism", count: 8 } - ] - }; -}; - -export function DistrictPopup({ - longitude, - latitude, - onClose, - district, - year, - month -}: { - longitude: number - latitude: number - onClose: () => void - district: DistrictFeature - year?: string - month?: string -}) { - const { data: districtDetails, isLoading } = useQuery({ - queryKey: ['district-details', district.id, year, month], - queryFn: () => getDistrictDetails(district.id, year, month), - enabled: !!district.id - }); - - const rateInfo = getCrimeRateInfo(district.level); - - return ( - -
-

{district.name}

- - {district.number_of_crime !== undefined && ( -
- Total Incidents: {district.number_of_crime} -
- )} - - {district.level && ( -
- Crime Rate: - - {rateInfo.text} - -
- )} - - {year && ( -
- Year: {year} - {month && <>, Month: {month}} -
- )} - - {isLoading ? ( -
- -
- ) : districtDetails?.category_breakdown && districtDetails.category_breakdown.length > 0 ? ( -
-

Crime Categories:

-
- {districtDetails.category_breakdown.map((cat, idx) => ( -
- {cat.category} - {cat.count} -
- ))} -
-
- ) : null} - -
- District ID: {district.id} -
-
-
- ) -} - -export function CrimePopup({ - longitude, - latitude, - onClose, - crime -}: { - longitude: number - latitude: number - onClose: () => void - crime: CrimeIncident -}) { - return ( - -
-
- Type: {crime.category} -
- {crime.timestamp && ( -
- Date: {new Date(crime.timestamp).toLocaleDateString()} -
- )} - {crime.description && ( -
- Description: -

{crime.description}

-
- )} -
- ID: {crime.id} -
-
-
- ) -} diff --git a/sigap-website/app/_components/map/pop-up/crime-popup.tsx b/sigap-website/app/_components/map/pop-up/crime-popup.tsx new file mode 100644 index 0000000..35f628a --- /dev/null +++ b/sigap-website/app/_components/map/pop-up/crime-popup.tsx @@ -0,0 +1,125 @@ +import { Popup } from 'react-map-gl/mapbox' +import { Badge } from '@/app/_components/ui/badge' +import { Card } from '@/app/_components/ui/card' +import { MapPin, AlertTriangle, Calendar, Clock, Tag, Bookmark, FileText } from 'lucide-react' + +interface CrimePopupProps { + longitude: number + latitude: number + onClose: () => void + crime: { + id: string + district?: string + category?: string + type?: string + description?: string + status?: string + address?: string + timestamp?: Date + latitude?: number + longitude?: number + } +} + +export default function CrimePopup({ longitude, latitude, onClose, crime }: CrimePopupProps) { + console.log("CrimePopup rendering with props:", { longitude, latitude, crime }) + + const formatDate = (date?: Date) => { + if (!date) return 'Unknown date' + return new Date(date).toLocaleDateString() + } + + const formatTime = (date?: Date) => { + if (!date) return 'Unknown time' + return new Date(date).toLocaleTimeString() + } + + const getStatusBadge = (status?: string) => { + if (!status) return Unknown + + const statusLower = status.toLowerCase() + if (statusLower.includes('resolv') || statusLower.includes('closed')) { + return Resolved + } + if (statusLower.includes('progress') || statusLower.includes('invest')) { + return In Progress + } + if (statusLower.includes('open') || statusLower.includes('new')) { + return Open + } + + return {status} + } + + return ( + + +
+

+ + {crime.category || 'Unknown Incident'} +

+ {getStatusBadge(crime.status)} +
+ + {crime.description && ( +
+

+ + {crime.description} +

+
+ )} + +
+ {crime.district && ( +

+ + {crime.district} +

+ )} + + {crime.address && ( +

+ + {crime.address} +

+ )} + + {crime.timestamp && ( + <> +

+ + {formatDate(crime.timestamp)} +

+

+ + {formatTime(crime.timestamp)} +

+ + )} + + {crime.type && ( +

+ + Type: {crime.type} +

+ )} + +

+ ID: {crime.id} +

+
+
+
+ ) +} diff --git a/sigap-website/app/_components/map/pop-up/district-popup.tsx b/sigap-website/app/_components/map/pop-up/district-popup.tsx new file mode 100644 index 0000000..7fc11c8 --- /dev/null +++ b/sigap-website/app/_components/map/pop-up/district-popup.tsx @@ -0,0 +1,262 @@ +import { useState, useMemo } from 'react' +import { Popup } from 'react-map-gl/mapbox' +import { Badge } from '@/app/_components/ui/badge' +import { Button } from '@/app/_components/ui/button' +import { Card } from '@/app/_components/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/app/_components/ui/tabs' +import { Separator } from '@/app/_components/ui/separator' +import { getMonthName } from '@/app/_utils/common' +import { BarChart, Map, Users, Home, FileBarChart, AlertTriangle } from 'lucide-react' +import type { DistrictFeature } from '../layers/district-layer' + +// Helper function to format numbers +function formatNumber(num?: number): string { + if (num === undefined || num === null) return "N/A"; + + if (num >= 1_000_000) { + return (num / 1_000_000).toFixed(1) + 'M'; + } + + if (num >= 1_000) { + return (num / 1_000).toFixed(1) + 'K'; + } + + return num.toLocaleString(); +} + +interface DistrictPopupProps { + longitude: number + latitude: number + onClose: () => void + district: DistrictFeature + year?: string + month?: string + filterCategory?: string | "all" +} + +export default function DistrictPopup({ + longitude, + latitude, + onClose, + district, + year, + month, + filterCategory = "all" +}: DistrictPopupProps) { + console.log("DistrictPopup rendering with props:", { longitude, latitude, district, year, month }) + const [activeTab, setActiveTab] = useState('overview') + + // Extract all crime incidents from the district data and apply filtering if needed + const allCrimeIncidents = useMemo(() => { + // Check if there are crime incidents in the district object + if (!Array.isArray(district.crime_incidents)) { + console.warn("No crime incidents array found in district data"); + return []; + } + + // Return all incidents if filterCategory is 'all' + if (filterCategory === 'all') { + return district.crime_incidents; + } + + // Otherwise, filter by category + return district.crime_incidents.filter(incident => + incident.category === filterCategory + ); + }, [district, filterCategory]); + + // For debugging: log the actual crime incidents count vs number_of_crime + console.log(`District ${district.name} - Number of crime from data: ${district.number_of_crime}, Incidents array length: ${allCrimeIncidents.length}`); + + const getCrimeRateBadge = (level?: string) => { + switch (level) { + case 'low': + return Low + case 'medium': + return Medium + case 'high': + return High + case 'critical': + return Critical + default: + return Unknown + } + } + + // Format a time period string from year and month + const getTimePeriod = () => { + if (year && month && month !== 'all') { + return `${getMonthName(Number(month))} ${year}` + } + return year || 'All time' + } + + return ( + + +
+
+

{district.name}

+ {getCrimeRateBadge(district.level)} +
+

+ + District ID: {district.id} +

+

+ + {district.number_of_crime || 0} crime incidents in {getTimePeriod()} + {filterCategory !== 'all' ? ` (${filterCategory} category)` : ''} +

+
+ + +
+ + + Overview + + + Demographics + + + Incidents + + +
+ + +
+
+

Crime Level

+

+ This area has a {district.level || 'unknown'} level of crime based on incident reports. +

+
+ + {district.geographics && district.geographics.land_area && ( +
+

+ Geography +

+

+ Land area: {formatNumber(district.geographics.land_area)} km² +

+ {district.geographics.address && ( +

+ Address: {district.geographics.address} +

+ )} +
+ )} + +
+

Time Period

+

+ Data shown for {getTimePeriod()} +

+
+
+
+ + + {district.demographics ? ( +
+
+

+ Population +

+

+ Total: {formatNumber(district.demographics.population || 0)} +

+

+ Density: {formatNumber(district.demographics.population_density || 0)} people/km² +

+
+ +
+

Unemployment

+

+ {formatNumber(district.demographics.number_of_unemployed || 0)} unemployed people +

+ {district.demographics.population && district.demographics.number_of_unemployed && ( +

+ Rate: {((district.demographics.number_of_unemployed / district.demographics.population) * 100).toFixed(1)}% +

+ )} +
+ +
+

Crime Rate

+ {district.number_of_crime && district.demographics.population ? ( +

+ {((district.number_of_crime / district.demographics.population) * 10000).toFixed(2)} crime incidents per 10,000 people +

+ ) : ( +

No data available

+ )} +
+
+ ) : ( +
+ +

No demographic data available for this district.

+
+ )} +
+ + {/* // Inside the TabsContent for crime_incidents */} + + {allCrimeIncidents && allCrimeIncidents.length > 0 ? ( +
+ {allCrimeIncidents.map((incident, index) => ( +
+
+ + {incident.category || incident.type || "Unknown"} + + + {incident.status || "unknown"} + +
+

+ {incident.description || "No description"} +

+

+ {incident.timestamp ? new Date(incident.timestamp).toLocaleString() : "Unknown date"} +

+
+ ))} + + {/* Show a note if we're missing some incidents */} + {district.number_of_crime > allCrimeIncidents.length && ( +
+

+ Showing {allCrimeIncidents.length} of {district.number_of_crime} total incidents + {filterCategory !== 'all' ? ` for ${filterCategory} category` : ''} +

+
+ )} +
+ ) : ( +
+ +

No crime incidents available to display{filterCategory !== 'all' ? ` for ${filterCategory}` : ''}.

+

Total reported incidents: {district.number_of_crime || 0}

+
+ )} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/sigap-website/app/_components/map/sidebar/map-sidebar.tsx b/sigap-website/app/_components/map/sidebar/map-sidebar.tsx index 44b3dcc..668802c 100644 --- a/sigap-website/app/_components/map/sidebar/map-sidebar.tsx +++ b/sigap-website/app/_components/map/sidebar/map-sidebar.tsx @@ -1,101 +1,641 @@ "use client" -import React, { useState } from "react" -import { AlertTriangle, BarChart, ChevronRight, MapPin, Skull, Shield, FileText } from "lucide-react" +import React, { useState, useEffect, useMemo } from "react" +import { + AlertTriangle, BarChart, ChevronRight, MapPin, Skull, Shield, FileText, + Clock, Calendar, MapIcon, Info, CheckCircle, AlertCircle, XCircle, + Bell, Users, Search, List, RefreshCw, Eye +} from "lucide-react" import { Separator } from "@/app/_components/ui/separator" -import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card" import { cn } from "@/app/_lib/utils" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs" +import { Badge } from "@/app/_components/ui/badge" +import { CRIME_RATE_COLORS } from "@/app/_utils/const/map" +import { usePrefetchedCrimeData } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes" +import { getMonthName, formatDateString } from "@/app/_utils/common" +import { Skeleton } from "@/app/_components/ui/skeleton" +import { useGetCrimeCategories } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries" +import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client" interface CrimeSidebarProps { className?: string defaultCollapsed?: boolean + selectedCategory?: string | "all" + selectedYear?: number + selectedMonth?: number | "all" } -export default function CrimeSidebar({ className, defaultCollapsed = true }: CrimeSidebarProps) { +// Updated interface to match the structure returned by getCrimeByYearAndMonth +interface ICrimesProps { + id: string + district_id: string + districts: { + name: string + geographics?: { + address: string + land_area: number + year: 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 + } + }> +} + +export default function CrimeSidebar({ + className, + defaultCollapsed = true, + selectedCategory = "all", + selectedYear: propSelectedYear, + selectedMonth: propSelectedMonth +}: CrimeSidebarProps) { const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed) const [activeTab, setActiveTab] = useState("incidents") + const [currentTime, setCurrentTime] = useState(new Date()) + const [location, setLocation] = useState("Jember, East Java") + + const { + availableYears, + isYearsLoading, + crimes, + isCrimesLoading, + crimesError, + selectedYear: hookSelectedYear, + selectedMonth: hookSelectedMonth, + } = usePrefetchedCrimeData() + + // Use props for selectedYear and selectedMonth if provided, otherwise fall back to hook values + const selectedYear = propSelectedYear || hookSelectedYear + const selectedMonth = propSelectedMonth || hookSelectedMonth + + // Update current time every minute for the real-time display + useEffect(() => { + const timer = setInterval(() => { + setCurrentTime(new Date()) + }, 60000) + + return () => clearInterval(timer) + }, []) + + // Format date with selected year and month if provided + const getDisplayDate = () => { + // If we have a specific month selected, use that for display + if (selectedMonth && selectedMonth !== 'all') { + const date = new Date() + date.setFullYear(selectedYear) + date.setMonth(Number(selectedMonth) - 1) // Month is 0-indexed in JS Date + + return new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'long' + }).format(date) + } + + // Otherwise show today's date + return new Intl.DateTimeFormat('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }).format(currentTime) + } + + const formattedDate = getDisplayDate() + + const formattedTime = new Intl.DateTimeFormat('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: true + }).format(currentTime) + + const { data: categoriesData } = useGetCrimeCategories() + + const crimeStats = useMemo(() => { + // Return default values if crimes is undefined, null, or not an array + if (!crimes || !Array.isArray(crimes) || crimes.length === 0) return { + todaysIncidents: 0, + totalIncidents: 0, + recentIncidents: [], + categoryCounts: {}, + districts: {}, + incidentsByMonth: Array(12).fill(0), + clearanceRate: 0 + } + + // Make sure we have a valid array to work with + let filteredCrimes = [...crimes] + + if (selectedCategory !== "all") { + filteredCrimes = crimes.filter((crime: ICrimesProps) => + crime.crime_incidents.some(incident => + incident.crime_categories.name === selectedCategory + ) + ) + } + + // Collect all incidents from all crimes + const allIncidents = filteredCrimes.flatMap((crime: ICrimesProps) => + 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 + })) + ) + + const totalIncidents = allIncidents.length + + const today = new Date() + const thirtyDaysAgo = new Date() + thirtyDaysAgo.setDate(today.getDate() - 30) + + const recentIncidents = allIncidents + .filter((incident) => { + if (!incident?.timestamp) return false + const incidentDate = new Date(incident.timestamp) + return incidentDate >= thirtyDaysAgo + }) + .sort((a, b) => { + const bTime = b?.timestamp ? new Date(b.timestamp).getTime() : 0 + const aTime = a?.timestamp ? new Date(a.timestamp).getTime() : 0 + return bTime - aTime + }) + + const todaysIncidents = recentIncidents.filter((incident) => { + const incidentDate = incident?.timestamp + ? new Date(incident.timestamp) + : new Date(0) + return incidentDate.toDateString() === today.toDateString() + }).length + + const categoryCounts = allIncidents.reduce((acc: Record, incident) => { + const category = incident?.category || 'Unknown' + acc[category] = (acc[category] || 0) + 1 + return acc + }, {} as Record) + + const districts = filteredCrimes.reduce((acc: Record, crime: ICrimesProps) => { + const districtName = crime.districts.name || 'Unknown' + acc[districtName] = (acc[districtName] || 0) + (crime.number_of_crime || 0) + return acc + }, {} as Record) + + const incidentsByMonth = Array(12).fill(0) + allIncidents.forEach((incident) => { + if (!incident?.timestamp) return; + + const date = new Date(incident.timestamp) + const month = date.getMonth() + if (month >= 0 && month < 12) { + incidentsByMonth[month]++ + } + }) + + const resolvedIncidents = allIncidents.filter(incident => + incident?.status?.toLowerCase() === "resolved" + ).length + + const clearanceRate = totalIncidents > 0 ? + Math.round((resolvedIncidents / totalIncidents) * 100) : 0 + + return { + todaysIncidents, + totalIncidents, + recentIncidents: recentIncidents.slice(0, 10), + categoryCounts, + districts, + incidentsByMonth, + clearanceRate + } + }, [crimes, selectedCategory]) + + // Generate a time period display for the current view + const getTimePeriodDisplay = () => { + if (selectedMonth && selectedMonth !== 'all') { + return `${getMonthName(Number(selectedMonth))} ${selectedYear}` + } + return `${selectedYear} - All months` + } + + const topCategories = useMemo(() => { + if (!crimeStats.categoryCounts) return [] + + return Object.entries(crimeStats.categoryCounts) + .sort((a, b) => (b[1] as number) - (a[1] as number)) + .slice(0, 4) + .map(([type, count]) => { + const percentage = Math.round(((count as number) / crimeStats.totalIncidents) * 100) || 0 + return { type, count: count as number, percentage } + }) + }, [crimeStats]) + + const getTimeAgo = (timestamp: string | Date) => { + const now = new Date() + const eventTime = new Date(timestamp) + const diffMs = now.getTime() - eventTime.getTime() + + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffDays > 0) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago` + if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago` + if (diffMins > 0) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago` + return 'just now' + } + + const getIncidentSeverity = (incident: any): "Low" | "Medium" | "High" | "Critical" => { + if (!incident) return "Low"; + + const category = incident.category || "Unknown"; + + const highSeverityCategories = [ + 'Pembunuhan', 'Perkosaan', 'Penculikan', 'Lahgun Senpi/Handak/Sajam', + 'PTPPO', 'Trafficking In Person' + ] + + const mediumSeverityCategories = [ + 'Penganiayaan Berat', 'Penganiayaan Ringan', 'Pencurian Biasa', 'Curat', + 'Curas', 'Curanmor', 'Pengeroyokan', 'PKDRT', 'Penggelapan', 'Pengrusakan' + ] + + if (highSeverityCategories.includes(category)) return "High" + if (mediumSeverityCategories.includes(category)) return "Medium" + + if (incident.type === "Pidana Tertentu") return "Medium" + return "Low" + } return (
- {/* Main Sidebar Content */} -
-
- +
+
+ Crime Analysis + {!isCrimesLoading && ( + + {getTimePeriodDisplay()} + + )} - + - Incidents + Dashboard Statistics - Reports + Information -
- - }> -
- {[1, 2, 3, 4, 5, 6].map((i) => ( - - ))} +
+ + {isCrimesLoading ? ( +
+ +
+ + + + +
+
+ + + +
- + ) : ( + <> + + +
+
+ + {formattedDate} +
+
+ + {formattedTime} +
+
+
+ + {location} +
+
+ + + {crimeStats.totalIncidents || 0} incidents reported + {selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`} + +
+
+
+ +
+ } + statusColor="text-blue-500" + updatedTime={getTimePeriodDisplay()} + /> + } + statusColor="text-amber-500" + updatedTime="Last 30 days" + /> + 0 ? topCategories[0].type : "None"} + statusIcon={} + statusColor="text-green-500" + /> + } + statusColor="text-purple-500" + updatedTime="Affected areas" + /> +
+ + } + > + {crimeStats.recentIncidents.length === 0 ? ( + + +
+ +

+ {selectedCategory !== "all" + ? `No ${selectedCategory} incidents found` + : "No recent incidents reported"} +

+

Try adjusting your filters or checking back later

+
+
+
+ ) : ( +
+ {crimeStats.recentIncidents.slice(0, 6).map((incident) => ( + + ))} +
+ )} +
+ + )}
- - }> -
- - - + + {isCrimesLoading ? ( +
+ + + +
- + ) : ( + <> + + + Monthly Incidents + {selectedYear} + + +
+ {crimeStats.incidentsByMonth.map((count, i) => { + const maxCount = Math.max(...crimeStats.incidentsByMonth) + const height = maxCount > 0 ? (count / maxCount) * 100 : 0 - + return ( +
+ ) + })} +
+
+ Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec +
+ + - }> -
- - - - -
-
+ }> +
+ + c > 0).length || 1) + ).toString()} + change={selectedMonth !== 'all' ? + `in ${getMonthName(Number(selectedMonth))}` : + "per active month"} + isPositive={false} + /> + 50} + /> +
+
+ + + + }> +
+ {topCategories.length > 0 ? ( + topCategories.map((category) => ( + + )) + ) : ( + + +
+ +

No crime data available

+

Try selecting a different time period

+
+
+
+ )} +
+
+ + )} - - }> -
- - - - -
+ + }> + + +
+

Crime Severity

+
+
+ Low Crime Rate +
+
+
+ Medium Crime Rate +
+
+
+ High Crime Rate +
+
+ + + +
+

Map Markers

+
+ + Individual Incident +
+
+
5
+ Incident Cluster +
+
+
+
+
+ + }> + + +

+ SIGAP Crime Map provides real-time visualization and analysis + of crime incidents across Jember region. +

+

+ Data is sourced from official police reports and updated + daily to ensure accurate information. +

+
+
+ Version + 1.2.4 +
+
+ Last Updated + June 18, 2024 +
+
+
+
+
+ + }> + + +
+ Filtering +

+ Use the year, month, and category filters at the top to + refine the data shown on the map. +

+
+
+ District Information +

+ Click on any district to view detailed crime statistics for that area. +

+
+
+ Incidents +

+ Click on incident markers to view details about specific crime reports. +

+
+
+
@@ -103,13 +643,12 @@ export default function CrimeSidebar({ className, defaultCollapsed = true }: Cri
- {/* Toggle Button - always visible and positioned correctly */}