feat: add crime and district popups with detailed information and filtering capabilities
- Added `CrimePopup` component to display detailed information about individual crime incidents. - Introduced `DistrictPopup` component to show district-level crime statistics and demographics. - Implemented `useFilteredCrimeData` hook for filtering crime data based on selected categories. - Created `calculateCrimeStats` utility function to compute various crime statistics from raw data. - Updated package.json to include necessary dependencies for map and geocoder functionalities.
This commit is contained in:
parent
edf5013363
commit
428986c927
|
@ -85,12 +85,25 @@ export function usePrefetchedCrimeData(initialYear: number = 2024, initialMonth:
|
||||||
const currentKey = `${selectedYear}-${selectedMonth}`
|
const currentKey = `${selectedYear}-${selectedMonth}`
|
||||||
const currentData = prefetchedData[currentKey]
|
const currentData = prefetchedData[currentKey]
|
||||||
|
|
||||||
|
// Add better debugging and handle potential undefined values
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (isPrefetching) {
|
||||||
|
// console.log("Currently prefetching data...")
|
||||||
|
// } else {
|
||||||
|
// console.log(`Current data for ${currentKey}:`, currentData);
|
||||||
|
// if (!currentData) {
|
||||||
|
// console.log("Available prefetched data keys:", Object.keys(prefetchedData));
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }, [isPrefetching, currentKey, currentData, prefetchedData]);
|
||||||
|
|
||||||
|
// Ensure we always return a valid crimes array, even if empty
|
||||||
return {
|
return {
|
||||||
availableYears,
|
availableYears,
|
||||||
isYearsLoading,
|
isYearsLoading,
|
||||||
yearsError,
|
yearsError,
|
||||||
crimes: currentData,
|
crimes: Array.isArray(currentData) ? currentData : [],
|
||||||
isCrimesLoading: isPrefetching && !currentData,
|
isCrimesLoading: isPrefetching || !currentData,
|
||||||
crimesError: prefetchError,
|
crimesError: prefetchError,
|
||||||
setSelectedYear,
|
setSelectedYear,
|
||||||
setSelectedMonth,
|
setSelectedMonth,
|
||||||
|
|
|
@ -145,29 +145,7 @@ export async function getCrimes() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return crimes.map((crime) => {
|
return crimes;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InputParseError) {
|
if (err instanceof InputParseError) {
|
||||||
// return {
|
// return {
|
||||||
|
@ -233,6 +211,8 @@ export async function getCrimeByYearAndMonth(
|
||||||
address: true,
|
address: true,
|
||||||
land_area: true,
|
land_area: true,
|
||||||
year: true,
|
year: true,
|
||||||
|
latitude: true,
|
||||||
|
longitude: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
demographics: {
|
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 {
|
return {
|
||||||
id: crime.id,
|
...crime,
|
||||||
distrcit_id: crime.district_id,
|
districts: {
|
||||||
district_name: crime.districts.name,
|
...crime.districts,
|
||||||
number_of_crime: crime.number_of_crime,
|
// Convert geographics array to single object matching the year
|
||||||
level: crime.level,
|
geographics: crime.districts.geographics[0] || null,
|
||||||
score: crime.score,
|
// Convert demographics array to single object matching the year
|
||||||
month: crime.month,
|
demographics: crime.districts.demographics[0] || null,
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return processedCrimes;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InputParseError) {
|
if (err instanceof InputParseError) {
|
||||||
throw new InputParseError(err.message);
|
throw new InputParseError(err.message);
|
||||||
|
|
|
@ -43,7 +43,7 @@ function YearSelectorUI({
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="mapboxgl-year-selector">
|
<div ref={containerRef} className="mapboxgl-year-selector">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center h-8">
|
<div className=" h-8">
|
||||||
<Skeleton className="h-full w-full rounded-md" />
|
<Skeleton className="h-full w-full rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -2,14 +2,12 @@
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
|
||||||
import { Skeleton } from "@/app/_components/ui/skeleton"
|
import { Skeleton } from "@/app/_components/ui/skeleton"
|
||||||
import DistrictLayer, { type DistrictFeature } from "./layers/district-layer"
|
import DistrictLayer, { type DistrictFeature, ICrimeData } from "./layers/district-layer"
|
||||||
import MapView from "./map"
|
import MapView from "./map"
|
||||||
import { Button } from "@/app/_components/ui/button"
|
import { Button } from "@/app/_components/ui/button"
|
||||||
import { AlertCircle } from "lucide-react"
|
import { AlertCircle } from "lucide-react"
|
||||||
import { getMonthName } from "@/app/_utils/common"
|
import { getMonthName } from "@/app/_utils/common"
|
||||||
import { useRef, useState, useCallback, useMemo } from "react"
|
import { useRef, useState, useCallback, useMemo, useEffect } from "react"
|
||||||
import { CrimePopup } from "./pop-up"
|
|
||||||
import type { CrimeIncident } from "./markers/crime-marker"
|
|
||||||
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
|
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
|
||||||
import { Overlay } from "./overlay"
|
import { Overlay } from "./overlay"
|
||||||
import MapLegend from "./controls/map-legend"
|
import MapLegend from "./controls/map-legend"
|
||||||
|
@ -20,6 +18,22 @@ import MapSelectors from "./controls/map-selector"
|
||||||
import TopNavigation from "./controls/map-navigations"
|
import TopNavigation from "./controls/map-navigations"
|
||||||
import CrimeSidebar from "./sidebar/map-sidebar"
|
import CrimeSidebar from "./sidebar/map-sidebar"
|
||||||
import SidebarToggle from "./sidebar/sidebar-toggle"
|
import SidebarToggle from "./sidebar/sidebar-toggle"
|
||||||
|
import { cn } from "@/app/_lib/utils"
|
||||||
|
import 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() {
|
export default function CrimeMap() {
|
||||||
// State for sidebar
|
// State for sidebar
|
||||||
|
@ -66,33 +80,34 @@ export default function CrimeMap() {
|
||||||
if (!crimes) return []
|
if (!crimes) return []
|
||||||
if (selectedCategory === "all") return crimes
|
if (selectedCategory === "all") return crimes
|
||||||
|
|
||||||
return crimes.map((district: { incidents: CrimeIncident[], number_of_crime: number }) => ({
|
return crimes.map((crime: ICrimeData) => {
|
||||||
...district,
|
const filteredIncidents = crime.crime_incidents.filter(
|
||||||
incidents: district.incidents.filter(incident =>
|
incident => incident.crime_categories.name === selectedCategory
|
||||||
incident.category === selectedCategory
|
)
|
||||||
),
|
|
||||||
// Update number_of_crime to reflect the filtered count
|
return {
|
||||||
number_of_crime: district.incidents.filter(
|
...crime,
|
||||||
incident => incident.category === selectedCategory
|
crime_incidents: filteredIncidents,
|
||||||
).length
|
number_of_crime: filteredIncidents.length
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
}, [crimes, selectedCategory])
|
}, [crimes, selectedCategory])
|
||||||
|
|
||||||
// Extract all incidents from all districts for marker display
|
// Extract all incidents from all districts for marker display
|
||||||
const allIncidents = useMemo(() => {
|
const allIncidents = useMemo(() => {
|
||||||
if (!filteredCrimes) return []
|
if (!filteredCrimes) return []
|
||||||
|
|
||||||
return filteredCrimes.flatMap((district: { incidents: CrimeIncident[] }) =>
|
return filteredCrimes.flatMap((crime: ICrimeData) =>
|
||||||
district.incidents.map((incident) => ({
|
crime.crime_incidents.map((incident) => ({
|
||||||
id: incident.id,
|
id: incident.id,
|
||||||
timestamp: incident.timestamp,
|
timestamp: incident.timestamp,
|
||||||
description: incident.description,
|
description: incident.description,
|
||||||
status: incident.status,
|
status: incident.status,
|
||||||
category: incident.category,
|
category: incident.crime_categories.name,
|
||||||
type: incident.type,
|
type: incident.crime_categories.type,
|
||||||
address: incident.address,
|
address: incident.locations.address,
|
||||||
latitude: incident.latitude,
|
latitude: incident.locations.latitude,
|
||||||
longitude: incident.longitude,
|
longitude: incident.locations.longitude,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
}, [filteredCrimes])
|
}, [filteredCrimes])
|
||||||
|
@ -104,8 +119,40 @@ export default function CrimeMap() {
|
||||||
|
|
||||||
// Handle incident marker click
|
// Handle incident marker click
|
||||||
const handleIncidentClick = (incident: CrimeIncident) => {
|
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
|
// Reset filters
|
||||||
const resetFilters = useCallback(() => {
|
const resetFilters = useCallback(() => {
|
||||||
|
@ -156,6 +203,10 @@ export default function CrimeMap() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative h-[600px]" ref={mapContainerRef}>
|
<div className="relative h-[600px]" ref={mapContainerRef}>
|
||||||
|
<div className={cn(
|
||||||
|
"transition-all duration-300 ease-in-out",
|
||||||
|
!sidebarCollapsed && isFullscreen && "ml-[400px]"
|
||||||
|
)}>
|
||||||
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
|
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
|
||||||
{/* District Layer with crime data */}
|
{/* District Layer with crime data */}
|
||||||
<DistrictLayer
|
<DistrictLayer
|
||||||
|
@ -167,13 +218,20 @@ export default function CrimeMap() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Popup for selected incident */}
|
{/* Popup for selected incident */}
|
||||||
{selectedIncident && (
|
{selectedIncident && selectedIncident.longitude !== undefined && selectedIncident.latitude !== undefined && (
|
||||||
|
<>
|
||||||
|
{console.log("Rendering CrimePopup with:", selectedIncident)}
|
||||||
<CrimePopup
|
<CrimePopup
|
||||||
longitude={selectedIncident.longitude}
|
longitude={selectedIncident.longitude}
|
||||||
latitude={selectedIncident.latitude}
|
latitude={selectedIncident.latitude}
|
||||||
onClose={() => setSelectedIncident(null)}
|
onClose={() => setSelectedIncident(null)}
|
||||||
crime={selectedIncident}
|
crime={{
|
||||||
|
...selectedIncident,
|
||||||
|
latitude: selectedIncident.latitude,
|
||||||
|
longitude: selectedIncident.longitude
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Components that are only visible in fullscreen mode */}
|
{/* Components that are only visible in fullscreen mode */}
|
||||||
|
@ -196,14 +254,20 @@ export default function CrimeMap() {
|
||||||
</div>
|
</div>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
|
|
||||||
{/* Sidebar component without overlay */}
|
{/* Pass selectedCategory, selectedYear, and selectedMonth to the sidebar */}
|
||||||
<CrimeSidebar defaultCollapsed={sidebarCollapsed} />
|
<CrimeSidebar
|
||||||
|
defaultCollapsed={sidebarCollapsed}
|
||||||
|
selectedCategory={selectedCategory}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
selectedMonth={selectedMonth}
|
||||||
|
/>
|
||||||
|
|
||||||
<MapLegend position="bottom-right" />
|
<MapLegend position="bottom-right" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
@ -1,19 +1,89 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from "react"
|
import { useEffect, useState, useRef, useCallback } from "react"
|
||||||
import { useMap } from "react-map-gl/mapbox"
|
import { useMap } from "react-map-gl/mapbox"
|
||||||
import { CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from "@/app/_utils/const/map"
|
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
|
// Types for district properties
|
||||||
export interface DistrictFeature {
|
export interface DistrictFeature {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
properties: Record<string, any>
|
properties: Record<string, any>
|
||||||
longitude?: number
|
longitude: number
|
||||||
latitude?: number
|
latitude: number
|
||||||
number_of_crime?: number
|
number_of_crime: number
|
||||||
level?: "low" | "medium" | "high" | "critical"
|
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
|
// District layer props
|
||||||
|
@ -23,14 +93,7 @@ export interface DistrictLayerProps {
|
||||||
year?: string
|
year?: string
|
||||||
month?: string
|
month?: string
|
||||||
filterCategory?: string | "all"
|
filterCategory?: string | "all"
|
||||||
crimes?: Array<{
|
crimes?: ICrimeData[]
|
||||||
id: string
|
|
||||||
district_name: string
|
|
||||||
distrcit_id?: string
|
|
||||||
number_of_crime?: number
|
|
||||||
level?: "low" | "medium" | "high" | "critical"
|
|
||||||
incidents: any[]
|
|
||||||
}>
|
|
||||||
tilesetId?: string
|
tilesetId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,6 +113,10 @@ export default function DistrictLayer({
|
||||||
y: number
|
y: number
|
||||||
feature: any
|
feature: any
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
|
// Menggunakan useRef untuk menyimpan informasi distrik yang dipilih
|
||||||
|
// sehingga nilainya tidak hilang saat komponen di-render ulang
|
||||||
|
const selectedDistrictRef = useRef<DistrictFeature | null>(null)
|
||||||
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
||||||
|
|
||||||
// Use a ref to track whether layers have been added
|
// Use a ref to track whether layers have been added
|
||||||
|
@ -59,9 +126,7 @@ export default function DistrictLayer({
|
||||||
const crimeDataByDistrict = crimes.reduce(
|
const crimeDataByDistrict = crimes.reduce(
|
||||||
(acc, crime) => {
|
(acc, crime) => {
|
||||||
// Use district_id (which corresponds to kode_kec in the tileset) as the key
|
// 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] = {
|
acc[districtId] = {
|
||||||
number_of_crime: crime.number_of_crime,
|
number_of_crime: crime.number_of_crime,
|
||||||
|
@ -69,7 +134,7 @@ export default function DistrictLayer({
|
||||||
}
|
}
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{} as Record<string, { number_of_crime?: number; level?: "low" | "medium" | "high" | "critical" }>,
|
{} as Record<string, { number_of_crime?: number; level?: $Enums.crime_rates }>,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handle click on district
|
// Handle click on district
|
||||||
|
@ -80,59 +145,128 @@ export default function DistrictLayer({
|
||||||
const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier
|
const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier
|
||||||
const crimeData = crimeDataByDistrict[districtId] || {}
|
const crimeData = crimeDataByDistrict[districtId] || {}
|
||||||
|
|
||||||
|
// Get ALL crime_incidents for this district by aggregating from all matching crime records
|
||||||
|
let crime_incidents: Array<{
|
||||||
|
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 = {
|
const district: DistrictFeature = {
|
||||||
id: districtId,
|
id: districtId,
|
||||||
name: feature.properties.nama || feature.properties.kecamatan,
|
name: feature.properties.nama || feature.properties.kecamatan || "Unknown District",
|
||||||
properties: feature.properties,
|
properties: feature.properties,
|
||||||
longitude: e.lngLat.lng,
|
longitude: geographics.longitude || clickLng || 0,
|
||||||
latitude: e.lngLat.lat,
|
latitude: geographics.latitude || clickLat || 0,
|
||||||
...crimeData,
|
// Use the total crime count across all months
|
||||||
|
number_of_crime: crimeData.number_of_crime || 0,
|
||||||
|
// Use the level from the currently selected month/year (or default to low)
|
||||||
|
level: crimeData.level || $Enums.crime_rates.low,
|
||||||
|
demographics,
|
||||||
|
geographics,
|
||||||
|
// Include all aggregated crime incidents
|
||||||
|
crime_incidents: crime_incidents || [],
|
||||||
|
selectedYear: year,
|
||||||
|
selectedMonth: month
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!district.longitude || !district.latitude) {
|
||||||
|
console.error("Invalid district coordinates:", district);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Selected district:", district);
|
||||||
|
console.log(`Selected district has ${district.crime_incidents?.length || 0} crime_incidents out of ${district.number_of_crime} total crimes`);
|
||||||
|
|
||||||
|
// Set the reference BEFORE handling the onClick or setState
|
||||||
|
selectedDistrictRef.current = district;
|
||||||
|
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick(district)
|
onClick(district);
|
||||||
} else {
|
} else {
|
||||||
setSelectedDistrict(district)
|
setSelectedDistrict(district);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle mouse move for hover effect
|
// Pastikan event handler klik selalu diperbarui
|
||||||
const handleMouseMove = (e: any) => {
|
// dan re-attach setiap kali ada perubahan data
|
||||||
if (!map || !e.features || e.features.length === 0) return
|
useEffect(() => {
|
||||||
|
if (!map || !visible || !map.getMap().getLayer("district-fill")) return;
|
||||||
|
|
||||||
const feature = e.features[0]
|
// Re-attach click handler
|
||||||
const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier
|
map.off("click", "district-fill", handleClick);
|
||||||
const crimeData = crimeDataByDistrict[districtId] || {}
|
map.on("click", "district-fill", handleClick);
|
||||||
|
|
||||||
// console.log("Hover district:", districtId, "found data:", crimeData)
|
console.log("Re-attached click handler, current district:", selectedDistrict?.name || "None");
|
||||||
|
|
||||||
// Enhance feature with crime data
|
return () => {
|
||||||
feature.properties = {
|
if (map) {
|
||||||
...feature.properties,
|
map.off("click", "district-fill", handleClick);
|
||||||
...crimeData,
|
|
||||||
}
|
|
||||||
|
|
||||||
setHoverInfo({
|
|
||||||
x: e.point.x,
|
|
||||||
y: e.point.y,
|
|
||||||
feature: feature,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
}, [map, visible, crimes, filterCategory, year, month]);
|
||||||
|
|
||||||
// Add district layer to the map when it's loaded
|
// Add district layer to the map when it's loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !visible) return
|
if (!map || !visible) return;
|
||||||
|
|
||||||
// Handler for style load event
|
|
||||||
const onStyleLoad = () => {
|
const onStyleLoad = () => {
|
||||||
// Skip if map is not available
|
if (!map) return;
|
||||||
if (!map) return
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if the source already exists to prevent duplicates
|
|
||||||
if (!map.getMap().getSource("districts")) {
|
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
|
const layers = map.getStyle().layers
|
||||||
let firstSymbolId: string | undefined
|
let firstSymbolId: string | undefined
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
|
@ -142,13 +276,11 @@ export default function DistrictLayer({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the vector tile source
|
|
||||||
map.getMap().addSource("districts", {
|
map.getMap().addSource("districts", {
|
||||||
type: "vector",
|
type: "vector",
|
||||||
url: `mapbox://${tilesetId}`,
|
url: `mapbox://${tilesetId}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create the dynamic fill color expression based on crime data
|
|
||||||
const fillColorExpression: any = [
|
const fillColorExpression: any = [
|
||||||
"case",
|
"case",
|
||||||
["has", "kode_kec"],
|
["has", "kode_kec"],
|
||||||
|
@ -156,7 +288,6 @@ export default function DistrictLayer({
|
||||||
"match",
|
"match",
|
||||||
["get", "kode_kec"],
|
["get", "kode_kec"],
|
||||||
...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
||||||
console.log("Initial color setting for:", districtId, "level:", data.level)
|
|
||||||
return [
|
return [
|
||||||
districtId,
|
districtId,
|
||||||
data.level === "low"
|
data.level === "low"
|
||||||
|
@ -173,10 +304,7 @@ export default function DistrictLayer({
|
||||||
CRIME_RATE_COLORS.default,
|
CRIME_RATE_COLORS.default,
|
||||||
]
|
]
|
||||||
|
|
||||||
// Only add layers if they don't already exist
|
|
||||||
if (!map.getMap().getLayer("district-fill")) {
|
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(
|
map.getMap().addLayer(
|
||||||
{
|
{
|
||||||
id: "district-fill",
|
id: "district-fill",
|
||||||
|
@ -184,16 +312,15 @@ export default function DistrictLayer({
|
||||||
source: "districts",
|
source: "districts",
|
||||||
"source-layer": "Districts",
|
"source-layer": "Districts",
|
||||||
paint: {
|
paint: {
|
||||||
"fill-color": fillColorExpression, // Apply colors based on crime data
|
"fill-color": fillColorExpression,
|
||||||
"fill-opacity": 0.6,
|
"fill-opacity": 0.6,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
firstSymbolId,
|
firstSymbolId,
|
||||||
) // Add before the first symbol layer
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getMap().getLayer("district-line")) {
|
if (!map.getMap().getLayer("district-line")) {
|
||||||
// Add the line layer for district borders
|
|
||||||
map.getMap().addLayer(
|
map.getMap().addLayer(
|
||||||
{
|
{
|
||||||
id: "district-line",
|
id: "district-line",
|
||||||
|
@ -210,93 +337,34 @@ export default function DistrictLayer({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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")) {
|
if (crimes.length > 0 && !map.getMap().getSource("crime-incidents")) {
|
||||||
// Collect all incidents from all districts
|
|
||||||
const allIncidents = crimes.flatMap((crime) => {
|
const allIncidents = crimes.flatMap((crime) => {
|
||||||
// Apply category filter if specified
|
|
||||||
let filteredIncidents = crime.incidents;
|
let filteredIncidents = crime.crime_incidents
|
||||||
|
|
||||||
if (filterCategory !== "all") {
|
if (filterCategory !== "all") {
|
||||||
filteredIncidents = crime.incidents.filter(
|
filteredIncidents = crime.crime_incidents.filter(
|
||||||
incident => incident.category === filterCategory
|
incident => incident.crime_categories.name === filterCategory
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredIncidents.map((incident) => ({
|
return filteredIncidents.map((incident) => ({
|
||||||
type: "Feature" as const,
|
type: "Feature" as const,
|
||||||
properties: {
|
properties: {
|
||||||
id: incident.id,
|
id: incident.id,
|
||||||
district: crime.district_name,
|
district: crime.districts.name,
|
||||||
category: incident.category,
|
category: incident.crime_categories.name,
|
||||||
incidentType: incident.type,
|
incidentType: incident.crime_categories.type,
|
||||||
level: crime.level,
|
level: crime.level,
|
||||||
description: incident.description,
|
description: incident.description,
|
||||||
},
|
},
|
||||||
geometry: {
|
geometry: {
|
||||||
type: "Point" as const,
|
type: "Point" as const,
|
||||||
coordinates: [incident.longitude, incident.latitude],
|
coordinates: [incident.locations.longitude, incident.locations.latitude],
|
||||||
},
|
},
|
||||||
}));
|
}))
|
||||||
});
|
})
|
||||||
|
|
||||||
// Add a clustered GeoJSON source for incidents
|
|
||||||
map.getMap().addSource("crime-incidents", {
|
map.getMap().addSource("crime-incidents", {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
data: {
|
data: {
|
||||||
|
@ -306,11 +374,9 @@ export default function DistrictLayer({
|
||||||
cluster: true,
|
cluster: true,
|
||||||
clusterMaxZoom: 14,
|
clusterMaxZoom: 14,
|
||||||
clusterRadius: 50,
|
clusterRadius: 50,
|
||||||
});
|
})
|
||||||
|
|
||||||
// Only add layers if they don't already exist
|
|
||||||
if (!map.getMap().getLayer("clusters")) {
|
if (!map.getMap().getLayer("clusters")) {
|
||||||
// Add a layer for the clusters - place below default symbol layers
|
|
||||||
map.getMap().addLayer(
|
map.getMap().addLayer(
|
||||||
{
|
{
|
||||||
id: "clusters",
|
id: "clusters",
|
||||||
|
@ -321,20 +387,20 @@ export default function DistrictLayer({
|
||||||
"circle-color": [
|
"circle-color": [
|
||||||
"step",
|
"step",
|
||||||
["get", "point_count"],
|
["get", "point_count"],
|
||||||
"#51bbd6", // Blue for small clusters
|
"#51bbd6",
|
||||||
5,
|
5,
|
||||||
"#f1f075", // Yellow for medium clusters
|
"#f1f075",
|
||||||
15,
|
15,
|
||||||
"#f28cb1", // Pink for large clusters
|
"#f28cb1",
|
||||||
],
|
],
|
||||||
"circle-radius": [
|
"circle-radius": [
|
||||||
"step",
|
"step",
|
||||||
["get", "point_count"],
|
["get", "point_count"],
|
||||||
20, // Size for small clusters
|
20,
|
||||||
5,
|
5,
|
||||||
30, // Size for medium clusters
|
30,
|
||||||
15,
|
15,
|
||||||
40, // Size for large clusters
|
40,
|
||||||
],
|
],
|
||||||
"circle-opacity": 0.75,
|
"circle-opacity": 0.75,
|
||||||
},
|
},
|
||||||
|
@ -344,7 +410,6 @@ export default function DistrictLayer({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getMap().getLayer("cluster-count")) {
|
if (!map.getMap().getLayer("cluster-count")) {
|
||||||
// Add a layer for cluster counts
|
|
||||||
map.getMap().addLayer({
|
map.getMap().addLayer({
|
||||||
id: "cluster-count",
|
id: "cluster-count",
|
||||||
type: "symbol",
|
type: "symbol",
|
||||||
|
@ -362,7 +427,6 @@ export default function DistrictLayer({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getMap().getLayer("unclustered-point")) {
|
if (!map.getMap().getLayer("unclustered-point")) {
|
||||||
// Add a layer for individual incident points
|
|
||||||
map.getMap().addLayer(
|
map.getMap().addLayer(
|
||||||
{
|
{
|
||||||
id: "unclustered-point",
|
id: "unclustered-point",
|
||||||
|
@ -380,7 +444,6 @@ export default function DistrictLayer({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add click handler for clusters
|
|
||||||
map.on("click", "clusters", (e) => {
|
map.on("click", "clusters", (e) => {
|
||||||
const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })
|
const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })
|
||||||
|
|
||||||
|
@ -388,7 +451,6 @@ export default function DistrictLayer({
|
||||||
|
|
||||||
const clusterId = features[0].properties?.cluster_id
|
const clusterId = features[0].properties?.cluster_id
|
||||||
|
|
||||||
// Get the cluster expansion zoom
|
|
||||||
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(
|
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(
|
||||||
clusterId,
|
clusterId,
|
||||||
(err, zoom) => {
|
(err, zoom) => {
|
||||||
|
@ -402,7 +464,6 @@ export default function DistrictLayer({
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show pointer cursor on clusters and points
|
|
||||||
map.on("mouseenter", "clusters", () => {
|
map.on("mouseenter", "clusters", () => {
|
||||||
map.getCanvas().style.cursor = "pointer"
|
map.getCanvas().style.cursor = "pointer"
|
||||||
})
|
})
|
||||||
|
@ -420,19 +481,14 @@ export default function DistrictLayer({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set event handlers
|
// Rebind the click event listener for "district-fill"
|
||||||
map.on("click", "district-fill", handleClick)
|
map.on("click", "district-fill", handleClick);
|
||||||
map.on("mousemove", "district-fill", handleMouseMove)
|
|
||||||
map.on("mouseleave", "district-fill", () => setHoverInfo(null))
|
|
||||||
|
|
||||||
// Mark layers as added
|
// Ensure hover info is cleared when leaving the layer
|
||||||
layersAdded.current = true
|
map.on("mouseleave", "district-fill", () => setHoverInfo(null));
|
||||||
console.log("District layers added successfully")
|
|
||||||
|
layersAdded.current = true;
|
||||||
} else {
|
} 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")) {
|
if (map.getMap().getLayer("district-fill")) {
|
||||||
map.getMap().setPaintProperty("district-fill", "fill-color", [
|
map.getMap().setPaintProperty("district-fill", "fill-color", [
|
||||||
"case",
|
"case",
|
||||||
|
@ -461,49 +517,37 @@ export default function DistrictLayer({
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error adding district layers:", error)
|
console.error("Error adding district layers:", error)
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// If the map's style is already loaded, add the layers immediately
|
|
||||||
if (map.isStyleLoaded()) {
|
if (map.isStyleLoaded()) {
|
||||||
onStyleLoad()
|
onStyleLoad();
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, wait for the style.load event
|
map.once("style.load", onStyleLoad);
|
||||||
map.once("style.load", onStyleLoad)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup function
|
|
||||||
return () => {
|
return () => {
|
||||||
if (map) {
|
if (map) {
|
||||||
// Only remove event listeners, not the layers themselves
|
// Remove the click event listener when the component unmounts or dependencies change
|
||||||
map.off("click", "district-fill", handleClick)
|
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])
|
}, [map, visible, tilesetId, crimes, filterCategory, year, month]);
|
||||||
|
|
||||||
// Update the crime data when it changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !layersAdded.current) return
|
if (!map || !layersAdded.current) return
|
||||||
|
|
||||||
console.log("Updating district colors with data:", crimeDataByDistrict)
|
|
||||||
|
|
||||||
// Update the district-fill layer with new crime data
|
|
||||||
try {
|
try {
|
||||||
// Check if the layer exists before updating it
|
|
||||||
if (map.getMap().getLayer("district-fill")) {
|
if (map.getMap().getLayer("district-fill")) {
|
||||||
// We need to update the layer paint property to correctly apply colors
|
// Create a safety check for empty or invalid data
|
||||||
map.getMap().setPaintProperty("district-fill", "fill-color", [
|
const colorEntries = Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
||||||
"case",
|
if (!data || !data.level) {
|
||||||
["has", "kode_kec"],
|
return [
|
||||||
[
|
districtId,
|
||||||
"match",
|
CRIME_RATE_COLORS.default
|
||||||
["get", "kode_kec"],
|
]
|
||||||
...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
}
|
||||||
console.log("Setting color for:", districtId, "level:", data.level)
|
|
||||||
return [
|
return [
|
||||||
districtId,
|
districtId,
|
||||||
data.level === "low"
|
data.level === "low"
|
||||||
|
@ -514,65 +558,208 @@ export default function DistrictLayer({
|
||||||
? CRIME_RATE_COLORS.high
|
? CRIME_RATE_COLORS.high
|
||||||
: CRIME_RATE_COLORS.default,
|
: CRIME_RATE_COLORS.default,
|
||||||
]
|
]
|
||||||
}),
|
})
|
||||||
|
|
||||||
|
const fillColorExpression = [
|
||||||
|
"case",
|
||||||
|
["has", "kode_kec"],
|
||||||
|
[
|
||||||
|
"match",
|
||||||
|
["get", "kode_kec"],
|
||||||
|
...colorEntries,
|
||||||
CRIME_RATE_COLORS.default,
|
CRIME_RATE_COLORS.default,
|
||||||
],
|
],
|
||||||
CRIME_RATE_COLORS.default,
|
CRIME_RATE_COLORS.default,
|
||||||
] as any)
|
] as any
|
||||||
|
|
||||||
|
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating district layer:", error)
|
console.error("Error updating district layer:", error)
|
||||||
}
|
}
|
||||||
}, [map, crimes])
|
}, [map, crimes, crimeDataByDistrict])
|
||||||
|
|
||||||
// Update the incident data when it changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !map.getMap().getSource("crime-incidents")) return;
|
if (!map || !map.getMap().getSource("crime-incidents")) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get all incidents, filtered by category if needed
|
|
||||||
const allIncidents = crimes.flatMap((crime) => {
|
const allIncidents = crimes.flatMap((crime) => {
|
||||||
|
// Make sure we handle cases where crime_incidents might be undefined
|
||||||
|
if (!crime.crime_incidents) return []
|
||||||
|
|
||||||
// Apply category filter if specified
|
// Apply category filter if specified
|
||||||
let filteredIncidents = crime.incidents;
|
let filteredIncidents = crime.crime_incidents
|
||||||
|
|
||||||
if (filterCategory !== "all") {
|
if (filterCategory !== "all") {
|
||||||
filteredIncidents = crime.incidents.filter(
|
filteredIncidents = crime.crime_incidents.filter(
|
||||||
incident => incident.category === filterCategory
|
incident => incident.crime_categories &&
|
||||||
);
|
incident.crime_categories.name === filterCategory
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredIncidents.map((incident) => ({
|
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,
|
type: "Feature" as const,
|
||||||
properties: {
|
properties: {
|
||||||
id: incident.id,
|
id: incident.id,
|
||||||
district: crime.district_name,
|
district: crime.districts?.name || "Unknown",
|
||||||
category: incident.category,
|
category: incident.crime_categories?.name || "Unknown",
|
||||||
incidentType: incident.type,
|
incidentType: incident.crime_categories?.type || "Unknown",
|
||||||
level: crime.level,
|
level: crime.level || "low",
|
||||||
description: incident.description,
|
description: incident.description || "",
|
||||||
},
|
},
|
||||||
geometry: {
|
geometry: {
|
||||||
type: "Point" as const,
|
type: "Point" as const,
|
||||||
coordinates: [incident.longitude, incident.latitude],
|
coordinates: [
|
||||||
|
incident.locations.longitude || 0,
|
||||||
|
incident.locations.latitude || 0
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}));
|
}
|
||||||
|
}).filter(Boolean) // Remove null values
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the data source
|
|
||||||
(map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
(map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: allIncidents,
|
features: allIncidents as GeoJSON.Feature[],
|
||||||
});
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} 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
|
if (!visible) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hover tooltip */}
|
{/* {hoverInfo && (
|
||||||
{hoverInfo && (
|
|
||||||
<div
|
<div
|
||||||
className="absolute z-10 bg-white rounded-md shadow-md px-3 py-2 pointer-events-none"
|
className="absolute z-10 bg-white rounded-md shadow-md px-3 py-2 pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
|
@ -585,26 +772,29 @@ export default function DistrictLayer({
|
||||||
</p>
|
</p>
|
||||||
{hoverInfo.feature.properties.number_of_crime !== undefined && (
|
{hoverInfo.feature.properties.number_of_crime !== undefined && (
|
||||||
<p className="text-xs text-gray-600">
|
<p className="text-xs text-gray-600">
|
||||||
{hoverInfo.feature.properties.number_of_crime} incidents
|
{hoverInfo.feature.properties.number_of_crime} crime_incidents
|
||||||
{hoverInfo.feature.properties.level && (
|
{hoverInfo.feature.properties.level && (
|
||||||
<span className="ml-2 text-xs font-semibold text-gray-500">({hoverInfo.feature.properties.level})</span>
|
<span className="ml-2 text-xs font-semibold text-gray-500">({hoverInfo.feature.properties.level})</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
{/* District popup */}
|
{selectedDistrictRef.current ? (
|
||||||
{selectedDistrict && selectedDistrict.longitude && selectedDistrict.latitude && (
|
|
||||||
<DistrictPopup
|
<DistrictPopup
|
||||||
longitude={selectedDistrict.longitude}
|
longitude={selectedDistrictRef.current.longitude || 0}
|
||||||
latitude={selectedDistrict.latitude}
|
latitude={selectedDistrictRef.current.latitude || 0}
|
||||||
onClose={() => setSelectedDistrict(null)}
|
onClose={() => {
|
||||||
district={selectedDistrict}
|
selectedDistrictRef.current = null;
|
||||||
|
setSelectedDistrict(null);
|
||||||
|
}}
|
||||||
|
district={selectedDistrictRef.current}
|
||||||
year={year}
|
year={year}
|
||||||
month={month}
|
month={month}
|
||||||
|
filterCategory={filterCategory}
|
||||||
/>
|
/>
|
||||||
)}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
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 { type ViewState, Map, type MapRef, NavigationControl, GeolocateControl } from "react-map-gl/mapbox"
|
||||||
import { FullscreenControl } 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 { BASE_LATITUDE, BASE_LONGITUDE, BASE_ZOOM, MAPBOX_STYLES, type MapboxStyle } from "@/app/_utils/const/map"
|
||||||
import "mapbox-gl/dist/mapbox-gl.css"
|
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 {
|
interface MapViewProps {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
@ -29,7 +32,8 @@ export default function MapView({
|
||||||
mapboxApiAccessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN,
|
mapboxApiAccessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN,
|
||||||
onMoveEnd,
|
onMoveEnd,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const [mapRef, setMapRef] = useState<MapRef | null>(null)
|
const mapContainerRef = useRef<MapRef | null>(null);
|
||||||
|
const mapRef = useRef<MapRef | null>(null);
|
||||||
|
|
||||||
const defaultViewState: Partial<ViewState> = {
|
const defaultViewState: Partial<ViewState> = {
|
||||||
longitude: BASE_LONGITUDE,
|
longitude: BASE_LONGITUDE,
|
||||||
|
@ -40,6 +44,32 @@ export default function MapView({
|
||||||
...initialViewState,
|
...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(
|
const handleMoveEnd = useCallback(
|
||||||
(event: any) => {
|
(event: any) => {
|
||||||
if (onMoveEnd) {
|
if (onMoveEnd) {
|
||||||
|
@ -54,16 +84,18 @@ export default function MapView({
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<div className="relative flex-grow h-full transition-all duration-300">
|
<div className="relative flex-grow h-full transition-all duration-300">
|
||||||
<Map
|
<Map
|
||||||
|
ref={mapRef}
|
||||||
mapStyle={mapStyle}
|
mapStyle={mapStyle}
|
||||||
mapboxAccessToken={mapboxApiAccessToken}
|
mapboxAccessToken={mapboxApiAccessToken}
|
||||||
initialViewState={defaultViewState}
|
initialViewState={defaultViewState}
|
||||||
|
onLoad={handleMapLoad}
|
||||||
onMoveEnd={handleMoveEnd}
|
onMoveEnd={handleMoveEnd}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
preserveDrawingBuffer={true} // This helps with fullscreen stability
|
preserveDrawingBuffer={true} // This helps with fullscreen stability
|
||||||
>
|
>
|
||||||
<FullscreenControl position="top-right" />
|
{/* <FullscreenControl position="top-right" />
|
||||||
<NavigationControl position="top-right" showCompass={false} />
|
<NavigationControl position="top-right" showCompass={false} /> */}
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</Map>
|
</Map>
|
||||||
|
|
|
@ -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 (
|
|
||||||
<Popup
|
|
||||||
longitude={longitude}
|
|
||||||
latitude={latitude}
|
|
||||||
closeOnClick={false}
|
|
||||||
onClose={onClose}
|
|
||||||
anchor="bottom"
|
|
||||||
offset={20}
|
|
||||||
className="z-10"
|
|
||||||
>
|
|
||||||
<div className="p-3 min-w-[200px] max-w-[300px]">
|
|
||||||
{title && <h3 className="text-sm font-medium border-b pb-1 mb-2 pr-4">{title}</h3>}
|
|
||||||
<button
|
|
||||||
className="absolute top-2 right-2 p-1 rounded-full hover:bg-gray-100"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</Popup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<MapPopup
|
|
||||||
longitude={longitude}
|
|
||||||
latitude={latitude}
|
|
||||||
onClose={onClose}
|
|
||||||
title="District Information"
|
|
||||||
>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="font-medium text-base">{district.name}</h3>
|
|
||||||
|
|
||||||
{district.number_of_crime !== undefined && (
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
<span className="font-medium">Total Incidents:</span> {district.number_of_crime}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{district.level && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">Crime Rate:</span>
|
|
||||||
<span className={`inline-flex items-center rounded-full ${rateInfo.color} px-2.5 py-0.5 text-xs font-medium`}>
|
|
||||||
{rateInfo.text}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{year && (
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
<span className="font-medium">Year:</span> {year}
|
|
||||||
{month && <>, Month: {month}</>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="h-20 flex items-center justify-center">
|
|
||||||
<Skeleton className="h-16 w-full" />
|
|
||||||
</div>
|
|
||||||
) : districtDetails?.category_breakdown && districtDetails.category_breakdown.length > 0 ? (
|
|
||||||
<div className="mt-2">
|
|
||||||
<h4 className="text-sm font-medium mb-1">Crime Categories:</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{districtDetails.category_breakdown.map((cat, idx) => (
|
|
||||||
<div key={idx} className="flex justify-between text-xs">
|
|
||||||
<span>{cat.category}</span>
|
|
||||||
<span className="font-medium">{cat.count}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
<span className="font-medium">District ID:</span> {district.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MapPopup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CrimePopup({
|
|
||||||
longitude,
|
|
||||||
latitude,
|
|
||||||
onClose,
|
|
||||||
crime
|
|
||||||
}: {
|
|
||||||
longitude: number
|
|
||||||
latitude: number
|
|
||||||
onClose: () => void
|
|
||||||
crime: CrimeIncident
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<MapPopup
|
|
||||||
longitude={longitude}
|
|
||||||
latitude={latitude}
|
|
||||||
onClose={onClose}
|
|
||||||
title="Crime Incident"
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="font-medium">Type:</span> {crime.category}
|
|
||||||
</div>
|
|
||||||
{crime.timestamp && (
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="font-medium">Date:</span> {new Date(crime.timestamp).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{crime.description && (
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="font-medium">Description:</span>
|
|
||||||
<p className="text-xs mt-1">{crime.description}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-gray-500 mt-2">
|
|
||||||
<span className="font-medium">ID:</span> {crime.id}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</MapPopup>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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 <Badge variant="outline">Unknown</Badge>
|
||||||
|
|
||||||
|
const statusLower = status.toLowerCase()
|
||||||
|
if (statusLower.includes('resolv') || statusLower.includes('closed')) {
|
||||||
|
return <Badge className="bg-green-600">Resolved</Badge>
|
||||||
|
}
|
||||||
|
if (statusLower.includes('progress') || statusLower.includes('invest')) {
|
||||||
|
return <Badge className="bg-yellow-600">In Progress</Badge>
|
||||||
|
}
|
||||||
|
if (statusLower.includes('open') || statusLower.includes('new')) {
|
||||||
|
return <Badge className="bg-blue-600">Open</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Badge variant="outline">{status}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popup
|
||||||
|
longitude={longitude}
|
||||||
|
latitude={latitude}
|
||||||
|
closeButton={true}
|
||||||
|
closeOnClick={false}
|
||||||
|
onClose={onClose}
|
||||||
|
anchor="top"
|
||||||
|
maxWidth="280px"
|
||||||
|
className="crime-popup z-50"
|
||||||
|
>
|
||||||
|
<Card className="bg-background p-3 w-full max-w-[280px] shadow-xl border-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-semibold text-sm flex items-center gap-1">
|
||||||
|
<AlertTriangle className="h-3 w-3 text-red-500" />
|
||||||
|
{crime.category || 'Unknown Incident'}
|
||||||
|
</h3>
|
||||||
|
{getStatusBadge(crime.status)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{crime.description && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<FileText className="inline-block h-3 w-3 mr-1 align-text-top" />
|
||||||
|
{crime.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
{crime.district && (
|
||||||
|
<p className="flex items-center">
|
||||||
|
<Bookmark className="inline-block h-3 w-3 mr-1 shrink-0" />
|
||||||
|
<span>{crime.district}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{crime.address && (
|
||||||
|
<p className="flex items-center">
|
||||||
|
<MapPin className="inline-block h-3 w-3 mr-1 shrink-0" />
|
||||||
|
<span>{crime.address}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{crime.timestamp && (
|
||||||
|
<>
|
||||||
|
<p className="flex items-center">
|
||||||
|
<Calendar className="inline-block h-3 w-3 mr-1 shrink-0" />
|
||||||
|
<span>{formatDate(crime.timestamp)}</span>
|
||||||
|
</p>
|
||||||
|
<p className="flex items-center">
|
||||||
|
<Clock className="inline-block h-3 w-3 mr-1 shrink-0" />
|
||||||
|
<span>{formatTime(crime.timestamp)}</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{crime.type && (
|
||||||
|
<p className="flex items-center">
|
||||||
|
<Tag className="inline-block h-3 w-3 mr-1 shrink-0" />
|
||||||
|
<span>Type: {crime.type}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
ID: {crime.id}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Popup>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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 <Badge className="bg-green-600">Low</Badge>
|
||||||
|
case 'medium':
|
||||||
|
return <Badge className="bg-yellow-600">Medium</Badge>
|
||||||
|
case 'high':
|
||||||
|
return <Badge className="bg-red-600">High</Badge>
|
||||||
|
case 'critical':
|
||||||
|
return <Badge className="bg-red-800">Critical</Badge>
|
||||||
|
default:
|
||||||
|
return <Badge className="bg-gray-600">Unknown</Badge>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<Popup
|
||||||
|
longitude={longitude}
|
||||||
|
latitude={latitude}
|
||||||
|
closeButton={true}
|
||||||
|
closeOnClick={false}
|
||||||
|
onClose={onClose}
|
||||||
|
anchor="top"
|
||||||
|
maxWidth="300px"
|
||||||
|
className="district-popup z-50"
|
||||||
|
>
|
||||||
|
<Card className="bg-background p-0 w-full max-w-[300px] shadow-xl border-0">
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="font-semibold text-base">{district.name}</h3>
|
||||||
|
{getCrimeRateBadge(district.level)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<Map className="inline w-3 h-3 mr-1" />
|
||||||
|
District ID: {district.id}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
<FileBarChart className="inline w-3 h-3 mr-1" />
|
||||||
|
{district.number_of_crime || 0} crime incidents in {getTimePeriod()}
|
||||||
|
{filterCategory !== 'all' ? ` (${filterCategory} category)` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
|
<div className="border-t border-border">
|
||||||
|
<TabsList className="w-full rounded-none bg-muted/50 p-0 h-9">
|
||||||
|
<TabsTrigger value="overview" className="rounded-none flex-1 text-xs h-full">
|
||||||
|
Overview
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="demographics" className="rounded-none flex-1 text-xs h-full">
|
||||||
|
Demographics
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="crime_incidents" className="rounded-none flex-1 text-xs h-full">
|
||||||
|
Incidents
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="overview" className="mt-0 p-3">
|
||||||
|
<div className="text-sm space-y-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Crime Level</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
This area has a {district.level || 'unknown'} level of crime based on incident reports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{district.geographics && district.geographics.land_area && (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium flex items-center gap-1">
|
||||||
|
<Home className="w-3 h-3" /> Geography
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Land area: {formatNumber(district.geographics.land_area)} km²
|
||||||
|
</p>
|
||||||
|
{district.geographics.address && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Address: {district.geographics.address}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Time Period</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Data shown for {getTimePeriod()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="demographics" className="mt-0 p-3">
|
||||||
|
{district.demographics ? (
|
||||||
|
<div className="text-sm space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium flex items-center gap-1">
|
||||||
|
<Users className="w-3 h-3" /> Population
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Total: {formatNumber(district.demographics.population || 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Density: {formatNumber(district.demographics.population_density || 0)} people/km²
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Unemployment</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{formatNumber(district.demographics.number_of_unemployed || 0)} unemployed people
|
||||||
|
</p>
|
||||||
|
{district.demographics.population && district.demographics.number_of_unemployed && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Rate: {((district.demographics.number_of_unemployed / district.demographics.population) * 100).toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Crime Rate</p>
|
||||||
|
{district.number_of_crime && district.demographics.population ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{((district.number_of_crime / district.demographics.population) * 10000).toFixed(2)} crime incidents per 10,000 people
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-xs">No data available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-4 text-sm text-muted-foreground">
|
||||||
|
<Users className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No demographic data available for this district.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* // Inside the TabsContent for crime_incidents */}
|
||||||
|
<TabsContent value="crime_incidents" className="mt-0 max-h-[250px] overflow-y-auto">
|
||||||
|
{allCrimeIncidents && allCrimeIncidents.length > 0 ? (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{allCrimeIncidents.map((incident, index) => (
|
||||||
|
<div key={incident.id || index} className="p-3 text-xs">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="font-medium">
|
||||||
|
{incident.category || incident.type || "Unknown"}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline" className="text-[10px] h-5">
|
||||||
|
{incident.status || "unknown"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 truncate">
|
||||||
|
{incident.description || "No description"}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{incident.timestamp ? new Date(incident.timestamp).toLocaleString() : "Unknown date"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Show a note if we're missing some incidents */}
|
||||||
|
{district.number_of_crime > allCrimeIncidents.length && (
|
||||||
|
<div className="p-3 text-xs text-center text-muted-foreground bg-muted/50">
|
||||||
|
<p>
|
||||||
|
Showing {allCrimeIncidents.length} of {district.number_of_crime} total incidents
|
||||||
|
{filterCategory !== 'all' ? ` for ${filterCategory} category` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-4 text-sm text-muted-foreground">
|
||||||
|
<AlertTriangle className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||||
|
<p>No crime incidents available to display{filterCategory !== 'all' ? ` for ${filterCategory}` : ''}.</p>
|
||||||
|
<p className="text-xs mt-2">Total reported incidents: {district.number_of_crime || 0}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</Card>
|
||||||
|
</Popup>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,101 +1,641 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import React, { useState } from "react"
|
import React, { useState, useEffect, useMemo } from "react"
|
||||||
import { AlertTriangle, BarChart, ChevronRight, MapPin, Skull, Shield, FileText } from "lucide-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 { 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 { cn } from "@/app/_lib/utils"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
|
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 {
|
interface CrimeSidebarProps {
|
||||||
className?: string
|
className?: string
|
||||||
defaultCollapsed?: boolean
|
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 [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
|
||||||
const [activeTab, setActiveTab] = useState("incidents")
|
const [activeTab, setActiveTab] = useState("incidents")
|
||||||
|
const [currentTime, setCurrentTime] = useState<Date>(new Date())
|
||||||
|
const [location, setLocation] = useState<string>("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<string, number>, incident) => {
|
||||||
|
const category = incident?.category || 'Unknown'
|
||||||
|
acc[category] = (acc[category] || 0) + 1
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, number>)
|
||||||
|
|
||||||
|
const districts = filteredCrimes.reduce((acc: Record<string, number>, crime: ICrimesProps) => {
|
||||||
|
const districtName = crime.districts.name || 'Unknown'
|
||||||
|
acc[districtName] = (acc[districtName] || 0) + (crime.number_of_crime || 0)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, number>)
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"absolute top-0 left-0 h-full z-10 transition-all duration-300 ease-in-out bg-background backdrop-blur-sm border-r border-white/10 ",
|
"fixed top-0 left-0 h-full z-40 transition-transform duration-300 ease-in-out bg-background backdrop-blur-sm border-r border-white/10",
|
||||||
isCollapsed ? "translate-x-[-100%]" : "translate-x-0",
|
isCollapsed ? "-translate-x-full" : "translate-x-0",
|
||||||
className
|
className
|
||||||
)}>
|
)}>
|
||||||
<div className="relative h-full flex items-stretch">
|
<div className="relative h-full flex items-stretch">
|
||||||
{/* Main Sidebar Content */}
|
<div className="bg-background backdrop-blur-sm border-r border-white/10 h-full w-[400px]">
|
||||||
<div className="bg-background backdrop-blur-sm border-r border-white/10 h-full w-[320px]">
|
<div className="p-4 text-white h-full flex flex-col max-h-full overflow-hidden">
|
||||||
<div className="p-4 text-white h-full flex flex-col">
|
<CardHeader className="p-0 pb-2 shrink-0">
|
||||||
<CardHeader className="p-0 pb-2">
|
|
||||||
<CardTitle className="text-xl font-semibold flex items-center gap-2">
|
<CardTitle className="text-xl font-semibold flex items-center gap-2">
|
||||||
<AlertTriangle className="h-5 w-5" />
|
<AlertTriangle className="h-5 w-5" />
|
||||||
Crime Analysis
|
Crime Analysis
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
{!isCrimesLoading && (
|
||||||
|
<CardDescription className="text-xs text-white/60">
|
||||||
|
{getTimePeriodDisplay()}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<Tabs defaultValue="incidents" className="w-full" value={activeTab} onValueChange={setActiveTab}>
|
<Tabs defaultValue="incidents" className="w-full flex-1 flex flex-col overflow-hidden" value={activeTab} onValueChange={setActiveTab}>
|
||||||
<TabsList className="w-full mb-2 bg-black/30">
|
<TabsList className="w-full mb-2 bg-black/30">
|
||||||
<TabsTrigger value="incidents" className="flex-1">Incidents</TabsTrigger>
|
<TabsTrigger value="incidents" className="flex-1">Dashboard</TabsTrigger>
|
||||||
<TabsTrigger value="statistics" className="flex-1">Statistics</TabsTrigger>
|
<TabsTrigger value="statistics" className="flex-1">Statistics</TabsTrigger>
|
||||||
<TabsTrigger value="reports" className="flex-1">Reports</TabsTrigger>
|
<TabsTrigger value="info" className="flex-1">Information</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1 max-h-[calc(100vh-10rem)]">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1">
|
||||||
<TabsContent value="incidents" className="m-0 p-0">
|
<TabsContent value="incidents" className="m-0 p-0 space-y-4">
|
||||||
<SidebarSection title="Recent Incidents" icon={<AlertTriangle className="h-4 w-4 text-red-400" />}>
|
{isCrimesLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
<Skeleton className="h-20 w-full" />
|
||||||
<IncidentCard key={i} />
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card className="bg-black/20 border border-white/10">
|
||||||
|
<CardContent className="p-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-blue-400" />
|
||||||
|
<span className="font-medium">{formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-blue-400" />
|
||||||
|
<span>{formattedTime}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<MapPin className="h-4 w-4 text-blue-400" />
|
||||||
|
<span className="text-white/70">{location}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-400" />
|
||||||
|
<span>
|
||||||
|
<strong>{crimeStats.totalIncidents || 0}</strong> incidents reported
|
||||||
|
{selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<SystemStatusCard
|
||||||
|
title="Total Cases"
|
||||||
|
status={`${crimeStats?.totalIncidents || 0}`}
|
||||||
|
statusIcon={<AlertCircle className="h-4 w-4 text-blue-500" />}
|
||||||
|
statusColor="text-blue-500"
|
||||||
|
updatedTime={getTimePeriodDisplay()}
|
||||||
|
/>
|
||||||
|
<SystemStatusCard
|
||||||
|
title="Recent Cases"
|
||||||
|
status={`${crimeStats?.recentIncidents?.length || 0}`}
|
||||||
|
statusIcon={<Clock className="h-4 w-4 text-amber-500" />}
|
||||||
|
statusColor="text-amber-500"
|
||||||
|
updatedTime="Last 30 days"
|
||||||
|
/>
|
||||||
|
<SystemStatusCard
|
||||||
|
title="Top Category"
|
||||||
|
status={topCategories.length > 0 ? topCategories[0].type : "None"}
|
||||||
|
statusIcon={<Shield className="h-4 w-4 text-green-500" />}
|
||||||
|
statusColor="text-green-500"
|
||||||
|
/>
|
||||||
|
<SystemStatusCard
|
||||||
|
title="Districts"
|
||||||
|
status={`${Object.keys(crimeStats.districts).length}`}
|
||||||
|
statusIcon={<MapPin className="h-4 w-4 text-purple-500" />}
|
||||||
|
statusColor="text-purple-500"
|
||||||
|
updatedTime="Affected areas"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SidebarSection
|
||||||
|
title={selectedCategory !== "all"
|
||||||
|
? `${selectedCategory} Incidents`
|
||||||
|
: "Recent Incidents"}
|
||||||
|
icon={<AlertTriangle className="h-4 w-4 text-red-400" />}
|
||||||
|
>
|
||||||
|
{crimeStats.recentIncidents.length === 0 ? (
|
||||||
|
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<AlertCircle className="h-6 w-6 text-white/40" />
|
||||||
|
<p className="text-sm text-white/70">
|
||||||
|
{selectedCategory !== "all"
|
||||||
|
? `No ${selectedCategory} incidents found`
|
||||||
|
: "No recent incidents reported"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-white/50">Try adjusting your filters or checking back later</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{crimeStats.recentIncidents.slice(0, 6).map((incident) => (
|
||||||
|
<IncidentCard
|
||||||
|
key={incident.id}
|
||||||
|
title={`${incident.category || 'Unknown'} in ${incident.address?.split(',')[0] || 'Unknown Location'}`}
|
||||||
|
time={incident.timestamp ? getTimeAgo(incident.timestamp) : 'Unknown time'}
|
||||||
|
location={incident.address?.split(',').slice(1, 3).join(', ') || 'Unknown Location'}
|
||||||
|
severity={getIncidentSeverity(incident)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="statistics" className="m-0 p-0">
|
<TabsContent value="statistics" className="m-0 p-0 space-y-4">
|
||||||
|
{isCrimesLoading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
<Skeleton className="h-36 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card className="bg-black/20 border border-white/10 p-3">
|
||||||
|
<CardHeader className="p-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Monthly Incidents</CardTitle>
|
||||||
|
<CardDescription className="text-xs text-white/60">{selectedYear}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="h-24 flex items-end gap-1 mt-2">
|
||||||
|
{crimeStats.incidentsByMonth.map((count, i) => {
|
||||||
|
const maxCount = Math.max(...crimeStats.incidentsByMonth)
|
||||||
|
const height = maxCount > 0 ? (count / maxCount) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"bg-blue-500 w-full rounded-t",
|
||||||
|
selectedMonth !== 'all' && i + 1 === Number(selectedMonth) ? "bg-yellow-500" : ""
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
height: `${Math.max(5, height)}%`,
|
||||||
|
opacity: 0.6 + (i / 24)
|
||||||
|
}}
|
||||||
|
title={`${getMonthName(i + 1)}: ${count} incidents`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-1 text-[10px] text-white/60">
|
||||||
|
<span>Jan</span>
|
||||||
|
<span>Feb</span>
|
||||||
|
<span>Mar</span>
|
||||||
|
<span>Apr</span>
|
||||||
|
<span>May</span>
|
||||||
|
<span>Jun</span>
|
||||||
|
<span>Jul</span>
|
||||||
|
<span>Aug</span>
|
||||||
|
<span>Sep</span>
|
||||||
|
<span>Oct</span>
|
||||||
|
<span>Nov</span>
|
||||||
|
<span>Dec</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<SidebarSection title="Crime Overview" icon={<BarChart className="h-4 w-4 text-blue-400" />}>
|
<SidebarSection title="Crime Overview" icon={<BarChart className="h-4 w-4 text-blue-400" />}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<StatCard title="Total Incidents" value="254" change="+12%" />
|
<StatCard
|
||||||
<StatCard title="Hot Zones" value="6" change="+2" />
|
title="Total Incidents"
|
||||||
<StatCard title="Case Clearance" value="68%" change="+5%" isPositive />
|
value={crimeStats.totalIncidents.toString()}
|
||||||
|
change={`${Object.keys(crimeStats.districts).length} districts`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={selectedMonth !== 'all' ?
|
||||||
|
`${getMonthName(Number(selectedMonth))} Cases` :
|
||||||
|
"Monthly Average"}
|
||||||
|
value={selectedMonth !== 'all' ?
|
||||||
|
crimeStats.totalIncidents.toString() :
|
||||||
|
Math.round(crimeStats.totalIncidents /
|
||||||
|
(crimeStats.incidentsByMonth.filter(c => c > 0).length || 1)
|
||||||
|
).toString()}
|
||||||
|
change={selectedMonth !== 'all' ?
|
||||||
|
`in ${getMonthName(Number(selectedMonth))}` :
|
||||||
|
"per active month"}
|
||||||
|
isPositive={false}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Clearance Rate"
|
||||||
|
value={`${crimeStats.clearanceRate}%`}
|
||||||
|
change="of cases resolved"
|
||||||
|
isPositive={crimeStats.clearanceRate > 50}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
<Separator className="bg-white/20 my-4" />
|
<Separator className="bg-white/20 my-2" />
|
||||||
|
|
||||||
<SidebarSection title="Most Reported" icon={<Skull className="h-4 w-4 text-amber-400" />}>
|
<SidebarSection title="Most Common Crimes" icon={<Skull className="h-4 w-4 text-amber-400" />}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<CrimeTypeCard type="Theft" count={42} percentage={23} />
|
{topCategories.length > 0 ? (
|
||||||
<CrimeTypeCard type="Assault" count={28} percentage={15} />
|
topCategories.map((category) => (
|
||||||
<CrimeTypeCard type="Vandalism" count={19} percentage={10} />
|
<CrimeTypeCard
|
||||||
<CrimeTypeCard type="Burglary" count={15} percentage={8} />
|
key={category.type}
|
||||||
|
type={category.type}
|
||||||
|
count={category.count}
|
||||||
|
percentage={category.percentage}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||||
|
<CardContent className="p-4 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<FileText className="h-6 w-6 text-white/40" />
|
||||||
|
<p className="text-sm text-white/70">No crime data available</p>
|
||||||
|
<p className="text-xs text-white/50">Try selecting a different time period</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="reports" className="m-0 p-0">
|
<TabsContent value="info" className="m-0 p-0 space-y-4">
|
||||||
<SidebarSection title="Recent Reports" icon={<FileText className="h-4 w-4 text-indigo-400" />}>
|
<SidebarSection title="Map Legend" icon={<MapIcon className="h-4 w-4 text-blue-400" />}>
|
||||||
|
<Card className="bg-black/20 border border-white/10">
|
||||||
|
<CardContent className="p-3 text-xs space-y-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<ReportCard
|
<h4 className="font-medium mb-1">Crime Severity</h4>
|
||||||
title="Monthly Crime Summary"
|
<div className="flex items-center gap-2">
|
||||||
date="June 15, 2024"
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: CRIME_RATE_COLORS.low }}></div>
|
||||||
author="Dept. Analysis Team"
|
<span>Low Crime Rate</span>
|
||||||
/>
|
|
||||||
<ReportCard
|
|
||||||
title="High Risk Areas Analysis"
|
|
||||||
date="June 12, 2024"
|
|
||||||
author="Regional Coordinator"
|
|
||||||
/>
|
|
||||||
<ReportCard
|
|
||||||
title="Case Resolution Statistics"
|
|
||||||
date="June 10, 2024"
|
|
||||||
author="Investigation Unit"
|
|
||||||
/>
|
|
||||||
<ReportCard
|
|
||||||
title="Quarterly Report Q2 2024"
|
|
||||||
date="June 1, 2024"
|
|
||||||
author="Crime Analysis Department"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: CRIME_RATE_COLORS.medium }}></div>
|
||||||
|
<span>Medium Crime Rate</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded" style={{ backgroundColor: CRIME_RATE_COLORS.high }}></div>
|
||||||
|
<span>High Crime Rate</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="bg-white/20 my-2" />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium mb-1">Map Markers</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||||
|
<span>Individual Incident</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-4 h-4 rounded-full bg-blue-500 flex items-center justify-center text-[8px] text-white">5</div>
|
||||||
|
<span>Incident Cluster</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</SidebarSection>
|
||||||
|
|
||||||
|
<SidebarSection title="About" icon={<Info className="h-4 w-4 text-blue-400" />}>
|
||||||
|
<Card className="bg-black/20 border border-white/10">
|
||||||
|
<CardContent className="p-3 text-xs">
|
||||||
|
<p className="mb-2">
|
||||||
|
SIGAP Crime Map provides real-time visualization and analysis
|
||||||
|
of crime incidents across Jember region.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Data is sourced from official police reports and updated
|
||||||
|
daily to ensure accurate information.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 text-white/60">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Version</span>
|
||||||
|
<span>1.2.4</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Last Updated</span>
|
||||||
|
<span>June 18, 2024</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</SidebarSection>
|
||||||
|
|
||||||
|
<SidebarSection title="How to Use" icon={<Eye className="h-4 w-4 text-blue-400" />}>
|
||||||
|
<Card className="bg-black/20 border border-white/10">
|
||||||
|
<CardContent className="p-3 text-xs space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Filtering</span>
|
||||||
|
<p className="text-white/70">
|
||||||
|
Use the year, month, and category filters at the top to
|
||||||
|
refine the data shown on the map.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">District Information</span>
|
||||||
|
<p className="text-white/70">
|
||||||
|
Click on any district to view detailed crime statistics for that area.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Incidents</span>
|
||||||
|
<p className="text-white/70">
|
||||||
|
Click on incident markers to view details about specific crime reports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
|
@ -103,13 +643,12 @@ export default function CrimeSidebar({ className, defaultCollapsed = true }: Cri
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toggle Button - always visible and positioned correctly */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute h-12 w-8 bg-background backdrop-blur-sm border-t border-b border-r border-white/10 flex items-center justify-center",
|
"absolute h-12 w-8 bg-background backdrop-blur-sm border-t border-b border-r border-white/10 flex items-center justify-center",
|
||||||
"top-1/2 -translate-y-1/2 transition-all duration-300 ease-in-out",
|
"top-1/2 -translate-y-1/2 transition-all duration-300 ease-in-out",
|
||||||
isCollapsed ? "-right-8 rounded-r-md" : "left-[320px] rounded-r-md",
|
isCollapsed ? "-right-8 rounded-r-md" : "left-[400px] rounded-r-md",
|
||||||
)}
|
)}
|
||||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
>
|
>
|
||||||
|
@ -123,8 +662,6 @@ export default function CrimeSidebar({ className, defaultCollapsed = true }: Cri
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper components for sidebar content
|
|
||||||
|
|
||||||
interface SidebarSectionProps {
|
interface SidebarSectionProps {
|
||||||
title: string
|
title: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
@ -143,24 +680,69 @@ function SidebarSection({ title, children, icon }: SidebarSectionProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function IncidentCard() {
|
interface SystemStatusCardProps {
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
statusIcon: React.ReactNode
|
||||||
|
statusColor: string
|
||||||
|
updatedTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemStatusCard({ title, status, statusIcon, statusColor, updatedTime }: SystemStatusCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-black/20 border border-white/10">
|
||||||
|
<CardContent className="p-2 text-xs">
|
||||||
|
<div className="font-medium mb-1">{title}</div>
|
||||||
|
<div className={`flex items-center gap-1 ${statusColor}`}>
|
||||||
|
{statusIcon}
|
||||||
|
<span>{status}</span>
|
||||||
|
</div>
|
||||||
|
{updatedTime && (
|
||||||
|
<div className="text-white/50 text-[10px] mt-1">{updatedTime}</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnhancedIncidentCardProps {
|
||||||
|
title: string
|
||||||
|
time: string
|
||||||
|
location: string
|
||||||
|
severity: "Low" | "Medium" | "High" | "Critical"
|
||||||
|
}
|
||||||
|
|
||||||
|
function IncidentCard({ title, time, location, severity }: EnhancedIncidentCardProps) {
|
||||||
|
const getBadgeColor = () => {
|
||||||
|
switch (severity) {
|
||||||
|
case "Low": return "bg-green-500/20 text-green-300";
|
||||||
|
case "Medium": return "bg-yellow-500/20 text-yellow-300";
|
||||||
|
case "High": return "bg-orange-500/20 text-orange-300";
|
||||||
|
case "Critical": return "bg-red-500/20 text-red-300";
|
||||||
|
default: return "bg-gray-500/20 text-gray-300";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-white/10 border-0 text-white shadow-none">
|
<Card className="bg-white/10 border-0 text-white shadow-none">
|
||||||
<CardContent className="p-3 text-xs">
|
<CardContent className="p-3 text-xs">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">Theft reported at Jalan Srikandi</p>
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-medium">{title}</p>
|
||||||
|
<Badge className={`${getBadgeColor()} text-[9px] h-4 ml-1`}>{severity}</Badge>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-1 text-white/60">
|
<div className="flex items-center gap-2 mt-1 text-white/60">
|
||||||
<MapPin className="h-3 w-3" />
|
<MapPin className="h-3 w-3" />
|
||||||
<span>Jombang District</span>
|
<span>{location}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-white/60">3 hours ago</div>
|
<div className="mt-1 text-white/60">{time}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
|
@ -232,3 +814,7 @@ function ReportCard({ title, date, author }: ReportCardProps) {
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PieChart(props: any) {
|
||||||
|
return <BarChart {...props} />;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,36 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
|
||||||
|
export function useFilteredCrimeData(
|
||||||
|
crimes: any[] | undefined,
|
||||||
|
selectedCategory: string | "all" = "all"
|
||||||
|
) {
|
||||||
|
// Handle filtered crime data
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!crimes || crimes.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCategory === "all") {
|
||||||
|
return crimes
|
||||||
|
}
|
||||||
|
|
||||||
|
return crimes.filter(crime => {
|
||||||
|
// Check if any incident in this crime matches the category
|
||||||
|
return crime.incidents?.some((incident: any) =>
|
||||||
|
incident.category === selectedCategory
|
||||||
|
)
|
||||||
|
}).map(crime => ({
|
||||||
|
...crime,
|
||||||
|
// Only include incidents that match the selected category
|
||||||
|
incidents: crime.incidents.filter((incident: any) =>
|
||||||
|
incident.category === selectedCategory
|
||||||
|
),
|
||||||
|
// Update number_of_crime to reflect the filtered count
|
||||||
|
number_of_crime: crime.incidents.filter(
|
||||||
|
(incident: any) => incident.category === selectedCategory
|
||||||
|
).length
|
||||||
|
}))
|
||||||
|
}, [crimes, selectedCategory])
|
||||||
|
}
|
|
@ -770,3 +770,22 @@ export const getDistrictName = (districtId: string): string => {
|
||||||
'Unknown District'
|
'Unknown District'
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format number with commas or abbreviate large numbers
|
||||||
|
*/
|
||||||
|
export function formatNumber(num?: number): string {
|
||||||
|
if (num === undefined || num === null) return "N/A";
|
||||||
|
|
||||||
|
// If number is in the thousands, abbreviate
|
||||||
|
if (num >= 1_000_000) {
|
||||||
|
return (num / 1_000_000).toFixed(1) + 'M';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (num >= 1_000) {
|
||||||
|
return (num / 1_000).toFixed(1) + 'K';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, format with commas
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* Calculates crime statistics from raw crime data
|
||||||
|
*/
|
||||||
|
export function calculateCrimeStats(
|
||||||
|
crimes: any[] | undefined,
|
||||||
|
selectedCategory: string | 'all' = 'all'
|
||||||
|
) {
|
||||||
|
if (!crimes || crimes.length === 0) {
|
||||||
|
return {
|
||||||
|
todaysIncidents: 0,
|
||||||
|
totalIncidents: 0,
|
||||||
|
recentIncidents: [],
|
||||||
|
categoryCounts: {},
|
||||||
|
districts: {},
|
||||||
|
incidentsByMonth: Array(12).fill(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter incidents by category if needed
|
||||||
|
let filteredIncidents: any[] = [];
|
||||||
|
|
||||||
|
crimes.forEach((crime) => {
|
||||||
|
if (selectedCategory === 'all') {
|
||||||
|
// Include all incidents
|
||||||
|
if (crime.incidents) {
|
||||||
|
filteredIncidents = [...filteredIncidents, ...crime.incidents];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Filter by category
|
||||||
|
const matchingIncidents =
|
||||||
|
crime.incidents?.filter(
|
||||||
|
(incident: any) => incident.category === selectedCategory
|
||||||
|
) || [];
|
||||||
|
filteredIncidents = [...filteredIncidents, ...matchingIncidents];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
const today = new Date();
|
||||||
|
const thirtyDaysAgo = new Date();
|
||||||
|
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||||
|
|
||||||
|
// Get recent incidents (last 30 days)
|
||||||
|
const recentIncidents = filteredIncidents
|
||||||
|
.filter((incident) => {
|
||||||
|
const incidentDate = new Date(incident.timestamp);
|
||||||
|
return incidentDate >= thirtyDaysAgo;
|
||||||
|
})
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get today's incidents
|
||||||
|
const todaysIncidents = recentIncidents.filter((incident) => {
|
||||||
|
const incidentDate = new Date(incident.timestamp);
|
||||||
|
return incidentDate.toDateString() === today.toDateString();
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
// Count by category
|
||||||
|
const categoryCounts = filteredIncidents.reduce(
|
||||||
|
(acc: Record<string, number>, incident) => {
|
||||||
|
const category = incident.category || 'Unknown';
|
||||||
|
acc[category] = (acc[category] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count by district
|
||||||
|
const districts = crimes.reduce((acc: Record<string, number>, crime) => {
|
||||||
|
if (selectedCategory === 'all') {
|
||||||
|
acc[crime.district_name] =
|
||||||
|
(acc[crime.district_name] || 0) + (crime.number_of_crime || 0);
|
||||||
|
} else {
|
||||||
|
// Count only matching incidents
|
||||||
|
const matchCount =
|
||||||
|
crime.incidents?.filter(
|
||||||
|
(incident: any) => incident.category === selectedCategory
|
||||||
|
).length || 0;
|
||||||
|
|
||||||
|
if (matchCount > 0) {
|
||||||
|
acc[crime.district_name] = (acc[crime.district_name] || 0) + matchCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Group by month
|
||||||
|
const incidentsByMonth = Array(12).fill(0);
|
||||||
|
filteredIncidents.forEach((incident) => {
|
||||||
|
const date = new Date(incident.timestamp);
|
||||||
|
const month = date.getMonth();
|
||||||
|
if (month >= 0 && month < 12) {
|
||||||
|
incidentsByMonth[month]++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
todaysIncidents,
|
||||||
|
totalIncidents: filteredIncidents.length,
|
||||||
|
recentIncidents,
|
||||||
|
categoryCounts,
|
||||||
|
districts,
|
||||||
|
incidentsByMonth,
|
||||||
|
};
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -13,6 +13,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@evyweb/ioctopus": "^1.2.0",
|
"@evyweb/ioctopus": "^1.2.0",
|
||||||
"@hookform/resolvers": "^4.1.2",
|
"@hookform/resolvers": "^4.1.2",
|
||||||
|
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
|
||||||
"@prisma/client": "^6.4.1",
|
"@prisma/client": "^6.4.1",
|
||||||
"@prisma/instrumentation": "^6.5.0",
|
"@prisma/instrumentation": "^6.5.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
|
@ -72,6 +73,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/eslint-plugin-query": "^5.67.2",
|
"@tanstack/eslint-plugin-query": "^5.67.2",
|
||||||
"@tanstack/react-query-devtools": "^5.67.2",
|
"@tanstack/react-query-devtools": "^5.67.2",
|
||||||
|
"@types/mapbox__mapbox-gl-geocoder": "^5.0.0",
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.0.2",
|
||||||
"@types/react-dom": "19.0.2",
|
"@types/react-dom": "19.0.2",
|
||||||
|
|
Loading…
Reference in New Issue