feat: add crime map components and functionality

- Implemented MapLegend component to display crime rate legend.
- Created YearSelector component for selecting the year of crime data.
- Developed CrimeMap component to manage crime incidents and district data.
- Added DistrictLayer for rendering districts on the map with hover and click interactions.
- Introduced CrimeMarker for displaying crime incidents on the map.
- Built MapView component for rendering the map with various controls.
- Established utility constants for crime colors and rates.
- Defined types for crime management and map features.
This commit is contained in:
vergiLgood1 2025-04-14 23:47:44 +07:00
parent 20238994dc
commit c6f803b08c
23 changed files with 4314 additions and 49 deletions

View File

@ -0,0 +1,126 @@
import { getInjection } from "@/di/container";
import db from "@/prisma/db";
import { AuthenticationError, UnauthenticatedError } from "@/src/entities/errors/auth";
import { InputParseError } from "@/src/entities/errors/common";
export async function getAvailableYears() {
const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction(
'Available Years',
{ recordResponse: true },
async () => {
try {
const years = await db.crimes.findMany({
select: {
year: true,
},
distinct: ["year"],
orderBy: {
year: "asc",
},
})
return years.map((year) => year.year);
} catch (err) {
if (err instanceof InputParseError) {
// return {
// error: err.message,
// };
throw new InputParseError(err.message);
}
if (err instanceof AuthenticationError) {
// return {
// error: 'User not found.',
// };
throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.');
}
const crashReporterService = getInjection('ICrashReporterService');
crashReporterService.report(err);
// return {
// error:
// 'An error happened. The developers have been notified. Please try again later.',
// };
throw new Error('An error happened. The developers have been notified. Please try again later.');
}
}
);
}
// Fetch districts with their geographic data and crime rates for the specified year
export async function fetchDistricts(year: number) {
const instrumentationService = getInjection('IInstrumentationService');
return await instrumentationService.instrumentServerAction(
'Fetch Districts',
{ recordResponse: true },
async () => {
try {
const districts = await db.districts.findMany({
include: {
geographics: true,
crimes: {
where: {
year: year,
},
},
cities: {
select: {
name: true,
},
},
},
})
// Transform the data for the map
const geoData = districts.map((district) => {
const crimeData = district.crimes[0] || null
return {
id: district.id,
name: district.name,
cityName: district.cities?.name || "Unknown City",
polygon: district.geographics?.polygon || null,
crimeRate: crimeData?.rate || "no_data",
crimeCount: crimeData?.number_of_crime || 0,
year: year,
}
})
return geoData
} catch (err) {
if (err instanceof InputParseError) {
// return {
// error: err.message,
// };
throw new InputParseError(err.message);
}
if (err instanceof AuthenticationError) {
// return {
// error: 'User not found.',
// };
throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.');
}
const crashReporterService = getInjection('ICrashReporterService');
crashReporterService.report(err);
// return {
// error:
// 'An error happened. The developers have been notified. Please try again later.',
// };
throw new Error('An error happened. The developers have been notified. Please try again later.');
}
}
);
}

View File

@ -0,0 +1,289 @@
"use client"
import { AlertTriangle, BarChart3, Briefcase, FileText, MapPin, Shield, User, Users, Clock, Search } from "lucide-react"
import { Badge } from "@/app/_components/ui/badge"
import { BentoGrid, BentoGridItem } from "@/app/_components/ui/bento-grid"
import { Progress } from "@/app/_components/ui/progress"
import CrimeMap from "@/app/_components/map/crime-map"
export default function CrimeManagement() {
return (
<div className="container py-4 min-h-screen">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-3xl font-bold tracking-tight">Crime Management Dashboard</h2>
<p className="text-muted-foreground">Overview of current cases, incidents, and department status</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-green-100 text-green-800 hover:bg-green-100">
System: Online
</Badge>
<Badge variant="outline" className="bg-blue-100 text-blue-800 hover:bg-blue-100">
Alert Level: Normal
</Badge>
</div>
</div>
<BentoGrid>
<BentoGridItem
title="Incident Map"
description="Recent crime locations in the district"
icon={<MapPin className="w-5 h-5" />}
colSpan="2"
rowSpan="2"
>
{/* <div className="mt-4 rounded-md border flex items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 opacity-50 bg-[url('/placeholder.svg?height=400&width=600')] bg-center bg-cover"></div>
</div> */}
<CrimeMap />
</BentoGridItem>
<BentoGridItem
title="Crime Statistics"
description="Weekly crime rate analysis"
icon={<BarChart3 className="w-5 h-5" />}
>
<div className="space-y-4 mt-4">
<div>
<div className="flex justify-between mb-1 text-sm">
<span>Violent Crime</span>
<span className="text-red-600">+12%</span>
</div>
<Progress value={65} className="h-2 bg-slate-200" indicatorClassName="bg-red-500" />
</div>
<div>
<div className="flex justify-between mb-1 text-sm">
<span>Property Crime</span>
<span className="text-green-600">-8%</span>
</div>
<Progress value={42} className="h-2 bg-slate-200" indicatorClassName="bg-yellow-500" />
</div>
<div>
<div className="flex justify-between mb-1 text-sm">
<span>Cybercrime</span>
<span className="text-red-600">+23%</span>
</div>
<Progress value={78} className="h-2 bg-slate-200" indicatorClassName="bg-blue-500" />
</div>
</div>
</BentoGridItem>
<BentoGridItem
title="Active Officers"
description="Personnel currently on duty"
icon={<Shield className="w-5 h-5" />}
>
<div className="flex -space-x-2 mt-4">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="w-10 h-10 rounded-full border-2 border-background bg-slate-200 flex items-center justify-center text-xs font-medium"
>
{i}
</div>
))}
<div className="w-10 h-10 rounded-full border-2 border-background bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
+12
</div>
</div>
<div className="mt-4 flex justify-between text-sm">
<span className="text-muted-foreground">Total on duty:</span>
<span className="font-medium">18/24 officers</span>
</div>
</BentoGridItem>
<BentoGridItem
title="High Priority Cases"
description="Cases requiring immediate attention"
icon={<AlertTriangle className="w-5 h-5 text-red-500" />}
colSpan="2"
>
<div className="space-y-3 mt-4">
{[
{ id: "CR-7823", type: "Homicide", location: "Downtown", priority: "Critical", time: "2h ago" },
{ id: "CR-7825", type: "Armed Robbery", location: "North District", priority: "High", time: "4h ago" },
{ id: "CR-7830", type: "Kidnapping", location: "West Side", priority: "Critical", time: "6h ago" },
].map((case_) => (
<div key={case_.id} className="flex items-center gap-3 rounded-lg p-3 bg-red-50 border border-red-100">
<div className="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center shrink-0">
<AlertTriangle className="h-5 w-5 text-red-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between">
<p className="text-sm font-medium">Case #{case_.id}</p>
<Badge variant="outline" className="bg-red-100 text-red-800 hover:bg-red-100">
{case_.priority}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{case_.type} {case_.location} {case_.time}
</p>
</div>
</div>
))}
</div>
</BentoGridItem>
<BentoGridItem
title="Evidence Tracking"
description="Recently logged evidence items"
icon={<Briefcase className="w-5 h-5" />}
>
<div className="space-y-2 mt-4">
{[
{ id: "EV-4523", type: "Weapon", case: "CR-7823", status: "Processing" },
{ id: "EV-4525", type: "Digital Media", case: "CR-7825", status: "Secured" },
{ id: "EV-4527", type: "DNA Sample", case: "CR-7830", status: "Lab Analysis" },
].map((evidence) => (
<div key={evidence.id} className="flex items-center gap-2 rounded-lg p-2">
<div className="w-8 h-8 rounded bg-slate-200 flex items-center justify-center">
<FileText className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{evidence.id}</p>
<p className="text-xs text-muted-foreground">
{evidence.type} Case #{evidence.case}
</p>
</div>
<Badge variant="outline" className="text-xs">
{evidence.status}
</Badge>
</div>
))}
</div>
</BentoGridItem>
<BentoGridItem
title="Persons of Interest"
description="Individuals under investigation"
icon={<User className="w-5 h-5" />}
>
<div className="space-y-2 mt-4">
{[
{ id: "POI-3421", name: "John Doe", case: "CR-7823", status: "Wanted" },
{ id: "POI-3422", name: "Jane Smith", case: "CR-7825", status: "In Custody" },
{ id: "POI-3423", name: "Robert Johnson", case: "CR-7830", status: "Under Surveillance" },
].map((person) => (
<div key={person.id} className="flex items-center gap-2 rounded-lg p-2">
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center">
<User className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{person.name}</p>
<p className="text-xs text-muted-foreground">
{person.id} Case #{person.case}
</p>
</div>
<Badge
variant="outline"
className={
person.status === "Wanted"
? "bg-red-100 text-red-800"
: person.status === "In Custody"
? "bg-green-100 text-green-800"
: "bg-yellow-100 text-yellow-800"
}
>
{person.status}
</Badge>
</div>
))}
</div>
</BentoGridItem>
<BentoGridItem
title="Department Performance"
description="Case resolution metrics"
icon={<BarChart3 className="w-5 h-5" />}
>
<div className="space-y-4 mt-4">
<div>
<div className="flex justify-between mb-1 text-sm">
<span>Case Clearance Rate</span>
<span>68%</span>
</div>
<Progress value={68} className="h-2" />
</div>
<div>
<div className="flex justify-between mb-1 text-sm">
<span>Response Time</span>
<span>4.2 min avg</span>
</div>
<Progress value={85} className="h-2" />
</div>
<div>
<div className="flex justify-between mb-1 text-sm">
<span>Evidence Processing</span>
<span>72%</span>
</div>
<Progress value={72} className="h-2" />
</div>
</div>
</BentoGridItem>
<BentoGridItem title="Recent Arrests" description="Last 24 hours" icon={<Users className="w-5 h-5" />}>
<div className="mt-4">
<div className="flex items-center justify-between">
<div className="text-2xl font-bold">14</div>
<div className="text-xs px-2 py-1 rounded-full bg-green-100 text-green-800">+3 from yesterday</div>
</div>
<div className="grid grid-cols-3 gap-2 mt-4">
{["Assault", "Theft", "DUI", "Drugs", "Trespassing", "Vandalism"].map((crime) => (
<div key={crime} className="px-2 py-1 rounded-md text-xs font-medium text-center">
{crime}
</div>
))}
</div>
</div>
</BentoGridItem>
<BentoGridItem title="Emergency Calls" description="911 call volume" icon={<Clock className="w-5 h-5" />}>
<div className="space-y-2 mt-4">
<div className="flex justify-between items-center">
<span className="text-sm">Current Hour</span>
<div className="flex items-center">
<span className="text-lg font-bold mr-1">24</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-yellow-100 text-yellow-800">High</span>
</div>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">Average Wait</span>
<span className="text-lg font-bold">1:42</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">Operators Available</span>
<div className="flex items-center">
<span className="text-lg font-bold mr-1">4/6</span>
<span className="text-xs px-1.5 py-0.5 rounded-full bg-red-100 text-red-800">Understaffed</span>
</div>
</div>
</div>
</BentoGridItem>
<BentoGridItem
title="Case Search"
description="Quick access to case files"
icon={<Search className="w-5 h-5" />}
>
<div className="mt-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<input
type="search"
placeholder="Search case number or name..."
className="w-full rounded-md border border-input bg-background px-9 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
<div className="mt-3 text-xs text-muted-foreground">
Recent searches: CR-7823, CR-7825, John Doe, Jane Smith
</div>
</div>
</BentoGridItem>
</BentoGrid>
</div>
</div>
)
}

View File

@ -1,7 +1,20 @@
import { BentoGrid, BentoGridItem } from "@/app/_components/ui/bento-grid";
import { DateTimePicker2 } from "@/app/_components/ui/date-picker";
import { createClient } from "@/app/_utils/supabase/server";
import { redirect } from "next/navigation";
import {
BarChart3,
Calendar,
CreditCard,
Globe,
LineChart,
MessageSquare,
Settings,
ShoppingCart,
Users,
} from "lucide-react";
export default async function DashboardPage() {
// const supabase = await createClient();
@ -16,21 +29,138 @@ export default async function DashboardPage() {
// console.log("user", user);
return (
<>
<header className="flex h-12 shrink-0 items-center justify-end border-b px-4 mb-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-8"></header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="aspect-video rounded-xl bg-muted/50">
<pre className="text-xs font-mono p-3 rounded border overflow-auto">
{/* {JSON.stringify(user, null, 2)} */}
</pre>
<div className="container py-4">
{/* <h2 className="text-3xl font-bold tracking-tight mb-8">Dashboard Overview</h2> */}
<BentoGrid className="max-w-full mx-auto">
<BentoGridItem
title="Sales Analytics"
description="Monthly revenue and transaction data"
icon={<BarChart3 className="w-5 h-5" />}
colSpan="2"
rowSpan="2"
>
<div className="h-[200px] mt-4 rounded-md bg-muted flex items-center justify-center">
<LineChart className="h-8 w-8 text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Chart Visualization</span>
</div>
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
</div>
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min" />
</div>
</>
);
</BentoGridItem>
<BentoGridItem
title="Recent Orders"
description="Latest customer purchases"
icon={<ShoppingCart className="w-5 h-5" />}
>
<div className="space-y-2 mt-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-2 rounded-lg p-2 bg-muted/50">
<div className="w-8 h-8 rounded bg-muted"></div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">Order #{i}0234</p>
<p className="text-xs text-muted-foreground">2 mins ago</p>
</div>
<div className="text-sm font-medium">$149.99</div>
</div>
))}
</div>
</BentoGridItem>
<BentoGridItem title="Team Members" description="Active users this month" icon={<Users className="w-5 h-5" />}>
<div className="flex -space-x-2 mt-4">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="w-8 h-8 rounded-full border-2 border-background bg-muted flex items-center justify-center text-xs font-medium"
>
{i}
</div>
))}
<div className="w-8 h-8 rounded-full border-2 border-background bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium">
+3
</div>
</div>
</BentoGridItem>
<BentoGridItem
title="Global Reach"
description="Customer distribution worldwide"
icon={<Globe className="w-5 h-5" />}
colSpan="2"
>
<div className="h-[150px] mt-4 rounded-md bg-muted flex items-center justify-center">
<Globe className="h-8 w-8 text-muted-foreground" />
<span className="ml-2 text-muted-foreground">World Map Visualization</span>
</div>
</BentoGridItem>
<BentoGridItem
title="Upcoming Events"
description="Scheduled meetings and deadlines"
icon={<Calendar className="w-5 h-5" />}
>
<div className="space-y-2 mt-4">
{[1, 2].map((i) => (
<div key={i} className="flex items-center gap-2 rounded-lg p-2 bg-muted/50">
<div className="w-8 h-8 rounded bg-primary/10 flex items-center justify-center">
<Calendar className="h-4 w-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">Team Meeting</p>
<p className="text-xs text-muted-foreground">Tomorrow, 10:00 AM</p>
</div>
</div>
))}
</div>
</BentoGridItem>
<BentoGridItem
title="Support Tickets"
description="Customer inquiries and issues"
icon={<MessageSquare className="w-5 h-5" />}
>
<div className="flex items-center justify-between mt-4">
<div className="text-2xl font-bold">24</div>
<div className="text-xs px-2 py-1 rounded-full bg-green-100 text-green-800">-12% from last week</div>
</div>
<div className="h-1 w-full bg-muted mt-2 rounded-full overflow-hidden">
<div className="bg-green-500 h-full w-[65%]" />
</div>
</BentoGridItem>
<BentoGridItem
title="Payment Methods"
description="Active payment options"
icon={<CreditCard className="w-5 h-5" />}
>
<div className="flex gap-2 mt-4">
{["Visa", "Mastercard", "PayPal"].map((method) => (
<div key={method} className="px-3 py-1 rounded-full bg-muted text-xs font-medium">
{method}
</div>
))}
</div>
</BentoGridItem>
<BentoGridItem
title="System Status"
description="Server and application health"
icon={<Settings className="w-5 h-5" />}
>
<div className="space-y-2 mt-4">
<div className="flex justify-between items-center">
<span className="text-sm">API</span>
<span className="text-xs px-2 py-1 rounded-full bg-green-100 text-green-800">Operational</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">Database</span>
<span className="text-xs px-2 py-1 rounded-full bg-green-100 text-green-800">Operational</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">Storage</span>
<span className="text-xs px-2 py-1 rounded-full bg-yellow-100 text-yellow-800">Degraded</span>
</div>
</div>
</BentoGridItem>
</BentoGrid>
</div>
)
}

View File

@ -0,0 +1,38 @@
"use client"
import { Card } from "@/app/_components/ui/card"
import { CRIME_COLORS, CRIME_RATES } from "@/app/_utils/const/crime"
type MapLegendProps = {
title?: string
position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"
}
export default function MapLegend({ title = "Crime Rate Legend", position = "bottom-right" }: MapLegendProps) {
// Define position classes
const positionClasses = {
"top-left": "top-4 left-4",
"top-right": "top-4 right-4",
"bottom-left": "bottom-4 left-4",
"bottom-right": "bottom-4 right-4",
}
return (
<div className={`absolute ${positionClasses[position]} z-10`}>
<Card className="p-3 shadow-md bg-white/90 backdrop-blur-sm">
<div className="text-sm font-medium mb-1">{title}</div>
<div className="space-y-1">
{Object.entries(CRIME_RATES).map(([key, label]) => (
<div key={key} className="flex items-center gap-2">
<div
className="w-4 h-4 rounded-sm"
style={{ backgroundColor: CRIME_COLORS[key as keyof typeof CRIME_COLORS] }}
/>
<span className="text-xs">{label}</span>
</div>
))}
</div>
</Card>
</div>
)
}

View File

@ -0,0 +1,25 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
type YearSelectorProps = {
years: number[]
selectedYear: string
onChange: (year: string) => void
className?: string
}
export default function YearSelector({ years, selectedYear, onChange, className = "" }: YearSelectorProps) {
return (
<Select value={selectedYear} onValueChange={onChange}>
<SelectTrigger className={`w-[180px] ${className}`}>
<SelectValue placeholder="Select Year" />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={year.toString()}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
)
}

View File

@ -0,0 +1,122 @@
"use client"
import { useEffect, useState } from "react"
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 MapLegend from "./controls/map-legend"
import YearSelector from "./controls/year-selector"
import CrimeMarker, { type CrimeIncident } from "./markers/crime-marker"
import { useToast } from "@/app/_hooks/use-toast"
import MapView from "./map"
import { useQuery } from "@tanstack/react-query"
import { getAvailableYears } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action"
const years = [2020, 2021, 2022, 2023, 2024, 2025]
export default function CrimeMap() {
const [loading, setLoading] = useState(false)
const [year, setYear] = useState<string>(new Date().getFullYear().toString())
const [availableYears, setAvailableYears] = useState<number[]>(years)
const [districtData, setDistrictData] = useState<DistrictFeature[]>([])
const [crimeIncidents, setCrimeIncidents] = useState<CrimeIncident[]>([])
const [showIncidents, setShowIncidents] = useState(false)
const { toast } = useToast()
// fetch available years (example function)
// const { data: year } = useQuery({
// queryKey: ["available-years"],
// queryFn: getAvailableYears,
// })
// Fetch crime incidents (example function)
const fetchCrimeIncidents = async (districtId: string) => {
try {
// This would be replaced with an actual API call
// const response = await fetch(`/api/crime-incidents?districtId=${districtId}&year=${year}`)
// const data = await response.json()
// For demonstration, we'll create some sample data
const sampleIncidents: CrimeIncident[] = [
{
id: "1",
latitude: -8.1842 + (Math.random() - 0.5) * 0.1,
longitude: 113.7031 + (Math.random() - 0.5) * 0.1,
description: "Theft incident",
date: "2023-05-15",
category: "Theft",
},
{
id: "2",
latitude: -8.1842 + (Math.random() - 0.5) * 0.1,
longitude: 113.7031 + (Math.random() - 0.5) * 0.1,
description: "Vandalism",
date: "2023-06-22",
category: "Property Crime",
},
{
id: "3",
latitude: -8.1842 + (Math.random() - 0.5) * 0.1,
longitude: 113.7031 + (Math.random() - 0.5) * 0.1,
description: "Assault",
date: "2023-07-10",
category: "Violent Crime",
},
]
setCrimeIncidents(sampleIncidents)
setShowIncidents(true)
} catch (error) {
console.error("Error fetching crime incidents:", error)
}
}
const handleDistrictClick = (feature: any) => {
const districtId = feature.properties.id
const districtName = feature.properties.name
toast({
title: `Selected: ${districtName}`,
description: "Loading crime incidents for this district...",
})
fetchCrimeIncidents(districtId)
}
const handleIncidentClick = (incident: CrimeIncident) => {
toast({
title: "Crime Incident",
description: `${incident.description} on ${incident.date}`,
})
}
return (
<Card className="w-full">
{/* <CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Crime Rate Map - Jember Regency</CardTitle>
<YearSelector years={availableYears} selectedYear={year} onChange={setYear} />
</CardHeader> */}
<CardContent className="p-0">
{loading ? (
<Skeleton className="w-full rounded-md" />
) : (
<div className="relative">
<MapView mapStyle="mapbox://styles/mapbox/dark-v11">
{/* District Layer */}
<DistrictLayer data={districtData} onClick={handleDistrictClick} />
{/* Crime Incident Markers */}
{showIncidents &&
crimeIncidents.map((incident) => (
<CrimeMarker key={incident.id} incident={incident} onClick={handleIncidentClick} />
))}
{/* Map Legend */}
{/* <MapLegend position="bottom-right" /> */}
</MapView>
</div>
)}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,159 @@
"use client"
import { useEffect, useMemo } from "react"
import { Layer, Source, useMap, Popup } from "react-map-gl/mapbox"
import { useState } from "react"
import { IGeoJSONPolygon } from "@/app/_utils/types/map"
import { CRIME_COLORS, CRIME_RATES } from "@/app/_utils/const/crime"
export type DistrictFeature = {
id: string
name: string
cityName: string
code: string
polygon: IGeoJSONPolygon
crimeRate: "low" | "medium" | "high" | "no_data"
crimeCount: number
year: number
}
type DistrictLayerProps = {
data: DistrictFeature[]
visible?: boolean
onClick?: (feature: any) => void
}
type hoverInfoType = {
feature: {
properties: {
crimeRate: "low" | "medium" | "high" | "no_data"
name: string
cityName: string
crimeCount: number
}
geometry: {
coordinates: number[][][]
}
}
x: number
y: number
}
export default function DistrictLayer({ data, visible = true, onClick }: DistrictLayerProps) {
const { current: map } = useMap()
const [hoverInfo, setHoverInfo] = useState<hoverInfoType | null>(null)
// Convert data to GeoJSON
const geojson = useMemo(() => {
return {
type: "FeatureCollection",
features: data
.filter((district) => district.polygon) // Only include districts with polygon data
.map((district) => ({
type: "Feature",
properties: {
id: district.id,
name: district.name,
cityName: district.cityName,
crimeRate: district.crimeRate,
crimeCount: district.crimeCount,
color: CRIME_COLORS[district.crimeRate],
},
geometry: district.polygon,
})),
}
}, [data])
// Handle hover events
useEffect(() => {
if (!map) return
const onHover = (event: any) => {
const { features, point } = event
const hoveredFeature = features && features[0]
// Update hover state
setHoverInfo(
hoveredFeature
? {
feature: hoveredFeature,
x: point.x,
y: point.y,
}
: null,
)
}
// Change cursor on hover
const onMouseEnter = () => {
if (map) map.getCanvas().style.cursor = "pointer"
}
const onMouseLeave = () => {
if (map) map.getCanvas().style.cursor = ""
setHoverInfo(null)
}
// Add event listeners
map.on("mousemove", "district-fills", onHover)
map.on("mouseenter", "district-fills", onMouseEnter)
map.on("mouseleave", "district-fills", onMouseLeave)
map.on("click", "district-fills", (e) => {
if (onClick && e.features && e.features[0]) {
onClick(e.features[0])
}
})
// Clean up
return () => {
map.off("mousemove", "district-fills", onHover)
map.off("mouseenter", "district-fills", onMouseEnter)
map.off("mouseleave", "district-fills", onMouseLeave)
map.off("click", "district-fills", onClick as any)
}
}, [map, onClick])
if (!visible) return null
return (
<>
<Source id="districts" type="geojson" data={geojson as any}>
<Layer
id="district-fills"
type="fill"
paint={{
"fill-color": ["get", "color"],
"fill-opacity": 0.6,
}}
/>
<Layer
id="district-borders"
type="line"
paint={{
"line-color": "#627D98",
"line-width": 1,
}}
/>
</Source>
{/* Popup on hover */}
{hoverInfo && (
<Popup
longitude={hoverInfo.feature.geometry.coordinates[0][0][0]}
latitude={hoverInfo.feature.geometry.coordinates[0][0][1]}
closeButton={false}
closeOnClick={false}
anchor="bottom"
offset={[0, -10]}
>
<div className="p-2">
<h3 className="font-bold">{hoverInfo.feature.properties.name}</h3>
<p>City: {hoverInfo.feature.properties.cityName}</p>
<p>Crime Rate: {CRIME_RATES[hoverInfo.feature.properties.crimeRate]}</p>
<p>Crime Count: {hoverInfo.feature.properties.crimeCount}</p>
</div>
</Popup>
)}
</>
)
}

View File

@ -0,0 +1,61 @@
"use client";
import { useState } from 'react';
import Map, { Source, Layer, MapRef, ViewState, NavigationControl, ScaleControl, FullscreenControl } from 'react-map-gl/mapbox';
import 'mapbox-gl/dist/mapbox-gl.css';
import { useRef } from 'react';
import { MAPBOX_STYLES, MapboxStyle } from '@/app/_utils/const/map';
interface MapViewProps {
initialViewState?: Partial<ViewState>;
mapStyle?: MapboxStyle
onMapLoad?: (map: MapRef) => void
className?: string
children?: React.ReactNode;
}
const MapView: React.FC<MapViewProps> = ({
initialViewState = {
longitude: 113.6922, // Center of Jember Regency (approximately)
latitude: -8.1843,
zoom: 9
},
mapStyle = MAPBOX_STYLES.Standard,
children,
onMapLoad,
className = "h-[600px] w-full rounded-md",
}) => {
const [viewState, setViewState] = useState<Partial<ViewState>>(initialViewState);
const mapRef = useRef<MapRef | null>(null);
const handleMapLoad = () => {
if (mapRef.current && onMapLoad) {
onMapLoad(mapRef.current)
}
}
return (
<div className="w-full h-96">
<Map
{...viewState}
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN}
onLoad={handleMapLoad}
onMove={evt => setViewState(evt.viewState)}
mapStyle={mapStyle}
ref={mapRef}
attributionControl={false}
reuseMaps
>
{/* Default Controls */}
<FullscreenControl position="top-right" />
<NavigationControl position="top-left" />
{children}
</Map>
</div>
);
};
export default MapView;

View File

@ -0,0 +1,33 @@
"use client"
import { Marker } from "react-map-gl/mapbox"
import { AlertTriangle } from "lucide-react"
export type CrimeIncident = {
id: string
latitude: number
longitude: number
description: string
date: string
category?: string
}
type CrimeMarkerProps = {
incident: CrimeIncident
onClick?: (incident: CrimeIncident) => void
}
export default function CrimeMarker({ incident, onClick }: CrimeMarkerProps) {
return (
<Marker
longitude={incident.longitude}
latitude={incident.latitude}
anchor="bottom"
onClick={() => onClick && onClick(incident)}
>
<div className="cursor-pointer text-red-500 hover:text-red-700 transition-colors">
<AlertTriangle size={24} />
</div>
</Marker>
)
}

View File

@ -0,0 +1,68 @@
import { cn } from "@/app/_lib/utils"
import type React from "react"
import type { HTMLAttributes } from "react"
interface BentoGridProps extends HTMLAttributes<HTMLDivElement> {
className?: string
children: React.ReactNode
}
export function BentoGrid({ className, children, ...props }: BentoGridProps) {
return (
<div className={cn("grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4", className)} {...props}>
{children}
</div>
)
}
interface BentoGridItemProps extends HTMLAttributes<HTMLDivElement> {
className?: string
title?: string
description?: string
header?: React.ReactNode
icon?: React.ReactNode
children?: React.ReactNode
colSpan?: "1" | "2" | "3"
rowSpan?: "1" | "2" | "3"
}
export function BentoGridItem({
className,
title,
description,
header,
icon,
children,
colSpan = "1",
rowSpan = "1",
...props
}: BentoGridItemProps) {
return (
<div
className={cn(
"row-span-1 col-span-1 rounded-xl group/bento overflow-hidden border bg-background p-4 shadow-sm transition-all hover:shadow-md",
colSpan === "2" && "md:col-span-2",
colSpan === "3" && "md:col-span-3",
rowSpan === "2" && "md:row-span-2",
rowSpan === "3" && "md:row-span-3",
className,
)}
{...props}
>
{header && <div className="mb-4">{header}</div>}
<div className="flex items-center gap-3 mb-4">
{icon && (
<div className="p-2 w-10 h-10 shrink-0 rounded-full flex items-center justify-center bg-muted">{icon}</div>
)}
{(title || description) && (
<div className="space-y-1">
{title && <h3 className="font-semibold tracking-tight">{title}</h3>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
</div>
)}
</div>
{children && <div>{children}</div>}
</div>
)
}

View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/app/_lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string
}
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)}
{...props}
>
<ProgressPrimitive.Indicator
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -0,0 +1,121 @@
// // utils/clustering.ts
// import * as math from 'mathjs';
// interface ClusteringData {
// districtId: string;
// populationDensity: number;
// unemploymentRate: number;
// crimeCount: number;
// }
// export function normalizeData(data: ClusteringData[]): ClusteringData[] {
// // Ekstrak nilai untuk setiap dimensi
// const densities = data.map(item => item.populationDensity);
// const unemploymentRates = data.map(item => item.unemploymentRate);
// const crimeCounts = data.map(item => item.crimeCount);
// // Hitung min dan max untuk normalisasi
// const minDensity = Math.min(...densities);
// const maxDensity = Math.max(...densities);
// const minUnemployment = Math.min(...unemploymentRates);
// const maxUnemployment = Math.max(...unemploymentRates);
// const minCrimeCount = Math.min(...crimeCounts);
// const maxCrimeCount = Math.max(...crimeCounts);
// // Normalisasi data antara 0 dan 1
// return data.map(item => ({
// ...item,
// populationDensity: (item.populationDensity - minDensity) / (maxDensity - minDensity || 1),
// unemploymentRate: (item.unemploymentRate - minUnemployment) / (maxUnemployment - minUnemployment || 1),
// crimeCount: (item.crimeCount - minCrimeCount) / (maxCrimeCount - minCrimeCount || 1)
// }));
// }
// export function kMeansClustering(data: ClusteringData[], k = 3, maxIterations = 100): { clusters: number[], centroids: number[][] } {
// const normalizedData = normalizeData(data);
// // Mengubah data ke format yang sesuai untuk algoritma k-means
// const points = normalizedData.map(item => [
// item.populationDensity,
// item.unemploymentRate,
// item.crimeCount
// ]);
// // Inisialisasi centroid secara acak
// let centroids = Array(k).fill(0).map(() => {
// return [
// Math.random(),
// Math.random(),
// Math.random()
// ];
// });
// let clusters: number[] = [];
// let iterations = 0;
// let oldCentroids: number[][] = [];
// // Algoritma K-means
// while (iterations < maxIterations) {
// // Tetapkan setiap titik ke centroid terdekat
// clusters = points.map(point => {
// const distances = centroids.map(centroid =>
// Math.sqrt(
// Math.pow(point[0] - centroid[0], 2) +
// Math.pow(point[1] - centroid[1], 2) +
// Math.pow(point[2] - centroid[2], 2)
// )
// );
// return distances.indexOf(Math.min(...distances));
// });
// // Simpan centroid lama untuk mengetahui konvergensi
// oldCentroids = [...centroids];
// // Hitung centroid baru berdasarkan pengelompokan saat ini
// for (let i = 0; i < k; i++) {
// const clusterPoints = points.filter((_, index) => clusters[index] === i);
// if (clusterPoints.length > 0) {
// centroids[i] = [
// clusterPoints.reduce((sum, point) => sum + point[0], 0) / clusterPoints.length,
// clusterPoints.reduce((sum, point) => sum + point[1], 0) / clusterPoints.length,
// clusterPoints.reduce((sum, point) => sum + point[2], 0) / clusterPoints.length
// ];
// }
// }
// // Cek konvergensi
// const centroidChange = centroids.reduce((acc, curr, i) => {
// return acc + Math.sqrt(
// Math.pow(curr[0] - oldCentroids[i][0], 2) +
// Math.pow(curr[1] - oldCentroids[i][1], 2) +
// Math.pow(curr[2] - oldCentroids[i][2], 2)
// );
// }, 0);
// if (centroidChange < 0.001) {
// break;
// }
// iterations++;
// }
// // Urutkan cluster berdasarkan tingkat bahaya (tingkat kejahatan)
// // Semakin tinggi nilai pada centroid ketiga (crime count), semakin tinggi risikonya
// const orderedClusters = [...Array(k).keys()].sort((a, b) =>
// centroids[a][2] - centroids[b][2]
// );
// // Petakan cluster asli ke cluster terurut (low, medium, high)
// const mappedClusters = clusters.map(cluster =>
// orderedClusters.indexOf(cluster)
// );
// return { clusters: mappedClusters, centroids };
// }
// // Fungsi untuk mengubah hasil clustering ke format Prisma untuk disimpan
// export function mapClustersToCrimeRates(clusters: number[]): ('low' | 'medium' | 'high')[] {
// const rateMap = ['low', 'medium', 'high'] as const;
// return clusters.map(cluster => rateMap[cluster]);
// }

View File

@ -0,0 +1,15 @@
// Define the color scheme for crime rates
export const CRIME_COLORS = {
low: "#4ade80", // green
medium: "#facc15", // yellow
high: "#ef4444", // red
no_data: "#94a3b8", // slate
}
// Define the crime rate labels
export const CRIME_RATES = {
low: "Low",
medium: "Medium",
high: "High",
no_data: "No Data",
}

View File

@ -0,0 +1,15 @@
// mapStyles.ts
export const MAPBOX_STYLES = {
Standard: 'mapbox://styles/mapbox/standard',
StandardSatellite: 'mapbox://styles/mapbox/standard-satellite',
Streets: 'mapbox://styles/mapbox/streets-v12',
Outdoors: 'mapbox://styles/mapbox/outdoors-v12',
Light: 'mapbox://styles/mapbox/light-v11',
Dark: 'mapbox://styles/mapbox/dark-v11',
Satellite: 'mapbox://styles/mapbox/satellite-v9',
SatelliteStreets: 'mapbox://styles/mapbox/satellite-streets-v12',
NavigationDay: 'mapbox://styles/mapbox/navigation-day-v1',
NavigationNight: 'mapbox://styles/mapbox/navigation-night-v1',
} as const;
export type MapboxStyle = (typeof MAPBOX_STYLES)[keyof typeof MAPBOX_STYLES];

View File

@ -0,0 +1,12 @@
import { IGeoJSONPolygon } from "./map"
export type IDistrictGeoData = {
id: string
name: string
cityName: string
code: string
polygon: IGeoJSONPolygon
crimeRate: "low" | "medium" | "high" | "empty"
crimeCount: number
year: number
}

View File

@ -0,0 +1,17 @@
import { IDistrictGeoData } from "./crime-management";
export interface IGeoJSONPolygon {
type: 'Polygon' | 'MultiPolygon';
coordinates: number[][][];
}
export interface IGeoJSONFeature {
type: 'Feature';
geometry: IGeoJSONPolygon;
properties: IDistrictGeoData;
}
export interface IGeoJSONFeatureCollection {
type: 'FeatureCollection';
features: IGeoJSONFeature[];
}

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.1",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-progress": "^1.1.3",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
@ -37,6 +38,8 @@
"@tanstack/react-query": "^5.66.9",
"@tanstack/react-table": "^8.21.2",
"@tanstack/react-virtual": "^3.13.2",
"@turf/turf": "^7.2.0",
"@types/mapbox-gl": "^3.4.1",
"autoprefixer": "10.4.20",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@ -44,6 +47,7 @@
"embla-carousel-react": "^8.5.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.468.0",
"mapbox-gl": "^3.11.0",
"motion": "^12.4.7",
"next": "latest",
"next-themes": "^0.4.4",
@ -52,6 +56,7 @@
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-map-gl": "^8.0.3",
"resend": "^4.1.2",
"sonner": "^2.0.1",
"vaul": "^1.1.2",
@ -73,4 +78,4 @@
"ts-node": "^10.9.2",
"typescript": "^5.7.2"
}
}
}

View File

@ -116,15 +116,15 @@ export const navData = {
},
{
title: "Cases",
url: "/dashboard/crime-management/crime-cases",
slug: "crime-cases",
url: "/dashboard/crime-management/crime-incident",
slug: "crime-incident",
icon: IconAlertTriangle,
orderSeq: 3,
isActive: true,
subSubItems: [
{
title: "New Case",
url: "/dashboard/crime-management/crime-cases/case-new",
url: "/dashboard/crime-management/crime-incident/case-new",
slug: "new-case",
icon: IconAlertTriangle,
orderSeq: 1,
@ -132,7 +132,7 @@ export const navData = {
},
{
title: "Active Cases",
url: "/dashboard/crime-management/crime-cases/case-active",
url: "/dashboard/crime-management/crime-incident/case-active",
slug: "active-cases",
icon: IconAlertTriangle,
orderSeq: 2,
@ -140,7 +140,7 @@ export const navData = {
},
{
title: "Resolved Cases",
url: "/dashboard/crime-management/crime-cases/case-closed",
url: "/dashboard/crime-management/crime-incident/case-closed",
slug: "resolved-cases",
icon: IconAlertTriangle,
orderSeq: 3,

View File

@ -11,10 +11,9 @@ datasource db {
}
model cities {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
id String @id
geographic_id String? @db.Uuid
name String @db.VarChar(100)
code String @db.VarChar(10)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
geographics geographics? @relation(fields: [geographic_id], references: [id])
@ -38,7 +37,7 @@ model contact_messages {
updated_at DateTime @db.Timestamptz(6)
}
model crime_cases {
model crime_incidents {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
crime_id String? @db.Uuid
crime_category_id String? @db.Uuid
@ -57,27 +56,27 @@ model crime_cases {
}
model crime_categories {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String @db.VarChar(255)
description String
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
crime_cases crime_cases[]
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String @db.VarChar(255)
description String
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
crime_incidents crime_incidents[]
}
model crimes {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
district_id String? @db.Uuid
city_id String? @db.Uuid
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
district_id String?
city_id String?
year Int
number_of_crime Int
rate crime_rates @default(low)
rate crime_rates @default(low)
heat_map Json?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
crime_cases crime_cases[]
cities cities? @relation(fields: [city_id], references: [id])
districts districts? @relation(fields: [district_id], references: [id])
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
crime_incidents crime_incidents[]
cities cities? @relation(fields: [city_id], references: [id])
districts districts? @relation(fields: [district_id], references: [id])
@@unique([city_id, year])
@@unique([district_id, year])
@ -85,9 +84,9 @@ model crimes {
model demographics {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
district_id String? @db.Uuid
city_id String? @db.Uuid
province_id String? @db.Uuid
district_id String? @unique
city_id String?
province_id String?
year Int
population Int
population_density Float
@ -102,10 +101,9 @@ model demographics {
}
model districts {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
city_id String @db.Uuid
id String @id
city_id String
name String @db.VarChar(100)
code String @db.VarChar(10)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
crimes crimes[]
@ -118,11 +116,12 @@ model districts {
model geographics {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
district_id String? @unique @db.Uuid
district_id String? @unique
latitude Float?
longitude Float?
land_area Float?
polygon Json?
geometry Json?
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
cities cities[]

View File

@ -198,4 +198,78 @@ main()
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
});
// import { PrismaClient } from '@prisma/client';
// import fs from 'fs';
// import * as turf from '@turf/turf';
// const prisma = new PrismaClient();
// async function main() {
// const geojson = JSON.parse(fs.readFileSync('prisma/data/geojson/jember/districts.geojson', 'utf-8'));
// // 1. Insert Kota/Kabupaten: Jember
// const city = await prisma.cities.upsert(
// {
// where: { id: '3574' },
// update: {},
// create: {
// id: '3574',
// name: 'Jember',
// }
// }
// )
// console.log(`City Jember inserted with ID: ${city.id}`);
// // 2. Loop Semua District di GeoJSON
// for (const feature of geojson.features) {
// const properties = feature.properties;
// const geometry = feature.geometry;
// // Cleanup code
// const districtCode = properties.kode_kec.replace(/\./g, '');
// // Insert District
// const district = await prisma.districts.create({
// data: {
// id: districtCode,
// name: properties.kecamatan,
// city_id: city.id,
// }
// });
// console.log(`Inserted district: ${district.name}`);
// // 3. Hitung Centroid dan Area
// const centroid = turf.centroid(feature);
// const [longitude, latitude] = centroid.geometry.coordinates;
// const area = turf.area(feature) / 1_000_000; // dari m² ke km²
// // 4. Insert Geographics
// await prisma.geographics.create({
// data: {
// district_id: district.id,
// latitude,
// longitude,
// land_area: area,
// geometry: feature.geometry,
// }
// });
// console.log(`Inserted geographics for district: ${district.name}`);
// }
// console.log("All data imported successfully!");
// }
// main()
// .catch((e) => {
// console.error(e);
// process.exit(1);
// })
// .finally(async () => {
// await prisma.$disconnect();
// });