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:
vergiLgood1 2025-05-02 22:20:22 +07:00
parent edf5013363
commit 428986c927
15 changed files with 3229 additions and 912 deletions

View File

@ -85,12 +85,25 @@ export function usePrefetchedCrimeData(initialYear: number = 2024, initialMonth:
const currentKey = `${selectedYear}-${selectedMonth}`
const currentData = prefetchedData[currentKey]
// Add better debugging and handle potential undefined values
// useEffect(() => {
// if (isPrefetching) {
// console.log("Currently prefetching data...")
// } else {
// console.log(`Current data for ${currentKey}:`, currentData);
// if (!currentData) {
// console.log("Available prefetched data keys:", Object.keys(prefetchedData));
// }
// }
// }, [isPrefetching, currentKey, currentData, prefetchedData]);
// Ensure we always return a valid crimes array, even if empty
return {
availableYears,
isYearsLoading,
yearsError,
crimes: currentData,
isCrimesLoading: isPrefetching && !currentData,
crimes: Array.isArray(currentData) ? currentData : [],
isCrimesLoading: isPrefetching || !currentData,
crimesError: prefetchError,
setSelectedYear,
setSelectedMonth,

View File

@ -145,29 +145,7 @@ export async function getCrimes() {
},
});
return crimes.map((crime) => {
return {
id: crime.id,
district: crime.districts.name,
number_of_crime: crime.number_of_crime,
level: crime.level,
score: crime.score,
month: crime.month,
year: crime.year,
incidents: crime.crime_incidents.map((incident) => {
return {
timestamp: incident.timestamp,
description: incident.description,
status: incident.status,
category: incident.crime_categories.name,
type: incident.crime_categories.type,
address: incident.locations.address,
latitude: incident.locations.latitude,
longitude: incident.locations.longitude,
};
}),
};
});
return crimes;
} catch (err) {
if (err instanceof InputParseError) {
// return {
@ -233,6 +211,8 @@ export async function getCrimeByYearAndMonth(
address: true,
land_area: true,
year: true,
latitude: true,
longitude: true,
},
},
demographics: {
@ -270,46 +250,21 @@ export async function getCrimeByYearAndMonth(
},
});
return crimes.map((crime) => {
// Process the data to transform geographics and demographics from array to single object
const processedCrimes = crimes.map((crime) => {
return {
id: crime.id,
distrcit_id: crime.district_id,
district_name: crime.districts.name,
number_of_crime: crime.number_of_crime,
level: crime.level,
score: crime.score,
month: crime.month,
year: crime.year,
geographics: crime.districts.geographics.map((geo) => {
return {
address: geo.address,
land_area: geo.land_area,
year: geo.year,
};
}),
demographics: crime.districts.demographics.map((demo) => {
return {
number_of_unemployed: demo.number_of_unemployed,
population: demo.population,
population_density: demo.population_density,
year: demo.year,
};
}),
incidents: crime.crime_incidents.map((incident) => {
return {
id: incident.id,
timestamp: incident.timestamp,
description: incident.description,
status: incident.status,
category: incident.crime_categories.name,
type: incident.crime_categories.type,
address: incident.locations.address,
latitude: incident.locations.latitude,
longitude: incident.locations.longitude,
};
}),
...crime,
districts: {
...crime.districts,
// Convert geographics array to single object matching the year
geographics: crime.districts.geographics[0] || null,
// Convert demographics array to single object matching the year
demographics: crime.districts.demographics[0] || null,
},
};
});
return processedCrimes;
} catch (err) {
if (err instanceof InputParseError) {
throw new InputParseError(err.message);

View File

@ -43,7 +43,7 @@ function YearSelectorUI({
return (
<div ref={containerRef} className="mapboxgl-year-selector">
{isLoading ? (
<div className="flex items-center justify-center h-8">
<div className=" h-8">
<Skeleton className="h-full w-full rounded-md" />
</div>
) : (

View File

@ -2,14 +2,12 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
import { Skeleton } from "@/app/_components/ui/skeleton"
import DistrictLayer, { type DistrictFeature } from "./layers/district-layer"
import DistrictLayer, { type DistrictFeature, ICrimeData } from "./layers/district-layer"
import MapView from "./map"
import { Button } from "@/app/_components/ui/button"
import { AlertCircle } from "lucide-react"
import { getMonthName } from "@/app/_utils/common"
import { useRef, useState, useCallback, useMemo } from "react"
import { CrimePopup } from "./pop-up"
import type { CrimeIncident } from "./markers/crime-marker"
import { useRef, useState, useCallback, useMemo, useEffect } from "react"
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
import { Overlay } from "./overlay"
import MapLegend from "./controls/map-legend"
@ -20,6 +18,22 @@ import MapSelectors from "./controls/map-selector"
import TopNavigation from "./controls/map-navigations"
import CrimeSidebar from "./sidebar/map-sidebar"
import SidebarToggle from "./sidebar/sidebar-toggle"
import { cn } from "@/app/_lib/utils"
import CrimePopup from "./pop-up/crime-popup"
// Updated CrimeIncident type to match the structure in crime_incidents
export interface CrimeIncident {
id: string
timestamp: Date
description: string
status: string
category?: string
type?: string
address?: string
latitude?: number
longitude?: number
}
export default function CrimeMap() {
// State for sidebar
@ -66,33 +80,34 @@ export default function CrimeMap() {
if (!crimes) return []
if (selectedCategory === "all") return crimes
return crimes.map((district: { incidents: CrimeIncident[], number_of_crime: number }) => ({
...district,
incidents: district.incidents.filter(incident =>
incident.category === selectedCategory
),
// Update number_of_crime to reflect the filtered count
number_of_crime: district.incidents.filter(
incident => incident.category === selectedCategory
).length
}))
return crimes.map((crime: ICrimeData) => {
const filteredIncidents = crime.crime_incidents.filter(
incident => incident.crime_categories.name === selectedCategory
)
return {
...crime,
crime_incidents: filteredIncidents,
number_of_crime: filteredIncidents.length
}
})
}, [crimes, selectedCategory])
// Extract all incidents from all districts for marker display
const allIncidents = useMemo(() => {
if (!filteredCrimes) return []
return filteredCrimes.flatMap((district: { incidents: CrimeIncident[] }) =>
district.incidents.map((incident) => ({
return filteredCrimes.flatMap((crime: ICrimeData) =>
crime.crime_incidents.map((incident) => ({
id: incident.id,
timestamp: incident.timestamp,
description: incident.description,
status: incident.status,
category: incident.category,
type: incident.type,
address: incident.address,
latitude: incident.latitude,
longitude: incident.longitude,
category: incident.crime_categories.name,
type: incident.crime_categories.type,
address: incident.locations.address,
latitude: incident.locations.latitude,
longitude: incident.locations.longitude,
}))
)
}, [filteredCrimes])
@ -104,9 +119,41 @@ export default function CrimeMap() {
// Handle incident marker click
const handleIncidentClick = (incident: CrimeIncident) => {
setSelectedIncident(incident)
if (!incident.longitude || !incident.latitude) {
console.error("Invalid incident coordinates:", incident);
return;
}
setSelectedIncident(incident);
}
// Set up event listener for incident clicks from the district layer
useEffect(() => {
const handleIncidentClick = (e: CustomEvent) => {
// console.log("Received incident_click event:", e.detail)
if (e.detail) {
setSelectedIncident(e.detail)
}
}
// Add event listener to the map container and document
const mapContainer = mapContainerRef.current
// Listen on both the container and document to ensure we catch the event
document.addEventListener('incident_click', handleIncidentClick as EventListener)
if (mapContainer) {
mapContainer.addEventListener('incident_click', handleIncidentClick as EventListener)
}
return () => {
document.removeEventListener('incident_click', handleIncidentClick as EventListener)
if (mapContainer) {
mapContainer.removeEventListener('incident_click', handleIncidentClick as EventListener)
}
}
}, [])
// Reset filters
const resetFilters = useCallback(() => {
setSelectedYear(2024)
@ -156,53 +203,70 @@ export default function CrimeMap() {
</div>
) : (
<div className="relative h-[600px]" ref={mapContainerRef}>
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
{/* District Layer with crime data */}
<DistrictLayer
onClick={handleDistrictClick}
crimes={filteredCrimes || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
/>
{/* Popup for selected incident */}
{selectedIncident && (
<CrimePopup
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={() => setSelectedIncident(null)}
crime={selectedIncident}
<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">
{/* District Layer with crime data */}
<DistrictLayer
onClick={handleDistrictClick}
crimes={filteredCrimes || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
/>
)}
{/* Components that are only visible in fullscreen mode */}
{isFullscreen && (
<>
<Overlay position="top" className="m-0 bg-transparent shadow-none p-0 border-none">
<div className="flex justify-center">
<TopNavigation
activeControl={activeControl}
onControlChange={setActiveControl}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
selectedMonth={selectedMonth}
setSelectedMonth={setSelectedMonth}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
availableYears={availableYears || []}
categories={categories}
/>
</div>
</Overlay>
{/* Popup for selected incident */}
{selectedIncident && selectedIncident.longitude !== undefined && selectedIncident.latitude !== undefined && (
<>
{console.log("Rendering CrimePopup with:", selectedIncident)}
<CrimePopup
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={() => setSelectedIncident(null)}
crime={{
...selectedIncident,
latitude: selectedIncident.latitude,
longitude: selectedIncident.longitude
}}
/>
</>
)}
{/* Sidebar component without overlay */}
<CrimeSidebar defaultCollapsed={sidebarCollapsed} />
{/* Components that are only visible in fullscreen mode */}
{isFullscreen && (
<>
<Overlay position="top" className="m-0 bg-transparent shadow-none p-0 border-none">
<div className="flex justify-center">
<TopNavigation
activeControl={activeControl}
onControlChange={setActiveControl}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
selectedMonth={selectedMonth}
setSelectedMonth={setSelectedMonth}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
availableYears={availableYears || []}
categories={categories}
/>
</div>
</Overlay>
<MapLegend position="bottom-right" />
</>
)}
</MapView>
{/* Pass selectedCategory, selectedYear, and selectedMonth to the sidebar */}
<CrimeSidebar
defaultCollapsed={sidebarCollapsed}
selectedCategory={selectedCategory}
selectedYear={selectedYear}
selectedMonth={selectedMonth}
/>
<MapLegend position="bottom-right" />
</>
)}
</MapView>
</div>
</div>
)}
</CardContent>

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,14 @@
"use client"
import type React from "react"
import { useState, useCallback } from "react"
import { useState, useCallback, useRef } from "react"
import { type ViewState, Map, type MapRef, NavigationControl, GeolocateControl } from "react-map-gl/mapbox"
import { FullscreenControl } from "react-map-gl/mapbox"
import { BASE_LATITUDE, BASE_LONGITUDE, BASE_ZOOM, MAPBOX_STYLES, type MapboxStyle } from "@/app/_utils/const/map"
import "mapbox-gl/dist/mapbox-gl.css"
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import mapboxgl from "mapbox-gl"
interface MapViewProps {
children?: React.ReactNode
@ -29,7 +32,8 @@ export default function MapView({
mapboxApiAccessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN,
onMoveEnd,
}: MapViewProps) {
const [mapRef, setMapRef] = useState<MapRef | null>(null)
const mapContainerRef = useRef<MapRef | null>(null);
const mapRef = useRef<MapRef | null>(null);
const defaultViewState: Partial<ViewState> = {
longitude: BASE_LONGITUDE,
@ -40,6 +44,32 @@ export default function MapView({
...initialViewState,
}
const geocoder = new MapboxGeocoder(
{
accessToken: mapboxApiAccessToken!,
mapboxgl: mapboxgl as any, // Type assertion to bypass type checking
marker: false,
placeholder: "Search for places",
proximity: {
longitude: BASE_LONGITUDE,
latitude: BASE_LATITUDE,
},
},
)
const fullscreenControl = new mapboxgl.FullscreenControl();
const navigationControl = new mapboxgl.NavigationControl({
showCompass: false,
});
const handleMapLoad = useCallback(() => {
if (mapRef.current) {
// mapRef.current.addControl(geocoder, "top-right")
mapRef.current.addControl(fullscreenControl, "top-right")
mapRef.current.addControl(navigationControl, "top-right")
}
}, [mapRef, geocoder, fullscreenControl, navigationControl])
const handleMoveEnd = useCallback(
(event: any) => {
if (onMoveEnd) {
@ -54,16 +84,18 @@ export default function MapView({
<div className="flex h-full">
<div className="relative flex-grow h-full transition-all duration-300">
<Map
ref={mapRef}
mapStyle={mapStyle}
mapboxAccessToken={mapboxApiAccessToken}
initialViewState={defaultViewState}
onLoad={handleMapLoad}
onMoveEnd={handleMoveEnd}
style={{ width: "100%", height: "100%" }}
attributionControl={false}
preserveDrawingBuffer={true} // This helps with fullscreen stability
>
<FullscreenControl position="top-right" />
<NavigationControl position="top-right" showCompass={false} />
{/* <FullscreenControl position="top-right" />
<NavigationControl position="top-right" showCompass={false} /> */}
{children}
</Map>
@ -71,4 +103,4 @@ export default function MapView({
</div>
</div>
)
}
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -1,101 +1,641 @@
"use client"
import React, { useState } from "react"
import { AlertTriangle, BarChart, ChevronRight, MapPin, Skull, Shield, FileText } from "lucide-react"
import React, { useState, useEffect, useMemo } from "react"
import {
AlertTriangle, BarChart, ChevronRight, MapPin, Skull, Shield, FileText,
Clock, Calendar, MapIcon, Info, CheckCircle, AlertCircle, XCircle,
Bell, Users, Search, List, RefreshCw, Eye
} from "lucide-react"
import { Separator } from "@/app/_components/ui/separator"
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card"
import { cn } from "@/app/_lib/utils"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
import { Badge } from "@/app/_components/ui/badge"
import { CRIME_RATE_COLORS } from "@/app/_utils/const/map"
import { usePrefetchedCrimeData } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes"
import { getMonthName, formatDateString } from "@/app/_utils/common"
import { Skeleton } from "@/app/_components/ui/skeleton"
import { useGetCrimeCategories } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client"
interface CrimeSidebarProps {
className?: string
defaultCollapsed?: boolean
selectedCategory?: string | "all"
selectedYear?: number
selectedMonth?: number | "all"
}
export default function CrimeSidebar({ className, defaultCollapsed = true }: CrimeSidebarProps) {
// Updated interface to match the structure returned by getCrimeByYearAndMonth
interface ICrimesProps {
id: string
district_id: string
districts: {
name: string
geographics?: {
address: string
land_area: number
year: number
}[]
demographics?: {
number_of_unemployed: number
population: number
population_density: number
year: number
}[]
}
number_of_crime: number
level: $Enums.crime_rates
score: number
month: number
year: number
crime_incidents: Array<{
id: string
timestamp: Date
description: string
status: string
crime_categories: {
name: string
type: string
}
locations: {
address: string
latitude: number
longitude: number
}
}>
}
export default function CrimeSidebar({
className,
defaultCollapsed = true,
selectedCategory = "all",
selectedYear: propSelectedYear,
selectedMonth: propSelectedMonth
}: CrimeSidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
const [activeTab, setActiveTab] = useState("incidents")
const [currentTime, setCurrentTime] = useState<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 (
<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 ",
isCollapsed ? "translate-x-[-100%]" : "translate-x-0",
"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-full" : "translate-x-0",
className
)}>
<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-[320px]">
<div className="p-4 text-white h-full flex flex-col">
<CardHeader className="p-0 pb-2">
<div className="bg-background backdrop-blur-sm border-r border-white/10 h-full w-[400px]">
<div className="p-4 text-white h-full flex flex-col max-h-full overflow-hidden">
<CardHeader className="p-0 pb-2 shrink-0">
<CardTitle className="text-xl font-semibold flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Crime Analysis
</CardTitle>
{!isCrimesLoading && (
<CardDescription className="text-xs text-white/60">
{getTimePeriodDisplay()}
</CardDescription>
)}
</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">
<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="reports" className="flex-1">Reports</TabsTrigger>
<TabsTrigger value="info" className="flex-1">Information</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1 max-h-[calc(100vh-10rem)]">
<TabsContent value="incidents" className="m-0 p-0">
<SidebarSection title="Recent Incidents" icon={<AlertTriangle className="h-4 w-4 text-red-400" />}>
<div className="space-y-2">
{[1, 2, 3, 4, 5, 6].map((i) => (
<IncidentCard key={i} />
))}
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1">
<TabsContent value="incidents" className="m-0 p-0 space-y-4">
{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">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div>
</SidebarSection>
) : (
<>
<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>
)}
</SidebarSection>
</>
)}
</TabsContent>
<TabsContent value="statistics" className="m-0 p-0">
<SidebarSection title="Crime Overview" icon={<BarChart className="h-4 w-4 text-blue-400" />}>
<div className="space-y-2">
<StatCard title="Total Incidents" value="254" change="+12%" />
<StatCard title="Hot Zones" value="6" change="+2" />
<StatCard title="Case Clearance" value="68%" change="+5%" isPositive />
<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>
</SidebarSection>
) : (
<>
<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
<Separator className="bg-white/20 my-4" />
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="Most Reported" icon={<Skull className="h-4 w-4 text-amber-400" />}>
<div className="space-y-2">
<CrimeTypeCard type="Theft" count={42} percentage={23} />
<CrimeTypeCard type="Assault" count={28} percentage={15} />
<CrimeTypeCard type="Vandalism" count={19} percentage={10} />
<CrimeTypeCard type="Burglary" count={15} percentage={8} />
</div>
</SidebarSection>
<SidebarSection title="Crime Overview" icon={<BarChart className="h-4 w-4 text-blue-400" />}>
<div className="space-y-2">
<StatCard
title="Total Incidents"
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>
</SidebarSection>
<Separator className="bg-white/20 my-2" />
<SidebarSection title="Most Common Crimes" icon={<Skull className="h-4 w-4 text-amber-400" />}>
<div className="space-y-2">
{topCategories.length > 0 ? (
topCategories.map((category) => (
<CrimeTypeCard
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>
</SidebarSection>
</>
)}
</TabsContent>
<TabsContent value="reports" className="m-0 p-0">
<SidebarSection title="Recent Reports" icon={<FileText className="h-4 w-4 text-indigo-400" />}>
<div className="space-y-2">
<ReportCard
title="Monthly Crime Summary"
date="June 15, 2024"
author="Dept. Analysis Team"
/>
<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>
<TabsContent value="info" className="m-0 p-0 space-y-4">
<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">
<h4 className="font-medium mb-1">Crime Severity</h4>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ backgroundColor: CRIME_RATE_COLORS.low }}></div>
<span>Low Crime Rate</span>
</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>
</TabsContent>
</div>
@ -103,13 +643,12 @@ export default function CrimeSidebar({ className, defaultCollapsed = true }: Cri
</div>
</div>
{/* Toggle Button - always visible and positioned correctly */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
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",
"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"}
>
@ -123,8 +662,6 @@ export default function CrimeSidebar({ className, defaultCollapsed = true }: Cri
)
}
// Helper components for sidebar content
interface SidebarSectionProps {
title: string
children: React.ReactNode
@ -133,7 +670,7 @@ interface SidebarSectionProps {
function SidebarSection({ title, children, icon }: SidebarSectionProps) {
return (
<div>
<div>
<h3 className="text-sm font-medium text-white/80 mb-2 flex items-center gap-1.5">
{icon}
{title}
@ -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 (
<Card className="bg-white/10 border-0 text-white shadow-none">
<CardContent className="p-3 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
<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">
<MapPin className="h-3 w-3" />
<span>Jombang District</span>
<span>{location}</span>
</div>
<div className="mt-1 text-white/60">3 hours ago</div>
<div className="mt-1 text-white/60">{time}</div>
</div>
</div>
</CardContent>
</Card>
)
);
}
interface StatCardProps {
@ -192,7 +774,7 @@ interface CrimeTypeCardProps {
function CrimeTypeCard({ type, count, percentage }: CrimeTypeCardProps) {
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">
<div className="flex justify-between items-center">
<span className="font-medium">{type}</span>
@ -215,7 +797,7 @@ interface ReportCardProps {
function ReportCard({ title, date, author }: ReportCardProps) {
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">
<div className="flex items-start gap-2">
<FileText className="h-4 w-4 text-indigo-400 shrink-0 mt-0.5" />
@ -232,3 +814,7 @@ function ReportCard({ title, date, author }: ReportCardProps) {
</Card>
)
}
function PieChart(props: any) {
return <BarChart {...props} />;
}

View File

@ -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])
}

View File

@ -770,3 +770,22 @@ export const getDistrictName = (districtId: string): string => {
'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();
}

View File

@ -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

View File

@ -13,6 +13,7 @@
"dependencies": {
"@evyweb/ioctopus": "^1.2.0",
"@hookform/resolvers": "^4.1.2",
"@mapbox/mapbox-gl-geocoder": "^5.0.3",
"@prisma/client": "^6.4.1",
"@prisma/instrumentation": "^6.5.0",
"@radix-ui/react-alert-dialog": "^1.1.6",
@ -72,6 +73,7 @@
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.67.2",
"@tanstack/react-query-devtools": "^5.67.2",
"@types/mapbox__mapbox-gl-geocoder": "^5.0.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "19.0.2",