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:
parent
20238994dc
commit
c6f803b08c
|
@ -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.');
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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 }
|
|
@ -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]);
|
||||
// }
|
|
@ -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",
|
||||
}
|
|
@ -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];
|
|
@ -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
|
||||
}
|
|
@ -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
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -199,3 +199,77 @@ main()
|
|||
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();
|
||||
// });
|
||||
|
|
Loading…
Reference in New Issue